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

yext / search-ui-react / 15446310287

04 Jun 2025 03:28PM UTC coverage: 87.45% (-0.3%) from 87.719%
15446310287

Pull #524

github

web-flow
Merge 282eb5109 into 2eab3c761
Pull Request #524: Localize the Mapbox map based on the search locale

868 of 1107 branches covered (78.41%)

Branch coverage included in aggregate %.

14 of 25 new or added lines in 1 file covered. (56.0%)

1982 of 2152 relevant lines covered (92.1%)

110.8 hits per line

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

77.84
/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 } 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 onDragDebounced = useDebouncedFunction(onDrag, 100);
21✔
152
  const [selectedResult, setSelectedResult] = useState<Result<T> | undefined>(undefined);
21✔
153

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

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

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

166
  useEffect(() => {
21✔
167
    if (mapContainer.current) {
21!
168
      if (map.current && allowUpdates) {
21!
169
        // Compare current and previous mapboxOptions using deep equality
NEW
170
        if (!_.isEqual(prevMapboxOptions.current, mapboxOptions)) {
×
171
          // Update to existing Map
NEW
172
          handleMapboxOptionsUpdates(mapboxOptions, map.current);
×
NEW
173
          prevMapboxOptions.current = (mapboxOptions);
×
174
        }
175
      } else if (!map.current && mapboxInstance) {
21✔
176
        const options: mapboxgl.MapboxOptions = {
21✔
177
          container: mapContainer.current,
178
          style: 'mapbox://styles/mapbox/streets-v11',
179
          center: [-74.005371, 40.741611],
180
          zoom: 9,
181
          ...mapboxOptions
182
        };
183
        map.current = new mapboxInstance.Map(options);
16✔
184
        const mapbox = map.current;
16✔
185
        mapbox.resize();
16✔
186
        const nav = new mapboxInstance.NavigationControl({
16✔
187
          showCompass: false,
188
          showZoom: true,
189
          visualizePitch: false
190
        });
191
        mapbox.addControl(nav, 'top-right');
16✔
192
        if (onDragDebounced) {
16!
193
          mapbox.on('drag', () => {
11✔
194
            onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
1✔
195
          });
196
          mapbox.on('zoom', (e) => {
11✔
197
            if (e.originalEvent) {
231!
198
              // only trigger on user zoom, not programmatic zoom (e.g. from fitBounds)
199
              onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
200
            }
201
          });
202
        }
203
      }
204
    }
205
  }, [mapboxOptions, onDragDebounced]);
206

207
  useEffect(() => {
21✔
208
    const mapbox = map.current;
16✔
209
    if (!mapbox || !locale) return;
16!
210

NEW
211
    const localizeMap = () => {
×
NEW
212
      mapbox.getStyle().layers.forEach(layer => {
×
NEW
213
        if (layer.type === "symbol" && layer.layout?.["text-field"]) {
×
NEW
214
          mapbox.setLayoutProperty(
×
215
            layer.id,
216
            "text-field",
217
            [
218
              'coalesce',
219
              ['get', `name_${getMapboxLanguage(locale)}`],
220
              ['get', 'name']
221
            ]
222
          );
223
        }
224
      });
225
    }
226

NEW
227
    if (mapbox.isStyleLoaded()) {
×
NEW
228
      localizeMap();
×
229
    } else {
NEW
230
      mapbox.once("styledata", () => localizeMap())
×
231
    }
232
  }, [locale]);
233

234
  useEffect(() => {
21✔
235
    if (iframeWindow && map.current) {
19!
236
      map.current.resize();
×
237
    }
238
  }, [mapContainer.current]);
239

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

277
          const marker = new mapboxInstance.Marker(markerOptions)
24✔
278
            .setLngLat({ lat: latitude, lng: longitude })
279
            .addTo(mapbox);
280

281
          marker?.getElement().addEventListener('click', () => handlePinClick(result));
24✔
282
          markers.current.push(marker);
24✔
283
          bounds.extend([longitude, latitude]);
24✔
284
        }
285
      });
286

287
      if (!bounds.isEmpty()){
20!
288
        mapbox.fitBounds(bounds, {
20✔
289
          padding: { top: 50, bottom: 50, left: 50, right: 50 },
290
          maxZoom: mapboxOptions?.maxZoom ?? 15
40✔
291
        });
292
      }
293
    }
294
  }, [PinComponent, getCoordinate, locationResults, selectedResult, markerOptionsOverride]);
295

296
  return (
21✔
297
    <div ref={mapContainer} className='h-full w-full' />
298
  );
299
}
300

301
function handleMapboxOptionsUpdates(mapboxOptions: Omit<mapboxgl.MapboxOptions, 'container'> | undefined, currentMap: mapboxgl.Map) {
302
  if (mapboxOptions?.style) {
×
303
    currentMap.setStyle(mapboxOptions.style);
×
304
  }
305
  // Add more options to update as needed
306
}
307

308
function isCoordinate(data: unknown): data is Coordinate {
309
  return typeof data == 'object'
24✔
310
    && typeof data?.['latitude'] === 'number'
311
    && typeof data?.['longitude'] === 'number';
312
}
313

314
function getDefaultCoordinate<T>(result: Result<T>): Coordinate | undefined {
315
  const yextDisplayCoordinate = result.rawData['yextDisplayCoordinate'];
25✔
316
  if (!yextDisplayCoordinate) {
25✔
317
    console.error('Unable to use the default "yextDisplayCoordinate" field as the result\'s coordinate to display on map.'
1✔
318
    + '\nConsider providing the "getCoordinate" prop to MapboxMap component to fetch the desire coordinate from result.');
319
    return undefined;
1✔
320
  }
321
  if (!isCoordinate(yextDisplayCoordinate)) {
24✔
322
    console.error('The default `yextDisplayCoordinate` field from result is not of type "Coordinate".');
1✔
323
    return undefined;
1✔
324
  }
325
  return yextDisplayCoordinate;
23✔
326
}
327

328
export function getMapboxLanguage(locale: string) {
6✔
329
  try {
22✔
330
    const localeOptions = new Intl.Locale(locale.replaceAll('_', '-'));
22✔
331
    return localeOptions.script ? `${localeOptions.language}-${localeOptions.script}` : localeOptions.language;
22✔
332
  } catch (e) {
333
    console.warn(`Locale "${locale}" is not supported.`)
22✔
334
  }
NEW
335
  return 'en';
×
336
}
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