@eeacms/volto-cca-policy 1.0.2 → 1.0.4

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 (78) hide show
  1. package/CHANGELOG.md +259 -3
  2. package/jest-addon.config.js +4 -2
  3. package/locales/bg/LC_MESSAGES/volto.po +576 -0
  4. package/locales/cs/LC_MESSAGES/volto.po +576 -0
  5. package/locales/da/LC_MESSAGES/volto.po +576 -0
  6. package/locales/de/LC_MESSAGES/volto.po +576 -0
  7. package/locales/el/LC_MESSAGES/volto.po +576 -0
  8. package/locales/en/LC_MESSAGES/volto.po +576 -0
  9. package/locales/es/LC_MESSAGES/volto.po +576 -0
  10. package/locales/et/LC_MESSAGES/volto.po +576 -0
  11. package/locales/fi/LC_MESSAGES/volto.po +576 -0
  12. package/locales/fr/LC_MESSAGES/volto.po +576 -0
  13. package/locales/ga/LC_MESSAGES/volto.po +576 -0
  14. package/locales/hr/LC_MESSAGES/volto.po +576 -0
  15. package/locales/hu/LC_MESSAGES/volto.po +576 -0
  16. package/locales/is/LC_MESSAGES/volto.po +576 -0
  17. package/locales/it/LC_MESSAGES/volto.po +576 -0
  18. package/locales/lt/LC_MESSAGES/volto.po +576 -0
  19. package/locales/lv/LC_MESSAGES/volto.po +576 -0
  20. package/locales/mt/LC_MESSAGES/volto.po +576 -0
  21. package/locales/nl/LC_MESSAGES/volto.po +576 -0
  22. package/locales/nn/LC_MESSAGES/volto.po +576 -0
  23. package/locales/pl/LC_MESSAGES/volto.po +576 -0
  24. package/locales/pt/LC_MESSAGES/volto.po +576 -0
  25. package/locales/ro/LC_MESSAGES/volto.po +576 -0
  26. package/locales/sk/LC_MESSAGES/volto.po +576 -0
  27. package/locales/sl/LC_MESSAGES/volto.po +576 -0
  28. package/locales/sv/LC_MESSAGES/volto.po +576 -0
  29. package/locales/tr/LC_MESSAGES/volto.po +576 -0
  30. package/locales/volto.pot +577 -1
  31. package/package.json +1 -1
  32. package/src/components/Search/NavigatorCatalogue/NavigatorCatalogueCardItem.jsx +224 -0
  33. package/src/components/Search/NavigatorCatalogue/NavigatorCatalogueCardItem.test.jsx +173 -0
  34. package/src/components/Search/NavigatorCatalogue/NavigatorCatalogueContentView.jsx +189 -0
  35. package/src/components/Search/NavigatorCatalogue/NavigatorCatalogueMapView.jsx +272 -0
  36. package/src/components/Search/NavigatorCatalogue/NavigatorCatalogueMapView.test.jsx +160 -0
  37. package/src/components/Search/NavigatorCatalogue/utils.js +157 -0
  38. package/src/components/Search/NavigatorCatalogue/utils.test.js +132 -0
  39. package/src/components/Search/NavigatorGuide/NavigatorGuideContentView.jsx +409 -0
  40. package/src/components/Search/NavigatorGuide/NavigatorGuideLayout.jsx +8 -0
  41. package/src/components/index.js +2 -0
  42. package/src/components/theme/CompareTools/CompareToolsPanel.jsx +151 -0
  43. package/src/components/theme/CompareTools/CompareToolsPanel.test.jsx +96 -0
  44. package/src/components/theme/CompareTools/CompareToolsView.jsx +475 -0
  45. package/src/components/theme/CompareTools/CompareToolsView.test.jsx +110 -0
  46. package/src/components/theme/CompareTools/utils.js +111 -0
  47. package/src/components/theme/CompareTools/utils.test.jsx +217 -0
  48. package/src/components/theme/Views/ExtendedToolView.jsx +201 -201
  49. package/src/components/theme/Views/ExtendedToolView.test.jsx +420 -58
  50. package/src/constants.js +0 -48
  51. package/src/customizations/volto/reducers/breadcrumbs/breadcrumbs.js +1 -1
  52. package/src/helpers/Utils.jsx +29 -0
  53. package/src/helpers/index.js +3 -0
  54. package/src/index.js +28 -2
  55. package/src/search/index.js +6 -0
  56. package/src/search/navigator_catalogue/config.js +89 -0
  57. package/src/search/navigator_catalogue/facets.js +86 -0
  58. package/src/search/navigator_catalogue/views.js +28 -0
  59. package/src/search/navigator_guide/config.js +70 -0
  60. package/src/search/navigator_guide/facets.js +31 -0
  61. package/src/search/navigator_guide/guideSteps.js +93 -0
  62. package/src/slate-styles.less +4 -0
  63. package/src/state.js +1 -0
  64. package/theme/elements/button.overrides +0 -11
  65. package/theme/elements/label.overrides +15 -0
  66. package/theme/globals/blocks.less +3 -2
  67. package/theme/globals/navigator.less +1090 -0
  68. package/theme/globals/site.overrides +5 -0
  69. package/theme/tokens/colors.less +2 -0
  70. package/artifacts/BLOCKS.md +0 -167
  71. package/artifacts/link-integrity-workflow/README.md +0 -76
  72. package/artifacts/link-integrity-workflow/index.md +0 -33
  73. package/artifacts/link-integrity-workflow/link-integrity-block-fields-report.md +0 -132
  74. package/artifacts/link-integrity-workflow/link-integrity-blocks-report.md +0 -52
  75. package/artifacts/link-integrity-workflow/understanding-link-integrity.md +0 -143
  76. package/artifacts/link-integrity-workflow/volto-block-link-analysis.md +0 -63
  77. package/artifacts/link-integrity-workflow/volto-block-link-discovery.md +0 -60
  78. package/artifacts/test-fixes/test-fixes-specification.md +0 -267
