@salesforcedevs/dx-components 1.32.0-alpha.8 → 1.32.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.
@@ -1,71 +1,75 @@
1
1
  import { LightningElement, api } from "lwc";
2
2
  import debounce from "debounce";
3
- import { track as trackGTM } from "dxUtils/analytics";
4
3
  import {
5
- type Data360SearchCacheItem,
6
- type Data360SearchResultItem,
7
- fetchSearch,
8
- getBaseUrlPath,
9
- getStoredSearch,
10
- isCacheStale,
11
- setStoredSearch
12
- } from "dxUtils/data360Search";
13
- import { createSearchRegExp } from "dxUtils/regexps";
4
+ Result,
5
+ ResultList,
6
+ Unsubscribe,
7
+ ResultsPerPage,
8
+ SearchBox,
9
+ buildInteractiveResult,
10
+ buildResultList,
11
+ buildResultsPerPage,
12
+ buildSearchBox,
13
+ loadAdvancedSearchQueryActions,
14
+ SearchEngine,
15
+ SearchAppState,
16
+ loadQueryActions
17
+ } from "@coveo/headless";
18
+ import { buildSearchEngine, getSidebarSearchParams } from "dxUtils/coveo";
14
19
  import { RecentSearches } from "dxUtils/recentSearches";
20
+ import { toJson } from "dxUtils/normalizers";
15
21
  import {
16
- type HighlightedSections,
17
22
  Option,
18
23
  PopoverRequestCloseType,
19
24
  SidebarSearchResult
20
25
  } from "typings/custom";
21
26
 
22
- const SEARCH_DEBOUNCE_DELAY = 1200;
27
+ const RESULT_CLICK_LOCAL_STORAGE_KEY = "dx-sidebar-search-result-click";
28
+ export const SESSION_KEY = "dx-sidebar-search-state";
23
29
 
24
- /**
25
- * Find all matches of the search query in text and return ranges for highlighting.
26
- * Uses the same regex as the full-doc highlighter (createSearchRegExp) so sidebar
27
- * and doc highlight the same phrases (including spaces between words).
28
- */
29
- function getHighlightRanges(
30
- text: string,
31
- searchQuery: string
32
- ): HighlightedSections {
33
- if (!text || !searchQuery.trim()) {
34
- return [];
35
- }
36
- const re = createSearchRegExp(searchQuery.trim());
37
- const ranges: HighlightedSections = [];
38
- let match: RegExpExecArray | null;
39
- while ((match = re.exec(text)) !== null) {
40
- ranges.push({ offset: match.index, length: match[0].length });
41
- }
42
- return ranges;
43
- }
30
+ const DEFAULT_RESULT_COUNT = 20;
31
+ const SEARCH_DEBOUNCE_DELAY = 1200;
44
32
 
45
33
  const UserRecentSearches = new RecentSearches();
46
34
 
47
- function resultPathFromHref(href: string): string {
48
- return href.startsWith("http") ? new URL(href).pathname : href;
49
- }
35
+ const normalizeCoveoAdvancedQueryValue = (version: string): string =>
36
+ version?.split(".").join("\\.");
50
37
 
