blob: 178e394125746ecb4719d812ea9774fee8b63573 [file] [log] [blame]
/**
* 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,
useEffect,
useCallback,
useReducer,
Reducer,
FunctionComponent,
} from 'react';
import { t, SupersetTheme } from '@superset-ui/core';
import { bindActionCreators } from 'redux';
import { connect, useDispatch, useSelector } from 'react-redux';
import { addReport, editReport } from 'src/reports/actions/reports';
import { AlertObject } from 'src/views/CRUD/alert/types';
import TimezoneSelector from 'src/components/TimezoneSelector';
import LabeledErrorBoundInput from 'src/components/Form/LabeledErrorBoundInput';
import Icons from 'src/components/Icons';
import withToasts from 'src/components/MessageToasts/withToasts';
import { CronError } from 'src/components/CronPicker';
import { RadioChangeEvent } from 'src/common/components';
import {
StyledModal,
StyledTopSection,
StyledBottomSection,
StyledIconWrapper,
StyledScheduleTitle,
StyledCronPicker,
StyledCronError,
noBottomMargin,
StyledFooterButton,
TimezoneHeaderStyle,
SectionHeaderStyle,
StyledMessageContentTitle,
StyledRadio,
StyledRadioGroup,
} from './styles';
export interface ReportObject {
id?: number;
active: boolean;
crontab: string;
dashboard?: number;
chart?: number;
description?: string;
log_retention: number;
name: string;
owners: number[];
recipients: [{ recipient_config_json: { target: string }; type: string }];
report_format: string;
timezone: string;
type: string;
validator_config_json: {} | null;
validator_type: string;
working_timeout: number;
creation_method: string;
}
interface ChartObject {
id: number;
chartAlert: string;
chartStatus: string;
chartUpdateEndTime: number;
chartUpdateStartTime: number;
latestQueryFormData: object;
queryController: { abort: () => {} };
queriesResponse: object;
triggerQuery: boolean;
lastRendered: number;
}
interface ReportProps {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
addReport: (report?: ReportObject) => {};
onHide: () => {};
onReportAdd: (report?: ReportObject) => {};
show: boolean;
userId: number;
userEmail: string;
dashboardId?: number;
chart?: ChartObject;
creationMethod: string;
props: any;
}
interface ReportPayloadType {
name: string;
value: string;
}
enum ActionType {
inputChange,
fetched,
reset,
}
type ReportActionType =
| {
type: ActionType.inputChange;
payload: ReportPayloadType;
}
| {
type: ActionType.fetched;
payload: Partial<ReportObject>;
}
| {
type: ActionType.reset;
};
const TEXT_BASED_VISUALIZATION_TYPES = [
'pivot_table',
'pivot_table_v2',
'table',
'paired_ttest',
];
const NOTIFICATION_FORMATS = {
TEXT: 'TEXT',
PNG: 'PNG',
CSV: 'CSV',
};
const reportReducer = (
state: Partial<ReportObject> | null,
action: ReportActionType,
): Partial<ReportObject> | null => {
const initialState = {
name: 'Weekly Report',
};
switch (action.type) {
case ActionType.inputChange:
return {
...initialState,
...state,
[action.payload.name]: action.payload.value,
};
case ActionType.fetched:
return {
...initialState,
...action.payload,
};
case ActionType.reset:
return { ...initialState };
default:
return state;
}
};
const ReportModal: FunctionComponent<ReportProps> = ({
onReportAdd,
onHide,
show = false,
...props
}) => {
const vizType = props.props.chart?.sliceFormData?.viz_type;
const isChart = !!props.props.chart;
const defaultNotificationFormat =
isChart && TEXT_BASED_VISUALIZATION_TYPES.includes(vizType)
? NOTIFICATION_FORMATS.TEXT
: NOTIFICATION_FORMATS.PNG;
const [currentReport, setCurrentReport] = useReducer<
Reducer<Partial<ReportObject> | null, ReportActionType>
>(reportReducer, null);
const onChange = useCallback((type: any, payload: any) => {
setCurrentReport({ type, payload });
}, []);
const [error, setError] = useState<CronError>();
// const [isLoading, setLoading] = useState<boolean>(false);
const dispatch = useDispatch();
// Report fetch logic
const reports = useSelector<any, AlertObject>(state => state.reports);
const isEditMode = reports && Object.keys(reports).length;
useEffect(() => {
if (isEditMode) {
const reportsIds = Object.keys(reports);
const report = reports[reportsIds[0]];
setCurrentReport({
type: ActionType.fetched,
payload: report,
});
} else {
setCurrentReport({
type: ActionType.reset,
});
}
}, [reports]);
const onClose = () => {
onHide();
};
const onSave = async () => {
// Create new Report
const newReportValues: Partial<ReportObject> = {
crontab: currentReport?.crontab,
dashboard: props.props.dashboardId,
chart: props.props.chart?.id,
description: currentReport?.description,
name: currentReport?.name,
owners: [props.props.userId],
recipients: [
{
recipient_config_json: { target: props.props.userEmail },
type: 'Email',
},
],
type: 'Report',
creation_method: props.props.creationMethod,
active: true,
report_format: currentReport?.report_format || defaultNotificationFormat,
timezone: currentReport?.timezone,
};
if (isEditMode) {
await dispatch(
editReport(currentReport?.id, newReportValues as ReportObject),
);
} else {
await dispatch(addReport(newReportValues as ReportObject));
}
if (onReportAdd) {
onReportAdd();
}
onClose();
};
const wrappedTitle = (
<StyledIconWrapper>
<Icons.Calendar />
<span className="text">
{isEditMode ? t('Edit Email Report') : t('New Email Report')}
</span>
</StyledIconWrapper>
);
const renderModalFooter = (
<>
<StyledFooterButton key="back" onClick={onClose}>
{t('Cancel')}
</StyledFooterButton>
<StyledFooterButton
key="submit"
buttonStyle="primary"
onClick={onSave}
disabled={!currentReport?.name}
>
{isEditMode ? t('Save') : t('Add')}
</StyledFooterButton>
</>
);
const renderMessageContentSection = (
<>
<StyledMessageContentTitle>
<h4>{t('Message Content')}</h4>
</StyledMessageContentTitle>
<div className="inline-container">
<StyledRadioGroup
onChange={(event: RadioChangeEvent) => {
onChange(ActionType.inputChange, {
name: 'report_format',
value: event.target.value,
});
}}
value={currentReport?.report_format || defaultNotificationFormat}
>
{TEXT_BASED_VISUALIZATION_TYPES.includes(vizType) && (
<StyledRadio value={NOTIFICATION_FORMATS.TEXT}>
{t('Text embedded in email')}
</StyledRadio>
)}
<StyledRadio value={NOTIFICATION_FORMATS.PNG}>
{t('Image (PNG) embedded in email')}
</StyledRadio>
<StyledRadio value={NOTIFICATION_FORMATS.CSV}>
{t('Formatted CSV attached in email')}
</StyledRadio>
</StyledRadioGroup>
</div>
</>
);
return (
<StyledModal
show={show}
onHide={onClose}
title={wrappedTitle}
footer={renderModalFooter}
width="432"
centered
>
<StyledTopSection>
<LabeledErrorBoundInput
id="name"
name="name"
value={currentReport?.name || ''}
placeholder="Weekly Report"
required
validationMethods={{
onChange: ({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.inputChange, {
name: target.name,
value: target.value,
}),
}}
errorMessage={
currentReport?.name === 'error' ? t('REPORT NAME ERROR') : ''
}
label="Report Name"
data-test="report-name-test"
/>
<LabeledErrorBoundInput
id="description"
name="description"
value={currentReport?.description || ''}
validationMethods={{
onChange: ({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.inputChange, {
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"
/>
</StyledTopSection>
<StyledBottomSection>
<StyledScheduleTitle>
<h4 css={(theme: SupersetTheme) => SectionHeaderStyle(theme)}>
{t('Schedule')}
</h4>
<p>{t('Scheduled reports will be sent to your email as a PNG')}</p>
</StyledScheduleTitle>
<StyledCronPicker
clearButton={false}
value={currentReport?.crontab || '0 12 * * 1'}
setValue={(newValue: string) => {
onChange(ActionType.inputChange, {
name: 'crontab',
value: newValue,
});
}}
onError={setError}
/>
<StyledCronError>{error}</StyledCronError>
<div
className="control-label"
css={(theme: SupersetTheme) => TimezoneHeaderStyle(theme)}
>
{t('Timezone')}
</div>
<TimezoneSelector
onTimezoneChange={value => {
setCurrentReport({
type: ActionType.inputChange,
payload: { name: 'timezone', value },
});
}}
timezone={currentReport?.timezone}
/>
{isChart && renderMessageContentSection}
</StyledBottomSection>
</StyledModal>
);
};
const mapDispatchToProps = (dispatch: any) =>
bindActionCreators({ addReport, editReport }, dispatch);
export default connect(null, mapDispatchToProps)(withToasts(ReportModal));