Browse Source

feat(dashboards): Add to Dashboard from Explore (#81151)

Same as #81064 but with tests fixed.
Nar Saynorath 3 months ago
parent
commit
38218a56ca

+ 3 - 1
static/app/views/dashboards/widgetBuilder/buildSteps/yAxisStep/yAxisSelector/index.tsx

@@ -21,6 +21,8 @@ import {FieldValueKind} from 'sentry/views/discover/table/types';
 import {AddButton} from './addButton';
 import {DeleteButton} from './deleteButton';
 
+export const MAX_NUM_Y_AXES = 3;
+
 interface Props {
   aggregates: QueryFieldValue[];
   displayType: DisplayType;
@@ -107,7 +109,7 @@ export function YAxisSelector({
 
   const hideAddYAxisButtons =
     ([DisplayType.LINE, DisplayType.AREA, DisplayType.BAR].includes(displayType) &&
-      aggregates.length === 3) ||
+      aggregates.length === MAX_NUM_Y_AXES) ||
     (displayType === DisplayType.BIG_NUMBER && widgetType === WidgetType.RELEASE);
 
   let injectedFunctions: Set<string> = new Set();

+ 1 - 0
static/app/views/discover/utils.tsx

@@ -823,6 +823,7 @@ export function constructAddQueryToDashboardLink({
       defaultTitle,
       displayType: displayType === DisplayType.TOP_N ? DisplayType.AREA : displayType,
       dataset: widgetType,
+      field: eventView.getFields(),
       limit:
         displayType === DisplayType.TOP_N
           ? Number(eventView.topEvents) || TOP_N

+ 4 - 0
static/app/views/explore/charts/index.tsx

@@ -23,6 +23,7 @@ import usePageFilters from 'sentry/utils/usePageFilters';
 import useProjects from 'sentry/utils/useProjects';
 import {formatVersion} from 'sentry/utils/versions/formatVersion';
 import {Dataset} from 'sentry/views/alerts/rules/metric/types';
+import {AddToDashboardButton} from 'sentry/views/explore/components/addToDashboardButton';
 import {useChartInterval} from 'sentry/views/explore/hooks/useChartInterval';
 import {useDataset} from 'sentry/views/explore/hooks/useDataset';
 import {useVisualizes} from 'sentry/views/explore/hooks/useVisualizes';
@@ -273,6 +274,9 @@ export function ExploreCharts({query, setError}: ExploreChartsProps) {
                     />
                   </Tooltip>
                 </Feature>
+                <Feature features="organizations:dashboards-eap">
+                  <AddToDashboardButton visualizeIndex={index} />
+                </Feature>
               </ChartHeader>
               <Chart
                 height={CHART_HEIGHT}

+ 267 - 0
static/app/views/explore/components/addToDashboardButton.spec.tsx

@@ -0,0 +1,267 @@
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {openAddToDashboardModal} from 'sentry/actionCreators/modal';
+import {DisplayType, WidgetType} from 'sentry/views/dashboards/types';
+import {AddToDashboardButton} from 'sentry/views/explore/components/addToDashboardButton';
+import {useResultMode} from 'sentry/views/explore/hooks/useResultsMode';
+import {useVisualizes} from 'sentry/views/explore/hooks/useVisualizes';
+import {ChartType} from 'sentry/views/insights/common/components/chart';
+
+jest.mock('sentry/actionCreators/modal');
+jest.mock('sentry/views/explore/hooks/useVisualizes');
+jest.mock('sentry/views/explore/hooks/useResultsMode');
+
+describe('AddToDashboardButton', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+
+    jest.mocked(useVisualizes).mockReturnValue([
+      [
+        {
+          yAxes: ['avg(span.duration)'],
+          chartType: ChartType.LINE,
+          label: 'Custom Explore Widget',
+        },
+      ],
+      jest.fn(),
+    ]);
+
+    jest.mocked(useResultMode).mockReturnValue(['samples', jest.fn()]);
+  });
+
+  it('renders', async () => {
+    render(<AddToDashboardButton visualizeIndex={0} />);
+    await userEvent.hover(screen.getByLabelText('Add to Dashboard'));
+    expect(await screen.findByText('Add to Dashboard')).toBeInTheDocument();
+  });
+
+  it('opens the dashboard modal with the correct query for samples mode', async () => {
+    render(<AddToDashboardButton visualizeIndex={0} />);
+    await userEvent.click(screen.getByLabelText('Add to Dashboard'));
+
+    // The table columns are encoded as the fields for the defaultWidgetQuery
+    expect(openAddToDashboardModal).toHaveBeenCalledWith(
+      expect.objectContaining({
+        // For Add + Stay on Page
+        widget: {
+          title: 'Custom Explore Widget',
+          displayType: DisplayType.LINE,
+          interval: undefined,
+          limit: undefined,
+          widgetType: WidgetType.SPANS,
+          queries: [
+            {
+              aggregates: ['avg(span.duration)'],
+              columns: [],
+              fields: ['avg(span.duration)'],
+              conditions: '',
+              orderby: '-timestamp',
+              name: '',
+            },
+          ],
+        },
+
+        // For Open in Widget Builder
+        widgetAsQueryParams: expect.objectContaining({
+          dataset: WidgetType.SPANS,
+          defaultTableColumns: [
+            'id',
+            'project',
+            'span.op',
+            'span.description',
+            'span.duration',
+            'timestamp',
+          ],
+          defaultTitle: 'Custom Explore Widget',
+          defaultWidgetQuery:
+            'name=&aggregates=avg(span.duration)&columns=&fields=avg(span.duration)&conditions=&orderby=-timestamp',
+          displayType: DisplayType.LINE,
+          field: [
+            'id',
+            'project',
+            'span.op',
+            'span.description',
+            'span.duration',
+            'timestamp',
+          ],
+        }),
+      })
+    );
+  });
+
+  it('opens the dashboard modal with the correct query based on the visualize index', async () => {
+    // Mock a second visualize object
+    jest.mocked(useVisualizes).mockReturnValue([
+      [
+        {
+          yAxes: ['avg(span.duration)'],
+          chartType: ChartType.LINE,
+          label: 'Custom Explore Widget',
+        },
+        {
+          yAxes: ['max(span.duration)'],
+          chartType: ChartType.LINE,
+          label: 'Custom Explore Widget',
+        },
+      ],
+      jest.fn(),
+    ]);
+
+    render(<AddToDashboardButton visualizeIndex={1} />);
+    await userEvent.click(screen.getByLabelText('Add to Dashboard'));
+
+    // The group by and the yAxes are encoded as the fields for the defaultTableQuery
+    expect(openAddToDashboardModal).toHaveBeenCalledWith(
+      expect.objectContaining({
+        // For Add + Stay on Page
+        widget: {
+          title: 'Custom Explore Widget',
+          displayType: DisplayType.LINE,
+          interval: undefined,
+          limit: undefined,
+          widgetType: WidgetType.SPANS,
+          queries: [
+            {
+              aggregates: ['max(span.duration)'],
+              columns: [],
+              fields: ['max(span.duration)'],
+              conditions: '',
+              orderby: '-timestamp',
+              name: '',
+            },
+          ],
+        },
+
+        // For Open in Widget Builder
+        widgetAsQueryParams: expect.objectContaining({
+          dataset: WidgetType.SPANS,
+          defaultTableColumns: [
+            'id',
+            'project',
+            'span.op',
+            'span.description',
+            'span.duration',
+            'timestamp',
+          ],
+          defaultTitle: 'Custom Explore Widget',
+          defaultWidgetQuery:
+            'name=&aggregates=max(span.duration)&columns=&fields=max(span.duration)&conditions=&orderby=-timestamp',
+          displayType: DisplayType.LINE,
+          field: [
+            'id',
+            'project',
+            'span.op',
+            'span.description',
+            'span.duration',
+            'timestamp',
+          ],
+        }),
+      })
+    );
+  });
+
+  it('uses the yAxes for the aggregate mode', async () => {
+    jest.mocked(useResultMode).mockReturnValue(['aggregate', jest.fn()]);
+
+    render(<AddToDashboardButton visualizeIndex={0} />);
+    await userEvent.click(screen.getByLabelText('Add to Dashboard'));
+
+    expect(openAddToDashboardModal).toHaveBeenCalledWith(
+      expect.objectContaining({
+        // For Add + Stay on Page
+        widget: {
+          title: 'Custom Explore Widget',
+          displayType: DisplayType.LINE,
+          interval: undefined,
+          limit: undefined,
+          widgetType: WidgetType.SPANS,
+          queries: [
+            {
+              aggregates: ['avg(span.duration)'],
+              columns: [],
+              fields: ['avg(span.duration)'],
+              conditions: '',
+              orderby: '-avg(span.duration)',
+              name: '',
+            },
+          ],
+        },
+
+        // For Open in Widget Builder
+        widgetAsQueryParams: expect.objectContaining({
+          dataset: WidgetType.SPANS,
+          defaultTableColumns: ['avg(span.duration)'],
+          defaultTitle: 'Custom Explore Widget',
+          defaultWidgetQuery:
+            'name=&aggregates=avg(span.duration)&columns=&fields=avg(span.duration)&conditions=&orderby=-avg(span.duration)',
+          displayType: DisplayType.LINE,
+          field: ['avg(span.duration)'],
+        }),
+      })
+    );
+  });
+
+  it('takes the first 3 yAxes', async () => {
+    jest.mocked(useResultMode).mockReturnValue(['aggregate', jest.fn()]);
+    jest.mocked(useVisualizes).mockReturnValue([
+      [
+        {
+          yAxes: [
+            'avg(span.duration)',
+            'max(span.duration)',
+            'min(span.duration)',
+            'p90(span.duration)',
+          ],
+          chartType: ChartType.LINE,
+          label: 'Custom Explore Widget',
+        },
+      ],
+      jest.fn(),
+    ]);
+
+    render(<AddToDashboardButton visualizeIndex={0} />);
+    await userEvent.click(screen.getByLabelText('Add to Dashboard'));
+
+    expect(openAddToDashboardModal).toHaveBeenCalledWith(
+      expect.objectContaining({
+        // For Add + Stay on Page
+        widget: {
+          title: 'Custom Explore Widget',
+          displayType: DisplayType.LINE,
+          interval: undefined,
+          limit: undefined,
+          widgetType: WidgetType.SPANS,
+          queries: [
+            {
+              aggregates: [
+                'avg(span.duration)',
+                'max(span.duration)',
+                'min(span.duration)',
+              ],
+              columns: [],
+              fields: ['avg(span.duration)', 'max(span.duration)', 'min(span.duration)'],
+              conditions: '',
+              orderby: '-avg(span.duration)',
+              name: '',
+            },
+          ],
+        },
+
+        // For Open in Widget Builder
+        widgetAsQueryParams: expect.objectContaining({
+          dataset: WidgetType.SPANS,
+          defaultTableColumns: [
+            'avg(span.duration)',
+            'max(span.duration)',
+            'min(span.duration)',
+          ],
+          defaultTitle: 'Custom Explore Widget',
+          defaultWidgetQuery:
+            'name=&aggregates=avg(span.duration)%2Cmax(span.duration)%2Cmin(span.duration)&columns=&fields=avg(span.duration)%2Cmax(span.duration)%2Cmin(span.duration)&conditions=&orderby=-avg(span.duration)',
+          displayType: DisplayType.LINE,
+          field: ['avg(span.duration)', 'max(span.duration)', 'min(span.duration)'],
+        }),
+      })
+    );
+  });
+});

+ 97 - 0
static/app/views/explore/components/addToDashboardButton.tsx

@@ -0,0 +1,97 @@
+import {useCallback, useMemo} from 'react';
+
+import {Button} from 'sentry/components/button';
+import {Tooltip} from 'sentry/components/tooltip';
+import {IconDashboard} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import type {NewQuery} from 'sentry/types/organization';
+import EventView from 'sentry/utils/discover/eventView';
+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 useRouter from 'sentry/utils/useRouter';
+import {WidgetType} from 'sentry/views/dashboards/types';
+import {MAX_NUM_Y_AXES} from 'sentry/views/dashboards/widgetBuilder/buildSteps/yAxisStep/yAxisSelector';
+import {handleAddQueryToDashboard} from 'sentry/views/discover/utils';
+import {useDataset} from 'sentry/views/explore/hooks/useDataset';
+import {useGroupBys} from 'sentry/views/explore/hooks/useGroupBys';
+import {useResultMode} from 'sentry/views/explore/hooks/useResultsMode';
+import {useSampleFields} from 'sentry/views/explore/hooks/useSampleFields';
+import {useSorts} from 'sentry/views/explore/hooks/useSorts';
+import {useUserQuery} from 'sentry/views/explore/hooks/useUserQuery';
+import {useVisualizes} from 'sentry/views/explore/hooks/useVisualizes';
+import {formatSort} from 'sentry/views/explore/tables/aggregatesTable';
+
+interface AddToDashboardButtonProps {
+  visualizeIndex: number;
+}
+
+export function AddToDashboardButton({visualizeIndex}: AddToDashboardButtonProps) {
+  const location = useLocation();
+  const router = useRouter();
+  const {selection} = usePageFilters();
+  const organization = useOrganization();
+
+  const [resultMode] = useResultMode();
+  const [dataset] = useDataset();
+  const {groupBys} = useGroupBys();
+  const [visualizes] = useVisualizes();
+  const [sampleFields] = useSampleFields();
+  const yAxes = useMemo(
+    () => visualizes[visualizeIndex].yAxes.slice(0, MAX_NUM_Y_AXES),
+    [visualizes, visualizeIndex]
+  );
+  const fields = useMemo(() => {
+    if (resultMode === 'samples') {
+      return sampleFields.filter(Boolean);
+    }
+
+    return [...groupBys, ...yAxes].filter(Boolean);
+  }, [groupBys, resultMode, sampleFields, yAxes]);
+  const [sorts] = useSorts({fields});
+  const [query] = useUserQuery();
+
+  const discoverQuery: NewQuery = useMemo(() => {
+    const search = new MutableSearch(query);
+
+    return {
+      name: t('Custom Explore Widget'),
+      fields,
+      orderby: sorts.map(formatSort),
+      query: search.formatString(),
+      version: 2,
+      dataset,
+      yAxis: yAxes,
+    };
+  }, [dataset, fields, sorts, query, yAxes]);
+
+  const eventView = useMemo(() => {
+    const newEventView = EventView.fromNewQueryWithPageFilters(discoverQuery, selection);
+    newEventView.dataset = dataset;
+    return newEventView;
+  }, [discoverQuery, selection, dataset]);
+
+  const handleAddToDashboard = useCallback(() => {
+    handleAddQueryToDashboard({
+      organization,
+      location,
+      eventView,
+      router,
+      yAxis: eventView.yAxis,
+      widgetType: WidgetType.SPANS,
+    });
+  }, [organization, location, eventView, router]);
+
+  return (
+    <Tooltip title={t('Add to Dashboard')}>
+      <Button
+        size="sm"
+        icon={<IconDashboard />}
+        onClick={handleAddToDashboard}
+        aria-label={t('Add to Dashboard')}
+        borderless
+      />
+    </Tooltip>
+  );
+}