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

yext / search-ui-react / 18923826213

29 Oct 2025 10:26PM UTC coverage: 87.922% (-0.4%) from 88.287%
18923826213

Pull #570

github

github-actions[bot]
Update snapshots
Pull Request #570: Move map to search location when there are no results (v1.10.1)

821 of 1033 branches covered (79.48%)

Branch coverage included in aggregate %.

30 of 38 new or added lines in 1 file covered. (78.95%)

2069 of 2254 relevant lines covered (91.79%)

135.39 hits per line

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

74.16
/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

9
/**
10
 * Props for rendering a custom marker on the map.
11
 *
12
 * @public
13
 */
14
export type PinComponentProps<T> = {
15
  /** The index of the pin. */
16
  index: number,
17
  /** The Mapbox map. */
18
  mapbox: mapboxgl.Map,
19
  /** The search result corresponding to the pin. */
20
  result: Result<T>,
21
  /** Where the pin is selected. */
22
  selected?: boolean,
23
};
24

25
/**
26
 * A functional component that can be used to render a custom marker on the map.
27
 *
28
 * @public
29
 */
30
export type PinComponent<T> = (props: PinComponentProps<T>) => JSX.Element;
31

32
/**
33
 * A function use to derive a result's coordinate.
34
 *
35
 * @public
36
 */
37
export type CoordinateGetter<T> = (result: Result<T>) => Coordinate | undefined;
38

39
/**
40
 * Coordinate use to represent the result's location on a map.
41
 *
42
 * @public
43
 */
44
export interface Coordinate {
45
  /** The latitude of the location. */
46
  latitude: number,
47
  /** The longitude of the location. */
48
  longitude: number
49
}
50

51
/**
52
 * A function which is called when user drags or zooms the map.
53
 *
54
 * @public
55
 */
56
export type OnDragHandler = (center: mapboxgl.LngLat, bounds: mapboxgl.LngLatBounds) => void;
57

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

110
/**
111
 * A component that renders a map with markers to show result locations using Mapbox GL.
112
 *
113
 * @remarks
114
 * For the map to work properly, be sure to include Mapbox GL stylesheet in the application.
115
 *
116
 * @example
117
 * For instance, user may add the following import statement in their application's index file
118
 * or in the file where `MapboxMap` is used:
119
 * `import 'mapbox-gl/dist/mapbox-gl.css';`
120
 *
121
 * Or, user may add a stylesheet link in their html page:
122
 * `<link href="https://api.mapbox.com/mapbox-gl-js/v2.9.2/mapbox-gl.css" rel="stylesheet" />`
123
 *
124
 * @param props - {@link MapboxMapProps}
125
 * @returns A React element containing a Mapbox Map
126
 *
127
 * @public
128
 */
