@salesforcedevs/dx-components 1.40.11 → 1.42.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,75 +1,68 @@
1
1
  import { LightningElement, api } from "lwc";
2
2
  import debounce from "debounce";
3
+ import { track as trackGTM } from "dxUtils/analytics";
3
4
  import {
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";
5
+ type OpenSearchCacheItem,
6
+ type OpenSearchResultItem,
7
+ fetchSearch,
8
+ resultHref,
9
+ getBaseUrlPath,
10
+ isCurrentLocation,
11
+ getStoredSearch,
12
+ isCacheStale,
13
+ setStoredSearch
14
+ } from "dxUtils/openSearch";
15
+ import { createSearchRegExp } from "dxUtils/regexps";
19
16
  import { RecentSearches } from "dxUtils/recentSearches";
20
- import { toJson } from "dxUtils/normalizers";
21
17
  import {
18
+ type HighlightedSections,
22
19
  Option,
23
20
  PopoverRequestCloseType,
24
21
  SidebarSearchResult
25
22
  } from "typings/custom";
26
23
 
27
- const RESULT_CLICK_LOCAL_STORAGE_KEY = "dx-sidebar-search-result-click";
28
- export const SESSION_KEY = "dx-sidebar-search-state";
29
-
30
- const DEFAULT_RESULT_COUNT = 20;
31
24
  const SEARCH_DEBOUNCE_DELAY = 1200;
32
25
 
33
- const UserRecentSearches = new RecentSearches();
34
-
35
- const normalizeCoveoAdvancedQueryValue = (version: string): string =>
36
- version?.split(".").join("\\.");
37
-
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;
26
+ /**
27
+ * Find all matches of the search query in text and return ranges for highlighting.
28
+ * Uses the same regex as the full-doc highlighter (createSearchRegExp) so sidebar
29
+ * and doc highlight the same phrases (including spaces between words).
30
+ */
31
+ function getHighlightRanges(
32
+ text: string,
33
+ searchQuery: string
34
+ ): HighlightedSections {
35
+ if (!text || !searchQuery.trim()) {
36
+ return [];
45
37
  }
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;
38
+ const re = createSearchRegExp(searchQuery.trim());
39
+ const ranges: HighlightedSections = [];
40
+ let match: RegExpExecArray | null;
41
+ while ((match = re.exec(text)) !== null) {
42
+ ranges.push({ offset: match.index, length: match[0].length });
43
+ }
44
+ return ranges;
45
+ }
55
46
 
56
- if (hasChanged && this.engine && this.value !== "") {
57
- this.dispatchOnLoading(true);
58
- }
47
+ const UserRecentSearches = new RecentSearches();
59
48
 
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
- }
49
+ function applySelectedAndSelect(
50
+ items: OpenSearchCacheItem[]
51
+ ): SidebarSearchResult[] {
52
+ return items.map((item) => ({
53
+ ...item,
54
+ selected: isCurrentLocation(item.href),
55
+ select: () => {}
56
+ }));
57
+ }
66
58
 
59
+ const getSearchQueryParam = (): string =>
60
+ new URLSearchParams(window.location.search).get("q") ?? "";
61
+
62
+ export default class SidebarSearch extends LightningElement {
67
63
  @api
68
64
  public fetchMoreResults() {
69
- if (this.resultList?.state?.moreResultsAvailable) {
70
- this.fetchingMoreResults = true;
71
- this.resultList.fetchMoreResults();
72
- }
65
+ // OpenSearch API does not expose pagination in the same way; no-op
73
66
  }
74
67
 
75
68
  @api
@@ -85,313 +78,150 @@ export default class SidebarSearch extends LightningElement {
85
78
  this.dropdownRequestedOpen = value;
86
79
  }
87
80
 
88
- private _coveoAdvancedQueryConfig!: { [key: string]: any };
89
81
  private dropdownRequestedOpen: boolean = false;
90
82
  private recentSearches: Option[] = [];
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;
83
+ private value: string = "";
99
84
  private didRender = 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
- }
85
+ private openSearchInitialized: boolean = false;
86
+ private didTrackThisSearch: boolean = false;
87
+ /** Bumped for every search and on clear; only the latest request may update state. */
88
+ private searchRequestId = 0;
114
89
 
