@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-eea-website-theme",
3
- "version": "4.3.1",
3
+ "version": "4.3.3",
4
4
  "description": "@eeacms/volto-eea-website-theme: Volto add-on",
5
5
  "main": "src/index.js",
6
6
  "author": "European Environment Agency: IDM2 A-Team",
@@ -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 React from 'react';
7
- import { Dropdown, Image } from 'semantic-ui-react';
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 cx from 'classnames';
25
- import loadable from '@loadable/component';
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
- * EEA Specific Header component.
33
+ * Merge backend navigation settings into the config-level menu layouts.
36
34
  */
37
- const EEAHeader = ({ pathname, token, items, history, subsite }) => {
38
- const router_pathname = useSelector((state) => {
39
- return removeTrailingSlash(state.router?.location?.pathname) || '';
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 { eea } = config.settings;
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
- // Map navigation settings to menu item URLs
81
- if (items) {
82
- items.forEach((menuItem) => {
83
- // Check if we have navigation settings for any route that might match this menu item
84
- Object.keys(navigationSettings).forEach((routeId) => {
85
- const route = navigationSettings[routeId];
86
- const backendSettings = {};
87
-
88
- if (route.hideChildrenFromNavigation !== undefined) {
89
- backendSettings.hideChildrenFromNavigation =
90
- route.hideChildrenFromNavigation;
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
- if (route.menuItemChildrenListColumns !== undefined) {
94
- // Convert strings back to integers for header usage
95
- backendSettings.menuItemChildrenListColumns = Array.isArray(
96
- route.menuItemChildrenListColumns,
97
- )
98
- ? route.menuItemChildrenListColumns
99
- .map((val) =>
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
- if (route.menuItemColumns !== undefined) {
107
- // Use menuItemColumns directly as they're already in semantic UI format
108
- backendSettings.menuItemColumns = route.menuItemColumns;
109
- }
156
+ const baseUrl = useMemo(() => {
157
+ const { settings } = config;
158
+ const navLang = settings.navigationLanguage;
159
+ let url = getBaseUrl(pathname);
110
160
 
111
- if (Object.keys(backendSettings).length > 0) {
112
- // Override the config setting with backend data
113
- enhancedLayouts[routeId] = {
114
- ...enhancedLayouts[routeId],
115
- ...backendSettings,
116
- };
117
- }
118
- });
119
- });
120
- }
161
+ if (isSubsite || !navLang || !navrootLang) {
162
+ return url;
163
+ }
121
164
 
122
- // Memoize navigationBaseUrl so it doesn't change on every pathname change
123
- // when navigationLanguage is set to a fixed language
124
- const navigationBaseUrl = React.useMemo(() => {
125
- const { settings } = config;
126
- return settings.navigationLanguage
127
- ? `/${settings.navigationLanguage}`
128
- : getBaseUrl(pathname);
129
- }, [pathname]);
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
- React.useEffect(() => {
176
+ // Fetch navigation settings on pathname change.
177
+ useEffect(() => {
132
178
  dispatch(getNavigationSettings(pathname));
133
179
  }, [dispatch, pathname]);
134
180
 
135
- // Separate effect for update request to avoid duplicate calls
136
- React.useEffect(() => {
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
- React.useEffect(() => {
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
- // When navigationLanguage is configured, always fetch navigation from that language
150
- // We MUST call getNavigation directly because API expanders fetch navigation for the current page
151
- if (settings.navigationLanguage) {
152
- // Always fetch navigation for the configured language
153
- dispatch(getNavigation(navigationBaseUrl, settings.navDepth));
154
- } else {
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
- // Replace the language prefix with the configured navigation language for menu matching
177
- // e.g., if navLang='en': /fr/topics -> /en/topics
178
- const pathParts = pathname.split('/').filter(Boolean);
179
- if (pathParts.length > 0 && pathParts[0].length === 2) {
180
- // First segment is a language code, replace it with the navigation language
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 },