@eeacms/volto-cca-policy 1.0.13 → 1.0.15

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 (22) hide show
  1. package/.eslintrc.js +1 -1
  2. package/CHANGELOG.md +39 -1
  3. package/package.json +3 -1
  4. package/src/components/manage/Blocks/ChatbotCatalogue/CatalogueChatView.jsx +48 -0
  5. package/src/components/manage/Blocks/ChatbotCatalogue/CatalogueChatView.test.jsx +73 -0
  6. package/src/components/manage/Blocks/ChatbotCatalogue/DocumentCard.jsx +226 -0
  7. package/src/components/manage/Blocks/ChatbotCatalogue/DocumentCard.test.jsx +153 -0
  8. package/src/components/manage/Blocks/ChatbotCatalogue/SuggestedNextSteps.jsx +20 -0
  9. package/src/components/manage/Blocks/ChatbotCatalogue/SuggestedNextSteps.test.jsx +34 -0
  10. package/src/components/manage/Blocks/ChatbotCatalogue/catalogue-chat.less +136 -0
  11. package/src/components/manage/Blocks/ChatbotCatalogue/docCards.js +74 -0
  12. package/src/components/manage/Blocks/ChatbotCatalogue/docCards.test.js +145 -0
  13. package/src/components/manage/Blocks/ChatbotCatalogue/index.js +24 -0
  14. package/src/components/manage/Blocks/ChatbotCatalogue/index.test.jsx +44 -0
  15. package/src/components/manage/Blocks/ChatbotCatalogue/nextSteps.js +79 -0
  16. package/src/components/manage/Blocks/ChatbotCatalogue/nextSteps.test.js +168 -0
  17. package/src/components/manage/Blocks/ChatbotCatalogue/useCatalogueDoc.js +116 -0
  18. package/src/components/manage/Blocks/ChatbotCatalogue/useCatalogueDoc.test.js +149 -0
  19. package/src/components/manage/Blocks/index.js +2 -0
  20. package/theme/globals/navigator.less +40 -23
  21. package/src/customizations/@eeacms/volto-banner/StagingBanner.jsx +0 -5
  22. package/src/customizations/@eeacms/volto-banner/index.js +0 -3
