@coveo/quantic 3.39.0 → 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 (24) 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__/accessibilityUtils.test.js +214 -0
  14. package/force-app/main/default/lwc/quanticUtils/__tests__/facetStoreUtils.test.js +86 -0
  15. package/force-app/main/default/lwc/quanticUtils/__tests__/markdownUtils.test.js +38 -0
  16. package/force-app/main/default/lwc/quanticUtils/accessibilityUtils.js +225 -0
  17. package/force-app/main/default/lwc/quanticUtils/facetStoreUtils.js +65 -0
  18. package/force-app/main/default/lwc/quanticUtils/markdownUtils.js +18 -0
  19. package/force-app/main/default/lwc/quanticUtils/quanticUtils.js +2 -291
  20. package/force-app/main/default/staticresources/coveoheadless/case-assist/headless.js +3 -3
  21. package/force-app/main/default/staticresources/coveoheadless/headless.js +3 -3
  22. package/force-app/main/default/staticresources/coveoheadless/insight/headless.js +3 -3
  23. package/force-app/main/default/staticresources/coveoheadless/recommendation/headless.js +3 -3
  24. package/package.json +2 -2
@@ -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
+ }
@@ -0,0 +1,214 @@
1
+ import {
2
+ AriaLiveRegion,
3
+ isFocusable,
4
+ getFirstFocusableElement,
5
+ getLastFocusableElement,
6
+ isCustomElement,
7
+ isParentOf,
8
+ } from 'c/quanticUtils';
9
+
10
+ describe('accessibilityUtils', () => {
11
+ describe('AriaLiveRegion', () => {
12
+ let elem;
13
+
14
+ beforeEach(() => {
15
+ elem = {dispatchEvent: jest.fn()};
16
+ });
17
+
18
+ it('should dispatch a register region event on creation', () => {
19
+ AriaLiveRegion('test-region', elem);
20
+ expect(elem.dispatchEvent).toHaveBeenCalledTimes(1);
21
+ const event = elem.dispatchEvent.mock.calls[0][0];
22
+ expect(event.type).toBe('quantic__registerregion');
23
+ expect(event.detail).toEqual({
24
+ regionName: 'test-region',
25
+ assertive: false,
26
+ });
27
+ });
28
+
29
+ it('should dispatch a register region event with assertive flag', () => {
30
+ AriaLiveRegion('test-region', elem, true);
31
+ const event = elem.dispatchEvent.mock.calls[0][0];
32
+ expect(event.detail.assertive).toBe(true);
33
+ });
34
+
35
+ it('should dispatch an aria live message event when dispatchMessage is called', () => {
36
+ const region = AriaLiveRegion('test-region', elem);
37
+ region.dispatchMessage('hello');
38
+ expect(elem.dispatchEvent).toHaveBeenCalledTimes(2);
39
+ const event = elem.dispatchEvent.mock.calls[1][0];
40
+ expect(event.type).toBe('quantic__arialivemessage');
41
+ expect(event.detail).toEqual({
42
+ regionName: 'test-region',
43
+ assertive: false,
44
+ message: 'hello',
45
+ });
46
+ });
47
+ });
48
+
49
+ describe('isFocusable', () => {
50
+ function createElement(tag, attrs = {}) {
51
+ const el = document.createElement(tag);
52
+ Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
53
+ return el;
54
+ }
55
+
56
+ it('should return true for a button', () => {
57
+ expect(isFocusable(createElement('button'))).toBe(true);
58
+ });
59
+
60
+ it('should return false for a disabled button', () => {
61
+ expect(isFocusable(createElement('button', {disabled: ''}))).toBe(false);
62
+ });
63
+
64
+ it('should return true for an anchor with href', () => {
65
+ expect(isFocusable(createElement('a', {href: '#'}))).toBe(true);
66
+ });
67
+
68
+ it('should return false for an anchor without href', () => {
69
+ expect(isFocusable(createElement('a'))).toBe(false);
70
+ });
71
+
72
+ it('should return true for an input', () => {
73
+ expect(isFocusable(createElement('input'))).toBe(true);
74
+ });
75
+
76
+ it('should return false for a disabled input', () => {
77
+ expect(isFocusable(createElement('input', {disabled: ''}))).toBe(false);
78
+ });
79
+
80
+ it('should return true for an element with tabindex >= 0', () => {
81
+ expect(isFocusable(createElement('div', {tabindex: '0'}))).toBe(true);
82
+ });
83
+
84
+ it('should return false for an element with tabindex -1', () => {
85
+ expect(isFocusable(createElement('div', {tabindex: '-1'}))).toBe(false);
86
+ });
87
+
88
+ it('should return true for a contentEditable element', () => {
89
+ expect(isFocusable(createElement('div', {contentEditable: 'true'}))).toBe(
90
+ true
91
+ );
92
+ });
93
+
94
+ it('should return false for a plain div', () => {
95
+ expect(isFocusable(createElement('div'))).toBe(false);
96
+ });
97
+
98
+ it('should return true for an iframe', () => {
99
+ expect(isFocusable(createElement('iframe'))).toBe(true);
100
+ });
101
+
102
+ it('should return true for a select', () => {
103
+ expect(isFocusable(createElement('select'))).toBe(true);
104
+ });
105
+
106
+ it('should return true for a textarea', () => {
107
+ expect(isFocusable(createElement('textarea'))).toBe(true);
108
+ });
109
+ });
110
+
111
+ describe('isCustomElement', () => {
112
+ it('should return true for elements with a hyphen in the tag name', () => {
113
+ const el = document.createElement('my-component');
114
+ expect(isCustomElement(el)).toBe(true);
115
+ });
116
+
117
+ it('should return false for standard elements', () => {
118
+ const el = document.createElement('div');
119
+ expect(isCustomElement(el)).toBe(false);
120
+ });
121
+
122
+ it('should return false for null', () => {
123
+ expect(isCustomElement(null)).toBe(false);
124
+ });
125
+ });
126
+
127
+ describe('getFirstFocusableElement', () => {
128
+ it('should return null for null input', () => {
129
+ expect(getFirstFocusableElement(null)).toBeNull();
130
+ });
131
+
132
+ it('should return the element itself if it is focusable and has no focusable children', () => {
133
+ const btn = document.createElement('button');
134
+ expect(getFirstFocusableElement(btn)).toBe(btn);
135
+ });
136
+
137
+ it('should return the first focusable child', () => {
138
+ const div = document.createElement('div');
139
+ const span = document.createElement('span');
140
+ const btn1 = document.createElement('button');
141
+ const btn2 = document.createElement('button');
142
+ div.appendChild(span);
143
+ div.appendChild(btn1);
144
+ div.appendChild(btn2);
145
+ expect(getFirstFocusableElement(div)).toBe(btn1);
146
+ });
147
+
148
+ it('should return null for a custom element without data-focusable', () => {
149
+ const el = document.createElement('my-component');
150
+ expect(getFirstFocusableElement(el)).toBeNull();
151
+ });
152
+
153
+ it('should return the custom element if data-focusable is true', () => {
154
+ const el = document.createElement('my-component');
155
+ el.dataset.focusable = 'true';
156
+ expect(getFirstFocusableElement(el)).toBe(el);
157
+ });
158
+
159
+ it('should return null for a text node', () => {
160
+ const text = document.createTextNode('hello');
161
+ expect(getFirstFocusableElement(text)).toBeNull();
162
+ });
163
+ });
164
+
165
+ describe('getLastFocusableElement', () => {
166
+ it('should return null for null input', () => {
167
+ expect(getLastFocusableElement(null)).toBeNull();
168
+ });
169
+
170
+ it('should return the last focusable child', () => {
171
+ const div = document.createElement('div');
172
+ const btn1 = document.createElement('button');
173
+ const btn2 = document.createElement('button');
174
+ div.appendChild(btn1);
175
+ div.appendChild(btn2);
176
+ expect(getLastFocusableElement(div)).toBe(btn2);
177
+ });
178
+
179
+ it('should return the element itself if it is focusable and has no focusable children', () => {
180
+ const input = document.createElement('input');
181
+ expect(getLastFocusableElement(input)).toBe(input);
182
+ });
183
+ });
184
+
185
+ describe('isParentOf', () => {
186
+ it('should return false for null', () => {
187
+ expect(isParentOf(null, 'MY-COMPONENT')).toBe(false);
188
+ });
189
+
190
+ it('should return true if the element itself matches the target tag', () => {
191
+ const el = document.createElement('my-component');
192
+ expect(isParentOf(el, 'MY-COMPONENT')).toBe(true);
193
+ });
194
+
195
+ it('should return true if a descendant matches the target tag', () => {
196
+ const wrapper = document.createElement('div');
197
+ const child = document.createElement('my-component');
198
+ wrapper.appendChild(child);
199
+ expect(isParentOf(wrapper, 'MY-COMPONENT')).toBe(true);
200
+ });
201
+
202
+ it('should return false if no descendant matches', () => {
203
+ const wrapper = document.createElement('div');
204
+ const child = document.createElement('span');
205
+ wrapper.appendChild(child);
206
+ expect(isParentOf(wrapper, 'MY-COMPONENT')).toBe(false);
207
+ });
208
+
209
+ it('should return false for a text node', () => {
210
+ const text = document.createTextNode('hello');
211
+ expect(isParentOf(text, 'MY-COMPONENT')).toBe(false);
212
+ });
213
+ });
214
+ });
@@ -0,0 +1,86 @@
1
+ import {Store} from 'c/quanticUtils';
2
+
3
+ describe('storeUtils', () => {
4
+ describe('Store.facetTypes', () => {
5
+ it('should expose the expected facet type keys', () => {
6
+ expect(Store.facetTypes).toEqual({
7
+ FACETS: 'facets',
8
+ NUMERICFACETS: 'numericFacets',
9
+ DATEFACETS: 'dateFacets',
10
+ CATEGORYFACETS: 'categoryFacets',
11
+ });
12
+ });
13
+ });
14
+
15
+ describe('Store.initialize', () => {
16
+ it('should return a store with empty state', () => {
17
+ const store = Store.initialize();
18
+ expect(store).toEqual({
19
+ state: {
20
+ facets: {},
21
+ numericFacets: {},
22
+ dateFacets: {},
23
+ categoryFacets: {},
24
+ sort: {},
25
+ },
26
+ });
27
+ });
28
+ });
29
+
30
+ describe('Store.registerFacetToStore', () => {
31
+ it('should register facet data under the given facet type and id', () => {
32
+ const store = Store.initialize();
33
+ const data = {facetId: 'author', label: 'Author'};
34
+ Store.registerFacetToStore(store, 'facets', data);
35
+ expect(store.state.facets.author).toEqual(data);
36
+ });
37
+
38
+ it('should not overwrite an existing facet entry', () => {
39
+ const store = Store.initialize();
40
+ const data = {facetId: 'author', label: 'Author'};
41
+ Store.registerFacetToStore(store, 'facets', data);
42
+ Store.registerFacetToStore(store, 'facets', {
43
+ facetId: 'author',
44
+ label: 'Updated',
45
+ });
46
+ expect(store.state.facets.author.label).toBe('Author');
47
+ });
48
+ });
49
+
50
+ describe('Store.registerSortOptionDataToStore', () => {
51
+ it('should set sort data on the store', () => {
52
+ const store = Store.initialize();
53
+ const sortData = [{label: 'Relevance', value: 'relevance'}];
54
+ Store.registerSortOptionDataToStore(store, sortData);
55
+ expect(store.state.sort).toBe(sortData);
56
+ });
57
+ });
58
+
59
+ describe('Store.getFromStore', () => {
60
+ it('should return facet data for the given type', () => {
61
+ const store = Store.initialize();
62
+ const data = {facetId: 'source', label: 'Source'};
63
+ Store.registerFacetToStore(store, 'facets', data);
64
+ expect(Store.getFromStore(store, 'facets')).toEqual({source: data});
65
+ });
66
+
67
+ it('should return an empty object when no facets are registered', () => {
68
+ const store = Store.initialize();
69
+ expect(Store.getFromStore(store, 'facets')).toEqual({});
70
+ });
71
+ });
72
+
73
+ describe('Store.getSortOptionsFromStore', () => {
74
+ it('should return the sort options', () => {
75
+ const store = Store.initialize();
76
+ const sortData = [{label: 'Date', value: 'date'}];
77
+ Store.registerSortOptionDataToStore(store, sortData);
78
+ expect(Store.getSortOptionsFromStore(store)).toBe(sortData);
79
+ });
80
+
81
+ it('should return an empty object when no sort options are registered', () => {
82
+ const store = Store.initialize();
83
+ expect(Store.getSortOptionsFromStore(store)).toEqual({});
84
+ });
85
+ });
86
+ });
@@ -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
  });