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",