From dda95ed2504d3ade9eb365b8da36ae692353242f Mon Sep 17 00:00:00 2001 From: Lily Kuang Date: Mon, 9 Nov 2020 13:25:16 -0800 Subject: [PATCH] feat: annotation delete modal, bulk delete and empty state (#11540) --- .../CRUD/annotation/AnnotationList_spec.jsx | 52 +++++- .../src/common/components/common.stories.tsx | 13 ++ .../src/components/Menu/SubMenu.tsx | 2 + .../views/CRUD/annotation/AnnotationList.tsx | 153 +++++++++++++++--- .../views/CRUD/annotation/AnnotationModal.tsx | 2 +- .../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 80c356209b..1fc7089e0c 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 19f597ea5b..7bdae99649 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 = () => ; +export const DateRangePicker = () => ( + +); diff --git a/superset-frontend/src/components/Menu/SubMenu.tsx b/superset-frontend/src/components/Menu/SubMenu.tsx index cc6914d484..00f3c85d07 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 = props => { key={`${i}`} buttonStyle={btn.buttonStyle} onClick={btn.onClick} + data-test={btn['data-test']} > {btn.name} diff --git a/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx b/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx index ee9b623cd7..dba16e3465 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( `annotation_layer/${annotationLayerId}/annotation`, t('annotation'), @@ -63,8 +74,11 @@ function AnnotationList({ addDangerToast }: AnnotationListProps) { currentAnnotation, setCurrentAnnotation, ] = useState(null); - - const handleAnnotationEdit = (annotation: AnnotationObject) => { + const [ + annotationCurrentlyDeleting, + setAnnotationCurrentlyDeleting, + ] = useState(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 = ( + + ); + + const emptyState = { + message: t('No annotation yet'), + slot: EmptyStateButton, + }; + return ( <> setAnnotationModalOpen(false)} /> - - className="css-templates-list-view" - columns={columns} - count={annotationsCount} - data={annotations} - fetchData={fetchData} - initialSort={initialSort} - loading={loading} - pageSize={PAGE_SIZE} - /> + {annotationCurrentlyDeleting && ( + { + if (annotationCurrentlyDeleting) { + handleAnnotationDelete(annotationCurrentlyDeleting); + } + }} + onHide={() => setAnnotationCurrentlyDeleting(null)} + open + title={t('Delete Annotation?')} + /> + )} + + {confirmDelete => { + const bulkActions: ListViewProps['bulkActions'] = [ + { + key: 'delete', + name: t('Delete'), + onSelect: confirmDelete, + type: 'danger', + }, + ]; + + return ( + + 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} + /> + ); + }} + ); } diff --git a/superset-frontend/src/views/CRUD/annotation/AnnotationModal.tsx b/superset-frontend/src/views/CRUD/annotation/AnnotationModal.tsx index ddd1931889..2befe09f09 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 = ({ *