@coveo/quantic 3.39.1 → 3.40.0
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/force-app/main/default/classes/HeadlessControllerTest.cls +1 -0
- package/force-app/main/default/classes/InsightControllerTest.cls +1 -0
- package/force-app/main/default/classes/RecommendationsControllerTest.cls +1 -1
- package/force-app/main/default/classes/SampleTokenProvider.cls +1 -0
- package/force-app/main/default/classes/SampleTokenProviderTest.cls +1 -0
- package/force-app/main/default/lwc/quanticGeneratedAnswer/__tests__/quanticGeneratedAnswer.test.js +54 -0
- package/force-app/main/default/lwc/quanticGeneratedAnswer/quanticGeneratedAnswer.js +26 -3
- package/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.css +5 -0
- package/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.html +3 -1
- package/force-app/main/default/lwc/quanticGeneratedAnswerContent/__tests__/quanticGeneratedAnswerContent.test.js +269 -0
- package/force-app/main/default/lwc/quanticGeneratedAnswerContent/quanticGeneratedAnswerContent.js +136 -0
- package/force-app/main/default/lwc/quanticGeneratedAnswerContent/templates/generatedMarkdownContent.css +10 -0
- package/force-app/main/default/lwc/quanticUtils/__tests__/markdownUtils.test.js +38 -0
- package/force-app/main/default/lwc/quanticUtils/markdownUtils.js +18 -0
- package/force-app/main/default/staticresources/coveoheadless/case-assist/headless.js +1 -1
- package/force-app/main/default/staticresources/coveoheadless/headless.js +1 -1
- package/force-app/main/default/staticresources/coveoheadless/insight/headless.js +1 -1
- package/force-app/main/default/staticresources/coveoheadless/recommendation/headless.js +1 -1
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
@isTest
|
|
2
2
|
private class HeadlessControllerTest {
|
|
3
|
+
// This API key is intentionally public — it belongs to a sample organization used for samples/docs.
|
|
3
4
|
static final String sampleHeadlessConfiguration = '{"accessToken":"xx564559b1-0045-48e1-953c-3addd1ee4457","organizationId":"searchuisamples"}';
|
|
4
5
|
@IsTest
|
|
5
6
|
static void shouldReturnStringifiedConfiguration() {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
@isTest
|
|
2
2
|
private class InsightControllerTest {
|
|
3
|
+
// This API key is intentionally public — it belongs to a sample organization used for samples/docs.
|
|
3
4
|
static final String sampleHeadlessConfiguration = '{"accessToken":"xx564559b1-0045-48e1-953c-3addd1ee4457","organizationId":"searchuisamples"}';
|
|
4
5
|
@IsTest
|
|
5
6
|
static void shouldReturnStringifiedConfiguration() {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
@isTest
|
|
2
2
|
private class RecommendationsControllerTest {
|
|
3
|
-
// This is
|
|
3
|
+
// This API key is intentionally public — it belongs to a sample organization used for samples/docs.
|
|
4
4
|
static final String sampleHeadlessConfiguration = '{"accessToken":"xx564559b1-0045-48e1-953c-3addd1ee4457","organizationId":"searchuisamples"}';
|
|
5
5
|
@IsTest
|
|
6
6
|
static void shouldReturnStringifiedConfiguration() {
|
|
@@ -10,6 +10,7 @@ global with sharing class SampleTokenProvider implements ITokenProvider {
|
|
|
10
10
|
headlessConfiguration.put('organizationId', 'searchuisamples');
|
|
11
11
|
headlessConfiguration.put(
|
|
12
12
|
'accessToken',
|
|
13
|
+
// This API key is intentionally public — it belongs to a sample organization used for samples/docs.
|
|
13
14
|
'xx564559b1-0045-48e1-953c-3addd1ee4457'
|
|
14
15
|
);
|
|
15
16
|
return JSON.serialize(headlessConfiguration);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
@isTest
|
|
2
2
|
private class SampleTokenProviderTest {
|
|
3
|
+
// This API key is intentionally public — it belongs to a sample organization used for samples/docs.
|
|
3
4
|
static final String sampleHeadlessConfiguration = '{"accessToken":"xx564559b1-0045-48e1-953c-3addd1ee4457","organizationId":"searchuisamples"}';
|
|
4
5
|
@IsTest
|
|
5
6
|
static void shouldReturnStringifiedConfiguration() {
|
package/force-app/main/default/lwc/quanticGeneratedAnswer/__tests__/quanticGeneratedAnswer.test.js
CHANGED
|
@@ -22,6 +22,8 @@ const exampleCitations = [
|
|
|
22
22
|
uri: 'https://example.com/2',
|
|
23
23
|
},
|
|
24
24
|
];
|
|
25
|
+
const exampleEngineId = 'example engine id';
|
|
26
|
+
const exampleAnswerId = 'example answer id';
|
|
25
27
|
jest.mock('c/quanticHeadlessLoader');
|
|
26
28
|
jest.mock('c/quanticUtils', () => ({
|
|
27
29
|
AriaLiveRegion: jest.fn(() => ({
|
|
@@ -50,6 +52,7 @@ jest.mock(
|
|
|
50
52
|
|
|
51
53
|
/** @type {Object} */
|
|
52
54
|
const defaultOptions = {
|
|
55
|
+
engineId: exampleEngineId,
|
|
53
56
|
fieldsToIncludeInCitations: 'sfid,sfkbid,sfkavid,filetype',
|
|
54
57
|
answerConfigurationId: undefined,
|
|
55
58
|
withToggle: false,
|
|
@@ -348,6 +351,7 @@ describe('c-quantic-generated-answer', () => {
|
|
|
348
351
|
isStreaming: true,
|
|
349
352
|
answer: exampleAnswer,
|
|
350
353
|
answerContentFormat: exampleAnswerContentFormat,
|
|
354
|
+
answerId: exampleAnswerId,
|
|
351
355
|
};
|
|
352
356
|
mockSuccessfulHeadlessInitialization();
|
|
353
357
|
prepareHeadlessState();
|
|
@@ -461,6 +465,8 @@ describe('c-quantic-generated-answer', () => {
|
|
|
461
465
|
expect(generatedAnswerContent.answerContentFormat).toBe(
|
|
462
466
|
exampleAnswerContentFormat
|
|
463
467
|
);
|
|
468
|
+
expect(generatedAnswerContent.engineId).toBe(exampleEngineId);
|
|
469
|
+
expect(generatedAnswerContent.answerId).toBe(exampleAnswerId);
|
|
464
470
|
});
|
|
465
471
|
|
|
466
472
|
it('should not display the generated answer actions', async () => {
|
|
@@ -784,6 +790,54 @@ describe('c-quantic-generated-answer', () => {
|
|
|
784
790
|
expect(generatedAnswerCitations).not.toBeNull();
|
|
785
791
|
expect(generatedAnswerCitations.disableCitationAnchoring).toBe(false);
|
|
786
792
|
});
|
|
793
|
+
|
|
794
|
+
describe('when follow-ups are enabled', () => {
|
|
795
|
+
// TODO SFINT-6786: Add test cases to cover the behavior of the component when follow-ups are enabled based on the actual implementation of the follow-up feature in the state.
|
|
796
|
+
it.skip('should render the content section with the scrollable class and ignore the collapsible feature', async () => {
|
|
797
|
+
mockAnswerHeight = defaultAnswerHeight + 100;
|
|
798
|
+
const element = createTestComponent({
|
|
799
|
+
...defaultOptions,
|
|
800
|
+
collapsible: true,
|
|
801
|
+
});
|
|
802
|
+
await flushPromises();
|
|
803
|
+
|
|
804
|
+
const generatedAnswerBody = element.shadowRoot.querySelector(
|
|
805
|
+
selectors.generatedAnswerBody
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
expect(generatedAnswerBody).not.toBeNull();
|
|
809
|
+
expect(
|
|
810
|
+
generatedAnswerBody.classList.contains(
|
|
811
|
+
'generated-answer__content--scrollable'
|
|
812
|
+
)
|
|
813
|
+
).toBe(true);
|
|
814
|
+
|
|
815
|
+
const generatedAnswerCollapseToggle =
|
|
816
|
+
element.shadowRoot.querySelector(
|
|
817
|
+
selectors.generatedAnswerCollapseToggle
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
expect(generatedAnswerCollapseToggle).toBeNull();
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
describe('when follow-ups are not enabled', () => {
|
|
825
|
+
it('should not render the content section with the scrollable class', async () => {
|
|
826
|
+
const element = createTestComponent();
|
|
827
|
+
await flushPromises();
|
|
828
|
+
|
|
829
|
+
const generatedAnswerBody = element.shadowRoot.querySelector(
|
|
830
|
+
selectors.generatedAnswerBody
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
expect(generatedAnswerBody).not.toBeNull();
|
|
834
|
+
expect(
|
|
835
|
+
generatedAnswerBody.classList.contains(
|
|
836
|
+
'generated-answer__content--scrollable'
|
|
837
|
+
)
|
|
838
|
+
).toBe(false);
|
|
839
|
+
});
|
|
840
|
+
});
|
|
787
841
|
});
|
|
788
842
|
|
|
789
843
|
describe('when the answer cannot be generated after a query is executed', () => {
|
|
@@ -172,6 +172,8 @@ export default class QuanticGeneratedAnswer extends LightningElement {
|
|
|
172
172
|
/** @type {boolean} */
|
|
173
173
|
hasInitializationError = false;
|
|
174
174
|
/** @type {boolean} */
|
|
175
|
+
_areFollowUpsEnabled = false;
|
|
176
|
+
/** @type {boolean} */
|
|
175
177
|
_exceedsMaximumHeight = false;
|
|
176
178
|
/** @type {boolean} */
|
|
177
179
|
_liked = false;
|
|
@@ -196,7 +198,7 @@ export default class QuanticGeneratedAnswer extends LightningElement {
|
|
|
196
198
|
|
|
197
199
|
renderedCallback() {
|
|
198
200
|
initializeWithHeadless(this, this.engineId, this.initialize);
|
|
199
|
-
if (this.
|
|
201
|
+
if (this.isCollapsibleEnabled) {
|
|
200
202
|
this._exceedsMaximumHeight = this.isMaximumHeightExceeded();
|
|
201
203
|
}
|
|
202
204
|
}
|
|
@@ -259,7 +261,7 @@ export default class QuanticGeneratedAnswer extends LightningElement {
|
|
|
259
261
|
this.updateFeedbackState();
|
|
260
262
|
this.ariaLiveMessage.dispatchMessage(this.getGeneratedAnswerStatus());
|
|
261
263
|
|
|
262
|
-
if (this.
|
|
264
|
+
if (this.isCollapsibleEnabled) {
|
|
263
265
|
this.updateGeneratedAnswerCSSVariables();
|
|
264
266
|
}
|
|
265
267
|
}
|
|
@@ -400,7 +402,7 @@ export default class QuanticGeneratedAnswer extends LightningElement {
|
|
|
400
402
|
|
|
401
403
|
handleAnswerContentUpdated = (event) => {
|
|
402
404
|
event.stopPropagation();
|
|
403
|
-
if (this.
|
|
405
|
+
if (this.isCollapsibleEnabled) {
|
|
404
406
|
this._exceedsMaximumHeight = this.isMaximumHeightExceeded();
|
|
405
407
|
}
|
|
406
408
|
this.updateGeneratedAnswerCSSVariables();
|
|
@@ -456,6 +458,10 @@ export default class QuanticGeneratedAnswer extends LightningElement {
|
|
|
456
458
|
return this?.state?.answer;
|
|
457
459
|
}
|
|
458
460
|
|
|
461
|
+
get answerId() {
|
|
462
|
+
return this?.state?.answerId;
|
|
463
|
+
}
|
|
464
|
+
|
|
459
465
|
get citations() {
|
|
460
466
|
return this?.state?.citations;
|
|
461
467
|
}
|
|
@@ -481,6 +487,15 @@ export default class QuanticGeneratedAnswer extends LightningElement {
|
|
|
481
487
|
return this.state.isVisible;
|
|
482
488
|
}
|
|
483
489
|
|
|
490
|
+
get areFollowUpsEnabled() {
|
|
491
|
+
// TODO SFINT-6786: Modify this getter to return the actual value from the state for follow-up enabled/agentId.
|
|
492
|
+
return this._areFollowUpsEnabled;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
get isCollapsibleEnabled() {
|
|
496
|
+
return this.collapsible && !this.areFollowUpsEnabled;
|
|
497
|
+
}
|
|
498
|
+
|
|
484
499
|
get isAnswerCollapsed() {
|
|
485
500
|
// Answer is considered collapsed only if it exceeds the maximum height and was not expanded.
|
|
486
501
|
return this._exceedsMaximumHeight && !this.isExpanded;
|
|
@@ -501,6 +516,14 @@ export default class QuanticGeneratedAnswer extends LightningElement {
|
|
|
501
516
|
return `generated-answer__answer ${collapsedStateClass}`;
|
|
502
517
|
}
|
|
503
518
|
|
|
519
|
+
get contentSectionClass() {
|
|
520
|
+
const baseClass =
|
|
521
|
+
'generated-answer__content slds-p-top_medium slds-p-horizontal_large';
|
|
522
|
+
return this.areFollowUpsEnabled
|
|
523
|
+
? `${baseClass} generated-answer__content--scrollable`
|
|
524
|
+
: baseClass;
|
|
525
|
+
}
|
|
526
|
+
|
|
504
527
|
get hasRetryableError() {
|
|
505
528
|
return !this?.searchStatusState?.hasError && this.state?.error?.isRetryable;
|
|
506
529
|
}
|
|
@@ -27,13 +27,15 @@
|
|
|
27
27
|
<template lwc:if={isVisible}>
|
|
28
28
|
<section
|
|
29
29
|
data-testid="generated-answer__body"
|
|
30
|
-
class=
|
|
30
|
+
class={contentSectionClass}
|
|
31
31
|
>
|
|
32
32
|
<div
|
|
33
33
|
data-testid="generated-answer__answer"
|
|
34
34
|
class={generatedAnswerClass}
|
|
35
35
|
>
|
|
36
36
|
<c-quantic-generated-answer-content
|
|
37
|
+
engine-id={engineId}
|
|
38
|
+
answer-id={answerId}
|
|
37
39
|
answer-content-format={answerContentFormat}
|
|
38
40
|
answer={answer}
|
|
39
41
|
is-streaming={isStreaming}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
/* eslint-disable no-import-assign */
|
|
1
2
|
// @ts-ignore
|
|
2
3
|
import QuanticGeneratedAnswerContent from 'c/quanticGeneratedAnswerContent';
|
|
3
4
|
import {createElement} from 'lwc';
|
|
4
5
|
import {loadMarkdownDependencies} from 'c/quanticUtils';
|
|
6
|
+
import * as mockHeadlessLoader from 'c/quanticHeadlessLoader';
|
|
5
7
|
|
|
8
|
+
jest.mock('c/quanticHeadlessLoader');
|
|
6
9
|
jest.mock('c/quanticUtils', () => ({
|
|
7
10
|
loadMarkdownDependencies: jest.fn(
|
|
8
11
|
() =>
|
|
@@ -11,6 +14,9 @@ jest.mock('c/quanticUtils', () => ({
|
|
|
11
14
|
})
|
|
12
15
|
),
|
|
13
16
|
transformMarkdownToHtml: jest.fn((value) => value),
|
|
17
|
+
LinkUtils: {
|
|
18
|
+
bindAnalyticsToLink: jest.fn(() => jest.fn()),
|
|
19
|
+
},
|
|
14
20
|
}));
|
|
15
21
|
|
|
16
22
|
const mockMarkedUse = jest.fn();
|
|
@@ -23,14 +29,47 @@ global.marked = {
|
|
|
23
29
|
const SELECTORS = {
|
|
24
30
|
textAnswerContainer: 'span.generated-answer-content__answer',
|
|
25
31
|
markdownAnswerContainer: 'div.generated-answer-content__answer',
|
|
32
|
+
inlineLink: 'a[data-answer-inline-link]',
|
|
33
|
+
inlineLinkIconContainer: 'span.slds-icon_container',
|
|
34
|
+
inlineLinkIconSvg: 'svg.slds-icon',
|
|
26
35
|
};
|
|
27
36
|
|
|
37
|
+
const exampleEngineId = 'example-engine-id';
|
|
38
|
+
const exampleAnswerId = 'example-answer-id';
|
|
39
|
+
|
|
28
40
|
const defaultOptions = {
|
|
29
41
|
isStreaming: false,
|
|
30
42
|
answerContentFormat: 'text/plain',
|
|
31
43
|
answer: '',
|
|
32
44
|
};
|
|
33
45
|
|
|
46
|
+
const exampleEngine = {id: 'dummy-engine'};
|
|
47
|
+
let isInitialized = false;
|
|
48
|
+
|
|
49
|
+
const mockBuildInteractiveGeneratedAnswerInlineLink = jest.fn(() => ({
|
|
50
|
+
select: jest.fn(),
|
|
51
|
+
beginDelayedSelect: jest.fn(),
|
|
52
|
+
cancelPendingSelect: jest.fn(),
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
function prepareHeadlessState() {
|
|
56
|
+
// @ts-ignore
|
|
57
|
+
mockHeadlessLoader.getHeadlessBundle = () => ({
|
|
58
|
+
buildInteractiveGeneratedAnswerInlineLink:
|
|
59
|
+
mockBuildInteractiveGeneratedAnswerInlineLink,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function mockSuccessfulHeadlessInitialization() {
|
|
64
|
+
// @ts-ignore
|
|
65
|
+
mockHeadlessLoader.initializeWithHeadless = (element, _, initialize) => {
|
|
66
|
+
if (element instanceof QuanticGeneratedAnswerContent && !isInitialized) {
|
|
67
|
+
isInitialized = true;
|
|
68
|
+
initialize(exampleEngine);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
34
73
|
function createTestComponent(options = defaultOptions) {
|
|
35
74
|
const element = createElement('c-quantic-generated-answer-content', {
|
|
36
75
|
is: QuanticGeneratedAnswerContent,
|
|
@@ -43,6 +82,20 @@ function createTestComponent(options = defaultOptions) {
|
|
|
43
82
|
return element;
|
|
44
83
|
}
|
|
45
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Builds an HTML string containing anchor tags with the data-answer-inline-link
|
|
87
|
+
* attribute, which is what the component's processInlineLinks method targets.
|
|
88
|
+
* @param {Array<{href: string, text: string}>} links
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
function buildAnswerWithInlineLinks(links) {
|
|
92
|
+
return links
|
|
93
|
+
.map(
|
|
94
|
+
({href, text}) => `<a href="${href}" data-answer-inline-link>${text}</a>`
|
|
95
|
+
)
|
|
96
|
+
.join(' ');
|
|
97
|
+
}
|
|
98
|
+
|
|
46
99
|
// Helper function to wait until the microtask queue is empty.
|
|
47
100
|
function flushPromises() {
|
|
48
101
|
// eslint-disable-next-line @lwc/lwc/no-async-operation
|
|
@@ -56,6 +109,7 @@ describe('c-quantic-generated-answer-content', () => {
|
|
|
56
109
|
document.body.removeChild(document.body.firstChild);
|
|
57
110
|
}
|
|
58
111
|
jest.clearAllMocks();
|
|
112
|
+
isInitialized = false;
|
|
59
113
|
}
|
|
60
114
|
|
|
61
115
|
afterEach(() => {
|
|
@@ -148,4 +202,219 @@ describe('c-quantic-generated-answer-content', () => {
|
|
|
148
202
|
expect(loadMarkdownDependencies).toHaveBeenCalled();
|
|
149
203
|
});
|
|
150
204
|
});
|
|
205
|
+
|
|
206
|
+
describe('inline links in a markdown answer', () => {
|
|
207
|
+
const exampleLinks = [
|
|
208
|
+
{href: 'https://example.com/1', text: 'Link one'},
|
|
209
|
+
{href: 'https://example.com/2', text: 'Link two'},
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
beforeEach(() => {
|
|
213
|
+
mockSuccessfulHeadlessInitialization();
|
|
214
|
+
prepareHeadlessState();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('when the answer contains anchors with data-answer-inline-link', () => {
|
|
218
|
+
it('should set target="_blank" and append an icon span to each inline link anchor', async () => {
|
|
219
|
+
const {LinkUtils} = jest.requireMock('c/quanticUtils');
|
|
220
|
+
LinkUtils.bindAnalyticsToLink.mockReturnValue(jest.fn());
|
|
221
|
+
|
|
222
|
+
const element = createTestComponent({
|
|
223
|
+
...defaultOptions,
|
|
224
|
+
engineId: exampleEngineId,
|
|
225
|
+
answerId: exampleAnswerId,
|
|
226
|
+
answerContentFormat: 'text/markdown',
|
|
227
|
+
answer: buildAnswerWithInlineLinks(exampleLinks),
|
|
228
|
+
});
|
|
229
|
+
await flushPromises();
|
|
230
|
+
|
|
231
|
+
const answerContainer = element.shadowRoot.querySelector(
|
|
232
|
+
SELECTORS.markdownAnswerContainer
|
|
233
|
+
);
|
|
234
|
+
const anchors = answerContainer.querySelectorAll(SELECTORS.inlineLink);
|
|
235
|
+
|
|
236
|
+
expect(anchors).toHaveLength(exampleLinks.length);
|
|
237
|
+
anchors.forEach((anchor) => {
|
|
238
|
+
expect(anchor.target).toBe('_blank');
|
|
239
|
+
});
|
|
240
|
+
anchors.forEach((anchor) => {
|
|
241
|
+
const iconSpan = anchor.querySelector(
|
|
242
|
+
SELECTORS.inlineLinkIconContainer
|
|
243
|
+
);
|
|
244
|
+
expect(iconSpan).not.toBeNull();
|
|
245
|
+
expect(iconSpan.classList).toContain('slds-icon-utility-new_window');
|
|
246
|
+
expect(iconSpan.classList).toContain('slds-current-color');
|
|
247
|
+
expect(
|
|
248
|
+
iconSpan.querySelector(SELECTORS.inlineLinkIconSvg)
|
|
249
|
+
).not.toBeNull();
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should call buildInteractiveGeneratedAnswerInlineLink with the correct parameters for each inline link', async () => {
|
|
254
|
+
const {LinkUtils} = jest.requireMock('c/quanticUtils');
|
|
255
|
+
LinkUtils.bindAnalyticsToLink.mockReturnValue(jest.fn());
|
|
256
|
+
|
|
257
|
+
createTestComponent({
|
|
258
|
+
...defaultOptions,
|
|
259
|
+
engineId: exampleEngineId,
|
|
260
|
+
answerId: exampleAnswerId,
|
|
261
|
+
answerContentFormat: 'text/markdown',
|
|
262
|
+
answer: buildAnswerWithInlineLinks(exampleLinks),
|
|
263
|
+
});
|
|
264
|
+
await flushPromises();
|
|
265
|
+
|
|
266
|
+
expect(
|
|
267
|
+
mockBuildInteractiveGeneratedAnswerInlineLink
|
|
268
|
+
).toHaveBeenCalledTimes(exampleLinks.length);
|
|
269
|
+
expect(LinkUtils.bindAnalyticsToLink).toHaveBeenCalledTimes(
|
|
270
|
+
exampleLinks.length
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
exampleLinks.forEach((anchor, index) => {
|
|
274
|
+
expect(
|
|
275
|
+
mockBuildInteractiveGeneratedAnswerInlineLink
|
|
276
|
+
).toHaveBeenNthCalledWith(index + 1, exampleEngine, {
|
|
277
|
+
options: {
|
|
278
|
+
link: {
|
|
279
|
+
linkURL: anchor.href,
|
|
280
|
+
linkText: anchor.text,
|
|
281
|
+
},
|
|
282
|
+
answerId: exampleAnswerId,
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('should call LinkUtils.bindAnalyticsToLink for each inline link with the anchor and its controller', async () => {
|
|
289
|
+
const {LinkUtils} = jest.requireMock('c/quanticUtils');
|
|
290
|
+
const mockUnbind = jest.fn();
|
|
291
|
+
LinkUtils.bindAnalyticsToLink.mockReturnValue(mockUnbind);
|
|
292
|
+
|
|
293
|
+
const element = createTestComponent({
|
|
294
|
+
...defaultOptions,
|
|
295
|
+
engineId: exampleEngineId,
|
|
296
|
+
answerId: exampleAnswerId,
|
|
297
|
+
answerContentFormat: 'text/markdown',
|
|
298
|
+
answer: buildAnswerWithInlineLinks(exampleLinks),
|
|
299
|
+
});
|
|
300
|
+
await flushPromises();
|
|
301
|
+
|
|
302
|
+
expect(LinkUtils.bindAnalyticsToLink).toHaveBeenCalledTimes(
|
|
303
|
+
exampleLinks.length
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const answerContainer = element.shadowRoot.querySelector(
|
|
307
|
+
SELECTORS.markdownAnswerContainer
|
|
308
|
+
);
|
|
309
|
+
const anchors = Array.from(
|
|
310
|
+
answerContainer.querySelectorAll(SELECTORS.inlineLink)
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
anchors.forEach((anchor, index) => {
|
|
314
|
+
expect(LinkUtils.bindAnalyticsToLink).toHaveBeenNthCalledWith(
|
|
315
|
+
index + 1,
|
|
316
|
+
anchor,
|
|
317
|
+
expect.objectContaining({
|
|
318
|
+
select: expect.any(Function),
|
|
319
|
+
beginDelayedSelect: expect.any(Function),
|
|
320
|
+
cancelPendingSelect: expect.any(Function),
|
|
321
|
+
})
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe('when the answer does not contain anchors with data-answer-inline-link', () => {
|
|
328
|
+
it('should not call buildInteractiveGeneratedAnswerInlineLink and LinkUtils.bindAnalyticsToLink', async () => {
|
|
329
|
+
const {LinkUtils} = jest.requireMock('c/quanticUtils');
|
|
330
|
+
|
|
331
|
+
createTestComponent({
|
|
332
|
+
...defaultOptions,
|
|
333
|
+
engineId: exampleEngineId,
|
|
334
|
+
answerId: exampleAnswerId,
|
|
335
|
+
answerContentFormat: 'text/markdown',
|
|
336
|
+
answer: '<p>No inline links here</p>',
|
|
337
|
+
});
|
|
338
|
+
await flushPromises();
|
|
339
|
+
|
|
340
|
+
expect(
|
|
341
|
+
mockBuildInteractiveGeneratedAnswerInlineLink
|
|
342
|
+
).not.toHaveBeenCalled();
|
|
343
|
+
expect(LinkUtils.bindAnalyticsToLink).not.toHaveBeenCalled();
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe('when the headless bundle does not expose buildInteractiveGeneratedAnswerInlineLink', () => {
|
|
348
|
+
it('should not throw and should not call LinkUtils.bindAnalyticsToLink', async () => {
|
|
349
|
+
// @ts-ignore
|
|
350
|
+
mockHeadlessLoader.getHeadlessBundle = () => ({});
|
|
351
|
+
const {LinkUtils} = jest.requireMock('c/quanticUtils');
|
|
352
|
+
|
|
353
|
+
expect(() => {
|
|
354
|
+
createTestComponent({
|
|
355
|
+
...defaultOptions,
|
|
356
|
+
engineId: exampleEngineId,
|
|
357
|
+
answerId: exampleAnswerId,
|
|
358
|
+
answerContentFormat: 'text/markdown',
|
|
359
|
+
answer: buildAnswerWithInlineLinks(exampleLinks),
|
|
360
|
+
});
|
|
361
|
+
}).not.toThrow();
|
|
362
|
+
|
|
363
|
+
await flushPromises();
|
|
364
|
+
|
|
365
|
+
expect(LinkUtils.bindAnalyticsToLink).not.toHaveBeenCalled();
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
describe('when the answer is updated', () => {
|
|
370
|
+
it('should clean up previous inline link bindings before processing the new answer', async () => {
|
|
371
|
+
const {LinkUtils} = jest.requireMock('c/quanticUtils');
|
|
372
|
+
const mockUnbind = jest.fn();
|
|
373
|
+
LinkUtils.bindAnalyticsToLink.mockReturnValue(mockUnbind);
|
|
374
|
+
|
|
375
|
+
const element = createTestComponent({
|
|
376
|
+
...defaultOptions,
|
|
377
|
+
engineId: exampleEngineId,
|
|
378
|
+
answerId: exampleAnswerId,
|
|
379
|
+
answerContentFormat: 'text/markdown',
|
|
380
|
+
answer: buildAnswerWithInlineLinks(exampleLinks),
|
|
381
|
+
});
|
|
382
|
+
await flushPromises();
|
|
383
|
+
|
|
384
|
+
expect(mockUnbind).not.toHaveBeenCalled();
|
|
385
|
+
|
|
386
|
+
const updatedLinks = [
|
|
387
|
+
{href: 'https://example.com/3', text: 'Link three'},
|
|
388
|
+
];
|
|
389
|
+
element.answer = buildAnswerWithInlineLinks(updatedLinks);
|
|
390
|
+
await flushPromises();
|
|
391
|
+
|
|
392
|
+
expect(mockUnbind).toHaveBeenCalledTimes(exampleLinks.length);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
describe('when the component is disconnected', () => {
|
|
397
|
+
it('should call the unbind function for each inline link binding', async () => {
|
|
398
|
+
const {LinkUtils} = jest.requireMock('c/quanticUtils');
|
|
399
|
+
const mockUnbind = jest.fn();
|
|
400
|
+
LinkUtils.bindAnalyticsToLink.mockReturnValue(mockUnbind);
|
|
401
|
+
|
|
402
|
+
const element = createTestComponent({
|
|
403
|
+
...defaultOptions,
|
|
404
|
+
engineId: exampleEngineId,
|
|
405
|
+
answerId: exampleAnswerId,
|
|
406
|
+
answerContentFormat: 'text/markdown',
|
|
407
|
+
answer: buildAnswerWithInlineLinks(exampleLinks),
|
|
408
|
+
});
|
|
409
|
+
await flushPromises();
|
|
410
|
+
|
|
411
|
+
expect(mockUnbind).not.toHaveBeenCalled();
|
|
412
|
+
|
|
413
|
+
document.body.removeChild(element);
|
|
414
|
+
await flushPromises();
|
|
415
|
+
|
|
416
|
+
expect(mockUnbind).toHaveBeenCalledTimes(exampleLinks.length);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
});
|
|
151
420
|
});
|
package/force-app/main/default/lwc/quanticGeneratedAnswerContent/quanticGeneratedAnswerContent.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import {
|
|
2
|
+
registerComponentForInit,
|
|
3
|
+
initializeWithHeadless,
|
|
4
|
+
getHeadlessBundle,
|
|
5
|
+
} from 'c/quanticHeadlessLoader';
|
|
1
6
|
import {
|
|
2
7
|
transformMarkdownToHtml,
|
|
3
8
|
loadMarkdownDependencies,
|
|
9
|
+
LinkUtils,
|
|
4
10
|
} from 'c/quanticUtils';
|
|
5
11
|
import {LightningElement, api} from 'lwc';
|
|
6
12
|
// @ts-ignore
|
|
@@ -8,6 +14,21 @@ import generatedMarkdownContentTemplate from './templates/generatedMarkdownConte
|
|
|
8
14
|
// @ts-ignore
|
|
9
15
|
import generatedTextContentTemplate from './templates/generatedTextContent.html';
|
|
10
16
|
|
|
17
|
+
/** @typedef {import("coveo").SearchEngine} SearchEngine */
|
|
18
|
+
/** @typedef {import("coveo").InsightEngine} InsightEngine */
|
|
19
|
+
|
|
20
|
+
const INLINE_LINK_ICON = `
|
|
21
|
+
<svg
|
|
22
|
+
class="slds-icon answer-content__link-icon-svg"
|
|
23
|
+
aria-hidden="true"
|
|
24
|
+
focusable="false"
|
|
25
|
+
viewBox="0 0 52 52"
|
|
26
|
+
>
|
|
27
|
+
<path d="M48.5 2h-15c-.8 0-1.5.7-1.5 1.5v3c0 .8.7 1.5 1.5 1.5h6.4L22.1 25.8c-.6.6-.6 1.5 0 2.1l2.1 2.1c.6.6 1.5.6 2.1 0L44 12.3v6.2c0 .8.7 1.5 1.5 1.5h3c.8 0 1.5-.7 1.5-1.5v-15c0-.8-.7-1.5-1.5-1.5z"></path>
|
|
28
|
+
<path d="M38 28.5V46H8V16h17.5c.8 0 1.5-.7 1.5-1.5v-3c0-.8-.7-1.5-1.5-1.5H6c-2.2 0-4 1.8-4 4v34c0 2.2 1.8 4 4 4h34c2.2 0 4-1.8 4-4V28.5c0-.8-.7-1.5-1.5-1.5h-3c-.8 0-1.5.7-1.5 1.5z"></path>
|
|
29
|
+
</svg>
|
|
30
|
+
`;
|
|
31
|
+
|
|
11
32
|
/**
|
|
12
33
|
* The `QuanticGeneratedAnswerContent` component displays the generated answer content.
|
|
13
34
|
* @category Internal
|
|
@@ -16,6 +37,18 @@ import generatedTextContentTemplate from './templates/generatedTextContent.html'
|
|
|
16
37
|
* <c-quantic-generated-answer-content answer-content-format={answerContentFormat} answer={answer} is-streaming={isStreaming}></c-quantic-generated-answer-content>
|
|
17
38
|
*/
|
|
18
39
|
export default class QuanticGeneratedAnswerContent extends LightningElement {
|
|
40
|
+
/**
|
|
41
|
+
* The ID of the engine instance the component registers to.
|
|
42
|
+
* @api
|
|
43
|
+
* @type {string}
|
|
44
|
+
*/
|
|
45
|
+
@api engineId;
|
|
46
|
+
/**
|
|
47
|
+
* The unique identifier of the generated answer.
|
|
48
|
+
* @api
|
|
49
|
+
* @type {string}
|
|
50
|
+
*/
|
|
51
|
+
@api answerId;
|
|
19
52
|
/**
|
|
20
53
|
* If the answer is streaming, it will render a blinking cursor at the end of the answer.
|
|
21
54
|
* @api
|
|
@@ -65,6 +98,32 @@ export default class QuanticGeneratedAnswerContent extends LightningElement {
|
|
|
65
98
|
/** @type {'text/plain' | 'text/markdown'} */
|
|
66
99
|
_answerContentFormat = 'text/plain';
|
|
67
100
|
_markdownDependenciesLoaded = false;
|
|
101
|
+
/** @type {Array<function>} */
|
|
102
|
+
_inlineLinkBindings = [];
|
|
103
|
+
/** @type {AnyHeadless} */
|
|
104
|
+
headless;
|
|
105
|
+
/** @type {SearchEngine | InsightEngine} */
|
|
106
|
+
engine;
|
|
107
|
+
|
|
108
|
+
connectedCallback() {
|
|
109
|
+
registerComponentForInit(this, this.engineId);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
renderedCallback() {
|
|
113
|
+
initializeWithHeadless(this, this.engineId, this.initialize);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {SearchEngine | InsightEngine} engine
|
|
118
|
+
*/
|
|
119
|
+
initialize = (engine) => {
|
|
120
|
+
this.headless = getHeadlessBundle(this.engineId);
|
|
121
|
+
this.engine = engine;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
disconnectedCallback() {
|
|
125
|
+
this.cleanUpInlineLinkBindings();
|
|
126
|
+
}
|
|
68
127
|
|
|
69
128
|
loadMarkdownDependencies() {
|
|
70
129
|
if (
|
|
@@ -105,6 +164,7 @@ export default class QuanticGeneratedAnswerContent extends LightningElement {
|
|
|
105
164
|
// eslint-disable-next-line @lwc/lwc/no-inner-html
|
|
106
165
|
answerContainer.innerHTML = newHTMLContent;
|
|
107
166
|
}
|
|
167
|
+
this.processInlineLinks(answerContainer);
|
|
108
168
|
}
|
|
109
169
|
}
|
|
110
170
|
// Fallback to display answer as text if the Marked library failed to load
|
|
@@ -114,6 +174,82 @@ export default class QuanticGeneratedAnswerContent extends LightningElement {
|
|
|
114
174
|
this.dispatchEvent(new CustomEvent('quantic__answercontentupdated'));
|
|
115
175
|
}
|
|
116
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Decorates each inline link with an icon and binds analytics, replacing any previous bindings.
|
|
179
|
+
* @param {Element|null} answerContainer
|
|
180
|
+
*/
|
|
181
|
+
processInlineLinks(answerContainer) {
|
|
182
|
+
if (!answerContainer) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
this.cleanUpInlineLinkBindings();
|
|
186
|
+
/** @type {NodeListOf<HTMLAnchorElement>} */
|
|
187
|
+
const anchors = answerContainer.querySelectorAll(
|
|
188
|
+
'a[data-answer-inline-link]'
|
|
189
|
+
);
|
|
190
|
+
anchors.forEach((anchor) => {
|
|
191
|
+
anchor.target = '_blank';
|
|
192
|
+
anchor.rel = 'noopener';
|
|
193
|
+
this.bindAnalyticsToInlineLink(anchor);
|
|
194
|
+
this.appendInlineLinkIcon(anchor);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Appends the external link icon to the given anchor.
|
|
200
|
+
* @param {HTMLAnchorElement} anchor
|
|
201
|
+
*/
|
|
202
|
+
appendInlineLinkIcon(anchor) {
|
|
203
|
+
const icon = document.createElement('span');
|
|
204
|
+
icon.classList.add(
|
|
205
|
+
'slds-icon_container',
|
|
206
|
+
'slds-icon-utility-new_window',
|
|
207
|
+
'slds-current-color',
|
|
208
|
+
'slds-m-left_xxx-small',
|
|
209
|
+
'answer-content__link-icon'
|
|
210
|
+
);
|
|
211
|
+
// eslint-disable-next-line @lwc/lwc/no-inner-html
|
|
212
|
+
icon.innerHTML = INLINE_LINK_ICON;
|
|
213
|
+
anchor.appendChild(icon);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Creates an `InteractiveGeneratedAnswerInlineLink` controller for the given anchor
|
|
218
|
+
* and binds analytics event listeners to it.
|
|
219
|
+
* @param {HTMLAnchorElement} anchor
|
|
220
|
+
*/
|
|
221
|
+
bindAnalyticsToInlineLink(anchor) {
|
|
222
|
+
if (
|
|
223
|
+
!this.headless?.buildInteractiveGeneratedAnswerInlineLink ||
|
|
224
|
+
!this.engine
|
|
225
|
+
) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const controller = this.headless.buildInteractiveGeneratedAnswerInlineLink(
|
|
229
|
+
this.engine,
|
|
230
|
+
{
|
|
231
|
+
options: {
|
|
232
|
+
link: {
|
|
233
|
+
linkURL: anchor.getAttribute('href') || '',
|
|
234
|
+
linkText: anchor.textContent?.trim() || '',
|
|
235
|
+
},
|
|
236
|
+
answerId: this.answerId,
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
);
|
|
240
|
+
this._inlineLinkBindings.push(
|
|
241
|
+
LinkUtils.bindAnalyticsToLink(anchor, controller)
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Cleans up all inline link bindings.
|
|
247
|
+
*/
|
|
248
|
+
cleanUpInlineLinkBindings() {
|
|
249
|
+
this._inlineLinkBindings.forEach((remove) => remove());
|
|
250
|
+
this._inlineLinkBindings = [];
|
|
251
|
+
}
|
|
252
|
+
|
|
117
253
|
get generatedAnswerContentClass() {
|
|
118
254
|
return `generated-answer-content__answer ${this.isStreaming ? 'generated-answer-content__answer--streaming' : ''}`;
|
|
119
255
|
}
|
|
@@ -152,3 +152,13 @@ tbody tr td:first-of-type {
|
|
|
152
152
|
tbody tr td:last-of-type td {
|
|
153
153
|
border-bottom: unset;
|
|
154
154
|
}
|
|
155
|
+
|
|
156
|
+
.answer-content__link-icon-svg {
|
|
157
|
+
width: 0.625rem;
|
|
158
|
+
height: 0.625rem;
|
|
159
|
+
fill: currentColor;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.answer-content__link-icon {
|
|
163
|
+
display: inline-flex;
|
|
164
|
+
}
|
|
@@ -67,5 +67,43 @@ describe('c/markdownUtils', () => {
|
|
|
67
67
|
const resultCode = transformMarkdownToHtml(textCode, marked);
|
|
68
68
|
expect(removeLineBreaks(resultCode)).toEqual('<p><code>code</code></p>');
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
it('should transform markdown link to HTML <a> with data-answer-inline-link attribute', () => {
|
|
72
|
+
const text = '[Google](https://google.com)';
|
|
73
|
+
const result = transformMarkdownToHtml(text, marked);
|
|
74
|
+
expect(removeLineBreaks(result)).toEqual(
|
|
75
|
+
'<p><a href="https://google.com" data-answer-inline-link="true">Google</a></p>'
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should transform markdown link with title to HTML <a> with title attribute', () => {
|
|
80
|
+
const text = '[Google](https://google.com "Search Engine")';
|
|
81
|
+
const result = transformMarkdownToHtml(text, marked);
|
|
82
|
+
expect(removeLineBreaks(result)).toEqual(
|
|
83
|
+
'<p><a href="https://google.com" title="Search Engine" data-answer-inline-link="true">Google</a></p>'
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should escape special characters in link href', () => {
|
|
88
|
+
const text = '[Link](https://example.com?param="value"&other=test)';
|
|
89
|
+
const result = transformMarkdownToHtml(text, marked);
|
|
90
|
+
expect(removeLineBreaks(result)).toEqual(
|
|
91
|
+
'<p><a href="https://example.com?param="value"&other=test" data-answer-inline-link="true">Link</a></p>'
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should escape special characters in link title', () => {
|
|
96
|
+
const text = '[Link](https://example.com "Title with <script> & quotes")';
|
|
97
|
+
const result = transformMarkdownToHtml(text, marked);
|
|
98
|
+
expect(removeLineBreaks(result)).toEqual(
|
|
99
|
+
'<p><a href="https://example.com" title="Title with &lt;script&gt; &amp; quotes" data-answer-inline-link="true">Link</a></p>'
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should render link text as span when href is missing', () => {
|
|
104
|
+
const text = '[Link]()';
|
|
105
|
+
const result = transformMarkdownToHtml(text, marked);
|
|
106
|
+
expect(removeLineBreaks(result)).toEqual('<p><span>Link</span></p>');
|
|
107
|
+
});
|
|
70
108
|
});
|
|
71
109
|
});
|
|
@@ -117,6 +117,24 @@ const customRenderer = {
|
|
|
117
117
|
text(text) {
|
|
118
118
|
return completeUnclosedElement(text);
|
|
119
119
|
},
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Custom Marked renderer for links.
|
|
123
|
+
* @param {string} href The link href.
|
|
124
|
+
* @param {string} title The link title.
|
|
125
|
+
* @param {string} text The link text.
|
|
126
|
+
* @returns {string} The link element to render.
|
|
127
|
+
*/
|
|
128
|
+
link(href, title, text) {
|
|
129
|
+
const titleAttribute = title ? ` title="${escapeHtml(title)}"` : '';
|
|
130
|
+
const safeHref = href ? escapeHtml(href) : '';
|
|
131
|
+
|
|
132
|
+
if (!safeHref) {
|
|
133
|
+
return `<span>${text}</span>`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return `<a href="${safeHref}"${titleAttribute} data-answer-inline-link="true">${text}</a>`;
|
|
137
|
+
},
|
|
120
138
|
};
|
|
121
139
|
|
|
122
140
|
/**
|
package/package.json
CHANGED