genericWidgetQueries.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. import {Component} from 'react';
  2. import isEqual from 'lodash/isEqual';
  3. import {Client, ResponseMeta} from 'sentry/api';
  4. import {isSelectionEqual} from 'sentry/components/organizations/pageFilters/utils';
  5. import {t} from 'sentry/locale';
  6. import {Organization, PageFilters} from 'sentry/types';
  7. import {Series} from 'sentry/types/echarts';
  8. import {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
  9. import {DatasetConfig} from '../datasetConfig/base';
  10. import {DEFAULT_TABLE_LIMIT, DisplayType, Widget, WidgetQuery} from '../types';
  11. function getReferrer(displayType: DisplayType) {
  12. let referrer: string = '';
  13. if (displayType === DisplayType.TABLE) {
  14. referrer = 'api.dashboards.tablewidget';
  15. } else if (displayType === DisplayType.BIG_NUMBER) {
  16. referrer = 'api.dashboards.bignumberwidget';
  17. } else if (displayType === DisplayType.WORLD_MAP) {
  18. referrer = 'api.dashboards.worldmapwidget';
  19. } else {
  20. referrer = `api.dashboards.widget.${displayType}-chart`;
  21. }
  22. return referrer;
  23. }
  24. export type OnDataFetchedProps = {
  25. pageLinks?: string;
  26. tableResults?: TableDataWithTitle[];
  27. timeseriesResults?: Series[];
  28. totalIssuesCount?: string;
  29. };
  30. export type GenericWidgetQueriesChildrenProps = {
  31. loading: boolean;
  32. errorMessage?: string;
  33. pageLinks?: string;
  34. tableResults?: TableDataWithTitle[];
  35. timeseriesResults?: Series[];
  36. totalCount?: string;
  37. };
  38. export type GenericWidgetQueriesProps<SeriesResponse, TableResponse> = {
  39. api: Client;
  40. children: (props: GenericWidgetQueriesChildrenProps) => JSX.Element;
  41. config: DatasetConfig<SeriesResponse, TableResponse>;
  42. organization: Organization;
  43. selection: PageFilters;
  44. widget: Widget;
  45. afterFetchSeriesData?: (result: SeriesResponse) => void;
  46. afterFetchTableData?: (
  47. result: TableResponse,
  48. response?: ResponseMeta
  49. ) => void | {totalIssuesCount?: string};
  50. cursor?: string;
  51. customDidUpdateComparator?: (
  52. prevProps: GenericWidgetQueriesProps<SeriesResponse, TableResponse>,
  53. nextProps: GenericWidgetQueriesProps<SeriesResponse, TableResponse>
  54. ) => boolean;
  55. limit?: number;
  56. loading?: boolean;
  57. onDataFetched?: ({
  58. tableResults,
  59. timeseriesResults,
  60. totalIssuesCount,
  61. pageLinks,
  62. }: OnDataFetchedProps) => void;
  63. };
  64. type State<SeriesResponse> = {
  65. loading: boolean;
  66. errorMessage?: GenericWidgetQueriesChildrenProps['errorMessage'];
  67. pageLinks?: GenericWidgetQueriesChildrenProps['pageLinks'];
  68. queryFetchID?: symbol;
  69. rawResults?: SeriesResponse[];
  70. tableResults?: GenericWidgetQueriesChildrenProps['tableResults'];
  71. timeseriesResults?: GenericWidgetQueriesChildrenProps['timeseriesResults'];
  72. };
  73. class GenericWidgetQueries<SeriesResponse, TableResponse> extends Component<
  74. GenericWidgetQueriesProps<SeriesResponse, TableResponse>,
  75. State<SeriesResponse>
  76. > {
  77. state: State<SeriesResponse> = {
  78. loading: true,
  79. queryFetchID: undefined,
  80. errorMessage: undefined,
  81. timeseriesResults: undefined,
  82. rawResults: undefined,
  83. tableResults: undefined,
  84. pageLinks: undefined,
  85. };
  86. componentDidMount() {
  87. this._isMounted = true;
  88. if (!this.props.loading) {
  89. this.fetchData();
  90. }
  91. }
  92. componentDidUpdate(
  93. prevProps: GenericWidgetQueriesProps<SeriesResponse, TableResponse>
  94. ) {
  95. const {selection, widget, cursor, organization, config, customDidUpdateComparator} =
  96. this.props;
  97. // We do not fetch data whenever the query name changes.
  98. // Also don't count empty fields when checking for field changes
  99. const [prevWidgetQueryNames, prevWidgetQueries] = prevProps.widget.queries
  100. .map((query: WidgetQuery) => {
  101. query.aggregates = query.aggregates.filter(field => !!field);
  102. query.columns = query.columns.filter(field => !!field);
  103. return query;
  104. })
  105. .reduce(
  106. ([names, queries]: [string[], Omit<WidgetQuery, 'name'>[]], {name, ...rest}) => {
  107. names.push(name);
  108. queries.push(rest);
  109. return [names, queries];
  110. },
  111. [[], []]
  112. );
  113. const [widgetQueryNames, widgetQueries] = widget.queries
  114. .map((query: WidgetQuery) => {
  115. query.aggregates = query.aggregates.filter(
  116. field => !!field && field !== 'equation|'
  117. );
  118. query.columns = query.columns.filter(field => !!field && field !== 'equation|');
  119. return query;
  120. })
  121. .reduce(
  122. ([names, queries]: [string[], Omit<WidgetQuery, 'name'>[]], {name, ...rest}) => {
  123. names.push(name);
  124. queries.push(rest);
  125. return [names, queries];
  126. },
  127. [[], []]
  128. );
  129. if (
  130. customDidUpdateComparator
  131. ? customDidUpdateComparator(prevProps, this.props)
  132. : widget.limit !== prevProps.widget.limit ||
  133. !isEqual(widget.displayType, prevProps.widget.displayType) ||
  134. !isEqual(widget.interval, prevProps.widget.interval) ||
  135. !isEqual(widgetQueries, prevWidgetQueries) ||
  136. !isSelectionEqual(selection, prevProps.selection) ||
  137. cursor !== prevProps.cursor
  138. ) {
  139. this.fetchData();
  140. return;
  141. }
  142. if (
  143. !this.state.loading &&
  144. !isEqual(prevWidgetQueryNames, widgetQueryNames) &&
  145. this.state.rawResults?.length === widget.queries.length
  146. ) {
  147. // If the query names has changed, then update timeseries labels
  148. // eslint-disable-next-line react/no-did-update-set-state
  149. this.setState(prevState => {
  150. const timeseriesResults = widget.queries.reduce((acc: Series[], query, index) => {
  151. return acc.concat(
  152. config.transformSeries!(prevState.rawResults![index], query, organization)
  153. );
  154. }, []);
  155. return {...prevState, timeseriesResults};
  156. });
  157. }
  158. }
  159. componentWillUnmount() {
  160. this._isMounted = false;
  161. }
  162. private _isMounted: boolean = false;
  163. async fetchTableData(queryFetchID: symbol) {
  164. const {
  165. widget,
  166. limit,
  167. config,
  168. api,
  169. organization,
  170. selection,
  171. cursor,
  172. afterFetchTableData,
  173. onDataFetched,
  174. } = this.props;
  175. const responses = await Promise.all(
  176. widget.queries.map(query => {
  177. let requestLimit: number | undefined = limit ?? DEFAULT_TABLE_LIMIT;
  178. let requestCreator = config.getTableRequest;
  179. if (widget.displayType === DisplayType.WORLD_MAP) {
  180. requestLimit = undefined;
  181. requestCreator = config.getWorldMapRequest;
  182. }
  183. if (!requestCreator) {
  184. throw new Error(
  185. t('This display type is not supported by the selected dataset.')
  186. );
  187. }
  188. return requestCreator(
  189. api,
  190. query,
  191. organization,
  192. selection,
  193. requestLimit,
  194. cursor,
  195. getReferrer(widget.displayType)
  196. );
  197. })
  198. );
  199. let transformedTableResults: TableDataWithTitle[] = [];
  200. let responsePageLinks: string | undefined;
  201. let afterTableFetchData: OnDataFetchedProps | undefined;
  202. responses.forEach(([data, _textstatus, resp], i) => {
  203. afterTableFetchData = afterFetchTableData?.(data, resp) ?? {};
  204. // Cast so we can add the title.
  205. const transformedData = config.transformTable(
  206. data,
  207. widget.queries[0],
  208. organization,
  209. selection
  210. ) as TableDataWithTitle;
  211. transformedData.title = widget.queries[i]?.name ?? '';
  212. // Overwrite the local var to work around state being stale in tests.
  213. transformedTableResults = [...transformedTableResults, transformedData];
  214. // There is some inconsistency with the capitalization of "link" in response headers
  215. responsePageLinks =
  216. (resp?.getResponseHeader('Link') || resp?.getResponseHeader('link')) ?? undefined;
  217. });
  218. if (this._isMounted && this.state.queryFetchID === queryFetchID) {
  219. onDataFetched?.({
  220. tableResults: transformedTableResults,
  221. pageLinks: responsePageLinks,
  222. ...afterTableFetchData,
  223. });
  224. this.setState({
  225. tableResults: transformedTableResults,
  226. pageLinks: responsePageLinks,
  227. });
  228. }
  229. }
  230. async fetchSeriesData(queryFetchID: symbol) {
  231. const {
  232. widget,
  233. config,
  234. api,
  235. organization,
  236. selection,
  237. afterFetchSeriesData,
  238. onDataFetched,
  239. } = this.props;
  240. const responses = await Promise.all(
  241. widget.queries.map((_query, index) => {
  242. return config.getSeriesRequest!(
  243. api,
  244. widget,
  245. index,
  246. organization,
  247. selection,
  248. getReferrer(widget.displayType)
  249. );
  250. })
  251. );
  252. const transformedTimeseriesResults: Series[] = [];
  253. responses.forEach(([data], requestIndex) => {
  254. afterFetchSeriesData?.(data);
  255. const transformedResult = config.transformSeries!(
  256. data,
  257. widget.queries[requestIndex],
  258. organization
  259. );
  260. // When charting timeseriesData on echarts, color association to a timeseries result
  261. // is order sensitive, ie series at index i on the timeseries array will use color at
  262. // index i on the color array. This means that on multi series results, we need to make
  263. // sure that the order of series in our results do not change between fetches to avoid
  264. // coloring inconsistencies between renders.
  265. transformedResult.forEach((result, resultIndex) => {
  266. transformedTimeseriesResults[
  267. requestIndex * transformedResult.length + resultIndex
  268. ] = result;
  269. });
  270. });
  271. if (this._isMounted && this.state.queryFetchID === queryFetchID) {
  272. onDataFetched?.({timeseriesResults: transformedTimeseriesResults});
  273. this.setState({timeseriesResults: transformedTimeseriesResults});
  274. }
  275. }
  276. async fetchData() {
  277. const {widget} = this.props;
  278. const queryFetchID = Symbol('queryFetchID');
  279. this.setState({
  280. loading: true,
  281. tableResults: undefined,
  282. timeseriesResults: undefined,
  283. errorMessage: undefined,
  284. queryFetchID,
  285. });
  286. try {
  287. if (
  288. [DisplayType.TABLE, DisplayType.BIG_NUMBER, DisplayType.WORLD_MAP].includes(
  289. widget.displayType
  290. )
  291. ) {
  292. await this.fetchTableData(queryFetchID);
  293. } else {
  294. await this.fetchSeriesData(queryFetchID);
  295. }
  296. } catch (err) {
  297. if (this._isMounted) {
  298. this.setState({
  299. errorMessage:
  300. err?.responseJSON?.detail || err?.message || t('An unknown error occurred.'),
  301. });
  302. }
  303. } finally {
  304. if (this._isMounted) {
  305. this.setState({loading: false});
  306. }
  307. }
  308. }
  309. render() {
  310. const {children} = this.props;
  311. const {loading, tableResults, timeseriesResults, errorMessage, pageLinks} =
  312. this.state;
  313. return children({loading, tableResults, timeseriesResults, errorMessage, pageLinks});
  314. }
  315. }
  316. export default GenericWidgetQueries;