From a2a614d76070c1f4b0d11571743a7a056cbfc245 Mon Sep 17 00:00:00 2001 From: Moriah Kreeger Date: Wed, 21 Oct 2020 20:32:59 -0700 Subject: [PATCH] feat: CSS Templates List Actions (#11271) --- .../csstemplates/CssTemplatesList_spec.jsx | 91 +++++++- .../CRUD/csstemplates/CssTemplatesList.tsx | 203 +++++++++++++++--- .../CRUD/data/savedquery/SavedQueryList.tsx | 2 +- superset/css_templates/api.py | 3 + superset/views/css_templates.py | 6 +- tests/css_templates/api_tests.py | 1 + 6 files changed, 272 insertions(+), 34 deletions(-) diff --git a/superset-frontend/spec/javascripts/views/CRUD/csstemplates/CssTemplatesList_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/csstemplates/CssTemplatesList_spec.jsx index be2b05ea50..e5b63ab20c 100644 --- a/superset-frontend/spec/javascripts/views/CRUD/csstemplates/CssTemplatesList_spec.jsx +++ b/superset-frontend/spec/javascripts/views/CRUD/csstemplates/CssTemplatesList_spec.jsx @@ -25,9 +25,12 @@ import { styledMount as mount } from 'spec/helpers/theming'; import CssTemplatesList from 'src/views/CRUD/csstemplates/CssTemplatesList'; import SubMenu from 'src/components/Menu/SubMenu'; import ListView from 'src/components/ListView'; -// import Filters from 'src/components/ListView/Filters'; +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 waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; -// import { act } from 'react-dom/test-utils'; +import { act } from 'react-dom/test-utils'; // store needed for withToasts(DatabaseList) const mockStore = configureStore([thunk]); @@ -35,6 +38,8 @@ const store = mockStore({}); const templatesInfoEndpoint = 'glob:*/api/v1/css_template/_info*'; const templatesEndpoint = 'glob:*/api/v1/css_template/?*'; +const templateEndpoint = 'glob:*/api/v1/css_template/*'; +const templatesRelatedEndpoint = 'glob:*/api/v1/css_template/related/*'; const mocktemplates = [...new Array(3)].map((_, i) => ({ changed_on_delta_humanized: `${i} day(s) ago`, @@ -56,6 +61,16 @@ fetchMock.get(templatesEndpoint, { templates_count: 3, }); +fetchMock.delete(templateEndpoint, {}); +fetchMock.delete(templatesEndpoint, {}); + +fetchMock.get(templatesRelatedEndpoint, { + created_by: { + count: 0, + result: [], + }, +}); + describe('CssTemplatesList', () => { const wrapper = mount(, { context: { store } }); @@ -74,4 +89,76 @@ describe('CssTemplatesList', () => { it('renders a ListView', () => { expect(wrapper.find(ListView)).toExist(); }); + + it('fetches templates', () => { + const callsQ = fetchMock.calls(/css_template\/\?q/); + expect(callsQ).toHaveLength(1); + expect(callsQ[0][0]).toMatchInlineSnapshot( + `"http://localhost/api/v1/css_template/?q=(order_column:template_name,order_direction:desc,page:0,page_size:25)"`, + ); + }); + + it('renders Filters', () => { + expect(wrapper.find(Filters)).toExist(); + }); + + it('searches', async () => { + const filtersWrapper = wrapper.find(Filters); + act(() => { + filtersWrapper + .find('[name="template_name"]') + .first() + .props() + .onSubmit('fooo'); + }); + await waitForComponentToPaint(wrapper); + + expect(fetchMock.lastCall()[0]).toMatchInlineSnapshot( + `"http://localhost/api/v1/css_template/?q=(filters:!((col:template_name,opr:ct,value:fooo)),order_column:template_name,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( + `"This action will permanently delete the template."`, + ); + + 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(/css_template\/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( + mocktemplates.length + 1, // 1 for each row and 1 for select all + ); + }); }); diff --git a/superset-frontend/src/views/CRUD/csstemplates/CssTemplatesList.tsx b/superset-frontend/src/views/CRUD/csstemplates/CssTemplatesList.tsx index cd7e90d938..ef9134754d 100644 --- a/superset-frontend/src/views/CRUD/csstemplates/CssTemplatesList.tsx +++ b/superset-frontend/src/views/CRUD/csstemplates/CssTemplatesList.tsx @@ -18,15 +18,20 @@ */ import React, { useMemo, useState } from 'react'; -import { t } from '@superset-ui/core'; +import { t, SupersetClient } from '@superset-ui/core'; + +import rison from 'rison'; import moment from 'moment'; import { useListViewResource } from 'src/views/CRUD/hooks'; +import { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils'; import withToasts from 'src/messageToasts/enhancers/withToasts'; import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu'; +import DeleteModal from 'src/components/DeleteModal'; +import TooltipWrapper from 'src/components/TooltipWrapper'; +import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import { IconName } from 'src/components/Icon'; import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar'; -// import ListView, { Filters } from 'src/components/ListView'; -import ListView from 'src/components/ListView'; +import ListView, { ListViewProps, Filters } from 'src/components/ListView'; import CssTemplateModal from './CssTemplateModal'; import { TemplateObject } from './types'; @@ -46,10 +51,12 @@ function CssTemplatesList({ loading, resourceCount: templatesCount, resourceCollection: templates, + bulkSelectEnabled, }, hasPerm, fetchData, refreshData, + toggleBulkSelect, } = useListViewResource( 'css_template', t('css templates'), @@ -67,6 +74,46 @@ function CssTemplatesList({ const canEdit = hasPerm('can_edit'); const canDelete = hasPerm('can_delete'); + const [ + templateCurrentlyDeleting, + setTemplateCurrentlyDeleting, + ] = useState(null); + + const handleTemplateDelete = ({ id, template_name }: TemplateObject) => { + SupersetClient.delete({ + endpoint: `/api/v1/css_template/${id}`, + }).then( + () => { + refreshData(); + setTemplateCurrentlyDeleting(null); + addSuccessToast(t('Deleted: %s', template_name)); + }, + createErrorHandler(errMsg => + addDangerToast( + t('There was an issue deleting %s: %s', template_name, errMsg), + ), + ), + ); + }; + + const handleBulkTemplateDelete = (templatesToDelete: TemplateObject[]) => { + SupersetClient.delete({ + endpoint: `/api/v1/css_template/?q=${rison.encode( + templatesToDelete.map(({ id }) => id), + )}`, + }).then( + ({ json = {} }) => { + refreshData(); + addSuccessToast(json.message); + }, + createErrorHandler(errMsg => + addDangerToast( + t('There was an issue deleting the selected templates: %s', errMsg), + ), + ), + ); + }; + function handleCssTemplateEdit(cssTemplate: TemplateObject) { setCurrentCssTemplate(cssTemplate); setCssTemplateModalOpen(true); @@ -79,6 +126,36 @@ function CssTemplatesList({ accessor: 'template_name', Header: t('Name'), }, + { + Cell: ({ + row: { + original: { + changed_on_delta_humanized: changedOn, + changed_by: changedBy, + }, + }, + }: any) => { + let name = 'null'; + + if (changedBy) { + name = `${changedBy.first_name} ${changedBy.last_name}`; + } + + return ( + + {changedOn} + + ); + }, + Header: t('Last Modified'), + accessor: 'changed_on_delta_humanized', + size: 'xl', + disableSortBy: true, + }, { Cell: ({ row: { @@ -103,6 +180,7 @@ function CssTemplatesList({ Header: t('Created On'), accessor: 'created_on', size: 'xl', + disableSortBy: true, }, { accessor: 'created_by', @@ -116,20 +194,10 @@ function CssTemplatesList({ createdBy ? `${createdBy.first_name} ${createdBy.last_name}` : '', size: 'xl', }, - { - Cell: ({ - row: { - original: { changed_on_delta_humanized: changedOn }, - }, - }: any) => changedOn, - Header: t('Last Modified'), - accessor: 'changed_on_delta_humanized', - size: 'xl', - }, { Cell: ({ row: { original } }: any) => { const handleEdit = () => handleCssTemplateEdit(original); - const handleDelete = () => {}; // openDatabaseDeleteModal(original); + const handleDelete = () => setTemplateCurrentlyDeleting(original); const actions = [ canEdit @@ -164,6 +232,10 @@ function CssTemplatesList({ [canDelete, canCreate], ); + const menuData: SubMenuProps = { + name: t('CSS Templates'), + }; + const subMenuButtons: SubMenuProps['buttons'] = []; if (canCreate) { @@ -181,9 +253,49 @@ function CssTemplatesList({ }); } + if (canDelete) { + subMenuButtons.push({ + name: t('Bulk Select'), + onClick: toggleBulkSelect, + buttonStyle: 'secondary', + }); + } + + menuData.buttons = subMenuButtons; + + const filters: Filters = useMemo( + () => [ + { + Header: t('Created By'), + id: 'created_by', + input: 'select', + operator: 'rel_o_m', + unfilteredLabel: 'All', + fetchSelects: createFetchRelated( + 'css_template', + 'created_by', + createErrorHandler(errMsg => + t( + 'An error occurred while fetching dataset datasource values: %s', + errMsg, + ), + ), + ), + paginate: true, + }, + { + Header: t('Search'), + id: 'template_name', + input: 'search', + operator: 'ct', + }, + ], + [], + ); + return ( <> - + setCssTemplateModalOpen(false)} show={cssTemplateModalOpen} /> - - className="css-templates-list-view" - columns={columns} - count={templatesCount} - data={templates} - fetchData={fetchData} - // filters={filters} - initialSort={initialSort} - loading={loading} - pageSize={PAGE_SIZE} - /> + {templateCurrentlyDeleting && ( + { + if (templateCurrentlyDeleting) { + handleTemplateDelete(templateCurrentlyDeleting); + } + }} + onHide={() => setTemplateCurrentlyDeleting(null)} + open + title={t('Delete Template?')} + /> + )} + + {confirmDelete => { + const bulkActions: ListViewProps['bulkActions'] = canDelete + ? [ + { + key: 'delete', + name: t('Delete'), + onSelect: confirmDelete, + type: 'danger', + }, + ] + : []; + + return ( + + className="css-templates-list-view" + columns={columns} + count={templatesCount} + data={templates} + fetchData={fetchData} + filters={filters} + initialSort={initialSort} + loading={loading} + pageSize={PAGE_SIZE} + bulkActions={bulkActions} + bulkSelectEnabled={bulkSelectEnabled} + disableBulkSelect={toggleBulkSelect} + /> + ); + }} + ); } diff --git a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx index bf91d4d7a0..849547cd64 100644 --- a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx +++ b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx @@ -335,7 +335,7 @@ function SavedQueryList({ const handleCopy = () => { copyQueryLink(original.id); }; - const handleDelete = () => setQueryCurrentlyDeleting(original); // openQueryDeleteModal(original); + const handleDelete = () => setQueryCurrentlyDeleting(original); const actions = [ { diff --git a/superset/css_templates/api.py b/superset/css_templates/api.py index 4da895c297..e59fd5184f 100644 --- a/superset/css_templates/api.py +++ b/superset/css_templates/api.py @@ -43,6 +43,7 @@ class CssTemplateRestApi(BaseSupersetModelRestApi): datamodel = SQLAInterface(CssTemplate) include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | { + RouteMethod.RELATED, "bulk_delete", # not using RouteMethod since locally defined } class_permission_name = "CssTemplateModelView" @@ -59,6 +60,7 @@ class CssTemplateRestApi(BaseSupersetModelRestApi): ] list_columns = [ "changed_on_delta_humanized", + "changed_by", "created_on", "created_by.first_name", "created_by.id", @@ -72,6 +74,7 @@ class CssTemplateRestApi(BaseSupersetModelRestApi): order_columns = ["template_name"] search_filters = {"template_name": [CssTemplateAllTextFilter]} + allowed_rel_fields = {"created_by"} apispec_parameter_schemas = { "get_delete_ids_schema": get_delete_ids_schema, diff --git a/superset/views/css_templates.py b/superset/views/css_templates.py index 3bd978e37a..7e87d81d7f 100644 --- a/superset/views/css_templates.py +++ b/superset/views/css_templates.py @@ -21,7 +21,6 @@ from flask_babel import lazy_gettext as _ from superset import app from superset.constants import RouteMethod -from superset.extensions import feature_flag_manager from superset.models import core as models from superset.typing import FlaskResponse from superset.views.base import DeleteMixin, SupersetModelView @@ -46,10 +45,7 @@ class CssTemplateModelView( # pylint: disable=too-many-ancestors @expose("/list/") @has_access def list(self) -> FlaskResponse: - if not ( - app.config["ENABLE_REACT_CRUD_VIEWS"] - and feature_flag_manager.is_feature_enabled("SIP_34_CSS_TEMPLATES_UI") - ): + if not app.config["ENABLE_REACT_CRUD_VIEWS"]: return super().list() return super().render_app_template() diff --git a/tests/css_templates/api_tests.py b/tests/css_templates/api_tests.py index 095c2464cb..2d4073f634 100644 --- a/tests/css_templates/api_tests.py +++ b/tests/css_templates/api_tests.py @@ -76,6 +76,7 @@ class TestCssTemplateApi(SupersetTestCase): assert data["count"] == len(css_templates) expected_columns = [ "changed_on_delta_humanized", + "changed_by", "created_on", "created_by", "template_name",