115
90
  private get isDropdownOpen() {
116
91
  return (
117
92
  this.dropdownRequestedOpen &&
118
- this.recentSearches &&
119
- this.recentSearches.length > 0 &&
93
+ this.recentSearches?.length > 0 &&
120
94
  !this.value
121
95
  );
122
96
  }
123
97
 
124
98
  constructor() {
125
99
  super();
126
-
127
100
  this.updateRecentSearches();
128
- this.value = getSidebarSearchParams() || "";
129
- }
130
-
131
- disconnectedCallback() {
132
- this.unsubscribeFromResultList();
101
+ this.value = getSearchQueryParam();
133
102
  }
134
103
 
135
104
  renderedCallback() {
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
105
+ if (!this.openSearchInitialized) {
106
+ this.openSearchInitialized = true;
107
+ if (this.value) {
108
+ const baseUrlPath = getBaseUrlPath();
109
+ const cached = getStoredSearch(baseUrlPath);
110
+ const queryTrimmed = this.value.trim();
111
+ if (
112
+ cached?.query === queryTrimmed &&
113
+ Array.isArray(cached.results) &&
114
+ !isCacheStale(cached)
115
+ ) {
116
+ if (!this.didRender) {
117
+ this.dispatchHighlightedTermChange();
118
+ }
119
+ const results = applySelectedAndSelect(cached.results);
120
+ this.dispatchChange(results);
121
+ this.dispatchOnLoading(false);
122
+ } else {
123
+ if (!this.didRender) {
124
+ this.dispatchHighlightedTermChange();
125
+ }
126
+ this.dispatchOnLoading(true);
127
+ this.fetchOpenSearch();
150
128
  }
151
- );
129
+ } else {
130
+ // No URL query: restore search bar and results from localStorage for this path
131
+ const baseUrlPath = getBaseUrlPath();
132
+ const cached = getStoredSearch(baseUrlPath);
133
+ if (cached?.query && !isCacheStale(cached)) {
134
+ this.value = cached.query;
135
+ const results = applySelectedAndSelect(
136
+ cached.results ?? []
137
+ );
138
+ this.dispatchChange(results);
139
+ this.dispatchHighlightedTermChange();
140
+ }
141
+ this.dispatchOnLoading(false);
142
+ }
152
143
  }
153
-
154
- // If this is the first render and there is a search value, trigger
155
- // term highlighting
156
144
  if (!this.didRender && this.value) {
157
145
  this.dispatchHighlightedTermChange();
158
146
  }
159
-
160
147
  this.didRender = true;
161
148
  }
162
149
 