129
export function MapboxMap<T>({
6✔
130
  mapboxAccessToken,
131
  mapboxOptions,
132
  PinComponent,
133
  renderPin,
134
  getCoordinate = getDefaultCoordinate,
20✔
135
  onDrag,
136
  iframeWindow,
137
  allowUpdates = false,
21✔
138
  onPinClick,
139
  markerOptionsOverride,
140
}: MapboxMapProps<T>): JSX.Element {
141
  const mapboxInstance = (iframeWindow as Window & { mapboxgl?: typeof mapboxgl })?.mapboxgl ?? mapboxgl;
21✔
142
  useEffect(() => {
21✔
143
    mapboxInstance.accessToken = mapboxAccessToken;
16✔
144
  }, [mapboxAccessToken]);
145

146
  const mapContainer = useRef<HTMLDivElement>(null);
21✔
147
  const map = useRef<mapboxgl.Map | null>(null);
21✔
148
  const markers = useRef<mapboxgl.Marker[]>([]);
21✔
149

150
  const locationResults = useSearchState(state => state.vertical.results) as Result<T>[];
34✔
151
  const staticFilters = useSearchState(state => state.filters?.static);
34✔
152
  const onDragDebounced = useDebouncedFunction(onDrag, 100);
21✔
153
  const [selectedResult, setSelectedResult] = useState<Result<T> | undefined>(undefined);
21✔
154

155
  const handlePinClick = useCallback((result: Result<T>) => {
21✔
156
    setSelectedResult(prev => prev === result ? undefined : result)
9!
157
  }, [])
158

159
  useEffect(() => {
15✔
160
    onPinClick?.(selectedResult);
19✔
161
  }, [selectedResult])
162

163
  const locale = useSearchState(state => state.meta?.locale);
34✔
164
  // keep track of the previous value of mapboxOptions across renders
165
  const prevMapboxOptions = useRef(mapboxOptions);
21✔
166

167
  const localizeMap = useCallback(() => {
21✔
168
    const mapbox = map.current;
10✔
169
    if (!mapbox || !locale) return;
10✔
170

171
    const localizeLabels = () => {
×
172
      mapbox.getStyle().layers.forEach(layer => {
×
173
        if (layer.type === "symbol" && layer.layout?.["text-field"]) {
×
174
          mapbox.setLayoutProperty(
×
175
            layer.id,
176
            "text-field",
177
            [
178
              'coalesce',
179
              ['get', `name_${getMapboxLanguage(locale)}`],
180
              ['get', 'name']
181
            ]
182
          );
183
        }
184
      });
185
    }
186

187
    if (mapbox.isStyleLoaded()) {
×
188
      localizeLabels();
×
189
    } else {
190
      mapbox.once("styledata", () => localizeLabels())
×
191
    }
192
  }, [locale]);
193

194
  useEffect(() => {
21✔
195
    if (mapContainer.current) {
21✔
196
      if (map.current && allowUpdates) {
21!
197
        // Compare current and previous mapboxOptions using deep equality
198
        if (!_.isEqual(prevMapboxOptions.current, mapboxOptions)) {
×
199
          // Update to existing Map
200
          handleMapboxOptionsUpdates(mapboxOptions, map.current);
×
201
          prevMapboxOptions.current = (mapboxOptions);
×
202
        }
203
      } else if (!map.current && mapboxInstance) {
21✔
204
        const options: mapboxgl.MapboxOptions = {
21✔
205
          container: mapContainer.current,
206
          style: 'mapbox://styles/mapbox/streets-v11',
207
          center: [-74.005371, 40.741611],
208
          zoom: 9,
209
          ...mapboxOptions
210
        };
211
        map.current = new mapboxInstance.Map(options);
16✔
212
        const mapbox = map.current;
16✔
213
        mapbox.resize();
16✔
214
        const nav = new mapboxInstance.NavigationControl({
16✔
215
          showCompass: false,
216
          showZoom: true,
217
          visualizePitch: false
218
        });
219
        mapbox.addControl(nav, 'top-right');
16✔
220
        if (onDragDebounced) {
16✔
221
          mapbox.on('drag', () => {
11✔
222
            onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
1✔
223
          });
224
          mapbox.on('zoom', (e) => {
11✔
225
            if (e.originalEvent) {
237!
226
              // only trigger on user zoom, not programmatic zoom (e.g. from fitBounds)
227
              onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
228
            }
229
          });
230
          return () => {
11✔
231
            mapbox.off('drag', () => {
9✔
232
              onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
233
            });
234
            mapbox.off('zoom', (e) => {
9✔
235
              if (e.originalEvent) {
×
236
                onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
237
              }
238
            });
239
          };
240
        }
241
      }
242
      localizeMap();
10✔
243
    }
244
  }, [mapboxOptions, onDragDebounced, localizeMap]);
245

246
  useEffect(() => {
21✔
247
    if (iframeWindow && map.current) {
19!
248
      map.current.resize();
×
249
    }
250
  }, [mapContainer.current]);
251

252
  useEffect(() => {
21✔
253
    markers.current.forEach(marker => marker.remove());
21✔
254
    markers.current = [];
21✔
255
    const mapbox = map.current;
21✔
256
    if (mapbox && locationResults) {
21✔
257
      if (locationResults.length > 0) {
20!
258
        const bounds = new mapboxInstance.LngLatBounds();
20✔
259
        locationResults.forEach((result, i) => {
20✔
260
          const markerLocation = getCoordinate(result);
26✔
261
          if (markerLocation) {
26✔
262
            const { latitude, longitude } = markerLocation;
24✔
263
            const el = document.createElement('div');
24✔
264
            let markerOptions: mapboxgl.MarkerOptions = {};
24✔
265
            if (PinComponent) {
24✔
266
              if (renderPin) {
7✔
267
                console.warn(
1✔
268
                  'Found both PinComponent and renderPin props. Using PinComponent.'
269
                );
270
              }
271
              ReactDOM.render(<PinComponent
7✔
272
                index={i}
273
                mapbox={mapbox}
274
                result={result}
275
                selected={selectedResult === result}
276
              />, el);
277
              markerOptions.element = el;
7✔
278
            } else if (renderPin) {
17✔
279
              renderPin({ index: i, mapbox, result, container: el });
2✔
280
              markerOptions.element = el;
2✔
281
            }
282

283
            if (markerOptionsOverride) {
24!
284
              markerOptions = {
3✔
285
                ...markerOptions,
286
                ...markerOptionsOverride(selectedResult === result)
287
              }
288
            }
289

290
            const marker = new mapboxInstance.Marker(markerOptions)
24✔
291
              .setLngLat({ lat: latitude, lng: longitude })
292
              .addTo(mapbox);
293

294
            marker?.getElement().addEventListener('click', () => handlePinClick(result));
24✔
295
            markers.current.push(marker);
24✔
296
            bounds.extend([longitude, latitude]);
24✔
297
          }
298
        })
299

300
        if (!bounds.isEmpty()){
20✔
301
          mapbox.fitBounds(bounds, {
20✔
302
            // these settings are defaults and will be overriden if present on fitBoundsOptions
303
            padding: { top: 50, bottom: 50, left: 50, right: 50 },
304
            maxZoom: mapboxOptions?.maxZoom ?? 15,
40✔
305
            ...mapboxOptions?.fitBoundsOptions,
306
          });
307
        }
308

309
        return () => {
20✔
310
          markers.current.forEach((marker, i) => {
16✔
311
            marker?.getElement().removeEventListener('click', () => handlePinClick(locationResults[i]));
18✔
312
          });
313
        }
NEW
314
      } else if (staticFilters?.length) {
×
NEW
315
        const locationFilterValue = getLocationFilterValue(staticFilters);
×
NEW
316
        if (locationFilterValue) {
×
NEW
317
          mapbox.flyTo({
×
318
            center: locationFilterValue
319
          });
320
        }
321
      };
322
    }
323
  }, [PinComponent, getCoordinate, locationResults, selectedResult, markerOptionsOverride]);
324

325
  return (
21✔
326
    <div ref={mapContainer} className='h-full w-full' />
327
  );
328
}
329

