feat: annotation delete modal, bulk delete and empty state (#11540)

This commit is contained in:
Lily Kuang 2020-11-09 13:25:16 -08:00 committed by GitHub
parent 92a9acd5c9
commit dda95ed250
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 200 additions and 24 deletions

View File

@ -23,9 +23,13 @@ import fetchMock from 'fetch-mock';
import { styledMount as mount } from 'spec/helpers/theming'; import { styledMount as mount } from 'spec/helpers/theming';
import AnnotationList from 'src/views/CRUD/annotation/AnnotationList'; 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 ListView from 'src/components/ListView';
import SubMenu from 'src/components/Menu/SubMenu';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import { act } from 'react-dom/test-utils';
// store needed for withToasts(AnnotationList) // store needed for withToasts(AnnotationList)
const mockStore = configureStore([thunk]); const mockStore = configureStore([thunk]);
@ -34,7 +38,9 @@ const store = mockStore({});
const annotationsEndpoint = 'glob:*/api/v1/annotation_layer/*/annotation*'; const annotationsEndpoint = 'glob:*/api/v1/annotation_layer/*/annotation*';
const annotationLayerEndpoint = 'glob:*/api/v1/annotation_layer/*'; 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`, changed_on_delta_humanized: `${i} day(s) ago`,
created_by: { created_by: {
first_name: `user`, first_name: `user`,
@ -53,7 +59,7 @@ const mockannotation = [...new Array(3)].map((_, i) => ({
fetchMock.get(annotationsEndpoint, { fetchMock.get(annotationsEndpoint, {
ids: [2, 0, 1], ids: [2, 0, 1],
result: mockannotation, result: mockannotations,
count: 3, 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)"`, `"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
);
});
}); });

View File

@ -27,6 +27,10 @@ import AntdTooltip from './Tooltip';
import { Menu } from '.'; import { Menu } from '.';
import { Dropdown } from './Dropdown'; import { Dropdown } from './Dropdown';
import InfoTooltip from './InfoTooltip'; import InfoTooltip from './InfoTooltip';
import {
DatePicker as AntdDatePicker,
RangePicker as AntdRangePicker,
} from './DatePicker';
export default { export default {
title: 'Common Components', 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
/>
);

View File

@ -86,6 +86,7 @@ type MenuChild = {
export interface ButtonProps { export interface ButtonProps {
name: ReactNode; name: ReactNode;
onClick: OnClickHandler; onClick: OnClickHandler;
'data-test'?: string;
buttonStyle: buttonStyle:
| 'primary' | 'primary'
| 'secondary' | 'secondary'
@ -159,6 +160,7 @@ const SubMenu: React.FunctionComponent<SubMenuProps> = props => {
key={`${i}`} key={`${i}`}
buttonStyle={btn.buttonStyle} buttonStyle={btn.buttonStyle}
onClick={btn.onClick} onClick={btn.onClick}
data-test={btn['data-test']}
> >
{btn.name} {btn.name}
</Button> </Button>

View File

@ -20,15 +20,20 @@
import React, { useMemo, useState, useEffect, useCallback } from 'react'; import React, { useMemo, useState, useEffect, useCallback } from 'react';
import { useParams, Link, useHistory } from 'react-router-dom'; import { useParams, Link, useHistory } from 'react-router-dom';
import { t, styled, SupersetClient } from '@superset-ui/core'; import { t, styled, SupersetClient } from '@superset-ui/core';
import moment from 'moment'; import moment from 'moment';
import rison from 'rison';
import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar'; 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 SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu';
import getClientErrorObject from 'src/utils/getClientErrorObject'; import getClientErrorObject from 'src/utils/getClientErrorObject';
import withToasts from 'src/messageToasts/enhancers/withToasts'; import withToasts from 'src/messageToasts/enhancers/withToasts';
import { IconName } from 'src/components/Icon'; import { IconName } from 'src/components/Icon';
import { useListViewResource } from 'src/views/CRUD/hooks'; import { useListViewResource } from 'src/views/CRUD/hooks';
import { createErrorHandler } from 'src/views/CRUD/utils';
import { AnnotationObject } from './types'; import { AnnotationObject } from './types';
import AnnotationModal from './AnnotationModal'; import AnnotationModal from './AnnotationModal';
@ -37,18 +42,24 @@ const PAGE_SIZE = 25;
interface AnnotationListProps { interface AnnotationListProps {
addDangerToast: (msg: string) => void; addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
} }
function AnnotationList({ addDangerToast }: AnnotationListProps) { function AnnotationList({
addDangerToast,
addSuccessToast,
}: AnnotationListProps) {
const { annotationLayerId }: any = useParams(); const { annotationLayerId }: any = useParams();
const { const {
state: { state: {
loading, loading,
resourceCount: annotationsCount, resourceCount: annotationsCount,
resourceCollection: annotations, resourceCollection: annotations,
bulkSelectEnabled,
}, },
fetchData, fetchData,
refreshData, refreshData,
toggleBulkSelect,
} = useListViewResource<AnnotationObject>( } = useListViewResource<AnnotationObject>(
`annotation_layer/${annotationLayerId}/annotation`, `annotation_layer/${annotationLayerId}/annotation`,
t('annotation'), t('annotation'),
@ -63,8 +74,11 @@ function AnnotationList({ addDangerToast }: AnnotationListProps) {
currentAnnotation, currentAnnotation,
setCurrentAnnotation, setCurrentAnnotation,
] = useState<AnnotationObject | null>(null); ] = useState<AnnotationObject | null>(null);
const [
const handleAnnotationEdit = (annotation: AnnotationObject) => { annotationCurrentlyDeleting,
setAnnotationCurrentlyDeleting,
] = useState<AnnotationObject | null>(null);
const handleAnnotationEdit = (annotation: AnnotationObject | null) => {
setCurrentAnnotation(annotation); setCurrentAnnotation(annotation);
setAnnotationModalOpen(true); setAnnotationModalOpen(true);
}; };
@ -85,7 +99,44 @@ function AnnotationList({ addDangerToast }: AnnotationListProps) {
[annotationLayerId], [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(() => { useEffect(() => {
fetchAnnotationLayer(); fetchAnnotationLayer();
}, [fetchAnnotationLayer]); }, [fetchAnnotationLayer]);
@ -122,7 +173,7 @@ function AnnotationList({ addDangerToast }: AnnotationListProps) {
{ {
Cell: ({ row: { original } }: any) => { Cell: ({ row: { original } }: any) => {
const handleEdit = () => handleAnnotationEdit(original); const handleEdit = () => handleAnnotationEdit(original);
const handleDelete = () => {}; // openDatabaseDeleteModal(original); const handleDelete = () => setAnnotationCurrentlyDeleting(original);
const actions = [ const actions = [
{ {
label: 'edit-action', label: 'edit-action',
@ -159,11 +210,17 @@ function AnnotationList({ addDangerToast }: AnnotationListProps) {
), ),
buttonStyle: 'primary', buttonStyle: 'primary',
onClick: () => { onClick: () => {
setCurrentAnnotation(null); handleAnnotationEdit(null);
setAnnotationModalOpen(true);
}, },
}); });
subMenuButtons.push({
name: t('Bulk Select'),
onClick: toggleBulkSelect,
buttonStyle: 'secondary',
'data-test': 'annotation-bulk-select',
});
const StyledHeader = styled.div` const StyledHeader = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -186,6 +243,24 @@ function AnnotationList({ addDangerToast }: AnnotationListProps) {
hasHistory = false; 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 ( return (
<> <>
<SubMenu <SubMenu
@ -211,16 +286,56 @@ function AnnotationList({ addDangerToast }: AnnotationListProps) {
annnotationLayerId={annotationLayerId} annnotationLayerId={annotationLayerId}
onHide={() => setAnnotationModalOpen(false)} onHide={() => setAnnotationModalOpen(false)}
/> />
<ListView<AnnotationObject> {annotationCurrentlyDeleting && (
className="css-templates-list-view" <DeleteModal
columns={columns} description={t(
count={annotationsCount} `Are you sure you want to delete ${annotationCurrentlyDeleting?.short_descr}?`,
data={annotations} )}
fetchData={fetchData} onConfirm={() => {
initialSort={initialSort} if (annotationCurrentlyDeleting) {
loading={loading} handleAnnotationDelete(annotationCurrentlyDeleting);
pageSize={PAGE_SIZE} }
/> }}
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>
</> </>
); );
} }

View File

@ -287,9 +287,9 @@ const AnnotationModal: FunctionComponent<AnnotationModalProps> = ({
<span className="required">*</span> <span className="required">*</span>
</div> </div>
<RangePicker <RangePicker
format="YYYY-MM-DD hh:mm a"
onChange={onDateChange} onChange={onDateChange}
showTime={{ format: 'hh:mm a' }} showTime={{ format: 'hh:mm a' }}
format="YYYY-MM-DD hh:mm a"
use12Hours use12Hours
// @ts-ignore // @ts-ignore
value={ value={

View File

@ -320,7 +320,7 @@ function AnnotationLayersList({
); );
const emptyState = { const emptyState = {
message: 'No annotation layers yet', message: t('No annotation layers yet'),
slot: EmptyStateButton, slot: EmptyStateButton,
}; };