feat: CSS Templates List Actions (#11271)

This commit is contained in:
Moriah Kreeger 2020-10-21 20:32:59 -07:00 committed by GitHub
parent 9dfe9aef39
commit a2a614d760
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 272 additions and 34 deletions

View File

@ -25,9 +25,12 @@ import { styledMount as mount } from 'spec/helpers/theming';
import CssTemplatesList from 'src/views/CRUD/csstemplates/CssTemplatesList'; import CssTemplatesList from 'src/views/CRUD/csstemplates/CssTemplatesList';
import SubMenu from 'src/components/Menu/SubMenu'; import SubMenu from 'src/components/Menu/SubMenu';
import ListView from 'src/components/ListView'; 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 waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
// import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
// store needed for withToasts(DatabaseList) // store needed for withToasts(DatabaseList)
const mockStore = configureStore([thunk]); const mockStore = configureStore([thunk]);
@ -35,6 +38,8 @@ const store = mockStore({});
const templatesInfoEndpoint = 'glob:*/api/v1/css_template/_info*'; const templatesInfoEndpoint = 'glob:*/api/v1/css_template/_info*';
const templatesEndpoint = 'glob:*/api/v1/css_template/?*'; 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) => ({ const mocktemplates = [...new Array(3)].map((_, i) => ({
changed_on_delta_humanized: `${i} day(s) ago`, changed_on_delta_humanized: `${i} day(s) ago`,
@ -56,6 +61,16 @@ fetchMock.get(templatesEndpoint, {
templates_count: 3, templates_count: 3,
}); });
fetchMock.delete(templateEndpoint, {});
fetchMock.delete(templatesEndpoint, {});
fetchMock.get(templatesRelatedEndpoint, {
created_by: {
count: 0,
result: [],
},
});
describe('CssTemplatesList', () => { describe('CssTemplatesList', () => {
const wrapper = mount(<CssTemplatesList />, { context: { store } }); const wrapper = mount(<CssTemplatesList />, { context: { store } });
@ -74,4 +89,76 @@ describe('CssTemplatesList', () => {
it('renders a ListView', () => { it('renders a ListView', () => {
expect(wrapper.find(ListView)).toExist(); 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
);
});
}); });

View File

@ -18,15 +18,20 @@
*/ */
import React, { useMemo, useState } from 'react'; 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 moment from 'moment';
import { useListViewResource } from 'src/views/CRUD/hooks'; import { useListViewResource } from 'src/views/CRUD/hooks';
import { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils';
import withToasts from 'src/messageToasts/enhancers/withToasts'; import withToasts from 'src/messageToasts/enhancers/withToasts';
import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu'; 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 { IconName } from 'src/components/Icon';
import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar'; import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar';
// import ListView, { Filters } from 'src/components/ListView'; import ListView, { ListViewProps, Filters } from 'src/components/ListView';
import ListView from 'src/components/ListView';
import CssTemplateModal from './CssTemplateModal'; import CssTemplateModal from './CssTemplateModal';
import { TemplateObject } from './types'; import { TemplateObject } from './types';
@ -46,10 +51,12 @@ function CssTemplatesList({
loading, loading,
resourceCount: templatesCount, resourceCount: templatesCount,
resourceCollection: templates, resourceCollection: templates,
bulkSelectEnabled,
}, },
hasPerm, hasPerm,
fetchData, fetchData,
refreshData, refreshData,
toggleBulkSelect,
} = useListViewResource<TemplateObject>( } = useListViewResource<TemplateObject>(
'css_template', 'css_template',
t('css templates'), t('css templates'),
@ -67,6 +74,46 @@ function CssTemplatesList({
const canEdit = hasPerm('can_edit'); const canEdit = hasPerm('can_edit');
const canDelete = hasPerm('can_delete'); const canDelete = hasPerm('can_delete');
const [
templateCurrentlyDeleting,
setTemplateCurrentlyDeleting,
] = useState<TemplateObject | null>(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) { function handleCssTemplateEdit(cssTemplate: TemplateObject) {
setCurrentCssTemplate(cssTemplate); setCurrentCssTemplate(cssTemplate);
setCssTemplateModalOpen(true); setCssTemplateModalOpen(true);
@ -79,6 +126,36 @@ function CssTemplatesList({
accessor: 'template_name', accessor: 'template_name',
Header: t('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 (
<TooltipWrapper
label="allow-run-async-header"
tooltip={t('Last modified by %s', name)}
placement="right"
>
<span>{changedOn}</span>
</TooltipWrapper>
);
},
Header: t('Last Modified'),
accessor: 'changed_on_delta_humanized',
size: 'xl',
disableSortBy: true,
},
{ {
Cell: ({ Cell: ({
row: { row: {
@ -103,6 +180,7 @@ function CssTemplatesList({
Header: t('Created On'), Header: t('Created On'),
accessor: 'created_on', accessor: 'created_on',
size: 'xl', size: 'xl',
disableSortBy: true,
}, },
{ {
accessor: 'created_by', accessor: 'created_by',
@ -116,20 +194,10 @@ function CssTemplatesList({
createdBy ? `${createdBy.first_name} ${createdBy.last_name}` : '', createdBy ? `${createdBy.first_name} ${createdBy.last_name}` : '',
size: 'xl', 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) => { Cell: ({ row: { original } }: any) => {
const handleEdit = () => handleCssTemplateEdit(original); const handleEdit = () => handleCssTemplateEdit(original);
const handleDelete = () => {}; // openDatabaseDeleteModal(original); const handleDelete = () => setTemplateCurrentlyDeleting(original);
const actions = [ const actions = [
canEdit canEdit
@ -164,6 +232,10 @@ function CssTemplatesList({
[canDelete, canCreate], [canDelete, canCreate],
); );
const menuData: SubMenuProps = {
name: t('CSS Templates'),
};
const subMenuButtons: SubMenuProps['buttons'] = []; const subMenuButtons: SubMenuProps['buttons'] = [];
if (canCreate) { 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 ( return (
<> <>
<SubMenu name={t('CSS Templates')} buttons={subMenuButtons} /> <SubMenu {...menuData} />
<CssTemplateModal <CssTemplateModal
addDangerToast={addDangerToast} addDangerToast={addDangerToast}
cssTemplate={currentCssTemplate} cssTemplate={currentCssTemplate}
@ -191,17 +303,56 @@ function CssTemplatesList({
onHide={() => setCssTemplateModalOpen(false)} onHide={() => setCssTemplateModalOpen(false)}
show={cssTemplateModalOpen} show={cssTemplateModalOpen}
/> />
<ListView<TemplateObject> {templateCurrentlyDeleting && (
className="css-templates-list-view" <DeleteModal
columns={columns} description={t('This action will permanently delete the template.')}
count={templatesCount} onConfirm={() => {
data={templates} if (templateCurrentlyDeleting) {
fetchData={fetchData} handleTemplateDelete(templateCurrentlyDeleting);
// filters={filters} }
initialSort={initialSort} }}
loading={loading} onHide={() => setTemplateCurrentlyDeleting(null)}
pageSize={PAGE_SIZE} open
/> title={t('Delete Template?')}
/>
)}
<ConfirmStatusChange
title={t('Please confirm')}
description={t(
'Are you sure you want to delete the selected templates?',
)}
onConfirm={handleBulkTemplateDelete}
>
{confirmDelete => {
const bulkActions: ListViewProps['bulkActions'] = canDelete
? [
{
key: 'delete',
name: t('Delete'),
onSelect: confirmDelete,
type: 'danger',
},
]
: [];
return (
<ListView<TemplateObject>
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}
/>
);
}}
</ConfirmStatusChange>
</> </>
); );
} }

View File

@ -335,7 +335,7 @@ function SavedQueryList({
const handleCopy = () => { const handleCopy = () => {
copyQueryLink(original.id); copyQueryLink(original.id);
}; };
const handleDelete = () => setQueryCurrentlyDeleting(original); // openQueryDeleteModal(original); const handleDelete = () => setQueryCurrentlyDeleting(original);
const actions = [ const actions = [
{ {

View File

@ -43,6 +43,7 @@ class CssTemplateRestApi(BaseSupersetModelRestApi):
datamodel = SQLAInterface(CssTemplate) datamodel = SQLAInterface(CssTemplate)
include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | { include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {
RouteMethod.RELATED,
"bulk_delete", # not using RouteMethod since locally defined "bulk_delete", # not using RouteMethod since locally defined
} }
class_permission_name = "CssTemplateModelView" class_permission_name = "CssTemplateModelView"
@ -59,6 +60,7 @@ class CssTemplateRestApi(BaseSupersetModelRestApi):
] ]
list_columns = [ list_columns = [
"changed_on_delta_humanized", "changed_on_delta_humanized",
"changed_by",
"created_on", "created_on",
"created_by.first_name", "created_by.first_name",
"created_by.id", "created_by.id",
@ -72,6 +74,7 @@ class CssTemplateRestApi(BaseSupersetModelRestApi):
order_columns = ["template_name"] order_columns = ["template_name"]
search_filters = {"template_name": [CssTemplateAllTextFilter]} search_filters = {"template_name": [CssTemplateAllTextFilter]}
allowed_rel_fields = {"created_by"}
apispec_parameter_schemas = { apispec_parameter_schemas = {
"get_delete_ids_schema": get_delete_ids_schema, "get_delete_ids_schema": get_delete_ids_schema,

View File

@ -21,7 +21,6 @@ from flask_babel import lazy_gettext as _
from superset import app from superset import app
from superset.constants import RouteMethod from superset.constants import RouteMethod
from superset.extensions import feature_flag_manager
from superset.models import core as models from superset.models import core as models
from superset.typing import FlaskResponse from superset.typing import FlaskResponse
from superset.views.base import DeleteMixin, SupersetModelView from superset.views.base import DeleteMixin, SupersetModelView
@ -46,10 +45,7 @@ class CssTemplateModelView( # pylint: disable=too-many-ancestors
@expose("/list/") @expose("/list/")
@has_access @has_access
def list(self) -> FlaskResponse: def list(self) -> FlaskResponse:
if not ( if not app.config["ENABLE_REACT_CRUD_VIEWS"]:
app.config["ENABLE_REACT_CRUD_VIEWS"]
and feature_flag_manager.is_feature_enabled("SIP_34_CSS_TEMPLATES_UI")
):
return super().list() return super().list()
return super().render_app_template() return super().render_app_template()

View File

@ -76,6 +76,7 @@ class TestCssTemplateApi(SupersetTestCase):
assert data["count"] == len(css_templates) assert data["count"] == len(css_templates)
expected_columns = [ expected_columns = [
"changed_on_delta_humanized", "changed_on_delta_humanized",
"changed_by",
"created_on", "created_on",
"created_by", "created_by",
"template_name", "template_name",