51
- function applySelectedAndSelect(
52
- items: Data360SearchCacheItem[]
53
- ): SidebarSearchResult[] {
54
- const currentPath = window.location.pathname;
55
- return items.map((item) => ({
56
- ...item,
57
- selected: !!item.href && resultPathFromHref(item.href) === currentPath,
58
- select: () => {}
59
- }));
60
- }
38
+ export default class SidebarSearch extends LightningElement {
39
+ @api coveoOrganizationId!: string;
40
+ @api coveoPublicAccessToken!: string;
41
+ @api coveoSearchHub!: string;
42
+ @api
43
+ get coveoAdvancedQueryConfig(): { [key: string]: any } {
44
+ return this._coveoAdvancedQueryConfig;
45
+ }
46
+ set coveoAdvancedQueryConfig(value) {
47
+ const jsonValue = toJson(value);
48
+ const hasChanged =
49
+ this._coveoAdvancedQueryConfig &&
50
+ Object.entries(jsonValue).some(
51
+ ([key, val]: [string, any]) =>
52
+ this._coveoAdvancedQueryConfig[key] !== val
53
+ );
54
+ this._coveoAdvancedQueryConfig = jsonValue;
61
55
 
62
- const getSearchQueryParam = (): string =>
63
- new URLSearchParams(window.location.search).get("q") ?? "";
56
+ if (hasChanged && this.engine && this.value !== "") {
57
+ this.dispatchOnLoading(true);
58
+ }
59
+
60
+ if (hasChanged && this.engine) {
61
+ // set advanced filters needs access to the latest coveoAdvancedQueryConfig
62
+ this.setAdvancedFilters(this.engine);
63
+ this.submitSearch(true);
64
+ }
65
+ }
64
66
 
65
- export default class SidebarSearch extends LightningElement {
66
67
  @api
67
68
  public fetchMoreResults() {
68
- // Data 360 Search API does not expose pagination in the same way; no-op
69
+ if (this.resultList?.state?.moreResultsAvailable) {
70
+ this.fetchingMoreResults = true;
71
+ this.resultList.fetchMoreResults();
72
+ }
69
73
  }
70
74
 
71
75
  @api
@@ -81,144 +85,313 @@ export default class SidebarSearch extends LightningElement {
81
85
  this.dropdownRequestedOpen = value;
82
86
  }
83
87
 
88
+ private _coveoAdvancedQueryConfig!: { [key: string]: any };
84
89
  private dropdownRequestedOpen: boolean = false;
85
90
  private recentSearches: Option[] = [];
86
- private value: string = "";
91
+ private unsubscribeFromResultList: Unsubscribe = () => {};
92
+ private resultList: ResultList | null = null;
93
+ private resultsPerPage: ResultsPerPage | null = null;
94
+ private preloadedState?: SearchAppState;
95
+ private searchBox: SearchBox | null = null;
96
+ private value?: string = "";
97
+ private engine: SearchEngine | null = null;
98
+ private fetchingMoreResults: boolean = false;
87
99
  private didRender = false;
88
- private data360SearchInitialized: boolean = false;
89
- private didTrackThisSearch: boolean = false;
100
+
101
+ private get hasCorrectCoveoConfig(): boolean {
102
+ const coveoConfigurations = [
103
+ this.coveoOrganizationId,
104
+ this.coveoPublicAccessToken,
105
+ this.coveoSearchHub,
106
+ this.coveoAdvancedQueryConfig
107
+ ];
108
+
109
+ return coveoConfigurations.every(
110
+ (val) =>
111
+ !!val && (typeof val === "string" || typeof val === "object")
112
+ );
113
+ }
90
114
 
91
115
  private get isDropdownOpen() {
92
116
  return (
93
117
  this.dropdownRequestedOpen &&
94
- this.recentSearches?.length > 0 &&
118
+ this.recentSearches &&
119
+ this.recentSearches.length > 0 &&
95
120
  !this.value
96
121
  );
97
122
  }
98
123
 
99
124
  constructor() {
100
125
  super();
126
+
101
127
  this.updateRecentSearches();
102
- this.value = getSearchQueryParam();
128
+ this.value = getSidebarSearchParams() || "";
129
+ }
130
+
131
+ disconnectedCallback() {
132
+ this.unsubscribeFromResultList();
103
133
  }
104
134
 
105
135
  renderedCallback() {
106
- if (!this.data360SearchInitialized) {
107
- this.data360SearchInitialized = true;
108
- if (this.value) {
109
- const baseUrlPath = getBaseUrlPath();
110
- const cached = getStoredSearch(baseUrlPath);
111
- const queryTrimmed = this.value.trim();
112
- if (
113
- cached?.query === queryTrimmed &&
114
- Array.isArray(cached.results) &&
115
- !isCacheStale(cached)
116
- ) {
117
- if (!this.didRender) {
118
- this.dispatchHighlightedTermChange();
119
- }
120
- const results = applySelectedAndSelect(cached.results);
121
- this.dispatchChange(results);
122
- this.dispatchOnLoading(false);
123
- } else {
124
- if (!this.didRender) {
125
- this.dispatchHighlightedTermChange();
126
- }
127
- this.dispatchOnLoading(true);
128
- this.fetchDataCloudSearch();
129
- }
130
- } else {
131
- // No URL query: restore search bar and results from localStorage for this path
132
- const baseUrlPath = getBaseUrlPath();
133
- const cached = getStoredSearch(baseUrlPath);
134
- if (cached?.query && !isCacheStale(cached)) {
135
- this.value = cached.query;
136
- const results = applySelectedAndSelect(
137
- cached.results ?? []
138
- );
139
- this.dispatchChange(results);
140
- this.dispatchHighlightedTermChange();
136
+ // in prod some of the critical coveo configuration attributes seem to arrive later
137
+ // so I needed to put this coveo startup flow here where we can wait for them to arrive
138
+
139
+ if (this.hasCorrectCoveoConfig && !this.engine) {
140
+ this.initializeUserSession();
141
+ this.initializeCoveo();
142
+ } else if (!this.hasCorrectCoveoConfig) {
143
+ console.error(
144
+ "Incorrect coveo search configuration provided. All coveo configuration attributes must be defined.",
145
+ {
146
+ coveoOrganizationId: this.coveoOrganizationId,
147
+ coveoPublicAccessToken: this.coveoPublicAccessToken,
148
+ coveoSearchHub: this.coveoSearchHub,
149
+ coveoAdvancedQueryConfig: this.coveoAdvancedQueryConfig
141
150
  }
142
- this.dispatchOnLoading(false);
143
- }
151
+ );
144
152
  }
153
+
154
+ // If this is the first render and there is a search value, trigger
155
+ // term highlighting
145
156
  if (!this.didRender && this.value) {
146
157
  this.dispatchHighlightedTermChange();
147
158
  }
159
+
148
160
  this.didRender = true;
149
161
  }
150
162
 
151
- private normalizeDataCloudResult = (
152
- item: Data360SearchResultItem,
153
- index: number
154
- ): SidebarSearchResult => {
155
- const href = item.url ?? "";
156
- const resultPath = href.startsWith("http")
157
- ? new URL(href).pathname
158
- : href;
159
- const isSelected =
160
- !!resultPath && resultPath === window.location.pathname;
161
- const title = item.title ?? "";
162
- const excerpt = item.matchedText ?? "";
163
- const searchQuery = this.value.trim();
164
- return {
165
- title,
166
- titleHighlights: getHighlightRanges(title, searchQuery),
167
- excerpt,
168
- excerptHighlights: getHighlightRanges(excerpt, searchQuery),
169
- uniqueId: href || `result-${index}`,
170
- href,
171
- selected: isSelected,
172
- select: () => {}
173
- };
174
- };
163
+ private initializeUserSession() {
164
+ const stringified = window.sessionStorage.getItem(SESSION_KEY);
165
+
166
+ if (!stringified) {
167
+ return;
168
+ }
175
169
 
176
- private async fetchDataCloudSearch(): Promise<void> {
177
170
  try {
178
- const query = this.value.trim();
179
- const rawResults = await fetchSearch(query);
180
- const results: SidebarSearchResult[] = rawResults.map(
181
- this.normalizeDataCloudResult
171
+ const json = JSON.parse(stringified);
172
+
173
+ const hasSameConfig = Object.entries(
174
+ json.coveoAdvancedQueryConfig
175
+ ).every(
176
+ ([key, value]: [string, any]) =>
177
+ this.coveoAdvancedQueryConfig[key] === value
182
178
  );
183
- this.dispatchChange(results);
184
- this.trackSearchResultsOnce(query, results.length);
185
- const cacheItems: Data360SearchCacheItem[] = results.map((r) => ({
186
- title: r.title,
187
- titleHighlights: r.titleHighlights,
188
- excerpt: r.excerpt,
189
- excerptHighlights: r.excerptHighlights,
190
- uniqueId: r.uniqueId,
191
- href: r.href
192
- }));
193
- setStoredSearch(getBaseUrlPath(), {
194
- query: this.value.trim(),
195
- results: cacheItems
196
- });
197
- } catch (err) {
198
- console.error("Data 360 Search request failed", err);
199
- this.dispatchChange([]);
200
- } finally {
201
- this.dispatchOnLoading(false);
179
+ const hasSameQuery = json?.state?.query?.q === this.value;
180
+
181
+ if (hasSameConfig && hasSameQuery) {
182
+ this.preloadedState = json?.state;
183
+ } else {
184
+ window.sessionStorage.removeItem(SESSION_KEY);
185
+ }
186
+ } catch (e) {
187
+ console.error(e);
188
+ window.sessionStorage.removeItem(SESSION_KEY);
202
189
  }
203
190
  }
204
191
 
205
- private trackSearchResultsOnce(term: string, resultCount: number): void {
206
- if (this.didTrackThisSearch || !term) {
192
+ private initializeCoveo = () => {
193
+ try {
194
+ this.engine = buildSearchEngine({
195
+ organizationId: this.coveoOrganizationId,
196
+ publicAccessToken: this.coveoPublicAccessToken,
197
+ searchHub: this.coveoSearchHub,
198
+ preloadedState: this.preloadedState
199
+ });
200
+ } catch (ex) {
201
+ console.error(`Coveo engine failed to initialize (${ex})`);
202
+ }
203
+
204
+ if (!this.engine) {
205
+ console.error("Coveo engine failed to initialize!");
206
+ return;
207
+ }
208
+
209
+ if (this.preloadedState) {
210
+ const actions = loadQueryActions(this.engine);
211
+ this.engine.dispatch(actions.updateQuery({ q: this.value || "" }));
212
+ }
213
+
214
+ this.resultList = buildResultList(this.engine, {
215
+ options: { fieldsToInclude: ["sfhtml_url__c"] }
216
+ });
217
+
218
+ this.resultsPerPage = buildResultsPerPage(this.engine, {
219
+ initialState: {
220
+ numberOfResults: DEFAULT_RESULT_COUNT
221
+ }
222
+ });
223
+
224
+ this.searchBox = buildSearchBox(this.engine, {
225
+ options: { numberOfSuggestions: 3 }
226
+ });
227
+
228
+ this.setAdvancedFilters(this.engine);
229
+
230
+ this.unsubscribeFromResultList = this.resultList?.subscribe(
231
+ this.onResultListChange
232
+ );
233
+
234
+ if (this.value) {
235
+ this.dispatchHighlightedTermChange();
236
+ }
237
+
238
+ if (!this.preloadedState) {
239
+ if (this.value) {
240
+ this.submitSearch();
241
+ } else {
242
+ this.dispatchOnLoading(false);
243
+ }
244
+ }
245
+
246
+ this.syncAnalytics();
247
+ };
248
+
249
+ private syncAnalytics = () => {
250
+ const storedResult = window.localStorage.getItem(
251
+ RESULT_CLICK_LOCAL_STORAGE_KEY
252
+ );
253
+ if (storedResult) {
254
+ const interactiveResult = buildInteractiveResult(this.engine!, {
255
+ options: { result: JSON.parse(storedResult) }
256
+ });
257
+ interactiveResult.cancelPendingSelect();
258
+ interactiveResult.select();
259
+ window.localStorage.removeItem(RESULT_CLICK_LOCAL_STORAGE_KEY);
260
+ }
261
+ };
262
+
263
+ private onResultListChange = () => {
264
+ if (!this.resultList) {
207
265
  return;
208
266
  }
209
- this.didTrackThisSearch = true;
210
- trackGTM(this.template.host, "custEv_scopedSearch", {
211
- search_term: term,
212
- search_category: "",
213
- search_type: "site search",
214
- search_result_count: resultCount
267
+
268
+ const { isLoading, firstSearchExecuted, results } =
269
+ this.resultList.state;
270
+
271
+ if ((!firstSearchExecuted && !isLoading) || !this.value) {
272
+ // coveo search is firing off some unwanted payloads on startup
273
+ return;
274
+ }
275
+
276
+ if (!isLoading) {
277
+ this.dispatchOnChangeEvent(results);
278
+ }
279
+
280
+ if (!this.fetchingMoreResults) {
281
+ this.dispatchOnLoading(isLoading);
282
+ }
283
+
284
+ if (!isLoading && this.fetchingMoreResults) {
285
+ this.fetchingMoreResults = false;
286
+ }
287
+ };
288
+
289
+ private setAdvancedFilters(engine: SearchEngine) {
290
+ const aq = Object.entries(this.coveoAdvancedQueryConfig).reduce(
291
+ (result, [key, value]) =>
292
+ `${result}(@${key}=="${normalizeCoveoAdvancedQueryValue(
293
+ value
294
+ )}")`,
295
+ ""
296
+ );
297
+
298
+ const registerSearchQueryAction = loadAdvancedSearchQueryActions(
299
+ engine
300
+ ).registerAdvancedSearchQueries({
301
+ aq: aq
215
302
  });
303
+
304
+ engine.dispatch(registerSearchQueryAction);
216
305
  }
217
306
 
218
- private dispatchChange(results: SidebarSearchResult[]) {
307
+ private normalizeCoveoResult = (result: Result): SidebarSearchResult => {
308
+ const {
309
+ clickUri,
310
+ excerpt,
311
+ excerptHighlights,
312
+ raw: { sfhtml_url__c },
313
+ title,
314
+ titleHighlights,
315
+ uniqueId
316
+ } = result;
317
+
318
+ // discussion about this normalization here: https://salesforce-internal.slack.com/archives/C020S6784JX/p1639506238376600
319
+
320
+ let pathname, queryParam;
321
+ if (sfhtml_url__c) {
322
+ pathname = `/docs/${sfhtml_url__c}`;
323
+ } else {
324
+ const isNestedGuide = clickUri.includes("/guide/");
325
+ const url = new URL(clickUri);
326
+ const extension =
327
+ isNestedGuide && !url.pathname?.endsWith(".html")
328
+ ? ".html"
329
+ : "";
330
+ pathname = `${url.pathname}${extension}`;
331
+ queryParam = url.search;
332
+ }
333
+
334
+ let href;
335
+ let isSelected = false;
336
+ const isReferenceUrl = clickUri.includes("/references/");
337
+ if (isReferenceUrl) {
338
+ if (queryParam) {
339
+ href = `${pathname}${queryParam}&q=${this.value}`;
340
+ } else {
341
+ href = `${pathname}?q=${this.value}`;
342
+ }
343
+
344
+ //NOTE: This is specific to references related comparison
345
+ const resultHrefWithMetaQuery = `${pathname}${queryParam}`;
346
+
347
+ const urlParams = new URLSearchParams(window.location.search);
348
+ const metaQueryParam = urlParams.get("meta");
349
+
350
+ let currentUrlPathWithQuery = window.location.pathname;
351
+ if (metaQueryParam) {
352
+ currentUrlPathWithQuery = `${currentUrlPathWithQuery}?meta=${metaQueryParam}`;
353
+ }
354
+
355
+ isSelected = resultHrefWithMetaQuery === currentUrlPathWithQuery;
356
+ } else {
357
+ href = `${pathname}?q=${this.value}`;
358
+ isSelected = pathname === window.location.pathname;
359
+ }
360
+
361
+ return {
362
+ title,
363
+ titleHighlights,
364
+ excerpt,
365
+ excerptHighlights,
366
+ uniqueId,
367
+ href,
368
+ selected: isSelected,
369
+ select: () =>
370
+ window.localStorage.setItem(
371
+ RESULT_CLICK_LOCAL_STORAGE_KEY,
372
+ JSON.stringify(result)
373
+ )
374
+ };
375
+ };
376
+
377
+ private updateSessionStorage() {
378
+ window.sessionStorage.setItem(
379
+ SESSION_KEY,
380
+ JSON.stringify({
381
+ coveoAdvancedQueryConfig: this.coveoAdvancedQueryConfig,
382
+ state: this.engine?.state
383
+ })
384
+ );
385
+ }
386
+
387
+ private dispatchOnChangeEvent(results: Result[]) {
388
+ this.updateSessionStorage();
219
389
  this.dispatchEvent(
220
390
  new CustomEvent("change", {
221
- detail: { results, value: this.value }
391
+ detail: {
392
+ results: results.map(this.normalizeCoveoResult),
393
+ value: this.value
394
+ }
222
395
  })
223
396
  );
224
397
  }
@@ -254,18 +427,24 @@ export default class SidebarSearch extends LightningElement {
254
427
  }
255
428
 
256
429
  private submitSearch = debounce((isSyncingSearchValue = false) => {
257
- UserRecentSearches.add(this.value);
258
- this.updateRecentSearches();
259
- if (!isSyncingSearchValue) {
260
- this.dispatchSidebarSearchChange(this.value);
430
+ if (this.searchBox) {
431
+ UserRecentSearches.add(this.value);
432
+ this.updateRecentSearches();
433
+
434
+ // When `isSyncingSearchValue` is true, we are submitting in a case
435
+ // where the search box is already synced correctly.
436
+ if (!isSyncingSearchValue) {
437
+ this.dispatchSidebarSearchChange(this.value);
438
+ }
439
+
440
+ this.searchBox.updateText(this.value || "");
441
+ this.searchBox.submit();
261
442
  }
262
- this.fetchDataCloudSearch();
263
443
  }, SEARCH_DEBOUNCE_DELAY);
264
444
 
265
445
  private onClickRecentSearch(e: CustomEvent) {
266
446
  this.value = e.detail;
267
447
  this.dispatchSidebarSearchChange(this.value);
268
- this.dispatchOnLoading(true);
269
448
  this.submitSearch(true);
270
449
  }
271
450
 
@@ -282,6 +461,7 @@ export default class SidebarSearch extends LightningElement {
282
461
 
283
462
  private onInputChange(e: CustomEvent) {
284
463
  this.value = e.detail;
464
+
285
465
  this.handleValueChange(false);
286
466
  this.dispatchHighlightedTermChange();
287
467
  }
@@ -296,14 +476,18 @@ export default class SidebarSearch extends LightningElement {
296
476
 
297
477
  private handleValueChange(isSyncingSearchValue = false) {
298
478
  if (this.value) {
299
- this.didTrackThisSearch = false;
300
479
  this.dispatchOnLoading(true);
301
- this.submitSearch(isSyncingSearchValue);
480
+ if (this.searchBox) {
481
+ this.submitSearch(isSyncingSearchValue);
482
+ }
302
483
  } else {
484
+ // coveo's reaction to an empty value triggers a return of results
485
+ // bootlegging our own ux here`
303
486
  this.dispatchOnLoading(false);
304
- this.dispatchChange([]);
487
+ this.dispatchOnChangeEvent([]);
488
+ // Empty search values are not submitted for search, so we need to manually
489
+ // trigger a search change on our own here
305
490
  this.dispatchSidebarSearchChange(this.value);
306
- setStoredSearch(getBaseUrlPath(), { query: "", results: [] });
307
491
  }
308
492
  }
309
493
  }