Browse Source

feat(dashboards): Added query selector to dashboard open in discover button (#28415)

Clicking Open in Discover on a dashboard widget now displays a query selector modal to the user if the widget has more than 1 query.
edwardgou-sentry 3 years ago
parent
commit
1260d5ea4a

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

@@ -3,6 +3,7 @@ import * as React from 'react';
 import ModalActions from 'app/actions/modalActions';
 import ModalActions from 'app/actions/modalActions';
 import GlobalModal from 'app/components/globalModal';
 import GlobalModal from 'app/components/globalModal';
 import type {DashboardWidgetModalOptions} from 'app/components/modals/addDashboardWidgetModal';
 import type {DashboardWidgetModalOptions} from 'app/components/modals/addDashboardWidgetModal';
+import type {DashboardWidgetQuerySelectorModalOptions} from 'app/components/modals/dashboardWidgetQuerySelectorModal';
 import {InviteRow} from 'app/components/modals/inviteMembersModal/types';
 import {InviteRow} from 'app/components/modals/inviteMembersModal/types';
 import type {ReprocessEventModalOptions} from 'app/components/modals/reprocessEventModal';
 import type {ReprocessEventModalOptions} from 'app/components/modals/reprocessEventModal';
 import {AppStoreConnectContextProps} from 'app/components/projects/appStoreConnectContext';
 import {AppStoreConnectContextProps} from 'app/components/projects/appStoreConnectContext';
@@ -251,3 +252,12 @@ export async function demoSignupModal(options: ModalOptions = {}) {
 
 
   openModal(deps => <Modal {...deps} {...options} />, {modalCss});
   openModal(deps => <Modal {...deps} {...options} />, {modalCss});
 }
 }
+
+export async function openDashboardWidgetQuerySelectorModal(
+  options: DashboardWidgetQuerySelectorModalOptions
+) {
+  const mod = await import('app/components/modals/dashboardWidgetQuerySelectorModal');
+  const {default: Modal, modalCss} = mod;
+
+  openModal(deps => <Modal {...deps} {...options} />, {backdrop: 'static', modalCss});
+}

+ 129 - 0
static/app/components/modals/dashboardWidgetQuerySelectorModal.tsx

@@ -0,0 +1,129 @@
+import * as React from 'react';
+import {browserHistory} from 'react-router';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+
+import {ModalRenderProps} from 'app/actionCreators/modal';
+import {Client} from 'app/api';
+import Button from 'app/components/button';
+import {IconChevron, IconSearch} from 'app/icons';
+import {t} from 'app/locale';
+import space from 'app/styles/space';
+import {GlobalSelection, Organization} from 'app/types';
+import withApi from 'app/utils/withApi';
+import withGlobalSelection from 'app/utils/withGlobalSelection';
+import {Widget} from 'app/views/dashboardsV2/types';
+import {eventViewFromWidget} from 'app/views/dashboardsV2/utils';
+import Input from 'app/views/settings/components/forms/controls/input';
+
+export type DashboardWidgetQuerySelectorModalOptions = {
+  organization: Organization;
+  widget: Widget;
+};
+
+type Props = ModalRenderProps &
+  DashboardWidgetQuerySelectorModalOptions & {
+    api: Client;
+    organization: Organization;
+    selection: GlobalSelection;
+  };
+
+class DashboardWidgetQuerySelectorModal extends React.Component<Props> {
+  renderQueries() {
+    const {organization, widget, selection} = this.props;
+    const querySearchBars = widget.queries.map((query, index) => {
+      const eventView = eventViewFromWidget(
+        widget.title,
+        query,
+        selection,
+        widget.displayType
+      );
+      return (
+        <React.Fragment key={index}>
+          <QueryContainer>
+            <Container>
+              <SearchLabel htmlFor="smart-search-input" aria-label={t('Search events')}>
+                <IconSearch />
+              </SearchLabel>
+              <StyledInput value={query.conditions} disabled />
+            </Container>
+            <OpenInDiscoverButton
+              priority="primary"
+              icon={<IconChevron size="xs" direction="right" />}
+              onClick={() => {
+                browserHistory.push(eventView.getResultsViewUrlTarget(organization.slug));
+              }}
+            />
+          </QueryContainer>
+        </React.Fragment>
+      );
+    });
+    return querySearchBars;
+  }
+
+  render() {
+    const {Body, Header, widget} = this.props;
+    return (
+      <React.Fragment>
+        <Header closeButton>
+          <h4>{widget.title}</h4>
+        </Header>
+        <Body>
+          <p>
+            {t(
+              'Multiple queries were used to create this widget visualization. Which query would you like to view in Discover?'
+            )}
+          </p>
+          {this.renderQueries()}
+        </Body>
+      </React.Fragment>
+    );
+  }
+}
+
+const StyledInput = styled(Input)`
+  text-overflow: ellipsis;
+  padding: 0px;
+  box-shadow: none;
+  height: auto;
+  &:disabled {
+    border: none;
+    cursor: default;
+  }
+`;
+const QueryContainer = styled('div')`
+  display: flex;
+  margin-bottom: ${space(1)};
+`;
+const OpenInDiscoverButton = styled(Button)`
+  margin-left: ${space(1)};
+`;
+
+const Container = styled('div')`
+  border: 1px solid ${p => p.theme.border};
+  box-shadow: inset ${p => p.theme.dropShadowLight};
+  background: ${p => p.theme.backgroundSecondary};
+  padding: 7px ${space(1)};
+  position: relative;
+  display: grid;
+  grid-template-columns: max-content 1fr max-content;
+  grid-gap: ${space(1)};
+  align-items: start;
+  flex-grow: 1;
+  border-radius: ${p => p.theme.borderRadius};
+`;
+
+const SearchLabel = styled('label')`
+  display: flex;
+  padding: ${space(0.5)} 0;
+  margin: 0;
+  color: ${p => p.theme.gray300};
+`;
+
+export const modalCss = css`
+  width: 100%;
+  max-width: 700px;
+  margin: 70px auto;
+`;
+
+export default withApi(withGlobalSelection(DashboardWidgetQuerySelectorModal));

