Browse Source

feat(widget-library): Add Library Widgets to Dashboard (#29881)

Adds the ability to select multiple pre-built
widgets that can be added to the dashboard by
clicking the 'Confirm' button.
Shruthi 3 years ago
parent
commit
1fd4e4c13b

+ 29 - 15
static/app/components/modals/dashboardWidgetLibraryModal/index.tsx

@@ -8,8 +8,8 @@ import Tag from 'app/components/tagDeprecated';
 import {t} from 'app/locale';
 import space from 'app/styles/space';
 import {Organization} from 'app/types';
-import {DashboardDetails} from 'app/views/dashboardsV2/types';
-import {DEFAULT_WIDGETS, WidgetTemplate} from 'app/views/dashboardsV2/widgetLibrary/data';
+import {DashboardDetails, Widget} from 'app/views/dashboardsV2/types';
+import {WidgetTemplate} from 'app/views/dashboardsV2/widgetLibrary/data';
 
 import Button from '../../button';
 import ButtonBar from '../../buttonBar';
@@ -20,6 +20,7 @@ import DashboardWidgetLibraryTab from './libraryTab';
 export type DashboardWidgetLibraryModalOptions = {
   organization: Organization;
   dashboard: DashboardDetails;
+  onAddWidget: (widgets: Widget[]) => void;
 };
 
 export enum TAB {
@@ -29,9 +30,22 @@ export enum TAB {
 
 type Props = ModalRenderProps & DashboardWidgetLibraryModalOptions;
 
-function DashboardWidgetLibraryModal({Header, Body, Footer}: Props) {
+function DashboardWidgetLibraryModal({
+  Header,
+  Body,
+  Footer,
+  dashboard,
+  closeModal,
+  onAddWidget,
+}: Props) {
   const [tab, setTab] = useState(TAB.Library);
   const [selectedWidgets, setSelectedWidgets] = useState<WidgetTemplate[]>([]);
+  const [errored, setErrored] = useState(false);
+
+  function handleSubmit() {
+    onAddWidget([...dashboard.widgets, ...selectedWidgets]);
+    closeModal();
+  }
 
   return (
     <React.Fragment>
@@ -47,11 +61,12 @@ function DashboardWidgetLibraryModal({Header, Body, Footer}: Props) {
             {t('Custom')}
           </Button>
         </StyledButtonBar>
-        <Title>{t('%s WIDGETS', DEFAULT_WIDGETS.length)}</Title>
         {tab === TAB.Library ? (
           <DashboardWidgetLibraryTab
             selectedWidgets={selectedWidgets}
+            errored={errored}
             setSelectedWidgets={setSelectedWidgets}
+            setErrored={setErrored}
           />
         ) : (
           <DashboardWidgetCustomTab />
@@ -70,12 +85,19 @@ function DashboardWidgetLibraryModal({Header, Body, Footer}: Props) {
               {`${selectedWidgets.length} Selected`}
             </SelectedBadge>
             <Button
-              data-test-id="add-widget"
+              data-test-id="confirm-widgets"
               priority="primary"
               type="button"
-              onClick={() => {}}
+              onClick={(event: React.FormEvent) => {
+                event.preventDefault();
+                if (!!!selectedWidgets.length) {
+                  setErrored(true);
+                  return;
+                }
+                handleSubmit();
+              }}
             >
-              {t('Add Widget')}
+              {t('Confirm')}
             </Button>
           </div>
         </FooterButtonbar>
@@ -100,14 +122,6 @@ const FooterButtonbar = styled(ButtonBar)`
   width: 100%;
 `;
 
-const Title = styled('h3')`
-  margin-bottom: ${space(1)};
-  padding: 0 !important;
-  font-size: ${p => p.theme.fontSizeSmall};
-  text-transform: uppercase;
-  color: ${p => p.theme.gray300};
-`;
-
 const SelectedBadge = styled(Tag)`
   padding: 3px ${space(0.75)};
   display: inline-flex;

+ 27 - 1
static/app/components/modals/dashboardWidgetLibraryModal/libraryTab.tsx

@@ -1,18 +1,35 @@
 import * as React from 'react';
 import styled from '@emotion/styled';
 
+import Alert from 'app/components/alert';
+import {t} from 'app/locale';
 import space from 'app/styles/space';
 import {DEFAULT_WIDGETS, WidgetTemplate} from 'app/views/dashboardsV2/widgetLibrary/data';
 import WidgetLibraryCard from 'app/views/dashboardsV2/widgetLibrary/widgetCard';
 
 type Props = {
   selectedWidgets: WidgetTemplate[];
+  errored: boolean;
   setSelectedWidgets: (widgets: WidgetTemplate[]) => void;
+  setErrored: (errored: boolean) => void;
 };
 
-function DashboardWidgetLibraryTab({selectedWidgets, setSelectedWidgets}: Props) {
+function DashboardWidgetLibraryTab({
+  selectedWidgets,
+  errored,
+  setSelectedWidgets,
+  setErrored,
+}: Props) {
   return (
     <React.Fragment>
+      {errored && !!!selectedWidgets.length ? (
+        <Alert type="error">
+          {t(
+            'Please select at least one Widget from our Library. Alternatively, you can build a custom widget from scratch.'
+          )}
+        </Alert>
+      ) : null}
+      <Title>{t('%s WIDGETS', DEFAULT_WIDGETS.length)}</Title>
       <ScrollGrid>
         <WidgetLibraryGrid>
           {DEFAULT_WIDGETS.map(widgetCard => {
@@ -22,6 +39,7 @@ function DashboardWidgetLibraryTab({selectedWidgets, setSelectedWidgets}: Props)
                 widget={widgetCard}
                 selectedWidgets={selectedWidgets}
                 setSelectedWidgets={setSelectedWidgets}
+                setErrored={setErrored}
               />
             );
           })}
@@ -48,4 +66,12 @@ const ScrollGrid = styled('div')`
   }
 `;
 
+const Title = styled('h3')`
+  margin-bottom: ${space(1)};
+  padding: 0 !important;
+  font-size: ${p => p.theme.fontSizeSmall};
+  text-transform: uppercase;
+  color: ${p => p.theme.gray300};
+`;
+
 export default DashboardWidgetLibraryTab;

+ 3 - 5
static/app/views/dashboardsV2/controls.tsx

@@ -21,7 +21,7 @@ type Props = {
   onCancel: () => void;
   onCommit: () => void;
   onDelete: () => void;
-  onAddWidget?: () => void;
+  onAddWidget: () => void;
   dashboardState: DashboardState;
 };
 
@@ -117,14 +117,12 @@ class Controls extends React.Component<Props> {
               </Button>
               {organization.features.includes('widget-library') ? (
                 <Button
-                  data-test-id="dashboard-add-widget"
+                  data-test-id="add-widget-library"
                   priority="primary"
                   icon={<IconAdd isCircled size="s" />}
                   onClick={e => {
                     e.preventDefault();
-                    if (onAddWidget) {
-                      onAddWidget();
-                    }
+                    onAddWidget();
                   }}
                 >
                   {t('Add Widget')}

+ 33 - 2
static/app/views/dashboardsV2/detail.tsx

@@ -254,8 +254,39 @@ class DashboardDetail extends Component<Props, State> {
   };
 
   onAddWidget = () => {
-    const {organization, dashboard} = this.props;
-    openDashboardWidgetLibraryModal({organization, dashboard});
+    const {organization, dashboard, api, reloadData, location} = this.props;
+    this.setState({
+      modifiedDashboard: cloneDashboard(dashboard),
+    });
+    openDashboardWidgetLibraryModal({
+      organization,
+      dashboard,
+      onAddWidget: (widgets: Widget[]) => {
+        const modifiedDashboard = {
+          ...cloneDashboard(dashboard),
+          widgets,
+        };
+        updateDashboard(api, organization.slug, modifiedDashboard).then(
+          (newDashboard: DashboardDetails) => {
+            addSuccessMessage(t('Dashboard updated'));
+
+            if (reloadData) {
+              reloadData();
+            }
+            if (dashboard && newDashboard.id !== dashboard.id) {
+              browserHistory.replace({
+                pathname: `/organizations/${organization.slug}/dashboard/${newDashboard.id}/`,
+                query: {
+                  ...location.query,
+                },
+              });
+              return;
+            }
+          },
+          () => undefined
+        );
+      },
+    });
   };
 
   onCommit = () => {

+ 66 - 12
static/app/views/dashboardsV2/widgetLibrary/data.tsx

@@ -9,42 +9,96 @@ export const DEFAULT_WIDGETS: Readonly<Array<WidgetTemplate>> = [
     id: undefined,
     title: t('Total Errors'),
     displayType: DisplayType.BIG_NUMBER,
-    interval: '24h',
-    queries: [],
+    interval: '5m',
+    queries: [
+      {
+        name: '',
+        conditions: '!event.type:transaction',
+        fields: ['count()'],
+        orderby: '',
+      },
+    ],
   },
   {
     id: undefined,
     title: t('All Events'),
     displayType: DisplayType.AREA,
-    interval: '24h',
-    queries: [],
+    interval: '5m',
+    queries: [
+      {
+        name: '',
+        conditions: '!event.type:transaction',
+        fields: ['count()'],
+        orderby: '',
+      },
+    ],
   },
   {
     id: undefined,
     title: t('Affected Users'),
     displayType: DisplayType.LINE,
-    interval: '24h',
-    queries: [],
+    interval: '5m',
+    queries: [
+      {
+        name: 'Known Users',
+        conditions: 'has:user.email !event.type:transaction',
+        fields: ['count_unique(user)'],
+        orderby: '',
+      },
+      {
+        name: 'Anonymous Users',
+        conditions: '!has:user.email !event.type:transaction',
+        fields: ['count_unique(user)'],
+        orderby: '',
+      },
+    ],
   },
   {
     id: undefined,
     title: t('Handled vs. Unhandled'),
     displayType: DisplayType.LINE,
-    interval: '24h',
-    queries: [],
+    interval: '5m',
+    queries: [
+      {
+        name: 'Handled',
+        conditions: 'error.handled:true',
+        fields: ['count()'],
+        orderby: '',
+      },
+      {
+        name: 'Unhandled',
+        conditions: 'error.handled:false',
+        fields: ['count()'],
+        orderby: '',
+      },
+    ],
   },
   {
     id: undefined,
     title: t('Errors by Country'),
     displayType: DisplayType.WORLD_MAP,
-    interval: '24h',
-    queries: [],
+    interval: '5m',
+    queries: [
+      {
+        name: 'Error counts',
+        conditions: '!event.type:transaction has:geo.country_code',
+        fields: ['count()'],
+        orderby: '',
+      },
+    ],
   },
   {
     id: undefined,
     title: t('Errors by Browser'),
     displayType: DisplayType.TABLE,
-    interval: '24h',
-    queries: [],
+    interval: '5m',
+    queries: [
+      {
+        name: '',
+        conditions: '!event.type:transaction has:browser.name',
+        fields: ['browser.name', 'count()'],
+        orderby: '-count',
+      },
+    ],
   },
 ];

+ 8 - 1
static/app/views/dashboardsV2/widgetLibrary/widgetCard.tsx

@@ -14,16 +14,23 @@ import {WidgetTemplate} from './data';
 type Props = {
   widget: WidgetTemplate;
   setSelectedWidgets: (widgets: WidgetTemplate[]) => void;
+  setErrored: (errored: boolean) => void;
   selectedWidgets: WidgetTemplate[];
 };
 
-function WidgetLibraryCard({selectedWidgets, widget, setSelectedWidgets}: Props) {
+function WidgetLibraryCard({
+  selectedWidgets,
+  widget,
+  setSelectedWidgets,
+  setErrored,
+}: Props) {
   const selectButton = (
     <StyledButton
       type="button"
       icon={<IconAdd size="small" isCircled color="gray300" />}
       onClick={() => {
         const updatedWidgets = selectedWidgets.slice().concat(widget);
+        setErrored(false);
         setSelectedWidgets(updatedWidgets);
       }}
     >

+ 62 - 2
tests/js/spec/components/modals/dashboardWidgetLibraryModal.spec.jsx

@@ -4,8 +4,10 @@ import {mountWithTheme, screen, userEvent} from 'sentry-test/reactTestingLibrary
 import DashboardWidgetLibraryModal from 'app/components/modals/dashboardWidgetLibraryModal';
 
 const stubEl = props => <div>{props.children}</div>;
+const alertText =
+  'Please select at least one Widget from our Library. Alternatively, you can build a custom widget from scratch.';
 
-function mountModal({initialData}) {
+function mountModal({initialData}, onApply, closeModal) {
   const routerContext = TestStubs.routerContext();
   return mountWithTheme(
     <DashboardWidgetLibraryModal
@@ -20,6 +22,8 @@ function mountModal({initialData}) {
         createdBy: {id: '1'},
         widgetDisplay: [],
       })}
+      onAddWidget={onApply}
+      closeModal={closeModal}
     />,
     {context: routerContext}
   );
@@ -37,7 +41,7 @@ describe('Modals -> DashboardWidgetLibraryModal', function () {
     MockApiClient.clearMockResponses();
   });
 
-  it('renders the widget library modal', function () {
+  it('selects and unselcts widgets correctly', function () {
     // Checking initial modal states
     const container = mountModal({initialData});
     expect(screen.getByText('6 WIDGETS')).toBeInTheDocument();
@@ -47,13 +51,69 @@ describe('Modals -> DashboardWidgetLibraryModal', function () {
 
     // Select some widgets
     const selectButtons = screen.getAllByRole('button');
+    userEvent.click(selectButtons[3]);
     userEvent.click(selectButtons[4]);
     userEvent.click(selectButtons[5]);
 
+    expect(screen.getByTestId('selected-badge')).toHaveTextContent('3 Selected');
+    expect(screen.queryAllByText('Select')).toHaveLength(3);
+    expect(screen.queryAllByText('Selected')).toHaveLength(3);
+
+    // Deselect a widget
+    userEvent.click(selectButtons[4]);
     expect(screen.getByTestId('selected-badge')).toHaveTextContent('2 Selected');
     expect(screen.queryAllByText('Select')).toHaveLength(4);
     expect(screen.queryAllByText('Selected')).toHaveLength(2);
 
+    container.unmount();
+  });
+  it('submits selected widgets correctly', function () {
+    // Checking initial modal states
+    const mockApply = jest.fn();
+    const closeModal = jest.fn();
+    const container = mountModal({initialData}, mockApply, closeModal);
+    // Select some widgets
+    const selectButtons = screen.getAllByRole('button');
+    userEvent.click(selectButtons[3]);
+
+    expect(screen.getByTestId('selected-badge')).toHaveTextContent('1 Selected');
+    userEvent.click(screen.getByTestId('confirm-widgets'));
+
+    expect(mockApply).toHaveBeenCalledTimes(1);
+    expect(mockApply).toHaveBeenCalledWith([
+      {
+        displayType: 'area',
+        id: undefined,
+        interval: '5m',
+        queries: [
+          {
+            conditions: '!event.type:transaction',
+            fields: ['count()'],
+            name: '',
+            orderby: '',
+          },
+        ],
+        title: 'All Events',
+      },
+    ]);
+    expect(closeModal).toHaveBeenCalledTimes(1);
+
+    container.unmount();
+  });
+  it('raises warning if widget not selected', function () {
+    // Checking initial modal states
+    const mockApply = jest.fn();
+    const closeModal = jest.fn();
+    const container = mountModal({initialData}, mockApply, closeModal);
+    expect(screen.queryByText(alertText)).not.toBeInTheDocument();
+
+    expect(screen.getByTestId('selected-badge')).toHaveTextContent('0 Selected');
+    userEvent.click(screen.getByTestId('confirm-widgets'));
+
+    expect(mockApply).toHaveBeenCalledTimes(0);
+    expect(closeModal).toHaveBeenCalledTimes(0);
+    expect(screen.getByText(alertText)).toBeInTheDocument();
+
     container.unmount();
   });
 });

+ 110 - 1
tests/js/spec/views/dashboardsV2/detail.spec.jsx

@@ -173,7 +173,7 @@ describe('Dashboards > Detail', function () {
   });
 
   describe('custom dashboards', function () {
-    let wrapper, initialData, widgets, mockVisit;
+    let wrapper, initialData, widgets, mockVisit, mockPut;
 
     beforeEach(function () {
       initialData = initializeOrg({organization});
@@ -242,6 +242,10 @@ describe('Dashboards > Detail', function () {
         url: '/organizations/org-slug/dashboards/1/',
         body: TestStubs.Dashboard(widgets, {id: '1', title: 'Custom Errors'}),
       });
+      mockPut = MockApiClient.addMockResponse({
+        url: '/organizations/org-slug/dashboards/1/',
+        method: 'PUT',
+      });
       MockApiClient.addMockResponse({
         url: '/organizations/org-slug/events-stats/',
         body: {data: []},
@@ -475,5 +479,110 @@ describe('Dashboards > Detail', function () {
         DashboardState.VIEW
       );
     });
+
+    it('can add library widgets', async function () {
+      initialData = initializeOrg({
+        organization: TestStubs.Organization({
+          features: [
+            'global-views',
+            'dashboards-basic',
+            'dashboards-edit',
+            'discover-query',
+            'widget-library',
+          ],
+          projects: [TestStubs.Project()],
+        }),
+      });
+
+      wrapper = mountWithTheme(
+        <ViewEditDashboard
+          organization={initialData.organization}
+          params={{orgId: 'org-slug', dashboardId: '1'}}
+          router={initialData.router}
+          location={initialData.router.location}
+        />,
+        initialData.routerContext
+      );
+      await tick();
+      wrapper.update();
+
+      // Enter Add Widget mode
+      wrapper
+        .find('Controls Button[data-test-id="add-widget-library"]')
+        .simulate('click');
+
+      const modal = await mountGlobalModal();
+      await tick();
+      await modal.update();
+
+      modal.find('Button').at(4).simulate('click');
+
+      expect(modal.find('SelectedBadge').text()).toEqual('1 Selected');
+
+      modal.find('Button[data-test-id="confirm-widgets"]').simulate('click');
+
+      await tick();
+      wrapper.update();
+
+      expect(wrapper.find('DashboardDetail').state().dashboardState).toEqual(
+        DashboardState.VIEW
+      );
+      expect(mockPut).toHaveBeenCalledTimes(1);
+      expect(mockPut).toHaveBeenCalledWith(
+        '/organizations/org-slug/dashboards/1/',
+        expect.objectContaining({
+          data: expect.objectContaining({
+            title: 'Custom Errors',
+            widgets: [
+              {
+                id: '1',
+                interval: '1d',
+                queries: [
+                  {conditions: 'event.type:error', fields: ['count()'], name: ''},
+                ],
+                title: 'Errors',
+                type: 'line',
+              },
+              {
+                id: '2',
+                interval: '1d',
+                queries: [
+                  {conditions: 'event.type:transaction', fields: ['count()'], name: ''},
+                ],
+                title: 'Transactions',
+                type: 'line',
+              },
+              {
+                id: '3',
+                interval: '1d',
+                queries: [
+                  {
+                    conditions: 'event.type:transaction transaction:/api/cats',
+                    fields: ['p50()'],
+                    name: '',
+                  },
+                ],
+                title: 'p50 of /api/cats',
+                type: 'line',
+              },
+              {
+                displayType: 'area',
+                id: undefined,
+                interval: '5m',
+                queries: [
+                  {
+                    conditions: '!event.type:transaction',
+                    fields: ['count()'],
+                    name: '',
+                    orderby: '',
+                  },
+                ],
+                title: 'All Events',
+              },
+            ],
+          }),
+        })
+      );
+    });
   });
 });