mirror of
https://github.com/apache/superset.git
synced 2024-09-06 22:07:34 -04:00
feat: annotation layers delete logic + linking w/ annotation view (#11530)
This commit is contained in:
parent
eef4809978
commit
536346ff5e
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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'}
|
||||
|
@ -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>;
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -238,6 +238,8 @@ export function useSingleViewResource<D extends object = any>(
|
||||
updateState({
|
||||
resource: json.result,
|
||||
});
|
||||
|
||||
return json.id;
|
||||
},
|
||||
createErrorHandler(errMsg =>
|
||||
handleErrorMsg(
|
||||
|
Loading…
Reference in New Issue
Block a user