@eeacms/volto-eea-website-theme 3.7.0 → 3.9.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +19 -2
  2. package/package.json +3 -1
  3. package/src/actions/index.js +1 -0
  4. package/src/actions/navigation.js +24 -0
  5. package/src/actions/print.js +9 -1
  6. package/src/components/manage/Blocks/ContextNavigation/variations/Accordion.jsx +42 -35
  7. package/src/components/manage/Blocks/LayoutSettings/LayoutSettingsEdit.test.jsx +383 -0
  8. package/src/components/manage/Blocks/Title/variations/WebReportPage.test.jsx +232 -0
  9. package/src/components/theme/Banner/View.jsx +11 -92
  10. package/src/components/theme/PrintLoader/PrintLoader.jsx +56 -0
  11. package/src/components/theme/PrintLoader/PrintLoader.test.jsx +91 -0
  12. package/src/components/theme/PrintLoader/style.less +12 -0
  13. package/src/components/theme/WebReport/WebReportSectionView.test.jsx +462 -0
  14. package/src/components/theme/Widgets/ImageViewWidget.test.jsx +26 -0
  15. package/src/components/theme/Widgets/NavigationBehaviorWidget.jsx +601 -0
  16. package/src/components/theme/Widgets/NavigationBehaviorWidget.test.jsx +507 -0
  17. package/src/components/theme/Widgets/SimpleArrayWidget.jsx +183 -0
  18. package/src/components/theme/Widgets/SimpleArrayWidget.test.jsx +283 -0
  19. package/src/constants/ActionTypes.js +2 -0
  20. package/src/customizations/volto/components/manage/History/History.diff +207 -0
  21. package/src/customizations/volto/components/manage/History/History.jsx +444 -0
  22. package/src/customizations/volto/components/theme/Comments/Comments.jsx +9 -2
  23. package/src/customizations/volto/components/theme/Comments/Comments.test.jsx +4 -4
  24. package/src/customizations/volto/components/theme/Comments/comments.less +16 -0
  25. package/src/customizations/volto/components/theme/Header/Header.jsx +60 -1
  26. package/src/customizations/volto/components/theme/View/DefaultView.jsx +42 -33
  27. package/src/customizations/volto/helpers/Html/Html.jsx +212 -0
  28. package/src/customizations/volto/helpers/Html/Readme.md +1 -0
  29. package/src/customizations/volto/server.jsx +375 -0
  30. package/src/helpers/loadLazyImages.js +11 -0
  31. package/src/helpers/loadLazyImages.test.js +22 -0
  32. package/src/helpers/setupPrintView.js +134 -0
  33. package/src/helpers/setupPrintView.test.js +49 -0
  34. package/src/index.js +11 -1
  35. package/src/index.test.js +6 -0
  36. package/src/middleware/voltoCustom.test.js +282 -0
  37. package/src/reducers/index.js +2 -1
  38. package/src/reducers/navigation/navigation.js +47 -0
  39. package/src/reducers/navigation/navigation.test.js +348 -0
  40. package/src/reducers/navigation.js +55 -0
  41. package/src/reducers/print.js +18 -8
  42. package/src/reducers/print.test.js +117 -0
