Browse Source

feat(mobile-ui): Add screen details and span op table (#70580)

Adds the route for the screen detail page and the span op table UI
element. I've added a new component that renders this switcher for reuse
but takes a module specific implementation of the event samples panel
and the span op table. I'll refactor App Starts to use this component at
a later time, wanted to avoid a larger PR.
Nar Saynorath 10 months ago
parent
commit
355c9c78a0

+ 6 - 0
static/app/routes.tsx

@@ -1574,6 +1574,12 @@ function buildRoutes() {
           <IndexRoute
             component={make(() => import('sentry/views/performance/mobile/ui'))}
           />
+          <Route
+            path="spans/"
+            component={make(
+              () => import('sentry/views/performance/mobile/ui/screenSummary')
+            )}
+          />
         </Route>
       </Route>
       <Route path="traces/">

+ 44 - 0
static/app/views/performance/mobile/components/samplesTables.spec.tsx

@@ -0,0 +1,44 @@
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {SamplesTables} from 'sentry/views/performance/mobile/components/samplesTables';
+import {useReleaseSelection} from 'sentry/views/starfish/queries/useReleases';
+
+jest.mock('sentry/views/starfish/queries/useReleases');
+
+jest.mocked(useReleaseSelection).mockReturnValue({
+  primaryRelease: 'com.example.vu.android@2.10.5-alpha.1+42',
+  isLoading: false,
+  secondaryRelease: 'com.example.vu.android@2.10.3+42',
+});
+
+describe('SamplesTables', () => {
+  it('accepts components for event samples and span operation table', async () => {
+    render(
+      <SamplesTables
+        EventSamples={({release}) => (
+          <div>{`This is a custom Event Samples table for release: ${release}`}</div>
+        )}
+        SpanOperationTable={_props => <div>This is a custom Span Operation table</div>}
+        transactionName={''}
+      />
+    );
+
+    // The span operation table is rendered first
+    expect(
+      await screen.findByText('This is a custom Span Operation table')
+    ).toBeInTheDocument();
+
+    await userEvent.click(screen.getByRole('radio', {name: 'By Event'}));
+
+    expect(
+      await screen.findByText(
+        'This is a custom Event Samples table for release: com.example.vu.android@2.10.5-alpha.1+42'
+      )
+    ).toBeInTheDocument();
+    expect(
+      screen.getByText(
+        'This is a custom Event Samples table for release: com.example.vu.android@2.10.3+42'
+      )
+    ).toBeInTheDocument();
+  });
+});

+ 139 - 0
static/app/views/performance/mobile/components/samplesTables.tsx

@@ -0,0 +1,139 @@
+import {useMemo, useState} from 'react';
+import styled from '@emotion/styled';
+
+import ErrorBoundary from 'sentry/components/errorBoundary';
+import {SegmentedControl} from 'sentry/components/segmentedControl';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {SpanOpSelector} from 'sentry/views/performance/mobile/appStarts/screenSummary/spanOpSelector';
+import {DeviceClassSelector} from 'sentry/views/performance/mobile/screenload/screenLoadSpans/deviceClassSelector';
+import {
+  MobileCursors,
+  MobileSortKeys,
+} from 'sentry/views/performance/mobile/screenload/screens/constants';
+import {useReleaseSelection} from 'sentry/views/starfish/queries/useReleases';
+
+const EVENT = 'event';
+const SPANS = 'spans';
+
+interface EventSamplesProps {
+  cursorName: string;
+  footerAlignedPagination: boolean;
+  sortKey: string;
+  transaction: string;
+  release?: string;
+}
+
+export interface SpanOperationTableProps {
+  transaction: string;
+  primaryRelease?: string;
+  secondaryRelease?: string;
+}
+
+interface SamplesTablesProps {
+  EventSamples: React.ComponentType<EventSamplesProps>;
+  SpanOperationTable: React.ComponentType<SpanOperationTableProps>;
+  transactionName: string;
+}
+
+export function SamplesTables({
+  transactionName,
+  EventSamples,
+  SpanOperationTable,
+}: SamplesTablesProps) {
+  const [sampleType, setSampleType] = useState<typeof EVENT | typeof SPANS>(SPANS);
+  const {primaryRelease, secondaryRelease} = useReleaseSelection();
+
+  const content = useMemo(() => {
+    if (sampleType === EVENT) {
+      return (
+        <EventSplitContainer>
+          <ErrorBoundary mini>
+            <EventSamples
+              cursorName={MobileCursors.RELEASE_1_EVENT_SAMPLE_TABLE}
+              sortKey={MobileSortKeys.RELEASE_1_EVENT_SAMPLE_TABLE}
+              release={primaryRelease}
+              transaction={transactionName}
+              footerAlignedPagination
+            />
+          </ErrorBoundary>
+          <ErrorBoundary mini>
+            <EventSamples
+              cursorName={MobileCursors.RELEASE_2_EVENT_SAMPLE_TABLE}
+              sortKey={MobileSortKeys.RELEASE_2_EVENT_SAMPLE_TABLE}
+              release={secondaryRelease}
+              transaction={transactionName}
+              footerAlignedPagination
+            />
+          </ErrorBoundary>
+        </EventSplitContainer>
+      );
+    }
+
+    return (
+      <ErrorBoundary mini>
+        <SpanOperationTable
+          transaction={transactionName}
+          primaryRelease={primaryRelease}
+          secondaryRelease={secondaryRelease}
+        />
+      </ErrorBoundary>
+    );
+  }, [
+    EventSamples,
+    SpanOperationTable,
+    primaryRelease,
+    sampleType,
+    secondaryRelease,
+    transactionName,
+  ]);
+
+  return (
+    <div>
+      <Controls>
+        <FiltersContainer>
+          {sampleType === SPANS && (
+            <SpanOpSelector
+              primaryRelease={primaryRelease}
+              transaction={transactionName}
+              secondaryRelease={secondaryRelease}
+            />
+          )}
+          <DeviceClassSelector size="md" clearSpansTableCursor />
+        </FiltersContainer>
+        <SegmentedControl
+          onChange={value => setSampleType(value)}
+          defaultValue={SPANS}
+          label={t('Sample Type Selection')}
+        >
+          <SegmentedControl.Item key={SPANS} aria-label={t('By Spans')}>
+            {t('By Spans')}
+          </SegmentedControl.Item>
+          <SegmentedControl.Item key={EVENT} aria-label={t('By Event')}>
+            {t('By Event')}
+          </SegmentedControl.Item>
+        </SegmentedControl>
+      </Controls>
+      {content}
+    </div>
+  );
+}
+
+const EventSplitContainer = styled('div')`
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: ${space(1.5)};
+`;
+
+const Controls = styled('div')`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: ${space(1)};
+`;
+
+const FiltersContainer = styled('div')`
+  display: flex;
+  gap: ${space(1)};
+  align-items: center;
+`;

+ 1 - 0
static/app/views/performance/mobile/ui/referrers.tsx

@@ -1,4 +1,5 @@
 export enum Referrer {
   OVERVIEW_SCREENS_TABLE = 'api.performance.module.ui.screen-table',
   MOBILE_UI_BAR_CHART = 'api.performance.mobile.ui.bar-chart',
+  SPAN_OPERATION_TABLE = 'api.performance.mobile.ui.span-table',
 }

+ 156 - 0
static/app/views/performance/mobile/ui/screenSummary/index.tsx

@@ -0,0 +1,156 @@
+import styled from '@emotion/styled';
+import omit from 'lodash/omit';
+
+import type {Crumb} from 'sentry/components/breadcrumbs';
+import Breadcrumbs from 'sentry/components/breadcrumbs';
+import * as Layout from 'sentry/components/layouts/thirds';
+import {DatePageFilter} from 'sentry/components/organizations/datePageFilter';
+import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
+import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {PageAlert, PageAlertProvider} from 'sentry/utils/performance/contexts/pageAlert';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import useRouter from 'sentry/utils/useRouter';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import {SamplesTables} from 'sentry/views/performance/mobile/components/samplesTables';
+import {ScreenLoadSpanSamples} from 'sentry/views/performance/mobile/screenload/screenLoadSpans/samples';
+import {SpanOperationTable} from 'sentry/views/performance/mobile/ui/screenSummary/spanOperationTable';
+import {ReleaseComparisonSelector} from 'sentry/views/starfish/components/releaseSelector';
+import {SpanMetricsField} from 'sentry/views/starfish/types';
+import {QueryParameterNames} from 'sentry/views/starfish/views/queryParameters';
+
+type Query = {
+  'device.class': string;
+  primaryRelease: string;
+  project: string;
+  secondaryRelease: string;
+  spanDescription: string;
+  spanGroup: string;
+  spanOp: string;
+  transaction: string;
+};
+
+function ScreenSummary() {
+  const organization = useOrganization();
+  const location = useLocation<Query>();
+  const router = useRouter();
+
+  const {
+    transaction: transactionName,
+    spanGroup,
+    spanDescription,
+    spanOp,
+    'device.class': deviceClass,
+  } = location.query;
+
+  const crumbs: Crumb[] = [
+    {
+      label: t('Performance'),
+      to: normalizeUrl(`/organizations/${organization.slug}/performance/`),
+      preservePageFilters: true,
+    },
+    {
+      label: t('Mobile UI'),
+      to: normalizeUrl({
+        pathname: `/organizations/${organization.slug}/performance/mobile/ui/`,
+        query: {
+          ...omit(location.query, [
+            QueryParameterNames.SPANS_SORT,
+            'transaction',
+            SpanMetricsField.SPAN_OP,
+          ]),
+        },
+      }),
+      preservePageFilters: true,
+    },
+    {
+      label: t('Screen Summary'),
+    },
+  ];
+
+  return (
+    <SentryDocumentTitle title={transactionName} orgSlug={organization.slug}>
+      <Layout.Page>
+        <PageAlertProvider>
+          <Layout.Header>
+            <Layout.HeaderContent>
+              <Breadcrumbs crumbs={crumbs} />
+              <Layout.Title>{transactionName}</Layout.Title>
+            </Layout.HeaderContent>
+          </Layout.Header>
+
+          <Layout.Body>
+            <Layout.Main fullWidth>
+              <PageAlert />
+              <PageFiltersContainer>
+                <HeaderContainer>
+                  <ControlsContainer>
+                    <PageFilterBar condensed>
+                      <DatePageFilter />
+                    </PageFilterBar>
+                    <ReleaseComparisonSelector />
+                  </ControlsContainer>
+                </HeaderContainer>
+                <SamplesContainer>
+                  <SamplesTables
+                    transactionName={transactionName}
+                    SpanOperationTable={SpanOperationTable}
+                    // TODO(nar): Add event samples component specific to ui module
+                    EventSamples={_props => <div />}
+                  />
+                </SamplesContainer>
+
+                {spanGroup && spanOp && (
+                  <ScreenLoadSpanSamples
+                    additionalFilters={{
+                      ...(deviceClass
+                        ? {[SpanMetricsField.DEVICE_CLASS]: deviceClass}
+                        : {}),
+                    }}
+                    groupId={spanGroup}
+                    transactionName={transactionName}
+                    spanDescription={spanDescription}
+                    spanOp={spanOp}
+                    onClose={() => {
+                      router.replace({
+                        pathname: router.location.pathname,
+                        query: omit(
+                          router.location.query,
+                          'spanGroup',
+                          'transactionMethod',
+                          'spanDescription',
+                          'spanOp'
+                        ),
+                      });
+                    }}
+                  />
+                )}
+              </PageFiltersContainer>
+            </Layout.Main>
+          </Layout.Body>
+        </PageAlertProvider>
+      </Layout.Page>
+    </SentryDocumentTitle>
+  );
+}
+
+export default ScreenSummary;
+
+const ControlsContainer = styled('div')`
+  display: flex;
+  gap: ${space(1.5)};
+`;
+
+const HeaderContainer = styled('div')`
+  display: flex;
+  flex-wrap: wrap;
+  gap: ${space(2)};
+  justify-content: space-between;
+`;
+
+const SamplesContainer = styled('div')`
+  margin-top: ${space(2)};
+`;

+ 82 - 0
static/app/views/performance/mobile/ui/screenSummary/spanOperationTable.spec.tsx

@@ -0,0 +1,82 @@
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import usePageFilters from 'sentry/utils/usePageFilters';
+import {Referrer} from 'sentry/views/performance/mobile/ui/referrers';
+import {SpanOperationTable} from 'sentry/views/performance/mobile/ui/screenSummary/spanOperationTable';
+
+jest.mock('sentry/utils/usePageFilters');
+
+jest.mocked(usePageFilters).mockReturnValue({
+  isReady: true,
+  desyncedFilters: new Set(),
+  pinnedFilters: new Set(),
+  shouldPersist: true,
+  selection: {
+    datetime: {
+      period: '10d',
+      start: null,
+      end: null,
+      utc: false,
+    },
+    environments: [],
+    projects: [],
+  },
+});
+
+describe('SpanOperationTable', () => {
+  it('renders and fetches the proper data', () => {
+    const spanOpTableRequestMock = MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/events/',
+      body: [],
+      match: [MockApiClient.matchQuery({referrer: Referrer.SPAN_OPERATION_TABLE})],
+    });
+
+    render(
+      <SpanOperationTable
+        transaction="transaction"
+        primaryRelease="foo"
+        secondaryRelease="bar"
+      />
+    );
+
+    [
+      'Operation',
+      'Span Description',
+      'Slow (R1)',
+      'Slow (R2)',
+      'Frozen (R1)',
+      'Frozen (R2)',
+      'Delay (R1)',
+      'Delay (R2)',
+    ].forEach(header => {
+      expect(screen.getByRole('columnheader', {name: header})).toBeInTheDocument();
+    });
+
+    expect(screen.getAllByRole('columnheader', {name: 'Change'})).toHaveLength(3);
+
+    expect(spanOpTableRequestMock).toHaveBeenCalledTimes(1);
+
+    expect(spanOpTableRequestMock).toHaveBeenCalledWith(
+      '/organizations/org-slug/events/',
+      expect.objectContaining({
+        query: expect.objectContaining({
+          field: [
+            'project.id',
+            'span.op',
+            'span.group',
+            'span.description',
+            'avg_if(mobile.slow_frames,release,foo)',
+            'avg_if(mobile.slow_frames,release,bar)',
+            'avg_compare(mobile.slow_frames,release,foo,bar)',
+            'avg_if(mobile.frozen_frames,release,foo)',
+            'avg_if(mobile.frozen_frames,release,bar)',
+            'avg_compare(mobile.frozen_frames,release,foo,bar)',
+            'avg_if(mobile.frames_delay,release,foo)',
+            'avg_if(mobile.frames_delay,release,bar)',
+            'avg_compare(mobile.frames_delay,release,foo,bar)',
+          ],
+        }),
+      })
+    );
+  });
+});

