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

yext / search-ui-react / 27378873635

11 Jun 2026 09:32PM UTC coverage: 84.578% (+0.1%) from 84.43%
27378873635

push

github

mkouzel-yext
minor bug fixes

1083 of 1496 branches covered (72.39%)

Branch coverage included in aggregate %.

1 of 1 new or added line in 1 file covered. (100.0%)

1 existing line in 1 file now uncovered.

2361 of 2576 relevant lines covered (91.65%)

123.94 hits per line

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

89.66
/src/components/GenerativeDirectAnswer.tsx
1
import { useTranslation } from 'react-i18next';
7✔
2
import {
7✔
3
  GenerativeDirectAnswerResponse,
4
  useSearchActions,
5
  useSearchState,
6
  SearchTypeEnum,
7
  Result
8
} from '@yext/search-headless-react';
9
import { useComposedCssClasses } from '../hooks';
7✔
10
import { useCardAnalytics } from '../hooks/useCardAnalytics';
7✔
11
import { DefaultRawDataType } from '../models/index';
12
import { executeGenerativeDirectAnswer } from '../utils/search-operations';
7✔
13
import { AISignpostIcon } from '../icons/AISignpostIcon';
7✔
14
import { CloseIcon } from '../icons/CloseIcon';
7✔
15
import { Markdown, MarkdownCssClasses } from './Markdown';
7✔
16
import { useId } from '../hooks/useId';
7✔
17
import React, { useCallback, useMemo, useRef } from 'react';
7✔
18

19
/**
20
 * The CSS class interface used for {@link GenerativeDirectAnswer}.
21
 *
22
 * @public
23
 */
24
export interface GenerativeDirectAnswerCssClasses {
25
  container?: string,
26
  header?: string,
27
  answerText?: string,
28
  divider?: string,
29
  citationsContainer?: string,
30
  citation?: string,
31
  citationTitle?: string,
32
  citationSnippet?: string
33
}
34

35
const builtInCssClasses: Readonly<GenerativeDirectAnswerCssClasses> = {
8✔
36
  container: 'p-6 border border-gray-200 rounded-lg shadow-sm',
37
  header: 'text-xl',
38
  answerText: 'mt-4 prose',
39
  divider: 'border-b border-gray-200 w-full pb-6 mb-6',
40
  citationsContainer: 'mt-4 flex overflow-x-auto gap-4',
41
  citation: 'p-4 border border-gray-200 rounded-lg shadow-sm bg-slate-100 flex flex-col grow-0 shrink-0 basis-64 text-sm text-neutral overflow-x-auto cursor-pointer hover:border-indigo-500',
42
  citationTitle: 'font-bold',
43
  citationSnippet: 'line-clamp-2 text-ellipsis break-words'
44
};
45

46
/**
47
 * Props for {@link GenerativeDirectAnswer}.
48
 *
49
 * @public
50
 */
51
export interface GenerativeDirectAnswerProps {
52
  /** CSS classes for customizing the component styling. */
53
  customCssClasses?: GenerativeDirectAnswerCssClasses,
54
  /** The header for the answer section of the generative direct answer. */
55
  answerHeader?: string | React.JSX.Element,
56
  /** Whether to hide the AI signpost for the generative direct answer. */
57
  hideAISignpost?: boolean,
58
  /** The props to pass to the AI signpost component. */
59
  aiSignpostProps?: AISignpostProps,
60
  /** The header for the citations section of the generative direct answer. */
61
  citationsHeader?: string | React.JSX.Element,
62
  /**
63
   * The citations container component for customizing the logic that determines which results
64
   * can be rendered.
65
   * By default, a section for citations is displayed if the results that correspond to the
66
   * citations have the default minimum required info, which is `rawData.uid` and `rawData.name`.
67
  */
68
  CitationsContainer?: (props: CitationsProps) => React.JSX.Element | null,
69
  /** The citation card component for customizing how each citation is displayed. */
70
  CitationCard?: (props: CitationProps) => React.JSX.Element | null
71
}
72

73
/**
74
 * Displays the AI generated answer of a generative direct answer.
75
 *
76
 * @public
77
 *
78
 * @param props - {@link GenerativeDirectAnswerProps}
79
 * @returns A React element for the generative direct answer, or null if there is no generated answer
80
 */
