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(