@@ -0,0 +1,116 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { registry } from '@eeacms/search';
3
+ import { fetchResult } from '@eeacms/search/lib/hocs/useResult';
4
+
5
+ const searchAppName = 'navigatorCatalogueSearch';
6
+
7
+ /**
8
+ * Fetch a document from the globalsearch Elasticsearch index by URL.
9
+ *
10
+ * The globalsearch index uses the document URL as the ES `_id`, and the
11
+ * volto-searchlib express middleware whitelists/proxies
12
+ * `GET /_es/{app}/_doc/{id}` (see esGetDocWhitelist in
13
+ * volto-searchlib/src/middleware/elasticsearch.js). `fetchResult`
14
+ * (searchlib) issues that GET and wraps the hit in the searchlib Result
15
+ * model — the same object the Navigator catalogue cards consume.
16
+ *
17
+ * Results are cached in a module-level Map for the lifetime of the page
18
+ * (no refetch on re-render, repeat questions, or multiple cards for the
19
+ * same document).
20
+ */
21
+ const docCache = new Map();
22
+ // Resolved values (Result | null) keyed by URL. Lets a card that has already
23
+ // been fetched render the full card synchronously instead of flashing the
24
+ // basic card first (repeat doc, remount, or a second message citing the same
25
+ // URL).
26
+ const resolvedCache = new Map();
27
+
28
+ /**
29
+ * Normalize URLs so they match the globalsearch Elasticsearch index `_id`.
30
+ * In globalsearch, Climate-ADAPT tools use `https://climate-adapt.eea.europa.eu/en/...`
31
+ * without trailing slashes.
32
+ */
33
+ export function normalizeCatalogueUrl(url) {
34
+ if (!url || typeof url !== 'string') return url;
35
+ let normalized = url.trim();
36
+ // Strip trailing slashes
37
+ while (normalized.endsWith('/')) {
38
+ normalized = normalized.slice(0, -1);
39
+ }
40
+ // Map localhost or cca.localhost to production domain used in the globalsearch index
41
+ normalized = normalized.replace(
42
+ /^https?:\/\/(?:cca\.localhost|localhost(?::\d+)?)/i,
43
+ 'https://climate-adapt.eea.europa.eu',
44
+ );
45
+ // Ensure /en/ prefix for metadata or other paths in Climate-ADAPT
46
+ // e.g. https://climate-adapt.eea.europa.eu/metadata/... -> https://climate-adapt.eea.europa.eu/en/metadata/...
47
+ const ccaDomain = 'https://climate-adapt.eea.europa.eu';
48
+ if (normalized.toLowerCase().startsWith(ccaDomain.toLowerCase())) {
49
+ const path = normalized.slice(ccaDomain.length);
50
+ if (path.startsWith('/') && !path.startsWith('/en/') && path !== '/en') {
51
+ normalized = `${ccaDomain}/en${path}`;
52
+ }
53
+ }
54
+ return normalized;
55
+ }
56
+
57
+ // The search app configs live on the searchlib registry's mutable `searchui`
58
+ // part (see CompareTools/utils.js fetchResultsByUid for the same pattern).
59
+ function getAppConfig() {
60
+ return registry.searchui?.[searchAppName] || null;
61
+ }
62
+
63
+ function fetchCatalogueDoc(rawUrl) {
64
+ const url = normalizeCatalogueUrl(rawUrl);
65
+ const appConfig = getAppConfig();
66
+ if (!docCache.has(url)) {
67
+ docCache.set(
68
+ url,
69
+ appConfig
70
+ ? fetchResult(url, appConfig, registry)
71
+ .then((result) => {
72
+ const value = result && result.found !== false ? result : null;
73
+ resolvedCache.set(url, value);
74
+ return value;
75
+ })
76
+ .catch(() => {
77
+ resolvedCache.set(url, null);
78
+ return null;
79
+ })
80
+ : Promise.resolve(null),
81
+ );
82
+ }
83
+ return docCache.get(url);
84
+ }
85
+
86
+ export function useCatalogueDoc(rawUrl) {
87
+ const url = normalizeCatalogueUrl(rawUrl);
88
+ // Start from the resolved cache (if any) so a known doc renders as the full
89
+ // card immediately, avoiding a basic→full flash on mount.
90
+ const [result, setResult] = useState(() =>
91
+ url && resolvedCache.has(url) ? resolvedCache.get(url) : null,
92
+ );
93
+
94
+ useEffect(() => {
95
+ if (!url) {
96
+ setResult(null);
97
+ return undefined;
98
+ }
99
+ // Already resolved: stay in sync without an effect round-trip.
100
+ if (resolvedCache.has(url)) {
101
+ setResult(resolvedCache.get(url));
102
+ return undefined;
103
+ }
104
+ let ignore = false;
105
+ fetchCatalogueDoc(url).then((doc) => {
106
+ if (!ignore) {
107
+ setResult(doc);
108
+ }
109
+ });
110
+ return () => {
111
+ ignore = true;
112
+ };
113
+ }, [url]);
114
+
115
+ return { result, loading: !!url && result === null };
116
+ }
@@ -0,0 +1,149 @@
1
+ import { act, renderHook } from '@testing-library/react-hooks';
2
+
3
+ import { registry } from '@eeacms/search';
4
+ import { fetchResult } from '@eeacms/search/lib/hocs/useResult';
5
+ import { useCatalogueDoc } from './useCatalogueDoc';
6
+
7
+ jest.mock('@eeacms/search', () => ({
8
+ registry: { searchui: {} },
9
+ }));
10
+
11
+ jest.mock('@eeacms/search/lib/hocs/useResult', () => ({
12
+ fetchResult: jest.fn(),
13
+ }));
14
+
15
+ const appConfig = { app: 'navigatorCatalogueSearch', endpoint: '/_es' };
16
+
17
+ const flushPromises = () => act(() => Promise.resolve());
18
+
19
+ describe('useCatalogueDoc', () => {
20
+ beforeEach(() => {
21
+ fetchResult.mockReset();
22
+ registry.searchui.navigatorCatalogueSearch = appConfig;
23
+ });
24
+
25
+ it('returns a null result and loading state while the fetch is pending', () => {
26
+ fetchResult.mockReturnValue(new Promise(() => {}));
27
+ const { result } = renderHook(() =>
28
+ useCatalogueDoc('https://example.com/doc-a'),
29
+ );
30
+ expect(result.current).toEqual({ result: null, loading: true });
31
+ });
32
+
33
+ it('exposes the searchlib result when the document is found', async () => {
34
+ const esResult = { found: true, uid: 'https://example.com/doc-b' };
35
+ fetchResult.mockResolvedValue(esResult);
36
+
37
+ const { result } = renderHook(() =>
38
+ useCatalogueDoc('https://example.com/doc-b'),
39
+ );
40
+ await flushPromises();
41
+
42
+ expect(fetchResult).toHaveBeenCalledWith(
43
+ 'https://example.com/doc-b',
44
+ appConfig,
45
+ registry,
46
+ );
47
+ expect(result.current).toEqual({ result: esResult, loading: false });
48
+ });
49
+
50
+ it('resolves to null when the document is not found', async () => {
51
+ fetchResult.mockResolvedValue({ found: false });
52
+
53
+ const { result } = renderHook(() =>
54
+ useCatalogueDoc('https://example.com/doc-missing'),
55
+ );
56
+ await flushPromises();
57
+
58
+ expect(result.current).toEqual({ result: null, loading: true });
59
+ });
60
+
61
+ it('resolves to null when the request fails', async () => {
62
+ fetchResult.mockRejectedValue(new Error('ES unavailable'));
63
+
64
+ const { result } = renderHook(() =>
65
+ useCatalogueDoc('https://example.com/doc-fail'),
66
+ );
67
+ await flushPromises();
68
+
69
+ expect(result.current).toEqual({ result: null, loading: true });
70
+ });
71
+
72
+ it('caches the fetch per URL: repeated renders do not refetch', async () => {
73
+ fetchResult.mockResolvedValue({
74
+ found: true,
75
+ uid: 'https://example.com/doc-c',
76
+ });
77
+
78
+ const { result, rerender } = renderHook(() =>
79
+ useCatalogueDoc('https://example.com/doc-c'),
80
+ );
81
+ await flushPromises();
82
+ rerender();
83
+ await flushPromises();
84
+
85
+ expect(fetchResult).toHaveBeenCalledTimes(1);
86
+ expect(result.current.result).toEqual({
87
+ found: true,
88
+ uid: 'https://example.com/doc-c',
89
+ });
90
+ });
91
+
92
+ it('serves an already-resolved URL synchronously from the resolved cache', async () => {
93
+ fetchResult.mockResolvedValue({
94
+ found: true,
95
+ uid: 'https://example.com/doc-d',
96
+ });
97
+
98
+ const first = renderHook(() =>
99
+ useCatalogueDoc('https://example.com/doc-d'),
100
+ );
101
+ await flushPromises();
102
+ first.unmount();
103
+
104
+ // A fresh render for the same URL must not refetch and starts with the
105
+ // cached result (no basic -> full flash).
106
+ fetchResult.mockClear();
107
+ const second = renderHook(() =>
108
+ useCatalogueDoc('https://example.com/doc-d'),
109
+ );
110
+ expect(fetchResult).not.toHaveBeenCalled();
111
+ expect(second.result.current.result).toEqual({
112
+ found: true,
113
+ uid: 'https://example.com/doc-d',
114
+ });
115
+ });
116
+
117
+ it('does not fetch when the search app is not configured', async () => {
118
+ delete registry.searchui.navigatorCatalogueSearch;
119
+
120
+ const { result } = renderHook(() =>
121
+ useCatalogueDoc('https://example.com/doc-unconfigured'),
122
+ );
123
+ await flushPromises();
124
+
125
+ expect(fetchResult).not.toHaveBeenCalled();
126
+ expect(result.current).toEqual({ result: null, loading: true });
127
+ });
128
+
129
+ it('returns idle state (not loading) when no URL is given', () => {
130
+ const { result } = renderHook(() => useCatalogueDoc(undefined));
131
+ expect(result.current).toEqual({ result: null, loading: false });
132
+ });
133
+
134
+ it('normalizes URLs before fetching from Elasticsearch', async () => {
135
+ fetchResult.mockResolvedValue({ found: true });
136
+ renderHook(() =>
137
+ useCatalogueDoc(
138
+ 'http://cca.localhost/metadata/tools/climate-policy-radar/',
139
+ ),
140
+ );
141
+ await flushPromises();
142
+
143
+ expect(fetchResult).toHaveBeenCalledWith(
144
+ 'https://climate-adapt.eea.europa.eu/en/metadata/tools/climate-policy-radar',
145
+ appConfig,
146
+ registry,
147
+ );
148
+ });
149
+ });
@@ -23,6 +23,7 @@ import installASTNavigation from './ASTNavigation';
23
23
  import installFlourishEmbedBlock from './FlourishEmbedBlock';
