@plone/volto 18.0.0-alpha.34 → 18.0.0-alpha.36

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 (78) hide show
  1. package/.release-it.json +1 -1
  2. package/CHANGELOG.md +43 -0
  3. package/locales/ca/LC_MESSAGES/volto.po +41 -4
  4. package/locales/ca.json +1 -1
  5. package/locales/de/LC_MESSAGES/volto.po +41 -4
  6. package/locales/de.json +1 -1
  7. package/locales/en/LC_MESSAGES/volto.po +41 -4
  8. package/locales/en.json +1 -1
  9. package/locales/es/LC_MESSAGES/volto.po +41 -4
  10. package/locales/es.json +1 -1
  11. package/locales/eu/LC_MESSAGES/volto.po +41 -4
  12. package/locales/eu.json +1 -1
  13. package/locales/fi/LC_MESSAGES/volto.po +44 -7
  14. package/locales/fi.json +1 -1
  15. package/locales/fr/LC_MESSAGES/volto.po +41 -4
  16. package/locales/fr.json +1 -1
  17. package/locales/hi/LC_MESSAGES/volto.po +41 -4
  18. package/locales/hi.json +1 -1
  19. package/locales/it/LC_MESSAGES/volto.po +41 -4
  20. package/locales/it.json +1 -1
  21. package/locales/ja/LC_MESSAGES/volto.po +41 -4
  22. package/locales/ja.json +1 -1
  23. package/locales/nl/LC_MESSAGES/volto.po +41 -4
  24. package/locales/nl.json +1 -1
  25. package/locales/pt/LC_MESSAGES/volto.po +41 -4
  26. package/locales/pt.json +1 -1
  27. package/locales/pt_BR/LC_MESSAGES/volto.po +41 -4
  28. package/locales/pt_BR.json +1 -1
  29. package/locales/ro/LC_MESSAGES/volto.po +41 -4
  30. package/locales/ro.json +1 -1
  31. package/locales/volto.pot +42 -5
  32. package/locales/zh_CN/LC_MESSAGES/volto.po +41 -4
  33. package/locales/zh_CN.json +1 -1
  34. package/package.json +5 -5
  35. package/razzle.config.js +7 -1
  36. package/src/components/manage/AnchorPlugin/components/LinkButton/AddLinkForm.jsx +78 -72
  37. package/src/components/manage/AnchorPlugin/useLinkEditor.js +79 -0
  38. package/src/components/manage/Blocks/Image/Edit.jsx +45 -347
  39. package/src/components/manage/Blocks/Teaser/Data.jsx +69 -1
  40. package/src/components/manage/Blocks/Teaser/schema.js +24 -3
  41. package/src/components/manage/Form/BlocksToolbar.jsx +8 -7
  42. package/src/components/manage/Form/Form.jsx +8 -1
  43. package/src/components/manage/Form/InlineForm.jsx +4 -0
  44. package/src/components/manage/Widgets/ImageWidget.jsx +311 -0
  45. package/src/components/manage/Widgets/ImageWidget.stories.jsx +46 -0
  46. package/src/config/NonContentRoutes.jsx +2 -1
  47. package/src/config/Widgets.jsx +2 -0
  48. package/src/config/index.js +0 -1
  49. package/src/helpers/Blocks/Blocks.js +20 -7
  50. package/src/helpers/Blocks/Blocks.test.js +55 -0
  51. package/src/helpers/Blocks/cloneBlocks.test.js +54 -0
  52. package/src/helpers/Blocks/cloneBlocks.ts +43 -0
  53. package/src/helpers/index.js +1 -0
  54. package/src/icons/external-link.svg +4 -0
  55. package/theme/themes/pastanaga/collections/form.overrides +4 -0
  56. package/theme/themes/pastanaga/elements/button.overrides +2 -1
  57. package/theme/themes/pastanaga/extras/blocks.less +40 -4
  58. package/theme/themes/pastanaga/extras/grid.less +3 -3
  59. package/theme/themes/pastanaga/extras/teaser.less +53 -0
  60. package/theme/themes/pastanaga/extras/widgets.less +108 -0
  61. package/types/components/manage/AnchorPlugin/useLinkEditor.d.ts +7 -0
  62. package/types/components/manage/Blocks/Teaser/schema.d.ts +9 -2
  63. package/types/components/manage/Widgets/ImageWidget.d.ts +10 -0
  64. package/types/components/manage/Widgets/ImageWidget.stories.d.ts +11 -0
  65. package/types/config/Widgets.d.ts +2 -0
  66. package/types/helpers/Blocks/Blocks.d.ts +6 -0
  67. package/types/helpers/Blocks/cloneBlocks.d.ts +1 -0
  68. package/types/helpers/Blocks/cloneBlocks.test.d.ts +1 -0
  69. package/types/helpers/index.d.ts +1 -1
  70. /package/src/components/manage/Blocks/Image/{schema.js → schema.jsx} +0 -0
  71. /package/src/components/theme/LanguageSelector/{LanguageSelector.js → LanguageSelector.jsx} +0 -0
  72. /package/src/helpers/Extensions/{withBlockExtensions.js → withBlockExtensions.jsx} +0 -0
  73. /package/src/helpers/Extensions/{withBlockSchemaEnhancer.js → withBlockSchemaEnhancer.jsx} +0 -0
  74. /package/src/helpers/FormValidation/{FormValidation.js → FormValidation.jsx} +0 -0
  75. /package/src/helpers/Helmet/{Helmet.js → Helmet.jsx} +0 -0
  76. /package/src/helpers/Loadable/{Loadable.js → Loadable.jsx} +0 -0
  77. /package/src/helpers/Loadable/__mocks__/{Loadable.js → Loadable.jsx} +0 -0
  78. /package/src/helpers/Utils/{Utils.js → Utils.jsx} +0 -0
