@eeacms/volto-cca-policy 1.0.11 → 1.0.12

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,19 @@ 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.12](https://github.com/eea/volto-cca-policy/compare/1.0.11...1.0.12) - 24 August 2026
8
+
9
+ #### :bug: Bug Fixes
10
+
11
+ - fix: disable unavailable navigator guide options [kreafox - [`4b114c2`](https://github.com/eea/volto-cca-policy/commit/4b114c2b0ea7a6a6528ae8ae378c2e671b640945)]
12
+
13
+ #### :nail_care: Enhancements
14
+
15
+ - change: add custom hook for guide facet options and add tests [kreafox - [`872eb9b`](https://github.com/eea/volto-cca-policy/commit/872eb9b35c41fc2094c605b113411448a8cef9a8)]
16
+
17
+ #### :hammer_and_wrench: Others
18
+
19
+ - test: increase utility coverage [kreafox - [`d9c3db8`](https://github.com/eea/volto-cca-policy/commit/d9c3db8cc6fb91ead00f83b822f6fe48c26a70b1)]
7
20
  ### [1.0.11](https://github.com/eea/volto-cca-policy/compare/1.0.10...1.0.11) - 21 August 2026
8
21
 
9
22
  ### [1.0.10](https://github.com/eea/volto-cca-policy/compare/1.0.9...1.0.10) - 19 August 2026
@@ -288,7 +301,9 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
288
301
  #### :hammer_and_wrench: Others
289
302
 
290
303
  - 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
304
+ ## [1.0.0](https://github.com/eea/volto-cca-policy/compare/1.0.0-alpha.24...1.0.0) - 12 June 2026
305
+
306
+ ### [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
307
 
293
308
  ### [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
309
 
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.12",
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
+ });
@@ -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
  }