@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 +9 -0
- package/package.json +1 -1
- package/src/components/Search/NavigatorCatalogue/NavigatorCatalogueCardItem.jsx +4 -3
- package/src/components/Search/NavigatorCatalogue/NavigatorCatalogueCardItem.test.jsx +63 -0
- package/src/components/Search/NavigatorGuide/NavigatorGuideContentView.jsx +5 -6
- package/src/components/Search/NavigatorGuide/NavigatorGuideContentView.test.jsx +94 -0
- package/src/components/theme/CompareTools/CompareToolsPanel.jsx +2 -3
- package/src/components/theme/CompareTools/CompareToolsPanel.test.jsx +54 -0
- package/src/components/theme/CompareTools/CompareToolsView.jsx +2 -6
- package/src/components/theme/CompareTools/CompareToolsView.test.jsx +46 -2
- package/src/components/theme/ToolThumbnail/ToolThumbnail.jsx +40 -0
- package/src/components/theme/ToolThumbnail/ToolThumbnail.test.jsx +29 -0
- package/src/components/theme/ToolThumbnail/utils.js +49 -0
- package/src/components/theme/ToolThumbnail/utils.test.js +124 -0
- package/src/components/theme/Views/ExtendedToolView.jsx +2 -0
- package/src/components/theme/Views/ExtendedToolView.test.jsx +67 -0
- package/src/components/theme/Views/RelatedTools.jsx +2 -6
- package/theme/globals/navigator.less +9 -0
- package/theme/globals/views.less +2 -18
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
|
@@ -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
|
-
<
|
|
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
|
-
<
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
|
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
|
-
<
|
|
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;
|
package/theme/globals/views.less
CHANGED
|
@@ -565,7 +565,8 @@ hr {
|
|
|
565
565
|
justify-content: space-between;
|
|
566
566
|
margin-bottom: 1em;
|
|
567
567
|
gap: 0.5em;
|
|
568
|
-
|
|
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;
|