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 { 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
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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={
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user