@eeacms/volto-editing-progress 0.2.3 → 0.4.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.
@@ -0,0 +1,294 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { ModalForm } from '@plone/volto/components';
3
+ import { JSONSchema } from './schema';
4
+ import { Dropdown } from 'semantic-ui-react';
5
+
6
+ import {
7
+ Button,
8
+ Container,
9
+ Segment,
10
+ Divider,
11
+ Sidebar,
12
+ } from 'semantic-ui-react';
13
+ import { flattenToAppURL } from '@plone/volto/helpers';
14
+ import './less/editor.less';
15
+ import _ from 'lodash';
16
+ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
17
+ import PropTypes from 'prop-types';
18
+ import { useSelector, useDispatch } from 'react-redux';
19
+ import { getRawContent } from './actions';
20
+ import SidebarComponent from './WidgetSidebar';
21
+ import EditDataComponent from './WidgetDataComponent';
22
+ import './less/editor.less';
23
+ const messages = defineMessages({
24
+ jsonTitle: {
25
+ id: 'Edit JSON',
26
+ defaultMessage: 'Edit JSON',
27
+ },
28
+ });
29
+
30
+ export const SIDEBAR_WIDTH = '250px';
31
+ export const COMPONENT_HEIGHT = '750px';
32
+
33
+ function isValidJson(json) {
34
+ try {
35
+ JSON.parse(json);
36
+ return true;
37
+ } catch (e) {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ function addNewStateToAlreadyExistingField(
43
+ currentContentTypeData,
44
+ currentField,
45
+ statesToAdd,
46
+ message,
47
+ condition,
48
+ link,
49
+ ) {
50
+ for (
51
+ let localRuleIndex = 0;
52
+ localRuleIndex < currentContentTypeData.length;
53
+ localRuleIndex++
54
+ ) {
55
+ if (currentContentTypeData[localRuleIndex].prefix !== currentField)
56
+ continue;
57
+
58
+ // TODO rewrite message as an object with multiple message as a key and add it
59
+ // to currentContentTypeData dinamically in order to create the posibillity
60
+ // for multiple fields
61
+
62
+ if (message) currentContentTypeData[localRuleIndex].linkLabel = message;
63
+ if (condition) currentContentTypeData[localRuleIndex].condition = condition;
64
+ if (link) currentContentTypeData[localRuleIndex].link = link;
65
+ if (statesToAdd !== undefined) {
66
+ currentContentTypeData[localRuleIndex].states = statesToAdd;
67
+ } else if (!message) currentContentTypeData.splice(localRuleIndex, 1);
68
+ }
69
+ }
70
+
71
+ function doesPrefixExistInCurrentContentTypeData(
72
+ currentContentTypeData,
73
+ currentField,
74
+ ) {
75
+ return currentContentTypeData.every((rule) => rule.prefix !== currentField);
76
+ }
77
+
78
+ function createFieldRule(currentField, statesToAdd) {
79
+ return {
80
+ prefix: currentField,
81
+ states: statesToAdd,
82
+ condition: 'python:value',
83
+ link: 'edit#fieldset-metadata-field-label-' + currentField,
84
+ linkLabel: 'Add {label}',
85
+ };
86
+ }
87
+
88
+ const VisualJSONWidget = (props) => {
89
+ const { id, value = {}, onChange } = props;
90
+ const [isJSONEditorOpen, setIsJSONEditorOpen] = useState(false);
91
+ const [currentContentType, setCurrentContentType] = useState();
92
+
93
+ const path = flattenToAppURL(
94
+ currentContentType?.['@id'] ? `${currentContentType['@id']}` : null,
95
+ );
96
+
97
+ const dispatch = useDispatch();
98
+ const request = useSelector((state) => state.rawdata?.[path]);
99
+ const content = request?.data;
100
+ const types = useSelector((state) => state.types);
101
+ const fields =
102
+ request?.data?.fieldsets.reduce((acc, cur) => {
103
+ return [...acc, ...(cur.fields || [])];
104
+ }, []) || [];
105
+ useEffect(() => {
106
+ if (path && !request?.loading && !request?.loaded && !content)
107
+ dispatch(getRawContent(path));
108
+ // eslint-disable-next-line react-hooks/exhaustive-deps
109
+ }, [dispatch, path, content, request?.loaded, request?.loading]);
110
+
111
+ useEffect(() => {
112
+ if (types.loaded && !types.loading && Array.isArray(types.types)) {
113
+ setCurrentContentType(types.types[0]);
114
+ }
115
+ }, [types]);
116
+
117
+ const handleOnCancel = (e) => {
118
+ setIsJSONEditorOpen(false);
119
+ };
120
+
121
+ const handleEditJSON = (e) => {
122
+ e.preventDefault();
123
+ setIsJSONEditorOpen(true);
124
+ };
125
+ const makeFirstLetterCapital = (string) => {
126
+ return string.charAt(0).toUpperCase() + string.slice(1);
127
+ };
128
+ const onJSONSubmit = (e) => {
129
+ setIsJSONEditorOpen(false);
130
+ if (typeof e.json === 'string' && isValidJson(e.json)) {
131
+ onChange(id, JSON.parse(e.json));
132
+ return;
133
+ }
134
+ onChange(id, e.json);
135
+ };
136
+
137
+ const handleChangeSelectedContentType = (e, type) => {
138
+ setCurrentContentType(type);
139
+ };
140
+
141
+ const handleOnDropdownChange = (
142
+ e,
143
+ data,
144
+ currentField,
145
+ message,
146
+ condition,
147
+ link,
148
+ ) => {
149
+ const states = data.value;
150
+ const statesToAdd = states?.map((state) => state.toLowerCase());
151
+ const localCopyOfValue = _.cloneDeep(value);
152
+ const currentContentTypeData = localCopyOfValue[currentContentType.id];
153
+
154
+ if (!currentContentTypeData && data) {
155
+ //Reference doesn't work with currentContentTypeData
156
+ localCopyOfValue[currentContentType.id] = [
157
+ createFieldRule(currentField, statesToAdd),
158
+ ];
159
+ } else if (
160
+ doesPrefixExistInCurrentContentTypeData(
161
+ currentContentTypeData,
162
+ currentField,
163
+ ) &&
164
+ data
165
+ ) {
166
+ currentContentTypeData.push(createFieldRule(currentField, statesToAdd));
167
+ } else {
168
+ addNewStateToAlreadyExistingField(
169
+ currentContentTypeData,
170
+ currentField,
171
+ statesToAdd,
172
+ message,
173
+ condition,
174
+ link,
175
+ );
176
+ }
177
+ //The variable currentContentTypeData cannot be used here because of eslint and delete keyword
178
+ if (localCopyOfValue[currentContentType.id]?.length === 0) {
179
+ delete localCopyOfValue[currentContentType.id];
180
+ }
181
+ onChange(id, localCopyOfValue);
182
+ };
183
+ const getDropdownValues = (currentField) => {
184
+ if (
185
+ !request.loading &&
186
+ request.loaded &&
187
+ currentContentType &&
188
+ value[currentContentType.id]
189
+ )
190
+ return value[currentContentType.id]
191
+ .find((rule) => rule?.prefix === currentField)
192
+ ?.states.map((state) => makeFirstLetterCapital(state));
193
+
194
+ return undefined;
195
+ };
196
+
197
+ return (
198
+ <>
199
+ <div>
200
+ {isJSONEditorOpen && (
201
+ <ModalForm
202
+ schema={JSONSchema(props)}
203
+ onSubmit={onJSONSubmit}
204
+ title={props.intl.formatMessage(messages.jsonTitle)}
205
+ open={isJSONEditorOpen}
206
+ formData={{ json: JSON.stringify(value, undefined, 2) }}
207
+ onCancel={handleOnCancel}
208
+ key="JSON"
209
+ />
210
+ )}
211
+ <Container>
212
+ <Button onClick={handleEditJSON} color="grey" id="json_button">
213
+ <FormattedMessage id="Edit JSON" defaultMessage="Edit JSON" />
214
+ </Button>
215
+
216
+ {fields && (
217
+ <Dropdown
218
+ className="ui grey button dropdown-button"
219
+ text="Add Property"
220
+ options={fields
221
+ .filter((field) => {
222
+ return (
223
+ getDropdownValues(field) === undefined &&
224
+ !request.data.required.includes(field)
225
+ );
226
+ })
227
+ .map((field) => {
228
+ return { key: field, text: field, value: field };
229
+ })}
230
+ onChange={(e, t) => {
231
+ handleOnDropdownChange(e, { value: ['all'] }, t.value);
232
+ }}
233
+ />
234
+ )}
235
+ </Container>
236
+ <Divider />
237
+ </div>
238
+ <Sidebar.Pushable as={Segment} style={{ height: COMPONENT_HEIGHT }}>
239
+ <SidebarComponent
240
+ handleChangeSelectedContentType={handleChangeSelectedContentType}
241
+ currentContentType={currentContentType}
242
+ types={types}
243
+ value={value}
244
+ />
245
+ <Sidebar.Pusher style={{ width: `calc(100% - ${SIDEBAR_WIDTH})` }}>
246
+ <EditDataComponent
247
+ request={request}
248
+ handleOnDropdownChange={handleOnDropdownChange}
249
+ currentContentType={currentContentType}
250
+ value={value}
251
+ fields={fields}
252
+ getDropdownValues={getDropdownValues}
253
+ />
254
+ </Sidebar.Pusher>
255
+ </Sidebar.Pushable>
256
+ </>
257
+ );
258
+ };
259
+ /**
260
+ * Property types.
261
+ * @property {Object} propTypes Property types.
262
+ * @static
263
+ */
264
+ VisualJSONWidget.propTypes = {
265
+ id: PropTypes.string.isRequired,
266
+ title: PropTypes.string.isRequired,
267
+ description: PropTypes.string,
268
+ required: PropTypes.bool,
269
+ error: PropTypes.arrayOf(PropTypes.string),
270
+ value: PropTypes.object,
271
+ onChange: PropTypes.func,
272
+ onEdit: PropTypes.func,
273
+ onDelete: PropTypes.func,
274
+ wrapped: PropTypes.bool,
275
+ placeholder: PropTypes.string,
276
+ };
277
+
278
+ /**
279
+ * Default properties.
280
+ * @property {Object} defaultProps Default properties.
281
+ * @static
282
+ */
283
+ VisualJSONWidget.defaultProps = {
284
+ description: null,
285
+ required: false,
286
+ error: [],
287
+ value: null,
288
+ onChange: null,
289
+ onEdit: null,
290
+ onDelete: null,
291
+ title: '',
292
+ id: '',
293
+ };
294
+ export default injectIntl(VisualJSONWidget);
@@ -0,0 +1,51 @@
1
+ import { makeFirstLetterCapital } from './WidgetDataComponent';
2
+ import React from 'react';
3
+ import { Provider } from 'react-intl-redux';
4
+ import { MemoryRouter } from 'react-router-dom';
5
+ import renderer from 'react-test-renderer';
6
+ import configureStore from 'redux-mock-store';
7
+ import VisualJSONWidget from './VisualJSONWidget';
8
+ import { backgroundColor } from './WidgetSidebar';
9
+ const mockStore = configureStore();
10
+ const propsEmpty = {};
11
+ describe('Widget Data Component', () => {
12
+ it('should make first letter capital', () => {
13
+ const testString = 'this is a test string';
14
+ expect(makeFirstLetterCapital(testString)[0]).toEqual(
15
+ testString[0].toUpperCase(),
16
+ );
17
+ });
18
+ });
19
+ describe('Widget Sidebar', () => {
20
+ it('should return a background lightblue', () => {
21
+ expect(backgroundColor(true, false)).toEqual('lightblue');
22
+ expect(backgroundColor(true, true)).toEqual('lightblue');
23
+ });
24
+ it('should return a background lightpink', () => {
25
+ expect(backgroundColor(false, true)).toEqual('lightpink');
26
+ });
27
+ });
28
+ describe('Visual widget', () => {
29
+ it('renders the VisualJSONWidget component without breaking if props and progressEditing are empty', () => {
30
+ const store = mockStore({
31
+ intl: {
32
+ locale: 'en',
33
+ messages: {},
34
+ },
35
+ progressEditing: {},
36
+ });
37
+ const component = renderer.create(
38
+ <Provider store={store}>
39
+ <MemoryRouter>
40
+ <VisualJSONWidget
41
+ pathname="/test"
42
+ {...propsEmpty}
43
+ hasToolbar={true}
44
+ />
45
+ </MemoryRouter>
46
+ </Provider>,
47
+ );
48
+ const json = component.toJSON();
49
+ expect(json).toMatchSnapshot();
50
+ });
51
+ });
@@ -0,0 +1,262 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Segment, Dropdown, Accordion, Icon } from 'semantic-ui-react';
3
+ import { flattenToAppURL } from '@plone/volto/helpers';
4
+ import { useSelector, useDispatch } from 'react-redux';
5
+ import { getRawContent } from './actions';
6
+ import { COMPONENT_HEIGHT } from './VisualJSONWidget';
7
+
8
+ import './less/editor.less';
9
+
10
+ export function makeFirstLetterCapital(string) {
11
+ return string.charAt(0).toUpperCase() + string.slice(1);
12
+ }
13
+
14
+ const EditDataComponent = ({
15
+ request,
16
+ handleOnDropdownChange,
17
+ currentContentType,
18
+ value,
19
+ fields,
20
+ getDropdownValues,
21
+ }) => {
22
+ const path = flattenToAppURL(
23
+ '/@vocabularies/plone.app.vocabularies.WorkflowStates',
24
+ );
25
+
26
+ const dispatch = useDispatch();
27
+ const requestStateOptions = useSelector((state) => state.rawdata?.[path]);
28
+ const content = requestStateOptions?.data;
29
+
30
+ useEffect(() => {
31
+ if (
32
+ path &&
33
+ !requestStateOptions?.loading &&
34
+ !requestStateOptions?.loaded &&
35
+ !content
36
+ )
37
+ dispatch(getRawContent(path));
38
+ // eslint-disable-next-line react-hooks/exhaustive-deps
39
+ }, [
40
+ dispatch,
41
+ path,
42
+ content,
43
+ // eslint-disable-next-line react-hooks/exhaustive-deps
44
+ requestStateOptions?.loaded,
45
+ // eslint-disable-next-line react-hooks/exhaustive-deps
46
+ requestStateOptions?.loading,
47
+ ]);
48
+
49
+ //Returns the saved values for dropdown with the first letter in uppercase
50
+
51
+ const getValues = (currentField) => {
52
+ if (
53
+ !request.loading &&
54
+ request.loaded &&
55
+ currentContentType &&
56
+ value[currentContentType.id]
57
+ )
58
+ return value[currentContentType.id].find(
59
+ (rule) => rule?.prefix === currentField,
60
+ );
61
+
62
+ return undefined;
63
+ };
64
+
65
+ const renderLabel = (label) => ({
66
+ color: 'blue',
67
+ content: `${label.text}`,
68
+ });
69
+
70
+ const createStateOption = (stateOptions) => {
71
+ return ['all', ...(stateOptions || [])].map((state) => ({
72
+ key: makeFirstLetterCapital(state),
73
+ text: makeFirstLetterCapital(state),
74
+ value: makeFirstLetterCapital(state),
75
+ }));
76
+ };
77
+ const [activeIndex, setActiveIndex] = useState(0);
78
+ // const inputRef = useRef();
79
+ const [inputValue, setInputValue] = useState('');
80
+ const [conditionValue, setConditionValue] = useState('');
81
+ const [linkValue, setLinkValue] = useState();
82
+ const handleClick = (e, titleProps, currentField) => {
83
+ const { index } = titleProps;
84
+ const newIndex = activeIndex === index ? -1 : index;
85
+
86
+ setActiveIndex(newIndex);
87
+ setInputValue(getValues(currentField)?.linkLabel || '');
88
+ setConditionValue(getValues(currentField)?.condition || '');
89
+ setLinkValue(getValues(currentField)?.link || '');
90
+ };
91
+ const handleInputChange = (e, currentField) => {
92
+ setInputValue(e.target.value);
93
+ handleOnDropdownChange(
94
+ null,
95
+ { value: getValues(currentField).states || [] },
96
+ currentField,
97
+ e.target.value,
98
+ );
99
+ };
100
+ const handleInputLinkChange = (e, currentField) => {
101
+ setLinkValue(e.target.value);
102
+ handleOnDropdownChange(
103
+ null,
104
+ { value: getValues(currentField).states || [] },
105
+ currentField,
106
+ undefined,
107
+ undefined,
108
+ e.target.value,
109
+ );
110
+ };
111
+ const handleInputConditionChange = (e, currentField) => {
112
+ setConditionValue(e.target.value);
113
+ handleOnDropdownChange(
114
+ null,
115
+ { value: getValues(currentField).states || [] },
116
+ currentField,
117
+ undefined,
118
+ e.target.value,
119
+ );
120
+ };
121
+ return (
122
+ <Segment
123
+ style={{
124
+ width: '100%',
125
+ paddingBottom:
126
+ request?.data?.fieldsets[0]?.fields.length > 9 ? '120px' : '',
127
+ height: COMPONENT_HEIGHT,
128
+ overflow: 'auto',
129
+ }}
130
+ >
131
+ <Accordion styled fluid>
132
+ {request?.loaded &&
133
+ !request?.loading &&
134
+ requestStateOptions?.loaded &&
135
+ !requestStateOptions?.loading &&
136
+ requestStateOptions?.data &&
137
+ fields.map((currentField, index) => {
138
+ if (
139
+ request.data.required.includes(currentField) ||
140
+ getDropdownValues(currentField) === undefined
141
+ )
142
+ return null;
143
+ return (
144
+ <React.Fragment key={`${currentField}${index}`}>
145
+ <Accordion.Title
146
+ active={activeIndex === index}
147
+ index={index}
148
+ onClick={(e, titleProps) =>
149
+ handleClick(e, titleProps, currentField)
150
+ }
151
+ id={`property_${currentField}`}
152
+ >
153
+ <div className="title-editing-progress">
154
+ <Icon name="dropdown" size="tiny" />
155
+ &nbsp;
156
+ {currentField}
157
+ </div>
158
+ <div className="title-editing-progress">
159
+ <Icon
160
+ name="cancel"
161
+ size="mini"
162
+ onClick={(e) => {
163
+ e.preventDefault();
164
+ handleOnDropdownChange(
165
+ e,
166
+ { value: undefined },
167
+ currentField,
168
+ );
169
+ }}
170
+ />
171
+ </div>
172
+ </Accordion.Title>
173
+ <Accordion.Content
174
+ active={activeIndex === index}
175
+ id={`property_content_${currentField}`}
176
+ >
177
+ <label
178
+ htmlFor="message"
179
+ style={{ display: 'block', padding: '10px' }}
180
+ >
181
+ Message
182
+ </label>
183
+ <input
184
+ className="message-input"
185
+ value={inputValue}
186
+ onChange={(e) => handleInputChange(e, currentField)}
187
+ // ref={inputRef}
188
+ name="message"
189
+ style={{ padding: '10px' }}
190
+ disabled={getDropdownValues(currentField) == null}
191
+ placeholder="Write a dfferent message after you set at lest one state"
192
+ />
193
+ <label
194
+ htmlFor="message"
195
+ style={{ display: 'block', padding: '10px' }}
196
+ >
197
+ Link
198
+ </label>
199
+ <input
200
+ className="link-input"
201
+ value={linkValue}
202
+ onChange={(e) => handleInputLinkChange(e, currentField)}
203
+ // ref={inputRef}
204
+ name="link"
205
+ style={{ padding: '10px' }}
206
+ disabled={getDropdownValues(currentField) == null}
207
+ placeholder="Write a dfferent href link"
208
+ />
209
+ <label
210
+ htmlFor="condition"
211
+ style={{ display: 'block', padding: '10px' }}
212
+ >
213
+ Condition
214
+ </label>
215
+ <input
216
+ className="condition-input"
217
+ value={conditionValue}
218
+ onChange={(e) =>
219
+ handleInputConditionChange(e, currentField)
220
+ }
221
+ // ref={inputRef}
222
+ name="condition"
223
+ style={{ padding: '10px' }}
224
+ disabled={getDropdownValues(currentField) == null}
225
+ placeholder="Write a dfferent condition"
226
+ />
227
+ <label
228
+ htmlFor="dropdown"
229
+ style={{ display: 'block', padding: '10px' }}
230
+ >
231
+ States
232
+ </label>
233
+ <Dropdown
234
+ placeholder="Fields"
235
+ multiple
236
+ floating
237
+ selection
238
+ search
239
+ name="dropdown"
240
+ defaultValue={getDropdownValues(currentField)}
241
+ options={[
242
+ ...createStateOption(
243
+ requestStateOptions.data.items.map(
244
+ (option) => option.token,
245
+ ),
246
+ ),
247
+ ]}
248
+ onChange={(e, data) =>
249
+ handleOnDropdownChange(e, data, currentField)
250
+ }
251
+ renderLabel={renderLabel}
252
+ />
253
+ </Accordion.Content>
254
+ </React.Fragment>
255
+ );
256
+ })}
257
+ </Accordion>
258
+ </Segment>
259
+ );
260
+ };
261
+
262
+ export default EditDataComponent;
@@ -0,0 +1,88 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Sidebar, List } from 'semantic-ui-react';
3
+ import { SIDEBAR_WIDTH } from './VisualJSONWidget';
4
+
5
+ export const backgroundColor = (currentContentType, modified) => {
6
+ let color = undefined;
7
+ if (modified) {
8
+ color = 'lightpink';
9
+ }
10
+ if (currentContentType) {
11
+ color = 'lightblue';
12
+ }
13
+ return color;
14
+ };
15
+
16
+ const SidebarComponent = (props) => {
17
+ const { types, currentContentType, handleChangeSelectedContentType } = props;
18
+ // console.log(types);
19
+ const [filtredTypes, setFiltredTypes] = useState({ ...types });
20
+ const [inputValue, setInputValue] = useState('');
21
+
22
+ useEffect(() => {
23
+ setFiltredTypes({ ...types });
24
+ }, [types]);
25
+
26
+ const handleInputChange = (e) => {
27
+ if (e.target.value == null) return;
28
+ setInputValue(e.target.value);
29
+ if (
30
+ types.loaded &&
31
+ !types.loading &&
32
+ Array.isArray(types.types) &&
33
+ types.types.length > 0
34
+ ) {
35
+ setFiltredTypes({
36
+ ...types,
37
+ types: types.types.filter((type) =>
38
+ type.title.toLowerCase().includes(e.target.value.toLowerCase()),
39
+ ),
40
+ });
41
+ }
42
+ };
43
+
44
+ return (
45
+ <Sidebar
46
+ as={List}
47
+ animation="push"
48
+ icon="labeled"
49
+ visible
50
+ relaxed
51
+ size="big"
52
+ divided
53
+ selection
54
+ style={{ width: SIDEBAR_WIDTH }}
55
+ >
56
+ <input
57
+ value={inputValue}
58
+ onChange={handleInputChange}
59
+ placeholder="Search... "
60
+ style={{ paddingLeft: ' 10px' }}
61
+ />
62
+ {filtredTypes.loaded &&
63
+ !filtredTypes.loading &&
64
+ Array.isArray(filtredTypes.types) &&
65
+ filtredTypes.types.map((type) => (
66
+ <List.Item
67
+ style={{
68
+ padding: '25px 5px',
69
+ textAlign: 'center',
70
+ backgroundColor: backgroundColor(
71
+ currentContentType?.id === type.id,
72
+ Object.keys(props.value).includes(type.id),
73
+ ),
74
+ }}
75
+ key={type.id}
76
+ id={`sidebar_${type.id}`}
77
+ onClick={(e) => handleChangeSelectedContentType(e, type)}
78
+ >
79
+ <List.Content>
80
+ <List.Header>{type.title}</List.Header>
81
+ </List.Content>
82
+ </List.Item>
83
+ ))}
84
+ </Sidebar>
85
+ );
86
+ };
87
+
88
+ export default SidebarComponent;
@@ -1 +1,2 @@
1
1
  export const EDITING_PROGRESS = 'EDITING_PROGRESS';
2
+ export const GET_RAW_CONTENT = 'GET_RAW_CONTENT';