genericDiscoverQuery.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import {Component, useContext} from 'react';
  2. import {Location} from 'history';
  3. import {EventQuery} from 'sentry/actionCreators/events';
  4. import {Client, ResponseMeta} from 'sentry/api';
  5. import {t} from 'sentry/locale';
  6. import EventView, {
  7. ImmutableEventView,
  8. isAPIPayloadSimilar,
  9. LocationQuery,
  10. } from 'sentry/utils/discover/eventView';
  11. import {QueryBatching} from 'sentry/utils/performance/contexts/genericQueryBatcher';
  12. import {PerformanceEventViewContext} from 'sentry/utils/performance/contexts/performanceEventViewContext';
  13. import {OrganizationContext} from 'sentry/views/organizationContext';
  14. export class QueryError {
  15. message: string;
  16. private originalError: any; // For debugging in case parseError picks a value that doesn't make sense.
  17. constructor(errorMessage: string, originalError?: any) {
  18. this.message = errorMessage;
  19. this.originalError = originalError;
  20. }
  21. getOriginalError() {
  22. return this.originalError;
  23. }
  24. }
  25. export type GenericChildrenProps<T> = {
  26. /**
  27. * Error, if not null.
  28. */
  29. error: null | QueryError;
  30. /**
  31. * Loading state of this query.
  32. */
  33. isLoading: boolean;
  34. /**
  35. * Pagelinks, if applicable. Can be provided to the Pagination component.
  36. */
  37. pageLinks: null | string;
  38. /**
  39. * Data / result.
  40. */
  41. tableData: T | null;
  42. };
  43. type OptionalContextProps = {
  44. eventView?: EventView | ImmutableEventView;
  45. orgSlug?: string;
  46. };
  47. type BaseDiscoverQueryProps = {
  48. api: Client;
  49. /**
  50. * Used as the default source for cursor values.
  51. */
  52. location: Location;
  53. /**
  54. * Explicit cursor value if you aren't using `location.query.cursor` because there are
  55. * multiple paginated results on the page.
  56. */
  57. cursor?: string;
  58. /**
  59. * Appends a raw string to query to be able to sidestep the tokenizer.
  60. * @deprecated
  61. */
  62. forceAppendRawQueryString?: string;
  63. /**
  64. * Record limit to get.
  65. */
  66. limit?: number;
  67. /**
  68. * Include this whenever pagination won't be used. Limit can still be used when this is
  69. * passed, but cursor will be ignored.
  70. */
  71. noPagination?: boolean;
  72. /**
  73. * A container for query batching data and functions.
  74. */
  75. queryBatching?: QueryBatching;
  76. /**
  77. * Extra query parameters to be added.
  78. */
  79. queryExtras?: Record<string, string>;
  80. /**
  81. * Sets referrer parameter in the API Payload. Set of allowed referrers are defined
  82. * on the OrganizationEventsV2Endpoint view.
  83. */
  84. referrer?: string;
  85. /**
  86. * A callback to set an error so that the error can be rendered in parent components
  87. */
  88. setError?: (errObject: QueryError | undefined) => void;
  89. };
  90. export type DiscoverQueryPropsWithContext = BaseDiscoverQueryProps & OptionalContextProps;
  91. export type DiscoverQueryProps = BaseDiscoverQueryProps & {
  92. eventView: EventView | ImmutableEventView;
  93. orgSlug: string;
  94. };
  95. type InnerRequestProps<P> = DiscoverQueryProps & P;
  96. type OuterRequestProps<P> = DiscoverQueryPropsWithContext & P;
  97. export type ReactProps<T> = {
  98. children?: (props: GenericChildrenProps<T>) => React.ReactNode;
  99. };
  100. type ComponentProps<T, P> = {
  101. /**
  102. * Route to the endpoint
  103. */
  104. route: string;
  105. /**
  106. * A hook to modify data into the correct output after data has been received
  107. */
  108. afterFetch?: (data: any, props?: Props<T, P>) => T;
  109. /**
  110. * A hook before fetch that can be used to do things like clearing the api
  111. */
  112. beforeFetch?: (api: Client) => void;
  113. /**
  114. * A hook for parent orchestrators to pass down data based on query results, unlike afterFetch it is not meant for specializations as it will not modify data.
  115. */
  116. didFetch?: (data: T) => void;
  117. /**
  118. * Allows components to modify the payload before it is set.
  119. */
  120. getRequestPayload?: (props: Props<T, P>) => any;
  121. /**
  122. * An external hook to parse errors in case there are differences for a specific api.
  123. */
  124. parseError?: (error: any) => QueryError | null;
  125. /**
  126. * An external hook in addition to the event view check to check if data should be refetched
  127. */
  128. shouldRefetchData?: (prevProps: Props<T, P>, props: Props<T, P>) => boolean;
  129. };
  130. type Props<T, P> = InnerRequestProps<P> & ReactProps<T> & ComponentProps<T, P>;
  131. type OuterProps<T, P> = OuterRequestProps<P> & ReactProps<T> & ComponentProps<T, P>;
  132. type State<T> = {
  133. tableFetchID: symbol | undefined;
  134. } & GenericChildrenProps<T>;
  135. /**
  136. * Generic component for discover queries
  137. */
  138. class _GenericDiscoverQuery<T, P> extends Component<Props<T, P>, State<T>> {
  139. state: State<T> = {
  140. isLoading: true,
  141. tableFetchID: undefined,
  142. error: null,
  143. tableData: null,
  144. pageLinks: null,
  145. };
  146. componentDidMount() {
  147. this.fetchData();
  148. }
  149. componentDidUpdate(prevProps: Props<T, P>) {
  150. // Reload data if the payload changes
  151. const refetchCondition = this._shouldRefetchData(prevProps);
  152. // or if we've moved from an invalid view state to a valid one,
  153. const eventViewValidation =
  154. prevProps.eventView.isValid() === false && this.props.eventView.isValid();
  155. const shouldRefetchExternal = this.props.shouldRefetchData
  156. ? this.props.shouldRefetchData(prevProps, this.props)
  157. : false;
  158. if (refetchCondition || eventViewValidation || shouldRefetchExternal) {
  159. this.fetchData();
  160. }
  161. }
  162. getPayload(props: Props<T, P>) {
  163. const {cursor, limit, noPagination, referrer} = props;
  164. const payload = this.props.getRequestPayload
  165. ? this.props.getRequestPayload(props)
  166. : props.eventView.getEventsAPIPayload(
  167. props.location,
  168. props.forceAppendRawQueryString
  169. );
  170. if (cursor) {
  171. payload.cursor = cursor;
  172. }
  173. if (limit) {
  174. payload.per_page = limit;
  175. }
  176. if (noPagination) {
  177. payload.noPagination = noPagination;
  178. }
  179. if (referrer) {
  180. payload.referrer = referrer;
  181. }
  182. Object.assign(payload, props.queryExtras ?? {});
  183. return payload;
  184. }
  185. _shouldRefetchData = (prevProps: Props<T, P>): boolean => {
  186. const thisAPIPayload = this.getPayload(this.props);
  187. const otherAPIPayload = this.getPayload(prevProps);
  188. return (
  189. !isAPIPayloadSimilar(thisAPIPayload, otherAPIPayload) ||
  190. prevProps.limit !== this.props.limit ||
  191. prevProps.route !== this.props.route ||
  192. prevProps.cursor !== this.props.cursor
  193. );
  194. };
  195. /**
  196. * The error type isn't consistent across APIs. We see detail as just string some times, other times as an object.
  197. */
  198. _parseError = (error: any): QueryError | null => {
  199. if (this.props.parseError) {
  200. return this.props.parseError(error);
  201. }
  202. if (!error) {
  203. return null;
  204. }
  205. const detail = error.responseJSON?.detail;
  206. if (typeof detail === 'string') {
  207. return new QueryError(detail, error);
  208. }
  209. const message = detail?.message;
  210. if (typeof message === 'string') {
  211. return new QueryError(message, error);
  212. }
  213. const unknownError = new QueryError(t('An unknown error occurred.'), error);
  214. return unknownError;
  215. };
  216. fetchData = async () => {
  217. const {
  218. api,
  219. queryBatching,
  220. beforeFetch,
  221. afterFetch,
  222. didFetch,
  223. eventView,
  224. orgSlug,
  225. route,
  226. setError,
  227. } = this.props;
  228. if (!eventView.isValid()) {
  229. return;
  230. }
  231. const url = `/organizations/${orgSlug}/${route}/`;
  232. const tableFetchID = Symbol(`tableFetchID`);
  233. const apiPayload: Partial<EventQuery & LocationQuery> = this.getPayload(this.props);
  234. this.setState({isLoading: true, tableFetchID});
  235. setError?.(undefined);
  236. beforeFetch?.(api);
  237. // clear any inflight requests since they are now stale
  238. api.clear();
  239. try {
  240. const [data, , resp] = await doDiscoverQuery<T>(
  241. api,
  242. url,
  243. apiPayload,
  244. queryBatching
  245. );
  246. if (this.state.tableFetchID !== tableFetchID) {
  247. // invariant: a different request was initiated after this request
  248. return;
  249. }
  250. const tableData = afterFetch ? afterFetch(data, this.props) : data;
  251. didFetch?.(tableData);
  252. this.setState(prevState => ({
  253. isLoading: false,
  254. tableFetchID: undefined,
  255. error: null,
  256. pageLinks: resp?.getResponseHeader('Link') ?? prevState.pageLinks,
  257. tableData,
  258. }));
  259. } catch (err) {
  260. const error = this._parseError(err);
  261. this.setState({
  262. isLoading: false,
  263. tableFetchID: undefined,
  264. error,
  265. tableData: null,
  266. });
  267. if (setError) {
  268. setError(error ?? undefined);
  269. }
  270. }
  271. };
  272. render() {
  273. const {isLoading, error, tableData, pageLinks} = this.state;
  274. const childrenProps: GenericChildrenProps<T> = {
  275. isLoading,
  276. error,
  277. tableData,
  278. pageLinks,
  279. };
  280. const children: ReactProps<T>['children'] = this.props.children; // Explicitly setting type due to issues with generics and React's children
  281. return children?.(childrenProps);
  282. }
  283. }
  284. // Shim to allow us to use generic discover query or any specialization with or without passing org slug or eventview, which are now contexts.
  285. // This will help keep tests working and we can remove extra uses of context-provided props and update tests as we go.
  286. export function GenericDiscoverQuery<T, P>(props: OuterProps<T, P>) {
  287. const organizationSlug = useContext(OrganizationContext)?.slug;
  288. const performanceEventView = useContext(PerformanceEventViewContext)?.eventView;
  289. const orgSlug = props.orgSlug ?? organizationSlug;
  290. const eventView = props.eventView ?? performanceEventView;
  291. if (orgSlug === undefined || eventView === undefined) {
  292. throw new Error('GenericDiscoverQuery requires both an orgSlug and eventView');
  293. }
  294. const _props: Props<T, P> = {
  295. ...props,
  296. orgSlug,
  297. eventView,
  298. };
  299. return <_GenericDiscoverQuery<T, P> {..._props} />;
  300. }
  301. export type DiscoverQueryRequestParams = Partial<EventQuery & LocationQuery>;
  302. export function doDiscoverQuery<T>(
  303. api: Client,
  304. url: string,
  305. params: DiscoverQueryRequestParams,
  306. queryBatching?: QueryBatching
  307. ): Promise<[T, string | undefined, ResponseMeta<T> | undefined]> {
  308. if (queryBatching?.batchRequest) {
  309. return queryBatching.batchRequest(api, url, {
  310. query: params,
  311. includeAllArgs: true,
  312. });
  313. }
  314. return api.requestPromise(url, {
  315. method: 'GET',
  316. includeAllArgs: true,
  317. query: {
  318. // marking params as any so as to not cause typescript errors
  319. ...(params as any),
  320. },
  321. });
  322. }
  323. export default GenericDiscoverQuery;