24
24
  import installDataConnectedEmbed from './DataConnectedEmbedBlock';
25
25
  import installCountryMapProfile from './CountryMapProfile';
26
+ import installChatbotCatalogue from './ChatbotCatalogue';
26
27
 
27
28
  export default function installBlocks(config) {
28
29
  config.blocks.blocksConfig.title.restricted = false;
@@ -81,6 +82,7 @@ export default function installBlocks(config) {
81
82
  installTransRegionSelect,
82
83
  installCollectionStats,
83
84
  installTabsBlock,
85
+ installChatbotCatalogue,
84
86
  installListing,
85
87
  installRedirectBlock,
86
88
  installContentLinks,
@@ -83,7 +83,12 @@
83
83
  }
84
84
  }
85
85
 
86
- .searchapp-navigatorCatalogueSearch .catalogue-compare {
86
+ // The label+checkbox row is aligned via inline-flex. Needed both in the
87
+ // searchlib results page and in the CCA chatbot "catalogue" chat cards
88
+ // (see .catalogue-chat-navigator-card), otherwise the "Compare" text drops
89
+ // out of line with the checkbox.
90
+ .searchapp-navigatorCatalogueSearch .catalogue-compare,
91
+ .catalogue-chat-navigator-card .catalogue-compare {
87
92
  display: inline-flex;
88
93
  align-items: center;
89
94
  gap: 0.45rem;
@@ -571,28 +576,9 @@
571
576
  }
572
577
  }