+ 201 - 0
static/app/views/performance/mobile/ui/screenSummary/spanOperationTable.tsx

@@ -0,0 +1,201 @@
+import * as qs from 'query-string';
+
+import {getInterval} from 'sentry/components/charts/utils';
+import Duration from 'sentry/components/duration';
+import Link from 'sentry/components/links/link';
+import {t} from 'sentry/locale';
+import type {NewQuery} from 'sentry/types/organization';
+import EventView from 'sentry/utils/discover/eventView';
+import {NumberContainer} from 'sentry/utils/discover/styles';
+import {DiscoverDatasets} from 'sentry/utils/discover/types';
+import {decodeScalar} from 'sentry/utils/queryString';
+import {MutableSearch} from 'sentry/utils/tokenizeSearch';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import {APP_START_SPANS} from 'sentry/views/performance/mobile/appStarts/screenSummary/spanOpSelector';
+import type {SpanOperationTableProps} from 'sentry/views/performance/mobile/components/samplesTables';
+import {ScreensTable} from 'sentry/views/performance/mobile/components/screensTable';
+import {MobileCursors} from 'sentry/views/performance/mobile/screenload/screens/constants';
+import {useTableQuery} from 'sentry/views/performance/mobile/screenload/screens/screensTable';
+import {Referrer} from 'sentry/views/performance/mobile/ui/referrers';
+import {
+  PRIMARY_RELEASE_ALIAS,
+  SECONDARY_RELEASE_ALIAS,
+} from 'sentry/views/starfish/components/releaseSelector';
+import {OverflowEllipsisTextContainer} from 'sentry/views/starfish/components/textAlign';
+import {SpanMetricsField} from 'sentry/views/starfish/types';
+import {STARFISH_CHART_INTERVAL_FIDELITY} from 'sentry/views/starfish/utils/constants';
+import {appendReleaseFilters} from 'sentry/views/starfish/utils/releaseComparison';
+
+const {SPAN_DESCRIPTION, SPAN_GROUP, SPAN_OP, PROJECT_ID} = SpanMetricsField;
+
+const VALID_SPAN_OPS = APP_START_SPANS;
+
+export function SpanOperationTable({
+  transaction,
+  primaryRelease,
+  secondaryRelease,
+}: SpanOperationTableProps) {
+  const location = useLocation();
+  const {selection} = usePageFilters();
+  const organization = useOrganization();
+  const cursor = decodeScalar(location.query?.[MobileCursors.SPANS_TABLE]);
+
+  const spanOp = decodeScalar(location.query[SpanMetricsField.SPAN_OP]) ?? '';
+  const deviceClass = decodeScalar(location.query[SpanMetricsField.DEVICE_CLASS]) ?? '';
+
+  // TODO: These filters seem to be too aggressive, check that they are ingesting properly
+  const searchQuery = new MutableSearch([
+    // 'has:span.description',
+    // 'transaction.op:ui.load',
+    `transaction:${transaction}`,
+    `${SpanMetricsField.SPAN_OP}:${spanOp ? spanOp : `[${VALID_SPAN_OPS.join(',')}]`}`,
+    ...(spanOp ? [`${SpanMetricsField.SPAN_OP}:${spanOp}`] : []),
+    ...(deviceClass ? [`${SpanMetricsField.DEVICE_CLASS}:${deviceClass}`] : []),
+  ]);
+  const queryStringPrimary = appendReleaseFilters(
+    searchQuery,
+    primaryRelease,
+    secondaryRelease
+  );
+
+  const orderby = decodeScalar(location.query.sort, '');
+
+  const newQuery: NewQuery = {
+    name: '',
+    fields: [
+      PROJECT_ID,
+      SPAN_OP,
+      SPAN_GROUP,
+      SPAN_DESCRIPTION,
+      `avg_if(mobile.slow_frames,release,${primaryRelease})`,
+      `avg_if(mobile.slow_frames,release,${secondaryRelease})`,
+      `avg_compare(mobile.slow_frames,release,${primaryRelease},${secondaryRelease})`,
+      `avg_if(mobile.frozen_frames,release,${primaryRelease})`,
+      `avg_if(mobile.frozen_frames,release,${secondaryRelease})`,
+      `avg_compare(mobile.frozen_frames,release,${primaryRelease},${secondaryRelease})`,
+      `avg_if(mobile.frames_delay,release,${primaryRelease})`,
+      `avg_if(mobile.frames_delay,release,${secondaryRelease})`,
+      `avg_compare(mobile.frames_delay,release,${primaryRelease},${secondaryRelease})`,
+    ],
+    query: queryStringPrimary,
+    orderby,
+    dataset: DiscoverDatasets.SPANS_METRICS,
+    version: 2,
+    projects: selection.projects,
+    interval: getInterval(selection.datetime, STARFISH_CHART_INTERVAL_FIDELITY),
+  };
+
+  const eventView = EventView.fromNewQueryWithLocation(newQuery, location);
+
+  const {data, isLoading, pageLinks} = useTableQuery({
+    eventView,
+    enabled: true,
+    referrer: Referrer.SPAN_OPERATION_TABLE,
+    cursor,
+  });
+
+  const columnNameMap = {
+    [SPAN_OP]: t('Operation'),
+    [SPAN_DESCRIPTION]: t('Span Description'),
+    [`avg_if(mobile.slow_frames,release,${primaryRelease})`]: t(
+      'Slow (%s)',
+      PRIMARY_RELEASE_ALIAS
+    ),
+    [`avg_if(mobile.slow_frames,release,${secondaryRelease})`]: t(
+      'Slow (%s)',
+      SECONDARY_RELEASE_ALIAS
+    ),
+    [`avg_compare(mobile.slow_frames,release,${primaryRelease},${secondaryRelease})`]:
+      t('Change'),
+    [`avg_if(mobile.frozen_frames,release,${primaryRelease})`]: t(
+      'Frozen (%s)',
+      PRIMARY_RELEASE_ALIAS
+    ),
+    [`avg_if(mobile.frozen_frames,release,${secondaryRelease})`]: t(
+      'Frozen (%s)',
+      SECONDARY_RELEASE_ALIAS
+    ),
+    [`avg_compare(mobile.frozen_frames,release,${primaryRelease},${secondaryRelease})`]:
+      t('Change'),
+    [`avg_if(mobile.frames_delay,release,${primaryRelease})`]: t(
+      'Delay (%s)',
+      PRIMARY_RELEASE_ALIAS
+    ),
+    [`avg_if(mobile.frames_delay,release,${secondaryRelease})`]: t(
+      'Delay (%s)',
+      SECONDARY_RELEASE_ALIAS
+    ),
+    [`avg_compare(mobile.frames_delay,release,${primaryRelease},${secondaryRelease})`]:
+      t('Change'),
+  };
+
+  function renderBodyCell(column, row) {
+    if (column.key === SPAN_DESCRIPTION) {
+      const label = row[SpanMetricsField.SPAN_DESCRIPTION];
+
+      const pathname = normalizeUrl(
+        `/organizations/${organization.slug}/performance/mobile/ui/spans/`
+      );
+      const query = {
+        ...location.query,
+        transaction,
+        spanOp: row[SpanMetricsField.SPAN_OP],
+        spanGroup: row[SpanMetricsField.SPAN_GROUP],
+        spanDescription: row[SpanMetricsField.SPAN_DESCRIPTION],
+      };
+
+      return (
+        <Link to={`${pathname}?${qs.stringify(query)}`}>
+          <OverflowEllipsisTextContainer>{label}</OverflowEllipsisTextContainer>
+        </Link>
+      );
+    }
+
+    if (column.key.startsWith('avg_if(mobile.frames_delay')) {
+      return (
+        <NumberContainer>
+          {typeof row[column.key] === 'number' ? (
+            <Duration seconds={row[column.key]} fixedDigits={2} abbreviation />
+          ) : (
+            '-'
+          )}
+        </NumberContainer>
+      );
+    }
+
+    return null;
+  }
+
+  return (
+    <ScreensTable
+      columnNameMap={columnNameMap}
+      data={data}
+      eventView={eventView}
+      isLoading={isLoading}
+      pageLinks={pageLinks}
+      columnOrder={[
+        String(SPAN_OP),
+        String(SPAN_DESCRIPTION),
+        `avg_if(mobile.slow_frames,release,${primaryRelease})`,
+        `avg_if(mobile.slow_frames,release,${secondaryRelease})`,
+        `avg_compare(mobile.slow_frames,release,${primaryRelease},${secondaryRelease})`,
+        `avg_if(mobile.frozen_frames,release,${primaryRelease})`,
+        `avg_if(mobile.frozen_frames,release,${secondaryRelease})`,
+        `avg_compare(mobile.frozen_frames,release,${primaryRelease},${secondaryRelease})`,
+        `avg_if(mobile.frames_delay,release,${primaryRelease})`,
+        `avg_if(mobile.frames_delay,release,${secondaryRelease})`,
+        `avg_compare(mobile.frames_delay,release,${primaryRelease},${secondaryRelease})`,
+      ]}
+      defaultSort={[
+        {
+          key: `avg_compare(mobile.frames_delay,release,${primaryRelease},${secondaryRelease})`,
+          order: 'desc',
+        },
+      ]}
+      customBodyCellRenderer={renderBodyCell}
+    />
+  );
+}