Browse Source

feat(explore): Adding chart. (#76673)

![Screenshot 2024-08-28 at 5 02
38 PM](https://github.com/user-attachments/assets/d5b2fd7c-b754-4269-b474-a5aab17ce7d0)

---------

Co-authored-by: Abdullah Khan <abdullahkhan@PG9Y57YDXQ.local>
Abdullah Khan 6 months ago
parent
commit
59c1985ec1

+ 124 - 3
static/app/views/explore/charts/index.tsx

@@ -1,5 +1,126 @@
-interface ExploreChartsProps {}
+import styled from '@emotion/styled';
 
-export function ExploreCharts({}: ExploreChartsProps) {
-  return <div>TODO: visualize charts</div>;
+import {getInterval} from 'sentry/components/charts/utils';
+import {CompactSelect} from 'sentry/components/compactSelect';
+import {CHART_PALETTE} from 'sentry/constants/chartPalette';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {tooltipFormatter} from 'sentry/utils/discover/charts';
+import {MutableSearch} from 'sentry/utils/tokenizeSearch';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import Chart, {ChartType} from 'sentry/views/insights/common/components/chart';
+import ChartPanel from 'sentry/views/insights/common/components/chartPanel';
+import {useSpanIndexedSeries} from 'sentry/views/insights/common/queries/useDiscoverSeries';
+import {CHART_HEIGHT} from 'sentry/views/insights/database/settings';
+
+import {useChartInterval} from '../hooks/useChartInterval';
+import {useChartType} from '../hooks/useChartType';
+import {useVisualize} from '../hooks/useVisualize';
+
+interface ExploreChartsProps {
+  query: string;
 }
+
+const exploreChartTypeOptions = [
+  {
+    value: ChartType.LINE,
+    label: t('Line'),
+  },
+  {
+    value: ChartType.AREA,
+    label: t('Area'),
+  },
+  {
+    value: ChartType.BAR,
+    label: t('Bar'),
+  },
+];
+
+// TODO: Update to support aggregate mode and multiple queries / visualizations
+export function ExploreCharts({query}: ExploreChartsProps) {
+  const pageFilters = usePageFilters();
+  const [visualize] = useVisualize();
+  const [chartType, setChartType] = useChartType();
+  const [interval, setInterval, intervalOptions] = useChartInterval();
+
+  const series = useSpanIndexedSeries(
+    {
+      search: new MutableSearch(query ?? ''),
+      yAxis: [visualize],
+      interval: interval ?? getInterval(pageFilters.selection.datetime, 'metrics'),
+      enabled: true,
+    },
+    'api.explorer.stats'
+  );
+
+  return (
+    <ChartContainer>
+      <ChartPanel>
+        <ChartHeader>
+          <ChartTitle>{visualize}</ChartTitle>
+          <ChartSettingsContainer>
+            <CompactSelect
+              size="xs"
+              triggerProps={{prefix: t('Display')}}
+              value={chartType}
+              options={exploreChartTypeOptions}
+              onChange={newChartType => setChartType(newChartType.value)}
+            />
+            <CompactSelect
+              size="xs"
+              value={interval}
+              onChange={({value}) => setInterval(value)}
+              triggerProps={{
+                prefix: t('Interval'),
+              }}
+              options={intervalOptions}
+            />
+          </ChartSettingsContainer>
+        </ChartHeader>
+        <Chart
+          height={CHART_HEIGHT}
+          grid={{
+            left: '0',
+            right: '0',
+            top: '8px',
+            bottom: '0',
+          }}
+          data={[series.data[visualize]]}
+          error={series.error}
+          loading={series.isPending}
+          chartColors={CHART_PALETTE[2]}
+          type={chartType}
+          aggregateOutputFormat="number"
+          showLegend
+          tooltipFormatterOptions={{
+            valueFormatter: value => tooltipFormatter(value),
+          }}
+        />
+      </ChartPanel>
+    </ChartContainer>
+  );
+}
+
+const ChartContainer = styled('div')`
+  display: grid;
+  gap: 0;
+  grid-template-columns: 1fr;
+  margin-bottom: ${space(3)};
+`;
+
+const ChartHeader = styled('div')`
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  margin-bottom: ${space(1)};
+`;
+
+const ChartTitle = styled('div')`
+  ${p => p.theme.text.cardTitle}
+`;
+
+const ChartSettingsContainer = styled('div')`
+  display: flex;
+  align-items: center;
+  gap: ${space(0.5)};
+`;

+ 1 - 1
static/app/views/explore/content.tsx

@@ -60,7 +60,7 @@ export function ExploreContent({}: ExploreContentProps) {
               <ExploreToolbar extras={toolbarExtras} />
             </Side>
             <Main fullWidth>
-              <ExploreCharts />
+              <ExploreCharts query={userQuery} />
               <ExploreTables />
             </Main>
           </Body>

+ 72 - 0
static/app/views/explore/hooks/useChartInterval.spec.tsx

@@ -0,0 +1,72 @@
+import {createMemoryHistory, Route, Router, RouterContext} from 'react-router';
+
+import {act, render} from 'sentry-test/reactTestingLibrary';
+
+import PageFiltersStore from 'sentry/stores/pageFiltersStore';
+import {RouteContext} from 'sentry/views/routeContext';
+
+import {useChartInterval} from './useChartInterval';
+
+describe('useChartInterval', function () {
+  beforeEach(() => {
+    PageFiltersStore.reset();
+    PageFiltersStore.init();
+  });
+
+  it('allows changing chart interval', async function () {
+    let chartInterval, setChartInterval, intervalOptions;
+
+    function TestPage() {
+      [chartInterval, setChartInterval, intervalOptions] = useChartInterval();
+      return null;
+    }
+
+    const memoryHistory = createMemoryHistory();
+
+    render(
+      <Router
+        history={memoryHistory}
+        render={props => {
+          return (
+            <RouteContext.Provider value={props}>
+              <RouterContext {...props} />
+            </RouteContext.Provider>
+          );
+        }}
+      >
+        <Route path="/" component={TestPage} />
+      </Router>
+    );
+
+    expect(intervalOptions).toEqual([
+      {value: '1h', label: '1 hour'},
+      {value: '4h', label: '4 hours'},
+      {value: '1d', label: '1 day'},
+      {value: '1w', label: '1 week'},
+    ]);
+    expect(chartInterval).toEqual('1h'); // default
+
+    await act(() => setChartInterval('1d'));
+    expect(chartInterval).toEqual('1d');
+
+    // Update page filters to change interval options
+    await act(() =>
+      PageFiltersStore.updateDateTime({
+        period: '1h',
+        start: null,
+        end: null,
+        utc: true,
+      })
+    );
+
+    expect(intervalOptions).toEqual([
+      {value: '1m', label: '1 minute'},
+      {value: '5m', label: '5 minutes'},
+      {value: '15m', label: '15 minutes'},
+    ]);
+    await act(() => {
+      setChartInterval('1m');
+    });
+    expect(chartInterval).toEqual('1m');
+  });
+});

+ 66 - 0
static/app/views/explore/hooks/useChartInterval.tsx

@@ -0,0 +1,66 @@
+import {useCallback, useMemo} from 'react';
+import type {Location} from 'history';
+
+import {decodeScalar} from 'sentry/utils/queryString';
+import {useLocation} from 'sentry/utils/useLocation';
+import {useNavigate} from 'sentry/utils/useNavigate';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import {getIntervalOptionsForStatsPeriod} from 'sentry/views/metrics/utils/useMetricsIntervalParam';
+
+interface Options {
+  location: Location;
+  navigate: ReturnType<typeof useNavigate>;
+  pagefilters: ReturnType<typeof usePageFilters>;
+}
+
+export function useChartInterval(): [
+  string,
+  (interval: string) => void,
+  intervalOptions: {label: string; value: string}[],
+] {
+  const location = useLocation();
+  const navigate = useNavigate();
+  const pagefilters = usePageFilters();
+  const options = {location, navigate, pagefilters};
+
+  return useChartIntervalImpl(options);
+}
+
+function useChartIntervalImpl({
+  location,
+  navigate,
+  pagefilters,
+}: Options): [
+  string,
+  (interval: string) => void,
+  intervalOptions: {label: string; value: string}[],
+] {
+  const {datetime} = pagefilters.selection;
+  const intervalOptions = useMemo(() => {
+    return getIntervalOptionsForStatsPeriod(datetime);
+  }, [datetime]);
+
+  const interval: string = useMemo(() => {
+    const decodedInterval = decodeScalar(location.query.interval);
+
+    return decodedInterval &&
+      intervalOptions.some(option => option.value === decodedInterval)
+      ? decodedInterval
+      : intervalOptions[0].value;
+  }, [location.query.interval, intervalOptions]);
+
+  const setInterval = useCallback(
+    (newInterval: string) => {
+      navigate({
+        ...location,
+        query: {
+          ...location.query,
+          interval: newInterval,
+        },
+      });
+    },
+    [location, navigate]
+  );
+
+  return [interval, setInterval, intervalOptions];
+}

+ 44 - 0
static/app/views/explore/hooks/useChartType.spec.tsx

@@ -0,0 +1,44 @@
+import {createMemoryHistory, Route, Router, RouterContext} from 'react-router';
+
+import {act, render} from 'sentry-test/reactTestingLibrary';
+
+import {ChartType} from 'sentry/views/insights/common/components/chart';
+import {RouteContext} from 'sentry/views/routeContext';
+
+import {useChartType} from './useChartType';
+
+describe('useChartType', function () {
+  it('allows changing chart type', function () {
+    let chartType, setChartType;
+
+    function TestPage() {
+      [chartType, setChartType] = useChartType();
+      return null;
+    }
+
+    const memoryHistory = createMemoryHistory();
+
+    render(
+      <Router
+        history={memoryHistory}
+        render={props => {
+          return (
+            <RouteContext.Provider value={props}>
+              <RouterContext {...props} />
+            </RouteContext.Provider>
+          );
+        }}
+      >
+        <Route path="/" component={TestPage} />
+      </Router>
+    );
+
+    expect(chartType).toEqual(ChartType.LINE); // default
+
+    act(() => setChartType(ChartType.BAR));
+    expect(chartType).toEqual(ChartType.BAR);
+
+    act(() => setChartType(ChartType.LINE));
+    expect(chartType).toEqual(ChartType.LINE);
+  });
+});

+ 50 - 0
static/app/views/explore/hooks/useChartType.tsx

@@ -0,0 +1,50 @@
+import {useCallback, useMemo} from 'react';
+import type {Location} from 'history';
+
+import {decodeScalar} from 'sentry/utils/queryString';
+import {useLocation} from 'sentry/utils/useLocation';
+import {useNavigate} from 'sentry/utils/useNavigate';
+import {ChartType} from 'sentry/views/insights/common/components/chart';
+
+interface Options {
+  location: Location;
+  navigate: ReturnType<typeof useNavigate>;
+}
+
+export function useChartType(): [ChartType, (chartType: ChartType) => void] {
+  const location = useLocation();
+  const navigate = useNavigate();
+  const options = {location, navigate};
+
+  return useChartTypeImpl(options);
+}
+
+function useChartTypeImpl({
+  location,
+  navigate,
+}: Options): [ChartType, (chartType: ChartType) => void] {
+  const chartType: ChartType = useMemo(() => {
+    const parsedType = Number(decodeScalar(location.query.chartType));
+
+    if (isNaN(parsedType) || !Object.values(ChartType).includes(parsedType)) {
+      return ChartType.LINE;
+    }
+
+    return parsedType as ChartType;
+  }, [location.query.chartType]);
+
+  const setChartType = useCallback(
+    (newChartType: ChartType) => {
+      navigate({
+        ...location,
+        query: {
+          ...location.query,
+          chartType: newChartType,
+        },
+      });
+    },
+    [location, navigate]
+  );
+
+  return [chartType, setChartType];
+}

+ 1 - 1
static/app/views/explore/hooks/useVisualize.spec.tsx

@@ -6,7 +6,7 @@ import {useVisualize} from 'sentry/views/explore/hooks/useVisualize';
 import {RouteContext} from 'sentry/views/routeContext';
 
 describe('useVisualize', function () {
-  it('allows changing results mode', function () {
+  it('allows changing visualize function', function () {
     let visualize, setVisualize;
 
     function TestPage() {