81
export function GenerativeDirectAnswer({
7✔
82
  customCssClasses,
83
  answerHeader,
84
  hideAISignpost = false,
11✔
85
  aiSignpostProps,
86
  citationsHeader,
87
  CitationCard,
88
  CitationsContainer = Citations,
11✔
89
}: GenerativeDirectAnswerProps): React.JSX.Element | null {
90
  const cssClasses = useComposedCssClasses(builtInCssClasses, customCssClasses);
12✔
91

92
  const isUniversal = useSearchState(state => state.meta.searchType) === SearchTypeEnum.Universal;
12✔
93
  const universalResults = useSearchState(state => state.universal);
12✔
94
  const verticalResults = useSearchState(state => state.vertical);
12✔
95
  const searchId = useSearchState(state => state.meta.uuid);
12✔
96

97
  const searchResults: Result[] | undefined = React.useMemo(() => {
12✔
98
    if (isUniversal) {
12!
99
      return universalResults.verticals?.flatMap(v => v.results);
37✔
100
    } else {
101
      return verticalResults.results;
1✔
102
    }
103
  }, [isUniversal, universalResults, verticalResults]);
104

105
  const lastExecutedSearchResults = useRef(undefined as Result[] | undefined);
12✔
106
  const searchActions = useSearchActions();
12✔
107
  const gdaResponse = useSearchState(state => state.generativeDirectAnswer?.response);
12✔
108
  const isLoading = useSearchState(state => state.generativeDirectAnswer?.isLoading);
12✔
109
  const handleClickEvent = useReportClickEvent();
12✔
110

111
  React.useEffect(() => {
11✔
112
    if (!searchResults?.length || !searchId || searchResults === lastExecutedSearchResults.current) {
11✔
113
      return;
1✔
114
    }
115
    executeGenerativeDirectAnswer(searchActions);
10✔
116
    lastExecutedSearchResults.current = searchResults;
10✔
117
  }, [searchActions, searchResults, searchId]);
118

119
  if (!searchResults?.length || isLoading || !gdaResponse || gdaResponse.resultStatus !== 'SUCCESS') {
11!
UNCOV
120
    return null;
×
121
  }
122

123
  return (
11✔
124
    <div className={cssClasses.container}>
125
      <Answer
126
        gdaResponse={gdaResponse}
127
        cssClasses={cssClasses}
128
        answerHeader={answerHeader}
129
        hideAISignpost={hideAISignpost}
130
        aiSignpostProps={aiSignpostProps}
131
        linkClickHandler={handleClickEvent}
132
      />
133
      <CitationsContainer
134
        gdaResponse={gdaResponse}
135
        cssClasses={cssClasses}
136
        searchResults={searchResults}
137
        citationsHeader={citationsHeader}
138
        CitationCard={CitationCard}
139
        citationClickHandler={handleClickEvent}
140
      />
141
    </div>
142
  );
143
}
144

145
interface AnswerProps {
146
  gdaResponse: GenerativeDirectAnswerResponse,
147
  cssClasses: GenerativeDirectAnswerCssClasses,
148
  answerHeader?: string | React.JSX.Element,
149
  hideAISignpost: boolean,
150
  aiSignpostProps?: AISignpostProps,
151
  linkClickHandler?: (data: GdaClickEventData) => void
152
}
153

154
/**
155
 * Props for the built-in AI signpost component.
156
 *
157
 * @public
158
 */
159
export interface AISignpostProps {
160
  /** Icon displayed before the signpost label. Defaults to the SDK's AI signpost icon. */
161
  icon?: React.ReactNode,
162
  /** Label displayed in the signpost button. Defaults to "AI-Generated". */
163
  label?: React.ReactNode,
164
  /** Header displayed in the signpost popover. Defaults to "AI-Generated Content". */
165
  popoverHeader?: React.ReactNode,
166
  /** Body displayed in the signpost popover. */
167
  popoverBody?: React.ReactNode
168
}
169

170
/**
171
 * Displays AI signpost content for the generative direct answer.
172
 */