+ 13 - 12
static/app/views/dashboardsV2/widgetCard.tsx

@@ -5,6 +5,7 @@ import styled from '@emotion/styled';
 import {Location} from 'history';
 import {Location} from 'history';
 import isEqual from 'lodash/isEqual';
 import isEqual from 'lodash/isEqual';
 
 
+import {openDashboardWidgetQuerySelectorModal} from 'app/actionCreators/modal';
 import {Client} from 'app/api';
 import {Client} from 'app/api';
 import {HeaderTitle} from 'app/components/charts/styles';
 import {HeaderTitle} from 'app/components/charts/styles';
 import ErrorBoundary from 'app/components/errorBoundary';
 import ErrorBoundary from 'app/components/errorBoundary';
@@ -21,10 +22,10 @@ import {trackAnalyticsEvent} from 'app/utils/analytics';
 import withApi from 'app/utils/withApi';
 import withApi from 'app/utils/withApi';
 import withGlobalSelection from 'app/utils/withGlobalSelection';
 import withGlobalSelection from 'app/utils/withGlobalSelection';
 import withOrganization from 'app/utils/withOrganization';
 import withOrganization from 'app/utils/withOrganization';
+import {eventViewFromWidget} from 'app/views/dashboardsV2/utils';
 
 
 import ContextMenu from './contextMenu';
 import ContextMenu from './contextMenu';
 import {Widget} from './types';
 import {Widget} from './types';
-import {eventViewFromWidget} from './utils';
 import WidgetCardChart from './widgetCardChart';
 import WidgetCardChart from './widgetCardChart';
 import WidgetQueries from './widgetQueries';
 import WidgetQueries from './widgetQueries';
 
 
@@ -120,16 +121,6 @@ class WidgetCard extends React.Component<Props> {
       // Open table widget in Discover
       // Open table widget in Discover
 
 
       if (widget.queries.length) {
       if (widget.queries.length) {
-        // We expect Table widgets to have only one query.
-        const query = widget.queries[0];
-
-        const eventView = eventViewFromWidget(
-          widget.title,
-          query,
-          selection,
-          widget.displayType
-        );
-
         menuOptions.push(
         menuOptions.push(
           <MenuItem
           <MenuItem
             key="open-discover"
             key="open-discover"
@@ -140,7 +131,17 @@ class WidgetCard extends React.Component<Props> {
                 eventName: 'Dashboards2: Table Widget - Open in Discover',
                 eventName: 'Dashboards2: Table Widget - Open in Discover',
                 organization_id: parseInt(this.props.organization.id, 10),
                 organization_id: parseInt(this.props.organization.id, 10),
               });
               });
-              browserHistory.push(eventView.getResultsViewUrlTarget(organization.slug));
+              if (widget.queries.length === 1) {
+                const eventView = eventViewFromWidget(
+                  widget.title,
+                  widget.queries[0],
+                  selection,
+                  widget.displayType
+                );
+                browserHistory.push(eventView.getResultsViewUrlTarget(organization.slug));
+              } else {
+                openDashboardWidgetQuerySelectorModal({organization, widget});
+              }
             }}
             }}
           >
           >
             {t('Open in Discover')}
             {t('Open in Discover')}

