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 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(<CssTemplatesList />, { 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
);
});
});

View File

@ -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<TemplateObject>(
'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<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) {
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 (
<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: ({
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 (
<>
<SubMenu name={t('CSS Templates')} buttons={subMenuButtons} />
<SubMenu {...menuData} />
<CssTemplateModal
addDangerToast={addDangerToast}
cssTemplate={currentCssTemplate}
@ -191,17 +303,56 @@ function CssTemplatesList({
onHide={() => setCssTemplateModalOpen(false)}
show={cssTemplateModalOpen}
/>
<ListView<TemplateObject>
className="css-templates-list-view"
columns={columns}
count={templatesCount}
data={templates}
fetchData={fetchData}
// filters={filters}
initialSort={initialSort}
loading={loading}
pageSize={PAGE_SIZE}
/>
{templateCurrentlyDeleting && (
<DeleteModal
description={t('This action will permanently delete the template.')}
onConfirm={() => {
if (templateCurrentlyDeleting) {
handleTemplateDelete(templateCurrentlyDeleting);
}
}}
onHide={() => setTemplateCurrentlyDeleting(null)}
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 = () => {
copyQueryLink(original.id);
};
const handleDelete = () => setQueryCurrentlyDeleting(original); // openQueryDeleteModal(original);
const handleDelete = () => setQueryCurrentlyDeleting(original);
const actions = [
{

View File

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

View File

@ -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()

View File

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