@eeacms/volto-cca-policy 1.0.2 → 1.0.3

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 (38) hide show
  1. package/CHANGELOG.md +231 -16
  2. package/jest-addon.config.js +4 -2
  3. package/package.json +1 -1
  4. package/src/components/Search/NavigatorCatalogue/NavigatorCatalogueCardItem.jsx +228 -0
  5. package/src/components/Search/NavigatorCatalogue/NavigatorCatalogueCardItem.test.jsx +167 -0
  6. package/src/components/Search/NavigatorCatalogue/NavigatorCatalogueContentView.jsx +189 -0
  7. package/src/components/Search/NavigatorCatalogue/NavigatorCatalogueMapView.jsx +11 -0
  8. package/src/components/Search/NavigatorCatalogue/utils.js +101 -0
  9. package/src/components/Search/NavigatorCatalogue/utils.test.js +132 -0
  10. package/src/components/Search/NavigatorGuide/NavigatorGuideContentView.jsx +409 -0
  11. package/src/components/Search/NavigatorGuide/NavigatorGuideLayout.jsx +8 -0
  12. package/src/components/index.js +2 -0
  13. package/src/components/theme/CompareTools/CompareToolsPanel.jsx +151 -0
  14. package/src/components/theme/CompareTools/CompareToolsPanel.test.jsx +96 -0
  15. package/src/components/theme/CompareTools/CompareToolsView.jsx +468 -0
  16. package/src/components/theme/CompareTools/utils.js +111 -0
  17. package/src/components/theme/CompareTools/utils.test.jsx +217 -0
  18. package/src/components/theme/Views/ExtendedToolView.jsx +201 -201
  19. package/src/components/theme/Views/ExtendedToolView.test.jsx +420 -58
  20. package/src/customizations/volto/reducers/breadcrumbs/breadcrumbs.js +1 -1
  21. package/src/helpers/Utils.jsx +29 -0
  22. package/src/helpers/index.js +3 -0
  23. package/src/index.js +28 -2
  24. package/src/search/index.js +6 -0
  25. package/src/search/navigator_catalogue/config.js +89 -0
  26. package/src/search/navigator_catalogue/facets.js +86 -0
  27. package/src/search/navigator_catalogue/views.js +28 -0
  28. package/src/search/navigator_guide/config.js +70 -0
  29. package/src/search/navigator_guide/facets.js +31 -0
  30. package/src/search/navigator_guide/guideSteps.js +93 -0
  31. package/src/slate-styles.less +4 -0
  32. package/src/state.js +1 -0
  33. package/theme/elements/button.overrides +0 -11
  34. package/theme/elements/label.overrides +15 -0
  35. package/theme/globals/blocks.less +3 -2
  36. package/theme/globals/navigator.less +967 -0
  37. package/theme/globals/site.overrides +5 -0
  38. package/theme/tokens/colors.less +2 -0
