Browse Source

feat(ddm): save as dashboard (#61518)

Add dialog for saving a whole scratchpad as a dashboard.

---------

Co-authored-by: Arthur Knaus <arthur.knaus@sentry.io>
Ogi 1 year ago
parent
commit
a35883ef2a

+ 10 - 0
static/app/actionCreators/modal.tsx

@@ -275,6 +275,16 @@ export async function openImportDashboardFromFileModal(options) {
   });
 }
 
+export async function openCreateDashboardFromScratchpad(options) {
+  const mod = await import('sentry/components/modals/createDashboardFromScratchpadModal');
+  const {default: Modal, modalCss} = mod;
+
+  openModal(deps => <Modal {...deps} {...options} />, {
+    closeEvents: 'escape-key',
+    modalCss,
+  });
+}
+
 export async function openReprocessEventModal({
   onClose,
   ...options

+ 207 - 0
static/app/components/modals/createDashboardFromScratchpadModal.tsx

@@ -0,0 +1,207 @@
+import {useEffect, useState} from 'react';
+import {InjectedRouter} from 'react-router';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+import {Location} from 'history';
+
+import {createDashboard, updateDashboard} from 'sentry/actionCreators/dashboards';
+import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
+import {ModalRenderProps} from 'sentry/actionCreators/modal';
+import {Button} from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
+import SelectControl from 'sentry/components/forms/controls/selectControl';
+import LoadingError from 'sentry/components/loadingError';
+import {t, tct} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {Organization, SelectValue} from 'sentry/types';
+import {useApiQuery} from 'sentry/utils/queryClient';
+import useApi from 'sentry/utils/useApi';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import {
+  DashboardDetails,
+  DashboardListItem,
+  MAX_WIDGETS,
+} from 'sentry/views/dashboards/types';
+import {NEW_DASHBOARD_ID} from 'sentry/views/dashboards/widgetBuilder/utils';
+import {OrganizationContext} from 'sentry/views/organizationContext';
+
+export type AddToDashboardModalProps = {
+  location: Location;
+  newDashboard: DashboardDetails;
+  organization: Organization;
+  router: InjectedRouter;
+};
+
+type Props = ModalRenderProps & AddToDashboardModalProps;
+
+const SELECT_DASHBOARD_MESSAGE = t('Select a dashboard');
+
+function CreateDashboardFromScratchpadModal({
+  Header,
+  Body,
+  Footer,
+  closeModal,
+  organization,
+  router,
+  newDashboard,
+}: Props) {
+  const api = useApi();
+  const [selectedDashboardId, setSelectedDashboardId] =
+    useState<string>(NEW_DASHBOARD_ID);
+
+  const {data: dashboards, isError: isDashboardError} = useApiQuery<DashboardListItem[]>(
+    [
+      `/organizations/${organization.slug}/dashboards/`,
+      {
+        query: {sort: 'myDashboardsAndRecentlyViewed'},
+      },
+    ],
+    {staleTime: 0}
+  );
+
+  const shouldFetchSelectedDashboard = selectedDashboardId !== NEW_DASHBOARD_ID;
+
+  const {data: selectedDashboard, isError: isSelectedDashboardError} =
+    useApiQuery<DashboardDetails>(
+      [`/organizations/${organization.slug}/dashboards/${selectedDashboardId}/`],
+      {
+        staleTime: 0,
+        enabled: shouldFetchSelectedDashboard,
+      }
+    );
+
+  useEffect(() => {
+    if (isSelectedDashboardError) {
+      addErrorMessage(t('Unable to load dashboard'));
+    }
+  }, [isSelectedDashboardError]);
+
+  async function createOrUpdateDashboard() {
+    if (selectedDashboardId === NEW_DASHBOARD_ID) {
+      const dashboard = await createDashboard(api, organization.slug, newDashboard);
+
+      addSuccessMessage(t('Successfully created dashboard'));
+      return dashboard;
+    }
+
+    if (!selectedDashboard) {
+      return null;
+    }
+
+    const updatedDashboard = {
+      ...selectedDashboard,
+      widgets: [...selectedDashboard.widgets, ...newDashboard.widgets],
+    };
+
+    const dashboard = await updateDashboard(api, organization.slug, updatedDashboard);
+
+    addSuccessMessage(t('Successfully added widgets to dashboard'));
+    return dashboard;
+  }
+
+  async function handleAddAndStayOnCurrentPage() {
+    await createOrUpdateDashboard();
+    closeModal();
+  }
+
+  async function handleGoToDashboard() {
+    const dashboard = await createOrUpdateDashboard();
+
+    if (!dashboard) {
+      return;
+    }
+
+    router.push(
+      normalizeUrl({
+        pathname: `/organizations/${organization.slug}/dashboards/${dashboard.id}/`,
+      })
+    );
+
+    closeModal();
+  }
+
+  return (
+    <OrganizationContext.Provider value={organization}>
+      <Header closeButton>
+        <h4>{t('Add to Dashboard')}</h4>
+      </Header>
+      <Body>
+        {isDashboardError && <LoadingError message={t('Unable to load dashboards')} />}
+        <Wrapper>
+          <SelectControl
+            disabled={dashboards === null}
+            menuPlacement="auto"
+            name="dashboard"
+            placeholder={t('Select Dashboard')}
+            value={selectedDashboardId}
+            options={
+              dashboards && [
+                {label: t('+ Create New Dashboard'), value: 'new'},
+                ...dashboards.map(({title, id, widgetDisplay}) => ({
+                  label: title,
+                  value: id,
+                  disabled: widgetDisplay.length >= MAX_WIDGETS,
+                  tooltip:
+                    widgetDisplay.length >= MAX_WIDGETS &&
+                    tct('Max widgets ([maxWidgets]) per dashboard reached.', {
+                      maxWidgets: MAX_WIDGETS,
+                    }),
+                  tooltipOptions: {position: 'right'},
+                })),
+              ]
+            }
+            onChange={(option: SelectValue<string>) => {
+              if (option.disabled) {
+                return;
+              }
+              setSelectedDashboardId(option.value);
+            }}
+          />
+        </Wrapper>
+      </Body>
+
+      <Footer>
+        <StyledButtonBar gap={1.5}>
+          <Button
+            disabled={isSelectedDashboardError}
+            onClick={handleAddAndStayOnCurrentPage}
+            title={SELECT_DASHBOARD_MESSAGE}
+          >
+            {t('Add + Stay on this Page')}
+          </Button>
+          <Button
+            disabled={isSelectedDashboardError}
+            priority="primary"
+            onClick={handleGoToDashboard}
+            title={SELECT_DASHBOARD_MESSAGE}
+          >
+            {t('Open in Dashboards')}
+          </Button>
+        </StyledButtonBar>
+      </Footer>
+    </OrganizationContext.Provider>
+  );
+}
+
+export default CreateDashboardFromScratchpadModal;
+
+const Wrapper = styled('div')`
+  margin-bottom: ${space(2)};
+`;
+
+const StyledButtonBar = styled(ButtonBar)`
+  @media (max-width: ${props => props.theme.breakpoints.small}) {
+    grid-template-rows: repeat(2, 1fr);
+    gap: ${space(1.5)};
+    width: 100%;
+
+    > button {
+      width: 100%;
+    }
+  }
+`;
+
+export const modalCss = css`
+  max-width: 700px;
+  margin: 70px auto;
+`;

+ 79 - 0
static/app/utils/metrics/dashboard.spec.tsx

@@ -0,0 +1,79 @@
+import {MetricDisplayType} from 'sentry/utils/metrics';
+import {convertToDashboardWidget} from 'sentry/utils/metrics/dashboard';
+import {DisplayType} from 'sentry/views/dashboards/types';
+
+describe('convertToDashboardWidget', () => {
+  it('should convert a metrics query to a dashboard widget (metrics mri, with grouping)', () => {
+    expect(
+      convertToDashboardWidget(
+        {
+          datetime: {
+            start: '2021-06-01T00:00:00',
+            end: '2021-06-02T00:00:00',
+            period: '1d',
+            utc: false,
+          },
+          groupBy: ['project'],
+          query: 'event.type:transaction',
+          projects: [1],
+          environments: ['prod'],
+          mri: 'c:custom/login@second',
+          op: 'p95',
+        },
+        MetricDisplayType.AREA
+      )
+    ).toEqual({
+      title: 'DDM Widget',
+      displayType: DisplayType.AREA,
+      widgetType: 'custom-metrics',
+      limit: 10,
+      queries: [
+        {
+          name: '',
+          aggregates: ['p95(c:custom/login@second)'],
+          columns: ['project'],
+          fields: ['p95(c:custom/login@second)'],
+          conditions: 'event.type:transaction',
+          orderby: '',
+        },
+      ],
+    });
+  });
+
+  it('should convert a metrics query to a dashboard widget (transaction mri, with grouping)', () => {
+    expect(
+      convertToDashboardWidget(
+        {
+          datetime: {
+            start: '2021-06-01T00:00:00',
+            end: '2021-06-02T00:00:00',
+            period: '1d',
+            utc: false,
+          },
+          groupBy: [],
+          query: '',
+          projects: [1],
+          environments: ['prod'],
+          mri: 'd:transactions/measurements.duration@second',
+          op: 'p95',
+        },
+        MetricDisplayType.BAR
+      )
+    ).toEqual({
+      title: 'DDM Widget',
+      displayType: DisplayType.BAR,
+      widgetType: 'discover',
+      limit: 1,
+      queries: [
+        {
+          name: '',
+          aggregates: ['p95(measurements.duration)'],
+          columns: [],
+          fields: ['p95(measurements.duration)'],
+          conditions: '',
+          orderby: '',
+        },
+      ],
+    });
+  });
+});

+ 69 - 0
static/app/utils/metrics/dashboard.tsx

@@ -0,0 +1,69 @@
+import {urlEncode} from '@sentry/utils';
+
+import {
+  getFieldFromMetricsQuery,
+  isCustomMetric,
+  MetricDisplayType,
+  MetricsQuery,
+} from 'sentry/utils/metrics';
+import {DashboardWidgetSource, Widget, WidgetType} from 'sentry/views/dashboards/types';
+
+export function convertToDashboardWidget(
+  metricsQuery: MetricsQuery,
+  displayType?: MetricDisplayType
+): Widget {
+  const isCustomMetricQuery = isCustomMetric(metricsQuery);
+
+  return {
+    title: 'DDM Widget',
+    // @ts-expect-error this is a valid widget type
+    displayType,
+    widgetType: isCustomMetricQuery ? WidgetType.METRICS : WidgetType.DISCOVER,
+    limit: !metricsQuery.groupBy?.length ? 1 : 10,
+    queries: [getWidgetQuery(metricsQuery)],
+  };
+}
+
+export function getWidgetQuery(metricsQuery: MetricsQuery) {
+  const field = getFieldFromMetricsQuery(metricsQuery);
+
+  return {
+    name: '',
+    aggregates: [field],
+    columns: metricsQuery.groupBy ?? [],
+    fields: [field],
+    conditions: metricsQuery.query ?? '',
+    orderby: '',
+  };
+}
+
+export function encodeWidgetQuery(query) {
+  return urlEncode({
+    ...query,
+    aggregates: query.aggregates.join(','),
+    fields: query.fields?.join(','),
+    columns: query.columns.join(','),
+  });
+}
+
+export function getWidgetAsQueryParams(
+  metricsQuery: MetricsQuery,
+  urlWidgetQuery: string,
+  displayType?: MetricDisplayType
+) {
+  const {start, end, period} = metricsQuery.datetime;
+  const {projects} = metricsQuery;
+
+  return {
+    source: DashboardWidgetSource.DDM,
+    start,
+    end,
+    statsPeriod: period,
+    defaultWidgetQuery: urlWidgetQuery,
+    defaultTableColumns: [],
+    defaultTitle: 'DDM Widget',
+    environment: metricsQuery.environments,
+    displayType,
+    project: projects,
+  };
+}

+ 8 - 70
static/app/views/ddm/contextMenu.tsx

@@ -1,6 +1,5 @@
 import {useMemo} from 'react';
 import styled from '@emotion/styled';
-import {urlEncode} from '@sentry/utils';
 
 import {openAddToDashboardModal, openModal} from 'sentry/actionCreators/modal';
 import {DropdownMenu, MenuItemProps} from 'sentry/components/dropdownMenu';
@@ -8,17 +7,16 @@ import {IconCopy, IconDashboard, IconDelete, IconEllipsis, IconSiren} from 'sent
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {Organization} from 'sentry/types';
+import {isCustomMeasurement, MetricDisplayType, MetricsQuery} from 'sentry/utils/metrics';
 import {
-  getFieldFromMetricsQuery,
-  isCustomMeasurement,
-  isCustomMetric,
-  MetricDisplayType,
-  MetricsQuery,
-} from 'sentry/utils/metrics';
+  convertToDashboardWidget,
+  encodeWidgetQuery,
+  getWidgetAsQueryParams,
+  getWidgetQuery,
+} from 'sentry/utils/metrics/dashboard';
 import {hasDDMFeature} from 'sentry/utils/metrics/features';
 import useOrganization from 'sentry/utils/useOrganization';
 import useRouter from 'sentry/utils/useRouter';
-import {DashboardWidgetSource, WidgetType} from 'sentry/views/dashboards/types';
 import {useDDMContext} from 'sentry/views/ddm/context';
 import {CreateAlertModal} from 'sentry/views/ddm/createAlertModal';
 import {OrganizationContext} from 'sentry/views/organizationContext';
@@ -125,7 +123,6 @@ export function useCreateDashboardWidget(
 ) {
   const router = useRouter();
   const {projects, environments, datetime} = metricsQuery;
-  const isCustomMetricQuery = isCustomMetric(metricsQuery);
 
   return useMemo(() => {
     if (!metricsQuery.mri || !metricsQuery.op || isCustomMeasurement(metricsQuery)) {
@@ -148,69 +145,10 @@ export function useCreateDashboardWidget(
           environments,
           datetime,
         },
-        widget: {
-          title: 'DDM Widget',
-          displayType,
-          widgetType: isCustomMetricQuery ? WidgetType.METRICS : WidgetType.DISCOVER,
-          limit: !metricsQuery.groupBy?.length ? 1 : 10,
-          queries: [widgetQuery],
-        },
+        widget: convertToDashboardWidget(metricsQuery, displayType),
         router,
         widgetAsQueryParams,
         location: router.location,
       });
-  }, [
-    isCustomMetricQuery,
-    metricsQuery,
-    datetime,
-    displayType,
-    environments,
-    organization,
-    projects,
-    router,
-  ]);
-}
-
-function getWidgetQuery(metricsQuery: MetricsQuery) {
-  const field = getFieldFromMetricsQuery(metricsQuery);
-
-  return {
-    name: '',
-    aggregates: [field],
-    columns: metricsQuery.groupBy ?? [],
-    fields: [field],
-    conditions: metricsQuery.query ?? '',
-    orderby: '',
-  };
-}
-
-function encodeWidgetQuery(query) {
-  return urlEncode({
-    ...query,
-    aggregates: query.aggregates.join(','),
-    fields: query.fields?.join(','),
-    columns: query.columns.join(','),
-  });
-}
-
-function getWidgetAsQueryParams(
-  metricsQuery: MetricsQuery,
-  urlWidgetQuery: string,
-  displayType?: MetricDisplayType
-) {
-  const {start, end, period} = metricsQuery.datetime;
-  const {projects} = metricsQuery;
-
-  return {
-    source: DashboardWidgetSource.DDM,
-    start,
-    end,
-    statsPeriod: period,
-    defaultWidgetQuery: urlWidgetQuery,
-    defaultTableColumns: [],
-    defaultTitle: 'DDM Widget',
-    environment: metricsQuery.environments,
-    displayType,
-    project: projects,
-  };
+  }, [metricsQuery, datetime, displayType, environments, organization, projects, router]);
 }

