@eeacms/volto-cca-policy 1.0.16 → 1.0.18

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.
Files changed (27) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/package.json +1 -1
  3. package/src/components/Search/NavigatorCatalogue/NavigatorCatalogueCardItem.jsx +4 -3
  4. package/src/components/Search/NavigatorCatalogue/NavigatorCatalogueCardItem.test.jsx +63 -0
  5. package/src/components/Search/NavigatorCatalogue/NavigatorCatalogueContentView.jsx +1 -1
  6. package/src/components/Search/NavigatorCatalogue/utils.js +4 -7
  7. package/src/components/Search/NavigatorCatalogue/utils.test.js +11 -22
  8. package/src/components/Search/NavigatorGuide/NavigatorGuideContentView.jsx +7 -9
  9. package/src/components/Search/NavigatorGuide/NavigatorGuideContentView.test.jsx +94 -0
  10. package/src/components/theme/CompareTools/CompareToolsPanel.jsx +15 -13
  11. package/src/components/theme/CompareTools/CompareToolsPanel.test.jsx +74 -12
  12. package/src/components/theme/CompareTools/CompareToolsView.jsx +26 -31
  13. package/src/components/theme/CompareTools/CompareToolsView.test.jsx +47 -12
  14. package/src/components/theme/CompareTools/utils.js +19 -9
  15. package/src/components/theme/CompareTools/utils.test.jsx +21 -8
  16. package/src/components/theme/ToolThumbnail/ToolThumbnail.jsx +40 -0
  17. package/src/components/theme/ToolThumbnail/ToolThumbnail.test.jsx +29 -0
  18. package/src/components/theme/ToolThumbnail/utils.js +49 -0
  19. package/src/components/theme/ToolThumbnail/utils.test.js +124 -0
  20. package/src/components/theme/Views/ExtendedToolView.jsx +22 -0
  21. package/src/components/theme/Views/ExtendedToolView.test.jsx +93 -0
  22. package/src/components/theme/Views/RelatedTools.jsx +2 -6
  23. package/src/hocs/withClientOnly.jsx +13 -1
  24. package/src/hocs/withClientOnly.test.jsx +19 -0
  25. package/src/search/navigator_guide/config.js +0 -2
  26. package/theme/globals/navigator.less +9 -0
  27. package/theme/globals/views.less +24 -49
@@ -17,6 +17,7 @@ import { GET_BREADCRUMBS } from '@plone/volto/constants/ActionTypes';
17
17
  import config from '@plone/volto/registry';
18
18
  import { defineMessages, useIntl } from 'react-intl';
19
19
  import BannerTitle from '../BannerTitle/BannerTitle';