@@ -0,0 +1,409 @@
1
+ import React from 'react';
2
+ import { useAtom } from 'jotai';
3
+ import { useSelector } from 'react-redux';
4
+ import { useHistory } from 'react-router-dom';
5
+ import { defineMessages, useIntl } from 'react-intl';
6
+ import { Button, Checkbox, Icon, Loader, Message } from 'semantic-ui-react';
7
+ import URLManager from '@elastic/search-ui/lib/cjs/URLManager';
8
+ import { useSearchContext } from '@eeacms/search/lib/hocs';
9
+ import guideSteps from '../../../search/navigator_guide/guideSteps';
10
+ import { navigatorGuideStepAtom } from '../../../state';
11
+
12
+ const messages = defineMessages({
13
+ noSteps: {
14
+ id: 'No Navigator Guide steps are configured.',
15
+ defaultMessage: 'No Navigator Guide steps are configured.',
16
+ },
17
+ guideMe: {
18
+ id: 'Guide me',
19
+ defaultMessage: 'Guide me',
20
+ },
21
+ findTheRightTool: {
22
+ id: 'Find the right tool',
23
+ defaultMessage: 'Find the right tool',
24
+ },
25
+ introduction: {
26
+ id: 'Navigator Guide introduction',
27
+ defaultMessage:
28
+ 'Answer a few guided questions. The catalogue on the right narrows as you go - you can skip to the results at any point.',
29
+ },
30
+ stepProgress: {
31
+ id: 'Step {current} of {total}',
32
+ defaultMessage: 'Step {current} of {total}',
33
+ },
34
+ selectAllThatApply: {
35
+ id: 'Select all that apply',
36
+ defaultMessage: 'Select all that apply',
37
+ },
38
+ noOptions: {
39
+ id: 'No options are available for this step.',
40
+ defaultMessage: 'No options are available for this step.',
41
+ },
42
+ back: {
43
+ id: 'Back',
44
+ defaultMessage: 'Back',
45
+ },
46
+ skipToResults: {
47
+ id: 'Skip to results',
48
+ defaultMessage: 'Skip to results',
49
+ },
50
+ seeResults: {
51
+ id: 'See results',
52
+ defaultMessage: 'See results',
53
+ },
54
+ nextStep: {
55
+ id: 'Next step',
56
+ defaultMessage: 'Next step',
57
+ },
58
+ livePreview: {
59
+ id: 'Live preview · results based on your selection',
60
+ defaultMessage: 'Live preview · results based on your selection',
61
+ },
62
+ previewEmpty: {
63
+ id: 'Navigator Guide preview empty state',
64
+ defaultMessage:
65
+ 'Start by selecting the categories that interest you. The tools filtered by your chosen categories will appear in this box.',
66
+ },
67
+ toolsMatch: {
68
+ id: '{count, plural, one {tool matches} other {tools match}}',
69
+ defaultMessage: '{count, plural, one {tool matches} other {tools match}}',
70
+ },
71
+ refinedBy: {
72
+ id: 'Refined by {criteria}',
73
+ defaultMessage: 'Refined by {criteria}',
74
+ },
75
+ seeAllMatchingTools: {
76
+ id: 'See all matching tools',
77
+ defaultMessage: 'See all matching tools',
78
+ },
79
+ });
80
+
81
+ const getFacetOptions = (facets, field) =>
82
+ (facets?.[field]?.[0]?.data || []).map(({ value, count }) => ({
83
+ value,
84
+ count,
85
+ }));
86
+
87
+ const isStepSelected = (filters, field) =>
88
+ (filters || []).some(
89
+ (filter) => filter.field === field && filter.values?.length,
90
+ );
91
+
92
+ const previewTagTypes = {
93
+ adaptationSectors: 'sector',
94
+ climateImpacts: 'hazard',
95
+ adaptationStage: 'adaptation-stage',
96
+ coverage: 'coverage',
97
+ };
98
+
99
+ const NavigatorGuideContentView = ({ appConfig }) => {
100
+ const intl = useIntl();
101
+ const history = useHistory();
102
+ const currentLang = useSelector((state) => state.intl.locale || 'en');
103
+ const searchContext = useSearchContext();
104
+ const {
105
+ addFilter,
106
+ facets,
107
+ filters,
108
+ isLoading,
109
+ removeFilter,
110
+ results,
111
+ totalResults,
112
+ } = searchContext;
113
+ const steps = guideSteps;
114
+ const [storedActiveStep, setActiveStep] = useAtom(navigatorGuideStepAtom);
115
+ const activeStep =
116
+ Number.isInteger(storedActiveStep) &&
117
+ storedActiveStep >= 0 &&
118
+ storedActiveStep < steps.length
119
+ ? storedActiveStep
120
+ : 0;
121
+ const step = steps[activeStep];
122
+ const selectedValues =
123
+ (filters || []).find((filter) => filter.field === step?.field)?.values ||
124
+ [];
125
+ const options = getFacetOptions(facets, step?.field);
126
+ const isLastStep = activeStep === steps.length - 1;
127
+ const hasSelections = steps.some(({ field }) =>
128
+ isStepSelected(filters, field),
129
+ );
130
+ const selectedStepLabels = steps
131
+ .filter(({ field }) => isStepSelected(filters, field))
132
+ .map(({ label }) =>
133
+ intl.formatMessage(label).toLocaleLowerCase(currentLang),
134
+ );
135
+ const selectedPreviewTags = steps.flatMap((item) => {
136
+ const values =
137
+ (filters || []).find((filter) => filter.field === item.field)?.values ||
138
+ [];
139
+
140
+ return values.map((value) => ({
141
+ label: item.id === 'adaptationStage' ? value.split(':')[0] : value,
142
+ type: previewTagTypes[item.id],
143
+ }));
144
+ });
145
+ const visiblePreviewTags = selectedPreviewTags.slice(0, 3);
146
+ const remainingPreviewTags =
147
+ selectedPreviewTags.length - visiblePreviewTags.length;
148
+
149
+ React.useEffect(() => {
150
+ if (storedActiveStep !== activeStep) {
151
+ setActiveStep(activeStep);
152
+ }
153
+ }, [activeStep, setActiveStep, storedActiveStep]);
154
+
155
+ const toggleValue = (value) => {
156
+ if (selectedValues.includes(value)) {
157
+ removeFilter(step.field, value, step.filterType);
158
+ } else {
159
+ addFilter(step.field, value, step.filterType);
160
+ }
161
+ };
162
+
163
+ const showResults = () => {
164
+ const allowedFields = new Set([
165
+ 'language',
166
+ ...steps.map(({ field }) => field),
167
+ ]);
168
+ const resultFilters = (filters || []).filter(({ field }) =>
169
+ allowedFields.has(field),
170
+ );
171
+ const query = new URLManager().stateToUrl({ filters: resultFilters });
172
+ const pathname = (
173
+ appConfig.resultsPageURL || '/en/navigator/tool-catalogue'
174
+ ).replace(/^\/en(?=\/|$)/, `/${currentLang}`);
175
+
176
+ history.push({ pathname, search: query ? `?${query}` : '' });
177
+ };
178
+
179
+ if (!step) {
180
+ return <Message warning>{intl.formatMessage(messages.noSteps)}</Message>;
181
+ }
182
+
183
+ return (
184
+ <div className="navigator-guide-search">
185
+ <header className="navigator-guide-header">
186
+ <div className="navigator-guide-eyebrow">
187
+ {intl.formatMessage(messages.guideMe)}
188
+ </div>
189
+ <h2>
190
+ {appConfig.headline || intl.formatMessage(messages.findTheRightTool)}
191
+ </h2>
192
+ <p>
193
+ {appConfig.subheadline || intl.formatMessage(messages.introduction)}
194
+ </p>
195
+ </header>
196
+
197
+ <div className="navigator-guide-layout">
198
+ <section className="navigator-guide-wizard">
199
+ <div className="navigator-guide-progress">
200
+ {steps.map((item, index) => (
201
+ <React.Fragment key={item.id}>
202
+ <Button
203
+ className={`navigator-guide-progress-step${
204
+ index === activeStep ? ' active' : ''
205
+ }${index < activeStep ? ' completed' : ''}`}
206
+ aria-current={index === activeStep ? 'step' : undefined}
207
+ onClick={() => setActiveStep(index)}
208
+ >
209
+ <span>
210
+ {isStepSelected(filters, item.field) ? (
211
+ <Icon className="ri-check-line" />
212
+ ) : (
213
+ index + 1
214
+ )}
215
+ </span>
216
+ {intl.formatMessage(item.label)}
217
+ </Button>
218
+ {index < steps.length - 1 && (
219
+ <span
220
+ className={`navigator-guide-progress-connector${
221
+ index < activeStep ? ' completed' : ''
222
+ }`}
223
+ aria-hidden="true"
224
+ />
225
+ )}
226
+ </React.Fragment>
227
+ ))}
228
+ </div>
229
+ <div
230
+ className="navigator-guide-progress-bar"
231
+ role="progressbar"
232
+ aria-valuemin="1"
233
+ aria-valuemax={steps.length}
234
+ aria-valuenow={activeStep + 1}
235
+ >
236
+ <div
237
+ className="navigator-guide-progress-bar-fill"
238
+ style={{ width: `${((activeStep + 1) / steps.length) * 100}%` }}
239
+ />
240
+ </div>
241
+
242
+ <div className="navigator-guide-step-meta">
243
+ <span className="navigator-guide-step-number">
244
+ {intl.formatMessage(messages.stepProgress, {
245
+ current: activeStep + 1,
246
+ total: steps.length,
247
+ })}
248
+ </span>
249
+ <span>{intl.formatMessage(messages.selectAllThatApply)}</span>
250
+ </div>
251
+ <h3>{intl.formatMessage(step.title)}</h3>
252
+ {step.description && <p>{intl.formatMessage(step.description)}</p>}
253
+
254
+ {isLoading ? (
255
+ <div className="navigator-guide-options-loading">
256
+ <Loader active inline />
257
+ </div>
258
+ ) : options.length > 0 ? (
259
+ <div className="navigator-guide-options">
260
+ {options.map((option) => (
261
+ <label
262
+ key={option.value}
263
+ className={`navigator-guide-option${
264
+ selectedValues.includes(option.value) ? ' selected' : ''
265
+ }`}
266
+ >
267
+ <Checkbox
268
+ checked={selectedValues.includes(option.value)}
269
+ onChange={() => toggleValue(option.value)}
270
+ />
271
+ <span>{option.value}</span>
272
+ <small>{option.count}</small>
273
+ </label>
274
+ ))}
275
+ </div>
276
+ ) : (
277
+ <Message>{intl.formatMessage(messages.noOptions)}</Message>
278
+ )}
279
+
280
+ <div className="navigator-guide-actions">
281
+ <Button
282
+ disabled={activeStep === 0}
283
+ onClick={() => setActiveStep((value) => value - 1)}
284
+ >
285
+ <Icon className="ri-arrow-left-line" />
286
+ {intl.formatMessage(messages.back)}
287
+ </Button>
288
+ <div>
289
+ <Button className="primary inverted" onClick={showResults}>
290
+ {intl.formatMessage(messages.skipToResults)}
291
+ </Button>
292
+ <Button
293
+ labelPosition="right"
294
+ className="primary icon"
295
+ onClick={() =>
296
+ isLastStep
297
+ ? showResults()
298
+ : setActiveStep((value) => value + 1)
299
+ }
300
+ >
301
+ {intl.formatMessage(
302
+ isLastStep ? messages.seeResults : messages.nextStep,
303
+ )}
304
+ <Icon className="ri-arrow-right-line" />
305
+ </Button>
306
+ </div>
307
+ </div>
308
+ </section>
309
+
310
+ <aside className="navigator-guide-preview">
311
+ <div className="navigator-guide-preview-header">
312
+ <Icon className="ri-stack-line" />
313
+ <div className="navigator-guide-preview-label">
314
+ {intl.formatMessage(messages.livePreview)}
315
+ </div>
316
+ </div>
317
+ <div className="navigator-guide-result-count">
318
+ <span className="navigator-guide-result-count-value">
319
+ {isLoading ? '…' : totalResults || 0}
320
+ </span>
321
+ <p>
322
+ {intl.formatMessage(messages.toolsMatch, {
323
+ count: totalResults || 0,
324
+ })}
325
+ </p>
326
+ </div>
327
+ {selectedStepLabels.length > 0 && (
328
+ <p className="navigator-guide-preview-refinements">
329
+ {intl.formatMessage(messages.refinedBy, {
330
+ criteria: selectedStepLabels.join(' · '),
331
+ })}
332
+ </p>
333
+ )}
334
+ {hasSelections ? (
335
+ <>
336
+ <div className="navigator-guide-preview-results">
337
+ {(results || [])
338
+ .slice(0, appConfig.previewResultsLimit)
339
+ .map((result) => (
340
+ <div
341
+ className="navigator-guide-preview-result"
342
+ key={result._original?._id || result.href}
343
+ >
344
+ <div
345
+ className="navigator-tool-icon medium"
346
+ aria-hidden="true"
347
+ >
348
+ <Icon className="ri-stack-line" />
349
+ </div>
350
+ <div className="navigator-guide-preview-result-content">
351
+ <small
352
+ className="navigator-tool-provider"
353
+ title={result?._result?.tool_provider?.raw}
354
+ >
355
+ {result?._result?.tool_provider?.raw}
356
+ </small>
357
+ <a
358
+ href={result.href}
359
+ target="_blank"
360
+ rel="noopener noreferrer"
361
+ className="navigator-guide-preview-result-title"
362
+ title={result.title}
363
+ >
364
+ <h5>{result.title}</h5>
365
+ </a>
366
+ {selectedPreviewTags.length > 0 && (
367
+ <div className="navigator-guide-preview-tags">
368
+ {visiblePreviewTags.map(
369
+ ({ label, type }, index) => (
370
+ <span
371
+ className={`navigator-tag ${type}`}
372
+ key={`${type}-${label}-${index}`}
373
+ >
374
+ {label}
375
+ </span>
376
+ ),
377
+ )}
378
+ {remainingPreviewTags > 0 && (
379
+ <span className="navigator-guide-preview-tag-more">
380
+ + {remainingPreviewTags}
381
+ </span>
382
+ )}
383
+ </div>
384
+ )}
385
+ </div>
386
+ </div>
387
+ ))}
388
+ </div>
389
+ <Button
390
+ labelPosition="right"
391
+ className="primary icon inverted"
392
+ onClick={showResults}
393
+ >
394
+ {intl.formatMessage(messages.seeAllMatchingTools)}
395
+ <Icon className="ri-arrow-right-line" />
396
+ </Button>
397
+ </>
398
+ ) : (
399
+ <p className="navigator-guide-preview-empty">
400
+ {intl.formatMessage(messages.previewEmpty)}
401
+ </p>
402
+ )}
403
+ </aside>
404
+ </div>
405
+ </div>
406
+ );
407
+ };
408
+
409
+ export default NavigatorGuideContentView;
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ import { Container } from 'semantic-ui-react';
3
+
4
+ const NavigatorGuideLayout = ({ bodyContent }) => (
5
+ <Container className="navigator-guide-search-layout">{bodyContent}</Container>
6
+ );
7
+
8
+ export default NavigatorGuideLayout;
@@ -7,6 +7,8 @@ export { default as ASTNavigation } from './theme/ASTNavigation/ASTNavigation';
7
7
  export { default as RedirectToLogin } from './theme/RedirectToLogin/RedirectToLogin';
