@eeacms/volto-cca-policy 1.0.11 → 1.0.13

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,25 @@ 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.13](https://github.com/eea/volto-cca-policy/compare/1.0.12...1.0.13) - 31 August 2026
8
+
9
+ #### :hammer_and_wrench: Others
10
+
11
+ - Refs #307245 - Visualizations: fix order in view mode. [GhitaB - [`bfbe754`](https://github.com/eea/volto-cca-policy/commit/bfbe7549af5a4f767c3d023b894b55b5eb81918e)]
12
+ - Refs #307245 - Visualizations: order and descriptions. [GhitaB - [`5fff846`](https://github.com/eea/volto-cca-policy/commit/5fff846b623250e89cbf2a844828f545c0f2f9f9)]
13
+ ### [1.0.12](https://github.com/eea/volto-cca-policy/compare/1.0.11...1.0.12) - 24 August 2026
14
+
15
+ #### :bug: Bug Fixes
16
+
17
+ - fix: disable unavailable navigator guide options [kreafox - [`4b114c2`](https://github.com/eea/volto-cca-policy/commit/4b114c2b0ea7a6a6528ae8ae378c2e671b640945)]
18
+
19
+ #### :nail_care: Enhancements
20
+
21
+ - change: add custom hook for guide facet options and add tests [kreafox - [`872eb9b`](https://github.com/eea/volto-cca-policy/commit/872eb9b35c41fc2094c605b113411448a8cef9a8)]
22
+
23
+ #### :hammer_and_wrench: Others
24
+
25
+ - test: increase utility coverage [kreafox - [`d9c3db8`](https://github.com/eea/volto-cca-policy/commit/d9c3db8cc6fb91ead00f83b822f6fe48c26a70b1)]
7
26
  ### [1.0.11](https://github.com/eea/volto-cca-policy/compare/1.0.10...1.0.11) - 21 August 2026
8
27
 
9
28
  ### [1.0.10](https://github.com/eea/volto-cca-policy/compare/1.0.9...1.0.10) - 19 August 2026
@@ -288,7 +307,25 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
288
307
  #### :hammer_and_wrench: Others
289
308
 
290
309
  - test: pin Chromium version 149 to work with Cypress [valentinab25 - [`659ec82`](https://github.com/eea/volto-cca-policy/commit/659ec823dd71fe592a3a7accda22effbe1fb07bc)]
291
- ## [1.0.0](https://github.com/eea/volto-cca-policy/compare/1.0.0-alpha.23...1.0.0) - 12 June 2026
310
+ ## [1.0.0](https://github.com/eea/volto-cca-policy/compare/1.0.0-alpha.26...1.0.0) - 12 June 2026
311
+
312
+ ### [1.0.0-alpha.26](https://github.com/eea/volto-cca-policy/compare/1.0.0-alpha.25...1.0.0-alpha.26) - 28 August 2026
313
+
314
+ ### [1.0.0-alpha.25](https://github.com/eea/volto-cca-policy/compare/1.0.0-alpha.24...1.0.0-alpha.25) - 27 August 2026
315
+
316
+ #### :bug: Bug Fixes
317
+
318
+ - fix: disable unavailable navigator guide options [kreafox - [`4b114c2`](https://github.com/eea/volto-cca-policy/commit/4b114c2b0ea7a6a6528ae8ae378c2e671b640945)]
319
+
320
+ #### :nail_care: Enhancements
321
+
322
+ - change: add custom hook for guide facet options and add tests [kreafox - [`872eb9b`](https://github.com/eea/volto-cca-policy/commit/872eb9b35c41fc2094c605b113411448a8cef9a8)]
323
+
324
+ #### :hammer_and_wrench: Others
325
+
326
+ - Refs #307245 - Visualizations: order and descriptions. [GhitaB - [`5fff846`](https://github.com/eea/volto-cca-policy/commit/5fff846b623250e89cbf2a844828f545c0f2f9f9)]
327
+ - test: increase utility coverage [kreafox - [`d9c3db8`](https://github.com/eea/volto-cca-policy/commit/d9c3db8cc6fb91ead00f83b822f6fe48c26a70b1)]
328
+ ### [1.0.0-alpha.24](https://github.com/eea/volto-cca-policy/compare/1.0.0-alpha.23...1.0.0-alpha.24) - 24 August 2026
292
329
 
293
330
  ### [1.0.0-alpha.23](https://github.com/eea/volto-cca-policy/compare/1.0.0-alpha.22...1.0.0-alpha.23) - 19 August 2026
294
331
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-cca-policy",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
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",
@@ -8,6 +8,8 @@ import URLManager from '@elastic/search-ui/lib/cjs/URLManager';
8
8
  import { useSearchContext } from '@eeacms/search/lib/hocs';
9
9
  import guideSteps from '../../../search/navigator_guide/guideSteps';
10
10
  import { navigatorGuideStepAtom } from '../../../state';
11
+ import { mergeGuideOptions } from './utils';
12
+ import useGuideFacetOptions from './useGuideFacetOptions';
11
13
 
12
14
  const messages = defineMessages({
13
15
  noSteps: {
@@ -111,6 +113,7 @@ const NavigatorGuideContentView = ({ appConfig }) => {
111
113
  totalResults,
112
114
  } = searchContext;
113
115
  const steps = guideSteps;
116
+ const allFacetOptions = useGuideFacetOptions(appConfig, steps);
114
117
  const [storedActiveStep, setActiveStep] = useAtom(navigatorGuideStepAtom);
115
118
  const activeStep =
116
119
  Number.isInteger(storedActiveStep) &&
@@ -122,11 +125,16 @@ const NavigatorGuideContentView = ({ appConfig }) => {
122
125
  const selectedValues =
123
126
  (filters || []).find((filter) => filter.field === step?.field)?.values ||
124
127
  [];
125
- const options = getFacetOptions(facets, step?.field);
126
- const isLastStep = activeStep === steps.length - 1;
127
128
  const hasSelections = steps.some(({ field }) =>
128
129
  isStepSelected(filters, field),
129
130
  );
131
+ const options = mergeGuideOptions(
132
+ allFacetOptions?.[step?.field],
133
+ getFacetOptions(facets, step?.field),
134
+ selectedValues,
135
+ hasSelections,
136
+ );
137
+ const isLastStep = activeStep === steps.length - 1;
130
138
  const selectedStepLabels = steps
131
139
  .filter(({ field }) => isStepSelected(filters, field))
132
140
  .map(({ label }) =>
@@ -262,10 +270,12 @@ const NavigatorGuideContentView = ({ appConfig }) => {
262
270
  key={option.value}
263
271
  className={`navigator-guide-option${
264
272
  selectedValues.includes(option.value) ? ' selected' : ''
265
- }`}
273
+ }${option.disabled ? ' disabled' : ''}`}
274
+ aria-disabled={option.disabled || undefined}
266
275
  >
267
276
  <Checkbox
268
277
  checked={selectedValues.includes(option.value)}
278
+ disabled={option.disabled}
269
279
  onChange={() => toggleValue(option.value)}
270
280
  />
271
281
  <span>{option.value}</span>
@@ -0,0 +1,27 @@
1
+ import React from 'react';
2
+ import { getFacetOptions } from '@eeacms/search/components/SearchApp/useFacetsWithAllOptions';
3
+
4
+ const useGuideFacetOptions = (appConfig, steps) => {
5
+ const [allFacetOptions, setAllFacetOptions] = React.useState({});
6
+
7
+ React.useEffect(() => {
8
+ let isCurrent = true;
9
+ const stepFields = steps.map(({ field }) => field);
10
+
11
+ getFacetOptions(appConfig, stepFields)
12
+ .then((facetOptions) => {
13
+ if (isCurrent) setAllFacetOptions(facetOptions);
14
+ })
15
+ .catch(() => {
16
+ // Live facet options remain available if the supplementary request fails.
17
+ });
18
+
19
+ return () => {
20
+ isCurrent = false;
21
+ };
22
+ }, [appConfig, steps]);
23
+
24
+ return allFacetOptions;
25
+ };
26
+
27
+ export default useGuideFacetOptions;
@@ -0,0 +1,58 @@
1
+ import { act, renderHook } from '@testing-library/react-hooks';
2
+ import { getFacetOptions } from '@eeacms/search/components/SearchApp/useFacetsWithAllOptions';
3
+ import useGuideFacetOptions from './useGuideFacetOptions';
4
+
5
+ jest.mock(
6
+ '@eeacms/search/components/SearchApp/useFacetsWithAllOptions',
7
+ () => ({ getFacetOptions: jest.fn() }),
8
+ );
9
+
10
+ const flushPromises = () => act(() => Promise.resolve());
11
+
12
+ describe('useGuideFacetOptions', () => {
13
+ const appConfig = { facets: [] };
14
+ const steps = [{ field: 'sector' }, { field: 'stage' }];
15
+
16
+ beforeEach(() => {
17
+ getFacetOptions.mockReset();
18
+ });
19
+
20
+ it('loads the complete option list for every guide field', async () => {
21
+ const facetOptions = { sector: ['Energy'], stage: ['Step 1'] };
22
+ getFacetOptions.mockResolvedValue(facetOptions);
23
+
24
+ const { result } = renderHook(() => useGuideFacetOptions(appConfig, steps));
25
+ await flushPromises();
26
+
27
+ expect(getFacetOptions).toHaveBeenCalledWith(appConfig, [
28
+ 'sector',
29
+ 'stage',
30
+ ]);
31
+ expect(result.current).toEqual(facetOptions);
32
+ });
33
+
34
+ it('keeps the fallback state when loading fails', async () => {
35
+ getFacetOptions.mockRejectedValue(new Error('Search unavailable'));
36
+
37
+ const { result } = renderHook(() => useGuideFacetOptions(appConfig, steps));
38
+ await flushPromises();
39
+
40
+ expect(result.current).toEqual({});
41
+ });
42
+
43
+ it('does not update state after unmounting', async () => {
44
+ let resolveRequest;
45
+ getFacetOptions.mockReturnValue(
46
+ new Promise((resolve) => {
47
+ resolveRequest = resolve;
48
+ }),
49
+ );
50
+
51
+ const { unmount } = renderHook(() =>
52
+ useGuideFacetOptions(appConfig, steps),
53
+ );
54
+ unmount();
55
+ resolveRequest({ sector: ['Energy'] });
56
+ await flushPromises();
57
+ });
58
+ });
@@ -0,0 +1,30 @@
1
+ export const mergeGuideOptions = (
2
+ availableValues = [],
3
+ facetOptions = [],
4
+ selectedValues = [],
5
+ hasGuideSelections = false,
6
+ ) => {
7
+ const optionsByValue = new Map(
8
+ facetOptions.map(({ value, count }) => [value, { value, count }]),
9
+ );
10
+
11
+ availableValues.forEach((value) => {
12
+ if (!optionsByValue.has(value)) {
13
+ optionsByValue.set(value, { value, count: 0 });
14
+ }
15
+ });
16
+
17
+ selectedValues.forEach((value) => {
18
+ if (!optionsByValue.has(value)) {
19
+ optionsByValue.set(value, { value, count: 0 });
20
+ }
21
+ });
22
+
23
+ return Array.from(optionsByValue.values()).map((option) => ({
24
+ ...option,
25
+ disabled:
26
+ hasGuideSelections &&
27
+ option.count === 0 &&
28
+ !selectedValues.includes(option.value),
29
+ }));
30
+ };
@@ -0,0 +1,40 @@
1
+ import { mergeGuideOptions } from './utils';
2
+
3
+ describe('Navigator Guide utilities', () => {
4
+ it('keeps unavailable values and marks them as disabled', () => {
5
+ expect(
6
+ mergeGuideOptions(
7
+ ['Step 1', 'Step 2', 'Step 6'],
8
+ [
9
+ { value: 'Step 1', count: 4 },
10
+ { value: 'Step 2', count: 2 },
11
+ ],
12
+ [],
13
+ true,
14
+ ),
15
+ ).toEqual([
16
+ { value: 'Step 1', count: 4, disabled: false },
17
+ { value: 'Step 2', count: 2, disabled: false },
18
+ { value: 'Step 6', count: 0, disabled: true },
19
+ ]);
20
+ });
21
+
22
+ it('keeps a selected zero-count value enabled so it can be removed', () => {
23
+ expect(mergeGuideOptions([], [], ['Step 6'], true)).toEqual([
24
+ { value: 'Step 6', count: 0, disabled: false },
25
+ ]);
26
+ });
27
+
28
+ it('keeps every value enabled before the guide has been refined', () => {
29
+ expect(mergeGuideOptions(['Step 1', 'Step 6'], [], [])).toEqual([
30
+ { value: 'Step 1', count: 0, disabled: false },
31
+ { value: 'Step 6', count: 0, disabled: false },
32
+ ]);
33
+ });
34
+
35
+ it('falls back to live facet options while the full list is loading', () => {
36
+ expect(mergeGuideOptions([], [{ value: 'Energy', count: 3 }], [])).toEqual([
37
+ { value: 'Energy', count: 3, disabled: false },
38
+ ]);
39
+ });
40
+ });
@@ -7,6 +7,7 @@ import './visualizations.less';
7
7
 
8
8
  const emptyVisualization = {
9
9
  title: '',
10
+ description: '',
10
11
  embed_code: '',
11
12
  height: '',
12
13
  full_width: false,
@@ -43,6 +44,19 @@ const VisualizationsWidget = (props) => {
43
44
  );
44
45
  };
45
46
 
47
+ const moveItem = (index, direction) => {
48
+ const nextIndex = index + direction;
49
+
50
+ if (nextIndex < 0 || nextIndex >= visualizations.length) return;
51
+
52
+ const nextVisualizations = [...visualizations];
53
+ [nextVisualizations[index], nextVisualizations[nextIndex]] = [
54
+ nextVisualizations[nextIndex],
55
+ nextVisualizations[index],
56
+ ];
57
+ updateVisualizations(nextVisualizations);
58
+ };
59
+
46
60
  return (
47
61
  <FormFieldWrapper {...props}>
48
62
  <div className="visualizations-widget">
@@ -53,16 +67,30 @@ const VisualizationsWidget = (props) => {
53
67
  >
54
68
  <div className="visualizations-widget-item-header">
55
69
  <h4>Visualization {index + 1}</h4>
56
- <Button
57
- type="button"
58
- basic
59
- compact
60
- negative
61
- disabled={isDisabled}
62
- onClick={() => deleteItem(index)}
63
- >
64
- Delete
65
- </Button>
70
+ <Button.Group basic compact>
71
+ <Button
72
+ type="button"
73
+ disabled={isDisabled || index === 0}
74
+ onClick={() => moveItem(index, -1)}
75
+ >
76
+ Up
77
+ </Button>
78
+ <Button
79
+ type="button"
80
+ disabled={isDisabled || index === visualizations.length - 1}
81
+ onClick={() => moveItem(index, 1)}
82
+ >
83
+ Down
84
+ </Button>
85
+ <Button
86
+ type="button"
87
+ negative
88
+ disabled={isDisabled}
89
+ onClick={() => deleteItem(index)}
90
+ >
91
+ Delete
92
+ </Button>
93
+ </Button.Group>
66
94
  </div>
67
95
 
68
96
  <Form.Field>
@@ -77,6 +105,19 @@ const VisualizationsWidget = (props) => {
77
105
  />
78
106
  </Form.Field>
79
107
 
108
+ <Form.Field>
109
+ <label htmlFor={`${id}-${index}-description`}>Description</label>
110
+ <TextArea
111
+ id={`${id}-${index}-description`}
112
+ value={item.description || ''}
113
+ disabled={isDisabled}
114
+ rows={3}
115
+ onChange={(event) =>
116
+ updateItem(index, 'description', event.target.value)
117
+ }
118
+ />
119
+ </Form.Field>
120
+
80
121
  <Form.Field>
81
122
  <label htmlFor={`${id}-${index}-embed-code`}>
82
123
  Embed code or URL
@@ -22,6 +22,10 @@
22
22
  color: #1f405c;
23
23
  font-size: 1rem;
24
24
  }
25
+
26
+ .ui.buttons {
27
+ flex-shrink: 0;
28
+ }
25
29
  }
26
30
 
27
31
  .field {
@@ -107,6 +107,9 @@ const MaybeIframeVisualization = ({ visualization }) => {
107
107
  const Visualization = ({ visualization }) => (
108
108
  <>
109
109
  {visualization?.title && <h2>{visualization.title}</h2>}
110
+ {visualization?.description && (
111
+ <p className="visualization-description">{visualization.description}</p>
112
+ )}
110
113
  <MaybeFlourishVisualization visualization={visualization} />
111
114
  <MaybeIframeVisualization visualization={visualization} />
112
115
  </>
@@ -129,6 +132,20 @@ const getVisualizations = (content) => {
129
132
  : [];
130
133
  };
131
134
 
135
+ const groupVisualizations = (visualizations) =>
136
+ visualizations.reduce((groups, visualization) => {
137
+ const fullWidth = !!visualization.full_width;
138
+ const lastGroup = groups[groups.length - 1];
139
+
140
+ if (lastGroup && lastGroup.fullWidth === fullWidth) {
141
+ lastGroup.items.push(visualization);
142
+ } else {
143
+ groups.push({ fullWidth, items: [visualization] });
144
+ }
145
+
146
+ return groups;
147
+ }, []);
148
+
132
149
  const BottomInfo = (props) => {
133
150
  const { content } = props;
134
151
  const { organisational_websites, organisational_contact_information } =
@@ -185,13 +202,11 @@ const DatabaseItemView = (props) => {
185
202
  const item_title = acronym ? title + ' (' + acronym + ')' : title;
186
203
  const subtitle = CONTENT_TYPE_LABELS[type] ?? '';
187
204
  const visualizations = getVisualizations(content);
188
- const columnVisualizations = visualizations.filter(
189
- (visualization) => !visualization.full_width,
205
+ const visualizationGroups = groupVisualizations(visualizations);
206
+ const firstVisualizationGroup = visualizationGroups[0];
207
+ const hasFullWidthVisualizations = visualizationGroups.some(
208
+ (group) => group.fullWidth,
190
209
  );
191
- const fullWidthVisualizations = visualizations.filter(
192
- (visualization) => visualization.full_width,
193
- );
194
- const hasFullWidthVisualizations = !!fullWidthVisualizations.length;
195
210
 
196
211
  const is_cmshare_video = SHARE_EEA.some((domain) =>
197
212
  content?.embed_url?.includes(domain),
@@ -284,9 +299,12 @@ const DatabaseItemView = (props) => {
284
299
  </>
285
300
  )}
286
301
 
287
- {!!columnVisualizations.length && (
288
- <Visualizations visualizations={columnVisualizations} />
289
- )}
302
+ {firstVisualizationGroup &&
303
+ !firstVisualizationGroup.fullWidth && (
304
+ <Visualizations
305
+ visualizations={firstVisualizationGroup.items}
306
+ />
307
+ )}
290
308
 
291
309
  {!hasFullWidthVisualizations && <BottomInfo {...props} />}
292
310
  </Grid.Column>
@@ -304,11 +322,22 @@ const DatabaseItemView = (props) => {
304
322
 
305
323
  {hasFullWidthVisualizations && (
306
324
  <>
307
- <Grid.Row>
308
- <Grid.Column mobile={12} tablet={12} computer={12}>
309
- <Visualizations visualizations={fullWidthVisualizations} />
310
- </Grid.Column>
311
- </Grid.Row>
325
+ {visualizationGroups.map((group, index) => {
326
+ if (index === 0 && !group.fullWidth) return null;
327
+
328
+ return (
329
+ <Grid.Row key={`visualization-group-${index}`}>
330
+ <Grid.Column
331
+ mobile={12}
332
+ tablet={12}
333
+ computer={group.fullWidth ? 12 : 8}
334
+ className={group.fullWidth ? undefined : 'col-left'}
335
+ >
336
+ <Visualizations visualizations={group.items} />
337
+ </Grid.Column>
338
+ </Grid.Row>
339
+ );
340
+ })}
312
341
  <Grid.Row>
313
342
  <Grid.Column
314
343
  mobile={12}
@@ -46,6 +46,7 @@ const visualizationEmbedCode =
46
46
 
47
47
  const getVisualization = (overrides = {}) => ({
48
48
  title: '',
49
+ description: '',
49
50
  embed_code: visualizationEmbedCode,
50
51
  height: '',
51
52
  full_width: false,
@@ -125,6 +126,39 @@ describe('DatabaseItemView', () => {
125
126
  expect(fullWidthColumn.findAllByType('iframe')).toHaveLength(1);
126
127
  });
127
128
 
129
+ it('renders visualization descriptions', () => {
130
+ const component = renderDatabaseItemView({
131
+ ...baseContent,
132
+ visualizations: [
133
+ getVisualization({ description: 'Visualization summary text.' }),
134
+ ],
135
+ });
136
+
137
+ expect(
138
+ component.root.findByProps({
139
+ className: 'visualization-description',
140
+ }).children,
141
+ ).toEqual(['Visualization summary text.']);
142
+ });
143
+
144
+ it('preserves visualization order when full-width and column items are mixed', () => {
145
+ const component = renderDatabaseItemView({
146
+ ...baseContent,
147
+ visualizations: [
148
+ getVisualization({ title: 'First', full_width: true }),
149
+ getVisualization({ title: 'Second' }),
150
+ getVisualization({ title: 'Third', full_width: true }),
151
+ ],
152
+ });
153
+
154
+ const headings = component.root
155
+ .findAllByType('h2')
156
+ .map((heading) => heading.children.join(''))
157
+ .filter((title) => ['First', 'Second', 'Third'].includes(title));
158
+
159
+ expect(headings).toEqual(['First', 'Second', 'Third']);
160
+ });
161
+
128
162
  it('does not render a visualization when the visualizations field is empty', () => {
129
163
  const component = renderDatabaseItemView({
130
164
  ...baseContent,
@@ -0,0 +1,127 @@
1
+ import { flattenToAppURL } from '@plone/volto/helpers/Url/Url';
2
+ import {
3
+ blockAvailableInMission,
4
+ extractPlanNameAndURL,
5
+ filterBlocks,
6
+ formatTextToHTML,
7
+ getBaseUrl,
8
+ getFilteredBlocks,
9
+ hasTypeOfBlock,
10
+ isEmpty,
11
+ normalizeImageFileName,
12
+ } from './utils';
13
+
14
+ jest.mock('@plone/volto/helpers/Url/Url', () => ({
15
+ flattenToAppURL: jest.fn((url) => url.replace('https://example.com', '')),
16
+ }));
17
+
18
+ describe('general utilities', () => {
19
+ it('checks whether blocks are available inside and outside Mission', () => {
20
+ expect(blockAvailableInMission({}, { id: 'text' })).toBe(false);
21
+ expect(
22
+ blockAvailableInMission(
23
+ { '@id': 'https://example.com/en/mission/page' },
24
+ { id: 'mkh_map' },
25
+ ),
26
+ ).toBe(false);
27
+ expect(
28
+ blockAvailableInMission(
29
+ { '@id': 'https://example.com/en/mission/page' },
30
+ { id: 'text' },
31
+ ),
32
+ ).toBe(true);
33
+ expect(
34
+ blockAvailableInMission(
35
+ { '@id': 'https://example.com/en/page' },
36
+ { id: 'rastBlock' },
37
+ ),
38
+ ).toBe(true);
39
+ });
40
+
41
+ it('resolves a block base URL from the supported properties', () => {
42
+ expect(
43
+ getBaseUrl({ data: { href: [{ '@id': 'https://example.com/page' }] } }),
44
+ ).toBe('/page');
45
+ expect(getBaseUrl({ path: '/from-path' })).toBe('/from-path');
46
+ expect(getBaseUrl({ location: { pathname: '/from-location' } })).toBe(
47
+ '/from-location',
48
+ );
49
+ expect(getBaseUrl({})).toBe('');
50
+ expect(flattenToAppURL).toHaveBeenCalledTimes(3);
51
+ });
52
+
53
+ it('finds nested block types and removes selected block types', () => {
54
+ expect(
55
+ hasTypeOfBlock({ blocks: { child: { '@type': 'listing' } } }, 'listing'),
56
+ ).toBe(true);
57
+ expect(hasTypeOfBlock({ child: null }, 'listing')).toBe(false);
58
+
59
+ expect(
60
+ filterBlocks(
61
+ {
62
+ blocks: {
63
+ one: { '@type': 'text' },
64
+ two: { '@type': 'listing' },
65
+ },
66
+ blocks_layout: { items: ['one', 'two'] },
67
+ },
68
+ ['listing'],
69
+ ),
70
+ ).toEqual({
71
+ blocks: { one: { '@type': 'text' } },
72
+ blocks_layout: { items: ['one'] },
73
+ hasBlockTypes: true,
74
+ });
75
+ });
76
+
77
+ it('formats escaped text, paragraphs, bullets, and links as HTML', () => {
78
+ expect(formatTextToHTML()).toBe('');
79
+ expect(formatTextToHTML('Simple text')).toBe('<p>Simple text</p>');
80
+ expect(formatTextToHTML('First\\n\\nSecond')).toBe('First</p><p>Second');
81
+ expect(formatTextToHTML('Item\\no next')).toBe('<p>Item<br />• next</p>');
82
+ expect(formatTextToHTML('See https://example.com')).toContain(
83
+ '<a href="https://example.com"',
84
+ );
85
+ });
86
+
87
+ it('keeps only parent blocks without the excluded nested type', () => {
88
+ const result = getFilteredBlocks(
89
+ {
90
+ blocks: {
91
+ keep: { '@type': 'group', blocks: { child: { '@type': 'text' } } },
92
+ exclude: {
93
+ '@type': 'group',
94
+ data: { blocks: { child: { '@type': 'map' } } },
95
+ },
96
+ other: { '@type': 'text' },
97
+ },
98
+ blocks_layout: { items: ['keep', 'exclude', 'other'] },
99
+ },
100
+ 'group',
101
+ 'map',
102
+ );
103
+
104
+ expect(result.keptKeys).toEqual(['keep']);
105
+ expect(result.blocks_layout.items).toEqual(['keep']);
106
+ expect(result.hasMatches).toBe(true);
107
+ });
108
+
109
+ it('extracts plan links and normalizes common values', () => {
110
+ expect(extractPlanNameAndURL()).toEqual({ name: '', url: '' });
111
+ expect(
112
+ extractPlanNameAndURL('Adaptation plan (https://example.com/plan)'),
113
+ ).toEqual({ name: 'Adaptation plan', url: 'https://example.com/plan' });
114
+ expect(
115
+ extractPlanNameAndURL(
116
+ 'Plan https://example.com/source https://example.com/document',
117
+ ),
118
+ ).toEqual({ name: 'Plan', url: 'https://example.com/document' });
119
+
120
+ expect(isEmpty()).toBe(true);
121
+ expect(isEmpty([])).toBe(true);
122
+ expect(isEmpty(['value'])).toBe(false);
123
+ expect(normalizeImageFileName()).toBe('');
124
+ expect(normalizeImageFileName('map(final).png')).toBe('map-final.png');
125
+ expect(normalizeImageFileName('map(final)')).toBe('map-final-');
126
+ });
127
+ });
@@ -1004,6 +1004,12 @@ body.searchlib-page .searchapp-navigatorCatalogueSearch {
1004
1004
  background: fade(@primaryColor, 5%);
1005
1005
  }
1006
1006
 
1007
+ &.disabled {
1008
+ background: #f5f5f5;
1009
+ color: @navigatorInactiveColor;
1010
+ opacity: 0.65;
1011
+ }
1012
+
1007
1013
  > span {
1008
1014
  flex: 1;
1009
1015
  }