diff --git a/superset-frontend/spec/javascripts/views/CRUD/csstemplates/CssTemplateModal_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/csstemplates/CssTemplateModal_spec.jsx new file mode 100644 index 0000000000000..d00186671d0c1 --- /dev/null +++ b/superset-frontend/spec/javascripts/views/CRUD/csstemplates/CssTemplateModal_spec.jsx @@ -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(, { + 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(); + }); +}); diff --git a/superset-frontend/src/views/CRUD/csstemplates/CssTemplateModal.tsx b/superset-frontend/src/views/CRUD/csstemplates/CssTemplateModal.tsx new file mode 100644 index 0000000000000..7614e15b1c643 --- /dev/null +++ b/superset-frontend/src/views/CRUD/csstemplates/CssTemplateModal.tsx @@ -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 = ({ + addDangerToast, + onCssTemplateAdd, + onHide, + show, + cssTemplate = null, +}) => { + const [disableSave, setDisableSave] = useState(true); + const [ + currentCssTemplate, + setCurrentCssTemplate, + ] = useState(null); + const [isHidden, setIsHidden] = useState(true); + const isEditMode = cssTemplate !== null; + + // cssTemplate fetch logic + const { + state: { loading, resource }, + fetchResource, + createResource, + updateResource, + } = useSingleViewResource( + '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) => { + 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 ( + + {isEditMode ? ( + + ) : ( + + )} + {isEditMode + ? t('Edit CSS Template Properties') + : t('Add CSS Template')} + + } + > + + {t('Basic Information')} + + + + {t('css template name')} + * + + + + + + {t('css')} + * + + + + + ); +}; + +export default withToasts(CssTemplateModal); diff --git a/superset-frontend/src/views/CRUD/csstemplates/CssTemplatesList.tsx b/superset-frontend/src/views/CRUD/csstemplates/CssTemplatesList.tsx index 5793a2262a833..461616a3ad1ba 100644 --- a/superset-frontend/src/views/CRUD/csstemplates/CssTemplatesList.tsx +++ b/superset-frontend/src/views/CRUD/csstemplates/CssTemplatesList.tsx @@ -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( 'css_template', t('css templates'), addDangerToast, ); + const [cssTemplateModalOpen, setCssTemplateModalOpen] = useState( + false, + ); + const [ + currentCssTemplate, + setCurrentCssTemplate, + ] = useState(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: ( + <> + {' '} + {t('Css Template')} + > + ), + buttonStyle: 'primary', + onClick: () => { + setCurrentCssTemplate(null); + setCssTemplateModalOpen(true); + }, + }); + } + return ( <> - + + refreshData()} + onHide={() => setCssTemplateModalOpen(false)} + show={cssTemplateModalOpen} + /> className="css-templates-list-view" columns={columns} diff --git a/superset-frontend/src/views/CRUD/csstemplates/types.ts b/superset-frontend/src/views/CRUD/csstemplates/types.ts new file mode 100644 index 0000000000000..1bb5b2e6593d3 --- /dev/null +++ b/superset-frontend/src/views/CRUD/csstemplates/types.ts @@ -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; +};