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

yext / search-ui-react / 15908473609

26 Jun 2025 05:27PM UTC coverage: 87.811% (-0.3%) from 88.143%
15908473609

push

github

web-flow
Merge main (v1.8.15) into develop (#542)

* Version 1.8.0

This PR adds Generative Direct Answers (GDA) and GDA analytics. Full merge thread can be found below - many of the changes were already in main and were just merged into the develop branch while developing.


* add generativeDirectAnswer support (#441)

J=CLIP-1226
TEST=auto,manual

added unit and visual regression test, ran and saw tests passed
added GDA to test-site App and verified that the appropriate gda content shows up and the UI looks as expected

* Added storybook stories for generative direct answer component (#442)

J=CLIP-1226
TEST=manual

ran `npm run storybook`

* update search-headless-react dependencies version to 2.5.0-beta.3 (#443)

New version has `queryDurationMillis` as an optional field of VerticalResults

J=CLIP-1226
TEST=auto,manual

ran `npm run test`
temporarily added GDA to ProductsPage in test site and verified that the appropriate gda content shows up after a vertical search.

* support clickable citation card with link (#444)

J=CLIP-1332
TEST=auto,manual

ran `npm run test` and test manually on test-site

* GDA: citations component override  (#451)

Changes:
- Added `CitationsContainer` prop to `<GenerativeDirectAnswer>` to allow custom components to be passed in
- Updated test cases and test-site with new component passed through

J=CLIP-1369
TEST=manual|auto

Ran `npm run build`, `npm run test`, and `npm run wcag` and did manual testing on test-site

* Check for search id before executing GDA (#454)

J=CLIP-1461
TEST=auto,manual

ran `npm run test` and tested manually on test-site to verified gda is executed as expected

* Automated update to THIRD-PARTY-NOTICES from github action's 3rd party notices check

* increment versions

* Automated update to THIRD-PARTY-NOTICES from github action's 3rd party notices check

* Update snapshots

* decrement package

* Function Vertical Support via search-core/headless upgrade (#459)

This change upgrades the version for s... (continued)

820 of 1031 branches covered (79.53%)

Branch coverage included in aggregate %.

16 of 27 new or added lines in 2 files covered. (59.26%)

2004 of 2185 relevant lines covered (91.72%)

110.26 hits per line

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

74.59
/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
  const localizeMap = useCallback(() => {
21✔
167
    const mapbox = map.current;
10✔
168
    if (!mapbox || !locale) return;
10✔
169

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

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

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

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

251
  useEffect(() => {
21✔
252
    const mapbox = map.current;
16✔
253
    if (!mapbox || !locale) return;
16✔
254

255
    const localizeMap = () => {
×
256
      mapbox.getStyle().layers.forEach(layer => {
×
257
        if (layer.type === "symbol" && layer.layout?.["text-field"]) {
×
258
          mapbox.setLayoutProperty(
×
259
            layer.id,
260
            "text-field",
261
            [
262
              'coalesce',
263
              ['get', `name_${getMapboxLanguage(locale)}`],
264
              ['get', 'name']
265
            ]
266
          );
267
        }
268
      });
269
    }
270

271
    if (mapbox.isStyleLoaded()) {
×
272
      localizeMap();
×
273
    } else {
274
      mapbox.once("styledata", () => localizeMap())
×
275
    }
276
  }, [locale]);
277

278
  useEffect(() => {
21✔
279
    if (iframeWindow && map.current) {
19!
280
      map.current.resize();
×
281
    }
282
  }, [mapContainer.current]);
283

284
  useEffect(() => {
21✔
285
    markers.current.forEach(marker => marker.remove());
21✔
286
    markers.current = [];
21✔
287
    const mapbox = map.current;
21✔
288
    if (mapbox && locationResults?.length > 0) {
21✔
289
      const bounds = new mapboxInstance.LngLatBounds();
20✔
290
      locationResults.forEach((result, i) => {
20✔
291
        const markerLocation = getCoordinate(result);
26✔
292
        if (markerLocation) {
26✔
293
          const { latitude, longitude } = markerLocation;
24✔
294
          const el = document.createElement('div');
24✔
295
          let markerOptions: mapboxgl.MarkerOptions = {};
24✔
296
          if (PinComponent) {
24✔
297
            if (renderPin) {
7✔
298
              console.warn(
1✔
299
                'Found both PinComponent and renderPin props. Using PinComponent.'
300
              );
301
            }
302
            ReactDOM.render(<PinComponent
7✔
303
              index={i}
304
              mapbox={mapbox}
305
              result={result}
306
              selected={selectedResult === result}
307
            />, el);
308
            markerOptions.element = el;
7✔
309
          } else if (renderPin) {
17✔
310
            renderPin({ index: i, mapbox, result, container: el });
2✔
311
            markerOptions.element = el;
2✔
312
          }
313

314
          if (markerOptionsOverride) {
24!
315
            markerOptions = {
3✔
316
              ...markerOptions,
317
              ...markerOptionsOverride(selectedResult === result)
318
            }
319
          }
320

321
          const marker = new mapboxInstance.Marker(markerOptions)
24✔
322
            .setLngLat({ lat: latitude, lng: longitude })
323
            .addTo(mapbox);
324

325
          marker?.getElement().addEventListener('click', () => handlePinClick(result));
24✔
326
          markers.current.push(marker);
24✔
327
          bounds.extend([longitude, latitude]);
24✔
328
        }
329
      });
330

331
      if (!bounds.isEmpty()){
20✔
332
        mapbox.fitBounds(bounds, {
20✔
333
          padding: { top: 50, bottom: 50, left: 50, right: 50 },
334
          maxZoom: mapboxOptions?.maxZoom ?? 15
40✔
335
        });
336
      }
337

338
      return () => {
21✔
339
        markers.current.forEach((marker, i) => {
16✔
340
          marker?.getElement().removeEventListener('click', () => handlePinClick(locationResults[i]));
18✔
341
        });
342
      }
343
    }
344
  }, [PinComponent, getCoordinate, locationResults, selectedResult, markerOptionsOverride]);
345

346
  return (
21✔
347
    <div ref={mapContainer} className='h-full w-full' />
348
  );
349
}
350

351
function handleMapboxOptionsUpdates(mapboxOptions: Omit<mapboxgl.MapboxOptions, 'container'> | undefined, currentMap: mapboxgl.Map) {
352
  if (mapboxOptions?.style) {
×
353
    currentMap.setStyle(mapboxOptions.style);
×
354
  }
355
  // Add more options to update as needed
356
}
357

358
function isCoordinate(data: unknown): data is Coordinate {
359
  return typeof data == 'object'
24✔
360
    && typeof data?.['latitude'] === 'number'
361
    && typeof data?.['longitude'] === 'number';
362
}
363

364
function getDefaultCoordinate<T>(result: Result<T>): Coordinate | undefined {
365
  const yextDisplayCoordinate = result.rawData['yextDisplayCoordinate'];
25✔
366
  if (!yextDisplayCoordinate) {
25✔
367
    console.error('Unable to use the default "yextDisplayCoordinate" field as the result\'s coordinate to display on map.'
1✔
368
    + '\nConsider providing the "getCoordinate" prop to MapboxMap component to fetch the desire coordinate from result.');
369
    return undefined;
1✔
370
  }
371
  if (!isCoordinate(yextDisplayCoordinate)) {
24✔
372
    console.error('The default `yextDisplayCoordinate` field from result is not of type "Coordinate".');
1✔
373
    return undefined;
1✔
374
  }
375
  return yextDisplayCoordinate;
23✔
376
}
377

378
export function getMapboxLanguage(locale: string) {
6✔
379
  try {
22✔
380
    const localeOptions = new Intl.Locale(locale.replaceAll('_', '-'));
22✔
381
    return localeOptions.script ? `${localeOptions.language}-${localeOptions.script}` : localeOptions.language;
22✔
382
  } catch (e) {
383
    console.warn(`Locale "${locale}" is not supported.`)
22✔
384
  }
385
  return 'en';
×
386
}
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