20
+ import ToolThumbnail from '../ToolThumbnail/ToolThumbnail';
20
21
  import {
21
22
  MAX_COMPARE_TOOLS,
22
23
  compareToolsAtom,
@@ -28,7 +29,7 @@ import {
28
29
  asArray,
29
30
  exportComparisonTable,
30
31
  formatFunctionalityScore,
31
- getLocalizedLandingPageURL,
32
+ getNavigatorCataloguePageURL,
32
33
  } from '../../Search/NavigatorCatalogue/utils';
33
34
 
34
35
  const messages = defineMessages({
@@ -99,10 +100,6 @@ const messages = defineMessages({
99
100
  id: 'Adaptation support cycle step',
100
101
  defaultMessage: 'Adaptation support cycle step',
101
102
  },
102
- sector: {
103
- id: 'Sector',
104
- defaultMessage: 'Sector',
105
- },
106
103
  });
107
104
 
108
105
  const getToolField = (tool, field) =>
@@ -111,6 +108,25 @@ const getToolField = (tool, field) =>
111
108
  const getToolFieldDisplay = (tool, field) =>
112
109
  asArray(getToolField(tool, field)).join(', ') || '—';
113
110
 
111
+ const MetadataTags = ({ value }) => {
112
+ const intl = useIntl();
113
+ const items = asArray(value)
114
+ .map((item) => item?.title || item?.token || item)
115
+ .filter(Boolean);
116
+
117
+ return items.length ? (
118
+ <div className="metadata-tags">
119
+ {items.map((item, index) => (
120
+ <span className="metadata-tag" key={`${item}-${index}`}>
121
+ {intl.formatMessage({ id: item, defaultMessage: item })}
122
+ </span>
123
+ ))}
124
+ </div>
125
+ ) : (
126
+ '—'
127
+ );
128
+ };
129
+
114
130
  const FieldValueList = ({ value, label }) => {
115
131
  const values = asArray(value);
116
132
 
@@ -205,8 +221,7 @@ const CompareToolsView = () => {
205
221
  [location.search],
206
222
  );
207
223
  const registry = config.settings.searchlib;
208
- const appConfig = registry.searchui.navigatorCatalogueSearch;
209
- const landingPageURL = getLocalizedLandingPageURL(appConfig, currentLang);
224
+ const landingPageURL = getNavigatorCataloguePageURL(currentLang);
210
225
  const compareToolsTitle = intl.formatMessage(messages.compareTools);
211
226
  const returnURL =
212
227
  location.state?.returnURL ||
@@ -368,12 +383,7 @@ const CompareToolsView = () => {
368
383
  <Table.HeaderCell key={tool.id}>
369
384
  <div className="compare-tool-header">
370
385
  <div className="compare-tool-title-row">
371
- <div
372
- className="navigator-tool-icon medium"
373
- aria-hidden="true"
374
- >
375
- <Icon className="ri-file-line" />
376
- </div>
386
+ <ToolThumbnail result={tool.result} />
377
387
  <div className="compare-tool-title" title={tool.title}>
378
388
  {tool.title}
379
389
  </div>
@@ -462,7 +472,9 @@ const CompareToolsView = () => {
462
472
  </Table.Cell>
463
473
  {visibleTools.map((tool) => (
464
474
  <Table.Cell key={`output-type-${tool.id}`}>
465
- {getToolFieldDisplay(tool, 'cca_type_of_outputs')}
475
+ <MetadataTags
476
+ value={getToolField(tool, 'cca_type_of_outputs')}
477
+ />
466
478
  </Table.Cell>
467
479
  ))}
468
480
  </Table.Row>
@@ -488,23 +500,6 @@ const CompareToolsView = () => {
488
500
  </Table.Cell>
489
501
  ))}
490
502
  </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
503
  </Table.Body>
509
504
  </Table>
510
505
  )}
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import '@testing-library/jest-dom';
3
- import { render, screen } from '@testing-library/react';
3
+ import { fireEvent, render, screen } from '@testing-library/react';
4
4
  import { IntlProvider } from 'react-intl';
5
5
  import { useAtom } from 'jotai';
6
6
  import { useDispatch, useSelector } from 'react-redux';
@@ -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
  },
@@ -58,7 +56,7 @@ jest.mock('./utils', () => ({
58
56
  getPathname: (url) => url?.split('?')[0] || '',
59
57
  }));
60
58
 
61
- describe('CompareToolsView accessibility', () => {
59
+ describe('CompareToolsView', () => {
62
60
  beforeEach(() => {
63
61
  jest.clearAllMocks();
64
62
  useAtom.mockReturnValue([[], jest.fn()]);
@@ -76,18 +74,19 @@ describe('CompareToolsView accessibility', () => {
76
74
  cca_uid: { raw: 'one' },
77
75
  title: 'Tool one',
78
76
  href: '/tool-one',
77
+ image: {
78
+ scales: { thumb: { download: '/uploaded-compare-thumb.jpg' } },
79
+ },
79
80
  functionality: { raw: 4 },
80
81
  cca_adaptation_support_cycle_step: {
81
82
  raw: [{ title: 'Assessing risks' }, { title: 'Monitoring' }],
82
83
  },
83
- cca_adaptation_sectors: {
84
- raw: [{ title: 'Agriculture' }, { title: 'Water management' }],
85
- },
86
84
  },
87
85
  {
88
86
  cca_uid: { raw: 'two' },
89
87
  title: 'Tool two',
90
88
  href: '/tool-two',
89
+ image: null,
91
90
  },
92
91
  ]);
93
92
  });
@@ -125,9 +124,45 @@ describe('CompareToolsView accessibility', () => {
125
124
  expect(adaptationSteps).toHaveTextContent('Assessing risks');
126
125
  expect(adaptationSteps).toHaveTextContent('Monitoring');
127
126
  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);
127
+ });
128
+
129
+ it('shows the uploaded tool result image and keeps the file icon for a missing image', async () => {
130
+ render(
131
+ <IntlProvider locale="en">
132
+ <CompareToolsView />
133
+ </IntlProvider>,
134
+ );
135
+ const table = await screen.findByRole('table', { name: 'Compare tools' });
136
+ const [thumbnail, missingThumbnail] = table.querySelectorAll(
137
+ '.navigator-tool-icon',
138
+ );
139
+ const img = thumbnail.querySelector('img');
140
+
141
+ expect(thumbnail).toHaveClass('medium');
142
+ expect(img).toHaveAttribute('src', '/uploaded-compare-thumb.jpg');
143
+ expect(img).toHaveStyle({ display: 'none' });
144
+ expect(thumbnail.querySelector('.ri-file-line')).toBeInTheDocument();
145
+ expect(missingThumbnail.querySelector('img')).not.toBeInTheDocument();
146
+ expect(missingThumbnail.querySelector('.ri-file-line')).toBeInTheDocument();
147
+
148
+ fireEvent.load(img);
149
+
150
+ expect(img.style.display).toBe('');
151
+ expect(thumbnail.querySelector('.ri-file-line')).not.toBeInTheDocument();
152
+ });
153
+
154
+ it('falls back to the file icon when the image fails', async () => {
155
+ render(
156
+ <IntlProvider locale="en">
157
+ <CompareToolsView />
158
+ </IntlProvider>,
159
+ );
160
+ const table = await screen.findByRole('table', { name: 'Compare tools' });
161
+ const thumbnail = table.querySelector('.navigator-tool-icon');
162
+
163
+ fireEvent.error(thumbnail.querySelector('img'));
164
+
165
+ expect(thumbnail.querySelector('img')).not.toBeInTheDocument();
166
+ expect(thumbnail.querySelector('.ri-file-line')).toBeInTheDocument();
132
167
  });
133
168
  });
