feat: annotation layers delete logic + linking w/ annotation view (#11530)

This commit is contained in:
Moriah Kreeger 2020-11-03 13:01:20 -08:00 committed by GitHub
parent eef4809978
commit 536346ff5e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 233 additions and 36 deletions

View File

@ -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
);
});
});

View File

@ -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'}

View File

@ -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>;

View File

@ -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

View File

@ -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();

View File

@ -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>
</>
);
}

View File

@ -238,6 +238,8 @@ export function useSingleViewResource<D extends object = any>(
updateState({
resource: json.result,
});
return json.id;
},
createErrorHandler(errMsg =>
handleErrorMsg(