330
function handleMapboxOptionsUpdates(mapboxOptions: Omit<mapboxgl.MapboxOptions, 'container'> | undefined, currentMap: mapboxgl.Map) {
331
  if (mapboxOptions?.style) {
×
332
    currentMap.setStyle(mapboxOptions.style);
×
333
  }
334
  // Add more options to update as needed
335
}
336

337
function isCoordinate(data: unknown): data is Coordinate {
338
  return typeof data == 'object'
24✔
339
    && typeof data?.['latitude'] === 'number'
340
    && typeof data?.['longitude'] === 'number';
341
}
342

343
function getDefaultCoordinate<T>(result: Result<T>): Coordinate | undefined {
344
  const yextDisplayCoordinate = result.rawData['yextDisplayCoordinate'];
25✔
345
  if (!yextDisplayCoordinate) {
25✔
346
    console.error('Unable to use the default "yextDisplayCoordinate" field as the result\'s coordinate to display on map.'
1✔
347
    + '\nConsider providing the "getCoordinate" prop to MapboxMap component to fetch the desire coordinate from result.');
348
    return undefined;
1✔
349
  }
350
  if (!isCoordinate(yextDisplayCoordinate)) {
24✔
351
    console.error('The default `yextDisplayCoordinate` field from result is not of type "Coordinate".');
1✔
352
    return undefined;
1✔
353
  }
354
  return yextDisplayCoordinate;
23✔
355
}
356

357
export function getMapboxLanguage(locale: string) {
6✔
358
  try {
22✔
359
    const localeOptions = new Intl.Locale(locale.replaceAll('_', '-'));
22✔
360
    return localeOptions.script ? `${localeOptions.language}-${localeOptions.script}` : localeOptions.language;
22✔
361
  } catch (e) {
362
    console.warn(`Locale "${locale}" is not supported.`)
22✔
363
  }
364
  return 'en';
×
365
}
366

367
function getLocationFilterValue(staticFilters: SelectableStaticFilter[]): [number, number] | undefined {
NEW
368
  const locationFilter = staticFilters.find(f => f.filter['fieldId'] === 'builtin.location' && f.filter['value'])?.filter;
×
NEW
369
  if (locationFilter) {
×
NEW
370
    const {lat, lng} = locationFilter['value'];
×
NEW
371
    return [lng, lat];
×
372
  }
373
}
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