@eeacms/volto-editing-progress 0.4.0 → 2.0.0

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.
@@ -2,12 +2,22 @@ import { makeFirstLetterCapital } from './WidgetDataComponent';
2
2
  import React from 'react';
3
3
  import { Provider } from 'react-intl-redux';
4
4
  import { MemoryRouter } from 'react-router-dom';
5
- import renderer from 'react-test-renderer';
5
+ import { render, screen, fireEvent, act } from '@testing-library/react';
6
6
  import configureStore from 'redux-mock-store';
7
+ import SidebarComponent, { backgroundColor } from './WidgetSidebar';
8
+ import '@testing-library/jest-dom/extend-expect';
9
+
7
10
  import VisualJSONWidget from './VisualJSONWidget';
8
- import { backgroundColor } from './WidgetSidebar';
11
+ import EditDataComponent from './WidgetDataComponent';
12
+ import ScrollIntoView from './ScrollIntoView';
13
+ import TextareaJSONWidget from './TextareaJSONWidget';
14
+ import { getEditingProgress, getRawContent } from './actions';
15
+ import { editingProgress, rawdata } from './reducers';
16
+ import { JSONSchema } from './schema';
17
+
9
18
  const mockStore = configureStore();
10
19
  const propsEmpty = {};
20
+
11
21
  describe('Widget Data Component', () => {
12
22
  it('should make first letter capital', () => {
13
23
  const testString = 'this is a test string';
@@ -16,6 +26,7 @@ describe('Widget Data Component', () => {
16
26
  );
17
27
  });
18
28
  });
29
+
19
30
  describe('Widget Sidebar', () => {
20
31
  it('should return a background lightblue', () => {
21
32
  expect(backgroundColor(true, false)).toEqual('lightblue');
@@ -24,7 +35,111 @@ describe('Widget Sidebar', () => {
24
35
  it('should return a background lightpink', () => {
25
36
  expect(backgroundColor(false, true)).toEqual('lightpink');
26
37
  });
38
+ it('should return undefined when no conditions match', () => {
39
+ expect(backgroundColor(false, false)).toEqual(undefined);
40
+ });
41
+
42
+ it('renders SidebarComponent and filters types on input change', () => {
43
+ const types = {
44
+ loaded: true,
45
+ loading: false,
46
+ types: [
47
+ { id: 'document', title: 'Document' },
48
+ { id: 'news-item', title: 'News Item' },
49
+ { id: 'event', title: 'Event' },
50
+ ],
51
+ };
52
+ const handleChange = jest.fn();
53
+
54
+ render(
55
+ <SidebarComponent
56
+ types={types}
57
+ currentContentType={{ id: 'document', title: 'Document' }}
58
+ handleChangeSelectedContentType={handleChange}
59
+ value={{}}
60
+ />,
61
+ );
62
+
63
+ // All types should be visible initially
64
+ expect(screen.getByText('Document')).toBeInTheDocument();
65
+ expect(screen.getByText('News Item')).toBeInTheDocument();
66
+ expect(screen.getByText('Event')).toBeInTheDocument();
67
+
68
+ // Type in search input to filter
69
+ const input = document.querySelector('input[placeholder="Search... "]');
70
+ fireEvent.change(input, { target: { value: 'News' } });
71
+
72
+ // Only News Item should match
73
+ expect(screen.getByText('News Item')).toBeInTheDocument();
74
+ expect(screen.queryByText('Event')).not.toBeInTheDocument();
75
+ });
76
+
77
+ it('handles click on list item', () => {
78
+ const types = {
79
+ loaded: true,
80
+ loading: false,
81
+ types: [{ id: 'document', title: 'Document' }],
82
+ };
83
+ const handleChange = jest.fn();
84
+
85
+ render(
86
+ <SidebarComponent
87
+ types={types}
88
+ currentContentType={null}
89
+ handleChangeSelectedContentType={handleChange}
90
+ value={{ document: [] }}
91
+ />,
92
+ );
93
+
94
+ fireEvent.click(screen.getByText('Document'));
95
+ expect(handleChange).toHaveBeenCalled();
96
+ });
97
+
98
+ it('handles null input value', () => {
99
+ const types = {
100
+ loaded: true,
101
+ loading: false,
102
+ types: [{ id: 'document', title: 'Document' }],
103
+ };
104
+
105
+ render(
106
+ <SidebarComponent
107
+ types={types}
108
+ currentContentType={null}
109
+ handleChangeSelectedContentType={jest.fn()}
110
+ value={{}}
111
+ />,
112
+ );
113
+
114
+ const input = document.querySelector('input[placeholder="Search... "]');
115
+ fireEvent.change(input, { target: { value: null } });
116
+
117
+ // Should still show all types
118
+ expect(screen.getByText('Document')).toBeInTheDocument();
119
+ });
120
+
121
+ it('handles types not loaded', () => {
122
+ const types = {
123
+ loaded: false,
124
+ loading: true,
125
+ types: [],
126
+ };
127
+
128
+ render(
129
+ <SidebarComponent
130
+ types={types}
131
+ currentContentType={null}
132
+ handleChangeSelectedContentType={jest.fn()}
133
+ value={{}}
134
+ />,
135
+ );
136
+
137
+ expect(
138
+ document.querySelector('input[placeholder="Search... "]'),
139
+ ).toBeInTheDocument();
140
+ });
27
141
  });
142
+
28
143
  describe('Visual widget', () => {
29
144
  it('renders the VisualJSONWidget component without breaking if props and progressEditing are empty', () => {
30
145
  const store = mockStore({
@@ -33,19 +148,1358 @@ describe('Visual widget', () => {
33
148
  messages: {},
34
149
  },
35
150
  progressEditing: {},
151
+ rawdata: {},
152
+ types: {
153
+ loaded: true,
154
+ loading: false,
155
+ types: [
156
+ {
157
+ id: 'content-type-1',
158
+ title: 'Content Type 1',
159
+ '@id': '/content-type-1',
160
+ },
161
+ ],
162
+ },
36
163
  });
37
- const component = renderer.create(
164
+ const { container } = render(
38
165
  <Provider store={store}>
39
166
  <MemoryRouter>
40
167
  <VisualJSONWidget
41
168
  pathname="/test"
42
169
  {...propsEmpty}
43
170
  hasToolbar={true}
171
+ value={{}}
172
+ onChange={jest.fn()}
173
+ />
174
+ </MemoryRouter>
175
+ </Provider>,
176
+ );
177
+
178
+ expect(screen.getByText('Edit JSON')).toBeInTheDocument();
179
+ expect(screen.getByText('Add Property')).toBeInTheDocument();
180
+ expect(
181
+ container.querySelector('input[placeholder="Search... "]'),
182
+ ).toBeInTheDocument();
183
+ expect(screen.getByText('Content Type 1')).toBeInTheDocument();
184
+ });
185
+
186
+ it('renders the VisualJSONWidget with enforceCharLimits option in dropdown', () => {
187
+ const store = mockStore({
188
+ intl: {
189
+ locale: 'en',
190
+ messages: {},
191
+ },
192
+ progressEditing: {},
193
+ rawdata: {
194
+ '/content-type-1': {
195
+ loaded: true,
196
+ loading: false,
197
+ data: {
198
+ fieldsets: [{ fields: ['title', 'description'] }],
199
+ required: ['title'],
200
+ },
201
+ },
202
+ },
203
+ types: {
204
+ loaded: true,
205
+ loading: false,
206
+ types: [
207
+ {
208
+ id: 'content-type-1',
209
+ title: 'Content Type 1',
210
+ '@id': '/content-type-1',
211
+ },
212
+ ],
213
+ },
214
+ });
215
+ render(
216
+ <Provider store={store}>
217
+ <MemoryRouter>
218
+ <VisualJSONWidget
219
+ pathname="/test"
220
+ hasToolbar={true}
221
+ value={{}}
222
+ onChange={jest.fn()}
223
+ />
224
+ </MemoryRouter>
225
+ </Provider>,
226
+ );
227
+
228
+ expect(screen.getByText('Add Property')).toBeInTheDocument();
229
+ });
230
+
231
+ it('handles enforceCharLimits rule in value', () => {
232
+ const store = mockStore({
233
+ intl: {
234
+ locale: 'en',
235
+ messages: {},
236
+ },
237
+ progressEditing: {},
238
+ rawdata: {
239
+ '/content-type-1': {
240
+ loaded: true,
241
+ loading: false,
242
+ data: {
243
+ fieldsets: [{ fields: ['title', 'description'] }],
244
+ required: ['title'],
245
+ },
246
+ },
247
+ },
248
+ types: {
249
+ loaded: true,
250
+ loading: false,
251
+ types: [
252
+ {
253
+ id: 'content-type-1',
254
+ title: 'Content Type 1',
255
+ '@id': '/content-type-1',
256
+ },
257
+ ],
258
+ },
259
+ });
260
+ const valueWithCharLimits = {
261
+ 'content-type-1': [
262
+ {
263
+ type: 'enforceCharLimits',
264
+ states: ['all'],
265
+ linkLabel: 'Fix {title}',
266
+ },
267
+ ],
268
+ };
269
+ render(
270
+ <Provider store={store}>
271
+ <MemoryRouter>
272
+ <VisualJSONWidget
273
+ pathname="/test"
274
+ hasToolbar={true}
275
+ value={valueWithCharLimits}
276
+ onChange={jest.fn()}
277
+ />
278
+ </MemoryRouter>
279
+ </Provider>,
280
+ );
281
+
282
+ expect(screen.getByText('Add Property')).toBeInTheDocument();
283
+ });
284
+
285
+ it('adds enforceCharLimits when dropdown option is selected', () => {
286
+ const mockOnChange = jest.fn();
287
+ const store = mockStore({
288
+ intl: {
289
+ locale: 'en',
290
+ messages: {},
291
+ },
292
+ progressEditing: {},
293
+ rawdata: {
294
+ '/content-type-1': {
295
+ loaded: true,
296
+ loading: false,
297
+ data: {
298
+ fieldsets: [{ fields: ['description'] }],
299
+ required: [],
300
+ },
301
+ },
302
+ },
303
+ types: {
304
+ loaded: true,
305
+ loading: false,
306
+ types: [
307
+ {
308
+ id: 'content-type-1',
309
+ title: 'Content Type 1',
310
+ '@id': '/content-type-1',
311
+ },
312
+ ],
313
+ },
314
+ });
315
+ render(
316
+ <Provider store={store}>
317
+ <MemoryRouter>
318
+ <VisualJSONWidget
319
+ pathname="/test"
320
+ hasToolbar={true}
321
+ value={{}}
322
+ onChange={mockOnChange}
323
+ />
324
+ </MemoryRouter>
325
+ </Provider>,
326
+ );
327
+
328
+ // Click on dropdown to see options
329
+ const dropdown = screen.getByText('Add Property');
330
+ fireEvent.click(dropdown);
331
+
332
+ expect(screen.getByText('Add Property')).toBeInTheDocument();
333
+ });
334
+
335
+ it('opens JSON editor modal when Edit JSON button is clicked', () => {
336
+ const store = mockStore({
337
+ intl: {
338
+ locale: 'en',
339
+ messages: {},
340
+ },
341
+ progressEditing: {},
342
+ rawdata: {},
343
+ types: {
344
+ loaded: true,
345
+ loading: false,
346
+ types: [
347
+ {
348
+ id: 'content-type-1',
349
+ title: 'Content Type 1',
350
+ '@id': '/content-type-1',
351
+ },
352
+ ],
353
+ },
354
+ });
355
+ render(
356
+ <Provider store={store}>
357
+ <MemoryRouter>
358
+ <VisualJSONWidget
359
+ pathname="/test"
360
+ hasToolbar={true}
361
+ value={{}}
362
+ onChange={jest.fn()}
363
+ />
364
+ </MemoryRouter>
365
+ </Provider>,
366
+ );
367
+
368
+ const editJsonButton = screen.getByText('Edit JSON');
369
+ fireEvent.click(editJsonButton);
370
+
371
+ // Modal should be visible after clicking
372
+ expect(editJsonButton).toBeInTheDocument();
373
+ });
374
+
375
+ it('handles types not loaded state', () => {
376
+ const store = mockStore({
377
+ intl: {
378
+ locale: 'en',
379
+ messages: {},
380
+ },
381
+ progressEditing: {},
382
+ rawdata: {},
383
+ types: {
384
+ loaded: false,
385
+ loading: true,
386
+ types: [],
387
+ },
388
+ });
389
+ render(
390
+ <Provider store={store}>
391
+ <MemoryRouter>
392
+ <VisualJSONWidget
393
+ pathname="/test"
394
+ hasToolbar={true}
395
+ value={{}}
396
+ onChange={jest.fn()}
397
+ />
398
+ </MemoryRouter>
399
+ </Provider>,
400
+ );
401
+
402
+ expect(screen.getByText('Edit JSON')).toBeInTheDocument();
403
+ });
404
+
405
+ it('updates existing field rule when dropdown changes for existing field', () => {
406
+ const mockOnChange = jest.fn();
407
+ const existingValue = {
408
+ 'content-type-1': [
409
+ {
410
+ prefix: 'description',
411
+ states: ['all'],
412
+ linkLabel: 'Add description',
413
+ condition: 'python:value',
414
+ link: 'edit#description',
415
+ },
416
+ ],
417
+ };
418
+ const store = mockStore({
419
+ intl: {
420
+ locale: 'en',
421
+ messages: {},
422
+ },
423
+ progressEditing: {},
424
+ rawdata: {
425
+ '/content-type-1': {
426
+ loaded: true,
427
+ loading: false,
428
+ data: {
429
+ fieldsets: [{ fields: ['title', 'description'] }],
430
+ required: ['title'],
431
+ },
432
+ },
433
+ '/@vocabularies/plone.app.vocabularies.WorkflowStates': {
434
+ loaded: true,
435
+ loading: false,
436
+ data: {
437
+ items: [{ token: 'published' }, { token: 'private' }],
438
+ },
439
+ },
440
+ },
441
+ types: {
442
+ loaded: true,
443
+ loading: false,
444
+ types: [
445
+ {
446
+ id: 'content-type-1',
447
+ title: 'Content Type 1',
448
+ '@id': '/content-type-1',
449
+ },
450
+ ],
451
+ },
452
+ });
453
+ render(
454
+ <Provider store={store}>
455
+ <MemoryRouter>
456
+ <VisualJSONWidget
457
+ pathname="/test"
458
+ hasToolbar={true}
459
+ value={existingValue}
460
+ onChange={mockOnChange}
461
+ />
462
+ </MemoryRouter>
463
+ </Provider>,
464
+ );
465
+
466
+ // The description field should be visible
467
+ expect(screen.getByText('description')).toBeInTheDocument();
468
+ });
469
+
470
+ it('handles field with multiple rules where prefix does not match', () => {
471
+ const mockOnChange = jest.fn();
472
+ const existingValue = {
473
+ 'content-type-1': [
474
+ {
475
+ prefix: 'other_field',
476
+ states: ['all'],
477
+ linkLabel: 'Other',
478
+ condition: 'python:value',
479
+ link: 'edit#other',
480
+ },
481
+ {
482
+ prefix: 'description',
483
+ states: ['published'],
484
+ linkLabel: 'Add description',
485
+ condition: 'python:value',
486
+ link: 'edit#description',
487
+ },
488
+ ],
489
+ };
490
+ const store = mockStore({
491
+ intl: {
492
+ locale: 'en',
493
+ messages: {},
494
+ },
495
+ progressEditing: {},
496
+ rawdata: {
497
+ '/content-type-1': {
498
+ loaded: true,
499
+ loading: false,
500
+ data: {
501
+ fieldsets: [{ fields: ['title', 'description', 'other_field'] }],
502
+ required: ['title'],
503
+ },
504
+ },
505
+ '/@vocabularies/plone.app.vocabularies.WorkflowStates': {
506
+ loaded: true,
507
+ loading: false,
508
+ data: {
509
+ items: [{ token: 'published' }, { token: 'private' }],
510
+ },
511
+ },
512
+ },
513
+ types: {
514
+ loaded: true,
515
+ loading: false,
516
+ types: [
517
+ {
518
+ id: 'content-type-1',
519
+ title: 'Content Type 1',
520
+ '@id': '/content-type-1',
521
+ },
522
+ ],
523
+ },
524
+ });
525
+ render(
526
+ <Provider store={store}>
527
+ <MemoryRouter>
528
+ <VisualJSONWidget
529
+ pathname="/test"
530
+ hasToolbar={true}
531
+ value={existingValue}
532
+ onChange={mockOnChange}
533
+ />
534
+ </MemoryRouter>
535
+ </Provider>,
536
+ );
537
+
538
+ // Both fields should be visible
539
+ expect(screen.getByText('description')).toBeInTheDocument();
540
+ expect(screen.getByText('other_field')).toBeInTheDocument();
541
+ });
542
+ });
543
+
544
+ describe('EditDataComponent with enforceCharLimits', () => {
545
+ it('renders enforceCharLimits section when rule exists', () => {
546
+ const store = mockStore({
547
+ intl: {
548
+ locale: 'en',
549
+ messages: {},
550
+ },
551
+ rawdata: {
552
+ '/@vocabularies/plone.app.vocabularies.WorkflowStates': {
553
+ loaded: true,
554
+ loading: false,
555
+ data: {
556
+ items: [{ token: 'published' }, { token: 'private' }],
557
+ },
558
+ },
559
+ },
560
+ });
561
+
562
+ const value = {
563
+ 'content-type-1': [
564
+ {
565
+ type: 'enforceCharLimits',
566
+ states: ['all'],
567
+ linkLabel: 'Fix {title}',
568
+ },
569
+ ],
570
+ };
571
+
572
+ render(
573
+ <Provider store={store}>
574
+ <MemoryRouter>
575
+ <EditDataComponent
576
+ request={{
577
+ loaded: true,
578
+ loading: false,
579
+ data: { fieldsets: [{ fields: [] }], required: [] },
580
+ }}
581
+ handleOnDropdownChange={jest.fn()}
582
+ currentContentType={{
583
+ id: 'content-type-1',
584
+ title: 'Content Type 1',
585
+ }}
586
+ value={value}
587
+ fields={[]}
588
+ getDropdownValues={jest.fn()}
589
+ handleUpdateEnforceCharLimits={jest.fn()}
590
+ handleRemoveEnforceCharLimits={jest.fn()}
591
+ />
592
+ </MemoryRouter>
593
+ </Provider>,
594
+ );
595
+
596
+ expect(screen.getByText('Enforce character limits')).toBeInTheDocument();
597
+ });
598
+
599
+ it('does not render enforceCharLimits section when rule does not exist', () => {
600
+ const store = mockStore({
601
+ intl: {
602
+ locale: 'en',
603
+ messages: {},
604
+ },
605
+ rawdata: {
606
+ '/@vocabularies/plone.app.vocabularies.WorkflowStates': {
607
+ loaded: true,
608
+ loading: false,
609
+ data: {
610
+ items: [{ token: 'published' }, { token: 'private' }],
611
+ },
612
+ },
613
+ },
614
+ });
615
+
616
+ render(
617
+ <Provider store={store}>
618
+ <MemoryRouter>
619
+ <EditDataComponent
620
+ request={{
621
+ loaded: true,
622
+ loading: false,
623
+ data: { fieldsets: [{ fields: [] }], required: [] },
624
+ }}
625
+ handleOnDropdownChange={jest.fn()}
626
+ currentContentType={{
627
+ id: 'content-type-1',
628
+ title: 'Content Type 1',
629
+ }}
630
+ value={{}}
631
+ fields={[]}
632
+ getDropdownValues={jest.fn()}
633
+ handleUpdateEnforceCharLimits={jest.fn()}
634
+ handleRemoveEnforceCharLimits={jest.fn()}
635
+ />
636
+ </MemoryRouter>
637
+ </Provider>,
638
+ );
639
+
640
+ expect(
641
+ screen.queryByText('Enforce character limits'),
642
+ ).not.toBeInTheDocument();
643
+ });
644
+
645
+ it('calls handleRemoveEnforceCharLimits when cancel icon is clicked', () => {
646
+ const store = mockStore({
647
+ intl: {
648
+ locale: 'en',
649
+ messages: {},
650
+ },
651
+ rawdata: {
652
+ '/@vocabularies/plone.app.vocabularies.WorkflowStates': {
653
+ loaded: true,
654
+ loading: false,
655
+ data: {
656
+ items: [{ token: 'published' }, { token: 'private' }],
657
+ },
658
+ },
659
+ },
660
+ });
661
+
662
+ const value = {
663
+ 'content-type-1': [
664
+ {
665
+ type: 'enforceCharLimits',
666
+ states: ['all'],
667
+ linkLabel: 'Fix {title}',
668
+ },
669
+ ],
670
+ };
671
+
672
+ const handleRemove = jest.fn();
673
+
674
+ render(
675
+ <Provider store={store}>
676
+ <MemoryRouter>
677
+ <EditDataComponent
678
+ request={{
679
+ loaded: true,
680
+ loading: false,
681
+ data: { fieldsets: [{ fields: [] }], required: [] },
682
+ }}
683
+ handleOnDropdownChange={jest.fn()}
684
+ currentContentType={{
685
+ id: 'content-type-1',
686
+ title: 'Content Type 1',
687
+ }}
688
+ value={value}
689
+ fields={[]}
690
+ getDropdownValues={jest.fn()}
691
+ handleUpdateEnforceCharLimits={jest.fn()}
692
+ handleRemoveEnforceCharLimits={handleRemove}
693
+ />
694
+ </MemoryRouter>
695
+ </Provider>,
696
+ );
697
+
698
+ const cancelIcon = screen
699
+ .getByText('Enforce character limits')
700
+ .parentElement.parentElement.querySelector('.cancel');
701
+ fireEvent.click(cancelIcon);
702
+ expect(handleRemove).toHaveBeenCalled();
703
+ });
704
+
705
+ it('calls handleUpdateEnforceCharLimits when linkLabel input changes', () => {
706
+ const store = mockStore({
707
+ intl: {
708
+ locale: 'en',
709
+ messages: {},
710
+ },
711
+ rawdata: {
712
+ '/@vocabularies/plone.app.vocabularies.WorkflowStates': {
713
+ loaded: true,
714
+ loading: false,
715
+ data: {
716
+ items: [{ token: 'published' }, { token: 'private' }],
717
+ },
718
+ },
719
+ },
720
+ });
721
+
722
+ const value = {
723
+ 'content-type-1': [
724
+ {
725
+ type: 'enforceCharLimits',
726
+ states: ['all'],
727
+ linkLabel: 'Fix {title}',
728
+ },
729
+ ],
730
+ };
731
+
732
+ const handleUpdate = jest.fn();
733
+
734
+ render(
735
+ <Provider store={store}>
736
+ <MemoryRouter>
737
+ <EditDataComponent
738
+ request={{
739
+ loaded: true,
740
+ loading: false,
741
+ data: { fieldsets: [{ fields: [] }], required: [] },
742
+ }}
743
+ handleOnDropdownChange={jest.fn()}
744
+ currentContentType={{
745
+ id: 'content-type-1',
746
+ title: 'Content Type 1',
747
+ }}
748
+ value={value}
749
+ fields={[]}
750
+ getDropdownValues={jest.fn()}
751
+ handleUpdateEnforceCharLimits={handleUpdate}
752
+ handleRemoveEnforceCharLimits={jest.fn()}
753
+ />
754
+ </MemoryRouter>
755
+ </Provider>,
756
+ );
757
+
758
+ // Click on accordion to expand it
759
+ fireEvent.click(screen.getByText('Enforce character limits'));
760
+
761
+ // Find and change the input
762
+ const input = screen.getByPlaceholderText('Fix {title}');
763
+ fireEvent.change(input, { target: { value: 'New label' } });
764
+
765
+ expect(handleUpdate).toHaveBeenCalledWith('linkLabel', 'New label');
766
+ });
767
+
768
+ it('renders with empty currentContentType', () => {
769
+ const store = mockStore({
770
+ intl: {
771
+ locale: 'en',
772
+ messages: {},
773
+ },
774
+ rawdata: {
775
+ '/@vocabularies/plone.app.vocabularies.WorkflowStates': {
776
+ loaded: true,
777
+ loading: false,
778
+ data: {
779
+ items: [],
780
+ },
781
+ },
782
+ },
783
+ });
784
+
785
+ render(
786
+ <Provider store={store}>
787
+ <MemoryRouter>
788
+ <EditDataComponent
789
+ request={{
790
+ loaded: true,
791
+ loading: false,
792
+ data: { fieldsets: [{ fields: [] }], required: [] },
793
+ }}
794
+ handleOnDropdownChange={jest.fn()}
795
+ currentContentType={null}
796
+ value={{}}
797
+ fields={[]}
798
+ getDropdownValues={jest.fn()}
799
+ handleUpdateEnforceCharLimits={jest.fn()}
800
+ handleRemoveEnforceCharLimits={jest.fn()}
801
+ />
802
+ </MemoryRouter>
803
+ </Provider>,
804
+ );
805
+
806
+ expect(
807
+ screen.queryByText('Enforce character limits'),
808
+ ).not.toBeInTheDocument();
809
+ });
810
+
811
+ it('renders with fields and handles field accordion', () => {
812
+ const store = mockStore({
813
+ intl: {
814
+ locale: 'en',
815
+ messages: {},
816
+ },
817
+ rawdata: {
818
+ '/@vocabularies/plone.app.vocabularies.WorkflowStates': {
819
+ loaded: true,
820
+ loading: false,
821
+ data: {
822
+ items: [{ token: 'published' }, { token: 'private' }],
823
+ },
824
+ },
825
+ },
826
+ });
827
+
828
+ const value = {
829
+ 'content-type-1': [
830
+ {
831
+ prefix: 'description',
832
+ states: ['all'],
833
+ linkLabel: 'Add description',
834
+ condition: 'python:value',
835
+ link: 'edit#description',
836
+ },
837
+ ],
838
+ };
839
+
840
+ render(
841
+ <Provider store={store}>
842
+ <MemoryRouter>
843
+ <EditDataComponent
844
+ request={{
845
+ loaded: true,
846
+ loading: false,
847
+ data: {
848
+ fieldsets: [{ fields: ['title', 'description'] }],
849
+ required: ['title'],
850
+ },
851
+ }}
852
+ handleOnDropdownChange={jest.fn()}
853
+ currentContentType={{
854
+ id: 'content-type-1',
855
+ title: 'Content Type 1',
856
+ }}
857
+ value={value}
858
+ fields={['title', 'description']}
859
+ getDropdownValues={(field) =>
860
+ field === 'description' ? ['All'] : undefined
861
+ }
862
+ handleUpdateEnforceCharLimits={jest.fn()}
863
+ handleRemoveEnforceCharLimits={jest.fn()}
864
+ />
865
+ </MemoryRouter>
866
+ </Provider>,
867
+ );
868
+
869
+ expect(screen.getByText('description')).toBeInTheDocument();
870
+ });
871
+
872
+ it('handles request loading state', () => {
873
+ const store = mockStore({
874
+ intl: {
875
+ locale: 'en',
876
+ messages: {},
877
+ },
878
+ rawdata: {
879
+ '/@vocabularies/plone.app.vocabularies.WorkflowStates': {
880
+ loaded: false,
881
+ loading: true,
882
+ data: null,
883
+ },
884
+ },
885
+ });
886
+
887
+ render(
888
+ <Provider store={store}>
889
+ <MemoryRouter>
890
+ <EditDataComponent
891
+ request={{ loaded: false, loading: true, data: null }}
892
+ handleOnDropdownChange={jest.fn()}
893
+ currentContentType={{
894
+ id: 'content-type-1',
895
+ title: 'Content Type 1',
896
+ }}
897
+ value={{}}
898
+ fields={[]}
899
+ getDropdownValues={jest.fn()}
900
+ handleUpdateEnforceCharLimits={jest.fn()}
901
+ handleRemoveEnforceCharLimits={jest.fn()}
902
+ />
903
+ </MemoryRouter>
904
+ </Provider>,
905
+ );
906
+
907
+ expect(
908
+ screen.queryByText('Enforce character limits'),
909
+ ).not.toBeInTheDocument();
910
+ });
911
+
912
+ it('handles message input change for field', () => {
913
+ const mockHandleOnDropdownChange = jest.fn();
914
+ const store = mockStore({
915
+ intl: {
916
+ locale: 'en',
917
+ messages: {},
918
+ },
919
+ rawdata: {
920
+ '/@vocabularies/plone.app.vocabularies.WorkflowStates': {
921
+ loaded: true,
922
+ loading: false,
923
+ data: {
924
+ items: [{ token: 'published' }, { token: 'private' }],
925
+ },
926
+ },
927
+ },
928
+ });
929
+
930
+ const value = {
931
+ 'content-type-1': [
932
+ {
933
+ prefix: 'description',
934
+ states: ['all'],
935
+ linkLabel: 'Add description',
936
+ condition: 'python:value',
937
+ link: 'edit#description',
938
+ },
939
+ ],
940
+ };
941
+
942
+ render(
943
+ <Provider store={store}>
944
+ <MemoryRouter>
945
+ <EditDataComponent
946
+ request={{
947
+ loaded: true,
948
+ loading: false,
949
+ data: {
950
+ fieldsets: [{ fields: ['description'] }],
951
+ required: [],
952
+ },
953
+ }}
954
+ handleOnDropdownChange={mockHandleOnDropdownChange}
955
+ currentContentType={{
956
+ id: 'content-type-1',
957
+ title: 'Content Type 1',
958
+ }}
959
+ value={value}
960
+ fields={['description']}
961
+ getDropdownValues={(field) =>
962
+ field === 'description' ? ['All'] : undefined
963
+ }
964
+ handleUpdateEnforceCharLimits={jest.fn()}
965
+ handleRemoveEnforceCharLimits={jest.fn()}
966
+ />
967
+ </MemoryRouter>
968
+ </Provider>,
969
+ );
970
+
971
+ // Click on field accordion to expand
972
+ fireEvent.click(screen.getByText('description'));
973
+
974
+ // Change message input
975
+ const messageInput = document.querySelector('input[name="message"]');
976
+ fireEvent.change(messageInput, { target: { value: 'New message' } });
977
+
978
+ expect(mockHandleOnDropdownChange).toHaveBeenCalled();
979
+ });
980
+
981
+ it('handles link input change for field', () => {
982
+ const mockHandleOnDropdownChange = jest.fn();
983
+ const store = mockStore({
984
+ intl: {
985
+ locale: 'en',
986
+ messages: {},
987
+ },
988
+ rawdata: {
989
+ '/@vocabularies/plone.app.vocabularies.WorkflowStates': {
990
+ loaded: true,
991
+ loading: false,
992
+ data: {
993
+ items: [{ token: 'published' }],
994
+ },
995
+ },
996
+ },
997
+ });
998
+
999
+ const value = {
1000
+ 'content-type-1': [
1001
+ {
1002
+ prefix: 'description',
1003
+ states: ['all'],
1004
+ linkLabel: 'Add',
1005
+ condition: 'python:value',
1006
+ link: 'edit#desc',
1007
+ },
1008
+ ],
1009
+ };
1010
+
1011
+ render(
1012
+ <Provider store={store}>
1013
+ <MemoryRouter>
1014
+ <EditDataComponent
1015
+ request={{
1016
+ loaded: true,
1017
+ loading: false,
1018
+ data: {
1019
+ fieldsets: [{ fields: ['description'] }],
1020
+ required: [],
1021
+ },
1022
+ }}
1023
+ handleOnDropdownChange={mockHandleOnDropdownChange}
1024
+ currentContentType={{
1025
+ id: 'content-type-1',
1026
+ title: 'Content Type 1',
1027
+ }}
1028
+ value={value}
1029
+ fields={['description']}
1030
+ getDropdownValues={(field) =>
1031
+ field === 'description' ? ['All'] : undefined
1032
+ }
1033
+ handleUpdateEnforceCharLimits={jest.fn()}
1034
+ handleRemoveEnforceCharLimits={jest.fn()}
1035
+ />
1036
+ </MemoryRouter>
1037
+ </Provider>,
1038
+ );
1039
+
1040
+ // Click on field accordion to expand
1041
+ fireEvent.click(screen.getByText('description'));
1042
+
1043
+ // Change link input
1044
+ const linkInput = document.querySelector('input[name="link"]');
1045
+ fireEvent.change(linkInput, { target: { value: 'new-link' } });
1046
+
1047
+ expect(mockHandleOnDropdownChange).toHaveBeenCalled();
1048
+ });
1049
+
1050
+ it('handles condition input change for field', () => {
1051
+ const mockHandleOnDropdownChange = jest.fn();
1052
+ const store = mockStore({
1053
+ intl: {
1054
+ locale: 'en',
1055
+ messages: {},
1056
+ },
1057
+ rawdata: {
1058
+ '/@vocabularies/plone.app.vocabularies.WorkflowStates': {
1059
+ loaded: true,
1060
+ loading: false,
1061
+ data: {
1062
+ items: [{ token: 'published' }],
1063
+ },
1064
+ },
1065
+ },
1066
+ });
1067
+
1068
+ const value = {
1069
+ 'content-type-1': [
1070
+ {
1071
+ prefix: 'description',
1072
+ states: ['all'],
1073
+ linkLabel: 'Add',
1074
+ condition: 'python:value',
1075
+ link: 'edit#desc',
1076
+ },
1077
+ ],
1078
+ };
1079
+
1080
+ render(
1081
+ <Provider store={store}>
1082
+ <MemoryRouter>
1083
+ <EditDataComponent
1084
+ request={{
1085
+ loaded: true,
1086
+ loading: false,
1087
+ data: {
1088
+ fieldsets: [{ fields: ['description'] }],
1089
+ required: [],
1090
+ },
1091
+ }}
1092
+ handleOnDropdownChange={mockHandleOnDropdownChange}
1093
+ currentContentType={{
1094
+ id: 'content-type-1',
1095
+ title: 'Content Type 1',
1096
+ }}
1097
+ value={value}
1098
+ fields={['description']}
1099
+ getDropdownValues={(field) =>
1100
+ field === 'description' ? ['All'] : undefined
1101
+ }
1102
+ handleUpdateEnforceCharLimits={jest.fn()}
1103
+ handleRemoveEnforceCharLimits={jest.fn()}
44
1104
  />
45
1105
  </MemoryRouter>
46
1106
  </Provider>,
47
1107
  );
48
- const json = component.toJSON();
49
- expect(json).toMatchSnapshot();
1108
+
1109
+ // Click on field accordion to expand
1110
+ fireEvent.click(screen.getByText('description'));
1111
+
1112
+ // Change condition input
1113
+ const conditionInput = document.querySelector('input[name="condition"]');
1114
+ fireEvent.change(conditionInput, { target: { value: 'python:True' } });
1115
+
1116
+ expect(mockHandleOnDropdownChange).toHaveBeenCalled();
1117
+ });
1118
+ });
1119
+
1120
+ describe('ScrollIntoView', () => {
1121
+ beforeEach(() => {
1122
+ jest.useFakeTimers();
1123
+ global.__CLIENT__ = true;
1124
+ });
1125
+
1126
+ afterEach(() => {
1127
+ jest.useRealTimers();
1128
+ delete global.__CLIENT__;
1129
+ });
1130
+
1131
+ it('renders null', () => {
1132
+ const { container } = render(
1133
+ <ScrollIntoView location={{ hash: '', pathname: '/test' }} />,
1134
+ );
1135
+ expect(container.firstChild).toBeNull();
1136
+ });
1137
+
1138
+ it('does nothing when no hash', () => {
1139
+ const { container } = render(
1140
+ <ScrollIntoView location={{ hash: '', pathname: '/test' }} />,
1141
+ );
1142
+ expect(container.firstChild).toBeNull();
1143
+ });
1144
+
1145
+ it('scrolls to element when hash is present', () => {
1146
+ const mockElement = document.createElement('div');
1147
+ mockElement.id = 'test-element';
1148
+ mockElement.scrollIntoView = jest.fn();
1149
+ mockElement.classList = { add: jest.fn(), remove: jest.fn() };
1150
+ mockElement.closest = jest.fn().mockReturnValue(null);
1151
+ document.body.appendChild(mockElement);
1152
+
1153
+ const originalGetElementById = document.getElementById;
1154
+ document.getElementById = jest.fn().mockReturnValue(mockElement);
1155
+
1156
+ render(
1157
+ <ScrollIntoView
1158
+ location={{ hash: '#test-element', pathname: '/test' }}
1159
+ />,
1160
+ );
1161
+
1162
+ jest.advanceTimersByTime(250);
1163
+
1164
+ expect(document.getElementById).toHaveBeenCalledWith('test-element');
1165
+ expect(mockElement.scrollIntoView).toHaveBeenCalledWith({
1166
+ behavior: 'smooth',
1167
+ block: 'center',
1168
+ });
1169
+
1170
+ document.getElementById = originalGetElementById;
1171
+ document.body.removeChild(mockElement);
1172
+ });
1173
+
1174
+ it('clears interval after 40 attempts', () => {
1175
+ const originalGetElementById = document.getElementById;
1176
+ document.getElementById = jest.fn().mockReturnValue(null);
1177
+ const clearIntervalSpy = jest.spyOn(window, 'clearInterval');
1178
+
1179
+ render(
1180
+ <ScrollIntoView location={{ hash: '#nonexistent', pathname: '/test' }} />,
1181
+ );
1182
+
1183
+ // Run 41 intervals (250ms each)
1184
+ jest.advanceTimersByTime(250 * 41);
1185
+
1186
+ expect(clearIntervalSpy).toHaveBeenCalled();
1187
+
1188
+ document.getElementById = originalGetElementById;
1189
+ clearIntervalSpy.mockRestore();
1190
+ });
1191
+
1192
+ it('clicks first tab on edit page with fieldset hash', () => {
1193
+ const mockElement = document.createElement('div');
1194
+ mockElement.id = 'fieldset-test';
1195
+ mockElement.scrollIntoView = jest.fn();
1196
+ mockElement.classList = { add: jest.fn(), remove: jest.fn() };
1197
+ mockElement.closest = jest.fn().mockReturnValue(null);
1198
+
1199
+ const mockTab = document.createElement('div');
1200
+ mockTab.click = jest.fn();
1201
+ mockTab.classList = { contains: jest.fn().mockReturnValue(false) };
1202
+
1203
+ const mockFormTabs = document.createElement('div');
1204
+ mockFormTabs.className = 'formtabs';
1205
+ Object.defineProperty(mockFormTabs, 'firstElementChild', {
1206
+ get: () => mockTab,
1207
+ });
1208
+
1209
+ const mockSidebar = document.createElement('div');
1210
+ mockSidebar.className = 'sidebar-container';
1211
+ mockSidebar.appendChild(mockFormTabs);
1212
+ document.body.appendChild(mockSidebar);
1213
+
1214
+ const originalGetElementById = document.getElementById;
1215
+ const originalQuerySelector = document.querySelector;
1216
+
1217
+ document.getElementById = jest.fn().mockReturnValue(mockElement);
1218
+ document.querySelector = jest.fn().mockImplementation((selector) => {
1219
+ if (selector === '.sidebar-container .formtabs') {
1220
+ return mockFormTabs;
1221
+ }
1222
+ return null;
1223
+ });
1224
+ document.querySelectorAll = jest.fn().mockReturnValue([]);
1225
+
1226
+ render(
1227
+ <ScrollIntoView
1228
+ location={{ hash: '#fieldset-test', pathname: '/test/edit' }}
1229
+ />,
1230
+ );
1231
+
1232
+ jest.advanceTimersByTime(250);
1233
+
1234
+ expect(mockTab.click).toHaveBeenCalled();
1235
+
1236
+ document.getElementById = originalGetElementById;
1237
+ document.querySelector = originalQuerySelector;
1238
+ document.body.removeChild(mockSidebar);
1239
+ });
1240
+
1241
+ it('does nothing on server side', () => {
1242
+ global.__CLIENT__ = false;
1243
+
1244
+ render(<ScrollIntoView location={{ hash: '#test', pathname: '/test' }} />);
1245
+
1246
+ // Should not throw or do anything
1247
+ expect(true).toBe(true);
1248
+ });
1249
+ });
1250
+
1251
+ describe('Actions', () => {
1252
+ it('getEditingProgress returns correct action', () => {
1253
+ const result = getEditingProgress('/test/item');
1254
+ expect(result.type).toBe('EDITING_PROGRESS');
1255
+ expect(result.request.path).toBe('/test/item/@editing.progress');
1256
+ });
1257
+
1258
+ it('getRawContent returns correct action', () => {
1259
+ const result = getRawContent('/test/url');
1260
+ expect(result.type).toBe('GET_RAW_CONTENT');
1261
+ expect(result.request.path).toBe('/test/url');
1262
+ expect(result.url).toBe('/test/url');
1263
+ });
1264
+
1265
+ it('getRawContent accepts custom headers', () => {
1266
+ const result = getRawContent('/test/url', { 'X-Custom': 'value' });
1267
+ expect(result.request.headers).toEqual({ 'X-Custom': 'value' });
1268
+ });
1269
+ });
1270
+
1271
+ describe('Reducers', () => {
1272
+ describe('editingProgress', () => {
1273
+ it('returns initial state', () => {
1274
+ const result = editingProgress(undefined, {});
1275
+ expect(result.get.loaded).toBe(false);
1276
+ expect(result.get.loading).toBe(false);
1277
+ });
1278
+
1279
+ it('handles EDITING_PROGRESS_PENDING', () => {
1280
+ const result = editingProgress(undefined, {
1281
+ type: 'EDITING_PROGRESS_PENDING',
1282
+ });
1283
+ expect(result.editing.loading).toBe(true);
1284
+ expect(result.editing.loaded).toBe(false);
1285
+ });
1286
+
1287
+ it('handles EDITING_PROGRESS_SUCCESS', () => {
1288
+ const result = editingProgress(undefined, {
1289
+ type: 'EDITING_PROGRESS_SUCCESS',
1290
+ result: { data: 'test' },
1291
+ });
1292
+ expect(result.editing.loading).toBe(false);
1293
+ expect(result.editing.loaded).toBe(true);
1294
+ expect(result.result).toEqual({ data: 'test' });
1295
+ });
1296
+
1297
+ it('handles EDITING_PROGRESS_FAIL', () => {
1298
+ const result = editingProgress(undefined, {
1299
+ type: 'EDITING_PROGRESS_FAIL',
1300
+ error: 'error message',
1301
+ });
1302
+ expect(result.editing.loading).toBe(false);
1303
+ expect(result.editing.loaded).toBe(false);
1304
+ expect(result.editing.error).toBe('error message');
1305
+ });
1306
+ });
1307
+
1308
+ describe('rawdata', () => {
1309
+ it('returns initial state', () => {
1310
+ const result = rawdata(undefined, {});
1311
+ expect(result).toEqual({});
1312
+ });
1313
+
1314
+ it('handles GET_RAW_CONTENT_PENDING', () => {
1315
+ const result = rawdata(
1316
+ {},
1317
+ {
1318
+ type: 'GET_RAW_CONTENT_PENDING',
1319
+ url: '/test',
1320
+ },
1321
+ );
1322
+ expect(result['/test'].loading).toBe(true);
1323
+ expect(result['/test'].loaded).toBe(false);
1324
+ });
1325
+
1326
+ it('handles GET_RAW_CONTENT_SUCCESS', () => {
1327
+ const result = rawdata(
1328
+ {},
1329
+ {
1330
+ type: 'GET_RAW_CONTENT_SUCCESS',
1331
+ url: '/test',
1332
+ result: { items: [] },
1333
+ },
1334
+ );
1335
+ expect(result['/test'].loading).toBe(false);
1336
+ expect(result['/test'].loaded).toBe(true);
1337
+ expect(result['/test'].data).toEqual({ items: [] });
1338
+ });
1339
+
1340
+ it('handles GET_RAW_CONTENT_FAIL', () => {
1341
+ const result = rawdata(
1342
+ {},
1343
+ {
1344
+ type: 'GET_RAW_CONTENT_FAIL',
1345
+ url: '/test',
1346
+ error: 'error',
1347
+ },
1348
+ );
1349
+ expect(result['/test'].loading).toBe(false);
1350
+ expect(result['/test'].loaded).toBe(false);
1351
+ expect(result['/test'].error).toBe('error');
1352
+ });
1353
+ });
1354
+ });
1355
+
1356
+ describe('JSONSchema', () => {
1357
+ it('returns schema with json field', () => {
1358
+ const mockIntl = {
1359
+ formatMessage: jest.fn().mockReturnValue('JSON code'),
1360
+ };
1361
+ const result = JSONSchema({ intl: mockIntl });
1362
+ expect(result.required).toContain('json');
1363
+ expect(result.fieldsets[0].fields).toContain('json');
1364
+ expect(result.properties.json.widget).toBe('jsonTextarea');
1365
+ });
1366
+ });
1367
+
1368
+ describe('TextareaJSONWidget', () => {
1369
+ const store = mockStore({
1370
+ intl: {
1371
+ locale: 'en',
1372
+ messages: {},
1373
+ },
1374
+ });
1375
+
1376
+ it('renders with initial value', () => {
1377
+ render(
1378
+ <Provider store={store}>
1379
+ <TextareaJSONWidget
1380
+ id="test-json"
1381
+ title="Test JSON"
1382
+ value={{ key: 'value' }}
1383
+ onChange={jest.fn()}
1384
+ />
1385
+ </Provider>,
1386
+ );
1387
+
1388
+ const textarea = document.querySelector('textarea');
1389
+ expect(textarea).toBeInTheDocument();
1390
+ expect(textarea.value).toContain('"key"');
1391
+ });
1392
+
1393
+ it('handles valid JSON input', () => {
1394
+ const mockOnChange = jest.fn();
1395
+ render(
1396
+ <Provider store={store}>
1397
+ <TextareaJSONWidget
1398
+ id="test-json"
1399
+ title="Test JSON"
1400
+ value={{ initial: 'value' }}
1401
+ onChange={mockOnChange}
1402
+ />
1403
+ </Provider>,
1404
+ );
1405
+
1406
+ const textarea = document.querySelector('textarea');
1407
+ fireEvent.change(textarea, {
1408
+ target: { value: '{"new": "json"}' },
1409
+ });
1410
+
1411
+ expect(mockOnChange).toHaveBeenCalledWith('test-json', { new: 'json' });
1412
+ });
1413
+
1414
+ it('handles invalid JSON input and shows error', () => {
1415
+ jest.useFakeTimers();
1416
+ const mockOnChange = jest.fn();
1417
+ render(
1418
+ <Provider store={store}>
1419
+ <TextareaJSONWidget
1420
+ id="test-json"
1421
+ title="Test JSON"
1422
+ value={{ initial: 'value' }}
1423
+ onChange={mockOnChange}
1424
+ />
1425
+ </Provider>,
1426
+ );
1427
+
1428
+ const textarea = document.querySelector('textarea');
1429
+ fireEvent.change(textarea, {
1430
+ target: { value: 'invalid json {' },
1431
+ });
1432
+
1433
+ // Should call onChange with previous value
1434
+ expect(mockOnChange).toHaveBeenCalledWith('test-json', {
1435
+ initial: 'value',
1436
+ });
1437
+
1438
+ // Error message should appear
1439
+ expect(screen.getByText('Please enter valid JSON!')).toBeInTheDocument();
1440
+
1441
+ // Error should disappear after 1.5 seconds
1442
+ act(() => {
1443
+ jest.advanceTimersByTime(1500);
1444
+ });
1445
+
1446
+ jest.useRealTimers();
1447
+ });
1448
+
1449
+ it('handles empty string input as undefined', () => {
1450
+ const mockOnChange = jest.fn();
1451
+ render(
1452
+ <Provider store={store}>
1453
+ <TextareaJSONWidget
1454
+ id="test-json"
1455
+ title="Test JSON"
1456
+ value={{ initial: 'value' }}
1457
+ onChange={mockOnChange}
1458
+ />
1459
+ </Provider>,
1460
+ );
1461
+
1462
+ const textarea = document.querySelector('textarea');
1463
+ fireEvent.change(textarea, {
1464
+ target: { value: '' },
1465
+ });
1466
+
1467
+ // Empty string triggers invalid JSON path with previous value
1468
+ expect(mockOnChange).toHaveBeenCalledWith('test-json', {
1469
+ initial: 'value',
1470
+ });
1471
+ });
1472
+
1473
+ it('handles string value prop', () => {
1474
+ render(
1475
+ <Provider store={store}>
1476
+ <TextareaJSONWidget
1477
+ id="test-json"
1478
+ title="Test JSON"
1479
+ value='{"stringified": "value"}'
1480
+ onChange={jest.fn()}
1481
+ />
1482
+ </Provider>,
1483
+ );
1484
+
1485
+ const textarea = document.querySelector('textarea');
1486
+ expect(textarea.value).toContain('"stringified"');
1487
+ });
1488
+
1489
+ it('renders disabled state', () => {
1490
+ render(
1491
+ <Provider store={store}>
1492
+ <TextareaJSONWidget
1493
+ id="test-json"
1494
+ title="Test JSON"
1495
+ value={{ key: 'value' }}
1496
+ onChange={jest.fn()}
1497
+ isDisabled={true}
1498
+ />
1499
+ </Provider>,
1500
+ );
1501
+
1502
+ const textarea = document.querySelector('textarea');
1503
+ expect(textarea).toBeDisabled();
50
1504
  });
51
1505
  });