173
function AISignpost({
174
  icon,
175
  label,
176
  popoverHeader,
177
  popoverBody
178
}: AISignpostProps): React.JSX.Element {
179
  const { t } = useTranslation();
17✔
180
  const [isOpen, setIsOpen] = React.useState(false);
17✔
181
  const popoverId = useId('ai-signpost-popover');
17✔
182
  const popoverHeaderId = useId('ai-signpost-popover-header');
17✔
183
  const popoverDescriptionId = useId('ai-signpost-popover-description');
17✔
184
  const handleSignpostClick = useCallback(() => {
17✔
185
    setIsOpen(current => !current);
4✔
186
  }, []);
187
  const onSignpostClose = useCallback(() => {
17✔
188
    setIsOpen(false);
2✔
189
  }, []);
190

191
  return (
15✔
192
    <div className='relative mt-4 text-sm text-gray-700'>
193
      <button
194
        type='button'
195
        aria-expanded={isOpen}
196
        aria-controls={popoverId}
197
        className='inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-slate-100'
198
        onClick={handleSignpostClick}
199
      >
200
        {icon ?? <AISignpostIcon className='h-4 w-4' />}
31✔
201
        <span>{label ?? t('aiGeneratedAnswerSignpostLabel')}</span>
31✔
202
      </button>
203
      {isOpen && (
21✔
204
        <div
205
          id={popoverId}
206
          role='dialog'
207
          aria-labelledby={popoverHeaderId}
208
          aria-describedby={popoverDescriptionId}
209
          className='absolute left-0 top-full z-10 mt-2 w-80 max-w-full rounded-lg border border-gray-200 bg-white shadow-lg'
210
        >
211
          <div className='flex flex-col px-4 py-3 gap-3'>
212
            <div className='flex items-center justify-between'>
213
              <div id={popoverHeaderId} className='text-sm font-semibold text-gray-900'>
214
                {popoverHeader ?? t('aiGeneratedAnswerSignpostPopoverHeader')}
6✔
215
              </div>
216
              <button
217
                type='button'
218
                className='inline-flex h-6 w-6 items-center justify-center rounded-full text-gray-500 hover:bg-gray-100 hover:text-gray-700'
219
                aria-label={t('dismiss')}
220
                onClick={onSignpostClose}
221
              >
222
                <CloseIcon className='h-3 w-3' />
223
              </button>
224
            </div>
225
            <div id={popoverDescriptionId} className='text-sm text-gray-700'>
226
              {popoverBody ?? t('aiGeneratedAnswerSignpostPopoverBody')}
6✔
227
            </div>
228
          </div>
229
        </div>
230
      )}
231
    </div>
232
  );
233
}
234

235
/**
236
 * The answer section of the Generative Direct Answer.
237
 */
238
function Answer(props: AnswerProps) {
239
  const {
240
    gdaResponse,
241
    cssClasses,
242
    answerHeader,
243
    hideAISignpost,
244
    aiSignpostProps,
245
    linkClickHandler
246
  } = props;
12✔
247
  const { t } = useTranslation();
12✔
248
  const markdownCssClasses: MarkdownCssClasses = useMemo(
11✔
249
    () => ({
12✔
250
      container: cssClasses.answerText,
251
    }),
252
    [cssClasses.answerText]
253
  );
254

255
  const handleMarkdownLinkClick = useCallback((destinationUrl?: string) => {
12✔
256
    if (destinationUrl) {
2!
257
      linkClickHandler?.({ destinationUrl });
2✔
258
    }
259
  }, [linkClickHandler]);
260

261
  return <>
11✔
262
    <div className={cssClasses.header}>
263
      {answerHeader ?? t('aiGeneratedAnswer')}
23✔
264
    </div>
265
    {!hideAISignpost && <AISignpost {...aiSignpostProps} />}
23✔
266
    <Markdown
267
      content={gdaResponse.directAnswer}
268
      onLinkClick={handleMarkdownLinkClick}
269
      customCssClasses={markdownCssClasses}
270
    />
271
  </>;
272
}
273

274
/**
275
 * Props for citations component.
276
 *
277
 * @public
278
 */
279
export interface CitationsProps {
280
  /** Response object containing generative direct answer info. */
281
  gdaResponse: GenerativeDirectAnswerResponse,
282
  /** CSS classes for customizing the component styling. */
283
  cssClasses: GenerativeDirectAnswerCssClasses,
284
  /** Returned results relevant to the users' query to be used in Citations. */
285
  searchResults: Result[],
286
  /** The header for the citations section generative direct answer. */
287
  citationsHeader?: string | React.JSX.Element,
288
  /** The component for citation card */
289
  CitationCard?: (props: CitationProps) => React.JSX.Element | null,
290
  /** Handle onClick event for citation link. */
291
  citationClickHandler?: (data: GdaClickEventData) => void
292
}
293

