diff --git a/superset-frontend/src/components/ReportModal/EmailReportModal.test.jsx b/superset-frontend/src/components/ReportModal/EmailReportModal.test.jsx new file mode 100644 index 0000000000000..a003e97300540 --- /dev/null +++ b/superset-frontend/src/components/ReportModal/EmailReportModal.test.jsx @@ -0,0 +1,58 @@ +/** + * 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 userEvent from '@testing-library/user-event'; +import { + render, + screen, + // within, + // cleanup, + // act, +} from 'spec/helpers/testing-library'; +import ReportModal from '.'; + +describe('Email Report Modal', () => { + it('inputs respond correctly', () => { + render(); + + // ----- Report name textbox + // Initial value + const reportNameTextbox = screen.getByTestId('report-name-test'); + expect(reportNameTextbox).toHaveDisplayValue('Weekly Report'); + // Type in the textbox and assert that it worked + userEvent.type(reportNameTextbox, 'Report name text test'); + expect(reportNameTextbox).toHaveDisplayValue('Report name text test'); + + // ----- Report description textbox + // Initial value + const reportDescriptionTextbox = screen.getByTestId( + 'report-description-test', + ); + expect(reportDescriptionTextbox).toHaveDisplayValue(''); + // Type in the textbox and assert that it worked + userEvent.type(reportDescriptionTextbox, 'Report description text test'); + expect(reportDescriptionTextbox).toHaveDisplayValue( + 'Report description text test', + ); + + // ----- Crontab + const crontabInputs = screen.getAllByRole('combobox'); + expect(crontabInputs).toHaveLength(4); + }); +}); diff --git a/superset-frontend/src/components/ReportModal/index.tsx b/superset-frontend/src/components/ReportModal/index.tsx new file mode 100644 index 0000000000000..8fbc50f011338 --- /dev/null +++ b/superset-frontend/src/components/ReportModal/index.tsx @@ -0,0 +1,220 @@ +/** + * 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, { + useState, + useCallback, + useReducer, + Reducer, + FunctionComponent, +} from 'react'; +import { styled, css, t } from '@superset-ui/core'; + +import LabeledErrorBoundInput from 'src/components/Form/LabeledErrorBoundInput'; +import Icons from 'src/components/Icons'; +import Modal from 'src/components/Modal'; +import { CronPicker, CronError } from 'src/components/CronPicker'; + +interface ReportProps { + onHide: () => {}; + show: boolean; + props: any; +} + +interface ReportObject { + active: boolean; + crontab: string; + dashboard: number; + description?: string; + log_retention: number; + name: string; + owners: number[]; + recipients: [{ recipient_config_json: { target: string }; type: string }]; + report_format: string; + type: string; + validator_config_json: {} | null; + validator_type: string; + working_timeout: number; +} + +enum ActionType { + textChange, + inputChange, + fetched, +} + +interface ReportPayloadType { + name: string; + description: string; + crontab: string; + value: string; +} + +type ReportActionType = + | { + type: ActionType.textChange | ActionType.inputChange; + payload: ReportPayloadType; + } + | { + type: ActionType.fetched; + payload: Partial; + }; + +const reportReducer = ( + state: Partial | null, + action: ReportActionType, +): Partial | null => { + const trimmedState = { + ...(state || {}), + }; + + switch (action.type) { + case ActionType.textChange: + return { + ...trimmedState, + [action.payload.name]: action.payload.value, + }; + default: + return state; + } +}; + +const StyledModal = styled(Modal)` + .ant-modal-body { + padding: 0; + } +`; + +const StyledTopSection = styled.div` + padding: ${({ theme }) => theme.gridUnit * 4}px; +`; + +const StyledBottomSection = styled.div` + border-top: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; + padding: ${({ theme }) => theme.gridUnit * 4}px; +`; + +const StyledIconWrapper = styled.span` + span { + margin-right: ${({ theme }) => theme.gridUnit * 2}px; + vertical-align: middle; + } + .text { + vertical-align: middle; + } +`; + +const StyledScheduleTitle = styled.div` + margin-bottom: ${({ theme }) => theme.gridUnit * 7}px; +`; + +const StyledCronError = styled.p` + color: ${({ theme }) => theme.colors.error.base}; +`; + +const noBottomMargin = css` + margin-bottom: 0; +`; + +const ReportModal: FunctionComponent = ({ + show = false, + onHide, + props, +}) => { + const [currentReport, setCurrentReport] = useReducer< + Reducer | null, ReportActionType> + >(reportReducer, null); + const onChange = useCallback((type: any, payload: any) => { + setCurrentReport({ type, payload } as ReportActionType); + }, []); + const [error, setError] = useState(); + + const wrappedTitle = ( + + + {t('New Email Report')} + + ); + + return ( + + + + onChange(ActionType.textChange, { + name: target.name, + value: target.value, + }), + }} + errorMessage={ + currentReport?.name === 'error' ? t('REPORT NAME ERROR') : '' + } + label="Report Name" + data-test="report-name-test" + /> + + + onChange(ActionType.textChange, { + name: target.name, + value: target.value, + }), + }} + errorMessage={ + currentReport?.description === 'error' ? t('DESCRIPTION ERROR') : '' + } + label="Description" + placeholder="Include a description that will be sent with your report" + css={noBottomMargin} + data-test="report-description-test" + /> + + + + +

Schedule

+

Scheduled reports will be sent to your email as a PNG

+
+ + { + onChange(ActionType.textChange, { + name: 'crontab', + value: newValue, + }); + }} + onError={setError} + /> + {error} +
+
+ ); +}; + +export default ReportModal; diff --git a/superset-frontend/src/dashboard/components/Header/index.jsx b/superset-frontend/src/dashboard/components/Header/index.jsx index 79411fad11883..28bf2b4e0b28f 100644 --- a/superset-frontend/src/dashboard/components/Header/index.jsx +++ b/superset-frontend/src/dashboard/components/Header/index.jsx @@ -38,6 +38,7 @@ import HeaderActionsDropdown from 'src/dashboard/components/Header/HeaderActions import PublishedStatus from 'src/dashboard/components/PublishedStatus'; import UndoRedoKeyListeners from 'src/dashboard/components/UndoRedoKeyListeners'; import PropertiesModal from 'src/dashboard/components/PropertiesModal'; +import ReportModal from 'src/components/ReportModal'; import { chartPropShape } from 'src/dashboard/util/propShapes'; import { UNDO_LIMIT, @@ -52,6 +53,7 @@ const propTypes = { addDangerToast: PropTypes.func.isRequired, addWarningToast: PropTypes.func.isRequired, userId: PropTypes.number, + userEmail: PropTypes.string, dashboardInfo: PropTypes.object.isRequired, dashboardTitle: PropTypes.string.isRequired, dataMask: PropTypes.object.isRequired, @@ -123,6 +125,7 @@ const StyledDashboardHeader = styled.div` flex-wrap: nowrap; .action-button { font-size: ${({ theme }) => theme.typography.sizes.xl}px; + margin-left: ${({ theme }) => theme.gridUnit * 2.5}px; } } `; @@ -141,6 +144,7 @@ class Header extends React.PureComponent { didNotifyMaxUndoHistoryToast: false, emphasizeUndo: false, showingPropertiesModal: false, + showingReportModal: false, }; this.handleChangeText = this.handleChangeText.bind(this); @@ -152,6 +156,8 @@ class Header extends React.PureComponent { this.overwriteDashboard = this.overwriteDashboard.bind(this); this.showPropertiesModal = this.showPropertiesModal.bind(this); this.hidePropertiesModal = this.hidePropertiesModal.bind(this); + this.showReportModal = this.showReportModal.bind(this); + this.hideReportModal = this.hideReportModal.bind(this); } componentDidMount() { @@ -354,6 +360,14 @@ class Header extends React.PureComponent { this.setState({ showingPropertiesModal: false }); } + showReportModal() { + this.setState({ showingReportModal: true }); + } + + hideReportModal() { + this.setState({ showingReportModal: false }); + } + render() { const { dashboardTitle, @@ -374,6 +388,7 @@ class Header extends React.PureComponent { editMode, isPublished, userId, + userEmail, dashboardInfo, hasUnsavedChanges, isLoading, @@ -502,6 +517,20 @@ class Header extends React.PureComponent { )} + {!editMode && ( + <> + + + + + )} + {this.state.showingPropertiesModal && ( )} + {this.state.showingReportModal && ( + + )} +