@@ -0,0 +1,475 @@
1
+ import React from 'react';
2
+ import { useAtom } from 'jotai';
3
+ import { useDispatch, useSelector } from 'react-redux';
4
+ import { useHistory, useLocation } from 'react-router-dom';
5
+ import {
6
+ Container,
7
+ Icon,
8
+ Loader,
9
+ Message,
10
+ Table,
11
+ Button,
12
+ } from 'semantic-ui-react';
13
+ import Helmet from '@plone/volto/helpers/Helmet/Helmet';
14
+ import BodyClass from '@plone/volto/helpers/BodyClass/BodyClass';
15
+ import UniversalLink from '@plone/volto/components/manage/UniversalLink/UniversalLink';
16
+ import { GET_BREADCRUMBS } from '@plone/volto/constants/ActionTypes';
17
+ import config from '@plone/volto/registry';
18
+ import { defineMessages, useIntl } from 'react-intl';
19
+ import BannerTitle from '../BannerTitle/BannerTitle';
20
+ import {
21
+ MAX_COMPARE_TOOLS,
22
+ compareToolsAtom,
23
+ fetchResultsByUid,
24
+ getCompareToolUid,
25
+ getPathname,
26
+ } from './utils';
27
+ import {
28
+ asArray,
29
+ exportComparisonTable,
30
+ formatFunctionalityScore,
31
+ getLocalizedLandingPageURL,
32
+ } from '../../Search/NavigatorCatalogue/utils';
33
+
34
+ const messages = defineMessages({
35
+ compareTools: {
36
+ id: 'Compare tools',
37
+ defaultMessage: 'Compare tools',
38
+ },
39
+ navigator: {
40
+ id: 'Navigator',
41
+ defaultMessage: 'Navigator',
42
+ },
43
+ comparingTools: {
44
+ id: 'Comparing {count} {count, plural, one {tool} other {tools}}',
45
+ defaultMessage:
46
+ 'Comparing {count} {count, plural, one {tool} other {tools}}',
47
+ },
48
+ notEnoughTools: {
49
+ id: 'At least two valid tools are required for comparison. Select tools from the Navigator Catalogue.',
50
+ defaultMessage:
51
+ 'At least two valid tools are required for comparison. Select tools from the Navigator Catalogue.',
52
+ },
53
+ toolsSkipped: {
54
+ id: 'Some selected tools could not be loaded and were skipped.',
55
+ defaultMessage: 'Some selected tools could not be loaded and were skipped.',
56
+ },
57
+ allToolsFailed: {
58
+ id: 'The selected tools could not be loaded. Return to the Navigator Catalogue and select them again.',
59
+ defaultMessage:
60
+ 'The selected tools could not be loaded. Return to the Navigator Catalogue and select them again.',
61
+ },
62
+ criteria: {
63
+ id: 'Criteria',
64
+ defaultMessage: 'Criteria',
65
+ },
66
+ openTool: {
67
+ id: 'Open tool',
68
+ defaultMessage: 'Open tool',
69
+ },
70
+ removeTool: {
71
+ id: 'Remove {title} from comparison',
72
+ defaultMessage: 'Remove {title} from comparison',
73
+ },
74
+ backToResults: {
75
+ id: 'Back to results',
76
+ defaultMessage: 'Back to results',
77
+ },
78
+ exportTable: {
79
+ id: 'Export table',
80
+ defaultMessage: 'Export table',
81
+ },
82
+ usability: {
83
+ id: 'Usability',
84
+ defaultMessage: 'Usability',
85
+ },
86
+ functionality: {
87
+ id: 'Functionality',
88
+ defaultMessage: 'Functionality',
89
+ },
90
+ spatialScale: {
91
+ id: 'Spatial scale',
92
+ defaultMessage: 'Spatial scale',
93
+ },
94
+ outputType: {
95
+ id: 'Output type',
96
+ defaultMessage: 'Output type',
97
+ },
98
+ adaptationSupportCycleStep: {
99
+ id: 'Adaptation support cycle step',
100
+ defaultMessage: 'Adaptation support cycle step',
101
+ },
102
+ sector: {
103
+ id: 'Sector',
104
+ defaultMessage: 'Sector',
105
+ },
106
+ });
107
+
108
+ const getToolField = (tool, field) =>
109
+ tool.result?.[field] ?? tool.result?._result?.[field];
110
+
111
+ const getToolFieldDisplay = (tool, field) =>
112
+ asArray(getToolField(tool, field)).join(', ') || '—';
113
+
114
+ const getCompareUids = (search) => {
115
+ const params = new URLSearchParams(search);
116
+
117
+ return [...new Set(params.getAll('uid').filter(Boolean))].slice(
118
+ 0,
119
+ MAX_COMPARE_TOOLS,
120
+ );
121
+ };
122
+
123
+ const getReturnURL = (search) => {
124
+ const returnURL = new URLSearchParams(search).get('return_url');
125
+
126
+ return returnURL?.startsWith('/') && !returnURL.startsWith('//')
127
+ ? returnURL
128
+ : '';
129
+ };
130
+
131
+ const getToolTitle = (result, fallback) =>
132
+ result?.title || result?._result?.title?.raw || fallback;
133
+
134
+ const getToolHref = (result) => result?.href || result?._result?.id?.raw || '';
135
+
136
+ const getTools = async (uids, registry) => {
137
+ try {
138
+ const results = await fetchResultsByUid(uids, registry);
139
+ const resultsByUid = new Map(
140
+ results.map((result) => [getCompareToolUid(result), result]),
141
+ );
142
+
143
+ return uids.map((uid) => {
144
+ const result = resultsByUid.get(uid);
145
+
146
+ return {
147
+ id: uid,
148
+ title: getToolTitle(result, uid),
149
+ href: getToolHref(result),
150
+ result,
151
+ error: !result,
152
+ };
153
+ });
154
+ } catch {
155
+ return uids.map((uid) => ({
156
+ id: uid,
157
+ title: uid,
158
+ error: true,
159
+ }));
160
+ }
161
+ };
162
+
163
+ const CompareToolsView = () => {
164
+ const intl = useIntl();
165
+ const dispatch = useDispatch();
166
+ const history = useHistory();
167
+ const location = useLocation();
168
+ const currentLang = useSelector((state) => state.intl.locale);
169
+ const [, setSelectedTools] = useAtom(compareToolsAtom);
170
+ const ids = React.useMemo(
171
+ () => getCompareUids(location.search),
172
+ [location.search],
173
+ );
174
+ const registry = config.settings.searchlib;
175
+ const appConfig = registry.searchui.navigatorCatalogueSearch;
176
+ const landingPageURL = getLocalizedLandingPageURL(appConfig, currentLang);
177
+ const compareToolsTitle = intl.formatMessage(messages.compareTools);
178
+ const returnURL =
179
+ location.state?.returnURL ||
180
+ getReturnURL(location.search) ||
181
+ landingPageURL;
182
+ const backURL =
183
+ getPathname(returnURL) === getPathname(landingPageURL)
184
+ ? returnURL
185
+ : landingPageURL;
186
+ const breadcrumbParentTitle = intl.formatMessage(messages.navigator);
187
+ const breadcrumbParentURL = backURL;
188
+ const [tools, setTools] = React.useState([]);
189
+ const [isLoading, setIsLoading] = React.useState(false);
190
+
191
+ React.useEffect(() => {
192
+ dispatch({ type: `${GET_BREADCRUMBS}_PENDING` });
193
+ dispatch({
194
+ type: `${GET_BREADCRUMBS}_SUCCESS`,
195
+ manual: true,
196
+ result: {
197
+ root: `/${currentLang}`,
198
+ items: [
199
+ {
200
+ title: breadcrumbParentTitle,
201
+ '@id': breadcrumbParentURL,
202
+ },
203
+ {
204
+ title: compareToolsTitle,
205
+ '@id': location.pathname,
206
+ },
207
+ ],
208
+ },
209
+ });
210
+ }, [
211
+ breadcrumbParentTitle,
212
+ breadcrumbParentURL,
213
+ compareToolsTitle,
214
+ currentLang,
215
+ dispatch,
216
+ location.pathname,
217
+ ]);
218
+
219
+ React.useEffect(() => {
220
+ let ignore = false;
221
+
222
+ const loadTools = async () => {
223
+ setIsLoading(true);
224
+ const results = await getTools(ids, registry);
225
+
226
+ if (!ignore) {
227
+ setTools(results);
228
+ setIsLoading(false);
229
+ }
230
+ };
231
+
232
+ if (ids.length >= 2) {
233
+ loadTools();
234
+ } else {
235
+ setTools([]);
236
+ setIsLoading(false);
237
+ }
238
+
239
+ return () => {
240
+ ignore = true;
241
+ };
242
+ }, [ids, registry]);
243
+
244
+ const failedTools = tools.filter((tool) => tool.error);
245
+ const visibleTools = tools.filter((tool) => !tool.error);
246
+ const visibleToolsCount = visibleTools.length;
247
+ const hasLoadedRequestedTools = tools.length === ids.length;
248
+ const hasEnoughTools = visibleToolsCount >= 2;
249
+ const allRequestedToolsFailed =
250
+ ids.length >= 2 &&
251
+ hasLoadedRequestedTools &&
252
+ failedTools.length === ids.length;
253
+ const someRequestedToolsFailed =
254
+ failedTools.length > 0 && !allRequestedToolsFailed;
255
+ const showNotEnoughTools =
256
+ ids.length < 2 ||
257
+ (!isLoading &&
258
+ hasLoadedRequestedTools &&
259
+ !hasEnoughTools &&
260
+ !allRequestedToolsFailed);
261
+
262
+ const removeTool = (toolId) => {
263
+ const params = new URLSearchParams(location.search);
264
+ const remainingIds = ids.filter((uid) => uid !== toolId);
265
+
266
+ params.delete('uid');
267
+ remainingIds.forEach((uid) => params.append('uid', uid));
268
+
269
+ setSelectedTools((selectedTools) =>
270
+ selectedTools.filter((tool) => tool.uid !== toolId),
271
+ );
272
+
273
+ history.push({
274
+ pathname: location.pathname,
275
+ search: params.toString() ? `?${params.toString()}` : '',
276
+ hash: location.hash,
277
+ state: location.state,
278
+ });
279
+ };
280
+
281
+ return (
282
+ <div className="navigator-catalogue-compare-view">
283
+ <Helmet title={compareToolsTitle} />
284
+ <BodyClass className="navigator-catalogue-compare-page" />
285
+ <BannerTitle content={{ title: compareToolsTitle }} />
286
+
287
+ <Container>
288
+ <div className="compare-page-header">
289
+ <h3>
290
+ {intl.formatMessage(messages.comparingTools, {
291
+ count: visibleToolsCount,
292
+ })}
293
+ </h3>
294
+
295
+ <div className="compare-page-actions">
296
+ <Button onClick={() => history.push(backURL)}>
297
+ <Icon className="ri-arrow-left-line" />
298
+ {intl.formatMessage(messages.backToResults)}
299
+ </Button>
300
+ <Button
301
+ className="primary inverted"
302
+ disabled={!hasEnoughTools}
303
+ onClick={() => exportComparisonTable(visibleTools, getToolField)}
304
+ >
305
+ <Icon className="ri-download-2-line" />
306
+ {intl.formatMessage(messages.exportTable)}
307
+ </Button>
308
+ </div>
309
+ </div>
310
+
311
+ {showNotEnoughTools && (
312
+ <Message>{intl.formatMessage(messages.notEnoughTools)}</Message>
313
+ )}
314
+
315
+ {isLoading && <Loader active inline="centered" />}
316
+
317
+ {!isLoading && allRequestedToolsFailed && (
318
+ <Message error>{intl.formatMessage(messages.allToolsFailed)}</Message>
319
+ )}
320
+
321
+ {!isLoading && someRequestedToolsFailed && (
322
+ <Message warning>{intl.formatMessage(messages.toolsSkipped)}</Message>
323
+ )}
324
+
325
+ {!isLoading && hasEnoughTools && (
326
+ <Table celled aria-label={compareToolsTitle} unstackable>
327
+ <Table.Header>
328
+ <Table.Row>
329
+ <Table.HeaderCell>
330
+ <span className="compare-criteria-label">
331
+ {intl.formatMessage(messages.criteria)}
332
+ </span>
333
+ </Table.HeaderCell>
334
+ {visibleTools.map((tool) => (
335
+ <Table.HeaderCell key={tool.id}>
336
+ <div className="compare-tool-header">
337
+ <div className="compare-tool-title-row">
338
+ <div
339
+ className="navigator-tool-icon medium"
340
+ aria-hidden="true"
341
+ >
342
+ <Icon className="ri-file-line" />
343
+ </div>
344
+ <div className="compare-tool-title" title={tool.title}>
345
+ {tool.title}
346
+ </div>
347
+ </div>
348
+ <div className="compare-tool-actions">
349
+ {tool.href && (
350
+ <UniversalLink
351
+ className="ui button primary icon compare-tool-open"
352
+ href={tool.href}
353
+ >
354
+ {intl.formatMessage(messages.openTool)}
355
+ <Icon className="ri-external-link-line" />
356
+ </UniversalLink>
357
+ )}
358
+ <Button
359
+ className="icon compare-tool-clear"
360
+ aria-label={intl.formatMessage(messages.removeTool, {
361
+ title: tool.title,
362
+ })}
363
+ onClick={() => removeTool(tool.id)}
364
+ >
365
+ <Icon className="ri-close-line" aria-hidden="true" />
366
+ </Button>
367
+ </div>
368
+ </div>
369
+ </Table.HeaderCell>
370
+ ))}
371
+ </Table.Row>
372
+ </Table.Header>
373
+ <Table.Body>
374
+ <Table.Row>
375
+ <Table.Cell as="th" scope="row">
376
+ <div className="compare-criteria">
377
+ <div className="compare-criteria-title">
378
+ {intl.formatMessage(messages.usability)}
379
+ </div>
380
+ </div>
381
+ </Table.Cell>
382
+ {visibleTools.map((tool) => (
383
+ <Table.Cell key={`usability-${tool.id}`}>
384
+ <div className="usability-value">
385
+ {getToolFieldDisplay(tool, 'accessibility_and_usability')}
386
+ </div>
387
+ </Table.Cell>
388
+ ))}
389
+ </Table.Row>
390
+ <Table.Row>
391
+ <Table.Cell as="th" scope="row">
392
+ <div className="compare-criteria">
393
+ <div className="compare-criteria-title">
394
+ {intl.formatMessage(messages.functionality)}
395
+ </div>
396
+ </div>
397
+ </Table.Cell>
398
+ {visibleTools.map((tool) => (
399
+ <Table.Cell key={`functionality-${tool.id}`}>
400
+ <div className="functionality-value">
401
+ {formatFunctionalityScore(
402
+ getToolField(tool, 'functionality'),
403
+ )}
404
+ </div>
405
+ </Table.Cell>
406
+ ))}
407
+ </Table.Row>
408
+ <Table.Row>
409
+ <Table.Cell as="th" scope="row">
410
+ <div className="compare-criteria">
411
+ <div className="compare-criteria-title">
412
+ {intl.formatMessage(messages.spatialScale)}
413
+ </div>
414
+ </div>
415
+ </Table.Cell>
416
+ {visibleTools.map((tool) => (
417
+ <Table.Cell key={`spatial-scale-${tool.id}`}>
418
+ {getToolFieldDisplay(tool, 'cca_geographical_scale')}
419
+ </Table.Cell>
420
+ ))}
421
+ </Table.Row>
422
+ <Table.Row>
423
+ <Table.Cell as="th" scope="row">
424
+ <div className="compare-criteria">
425
+ <div className="compare-criteria-title">
426
+ {intl.formatMessage(messages.outputType)}
427
+ </div>
428
+ </div>
429
+ </Table.Cell>
430
+ {visibleTools.map((tool) => (
431
+ <Table.Cell key={`output-type-${tool.id}`}>
432
+ {getToolFieldDisplay(tool, 'cca_type_of_outputs')}
433
+ </Table.Cell>
434
+ ))}
435
+ </Table.Row>
436
+ <Table.Row>
437
+ <Table.Cell as="th" scope="row">
438
+ <div className="compare-criteria">
439
+ <div className="compare-criteria-title">
440
+ {intl.formatMessage(messages.adaptationSupportCycleStep)}
441
+ </div>
442
+ </div>
443
+ </Table.Cell>
444
+ {visibleTools.map((tool) => (
445
+ <Table.Cell key={`adaptation-support-cycle-step-${tool.id}`}>
446
+ {getToolFieldDisplay(
447
+ tool,
448
+ 'cca_adaptation_support_cycle_step',
449
+ )}
450
+ </Table.Cell>
451
+ ))}
452
+ </Table.Row>
453
+ <Table.Row>
454
+ <Table.Cell as="th" scope="row">
455
+ <div className="compare-criteria">
456
+ <div className="compare-criteria-title">
457
+ {intl.formatMessage(messages.sector)}
458
+ </div>
459
+ </div>
460
+ </Table.Cell>
461
+ {visibleTools.map((tool) => (
462
+ <Table.Cell key={`sector-${tool.id}`}>
463
+ {getToolFieldDisplay(tool, 'cca_adaptation_sectors')}
464
+ </Table.Cell>
465
+ ))}
466
+ </Table.Row>
467
+ </Table.Body>
468
+ </Table>
469
+ )}
470
+ </Container>
471
+ </div>
472
+ );
473
+ };
474
+
475
+ export default CompareToolsView;
@@ -0,0 +1,110 @@
1
+ import React from 'react';
2
+ import '@testing-library/jest-dom';
3
+ import { render, screen } from '@testing-library/react';
4
+ import { IntlProvider } from 'react-intl';
5
+ import { useAtom } from 'jotai';
6
+ import { useDispatch, useSelector } from 'react-redux';
7
+ import { useHistory, useLocation } from 'react-router-dom';
8
+ import CompareToolsView from './CompareToolsView';
9
+ import { fetchResultsByUid } from './utils';
10
+
11
+ jest.mock('jotai', () => ({
12
+ ...jest.requireActual('jotai'),
13
+ useAtom: jest.fn(),
14
+ }));
15
+
16
+ jest.mock('react-redux', () => ({
17
+ useDispatch: jest.fn(),
18
+ useSelector: jest.fn(),
19
+ }));
20
+
21
+ jest.mock('react-router-dom', () => ({
22
+ useHistory: jest.fn(),
23
+ useLocation: jest.fn(),
24
+ }));
25
+
26
+ jest.mock('@plone/volto/helpers/Helmet/Helmet', () => () => null);
27
+ jest.mock('@plone/volto/helpers/BodyClass/BodyClass', () => () => null);
28
+ jest.mock('../BannerTitle/BannerTitle', () => () => null);
29
+ jest.mock(
30
+ '@plone/volto/components/manage/UniversalLink/UniversalLink',
31
+ () =>
32
+ ({ children, href, ...props }) => (
33
+ <a href={href} {...props}>
34
+ {children}
35
+ </a>
36
+ ),
37
+ );
38
+ jest.mock('@plone/volto/registry', () => ({
39
+ __esModule: true,
40
+ default: {
41
+ settings: {
42
+ searchlib: {
43
+ searchui: {
44
+ navigatorCatalogueSearch: {
45
+ landingPageURL: '/en/navigator/tool-catalogue',
46
+ },
47
+ },
48
+ },
49
+ },
50
+ },
51
+ }));
52
+
53
+ jest.mock('./utils', () => ({
54
+ MAX_COMPARE_TOOLS: 4,
55
+ compareToolsAtom: {},
56
+ fetchResultsByUid: jest.fn(),
57
+ getCompareToolUid: (result) => result.cca_uid?.raw || '',
58
+ getPathname: (url) => url?.split('?')[0] || '',
59
+ }));
60
+
61
+ describe('CompareToolsView accessibility', () => {
62
+ beforeEach(() => {
63
+ jest.clearAllMocks();
64
+ useAtom.mockReturnValue([[], jest.fn()]);
65
+ useDispatch.mockReturnValue(jest.fn());
66
+ useSelector.mockReturnValue('en');
67
+ useHistory.mockReturnValue({ push: jest.fn() });
68
+ useLocation.mockReturnValue({
69
+ pathname: '/en/navigator/compare',
70
+ search: '?uid=one&uid=two',
71
+ hash: '',
72
+ state: {},
73
+ });
74
+ fetchResultsByUid.mockResolvedValue([
75
+ {
76
+ cca_uid: { raw: 'one' },
77
+ title: 'Tool one',
78
+ href: '/tool-one',
79
+ },
80
+ {
81
+ cca_uid: { raw: 'two' },
82
+ title: 'Tool two',
83
+ href: '/tool-two',
84
+ },
85
+ ]);
86
+ });
87
+
88
+ it('labels the table, row headers, and remove actions', async () => {
89
+ render(
90
+ <IntlProvider locale="en">
91
+ <CompareToolsView />
92
+ </IntlProvider>,
93
+ );
94
+
95
+ expect(
96
+ await screen.findByRole('table', { name: 'Compare tools' }),
97
+ ).toBeInTheDocument();
98
+ expect(
99
+ screen.getByRole('rowheader', { name: 'Usability' }),
100
+ ).toHaveAttribute('scope', 'row');
101
+ expect(
102
+ screen.getByRole('rowheader', { name: 'Functionality' }),
103
+ ).toHaveAttribute('scope', 'row');
104
+ expect(
105
+ screen.getByRole('button', {
106
+ name: 'Remove Tool one from comparison',
107
+ }),
108
+ ).toBeInTheDocument();
109
+ });
110
+ });
@@ -0,0 +1,111 @@
1
+ import { useAtom } from 'jotai';
2
+ import { atomWithStorage } from 'jotai/utils';
3
+ import { applyConfigurationSchema, rebind } from '@eeacms/search';
4
+ import runRequest from '@eeacms/search/lib/runRequest';
5
+ import { flattenToAppURL } from '@plone/volto/helpers/Url/Url';
6
+ import { getComparePageURL } from '../../Search/NavigatorCatalogue/utils';
7
+
8
+ export const MAX_COMPARE_TOOLS = 4;
9
+ export const compareToolsAtom = atomWithStorage('cca-compare-tools', []);
10
+ const searchAppName = 'navigatorCatalogueSearch';
11
+
12
+ const getRawValue = (value) =>
13
+ value?.raw !== undefined ? value.raw : value || '';
14
+
15
+ export const getCompareToolUid = (result) =>
16
+ getRawValue(result.cca_uid || result.UID || result._result?.cca_uid);
17
+
18
+ export const getCompareToolTitle = (result) => result.title || '';
19
+
20
+ export const getPathname = (url) => {
21
+ if (!url) return '';
22
+
23
+ const pathname = flattenToAppURL(url).split('#')[0].split('?')[0];
24
+
25
+ return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
26
+ };
27
+
28
+ export const useCompareTools = (compareTool) => {
29
+ const [selectedTools, setSelectedTools] = useAtom(compareToolsAtom);
30
+ const isSelected = selectedTools.some(
31
+ (tool) => tool.uid === compareTool?.uid,
32
+ );
33
+ const isLimitReached =
34
+ selectedTools.length >= MAX_COMPARE_TOOLS && !isSelected;
35
+
36
+ const setSelected = (selected) => {
37
+ if (!compareTool?.uid) return;
38
+
39
+ setSelectedTools((tools) => {
40
+ if (!selected) {
41
+ return tools.filter((tool) => tool.uid !== compareTool.uid);
42
+ }
43
+
44
+ if (
45
+ tools.some((tool) => tool.uid === compareTool.uid) ||
46
+ tools.length >= MAX_COMPARE_TOOLS
47
+ ) {
48
+ return tools;
49
+ }
50
+
51
+ return [...tools, compareTool];
52
+ });
53
+ };
54
+
55
+ return {
56
+ isSelected,
57
+ isLimitReached,
58
+ setSelected,
59
+ toggle: () => setSelected(!isSelected),
60
+ };
61
+ };
62
+
63
+ export const getCompareLocation = (
64
+ tools,
65
+ appConfig,
66
+ returnURL,
67
+ currentLang = 'en',
68
+ ) => {
69
+ const params = new URLSearchParams();
70
+
71
+ tools.slice(0, MAX_COMPARE_TOOLS).forEach((tool) => {
72
+ if (tool.uid) {
73
+ params.append('uid', tool.uid);
74
+ }
75
+ });
76
+
77
+ const query = params.toString();
78
+ const path = getComparePageURL(appConfig, currentLang);
79
+
80
+ return {
81
+ pathname: path,
82
+ search: query ? `?${query}` : '',
83
+ state: { returnURL },
84
+ };
85
+ };
86
+
87
+ export const fetchResultsByUid = async (uids, registry) => {
88
+ const appConfig = applyConfigurationSchema(
89
+ rebind(registry.searchui[searchAppName]),
90
+ );
91
+ const response = await runRequest(
92
+ {
93
+ ...(appConfig.index_name ? { index: appConfig.index_name } : {}),
94
+ size: uids.length,
95
+ query: {
96
+ bool: {
97
+ minimum_should_match: 1,
98
+ should: [
99
+ { terms: { 'cca_uid.keyword': uids } },
100
+ { terms: { cca_uid: uids } },
101
+ ],
102
+ },
103
+ },
104
+ },
105
+ appConfig,
106
+ );
107
+ const hits = response.body?.hits?.hits || [];
108
+ const Model = registry.resolve[appConfig.resultItemModel.factory];
109
+
110
+ return hits.map((hit) => new Model(hit, appConfig));
111
+ };