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

yext / search-ui-react / 16754642759

05 Aug 2025 03:33PM UTC coverage: 87.544% (-0.05%) from 87.593%
16754642759

Pull #554

github

github-actions[bot]
Update snapshots
Pull Request #554: Deduplicate generative direct answer calls

885 of 1128 branches covered (78.46%)

Branch coverage included in aggregate %.

3 of 4 new or added lines in 1 file covered. (75.0%)

2 existing lines in 1 file now uncovered.

2067 of 2244 relevant lines covered (92.11%)

135.93 hits per line

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

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

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

32
const builtInCssClasses: Readonly<GenerativeDirectAnswerCssClasses> = {
71✔
33
  container: 'p-6 border border-gray-200 rounded-lg shadow-sm',
34
  header: 'text-xl',
35
  answerText: 'mt-4 prose',
36
  divider: 'border-b border-gray-200 w-full pb-6 mb-6',
37
  citationsContainer: 'mt-4 flex overflow-x-auto gap-4',
38
  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',
39
  citationTitle: 'font-bold',
40
  citationSnippet: 'line-clamp-2 text-ellipsis break-words'
41
};
42

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

65
/**
66
 * Displays the AI generated answer of a generative direct answer.
67
 *
68
 * @public
69
 *
70
 * @param props - {@link GenerativeDirectAnswerProps}
71
 * @returns A React element for the generative direct answer, or null if there is no generated answer
72
 */
73
export function GenerativeDirectAnswer({
6✔
74
  customCssClasses,
75
  answerHeader,
76
  citationsHeader,
77
  CitationCard,
78
  CitationsContainer = Citations,
7✔
79
}: GenerativeDirectAnswerProps): JSX.Element | null {
80
  const cssClasses = useComposedCssClasses(builtInCssClasses, customCssClasses);
8✔
81

82
  const isUniversal = useSearchState(state => state.meta.searchType) === SearchTypeEnum.Universal;
8✔
83
  const universalResults = useSearchState(state => state.universal);
8✔
84
  const verticalResults = useSearchState(state => state.vertical);
8✔
85
  const searchId = useSearchState(state => state.meta.uuid);
8✔
86

87
  const searchResults: Result[] | undefined = React.useMemo(() => {
8✔
88
    if (isUniversal) {
8!
89
      return universalResults.verticals?.flatMap(v => v.results);
25✔
90
    } else {
91
      return verticalResults.results;
×
92
    }
93
  }, [isUniversal, universalResults, verticalResults]);
94

95
  const [lastExecutedSearchResults, setLastExecutedSearchResults] =
96
      useState(undefined as Result[] | undefined);
8✔
97
  const searchActions = useSearchActions();
8✔
98
  const gdaResponse = useSearchState(state => state.generativeDirectAnswer?.response);
8✔
99
  const isLoading = useSearchState(state => state.generativeDirectAnswer?.isLoading);
8✔
100
  const handleClickEvent = useReportClickEvent();
8✔
101

102
  React.useEffect(() => {
8✔
103
    if (!searchResults?.length || !searchId || searchResults === lastExecutedSearchResults) {
8!
104
      return;
8✔
105
    }
106
    executeGenerativeDirectAnswer(searchActions);
×
NEW
UNCOV
107
    setLastExecutedSearchResults(searchResults);
×
108
  }, [searchResults, searchId]);
109

110
  if (!searchResults?.length || isLoading || !gdaResponse || gdaResponse.resultStatus !== 'SUCCESS') {
8!
UNCOV
111
    return null;
×
112
  }
113

114
  return (
8✔
115
    <div className={cssClasses.container}>
116
      <Answer
117
        gdaResponse={gdaResponse}
118
        cssClasses={cssClasses}
119
        answerHeader={answerHeader}
120
        linkClickHandler={handleClickEvent}
121
      />
122
      <CitationsContainer
123
        gdaResponse={gdaResponse}
124
        cssClasses={cssClasses}
125
        searchResults={searchResults}
126
        citationsHeader={citationsHeader}
127
        CitationCard={CitationCard}
128
        citationClickHandler={handleClickEvent}
129
      />
130
    </div>
131
  );
132
}
133

134
interface AnswerProps {
135
  gdaResponse: GenerativeDirectAnswerResponse,
136
  cssClasses: GenerativeDirectAnswerCssClasses,
137
  answerHeader?: string | JSX.Element,
138
  linkClickHandler?: (data: GdaClickEventData) => void
139
}
140

141
/**
142
 * The answer section of the Generative Direct Answer.
143
 */
144
function Answer(props: AnswerProps) {
145
  const {
146
    gdaResponse,
147
    cssClasses,
148
    answerHeader,
149
    linkClickHandler
150
  } = props;
10✔
151
  const { t } = useTranslation();
8✔
152
  const markdownCssClasses: MarkdownCssClasses = useMemo(
7✔
153
    () => ({
8✔
154
      container: cssClasses.answerText,
155
    }),
156
    [cssClasses.answerText]
157
  );
158

159
  return <>
8✔
160
    <div className={cssClasses.header}>
161
      {answerHeader ?? t('aiGeneratedAnswer')}
15✔
162
    </div>
163
    <Markdown
164
      content={gdaResponse.directAnswer}
165
      onLinkClick={(destinationUrl) => destinationUrl && linkClickHandler?.({destinationUrl})}
1✔
166
      customCssClasses={markdownCssClasses}
167
    />
168
  </>;
169
}
170