@@ -0,0 +1,311 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import { Button, Dimmer, Loader, Message } from 'semantic-ui-react';
3
+ import { useIntl, defineMessages } from 'react-intl';
4
+ import { useDispatch } from 'react-redux';
5
+ import { useLocation } from 'react-router-dom';
6
+ import loadable from '@loadable/component';
7
+ import { connect } from 'react-redux';
8
+ import { compose } from 'redux';
9
+ import useLinkEditor from '@plone/volto/components/manage/AnchorPlugin/useLinkEditor';
10
+ import withObjectBrowser from '@plone/volto/components/manage/Sidebar/ObjectBrowser';
11
+
12
+ import {
13
+ flattenToAppURL,
14
+ getBaseUrl,
15
+ isInternalURL,
16
+ validateFileUploadSize,
17
+ } from '@plone/volto/helpers';
18
+ import { createContent } from '@plone/volto/actions';
19
+ import { readAsDataURL } from 'promise-file-reader';
20
+ import { FormFieldWrapper, Icon } from '@plone/volto/components';
21
+
22
+ import imageBlockSVG from '@plone/volto/components/manage/Blocks/Image/block-image.svg';
23
+ import clearSVG from '@plone/volto/icons/clear.svg';
24
+ import navTreeSVG from '@plone/volto/icons/nav.svg';
25
+ import linkSVG from '@plone/volto/icons/link.svg';
26
+ import uploadSVG from '@plone/volto/icons/upload.svg';
27
+
28
+ const Dropzone = loadable(() => import('react-dropzone'));
29
+
30
+ export const ImageToolbar = ({ className, data, id, onChange, selected }) => (
31
+ <div className="image-upload-widget-toolbar">
32
+ <Button.Group>
33
+ <Button icon basic onClick={() => onChange(id, null)}>
34
+ <Icon className="circled" name={clearSVG} size="24px" color="#e40166" />
35
+ </Button>
36
+ </Button.Group>
37
+ </div>
38
+ );
39
+
40
+ const messages = defineMessages({
41
+ addImage: {
42
+ id: 'Browse the site, drop an image, or type a URL',
43
+ defaultMessage: 'Browse the site, drop an image, or use a URL',
44
+ },
45
+ pickAnImage: {
46
+ id: 'pickAnImage',
47
+ defaultMessage: 'Pick an existing image',
48
+ },
49
+ uploadAnImage: {
50
+ id: 'uploadAnImage',
51
+ defaultMessage: 'Upload an image from your computer',
52
+ },
53
+ linkAnImage: {
54
+ id: 'linkAnImage',
55
+ defaultMessage: 'Enter a URL to an image',
56
+ },
57
+ uploadingImage: {
58
+ id: 'Uploading image',
59
+ defaultMessage: 'Uploading image',
60
+ },
61
+ });
62
+
63
+ const UnconnectedImageInput = (props) => {
64
+ const {
65
+ id,
66
+ onChange,
67
+ onFocus,
68
+ openObjectBrowser,
69
+ value,
70
+ imageSize = 'teaser',
71
+ selected = true,
72
+ hideLinkPicker = false,
73
+ hideObjectBrowserPicker = false,
74
+ restrictFileUpload = false,
75
+ objectBrowserPickerType = 'image',
76
+ description,
77
+ placeholderLinkInput = '',
78
+ } = props;
79
+
80
+ const intl = useIntl();
81
+ const linkEditor = useLinkEditor();
82
+ const location = useLocation();
83
+ const dispatch = useDispatch();
84
+ const contextUrl = location.pathname;
85
+
86
+ const [uploading, setUploading] = React.useState(false);
87
+ const [dragging, setDragging] = React.useState(false);
88
+
89
+ const imageUploadInputRef = useRef(null);
90
+
91
+ const requestId = `image-upload-${id}`;
92
+
93
+ const { loading, loaded } = props.request;
94
+ const { content } = props;
95
+ const imageId = content?.['@id'];
96
+ const image = content?.image;
97
+
98
+ useEffect(() => {
99
+ if (uploading && !loading && loaded) {
100
+ setUploading(false);
101
+ onChange(id, imageId, {
102
+ image_field: 'image',
103
+ image_scales: { image: [image] },
104
+ });
105
+ }
106
+ }, [loading, loaded, uploading, imageId, image, id, onChange]); // Explicitly list all dependencies
107
+
108
+ const handleUpload = React.useCallback(
109
+ (eventOrFile) => {
110
+ if (restrictFileUpload === true) return;
111
+ eventOrFile.target && eventOrFile.stopPropagation();
112
+
113
+ setUploading(true);
114
+ const file = eventOrFile.target
115
+ ? eventOrFile.target.files[0]
116
+ : eventOrFile[0];
117
+ if (!validateFileUploadSize(file, intl.formatMessage)) return;
118
+ readAsDataURL(file).then((fileData) => {
119
+ const fields = fileData.match(/^data:(.*);(.*),(.*)$/);
120
+ dispatch(
121
+ createContent(
122
+ getBaseUrl(contextUrl),
123
+ {
124
+ '@type': 'Image',
125
+ title: file.name,
126
+ image: {
127
+ data: fields[3],
128
+ encoding: fields[2],
129
+ 'content-type': fields[1],
130
+ filename: file.name,
131
+ },
132
+ },
133
+ props.block || requestId,
134
+ ),
135
+ );
136
+ });
137
+ },
138
+ [
139
+ restrictFileUpload,
140
+ intl.formatMessage,
141
+ dispatch,
142
+ props,
143
+ contextUrl,
144
+ requestId,
145
+ ],
146
+ );
147
+
148
+ const onDragEnter = React.useCallback(() => {
149
+ if (restrictFileUpload === false) setDragging(true);
150
+ }, [restrictFileUpload]);
151
+ const onDragLeave = React.useCallback(() => setDragging(false), []);
152
+
153
+ return value ? (
154
+ <div
155
+ className="image-upload-widget-image"
156
+ onClick={onFocus}
157
+ onKeyDown={onFocus}
158
+ role="toolbar"
159
+ >
160
+ {selected && <ImageToolbar {...props} />}
161
+ <img
162
+ className={props.className}
163
+ src={`${flattenToAppURL(value)}/@@images/image/${imageSize}`}
164
+ alt=""
165
+ />
166
+ </div>
167
+ ) : (
168
+ <div
169
+ className="image-upload-widget"
170
+ onClick={onFocus}
171
+ onKeyDown={onFocus}
172
+ role="toolbar"
173
+ >
174
+ <Dropzone
175
+ noClick
176
+ onDrop={handleUpload}
177
+ onDragEnter={onDragEnter}
178
+ onDragLeave={onDragLeave}
179
+ className="dropzone"
180
+ >
181
+ {({ getRootProps, getInputProps }) => (
182
+ <div {...getRootProps()}>
183
+ <Message>
184
+ {dragging && <Dimmer active></Dimmer>}
185
+ {uploading && (
186
+ <Dimmer active>
187
+ <Loader indeterminate>
188
+ {intl.formatMessage(messages.uploadingImage)}
189
+ </Loader>
190
+ </Dimmer>
191
+ )}
192
+ <img src={imageBlockSVG} alt="" className="placeholder" />
193
+ <p>{description || intl.formatMessage(messages.addImage)}</p>
194
+ <div className="toolbar-wrapper">
195
+ <div className="toolbar-inner" ref={linkEditor.anchorNode}>
196
+ {hideObjectBrowserPicker === false && (
197
+ <Button.Group>
198
+ <Button
199
+ aria-label={intl.formatMessage(messages.pickAnImage)}
200
+ icon
201
+ basic
202
+ onClick={(e) => {
203
+ onFocus && onFocus();
204
+ e.preventDefault();
205
+ openObjectBrowser({
206
+ mode: objectBrowserPickerType,
207
+ onSelectItem: (
208
+ url,
209
+ { title, image_field, image_scales },
210
+ ) => {
211
+ onChange(props.id, flattenToAppURL(url), {
212
+ title,
213
+ image_field,
214
+ image_scales,
215
+ });
216
+ },
217
+ currentPath: contextUrl,
218
+ });
219
+ }}
220
+ >
221
+ <Icon name={navTreeSVG} size="24px" />
222
+ </Button>
223
+ </Button.Group>
224
+ )}
225
+ {restrictFileUpload === false && (
226
+ <Button.Group>
227
+ <Button
228
+ aria-label={intl.formatMessage(messages.uploadAnImage)}
229
+ icon
230
+ basic
231
+ compact
232
+ onClick={() => {
233
+ imageUploadInputRef.current.click();
234
+ }}
235
+ >
236
+ <Icon name={uploadSVG} size="24px" />
237
+ </Button>
238
+ <input
239
+ {...getInputProps({
240
+ type: 'file',
241
+ ref: imageUploadInputRef,
242
+ onChange: handleUpload,
243
+ style: { display: 'none' },
244
+ })}
245
+ />
246
+ </Button.Group>
247
+ )}
248
+
249
+ {hideLinkPicker === false && (
250
+ <Button.Group>
251
+ <Button
252
+ icon
253
+ basic
254
+ aria-label={intl.formatMessage(messages.linkAnImage)}
255
+ onClick={(e) => {
256
+ !props.selected && onFocus && onFocus();
257
+ linkEditor.show();
258
+ }}
259
+ >
260
+ <Icon name={linkSVG} circled size="24px" />
261
+ </Button>
262
+ </Button.Group>
263
+ )}
264
+ </div>
265
+ {linkEditor.anchorNode && (
266
+ <linkEditor.LinkEditor
267
+ value={value}
268
+ placeholder={
269
+ placeholderLinkInput ||
270
+ intl.formatMessage(messages.linkAnImage)
271
+ }
272
+ objectBrowserPickerType={objectBrowserPickerType}
273
+ onChange={(_, e) =>
274
+ onChange(
275
+ props.id,
276
+ isInternalURL(e) ? flattenToAppURL(e) : e,
277
+ {},
278
+ )
279
+ }
280
+ id={id}
281
+ />
282
+ )}
283
+ </div>
284
+ </Message>
285
+ </div>
286
+ )}
287
+ </Dropzone>
288
+ </div>
289
+ );
290
+ };
291
+
292
+ export const ImageInput = compose(
293
+ connect(
294
+ (state, ownProps) => {
295
+ const requestId = `image-upload-${ownProps.id}`;
296
+ return {
297
+ request: state.content.subrequests[ownProps.block || requestId] || {},
298
+ content: state.content.subrequests[ownProps.block || requestId]?.data,
299
+ };
300
+ },
301
+ { createContent },
302
+ ),
303
+ )(withObjectBrowser(UnconnectedImageInput));
304
+
305
+ const ImageUploadWidget = (props) => (
306
+ <FormFieldWrapper {...props} className="image-upload-widget">
307
+ <ImageInput {...props} />
308
+ </FormFieldWrapper>
309
+ );
310
+
311
+ export default ImageUploadWidget;
@@ -0,0 +1,46 @@
1
+ import React from 'react';
2
+ import ImageWidget from './ImageWidget';
3
+ import WidgetStory from './story';
4
+
5
+ export const Default = WidgetStory.bind({
6
+ props: { id: 'image_widget', title: 'Image', data: {}, properties: {} },
7
+ widget: ImageWidget,
8
+ });
9
+
10
+ export const Filled = WidgetStory.bind({
11
+ props: {
12
+ id: 'image_widget',
13
+ title: 'Image',
14
+ data: {},
15
+ properties: {},
16
+ value: 'halfdome2022.jpeg',
17
+ },
18
+ widget: ImageWidget,
19
+ });
20
+ Filled.args = {
21
+ value: 'halfdome2022.jpeg',
22
+ };
23
+
24
+ export const Inline = WidgetStory.bind({
25
+ props: {
26
+ id: 'image_widget',
27
+ title: 'Image',
28
+ data: {},
29
+ properties: {},
30
+ inline: true,
31
+ },
32
+ widget: ImageWidget,
33
+ });
34
+
35
+ export default {
36
+ title: 'Edit Widgets/Image',
37
+ component: ImageWidget,
38
+ decorators: [
39
+ (Story) => (
40
+ <div className="ui segment form attached" style={{ width: '400px' }}>
41
+ <Story />
42
+ </div>
43
+ ),
44
+ ],
45
+ argTypes: {},
46
+ };
@@ -9,7 +9,8 @@ export const nonContentRoutes = [
9
9
  '/aliases',
10
10
  '/contents',
11
11
  '/delete',
12
- '/diff',
12
+ /\/diff$/,
13
+ /\/diff\?/,
13
14
  /\/edit$/,
14
15
  '/historyview',
15
16
  '/links-to-item',
@@ -50,6 +50,7 @@ import TextViewWidget from '@plone/volto/components/theme/Widgets/TextWidget';
50
50
  import TitleViewWidget from '@plone/volto/components/theme/Widgets/TitleWidget';
51
51
  import TokenViewWidget from '@plone/volto/components/theme/Widgets/TokenWidget';
52
52
  import UrlViewWidget from '@plone/volto/components/theme/Widgets/UrlWidget';
53
+ import ImageWidget from '@plone/volto/components/manage/Widgets/ImageWidget';
53
54
 
54
55
  // Widgets mapping
55
56
  export const widgetMapping = {
@@ -68,6 +69,7 @@ export const widgetMapping = {
68
69
  date: DatetimeWidget,
69
70
  password: PasswordWidget,
70
71
  file: FileWidget,
72
+ image: ImageWidget,
71
73
  align: AlignWidget,
72
74
  buttons: ButtonsWidget,
73
75
  url: UrlWidget,
@@ -178,7 +178,6 @@ let config = {
178
178
  querystringSearchGet: false,
179
179
  blockSettingsTabFieldsetsInitialStateOpen: true,
180
180
  excludeLinksAndReferencesMenuItem: false,
181
- containerBlockTypes: ['gridBlock'],
182
181
  siteTitleFormat: {
183
182
  includeSiteTitle: false,
184
183
  titleAndSiteTitleSeparator: '-',
@@ -693,6 +693,19 @@ export const getPreviousNextBlock = ({ content, block }) => {
693
693
  return [previousBlock, nextBlock];
694
694
  };
695
695
 
696
+ /**
697
+ * Check if a block is a container block
698
+ * check blocks from data as well since some add-ons use that
699
+ * such as @eeacms/volto-tabs-block
700
+ */
701
+ export function isBlockContainer(block) {
702
+ return (
703
+ block &&
704
+ (hasBlocksData(block) ||
705
+ (block.hasOwnProperty('data') && hasBlocksData(block.data)))
706
+ );
707
+ }
708
+
696
709
  /**
697
710
  * Given a `block` object and a list of block types, return a list of block ids matching the types
698
711
  *
@@ -701,8 +714,6 @@ export const getPreviousNextBlock = ({ content, block }) => {
701
714
  * @return {Array} An array of block ids
702
715
  */
703
716
  export function findBlocks(blocks, types, result = []) {
704
- const containerBlockTypes = config.settings.containerBlockTypes;
705
-
706
717
  Object.keys(blocks).forEach((blockId) => {
707
718
  const block = blocks[blockId];
708
719
  // check blocks from data as well since some add-ons use that
@@ -710,7 +721,7 @@ export function findBlocks(blocks, types, result = []) {
710
721
  const child_blocks = block.blocks || block.data?.blocks;
711
722
  if (types.includes(block['@type'])) {
712
723
  result.push(blockId);
713
- } else if (containerBlockTypes.includes(block['@type']) || child_blocks) {
724
+ } else if (isBlockContainer(block)) {
714
725
  findBlocks(child_blocks, types, result);
715
726
  }
716
727
  });
@@ -718,6 +729,9 @@ export function findBlocks(blocks, types, result = []) {
718
729
  return result;
719
730
  }
720
731
 
732
+ /**
733
+ * Build a block's hierarchy that the order tab can understand and uses
734
+ */
721
735
  export const getBlocksHierarchy = (properties) => {
722
736
  const blocksFieldName = getBlocksFieldname(properties);
723
737
  const blocksLayoutFieldname = getBlocksLayoutFieldname(properties);
@@ -725,10 +739,9 @@ export const getBlocksHierarchy = (properties) => {
725
739
  id: n,
726
740
  title: properties[blocksFieldName][n]?.['@type'],
727
741
  data: properties[blocksFieldName][n],
728
- children:
729
- properties[blocksFieldName][n]?.['@type'] === 'gridBlock'
730
- ? getBlocksHierarchy(properties[blocksFieldName][n])
731
- : [],
742
+ children: isBlockContainer(properties[blocksFieldName][n])
743
+ ? getBlocksHierarchy(properties[blocksFieldName][n])
744
+ : [],
732
745
  }));
733
746
  };
734
747
 
@@ -23,6 +23,7 @@ import {
23
23
  blocksFormGenerator,
24
24
  findBlocks,
25
25
  findContainer,
26
+ isBlockContainer,
26
27
  } from './Blocks';
27
28
 
28
29
  import config from '@plone/volto/registry';
@@ -1642,4 +1643,58 @@ describe('findContainer', () => {
1642
1643
  });
1643
1644
  });
1644
1645
  });
1646
+
1647
+ describe('isBlockContainer', () => {
1648
+ const blocksData = { blocks: {}, blocks_layout: { items: [] } };
1649
+
1650
+ it('basic test', () => {
1651
+ const formData = {
1652
+ title: 'Example',
1653
+ blocks: {
1654
+ 1: { title: 'title', '@type': 'title' },
1655
+ 2: { title: 'an image', '@type': 'image' },
1656
+ 3: { title: 'description', '@type': 'description' },
1657
+ 4: { title: 'a container', '@type': 'container', ...blocksData },
1658
+ },
1659
+ blocks_layout: {
1660
+ items: ['1', '2', '3', '4'],
1661
+ },
1662
+ };
1663
+
1664
+ const container = isBlockContainer(formData);
1665
+ expect(container).toBeTruthy();
1666
+ });
1667
+
1668
+ it('in data key (EEA add-ons)', () => {
1669
+ const formData = {
1670
+ title: 'Example',
1671
+ data: {
1672
+ blocks: {
1673
+ 1: { title: 'title', '@type': 'title' },
1674
+ 2: { title: 'an image', '@type': 'image' },
1675
+ 3: { title: 'description', '@type': 'description' },
1676
+ 4: { title: 'a container', '@type': 'container', ...blocksData },
1677
+ },
1678
+ blocks_layout: {
1679
+ items: ['1', '2', '3', '4'],
1680
+ },
1681
+ },
1682
+ };
1683
+
1684
+ const container = isBlockContainer(formData);
1685
+ expect(container).toBeTruthy();
1686
+ });
1687
+
1688
+ it('not a container', () => {
1689
+ const formData = {
1690
+ title: 'Example',
1691
+ styles: {
1692
+ color: 'red',
1693
+ },
1694
+ };
1695
+
1696
+ const container = isBlockContainer(formData);
1697
+ expect(container).toBeFalsy();
1698
+ });
1699
+ });
1645
1700
  });