+ 172 - 0
tests/js/spec/components/modals/dashboardWidgetQuerySelectorModal.spec.tsx

@@ -0,0 +1,172 @@
+import {browserHistory} from 'react-router';
+
+import {mountWithTheme} from 'sentry-test/enzyme';
+import {initializeOrg} from 'sentry-test/initializeOrg';
+
+import {Client} from 'app/api';
+import DashboardWidgetQuerySelectorModal from 'app/components/modals/dashboardWidgetQuerySelectorModal';
+import {t} from 'app/locale';
+import {DisplayType} from 'app/views/dashboardsV2/types';
+
+const stubEl = props => <div>{props.children}</div>;
+const api: Client = new Client();
+
+function mountModal({initialData, widget}) {
+  return mountWithTheme(
+    <DashboardWidgetQuerySelectorModal
+      Header={stubEl}
+      // @ts-expect-error
+      Footer={stubEl}
+      // @ts-expect-error
+      Body={stubEl}
+      organization={initialData.organization}
+      widget={widget}
+      api={api}
+    />,
+    initialData.routerContext
+  );
+}
+
+describe('Modals -> AddDashboardWidgetModal', function () {
+  const initialData = initializeOrg({
+    organization: {
+      features: ['performance-view', 'discover-query'],
+      apdexThreshold: 400,
+    },
+    router: {},
+    project: 1,
+    projects: [],
+  });
+  let mockQuery;
+  let mockWidget;
+
+  beforeEach(function () {
+    mockQuery = {
+      conditions: 'title:/organizations/:orgId/performance/summary/',
+      fields: ['count()', 'failure_count()'],
+      id: '1',
+      name: 'Query Name',
+      orderby: '',
+    };
+
+    mockWidget = {
+      title: 'Test Widget',
+      displayType: DisplayType.AREA,
+      interval: '5m',
+      queries: [mockQuery],
+    };
+    // @ts-expect-error
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/dashboards/widgets/',
+      method: 'POST',
+      statusCode: 200,
+      body: [],
+    });
+    // @ts-expect-error
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/events-stats/',
+      body: [],
+    });
+    // @ts-expect-error
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/eventsv2/',
+      body: {data: [{'event.type': 'error'}], meta: {'event.type': 'string'}},
+    });
+    // @ts-expect-error
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/events-geo/',
+      body: {data: [], meta: {}},
+    });
+    // @ts-expect-error
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/recent-searches/',
+      body: [],
+    });
+    // @ts-expect-error
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/dashboards/',
+      body: [{id: '1', title: t('Test Dashboard')}],
+    });
+  });
+
+  afterEach(() => {
+    // @ts-expect-error
+    MockApiClient.clearMockResponses();
+  });
+
+  it('renders a single query selection when the widget only has one query', async function () {
+    const wrapper = mountModal({initialData, widget: mockWidget});
+    // @ts-expect-error
+    await tick();
+    expect(wrapper.find('StyledInput').length).toEqual(1);
+    expect(wrapper.find('StyledInput').props().value).toEqual(
+      'title:/organizations/:orgId/performance/summary/'
+    );
+    expect(wrapper.find('OpenInDiscoverButton').length).toEqual(1);
+    wrapper.unmount();
+  });
+
+  it('renders a multiple query selections when the widget only has multiple queries', async function () {
+    mockWidget.queries.push({
+      ...mockQuery,
+      conditions: 'title:/organizations/:orgId/performance/',
+      id: '2',
+    });
+    mockWidget.queries.push({
+      ...mockQuery,
+      conditions: 'title:/organizations/:orgId/',
+      id: '3',
+    });
+    const wrapper = mountModal({initialData, widget: mockWidget});
+    // @ts-expect-error
+    await tick();
+    expect(wrapper.find('StyledInput').length).toEqual(3);
+    expect(wrapper.find('StyledInput').at(0).props().value).toEqual(
+      'title:/organizations/:orgId/performance/summary/'
+    );
+    expect(wrapper.find('StyledInput').at(1).props().value).toEqual(
+      'title:/organizations/:orgId/performance/'
+    );
+    expect(wrapper.find('StyledInput').at(2).props().value).toEqual(
+      'title:/organizations/:orgId/'
+    );
+    expect(wrapper.find('OpenInDiscoverButton').length).toEqual(3);
+    wrapper.unmount();
+  });
+
+  it('links user to the query in discover when a query is selected from the modal', async function () {
+    const wrapper = mountModal({initialData, widget: mockWidget});
+    // @ts-expect-error
+    await tick();
+    wrapper.find('OpenInDiscoverButton').simulate('click');
+    expect(browserHistory.push).toHaveBeenCalledWith(
+      expect.objectContaining({
+        pathname: '/organizations/org-slug/discover/results/',
+        query: expect.objectContaining({
+          field: ['count()', 'failure_count()'],
+          name: 'Test Widget',
+          query: 'title:/organizations/:orgId/performance/summary/',
+        }),
+      })
+    );
+    wrapper.unmount();
+  });
+
+  it('links user to the query in discover with additional field when a world map query is selected from the modal', async function () {
+    mockWidget.queries[0].fields = ['count()'];
+    mockWidget.displayType = DisplayType.WORLD_MAP;
+    const wrapper = mountModal({initialData, widget: mockWidget});
+    // @ts-expect-error
+    await tick();
+    wrapper.find('OpenInDiscoverButton').simulate('click');
+    expect(browserHistory.push).toHaveBeenCalledWith({
+      pathname: '/organizations/org-slug/discover/results/',
+      query: expect.objectContaining({
+        field: ['geo.country_code', 'count()'],
+        name: 'Test Widget',
+        query: 'title:/organizations/:orgId/performance/summary/ has:geo.country_code',
+      }),
+    });
+    wrapper.unmount();
+  });
+});