8
8
  export { default as MissionSignatoryProfileView } from './theme/MissionSignatoryProfile/MissionSignatoryProfileView';
9
9
  export { default as AccordionList } from './theme/AccordionList/AccordionList';
10
+ export { default as CompareToolsView } from './theme/CompareTools/CompareToolsView';
11
+ export { CompareToolsPanel } from './theme/CompareTools/CompareToolsPanel';
10
12
 
11
13
  // Widgets
12
14
  export { default as RASTWidgetView } from './theme/Widgets/RASTWidgetView';
@@ -0,0 +1,151 @@
1
+ import React from 'react';
2
+ import { useAtom } from 'jotai';
3
+ import { useSelector } from 'react-redux';
4
+ import { useHistory } from 'react-router-dom';
5
+ import { Icon, Button } from 'semantic-ui-react';
6
+ import { defineMessages, useIntl } from 'react-intl';
7
+ import {
8
+ MAX_COMPARE_TOOLS,
9
+ compareToolsAtom,
10
+ getCompareLocation,
11
+ } from './utils';
12
+
13
+ const messages = defineMessages({
14
+ compareTools: {
15
+ id: 'Compare tools',
16
+ defaultMessage: 'Compare tools',
17
+ },
18
+ removeTool: {
19
+ id: 'Remove {title}',
20
+ defaultMessage: 'Remove {title}',
21
+ },
22
+ readyToCompare: {
23
+ id: 'Ready to compare',
24
+ defaultMessage: 'Ready to compare',
25
+ },
26
+ addAnotherTool: {
27
+ id: 'Add at least another tool',
28
+ defaultMessage: 'Add at least another tool',
29
+ },
30
+ compareSelectedTools: {
31
+ id: 'Compare selected tools',
32
+ defaultMessage: 'Compare selected tools',
33
+ },
34
+ addTool: {
35
+ id: 'Add a tool',
36
+ defaultMessage: 'Add a tool',
37
+ },
38
+ clearAll: {
39
+ id: 'Clear all',
40
+ defaultMessage: 'Clear all',
41
+ },
42
+ });
43
+
44
+ export const CompareToolsPanel = ({ appConfig: suppliedAppConfig }) => {
45
+ const intl = useIntl();
46
+ const history = useHistory();
47
+ const currentLang = useSelector((state) => state.intl.locale);
48
+ const appConfig = suppliedAppConfig || {
49
+ landingPageURL: '/en/navigator/tool-catalogue',
50
+ };
51
+ const [selectedTools, setSelectedTools] = useAtom(compareToolsAtom);
52
+ const resolvableSelectedTools = selectedTools.filter((tool) => tool.uid);
53
+ const isReadyToCompare = resolvableSelectedTools.length >= 2;
54
+ const emptySlots = MAX_COMPARE_TOOLS - selectedTools.length;
55
+
56
+ if (selectedTools.length === 0) return null;
57
+
58
+ const removeTool = (toolUid) => {
59
+ setSelectedTools((tools) => tools.filter((tool) => tool.uid !== toolUid));
60
+ };
61
+
62
+ const compareTools = () => {
63
+ const returnURL = [
64
+ window.location.pathname,
65
+ window.location.search,
66
+ window.location.hash,
67
+ ].join('');
68
+
69
+ history.push(
70
+ getCompareLocation(selectedTools, appConfig, returnURL, currentLang),
71
+ );
72
+ };
73
+
74
+ return (
75
+ <div className="catalogue-compare-panel" aria-live="polite">
76
+ <div className="compare-panel-header">
77
+ <h2>
78
+ {intl.formatMessage(messages.compareTools)}
79
+ <span className="compare-panel-count">
80
+ {selectedTools.length}/{MAX_COMPARE_TOOLS}
81
+ </span>
82
+ </h2>
83
+ </div>
84
+
85
+ <div className="compare-panel-content">
86
+ <div className="compare-panel-tools">
87
+ {selectedTools.map((tool) => (
88
+ <div key={tool.uid} className="compare-panel-tool">
89
+ <span className="navigator-tool-icon small" aria-hidden="true">
90
+ <Icon className="ri-file-line" />
91
+ </span>
92
+ <span className="compare-panel-tool-title">{tool.title}</span>
93
+ <Button
94
+ className="compare-panel-tool-clear"
95
+ aria-label={intl.formatMessage(messages.removeTool, {
96
+ title: tool.title,
97
+ })}
98
+ onClick={() => removeTool(tool.uid)}
99
+ >
100
+ <Icon className="ri-close-line" />
101
+ </Button>
102
+ </div>
103
+ ))}
104
+ {Array.from({ length: emptySlots }).map((_, index) => (
105
+ <div
106
+ key={`compare-placeholder-${index}`}
107
+ className="compare-panel-tool placeholder"
108
+ >
109
+ <span className="navigator-tool-icon small" aria-hidden="true">
110
+ <Icon className="ri-add-line" />
111
+ </span>
112
+ <span className="compare-panel-tool-title">
113
+ {intl.formatMessage(messages.addTool)}
114
+ </span>
115
+ </div>
116
+ ))}
117
+ </div>
118
+
119
+ <div className="compare-panel-actions">
120
+ <div
121
+ className={`compare-panel-status ${
122
+ isReadyToCompare ? 'ready' : 'incomplete'
123
+ }`}
124
+ >
125
+ {isReadyToCompare && <Icon className="ri-check-line" />}
126
+ {intl.formatMessage(
127
+ isReadyToCompare
128
+ ? messages.readyToCompare
129
+ : messages.addAnotherTool,
130
+ )}
131
+ </div>
132
+ <Button
133
+ primary
134
+ className="compare-panel-submit"
135
+ disabled={!isReadyToCompare}
136
+ onClick={compareTools}
137
+ >
138
+ <Icon className="ri-layout-column-line" />
139
+ <span>{intl.formatMessage(messages.compareSelectedTools)}</span>
140
+ </Button>
141
+ <Button
142
+ className="compare-panel-clear-all"
143
+ onClick={() => setSelectedTools([])}
144
+ >
145
+ {intl.formatMessage(messages.clearAll)}
146
+ </Button>
147
+ </div>
148
+ </div>
149
+ </div>
150
+ );
151
+ };
@@ -0,0 +1,96 @@
1
+ import React from 'react';
2
+ import '@testing-library/jest-dom';
3
+ import { fireEvent, render, screen } from '@testing-library/react';
4
+ import { IntlProvider } from 'react-intl';
5
+ import { Provider as JotaiProvider } from 'jotai';
6
+ import { useHistory } from 'react-router-dom';
7
+ import { CompareToolsPanel } from './CompareToolsPanel';
8
+
9
+ jest.mock('react-redux', () => ({
10
+ ...jest.requireActual('react-redux'),
11
+ useSelector: (selector) => selector({ intl: { locale: 'en' } }),
12
+ }));
13
+
14
+ jest.mock('react-router-dom', () => ({
15
+ ...jest.requireActual('react-router-dom'),
16
+ useHistory: jest.fn(),
17
+ }));
18
+
19
+ const renderPanel = (selectedTools, appConfig) => {
20
+ window.localStorage.setItem(
21
+ 'cca-compare-tools',
22
+ JSON.stringify(selectedTools),
23
+ );
24
+
25
+ return render(
26
+ <IntlProvider locale="en">
27
+ <JotaiProvider>
28
+ <CompareToolsPanel appConfig={appConfig} />
29
+ </JotaiProvider>
30
+ </IntlProvider>,
31
+ );
32
+ };
33
+
34
+ describe('CompareToolsPanel', () => {
35
+ const push = jest.fn();
36
+
37
+ beforeEach(() => {
38
+ jest.clearAllMocks();
39
+ window.localStorage.clear();
40
+ useHistory.mockReturnValue({ push });
41
+ });
42
+
43
+ it('does not render without selected tools', () => {
44
+ const { container } = renderPanel([]);
45
+
46
+ expect(container).toBeEmptyDOMElement();
47
+ });
48
+
49
+ it('shows an incomplete warning and removes the only tool', () => {
50
+ renderPanel([{ uid: 'one', title: 'Tool one' }]);
51
+
52
+ expect(screen.getByText('Add at least another tool')).toHaveClass(
53
+ 'compare-panel-status',
54
+ 'incomplete',
55
+ );
56
+ expect(
57
+ screen.getByText('Compare selected tools').closest('button'),
58
+ ).toBeDisabled();
59
+
60
+ fireEvent.click(screen.getByLabelText('Remove Tool one'));
61
+ expect(screen.queryByText('Compare tools')).not.toBeInTheDocument();
62
+ });
63
+
64
+ it('shows the ready state and opens the comparison page', () => {
65
+ renderPanel(
66
+ [
67
+ { uid: 'one', title: 'Tool one' },
68
+ { uid: 'two', title: 'Tool two' },
69
+ ],
70
+ { landingPageURL: '/en/navigator/tool-catalogue' },
71
+ );
72
+
73
+ expect(screen.getByText('Ready to compare')).toHaveClass(
74
+ 'compare-panel-status',
75
+ 'ready',
76
+ );
77
+
78
+ fireEvent.click(screen.getByText('Compare selected tools'));
79
+
80
+ expect(push).toHaveBeenCalledWith({
81
+ pathname: '/en/navigator/compare',
82
+ search: '?uid=one&uid=two',
83
+ state: { returnURL: '/' },
84
+ });
85
+ });
86
+
87
+ it('clears all selected tools', () => {
88
+ renderPanel([
89
+ { uid: 'one', title: 'Tool one' },
90
+ { uid: 'two', title: 'Tool two' },
91
+ ]);
92
+
93
+ fireEvent.click(screen.getByText('Clear all'));
94
+ expect(screen.queryByText('Compare tools')).not.toBeInTheDocument();
95
+ });
96
+ });