diff --git a/superset-frontend/spec/javascripts/views/CRUD/annotationlayers/AnnotationLayersList_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/annotationlayers/AnnotationLayersList_spec.jsx index c224bd2e86..cfe74ffee6 100644 --- a/superset-frontend/spec/javascripts/views/CRUD/annotationlayers/AnnotationLayersList_spec.jsx +++ b/superset-frontend/spec/javascripts/views/CRUD/annotationlayers/AnnotationLayersList_spec.jsx @@ -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 + ); + }); }); diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx index 5f1f6fc521..f496680f77 100644 --- a/superset-frontend/src/components/ListView/ListView.tsx +++ b/superset-frontend/src/components/ListView/ListView.tsx @@ -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({ )} -
+
{bulkSelectEnabled && ( ({ /> )} {!loading && rows.length === 0 && ( - + } description={emptyState.message || 'No Data'} diff --git a/superset-frontend/src/components/Menu/SubMenu.tsx b/superset-frontend/src/components/Menu/SubMenu.tsx index bf626f1c52..8f0e171a2c 100644 --- a/superset-frontend/src/components/Menu/SubMenu.tsx +++ b/superset-frontend/src/components/Menu/SubMenu.tsx @@ -97,8 +97,9 @@ export interface ButtonProps { export interface SubMenuProps { buttons?: Array; - name?: string; + name?: string | ReactNode; tabs?: MenuChild[]; + children?: MenuChild[]; activeChild?: MenuChild['name']; /* If usesRouter is true, a react-router component will be used instead of href. * ONLY set usesRouter to true if SubMenu is wrapped in a react-router ; diff --git a/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx b/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx index e67e5ae3e0..ee9b623cd7 100644 --- a/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx +++ b/superset-frontend/src/views/CRUD/annotation/AnnotationList.tsx @@ -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 in render + hasHistory = false; + } + return ( <> + {t(`Annotation Layer ${annotationLayerName}`)} + + {hasHistory ? ( + Back to all + ) : ( + Back to all + )} + + + } buttons={subMenuButtons} /> = ({ } } else if (currentLayer) { // Create - createResource(currentLayer).then(() => { + createResource(currentLayer).then(response => { if (onLayerAdd) { - onLayerAdd(); + onLayerAdd(response); } hide(); diff --git a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx b/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx index 6589277e97..7c24b0f663 100644 --- a/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx +++ b/superset-frontend/src/views/CRUD/annotationlayers/AnnotationLayersList.tsx @@ -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( 'annotation_layer', t('annotation layers'), @@ -62,6 +72,44 @@ function AnnotationLayersList({ setCurrentAnnotationLayer, ] = useState(null); + const [ + layerCurrentlyDeleting, + setLayerCurrentlyDeleting, + ] = useState(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 in render + hasHistory = false; + } + + if (hasHistory) { + return ( + {name} + ); + } + + return {name}; + }, }, { 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 ( <> refreshData()} + onLayerAdd={onLayerAdd} onHide={() => setAnnotationLayerModalOpen(false)} show={annotationLayerModalOpen} /> - - 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 && ( + { + if (layerCurrentlyDeleting) { + handleLayerDelete(layerCurrentlyDeleting); + } + }} + onHide={() => setLayerCurrentlyDeleting(null)} + open + title={t('Delete Layer?')} + /> + )} + + {confirmDelete => { + const bulkActions: ListViewProps['bulkActions'] = canDelete + ? [ + { + key: 'delete', + name: t('Delete'), + onSelect: confirmDelete, + type: 'danger', + }, + ] + : []; + + return ( + + 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} + /> + ); + }} + ); } diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts index 827e4a5fb2..054b1e5138 100644 --- a/superset-frontend/src/views/CRUD/hooks.ts +++ b/superset-frontend/src/views/CRUD/hooks.ts @@ -238,6 +238,8 @@ export function useSingleViewResource( updateState({ resource: json.result, }); + + return json.id; }, createErrorHandler(errMsg => handleErrorMsg(