@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.
Files changed (19) hide show
  1. package/force-app/main/default/classes/HeadlessControllerTest.cls +1 -0
  2. package/force-app/main/default/classes/InsightControllerTest.cls +1 -0
  3. package/force-app/main/default/classes/RecommendationsControllerTest.cls +1 -1
  4. package/force-app/main/default/classes/SampleTokenProvider.cls +1 -0
  5. package/force-app/main/default/classes/SampleTokenProviderTest.cls +1 -0
  6. package/force-app/main/default/lwc/quanticGeneratedAnswer/__tests__/quanticGeneratedAnswer.test.js +54 -0
  7. package/force-app/main/default/lwc/quanticGeneratedAnswer/quanticGeneratedAnswer.js +26 -3
  8. package/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.css +5 -0
  9. package/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.html +3 -1
  10. package/force-app/main/default/lwc/quanticGeneratedAnswerContent/__tests__/quanticGeneratedAnswerContent.test.js +269 -0
  11. package/force-app/main/default/lwc/quanticGeneratedAnswerContent/quanticGeneratedAnswerContent.js +136 -0
  12. package/force-app/main/default/lwc/quanticGeneratedAnswerContent/templates/generatedMarkdownContent.css +10 -0
  13. package/force-app/main/default/lwc/quanticUtils/__tests__/markdownUtils.test.js +38 -0
  14. package/force-app/main/default/lwc/quanticUtils/markdownUtils.js +18 -0
  15. package/force-app/main/default/staticresources/coveoheadless/case-assist/headless.js +1 -1
  16. package/force-app/main/default/staticresources/coveoheadless/headless.js +1 -1
  17. package/force-app/main/default/staticresources/coveoheadless/insight/headless.js +1 -1
  18. package/force-app/main/default/staticresources/coveoheadless/recommendation/headless.js +1 -1
  19. 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 a demo token and it's okay to be public.
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() {
@@ -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.collapsible) {
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.collapsible) {
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.collapsible) {
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
  }
@@ -9,6 +9,11 @@
9
9
  word-wrap: break-word;
10
10
  }
11
11
 
12
+ .generated-answer__content--scrollable {
13
+ overflow-y: auto;
14
+ height: var(--quantic-generated-answer-content-fixed-height, 50vh);
15
+ }
16
+
12
17
  .generated-answer__footer {
13
18
  gap: 1rem;
14
19
  }
@@ -27,13 +27,15 @@
27
27
  <template lwc:if={isVisible}>
28
28
  <section
29
29
  data-testid="generated-answer__body"
30
- class="generated-answer__content slds-p-top_medium slds-p-horizontal_large"
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
  });
@@ -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=&quot;value&quot;&amp;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 &amp;lt;script&amp;gt; &amp;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
  /**
@@ -75,4 +75,4 @@ fast-json-patch/module/duplex.mjs:
75
75
  *)
76
76
  */if(__exports!=exports)module.exports=exports;return module.exports});
77
77
 
78
- window.coveoQuanticVersion = '3.39.1';
78
+ window.coveoQuanticVersion = '3.40.0';
@@ -79,4 +79,4 @@ fast-json-patch/module/duplex.mjs:
79
79
  *)
80
80
  */if(__exports!=exports)module.exports=exports;return module.exports});
81
81
 
82
- window.coveoQuanticVersion = '3.39.1';
82
+ window.coveoQuanticVersion = '3.40.0';
@@ -76,4 +76,4 @@ fast-json-patch/module/duplex.mjs:
76
76
  *)
77
77
  */if(__exports!=exports)module.exports=exports;return module.exports});
78
78
 
79
- window.coveoQuanticVersion = '3.39.1';
79
+ window.coveoQuanticVersion = '3.40.0';
@@ -72,4 +72,4 @@ fast-json-patch/module/duplex.mjs:
72
72
  *)
73
73
  */if(__exports!=exports)module.exports=exports;return module.exports});
74
74
 
75
- window.coveoQuanticVersion = '3.39.1';
75
+ window.coveoQuanticVersion = '3.40.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coveo/quantic",
3
- "version": "3.39.1",
3
+ "version": "3.40.0",
4
4
  "description": "A Salesforce Lightning Web Component (LWC) library for building modern UIs interfacing with the Coveo platform",
5
5
  "author": "coveo.com",
6
6
  "homepage": "https://coveo.com",