genericDiscoverQuery.tsx 9.5 KB

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