+ 6 - 42
tests/js/spec/views/dashboardsV2/widgetCard.spec.jsx

@@ -3,6 +3,7 @@ import {browserHistory} from 'react-router';
 import {mountWithTheme} from 'sentry-test/enzyme';
 import {mountWithTheme} from 'sentry-test/enzyme';
 import {initializeOrg} from 'sentry-test/initializeOrg';
 import {initializeOrg} from 'sentry-test/initializeOrg';
 
 
+import * as modal from 'app/actionCreators/modal';
 import {Client} from 'app/api';
 import {Client} from 'app/api';
 import {t} from 'app/locale';
 import {t} from 'app/locale';
 import WidgetCard from 'app/views/dashboardsV2/widgetCard';
 import WidgetCard from 'app/views/dashboardsV2/widgetCard';
@@ -54,7 +55,8 @@ describe('Dashboards > WidgetCard', function () {
     MockApiClient.clearMockResponses();
     MockApiClient.clearMockResponses();
   });
   });
 
 
-  it('renders with Open in Discover button and properly redirects with query and field', async function () {
+  it('renders with Open in Discover button and opens the Query Selector Modal when clicked', async function () {
+    const spy = jest.spyOn(modal, 'openDashboardWidgetQuerySelectorModal');
     const wrapper = mountWithTheme(
     const wrapper = mountWithTheme(
       <WidgetCard
       <WidgetCard
         api={api}
         api={api}
@@ -80,47 +82,9 @@ describe('Dashboards > WidgetCard', function () {
     expect(menuOptions.length > 0).toBe(true);
     expect(menuOptions.length > 0).toBe(true);
     expect(menuOptions[0].props.children).toEqual(t('Open in Discover'));
     expect(menuOptions[0].props.children).toEqual(t('Open in Discover'));
     menuOptions[0].props.onClick(mockEvent);
     menuOptions[0].props.onClick(mockEvent);
-    expect(browserHistory.push).toHaveBeenCalledWith({
-      pathname: '/organizations/org-slug/discover/results/',
-      query: expect.objectContaining({
-        query: 'event.type:error',
-        field: ['count()'],
-      }),
-    });
-  });
-
-  it('renders and redirects correctly for World Map widgets', async function () {
-    const wrapper = mountWithTheme(
-      <WidgetCard
-        api={api}
-        organization={initialData.organization}
-        widget={{...multipleQueryWidget, displayType: 'world_map'}}
-        selection={selection}
-        isEditing={false}
-        onDelete={() => undefined}
-        onEdit={() => undefined}
-        renderErrorMessage={() => undefined}
-        isSorting={false}
-        currentWidgetDragging={false}
-        showContextMenu
-      >
-        {() => <div data-test-id="child" />}
-      </WidgetCard>,
-      initialData.routerContext
-    );
-
-    await tick();
-
-    const menuOptions = wrapper.find('ContextMenu').props().children;
-    expect(menuOptions.length > 0).toBe(true);
-    expect(menuOptions[0].props.children).toEqual(t('Open in Discover'));
-    menuOptions[0].props.onClick(mockEvent);
-    expect(browserHistory.push).toHaveBeenCalledWith({
-      pathname: '/organizations/org-slug/discover/results/',
-      query: expect.objectContaining({
-        query: 'event.type:error has:geo.country_code',
-        field: ['geo.country_code', 'count()'],
-      }),
+    expect(spy).toHaveBeenCalledWith({
+      organization: initialData.organization,
+      widget: multipleQueryWidget,
     });
     });
   });
   });
 });
 });