• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

yext / search-ui-react / 20970077140

13 Jan 2026 07:38PM UTC coverage: 85.288% (-0.5%) from 85.752%
20970077140

push

github

web-flow
ksearch: upgrade to Events API (#508)

* ksearch: upgrade to Events API

This PR upgrades our analytics calls to the next major
version, also called the Events API. As part of this
work, analytics calls have changed shape, and some deprecated
properties have been dropped.
J=WAT-4651
TEST=auto,manual

Updated auto tests. Ran test site locally and saw events all the
way to snowflake.

---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>

1019 of 1401 branches covered (72.73%)

Branch coverage included in aggregate %.

104 of 127 new or added lines in 19 files covered. (81.89%)

2 existing lines in 2 files now uncovered.

2239 of 2419 relevant lines covered (92.56%)

141.11 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

72.06
/src/components/MapboxMap.tsx
1
import React, { useRef, useEffect, useState, useCallback } from 'react';
6✔
2
import mapboxgl, { MarkerOptions } from 'mapbox-gl';
6✔
3
import { Result, useSearchState, SelectableStaticFilter } from '@yext/search-headless-react';
6✔
4
import { useDebouncedFunction } from '../hooks/useDebouncedFunction';
6✔
5
import _ from 'lodash';
6✔
6

7
import ReactDOM from 'react-dom';
6✔
8
// Try to statically import createRoot, will be undefined for <18.
9
import * as ReactDomClient from 'react-dom/client';
6✔
10

11
type LegacyReactDOM = {
12
  render?: (element: React.ReactElement, container: Element) => void;
13
  unmountComponentAtNode?: (container: Element | DocumentFragment) => boolean;
14
};
15

16
type RootHandle = {
17
  render: (children: React.ReactNode) => void;
18
  unmount: () => void;
19
};
20

21
const legacyReactDOM = ReactDOM as LegacyReactDOM;
10✔
22
const reactMajorVersion = Number(React.version.split('.')[0]);
10✔
23
const supportsCreateRoot = !Number.isNaN(reactMajorVersion) && reactMajorVersion >= 18;
10✔
24

25

26
/**
27
 * Props for rendering a custom marker on the map.
28
 *
29
 * @public
30
 */
31
export type PinComponentProps<T> = {
32
  /** The index of the pin. */
33
  index: number,
34
  /** The Mapbox map. */
35
  mapbox: mapboxgl.Map,
36
  /** The search result corresponding to the pin. */
37
  result: Result<T>,
38
  /** Where the pin is selected. */
39
  selected?: boolean,
40
};
41

42
/**
43
 * A functional component that can be used to render a custom marker on the map.
44
 *
45
 * @public
46
 */
47
export type PinComponent<T> = (props: PinComponentProps<T>) => React.JSX.Element;
48

49
/**
50
 * A function use to derive a result's coordinate.
51
 *
52
 * @public
53
 */
54
export type CoordinateGetter<T> = (result: Result<T>) => Coordinate | undefined;
55

56
/**
57
 * Coordinate use to represent the result's location on a map.
58
 *
59
 * @public
60
 */
61
export interface Coordinate {
62
  /** The latitude of the location. */
63
  latitude: number,
64
  /** The longitude of the location. */
65
  longitude: number
66
}
67

68
/**
69
 * A function which is called when user drags or zooms the map.
70
 *
71
 * @public
72
 */
73
export type OnDragHandler = (center: mapboxgl.LngLat, bounds: mapboxgl.LngLatBounds) => void;
74

75
/**
76
 * Props for the {@link MapboxMap} component.
77
 * The type param "T" represents the type of "rawData" field of the results use in the map.
78
 *
79
 * @public
80
 */
81
export interface MapboxMapProps<T> {
82
  /** Mapbox access token. */
83
  mapboxAccessToken: string,
84
  /** Interface for map customization derived from Mapbox GL's Map options. */
85
  mapboxOptions?: Omit<mapboxgl.MapboxOptions, 'container'>,
86
  /**
87
   * Custom Pin component to render for markers on the map.
88
   * By default, the built-in marker image from Mapbox GL is used.
89
   * This prop should not be used with
90
   * {@link MapboxMapProps.renderPin | renderPin}. If both are provided,
91
   * only PinComponent will be used.
92
   */
93
  PinComponent?: PinComponent<T>,
94
  /**
95
   * Render function for a custom marker on the map. This function takes in an
96
   * HTML element and is responible for rendering the pin into that element,
97
   * which will be used as the marker.
98
   * By default, the built-in marker image from Mapbox GL is used.
99
   * This prop should not be used with
100
   * {@link MapboxMapProps.PinComponent | PinComponent}. If both are provided,
101
   * only PinComponent will be used.
102
   */
103
  renderPin?: (props: PinComponentProps<T> & { container: HTMLElement }) => void,
104
  /**
105
   * A function to derive a result's coordinate for the corresponding marker's location on the map.
106
   * By default, "yextDisplayCoordinate" field is used as the result's display coordinate.
107
   */
108
  getCoordinate?: CoordinateGetter<T>,
109
  /** {@inheritDoc OnDragHandler} */
110
  onDrag?: OnDragHandler,
111
  /**
112
   * The window object of the iframe where the map should rendered. Must have mapboxgl loaded.
113
   * If not provided or mapboxgl not loaded, the map will be rendered in the parent window.
114
   */
115
  iframeWindow?: Window,
116
  /**
117
   * If set to true, the map will update its options when the mapboxOptions prop changes.
118
   * Otherwise, the map will not update its options once initially set.
119
   */
120
  allowUpdates?: boolean,
121
  /** A function that handles a pin click event. */
122
  onPinClick?: (result: Result<T> | undefined) => void,
123
  /** The options to apply to the map markers based on whether it is selected. */
124
  markerOptionsOverride?: (selected: boolean) => MarkerOptions,
125
}
126

127
/**
128
 * A component that renders a map with markers to show result locations using Mapbox GL.
129
 *
130
 * @remarks
131
 * For the map to work properly, be sure to include Mapbox GL stylesheet in the application.
132
 *
133
 * @example
134
 * For instance, user may add the following import statement in their application's index file
135
 * or in the file where `MapboxMap` is used:
136
 * `import 'mapbox-gl/dist/mapbox-gl.css';`
137
 *
138
 * Or, user may add a stylesheet link in their html page:
139
 * `<link href="https://api.mapbox.com/mapbox-gl-js/v2.9.2/mapbox-gl.css" rel="stylesheet" />`
140
 *
141
 * @param props - {@link MapboxMapProps}
142
 * @returns A React element containing a Mapbox Map
143
 *
144
 * @public
145
 */
146
export function MapboxMap<T>({
6✔
147
  mapboxAccessToken,
148
  mapboxOptions,
149
  PinComponent,
150
  renderPin,
151
  getCoordinate = getDefaultCoordinate,
18✔
152
  onDrag,
153
  iframeWindow,
154
  allowUpdates = false,
19✔
155
  onPinClick,
156
  markerOptionsOverride,
157
}: MapboxMapProps<T>): React.JSX.Element {
158
  const mapboxInstance = (iframeWindow as Window & { mapboxgl?: typeof mapboxgl })?.mapboxgl ?? mapboxgl;
19✔
159
  useEffect(() => {
13✔
160
    mapboxInstance.accessToken = mapboxAccessToken;
10✔
161
  }, [mapboxAccessToken]);
162

163
  const mapContainer = useRef<HTMLDivElement>(null);
19✔
164
  const map = useRef<mapboxgl.Map | null>(null);
19✔
165
  const markers = useRef<mapboxgl.Marker[]>([]);
19✔
166
  const markerRoots = useRef(new Map<HTMLElement, RootHandle>());
19✔
167
  const activeMarkerElements = useRef(new Set<HTMLElement>());
19✔
168
  const markerData = useRef<Array<{ marker: mapboxgl.Marker, result: Result<T>, index: number }>>([]);
19✔
169

170
  const locationResults = useSearchState(state => state.vertical.results) as Result<T>[];
19✔
171
  const staticFilters = useSearchState(state => state.filters?.static);
19✔
172
  const onDragDebounced = useDebouncedFunction(onDrag, 100);
19✔
173
  const [selectedResult, setSelectedResult] = useState<Result<T> | undefined>(undefined);
19✔
174

175
  const handlePinClick = useCallback((result: Result<T>) => {
13✔
176
    setSelectedResult(prev => prev === result ? undefined : result)
3!
177
  }, [])
178

179
  useEffect(() => {
19✔
180
    onPinClick?.(selectedResult);
13✔
181
  }, [selectedResult])
182

183
  const cleanupPinComponent = useCallback((element: HTMLElement) => {
19✔
184
    activeMarkerElements.current.delete(element);
13✔
185
    if (supportsCreateRoot) {
13!
186
      const root = markerRoots.current.get(element);
10✔
187
      if (root) {
13!
188
        root.unmount();
13✔
189
        markerRoots.current.delete(element);
13✔
190
      }
191
    } else {
192
      legacyReactDOM.unmountComponentAtNode?.(element);
13✔
193
    }
194
  }, []);
195

196
  const attachPinComponent = useCallback((element: HTMLElement, component: React.JSX.Element) => {
19✔
197
    if (supportsCreateRoot && typeof ReactDomClient.createRoot === 'function') {
14!
198
      // Use React 18+ API
199
      let root = markerRoots.current.get(element);
7✔
200
      if (!root) {
14✔
201
        root = ReactDomClient.createRoot(element);
14✔
202
        markerRoots.current.set(element, root);
14✔
203
      }
204
      root.render(component);
14✔
205
    } else if (typeof legacyReactDOM.render === 'function') {
13!
206
      // Fallback for React <18
207
      legacyReactDOM.render(component, element);
13✔
208
    }
209
  }, []);
210

211
  const removeMarkers = useCallback(() => {
19✔
212
    markers.current.forEach(marker => {
24✔
213
      if (!marker) {
16!
214
        return;
16✔
215
      }
216
      const element = typeof marker.getElement === 'function' ? marker.getElement() : null;
10!
217
      if (element) {
13!
218
        cleanupPinComponent(element);
13✔
219
      }
220
      if (typeof marker.remove === 'function') {
13!
221
        marker.remove();
13✔
222
      }
223
    });
224
    markers.current = [];
24✔
225
    markerData.current = [];
24✔
226
  }, [cleanupPinComponent]);
227

228
  const locale = useSearchState(state => state.meta?.locale);
19✔
229
  // keep track of the previous value of mapboxOptions across renders
230
  const prevMapboxOptions = useRef(mapboxOptions);
19✔
231

232
  /**
233
   * Localizes Mapbox label text to a specific locale.
234
   *
235
   * Updates symbol layers that are place names such that labels prefer `name_<lang>`
236
   * (e.g. `name_fr`) and fall back to `name` when unavailable.
237
   *
238
   * Note:
239
   * - Symbol layers that are place names would have `text-field` properties that includes 'name', which are localized.
240
   * - Other symbol layers (e.g. road shields, transit, icons) are left unchanged.
241
   */
242
  const localizeMap = useCallback(() => {
19✔
243
    const mapbox = map.current;
8✔
244
    if (!mapbox || !locale) return;
18✔
245

246
    const localizeLabels = () => {
13✔
247
      mapbox.getStyle().layers.forEach(layer => {
13✔
248
        if (layer.type !== "symbol") {
13!
249
          return;
13✔
250
        }
251
        const textField = layer.layout?.["text-field"];
×
252
        if (typeof textField === "string"
13!
253
          ? textField.includes("name")
254
          : (Array.isArray(textField) && JSON.stringify(textField).includes("name"))) {
×
255
          mapbox.setLayoutProperty(
13✔
256
            layer.id,
257
            "text-field",
258
            [
259
              'coalesce',
260
              ['get', `name_${getMapboxLanguage(locale)}`],
261
              ['get', 'name']
262
            ]
263
          );
264
        }
265
      });
266
    }
267

268
    if (mapbox.isStyleLoaded()) {
13!
269
      localizeLabels();
13✔
270
    } else {
271
      mapbox.once("styledata", () => localizeLabels())
×
272
    }
273
  }, [locale]);
274

275
  useEffect(() => {
13✔
276
    if (mapContainer.current) {
13✔
277
      if (map.current && allowUpdates) {
13!
278
        // Compare current and previous mapboxOptions using deep equality
279
        if (!_.isEqual(prevMapboxOptions.current, mapboxOptions)) {
×
280
          // Update to existing Map
281
          handleMapboxOptionsUpdates(mapboxOptions, map.current);
×
282
          prevMapboxOptions.current = (mapboxOptions);
×
283
        }
284
      } else if (!map.current && mapboxInstance) {
13✔
285
        const options: mapboxgl.MapboxOptions = {
16✔
286
          container: mapContainer.current,
287
          style: 'mapbox://styles/mapbox/streets-v11',
288
          center: [-74.005371, 40.741611],
289
          zoom: 9,
290
          ...mapboxOptions
291
        };
292
        map.current = new mapboxInstance.Map(options);
10✔
293
        const mapbox = map.current;
16✔
294
        mapbox.resize();
10✔
295
        const nav = new mapboxInstance.NavigationControl({
16✔
296
          showCompass: false,
297
          showZoom: true,
298
          visualizePitch: false
299
        });
300
        mapbox.addControl(nav, 'top-right');
10✔
301
        if (onDragDebounced) {
10✔
302
          mapbox.on('drag', () => {
10✔
303
            onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
1✔
304
          });
305
          mapbox.on('zoom', (e) => {
10✔
306
            if (e.originalEvent) {
127!
307
              // only trigger on user zoom, not programmatic zoom (e.g. from fitBounds)
308
              onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
309
            }
310
          });
311
          return () => {
10✔
312
            mapbox.off('drag', () => {
8✔
313
              onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
314
            });
315
            mapbox.off('zoom', (e) => {
8✔
316
              if (e.originalEvent) {
×
317
                onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
318
              }
319
            });
320
          };
321
        }
322
      }
323
      localizeMap();
5✔
324
    }
325
  }, [mapboxOptions, onDragDebounced, localizeMap]);
326

327
  useEffect(() => {
13✔
328
    if (iframeWindow && map.current) {
13!
329
      map.current.resize();
×
330
    }
331
  }, [mapContainer.current]);
332

333
  useEffect(() => {
13✔
334
    removeMarkers();
10✔
335
    const mapbox = map.current;
16✔
336
    if (mapbox && locationResults) {
10✔
337
      if (locationResults.length > 0) {
15!
338
        const bounds = new mapboxInstance.LngLatBounds();
15✔
339
        locationResults.forEach((result, i) => {
15✔
340
          const markerLocation = getCoordinate(result);
21✔
341
          if (markerLocation) {
16✔
342
            const { latitude, longitude } = markerLocation;
19✔
343
            const el = document.createElement('div');
19✔
344
            let markerOptions: mapboxgl.MarkerOptions = {};
19✔
345
            if (PinComponent) {
16✔
346
              if (renderPin) {
11!
347
                console.warn(
11✔
348
                  'Found both PinComponent and renderPin props. Using PinComponent.'
349
                );
350
              }
351
              attachPinComponent(el, (
11✔
352
                <PinComponent
353
                  index={i}
354
                  mapbox={mapbox}
355
                  result={result}
356
                  selected={selectedResult === result}
357
                />
358
              ));
359
              markerOptions.element = el;
11✔
360
            } else if (renderPin) {
14✔
361
              renderPin({ index: i, mapbox, result, container: el });
10✔
362
              markerOptions.element = el;
10✔
363
            }
364

365
            if (markerOptionsOverride) {
16!
366
              markerOptions = {
×
367
                ...markerOptions,
368
                ...markerOptionsOverride(selectedResult === result)
369
              }
370
            }
371

372
            const marker = new mapboxInstance.Marker(markerOptions)
19✔
373
              .setLngLat({ lat: latitude, lng: longitude })
374
              .addTo(mapbox);
375

376
            marker?.getElement().addEventListener('click', () => handlePinClick(result));
16✔
377
            markers.current.push(marker);
16✔
378
            if (marker) {
16✔
379
              markerData.current.push({ marker, result, index: i });
16✔
380
            }
381
            bounds.extend([longitude, latitude]);
16✔
382
          }
383
        })
384

385
        const canvas = mapbox.getCanvas();
15✔
386
        if (!bounds.isEmpty() && !!canvas && canvas.height > 0 && canvas.width > 0) {
15✔
387
          const resolvedOptions = {
10✔
388
            // these settings are defaults and will be overriden if present on fitBoundsOptions
389
            padding: { top: 50, bottom: 50, left: 50, right: 50 },
390
            maxZoom: mapboxOptions?.maxZoom ?? 15,
20✔
391
            ...mapboxOptions?.fitBoundsOptions,
392
          };
393

394
          let resolvedPadding;
395
          if (typeof resolvedOptions.padding === 'number') {
10!
396
            resolvedPadding = { top: resolvedOptions.padding, bottom: resolvedOptions.padding, left: resolvedOptions.padding, right: resolvedOptions.padding };
10✔
397
          } else {
398
            resolvedPadding = {
10✔
399
              top: resolvedOptions.padding?.top ?? 0,
10!
400
              bottom: resolvedOptions.padding?.bottom ?? 0,
10!
401
              left: resolvedOptions.padding?.left ?? 0,
10!
402
              right: resolvedOptions.padding?.right ?? 0
10!
403
            };
404
          }
405

406
          // Padding must not exceed the map's canvas dimensions
407
          const verticalPaddingSum = resolvedPadding.top + resolvedPadding.bottom;
10✔
408
          if (verticalPaddingSum >= canvas.height) {
10!
409
            const ratio = canvas.height / (verticalPaddingSum || 1);
×
410
            resolvedPadding.top = Math.max(0, resolvedPadding.top * ratio - 1);
10✔
411
            resolvedPadding.bottom = Math.max(0, resolvedPadding.bottom * ratio - 1);
10✔
412
          }
413
          const horizontalPaddingSum = resolvedPadding.left + resolvedPadding.right;
10✔
414
          if (horizontalPaddingSum >= canvas.width) {
10!
415
            const ratio = canvas.width / (horizontalPaddingSum || 1);
×
416
            resolvedPadding.left = Math.max(0, resolvedPadding.left * ratio - 1);
10✔
417
            resolvedPadding.right = Math.max(0, resolvedPadding.right * ratio - 1);
10✔
418
          }
419
          resolvedOptions.padding = resolvedPadding;
10✔
420
          mapbox.fitBounds(bounds, resolvedOptions);
10✔
421
        }
422

423
        return () => {
15✔
424
          markers.current.forEach((marker, i) => {
15✔
425
            marker?.getElement().removeEventListener('click', () => handlePinClick(locationResults[i]));
13✔
426
          });
427
          removeMarkers();
15✔
428
        }
429
      } else if (staticFilters?.length) {
×
430
        const locationFilterValue = getLocationFilterValue(staticFilters);
×
431
        if (locationFilterValue) {
×
432
          mapbox.flyTo({
×
433
            center: locationFilterValue
434
          });
435
        }
436
      };
437
    }
438
  }, [PinComponent, getCoordinate, locationResults, removeMarkers, markerOptionsOverride, renderPin]);
439

440
  useEffect(() => {
13✔
441
    const mapbox = map.current;
19✔
442
    if (!mapbox || !PinComponent) {
13✔
443
      return;
9✔
444
    }
445
    markerData.current.forEach(({ marker, result, index }) => {
4✔
446
      const element = typeof marker.getElement === 'function' ? marker.getElement() : null;
4!
447
      if (!element) {
4!
448
        return;
×
449
      }
450
      attachPinComponent(element, (
4✔
451
        <PinComponent
452
          index={index}
453
          mapbox={mapbox}
454
          result={result}
455
          selected={selectedResult === result}
456
        />
457
      ));
458
    });
459
  }, [attachPinComponent, PinComponent, selectedResult]);
460

461
  return (
13✔
462
    <div ref={mapContainer} className='h-full w-full' />
463
  );
464
}
465

466
function handleMapboxOptionsUpdates(mapboxOptions: Omit<mapboxgl.MapboxOptions, 'container'> | undefined, currentMap: mapboxgl.Map) {
467
  if (mapboxOptions?.style) {
×
468
    currentMap.setStyle(mapboxOptions.style);
×
469
  }
470
  // Add more options to update as needed
471
}
472

473
function isCoordinate(data: unknown): data is Coordinate {
474
  return typeof data == 'object'
16✔
475
    && typeof (data as any)?.['latitude'] === 'number'
476
    && typeof (data as any)?.['longitude'] === 'number';
477
}
478

479
function getDefaultCoordinate<T>(result: Result<T>): Coordinate | undefined {
480
  const yextDisplayCoordinate: Coordinate =(result.rawData as any)["yextDisplayCoordinate"];
20✔
481
  if (!yextDisplayCoordinate) {
16!
482
    console.error('Unable to use the default "yextDisplayCoordinate" field as the result\'s coordinate to display on map.'
1✔
483
    + '\nConsider providing the "getCoordinate" prop to MapboxMap component to fetch the desire coordinate from result.');
484
    return undefined;
1✔
485
  }
486
  if (!isCoordinate(yextDisplayCoordinate)) {
16!
487
    console.error('The default `yextDisplayCoordinate` field from result is not of type "Coordinate".');
1✔
488
    return undefined;
1✔
489
  }
490
  return yextDisplayCoordinate;
16✔
491
}
492

493
export function getMapboxLanguage(locale: string) {
6✔
494
  try {
22✔
495
    const localeOptions = new Intl.Locale(locale.replaceAll('_', '-'));
22✔
496
    return localeOptions.script ? `${localeOptions.language}-${localeOptions.script}` : localeOptions.language;
22✔
497
  } catch (e) {
498
    console.warn(`Locale "${locale}" is not supported.`)
×
499
  }
500
  return 'en';
×
501
}
502

503
function getLocationFilterValue(staticFilters: SelectableStaticFilter[]): [number, number] | undefined {
NEW
504
  const locationFilter = staticFilters.find(f => (f.filter as any)['fieldId'] === 'builtin.location' && (f.filter as any)['value'])?.filter;
×
505
  if (locationFilter) {
×
NEW
506
    const {lat, lng} = (locationFilter as any)['value'];
×
507
    return [lng, lat];
×
508
  }
509
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc