@salesforcedevs/dx-components 1.31.0 → 1.32.0-alpha.10

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,71 @@
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 Data360SearchCacheItem,
6
+ type Data360SearchResultItem,
7
+ fetchSearch,
8
+ getBaseUrlPath,
9
+ getStoredSearch,
10
+ isCacheStale,
11
+ setStoredSearch
12
+ } from "dxUtils/data360Search";
13
+ import { createSearchRegExp } from "dxUtils/regexps";
19
14
  import { RecentSearches } from "dxUtils/recentSearches";
20
- import { toJson } from "dxUtils/normalizers";
21
15
  import {
16
+ type HighlightedSections,
22
17
  Option,
23
18
  PopoverRequestCloseType,
24
19
  SidebarSearchResult
25
20
  } from "typings/custom";
26
21
 
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
22
  const SEARCH_DEBOUNCE_DELAY = 1200;
32
23
 
33
- const UserRecentSearches = new RecentSearches();
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
+ }
34
44
 
35
- const normalizeCoveoAdvancedQueryValue = (version: string): string =>
36
- version?.split(".").join("\\.");
45
+ const UserRecentSearches = new RecentSearches();
37
46
 
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;
47
+ function resultPathFromHref(href: string): string {
48
+ return href.startsWith("http") ? new URL(href).pathname : href;
49
+ }
55
50
 
56
- if (hasChanged && this.engine && this.value !== "") {
57
- this.dispatchOnLoading(true);
58
- }
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
+ }
59
61
 
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
- }
62
+ const getSearchQueryParam = (): string =>
63
+ new URLSearchParams(window.location.search).get("q") ?? "";
66
64
 
65
+ export default class SidebarSearch extends LightningElement {
67
66
  @api
68
67
  public fetchMoreResults() {
69
- if (this.resultList?.state?.moreResultsAvailable) {
70
- this.fetchingMoreResults = true;
71
- this.resultList.fetchMoreResults();
72
- }
68
+ // Data 360 Search API does not expose pagination in the same way; no-op
73
69
  }
74
70
 
75
71
  @api
@@ -85,313 +81,144 @@ export default class SidebarSearch extends LightningElement {
85
81
  this.dropdownRequestedOpen = value;
86
82
  }
87
83
 
88
- private _coveoAdvancedQueryConfig!: { [key: string]: any };
89
84
  private dropdownRequestedOpen: boolean = false;
90
85
  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;
86
+ private value: string = "";
99
87
  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
- }
88
+ private data360SearchInitialized: boolean = false;
89
+ private didTrackThisSearch: boolean = false;
114
90
 
115
91
  private get isDropdownOpen() {
116
92
  return (
117
93
  this.dropdownRequestedOpen &&
118
- this.recentSearches &&
119
- this.recentSearches.length > 0 &&
94
+ this.recentSearches?.length > 0 &&
120
95
  !this.value
121
96
  );
122
97
  }
123
98
 
124
99
  constructor() {
125
100
  super();
126
-
127
101
  this.updateRecentSearches();
128
- this.value = getSidebarSearchParams() || "";
129
- }
130
-
131
- disconnectedCallback() {
132
- this.unsubscribeFromResultList();
102
+ this.value = getSearchQueryParam();
133
103
  }
134
104
 
135
105
  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
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();
150
129
  }
151
- );
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();
141
+ }
142
+ this.dispatchOnLoading(false);
143
+ }
152
144
  }
153
-
154
- // If this is the first render and there is a search value, trigger
155
- // term highlighting
156
145
  if (!this.didRender && this.value) {
157
146
  this.dispatchHighlightedTermChange();
158
147
  }
159
-
160
148
  this.didRender = true;
161
149
  }
162
150
 
163
- private initializeUserSession() {
164
- const stringified = window.sessionStorage.getItem(SESSION_KEY);
165
-
166
- if (!stringified) {
167
- return;
168
- }
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
+ };
169
175
 
176
+ private async fetchDataCloudSearch(): Promise<void> {
170
177
  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
+ const query = this.value.trim();
179
+ const rawResults = await fetchSearch(query);
180
+ const results: SidebarSearchResult[] = rawResults.map(
181
+ this.normalizeDataCloudResult
178
182
  );
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);
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
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) }
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
256
196
  });
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;
197
+ } catch (err) {
198
+ console.error("Data 360 Search request failed", err);
199
+ this.dispatchChange([]);
200
+ } finally {
201
+ this.dispatchOnLoading(false);
266
202
  }
203
+ }
267
204
 
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
205
+ private trackSearchResultsOnce(term: string, resultCount: number): void {
206
+ if (this.didTrackThisSearch || !term) {
273
207
  return;
274
208
  }
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
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
302
215
  });
303
-
304
- engine.dispatch(registerSearchQueryAction);
305
216
  }
306
217
 
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();
218
+ private dispatchChange(results: SidebarSearchResult[]) {
389
219
  this.dispatchEvent(
390
220
  new CustomEvent("change", {
391
- detail: {
392
- results: results.map(this.normalizeCoveoResult),
393
- value: this.value
394
- }
221
+ detail: { results, value: this.value }
395
222
  })
396
223
  );
397
224
  }
@@ -427,24 +254,18 @@ export default class SidebarSearch extends LightningElement {
427
254
  }
428
255
 
429
256
  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();
257
+ UserRecentSearches.add(this.value);
258
+ this.updateRecentSearches();
259
+ if (!isSyncingSearchValue) {
260
+ this.dispatchSidebarSearchChange(this.value);
442
261
  }
262
+ this.fetchDataCloudSearch();
443
263
  }, SEARCH_DEBOUNCE_DELAY);
444
264
 
445
265
  private onClickRecentSearch(e: CustomEvent) {
446
266
  this.value = e.detail;
447
267
  this.dispatchSidebarSearchChange(this.value);
268
+ this.dispatchOnLoading(true);
448
269
  this.submitSearch(true);
449
270
  }
450
271
 
@@ -461,7 +282,6 @@ export default class SidebarSearch extends LightningElement {
461
282
 
462
283
  private onInputChange(e: CustomEvent) {
463
284
  this.value = e.detail;
464
-
465
285
  this.handleValueChange(false);
466
286
  this.dispatchHighlightedTermChange();
467
287
  }
@@ -476,18 +296,14 @@ export default class SidebarSearch extends LightningElement {
476
296
 
477
297
  private handleValueChange(isSyncingSearchValue = false) {
478
298
  if (this.value) {
299
+ this.didTrackThisSearch = false;
479
300
  this.dispatchOnLoading(true);
480
- if (this.searchBox) {
481
- this.submitSearch(isSyncingSearchValue);
482
- }
301
+ this.submitSearch(isSyncingSearchValue);
483
302
  } else {
484
- // coveo's reaction to an empty value triggers a return of results
485
- // bootlegging our own ux here`
486
303
  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
304
+ this.dispatchChange([]);
490
305
  this.dispatchSidebarSearchChange(this.value);
306
+ setStoredSearch(getBaseUrlPath(), { query: "", results: [] });
491
307
  }
492
308
  }
493
309
  }