294
/**
295
 * Displays the citations section of the Generative Direct Answer.
296
 */
297
function Citations(props: CitationsProps) {
298
  const {
299
    gdaResponse,
300
    cssClasses,
301
    searchResults,
302
    citationsHeader,
303
    CitationCard = Citation,
11✔
304
    citationClickHandler
305
  } = props;
11✔
306
  const { t } = useTranslation();
11✔
307
  const citationResults = React.useMemo(() => {
11✔
308
    // If an entity is returned by multiple different verticals, it will be present in
309
    // searchResults multiple times. We want to only show it once in the citations.
310
    const citationIds = new Set(gdaResponse.citations);
11✔
311
    return searchResults.filter(result => {
11✔
312
      const { uid, name } = result.rawData ?? {};
34!
313
      const dataIsInvalid = !uid || !name || typeof name != 'string' || typeof uid != 'string';
34✔
314
      if (dataIsInvalid || !citationIds.has(uid)) {
32✔
315
        return false;
15✔
316
      }
317
      citationIds.delete(uid);
18✔
318
      return true;
18✔
319
    });
320
  }, [gdaResponse.citations, searchResults]);
321

322
  const count = citationResults.length;
11✔
323
  if (!count) {
10!
324
    return null;
1✔
325
  }
326

327
  return <>
9✔
328
    <div className={cssClasses.divider} />
329
    <div className={cssClasses.header}>
330
      {citationsHeader ?? t('sources', { count })}
19✔
331
    </div>
332
    <div className={cssClasses.citationsContainer}>
333
      {citationResults.map((result, index) => (
334
        <CitationCard
17✔
335
          key={index}
336
          searchResult={result}
337
          cssClasses={cssClasses}
338
          citationClickHandler={citationClickHandler}
339
        />
340
      ))}
341
    </div>
342
  </>;
343
}
344

345
/**
346
 * Props for citation card.
347
 *
348
 * @public
349
 */
350
export interface CitationProps {
351
  searchResult: Result,
352
  cssClasses: GenerativeDirectAnswerCssClasses,
353
  citationClickHandler?: (data: GdaClickEventData) => void
354
}
355

356
/**
357
 * Displays a citation card for the citations section of the Generative Direct Answer.
358
 */
359
function Citation(props: CitationProps) {
360
  const {
361
    searchResult,
362
    cssClasses,
363
    citationClickHandler
364
  } = props;
19✔
365
  const { name, description, answer, link } = searchResult.rawData ?? {};
19!
366
  const citationTitle = String(name ?? '');
19!
367
  const citationSnippet = String(description ?? answer ?? '');
19!
368
  const citationUrl = typeof link === 'string' ? link : undefined;
19✔
369
  const handleCitationClick = useCallback(() => {
19✔
370
    if (citationUrl) {
3!
371
      citationClickHandler?.({ searchResult, destinationUrl: citationUrl });
3✔
372
    }
373
  }, [citationClickHandler, citationUrl, searchResult]);
374

375
  return (
17✔
376
    <a
377
      className={cssClasses.citation}
378
      href={citationUrl}
379
      onClick={handleCitationClick}
380
    >
381
      <div className={cssClasses.citationTitle}>{citationTitle}</div>
382
      <div className={cssClasses.citationSnippet}>{citationSnippet}</div>
383
    </a>
384
  );
385
}
386

387
/**
388
 * Payload for click events fired on a generative direct answer card.
389
 *
390
 * @public
391
 */
392
export interface GdaClickEventData {
393
  searchResult?: Result,
394
  destinationUrl: string
395
}
396

397
function useReportClickEvent<T = DefaultRawDataType>(): (data: GdaClickEventData) => void {
398
  const reportAnalyticsEvent = useCardAnalytics<T>();
12✔
399
  return React.useCallback((data: GdaClickEventData) => {
11✔
400
    if (data.searchResult) {
2!
401
      reportAnalyticsEvent(data, 'CITATION_CLICK');
1✔
402
    } else {
403
      reportAnalyticsEvent(data, 'CTA_CLICK');
1✔
404
    }
405
  }, [reportAnalyticsEvent]);
406
}
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