@eeacms/volto-eea-website-theme 4.3.1 → 4.3.3
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 +8 -0
- package/package.json +1 -1
- package/src/customizations/volto/components/manage/Form/BlocksToolbar.jsx +236 -0
- package/src/customizations/volto/components/manage/Form/BlocksToolbar.jsx.diff +33 -0
- package/src/customizations/volto/components/manage/Form/BlocksToolbar.jsx.md +92 -0
- package/src/customizations/volto/components/manage/Form/blocksClipboardUtils.js +68 -0
- package/src/customizations/volto/components/theme/Header/Header.jsx +157 -120
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ 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
|
+
### [4.3.3](https://github.com/eea/volto-eea-website-theme/compare/4.3.2...4.3.3) - 26 May 2026
|
|
8
|
+
|
|
9
|
+
### [4.3.2](https://github.com/eea/volto-eea-website-theme/compare/4.3.1...4.3.2) - 15 May 2026
|
|
10
|
+
|
|
11
|
+
#### :bug: Bug Fixes
|
|
12
|
+
|
|
13
|
+
- fix(Header): use navroot language to fetch navigation, fix subsite case - refs #303244 [Miu Razvan - [`c25711e`](https://github.com/eea/volto-eea-website-theme/commit/c25711ec3f8492d442c9a130e5e4cb9feb1e80ad)]
|
|
14
|
+
|
|
7
15
|
### [4.3.1](https://github.com/eea/volto-eea-website-theme/compare/4.3.0...4.3.1) - 14 May 2026
|
|
8
16
|
|
|
9
17
|
#### :bug: Bug Fixes
|
package/package.json
CHANGED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { compose } from 'redux';
|
|
3
|
+
import { connect } from 'react-redux';
|
|
4
|
+
import { injectIntl } from 'react-intl';
|
|
5
|
+
import { messages } from '@plone/volto/helpers/MessageLabels/MessageLabels';
|
|
6
|
+
import {
|
|
7
|
+
getBlocksFieldname,
|
|
8
|
+
getBlocksLayoutFieldname,
|
|
9
|
+
} from '@plone/volto/helpers/Blocks/Blocks';
|
|
10
|
+
import Icon from '@plone/volto/components/theme/Icon/Icon';
|
|
11
|
+
import { Plug } from '@plone/volto/components/manage/Pluggable';
|
|
12
|
+
import { v4 as uuid } from 'uuid';
|
|
13
|
+
import isEqual from 'lodash/isEqual';
|
|
14
|
+
import omit from 'lodash/omit';
|
|
15
|
+
import without from 'lodash/without';
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
setBlocksClipboard,
|
|
19
|
+
resetBlocksClipboard,
|
|
20
|
+
} from '@plone/volto/actions/blocksClipboard/blocksClipboard';
|
|
21
|
+
import config from '@plone/volto/registry';
|
|
22
|
+
|
|
23
|
+
import copySVG from '@plone/volto/icons/copy.svg';
|
|
24
|
+
import cutSVG from '@plone/volto/icons/cut.svg';
|
|
25
|
+
import pasteSVG from '@plone/volto/icons/paste.svg';
|
|
26
|
+
import trashSVG from '@plone/volto/icons/delete.svg';
|
|
27
|
+
import {
|
|
28
|
+
cloneBlocks,
|
|
29
|
+
loadBlocksClipboardFromStorage,
|
|
30
|
+
} from './blocksClipboardUtils';
|
|
31
|
+
|
|
32
|
+
export class BlocksToolbarComponent extends React.Component {
|
|
33
|
+
constructor(props) {
|
|
34
|
+
super(props);
|
|
35
|
+
|
|
36
|
+
this.copyBlocksToClipboard = this.copyBlocksToClipboard.bind(this);
|
|
37
|
+
this.cutBlocksToClipboard = this.cutBlocksToClipboard.bind(this);
|
|
38
|
+
this.deleteBlocks = this.deleteBlocks.bind(this);
|
|
39
|
+
this.loadFromStorage = this.loadFromStorage.bind(this);
|
|
40
|
+
this.pasteBlocks = this.pasteBlocks.bind(this);
|
|
41
|
+
this.setBlocksClipboard = this.setBlocksClipboard.bind(this);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
loadFromStorage(event) {
|
|
45
|
+
if (event?.key && !event.key.includes('blocksClipboard')) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const clipboard = loadBlocksClipboardFromStorage();
|
|
50
|
+
const currentClipboard = this.props.blocksClipboard || {};
|
|
51
|
+
const currentClipboardHasBlocks =
|
|
52
|
+
currentClipboard?.cut || currentClipboard?.copy;
|
|
53
|
+
|
|
54
|
+
if (!event && !clipboard && currentClipboardHasBlocks) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!isEqual(clipboard || {}, currentClipboard)) {
|
|
59
|
+
this.props.setBlocksClipboard(clipboard || {});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
componentDidMount() {
|
|
64
|
+
this.loadFromStorage();
|
|
65
|
+
window.addEventListener('storage', this.loadFromStorage, true);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
componentWillUnmount() {
|
|
69
|
+
window.removeEventListener('storage', this.loadFromStorage);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
deleteBlocks() {
|
|
73
|
+
const blockIds = this.props.selectedBlocks;
|
|
74
|
+
|
|
75
|
+
const { formData } = this.props;
|
|
76
|
+
const blocksFieldname = getBlocksFieldname(formData);
|
|
77
|
+
const blocksLayoutFieldname = getBlocksLayoutFieldname(formData);
|
|
78
|
+
|
|
79
|
+
// Might need ReactDOM.unstable_batchedUpdates()
|
|
80
|
+
this.props.onSelectBlock(null);
|
|
81
|
+
const newBlockData = {
|
|
82
|
+
[blocksFieldname]: omit(formData[blocksFieldname], blockIds),
|
|
83
|
+
[blocksLayoutFieldname]: {
|
|
84
|
+
...formData[blocksLayoutFieldname],
|
|
85
|
+
items: without(formData[blocksLayoutFieldname].items, ...blockIds),
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
this.props.onChangeBlocks(newBlockData);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
copyBlocksToClipboard() {
|
|
92
|
+
this.setBlocksClipboard('copy');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
cutBlocksToClipboard() {
|
|
96
|
+
this.setBlocksClipboard('cut');
|
|
97
|
+
this.deleteBlocks();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
setBlocksClipboard(actionType) {
|
|
101
|
+
const { formData } = this.props;
|
|
102
|
+
const blocksFieldname = getBlocksFieldname(formData);
|
|
103
|
+
const blocks = formData[blocksFieldname];
|
|
104
|
+
const blocksData = this.props.selectedBlocks
|
|
105
|
+
.map((blockId) => [blockId, blocks[blockId]])
|
|
106
|
+
.filter(([blockId]) => !!blockId); // Removes null blocks
|
|
107
|
+
this.props.setBlocksClipboard({ [actionType]: blocksData });
|
|
108
|
+
this.props.onSetSelectedBlocks([]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
pasteBlocks(e) {
|
|
112
|
+
const { formData, blocksClipboard = {}, selectedBlock } = this.props;
|
|
113
|
+
const mode = Object.keys(blocksClipboard).includes('cut') ? 'cut' : 'copy';
|
|
114
|
+
const blocksData = blocksClipboard[mode] || [];
|
|
115
|
+
const cloneWithIds = blocksData
|
|
116
|
+
.filter(([blockId, blockData]) => blockId && !!blockData['@type']) // Removes null blocks
|
|
117
|
+
.map(([blockId, blockData]) => {
|
|
118
|
+
const blockConfig = config.blocks.blocksConfig[blockData['@type']];
|
|
119
|
+
return mode === 'copy'
|
|
120
|
+
? blockConfig.cloneData
|
|
121
|
+
? blockConfig.cloneData(blockData)
|
|
122
|
+
: [uuid(), cloneBlocks(blockData)]
|
|
123
|
+
: [blockId, blockData]; // if cut/pasting blocks, we don't clone
|
|
124
|
+
})
|
|
125
|
+
.filter((info) => !!info); // some blocks may refuse to be copied
|
|
126
|
+
const blocksFieldname = getBlocksFieldname(formData);
|
|
127
|
+
const blocksLayoutFieldname = getBlocksLayoutFieldname(formData);
|
|
128
|
+
const selectedIndex =
|
|
129
|
+
formData[blocksLayoutFieldname].items.indexOf(selectedBlock) + 1;
|
|
130
|
+
|
|
131
|
+
const newBlockData = {
|
|
132
|
+
[blocksFieldname]: {
|
|
133
|
+
...formData[blocksFieldname],
|
|
134
|
+
...Object.assign(
|
|
135
|
+
{},
|
|
136
|
+
...cloneWithIds.map(([id, data]) => ({ [id]: data })),
|
|
137
|
+
),
|
|
138
|
+
},
|
|
139
|
+
[blocksLayoutFieldname]: {
|
|
140
|
+
...formData[blocksLayoutFieldname],
|
|
141
|
+
items: [
|
|
142
|
+
...formData[blocksLayoutFieldname].items.slice(0, selectedIndex),
|
|
143
|
+
...cloneWithIds.map(([id]) => id),
|
|
144
|
+
...formData[blocksLayoutFieldname].items.slice(selectedIndex),
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
if (!(e.ctrlKey || e.metaKey)) this.props.resetBlocksClipboard();
|
|
150
|
+
this.props.onChangeBlocks(newBlockData);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
render() {
|
|
154
|
+
const {
|
|
155
|
+
blocksClipboard = {},
|
|
156
|
+
selectedBlock,
|
|
157
|
+
selectedBlocks,
|
|
158
|
+
intl,
|
|
159
|
+
} = this.props;
|
|
160
|
+
return (
|
|
161
|
+
<>
|
|
162
|
+
{selectedBlocks.length > 0 ? (
|
|
163
|
+
<>
|
|
164
|
+
<Plug pluggable="main.toolbar.bottom" id="blocks-delete-btn">
|
|
165
|
+
<button
|
|
166
|
+
aria-label={intl.formatMessage(messages.deleteBlocks)}
|
|
167
|
+
onClick={this.deleteBlocks}
|
|
168
|
+
tabIndex={0}
|
|
169
|
+
className="deleteBlocks"
|
|
170
|
+
id="toolbar-delete-blocks"
|
|
171
|
+
>
|
|
172
|
+
<Icon name={trashSVG} size="30px" />
|
|
173
|
+
</button>
|
|
174
|
+
</Plug>
|
|
175
|
+
<Plug pluggable="main.toolbar.bottom" id="blocks-cut-btn">
|
|
176
|
+
<button
|
|
177
|
+
aria-label={intl.formatMessage(messages.cutBlocks)}
|
|
178
|
+
onClick={this.cutBlocksToClipboard}
|
|
179
|
+
tabIndex={0}
|
|
180
|
+
className="cutBlocks"
|
|
181
|
+
id="toolbar-cut-blocks"
|
|
182
|
+
>
|
|
183
|
+
<Icon name={cutSVG} size="30px" />
|
|
184
|
+
</button>
|
|
185
|
+
</Plug>
|
|
186
|
+
<Plug pluggable="main.toolbar.bottom" id="blocks-copy-btn">
|
|
187
|
+
<button
|
|
188
|
+
aria-label={intl.formatMessage(messages.copyBlocks)}
|
|
189
|
+
onClick={this.copyBlocksToClipboard}
|
|
190
|
+
tabIndex={0}
|
|
191
|
+
className="copyBlocks"
|
|
192
|
+
id="toolbar-copy-blocks"
|
|
193
|
+
>
|
|
194
|
+
<Icon name={copySVG} size="30px" />
|
|
195
|
+
</button>
|
|
196
|
+
</Plug>
|
|
197
|
+
</>
|
|
198
|
+
) : (
|
|
199
|
+
''
|
|
200
|
+
)}
|
|
201
|
+
{selectedBlock && (blocksClipboard?.cut || blocksClipboard?.copy) && (
|
|
202
|
+
<Plug
|
|
203
|
+
pluggable="main.toolbar.bottom"
|
|
204
|
+
id="block-paste-btn"
|
|
205
|
+
dependencies={[selectedBlock]}
|
|
206
|
+
>
|
|
207
|
+
<button
|
|
208
|
+
aria-label={intl.formatMessage(messages.pasteBlocks)}
|
|
209
|
+
onClick={this.pasteBlocks}
|
|
210
|
+
tabIndex={0}
|
|
211
|
+
className="pasteBlocks"
|
|
212
|
+
id="toolbar-paste-blocks"
|
|
213
|
+
>
|
|
214
|
+
<span className="blockCount">
|
|
215
|
+
{(blocksClipboard.cut || blocksClipboard.copy).length}
|
|
216
|
+
</span>
|
|
217
|
+
<Icon name={pasteSVG} size="30px" />
|
|
218
|
+
</button>
|
|
219
|
+
</Plug>
|
|
220
|
+
)}
|
|
221
|
+
</>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export default compose(
|
|
227
|
+
injectIntl,
|
|
228
|
+
connect(
|
|
229
|
+
(state) => {
|
|
230
|
+
return {
|
|
231
|
+
blocksClipboard: state?.blocksClipboard || {},
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
{ setBlocksClipboard, resetBlocksClipboard },
|
|
235
|
+
),
|
|
236
|
+
)(BlocksToolbarComponent);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
13d12
|
|
2
|
+
< import { load } from 'redux-localstorage-simple';
|
|
3
|
+
28c27,30
|
|
4
|
+
< import { cloneBlocks } from '@plone/volto/helpers/Blocks/cloneBlocks';
|
|
5
|
+
---
|
|
6
|
+
> import {
|
|
7
|
+
> cloneBlocks,
|
|
8
|
+
> loadBlocksClipboardFromStorage,
|
|
9
|
+
> } from './blocksClipboardUtils';
|
|
10
|
+
42,44c44,58
|
|
11
|
+
< loadFromStorage() {
|
|
12
|
+
< const clipboard = load({ states: ['blocksClipboard'] })?.blocksClipboard;
|
|
13
|
+
< if (!isEqual(clipboard, this.props.blocksClipboard))
|
|
14
|
+
---
|
|
15
|
+
> loadFromStorage(event) {
|
|
16
|
+
> if (event?.key && !event.key.includes('blocksClipboard')) {
|
|
17
|
+
> return;
|
|
18
|
+
> }
|
|
19
|
+
>
|
|
20
|
+
> const clipboard = loadBlocksClipboardFromStorage();
|
|
21
|
+
> const currentClipboard = this.props.blocksClipboard || {};
|
|
22
|
+
> const currentClipboardHasBlocks =
|
|
23
|
+
> currentClipboard?.cut || currentClipboard?.copy;
|
|
24
|
+
>
|
|
25
|
+
> if (!event && !clipboard && currentClipboardHasBlocks) {
|
|
26
|
+
> return;
|
|
27
|
+
> }
|
|
28
|
+
>
|
|
29
|
+
> if (!isEqual(clipboard || {}, currentClipboard)) {
|
|
30
|
+
45a60
|
|
31
|
+
> }
|
|
32
|
+
48a64
|
|
33
|
+
> this.loadFromStorage();
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# BlocksToolbar.jsx — Customization Explanation
|
|
2
|
+
|
|
3
|
+
**Upstream:** [`@plone/volto` 18.34.0 — `BlocksToolbar.jsx`](https://github.com/plone/volto/blob/18.x.x/packages/volto/src/components/manage/Form/BlocksToolbar.jsx)
|
|
4
|
+
**Local override:** `src/customizations/volto/components/manage/Form/BlocksToolbar.jsx`
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Why this override exists
|
|
9
|
+
|
|
10
|
+
Volto 18 changed how `redux-localstorage-simple` persists the `blocksClipboard` Redux slice. Instead of storing it as a single monolithic `blocksClipboard` key in `localStorage`, Volto 18's `persistentReducers` config now stores it as separate keys: `blocksClipboard.cut` and `blocksClipboard.copy`.
|
|
11
|
+
|
|
12
|
+
The upstream `loadFromStorage()` only queries for `states: ['blocksClipboard']` (the monolithic key), so after the Volto 18 migration, clipboard data stored under the new split keys is never found. On component remount or navigation, the clipboard gets wiped to `{}`, causing the paste button to disappear even though the data is still in localStorage.
|
|
13
|
+
|
|
14
|
+
## What changed
|
|
15
|
+
|
|
16
|
+
### 1. Import changes
|
|
17
|
+
|
|
18
|
+
| Upstream | Override |
|
|
19
|
+
| ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
20
|
+
| `import { load } from 'redux-localstorage-simple'` | _Removed_ |
|
|
21
|
+
| `import { cloneBlocks } from '@plone/volto/helpers/Blocks/cloneBlocks'` | _Removed_ |
|
|
22
|
+
| — | `import { cloneBlocks, loadBlocksClipboardFromStorage } from './blocksClipboardUtils'` |
|
|
23
|
+
|
|
24
|
+
Both `load` and `cloneBlocks` are now provided by the local `blocksClipboardUtils.js` module instead of being imported directly from Volto.
|
|
25
|
+
|
|
26
|
+
### 2. `loadFromStorage(event)` — the core bug fix
|
|
27
|
+
|
|
28
|
+
**Upstream:**
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
loadFromStorage() {
|
|
32
|
+
const clipboard = load({ states: ['blocksClipboard'] })?.blocksClipboard;
|
|
33
|
+
if (!isEqual(clipboard, this.props.blocksClipboard))
|
|
34
|
+
this.props.setBlocksClipboard(clipboard || {});
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Override:**
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
loadFromStorage(event) {
|
|
42
|
+
if (event?.key && !event.key.includes('blocksClipboard')) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const clipboard = loadBlocksClipboardFromStorage();
|
|
47
|
+
const currentClipboard = this.props.blocksClipboard || {};
|
|
48
|
+
const currentClipboardHasBlocks =
|
|
49
|
+
currentClipboard?.cut || currentClipboard?.copy;
|
|
50
|
+
|
|
51
|
+
if (!event && !clipboard && currentClipboardHasBlocks) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!isEqual(clipboard || {}, currentClipboard)) {
|
|
56
|
+
this.props.setBlocksClipboard(clipboard || {});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Three improvements:
|
|
62
|
+
|
|
63
|
+
1. **Event guard** — If called from a `storage` event, returns early when the changed key is unrelated to `blocksClipboard`. Avoids unnecessary work on unrelated localStorage mutations.
|
|
64
|
+
|
|
65
|
+
2. **Volto 18 key resolution** — Delegates to `loadBlocksClipboardFromStorage()`, which tries the split-key format (`blocksClipboard.cut`/`blocksClipboard.copy`) first, then falls back to the monolithic key.
|
|
66
|
+
|
|
67
|
+
3. **Mount guard** — When called on mount (`!event`), if localStorage is empty but Redux still has clipboard data in memory, the old code would wipe Redux to `{}`. The new guard prevents losing an in-memory clipboard on remount.
|
|
68
|
+
|
|
69
|
+
### 3. `componentDidMount()` — eager rehydration
|
|
70
|
+
|
|
71
|
+
**Upstream:**
|
|
72
|
+
|
|
73
|
+
```js
|
|
74
|
+
componentDidMount() {
|
|
75
|
+
window.addEventListener('storage', this.loadFromStorage, true);
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Override:**
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
componentDidMount() {
|
|
83
|
+
this.loadFromStorage();
|
|
84
|
+
window.addEventListener('storage', this.loadFromStorage, true);
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The upstream code only loads clipboard data when a `storage` event fires (typically from another browser tab). It never loads on initial mount, so the paste button is invisible until some other tab triggers a storage event. The override calls `loadFromStorage()` immediately on mount.
|
|
89
|
+
|
|
90
|
+
## What did NOT change
|
|
91
|
+
|
|
92
|
+
All other methods — `deleteBlocks`, `copyBlocksToClipboard`, `cutBlocksToClipboard`, `setBlocksClipboard`, `pasteBlocks`, `render()`, and the `connect`/`compose` wrapper — are **identical** to the Volto 18 upstream.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { v4 as uuid } from 'uuid';
|
|
2
|
+
import { load } from 'redux-localstorage-simple';
|
|
3
|
+
import {
|
|
4
|
+
getBlocksFieldname,
|
|
5
|
+
getBlocksLayoutFieldname,
|
|
6
|
+
hasBlocksData,
|
|
7
|
+
} from '@plone/volto/helpers/Blocks/Blocks';
|
|
8
|
+
import config from '@plone/volto/registry';
|
|
9
|
+
|
|
10
|
+
const fallbackBlocksClipboardStates = [
|
|
11
|
+
'blocksClipboard.cut',
|
|
12
|
+
'blocksClipboard.copy',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export function cloneBlocks(blocksData) {
|
|
16
|
+
if (hasBlocksData(blocksData)) {
|
|
17
|
+
const blocksFieldname = getBlocksFieldname(blocksData);
|
|
18
|
+
const blocksLayoutFieldname = getBlocksLayoutFieldname(blocksData);
|
|
19
|
+
|
|
20
|
+
const cloneWithIds = Object.keys(blocksData.blocks)
|
|
21
|
+
.map((key) => {
|
|
22
|
+
const block = blocksData.blocks[key];
|
|
23
|
+
const blockConfig = config.blocks.blocksConfig[blocksData['@type']];
|
|
24
|
+
return blockConfig?.cloneData
|
|
25
|
+
? blockConfig.cloneData(block)
|
|
26
|
+
: [uuid(), cloneBlocks(block)];
|
|
27
|
+
})
|
|
28
|
+
.filter((info) => !!info); // some blocks may refuse to be copied
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
...blocksData,
|
|
32
|
+
[blocksFieldname]: {
|
|
33
|
+
...Object.assign(
|
|
34
|
+
{},
|
|
35
|
+
...cloneWithIds.map(([id, data]) => ({ [id]: data })),
|
|
36
|
+
),
|
|
37
|
+
},
|
|
38
|
+
[blocksLayoutFieldname]: {
|
|
39
|
+
...blocksData[blocksLayoutFieldname],
|
|
40
|
+
items: [...cloneWithIds.map(([id]) => id)],
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return blocksData;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const getBlocksClipboardStates = () => {
|
|
49
|
+
const persistentReducers = config.settings?.persistentReducers || [];
|
|
50
|
+
const blocksClipboardStates = persistentReducers.filter(
|
|
51
|
+
(state) =>
|
|
52
|
+
state === 'blocksClipboard' || state.startsWith('blocksClipboard.'),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return blocksClipboardStates.length
|
|
56
|
+
? blocksClipboardStates
|
|
57
|
+
: fallbackBlocksClipboardStates;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const loadBlocksClipboardFromStorage = () =>
|
|
61
|
+
load({
|
|
62
|
+
states: getBlocksClipboardStates(),
|
|
63
|
+
disableWarnings: true,
|
|
64
|
+
})?.blocksClipboard ||
|
|
65
|
+
load({
|
|
66
|
+
states: ['blocksClipboard'],
|
|
67
|
+
disableWarnings: true,
|
|
68
|
+
})?.blocksClipboard;
|
|
@@ -3,26 +3,24 @@
|
|
|
3
3
|
* @module components/theme/Header/Header
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import
|
|
7
|
-
import
|
|
6
|
+
import loadable from '@loadable/component';
|
|
7
|
+
import cx from 'classnames';
|
|
8
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
8
9
|
import { connect, useDispatch, useSelector } from 'react-redux';
|
|
9
|
-
|
|
10
10
|
import { withRouter } from 'react-router-dom';
|
|
11
|
+
import { compose } from 'redux';
|
|
12
|
+
import { Dropdown, Image } from 'semantic-ui-react';
|
|
13
|
+
|
|
14
|
+
import { getNavigation } from '@plone/volto/actions/navigation/navigation';
|
|
11
15
|
import UniversalLink from '@plone/volto/components/manage/UniversalLink/UniversalLink';
|
|
12
16
|
import { getBaseUrl } from '@plone/volto/helpers/Url/Url';
|
|
13
17
|
import { hasApiExpander } from '@plone/volto/helpers/Utils/Utils';
|
|
14
|
-
import { getNavigation } from '@plone/volto/actions/navigation/navigation';
|
|
15
|
-
import { getNavigationSettings } from '@eeacms/volto-eea-website-theme/actions';
|
|
16
|
-
import Header from '@eeacms/volto-eea-design-system/ui/Header/Header';
|
|
17
|
-
import EEALogo from '@eeacms/volto-eea-website-theme/components/theme/Logo';
|
|
18
|
-
import { usePrevious } from '@eeacms/volto-eea-design-system/helpers';
|
|
19
|
-
import eeaFlag from '@eeacms/volto-eea-design-system/../theme/themes/eea/assets/images/Header/eea.png';
|
|
20
|
-
|
|
21
18
|
import config from '@plone/volto/registry';
|
|
22
|
-
import { compose } from 'redux';
|
|
23
19
|
|
|
24
|
-
import
|
|
25
|
-
import
|
|
20
|
+
import eeaFlag from '@eeacms/volto-eea-design-system/../theme/themes/eea/assets/images/Header/eea.png';
|
|
21
|
+
import Header from '@eeacms/volto-eea-design-system/ui/Header/Header';
|
|
22
|
+
import { getNavigationSettings } from '@eeacms/volto-eea-website-theme/actions';
|
|
23
|
+
import EEALogo from '@eeacms/volto-eea-website-theme/components/theme/Logo';
|
|
26
24
|
|
|
27
25
|
const LazyLanguageSwitcher = loadable(() => import('./LanguageSwitcher'));
|
|
28
26
|
const EMPTY_NAVIGATION_SETTINGS = {};
|
|
@@ -32,15 +30,79 @@ function removeTrailingSlash(path) {
|
|
|
32
30
|
}
|
|
33
31
|
|
|
34
32
|
/**
|
|
35
|
-
*
|
|
33
|
+
* Merge backend navigation settings into the config-level menu layouts.
|
|
36
34
|
*/
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
35
|
+
function buildEnhancedLayouts(items, navigationSettings) {
|
|
36
|
+
const configLayouts = config.settings?.menuItemsLayouts || {};
|
|
37
|
+
const enhancedLayouts = { ...configLayouts };
|
|
38
|
+
|
|
39
|
+
if (!items) return enhancedLayouts;
|
|
40
|
+
|
|
41
|
+
items.forEach(() => {
|
|
42
|
+
Object.keys(navigationSettings).forEach((routeId) => {
|
|
43
|
+
const route = navigationSettings[routeId];
|
|
44
|
+
const backendSettings = {};
|
|
45
|
+
|
|
46
|
+
if (route.hideChildrenFromNavigation !== undefined) {
|
|
47
|
+
backendSettings.hideChildrenFromNavigation =
|
|
48
|
+
route.hideChildrenFromNavigation;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (route.menuItemChildrenListColumns !== undefined) {
|
|
52
|
+
backendSettings.menuItemChildrenListColumns = Array.isArray(
|
|
53
|
+
route.menuItemChildrenListColumns,
|
|
54
|
+
)
|
|
55
|
+
? route.menuItemChildrenListColumns
|
|
56
|
+
.map((val) => (typeof val === 'string' ? parseInt(val, 10) : val))
|
|
57
|
+
.filter((val) => !isNaN(val))
|
|
58
|
+
: route.menuItemChildrenListColumns;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (route.menuItemColumns !== undefined) {
|
|
62
|
+
backendSettings.menuItemColumns = route.menuItemColumns;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (Object.keys(backendSettings).length > 0) {
|
|
66
|
+
enhancedLayouts[routeId] = {
|
|
67
|
+
...enhancedLayouts[routeId],
|
|
68
|
+
...backendSettings,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
});
|
|
40
72
|
});
|
|
41
73
|
|
|
74
|
+
return enhancedLayouts;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* EEA Specific Header component.
|
|
79
|
+
*/
|
|
80
|
+
const EEAHeader = ({ pathname, token, items, history, navroot, subsite }) => {
|
|
81
|
+
// Config / static derived values
|
|
82
|
+
const { eea } = config.settings;
|
|
83
|
+
const headerOpts = eea.headerOpts || {};
|
|
84
|
+
const { logo, logoWhite } = headerOpts;
|
|
85
|
+
|
|
42
86
|
const isSubsite = subsite?.['@type'] === 'Subsite';
|
|
43
87
|
|
|
88
|
+
// Redux state
|
|
89
|
+
const dispatch = useDispatch();
|
|
90
|
+
const width = useSelector((state) => state.screen?.width);
|
|
91
|
+
|
|
92
|
+
const router_pathname = useSelector(
|
|
93
|
+
(state) => removeTrailingSlash(state.router?.location?.pathname) || '',
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const headerSettings = useSelector(
|
|
97
|
+
(state) => state.reduxAsyncConnect?.headerSettings,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const navigationSettings =
|
|
101
|
+
useSelector((state) => state.navigationSettings?.settings) ||
|
|
102
|
+
EMPTY_NAVIGATION_SETTINGS;
|
|
103
|
+
|
|
104
|
+
const updateRequest = useSelector((state) => state.content.update);
|
|
105
|
+
|
|
44
106
|
const isHomePageInverse = useSelector((state) => {
|
|
45
107
|
const layout = state.content?.data?.layout;
|
|
46
108
|
const has_home_layout =
|
|
@@ -55,85 +117,69 @@ const EEAHeader = ({ pathname, token, items, history, subsite }) => {
|
|
|
55
117
|
);
|
|
56
118
|
});
|
|
57
119
|
|
|
58
|
-
const
|
|
59
|
-
const headerOpts = eea.headerOpts || {};
|
|
60
|
-
const { logo, logoWhite } = headerOpts;
|
|
61
|
-
const width = useSelector((state) => state.screen?.width);
|
|
62
|
-
const dispatch = useDispatch();
|
|
63
|
-
|
|
64
|
-
const headerSettings = useSelector(
|
|
65
|
-
(state) => state.reduxAsyncConnect?.headerSettings,
|
|
66
|
-
);
|
|
120
|
+
const prevTokenRef = useRef(undefined);
|
|
67
121
|
|
|
122
|
+
// Derived / memoized values
|
|
68
123
|
const headerSearchBox =
|
|
69
124
|
headerSettings?.searchBox || eea.headerSearchBox || [];
|
|
70
|
-
const previousToken = usePrevious(token);
|
|
71
|
-
const navigationSettings =
|
|
72
|
-
useSelector((state) => state.navigationSettings?.settings) ||
|
|
73
|
-
EMPTY_NAVIGATION_SETTINGS;
|
|
74
|
-
const updateRequest = useSelector((state) => state.content.update);
|
|
75
|
-
|
|
76
|
-
// Combine navigation settings from backend with config fallback
|
|
77
|
-
const configLayouts = config.settings?.menuItemsLayouts || {};
|
|
78
|
-
const enhancedLayouts = { ...configLayouts };
|
|
79
125
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
126
|
+
const enhancedLayouts = buildEnhancedLayouts(items, navigationSettings);
|
|
127
|
+
|
|
128
|
+
// Prefer navroot.language; fall back to extracting language from pathname
|
|
129
|
+
// (validated against supportedLanguages) when navroot is not yet loaded.
|
|
130
|
+
const navrootLang = useMemo(() => {
|
|
131
|
+
const { supportedLanguages, navigationLanguage } = config.settings;
|
|
132
|
+
if (navroot?.language?.token) return navroot.language.token;
|
|
133
|
+
const supported = supportedLanguages || [];
|
|
134
|
+
const first = pathname.split('/').filter(Boolean)[0];
|
|
135
|
+
if (first === undefined) return navigationLanguage || null;
|
|
136
|
+
return supported.includes(first) ? first : null;
|
|
137
|
+
}, [navroot, pathname]);
|
|
138
|
+
|
|
139
|
+
// Normalize pathname for menu active-item matching when using
|
|
140
|
+
// navigationLanguage. Menu items come from the configured language; rewrite
|
|
141
|
+
// the current language prefix to match. E.g. navLang='en' on /fr/topics ->
|
|
142
|
+
// /en/topics. Uses navroot.language as source of truth instead of parsing
|
|
143
|
+
// the first path segment.
|
|
144
|
+
const normalizedPathname = useMemo(() => {
|
|
145
|
+
const navLang = config.settings.navigationLanguage;
|
|
146
|
+
if (!navLang || !navrootLang || navrootLang === navLang) return pathname;
|
|
92
147
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
typeof val === 'string' ? parseInt(val, 10) : val,
|
|
101
|
-
)
|
|
102
|
-
.filter((val) => !isNaN(val))
|
|
103
|
-
: route.menuItemChildrenListColumns;
|
|
104
|
-
}
|
|
148
|
+
const prefix = `/${navrootLang}`;
|
|
149
|
+
if (pathname === prefix) return `/${navLang}`;
|
|
150
|
+
if (pathname.startsWith(`${prefix}/`)) {
|
|
151
|
+
return `/${navLang}${pathname.slice(prefix.length)}`;
|
|
152
|
+
}
|
|
153
|
+
return pathname;
|
|
154
|
+
}, [pathname, navrootLang]);
|
|
105
155
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
156
|
+
const baseUrl = useMemo(() => {
|
|
157
|
+
const { settings } = config;
|
|
158
|
+
const navLang = settings.navigationLanguage;
|
|
159
|
+
let url = getBaseUrl(pathname);
|
|
110
160
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
...enhancedLayouts[routeId],
|
|
115
|
-
...backendSettings,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
}
|
|
161
|
+
if (isSubsite || !navLang || !navrootLang) {
|
|
162
|
+
return url;
|
|
163
|
+
}
|
|
121
164
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
165
|
+
// When the current navroot's language differs from the configured
|
|
166
|
+
// navigationLanguage, override the base url so navigation is fetched from
|
|
167
|
+
// the configured language root instead of the current navroot.
|
|
168
|
+
if (navLang !== navrootLang) {
|
|
169
|
+
url = `/${settings.navigationLanguage}`;
|
|
170
|
+
} else if (!url && navLang === navrootLang) {
|
|
171
|
+
url = `/${navLang}`;
|
|
172
|
+
}
|
|
173
|
+
return url;
|
|
174
|
+
}, [pathname, navrootLang, isSubsite]);
|
|
130
175
|
|
|
131
|
-
|
|
176
|
+
// Fetch navigation settings on pathname change.
|
|
177
|
+
useEffect(() => {
|
|
132
178
|
dispatch(getNavigationSettings(pathname));
|
|
133
179
|
}, [dispatch, pathname]);
|
|
134
180
|
|
|
135
|
-
//
|
|
136
|
-
|
|
181
|
+
// Re-fetch navigation settings after a content update for the current page.
|
|
182
|
+
useEffect(() => {
|
|
137
183
|
if (
|
|
138
184
|
updateRequest?.loaded &&
|
|
139
185
|
removeTrailingSlash(updateRequest?.content?.['@id'] || '') ===
|
|
@@ -143,46 +189,36 @@ const EEAHeader = ({ pathname, token, items, history, subsite }) => {
|
|
|
143
189
|
}
|
|
144
190
|
}, [updateRequest, dispatch, pathname]);
|
|
145
191
|
|
|
146
|
-
|
|
192
|
+
// Fetch the main navigation tree.
|
|
193
|
+
// Cases that force a fetch:
|
|
194
|
+
// 1. Language mismatch — the current navroot's language differs from the
|
|
195
|
+
// configured navigationLanguage, so the API expander's navigation (if
|
|
196
|
+
// any) is in the wrong language and must be replaced.
|
|
197
|
+
// 2. Token change — auth state affects which nav items are visible, so
|
|
198
|
+
// expander data loaded under the previous token may be stale.
|
|
199
|
+
// 3. No expander available — backend did not pre-supply navigation for
|
|
200
|
+
// this base url, so we fetch it explicitly.
|
|
201
|
+
// Otherwise the expander already supplied correct navigation; no fetch.
|
|
202
|
+
useEffect(() => {
|
|
147
203
|
const { settings } = config;
|
|
204
|
+
const navLang = settings.navigationLanguage;
|
|
205
|
+
const langMismatch = navLang && navrootLang && navrootLang !== navLang;
|
|
206
|
+
const tokenChanged = prevTokenRef.current !== token;
|
|
148
207
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
// When navigationLanguage is not configured, fetch navigation for current page language
|
|
156
|
-
// Check if navigation data needs to be fetched based on the API expander availability
|
|
157
|
-
if (!hasApiExpander('navigation', navigationBaseUrl)) {
|
|
158
|
-
dispatch(getNavigation(navigationBaseUrl, settings.navDepth));
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Additional check for token changes
|
|
162
|
-
if (token !== previousToken) {
|
|
163
|
-
dispatch(getNavigation(navigationBaseUrl, settings.navDepth));
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}, [navigationBaseUrl, token, dispatch, previousToken]);
|
|
167
|
-
|
|
168
|
-
// Normalize pathname for menu matching when using navigationLanguage
|
|
169
|
-
// This ensures menu items from the configured language match correctly even when on other language pages
|
|
170
|
-
const normalizedPathname = React.useMemo(() => {
|
|
171
|
-
const navLang = config.settings.navigationLanguage;
|
|
172
|
-
if (!navLang) {
|
|
173
|
-
return pathname;
|
|
208
|
+
if (
|
|
209
|
+
langMismatch ||
|
|
210
|
+
tokenChanged ||
|
|
211
|
+
!hasApiExpander('navigation', baseUrl)
|
|
212
|
+
) {
|
|
213
|
+
dispatch(getNavigation(baseUrl, settings.navDepth));
|
|
174
214
|
}
|
|
215
|
+
}, [dispatch, baseUrl, navrootLang, token]);
|
|
175
216
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const rest = pathParts.slice(1).join('/');
|
|
182
|
-
return rest ? `/${navLang}/${rest}` : `/${navLang}`;
|
|
183
|
-
}
|
|
184
|
-
return pathname;
|
|
185
|
-
}, [pathname]);
|
|
217
|
+
// Track the previous token value. Runs after the fetch effect so the
|
|
218
|
+
// comparison above sees the value from the prior render.
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
prevTokenRef.current = token;
|
|
221
|
+
}, [token]);
|
|
186
222
|
|
|
187
223
|
return (
|
|
188
224
|
<Header menuItems={items}>
|
|
@@ -327,6 +363,7 @@ export default compose(
|
|
|
327
363
|
(state) => ({
|
|
328
364
|
token: state.userSession.token,
|
|
329
365
|
items: state.navigation.items,
|
|
366
|
+
navroot: state.content.data?.['@components']?.navroot?.navroot,
|
|
330
367
|
subsite: state.content.data?.['@components']?.subsite,
|
|
331
368
|
}),
|
|
332
369
|
{ getNavigation },
|