573
578
 
574
- body.searchlib-page .searchapp-navigatorCatalogueSearch {
575
- .back-link {
576
- display: none;
577
- }
578
-
579
- .search-input,
580
- .sui-search-box {
581
- margin-bottom: 2rem;
582
- }
583
-
584
- .ui.right.sidebar {
585
- background-color: #fff;
586
- box-shadow: -4px 0 4px rgba(0, 0, 0, 0.25);
587
- }
588
-
589
- .sidebar-wrapper {
590
- display: flex;
591
- height: 100%;
592
- flex-direction: column;
593
- padding: 1em;
594
- }
595
-
579
+ // Shared styles for the Navigator catalogue card. Applied both in the
580
+ // searchlib results page and in the CCA chatbot "catalogue" chat cards.
581
+ .navigator-catalogue-card-styles() {
596
582
  .navigator-catalogue-item {
597
583
  display: grid;
598
584
  padding: 1.2rem;
@@ -725,6 +711,37 @@ body.searchlib-page .searchapp-navigatorCatalogueSearch {
725
711
  }
726
712
  }
727
713
 
714
+ body.searchlib-page .searchapp-navigatorCatalogueSearch {
715
+ .back-link {
716
+ display: none;
717
+ }
718
+
719
+ .search-input,
720
+ .sui-search-box {
721
+ margin-bottom: 2rem;
722
+ }
723
+
724
+ .ui.right.sidebar {
725
+ background-color: #fff;
726
+ box-shadow: -4px 0 4px rgba(0, 0, 0, 0.25);
727
+ }
728
+
729
+ .sidebar-wrapper {
730
+ display: flex;
731
+ height: 100%;
732
+ flex-direction: column;
733
+ padding: 1em;
734
+ }
735
+
736
+ .navigator-catalogue-card-styles();
737
+ }
738
+
739
+ // The chatbot "catalogue" chat renders the same Navigator card outside the
740
+ // searchlib results page, so apply the shared card styles there too.
741
+ .catalogue-chat-navigator-card {
742
+ .navigator-catalogue-card-styles();
743
+ }
744
+
728
745
  .navigator-tool-provider,
729
746
  .catalogue-date {
730
747
  color: @navigatorMetaTextColor;
@@ -1,5 +0,0 @@
1
- const StagingBanner = ({ banner, token, dispatch }) => {
2
- return null;
3
- };
4
-
5
- export default StagingBanner;
@@ -1,3 +0,0 @@
1
- export default function dummyConfig(config) {
2
- return config;
3
- }