Browse Source

feat(new-widget-builder-experience): Add to Dashboard Modal with Preview (#33398)

Nar Saynorath 2 years ago
parent
commit
6afcf19f8c

+ 28 - 1
static/app/actionCreators/dashboards.tsx

@@ -3,9 +3,36 @@ import omit from 'lodash/omit';
 import {addErrorMessage} from 'sentry/actionCreators/indicator';
 import {Client} from 'sentry/api';
 import {t} from 'sentry/locale';
-import {DashboardDetails, Widget} from 'sentry/views/dashboardsV2/types';
+import {
+  DashboardDetails,
+  DashboardListItem,
+  Widget,
+} from 'sentry/views/dashboardsV2/types';
 import {flattenErrors} from 'sentry/views/dashboardsV2/utils';
 
+export function fetchDashboards(api: Client, orgSlug: string) {
+  const promise: Promise<DashboardListItem[]> = api.requestPromise(
+    `/organizations/${orgSlug}/dashboards/`,
+    {
+      method: 'GET',
+      query: {sort: 'myDashboardsAndRecentlyViewed'},
+    }
+  );
+
+  promise.catch(response => {
+    const errorResponse = response?.responseJSON ?? null;
+
+    if (errorResponse) {
+      const errors = flattenErrors(errorResponse, {});
+      addErrorMessage(errors[Object.keys(errors)[0]]);
+    } else {
+      addErrorMessage(t('Unable to fetch dashboards'));
+    }
+  });
+
+  return promise;
+}
+
 export function createDashboard(
   api: Client,
   orgId: string,

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

@@ -251,6 +251,13 @@ export async function openWidgetBuilderOverwriteModal(
   openModal(deps => <Modal {...deps} {...options} />, {backdrop: 'static', modalCss});
 }
 
+export async function openAddToDashboardModal(options) {
+  const mod = await import('sentry/components/modals/widgetBuilder/addToDashboardModal');
+  const {default: Modal, modalCss} = mod;
+
+  openModal(deps => <Modal {...deps} {...options} />, {backdrop: 'static', modalCss});
+}
+
 export async function openReprocessEventModal({
   onClose,
   ...options

+ 160 - 0
static/app/components/modals/widgetBuilder/addToDashboardModal.tsx

@@ -0,0 +1,160 @@
+import {Fragment, useEffect, useState} from 'react';
+import {OptionProps} from 'react-select';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+
+import {fetchDashboards} from 'sentry/actionCreators/dashboards';
+import {ModalRenderProps} from 'sentry/actionCreators/modal';
+import Button from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
+import SelectControl from 'sentry/components/forms/selectControl';
+import SelectOption from 'sentry/components/forms/selectOption';
+import Tooltip from 'sentry/components/tooltip';
+import {t, tct} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {Organization, PageFilters, SelectValue} from 'sentry/types';
+import useApi from 'sentry/utils/useApi';
+import {DashboardListItem, MAX_WIDGETS, Widget} from 'sentry/views/dashboardsV2/types';
+import WidgetCard from 'sentry/views/dashboardsV2/widgetCard';
+
+export type AddToDashboardModalProps = {
+  organization: Organization;
+  selection: PageFilters;
+  widget: Widget;
+};
+
+type Props = ModalRenderProps & AddToDashboardModalProps;
+
+function AddToDashboardModal({
+  Header,
+  Body,
+  Footer,
+  closeModal,
+  organization,
+  selection,
+  widget,
+}: Props) {
+  const api = useApi();
+  const [dashboards, setDashboards] = useState<DashboardListItem[] | null>(null);
+  const [selectedDashboardId, setSelectedDashboardId] = useState<string | null>(null);
+
+  useEffect(() => {
+    fetchDashboards(api, organization.slug).then(setDashboards);
+  }, []);
+
+  function handleGoToBuilder() {
+    closeModal();
+    return;
+  }
+
+  function handleAddAndStayInDiscover() {
+    closeModal();
+    return;
+  }
+
+  const canSubmit = selectedDashboardId !== null;
+
+  return (
+    <Fragment>
+      <Header closeButton>
+        <h4>{t('Add to Dashboard')}</h4>
+      </Header>
+
+      <Body>
+        <SelectControlWrapper>
+          <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,
+                  isDisabled: widgetDisplay.length >= MAX_WIDGETS,
+                })),
+              ]
+            }
+            onChange={(option: SelectValue<string>) => {
+              if (option.disabled) {
+                return;
+              }
+              setSelectedDashboardId(option.value);
+            }}
+            components={{
+              Option: ({label, data, ...optionProps}: OptionProps<any>) => (
+                <Tooltip
+                  disabled={!!!data.isDisabled}
+                  title={tct('Max widgets ([maxWidgets]) per dashboard reached.', {
+                    maxWidgets: MAX_WIDGETS,
+                  })}
+                  containerDisplayMode="block"
+                  position="right"
+                >
+                  <SelectOption label={label} data={data} {...(optionProps as any)} />
+                </Tooltip>
+              ),
+            }}
+          />
+        </SelectControlWrapper>
+        {t('This is a preview of how the widget will appear in your dashboard.')}
+        <WidgetCard
+          api={api}
+          organization={organization}
+          currentWidgetDragging={false}
+          isEditing={false}
+          isSorting={false}
+          widgetLimitReached={false}
+          selection={selection}
+          widget={widget}
+        />
+      </Body>
+
+      <Footer>
+        <StyledButtonBar gap={1.5}>
+          <Button
+            onClick={handleAddAndStayInDiscover}
+            disabled={!canSubmit}
+            title={canSubmit ? undefined : t('Select a dashboard')}
+          >
+            {t('Add + Stay in Discover')}
+          </Button>
+          <Button
+            priority="primary"
+            onClick={handleGoToBuilder}
+            disabled={!canSubmit}
+            title={canSubmit ? undefined : t('Select a dashboard')}
+          >
+            {t('Open in Widget Builder')}
+          </Button>
+        </StyledButtonBar>
+      </Footer>
+    </Fragment>
+  );
+}
+
+export default AddToDashboardModal;
+
+const SelectControlWrapper = styled('div')`
+  margin-bottom: ${space(2)};
+`;
+
+const StyledButtonBar = styled(ButtonBar)`
+  @media (max-width: ${props => props.theme.breakpoints[0]}) {
+    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;
+`;