163
- private initializeUserSession() {
164
- const stringified = window.sessionStorage.getItem(SESSION_KEY);
165
-
166
- if (!stringified) {
167
- return;
168
- }
150
+ private normalizeOpenSearchResult = (
151
+ item: OpenSearchResultItem,
152
+ index: number
153
+ ): SidebarSearchResult => {
154
+ const href = resultHref(item);
155
+ const title = item.title ?? "";
156
+ const excerpt = item.matchedText ?? "";
157
+ const searchQuery = this.value.trim();
158
+ return {
159
+ title,
160
+ titleHighlights: getHighlightRanges(title, searchQuery),
161
+ excerpt,
162
+ excerptHighlights: getHighlightRanges(excerpt, searchQuery),
163
+ // Several hits can share a page URL, so the index keeps keys unique.
164
+ uniqueId: `${index}:${href}`,
165
+ href,
166
+ selected: isCurrentLocation(href),
167
+ select: () => {}
168
+ };
169
+ };
169
170
 
171
+ private async fetchOpenSearch(): Promise<void> {
172
+ const requestId = ++this.searchRequestId;
173
+ const query = this.value.trim();
170
174
  try {
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
178
- );
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);
175
+ const rawResults = await fetchSearch(query);
176
+ if (requestId !== this.searchRequestId) {
177
+ return;
185
178
  }
186
- } catch (e) {
187
- console.error(e);
188
- window.sessionStorage.removeItem(SESSION_KEY);
189
- }
190
- }
191
-
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
179
+ const results: SidebarSearchResult[] = rawResults.map(
180
+ this.normalizeOpenSearchResult
181
+ );
182
+ this.dispatchChange(results);
183
+ this.trackSearchResultsOnce(query, results.length);
184
+ const cacheItems: OpenSearchCacheItem[] = results.map((r) => ({
185
+ title: r.title,
186
+ titleHighlights: r.titleHighlights,
187
+ excerpt: r.excerpt,
188
+ excerptHighlights: r.excerptHighlights,
189
+ uniqueId: r.uniqueId,
190
+ href: r.href
191
+ }));
192
+ setStoredSearch(getBaseUrlPath(), {
193
+ query,
194
+ results: cacheItems
199
195
  });
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
196
+ } catch (err) {
197
+ if (requestId !== this.searchRequestId) {
198
+ return;
221
199
  }
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();
200
+ console.error("OpenSearch Search request failed", err);
201
+ this.dispatchChange([]);
236
202
  }
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) {
265
- return;
203
+ if (requestId === this.searchRequestId) {
204
+ this.dispatchOnLoading(false);
266
205
  }
206
+ }
267
207
 
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
208
+ private trackSearchResultsOnce(term: string, resultCount: number): void {
209
+ if (this.didTrackThisSearch || !term) {
273
210
  return;
274
211
  }
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
212
+ this.didTrackThisSearch = true;
213
+ trackGTM(this.template.host, "custEv_scopedSearch", {
214
+ search_term: term,
215
+ search_category: "",
216
+ search_type: "site search",
217
+ search_result_count: resultCount
302
218
  });
303
-
304
- engine.dispatch(registerSearchQueryAction);
305
219
  }
306
220
 
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();
221
+ private dispatchChange(results: SidebarSearchResult[]) {
389
222
  this.dispatchEvent(
390
223
  new CustomEvent("change", {
391
- detail: {
392
- results: results.map(this.normalizeCoveoResult),
393
- value: this.value
394
- }
224
+ detail: { results, value: this.value }
395
225
  })
396
226
  );
397
227
  }
@@ -427,24 +257,19 @@ export default class SidebarSearch extends LightningElement {
427
257
  }
428
258
 
429
259
  private submitSearch = debounce((isSyncingSearchValue = false) => {
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();
260
+ UserRecentSearches.add(this.value);
261
+ this.updateRecentSearches();
262
+ if (!isSyncingSearchValue) {
263
+ this.dispatchSidebarSearchChange(this.value);
442
264
  }
265
+ this.fetchOpenSearch();
443
266
  }, SEARCH_DEBOUNCE_DELAY);
444
267
 
445
268
  private onClickRecentSearch(e: CustomEvent) {
269
+ this.searchRequestId++;
446
270
  this.value = e.detail;
447
271
  this.dispatchSidebarSearchChange(this.value);
272
+ this.dispatchOnLoading(true);
448
273
  this.submitSearch(true);
449
274
  }
450
275
 
@@ -461,7 +286,6 @@ export default class SidebarSearch extends LightningElement {
461
286
 
462
287
  private onInputChange(e: CustomEvent) {
463
288
  this.value = e.detail;
464
-
465
289
  this.handleValueChange(false);
466
290
  this.dispatchHighlightedTermChange();
467
291
  }
@@ -475,19 +299,16 @@ export default class SidebarSearch extends LightningElement {
475
299
  }
476
300
 
477
301
  private handleValueChange(isSyncingSearchValue = false) {
302
+ this.searchRequestId++;
478
303
  if (this.value) {
304
+ this.didTrackThisSearch = false;
479
305
  this.dispatchOnLoading(true);
480
- if (this.searchBox) {
481
- this.submitSearch(isSyncingSearchValue);
482
- }
306
+ this.submitSearch(isSyncingSearchValue);
483
307
  } else {
484
- // coveo's reaction to an empty value triggers a return of results
485
- // bootlegging our own ux here`
486
308
  this.dispatchOnLoading(false);
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
309
+ this.dispatchChange([]);
490
310
  this.dispatchSidebarSearchChange(this.value);
311
+ setStoredSearch(getBaseUrlPath(), { query: "", results: [] });
491
312
  }
492
313
  }
493
314
  }