feat: css template add/edit modal (#11296)

This commit is contained in:
Lily Kuang 2020-10-20 12:15:39 -07:00 committed by GitHub
parent 9f8d0e7a06
commit 6f3d4c131f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 423 additions and 18 deletions

View File

@ -0,0 +1,90 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
import fetchMock from 'fetch-mock';
import CssTemplateModal from 'src/views/CRUD/csstemplates/CssTemplateModal';
import Modal from 'src/common/components/Modal';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import { CssEditor } from 'src/components/AsyncAceEditor';
import { styledMount as mount } from 'spec/helpers/theming';
const mockData = { id: 1, template_name: 'test' };
const FETCH_CSS_TEMPLATE_ENDPOINT = 'glob:*/api/v1/css_template/*';
const CSS_TEMPLATE_PAYLOAD = { result: mockData };
fetchMock.get(FETCH_CSS_TEMPLATE_ENDPOINT, CSS_TEMPLATE_PAYLOAD);
const mockStore = configureStore([thunk]);
const store = mockStore({});
const mockedProps = {
addDangerToast: () => {},
onCssTemplateAdd: jest.fn(() => []),
onHide: () => {},
show: true,
cssTemplate: mockData,
};
async function mountAndWait(props = mockedProps) {
const mounted = mount(<CssTemplateModal show {...props} />, {
context: { store },
});
await waitForComponentToPaint(mounted);
return mounted;
}
describe('CssTemplateModal', () => {
let wrapper;
beforeAll(async () => {
wrapper = await mountAndWait();
});
it('renders', () => {
expect(wrapper.find(CssTemplateModal)).toExist();
});
it('renders a Modal', () => {
expect(wrapper.find(Modal)).toExist();
});
it('renders add header when no css template is included', async () => {
const addWrapper = await mountAndWait({});
expect(
addWrapper.find('[data-test="css-template-modal-title"]').text(),
).toEqual('Add CSS Template');
});
it('renders edit header when css template prop is included', () => {
expect(
wrapper.find('[data-test="css-template-modal-title"]').text(),
).toEqual('Edit CSS Template Properties');
});
it('renders input elements for template name', () => {
expect(wrapper.find('input[name="template_name"]')).toExist();
});
it('renders css editor for css', () => {
expect(wrapper.find(CssEditor)).toExist();
});
});

View File

@ -0,0 +1,257 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { FunctionComponent, useState, useEffect } from 'react';
import { styled, t } from '@superset-ui/core';
import { useSingleViewResource } from 'src/views/CRUD/hooks';
import Icon from 'src/components/Icon';
import Modal from 'src/common/components/Modal';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import { CssEditor } from 'src/components/AsyncAceEditor';
import { TemplateObject } from './types';
interface CssTemplateModalProps {
addDangerToast: (msg: string) => void;
cssTemplate?: TemplateObject | null;
onCssTemplateAdd?: (cssTemplate?: TemplateObject) => void;
onHide: () => void;
show: boolean;
}
const StyledCssTemplateTitle = styled.div`
margin: ${({ theme }) => theme.gridUnit * 2}px auto
${({ theme }) => theme.gridUnit * 4}px auto;
`;
const StyledCssEditor = styled(CssEditor)`
border-radius: ${({ theme }) => theme.borderRadius}px;
border: 1px solid ${({ theme }) => theme.colors.secondary.light2};
`;
const StyledIcon = styled(Icon)`
margin: auto ${({ theme }) => theme.gridUnit * 2}px auto 0;
`;
const TemplateContainer = styled.div`
margin-bottom: ${({ theme }) => theme.gridUnit * 10}px;
.control-label {
margin-bottom: ${({ theme }) => theme.gridUnit * 2}px;
}
.required {
margin-left: ${({ theme }) => theme.gridUnit / 2}px;
color: ${({ theme }) => theme.colors.error.base};
}
input[type='text'] {
padding: ${({ theme }) => theme.gridUnit * 1.5}px
${({ theme }) => theme.gridUnit * 2}px;
border: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
border-radius: ${({ theme }) => theme.gridUnit}px;
width: 50%;
}
`;
const CssTemplateModal: FunctionComponent<CssTemplateModalProps> = ({
addDangerToast,
onCssTemplateAdd,
onHide,
show,
cssTemplate = null,
}) => {
const [disableSave, setDisableSave] = useState<boolean>(true);
const [
currentCssTemplate,
setCurrentCssTemplate,
] = useState<TemplateObject | null>(null);
const [isHidden, setIsHidden] = useState<boolean>(true);
const isEditMode = cssTemplate !== null;
// cssTemplate fetch logic
const {
state: { loading, resource },
fetchResource,
createResource,
updateResource,
} = useSingleViewResource<TemplateObject>(
'css_template',
t('css_template'),
addDangerToast,
);
// Functions
const hide = () => {
setIsHidden(true);
onHide();
};
const onSave = () => {
if (isEditMode) {
// Edit
if (currentCssTemplate && currentCssTemplate.id) {
const update_id = currentCssTemplate.id;
delete currentCssTemplate.id;
delete currentCssTemplate.created_by;
updateResource(update_id, currentCssTemplate).then(() => {
if (onCssTemplateAdd) {
onCssTemplateAdd();
}
hide();
});
}
} else if (currentCssTemplate) {
// Create
createResource(currentCssTemplate).then(() => {
if (onCssTemplateAdd) {
onCssTemplateAdd();
}
hide();
});
}
};
const onTemplateNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { target } = event;
const data = {
...currentCssTemplate,
template_name: currentCssTemplate ? currentCssTemplate.template_name : '',
css: currentCssTemplate ? currentCssTemplate.css : '',
};
data[target.name] = target.value;
setCurrentCssTemplate(data);
};
const onCssChange = (css: string) => {
const data = {
...currentCssTemplate,
template_name: currentCssTemplate ? currentCssTemplate.template_name : '',
css,
};
setCurrentCssTemplate(data);
};
const validate = () => {
if (
currentCssTemplate &&
currentCssTemplate.template_name.length &&
currentCssTemplate.css &&
currentCssTemplate.css.length
) {
setDisableSave(false);
} else {
setDisableSave(true);
}
};
// Initialize
if (
isEditMode &&
(!currentCssTemplate ||
!currentCssTemplate.id ||
(cssTemplate && cssTemplate.id !== currentCssTemplate.id) ||
(isHidden && show))
) {
if (cssTemplate && cssTemplate.id !== null && !loading) {
const id = cssTemplate.id || 0;
fetchResource(id).then(() => {
setCurrentCssTemplate(resource);
});
}
} else if (
!isEditMode &&
(!currentCssTemplate || currentCssTemplate.id || (isHidden && show))
) {
setCurrentCssTemplate({
template_name: '',
css: '',
});
}
// Validation
useEffect(() => {
validate();
}, [
currentCssTemplate ? currentCssTemplate.template_name : '',
currentCssTemplate ? currentCssTemplate.css : '',
]);
// Show/hide
if (isHidden && show) {
setIsHidden(false);
}
return (
<Modal
disablePrimaryButton={disableSave}
onHandledPrimaryAction={onSave}
onHide={hide}
primaryButtonName={isEditMode ? t('Save') : t('Add')}
show={show}
width="55%"
title={
<h4 data-test="css-template-modal-title">
{isEditMode ? (
<StyledIcon name="edit-alt" />
) : (
<StyledIcon name="plus-large" />
)}
{isEditMode
? t('Edit CSS Template Properties')
: t('Add CSS Template')}
</h4>
}
>
<StyledCssTemplateTitle>
<h4>{t('Basic Information')}</h4>
</StyledCssTemplateTitle>
<TemplateContainer>
<div className="control-label">
{t('css template name')}
<span className="required">*</span>
</div>
<input
name="template_name"
onChange={onTemplateNameChange}
type="text"
value={currentCssTemplate?.template_name}
/>
</TemplateContainer>
<TemplateContainer>
<div className="control-label">
{t('css')}
<span className="required">*</span>
</div>
<StyledCssEditor
onChange={onCssChange}
value={currentCssTemplate?.css}
width="100%"
/>
</TemplateContainer>
</Modal>
);
};
export default withToasts(CssTemplateModal);

