@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.
- package/.eslintrc.js +1 -1
- package/CHANGELOG.md +39 -1
- package/package.json +3 -1
- package/src/components/manage/Blocks/ChatbotCatalogue/CatalogueChatView.jsx +48 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/CatalogueChatView.test.jsx +73 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/DocumentCard.jsx +226 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/DocumentCard.test.jsx +153 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/SuggestedNextSteps.jsx +20 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/SuggestedNextSteps.test.jsx +34 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/catalogue-chat.less +136 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/docCards.js +74 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/docCards.test.js +145 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/index.js +24 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/index.test.jsx +44 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/nextSteps.js +79 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/nextSteps.test.js +168 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/useCatalogueDoc.js +116 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/useCatalogueDoc.test.js +149 -0
- package/src/components/manage/Blocks/index.js +2 -0
- package/theme/globals/navigator.less +40 -23
- package/src/customizations/@eeacms/volto-banner/StagingBanner.jsx +0 -5
- 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
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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;
|