171
/**
172
 * Props for citations component.
173
 *
174
 * @public
175
 */
176
export interface CitationsProps {
177
  /** Response object containing generative direct answer info. */
178
  gdaResponse: GenerativeDirectAnswerResponse,
179
  /** CSS classes for customizing the component styling. */
180
  cssClasses: GenerativeDirectAnswerCssClasses,
181
  /** Returned results relevant to the users' query to be used in Citations. */
182
  searchResults: Result[],
183
  /** The header for the citations section generative direct answer. */
184
  citationsHeader?: string | JSX.Element,
185
  /** The component for citation card */
186
  CitationCard?: (props: CitationProps) => JSX.Element | null,
187
  /** Handle onClick event for citation link. */
188
  citationClickHandler?: (data: GdaClickEventData) => void
189
}
190

191
/**
192
 * Displays the citations section of the Generative Direct Answer.
193
 */
194
function Citations(props: CitationsProps) {
195
  const {
196
    gdaResponse,
197
    cssClasses,
198
    searchResults,
199
    citationsHeader,
200
    CitationCard = Citation,
7✔
201
    citationClickHandler
202
  } = props;
7✔
203
  const { t } = useTranslation();
7✔
204
  const citationResults = React.useMemo(() => {
7✔
205
    // If an entity is returned by multiple different verticals, it will be present in
206
    // searchResults multiple times. We want to only show it once in the citations.
207
    let citationIds = new Set(gdaResponse.citations);
7✔
208
    return searchResults.filter(result => {
7✔
209
      const {uid, name} = result.rawData ?? {};
22!
210
      const dataIsInvalid = !uid || !name || typeof name != 'string' || typeof uid != 'string';
22✔
211
      if (dataIsInvalid || !citationIds.has(uid)) {
22✔
212
        return false;
11✔
213
      }
214
      citationIds.delete(uid);
11✔
215
      return true;
11✔
216
    });
217
  }, [gdaResponse.citations, searchResults]);
218

219
  const count = citationResults.length;
7✔
220
  if (!count) {
7✔
221
    return null;
1✔
222
  }
223

224
  return <>
6✔
225
    <div className={cssClasses.divider} />
226
    <div className={cssClasses.header}>
227
      {citationsHeader ?? t('sources', { count })}
11✔
228
    </div>
229
    <div className={cssClasses.citationsContainer}>
230
      {citationResults.map((r, i) => <CitationCard key={i} searchResult={r} cssClasses={cssClasses} citationClickHandler={citationClickHandler}/>)}
11✔
231
    </div>
232
  </>;
233
}
234

235
/**
236
 * Props for citation card.
237
 *
238
 * @public
239
 */
240
export interface CitationProps {
241
  searchResult: Result,
242
  cssClasses: GenerativeDirectAnswerCssClasses,
243
  citationClickHandler?: (data: GdaClickEventData) => void
244
}
245

246
/**
247
 * Displays a citation card for the citations section of the Generative Direct Answer.
248
 */
249
function Citation(props: CitationProps) {
250
  const {
251
    searchResult,
252
    cssClasses,
253
    citationClickHandler
254
  } = props;
11✔
255
  const {name, description, answer, link} = searchResult.rawData ?? {};
11!
256
  const citationTitle = String(name ?? '');
11!
257
  const citationSnippet = String(description ?? answer ?? '');
11!
258
  const citationUrl = typeof link === 'string' ? link : undefined;
11✔
259
  return (
11✔
260
    <a
261
      className={cssClasses.citation}
262
      href={citationUrl}
263
      onClick={() => citationUrl && citationClickHandler?.({searchResult, destinationUrl: citationUrl})}
1✔
264
    >
265
      <div className={cssClasses.citationTitle}>{citationTitle}</div>
266
      <div className={cssClasses.citationSnippet}>{citationSnippet}</div>
267
    </a>
268
  );
269
}
270

271
/**
272
 * Payload for click events fired on a generative direct answer card.
273
 *
274
 * @public
275
 */
276
export interface GdaClickEventData {
277
  searchResult?: Result,
278
  destinationUrl: string
279
}
280

281
function useReportClickEvent<T = DefaultRawDataType>(): (data: GdaClickEventData) => void {
282
  const reportAnalyticsEvent = useCardAnalytics<T>();
8✔
283
  return React.useCallback((data: GdaClickEventData) => {
8✔
284
    if (data.searchResult) {
2✔
285
      reportAnalyticsEvent(data, 'CITATION_CLICK');
1✔
286
    } else {
287
      reportAnalyticsEvent(data, 'CTA_CLICK');
1✔
288
    }
289
  }, [reportAnalyticsEvent]);
290
}
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