View File

@ -17,16 +17,18 @@
* under the License.
*/
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import { t } from '@superset-ui/core';
import moment from 'moment';
import { useListViewResource } from 'src/views/CRUD/hooks';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import SubMenu from 'src/components/Menu/SubMenu';
import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu';
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 CssTemplateModal from './CssTemplateModal';
import { TemplateObject } from './types';
const PAGE_SIZE = 25;
@ -35,19 +37,6 @@ interface CssTemplatesListProps {
addSuccessToast: (msg: string) => void;
}
type TemplateObject = {
id?: number;
changed_on_delta_humanized: string;
created_on: string;
created_by: {
id: number;
first_name: string;
last_name: string;
};
css: string;
template_name: string;
};
function CssTemplatesList({
addDangerToast,
addSuccessToast,
@ -60,17 +49,29 @@ function CssTemplatesList({
},
hasPerm,
fetchData,
// refreshData,
refreshData,
} = useListViewResource<TemplateObject>(
'css_template',
t('css templates'),
addDangerToast,
);
const [cssTemplateModalOpen, setCssTemplateModalOpen] = useState<boolean>(
false,
);
const [
currentCssTemplate,
setCurrentCssTemplate,
] = useState<TemplateObject | null>(null);
const canCreate = hasPerm('can_add');
const canEdit = hasPerm('can_edit');
const canDelete = hasPerm('can_delete');
function handleCssTemplateEdit(cssTemplate: TemplateObject) {
setCurrentCssTemplate(cssTemplate);
setCssTemplateModalOpen(true);
}
const initialSort = [{ id: 'template_name', desc: true }];
const columns = useMemo(
() => [
@ -127,7 +128,7 @@ function CssTemplatesList({
},
{
Cell: ({ row: { original } }: any) => {
const handleEdit = () => {}; // handleDatabaseEdit(original);
const handleEdit = () => handleCssTemplateEdit(original);
const handleDelete = () => {}; // openDatabaseDeleteModal(original);
const actions = [
@ -166,9 +167,34 @@ function CssTemplatesList({
[canDelete, canCreate],
);
const subMenuButtons: SubMenuProps['buttons'] = [];
if (canCreate) {
subMenuButtons.push({
name: (
<>
{' '}
<i className="fa fa-plus" /> {t('Css Template')}
</>
),
buttonStyle: 'primary',
onClick: () => {
setCurrentCssTemplate(null);
setCssTemplateModalOpen(true);
},
});
}
return (
<>
<SubMenu name={t('CSS Templates')} />
<SubMenu name={t('CSS Templates')} buttons={subMenuButtons} />
<CssTemplateModal
addDangerToast={addDangerToast}
cssTemplate={currentCssTemplate}
onCssTemplateAdd={() => refreshData()}
onHide={() => setCssTemplateModalOpen(false)}
show={cssTemplateModalOpen}
/>
<ListView<TemplateObject>
className="css-templates-list-view"
columns={columns}

View File

@ -0,0 +1,32 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
type CreatedByUser = {
id: number;
first_name: string;
last_name: string;
};
export type TemplateObject = {
id?: number;
changed_on_delta_humanized?: string;
created_on?: string;
created_by?: CreatedByUser;
css?: string;
template_name: string;
};