mirror of
https://github.com/apache/superset.git
synced 2024-09-12 00:29:39 -04:00
feat: annotation delete modal, bulk delete and empty state (#11540)
This commit is contained in:
parent
92a9acd5c9
commit
dda95ed250
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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
|
||||
/>
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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={
|
||||
|
@ -320,7 +320,7 @@ function AnnotationLayersList({
|
||||
);
|
||||
|
||||
const emptyState = {
|
||||
message: 'No annotation layers yet',
|
||||
message: t('No annotation layers yet'),
|
||||
slot: EmptyStateButton,
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user