@@ -0,0 +1,601 @@
1
+ import React, { useEffect, useCallback } from 'react';
2
+ import { useSelector, useDispatch } from 'react-redux';
3
+ import { v4 as uuid } from 'uuid';
4
+ import { Icon, FormFieldWrapper } from '@plone/volto/components';
5
+ import ObjectWidget from '@plone/volto/components/manage/Widgets/ObjectWidget';
6
+ import { Accordion, Button, Segment, Form, Dropdown } from 'semantic-ui-react';
7
+ import { getNavigation } from '@plone/volto/actions';
8
+ import { defineMessages, useIntl } from 'react-intl';
9
+ import config from '@plone/volto/registry';
10
+
11
+ import upSVG from '@plone/volto/icons/up-key.svg';
12
+ import downSVG from '@plone/volto/icons/down-key.svg';
13
+
14
+ const messages = defineMessages({
15
+ loadNavigationRoutes: {
16
+ id: 'Load Main Navigation Routes',
17
+ defaultMessage: 'Load Main Navigation Routes',
18
+ },
19
+ hideChildrenFromNavigation: {
20
+ id: 'Hide Children From Navigation',
21
+ defaultMessage: 'Hide Children From Navigation',
22
+ },
23
+
24
+ menuItemChildrenListColumns: {
25
+ id: 'Menu Item Children List Columns',
26
+ defaultMessage: 'Menu Item Children List Columns',
27
+ },
28
+ menuItemColumns: {
29
+ id: 'Menu Item Columns',
30
+ defaultMessage: 'Menu Item Columns',
31
+ },
32
+ });
33
+
34
+ const defaultRouteSettings = {
35
+ hideChildrenFromNavigation: true,
36
+ // Don't include empty arrays in default settings
37
+ };
38
+
39
+ // Helper functions for menuItemColumns conversion (numbers to semantic UI format)
40
+ const numberToColumnString = (num) => {
41
+ const numbers = [
42
+ '',
43
+ 'one',
44
+ 'two',
45
+ 'three',
46
+ 'four',
47
+ 'five',
48
+ 'six',
49
+ 'seven',
50
+ 'eight',
51
+ 'nine',
52
+ ];
53
+ return numbers[num] ? `${numbers[num]} wide column` : '';
54
+ };
55
+
56
+ const numbersToMenuItemColumns = (numbers) => {
57
+ if (!Array.isArray(numbers)) return [];
58
+ return numbers
59
+ .map((num) => numberToColumnString(parseInt(num)))
60
+ .filter((col) => col !== '');
61
+ };
62
+
63
+ const columnStringToNumber = (colString) => {
64
+ const numbers = {
65
+ one: 1,
66
+ two: 2,
67
+ three: 3,
68
+ four: 4,
69
+ five: 5,
70
+ six: 6,
71
+ seven: 7,
72
+ eight: 8,
73
+ nine: 9,
74
+ };
75
+ const match = colString.match(
76
+ /^(one|two|three|four|five|six|seven|eight|nine) wide column$/,
77
+ );
78
+ return match ? numbers[match[1]] : null;
79
+ };
80
+
81
+ const menuItemColumnsToNumbers = (columns) => {
82
+ if (!Array.isArray(columns)) return [];
83
+ return columns
84
+ .map((col) => columnStringToNumber(col))
85
+ .filter((num) => num !== null);
86
+ };
87
+
88
+ // Custom component for integer array fields
89
+ // eslint-disable-next-line no-unused-vars
90
+ const IntegerArrayField = ({
91
+ title,
92
+ description,
93
+ value = [],
94
+ onChange,
95
+ options = [],
96
+ routePath,
97
+ }) => {
98
+ const addValue = () => {
99
+ const newArray = [...value, options[0] || 1];
100
+ onChange(newArray);
101
+ };
102
+
103
+ const removeValue = (index) => {
104
+ const newArray = value.filter((_, i) => i !== index);
105
+ onChange(newArray);
106
+ };
107
+
108
+ const updateValue = (index, newValue) => {
109
+ const newArray = [...value];
110
+ newArray[index] = parseInt(newValue);
111
+ onChange(newArray);
112
+ };
113
+
114
+ return (
115
+ <Form.Field>
116
+ <label>{title}</label>
117
+ <div
118
+ style={{
119
+ marginBottom: '0.5em',
120
+ fontSize: '0.9em',
121
+ color: '#666',
122
+ fontStyle: 'italic',
123
+ }}
124
+ >
125
+ {description} • Route: <strong>{routePath}</strong>
126
+ </div>
127
+
128
+ {value.map((val, index) => (
129
+ <div
130
+ key={index}
131
+ style={{
132
+ display: 'flex',
133
+ alignItems: 'center',
134
+ marginBottom: '0.5em',
135
+ }}
136
+ >
137
+ <Dropdown
138
+ selection
139
+ value={val}
140
+ options={options.map((opt) => ({
141
+ key: opt,
142
+ value: opt,
143
+ text: opt,
144
+ }))}
145
+ onChange={(e, { value: newValue }) => updateValue(index, newValue)}
146
+ style={{ marginRight: '0.5em', minWidth: '80px' }}
147
+ />
148
+ <Button
149
+ icon="trash"
150
+ size="small"
151
+ color="red"
152
+ type="button"
153
+ onClick={() => removeValue(index)}
154
+ />
155
+ </div>
156
+ ))}
157
+
158
+ <Button
159
+ icon="plus"
160
+ content="Add"
161
+ size="small"
162
+ type="button"
163
+ onClick={addValue}
164
+ />
165
+ </Form.Field>
166
+ );
167
+ };
168
+
169
+ // Get settings from config.settings.menuItemsLayouts
170
+ const getConfigSettingsForRoute = (routePath) => {
171
+ const menuItemsLayouts = config.settings?.menuItemsLayouts || {};
172
+
173
+ // Find the most specific parent route match
174
+ const findMostSpecificConfig = (targetPath) => {
175
+ // First check for exact match
176
+ if (menuItemsLayouts[targetPath]) {
177
+ return menuItemsLayouts[targetPath];
178
+ }
179
+
180
+ // Find all matching parent routes
181
+ const matchingRoutes = Object.keys(menuItemsLayouts)
182
+ .filter((configPath) => {
183
+ // Skip wildcard for now
184
+ if (configPath === '*') return false;
185
+ // Check if targetPath starts with configPath
186
+ return targetPath.startsWith(configPath);
187
+ })
188
+ .sort((a, b) => b.length - a.length); // Sort by length (most specific first)
189
+
190
+ // Return the most specific parent route config
191
+ if (matchingRoutes.length > 0) {
192
+ return menuItemsLayouts[matchingRoutes[0]];
193
+ }
194
+
195
+ // Fall back to wildcard
196
+ return menuItemsLayouts['*'] || {};
197
+ };
198
+
199
+ const routeConfig = findMostSpecificConfig(routePath);
200
+
201
+ const settings = {
202
+ hideChildrenFromNavigation:
203
+ routeConfig.hideChildrenFromNavigation !== undefined
204
+ ? routeConfig.hideChildrenFromNavigation
205
+ : true,
206
+ includeInNavigation: true,
207
+ expandChildren: false,
208
+ navigationDepth: 0,
209
+ showIcons: true,
210
+ };
211
+
212
+ // Only add array properties if they have values
213
+ if (
214
+ routeConfig.menuItemChildrenListColumns &&
215
+ routeConfig.menuItemChildrenListColumns.length > 0
216
+ ) {
217
+ settings.menuItemChildrenListColumns =
218
+ routeConfig.menuItemChildrenListColumns;
219
+ }
220
+
221
+ if (routeConfig.menuItemColumns && routeConfig.menuItemColumns.length > 0) {
222
+ // Convert from semantic UI format to numbers for widget display
223
+ settings.menuItemColumns = menuItemColumnsToNumbers(
224
+ routeConfig.menuItemColumns,
225
+ );
226
+ }
227
+
228
+ return settings;
229
+ };
230
+
231
+ // Schema for individual route settings - simple version
232
+ const getRouteSettingsSchema = (intl) => ({
233
+ title: 'Route Navigation Settings',
234
+ fieldsets: [
235
+ {
236
+ id: 'default',
237
+ title: 'Default',
238
+ fields: [
239
+ 'hideChildrenFromNavigation',
240
+ 'menuItemChildrenListColumns',
241
+ 'menuItemColumns',
242
+ ],
243
+ },
244
+ ],
245
+ properties: {
246
+ hideChildrenFromNavigation: {
247
+ title: intl.formatMessage(messages.hideChildrenFromNavigation),
248
+ type: 'boolean',
249
+ default: true,
250
+ },
251
+
252
+ menuItemChildrenListColumns: {
253
+ title: intl.formatMessage(messages.menuItemChildrenListColumns),
254
+ description: 'Number of columns for each route',
255
+ type: 'array',
256
+ widget: 'simple_array',
257
+ items: {
258
+ minimum: 1,
259
+ maximum: 10,
260
+ },
261
+ default: [],
262
+ },
263
+ menuItemColumns: {
264
+ title: intl.formatMessage(messages.menuItemColumns),
265
+ description: 'Size of each route section',
266
+ type: 'array',
267
+ widget: 'simple_array',
268
+ items: {
269
+ minimum: 1,
270
+ maximum: 9,
271
+ },
272
+ default: [],
273
+ },
274
+ },
275
+ required: [],
276
+ });
277
+
278
+ const NavigationBehaviorWidget = (props) => {
279
+ const { value = '{}', id, onChange } = props;
280
+ const intl = useIntl();
281
+ const dispatch = useDispatch();
282
+ const navigation = useSelector((state) => state.navigation?.items || []);
283
+ const navigationLoaded = useSelector((state) => state.navigation?.loaded);
284
+
285
+ // Parse JSON string to object
286
+ const parseValue = (val) => {
287
+ try {
288
+ return typeof val === 'string' ? JSON.parse(val) : val || {};
289
+ } catch (e) {
290
+ return {};
291
+ }
292
+ };
293
+
294
+ const routeSettings = parseValue(value);
295
+
296
+ const [localActiveObject, setLocalActiveObject] = React.useState(-1);
297
+ let activeObject = localActiveObject;
298
+ let setActiveObject = setLocalActiveObject;
299
+
300
+ function handleChangeActiveObject(e, blockProps) {
301
+ const { index } = blockProps;
302
+ const newIndex = activeObject === index ? -1 : index;
303
+ setActiveObject(newIndex);
304
+ }
305
+
306
+ const flattenNavigationToRoutes = useCallback(
307
+ (items, level = 0) => {
308
+ let routes = [];
309
+
310
+ items.forEach((item) => {
311
+ const itemPath = item.url || item.id;
312
+ const currentPath = itemPath;
313
+ const routeId = item['@id'] || item.url || item.id || uuid();
314
+ const configSettings =
315
+ getConfigSettingsForRoute(currentPath) || defaultRouteSettings;
316
+ const savedSettings = routeSettings[routeId] || {};
317
+
318
+ // Merge settings intelligently - use config values for empty/missing fields
319
+ let finalSettings = { ...defaultRouteSettings };
320
+
321
+ // Add config settings first (as defaults)
322
+ if (configSettings) {
323
+ Object.keys(configSettings).forEach((key) => {
324
+ if (
325
+ configSettings[key] !== undefined &&
326
+ configSettings[key] !== null
327
+ ) {
328
+ finalSettings[key] = configSettings[key];
329
+ }
330
+ });
331
+ }
332
+
333
+ // Override with saved settings, including null values (explicit deletion)
334
+ if (savedSettings) {
335
+ Object.keys(savedSettings).forEach((key) => {
336
+ if (savedSettings[key] !== undefined) {
337
+ // Handle null values as explicit deletion - don't override with config
338
+ if (savedSettings[key] === null) {
339
+ // Field was explicitly cleared - remove it from finalSettings
340
+ delete finalSettings[key];
341
+ } else if (Array.isArray(savedSettings[key])) {
342
+ // For arrays, always override with saved value (including empty arrays)
343
+ finalSettings[key] = savedSettings[key];
344
+ } else {
345
+ // For non-arrays, override with saved value
346
+ finalSettings[key] = savedSettings[key];
347
+ }
348
+ }
349
+ });
350
+ }
351
+
352
+ // Convert menuItemColumns from semantic UI format to numbers for widget display
353
+ if (
354
+ finalSettings.menuItemColumns &&
355
+ Array.isArray(finalSettings.menuItemColumns)
356
+ ) {
357
+ // Check if values are in semantic UI format
358
+ if (
359
+ finalSettings.menuItemColumns.length > 0 &&
360
+ typeof finalSettings.menuItemColumns[0] === 'string' &&
361
+ finalSettings.menuItemColumns[0].includes('wide column')
362
+ ) {
363
+ finalSettings.menuItemColumns = menuItemColumnsToNumbers(
364
+ finalSettings.menuItemColumns,
365
+ );
366
+ }
367
+ }
368
+
369
+ const route = {
370
+ '@id': routeId,
371
+ title: item.title || item.name,
372
+ path: currentPath,
373
+ url: item.url,
374
+ level: level,
375
+ hasChildren: item.items && item.items.length > 0,
376
+ portal_type: item.portal_type || item['@type'],
377
+ ...finalSettings,
378
+ };
379
+
380
+ routes.push(route);
381
+
382
+ if (item.items && item.items.length > 0) {
383
+ routes = routes.concat(
384
+ flattenNavigationToRoutes(item.items, level + 1),
385
+ );
386
+ }
387
+ });
388
+
389
+ return routes;
390
+ },
391
+ [routeSettings],
392
+ );
393
+
394
+ useEffect(() => {
395
+ if (!navigationLoaded) {
396
+ dispatch(getNavigation('', 1));
397
+ }
398
+ }, [dispatch, navigationLoaded]);
399
+
400
+ // Auto-populate from config if no settings exist
401
+ useEffect(() => {
402
+ if (
403
+ navigationLoaded &&
404
+ navigation.length > 0 &&
405
+ Object.keys(routeSettings).length === 0
406
+ ) {
407
+ const routes = flattenNavigationToRoutes(navigation);
408
+ const level0Routes = routes.filter((route) => route.level === 0);
409
+
410
+ if (level0Routes.length > 0) {
411
+ const newSettings = {};
412
+ level0Routes.forEach((route) => {
413
+ // Save the current settings (which include config values) for each route
414
+ const {
415
+ '@id': routeId,
416
+ title: _,
417
+ url: ___,
418
+ level: ____,
419
+ hasChildren: _____,
420
+ portal_type: ______,
421
+ ...settings
422
+ } = route;
423
+ newSettings[routeId] = settings;
424
+ });
425
+
426
+ onChange(id, JSON.stringify(newSettings));
427
+ }
428
+ }
429
+ }, [
430
+ navigationLoaded,
431
+ navigation,
432
+ routeSettings,
433
+ onChange,
434
+ id,
435
+ flattenNavigationToRoutes,
436
+ ]);
437
+
438
+ const allRoutes = React.useMemo(() => {
439
+ const routes = flattenNavigationToRoutes(navigation);
440
+ // Filter to show only level 0 routes (main routes that decide navigation behavior)
441
+ return routes.filter((route) => route.level === 0); // level 0 = main routes
442
+ }, [navigation, flattenNavigationToRoutes]);
443
+
444
+ return (
445
+ <div className="navigation-objectlist-widget">
446
+ <FormFieldWrapper {...props} className="navigation-behavior-widget" />
447
+
448
+ <div className="routes-area">
449
+ {allRoutes.map((route, index) => (
450
+ <Accordion key={route['@id']} fluid styled>
451
+ <Accordion.Title
452
+ active={activeObject === index}
453
+ index={index}
454
+ onClick={handleChangeActiveObject}
455
+ >
456
+ <div className="label">
457
+ <strong style={{ marginLeft: '0.5rem' }}>{route.title}</strong>
458
+ <span
459
+ style={{
460
+ color: '#666',
461
+ fontSize: '0.85em',
462
+ fontStyle: 'italic',
463
+ marginLeft: '0.5rem',
464
+ }}
465
+ >
466
+ ({route.path})
467
+ </span>
468
+ </div>
469
+
470
+ <div className="accordion-tools">
471
+ {activeObject === index ? (
472
+ <Icon name={upSVG} size="20px" />
473
+ ) : (
474
+ <Icon name={downSVG} size="20px" />
475
+ )}
476
+ </div>
477
+ </Accordion.Title>
478
+ <Accordion.Content active={activeObject === index}>
479
+ <Segment>
480
+ <ObjectWidget
481
+ id={`${id}-${index}`}
482
+ key={`ow-${id}-${index}`}
483
+ schema={getRouteSettingsSchema(intl)}
484
+ value={route}
485
+ onChange={(fieldId, fieldValue) => {
486
+ const routeId = route['@id'];
487
+ const {
488
+ '@id': _,
489
+ title: __,
490
+ path: ___,
491
+ url: ____,
492
+ level: _____,
493
+ hasChildren: ______,
494
+ portal_type: _______,
495
+ ...settings
496
+ } = fieldValue;
497
+
498
+ // Preserve existing settings and merge with new ones
499
+ const existingSettings = routeSettings[routeId] || {};
500
+
501
+ // Convert existing menuItemColumns from semantic UI back to numbers for merging
502
+ const cleanedExistingSettings = { ...existingSettings };
503
+ if (
504
+ cleanedExistingSettings.menuItemColumns &&
505
+ Array.isArray(cleanedExistingSettings.menuItemColumns)
506
+ ) {
507
+ // Check if existing values are in semantic UI format and convert to numbers
508
+ if (
509
+ cleanedExistingSettings.menuItemColumns.length > 0 &&
510
+ typeof cleanedExistingSettings.menuItemColumns[0] ===
511
+ 'string' &&
512
+ cleanedExistingSettings.menuItemColumns[0].includes(
513
+ 'wide column',
514
+ )
515
+ ) {
516
+ cleanedExistingSettings.menuItemColumns =
517
+ menuItemColumnsToNumbers(
518
+ cleanedExistingSettings.menuItemColumns,
519
+ );
520
+ }
521
+ }
522
+
523
+ // Check if the field was explicitly set in the widget's settings
524
+ const explicitlySetFields = Object.keys(settings);
525
+
526
+ let mergedSettings = {
527
+ ...cleanedExistingSettings,
528
+ ...settings,
529
+ };
530
+
531
+ // Handle null values for explicitly cleared fields
532
+ Object.keys(settings).forEach((key) => {
533
+ if (settings[key] === null) {
534
+ // Field was explicitly cleared - store as null to preserve intent
535
+ mergedSettings[key] = null;
536
+ }
537
+ });
538
+
539
+ // Convert menuItemColumns from numbers back to semantic UI format for backend storage
540
+ if (
541
+ mergedSettings.menuItemColumns !== null &&
542
+ mergedSettings.menuItemColumns
543
+ ) {
544
+ if (mergedSettings.menuItemColumns.length > 0) {
545
+ mergedSettings.menuItemColumns =
546
+ numbersToMenuItemColumns(
547
+ mergedSettings.menuItemColumns,
548
+ );
549
+ } else if (
550
+ !explicitlySetFields.includes('menuItemColumns')
551
+ ) {
552
+ // Only remove empty menuItemColumns if not explicitly set by user
553
+ delete mergedSettings.menuItemColumns;
554
+ } else {
555
+ // Keep empty array if explicitly set by user (cleared all elements)
556
+ mergedSettings.menuItemColumns = [];
557
+ }
558
+ }
559
+
560
+ // Handle menuItemChildrenListColumns similarly
561
+ if (mergedSettings.menuItemChildrenListColumns !== null) {
562
+ if (
563
+ !mergedSettings.menuItemChildrenListColumns ||
564
+ (mergedSettings.menuItemChildrenListColumns.length ===
565
+ 0 &&
566
+ !explicitlySetFields.includes(
567
+ 'menuItemChildrenListColumns',
568
+ ))
569
+ ) {
570
+ delete mergedSettings.menuItemChildrenListColumns;
571
+ }
572
+ }
573
+
574
+ // Clean up other empty arrays only if not explicitly set by user
575
+ Object.keys(mergedSettings).forEach((key) => {
576
+ if (
577
+ Array.isArray(mergedSettings[key]) &&
578
+ mergedSettings[key].length === 0 &&
579
+ !explicitlySetFields.includes(key)
580
+ ) {
581
+ delete mergedSettings[key];
582
+ }
583
+ });
584
+
585
+ const newSettings = {
586
+ ...routeSettings,
587
+ [routeId]: mergedSettings,
588
+ };
589
+ onChange(id, JSON.stringify(newSettings));
590
+ }}
591
+ />
592
+ </Segment>
593
+ </Accordion.Content>
594
+ </Accordion>
595
+ ))}
596
+ </div>
597
+ </div>
598
+ );
599
+ };
600
+
601
+ export default NavigationBehaviorWidget;