mirror of https://github.com/apache/superset.git
feat: CSS Templates List Actions (#11271)
This commit is contained in:
parent
9dfe9aef39
commit
a2a614d760
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -335,7 +335,7 @@ function SavedQueryList({
|
|||
const handleCopy = () => {
|
||||
copyQueryLink(original.id);
|
||||
};
|
||||
const handleDelete = () => setQueryCurrentlyDeleting(original); // openQueryDeleteModal(original);
|
||||
const handleDelete = () => setQueryCurrentlyDeleting(original);
|
||||
|
||||
const actions = [
|
||||
{
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue