You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ta...@apache.org on 2020/11/09 21:25:52 UTC

[incubator-superset] branch master updated: feat: annotation delete modal, bulk delete and empty state (#11540)

This is an automated email from the ASF dual-hosted git repository.

tai pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git


The following commit(s) were added to refs/heads/master by this push:
     new dda95ed  feat: annotation delete modal, bulk delete and empty state (#11540)
dda95ed is described below

commit dda95ed2504d3ade9eb365b8da36ae692353242f
Author: Lily Kuang <li...@preset.io>
AuthorDate: Mon Nov 9 13:25:16 2020 -0800

    feat: annotation delete modal, bulk delete and empty state (#11540)
---
 .../views/CRUD/annotation/AnnotationList_spec.jsx  |  52 ++++++-
 .../src/common/components/common.stories.tsx       |  13 ++
 superset-frontend/src/components/Menu/SubMenu.tsx  |   2 +
 .../src/views/CRUD/annotation/AnnotationList.tsx   | 153 ++++++++++++++++++---
 .../src/views/CRUD/annotation/AnnotationModal.tsx  |   2 +-
 .../CRUD/annotationlayers/AnnotationLayersList.tsx |   2 +-
 6 files changed, 200 insertions(+), 24 deletions(-)

diff --git a/superset-frontend/spec/javascripts/views/CRUD/annotation/AnnotationList_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/annotation/AnnotationList_spec.jsx
index 80c3562..1fc7089 100644
--- a/superset-frontend/spec/javascripts/views/CRUD/annotation/AnnotationList_spec.jsx
+++ b/superset-frontend/spec/javascripts/views/CRUD/annotation/AnnotationList_spec.jsx
@@ -23,9 +23,13 @@ import fetchMock from 'fetch-mock';
 import { styledMount as mount } from 'spec/helpers/theming';
 
 import AnnotationList from 'src/views/CRUD/annotation/AnnotationList';
-import SubMenu from 'src/components/Menu/SubMenu';
+import DeleteModal from 'src/components/DeleteModal';
+import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
 import ListView from 'src/components/ListView';
+import SubMenu from 'src/components/Menu/SubMenu';
+
 import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
+import { act } from 'react-dom/test-utils';
 
 // store needed for withToasts(AnnotationList)
 const mockStore = configureStore([thunk]);
@@ -34,7 +38,9 @@ const store = mockStore({});
 const annotationsEndpoint = 'glob:*/api/v1/annotation_layer/*/annotation*';
 const annotationLayerEndpoint = 'glob:*/api/v1/annotation_layer/*';
 
-const mockannotation = [...new Array(3)].map((_, i) => ({
+fetchMock.delete(annotationsEndpoint, {});
+
+const mockannotations = [...new Array(3)].map((_, i) => ({
   changed_on_delta_humanized: `${i} day(s) ago`,
   created_by: {
     first_name: `user`,
@@ -53,7 +59,7 @@ const mockannotation = [...new Array(3)].map((_, i) => ({
 
 fetchMock.get(annotationsEndpoint, {
   ids: [2, 0, 1],
-  result: mockannotation,
+  result: mockannotations,
   count: 3,
 });
 
@@ -110,4 +116,44 @@ describe('AnnotationList', () => {
       `"http://localhost/api/v1/annotation_layer/1/annotation/?q=(order_column:short_descr,order_direction:desc,page:0,page_size:25)"`,
     );
   });
+
+  it('renders a DeleteModal', () => {
+    expect(wrapper.find(DeleteModal)).toExist();
+  });
+
+  it('deletes', async () => {
+    act(() => {
+      wrapper.find('[data-test="delete-action"]').first().props().onClick();
+    });
+    await waitForComponentToPaint(wrapper);
+
+    expect(
+      wrapper.find(DeleteModal).first().props().description,
+    ).toMatchInlineSnapshot(
+      `"Are you sure you want to delete annotation 0 label?"`,
+    );
+
+    act(() => {
+      wrapper
+        .find('#delete')
+        .first()
+        .props()
+        .onChange({ target: { value: 'DELETE' } });
+    });
+    await waitForComponentToPaint(wrapper);
+    act(() => {
+      wrapper.find('button').last().props().onClick();
+    });
+  });
+
+  it('shows/hides bulk actions when bulk actions is clicked', async () => {
+    const button = wrapper.find('[data-test="annotation-bulk-select"]').first();
+    act(() => {
+      button.props().onClick();
+    });
+    await waitForComponentToPaint(wrapper);
+    expect(wrapper.find(IndeterminateCheckbox)).toHaveLength(
+      mockannotations.length + 1, // 1 for each row and 1 for select all
+    );
+  });
 });
diff --git a/superset-frontend/src/common/components/common.stories.tsx b/superset-frontend/src/common/components/common.stories.tsx
index 19f597e..7bdae99 100644
--- a/superset-frontend/src/common/components/common.stories.tsx
+++ b/superset-frontend/src/common/components/common.stories.tsx
@@ -27,6 +27,10 @@ import AntdTooltip from './Tooltip';
 import { Menu } from '.';
 import { Dropdown } from './Dropdown';
 import InfoTooltip from './InfoTooltip';
+import {
+  DatePicker as AntdDatePicker,
+  RangePicker as AntdRangePicker,
+} from './DatePicker';
 
 export default {
   title: 'Common Components',
@@ -224,3 +228,12 @@ StyledInfoTooltip.argTypes = {
     },
   },
 };
+
+export const DatePicker = () => <AntdDatePicker showTime />;
+export const DateRangePicker = () => (
+  <AntdRangePicker
+    format="YYYY-MM-DD hh:mm a"
+    showTime={{ format: 'hh:mm a' }}
+    use12Hours
+  />
+);
diff --git a/superset-frontend/src/components/Menu/SubMenu.tsx b/superset-frontend/src/components/Menu/SubMenu.tsx
index cc6914d..00f3c85 100644
--- a/superset-frontend/src/components/Menu/SubMenu.tsx
+++ b/superset-frontend/src/components/Menu/SubMenu.tsx
@@ -86,6 +86,7 @@ type MenuChild = {
 export interface ButtonProps {
   name: ReactNode;
   onClick: OnClickHandler;
+  'data-test'?: string;
   buttonStyle:
     | 'primary'
     | 'secondary'
@@ -159,6 +160,7 @@ const SubMenu: React.FunctionComponent<SubMenuProps> = props => {
               key={`${i}`}
               buttonStyle={btn.buttonStyle}
               onClick={btn.onClick}
+              data-test={btn['data-test']}
             >
               {btn.name}
             </Button>
diff --git a/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx b/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx
index ee9b623..dba16e3 100644
--- a/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx
+++ b/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx
@@ -20,15 +20,20 @@
 import React, { useMemo, useState, useEffect, useCallback } from 'react';
 import { useParams, Link, useHistory } from 'react-router-dom';
 import { t, styled, SupersetClient } from '@superset-ui/core';
-
 import moment from 'moment';
+import rison from 'rison';
+
 import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar';
-import ListView from 'src/components/ListView';
+import Button from 'src/components/Button';
+import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
+import DeleteModal from 'src/components/DeleteModal';
+import ListView, { ListViewProps } from 'src/components/ListView';
 import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu';
 import getClientErrorObject from 'src/utils/getClientErrorObject';
 import withToasts from 'src/messageToasts/enhancers/withToasts';
 import { IconName } from 'src/components/Icon';
 import { useListViewResource } from 'src/views/CRUD/hooks';
+import { createErrorHandler } from 'src/views/CRUD/utils';
 
 import { AnnotationObject } from './types';
 import AnnotationModal from './AnnotationModal';
@@ -37,18 +42,24 @@ const PAGE_SIZE = 25;
 
 interface AnnotationListProps {
   addDangerToast: (msg: string) => void;
+  addSuccessToast: (msg: string) => void;
 }
 
-function AnnotationList({ addDangerToast }: AnnotationListProps) {
+function AnnotationList({
+  addDangerToast,
+  addSuccessToast,
+}: AnnotationListProps) {
   const { annotationLayerId }: any = useParams();
   const {
     state: {
       loading,
       resourceCount: annotationsCount,
       resourceCollection: annotations,
+      bulkSelectEnabled,
     },
     fetchData,
     refreshData,
+    toggleBulkSelect,
   } = useListViewResource<AnnotationObject>(
     `annotation_layer/${annotationLayerId}/annotation`,
     t('annotation'),
@@ -63,8 +74,11 @@ function AnnotationList({ addDangerToast }: AnnotationListProps) {
     currentAnnotation,
     setCurrentAnnotation,
   ] = useState<AnnotationObject | null>(null);
-
-  const handleAnnotationEdit = (annotation: AnnotationObject) => {
+  const [
+    annotationCurrentlyDeleting,
+    setAnnotationCurrentlyDeleting,
+  ] = useState<AnnotationObject | null>(null);
+  const handleAnnotationEdit = (annotation: AnnotationObject | null) => {
     setCurrentAnnotation(annotation);
     setAnnotationModalOpen(true);
   };
@@ -85,7 +99,44 @@ function AnnotationList({ addDangerToast }: AnnotationListProps) {
     [annotationLayerId],
   );
 
-  // get the owners of this slice
+  const handleAnnotationDelete = ({ id, short_descr }: AnnotationObject) => {
+    SupersetClient.delete({
+      endpoint: `/api/v1/annotation_layer/${annotationLayerId}/annotation/${id}`,
+    }).then(
+      () => {
+        refreshData();
+        setAnnotationCurrentlyDeleting(null);
+        addSuccessToast(t('Deleted: %s', short_descr));
+      },
+      createErrorHandler(errMsg =>
+        addDangerToast(
+          t('There was an issue deleting %s: %s', short_descr, errMsg),
+        ),
+      ),
+    );
+  };
+
+  const handleBulkAnnotationsDelete = (
+    annotationsToDelete: AnnotationObject[],
+  ) => {
+    SupersetClient.delete({
+      endpoint: `/api/v1/annotation_layer/${annotationLayerId}/annotation/?q=${rison.encode(
+        annotationsToDelete.map(({ id }) => id),
+      )}`,
+    }).then(
+      ({ json = {} }) => {
+        refreshData();
+        addSuccessToast(json.message);
+      },
+      createErrorHandler(errMsg =>
+        addDangerToast(
+          t('There was an issue deleting the selected annotations: %s', errMsg),
+        ),
+      ),
+    );
+  };
+
+  // get the Annotation Layer
   useEffect(() => {
     fetchAnnotationLayer();
   }, [fetchAnnotationLayer]);
@@ -122,7 +173,7 @@ function AnnotationList({ addDangerToast }: AnnotationListProps) {
       {
         Cell: ({ row: { original } }: any) => {
           const handleEdit = () => handleAnnotationEdit(original);
-          const handleDelete = () => {}; // openDatabaseDeleteModal(original);
+          const handleDelete = () => setAnnotationCurrentlyDeleting(original);
           const actions = [
             {
               label: 'edit-action',
@@ -159,11 +210,17 @@ function AnnotationList({ addDangerToast }: AnnotationListProps) {
     ),
     buttonStyle: 'primary',
     onClick: () => {
-      setCurrentAnnotation(null);
-      setAnnotationModalOpen(true);
+      handleAnnotationEdit(null);
     },
   });
 
+  subMenuButtons.push({
+    name: t('Bulk Select'),
+    onClick: toggleBulkSelect,
+    buttonStyle: 'secondary',
+    'data-test': 'annotation-bulk-select',
+  });
+
   const StyledHeader = styled.div`
     display: flex;
     flex-direction: row;
@@ -186,6 +243,24 @@ function AnnotationList({ addDangerToast }: AnnotationListProps) {
     hasHistory = false;
   }
 
+  const EmptyStateButton = (
+    <Button
+      buttonStyle="primary"
+      onClick={() => {
+        handleAnnotationEdit(null);
+      }}
+    >
+      <>
+        <i className="fa fa-plus" /> {t('Annotation')}
+      </>
+    </Button>
+  );
+
+  const emptyState = {
+    message: t('No annotation yet'),
+    slot: EmptyStateButton,
+  };
+
   return (
     <>
       <SubMenu
@@ -211,16 +286,56 @@ function AnnotationList({ addDangerToast }: AnnotationListProps) {
         annnotationLayerId={annotationLayerId}
         onHide={() => setAnnotationModalOpen(false)}
       />
-      <ListView<AnnotationObject>
-        className="css-templates-list-view"
-        columns={columns}
-        count={annotationsCount}
-        data={annotations}
-        fetchData={fetchData}
-        initialSort={initialSort}
-        loading={loading}
-        pageSize={PAGE_SIZE}
-      />
+      {annotationCurrentlyDeleting && (
+        <DeleteModal
+          description={t(
+            `Are you sure you want to delete ${annotationCurrentlyDeleting?.short_descr}?`,
+          )}
+          onConfirm={() => {
+            if (annotationCurrentlyDeleting) {
+              handleAnnotationDelete(annotationCurrentlyDeleting);
+            }
+          }}
+          onHide={() => setAnnotationCurrentlyDeleting(null)}
+          open
+          title={t('Delete Annotation?')}
+        />
+      )}
+      <ConfirmStatusChange
+        title={t('Please confirm')}
+        description={t(
+          'Are you sure you want to delete the selected annotations?',
+        )}
+        onConfirm={handleBulkAnnotationsDelete}
+      >
+        {confirmDelete => {
+          const bulkActions: ListViewProps['bulkActions'] = [
+            {
+              key: 'delete',
+              name: t('Delete'),
+              onSelect: confirmDelete,
+              type: 'danger',
+            },
+          ];
+
+          return (
+            <ListView<AnnotationObject>
+              className="annotations-list-view"
+              bulkActions={bulkActions}
+              bulkSelectEnabled={bulkSelectEnabled}
+              columns={columns}
+              count={annotationsCount}
+              data={annotations}
+              disableBulkSelect={toggleBulkSelect}
+              emptyState={emptyState}
+              fetchData={fetchData}
+              initialSort={initialSort}
+              loading={loading}
+              pageSize={PAGE_SIZE}
+            />
+          );
+        }}
+      </ConfirmStatusChange>
     </>
   );
 }
diff --git a/superset-frontend/src/views/CRUD/annotation/AnnotationModal.tsx b/superset-frontend/src/views/CRUD/annotation/AnnotationModal.tsx
index ddd1931..2befe09 100644
--- a/superset-frontend/src/views/CRUD/annotation/AnnotationModal.tsx
+++ b/superset-frontend/src/views/CRUD/annotation/AnnotationModal.tsx
@@ -287,9 +287,9 @@ const AnnotationModal: FunctionComponent<AnnotationModalProps> = ({
           <span className="required">*</span>
         </div>
         <RangePicker
+          format="YYYY-MM-DD hh:mm a"
           onChange={onDateChange}
           showTime={{ format: 'hh:mm a' }}
-          format="YYYY-MM-DD hh:mm a"
           use12Hours
           // @ts-ignore
           value={
diff --git a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx b/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx
index 7c24b0f..11db5b3 100644
--- a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx
+++ b/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx
@@ -320,7 +320,7 @@ function AnnotationLayersList({
   );
 
   const emptyState = {
-    message: 'No annotation layers yet',
+    message: t('No annotation layers yet'),
     slot: EmptyStateButton,
   };