@@ -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
  });
@@ -0,0 +1,40 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Icon } from 'semantic-ui-react';
3
+ import Image from '@plone/volto/components/theme/Image/Image';
4
+ import { getToolThumbnailUrl } from './utils';
5
+
6
+ const ToolThumbnail = ({
7
+ result,
8
+ size = 'medium',
9
+ fallbackIcon = 'ri-file-line',
10
+ }) => {
11
+ const [imageLoaded, setImageLoaded] = useState(false);
12
+ const [hasImageError, setHasImageError] = useState(false);
13
+ const thumbUrl = getToolThumbnailUrl(result);
14
+
15
+ useEffect(() => {
16
+ setImageLoaded(false);
17
+ setHasImageError(false);
18
+ }, [thumbUrl]);
19
+
20
+ return (
21
+ <div className={`navigator-tool-icon ${size}`} aria-hidden="true">
22
+ {thumbUrl && !hasImageError ? (
23
+ <>
24
+ <Image
25
+ src={thumbUrl}
26
+ alt=""
27
+ style={imageLoaded ? undefined : { display: 'none' }}
28
+ onLoad={() => setImageLoaded(true)}
29
+ onError={() => setHasImageError(true)}
30
+ />
31
+ {!imageLoaded && <Icon className={fallbackIcon} />}
32
+ </>
33
+ ) : (
34
+ <Icon className={fallbackIcon} />
35
+ )}
36
+ </div>
37
+ );
38
+ };
39
+
40
+ export default ToolThumbnail;
@@ -0,0 +1,29 @@
1
+ import React from 'react';
2
+ import '@testing-library/jest-dom';
3
+ import { fireEvent, render } from '@testing-library/react';
4
+ import ToolThumbnail from './ToolThumbnail';
5
+
6
+ describe('ToolThumbnail', () => {
7
+ it.each(['load', 'error'])(
8
+ 'resets the %s state when the thumbnail URL changes',
9
+ (event) => {
10
+ const { container, rerender } = render(
11
+ <ToolThumbnail result={{ image: '/first.jpg' }} />,
12
+ );
13
+ fireEvent[event](container.querySelector('img'));
14
+
15
+ rerender(<ToolThumbnail result={{ image: '/second.jpg' }} />);
16
+
17
+ const img = container.querySelector('img');
18
+ expect(img).toHaveAttribute('src', '/second.jpg');
19
+ expect(img).toHaveAttribute('alt', '');
20
+ expect(img).toHaveStyle({ display: 'none' });
21
+ expect(container.querySelector('.ri-file-line')).toBeInTheDocument();
22
+
23
+ fireEvent.load(img);
24
+
25
+ expect(img.style.display).toBe('');
26
+ expect(container.querySelector('.ri-file-line')).not.toBeInTheDocument();
27
+ },
28
+ );
29
+ });
@@ -0,0 +1,49 @@
1
+ import { flattenToAppURL } from '@plone/volto/helpers/Url/Url';
2
+
3
+ export function getToolThumbnailUrl(result) {
4
+ if (!result) return null;
5
+
6
+ if (result.image === null || result.image === false) {
7
+ return null;
8
+ }
9
+
10
+ if (typeof result.image === 'string' && result.image) {
11
+ return flattenToAppURL(result.image);
12
+ }
13
+ if (result.image && typeof result.image === 'object') {
14
+ const scaleUrl =
15
+ result.image.scales?.thumb?.download ||
16
+ result.image.scales?.tile?.download ||
17
+ result.image.scales?.preview?.download ||
18
+ result.image.scales?.mini?.download ||
19
+ result.image.download;
20
+ if (scaleUrl) {
21
+ return flattenToAppURL(scaleUrl);
22
+ }
23
+ }
24
+
25
+ if (
26
+ typeof result.thumbUrl === 'string' &&
27
+ result.thumbUrl &&
28
+ !result.thumbUrl.includes('portal_depiction')
29
+ ) {
30
+ return flattenToAppURL(result.thumbUrl);
31
+ }
32
+
33
+ if (
34
+ result.image_preview &&
35
+ typeof result.image_preview.raw === 'string' &&
36
+ result.image_preview.raw
37
+ ) {
38
+ return flattenToAppURL(result.image_preview.raw);
39
+ }
40
+
41
+ const href =
42
+ result.href || result['@id'] || result.about?.raw || result.about;
43
+ if (href && typeof href === 'string') {
44
+ const cleanHref = flattenToAppURL(href).replace(/\/+$/, '');
45
+ return `${cleanHref}/@@images/image/thumb`;
46
+ }
47
+
48
+ return null;
49
+ }
@@ -0,0 +1,124 @@
1
+ import { getToolThumbnailUrl } from './utils';
2
+
3
+ describe('getToolThumbnailUrl', () => {
4
+ it.each([null, false])(
5
+ 'does not resolve other URLs when image is %s',
6
+ (image) => {
7
+ expect(
8
+ getToolThumbnailUrl({
9
+ image,
10
+ thumbUrl: '/thumb.jpg',
11
+ image_preview: { raw: '/preview.jpg' },
12
+ href: '/tools/tool',
13
+ }),
14
+ ).toBeNull();
15
+ },
16
+ );
17
+
18
+ it.each(['thumb', 'tile', 'preview', 'mini'])(
19
+ 'prefers the %s scale to the original download',
20
+ (scale) => {
21
+ expect(
22
+ getToolThumbnailUrl({
23
+ image: {
24
+ scales: { [scale]: { download: '/scaled.jpg' } },
25
+ download: '/original.jpg',
26
+ },
27
+ thumbUrl: '/thumb.jpg',
28
+ }),
29
+ ).toBe('/scaled.jpg');
30
+ },
31
+ );
32
+
33
+ it('uses the raw image preview before constructing a traversal URL', () => {
34
+ expect(
35
+ getToolThumbnailUrl({
36
+ thumbUrl: '/portal_depiction/tool/image_preview',
37
+ image_preview: { raw: '/preview.jpg' },
38
+ href: '/tools/tool',
39
+ }),
40
+ ).toBe('/preview.jpg');
41
+ });
42
+
43
+ it('returns null for empty or invalid results', () => {
44
+ expect(getToolThumbnailUrl(null)).toBeNull();
45
+ expect(getToolThumbnailUrl(undefined)).toBeNull();
46
+ expect(getToolThumbnailUrl({})).toBeNull();
47
+ expect(getToolThumbnailUrl({ image: null })).toBeNull();
48
+ expect(getToolThumbnailUrl({ image: false })).toBeNull();
49
+ });
50
+
51
+ it('resolves direct image string URL', () => {
52
+ expect(
53
+ getToolThumbnailUrl({ image: '/metadata/tools/tool/image.png' }),
54
+ ).toBe('/metadata/tools/tool/image.png');
55
+ });
56
+
57
+ it('resolves image scales from Dexterity image field', () => {
58
+ expect(
59
+ getToolThumbnailUrl({
60
+ image: {
61
+ scales: {
62
+ thumb: { download: '/image-128.jpeg' },
63
+ tile: { download: '/image-64.jpeg' },
64
+ },
65
+ },
66
+ }),
67
+ ).toBe('/image-128.jpeg');
68
+
69
+ expect(
70
+ getToolThumbnailUrl({
71
+ image: {
72
+ scales: {
73
+ tile: { download: '/image-64.jpeg' },
74
+ },
75
+ },
76
+ }),
77
+ ).toBe('/image-64.jpeg');
78
+
79
+ expect(
80
+ getToolThumbnailUrl({
81
+ image: {
82
+ download: '/image-original.jpeg',
83
+ },
84
+ }),
85
+ ).toBe('/image-original.jpeg');
86
+ });
87
+
88
+ it('resolves thumbUrl when not portal_depiction fallback', () => {
89
+ expect(
90
+ getToolThumbnailUrl({
91
+ thumbUrl: '/custom-thumb.jpg',
92
+ }),
93
+ ).toBe('/custom-thumb.jpg');
94
+
95
+ // portal_depiction is ignored, falls back to href scale
96
+ expect(
97
+ getToolThumbnailUrl({
98
+ thumbUrl:
99
+ 'https://www.eea.europa.eu/portal_depiction/tool/image_preview',
100
+ href: '/tools/sample-tool',
101
+ }),
102
+ ).toBe('/tools/sample-tool/@@images/image/thumb');
103
+ });
104
+
105
+ it('constructs scale traversal URL from href, @id, or about', () => {
106
+ expect(
107
+ getToolThumbnailUrl({
108
+ href: '/tools/sample-tool/',
109
+ }),
110
+ ).toBe('/tools/sample-tool/@@images/image/thumb');
111
+
112
+ expect(
113
+ getToolThumbnailUrl({
114
+ '@id': '/tools/sample-tool',
115
+ }),
116
+ ).toBe('/tools/sample-tool/@@images/image/thumb');
117
+
118
+ expect(
119
+ getToolThumbnailUrl({
120
+ about: { raw: '/tools/sample-tool' },
121
+ }),
122
+ ).toBe('/tools/sample-tool/@@images/image/thumb');
123
+ });
124
+ });
@@ -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,8 @@ 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 { getToolThumbnailUrl } from '../ToolThumbnail/utils';
18
+ import { getNavigatorCataloguePageURL } from '../../Search/NavigatorCatalogue/utils';
15
19
  import RelatedTools from './RelatedTools';
