@eeacms/volto-group-block 6.1.2 → 6.2.1
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/CHANGELOG.md +22 -0
- package/DEVELOP.md +56 -0
- package/README.md +4 -0
- package/docker-compose.yml +28 -0
- package/locales/de/LC_MESSAGES/volto.po +14 -0
- package/locales/it/LC_MESSAGES/volto.po +14 -0
- package/locales/ro/LC_MESSAGES/volto.po +14 -0
- package/locales/volto.pot +16 -0
- package/package.json +1 -1
- package/src/components/manage/Blocks/Group/CounterComponent.jsx +91 -0
- package/src/components/manage/Blocks/Group/CounterComponent.test.jsx +234 -0
- package/src/components/manage/Blocks/Group/Edit.jsx +39 -135
- package/src/components/manage/Blocks/Group/Edit.test.jsx +107 -0
- package/src/index.js +1 -0
- package/src/index.test.js +101 -0
- package/.i18n.babel.config.js +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
|
4
4
|
|
|
5
5
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
6
6
|
|
|
7
|
+
### [6.2.1](https://github.com/eea/volto-group-block/compare/6.2.0...6.2.1) - 18 August 2023
|
|
8
|
+
|
|
9
|
+
#### :bug: Bug Fixes
|
|
10
|
+
|
|
11
|
+
- fix: ignoreSpaces on char counter - refs #256525 [rexalex - [`8d1ad2c`](https://github.com/eea/volto-group-block/commit/8d1ad2cfdf972b88f02f9efc4b7ef55b1e4b3592)]
|
|
12
|
+
|
|
13
|
+
#### :house: Documentation changes
|
|
14
|
+
|
|
15
|
+
- docs: Cleanup Makefile, update DEVELOP documentation, i18n - refs #254894 [valentinab25 - [`94b58f7`](https://github.com/eea/volto-group-block/commit/94b58f7c0390bfcac24adbfbea61577137c91e2b)]
|
|
16
|
+
|
|
17
|
+
#### :hammer_and_wrench: Others
|
|
18
|
+
|
|
19
|
+
- test: increase test coverage - refs #254313 [ana-oprea - [`5facade`](https://github.com/eea/volto-group-block/commit/5facadec2f866a066ca836f8247a5e58ea44d176)]
|
|
20
|
+
### [6.2.0](https://github.com/eea/volto-group-block/compare/6.1.2...6.2.0) - 20 July 2023
|
|
21
|
+
|
|
22
|
+
#### :nail_care: Enhancements
|
|
23
|
+
|
|
24
|
+
- refactor: char-count component refs #253801 [Nilesh - [`1a54719`](https://github.com/eea/volto-group-block/commit/1a54719af523663314190741417bb06142967f68)]
|
|
25
|
+
|
|
26
|
+
#### :hammer_and_wrench: Others
|
|
27
|
+
|
|
28
|
+
- Release 6.2.0 [Alin Voinea - [`e4e254d`](https://github.com/eea/volto-group-block/commit/e4e254da4c109ee1c8c8a9ce42821eeabfae06ef)]
|
|
7
29
|
### [6.1.2](https://github.com/eea/volto-group-block/compare/6.1.1...6.1.2) - 12 June 2023
|
|
8
30
|
|
|
9
31
|
#### :house: Internal changes
|
package/DEVELOP.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## Develop
|
|
4
4
|
|
|
5
|
+
1. Make sure you have `docker` and `docker compose` installed and running on your machine:
|
|
6
|
+
|
|
7
|
+
```Bash
|
|
8
|
+
git clone https://github.com/eea/volto-group-block.git
|
|
9
|
+
cd volto-group-block
|
|
10
|
+
git checkout -b bugfix-123456 develop
|
|
11
|
+
make
|
|
12
|
+
make start
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
1. Wait for `Volto started at 0.0.0.0:3000` meesage
|
|
16
|
+
|
|
17
|
+
1. Go to http://localhost:3000
|
|
18
|
+
|
|
19
|
+
1. Happy hacking!
|
|
20
|
+
|
|
21
|
+
```Bash
|
|
22
|
+
cd src/addons/volto-group-block/
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Or add @eeacms/volto-group-block to your Volto project
|
|
26
|
+
|
|
5
27
|
Before starting make sure your development environment is properly set. See [Volto Developer Documentation](https://docs.voltocms.com/getting-started/install/)
|
|
6
28
|
|
|
7
29
|
1. Make sure you have installed `yo`, `@plone/generator-volto` and `mrs-developer`
|
|
@@ -48,3 +70,37 @@ Before starting make sure your development environment is properly set. See [Vol
|
|
|
48
70
|
1. Happy hacking!
|
|
49
71
|
|
|
50
72
|
cd src/addons/volto-group-block/
|
|
73
|
+
|
|
74
|
+
## Cypress
|
|
75
|
+
|
|
76
|
+
To run cypress locally, first make sure you don't have any Volto/Plone running on ports `8080` and `3000`.
|
|
77
|
+
|
|
78
|
+
You don't have to be in a `clean-volto-project`, you can be in any Volto Frontend
|
|
79
|
+
project where you added `volto-group-block` to `mrs.developer.json`
|
|
80
|
+
|
|
81
|
+
Go to:
|
|
82
|
+
|
|
83
|
+
```BASH
|
|
84
|
+
cd src/addons/volto-group-block/
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Start:
|
|
88
|
+
|
|
89
|
+
```Bash
|
|
90
|
+
make
|
|
91
|
+
make start
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
This will build and start with Docker a clean `Plone backend` and `Volto Frontend` with `volto-group-block` block installed.
|
|
95
|
+
|
|
96
|
+
Open Cypress Interface:
|
|
97
|
+
|
|
98
|
+
```Bash
|
|
99
|
+
make cypress-open
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Or run it:
|
|
103
|
+
|
|
104
|
+
```Bash
|
|
105
|
+
make cypress-run
|
|
106
|
+
```
|
package/README.md
CHANGED
|
@@ -51,6 +51,10 @@
|
|
|
51
51
|
|
|
52
52
|
1. Make sure you have a [Plone backend](https://plone.org/download) up-and-running at http://localhost:8080/Plone
|
|
53
53
|
|
|
54
|
+
```Bash
|
|
55
|
+
docker compose up backend
|
|
56
|
+
```
|
|
57
|
+
|
|
54
58
|
1. Start Volto frontend
|
|
55
59
|
|
|
56
60
|
- If you already have a volto project, just update `package.json`:
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
version: "3"
|
|
2
|
+
services:
|
|
3
|
+
backend:
|
|
4
|
+
image: plone/plone-backend:${PLONE_VERSION:-6}
|
|
5
|
+
ports:
|
|
6
|
+
- "8080:8080"
|
|
7
|
+
environment:
|
|
8
|
+
SITE: "Plone"
|
|
9
|
+
|
|
10
|
+
frontend:
|
|
11
|
+
build:
|
|
12
|
+
context: ./
|
|
13
|
+
dockerfile: ./Dockerfile
|
|
14
|
+
args:
|
|
15
|
+
ADDON_NAME: "${ADDON_NAME}"
|
|
16
|
+
ADDON_PATH: "${ADDON_PATH}"
|
|
17
|
+
VOLTO_VERSION: ${VOLTO_VERSION:-16}
|
|
18
|
+
ports:
|
|
19
|
+
- "3000:3000"
|
|
20
|
+
- "3001:3001"
|
|
21
|
+
depends_on:
|
|
22
|
+
- backend
|
|
23
|
+
volumes:
|
|
24
|
+
- ./:/app/src/addons/${ADDON_PATH}
|
|
25
|
+
environment:
|
|
26
|
+
RAZZLE_INTERNAL_API_PATH: "http://backend:8080/Plone"
|
|
27
|
+
RAZZLE_DEV_PROXY_API_PATH: "http://backend:8080/Plone"
|
|
28
|
+
HOST: "0.0.0.0"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
msgid ""
|
|
2
|
+
msgstr ""
|
|
3
|
+
"Project-Id-Version: \n"
|
|
4
|
+
"Report-Msgid-Bugs-To: \n"
|
|
5
|
+
"POT-Creation-Date: \n"
|
|
6
|
+
"PO-Revision-Date: \n"
|
|
7
|
+
"Last-Translator: \n"
|
|
8
|
+
"Language: \n"
|
|
9
|
+
"Language-Team: \n"
|
|
10
|
+
"Content-Type: \n"
|
|
11
|
+
"Content-Transfer-Encoding: \n"
|
|
12
|
+
"Plural-Forms: \n"
|
|
13
|
+
|
|
14
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
msgid ""
|
|
2
|
+
msgstr ""
|
|
3
|
+
"Project-Id-Version: \n"
|
|
4
|
+
"Report-Msgid-Bugs-To: \n"
|
|
5
|
+
"POT-Creation-Date: \n"
|
|
6
|
+
"PO-Revision-Date: \n"
|
|
7
|
+
"Last-Translator: \n"
|
|
8
|
+
"Language: \n"
|
|
9
|
+
"Language-Team: \n"
|
|
10
|
+
"Content-Type: \n"
|
|
11
|
+
"Content-Transfer-Encoding: \n"
|
|
12
|
+
"Plural-Forms: \n"
|
|
13
|
+
|
|
14
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
msgid ""
|
|
2
|
+
msgstr ""
|
|
3
|
+
"Project-Id-Version: \n"
|
|
4
|
+
"Report-Msgid-Bugs-To: \n"
|
|
5
|
+
"POT-Creation-Date: \n"
|
|
6
|
+
"PO-Revision-Date: \n"
|
|
7
|
+
"Last-Translator: \n"
|
|
8
|
+
"Language: \n"
|
|
9
|
+
"Language-Team: \n"
|
|
10
|
+
"Content-Type: \n"
|
|
11
|
+
"Content-Transfer-Encoding: \n"
|
|
12
|
+
"Plural-Forms: \n"
|
|
13
|
+
|
|
14
|
+
|
package/locales/volto.pot
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
msgid ""
|
|
2
|
+
msgstr ""
|
|
3
|
+
"Project-Id-Version: Plone\n"
|
|
4
|
+
"POT-Creation-Date: 2023-06-28T10:48:22.678Z\n"
|
|
5
|
+
"Last-Translator: Plone i18n <plone-i18n@lists.sourceforge.net>\n"
|
|
6
|
+
"Language-Team: Plone i18n <plone-i18n@lists.sourceforge.net>\n"
|
|
7
|
+
"MIME-Version: 1.0\n"
|
|
8
|
+
"Content-Type: text/plain; charset=utf-8\n"
|
|
9
|
+
"Content-Transfer-Encoding: 8bit\n"
|
|
10
|
+
"Plural-Forms: nplurals=1; plural=0;\n"
|
|
11
|
+
"Language-Code: en\n"
|
|
12
|
+
"Language-Name: English\n"
|
|
13
|
+
"Preferred-Encodings: utf-8\n"
|
|
14
|
+
"Domain: volto\n"
|
|
15
|
+
|
|
16
|
+
|
package/package.json
CHANGED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import cx from 'classnames';
|
|
2
|
+
import isString from 'lodash/isString';
|
|
3
|
+
import isArray from 'lodash/isArray';
|
|
4
|
+
import { Icon } from '@plone/volto/components';
|
|
5
|
+
import config from '@plone/volto/registry';
|
|
6
|
+
import { visitBlocks } from '@plone/volto/helpers/Blocks/Blocks';
|
|
7
|
+
import { serializeNodesToText } from '@plone/volto-slate/editor/render';
|
|
8
|
+
import delightedSVG from '@plone/volto/icons/delighted.svg';
|
|
9
|
+
import dissatisfiedSVG from '@plone/volto/icons/dissatisfied.svg';
|
|
10
|
+
|
|
11
|
+
const countCharsWithoutSpaces = (paragraph) => {
|
|
12
|
+
const regex = /[^\s\\]/g;
|
|
13
|
+
return (paragraph.match(regex) || []).length;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const countCharsWithSpaces = (paragraph) => {
|
|
17
|
+
return paragraph?.length || 0;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const countTextInEachBlock = (countTextIn, ignoreSpaces, groupCharCount) => ([
|
|
21
|
+
id,
|
|
22
|
+
blockData,
|
|
23
|
+
]) => {
|
|
24
|
+
const foundText =
|
|
25
|
+
blockData && countTextIn?.includes(blockData?.['@type'])
|
|
26
|
+
? isString(blockData?.plaintext)
|
|
27
|
+
? blockData?.plaintext
|
|
28
|
+
: isArray(blockData?.value) && blockData?.value !== null
|
|
29
|
+
? serializeNodesToText(blockData?.value)
|
|
30
|
+
: ''
|
|
31
|
+
: '';
|
|
32
|
+
|
|
33
|
+
groupCharCount.value += ignoreSpaces
|
|
34
|
+
? countCharsWithoutSpaces(foundText)
|
|
35
|
+
: countCharsWithSpaces(foundText);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const countTextInBlocks = (blocksObject, ignoreSpaces, maxChars) => {
|
|
39
|
+
const { countTextIn } = config.blocks?.blocksConfig?.group;
|
|
40
|
+
// use obj ref to update value - if you send number it will not be updated
|
|
41
|
+
const groupCharCount = { value: 0 };
|
|
42
|
+
|
|
43
|
+
if (!maxChars || !blocksObject) {
|
|
44
|
+
return groupCharCount.value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
visitBlocks(
|
|
48
|
+
blocksObject,
|
|
49
|
+
countTextInEachBlock(countTextIn, ignoreSpaces, groupCharCount),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return groupCharCount.value;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const CounterComponent = ({ data, setSidebarTab, setSelectedBlock }) => {
|
|
56
|
+
const { maxChars, ignoreSpaces } = data;
|
|
57
|
+
const charCount = countTextInBlocks(data?.data, ignoreSpaces, maxChars);
|
|
58
|
+
const counterClass =
|
|
59
|
+
charCount < Math.ceil(maxChars / 1.05)
|
|
60
|
+
? 'info'
|
|
61
|
+
: charCount < maxChars
|
|
62
|
+
? 'warning'
|
|
63
|
+
: 'danger';
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<p
|
|
67
|
+
className={cx('counter', counterClass)}
|
|
68
|
+
onClick={() => {
|
|
69
|
+
setSelectedBlock();
|
|
70
|
+
setSidebarTab(1);
|
|
71
|
+
}}
|
|
72
|
+
aria-hidden="true"
|
|
73
|
+
>
|
|
74
|
+
{maxChars - charCount < 0 ? (
|
|
75
|
+
<>
|
|
76
|
+
<span>{`${charCount - maxChars} characters over the limit`}</span>
|
|
77
|
+
<Icon name={dissatisfiedSVG} size="24px" />
|
|
78
|
+
</>
|
|
79
|
+
) : (
|
|
80
|
+
<>
|
|
81
|
+
<span>{`${
|
|
82
|
+
maxChars - charCount
|
|
83
|
+
} characters remaining out of ${maxChars}`}</span>
|
|
84
|
+
<Icon name={delightedSVG} size="24px" />
|
|
85
|
+
</>
|
|
86
|
+
)}
|
|
87
|
+
</p>
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export default CounterComponent;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
import CounterComponent from './CounterComponent';
|
|
4
|
+
import '@testing-library/jest-dom/extend-expect';
|
|
5
|
+
|
|
6
|
+
jest.mock('@plone/volto/registry', () => ({
|
|
7
|
+
blocks: {
|
|
8
|
+
blocksConfig: {
|
|
9
|
+
group: {
|
|
10
|
+
countTextIn: ['text'],
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
jest.mock('@plone/volto-slate/editor/render', () => ({
|
|
17
|
+
serializeNodesToText: jest.fn((nodes) =>
|
|
18
|
+
nodes.map((node) => node.text).join(' '),
|
|
19
|
+
),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
describe('CounterComponent', () => {
|
|
23
|
+
const setSidebarTab = jest.fn();
|
|
24
|
+
const setSelectedBlock = jest.fn();
|
|
25
|
+
|
|
26
|
+
it('should render info class when character count is less than 95% of maxChars', () => {
|
|
27
|
+
const { container, getByText } = render(
|
|
28
|
+
<CounterComponent
|
|
29
|
+
data={{
|
|
30
|
+
maxChars: 100,
|
|
31
|
+
data: {
|
|
32
|
+
blocks: {
|
|
33
|
+
block1: { '@type': 'text', plaintext: 'test' },
|
|
34
|
+
},
|
|
35
|
+
blocks_layout: {
|
|
36
|
+
items: ['block1'],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
}}
|
|
40
|
+
setSidebarTab={setSidebarTab}
|
|
41
|
+
setSelectedBlock={setSelectedBlock}
|
|
42
|
+
/>,
|
|
43
|
+
);
|
|
44
|
+
expect(getByText('96 characters remaining out of 100')).toBeInTheDocument();
|
|
45
|
+
expect(container.querySelector('.counter.info')).toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should render warning class when character count is between 95% and 100% of maxChars', () => {
|
|
49
|
+
const { container, getByText } = render(
|
|
50
|
+
<CounterComponent
|
|
51
|
+
data={{
|
|
52
|
+
maxChars: 100,
|
|
53
|
+
data: {
|
|
54
|
+
blocks: {
|
|
55
|
+
block1: { '@type': 'text', plaintext: 'test'.repeat(24) },
|
|
56
|
+
},
|
|
57
|
+
blocks_layout: {
|
|
58
|
+
items: ['block1'],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
}}
|
|
62
|
+
setSidebarTab={setSidebarTab}
|
|
63
|
+
setSelectedBlock={setSelectedBlock}
|
|
64
|
+
/>,
|
|
65
|
+
);
|
|
66
|
+
expect(getByText('4 characters remaining out of 100')).toBeInTheDocument();
|
|
67
|
+
expect(container.querySelector('.counter.warning')).toBeInTheDocument();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should render warning class when character count is over the maxChars', () => {
|
|
71
|
+
const { container, getByText } = render(
|
|
72
|
+
<CounterComponent
|
|
73
|
+
data={{
|
|
74
|
+
maxChars: 100,
|
|
75
|
+
data: {
|
|
76
|
+
blocks: {
|
|
77
|
+
block1: { '@type': 'text', plaintext: 'test'.repeat(26) },
|
|
78
|
+
},
|
|
79
|
+
blocks_layout: {
|
|
80
|
+
items: ['block1'],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
}}
|
|
84
|
+
setSidebarTab={setSidebarTab}
|
|
85
|
+
setSelectedBlock={setSelectedBlock}
|
|
86
|
+
/>,
|
|
87
|
+
);
|
|
88
|
+
expect(getByText('4 characters over the limit')).toBeInTheDocument();
|
|
89
|
+
expect(container.querySelector('.counter.danger')).toBeInTheDocument();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should handle click event', () => {
|
|
93
|
+
const { container } = render(
|
|
94
|
+
<CounterComponent
|
|
95
|
+
data={{
|
|
96
|
+
maxChars: 100,
|
|
97
|
+
data: {
|
|
98
|
+
blocks: {
|
|
99
|
+
block1: {
|
|
100
|
+
'@type': 'text',
|
|
101
|
+
plaintext: 'test'.repeat(24),
|
|
102
|
+
ignoreSpaces: true,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
blocks_layout: {
|
|
106
|
+
items: ['block1'],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
}}
|
|
110
|
+
setSidebarTab={setSidebarTab}
|
|
111
|
+
setSelectedBlock={setSelectedBlock}
|
|
112
|
+
/>,
|
|
113
|
+
);
|
|
114
|
+
container.querySelector('.counter').click();
|
|
115
|
+
expect(setSidebarTab).toHaveBeenCalledWith(1);
|
|
116
|
+
expect(setSelectedBlock).toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should handle click event with maxChar undefined', () => {
|
|
120
|
+
const { container } = render(
|
|
121
|
+
<CounterComponent
|
|
122
|
+
data={{
|
|
123
|
+
maxChars: undefined,
|
|
124
|
+
data: undefined,
|
|
125
|
+
}}
|
|
126
|
+
setSidebarTab={setSidebarTab}
|
|
127
|
+
setSelectedBlock={setSelectedBlock}
|
|
128
|
+
/>,
|
|
129
|
+
);
|
|
130
|
+
container.querySelector('.counter').click();
|
|
131
|
+
expect(setSidebarTab).toHaveBeenCalledWith(1);
|
|
132
|
+
expect(setSelectedBlock).toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should handle click event with data undefined', () => {
|
|
136
|
+
const { container } = render(
|
|
137
|
+
<CounterComponent
|
|
138
|
+
data={{
|
|
139
|
+
maxChars: 100,
|
|
140
|
+
data: undefined,
|
|
141
|
+
}}
|
|
142
|
+
setSidebarTab={setSidebarTab}
|
|
143
|
+
setSelectedBlock={setSelectedBlock}
|
|
144
|
+
/>,
|
|
145
|
+
);
|
|
146
|
+
container.querySelector('.counter').click();
|
|
147
|
+
expect(setSidebarTab).toHaveBeenCalledWith(1);
|
|
148
|
+
expect(setSelectedBlock).toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should handle click event with plaintext undefined, but values present', () => {
|
|
152
|
+
const { container } = render(
|
|
153
|
+
<CounterComponent
|
|
154
|
+
data={{
|
|
155
|
+
maxChars: 100,
|
|
156
|
+
data: {
|
|
157
|
+
blocks: {
|
|
158
|
+
block1: {
|
|
159
|
+
'@type': 'text',
|
|
160
|
+
plaintext: undefined,
|
|
161
|
+
value: [
|
|
162
|
+
{ text: 'test' },
|
|
163
|
+
{ children: [{ text: 'more text' }] },
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
blocks_layout: {
|
|
168
|
+
items: ['block1'],
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
}}
|
|
172
|
+
setSidebarTab={setSidebarTab}
|
|
173
|
+
setSelectedBlock={setSelectedBlock}
|
|
174
|
+
/>,
|
|
175
|
+
);
|
|
176
|
+
container.querySelector('.counter').click();
|
|
177
|
+
expect(setSidebarTab).toHaveBeenCalledWith(1);
|
|
178
|
+
expect(setSelectedBlock).toHaveBeenCalled();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should handle click event with plaintext undefined and values is not an array and the type is in countTextIn', () => {
|
|
182
|
+
const { container } = render(
|
|
183
|
+
<CounterComponent
|
|
184
|
+
data={{
|
|
185
|
+
maxChars: 100,
|
|
186
|
+
data: {
|
|
187
|
+
blocks: {
|
|
188
|
+
block1: {
|
|
189
|
+
'@type': 'text',
|
|
190
|
+
plaintext: undefined,
|
|
191
|
+
value: {},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
blocks_layout: {
|
|
195
|
+
items: ['block1'],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
}}
|
|
199
|
+
setSidebarTab={setSidebarTab}
|
|
200
|
+
setSelectedBlock={setSelectedBlock}
|
|
201
|
+
/>,
|
|
202
|
+
);
|
|
203
|
+
container.querySelector('.counter').click();
|
|
204
|
+
expect(setSidebarTab).toHaveBeenCalledWith(1);
|
|
205
|
+
expect(setSelectedBlock).toHaveBeenCalled();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should handle click event with plaintext undefined and values is not an array and the type is not in countTextIn', () => {
|
|
209
|
+
const { container } = render(
|
|
210
|
+
<CounterComponent
|
|
211
|
+
data={{
|
|
212
|
+
maxChars: 100,
|
|
213
|
+
data: {
|
|
214
|
+
blocks: {
|
|
215
|
+
block1: {
|
|
216
|
+
'@type': 'test',
|
|
217
|
+
plaintext: undefined,
|
|
218
|
+
value: {},
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
blocks_layout: {
|
|
222
|
+
items: ['block1'],
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
}}
|
|
226
|
+
setSidebarTab={setSidebarTab}
|
|
227
|
+
setSelectedBlock={setSelectedBlock}
|
|
228
|
+
/>,
|
|
229
|
+
);
|
|
230
|
+
container.querySelector('.counter').click();
|
|
231
|
+
expect(setSidebarTab).toHaveBeenCalledWith(1);
|
|
232
|
+
expect(setSelectedBlock).toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
1
|
+
import React, { useState, useCallback } from 'react';
|
|
2
2
|
import { isEmpty, without } from 'lodash';
|
|
3
3
|
import config from '@plone/volto/registry';
|
|
4
4
|
import {
|
|
@@ -12,14 +12,12 @@ import {
|
|
|
12
12
|
emptyBlocksForm,
|
|
13
13
|
getBlocksLayoutFieldname,
|
|
14
14
|
} from '@plone/volto/helpers';
|
|
15
|
-
import delightedSVG from '@plone/volto/icons/delighted.svg';
|
|
16
|
-
import dissatisfiedSVG from '@plone/volto/icons/dissatisfied.svg';
|
|
17
15
|
import PropTypes from 'prop-types';
|
|
18
16
|
import { Button, Segment } from 'semantic-ui-react';
|
|
19
17
|
import EditBlockWrapper from './EditBlockWrapper';
|
|
20
18
|
import EditSchema from './EditSchema';
|
|
19
|
+
import CounterComponent from './CounterComponent';
|
|
21
20
|
import helpSVG from '@plone/volto/icons/help.svg';
|
|
22
|
-
import cx from 'classnames';
|
|
23
21
|
import './editor.less';
|
|
24
22
|
|
|
25
23
|
const Edit = (props) => {
|
|
@@ -43,8 +41,6 @@ const Edit = (props) => {
|
|
|
43
41
|
);
|
|
44
42
|
|
|
45
43
|
const blockState = {};
|
|
46
|
-
let charCount = 0;
|
|
47
|
-
|
|
48
44
|
const handleKeyDown = (
|
|
49
45
|
e,
|
|
50
46
|
index,
|
|
@@ -71,47 +67,48 @@ const Edit = (props) => {
|
|
|
71
67
|
}
|
|
72
68
|
};
|
|
73
69
|
|
|
74
|
-
const onSelectBlock = (
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
const onSelectBlock = useCallback(
|
|
71
|
+
(id, isMultipleSelection, event, activeBlock) => {
|
|
72
|
+
let newMultiSelected = [];
|
|
73
|
+
let selected = id;
|
|
77
74
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
75
|
+
if (isMultipleSelection) {
|
|
76
|
+
selected = null;
|
|
77
|
+
const blocksLayoutFieldname = getBlocksLayoutFieldname(data?.data);
|
|
78
|
+
const blocks_layout = data?.data[blocksLayoutFieldname].items;
|
|
79
|
+
if (event.shiftKey) {
|
|
80
|
+
const anchor =
|
|
81
|
+
multiSelected.length > 0
|
|
82
|
+
? blocks_layout.indexOf(multiSelected[0])
|
|
83
|
+
: blocks_layout.indexOf(activeBlock);
|
|
84
|
+
const focus = blocks_layout.indexOf(id);
|
|
85
|
+
if (anchor === focus) {
|
|
86
|
+
newMultiSelected = [id];
|
|
87
|
+
} else if (focus > anchor) {
|
|
88
|
+
newMultiSelected = [...blocks_layout.slice(anchor, focus + 1)];
|
|
89
|
+
} else {
|
|
90
|
+
newMultiSelected = [...blocks_layout.slice(focus, anchor + 1)];
|
|
91
|
+
}
|
|
94
92
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
93
|
+
if ((event.ctrlKey || event.metaKey) && !event.shiftKey) {
|
|
94
|
+
if (multiSelected.includes(id)) {
|
|
95
|
+
selected = null;
|
|
96
|
+
newMultiSelected = without(multiSelected, id);
|
|
97
|
+
} else {
|
|
98
|
+
newMultiSelected = [...(multiSelected || []), id];
|
|
99
|
+
}
|
|
102
100
|
}
|
|
103
101
|
}
|
|
104
|
-
}
|
|
105
102
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
103
|
+
setSelectedBlock(selected);
|
|
104
|
+
setMultiSelected(newMultiSelected);
|
|
105
|
+
},
|
|
106
|
+
[data.data, multiSelected],
|
|
107
|
+
);
|
|
109
108
|
|
|
110
109
|
const changeBlockData = (newBlockData) => {
|
|
111
110
|
let pastedBlocks = newBlockData.blocks_layout.items.filter((blockID) => {
|
|
112
|
-
|
|
113
|
-
return false;
|
|
114
|
-
return true;
|
|
111
|
+
return !data?.data?.blocks_layout.items.find((x) => x === blockID);
|
|
115
112
|
});
|
|
116
113
|
const selectedIndex =
|
|
117
114
|
data.data.blocks_layout.items.indexOf(selectedBlock) + 1;
|
|
@@ -144,101 +141,6 @@ const Edit = (props) => {
|
|
|
144
141
|
}
|
|
145
142
|
}, [onChangeBlock, properties, selectedBlock, block, data, data_blocks]);
|
|
146
143
|
|
|
147
|
-
/**
|
|
148
|
-
* Count the number of characters that are anything except using Regex
|
|
149
|
-
* @param {string} paragraph
|
|
150
|
-
* @returns
|
|
151
|
-
*/
|
|
152
|
-
const countCharsWithoutSpaces = (paragraph) => {
|
|
153
|
-
const regex = /[^\s\\]/g;
|
|
154
|
-
|
|
155
|
-
return (paragraph.match(regex) || []).length;
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Count the number of characters
|
|
160
|
-
* @param {string} paragraph
|
|
161
|
-
* @returns
|
|
162
|
-
*/
|
|
163
|
-
const countCharsWithSpaces = (paragraph) => {
|
|
164
|
-
return paragraph?.length || 0;
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Recursively look for any block that contains text or plaintext
|
|
169
|
-
* @param {Object} blocksObject
|
|
170
|
-
* @returns
|
|
171
|
-
*/
|
|
172
|
-
const countTextInBlocks = (blocksObject) => {
|
|
173
|
-
let groupCharCount = 0;
|
|
174
|
-
if (!props.data.maxChars) {
|
|
175
|
-
return groupCharCount;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
Object.keys(blocksObject).forEach((blockId) => {
|
|
179
|
-
const foundText = blocksObject[blockId]?.plaintext
|
|
180
|
-
? blocksObject[blockId]?.plaintext
|
|
181
|
-
: blocksObject[blockId]?.text?.blocks[0]?.text
|
|
182
|
-
? blocksObject[blockId].text.blocks[0].text
|
|
183
|
-
: blocksObject[blockId]?.data?.blocks
|
|
184
|
-
? countTextInBlocks(blocksObject[blockId]?.data?.blocks)
|
|
185
|
-
: blocksObject[blockId]?.blocks
|
|
186
|
-
? countTextInBlocks(blocksObject[blockId]?.blocks)
|
|
187
|
-
: '';
|
|
188
|
-
const resultText =
|
|
189
|
-
typeof foundText === 'string' || foundText instanceof String
|
|
190
|
-
? foundText
|
|
191
|
-
: '';
|
|
192
|
-
|
|
193
|
-
groupCharCount += props.data.ignoreSpaces
|
|
194
|
-
? countCharsWithoutSpaces(resultText)
|
|
195
|
-
: countCharsWithSpaces(resultText);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
return groupCharCount;
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
const showCharCounter = () => {
|
|
202
|
-
if (data_blocks) {
|
|
203
|
-
charCount = countTextInBlocks(data_blocks);
|
|
204
|
-
}
|
|
205
|
-
};
|
|
206
|
-
showCharCounter();
|
|
207
|
-
|
|
208
|
-
const counterClass =
|
|
209
|
-
charCount < Math.ceil(props.data.maxChars / 1.05)
|
|
210
|
-
? 'info'
|
|
211
|
-
: charCount < props.data.maxChars
|
|
212
|
-
? 'warning'
|
|
213
|
-
: 'danger';
|
|
214
|
-
|
|
215
|
-
const counterComponent = props.data.maxChars ? (
|
|
216
|
-
<p
|
|
217
|
-
className={cx('counter', counterClass)}
|
|
218
|
-
onClick={() => {
|
|
219
|
-
setSelectedBlock();
|
|
220
|
-
props.setSidebarTab(1);
|
|
221
|
-
}}
|
|
222
|
-
aria-hidden="true"
|
|
223
|
-
>
|
|
224
|
-
{props.data.maxChars - charCount < 0 ? (
|
|
225
|
-
<>
|
|
226
|
-
<span>{`${
|
|
227
|
-
charCount - props.data.maxChars
|
|
228
|
-
} characters over the limit`}</span>
|
|
229
|
-
<Icon name={dissatisfiedSVG} size="24px" />
|
|
230
|
-
</>
|
|
231
|
-
) : (
|
|
232
|
-
<>
|
|
233
|
-
<span>{`${
|
|
234
|
-
props.data.maxChars - charCount
|
|
235
|
-
} characters remaining out of ${props.data.maxChars}`}</span>
|
|
236
|
-
<Icon name={delightedSVG} size="24px" />
|
|
237
|
-
</>
|
|
238
|
-
)}
|
|
239
|
-
</p>
|
|
240
|
-
) : null;
|
|
241
|
-
|
|
242
144
|
// Get editing instructions from block settings or props
|
|
243
145
|
let instructions = data?.instructions?.data || data?.instructions;
|
|
244
146
|
if (!instructions || instructions === '<p><br/></p>') {
|
|
@@ -355,7 +257,9 @@ const Edit = (props) => {
|
|
|
355
257
|
)}
|
|
356
258
|
</BlocksForm>
|
|
357
259
|
|
|
358
|
-
{
|
|
260
|
+
{props.data.maxChars && (
|
|
261
|
+
<CounterComponent {...props} setSelectedBlock={setSelectedBlock} />
|
|
262
|
+
)}
|
|
359
263
|
<SidebarPortal selected={selected && !selectedBlock}>
|
|
360
264
|
{instructions && (
|
|
361
265
|
<Segment attached>
|
|
@@ -76,4 +76,111 @@ describe('Edit', () => {
|
|
|
76
76
|
);
|
|
77
77
|
fireEvent.keyDown(getByRole('presentation'), { key: 'ArrowUp', code: 38 });
|
|
78
78
|
});
|
|
79
|
+
|
|
80
|
+
it('should call ArrowUp keydown', () => {
|
|
81
|
+
const props = {
|
|
82
|
+
block: 'testBlock',
|
|
83
|
+
data: {
|
|
84
|
+
instructions: 'test',
|
|
85
|
+
data: {
|
|
86
|
+
blocks: {
|
|
87
|
+
block1: {
|
|
88
|
+
type: 'test',
|
|
89
|
+
data: {
|
|
90
|
+
value: 'Test',
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
blocks_layout: {
|
|
95
|
+
items: [undefined],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
onChangeBlock,
|
|
100
|
+
onChangeField,
|
|
101
|
+
pathname: '/',
|
|
102
|
+
selected: true,
|
|
103
|
+
manage: true,
|
|
104
|
+
};
|
|
105
|
+
const mockOnFocusPreviousBlock = jest.fn();
|
|
106
|
+
const mockOnFocusNextBlock = jest.fn();
|
|
107
|
+
const mockOnAddBlock = jest.fn();
|
|
108
|
+
const mockSidebarTab = jest.fn();
|
|
109
|
+
|
|
110
|
+
const { container } = render(
|
|
111
|
+
<Provider store={store}>
|
|
112
|
+
<Edit
|
|
113
|
+
{...props}
|
|
114
|
+
onFocusPreviousBlock={mockOnFocusPreviousBlock}
|
|
115
|
+
onFocusNextBlock={mockOnFocusNextBlock}
|
|
116
|
+
onAddBlock={mockOnAddBlock}
|
|
117
|
+
blockNode={mockBlockNode}
|
|
118
|
+
setSidebarTab={mockSidebarTab}
|
|
119
|
+
/>
|
|
120
|
+
</Provider>,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
fireEvent.keyDown(container.querySelector('.section-block'), {
|
|
124
|
+
key: 'ArrowUp',
|
|
125
|
+
code: 38,
|
|
126
|
+
});
|
|
127
|
+
fireEvent.keyDown(container.querySelector('.section-block'), {
|
|
128
|
+
key: 'ArrowDown',
|
|
129
|
+
code: 40,
|
|
130
|
+
});
|
|
131
|
+
fireEvent.keyDown(container.querySelector('.section-block'), {
|
|
132
|
+
key: 'Enter',
|
|
133
|
+
code: 13,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
fireEvent.click(container.querySelector('.blocks-form'), {
|
|
137
|
+
shiftKey: true,
|
|
138
|
+
});
|
|
139
|
+
fireEvent.click(container.querySelector('.section-block legend'));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should call ArrowUp keydown', () => {
|
|
143
|
+
const props = {
|
|
144
|
+
block: 'testBlock',
|
|
145
|
+
data: {
|
|
146
|
+
instructions: 'test',
|
|
147
|
+
data: {
|
|
148
|
+
blocks: {
|
|
149
|
+
block1: {
|
|
150
|
+
type: 'test',
|
|
151
|
+
data: {
|
|
152
|
+
value: 'Test',
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
blocks_layout: {
|
|
157
|
+
items: [undefined],
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
onChangeBlock,
|
|
162
|
+
onChangeField,
|
|
163
|
+
pathname: '/',
|
|
164
|
+
selected: true,
|
|
165
|
+
manage: true,
|
|
166
|
+
};
|
|
167
|
+
const mockOnFocusPreviousBlock = jest.fn();
|
|
168
|
+
const mockOnFocusNextBlock = jest.fn();
|
|
169
|
+
const mockOnAddBlock = jest.fn();
|
|
170
|
+
const mockSidebarTab = jest.fn();
|
|
171
|
+
const { container } = render(
|
|
172
|
+
<Provider store={store}>
|
|
173
|
+
<Edit
|
|
174
|
+
{...props}
|
|
175
|
+
onFocusPreviousBlock={mockOnFocusPreviousBlock}
|
|
176
|
+
onFocusNextBlock={mockOnFocusNextBlock}
|
|
177
|
+
onAddBlock={mockOnAddBlock}
|
|
178
|
+
setSidebarTab={mockSidebarTab}
|
|
179
|
+
blockNode={mockBlockNode}
|
|
180
|
+
/>
|
|
181
|
+
</Provider>,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
fireEvent.click(container.querySelector('.section-block legend'));
|
|
185
|
+
});
|
|
79
186
|
});
|
package/src/index.js
CHANGED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import applyConfig from './index';
|
|
2
|
+
|
|
3
|
+
describe('applyConfig', () => {
|
|
4
|
+
it('should add group block configuration', () => {
|
|
5
|
+
const config = {
|
|
6
|
+
blocks: {
|
|
7
|
+
blocksConfig: {
|
|
8
|
+
text: { title: 'Text', restricted: false },
|
|
9
|
+
image: { title: 'Image', restricted: true },
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const newConfig = applyConfig(config);
|
|
15
|
+
|
|
16
|
+
expect(newConfig.blocks.blocksConfig.group).toBeDefined();
|
|
17
|
+
expect(newConfig.blocks.blocksConfig.group.id).toEqual('group');
|
|
18
|
+
expect(newConfig.blocks.blocksConfig.group.title).toEqual(
|
|
19
|
+
'Section (Group)',
|
|
20
|
+
);
|
|
21
|
+
expect(newConfig.blocks.blocksConfig.group.icon).toBeDefined();
|
|
22
|
+
expect(newConfig.blocks.blocksConfig.group.view).toBeDefined();
|
|
23
|
+
expect(newConfig.blocks.blocksConfig.group.edit).toBeDefined();
|
|
24
|
+
expect(newConfig.blocks.blocksConfig.group.schema).toBeDefined();
|
|
25
|
+
expect(newConfig.blocks.blocksConfig.group.restricted).toEqual(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should include allowed blocks in schema', () => {
|
|
29
|
+
const config = {
|
|
30
|
+
blocks: {
|
|
31
|
+
blocksConfig: {
|
|
32
|
+
text: { title: 'Text', restricted: false },
|
|
33
|
+
image: { restricted: false },
|
|
34
|
+
image_test: { title: 'Image', restricted: true },
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const newConfig = applyConfig(config);
|
|
40
|
+
|
|
41
|
+
expect(
|
|
42
|
+
newConfig.blocks.blocksConfig.group.schema.properties.allowedBlocks.items
|
|
43
|
+
.choices,
|
|
44
|
+
).toEqual([
|
|
45
|
+
['text', 'Text'],
|
|
46
|
+
['image', 'image'],
|
|
47
|
+
['group', 'Group'],
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should generate tocEntries correctly', () => {
|
|
52
|
+
const config = {
|
|
53
|
+
blocks: {
|
|
54
|
+
blocksConfig: {},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const block = {
|
|
59
|
+
data: {
|
|
60
|
+
blocks: {
|
|
61
|
+
block1: { value: [{ type: 'h1' }], plaintext: 'Heading 1' },
|
|
62
|
+
block2: { value: [{ type: 'h2' }], plaintext: 'Heading 2' },
|
|
63
|
+
block3: { value: [{ type: 'h3' }], plaintext: 'Heading 3' }, // This should be ignored
|
|
64
|
+
},
|
|
65
|
+
blocks_layout: {
|
|
66
|
+
items: ['block1', 'block2', 'block3'],
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
const tocData = {
|
|
71
|
+
levels: ['h1', 'h2'],
|
|
72
|
+
};
|
|
73
|
+
const newConfig = applyConfig(config);
|
|
74
|
+
const entries = newConfig.blocks.blocksConfig.group.tocEntries(
|
|
75
|
+
block,
|
|
76
|
+
tocData,
|
|
77
|
+
);
|
|
78
|
+
expect(entries).toEqual([
|
|
79
|
+
[1, 'Heading 1', 'block1'],
|
|
80
|
+
[2, 'Heading 2', 'block2'],
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should generate no entries', () => {
|
|
85
|
+
const config = {
|
|
86
|
+
blocks: {
|
|
87
|
+
blocksConfig: {},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
const block = undefined;
|
|
91
|
+
const tocData = {
|
|
92
|
+
levels: undefined,
|
|
93
|
+
};
|
|
94
|
+
const newConfig = applyConfig(config);
|
|
95
|
+
const entries = newConfig.blocks.blocksConfig.group.tocEntries(
|
|
96
|
+
block,
|
|
97
|
+
tocData,
|
|
98
|
+
);
|
|
99
|
+
expect(entries).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
});
|
package/.i18n.babel.config.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
module.exports = require('@plone/volto/babel');
|