@eeacms/volto-cca-policy 1.0.15 → 1.0.17

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,22 @@ 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
+ ### [1.0.17](https://github.com/eea/volto-cca-policy/compare/1.0.16...1.0.17) - 8 September 2026
8
+
9
+ #### :rocket: New Features
10
+
11
+ - feat(navigator): add back to search link on tool view; code refactoring & cleanup - refs #304608 [kreafox - [`b80edc4`](https://github.com/eea/volto-cca-policy/commit/b80edc476f073a4023a06759765cb1ed1da874eb)]
12
+ - feat: add MetadataTags component to display tool output types - refs #304612 [kreafox - [`da36a73`](https://github.com/eea/volto-cca-policy/commit/da36a7340da0d36a807b619e1da57d246b11b07d)]
13
+
14
+ #### :nail_care: Enhancements
15
+
16
+ - change: remove sectors from CompareToolsView - refs #304612 [kreafox - [`9b9352f`](https://github.com/eea/volto-cca-policy/commit/9b9352fa116a951ceef64511bc7c75779a7f0ec4)]
17
+
18
+ #### :hammer_and_wrench: Others
19
+
20
+ - test: remove sectors from CompareToolsView accessibility tests [kreafox - [`b3df4e2`](https://github.com/eea/volto-cca-policy/commit/b3df4e21aa79c6d9c726ad498fee2ce9af7f17a0)]
21
+ ### [1.0.16](https://github.com/eea/volto-cca-policy/compare/1.0.15...1.0.16) - 4 September 2026
22
+
7
23
  ### [1.0.15](https://github.com/eea/volto-cca-policy/compare/1.0.14...1.0.15) - 4 September 2026
8
24
 
9
25
  #### :rocket: New Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-cca-policy",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "@eeacms/volto-cca-policy: Volto add-on",
5
5
  "main": "src/index.js",
6
6
  "author": "European Environment Agency: IDM2 A-Team",
@@ -158,7 +158,7 @@ const NavigatorCatalogueContentView = (props) => {
158
158
  {children.length === 0 && !isLoading && wasSearched && <NoResults />}
159
159
 
160
160
  <ResultViewComponent>{children}</ResultViewComponent>
161
- <CompareToolsPanel appConfig={appConfig} />
161
+ <CompareToolsPanel />
162
162
 
163
163
  {children.length > 0 && activeNavigatorViewId !== 'map' && (
164
164
  <div className="search-body-footer">
@@ -97,19 +97,16 @@ export const downloadCsv = (filename, rows) => {
97
97
  URL.revokeObjectURL(url);
98
98
  };
99
99
 
100
- export const getLocalizedLandingPageURL = (appConfig, currentLang = 'en') => {
100
+ export const getNavigatorCataloguePageURL = (currentLang = 'en') => {
101
101
  const lang = currentLang || 'en';
102
- const landingPageURL =
103
- appConfig?.landingPageURL || '/en/navigator/tool-catalogue';
104
102
 
105
- return landingPageURL.replace(/^\/en(?=\/|$)/, `/${lang}`);
103
+ return `/${lang}/navigator/tool-catalogue`;
106
104
  };
107
105
 
108
- export const getComparePageURL = (appConfig, currentLang = 'en') => {
106
+ export const getComparePageURL = (currentLang = 'en') => {
109
107
  const lang = currentLang || 'en';
110
- const comparePageURL = appConfig?.comparePageURL || '/en/navigator/compare';
111
108
 
112
- return comparePageURL.replace(/^\/en(?=\/|$)/, `/${lang}`);
109
+ return `/${lang}/navigator/compare`;
113
110
  };
114
111
 
115
112
  export const exportComparisonTable = (tools, getToolField) => {
@@ -6,7 +6,7 @@ import {
6
6
  exportComparisonTable,
7
7
  formatFunctionalityScore,
8
8
  getComparePageURL,
9
- getLocalizedLandingPageURL,
9
+ getNavigatorCataloguePageURL,
10
10
  rawValueAsArray,
11
11
  } from './utils';
12
12
 
@@ -86,27 +86,16 @@ describe('Navigator Catalogue utilities', () => {
86
86
  expect(revokeObjectURL).toHaveBeenCalledWith('blob:test');
87
87
  });
88
88
 
89
- it('localizes catalogue and comparison URLs', () => {
90
- expect(getLocalizedLandingPageURL(undefined)).toBe(
91
- '/en/navigator/tool-catalogue',
92
- );
93
- expect(
94
- getLocalizedLandingPageURL({ landingPageURL: '/en/tools' }, 'fr'),
95
- ).toBe('/fr/tools');
96
- expect(getLocalizedLandingPageURL({ landingPageURL: '/tools' }, '')).toBe(
97
- '/tools',
98
- );
99
- expect(
100
- getComparePageURL({ comparePageURL: '/en/tools/compare' }, 'de'),
101
- ).toBe('/de/tools/compare');
102
- expect(
103
- getComparePageURL(
104
- { landingPageURL: '/en/navigator/tool-catalogue' },
105
- 'de',
106
- ),
107
- ).toBe('/de/navigator/compare');
108
- expect(getComparePageURL()).toBe('/en/navigator/compare');
109
- });
89
+ it.each([undefined, '', 'en', 'fr', 'de'])(
90
+ 'builds catalogue and comparison URLs for locale %s',
91
+ (locale) => {
92
+ const lang = locale || 'en';
93
+ expect(getNavigatorCataloguePageURL(locale)).toBe(
94
+ `/${lang}/navigator/tool-catalogue`,
95
+ );
96
+ expect(getComparePageURL(locale)).toBe(`/${lang}/navigator/compare`);
97
+ },
98
+ );
110
99
 
111
100
  it('exports every comparison criterion', () => {
112
101
  const tools = [
@@ -10,6 +10,7 @@ import guideSteps from '../../../search/navigator_guide/guideSteps';
10
10
  import { navigatorGuideStepAtom } from '../../../state';
11
11
  import { mergeGuideOptions } from './utils';
12
12
  import useGuideFacetOptions from './useGuideFacetOptions';
13
+ import { getNavigatorCataloguePageURL } from '../../Search/NavigatorCatalogue/utils';
13
14
 
14
15
  const messages = defineMessages({
15
16
  noSteps: {
@@ -177,9 +178,7 @@ const NavigatorGuideContentView = ({ appConfig }) => {
177
178
  allowedFields.has(field),
178
179
  );
179
180
  const query = new URLManager().stateToUrl({ filters: resultFilters });
180
- const pathname = (
181
- appConfig.resultsPageURL || '/en/navigator/tool-catalogue'
182
- ).replace(/^\/en(?=\/|$)/, `/${currentLang}`);
181
+ const pathname = getNavigatorCataloguePageURL(currentLang);
183
182
 
184
183
  history.push({ pathname, search: query ? `?${query}` : '' });
185
184
  };
@@ -1,4 +1,6 @@
1
+ import React from 'react';
1
2
  import { ChatWindow } from '@eeacms/volto-eea-chatbot/ChatBlock/chat';
3
+ import { CompareToolsPanel } from '../../../theme/CompareTools/CompareToolsPanel';
2
4
 
3
5
  import { CcaDocCard } from './DocumentCard';
4
6
  import { remarkCcaDocCards } from './docCards';
@@ -31,17 +33,24 @@ const extraRemarkPlugins = [remarkCcaDocCards, remarkCcaNextSteps];
31
33
  * turned into inline Navigator-style catalogue cards (`CcaDocCard`),
32
34
  * and "Suggested next steps" sections are turned into styled callout boxes.
33
35
  *
36
+ * It also renders `<CompareToolsPanel />` so that when users activate the
37
+ * "Compare" checkbox on any document card, the floating comparison panel
38
+ * appears and allows navigating to the side-by-side comparison view.
39
+ *
34
40
  * `ChatBlockView` renders this as `<Presentation persona {...blockData} />`,
35
41
  * so all block fields arrive as top-level props and are forwarded unchanged.
36
42
  */
37
43
  function CatalogueChatView(props) {
38
44
  return (
39
- <ChatWindow
40
- {...props}
41
- hideSourcesTab
42
- extraRemarkPlugins={extraRemarkPlugins}
43
- extraMarkdownComponents={extraMarkdownComponents}
44
- />
45
+ <>
46
+ <ChatWindow
47
+ {...props}
48
+ hideSourcesTab
49
+ extraRemarkPlugins={extraRemarkPlugins}
50
+ extraMarkdownComponents={extraMarkdownComponents}
51
+ />
52
+ <CompareToolsPanel />
53
+ </>
45
54
  );
46
55
  }
47
56
 
@@ -17,6 +17,10 @@ jest.mock('@eeacms/volto-eea-chatbot/ChatBlock/chat', () => ({
17
17
  )),
18
18
  }));
19
19
 
20
+ jest.mock('../../../theme/CompareTools/CompareToolsPanel', () => ({
21
+ CompareToolsPanel: jest.fn(() => <div data-testid="compare-tools-panel" />),
22
+ }));
23
+
20
24
  describe('CatalogueChatView', () => {
21
25
  beforeEach(() => {
22
26
  ChatWindow.mockClear();
@@ -70,4 +74,9 @@ describe('CatalogueChatView', () => {
70
74
  expect(secondMd).toBe(firstMd);
71
75
  expect(secondPlugins).toBe(firstPlugins);
72
76
  });
77
+
78
+ it('renders the CompareToolsPanel alongside the chat window', () => {
79
+ const { getByTestId } = render(<CatalogueChatView persona={{}} />);
80
+ expect(getByTestId('compare-tools-panel')).toBeInTheDocument();
81
+ });
73
82
  });
@@ -1,8 +1,18 @@
1
1
  import CatalogueChatView from './CatalogueChatView';
2
2
 
3
+ const ALLOWED_ROLES = ['Manager', 'Site Administrator', 'Editor'];
4
+
5
+ export const isChatbotRestricted = ({ user }) => {
6
+ if (user?.roles) {
7
+ return !user.roles.some((role) => ALLOWED_ROLES.includes(role));
8
+ }
9
+ return false;
10
+ };
11
+
3
12
  /**
4
13
  * Registers the CCA "catalogue" presentation variation on the AI Chatbot
5
- * block (from @eeacms/volto-eea-chatbot).
14
+ * block (from @eeacms/volto-eea-chatbot) and loosens the role restriction
15
+ * to allow Managers, Site Administrators, and Editors.
6
16
  *
7
17
  * Pattern: cross-addon variation push, same as ./TabsBlock pushing the
8
18
  * "spotlight" variation onto the tabs block.
@@ -19,6 +29,13 @@ export default function applyConfig(config) {
19
29
  view: CatalogueChatView,
20
30
  });
21
31
  }
32
+ block.restricted = isChatbotRestricted;
22
33
  }
34
+
35
+ const danswer = config.blocks.blocksConfig.danswerChat;
36
+ if (danswer) {
37
+ danswer.restricted = isChatbotRestricted;
38
+ }
39
+
23
40
  return config;
24
41
  }
@@ -41,4 +41,33 @@ describe('ChatbotCatalogue applyConfig', () => {
41
41
  const config = { blocks: { blocksConfig: {} } };
42
42
  expect(applyConfig(config)).toEqual(config);
43
43
  });
44
+
45
+ it('loosens the restricted check to allow Manager, Site Administrator, and Editor', () => {
46
+ const config = applyConfig(makeConfig());
47
+ const isRestricted = config.blocks.blocksConfig.eeaChatbot.restricted;
48
+
49
+ expect(isRestricted({ user: { roles: ['Manager'] } })).toBe(false);
50
+ expect(isRestricted({ user: { roles: ['Site Administrator'] } })).toBe(
51
+ false,
52
+ );
53
+ expect(isRestricted({ user: { roles: ['Editor'] } })).toBe(false);
54
+ expect(isRestricted({ user: { roles: ['Contributor'] } })).toBe(true);
55
+ expect(isRestricted({ user: { roles: ['Reader'] } })).toBe(true);
56
+ expect(isRestricted({ user: null })).toBe(false);
57
+ });
58
+
59
+ it('also updates danswerChat restricted if present', () => {
60
+ const baseConfig = {
61
+ blocks: {
62
+ blocksConfig: {
63
+ eeaChatbot: {},
64
+ danswerChat: {},
65
+ },
66
+ },
67
+ };
68
+ const config = applyConfig(baseConfig);
69
+ const isRestricted = config.blocks.blocksConfig.danswerChat.restricted;
70
+ expect(isRestricted({ user: { roles: ['Editor'] } })).toBe(false);
71
+ expect(isRestricted({ user: { roles: ['Contributor'] } })).toBe(true);
72
+ });
44
73
  });
@@ -8,6 +8,7 @@ import {
8
8
  MAX_COMPARE_TOOLS,
9
9
  compareToolsAtom,
10
10
  getCompareLocation,
11
+ useHasMounted,
11
12
  } from './utils';
12
13
 
13
14
  const messages = defineMessages({
@@ -41,19 +42,21 @@ const messages = defineMessages({
41
42
  },
42
43
  });
43
44
 
44
- export const CompareToolsPanel = ({ appConfig: suppliedAppConfig }) => {
45
+ export const CompareToolsPanel = () => {
45
46
  const intl = useIntl();
46
47
  const history = useHistory();
47
48
  const currentLang = useSelector((state) => state.intl.locale);
48
- const appConfig = suppliedAppConfig || {
49
- landingPageURL: '/en/navigator/tool-catalogue',
50
- };
51
49
  const [selectedTools, setSelectedTools] = useAtom(compareToolsAtom);
52
- const resolvableSelectedTools = selectedTools.filter((tool) => tool.uid);
50
+ const hasMounted = useHasMounted();
51
+
52
+ const effectiveSelectedTools = hasMounted ? selectedTools : [];
53
+ const resolvableSelectedTools = effectiveSelectedTools.filter(
54
+ (tool) => tool.uid,
55
+ );
53
56
  const isReadyToCompare = resolvableSelectedTools.length >= 2;
54
- const emptySlots = MAX_COMPARE_TOOLS - selectedTools.length;
57
+ const emptySlots = MAX_COMPARE_TOOLS - effectiveSelectedTools.length;
55
58
 
56
- if (selectedTools.length === 0) return null;
59
+ if (!hasMounted || effectiveSelectedTools.length === 0) return null;
57
60
 
58
61
  const removeTool = (toolUid) => {
59
62
  setSelectedTools((tools) => tools.filter((tool) => tool.uid !== toolUid));
@@ -67,7 +70,7 @@ export const CompareToolsPanel = ({ appConfig: suppliedAppConfig }) => {
67
70
  ].join('');
68
71
 
69
72
  history.push(
70
- getCompareLocation(selectedTools, appConfig, returnURL, currentLang),
73
+ getCompareLocation(effectiveSelectedTools, returnURL, currentLang),
71
74
  );
72
75
  };
73
76
 
@@ -77,14 +80,14 @@ export const CompareToolsPanel = ({ appConfig: suppliedAppConfig }) => {
77
80
  <h2>
78
81
  {intl.formatMessage(messages.compareTools)}
79
82
  <span className="compare-panel-count">
80
- {selectedTools.length}/{MAX_COMPARE_TOOLS}
83
+ {effectiveSelectedTools.length}/{MAX_COMPARE_TOOLS}
81
84
  </span>
82
85
  </h2>
83
86
  </div>
84
87
 
85
88
  <div className="compare-panel-content">
86
89
  <div className="compare-panel-tools">
87
- {selectedTools.map((tool) => (
90
+ {effectiveSelectedTools.map((tool) => (
88
91
  <div key={tool.uid} className="compare-panel-tool">
89
92
  <span className="navigator-tool-icon small" aria-hidden="true">
90
93
  <Icon className="ri-file-line" />
@@ -3,12 +3,13 @@ import '@testing-library/jest-dom';
3
3
  import { fireEvent, render, screen } from '@testing-library/react';
4
4
  import { IntlProvider } from 'react-intl';
5
5
  import { Provider as JotaiProvider } from 'jotai';
6
+ import { useSelector } from 'react-redux';
6
7
  import { useHistory } from 'react-router-dom';
7
8
  import { CompareToolsPanel } from './CompareToolsPanel';
8
9
 
9
10
  jest.mock('react-redux', () => ({
10
11
  ...jest.requireActual('react-redux'),
11
- useSelector: (selector) => selector({ intl: { locale: 'en' } }),
12
+ useSelector: jest.fn(),
12
13
  }));
13
14
 
14
15
  jest.mock('react-router-dom', () => ({
@@ -16,7 +17,7 @@ jest.mock('react-router-dom', () => ({
16
17
  useHistory: jest.fn(),
17
18
  }));
18
19
 
19
- const renderPanel = (selectedTools, appConfig) => {
20
+ const renderPanel = (selectedTools) => {
20
21
  window.localStorage.setItem(
21
22
  'cca-compare-tools',
22
23
  JSON.stringify(selectedTools),
@@ -25,7 +26,7 @@ const renderPanel = (selectedTools, appConfig) => {
25
26
  return render(
26
27
  <IntlProvider locale="en">
27
28
  <JotaiProvider>
28
- <CompareToolsPanel appConfig={appConfig} />
29
+ <CompareToolsPanel />
29
30
  </JotaiProvider>
30
31
  </IntlProvider>,
31
32
  );
@@ -38,6 +39,8 @@ describe('CompareToolsPanel', () => {
38
39
  jest.clearAllMocks();
39
40
  window.localStorage.clear();
40
41
  useHistory.mockReturnValue({ push });
42
+ useSelector.mockReturnValue('en');
43
+ window.history.replaceState({}, '', '/');
41
44
  });
42
45
 
43
46
  it('does not render without selected tools', () => {
@@ -61,14 +64,17 @@ describe('CompareToolsPanel', () => {
61
64
  expect(screen.queryByText('Compare tools')).not.toBeInTheDocument();
62
65
  });
63
66
 
64
- it('shows the ready state and opens the comparison page', () => {
65
- renderPanel(
66
- [
67
- { uid: 'one', title: 'Tool one' },
68
- { uid: 'two', title: 'Tool two' },
69
- ],
70
- { landingPageURL: '/en/navigator/tool-catalogue' },
67
+ it.each(['en', 'fr'])('opens the comparison page in %s', (locale) => {
68
+ useSelector.mockReturnValue(locale);
69
+ window.history.replaceState(
70
+ {},
71
+ '',
72
+ `/${locale}/navigator/tool-catalogue?q=water#results`,
71
73
  );
74
+ renderPanel([
75
+ { uid: 'one', title: 'Tool one' },
76
+ { uid: 'two', title: 'Tool two' },
77
+ ]);
72
78
 
73
79
  expect(screen.getByText('Ready to compare')).toHaveClass(
74
80
  'compare-panel-status',
@@ -78,9 +84,11 @@ describe('CompareToolsPanel', () => {
78
84
  fireEvent.click(screen.getByText('Compare selected tools'));
79
85
 
80
86
  expect(push).toHaveBeenCalledWith({
81
- pathname: '/en/navigator/compare',
87
+ pathname: `/${locale}/navigator/compare`,
82
88
  search: '?uid=one&uid=two',
83
- state: { returnURL: '/' },
89
+ state: {
90
+ returnURL: `/${locale}/navigator/tool-catalogue?q=water#results`,
91
+ },
84
92
  });
85
93
  });
86
94
 
@@ -28,7 +28,7 @@ import {
28
28
  asArray,
29
29
  exportComparisonTable,
30
30
  formatFunctionalityScore,
31
- getLocalizedLandingPageURL,
31
+ getNavigatorCataloguePageURL,
32
32
  } from '../../Search/NavigatorCatalogue/utils';
33
33
 
34
34
  const messages = defineMessages({
@@ -99,10 +99,6 @@ const messages = defineMessages({
99
99
  id: 'Adaptation support cycle step',
100
100
  defaultMessage: 'Adaptation support cycle step',
101
101
  },
102
- sector: {
103
- id: 'Sector',
104
- defaultMessage: 'Sector',
105
- },
106
102
  });
107
103
 
108
104
  const getToolField = (tool, field) =>
@@ -111,6 +107,25 @@ const getToolField = (tool, field) =>
111
107
  const getToolFieldDisplay = (tool, field) =>
112
108
  asArray(getToolField(tool, field)).join(', ') || '—';
113
109
 
110
+ const MetadataTags = ({ value }) => {
111
+ const intl = useIntl();
112
+ const items = asArray(value)
113
+ .map((item) => item?.title || item?.token || item)
114
+ .filter(Boolean);
115
+
116
+ return items.length ? (
117
+ <div className="metadata-tags">
118
+ {items.map((item, index) => (
119
+ <span className="metadata-tag" key={`${item}-${index}`}>
120
+ {intl.formatMessage({ id: item, defaultMessage: item })}
121
+ </span>
122
+ ))}
123
+ </div>
124
+ ) : (
125
+ '—'
126
+ );
127
+ };
128
+
114
129
  const FieldValueList = ({ value, label }) => {
115
130
  const values = asArray(value);
116
131
 
@@ -205,8 +220,7 @@ const CompareToolsView = () => {
205
220
  [location.search],
206
221
  );
207
222
  const registry = config.settings.searchlib;
208
- const appConfig = registry.searchui.navigatorCatalogueSearch;
209
- const landingPageURL = getLocalizedLandingPageURL(appConfig, currentLang);
223
+ const landingPageURL = getNavigatorCataloguePageURL(currentLang);
210
224
  const compareToolsTitle = intl.formatMessage(messages.compareTools);
211
225
  const returnURL =
212
226
  location.state?.returnURL ||
@@ -462,7 +476,9 @@ const CompareToolsView = () => {
462
476
  </Table.Cell>
463
477
  {visibleTools.map((tool) => (
464
478
  <Table.Cell key={`output-type-${tool.id}`}>
465
- {getToolFieldDisplay(tool, 'cca_type_of_outputs')}
479
+ <MetadataTags
480
+ value={getToolField(tool, 'cca_type_of_outputs')}
481
+ />
466
482
  </Table.Cell>
467
483
  ))}
468
484
  </Table.Row>
@@ -488,23 +504,6 @@ const CompareToolsView = () => {
488
504
  </Table.Cell>
489
505
  ))}
490
506
  </Table.Row>
491
- <Table.Row>
492
- <Table.Cell as="th" scope="row">
493
- <div className="compare-criteria">
494
- <div className="compare-criteria-title">
495
- {intl.formatMessage(messages.sector)}
496
- </div>
497
- </div>
498
- </Table.Cell>
499
- {visibleTools.map((tool) => (
500
- <Table.Cell key={`sector-${tool.id}`}>
501
- <FieldValueList
502
- label={intl.formatMessage(messages.sector)}
503
- value={getToolField(tool, 'cca_adaptation_sectors')}
504
- />
505
- </Table.Cell>
506
- ))}
507
- </Table.Row>
508
507
  </Table.Body>
509
508
  </Table>
510
509
  )}
@@ -41,9 +41,7 @@ jest.mock('@plone/volto/registry', () => ({
41
41
  settings: {
42
42
  searchlib: {
43
43
  searchui: {
44
- navigatorCatalogueSearch: {
45
- landingPageURL: '/en/navigator/tool-catalogue',
46
- },
44
+ navigatorCatalogueSearch: {},
47
45
  },
48
46
  },
49
47
  },
@@ -80,9 +78,6 @@ describe('CompareToolsView accessibility', () => {
80
78
  cca_adaptation_support_cycle_step: {
81
79
  raw: [{ title: 'Assessing risks' }, { title: 'Monitoring' }],
82
80
  },
83
- cca_adaptation_sectors: {
84
- raw: [{ title: 'Agriculture' }, { title: 'Water management' }],
85
- },
86
81
  },
87
82
  {
88
83
  cca_uid: { raw: 'two' },
@@ -125,9 +120,5 @@ describe('CompareToolsView accessibility', () => {
125
120
  expect(adaptationSteps).toHaveTextContent('Assessing risks');
126
121
  expect(adaptationSteps).toHaveTextContent('Monitoring');
127
122
  expect(adaptationSteps.children).toHaveLength(2);
128
- const sectors = screen.getByRole('list', { name: 'Sector' });
129
- expect(sectors).toHaveTextContent('Agriculture');
130
- expect(sectors).toHaveTextContent('Water management');
131
- expect(sectors.children).toHaveLength(2);
132
123
  });
133
124
  });
@@ -1,3 +1,4 @@
1
+ import { useEffect, useState } from 'react';
1
2
  import { useAtom } from 'jotai';
2
3
  import { atomWithStorage } from 'jotai/utils';
3
4
  import { applyConfigurationSchema, rebind } from '@eeacms/search';
@@ -9,6 +10,16 @@ export const MAX_COMPARE_TOOLS = 4;
9
10
  export const compareToolsAtom = atomWithStorage('cca-compare-tools', []);
10
11
  const searchAppName = 'navigatorCatalogueSearch';
11
12
 
13
+ export const useHasMounted = () => {
14
+ const [hasMounted, setHasMounted] = useState(false);
15
+
16
+ useEffect(() => {
17
+ setHasMounted(true);
18
+ }, []);
19
+
20
+ return hasMounted;
21
+ };
22
+
12
23
  const getRawValue = (value) =>
13
24
  value?.raw !== undefined ? value.raw : value || '';
14
25
 
@@ -27,11 +38,14 @@ export const getPathname = (url) => {
27
38
 
28
39
  export const useCompareTools = (compareTool) => {
29
40
  const [selectedTools, setSelectedTools] = useAtom(compareToolsAtom);
30
- const isSelected = selectedTools.some(
41
+ const hasMounted = useHasMounted();
42
+
43
+ const effectiveSelectedTools = hasMounted ? selectedTools : [];
44
+ const isSelected = effectiveSelectedTools.some(
31
45
  (tool) => tool.uid === compareTool?.uid,
32
46
  );
33
47
  const isLimitReached =
34
- selectedTools.length >= MAX_COMPARE_TOOLS && !isSelected;
48
+ effectiveSelectedTools.length >= MAX_COMPARE_TOOLS && !isSelected;
35
49
 
36
50
  const setSelected = (selected) => {
37
51
  if (!compareTool?.uid) return;
@@ -57,15 +71,11 @@ export const useCompareTools = (compareTool) => {
57
71
  isLimitReached,
58
72
  setSelected,
59
73
  toggle: () => setSelected(!isSelected),
74
+ hasMounted,
60
75
  };
61
76
  };
62
77
 
63
- export const getCompareLocation = (
64
- tools,
65
- appConfig,
66
- returnURL,
67
- currentLang = 'en',
68
- ) => {
78
+ export const getCompareLocation = (tools, returnURL, currentLang = 'en') => {
69
79
  const params = new URLSearchParams();
70
80
 
71
81
  tools.slice(0, MAX_COMPARE_TOOLS).forEach((tool) => {
@@ -75,7 +85,7 @@ export const getCompareLocation = (
75
85
  });
76
86
 
77
87
  const query = params.toString();
78
- const path = getComparePageURL(appConfig, currentLang);
88
+ const path = getComparePageURL(currentLang);
79
89
 
80
90
  return {
81
91
  pathname: path,
@@ -12,6 +12,7 @@ import {
12
12
  getCompareToolUid,
13
13
  getPathname,
14
14
  useCompareTools,
15
+ useHasMounted,
15
16
  } from './utils';
16
17
 
17
18
  jest.mock('@eeacms/search', () => ({
@@ -26,11 +27,12 @@ jest.mock('@plone/volto/helpers/Url/Url', () => ({
26
27
  }));
27
28
 
28
29
  const CompareHarness = ({ tool }) => {
29
- const { isLimitReached, isSelected, setSelected, toggle } =
30
+ const { hasMounted, isLimitReached, isSelected, setSelected, toggle } =
30
31
  useCompareTools(tool);
31
32
 
32
33
  return (
33
34
  <>
35
+ <span data-testid="mounted">{String(hasMounted)}</span>
34
36
  <span data-testid="selected">{String(isSelected)}</span>
35
37
  <span data-testid="limit">{String(isLimitReached)}</span>
36
38
  <button type="button" onClick={toggle}>
@@ -95,19 +97,14 @@ describe('Compare Tools utilities', () => {
95
97
  ];
96
98
 
97
99
  expect(
98
- getCompareLocation(
99
- tools,
100
- { landingPageURL: '/en/navigator/tool-catalogue' },
101
- '/en/navigator/tool-catalogue?q=test',
102
- 'ro',
103
- ),
100
+ getCompareLocation(tools, '/en/navigator/tool-catalogue?q=test', 'ro'),
104
101
  ).toEqual({
105
102
  pathname: '/ro/navigator/compare',
106
103
  search: '?uid=one&uid=two&uid=three',
107
104
  state: { returnURL: '/en/navigator/tool-catalogue?q=test' },
108
105
  });
109
106
  expect(MAX_COMPARE_TOOLS).toBe(4);
110
- expect(getCompareLocation([], {}, undefined)).toEqual({
107
+ expect(getCompareLocation([])).toEqual({
111
108
  pathname: '/en/navigator/compare',
112
109
  search: '',
113
110
  state: { returnURL: undefined },
@@ -214,4 +211,20 @@ describe('Compare Tools utilities', () => {
214
211
  fireEvent.click(screen.getByText('Select'));
215
212
  expect(screen.getByTestId('selected')).toHaveTextContent('false');
216
213
  });
214
+
215
+ it('exposes hasMounted state to safely coordinate client hydration', () => {
216
+ renderCompareHarness({ uid: 'one', title: 'Tool one' });
217
+
218
+ expect(screen.getByTestId('mounted')).toHaveTextContent('true');
219
+ });
220
+
221
+ it('tracks mounting status with useHasMounted', () => {
222
+ const MountedComponent = () => {
223
+ const mounted = useHasMounted();
224
+ return <div data-testid="mounted-flag">{String(mounted)}</div>;
225
+ };
226
+
227
+ render(<MountedComponent />);
228
+ expect(screen.getByTestId('mounted-flag')).toHaveTextContent('true');
229
+ });
217
230
  });
@@ -1,5 +1,7 @@
1
1
  import { useEffect } from 'react';
2
2
  import { Button, Container, Grid, Icon, Segment } from 'semantic-ui-react';
3
+ import { useHistory } from 'react-router-dom';
4
+ import { useSelector } from 'react-redux';
3
5
  import {
4
6
  CompareToolsPanel,
5
7
  ExtendedToolGeographicMetadata,
@@ -12,6 +14,7 @@ import { FormattedMessage } from 'react-intl';
12
14
  import BodyClass from '@plone/volto/helpers/BodyClass/BodyClass';
13
15
  import useClipboard from '@plone/volto/hooks/clipboard/useClipboard';
14
16
  import { useCompareTools } from '../CompareTools/utils';
17
+ import { getNavigatorCataloguePageURL } from '../../Search/NavigatorCatalogue/utils';
15
18
  import RelatedTools from './RelatedTools';
16
19
 
17
20
  const ExtendedToolView = (props) => {
@@ -55,6 +58,8 @@ const ExtendedToolView = (props) => {
55
58
  const hasGeoChars = Boolean(geochars || spatial_layer?.length);
56
59
  const hasHyperlink = Boolean(hyperlink && hyperlink.length > 0);
57
60
  const hasCompareTool = Boolean(content.UID);
61
+ const history = useHistory();
62
+ const currentLang = useSelector((state) => state.intl.locale);
58
63
 
59
64
  const compareTool = {
60
65
  uid: content.UID,
@@ -75,6 +80,10 @@ const ExtendedToolView = (props) => {
75
80
  return () => clearTimeout(timeout);
76
81
  }, [isLinkCopied, setIsLinkCopied]);
77
82
 
83
+ const checkNavigatorCatalogue = () => {
84
+ history.push(getNavigatorCataloguePageURL(currentLang));
85
+ };
86
+
78
87
  return (
79
88
  <div className="extended-tool-view">
80
89
  <BodyClass className="extended-tool" />
@@ -226,6 +235,17 @@ const ExtendedToolView = (props) => {
226
235
  className="col-right"
227
236
  >
228
237
  <Segment className="metadata">
238
+ <Button
239
+ className="primary inverted"
240
+ onClick={checkNavigatorCatalogue}
241
+ >
242
+ <Icon className="ri-arrow-left-line" />
243
+ <FormattedMessage
244
+ id="Check Navigator Catalogue"
245
+ defaultMessage="Check Navigator Catalogue"
246
+ />
247
+ </Button>
248
+
229
249
  <h4 className="metadata-header">Metadata</h4>
230
250
 
231
251
  {intended_user_groups?.length > 0 && (
@@ -2,6 +2,8 @@ import React from 'react';
2
2
  import '@testing-library/jest-dom';
3
3
  import { fireEvent, render, screen, within } from '@testing-library/react';
4
4
  import { IntlProvider } from 'react-intl';
5
+ import { useSelector } from 'react-redux';
6
+ import { useHistory } from 'react-router-dom';
5
7
 
6
8
  import ExtendedToolView from './ExtendedToolView';
7
9
  import { useCompareTools } from '../CompareTools/utils';
@@ -11,8 +13,18 @@ jest.mock('../CompareTools/utils', () => ({
11
13
  useCompareTools: jest.fn(),
12
14
  }));
13
15
 
16
+ jest.mock('react-redux', () => ({
17
+ ...jest.requireActual('react-redux'),
18
+ useSelector: jest.fn(),
19
+ }));
20
+
14
21
  jest.mock('@plone/volto/hooks/clipboard/useClipboard', () => jest.fn());
15
22
 
23
+ jest.mock('react-router-dom', () => ({
24
+ ...jest.requireActual('react-router-dom'),
25
+ useHistory: jest.fn(),
26
+ }));
27
+
16
28
  jest.mock(
17
29
  '@plone/volto/components/manage/UniversalLink/UniversalLink',
18
30
  () =>
@@ -51,6 +63,7 @@ jest.mock('@eeacms/volto-cca-policy/components', () => ({
51
63
  const toggle = jest.fn();
52
64
  const copyShareUrl = jest.fn();
53
65
  const setIsLinkCopied = jest.fn();
66
+ const push = jest.fn();
54
67
 
55
68
  const renderComponent = (content = {}) =>
56
69
  render(
@@ -69,6 +82,19 @@ describe('ExtendedToolView', () => {
69
82
  toggle,
70
83
  });
71
84
  useClipboard.mockReturnValue([false, copyShareUrl, setIsLinkCopied]);
85
+ useHistory.mockReturnValue({ push });
86
+ useSelector.mockReturnValue('en');
87
+ });
88
+
89
+ it.each(['en', 'fr'])('opens the Navigator Catalogue in %s', (locale) => {
90
+ useSelector.mockReturnValue(locale);
91
+ renderComponent({ title: 'Climate Tool' });
92
+
93
+ fireEvent.click(
94
+ screen.getByRole('button', { name: /check navigator catalogue/i }),
95
+ );
96
+
97
+ expect(push).toHaveBeenCalledWith(`/${locale}/navigator/tool-catalogue`);
72
98
  });
73
99
 
74
100
  it('renders the title', () => {
@@ -1,6 +1,18 @@
1
+ import React, { useEffect, useState } from 'react';
2
+
1
3
  export default function withClientOnly(WrappedComponent) {
2
4
  const WithClientWrappedComponent = (props) => {
3
- return __SERVER__ ? null : <WrappedComponent {...props} />;
5
+ const [hasMounted, setHasMounted] = useState(false);
6
+
7
+ useEffect(() => {
8
+ setHasMounted(true);
9
+ }, []);
10
+
11
+ if (!hasMounted) {
12
+ return null;
13
+ }
14
+
15
+ return <WrappedComponent {...props} />;
4
16
  };
5
17
 
6
18
  return WithClientWrappedComponent;
@@ -0,0 +1,19 @@
1
+ import React from 'react';
2
+ import '@testing-library/jest-dom';
3
+ import { render, screen } from '@testing-library/react';
4
+ import withClientOnly from './withClientOnly';
5
+
6
+ describe('withClientOnly', () => {
7
+ it('renders the wrapped component after mount', () => {
8
+ const Component = (props) => (
9
+ <div data-testid="wrapped-content">{props.text}</div>
10
+ );
11
+ const ClientOnlyComponent = withClientOnly(Component);
12
+
13
+ render(<ClientOnlyComponent text="Client Hello" />);
14
+
15
+ expect(screen.getByTestId('wrapped-content')).toHaveTextContent(
16
+ 'Client Hello',
17
+ );
18
+ });
19
+ });
@@ -8,7 +8,6 @@ const previewResultsLimit = 3;
8
8
 
9
9
  const navigatorGuideConfig = {
10
10
  title: 'Navigator Guide',
11
- landingPageURL: '/en/navigator',
12
11
  alwaysSearchOnInitialLoad: true,
13
12
  resultsPerPage: previewResultsLimit,
14
13
  previewResultsLimit,
@@ -16,7 +15,6 @@ const navigatorGuideConfig = {
16
15
  showLandingPage: false,
17
16
  layoutComponent: 'NavigatorGuideLayout',
18
17
  contentBodyComponent: 'NavigatorGuideContentView',
19
- resultsPageURL: '/en/navigator/tool-catalogue',
20
18
  };
21
19
 
22
20
  export default function installNavigatorGuideSearch(config) {
@@ -481,7 +481,6 @@ hr {
481
481
  }
482
482
 
483
483
  .metadata-header {
484
- margin: 0;
485
484
  padding-bottom: 0.75rem;
486
485
  border-bottom: 1px dashed #dde3e9;
487
486
  }
@@ -519,28 +518,6 @@ hr {
519
518
  }
520
519
  }
521
520
 
522
- .metadata-tags {
523
- display: flex;
524
- flex-wrap: wrap;
525
- gap: 0.5rem;
526
- }
527
-
528
- .metadata-tag {
529
- padding: 0.35rem 0.75rem;
530
- border: 1px solid #d5dde5;
531
- border-radius: 2px;
532
- color: #4c5a6b;
533
- font-weight: 600;
534
- line-height: 1.25;
535
- font-size: 14px;
536
- }
537
-
538
- .metadata-tag-more {
539
- background: transparent;
540
- cursor: pointer;
541
- font-family: inherit;
542
- }
543
-
544
521
  .extended-tool-related {
545
522
  padding: 2.5rem;
546
523
  margin-top: 3rem;
@@ -663,14 +640,6 @@ hr {
663
640
  gap: 1rem;
664
641
  }
665
642
 
666
- // .extended-tool-related-reason {
667
- // padding: 0.3rem 0.6rem;
668
- // background: #e4f2d8;
669
- // color: #0a7b6c;
670
- // font-size: 12px;
671
- // font-weight: 500;
672
- // }
673
-
674
643
  .extended-tool-related-view {
675
644
  display: inline-flex;
676
645
  align-items: center;
@@ -697,6 +666,28 @@ hr {
697
666
  }
698
667
  }
699
668
 
669
+ .metadata-tags {
670
+ display: flex;
671
+ flex-wrap: wrap;
672
+ gap: 0.5rem;
673
+ }
674
+
675
+ .metadata-tag {
676
+ padding: 0.35rem 0.75rem;
677
+ border: 1px solid #d5dde5;
678
+ border-radius: 2px;
679
+ color: #4c5a6b;
680
+ font-weight: 600;
681
+ line-height: 1.25;
682
+ font-size: 14px;
683
+ }
684
+
685
+ .metadata-tag-more {
686
+ background: transparent;
687
+ cursor: pointer;
688
+ font-family: inherit;
689
+ }
690
+
700
691
  .extended-tool {
701
692
  .ui.segment.breadcrumbs {
702
693
  background-color: #f7f8fa;