+ 14 - 1
static/app/views/ddm/scratchpadSelector.tsx

@@ -12,7 +12,7 @@ import {openConfirmModal} from 'sentry/components/confirm';
 import InputControl from 'sentry/components/input';
 import {Overlay, PositionWrapper} from 'sentry/components/overlay';
 import {Tooltip} from 'sentry/components/tooltip';
-import {IconBookmark, IconDelete, IconStar} from 'sentry/icons';
+import {IconBookmark, IconDashboard, IconDelete, IconStar} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {trackAnalytics} from 'sentry/utils/analytics';
@@ -23,6 +23,8 @@ import useOrganization from 'sentry/utils/useOrganization';
 import useOverlay from 'sentry/utils/useOverlay';
 import useRouter from 'sentry/utils/useRouter';
 
+import {useCreateDashboard} from './useCreateDashboard';
+
 type Scratchpad = {
   id: string;
   name: string;
@@ -160,6 +162,7 @@ export function useScratchpads() {
 export function ScratchpadSelector() {
   const scratchpads = useScratchpads();
   const organization = useOrganization();
+  const createDashboard = useCreateDashboard();
 
   const isDefault = useCallback(
     scratchpad => scratchpads.default === scratchpad.id,
@@ -231,8 +234,18 @@ export function ScratchpadSelector() {
     [scratchpads, isDefault, organization]
   );
 
+  const selectedScratchpad = scratchpads.selected
+    ? scratchpads.all[scratchpads.selected]
+    : undefined;
+
   return (
     <ScratchpadGroup>
+      <Button
+        icon={<IconDashboard />}
+        onClick={() => createDashboard(selectedScratchpad)}
+      >
+        {t('Save to Dashboard')}
+      </Button>
       <SaveAsDropdown
         onSave={name => {
           scratchpads.add(name);

+ 41 - 0
static/app/views/ddm/useCreateDashboard.tsx

@@ -0,0 +1,41 @@
+import {useMemo} from 'react';
+
+import {openCreateDashboardFromScratchpad} from 'sentry/actionCreators/modal';
+import {convertToDashboardWidget} from 'sentry/utils/metrics/dashboard';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import useRouter from 'sentry/utils/useRouter';
+import {useDDMContext} from 'sentry/views/ddm/context';
+
+export function useCreateDashboard() {
+  const router = useRouter();
+  const organization = useOrganization();
+  const {widgets} = useDDMContext();
+  const {selection} = usePageFilters();
+
+  return useMemo(() => {
+    return function (scratchpad?: {name: string}) {
+      const newDashboard = {
+        title: scratchpad?.name || 'DDM Dashboard',
+        description: '',
+        widgets: widgets
+          .filter(widget => !!widget.mri)
+          .map(widget =>
+            // @ts-expect-error TODO(ogi): fix this
+            convertToDashboardWidget(widget, widget.displayType)
+          ),
+        projects: selection.projects,
+        environment: selection.environments,
+        start: selection.datetime.start as string,
+        end: selection.datetime.end as string,
+        period: selection.datetime.period as string,
+        filters: {},
+        utc: selection.datetime.utc ?? false,
+        id: 'ddm-scratchpad',
+        dateCreated: '',
+      };
+
+      openCreateDashboardFromScratchpad({newDashboard, router, organization});
+    };
+  }, [selection, widgets, organization, router]);
+}