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/03 21:01:38 UTC

[incubator-superset] branch master updated: feat: annotation layers delete logic + linking w/ annotation view (#11530)

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 536346f  feat: annotation layers delete logic + linking w/ annotation view (#11530)
536346f is described below

commit 536346ff5ecf8bae70bf72426b4edb9448ca04e5
Author: Moriah Kreeger <mo...@gmail.com>
AuthorDate: Tue Nov 3 13:01:20 2020 -0800

    feat: annotation layers delete logic + linking w/ annotation view (#11530)
---
 .../annotationlayers/AnnotationLayersList_spec.jsx |  50 ++++++-
 .../src/components/ListView/ListView.tsx           |  17 ++-
 superset-frontend/src/components/Menu/SubMenu.tsx  |   3 +-
 .../src/views/CRUD/annotation/AnnotationList.tsx   |  40 +++++-
 .../CRUD/annotationlayers/AnnotationLayerModal.tsx |   4 +-
 .../CRUD/annotationlayers/AnnotationLayersList.tsx | 153 ++++++++++++++++++---
 superset-frontend/src/views/CRUD/hooks.ts          |   2 +
 7 files changed, 233 insertions(+), 36 deletions(-)

diff --git a/superset-frontend/spec/javascripts/views/CRUD/annotationlayers/AnnotationLayersList_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/annotationlayers/AnnotationLayersList_spec.jsx
index c224bd2..cfe74ff 100644
--- a/superset-frontend/spec/javascripts/views/CRUD/annotationlayers/AnnotationLayersList_spec.jsx
+++ b/superset-frontend/spec/javascripts/views/CRUD/annotationlayers/AnnotationLayersList_spec.jsx
@@ -27,9 +27,9 @@ import AnnotationLayerModal from 'src/views/CRUD/annotationlayers/AnnotationLaye
 import SubMenu from 'src/components/Menu/SubMenu';
 import ListView from 'src/components/ListView';
 import Filters from 'src/components/ListView/Filters';
-// import DeleteModal from 'src/components/DeleteModal';
-// import Button from 'src/components/Button';
-// import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
+import DeleteModal from 'src/components/DeleteModal';
+import Button from 'src/components/Button';
+import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
 import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
 import { act } from 'react-dom/test-utils';
 
@@ -39,7 +39,7 @@ const store = mockStore({});
 
 const layersInfoEndpoint = 'glob:*/api/v1/annotation_layer/_info*';
 const layersEndpoint = 'glob:*/api/v1/annotation_layer/?*';
-// const layerEndpoint = 'glob:*/api/v1/annotation_layer/*';
+const layerEndpoint = 'glob:*/api/v1/annotation_layer/*';
 const layersRelatedEndpoint = 'glob:*/api/v1/annotation_layer/related/*';
 
 const mocklayers = [...new Array(3)].map((_, i) => ({
@@ -63,8 +63,8 @@ fetchMock.get(layersEndpoint, {
   layers_count: 3,
 });
 
-/* fetchMock.delete(layerEndpoint, {});
-fetchMock.delete(layersEndpoint, {}); */
+fetchMock.delete(layerEndpoint, {});
+fetchMock.delete(layersEndpoint, {});
 
 fetchMock.get(layersRelatedEndpoint, {
   created_by: {
@@ -119,4 +119,42 @@ describe('AnnotationLayersList', () => {
       `"http://localhost/api/v1/annotation_layer/?q=(filters:!((col:name,opr:ct,value:foo)),order_column:name,order_direction:desc,page:0,page_size:25)"`,
     );
   });
+
+  it('deletes', async () => {
+    act(() => {
+      wrapper.find('[data-test="delete-action"]').first().props().onClick();
+    });
+    await waitForComponentToPaint(wrapper);
+
+    expect(
+      wrapper.find(DeleteModal).first().props().description,
+    ).toMatchInlineSnapshot(`"This action will permanently delete the layer."`);
+
+    act(() => {
+      wrapper
+        .find('#delete')
+        .first()
+        .props()
+        .onChange({ target: { value: 'DELETE' } });
+    });
+    await waitForComponentToPaint(wrapper);
+    act(() => {
+      wrapper.find('button').last().props().onClick();
+    });
+
+    await waitForComponentToPaint(wrapper);
+
+    expect(fetchMock.calls(/annotation_layer\/0/, 'DELETE')).toHaveLength(1);
+  });
+
+  it('shows/hides bulk actions when bulk actions is clicked', async () => {
+    const button = wrapper.find(Button).at(0);
+    act(() => {
+      button.props().onClick();
+    });
+    await waitForComponentToPaint(wrapper);
+    expect(wrapper.find(IndeterminateCheckbox)).toHaveLength(
+      mocklayers.length + 1, // 1 for each row and 1 for select all
+    );
+  });
 });
diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx
index 5f1f6fc..f496680 100644
--- a/superset-frontend/src/components/ListView/ListView.tsx
+++ b/superset-frontend/src/components/ListView/ListView.tsx
@@ -56,13 +56,12 @@ const ListViewStyles = styled.div`
         text-align: right;
       }
     }
-    .body {
-      background: ${({ theme }) => theme.colors.grayscale.light5};
+
+    .body.empty table {
+      margin-bottom: 0;
     }
 
     .ant-empty {
-      padding-bottom: 160px;
-
       .ant-empty-image {
         height: auto;
       }
@@ -157,7 +156,11 @@ const ViewModeContainer = styled.div`
 `;
 
 const EmptyWrapper = styled.div`
-  margin: ${({ theme }) => theme.gridUnit * 40}px 0;
+  padding: ${({ theme }) => theme.gridUnit * 40}px 0;
+
+  &.table {
+    background: ${({ theme }) => theme.colors.grayscale.light5};
+  }
 `;
 
 const ViewModeToggle = ({
@@ -321,7 +324,7 @@ function ListView<T extends object = any>({
             )}
           </div>
         </div>
-        <div className="body">
+        <div className={`body ${rows.length === 0 ? 'empty' : ''}`}>
           {bulkSelectEnabled && (
             <BulkSelectWrapper
               data-test="bulk-select-controls"
@@ -382,7 +385,7 @@ function ListView<T extends object = any>({
             />
           )}
           {!loading && rows.length === 0 && (
-            <EmptyWrapper>
+            <EmptyWrapper className={viewingMode}>
               <Empty
                 image={<EmptyImage />}
                 description={emptyState.message || 'No Data'}
diff --git a/superset-frontend/src/components/Menu/SubMenu.tsx b/superset-frontend/src/components/Menu/SubMenu.tsx
index bf626f1..8f0e171 100644
--- a/superset-frontend/src/components/Menu/SubMenu.tsx
+++ b/superset-frontend/src/components/Menu/SubMenu.tsx
@@ -97,8 +97,9 @@ export interface ButtonProps {
 
 export interface SubMenuProps {
   buttons?: Array<ButtonProps>;
-  name?: string;
+  name?: string | ReactNode;
   tabs?: MenuChild[];
+  children?: MenuChild[];
   activeChild?: MenuChild['name'];
   /* If usesRouter is true, a react-router <Link> component will be used instead of href.
    *  ONLY set usesRouter to true if SubMenu is wrapped in a react-router <Router>;
diff --git a/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx b/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx
index e67e5ae..ee9b623 100644
--- a/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx
+++ b/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx
@@ -18,8 +18,9 @@
  */
 
 import React, { useMemo, useState, useEffect, useCallback } from 'react';
-import { useParams } from 'react-router-dom';
-import { t, SupersetClient } from '@superset-ui/core';
+import { useParams, Link, useHistory } from 'react-router-dom';
+import { t, styled, SupersetClient } from '@superset-ui/core';
+
 import moment from 'moment';
 import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar';
 import ListView from 'src/components/ListView';
@@ -163,10 +164,43 @@ function AnnotationList({ addDangerToast }: AnnotationListProps) {
     },
   });
 
+  const StyledHeader = styled.div`
+    display: flex;
+    flex-direction: row;
+
+    a,
+    Link {
+      margin-left: 16px;
+      font-size: 12px;
+      font-weight: normal;
+      text-decoration: underline;
+    }
+  `;
+
+  let hasHistory = true;
+
+  try {
+    useHistory();
+  } catch (err) {
+    // If error is thrown, we know not to use <Link> in render
+    hasHistory = false;
+  }
+
   return (
     <>
       <SubMenu
-        name={t(`Annotation Layer ${annotationLayerName}`)}
+        name={
+          <StyledHeader>
+            <span>{t(`Annotation Layer ${annotationLayerName}`)}</span>
+            <span>
+              {hasHistory ? (
+                <Link to="/annotationlayermodelview/list/">Back to all</Link>
+              ) : (
+                <a href="/annotationlayermodelview/list/">Back to all</a>
+              )}
+            </span>
+          </StyledHeader>
+        }
         buttons={subMenuButtons}
       />
       <AnnotationModal
diff --git a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayerModal.tsx b/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayerModal.tsx
index 77d99d2..4196ecf 100644
--- a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayerModal.tsx
+++ b/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayerModal.tsx
@@ -131,9 +131,9 @@ const AnnotationLayerModal: FunctionComponent<AnnotationLayerModalProps> = ({
       }
     } else if (currentLayer) {
       // Create
-      createResource(currentLayer).then(() => {
+      createResource(currentLayer).then(response => {
         if (onLayerAdd) {
-          onLayerAdd();
+          onLayerAdd(response);
         }
 
         hide();
diff --git a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx b/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx
index 6589277..7c24b0f 100644
--- a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx
+++ b/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx
@@ -18,7 +18,9 @@
  */
 
 import React, { useMemo, useState } from 'react';
-import { t } from '@superset-ui/core';
+import rison from 'rison';
+import { t, SupersetClient } from '@superset-ui/core';
+import { Link, useHistory } from 'react-router-dom';
 import moment from 'moment';
 import { useListViewResource } from 'src/views/CRUD/hooks';
 import { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils';
@@ -26,8 +28,10 @@ import withToasts from 'src/messageToasts/enhancers/withToasts';
 import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu';
 import { IconName } from 'src/components/Icon';
 import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar';
-import ListView, { Filters } from 'src/components/ListView';
+import ListView, { ListViewProps, Filters } from 'src/components/ListView';
 import Button from 'src/components/Button';
+import DeleteModal from 'src/components/DeleteModal';
+import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
 import AnnotationLayerModal from './AnnotationLayerModal';
 import { AnnotationLayerObject } from './types';
 
@@ -44,10 +48,16 @@ function AnnotationLayersList({
   addSuccessToast,
 }: AnnotationLayersListProps) {
   const {
-    state: { loading, resourceCount: layersCount, resourceCollection: layers },
+    state: {
+      loading,
+      resourceCount: layersCount,
+      resourceCollection: layers,
+      bulkSelectEnabled,
+    },
     hasPerm,
     fetchData,
     refreshData,
+    toggleBulkSelect,
   } = useListViewResource<AnnotationLayerObject>(
     'annotation_layer',
     t('annotation layers'),
@@ -62,6 +72,44 @@ function AnnotationLayersList({
     setCurrentAnnotationLayer,
   ] = useState<AnnotationLayerObject | null>(null);
 
+  const [
+    layerCurrentlyDeleting,
+    setLayerCurrentlyDeleting,
+  ] = useState<AnnotationLayerObject | null>(null);
+
+  const handleLayerDelete = ({ id, name }: AnnotationLayerObject) => {
+    SupersetClient.delete({
+      endpoint: `/api/v1/annotation_layer/${id}`,
+    }).then(
+      () => {
+        refreshData();
+        setLayerCurrentlyDeleting(null);
+        addSuccessToast(t('Deleted: %s', name));
+      },
+      createErrorHandler(errMsg =>
+        addDangerToast(t('There was an issue deleting %s: %s', name, errMsg)),
+      ),
+    );
+  };
+
+  const handleBulkLayerDelete = (layersToDelete: AnnotationLayerObject[]) => {
+    SupersetClient.delete({
+      endpoint: `/api/v1/annotation_layer/?q=${rison.encode(
+        layersToDelete.map(({ id }) => id),
+      )}`,
+    }).then(
+      ({ json = {} }) => {
+        refreshData();
+        addSuccessToast(json.message);
+      },
+      createErrorHandler(errMsg =>
+        addDangerToast(
+          t('There was an issue deleting the selected layers: %s', errMsg),
+        ),
+      ),
+    );
+  };
+
   const canCreate = hasPerm('can_add');
   const canEdit = hasPerm('can_edit');
   const canDelete = hasPerm('can_delete');
@@ -77,6 +125,28 @@ function AnnotationLayersList({
       {
         accessor: 'name',
         Header: t('Name'),
+        Cell: ({
+          row: {
+            original: { id, name },
+          },
+        }: any) => {
+          let hasHistory = true;
+
+          try {
+            useHistory();
+          } catch (err) {
+            // If error is thrown, we know not to use <Link> in render
+            hasHistory = false;
+          }
+
+          if (hasHistory) {
+            return (
+              <Link to={`/annotationmodelview/${id}/annotation`}>{name}</Link>
+            );
+          }
+
+          return <a href={`/annotationmodelview/${id}/annotation`}>{name}</a>;
+        },
       },
       {
         accessor: 'descr',
@@ -147,7 +217,7 @@ function AnnotationLayersList({
       {
         Cell: ({ row: { original } }: any) => {
           const handleEdit = () => handleAnnotationLayerEdit(original);
-          const handleDelete = () => {}; // openAnnotationLayerDeleteModal(original);
+          const handleDelete = () => setLayerCurrentlyDeleting(original);
 
           const actions = [
             canEdit
@@ -198,6 +268,14 @@ function AnnotationLayersList({
     });
   }
 
+  if (canDelete) {
+    subMenuButtons.push({
+      name: t('Bulk Select'),
+      onClick: toggleBulkSelect,
+      buttonStyle: 'secondary',
+    });
+  }
+
   const filters: Filters = useMemo(
     () => [
       {
@@ -246,28 +324,69 @@ function AnnotationLayersList({
     slot: EmptyStateButton,
   };
 
+  const onLayerAdd = (id?: number) => {
+    window.location.href = `/annotationmodelview/${id}/annotation`;
+  };
+
   return (
     <>
       <SubMenu name={t('Annotation Layers')} buttons={subMenuButtons} />
       <AnnotationLayerModal
         addDangerToast={addDangerToast}
         layer={currentAnnotationLayer}
-        onLayerAdd={() => refreshData()}
+        onLayerAdd={onLayerAdd}
         onHide={() => setAnnotationLayerModalOpen(false)}
         show={annotationLayerModalOpen}
       />
-      <ListView<AnnotationLayerObject>
-        className="annotation-layers-list-view"
-        columns={columns}
-        count={layersCount}
-        data={layers}
-        fetchData={fetchData}
-        filters={filters}
-        initialSort={initialSort}
-        loading={loading}
-        pageSize={PAGE_SIZE}
-        emptyState={emptyState}
-      />
+      {layerCurrentlyDeleting && (
+        <DeleteModal
+          description={t('This action will permanently delete the layer.')}
+          onConfirm={() => {
+            if (layerCurrentlyDeleting) {
+              handleLayerDelete(layerCurrentlyDeleting);
+            }
+          }}
+          onHide={() => setLayerCurrentlyDeleting(null)}
+          open
+          title={t('Delete Layer?')}
+        />
+      )}
+      <ConfirmStatusChange
+        title={t('Please confirm')}
+        description={t('Are you sure you want to delete the selected layers?')}
+        onConfirm={handleBulkLayerDelete}
+      >
+        {confirmDelete => {
+          const bulkActions: ListViewProps['bulkActions'] = canDelete
+            ? [
+                {
+                  key: 'delete',
+                  name: t('Delete'),
+                  onSelect: confirmDelete,
+                  type: 'danger',
+                },
+              ]
+            : [];
+
+          return (
+            <ListView<AnnotationLayerObject>
+              className="annotation-layers-list-view"
+              columns={columns}
+              count={layersCount}
+              data={layers}
+              fetchData={fetchData}
+              filters={filters}
+              initialSort={initialSort}
+              loading={loading}
+              pageSize={PAGE_SIZE}
+              bulkActions={bulkActions}
+              bulkSelectEnabled={bulkSelectEnabled}
+              disableBulkSelect={toggleBulkSelect}
+              emptyState={emptyState}
+            />
+          );
+        }}
+      </ConfirmStatusChange>
     </>
   );
 }
diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts
index 827e4a5..054b1e5 100644
--- a/superset-frontend/src/views/CRUD/hooks.ts
+++ b/superset-frontend/src/views/CRUD/hooks.ts
@@ -238,6 +238,8 @@ export function useSingleViewResource<D extends object = any>(
           updateState({
             resource: json.result,
           });
+
+          return json.id;
         },
         createErrorHandler(errMsg =>
           handleErrorMsg(