Browse Source

Add support to Duplicate and Delete dashboards from Manage Dashboards page (#25667)

Allow user to Delete and Duplicate Dashboards from
Manage Dashboards page.
Shruthi 3 years ago
parent
commit
5765e3353d

+ 23 - 2
src/sentry/api/endpoints/organization_dashboards.py

@@ -1,3 +1,5 @@
+import re
+
 from django.db import IntegrityError, transaction
 from rest_framework.response import Response
 
@@ -9,6 +11,9 @@ from sentry.api.serializers.models.dashboard import DashboardListSerializer
 from sentry.api.serializers.rest_framework import DashboardSerializer
 from sentry.models import Dashboard
 
+MAX_RETRIES = 10
+DUPLICATE_TITLE_PATTERN = r"(.*) copy(?:$|\s(\d+))"
+
 
 class OrganizationDashboardsPermission(OrganizationPermission):
     scope_map = {
@@ -72,7 +77,7 @@ class OrganizationDashboardsEndpoint(OrganizationEndpoint):
             on_results=handle_results,
         )
 
-    def post(self, request, organization):
+    def post(self, request, organization, retry=0):
         """
         Create a New Dashboard for an Organization
         ``````````````````````````````````````````
@@ -101,4 +106,20 @@ class OrganizationDashboardsEndpoint(OrganizationEndpoint):
                 dashboard = serializer.save()
                 return Response(serialize(dashboard, request.user), status=201)
         except IntegrityError:
-            return Response("Dashboard title already taken", status=409)
+            duplicate = request.data.get("duplicate", False)
+            if duplicate and retry < MAX_RETRIES:
+                title = request.data["title"]
+                match = re.match(DUPLICATE_TITLE_PATTERN, title)
+                if match:
+                    partial_title = match.group(1)
+                    copy_counter = match.group(2)
+                    if copy_counter:
+                        request.data["title"] = f"{partial_title} copy {int(copy_counter) + 1}"
+                    else:
+                        request.data["title"] = f"{partial_title} copy 1"
+                else:
+                    request.data["title"] = f"{title} copy"
+
+                return self.post(request, organization, retry=retry + 1)
+            else:
+                return Response("Dashboard title already taken", status=409)

+ 27 - 2
static/app/actionCreators/dashboards.tsx

@@ -6,7 +6,8 @@ import {DashboardDetails, Widget} from 'app/views/dashboardsV2/types';
 export function createDashboard(
   api: Client,
   orgId: string,
-  newDashboard: DashboardDetails
+  newDashboard: DashboardDetails,
+  duplicate?: boolean
 ): Promise<DashboardDetails> {
   const {title, widgets} = newDashboard;
 
@@ -14,7 +15,7 @@ export function createDashboard(
     `/organizations/${orgId}/dashboards/`,
     {
       method: 'POST',
-      data: {title, widgets},
+      data: {title, widgets, duplicate},
     }
   );
 
@@ -31,6 +32,30 @@ export function createDashboard(
   return promise;
 }
 
+export function fetchDashboard(
+  api: Client,
+  orgId: string,
+  dashboardId: string
+): Promise<DashboardDetails> {
+  const promise: Promise<DashboardDetails> = api.requestPromise(
+    `/organizations/${orgId}/dashboards/${dashboardId}/`,
+    {
+      method: 'GET',
+    }
+  );
+
+  promise.catch(response => {
+    const errorResponse = response?.responseJSON ?? null;
+
+    if (errorResponse) {
+      addErrorMessage(errorResponse);
+    } else {
+      addErrorMessage(t('Unable to load dashboard'));
+    }
+  });
+  return promise;
+}
+
 export function updateDashboard(
   api: Client,
   orgId: string,

+ 53 - 0
static/app/views/dashboardsV2/contextMenu.tsx

@@ -0,0 +1,53 @@
+import React, {MouseEvent} from 'react';
+import styled from '@emotion/styled';
+import classNames from 'classnames';
+
+import DropdownMenu from 'app/components/dropdownMenu';
+import {IconEllipsis} from 'app/icons';
+
+const ContextMenu = ({children}) => (
+  <DropdownMenu>
+    {({isOpen, getRootProps, getActorProps, getMenuProps}) => {
+      const topLevelCx = classNames('dropdown', {
+        'anchor-right': true,
+        open: isOpen,
+      });
+
+      return (
+        <MoreOptions
+          {...getRootProps({
+            className: topLevelCx,
+          })}
+        >
+          <DropdownTarget
+            {...getActorProps<HTMLDivElement>({
+              onClick: (event: MouseEvent) => {
+                event.stopPropagation();
+                event.preventDefault();
+              },
+            })}
+          >
+            <IconEllipsis data-test-id="context-menu" size="md" />
+          </DropdownTarget>
+          {isOpen && (
+            <ul {...getMenuProps({})} className={classNames('dropdown-menu')}>
+              {children}
+            </ul>
+          )}
+        </MoreOptions>
+      );
+    }}
+  </DropdownMenu>
+);
+
+const MoreOptions = styled('span')`
+  display: flex;
+  color: ${p => p.theme.textColor};
+`;
+
+const DropdownTarget = styled('div')`
+  display: flex;
+  cursor: pointer;
+`;
+
+export default ContextMenu;

+ 3 - 0
static/app/views/dashboardsV2/manage/dashboardCard.tsx

@@ -17,6 +17,7 @@ type Props = {
   createdBy?: User;
   dateStatus?: React.ReactNode;
   onEventClick?: () => void;
+  renderContextMenu?: () => void;
 };
 
 function DashboardCard({
@@ -27,6 +28,7 @@ function DashboardCard({
   dateStatus,
   to,
   onEventClick,
+  renderContextMenu,
 }: Props) {
   function onClick() {
     onEventClick?.();
@@ -59,6 +61,7 @@ function DashboardCard({
               <DateStatus />
             )}
           </DateSelected>
+          {renderContextMenu && renderContextMenu()}
         </CardFooter>
       </StyledDashboardCard>
     </Link>

+ 69 - 2
static/app/views/dashboardsV2/manage/dashboardList.tsx

@@ -10,24 +10,45 @@ import WidgetLine from 'sentry-images/dashboard/widget-line-1.svg';
 import WidgetTable from 'sentry-images/dashboard/widget-table.svg';
 import WidgetWorldMap from 'sentry-images/dashboard/widget-world-map.svg';
 
+import {
+  createDashboard,
+  deleteDashboard,
+  fetchDashboard,
+} from 'app/actionCreators/dashboards';
+import {addErrorMessage, addSuccessMessage} from 'app/actionCreators/indicator';
+import {Client} from 'app/api';
 import EmptyStateWarning from 'app/components/emptyStateWarning';
+import MenuItem from 'app/components/menuItem';
 import Pagination from 'app/components/pagination';
 import TimeSince from 'app/components/timeSince';
 import {t, tn} from 'app/locale';
 import space from 'app/styles/space';
 import {Organization} from 'app/types';
+import withApi from 'app/utils/withApi';
 import {DashboardListItem, DisplayType} from 'app/views/dashboardsV2/types';
 
+import ContextMenu from '../contextMenu';
+import {cloneDashboard} from '../utils';
+
 import DashboardCard from './dashboardCard';
 
 type Props = {
+  api: Client;
   organization: Organization;
   location: Location;
   dashboards: DashboardListItem[] | null;
   pageLinks: string;
+  onDashboardsChange: () => void;
 };
 
-function DashboardList({organization, location, dashboards, pageLinks}: Props) {
+function DashboardList({
+  api,
+  organization,
+  location,
+  dashboards,
+  pageLinks,
+  onDashboardsChange,
+}: Props) {
   function miniWidget(displayType: DisplayType): string {
     switch (displayType) {
       case DisplayType.BAR:
@@ -46,6 +67,30 @@ function DashboardList({organization, location, dashboards, pageLinks}: Props) {
     }
   }
 
+  function handleDelete(dashboard: DashboardListItem) {
+    deleteDashboard(api, organization.slug, dashboard.id)
+      .then(() => {
+        onDashboardsChange();
+        addSuccessMessage(t('Dashboard deleted'));
+      })
+      .catch(() => {
+        addErrorMessage(t('Error deleting Dashboard'));
+      });
+  }
+
+  function handleDuplicate(dashboard: DashboardListItem) {
+    fetchDashboard(api, organization.slug, dashboard.id)
+      .then(dashboardDetail => {
+        const newDashboard = cloneDashboard(dashboardDetail);
+        newDashboard.widgets.map(widget => (widget.id = undefined));
+        createDashboard(api, organization.slug, newDashboard, true).then(() => {
+          onDashboardsChange();
+          addSuccessMessage(t('Dashboard duplicated'));
+        });
+      })
+      .catch(() => addErrorMessage(t('Error duplicating Dashboard')));
+  }
+
   function renderMiniDashboards() {
     return dashboards?.map((dashboard, index) => {
       return (
@@ -78,6 +123,28 @@ function DashboardList({organization, location, dashboards, pageLinks}: Props) {
               })}
             </WidgetGrid>
           )}
+          renderContextMenu={() => (
+            <ContextMenu>
+              <MenuItem
+                data-test-id="dashboard-delete"
+                onClick={event => {
+                  event.preventDefault();
+                  handleDelete(dashboard);
+                }}
+              >
+                {t('Delete')}
+              </MenuItem>
+              <MenuItem
+                data-test-id="dashboard-duplicate"
+                onClick={event => {
+                  event.preventDefault();
+                  handleDuplicate(dashboard);
+                }}
+              >
+                {t('Duplicate')}
+              </MenuItem>
+            </ContextMenu>
+          )}
         />
       );
     });
@@ -179,4 +246,4 @@ const PaginationRow = styled(Pagination)`
   margin-bottom: ${space(3)};
 `;
 
-export default DashboardList;
+export default withApi(DashboardList);

+ 14 - 6
static/app/views/dashboardsV2/manage/index.tsx

@@ -1,9 +1,9 @@
 import React from 'react';
 import * as ReactRouter from 'react-router';
 import styled from '@emotion/styled';
-import {Location} from 'history';
 import pick from 'lodash/pick';
 
+import {Client} from 'app/api';
 import Feature from 'app/components/acl/feature';
 import Alert from 'app/components/alert';
 import Breadcrumbs from 'app/components/breadcrumbs';
@@ -14,6 +14,7 @@ import {t} from 'app/locale';
 import {PageContent, PageHeader} from 'app/styles/organization';
 import space from 'app/styles/space';
 import {Organization} from 'app/types';
+import withApi from 'app/utils/withApi';
 import withOrganization from 'app/utils/withOrganization';
 import AsyncView from 'app/views/asyncView';
 
@@ -22,6 +23,7 @@ import {DashboardListItem} from '../types';
 import DashboardList from './dashboardList';
 
 type Props = {
+  api: Client;
   organization: Organization;
   location: Location;
   router: ReactRouter.InjectedRouter;
@@ -49,14 +51,18 @@ class ManageDashboards extends AsyncView<Props, State> {
     ];
   }
 
-  handleSearch = (query: string) => {
+  onDashboardsChange() {
+    this.reloadData();
+  }
+
+  handleSearch(query: string) {
     const {location, router} = this.props;
 
     router.push({
       pathname: location.pathname,
       query: {...location.query, cursor: undefined, query},
     });
-  };
+  }
 
   getQuery() {
     const {query} = this.props.location.query;
@@ -71,7 +77,7 @@ class ManageDashboards extends AsyncView<Props, State> {
           defaultQuery=""
           query={this.getQuery()}
           placeholder={t('Search Dashboards')}
-          onSearch={this.handleSearch}
+          onSearch={query => this.handleSearch(query)}
         />
       </StyledActions>
     );
@@ -87,13 +93,15 @@ class ManageDashboards extends AsyncView<Props, State> {
 
   renderDashboards() {
     const {dashboards, dashboardsPageLinks} = this.state;
-    const {organization, location} = this.props;
+    const {organization, location, api} = this.props;
     return (
       <DashboardList
+        api={api}
         dashboards={dashboards}
         organization={organization}
         pageLinks={dashboardsPageLinks}
         location={location}
+        onDashboardsChange={() => this.onDashboardsChange()}
       />
     );
   }
@@ -152,4 +160,4 @@ const StyledActions = styled('div')`
   margin-bottom: ${space(3)};
 `;
 
-export default withOrganization(ManageDashboards);
+export default withApi(withOrganization(ManageDashboards));

+ 3 - 49
static/app/views/dashboardsV2/widgetCard.tsx

@@ -1,21 +1,19 @@
-import React, {MouseEvent} from 'react';
+import React from 'react';
 import * as ReactRouter from 'react-router';
 import {browserHistory} from 'react-router';
 import {useSortable} from '@dnd-kit/sortable';
 import styled from '@emotion/styled';
-import classNames from 'classnames';
 import {Location} from 'history';
 import isEqual from 'lodash/isEqual';
 
 import {Client} from 'app/api';
 import {HeaderTitle} from 'app/components/charts/styles';
-import DropdownMenu from 'app/components/dropdownMenu';
 import ErrorBoundary from 'app/components/errorBoundary';
 import MenuItem from 'app/components/menuItem';
 import {isSelectionEqual} from 'app/components/organizations/globalSelectionHeader/utils';
 import {Panel} from 'app/components/panels';
 import Placeholder from 'app/components/placeholder';
-import {IconDelete, IconEdit, IconEllipsis, IconGrabbable} from 'app/icons';
+import {IconDelete, IconEdit, IconGrabbable} from 'app/icons';
 import {t} from 'app/locale';
 import overflowEllipsis from 'app/styles/overflowEllipsis';
 import space from 'app/styles/space';
@@ -25,6 +23,7 @@ import withApi from 'app/utils/withApi';
 import withGlobalSelection from 'app/utils/withGlobalSelection';
 import withOrganization from 'app/utils/withOrganization';
 
+import ContextMenu from './contextMenu';
 import {Widget} from './types';
 import {eventViewFromWidget} from './utils';
 import WidgetCardChart from './widgetCardChart';
@@ -280,51 +279,6 @@ const WidgetHeader = styled('div')`
   justify-content: space-between;
 `;
 
-const ContextMenu = ({children}) => (
-  <DropdownMenu>
-    {({isOpen, getRootProps, getActorProps, getMenuProps}) => {
-      const topLevelCx = classNames('dropdown', {
-        'anchor-right': true,
-        open: isOpen,
-      });
-
-      return (
-        <MoreOptions
-          {...getRootProps({
-            className: topLevelCx,
-          })}
-        >
-          <DropdownTarget
-            {...getActorProps<HTMLDivElement>({
-              onClick: (event: MouseEvent) => {
-                event.stopPropagation();
-                event.preventDefault();
-              },
-            })}
-          >
-            <IconEllipsis data-test-id="context-menu" size="md" />
-          </DropdownTarget>
-          {isOpen && (
-            <ul {...getMenuProps({})} className={classNames('dropdown-menu')}>
-              {children}
-            </ul>
-          )}
-        </MoreOptions>
-      );
-    }}
-  </DropdownMenu>
-);
-
-const MoreOptions = styled('span')`
-  display: flex;
-  color: ${p => p.theme.textColor};
-`;
-
-const DropdownTarget = styled('div')`
-  display: flex;
-  cursor: pointer;
-`;
-
 const ContextWrapper = styled('div')`
   margin-left: ${space(1)};
 `;

+ 104 - 2
tests/js/spec/views/dashboardsV2/manage/dashboardList.spec.jsx

@@ -4,10 +4,18 @@ import {mountWithTheme} from 'sentry-test/enzyme';
 
 import DashboardList from 'app/views/dashboardsV2/manage/dashboardList';
 
+function openContextMenu(card) {
+  card.find('DropdownMenu MoreOptions svg').simulate('click');
+}
+
+function clickMenuItem(card, selector) {
+  card.find(`DropdownMenu MenuItem[data-test-id="${selector}"]`).simulate('click');
+}
+
 describe('Dashboards > DashboardList', function () {
-  let dashboards, widgets;
+  let dashboards, widgets, deleteMock, dashboardUpdateMock, createMock;
   const organization = TestStubs.Organization({
-    features: ['dashboards-manage'],
+    features: ['global-views', 'dashboards-basic', 'dashboards-edit', 'discover-query'],
     projects: [TestStubs.Project()],
   });
 
@@ -64,6 +72,50 @@ describe('Dashboards > DashboardList', function () {
         widgetDisplay: ['line', 'table'],
       }),
     ];
+    deleteMock = MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/dashboards/2/',
+      method: 'DELETE',
+      statusCode: 200,
+    });
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/dashboards/2/',
+      method: 'GET',
+      statusCode: 200,
+      body: {
+        id: '2',
+        title: 'Dashboard Demo',
+        widgets: [
+          {
+            id: '1',
+            title: 'Errors',
+            displayType: 'big_number',
+            interval: '5m',
+          },
+          {
+            id: '2',
+            title: 'Transactions',
+            displayType: 'big_number',
+            interval: '5m',
+          },
+          {
+            id: '3',
+            title: 'p50 of /api/cat',
+            displayType: 'big_number',
+            interval: '5m',
+          },
+        ],
+      },
+    });
+    createMock = MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/dashboards/',
+      method: 'POST',
+      statusCode: 200,
+    });
+    dashboardUpdateMock = jest.fn();
+  });
+
+  afterEach(function () {
+    MockApiClient.clearMockResponses();
   });
 
   it('renders an empty list', function () {
@@ -122,4 +174,54 @@ describe('Dashboards > DashboardList', function () {
     expect(link.pathname).toEqual(`/organizations/org-slug/dashboards/2/`);
     expect(link.query).toEqual({statsPeriod: '7d'});
   });
+
+  it('can delete dashboards', async function () {
+    const wrapper = mountWithTheme(
+      <DashboardList
+        organization={organization}
+        dashboards={dashboards}
+        pageLinks=""
+        location={{query: {}}}
+        onDashboardsChange={dashboardUpdateMock}
+      />
+    );
+    let card = wrapper.find('DashboardCard').last();
+    expect(card.find('Title').text()).toEqual(dashboards[1].title);
+
+    openContextMenu(card);
+    wrapper.update();
+
+    card = wrapper.find('DashboardCard').last();
+    clickMenuItem(card, 'dashboard-delete');
+
+    await tick();
+
+    expect(deleteMock).toHaveBeenCalled();
+    expect(dashboardUpdateMock).toHaveBeenCalled();
+  });
+
+  it('can duplicate dashboards', async function () {
+    const wrapper = mountWithTheme(
+      <DashboardList
+        organization={organization}
+        dashboards={dashboards}
+        pageLinks=""
+        location={{query: {}}}
+        onDashboardsChange={dashboardUpdateMock}
+      />
+    );
+    let card = wrapper.find('DashboardCard').last();
+    expect(card.find('Title').text()).toEqual(dashboards[1].title);
+
+    openContextMenu(card);
+    wrapper.update();
+
+    card = wrapper.find('DashboardCard').last();
+    clickMenuItem(card, 'dashboard-duplicate');
+
+    await tick();
+
+    expect(createMock).toHaveBeenCalled();
+    expect(dashboardUpdateMock).toHaveBeenCalled();
+  });
 });

+ 12 - 2
tests/js/spec/views/dashboardsV2/manage/index.spec.jsx

@@ -6,11 +6,17 @@ import ManageDashboards from 'app/views/dashboardsV2/manage';
 
 describe('Dashboards > Detail', function () {
   const mockUnauthorizedOrg = TestStubs.Organization({
-    features: [],
+    features: ['global-views', 'dashboards-basic', 'dashboards-edit', 'discover-query'],
   });
 
   const mockAuthorizedOrg = TestStubs.Organization({
-    features: ['dashboards-manage'],
+    features: [
+      'global-views',
+      'dashboards-basic',
+      'dashboards-edit',
+      'discover-query',
+      'dashboards-manage',
+    ],
   });
   beforeEach(function () {
     MockApiClient.addMockResponse({
@@ -22,6 +28,10 @@ describe('Dashboards > Detail', function () {
       body: [],
     });
   });
+  afterEach(function () {
+    MockApiClient.clearMockResponses();
+  });
+
   it('denies access on missing feature', function () {
     const wrapper = mountWithTheme(
       <ManageDashboards

+ 17 - 0
tests/sentry/api/endpoints/test_organization_dashboards.py

@@ -147,3 +147,20 @@ class OrganizationDashboardsTest(OrganizationDashboardWidgetTestCase):
         response = self.do_request("post", self.url, data={"title": self.dashboard.title})
         assert response.status_code == 409
         assert response.data == "Dashboard title already taken"
+
+    def test_duplicate_dashboard(self):
+        response = self.do_request(
+            "post",
+            self.url,
+            data={"title": self.dashboard.title, "duplicate": True},
+        )
+        assert response.status_code == 201, response.data
+        assert response.data["title"] == f"{self.dashboard.title} copy"
+
+        response = self.do_request(
+            "post",
+            self.url,
+            data={"title": self.dashboard.title, "duplicate": True},
+        )
+        assert response.status_code == 201, response.data
+        assert response.data["title"] == f"{self.dashboard.title} copy 1"