@@ -0,0 +1,54 @@
1
+ import { cloneDeep } from 'lodash';
2
+ import { cloneBlocks } from './cloneBlocks';
3
+
4
+ describe('cloneBlocks', () => {
5
+ it('cloneBlocks - basic', () => {
6
+ const blocksData = {
7
+ blocks: {
8
+ a: { '@type': 'slate', value: 1 },
9
+ b: { '@type': 'slate', value: 2 },
10
+ },
11
+ blocks_layout: { items: ['a', 'b'] },
12
+ };
13
+ const result = cloneBlocks(blocksData);
14
+
15
+ expect(result.blocks_layout.items[0]).not.toBe('a');
16
+ expect(result.blocks[result.blocks_layout.items[0]]).toStrictEqual({
17
+ '@type': 'slate',
18
+ value: 1,
19
+ });
20
+ });
21
+
22
+ it('cloneBlocks - nested', () => {
23
+ const blocksData = {
24
+ blocks: {
25
+ a: { '@type': 'slate', value: 1 },
26
+ b: { '@type': 'slate', value: 2 },
27
+ },
28
+ blocks_layout: { items: ['a', 'b'] },
29
+ };
30
+ blocksData.blocks.c = cloneDeep(blocksData);
31
+ blocksData.blocks_layout.items.push('c');
32
+
33
+ const result = cloneBlocks(blocksData);
34
+
35
+ expect(result.blocks_layout.items[0]).not.toBe('a');
36
+ expect(result.blocks[result.blocks_layout.items[0]]).toStrictEqual({
37
+ '@type': 'slate',
38
+ value: 1,
39
+ });
40
+
41
+ expect(
42
+ result.blocks[result.blocks_layout.items[2]].blocks_layout.items[0],
43
+ ).not.toBe('c');
44
+
45
+ expect(
46
+ result.blocks[result.blocks_layout.items[2]].blocks[
47
+ result.blocks[result.blocks_layout.items[2]].blocks_layout.items[0]
48
+ ],
49
+ ).toStrictEqual({
50
+ '@type': 'slate',
51
+ value: 1,
52
+ });
53
+ });
54
+ });
@@ -0,0 +1,43 @@
1
+ // @ts-nocheck
2
+ import { v4 as uuid } from 'uuid';
3
+ import {
4
+ getBlocksFieldname,
5
+ getBlocksLayoutFieldname,
6
+ hasBlocksData,
7
+ } from './Blocks';
8
+ import config from '@plone/registry';
9
+
10
+ export function cloneBlocks(blocksData) {
11
+ if (hasBlocksData(blocksData)) {
12
+ const blocksFieldname = getBlocksFieldname(blocksData);
13
+ const blocksLayoutFieldname = getBlocksLayoutFieldname(blocksData);
14
+
15
+ const cloneWithIds = Object.keys(blocksData.blocks)
16
+ .map((key) => {
17
+ const block = blocksData.blocks[key];
18
+ const blockConfig = config.blocks.blocksConfig[blocksData['@type']];
19
+ return blockConfig?.cloneData
20
+ ? blockConfig.cloneData(block)
21
+ : [uuid(), cloneBlocks(block)];
22
+ })
23
+ .filter((info) => !!info); // some blocks may refuse to be copied
24
+
25
+ const newBlockData = {
26
+ ...blocksData,
27
+ [blocksFieldname]: {
28
+ ...Object.assign(
29
+ {},
30
+ ...cloneWithIds.map(([id, data]) => ({ [id]: data })),
31
+ ),
32
+ },
33
+ [blocksLayoutFieldname]: {
34
+ ...blocksData[blocksLayoutFieldname],
35
+ items: [...cloneWithIds.map(([id]) => id)],
36
+ },
37
+ };
38
+
39
+ return newBlockData;
40
+ }
41
+
42
+ return blocksData;
43
+ }
@@ -30,6 +30,7 @@ export {
30
30
  removeProtocol,
31
31
  URLUtils,
32
32
  flattenScales,
33
+ getFieldURL,
33
34
  } from '@plone/volto/helpers/Url/Url';
34
35
  export { generateRobots } from '@plone/volto/helpers/Robots/Robots';
35
36
  export {
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="bi bi-box-arrow-up-right" viewBox="0 0 16 16">
2
+ <path fill-rule="evenodd" d="M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5z"/>
3
+ <path fill-rule="evenodd" d="M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0v-5z"/>
4
+ </svg>
@@ -6,6 +6,10 @@
6
6
  margin: 0;
7
7
  }
8
8
 
9
+ .ui.form .inline.field.overwritten input {
10
+ background-color: rgb(#edc3c2);
11
+ }
12
+
9
13
  .ui.form .inline.field.help .wrapper {
10
14
  border-bottom: none;
11
15
  }
@@ -26,7 +26,8 @@
26
26
  }
27
27
  }
28
28
 
29
- #main {
29
+ #main,
30
+ .slate-inline-toolbar.slate-toolbar {
30
31
  .ui.basic.buttons .button,
31
32
  .ui.basic.button {
32
33
  -webkit-box-shadow: 0px 0px 0px @basicBorderSize transparent inset !important;