@eeacms/volto-group-block 8.1.1 → 10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-group-block",
3
- "version": "8.1.1",
3
+ "version": "10.0.0",
4
4
  "description": "volto-group-block: Volto block to be used to group other blocks",
5
5
  "main": "src/index.js",
6
6
  "author": "European Environment Agency: IDM2 A-Team",
@@ -56,7 +56,7 @@ const countTextInEachBlock =
56
56
  : countCharsWithSpaces(foundText);
57
57
  };
58
58
 
59
- const countTextInBlocks = (blocksObject, ignoreSpaces, maxChars) => {
59
+ export const countTextInBlocks = (blocksObject, ignoreSpaces, maxChars) => {
60
60
  const { countTextIn, skipBlocksInGroups = [] } =
61
61
  config.blocks?.blocksConfig?.group || {};
62
62
  // use obj ref to update value - if you send number it will not be updated
@@ -82,12 +82,21 @@ const countTextInBlocks = (blocksObject, ignoreSpaces, maxChars) => {
82
82
  };
83
83
 
84
84
  const CounterComponent = ({ data, setSidebarTab, setSelectedBlock }) => {
85
- const { maxChars, ignoreSpaces } = data;
86
- const charCount = countTextInBlocks(data?.data, ignoreSpaces, maxChars);
85
+ const maxChars = parseInt(data.maxChars) || 0;
86
+ const maxCharsOverflowPercent = parseInt(data.maxCharsOverflowPercent) || 0;
87
+ const { ignoreSpaces } = data;
88
+ const charCount =
89
+ data.charCount ?? countTextInBlocks(data?.data, ignoreSpaces, maxChars);
90
+
91
+ const overflowLimit =
92
+ maxCharsOverflowPercent > 0
93
+ ? Math.ceil((maxChars * (100 + maxCharsOverflowPercent)) / 100)
94
+ : maxChars;
95
+
87
96
  const counterClass =
88
97
  charCount < Math.ceil(maxChars / 1.05)
89
98
  ? 'info'
90
- : charCount < maxChars
99
+ : charCount <= overflowLimit
91
100
  ? 'warning'
92
101
  : 'danger';
93
102
 
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import { render } from '@testing-library/react';
3
3
  import CounterComponent from './CounterComponent';
4
- import '@testing-library/jest-dom/extend-expect';
4
+ import '@testing-library/jest-dom';
5
5
 
6
6
  jest.mock('@plone/volto/registry', () => ({
7
7
  blocks: {
@@ -231,4 +231,119 @@ describe('CounterComponent', () => {
231
231
  expect(setSidebarTab).toHaveBeenCalledWith(1);
232
232
  expect(setSelectedBlock).toHaveBeenCalled();
233
233
  });
234
+
235
+ it('should render warning class when chars are within overflow zone (maxCharsOverflowPercent=10)', () => {
236
+ const { container, getByText } = render(
237
+ <CounterComponent
238
+ data={{
239
+ maxChars: 100,
240
+ maxCharsOverflowPercent: 10,
241
+ data: {
242
+ blocks: {
243
+ block1: { '@type': 'text', plaintext: 'a'.repeat(105) },
244
+ },
245
+ blocks_layout: {
246
+ items: ['block1'],
247
+ },
248
+ },
249
+ }}
250
+ setSidebarTab={setSidebarTab}
251
+ setSelectedBlock={setSelectedBlock}
252
+ />,
253
+ );
254
+ expect(getByText('5 characters over the limit')).toBeInTheDocument();
255
+ expect(container.querySelector('.counter.warning')).toBeInTheDocument();
256
+ });
257
+
258
+ it('should render danger class when chars exceed overflow zone (maxCharsOverflowPercent=10)', () => {
259
+ const { container, getByText } = render(
260
+ <CounterComponent
261
+ data={{
262
+ maxChars: 100,
263
+ maxCharsOverflowPercent: 10,
264
+ data: {
265
+ blocks: {
266
+ block1: { '@type': 'text', plaintext: 'a'.repeat(111) },
267
+ },
268
+ blocks_layout: {
269
+ items: ['block1'],
270
+ },
271
+ },
272
+ }}
273
+ setSidebarTab={setSidebarTab}
274
+ setSelectedBlock={setSelectedBlock}
275
+ />,
276
+ );
277
+ expect(getByText('11 characters over the limit')).toBeInTheDocument();
278
+ expect(container.querySelector('.counter.danger')).toBeInTheDocument();
279
+ });
280
+
281
+ it('should render warning class when chars equal maxChars with no overflow percent', () => {
282
+ const { container, getByText } = render(
283
+ <CounterComponent
284
+ data={{
285
+ maxChars: 100,
286
+ data: {
287
+ blocks: {
288
+ block1: { '@type': 'text', plaintext: 'a'.repeat(100) },
289
+ },
290
+ blocks_layout: {
291
+ items: ['block1'],
292
+ },
293
+ },
294
+ }}
295
+ setSidebarTab={setSidebarTab}
296
+ setSelectedBlock={setSelectedBlock}
297
+ />,
298
+ );
299
+ expect(getByText('0 characters remaining out of 100')).toBeInTheDocument();
300
+ expect(container.querySelector('.counter.warning')).toBeInTheDocument();
301
+ });
302
+
303
+ it('should render danger class when negative overflow percent falls back to maxChars', () => {
304
+ const { container, getByText } = render(
305
+ <CounterComponent
306
+ data={{
307
+ maxChars: 100,
308
+ maxCharsOverflowPercent: -5,
309
+ data: {
310
+ blocks: {
311
+ block1: { '@type': 'text', plaintext: 'a'.repeat(104) },
312
+ },
313
+ blocks_layout: {
314
+ items: ['block1'],
315
+ },
316
+ },
317
+ }}
318
+ setSidebarTab={setSidebarTab}
319
+ setSelectedBlock={setSelectedBlock}
320
+ />,
321
+ );
322
+ expect(getByText('4 characters over the limit')).toBeInTheDocument();
323
+ expect(container.querySelector('.counter.danger')).toBeInTheDocument();
324
+ });
325
+
326
+ it('should use persisted charCount from data instead of recomputing', () => {
327
+ const { container, getByText } = render(
328
+ <CounterComponent
329
+ data={{
330
+ maxChars: 100,
331
+ charCount: 42,
332
+ data: {
333
+ blocks: {
334
+ block1: { '@type': 'text', plaintext: 'a'.repeat(90) },
335
+ },
336
+ blocks_layout: {
337
+ items: ['block1'],
338
+ },
339
+ },
340
+ }}
341
+ setSidebarTab={setSidebarTab}
342
+ setSelectedBlock={setSelectedBlock}
343
+ />,
344
+ );
345
+ // Should use charCount=42 from data, not 90 from blocks
346
+ expect(getByText('58 characters remaining out of 100')).toBeInTheDocument();
347
+ expect(container.querySelector('.counter.info')).toBeInTheDocument();
348
+ });
234
349
  });
@@ -1,10 +1,7 @@
1
- import { Button } from 'semantic-ui-react';
2
- import { BlocksForm, Icon, RenderBlocks } from '@plone/volto/components';
3
- import EditBlockWrapper from './EditBlockWrapper';
1
+ import { BlocksForm, RenderBlocks } from '@plone/volto/components';
2
+ import { countTextInBlocks } from './CounterComponent';
4
3
  import { useLocation } from 'react-router-dom';
5
4
 
6
- import helpSVG from '@plone/volto/icons/help.svg';
7
-
8
5
  const GroupBlockDefaultBody = (props) => {
9
6
  const location = useLocation();
10
7
  const {
@@ -16,10 +13,8 @@ const GroupBlockDefaultBody = (props) => {
16
13
  selected,
17
14
  selectedBlock,
18
15
  onSelectBlock,
19
- setSelectedBlock,
20
16
  manage,
21
17
  childBlocksForm,
22
- multiSelected,
23
18
  formDescription,
24
19
  isEditMode,
25
20
  } = props;
@@ -41,6 +36,8 @@ const GroupBlockDefaultBody = (props) => {
41
36
  allowedBlocks={data.allowedBlocks}
42
37
  title={data.placeholder}
43
38
  description={instructions}
39
+ isMainForm={false}
40
+ stopPropagation={selectedBlock}
44
41
  onSelectBlock={(id, l, e) => {
45
42
  const isMultipleSelection = e
46
43
  ? e.shiftKey || e.ctrlKey || e.metaKey
@@ -48,58 +45,44 @@ const GroupBlockDefaultBody = (props) => {
48
45
  onSelectBlock(id, isMultipleSelection, e, selectedBlock);
49
46
  }}
50
47
  onChangeFormData={(newFormData) => {
51
- onChangeBlock(block, {
48
+ const newData = {
52
49
  ...data,
53
50
  data: newFormData,
54
- });
51
+ };
52
+ if (data.maxChars) {
53
+ newData.charCount = countTextInBlocks(
54
+ newFormData,
55
+ data.ignoreSpaces,
56
+ data.maxChars,
57
+ );
58
+ }
59
+ onChangeBlock(block, newData);
55
60
  }}
56
61
  onChangeField={(id, value) => {
57
62
  if (['blocks', 'blocks_layout'].indexOf(id) > -1) {
58
63
  blockState[id] = value;
59
- onChangeBlock(block, {
64
+ const newChildData = {
65
+ ...data.data,
66
+ ...blockState,
67
+ };
68
+ const newData = {
60
69
  ...data,
61
- data: {
62
- ...data.data,
63
- ...blockState,
64
- },
65
- });
70
+ data: newChildData,
71
+ };
72
+ if (data.maxChars) {
73
+ newData.charCount = countTextInBlocks(
74
+ newChildData,
75
+ data.ignoreSpaces,
76
+ data.maxChars,
77
+ );
78
+ }
79
+ onChangeBlock(block, newData);
66
80
  } else {
67
81
  onChangeField(id, value);
68
82
  }
69
83
  }}
70
84
  pathname={pathname}
71
- >
72
- {({ draginfo }, editBlock, blockProps) => (
73
- <EditBlockWrapper
74
- draginfo={draginfo}
75
- blockProps={blockProps}
76
- disabled={data.disableInnerButtons}
77
- extraControls={
78
- <>
79
- {instructions && (
80
- <>
81
- <Button
82
- icon
83
- basic
84
- title="Section help"
85
- onClick={() => {
86
- setSelectedBlock();
87
- const tab = manage ? 0 : 1;
88
- props.setSidebarTab(tab);
89
- }}
90
- >
91
- <Icon name={helpSVG} className="" size="19px" />
92
- </Button>
93
- </>
94
- )}
95
- </>
96
- }
97
- multiSelected={multiSelected.includes(blockProps.block)}
98
- >
99
- {editBlock}
100
- </EditBlockWrapper>
101
- )}
102
- </BlocksForm>
85
+ />
103
86
  ) : (
104
87
  <RenderBlocks
105
88
  location={location}
@@ -3,7 +3,9 @@ import { render } from '@testing-library/react';
3
3
  import { Provider } from 'react-intl-redux';
4
4
  import DefaultBody from './DefaultBody';
5
5
  import configureStore from 'redux-mock-store';
6
- import '@testing-library/jest-dom/extend-expect';
6
+ import '@testing-library/jest-dom';
7
+
8
+ const mockBlocksForm = jest.fn();
7
9
 
8
10
  jest.mock('react-router-dom', () => ({
9
11
  ...jest.requireActual('react-router-dom'),
@@ -15,7 +17,10 @@ jest.mock('react-router-dom', () => ({
15
17
  }));
16
18
 
17
19
  jest.mock('@plone/volto/components', () => ({
18
- BlocksForm: jest.fn(() => <div className="blocks-form">RenderBlocks</div>),
20
+ BlocksForm: jest.fn((props) => {
21
+ mockBlocksForm(props);
22
+ return <div className="blocks-form">RenderBlocks</div>;
23
+ }),
19
24
  RenderBlocks: jest.fn(() => <div>RenderBlocks</div>),
20
25
  }));
21
26
 
@@ -28,6 +33,10 @@ const store = mockStore({
28
33
  });
29
34
 
30
35
  describe('DefaultBody', () => {
36
+ beforeEach(() => {
37
+ mockBlocksForm.mockClear();
38
+ });
39
+
31
40
  it('renders children', () => {
32
41
  const props = {
33
42
  data: {
@@ -55,14 +64,26 @@ describe('DefaultBody Edit', () => {
55
64
  variation: {},
56
65
  allowedBlocks: ['listing'],
57
66
  },
67
+ childBlocksForm: {
68
+ blocks: {
69
+ a: {
70
+ '@type': 'slate',
71
+ },
72
+ },
73
+ blocks_layout: {
74
+ items: ['a'],
75
+ },
76
+ },
58
77
  metadata: {},
59
78
  properties: {},
60
79
  variation: {},
61
80
  onSelectBlock: jest.fn(),
62
- onDeleteBlock: jest.fn(),
63
- onMutateBlock: jest.fn(),
64
- onInsertBlock: jest.fn(),
81
+ onChangeBlock: jest.fn(),
82
+ onChangeField: jest.fn(),
83
+ selectedBlock: 'a',
65
84
  selected: true,
85
+ manage: true,
86
+ pathname: '/',
66
87
  };
67
88
 
68
89
  const { getByText } = render(
@@ -71,5 +92,7 @@ describe('DefaultBody Edit', () => {
71
92
  </Provider>,
72
93
  );
73
94
  expect(getByText('RenderBlocks')).toBeInTheDocument();
95
+ expect(mockBlocksForm).toHaveBeenCalledTimes(1);
96
+ expect(mockBlocksForm.mock.calls[0][0].children).toBeUndefined();
74
97
  });
75
98
  });
@@ -18,7 +18,7 @@ import PropTypes from 'prop-types';
18
18
  import { Segment } from 'semantic-ui-react';
19
19
  import EditSchema from './EditSchema';
20
20
 
21
- import CounterComponent from './CounterComponent';
21
+ import CounterComponent, { countTextInBlocks } from './CounterComponent';
22
22
  import './editor.less';
23
23
  import { defineMessages, injectIntl } from 'react-intl';
24
24
  import { compose } from 'redux';
@@ -115,20 +115,29 @@ const Edit = (props) => {
115
115
  });
116
116
  const selectedIndex =
117
117
  data.data.blocks_layout.items.indexOf(selectedBlock) + 1;
118
- onChangeBlock(block, {
119
- ...data,
120
- data: {
121
- ...data?.data,
122
- ...newBlockData,
123
- blocks_layout: {
124
- items: [
125
- ...data.data.blocks_layout.items.slice(0, selectedIndex),
126
- ...pastedBlocks,
127
- ...data.data.blocks_layout.items.slice(selectedIndex),
128
- ],
129
- },
118
+ const newChildData = {
119
+ ...data?.data,
120
+ ...newBlockData,
121
+ blocks_layout: {
122
+ items: [
123
+ ...data.data.blocks_layout.items.slice(0, selectedIndex),
124
+ ...pastedBlocks,
125
+ ...data.data.blocks_layout.items.slice(selectedIndex),
126
+ ],
130
127
  },
131
- });
128
+ };
129
+ const newData = {
130
+ ...data,
131
+ data: newChildData,
132
+ };
133
+ if (data.maxChars) {
134
+ newData.charCount = countTextInBlocks(
135
+ newChildData,
136
+ data.ignoreSpaces,
137
+ data.maxChars,
138
+ );
139
+ }
140
+ onChangeBlock(block, newData);
132
141
  };
133
142
 
134
143
  React.useEffect(() => {
@@ -137,10 +146,14 @@ const Edit = (props) => {
137
146
  childBlocksForm.blocks_layout.items[0] !== selectedBlock
138
147
  ) {
139
148
  setSelectedBlock(childBlocksForm.blocks_layout.items[0]);
140
- onChangeBlock(block, {
149
+ const newData = {
141
150
  ...data,
142
151
  data: childBlocksForm,
143
- });
152
+ };
153
+ if (data.maxChars) {
154
+ newData.charCount = 0;
155
+ }
156
+ onChangeBlock(block, newData);
144
157
  }
145
158
  }, [onChangeBlock, childBlocksForm, selectedBlock, block, data, data_blocks]);
146
159
 
@@ -154,7 +167,9 @@ const Edit = (props) => {
154
167
  <fieldset
155
168
  role="presentation"
156
169
  id={props.data.id}
157
- className={cx('section-block', props.data.className)}
170
+ className={cx('section-block', props.data.className, {
171
+ 'disable-inner-buttons': data.disableInnerButtons,
172
+ })}
158
173
  onKeyDown={(e) => {
159
174
  handleKeyDown(e, props.index, props.block, props.blockNode.current);
160
175
  }}
@@ -218,10 +233,24 @@ const Edit = (props) => {
218
233
  title={props.intl.formatMessage(messages.sectionGroupSettings)}
219
234
  formData={data}
220
235
  onChangeField={(id, value) => {
221
- props.onChangeBlock(props.block, {
236
+ const newData = {
222
237
  ...props.data,
223
238
  [id]: value,
224
- });
239
+ };
240
+ const effectiveMaxChars =
241
+ id === 'maxChars' ? value : props.data.maxChars;
242
+ const effectiveIgnoreSpaces =
243
+ id === 'ignoreSpaces' ? value : props.data.ignoreSpaces;
244
+ if (effectiveMaxChars) {
245
+ newData.charCount = countTextInBlocks(
246
+ props.data?.data,
247
+ effectiveIgnoreSpaces,
248
+ effectiveMaxChars,
249
+ );
250
+ } else {
251
+ delete newData.charCount;
252
+ }
253
+ props.onChangeBlock(props.block, newData);
225
254
  }}
226
255
  />
227
256
  )}
@@ -4,7 +4,7 @@ import configureStore from 'redux-mock-store';
4
4
  import { Provider } from 'react-intl-redux';
5
5
  import thunk from 'redux-thunk';
6
6
  import { render, screen, fireEvent } from '@testing-library/react';
7
- import '@testing-library/jest-dom/extend-expect';
7
+ import '@testing-library/jest-dom';
8
8
 
9
9
  const mockStore = configureStore([thunk]);
10
10
  const store = mockStore({
@@ -90,6 +90,22 @@ describe('Edit', () => {
90
90
  expect(getByRole('presentation')).toBeInTheDocument();
91
91
  });
92
92
 
93
+ it('adds a root modifier when inner buttons are disabled', () => {
94
+ const { getByRole } = render(
95
+ <Provider store={store}>
96
+ <Edit
97
+ {...props}
98
+ data={{
99
+ ...props.data,
100
+ disableInnerButtons: true,
101
+ }}
102
+ />
103
+ </Provider>,
104
+ );
105
+
106
+ expect(getByRole('presentation')).toHaveClass('disable-inner-buttons');
107
+ });
108
+
93
109
  it('should call ArrowUp keydown', () => {
94
110
  const mockOnFocusPreviousBlock = jest.fn();
95
111
  const { getByRole } = render(
@@ -11,6 +11,7 @@ const Schema = {
11
11
  'allowedBlocks',
12
12
  'as',
13
13
  'maxChars',
14
+ 'maxCharsOverflowPercent',
14
15
  'ignoreSpaces',
15
16
  'readOnlySettings',
16
17
  'disableInnerButtons',
@@ -68,6 +69,13 @@ const Schema = {
68
69
  type: 'integer',
69
70
  factory: 'Integer',
70
71
  },
72
+ maxCharsOverflowPercent: {
73
+ title: 'Maximum Characters Overflow Percent',
74
+ description:
75
+ 'The percentage of characters that can overflow the maximum characters limit before showing a warning: 0-100',
76
+ type: 'integer',
77
+ factory: 'Integer',
78
+ },
71
79
  ignoreSpaces: {
72
80
  title: 'Ignore spaces',
73
81
  description: 'Ignore spaces while calculating maximum characters',
@@ -2,7 +2,7 @@ import React from 'react';
2
2
  import View from './View';
3
3
  import { render, screen } from '@testing-library/react';
4
4
  import { RenderBlocks } from '@plone/volto/components';
5
- import '@testing-library/jest-dom/extend-expect';
5
+ import '@testing-library/jest-dom';
6
6
 
7
7
  jest.mock('@plone/volto/components', () => ({
8
8
  RenderBlocks: jest.fn(() => <div>RenderBlocks</div>),
@@ -10,10 +10,6 @@
10
10
  margin-bottom: 1rem;
11
11
  }
12
12
 
13
- .block-add-button {
14
- display: none !important;
15
- }
16
-
17
13
  .block.group.selected::before,
18
14
  .block.group:hover::before {
19
15
  border-style: dashed;
@@ -70,32 +66,11 @@
70
66
  margin-top: 0.5rem;
71
67
  }
72
68
 
73
- .blocks-chooser {
74
- position: absolute;
75
- right: 0 !important;
76
- left: auto !important;
77
- margin-top: 3rem;
78
- }
79
-
80
- .block-toolbar {
81
- position: absolute;
82
- z-index: 3;
83
- right: -9px;
84
- display: flex;
85
- border: none;
86
- border: 1px solid @borderColor;
87
- border-bottom: 1px solid @pageBackground;
88
- margin-top: -45px;
89
- background-color: @pageBackground;
90
- border-top-left-radius: 1rem;
91
- border-top-right-radius: 1rem;
92
-
93
- .ui.basic.button {
94
- padding: 8px 5px;
95
- }
96
-
97
- .ui.basic.button:hover {
98
- background: transparent !important;
69
+ .section-block.disable-inner-buttons {
70
+ .drag.handle.wrapper,
71
+ .delete-button,
72
+ .block-add-button {
73
+ display: none !important;
99
74
  }
100
75
  }
101
76
  }