@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
package/.eslintrc.js
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,39 @@ 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.15](https://github.com/eea/volto-cca-policy/compare/1.0.14...1.0.15) - 4 September 2026
|
|
8
|
+
|
|
9
|
+
#### :rocket: New Features
|
|
10
|
+
|
|
11
|
+
- feat: implement suggested next steps callout in catalogue chatbot [Tiberiu Ichim - [`ec91671`](https://github.com/eea/volto-cca-policy/commit/ec91671e11f232e59431a26108d82f342f439fef)]
|
|
12
|
+
- feat: match chatbot document cards to navigator catalogue cards [Tiberiu Ichim - [`39b21c0`](https://github.com/eea/volto-cca-policy/commit/39b21c071d7e7943dffdb6a9fcdf2dcb61a92601)]
|
|
13
|
+
- feat: register CCA 'catalogue' chatbot presentation variation [Tiberiu Ichim - [`1eb8690`](https://github.com/eea/volto-cca-policy/commit/1eb86902968225d20a77c6aa99488ca957c52b2d)]
|
|
14
|
+
|
|
15
|
+
#### :bug: Bug Fixes
|
|
16
|
+
|
|
17
|
+
- fix: eliminate regex backtracking / ReDoS vulnerabilities flagged by SonarQube [Tiberiu Ichim - [`728ef5f`](https://github.com/eea/volto-cca-policy/commit/728ef5f63dc1ae07881f647963347c99a83bd019)]
|
|
18
|
+
- fix: tolerate older chatbot without ChatMessageContext export [Tiberiu Ichim - [`be7f1d0`](https://github.com/eea/volto-cca-policy/commit/be7f1d06f70cdae46bcb532cae170fba6c3e9adb)]
|
|
19
|
+
- fix: let eslint alias resolver resolve .ts/.tsx entrypoints (chatbot deep imports) [Tiberiu Ichim - [`a007606`](https://github.com/eea/volto-cca-policy/commit/a007606cf127183fb2830e02f18c18f0a85d1137)]
|
|
20
|
+
- fix: declare @eeacms/volto-eea-chatbot dependency (CI lint resolution) [Tiberiu Ichim - [`dcb9759`](https://github.com/eea/volto-cca-policy/commit/dcb9759c97a7585e4c3e309fbe382767c8c276be)]
|
|
21
|
+
- fix: align the "Compare" checkbox label in chat doc cards [Tiberiu Ichim - [`f00333c`](https://github.com/eea/volto-cca-policy/commit/f00333c735f94b587a2f03fcf4794036caafd59b)]
|
|
22
|
+
- fix: style chat doc cards & robust marker matching [Tiberiu Ichim - [`2eede11`](https://github.com/eea/volto-cca-policy/commit/2eede11a5c9f4b0f309fe2b3cc3c6470d1a4606f)]
|
|
23
|
+
|
|
24
|
+
#### :nail_care: Enhancements
|
|
25
|
+
|
|
26
|
+
- perf: stop doc-card flicker while the answer streams [Tiberiu Ichim - [`5f5761c`](https://github.com/eea/volto-cca-policy/commit/5f5761c9534d1c64fc222510a0bacb5cd25ade68)]
|
|
27
|
+
- refactor: base catalogue chat on the classic ChatWindow [Tiberiu Ichim - [`2511c11`](https://github.com/eea/volto-cca-policy/commit/2511c115b9ee4b825293e08501f3d989ad07b5b3)]
|
|
28
|
+
|
|
29
|
+
#### :house: Internal changes
|
|
30
|
+
|
|
31
|
+
- chore: require @eeacms/volto-eea-chatbot ^4.1.0 (block presentation variations) [Tiberiu Ichim - [`03a9fa3`](https://github.com/eea/volto-cca-policy/commit/03a9fa3b538429b81667300ebcac3130f9103092)]
|
|
32
|
+
|
|
33
|
+
#### :hammer_and_wrench: Others
|
|
34
|
+
|
|
35
|
+
- test: add unit tests for the chatbot catalogue variation [Tiberiu Ichim - [`67025f9`](https://github.com/eea/volto-cca-policy/commit/67025f92ba74bade06bcca26fcd8e11bd2825efa)]
|
|
36
|
+
- perf: stop doc-card flicker while the answer streams [Tiberiu Ichim - [`5f5761c`](https://github.com/eea/volto-cca-policy/commit/5f5761c9534d1c64fc222510a0bacb5cd25ade68)]
|
|
37
|
+
- fix+feat: catalogue presentation — live streaming, block data, document cards [Tiberiu Ichim - [`e1a9f7a`](https://github.com/eea/volto-cca-policy/commit/e1a9f7a8eaacbf850b3069f34716b12ad1af0a8d)]
|
|
38
|
+
### [1.0.14](https://github.com/eea/volto-cca-policy/compare/1.0.13...1.0.14) - 1 September 2026
|
|
39
|
+
|
|
7
40
|
### [1.0.13](https://github.com/eea/volto-cca-policy/compare/1.0.12...1.0.13) - 31 August 2026
|
|
8
41
|
|
|
9
42
|
#### :hammer_and_wrench: Others
|
|
@@ -307,8 +340,13 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
|
|
307
340
|
#### :hammer_and_wrench: Others
|
|
308
341
|
|
|
309
342
|
- test: pin Chromium version 149 to work with Cypress [valentinab25 - [`659ec82`](https://github.com/eea/volto-cca-policy/commit/659ec823dd71fe592a3a7accda22effbe1fb07bc)]
|
|
310
|
-
## [1.0.0](https://github.com/eea/volto-cca-policy/compare/1.0.0-alpha.
|
|
343
|
+
## [1.0.0](https://github.com/eea/volto-cca-policy/compare/1.0.0-alpha.27...1.0.0) - 12 June 2026
|
|
344
|
+
|
|
345
|
+
### [1.0.0-alpha.27](https://github.com/eea/volto-cca-policy/compare/1.0.0-alpha.26...1.0.0-alpha.27) - 31 August 2026
|
|
346
|
+
|
|
347
|
+
#### :hammer_and_wrench: Others
|
|
311
348
|
|
|
349
|
+
- Refs #307732 - Remove volto-banner override. [GhitaB - [`44250da`](https://github.com/eea/volto-cca-policy/commit/44250da7145a69be41f6a4f11c84ef7c4a62d5c9)]
|
|
312
350
|
### [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
351
|
|
|
314
352
|
### [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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eeacms/volto-cca-policy",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.15",
|
|
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",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"url": "git@github.com:eea/volto-cca-policy.git"
|
|
18
18
|
},
|
|
19
19
|
"addons": [
|
|
20
|
+
"@eeacms/volto-eea-chatbot",
|
|
20
21
|
"@eeacms/volto-openlayers-map",
|
|
21
22
|
"@eeacms/volto-slate-label",
|
|
22
23
|
"@eeacms/volto-datablocks",
|
|
@@ -29,6 +30,7 @@
|
|
|
29
30
|
],
|
|
30
31
|
"dependencies": {
|
|
31
32
|
"@eeacms/volto-datablocks": "*",
|
|
33
|
+
"@eeacms/volto-eea-chatbot": "^4.1.0",
|
|
32
34
|
"@eeacms/volto-eea-design-system": "*",
|
|
33
35
|
"@eeacms/volto-eea-website-theme": "*",
|
|
34
36
|
"@eeacms/volto-embed": "*",
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ChatWindow } from '@eeacms/volto-eea-chatbot/ChatBlock/chat';
|
|
2
|
+
|
|
3
|
+
import { CcaDocCard } from './DocumentCard';
|
|
4
|
+
import { remarkCcaDocCards } from './docCards';
|
|
5
|
+
import { remarkCcaNextSteps } from './nextSteps';
|
|
6
|
+
import SuggestedNextSteps from './SuggestedNextSteps';
|
|
7
|
+
import './catalogue-chat.less';
|
|
8
|
+
|
|
9
|
+
// Stable object/component identity so react-markdown does not treat the
|
|
10
|
+
// custom element as a new component type on every render.
|
|
11
|
+
const extraMarkdownComponents = {
|
|
12
|
+
'cca-doc-card': CcaDocCard,
|
|
13
|
+
'cca-suggested-next-steps': SuggestedNextSteps,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const extraRemarkPlugins = [remarkCcaDocCards, remarkCcaNextSteps];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The CCA "catalogue" presentation for the AI Chatbot block.
|
|
20
|
+
*
|
|
21
|
+
* It reuses the classic `ChatWindow` (and therefore keeps all the classic
|
|
22
|
+
* functionality — the "Thinking"/tools accordion, styled "New chat" button,
|
|
23
|
+
* streaming placeholder, quality check, related questions, feedback, etc.)
|
|
24
|
+
* and only customises two things:
|
|
25
|
+
*
|
|
26
|
+
* 1. `hideSourcesTab` — the classic "Sources" tab/sidebar/inline list is
|
|
27
|
+
* suppressed, because the cited documents are presented as inline
|
|
28
|
+
* catalogue cards instead.
|
|
29
|
+
* 2. `extraRemarkPlugins` + `extraMarkdownComponents` — the
|
|
30
|
+
* `![[doc: Title]]` markers in the answer text (see `docCards.js`) are
|
|
31
|
+
* turned into inline Navigator-style catalogue cards (`CcaDocCard`),
|
|
32
|
+
* and "Suggested next steps" sections are turned into styled callout boxes.
|
|
33
|
+
*
|
|
34
|
+
* `ChatBlockView` renders this as `<Presentation persona {...blockData} />`,
|
|
35
|
+
* so all block fields arrive as top-level props and are forwarded unchanged.
|
|
36
|
+
*/
|
|
37
|
+
function CatalogueChatView(props) {
|
|
38
|
+
return (
|
|
39
|
+
<ChatWindow
|
|
40
|
+
{...props}
|
|
41
|
+
hideSourcesTab
|
|
42
|
+
extraRemarkPlugins={extraRemarkPlugins}
|
|
43
|
+
extraMarkdownComponents={extraMarkdownComponents}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default CatalogueChatView;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import '@testing-library/jest-dom';
|
|
3
|
+
import { render } from '@testing-library/react';
|
|
4
|
+
|
|
5
|
+
import { ChatWindow } from '@eeacms/volto-eea-chatbot/ChatBlock/chat';
|
|
6
|
+
import { remarkCcaDocCards } from './docCards';
|
|
7
|
+
import { remarkCcaNextSteps } from './nextSteps';
|
|
8
|
+
import CatalogueChatView from './CatalogueChatView';
|
|
9
|
+
|
|
10
|
+
jest.mock('@eeacms/volto-eea-chatbot/ChatBlock/chat', () => ({
|
|
11
|
+
ChatWindow: jest.fn((props) => (
|
|
12
|
+
<div
|
|
13
|
+
data-testid="chat-window"
|
|
14
|
+
data-hide-sources-tab={String(props.hideSourcesTab)}
|
|
15
|
+
data-persona={props.persona?.name || ''}
|
|
16
|
+
/>
|
|
17
|
+
)),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
describe('CatalogueChatView', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
ChatWindow.mockClear();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('forwards the block fields (top-level props) to the classic ChatWindow', () => {
|
|
26
|
+
const { getByTestId } = render(
|
|
27
|
+
<CatalogueChatView
|
|
28
|
+
persona={{ name: 'Assistanta' }}
|
|
29
|
+
block_id="block-1"
|
|
30
|
+
assistant="57"
|
|
31
|
+
/>,
|
|
32
|
+
);
|
|
33
|
+
const chatWindow = getByTestId('chat-window');
|
|
34
|
+
expect(chatWindow).toHaveAttribute('data-persona', 'Assistanta');
|
|
35
|
+
expect(ChatWindow.mock.calls[0][0]).toEqual(
|
|
36
|
+
expect.objectContaining({
|
|
37
|
+
persona: { name: 'Assistanta' },
|
|
38
|
+
block_id: 'block-1',
|
|
39
|
+
assistant: '57',
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('suppresses the classic sources UI', () => {
|
|
45
|
+
const { getByTestId } = render(<CatalogueChatView persona={{}} />);
|
|
46
|
+
expect(getByTestId('chat-window')).toHaveAttribute(
|
|
47
|
+
'data-hide-sources-tab',
|
|
48
|
+
'true',
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('plugs the doc-card and next-steps remark plugins and components', () => {
|
|
53
|
+
render(<CatalogueChatView persona={{}} />);
|
|
54
|
+
const { extraRemarkPlugins, extraMarkdownComponents } =
|
|
55
|
+
ChatWindow.mock.calls[0][0];
|
|
56
|
+
expect(extraRemarkPlugins).toEqual([remarkCcaDocCards, remarkCcaNextSteps]);
|
|
57
|
+
expect(typeof extraMarkdownComponents['cca-doc-card']).toBe('function');
|
|
58
|
+
expect(typeof extraMarkdownComponents['cca-suggested-next-steps']).toBe(
|
|
59
|
+
'function',
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('keeps the extraMarkdownComponents and extraRemarkPlugins identity stable across renders', () => {
|
|
64
|
+
const { rerender } = render(<CatalogueChatView persona={{}} />);
|
|
65
|
+
const firstMd = ChatWindow.mock.calls[0][0].extraMarkdownComponents;
|
|
66
|
+
const firstPlugins = ChatWindow.mock.calls[0][0].extraRemarkPlugins;
|
|
67
|
+
rerender(<CatalogueChatView persona={{}} />);
|
|
68
|
+
const secondMd = ChatWindow.mock.calls[1][0].extraMarkdownComponents;
|
|
69
|
+
const secondPlugins = ChatWindow.mock.calls[1][0].extraRemarkPlugins;
|
|
70
|
+
expect(secondMd).toBe(firstMd);
|
|
71
|
+
expect(secondPlugins).toBe(firstPlugins);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import React, { useContext, memo } from 'react';
|
|
2
|
+
import { Icon } from 'semantic-ui-react';
|
|
3
|
+
import ExternalLink from '@eeacms/search/components/Result/ExternalLink';
|
|
4
|
+
|
|
5
|
+
import NavigatorCatalogueCardItem from '@eeacms/volto-cca-policy/components/Search/NavigatorCatalogue/NavigatorCatalogueCardItem';
|
|
6
|
+
// The `ChatMessageContext` seam only exists in @eeacms/volto-eea-chatbot from
|
|
7
|
+
// the release that follows the "block variations" work
|
|
8
|
+
// (eea/volto-eea-chatbot#32). Environments that resolve the published ^4.0.0
|
|
9
|
+
// package (e.g. the standalone CI build) don't have the named export, and a
|
|
10
|
+
// static named import would make webpack fail the whole app build there. So
|
|
11
|
+
// import the module namespace instead and degrade gracefully when the seam
|
|
12
|
+
// is missing (the cards then fall back to the basic metadata card).
|
|
13
|
+
import * as ChatBlockChat from '@eeacms/volto-eea-chatbot/ChatBlock/chat';
|
|
14
|
+
import { useCatalogueDoc } from './useCatalogueDoc';
|
|
15
|
+
|
|
16
|
+
// Fall back to an inert context (default value `undefined`) when the seam is
|
|
17
|
+
// absent, so `useContext` below is always called with a stable context object.
|
|
18
|
+
const ChatMessageContext =
|
|
19
|
+
ChatBlockChat.ChatMessageContext || React.createContext(undefined);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Clean page titles returned from Onyx/scrapers that append pipeline/section suffixes,
|
|
23
|
+
* e.g. "Climate policy radar | Tools | Discover the key services...".
|
|
24
|
+
*/
|
|
25
|
+
export function cleanDocumentTitle(title) {
|
|
26
|
+
if (!title || typeof title !== 'string') return '';
|
|
27
|
+
const pipeIndex = title.indexOf('|');
|
|
28
|
+
const firstPart = pipeIndex === -1 ? title : title.slice(0, pipeIndex);
|
|
29
|
+
return firstPart.trim() || title.trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Robust title matching between the assistant's marker and the message's cited documents.
|
|
34
|
+
* Tolerates case differences, clean vs noisy pipeline titles, and prefixes.
|
|
35
|
+
*/
|
|
36
|
+
export function matchesDocumentTitle(docTitle, searchTitle) {
|
|
37
|
+
if (!docTitle || !searchTitle) return false;
|
|
38
|
+
const d1 = docTitle.trim().toLowerCase();
|
|
39
|
+
const s1 = searchTitle.trim().toLowerCase();
|
|
40
|
+
if (d1 === s1) return true;
|
|
41
|
+
|
|
42
|
+
const dClean = cleanDocumentTitle(docTitle).toLowerCase();
|
|
43
|
+
const sClean = cleanDocumentTitle(searchTitle).toLowerCase();
|
|
44
|
+
if (dClean && sClean && dClean === sClean) return true;
|
|
45
|
+
|
|
46
|
+
if (d1.startsWith(sClean) || s1.startsWith(dClean)) return true;
|
|
47
|
+
if (
|
|
48
|
+
dClean &&
|
|
49
|
+
sClean &&
|
|
50
|
+
(dClean.includes(sClean) || sClean.includes(dClean))
|
|
51
|
+
) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function formatDate(value) {
|
|
58
|
+
if (!value) return null;
|
|
59
|
+
const d = new Date(value);
|
|
60
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
61
|
+
return d.toLocaleDateString('en-GB', {
|
|
62
|
+
day: '2-digit',
|
|
63
|
+
month: 'short',
|
|
64
|
+
year: '2-digit',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// `InlineDocCard` rebuilds the `source` object on every render (the answer is
|
|
69
|
+
// re-rendered on each streaming tick), so a plain `===` prop comparison would
|
|
70
|
+
// never memoize. Compare the actual fields instead.
|
|
71
|
+
function sourceEqual(prevSource, nextSource) {
|
|
72
|
+
return (
|
|
73
|
+
prevSource?.semantic_identifier === nextSource?.semantic_identifier &&
|
|
74
|
+
prevSource?.blurb === nextSource?.blurb &&
|
|
75
|
+
prevSource?.updated_at === nextSource?.updated_at &&
|
|
76
|
+
prevSource?.source_type === nextSource?.source_type &&
|
|
77
|
+
prevSource?.link === nextSource?.link
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sourcePropsEqual(prev, next) {
|
|
82
|
+
return prev.index === next.index && sourceEqual(prev.source, next.source);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Document card styled to match the Navigator catalogue cards
|
|
87
|
+
* (volto-cca-policy NavigatorCatalogueCardItem), used when full searchlib
|
|
88
|
+
* metadata is not yet loaded or for external/unindexed sources.
|
|
89
|
+
*/
|
|
90
|
+
const DocumentCard = memo(function DocumentCard({ source, index }) {
|
|
91
|
+
if (!source || typeof source !== 'object' || !source.semantic_identifier) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const {
|
|
95
|
+
semantic_identifier: rawTitle,
|
|
96
|
+
blurb,
|
|
97
|
+
updated_at,
|
|
98
|
+
source_type,
|
|
99
|
+
link,
|
|
100
|
+
} = source;
|
|
101
|
+
const isWeb = source_type === 'web';
|
|
102
|
+
const title = cleanDocumentTitle(rawTitle);
|
|
103
|
+
const formattedDate = formatDate(updated_at);
|
|
104
|
+
const typeLabel = isWeb ? 'Web' : 'Document';
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div className="catalogue-chat-navigator-card">
|
|
108
|
+
<div className="navigator-catalogue-item">
|
|
109
|
+
<div className="navigator-tool-icon large" aria-hidden="true">
|
|
110
|
+
<Icon className="ri-file-line" />
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<div className="catalogue-item-main">
|
|
114
|
+
<div className="catalogue-item-top">
|
|
115
|
+
<div className="navigator-tool-provider">
|
|
116
|
+
{typeof index === 'number' && (
|
|
117
|
+
<span className="chat-citation">{index} </span>
|
|
118
|
+
)}
|
|
119
|
+
{typeLabel}
|
|
120
|
+
</div>
|
|
121
|
+
{formattedDate && (
|
|
122
|
+
<span className="catalogue-date">{formattedDate}</span>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div className="catalogue-item-heading">
|
|
127
|
+
<h4>
|
|
128
|
+
{link ? (
|
|
129
|
+
<ExternalLink href={link} title={title}>
|
|
130
|
+
{title}
|
|
131
|
+
</ExternalLink>
|
|
132
|
+
) : (
|
|
133
|
+
<span title={title}>{title}</span>
|
|
134
|
+
)}
|
|
135
|
+
</h4>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
{blurb && <p className="catalogue-description">{blurb}</p>}
|
|
139
|
+
|
|
140
|
+
<div className="catalogue-item-footer">
|
|
141
|
+
<div className="catalogue-meta license-type">
|
|
142
|
+
<span className="catalogue-type">Type: {typeLabel}</span>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<div className="catalogue-actions">
|
|
146
|
+
{link && (
|
|
147
|
+
<ExternalLink
|
|
148
|
+
href={link}
|
|
149
|
+
className="ui button primary icon"
|
|
150
|
+
labelPosition="left"
|
|
151
|
+
>
|
|
152
|
+
View
|
|
153
|
+
<Icon className="ri-arrow-right-line" />
|
|
154
|
+
</ExternalLink>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}, sourcePropsEqual);
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Document card that upgrades itself: while it has only the Onyx-provided
|
|
166
|
+
* fields it renders the basic `DocumentCard`; once the globalsearch ES
|
|
167
|
+
* lookup (by document URL) resolves it re-renders as the full Navigator
|
|
168
|
+
* catalogue card.
|
|
169
|
+
*
|
|
170
|
+
* Memoized: the answer markdown is re-rendered on every streaming tick, but
|
|
171
|
+
* the (already-shown, potentially heavy) card should only re-render when its
|
|
172
|
+
* own data changes, not on every tick. Its own basic→full upgrade is driven
|
|
173
|
+
* by internal state, which still re-renders it exactly once.
|
|
174
|
+
*/
|
|
175
|
+
const EnhancedDocCard = memo(function EnhancedDocCard({ source, index }) {
|
|
176
|
+
const { result } = useCatalogueDoc(source?.link);
|
|
177
|
+
|
|
178
|
+
if (result) {
|
|
179
|
+
return (
|
|
180
|
+
<div className="catalogue-chat-navigator-card">
|
|
181
|
+
<NavigatorCatalogueCardItem result={result} />
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return <DocumentCard source={source} index={index} />;
|
|
186
|
+
}, sourcePropsEqual);
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Inline document card, rendered in the message text where the assistant
|
|
190
|
+
* emitted a `![[doc: Title]]` marker (see docCards.js). Tries to match the
|
|
191
|
+
* title against the message's cited documents to enrich with link/blurb.
|
|
192
|
+
*/
|
|
193
|
+
export function InlineDocCard({ title, documents = [] }) {
|
|
194
|
+
const doc = (documents || []).find(
|
|
195
|
+
(d) =>
|
|
196
|
+
d.semantic_identifier &&
|
|
197
|
+
title &&
|
|
198
|
+
matchesDocumentTitle(d.semantic_identifier, title),
|
|
199
|
+
);
|
|
200
|
+
const source = {
|
|
201
|
+
semantic_identifier: title,
|
|
202
|
+
blurb: doc?.blurb,
|
|
203
|
+
updated_at: doc?.updated_at,
|
|
204
|
+
source_type: doc?.source_type,
|
|
205
|
+
link: doc?.link,
|
|
206
|
+
};
|
|
207
|
+
return (
|
|
208
|
+
<div className="catalogue-chat-card-inline">
|
|
209
|
+
<EnhancedDocCard source={source} />
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Stable react-markdown component for the `cca-doc-card` element emitted by
|
|
216
|
+
* the `remarkCcaDocCards` plugin. It reads the owning message from
|
|
217
|
+
* `ChatMessageContext` (set by the core `ChatMessage`) to match the marker
|
|
218
|
+
* title against that message's cited documents.
|
|
219
|
+
*
|
|
220
|
+
* Kept as a top-level (stable) component type so react does not remount it
|
|
221
|
+
* on every render while the answer streams.
|
|
222
|
+
*/
|
|
223
|
+
export function CcaDocCard({ title }) {
|
|
224
|
+
const message = useContext(ChatMessageContext);
|
|
225
|
+
return <InlineDocCard title={title} documents={message?.documents} />;
|
|
226
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import '@testing-library/jest-dom';
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
|
|
5
|
+
import { ChatMessageContext } from '@eeacms/volto-eea-chatbot/ChatBlock/chat';
|
|
6
|
+
import { useCatalogueDoc } from './useCatalogueDoc';
|
|
7
|
+
import { CcaDocCard, InlineDocCard } from './DocumentCard';
|
|
8
|
+
|
|
9
|
+
jest.mock('@eeacms/volto-eea-chatbot/ChatBlock/chat', () => ({
|
|
10
|
+
ChatMessageContext:
|
|
11
|
+
// eslint-disable-next-line global-require
|
|
12
|
+
require('react').createContext(null),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
jest.mock(
|
|
16
|
+
'@eeacms/volto-cca-policy/components/Search/NavigatorCatalogue/NavigatorCatalogueCardItem',
|
|
17
|
+
() =>
|
|
18
|
+
({ result }) => <div data-testid="navigator-card" />,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
jest.mock('./useCatalogueDoc', () => ({
|
|
22
|
+
useCatalogueDoc: jest.fn(() => ({ result: null, loading: true })),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const doc = {
|
|
26
|
+
semantic_identifier: 'France: National Adaptation Strategy',
|
|
27
|
+
blurb: 'The French NAS.',
|
|
28
|
+
updated_at: '2023-05-01T00:00:00Z',
|
|
29
|
+
source_type: 'document',
|
|
30
|
+
link: 'https://example.com/france-nas',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const webDoc = {
|
|
34
|
+
semantic_identifier: 'Some Web Source',
|
|
35
|
+
blurb: 'A web page.',
|
|
36
|
+
updated_at: '2023-05-01T00:00:00Z',
|
|
37
|
+
source_type: 'web',
|
|
38
|
+
link: 'https://example.com/web',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe('InlineDocCard', () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
useCatalogueDoc.mockReset();
|
|
44
|
+
useCatalogueDoc.mockReturnValue({ result: null, loading: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('renders a basic card with the marker title when no document matches', () => {
|
|
48
|
+
render(<InlineDocCard title="Unknown Title" documents={[doc]} />);
|
|
49
|
+
expect(screen.getByText('Unknown Title')).toBeInTheDocument();
|
|
50
|
+
expect(screen.getByText('Document')).toBeInTheDocument();
|
|
51
|
+
expect(screen.queryByText('The French NAS.')).not.toBeInTheDocument();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('matches the title case-insensitively and enriches with the document metadata', () => {
|
|
55
|
+
render(
|
|
56
|
+
<InlineDocCard
|
|
57
|
+
title=" france: NATIONAL adaptation strategy "
|
|
58
|
+
documents={[doc]}
|
|
59
|
+
/>,
|
|
60
|
+
);
|
|
61
|
+
expect(screen.getByText('The French NAS.')).toBeInTheDocument();
|
|
62
|
+
expect(screen.getByText('01 May 23')).toBeInTheDocument();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('renders the title as a link for web documents', () => {
|
|
66
|
+
render(<InlineDocCard title="Some Web Source" documents={[webDoc]} />);
|
|
67
|
+
const link = screen.getByRole('link', { name: 'Some Web Source' });
|
|
68
|
+
expect(link).toHaveAttribute('href', 'https://example.com/web');
|
|
69
|
+
expect(link).toHaveAttribute('target', '_blank');
|
|
70
|
+
expect(screen.getByText('Web')).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('omits the date when updated_at is missing or invalid', () => {
|
|
74
|
+
const noDate = { ...doc, updated_at: undefined };
|
|
75
|
+
render(
|
|
76
|
+
<InlineDocCard title={doc.semantic_identifier} documents={[noDate]} />,
|
|
77
|
+
);
|
|
78
|
+
expect(screen.queryByText('01 May 23')).not.toBeInTheDocument();
|
|
79
|
+
cleanup();
|
|
80
|
+
|
|
81
|
+
const badDate = { ...doc, updated_at: 'not-a-date' };
|
|
82
|
+
render(
|
|
83
|
+
<InlineDocCard title={doc.semantic_identifier} documents={[badDate]} />,
|
|
84
|
+
);
|
|
85
|
+
expect(screen.queryByText('01 May 23')).not.toBeInTheDocument();
|
|
86
|
+
cleanup();
|
|
87
|
+
|
|
88
|
+
render(<InlineDocCard title={doc.semantic_identifier} documents={[]} />);
|
|
89
|
+
expect(screen.getByText(doc.semantic_identifier)).toBeInTheDocument();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('upgrades to the full Navigator card once the ES lookup resolves', () => {
|
|
93
|
+
useCatalogueDoc.mockReturnValue({
|
|
94
|
+
result: { found: true, uid: 'https://example.com/france-nas' },
|
|
95
|
+
loading: false,
|
|
96
|
+
});
|
|
97
|
+
render(<InlineDocCard title={doc.semantic_identifier} documents={[doc]} />);
|
|
98
|
+
expect(screen.getByTestId('navigator-card')).toBeInTheDocument();
|
|
99
|
+
expect(useCatalogueDoc).toHaveBeenCalledWith(doc.link);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('cleans pipeline title noise and matches cleanly', () => {
|
|
103
|
+
const noisyDoc = {
|
|
104
|
+
...doc,
|
|
105
|
+
semantic_identifier:
|
|
106
|
+
'Climate policy radar | Tools | Discover the key services, thematic features and tools of Climate-ADAPT Climate-ADAPT',
|
|
107
|
+
};
|
|
108
|
+
render(
|
|
109
|
+
<InlineDocCard title="Climate policy radar" documents={[noisyDoc]} />,
|
|
110
|
+
);
|
|
111
|
+
expect(screen.getByText('Climate policy radar')).toBeInTheDocument();
|
|
112
|
+
expect(screen.getByText('The French NAS.')).toBeInTheDocument();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('renders a View button when link is present', () => {
|
|
116
|
+
render(<InlineDocCard title="Some Web Source" documents={[webDoc]} />);
|
|
117
|
+
const viewButton = screen.getByRole('link', { name: 'View' });
|
|
118
|
+
expect(viewButton).toHaveAttribute('href', 'https://example.com/web');
|
|
119
|
+
expect(viewButton).toHaveClass('ui button primary icon');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('calls the lookup with the document link (or undefined) from the card', () => {
|
|
123
|
+
useCatalogueDoc.mockReturnValue({ result: null, loading: true });
|
|
124
|
+
render(<InlineDocCard title="No Match" documents={[]} />);
|
|
125
|
+
expect(useCatalogueDoc).toHaveBeenCalledWith(undefined);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('CcaDocCard', () => {
|
|
130
|
+
it('matches the marker against the owning message documents via context', () => {
|
|
131
|
+
render(
|
|
132
|
+
<ChatMessageContext.Provider value={{ documents: [doc] }}>
|
|
133
|
+
<CcaDocCard title={doc.semantic_identifier} />
|
|
134
|
+
</ChatMessageContext.Provider>,
|
|
135
|
+
);
|
|
136
|
+
expect(screen.getByText('The French NAS.')).toBeInTheDocument();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('falls back to the basic card when the message context is absent', () => {
|
|
140
|
+
render(<CcaDocCard title={doc.semantic_identifier} />);
|
|
141
|
+
expect(screen.getByText(doc.semantic_identifier)).toBeInTheDocument();
|
|
142
|
+
expect(screen.queryByText('The French NAS.')).not.toBeInTheDocument();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('falls back to the basic card when the message has no documents', () => {
|
|
146
|
+
render(
|
|
147
|
+
<ChatMessageContext.Provider value={{ documents: [] }}>
|
|
148
|
+
<CcaDocCard title={doc.semantic_identifier} />
|
|
149
|
+
</ChatMessageContext.Provider>,
|
|
150
|
+
);
|
|
151
|
+
expect(screen.queryByText('The French NAS.')).not.toBeInTheDocument();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Icon } from 'semantic-ui-react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Renders the "Suggested next steps" callout box in the CCA chatbot
|
|
6
|
+
* catalogue presentation.
|
|
7
|
+
*/
|
|
8
|
+
function SuggestedNextSteps({ title = 'Suggested next steps', children }) {
|
|
9
|
+
return (
|
|
10
|
+
<div className="cca-suggested-next-steps">
|
|
11
|
+
<div className="cca-suggested-next-steps-header">
|
|
12
|
+
<Icon className="ri-list-check-2" aria-hidden="true" />
|
|
13
|
+
<span className="cca-suggested-next-steps-title">{title}</span>
|
|
14
|
+
</div>
|
|
15
|
+
<div className="cca-suggested-next-steps-content">{children}</div>
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default SuggestedNextSteps;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import '@testing-library/jest-dom';
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
|
|
5
|
+
import SuggestedNextSteps from './SuggestedNextSteps';
|
|
6
|
+
|
|
7
|
+
describe('SuggestedNextSteps component', () => {
|
|
8
|
+
it('renders default title and icon', () => {
|
|
9
|
+
const { container } = render(
|
|
10
|
+
<SuggestedNextSteps>
|
|
11
|
+
<ol>
|
|
12
|
+
<li>Step one</li>
|
|
13
|
+
</ol>
|
|
14
|
+
</SuggestedNextSteps>,
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
expect(screen.getByText('Suggested next steps')).toBeInTheDocument();
|
|
18
|
+
expect(screen.getByText('Step one')).toBeInTheDocument();
|
|
19
|
+
|
|
20
|
+
const icon = container.querySelector('.ri-list-check-2');
|
|
21
|
+
expect(icon).toBeInTheDocument();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('renders custom title when provided', () => {
|
|
25
|
+
render(
|
|
26
|
+
<SuggestedNextSteps title="How to use them">
|
|
27
|
+
<p>Follow instructions.</p>
|
|
28
|
+
</SuggestedNextSteps>,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
expect(screen.getByText('How to use them')).toBeInTheDocument();
|
|
32
|
+
expect(screen.getByText('Follow instructions.')).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
});
|