Browse Source

ref(dashboards): Generic widget queries for releases (#36258)

Nar Saynorath 2 years ago
parent
commit
e7d9aad491

+ 11 - 5
static/app/actionCreators/events.tsx

@@ -1,7 +1,7 @@
 import {LocationDescriptor} from 'history';
 import pick from 'lodash/pick';
 
-import {Client} from 'sentry/api';
+import {Client, ResponseMeta} from 'sentry/api';
 import {canIncludePreviousPeriod} from 'sentry/components/charts/utils';
 import {
   DateString,
@@ -60,7 +60,7 @@ type Options = {
  * @param {Record<string, string>} options.queryExtras A list of extra query parameters
  * @param {(org: OrganizationSummary) => string} options.generatePathname A function that returns an override for the pathname
  */
-export const doEventsRequest = (
+export const doEventsRequest = <IncludeAllArgsType extends boolean = false>(
   api: Client,
   {
     organization,
@@ -85,8 +85,13 @@ export const doEventsRequest = (
     generatePathname,
     queryExtras,
     excludeOther,
-  }: Options
-): Promise<EventsStats | MultiSeriesEventsStats> => {
+    includeAllArgs,
+  }: {includeAllArgs?: IncludeAllArgsType} & Options
+): IncludeAllArgsType extends true
+  ? Promise<
+      [EventsStats | MultiSeriesEventsStats, string | undefined, ResponseMeta | undefined]
+    >
+  : Promise<EventsStats | MultiSeriesEventsStats> => {
   const pathname =
     generatePathname?.(organization) ??
     `/organizations/${organization.slug}/events-stats/`;
@@ -117,6 +122,7 @@ export const doEventsRequest = (
   const periodObj = getPeriod({period, start, end}, {shouldDoublePeriod});
 
   const queryObject = {
+    includeAllArgs,
     query: {
       ...urlQuery,
       ...periodObj,
@@ -128,7 +134,7 @@ export const doEventsRequest = (
     return queryBatching.batchRequest(api, pathname, queryObject);
   }
 
-  return api.requestPromise(pathname, queryObject);
+  return api.requestPromise<IncludeAllArgsType>(pathname, queryObject);
 };
 
 export type EventQuery = {

+ 3 - 3
static/app/views/dashboardsV2/datasetConfig/base.tsx

@@ -1,6 +1,6 @@
 import trimStart from 'lodash/trimStart';
 
-import {Client} from 'sentry/api';
+import {Client, ResponseMeta} from 'sentry/api';
 import {SearchBarProps} from 'sentry/components/events/searchBar';
 import {Organization, PageFilters, SelectValue, TagCollection} from 'sentry/types';
 import {Series} from 'sentry/types/echarts';
@@ -130,7 +130,7 @@ export interface DatasetConfig<SeriesResponse, TableResponse> {
     organization: Organization,
     pageFilters: PageFilters,
     referrer?: string
-  ) => ReturnType<Client['requestPromise']>;
+  ) => Promise<[SeriesResponse, string | undefined, ResponseMeta | undefined]>;
   /**
    * Generate the request promises for fetching
    * tabular data.
@@ -143,7 +143,7 @@ export interface DatasetConfig<SeriesResponse, TableResponse> {
     limit?: number,
     cursor?: string,
     referrer?: string
-  ) => ReturnType<Client['requestPromise']>;
+  ) => Promise<[TableResponse, string | undefined, ResponseMeta | undefined]>;
   /**
    * Generate the list of sort options for table
    * displays on the 'Sort by' step of the Widget Builder.

+ 3 - 1
static/app/views/dashboardsV2/datasetConfig/errorsAndTransactions.tsx

@@ -489,6 +489,7 @@ function getEventsSeriesRequest(
       partial: true,
       field: [...widgetQuery.columns, ...widgetQuery.aggregates],
       queryExtras: getDashboardsMEPQueryParams(isMEPEnabled),
+      includeAllArgs: true,
     };
     if (widgetQuery.orderby) {
       requestData.orderby = widgetQuery.orderby;
@@ -509,6 +510,7 @@ function getEventsSeriesRequest(
       referrer,
       partial: true,
       queryExtras: getDashboardsMEPQueryParams(isMEPEnabled),
+      includeAllArgs: true,
     };
     if (widgetQuery.columns?.length !== 0) {
       requestData.topEvents = limit ?? TOP_N;
@@ -547,5 +549,5 @@ function getEventsSeriesRequest(
     }
   }
 
-  return doEventsRequest(api, requestData);
+  return doEventsRequest<true>(api, requestData);
 }

+ 31 - 17
static/app/views/dashboardsV2/widgetCard/genericWidgetQueries.tsx

@@ -37,7 +37,7 @@ export type OnDataFetchedProps = {
 export type GenericWidgetQueriesChildrenProps = {
   loading: boolean;
   errorMessage?: string;
-  pageLinks?: null | string;
+  pageLinks?: string;
   tableResults?: TableDataWithTitle[];
   timeseriesResults?: Series[];
   totalCount?: string;
@@ -56,7 +56,12 @@ export type GenericWidgetQueriesProps<SeriesResponse, TableResponse> = {
     response?: ResponseMeta
   ) => void | {totalIssuesCount?: string};
   cursor?: string;
+  customDidUpdateComparator?: (
+    prevProps: GenericWidgetQueriesProps<SeriesResponse, TableResponse>,
+    nextProps: GenericWidgetQueriesProps<SeriesResponse, TableResponse>
+  ) => boolean;
   limit?: number;
+  loading?: boolean;
   onDataFetched?: ({
     tableResults,
     timeseriesResults,
@@ -91,13 +96,16 @@ class GenericWidgetQueries<SeriesResponse, TableResponse> extends Component<
 
   componentDidMount() {
     this._isMounted = true;
-    this.fetchData();
+    if (!this.props.loading) {
+      this.fetchData();
+    }
   }
 
   componentDidUpdate(
     prevProps: GenericWidgetQueriesProps<SeriesResponse, TableResponse>
   ) {
-    const {selection, widget, cursor, organization, config} = this.props;
+    const {selection, widget, cursor, organization, config, customDidUpdateComparator} =
+      this.props;
 
     // We do not fetch data whenever the query name changes.
     // Also don't count empty fields when checking for field changes
@@ -134,12 +142,14 @@ class GenericWidgetQueries<SeriesResponse, TableResponse> extends Component<
       );
 
     if (
-      widget.limit !== prevProps.widget.limit ||
-      !isEqual(widget.displayType, prevProps.widget.displayType) ||
-      !isEqual(widget.interval, prevProps.widget.interval) ||
-      !isEqual(widgetQueries, prevWidgetQueries) ||
-      !isSelectionEqual(selection, prevProps.selection) ||
-      cursor !== prevProps.cursor
+      customDidUpdateComparator
+        ? customDidUpdateComparator(prevProps, this.props)
+        : widget.limit !== prevProps.widget.limit ||
+          !isEqual(widget.displayType, prevProps.widget.displayType) ||
+          !isEqual(widget.interval, prevProps.widget.interval) ||
+          !isEqual(widgetQueries, prevWidgetQueries) ||
+          !isSelectionEqual(selection, prevProps.selection) ||
+          cursor !== prevProps.cursor
     ) {
       this.fetchData();
       return;
@@ -183,7 +193,7 @@ class GenericWidgetQueries<SeriesResponse, TableResponse> extends Component<
       afterFetchTableData,
       onDataFetched,
     } = this.props;
-    const responses = await Promise.all<[TableResponse, string, ResponseMeta]>(
+    const responses = await Promise.all(
       widget.queries.map(query => {
         let requestLimit: number | undefined = limit ?? DEFAULT_TABLE_LIMIT;
         let requestCreator = config.getTableRequest;
@@ -211,7 +221,7 @@ class GenericWidgetQueries<SeriesResponse, TableResponse> extends Component<
     );
 
     let transformedTableResults: TableDataWithTitle[] = [];
-    let responsePageLinks: string | null = null;
+    let responsePageLinks: string | undefined;
     let afterTableFetchData: OnDataFetchedProps | undefined;
     responses.forEach(([data, _textstatus, resp], i) => {
       afterTableFetchData = afterFetchTableData?.(data, resp) ?? {};
@@ -226,13 +236,16 @@ class GenericWidgetQueries<SeriesResponse, TableResponse> extends Component<
 
       // Overwrite the local var to work around state being stale in tests.
       transformedTableResults = [...transformedTableResults, transformedData];
-      responsePageLinks = resp?.getResponseHeader('Link');
+
+      // There is some inconsistency with the capitalization of "link" in response headers
+      responsePageLinks =
+        (resp?.getResponseHeader('Link') || resp?.getResponseHeader('link')) ?? undefined;
     });
 
     if (this._isMounted && this.state.queryFetchID === queryFetchID) {
       onDataFetched?.({
         tableResults: transformedTableResults,
-        pageLinks: responsePageLinks ?? undefined,
+        pageLinks: responsePageLinks,
         ...afterTableFetchData,
       });
       this.setState({
@@ -252,7 +265,7 @@ class GenericWidgetQueries<SeriesResponse, TableResponse> extends Component<
       afterFetchSeriesData,
       onDataFetched,
     } = this.props;
-    const responses = await Promise.all<SeriesResponse>(
+    const responses = await Promise.all(
       widget.queries.map((_query, index) => {
         return config.getSeriesRequest!(
           api,
@@ -265,10 +278,10 @@ class GenericWidgetQueries<SeriesResponse, TableResponse> extends Component<
       })
     );
     const transformedTimeseriesResults: Series[] = [];
-    responses.forEach((rawResults, requestIndex) => {
-      afterFetchSeriesData?.(rawResults);
+    responses.forEach(([data], requestIndex) => {
+      afterFetchSeriesData?.(data);
       const transformedResult = config.transformSeries!(
-        rawResults,
+        data,
         widget.queries[requestIndex],
         organization
       );
@@ -298,6 +311,7 @@ class GenericWidgetQueries<SeriesResponse, TableResponse> extends Component<
       loading: true,
       tableResults: undefined,
       timeseriesResults: undefined,
+      errorMessage: undefined,
       queryFetchID,
     });
 

+ 146 - 275
static/app/views/dashboardsV2/widgetCard/releaseWidgetQueries.tsx

@@ -5,7 +5,7 @@ import omit from 'lodash/omit';
 import trimStart from 'lodash/trimStart';
 
 import {addErrorMessage} from 'sentry/actionCreators/indicator';
-import {Client, ResponseMeta} from 'sentry/api';
+import {Client} from 'sentry/api';
 import {isSelectionEqual} from 'sentry/components/organizations/pageFilters/utils';
 import {t} from 'sentry/locale';
 import {
@@ -20,14 +20,8 @@ import {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
 import {stripDerivedMetricsPrefix} from 'sentry/utils/discover/fields';
 import {TOP_N} from 'sentry/utils/discover/types';
 
-import {getDatasetConfig} from '../datasetConfig/base';
-import {
-  DEFAULT_TABLE_LIMIT,
-  DisplayType,
-  Widget,
-  WidgetQuery,
-  WidgetType,
-} from '../types';
+import {ReleasesConfig} from '../datasetConfig/releases';
+import {DEFAULT_TABLE_LIMIT, DisplayType, Widget, WidgetQuery} from '../types';
 import {
   DERIVED_STATUS_METRICS_PATTERN,
   DerivedStatusFields,
@@ -35,14 +29,14 @@ import {
   METRICS_EXPRESSION_TO_FIELD,
 } from '../widgetBuilder/releaseWidget/fields';
 
+import GenericWidgetQueries, {
+  GenericWidgetQueriesChildrenProps,
+  GenericWidgetQueriesProps,
+} from './genericWidgetQueries';
+
 type Props = {
   api: Client;
-  children: (
-    props: Pick<
-      State,
-      'loading' | 'timeseriesResults' | 'tableResults' | 'errorMessage' | 'pageLinks'
-    >
-  ) => React.ReactNode;
+  children: (props: GenericWidgetQueriesChildrenProps) => JSX.Element;
   organization: Organization;
   selection: PageFilters;
   widget: Widget;
@@ -57,12 +51,7 @@ type Props = {
 type State = {
   loading: boolean;
   errorMessage?: string;
-  pageLinks?: string;
-  queryFetchID?: symbol;
-  rawResults?: SessionApiResponse[] | MetricsApiResponse[];
   releases?: Release[];
-  tableResults?: TableDataWithTitle[];
-  timeseriesResults?: Series[];
 };
 
 export function derivedMetricsToField(field: string): string {
@@ -156,28 +145,87 @@ export function requiresCustomReleaseSorting(query: WidgetQuery): boolean {
 class ReleaseWidgetQueries extends Component<Props, State> {
   state: State = {
     loading: true,
-    queryFetchID: undefined,
     errorMessage: undefined,
-    timeseriesResults: undefined,
-    rawResults: undefined,
-    tableResults: undefined,
     releases: undefined,
   };
 
   componentDidMount() {
     this._isMounted = true;
-
     if (requiresCustomReleaseSorting(this.props.widget.queries[0])) {
-      this.fetchReleasesAndData();
+      this.fetchReleases();
       return;
     }
-    this.fetchData();
   }
 
-  componentDidUpdate(prevProps: Props) {
-    const {loading, rawResults} = this.state;
-    const {selection, widget, organization, limit, cursor} = this.props;
-    const ignroredWidgetProps = [
+  componentWillUnmount() {
+    this._isMounted = false;
+  }
+
+  config = ReleasesConfig;
+  private _isMounted: boolean = false;
+
+  fetchReleases = async () => {
+    this.setState({loading: true, errorMessage: undefined});
+    const {selection, api, organization} = this.props;
+    const {environments, projects} = selection;
+
+    try {
+      const releases = await api.requestPromise(
+        `/organizations/${organization.slug}/releases/`,
+        {
+          method: 'GET',
+          data: {
+            sort: 'date',
+            project: projects,
+            per_page: 50,
+            environments,
+          },
+        }
+      );
+      if (!this._isMounted) {
+        return;
+      }
+      this.setState({releases, loading: false});
+    } catch (error) {
+      if (!this._isMounted) {
+        return;
+      }
+
+      const message = error.responseJSON
+        ? error.responseJSON.error
+        : t('Error sorting by releases');
+      this.setState({errorMessage: message, loading: false});
+      addErrorMessage(message);
+    }
+  };
+
+  get limit() {
+    const {limit} = this.props;
+
+    switch (this.props.widget.displayType) {
+      case DisplayType.TOP_N:
+        return TOP_N;
+      case DisplayType.TABLE:
+        return limit ?? DEFAULT_TABLE_LIMIT;
+      case DisplayType.BIG_NUMBER:
+        return 1;
+      default:
+        return limit ?? 20; // TODO(dam): Can be changed to undefined once [INGEST-1079] is resolved
+    }
+  }
+
+  customDidUpdateComparator = (
+    prevProps: GenericWidgetQueriesProps<
+      SessionApiResponse | MetricsApiResponse,
+      SessionApiResponse | MetricsApiResponse
+    >,
+    nextProps: GenericWidgetQueriesProps<
+      SessionApiResponse | MetricsApiResponse,
+      SessionApiResponse | MetricsApiResponse
+    >
+  ) => {
+    const {loading, limit, widget, cursor, organization, selection} = nextProps;
+    const ignoredWidgetProps = [
       'queries',
       'title',
       'id',
@@ -186,30 +234,14 @@ class ReleaseWidgetQueries extends Component<Props, State> {
       'widgetType',
     ];
     const ignoredQueryProps = ['name', 'fields', 'aggregates', 'columns'];
-    const widgetQueryNames = widget.queries.map(q => q.name);
-    const prevWidgetQueryNames = prevProps.widget.queries.map(q => q.name);
-
-    if (
-      requiresCustomReleaseSorting(widget.queries[0]) &&
-      (!isEqual(
-        widget.queries.map(q => q.orderby),
-        prevProps.widget.queries.map(q => q.orderby)
-      ) ||
-        !isSelectionEqual(selection, prevProps.selection) ||
-        !isEqual(organization, prevProps.organization))
-    ) {
-      this.fetchReleasesAndData();
-      return;
-    }
-
-    if (
+    return (
       limit !== prevProps.limit ||
       organization.slug !== prevProps.organization.slug ||
       !isSelectionEqual(selection, prevProps.selection) ||
       // If the widget changed (ignore unimportant fields, + queries as they are handled lower)
       !isEqual(
-        omit(widget, ignroredWidgetProps),
-        omit(prevProps.widget, ignroredWidgetProps)
+        omit(widget, ignoredWidgetProps),
+        omit(prevProps.widget, ignoredWidgetProps)
       ) ||
       // If the queries changed (ignore unimportant name, + fields as they are handled lower)
       !isEqual(
@@ -231,114 +263,19 @@ class ReleaseWidgetQueries extends Component<Props, State> {
         widget.queries.flatMap(q => q.columns.filter(column => !!column)),
         prevProps.widget.queries.flatMap(q => q.columns.filter(column => !!column))
       ) ||
+      loading !== prevProps.loading ||
       cursor !== prevProps.cursor
-    ) {
-      this.fetchData();
-      return;
-    }
-    if (
-      !loading &&
-      !isEqual(widgetQueryNames, prevWidgetQueryNames) &&
-      rawResults?.length === widget.queries.length
-    ) {
-      // eslint-disable-next-line react/no-did-update-set-state
-      this.setState(prevState => {
-        return {
-          ...prevState,
-          timeseriesResults: prevState.rawResults?.flatMap((rawResult, index) =>
-            this.config.transformSeries!(rawResult, widget.queries[index], organization)
-          ),
-        };
-      });
-    }
-  }
-
-  componentWillUnmount() {
-    this._isMounted = false;
-  }
-
-  private _isMounted: boolean = false;
-  config = getDatasetConfig(WidgetType.RELEASE);
-
-  get limit() {
-    const {limit} = this.props;
-
-    switch (this.props.widget.displayType) {
-      case DisplayType.TOP_N:
-        return TOP_N;
-      case DisplayType.TABLE:
-        return limit ?? DEFAULT_TABLE_LIMIT;
-      case DisplayType.BIG_NUMBER:
-        return 1;
-      default:
-        return limit ?? 20; // TODO(dam): Can be changed to undefined once [INGEST-1079] is resolved
-    }
-  }
-
-  async fetchReleasesAndData() {
-    const {selection, api, organization} = this.props;
-    const {environments, projects} = selection;
-
-    try {
-      const releases = await api.requestPromise(
-        `/organizations/${organization.slug}/releases/`,
-        {
-          method: 'GET',
-          data: {
-            sort: 'date',
-            project: projects,
-            per_page: 50,
-            environments,
-          },
-        }
-      );
-      if (!this._isMounted) {
-        return;
-      }
-      this.setState({releases});
-    } catch (error) {
-      addErrorMessage(
-        error.responseJSON ? error.responseJSON.error : t('Error sorting by releases')
-      );
-    }
-    this.fetchData();
-  }
+    );
+  };
 
-  async fetchData() {
-    const {
-      selection,
-      api,
-      organization,
-      widget: initialWidget,
-      cursor,
-      onDataFetched,
-    } = this.props;
+  transformWidget = (initialWidget: Widget): Widget => {
     const {releases} = this.state;
-
-    // HACK: Cloning the widget because we're modifying the query conditions
-    // to support sorting by release
     const widget = cloneDeep(initialWidget);
 
     const isCustomReleaseSorting = requiresCustomReleaseSorting(widget.queries[0]);
     const isDescending = widget.queries[0].orderby.startsWith('-');
     const useSessionAPI = widget.queries[0].columns.includes('session.status');
 
-    if (widget.displayType === DisplayType.WORLD_MAP) {
-      this.setState({errorMessage: t('World Map is not supported by metrics.')});
-      return;
-    }
-
-    const queryFetchID = Symbol('queryFetchID');
-
-    this.setState({
-      loading: true,
-      errorMessage: undefined,
-      timeseriesResults: [],
-      rawResults: [],
-      tableResults: [],
-      queryFetchID,
-    });
-
     let releaseCondition = '';
     const releasesArray: string[] = [];
     if (isCustomReleaseSorting) {
@@ -364,141 +301,75 @@ class ReleaseWidgetQueries extends Component<Props, State> {
       });
     }
 
-    let responses: [MetricsApiResponse | SessionApiResponse, string, ResponseMeta][] = [];
-
-    try {
-      responses = await Promise.all(
-        widget.queries.map((query, index) => {
-          if ([DisplayType.TABLE, DisplayType.BIG_NUMBER].includes(widget.displayType)) {
-            return this.config.getTableRequest!(
-              api,
-              query,
-              organization,
-              selection,
-              this.limit,
-              cursor
-            );
-          }
-          return this.config.getSeriesRequest!(
-            api,
-            widget,
-            index,
-            organization,
-            selection
-          );
-        })
-      );
-    } catch (err) {
-      const errorMessage = err?.responseJSON?.detail || t('An unknown error occurred.');
-      if (!this._isMounted) {
-        return;
-      }
-      this.setState({errorMessage});
-    } finally {
-      if (!this._isMounted) {
-        return;
-      }
-    }
-
-    responses.forEach(([data, _textstatus, response], requestIndex) => {
-      if (!this._isMounted) {
-        return;
-      }
-      this.setState(prevState => {
-        if (prevState.queryFetchID !== queryFetchID) {
-          // invariant: a different request was initiated after this request
-          return prevState;
-        }
+    return widget;
+  };
 
-        if (releasesArray.length) {
-          data.groups.sort(function (group1, group2) {
-            const release1 = group1.by.release;
-            const release2 = group2.by.release;
-            return releasesArray.indexOf(release1) - releasesArray.indexOf(release2);
-          });
-          data.groups = data.groups.slice(0, this.limit);
-        }
+  afterFetchData = (data: SessionApiResponse | MetricsApiResponse) => {
+    const {widget} = this.props;
+    const {releases} = this.state;
 
-        let tableResults: TableDataWithTitle[] | undefined;
-        const timeseriesResults = [...(prevState.timeseriesResults ?? [])];
-        if ([DisplayType.TABLE, DisplayType.BIG_NUMBER].includes(widget.displayType)) {
-          // Transform to fit the table format
-          const tableData = this.config.transformTable(
-            data,
-            widget.queries[0],
-            organization,
-            selection
-          ) as TableDataWithTitle; // Cast so we can add the title.
-          tableData.title = widget.queries[requestIndex]?.name ?? '';
-          tableResults = [...(prevState.tableResults ?? []), tableData];
-        } else {
-          // Transform to fit the chart format
-          const transformedResult = this.config.transformSeries!(
-            data,
-            widget.queries[requestIndex],
-            organization
-          );
-
-          // When charting timeseriesData on echarts, color association to a timeseries result
-          // is order sensitive, ie series at index i on the timeseries array will use color at
-          // index i on the color array. This means that on multi series results, we need to make
-          // sure that the order of series in our results do not change between fetches to avoid
-          // coloring inconsistencies between renders.
-          transformedResult.forEach((result, resultIndex) => {
-            timeseriesResults[requestIndex * transformedResult.length + resultIndex] =
-              result;
-          });
-        }
+    const isDescending = widget.queries[0].orderby.startsWith('-');
 
-        onDataFetched?.({timeseriesResults, tableResults});
+    const releasesArray: string[] = [];
+    if (requiresCustomReleaseSorting(widget.queries[0])) {
+      if (releases && releases.length === 1) {
+        releasesArray.push(releases[0].version);
+      }
+      if (releases && releases.length > 1) {
+        const {releasesUsed} = getReleasesQuery(releases);
+        releasesArray.push(...releasesUsed);
 
-        if ([DisplayType.TABLE, DisplayType.BIG_NUMBER].includes(widget.displayType)) {
-          return {
-            ...prevState,
-            errorMessage: undefined,
-            tableResults,
-            pageLinks: response?.getResponseHeader('link') ?? undefined,
-          };
+        if (!!!isDescending) {
+          releasesArray.reverse();
         }
-
-        const rawResultsClone = cloneDeep(prevState.rawResults ?? []);
-        rawResultsClone[requestIndex] = data;
-
-        return {
-          ...prevState,
-          errorMessage: undefined,
-          timeseriesResults,
-          rawResults: rawResultsClone,
-          pageLinks: response?.getResponseHeader('link') ?? undefined,
-        };
-      });
-    });
-
-    this.setState(prevState => {
-      if (prevState.queryFetchID !== queryFetchID) {
-        // invariant: a different request was initiated after this request
-        return prevState;
       }
+    }
 
-      return {
-        ...prevState,
-        loading: false,
-      };
-    });
-  }
+    if (releasesArray.length) {
+      data.groups.sort(function (group1, group2) {
+        const release1 = group1.by.release;
+        const release2 = group2.by.release;
+        return releasesArray.indexOf(release1) - releasesArray.indexOf(release2);
+      });
+      data.groups = data.groups.slice(0, this.limit);
+    }
+  };
 
   render() {
-    const {children} = this.props;
-    const {loading, timeseriesResults, tableResults, errorMessage, pageLinks} =
-      this.state;
-
-    return children({
-      loading,
-      timeseriesResults,
-      tableResults,
-      errorMessage,
-      pageLinks,
-    });
+    const {api, children, organization, selection, widget, cursor, onDataFetched} =
+      this.props;
+    const config = ReleasesConfig;
+
+    return (
+      <GenericWidgetQueries<
+        SessionApiResponse | MetricsApiResponse,
+        SessionApiResponse | MetricsApiResponse
+      >
+        config={config}
+        api={api}
+        organization={organization}
+        selection={selection}
+        widget={this.transformWidget(widget)}
+        cursor={cursor}
+        limit={this.limit}
+        onDataFetched={onDataFetched}
+        loading={
+          requiresCustomReleaseSorting(widget.queries[0])
+            ? !this.state.releases
+            : undefined
+        }
+        customDidUpdateComparator={this.customDidUpdateComparator}
+        afterFetchTableData={this.afterFetchData}
+        afterFetchSeriesData={this.afterFetchData}
+      >
+        {({errorMessage, ...rest}) =>
+          children({
+            errorMessage: this.state.errorMessage ?? errorMessage,
+            ...rest,
+          })
+        }
+      </GenericWidgetQueries>
+    );
   }
 }
 

+ 2 - 2
tests/js/spec/views/dashboardsV2/releaseWidgetQueries.spec.tsx

@@ -92,7 +92,7 @@ describe('Dashboards > ReleaseWidgetQueries', function () {
     await waitFor(() =>
       expect(children).toHaveBeenLastCalledWith(
         expect.objectContaining({
-          tableResults: [],
+          tableResults: undefined,
           timeseriesResults: [
             {
               data: expect.arrayContaining([
@@ -515,7 +515,7 @@ describe('Dashboards > ReleaseWidgetQueries', function () {
               title: 'sessions',
             },
           ],
-          timeseriesResults: [],
+          timeseriesResults: undefined,
         })
       )
     );