16
20
 
17
21
  const ExtendedToolView = (props) => {
@@ -55,11 +59,14 @@ const ExtendedToolView = (props) => {
55
59
  const hasGeoChars = Boolean(geochars || spatial_layer?.length);
56
60
  const hasHyperlink = Boolean(hyperlink && hyperlink.length > 0);
57
61
  const hasCompareTool = Boolean(content.UID);
62
+ const history = useHistory();
63
+ const currentLang = useSelector((state) => state.intl.locale);
58
64
 
59
65
  const compareTool = {
60
66
  uid: content.UID,
61
67
  title,
62
68
  href: content['@id'],
69
+ image: getToolThumbnailUrl(content),
63
70
  };
64
71
 
65
72
  const { isSelected, isLimitReached, toggle } = useCompareTools(compareTool);
@@ -75,6 +82,10 @@ const ExtendedToolView = (props) => {
75
82
  return () => clearTimeout(timeout);
76
83
  }, [isLinkCopied, setIsLinkCopied]);
77
84
 
85
+ const checkNavigatorCatalogue = () => {
86
+ history.push(getNavigatorCataloguePageURL(currentLang));
87
+ };
88
+
78
89
  return (
79
90
  <div className="extended-tool-view">
80
91
  <BodyClass className="extended-tool" />
@@ -226,6 +237,17 @@ const ExtendedToolView = (props) => {
226
237
  className="col-right"
227
238
  >
228
239
  <Segment className="metadata">
240
+ <Button
241
+ className="primary inverted"
242
+ onClick={checkNavigatorCatalogue}
243
+ >
244
+ <Icon className="ri-arrow-left-line" />
245
+ <FormattedMessage
246
+ id="Check Navigator Catalogue"
247
+ defaultMessage="Check Navigator Catalogue"
248
+ />
249
+ </Button>
250
+
229
251
  <h4 className="metadata-header">Metadata</h4>
230
252
 
231
253
  {intended_user_groups?.length > 0 && (