@eeacms/volto-cca-policy 1.0.17 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@ 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.18](https://github.com/eea/volto-cca-policy/compare/1.0.17...1.0.18) - 10 September 2026
8
+
9
+ #### :rocket: New Features
10
+
11
+ - feat: implement ToolThumbnail component and integrate it across various views [kreafox - [`edd3087`](https://github.com/eea/volto-cca-policy/commit/edd3087830076efa968ef42d1ec99efa42010cd5)]
12
+
13
+ #### :hammer_and_wrench: Others
14
+
15
+ - test: update image display style assertions in tests for NavigatorCatalogueCardItem and ToolThumbnail components [kreafox - [`24e1437`](https://github.com/eea/volto-cca-policy/commit/24e14375674dca5e73d8919eb4578d1c184db21a)]
7
16
  ### [1.0.17](https://github.com/eea/volto-cca-policy/compare/1.0.16...1.0.17) - 8 September 2026
8
17
 
9
18
  #### :rocket: New Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-cca-policy",
3
- "version": "1.0.17",
3
+ "version": "1.0.18",
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",
@@ -3,6 +3,8 @@ import { Checkbox, Icon, Popup } from 'semantic-ui-react';
3
3
  import { defineMessages, useIntl } from 'react-intl';
4
4
  import ExternalLink from '@eeacms/search/components/Result/ExternalLink';
5
5
  import ResultContext from '@eeacms/search/components/Result/ResultContext';
6
+ import ToolThumbnail from '../../theme/ToolThumbnail/ToolThumbnail';
7
+ import { getToolThumbnailUrl } from '../../theme/ToolThumbnail/utils';
6
8
  import {
7
9
  getCompareToolTitle,
8
10
  getCompareToolUid,
@@ -136,6 +138,7 @@ const NavigatorCatalogueCardItem = (props) => {
136
138
  uid: getCompareToolUid(result),
137
139
  title: getCompareToolTitle(result),
138
140
  href: result.href,
141
+ image: getToolThumbnailUrl(result),
139
142
  };
140
143
  const { isSelected, isLimitReached, setSelected } =
141
144
  useCompareTools(compareTool);
@@ -146,9 +149,7 @@ const NavigatorCatalogueCardItem = (props) => {
146
149
 
147
150
  return (
148
151
  <div className={`navigator-catalogue-item${isSelected ? ' selected' : ''}`}>
149
- <div className="navigator-tool-icon large" aria-hidden="true">
150
- <Icon className="ri-file-line" />
151
- </div>
152
+ <ToolThumbnail result={result} size="large" />
152
153
 
153
154
  <div className="catalogue-item-main">
154
155
  <div className="catalogue-item-top">
@@ -76,6 +76,7 @@ describe('NavigatorCatalogueCardItem', () => {
76
76
  cca_uid: { raw: 'tool-uid' },
77
77
  title: 'Climate planning tool',
78
78
  href: 'https://example.com/tool',
79
+ image: '/uploaded-tool-thumb.jpg',
79
80
  publication_date: { raw: '2026-07-24' },
80
81
  cca_adaptation_sectors: {
81
82
  raw: ['Agriculture', 'Water', 'Health', 'Energy'],
@@ -138,6 +139,7 @@ describe('NavigatorCatalogueCardItem', () => {
138
139
  uid: 'tool-uid',
139
140
  title: 'Climate planning tool',
140
141
  href: 'https://example.com/tool',
142
+ image: '/uploaded-tool-thumb.jpg',
141
143
  });
142
144
  });
143
145
 
@@ -170,4 +172,65 @@ describe('NavigatorCatalogueCardItem', () => {
170
172
  expect(screen.getByText('Water')).toBeInTheDocument();
171
173
  expect(screen.queryByText('License:')).not.toBeInTheDocument();
172
174
  });
175
+
176
+ describe('thumbnail rendering and fallback', () => {
177
+ it('renders thumbnail image and smoothly transitions from placeholder on load', () => {
178
+ const { container } = renderCard({
179
+ title: 'Tool with image',
180
+ href: '/tools/my-tool',
181
+ image: { scales: { thumb: { download: '/uploaded-thumb.jpg' } } },
182
+ });
183
+
184
+ const img = container.querySelector('.navigator-tool-icon img');
185
+ expect(img).toBeInTheDocument();
186
+ expect(img).toHaveAttribute('src', '/uploaded-thumb.jpg');
187
+ expect(img.parentElement).toHaveClass('navigator-tool-icon', 'large');
188
+ expect(img).toHaveStyle({ display: 'none' });
189
+ expect(
190
+ container.querySelector('.navigator-tool-icon .ri-file-line'),
191
+ ).toBeInTheDocument();
192
+
193
+ // Fire load event on image
194
+ fireEvent.load(img);
195
+
196
+ expect(img.style.display).toBe('');
197
+ expect(
198
+ container.querySelector('.navigator-tool-icon .ri-file-line'),
199
+ ).not.toBeInTheDocument();
200
+ });
201
+
202
+ it('falls back to placeholder icon when image fails to load', () => {
203
+ const { container } = renderCard({
204
+ title: 'Tool with broken image',
205
+ href: '/tools/broken-tool',
206
+ });
207
+
208
+ const img = container.querySelector('.navigator-tool-icon img');
209
+ expect(img).toBeInTheDocument();
210
+
211
+ // Fire error event on image (e.g. 404 from backend)
212
+ fireEvent.error(img);
213
+
214
+ expect(
215
+ container.querySelector('.navigator-tool-icon img'),
216
+ ).not.toBeInTheDocument();
217
+ expect(
218
+ container.querySelector('.navigator-tool-icon .ri-file-line'),
219
+ ).toBeInTheDocument();
220
+ });
221
+
222
+ it('renders placeholder icon directly when item has no image or href', () => {
223
+ const { container } = renderCard({
224
+ title: 'Tool without image',
225
+ image: null,
226
+ });
227
+
228
+ expect(
229
+ container.querySelector('.navigator-tool-icon img'),
230
+ ).not.toBeInTheDocument();
231
+ expect(
232
+ container.querySelector('.navigator-tool-icon .ri-file-line'),
233
+ ).toBeInTheDocument();
234
+ });
235
+ });
173
236
  });
@@ -6,6 +6,7 @@ import { defineMessages, useIntl } from 'react-intl';
6
6
  import { Button, Checkbox, Icon, Loader, Message } from 'semantic-ui-react';
7
7
  import URLManager from '@elastic/search-ui/lib/cjs/URLManager';
8
8
  import { useSearchContext } from '@eeacms/search/lib/hocs';
9
+ import ToolThumbnail from '../../theme/ToolThumbnail/ToolThumbnail';
9
10
  import guideSteps from '../../../search/navigator_guide/guideSteps';
10
11
  import { navigatorGuideStepAtom } from '../../../state';
11
12
  import { mergeGuideOptions } from './utils';
@@ -350,12 +351,10 @@ const NavigatorGuideContentView = ({ appConfig }) => {
350
351
  className="navigator-guide-preview-result"
351
352
  key={result._original?._id || result.href}
352
353
  >
353
- <div
354
- className="navigator-tool-icon medium"
355
- aria-hidden="true"
356
- >
357
- <Icon className="ri-stack-line" />
358
- </div>
354
+ <ToolThumbnail
355
+ result={result}
356
+ fallbackIcon="ri-stack-line"
357
+ />
359
358
  <div className="navigator-guide-preview-result-content">
360
359
  <small
361
360
  className="navigator-tool-provider"
@@ -0,0 +1,94 @@
1
+ import React from 'react';
2
+ import '@testing-library/jest-dom';
3
+ import { fireEvent, render } from '@testing-library/react';
4
+ import { IntlProvider } from 'react-intl';
5
+ import { useAtom } from 'jotai';
6
+ import { useSelector } from 'react-redux';
7
+ import { useHistory } from 'react-router-dom';
8
+ import { useSearchContext } from '@eeacms/search/lib/hocs';
9
+ import NavigatorGuideContentView from './NavigatorGuideContentView';
10
+ import useGuideFacetOptions from './useGuideFacetOptions';
11
+
12
+ jest.mock('jotai', () => ({
13
+ ...jest.requireActual('jotai'),
14
+ useAtom: jest.fn(),
15
+ }));
16
+ jest.mock('react-redux', () => ({ useSelector: jest.fn() }));
17
+ jest.mock('react-router-dom', () => ({ useHistory: jest.fn() }));
18
+ jest.mock('@eeacms/search/lib/hocs', () => ({ useSearchContext: jest.fn() }));
19
+ jest.mock('./useGuideFacetOptions', () => ({
20
+ __esModule: true,
21
+ default: jest.fn(),
22
+ }));
23
+
24
+ const renderGuide = (result) => {
25
+ useSearchContext.mockReturnValue({
26
+ filters: [{ field: 'cca_adaptation_sectors.keyword', values: ['Water'] }],
27
+ facets: {},
28
+ results: [result],
29
+ totalResults: 1,
30
+ isLoading: false,
31
+ });
32
+ return render(
33
+ <IntlProvider locale="en">
34
+ <NavigatorGuideContentView appConfig={{ previewResultsLimit: 3 }} />
35
+ </IntlProvider>,
36
+ );
37
+ };
38
+
39
+ describe('NavigatorGuideContentView thumbnails', () => {
40
+ beforeEach(() => {
41
+ jest.clearAllMocks();
42
+ useAtom.mockReturnValue([0, jest.fn()]);
43
+ useSelector.mockReturnValue('en');
44
+ useHistory.mockReturnValue({ push: jest.fn() });
45
+ useGuideFacetOptions.mockReturnValue({});
46
+ });
47
+
48
+ it('shows the uploaded result image after loading, with a stack icon while loading', () => {
49
+ const { container } = renderGuide({
50
+ title: 'Guide tool',
51
+ href: '/tools/guide-tool',
52
+ image: { scales: { thumb: { download: '/uploaded-guide-thumb.jpg' } } },
53
+ });
54
+ const thumbnail = container.querySelector('.navigator-tool-icon');
55
+ const img = thumbnail.querySelector('img');
56
+
57
+ expect(thumbnail).toHaveClass('medium');
58
+ expect(thumbnail).toHaveAttribute('aria-hidden', 'true');
59
+ expect(img).toHaveAttribute('src', '/uploaded-guide-thumb.jpg');
60
+ expect(img).toHaveStyle({ display: 'none' });
61
+ expect(thumbnail.querySelector('.ri-stack-line')).toBeInTheDocument();
62
+
63
+ fireEvent.load(img);
64
+
65
+ expect(img).not.toHaveStyle({ display: 'none' });
66
+ expect(thumbnail.querySelector('.ri-stack-line')).not.toBeInTheDocument();
67
+ });
68
+
69
+ it('falls back to the stack icon when the image fails', () => {
70
+ const { container } = renderGuide({
71
+ title: 'Guide tool',
72
+ href: '/tools/guide-tool',
73
+ image: '/broken.jpg',
74
+ });
75
+ const thumbnail = container.querySelector('.navigator-tool-icon');
76
+
77
+ fireEvent.error(thumbnail.querySelector('img'));
78
+
79
+ expect(thumbnail.querySelector('img')).not.toBeInTheDocument();
80
+ expect(thumbnail.querySelector('.ri-stack-line')).toBeInTheDocument();
81
+ });
82
+
83
+ it.each([null, false])('shows the stack icon for image: %s', (image) => {
84
+ const { container } = renderGuide({
85
+ title: 'Guide tool',
86
+ href: '/tools/guide-tool',
87
+ image,
88
+ });
89
+ const thumbnail = container.querySelector('.navigator-tool-icon');
90
+
91
+ expect(thumbnail.querySelector('img')).not.toBeInTheDocument();
92
+ expect(thumbnail.querySelector('.ri-stack-line')).toBeInTheDocument();
93
+ });
94
+ });
@@ -4,6 +4,7 @@ import { useSelector } from 'react-redux';
4
4
  import { useHistory } from 'react-router-dom';
5
5
  import { Icon, Button } from 'semantic-ui-react';
6
6
  import { defineMessages, useIntl } from 'react-intl';
7
+ import ToolThumbnail from '../ToolThumbnail/ToolThumbnail';
7
8
  import {
8
9
  MAX_COMPARE_TOOLS,
9
10
  compareToolsAtom,
@@ -89,9 +90,7 @@ export const CompareToolsPanel = () => {
89
90
  <div className="compare-panel-tools">
90
91
  {effectiveSelectedTools.map((tool) => (
91
92
  <div key={tool.uid} className="compare-panel-tool">
92
- <span className="navigator-tool-icon small" aria-hidden="true">
93
- <Icon className="ri-file-line" />
94
- </span>
93
+ <ToolThumbnail result={tool} size="small" />
95
94
  <span className="compare-panel-tool-title">{tool.title}</span>
96
95
  <Button
97
96
  className="compare-panel-tool-clear"
@@ -101,4 +101,58 @@ describe('CompareToolsPanel', () => {
101
101
  fireEvent.click(screen.getByText('Clear all'));
102
102
  expect(screen.queryByText('Compare tools')).not.toBeInTheDocument();
103
103
  });
104
+
105
+ it('shows the selected tool thumbnail after loading and preserves empty slot icons', () => {
106
+ const { container } = renderPanel([
107
+ {
108
+ uid: 'one',
109
+ title: 'Tool one',
110
+ href: '/tools/one',
111
+ image: '/uploaded-tool-thumb.jpg',
112
+ },
113
+ ]);
114
+ const thumbnail = container.querySelector(
115
+ '.compare-panel-tool:not(.placeholder) .navigator-tool-icon',
116
+ );
117
+ const img = thumbnail.querySelector('img');
118
+
119
+ expect(thumbnail).toHaveClass('small');
120
+ expect(img).toHaveAttribute('src', '/uploaded-tool-thumb.jpg');
121
+ expect(img).toHaveStyle({ display: 'none' });
122
+ expect(thumbnail.querySelector('.ri-file-line')).toBeInTheDocument();
123
+ expect(
124
+ container.querySelectorAll('.placeholder .ri-add-line'),
125
+ ).toHaveLength(3);
126
+ expect(container.querySelector('.placeholder img')).not.toBeInTheDocument();
127
+
128
+ fireEvent.load(img);
129
+
130
+ expect(img).not.toHaveStyle({ display: 'none' });
131
+ expect(thumbnail.querySelector('.ri-file-line')).not.toBeInTheDocument();
132
+ });
133
+
134
+ it('uses the URL of an older saved selection and falls back on image error', () => {
135
+ const { container } = renderPanel([
136
+ { uid: 'one', title: 'Tool one', href: '/tools/one' },
137
+ ]);
138
+ const thumbnail = container.querySelector('.navigator-tool-icon');
139
+ const img = thumbnail.querySelector('img');
140
+
141
+ expect(img).toHaveAttribute('src', '/tools/one/@@images/image/thumb');
142
+
143
+ fireEvent.error(img);
144
+
145
+ expect(thumbnail.querySelector('img')).not.toBeInTheDocument();
146
+ expect(thumbnail.querySelector('.ri-file-line')).toBeInTheDocument();
147
+ });
148
+
149
+ it.each([null, false])('shows the file icon for image: %s', (image) => {
150
+ const { container } = renderPanel([
151
+ { uid: 'one', title: 'Tool one', href: '/tools/one', image },
152
+ ]);
153
+ const thumbnail = container.querySelector('.navigator-tool-icon');
154
+
155
+ expect(thumbnail.querySelector('img')).not.toBeInTheDocument();
156
+ expect(thumbnail.querySelector('.ri-file-line')).toBeInTheDocument();
157
+ });
104
158
  });
@@ -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,
@@ -382,12 +383,7 @@ const CompareToolsView = () => {
382
383
  <Table.HeaderCell key={tool.id}>
383
384
  <div className="compare-tool-header">
384
385
  <div className="compare-tool-title-row">
385
- <div
386
- className="navigator-tool-icon medium"
387
- aria-hidden="true"
388
- >
389
- <Icon className="ri-file-line" />
390
- </div>
386
+ <ToolThumbnail result={tool.result} />
391
387
  <div className="compare-tool-title" title={tool.title}>
392
388
  {tool.title}
393
389
  </div>
@@ -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';
@@ -56,7 +56,7 @@ jest.mock('./utils', () => ({
56
56
  getPathname: (url) => url?.split('?')[0] || '',
57
57
  }));
58
58
 
59
- describe('CompareToolsView accessibility', () => {
59
+ describe('CompareToolsView', () => {
60
60
  beforeEach(() => {
61
61
  jest.clearAllMocks();
62
62
  useAtom.mockReturnValue([[], jest.fn()]);
@@ -74,6 +74,9 @@ describe('CompareToolsView accessibility', () => {
74
74
  cca_uid: { raw: 'one' },
75
75
  title: 'Tool one',
76
76
  href: '/tool-one',
77
+ image: {
78
+ scales: { thumb: { download: '/uploaded-compare-thumb.jpg' } },
79
+ },
77
80
  functionality: { raw: 4 },
78
81
  cca_adaptation_support_cycle_step: {
79
82
  raw: [{ title: 'Assessing risks' }, { title: 'Monitoring' }],
@@ -83,6 +86,7 @@ describe('CompareToolsView accessibility', () => {
83
86
  cca_uid: { raw: 'two' },
84
87
  title: 'Tool two',
85
88
  href: '/tool-two',
89
+ image: null,
86
90
  },
87
91
  ]);
88
92
  });
@@ -121,4 +125,44 @@ describe('CompareToolsView accessibility', () => {
121
125
  expect(adaptationSteps).toHaveTextContent('Monitoring');
122
126
  expect(adaptationSteps.children).toHaveLength(2);
123
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();
167
+ });
124
168
  });
@@ -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
+ });
@@ -14,6 +14,7 @@ import { FormattedMessage } from 'react-intl';
14
14
  import BodyClass from '@plone/volto/helpers/BodyClass/BodyClass';
15
15
  import useClipboard from '@plone/volto/hooks/clipboard/useClipboard';
16
16
  import { useCompareTools } from '../CompareTools/utils';
17
+ import { getToolThumbnailUrl } from '../ToolThumbnail/utils';
17
18
  import { getNavigatorCataloguePageURL } from '../../Search/NavigatorCatalogue/utils';
18
19
  import RelatedTools from './RelatedTools';
19
20
 
@@ -65,6 +66,7 @@ const ExtendedToolView = (props) => {
65
66
  uid: content.UID,
66
67
  title,
67
68
  href: content['@id'],
69
+ image: getToolThumbnailUrl(content),
68
70
  };
69
71
 
70
72
  const { isSelected, isLimitReached, toggle } = useCompareTools(compareTool);
@@ -221,6 +221,69 @@ describe('ExtendedToolView', () => {
221
221
  ).not.toBeInTheDocument();
222
222
  });
223
223
 
224
+ describe('related tool thumbnails', () => {
225
+ const renderRelatedTool = (fields = {}) => {
226
+ const { container } = renderComponent({
227
+ title: 'Climate Tool',
228
+ '@components': {
229
+ relatedtools: {
230
+ items: [
231
+ {
232
+ '@id': '/tools/coastal-planner',
233
+ title: 'Coastal planner',
234
+ ...fields,
235
+ },
236
+ ],
237
+ },
238
+ },
239
+ });
240
+ return container.querySelector(
241
+ '.extended-tool-related-card .navigator-tool-icon',
242
+ );
243
+ };
244
+
245
+ it('shows the uploaded thumbnail after loading', () => {
246
+ const thumbnail = renderRelatedTool({
247
+ image: {
248
+ scales: { thumb: { download: '/uploaded-related-thumb.jpg' } },
249
+ },
250
+ });
251
+ const img = thumbnail.querySelector('img');
252
+
253
+ expect(thumbnail).toHaveClass('medium');
254
+ expect(img).toHaveAttribute('src', '/uploaded-related-thumb.jpg');
255
+ expect(img).toHaveStyle({ display: 'none' });
256
+ expect(thumbnail.querySelector('.ri-file-line')).toBeInTheDocument();
257
+
258
+ fireEvent.load(img);
259
+
260
+ expect(img).not.toHaveStyle({ display: 'none' });
261
+ expect(thumbnail.querySelector('.ri-file-line')).not.toBeInTheDocument();
262
+ });
263
+
264
+ it('resolves the thumbnail from the related item URL and falls back on error', () => {
265
+ const thumbnail = renderRelatedTool();
266
+ const img = thumbnail.querySelector('img');
267
+
268
+ expect(img).toHaveAttribute(
269
+ 'src',
270
+ '/tools/coastal-planner/@@images/image/thumb',
271
+ );
272
+
273
+ fireEvent.error(img);
274
+
275
+ expect(thumbnail.querySelector('img')).not.toBeInTheDocument();
276
+ expect(thumbnail.querySelector('.ri-file-line')).toBeInTheDocument();
277
+ });
278
+
279
+ it.each([null, false])('shows the file icon for image: %s', (image) => {
280
+ const thumbnail = renderRelatedTool({ image });
281
+
282
+ expect(thumbnail.querySelector('img')).not.toBeInTheDocument();
283
+ expect(thumbnail.querySelector('.ri-file-line')).toBeInTheDocument();
284
+ });
285
+ });
286
+
224
287
  it('renders the open-tool button when a hyperlink is provided', () => {
225
288
  renderComponent({
226
289
  title: 'Climate Tool',
@@ -323,12 +386,16 @@ describe('ExtendedToolView', () => {
323
386
  '@id': '/tools/climate-tool',
324
387
  title: 'Climate Tool',
325
388
  hyperlink: 'https://example.com/tool',
389
+ image: {
390
+ scales: { thumb: { download: '/uploaded-tool-thumb.jpg' } },
391
+ },
326
392
  });
327
393
 
328
394
  expect(useCompareTools).toHaveBeenCalledWith({
329
395
  uid: 'tool-uid',
330
396
  title: 'Climate Tool',
331
397
  href: '/tools/climate-tool',
398
+ image: '/uploaded-tool-thumb.jpg',
332
399
  });
333
400
  });
334
401
 
@@ -1,6 +1,7 @@
1
1
  import UniversalLink from '@plone/volto/components/manage/UniversalLink/UniversalLink';
2
2
  import { Container, Icon, Popup } from 'semantic-ui-react';
3
3
  import { defineMessages, useIntl } from 'react-intl';
4
+ import ToolThumbnail from '../ToolThumbnail/ToolThumbnail';
4
5
 
5
6
  const messages = defineMessages({
6
7
  relatedTools: {
@@ -84,12 +85,7 @@ const RelatedTools = ({ content }) => {
84
85
  </UniversalLink>
85
86
  </h4>
86
87
  </div>
87
- <span
88
- className="extended-tool-related-icon"
89
- aria-hidden="true"
90
- >
91
- <Icon className="ri-file-line" />
92
- </span>
88
+ <ToolThumbnail result={item} />
93
89
  </div>
94
90
  {sharedGroups.length > 0 && (
95
91
  <div className="extended-tool-related-tags">
@@ -50,6 +50,15 @@
50
50
  border-radius: 4px;
51
51
  background-color: #f7f8fa;
52
52
  color: @navigatorIconColor;
53
+ overflow: hidden;
54
+
55
+ img {
56
+ width: 100%;
57
+ height: 100%;
58
+ object-fit: cover;
59
+ display: block;
60
+ border-radius: inherit;
61
+ }
53
62
 
54
63
  i.icon {
55
64
  width: auto;
@@ -565,7 +565,8 @@ hr {
565
565
  justify-content: space-between;
566
566
  margin-bottom: 1em;
567
567
  gap: 0.5em;
568
- > div {
568
+
569
+ > div:not(.navigator-tool-icon) {
569
570
  min-width: 0;
570
571
  flex: 1 1 auto;
571
572
  }
@@ -606,23 +607,6 @@ hr {
606
607
  justify-content: flex-end;
607
608
  }
608
609
 
609
- .extended-tool-related-icon {
610
- display: inline-flex;
611
- width: 32px;
612
- height: 32px;
613
- align-items: center;
614
- flex: 0 0 auto;
615
- justify-content: center;
616
- border: 1px solid #d5dde5;
617
- border-radius: 3px;
618
- background: #f7f9fa;
619
- color: #60758a;
620
-
621
- .icon {
622
- margin: 0;
623
- }
624
- }
625
-
626
610
  .extended-tool-related-tags {
627
611
  margin-bottom: 1rem;
628
612
  gap: 0.4rem;