@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
package/.eslintrc.js CHANGED
@@ -40,7 +40,7 @@ const defaultConfig = {
40
40
  ['@root', `${__dirname}/src`],
41
41
  ['~', `${__dirname}/src`],
42
42
  ],
43
- extensions: ['.js', '.jsx', '.json'],
43
+ extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],
44
44
  },
45
45
  'babel-plugin-root-import': {
46
46
  rootPathSuffix: 'src',
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.26...1.0.0) - 12 June 2026
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.13",
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
+ });