+ 4 - 4
static/app/views/dashboardsV2/detail.tsx

@@ -571,8 +571,8 @@ class DashboardDetail extends Component<Props, State> {
     }));
   };
 
-  renderWidgetBuilder(dashboard: DashboardDetails) {
-    const {children} = this.props;
+  renderWidgetBuilder() {
+    const {children, dashboard} = this.props;
     const {modifiedDashboard} = this.state;
 
     return isValidElement(children)
@@ -760,10 +760,10 @@ class DashboardDetail extends Component<Props, State> {
   }
 
   render() {
-    const {organization, dashboard} = this.props;
+    const {organization} = this.props;
 
     if (this.isWidgetBuilderRouter) {
-      return this.renderWidgetBuilder(dashboard);
+      return this.renderWidgetBuilder();
     }
 
     if (organization.features.includes('dashboards-edit')) {

+ 1 - 1
static/app/views/dashboardsV2/view.tsx

@@ -51,7 +51,7 @@ function ViewEditDashboard(props: Props) {
         query: pick(location.query, ALLOWED_PARAMS),
       });
     }
-  }, [api, orgSlug, dashboardId]);
+  }, [api, orgSlug, dashboardId, location.query]);
 
   return (
     <DashboardBasicFeature organization={organization}>

+ 4 - 2
static/app/views/eventsV2/queryList.tsx

@@ -166,7 +166,8 @@ class QueryList extends React.Component<Props> {
         {
           key: 'add-to-dashboard',
           label: t('Add to Dashboard'),
-          ...(organization.features.includes('new-widget-builder-experience')
+          ...(organization.features.includes('new-widget-builder-experience') &&
+          !organization.features.includes('new-widget-builder-experience-design')
             ? {
                 to: constructAddQueryToDashboardLink({
                   eventView,
@@ -251,7 +252,8 @@ class QueryList extends React.Component<Props> {
               {
                 key: 'add-to-dashboard',
                 label: t('Add to Dashboard'),
-                ...(organization.features.includes('new-widget-builder-experience')
+                ...(organization.features.includes('new-widget-builder-experience') &&
+                !organization.features.includes('new-widget-builder-experience-design')
                   ? {
                       to: constructAddQueryToDashboardLink({
                         eventView,

+ 2 - 1
static/app/views/eventsV2/savedQuery/index.tsx

@@ -347,7 +347,8 @@ class SavedQueryButtonGroup extends React.PureComponent<Props, State> {
       <Button
         key="add-dashboard-widget-from-discover"
         data-test-id="add-dashboard-widget-from-discover"
-        {...(organization.features.includes('new-widget-builder-experience')
+        {...(organization.features.includes('new-widget-builder-experience') &&
+        !organization.features.includes('new-widget-builder-experience-design')
           ? {
               to: constructAddQueryToDashboardLink({
                 organization,

+ 27 - 1
static/app/views/eventsV2/utils.tsx

@@ -3,7 +3,10 @@ import {urlEncode} from '@sentry/utils';
 import {Location, Query} from 'history';
 import * as Papa from 'papaparse';
 
-import {openAddDashboardWidgetModal} from 'sentry/actionCreators/modal';
+import {
+  openAddDashboardWidgetModal,
+  openAddToDashboardModal,
+} from 'sentry/actionCreators/modal';
 import {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
 import {URL_PARAM} from 'sentry/constants/pageFilters';
 import {t} from 'sentry/locale';
@@ -603,6 +606,29 @@ export function handleAddQueryToDashboard({
     yAxis,
   });
 
+  if (organization.features.includes('new-widget-builder-experience-design')) {
+    openAddToDashboardModal({
+      organization,
+      selection: {
+        projects: eventView.project,
+        environments: eventView.environment,
+        datetime: {
+          start: eventView.start,
+          end: eventView.end,
+          period: eventView.statsPeriod,
+          utc: eventView.utc,
+        },
+      },
+      widget: {
+        title: query?.name ?? eventView?.name ?? '',
+        displayType,
+        queries: [defaultWidgetQuery],
+        interval: eventView.interval,
+      },
+    });
+    return;
+  }
+
   openAddDashboardWidgetModal({
     organization,
     start: eventView.start,

+ 178 - 0
tests/js/spec/components/modals/addToDashboardModal.spec.tsx

@@ -0,0 +1,178 @@
+import selectEvent from 'react-select-event';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import {ModalRenderProps} from 'sentry/actionCreators/modal';
+import AddToDashboardModal from 'sentry/components/modals/widgetBuilder/addToDashboardModal';
+import {DashboardDetails, DisplayType} from 'sentry/views/dashboardsV2/types';
+
+const stubEl = (props: {children?: React.ReactNode}) => <div>{props.children}</div>;
+
+describe('add to dashboard modal', () => {
+  let eventsStatsMock;
+  const initialData = initializeOrg({
+    ...initializeOrg(),
+    organization: {
+      features: ['new-widget-builder-experience', 'new-widget-builder-experience-design'],
+    },
+  });
+  const testDashboard: DashboardDetails = {
+    id: '1',
+    title: 'Test Dashboard',
+    createdBy: undefined,
+    dateCreated: '2020-01-01T00:00:00.000Z',
+    widgets: [],
+  };
+  const widget = {
+    title: 'Test title',
+    description: 'Test description',
+    displayType: DisplayType.LINE,
+    interval: '5m',
+    queries: [
+      {
+        conditions: '',
+        fields: ['count()'],
+        aggregates: ['count()'],
+        fieldAliases: [],
+        columns: [],
+        orderby: '',
+        name: '',
+      },
+    ],
+  };
+  const defaultSelection = {
+    projects: [],
+    environments: [],
+    datetime: {
+      start: null,
+      end: null,
+      period: '24h',
+      utc: false,
+    },
+  };
+
+  beforeEach(() => {
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/dashboards/',
+      body: [{...testDashboard, widgetDisplay: [DisplayType.AREA]}],
+    });
+
+    eventsStatsMock = MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/events-stats/',
+      body: [],
+    });
+  });
+
+  it('renders with the widget title and description', async function () {
+    render(
+      <AddToDashboardModal
+        Header={stubEl}
+        Footer={stubEl as ModalRenderProps['Footer']}
+        Body={stubEl as ModalRenderProps['Body']}
+        CloseButton={stubEl}
+        closeModal={() => undefined}
+        organization={initialData.organization}
+        widget={widget}
+        selection={defaultSelection}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Select Dashboard')).toBeEnabled();
+    });
+
+    expect(screen.getByText('Test title')).toBeInTheDocument();
+    expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
+    expect(
+      screen.getByText(
+        'This is a preview of how the widget will appear in your dashboard.'
+      )
+    ).toBeInTheDocument();
+    expect(screen.getByRole('button', {name: 'Add + Stay in Discover'})).toBeDisabled();
+    expect(screen.getByRole('button', {name: 'Open in Widget Builder'})).toBeDisabled();
+  });
+
+  it('enables the buttons when a dashboard is selected', async function () {
+    render(
+      <AddToDashboardModal
+        Header={stubEl}
+        Footer={stubEl as ModalRenderProps['Footer']}
+        Body={stubEl as ModalRenderProps['Body']}
+        CloseButton={stubEl}
+        closeModal={() => undefined}
+        organization={initialData.organization}
+        widget={widget}
+        selection={defaultSelection}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Select Dashboard')).toBeEnabled();
+    });
+
+    expect(screen.getByRole('button', {name: 'Add + Stay in Discover'})).toBeDisabled();
+    expect(screen.getByRole('button', {name: 'Open in Widget Builder'})).toBeDisabled();
+
+    await selectEvent.select(screen.getByText('Select Dashboard'), 'Test Dashboard');
+
+    expect(screen.getByRole('button', {name: 'Add + Stay in Discover'})).toBeEnabled();
+    expect(screen.getByRole('button', {name: 'Open in Widget Builder'})).toBeEnabled();
+  });
+
+  it('includes a New Dashboard option in the selector with saved dashboards', async function () {
+    render(
+      <AddToDashboardModal
+        Header={stubEl}
+        Footer={stubEl as ModalRenderProps['Footer']}
+        Body={stubEl as ModalRenderProps['Body']}
+        CloseButton={stubEl}
+        closeModal={() => undefined}
+        organization={initialData.organization}
+        widget={widget}
+        selection={defaultSelection}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Select Dashboard')).toBeEnabled();
+    });
+
+    selectEvent.openMenu(screen.getByText('Select Dashboard'));
+    expect(screen.getByText('+ Create New Dashboard')).toBeInTheDocument();
+    expect(screen.getByText('Test Dashboard')).toBeInTheDocument();
+  });
+
+  it('calls the events stats endpoint with the query and selection values', async function () {
+    render(
+      <AddToDashboardModal
+        Header={stubEl}
+        Footer={stubEl as ModalRenderProps['Footer']}
+        Body={stubEl as ModalRenderProps['Body']}
+        CloseButton={stubEl}
+        closeModal={() => undefined}
+        organization={initialData.organization}
+        widget={widget}
+        selection={defaultSelection}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Select Dashboard')).toBeEnabled();
+    });
+
+    expect(eventsStatsMock).toHaveBeenCalledWith(
+      '/organizations/org-slug/events-stats/',
+      expect.objectContaining({
+        query: expect.objectContaining({
+          environment: [],
+          project: [],
+          interval: '5m',
+          orderby: '',
+          statsPeriod: '24h',
+          yAxis: ['count()'],
+        }),
+      })
+    );
+  });
+});