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

yext / search-ui-react / 15285043284

27 May 2025 08:27PM UTC coverage: 87.715% (-0.004%) from 87.719%
15285043284

Pull #524

github

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

1330 of 1634 branches covered (81.4%)

Branch coverage included in aggregate %.

25 of 27 new or added lines in 1 file covered. (92.59%)

3 existing lines in 1 file now uncovered.

1983 of 2143 relevant lines covered (92.53%)

111.11 hits per line

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

89.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 Language from '@mapbox/mapbox-gl-language';
6✔
4
import { Result, useSearchState } from '@yext/search-headless-react';
6✔
5
import { useDebouncedFunction } from '../hooks/useDebouncedFunction';
6✔
6
import ReactDOM from 'react-dom';
6✔
7

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

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

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

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

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

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

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

153
  const mapContainer = useRef<HTMLDivElement>(null);
19✔
154
  const map = useRef<mapboxgl.Map | null>(null);
19✔
155
  const markers = useRef<mapboxgl.Marker[]>([]);
19✔
156

157
  const locale = useSearchState(state => state.meta?.locale);
32!
158
  const locationResults = useSearchState(state => state.vertical.results) as Result<T>[];
32✔
159
  const onDragDebounced = useDebouncedFunction(onDrag, 100);
19✔
160
  const [selectedResult, setSelectedResult] = useState<Result<T> | undefined>(undefined);
19✔
161

162
  const handlePinClick = useCallback((result: Result<T>) => {
19✔
163
    setSelectedResult(prev => prev === result ? undefined : result)
7!
164
  }, [])
165

166
  useEffect(() => {
13✔
167
    scrollToResult?.(selectedResult);
17!
168
  }, [selectedResult])
169

170
  useEffect(() => {
19✔
171
    if (mapContainer.current) {
19✔
172
      if (map.current && allowUpdates) {
19!
173
        // Update to existing Map
NEW
174
        handleMapboxOptionsUpdates(mapboxOptions, map.current);
×
175
      } else if (!map.current && mapboxInstance) {
19✔
176
        const options: mapboxgl.MapboxOptions = {
16✔
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
        mapbox.addControl(new Language({
16✔
193
          defaultLanguage: locale
194
        }));
195
        if (onDragDebounced) {
16✔
196
          mapbox.on('drag', () => {
11✔
197
            onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
1✔
198
          });
199
          mapbox.on('zoom', (e) => {
11✔
200
            if (e.originalEvent) {
139!
201
              // only trigger on user zoom, not programmatic zoom (e.g. from fitBounds)
UNCOV
202
              onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
203
            }
204
          });
205
        }
206
      }
207
    }
208
  }, [mapboxOptions, onDragDebounced]);
209

210
  useEffect(() => {
19✔
211
    if (iframeWindow && map.current) {
19!
NEW
212
      map.current.resize();
×
213
    }
214
  }, [mapContainer.current]);
215

216
  useEffect(() => {
19✔
217
    markers.current.forEach(marker => marker.remove());
19✔
218
    markers.current = [];
19✔
219
    const mapbox = map.current;
19✔
220
    if (mapbox && locationResults?.length > 0) {
19✔
221
      const bounds = new mapboxInstance.LngLatBounds();
18✔
222
      locationResults.forEach((result, i) => {
18✔
223
        const markerLocation = getCoordinate(result);
24✔
224
        if (markerLocation) {
24✔
225
          const { latitude, longitude } = markerLocation;
22✔
226
          const el = document.createElement('div');
22✔
227
          let markerOptions: mapboxgl.MarkerOptions = {};
22✔
228
          if (PinComponent) {
22✔
229
            if (renderPin) {
5✔
230
              console.warn(
1✔
231
                'Found both PinComponent and renderPin props. Using PinComponent.'
232
              );
233
            }
234
            ReactDOM.render(<PinComponent
5✔
235
              index={i}
236
              mapbox={mapbox}
237
              result={result}
238
              selected={selectedResult === result}
239
              onClick={handlePinClick}
240
            />, el);
241
            markerOptions.element = el;
5✔
242
          } else if (renderPin) {
17✔
243
            renderPin({ index: i, mapbox, result, container: el });
2✔
244
            markerOptions.element = el;
2✔
245
          } else if (markerOptionsOverride) {
15!
246
            markerOptions = {
2✔
247
              ...markerOptions,
248
              ...markerOptionsOverride(selectedResult === result)
249
            }
250
          }
251
          const marker = new mapboxInstance.Marker(markerOptions)
22✔
252
            .setLngLat({ lat: latitude, lng: longitude })
253
            .addTo(mapbox);
254

255
          if (!PinComponent) {
22✔
256
            marker?.getElement().addEventListener('click', () => handlePinClick(result));
17✔
257
          }
258

259
          markers.current.push(marker);
22✔
260
          bounds.extend([longitude, latitude]);
22✔
261
        }
262
      });
263

264
      if (!bounds.isEmpty()){
18✔
265
        mapbox.fitBounds(bounds, {
18✔
266
          padding: { top: 50, bottom: 50, left: 50, right: 50 },
267
          maxZoom: mapboxOptions?.maxZoom ?? 15
165✔
268
        });
269
      }
270
    }
271
  }, [PinComponent, getCoordinate, locationResults, selectedResult, markerOptionsOverride]);
272

273
  return (
19✔
274
    <div ref={mapContainer} className='h-full w-full' />
275
  );
276
}
277

278
function handleMapboxOptionsUpdates(mapboxOptions: Omit<mapboxgl.MapboxOptions, 'container'> | undefined, currentMap: mapboxgl.Map) {
UNCOV
279
  if (mapboxOptions?.style) {
×
UNCOV
280
    currentMap.setStyle(mapboxOptions.style);
×
281
  }
282
  // Add more options to update as needed
283
}
284

285
function isCoordinate(data: unknown): data is Coordinate {
286
  return typeof data == 'object'
22✔
287
    && typeof data?.['latitude'] === 'number'
85✔
288
    && typeof data?.['longitude'] === 'number';
82✔
289
}
290

291
function getDefaultCoordinate<T>(result: Result<T>): Coordinate | undefined {
292
  const yextDisplayCoordinate = result.rawData['yextDisplayCoordinate'];
23✔
293
  if (!yextDisplayCoordinate) {
23✔
294
    console.error('Unable to use the default "yextDisplayCoordinate" field as the result\'s coordinate to display on map.'
1✔
295
    + '\nConsider providing the "getCoordinate" prop to MapboxMap component to fetch the desire coordinate from result.');
296
    return undefined;
1✔
297
  }
298
  if (!isCoordinate(yextDisplayCoordinate)) {
22✔
299
    console.error('The default `yextDisplayCoordinate` field from result is not of type "Coordinate".');
1✔
300
    return undefined;
1✔
301
  }
302
  return yextDisplayCoordinate;
21✔
303
}
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