@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.
- package/.release-it.json +1 -1
- package/CHANGELOG.md +43 -0
- package/locales/ca/LC_MESSAGES/volto.po +41 -4
- package/locales/ca.json +1 -1
- package/locales/de/LC_MESSAGES/volto.po +41 -4
- package/locales/de.json +1 -1
- package/locales/en/LC_MESSAGES/volto.po +41 -4
- package/locales/en.json +1 -1
- package/locales/es/LC_MESSAGES/volto.po +41 -4
- package/locales/es.json +1 -1
- package/locales/eu/LC_MESSAGES/volto.po +41 -4
- package/locales/eu.json +1 -1
- package/locales/fi/LC_MESSAGES/volto.po +44 -7
- package/locales/fi.json +1 -1
- package/locales/fr/LC_MESSAGES/volto.po +41 -4
- package/locales/fr.json +1 -1
- package/locales/hi/LC_MESSAGES/volto.po +41 -4
- package/locales/hi.json +1 -1
- package/locales/it/LC_MESSAGES/volto.po +41 -4
- package/locales/it.json +1 -1
- package/locales/ja/LC_MESSAGES/volto.po +41 -4
- package/locales/ja.json +1 -1
- package/locales/nl/LC_MESSAGES/volto.po +41 -4
- package/locales/nl.json +1 -1
- package/locales/pt/LC_MESSAGES/volto.po +41 -4
- package/locales/pt.json +1 -1
- package/locales/pt_BR/LC_MESSAGES/volto.po +41 -4
- package/locales/pt_BR.json +1 -1
- package/locales/ro/LC_MESSAGES/volto.po +41 -4
- package/locales/ro.json +1 -1
- package/locales/volto.pot +42 -5
- package/locales/zh_CN/LC_MESSAGES/volto.po +41 -4
- package/locales/zh_CN.json +1 -1
- package/package.json +5 -5
- package/razzle.config.js +7 -1
- package/src/components/manage/AnchorPlugin/components/LinkButton/AddLinkForm.jsx +78 -72
- package/src/components/manage/AnchorPlugin/useLinkEditor.js +79 -0
- package/src/components/manage/Blocks/Image/Edit.jsx +45 -347
- package/src/components/manage/Blocks/Teaser/Data.jsx +69 -1
- package/src/components/manage/Blocks/Teaser/schema.js +24 -3
- package/src/components/manage/Form/BlocksToolbar.jsx +8 -7
- package/src/components/manage/Form/Form.jsx +8 -1
- package/src/components/manage/Form/InlineForm.jsx +4 -0
- package/src/components/manage/Widgets/ImageWidget.jsx +311 -0
- package/src/components/manage/Widgets/ImageWidget.stories.jsx +46 -0
- package/src/config/NonContentRoutes.jsx +2 -1
- package/src/config/Widgets.jsx +2 -0
- package/src/config/index.js +0 -1
- package/src/helpers/Blocks/Blocks.js +20 -7
- package/src/helpers/Blocks/Blocks.test.js +55 -0
- package/src/helpers/Blocks/cloneBlocks.test.js +54 -0
- package/src/helpers/Blocks/cloneBlocks.ts +43 -0
- package/src/helpers/index.js +1 -0
- package/src/icons/external-link.svg +4 -0
- package/theme/themes/pastanaga/collections/form.overrides +4 -0
- package/theme/themes/pastanaga/elements/button.overrides +2 -1
- package/theme/themes/pastanaga/extras/blocks.less +40 -4
- package/theme/themes/pastanaga/extras/grid.less +3 -3
- package/theme/themes/pastanaga/extras/teaser.less +53 -0
- package/theme/themes/pastanaga/extras/widgets.less +108 -0
- package/types/components/manage/AnchorPlugin/useLinkEditor.d.ts +7 -0
- package/types/components/manage/Blocks/Teaser/schema.d.ts +9 -2
- package/types/components/manage/Widgets/ImageWidget.d.ts +10 -0
- package/types/components/manage/Widgets/ImageWidget.stories.d.ts +11 -0
- package/types/config/Widgets.d.ts +2 -0
- package/types/helpers/Blocks/Blocks.d.ts +6 -0
- package/types/helpers/Blocks/cloneBlocks.d.ts +1 -0
- package/types/helpers/Blocks/cloneBlocks.test.d.ts +1 -0
- package/types/helpers/index.d.ts +1 -1
- /package/src/components/manage/Blocks/Image/{schema.js → schema.jsx} +0 -0
- /package/src/components/theme/LanguageSelector/{LanguageSelector.js → LanguageSelector.jsx} +0 -0
- /package/src/helpers/Extensions/{withBlockExtensions.js → withBlockExtensions.jsx} +0 -0
- /package/src/helpers/Extensions/{withBlockSchemaEnhancer.js → withBlockSchemaEnhancer.jsx} +0 -0
- /package/src/helpers/FormValidation/{FormValidation.js → FormValidation.jsx} +0 -0
- /package/src/helpers/Helmet/{Helmet.js → Helmet.jsx} +0 -0
- /package/src/helpers/Loadable/{Loadable.js → Loadable.jsx} +0 -0
- /package/src/helpers/Loadable/__mocks__/{Loadable.js → Loadable.jsx} +0 -0
- /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
|
+
};
|
package/src/config/Widgets.jsx
CHANGED
|
@@ -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,
|
package/src/config/index.js
CHANGED
|
@@ -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 (
|
|
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]
|
|
730
|
-
|
|
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
|
+
}
|
package/src/helpers/index.js
CHANGED
|
@@ -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>
|