From 5612671329c474cced50d3d94a58a7c88c53be6b Mon Sep 17 00:00:00 2001 From: Ran Byron Date: Mon, 19 Aug 2019 09:15:01 +0300 Subject: [PATCH 01/13] Alert redesign #1 - React and redesign --- client/app/assets/less/ant.less | 10 + client/app/assets/less/inc/alert.less | 69 +-- client/app/assets/less/inc/ant-variables.less | 1 + client/app/assets/less/inc/base.less | 8 + client/app/assets/less/inc/generics.less | 2 + client/app/components/HelpTrigger.jsx | 4 + client/app/components/QuerySelector.jsx | 3 + client/app/components/proptypes.js | 6 + .../app/components/queries/SchedulePhrase.jsx | 8 +- client/app/pages/alert/Alert.jsx | 392 ++++++++++++++++++ client/app/pages/alert/alert.html | 95 ----- .../app/pages/alert/components/Criteria.jsx | 123 ++++++ .../app/pages/alert/components/Criteria.less | 53 +++ client/app/pages/alert/components/Query.jsx | 56 +++ client/app/pages/alert/components/Query.less | 17 + client/app/pages/alert/components/Rearm.jsx | 138 ++++++ client/app/pages/alert/components/Rearm.less | 8 + client/app/pages/alert/index.js | 138 ------ client/app/pages/alerts/AlertsList.jsx | 2 +- 19 files changed, 867 insertions(+), 266 deletions(-) create mode 100644 client/app/pages/alert/Alert.jsx delete mode 100644 client/app/pages/alert/alert.html create mode 100644 client/app/pages/alert/components/Criteria.jsx create mode 100644 client/app/pages/alert/components/Criteria.less create mode 100644 client/app/pages/alert/components/Query.jsx create mode 100644 client/app/pages/alert/components/Query.less create mode 100644 client/app/pages/alert/components/Rearm.jsx create mode 100644 client/app/pages/alert/components/Rearm.less delete mode 100644 client/app/pages/alert/index.js diff --git a/client/app/assets/less/ant.less b/client/app/assets/less/ant.less index d1509a48b5..51e4514947 100644 --- a/client/app/assets/less/ant.less +++ b/client/app/assets/less/ant.less @@ -60,6 +60,11 @@ font-weight: normal; } +.ant-select-dropdown-menu-item em { + color: @input-color-placeholder; + font-size: 11px; +} + // Fix for disabled button styles inside Tooltip component. // Tooltip wraps disabled buttons with `` and moves all styles // and classes to that ``. This resets all button styles and @@ -362,4 +367,9 @@ border-radius: 50%; } } + + &.form-item-line-height-normal .@{form-prefix-cls}-item-control { + line-height: 20px; + margin-top: 9px; + } } \ No newline at end of file diff --git a/client/app/assets/less/inc/alert.less b/client/app/assets/less/inc/alert.less index b7f0badab9..51b543670a 100755 --- a/client/app/assets/less/inc/alert.less +++ b/client/app/assets/less/inc/alert.less @@ -1,44 +1,53 @@ -.alert { - padding: 15px; +.alert-page h3 { + flex-grow: 1; - span { - cursor: pointer; + input { + margin: -0.2em 0; // + width: 100%; + min-width: 170px; } } -.alert-dismissable, -.alert-dismissible { - padding-right: 44px; -} - -.alert-inverse { - .alert-variant(@alert-inverse-bg; @alert-inverse-border; @alert-inverse-text); +.btn-create-alert[disabled] { + display: block; + margin-top: -20px; } -.alert-link { - color: #fff !important; - font-weight: normal !important; - text-decoration: underline; -} +.alert-state { + border-bottom: 1px solid #e8e8e8; + padding-bottom: 30px; -.growl-animated { - &.alert-inverse { - box-shadow: 0 0 5px fade(@alert-inverse-bg, 50%); - } - - &.alert-info { - box-shadow: 0 0 5px fade(@alert-info-bg, 50%); + .alert-state-indicator { + text-transform: uppercase; + font-size: 14px; + padding: 5px 8px; } - &.alert-success { - box-shadow: 0 0 5px fade(@alert-success-bg, 50%); + .alert-last-triggered { + color: #333; } +} - &.alert-warning { - box-shadow: 0 0 5px fade(@alert-warning-bg, 50%); - } +.alert-query-selector { + min-width: 250px; + width: auto !important; +} - &.alert-danger { - box-shadow: 0 0 5px fade(@alert-danger-bg, 50%); +// allow form item labels to gracefully break line +.alert-form-item label { + white-space: initial; + padding-right: 8px; + line-height: 21px; + + &::after { + margin-right: 0 !important; } } + +.alert-actions { + flex-grow: 1; + display: flex; + justify-content: flex-end; + align-items: center; + margin-right: -15px; +} \ No newline at end of file diff --git a/client/app/assets/less/inc/ant-variables.less b/client/app/assets/less/inc/ant-variables.less index 99e379a841..0233fc8889 100644 --- a/client/app/assets/less/inc/ant-variables.less +++ b/client/app/assets/less/inc/ant-variables.less @@ -36,6 +36,7 @@ -----------------------------------------------------------*/ @input-height-base: 35px; @input-color: #595959; +@input-color-placeholder: #b4b4b4; @border-radius-base: 2px; @border-color-base: #E8E8E8; diff --git a/client/app/assets/less/inc/base.less b/client/app/assets/less/inc/base.less index d5f05424ef..fb3bcb9ecc 100755 --- a/client/app/assets/less/inc/base.less +++ b/client/app/assets/less/inc/base.less @@ -222,6 +222,14 @@ text.slicetext { } } +.fa-external-link { + font-size: inherit; +} + +.warning-icon-danger { + color: @red !important; +} + // page .page-header--new .btn-favourite, .page-header--new .btn-archive { font-size: 19px; diff --git a/client/app/assets/less/inc/generics.less b/client/app/assets/less/inc/generics.less index 0555e90643..d7f484da0a 100755 --- a/client/app/assets/less/inc/generics.less +++ b/client/app/assets/less/inc/generics.less @@ -76,6 +76,8 @@ .font-size(20, 8px, 8); +.f-inherit { font-size: inherit !important; } + /* -------------------------------------------------------- Font Weight diff --git a/client/app/components/HelpTrigger.jsx b/client/app/components/HelpTrigger.jsx index e3ece0cc9d..0c5ebdf9e6 100644 --- a/client/app/components/HelpTrigger.jsx +++ b/client/app/components/HelpTrigger.jsx @@ -68,6 +68,10 @@ export const TYPES = { '/user-guide/querying/query-results-data-source', 'Guide: Help Setting up Query Results', ], + ALERT_SETUP: [ + '/user-guide/alerts/setting-up-an-alert', + 'Guide: Setting Up a New Alert', + ], }; export class HelpTrigger extends React.Component { diff --git a/client/app/components/QuerySelector.jsx b/client/app/components/QuerySelector.jsx index 5681b367e5..b8fb532a02 100644 --- a/client/app/components/QuerySelector.jsx +++ b/client/app/components/QuerySelector.jsx @@ -146,6 +146,7 @@ export function QuerySelector(props) { notFoundContent={null} filterOption={false} defaultActiveFirstOption={false} + className={props.className} > {searchResults && searchResults.map((q) => { const disabled = q.is_draft; @@ -183,12 +184,14 @@ QuerySelector.propTypes = { onChange: PropTypes.func.isRequired, selectedQuery: PropTypes.object, // eslint-disable-line react/forbid-prop-types type: PropTypes.oneOf(['select', 'default']), + className: PropTypes.string, disabled: PropTypes.bool, }; QuerySelector.defaultProps = { selectedQuery: null, type: 'default', + className: null, disabled: false, }; diff --git a/client/app/components/proptypes.js b/client/app/components/proptypes.js index a1240cf029..761bbd4a26 100644 --- a/client/app/components/proptypes.js +++ b/client/app/components/proptypes.js @@ -86,6 +86,12 @@ export const UserProfile = PropTypes.shape({ isDisabled: PropTypes.bool, }); +export const AlertOptions = PropTypes.shape({ + column: PropTypes.string, + op: PropTypes.oneOf(['greater than', 'less than', 'equals']), + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), +}); + function checkMoment(isRequired, props, propName, componentName) { const value = props[propName]; const isRequiredValid = isRequired && (value !== null && value !== undefined) && moment.isMoment(value); diff --git a/client/app/components/queries/SchedulePhrase.jsx b/client/app/components/queries/SchedulePhrase.jsx index 24c1f65d46..26339225c0 100644 --- a/client/app/components/queries/SchedulePhrase.jsx +++ b/client/app/components/queries/SchedulePhrase.jsx @@ -1,6 +1,7 @@ import { react2angular } from 'react2angular'; import React from 'react'; import PropTypes from 'prop-types'; +import cx from 'classnames'; import Tooltip from 'antd/lib/tooltip'; import { localizeTime, durationHumanize } from '@/filters'; import { RefreshScheduleType, RefreshScheduleDefault } from '../proptypes'; @@ -12,11 +13,13 @@ export class SchedulePhrase extends React.Component { schedule: RefreshScheduleType, isNew: PropTypes.bool.isRequired, isLink: PropTypes.bool, + className: PropTypes.string, }; static defaultProps = { schedule: RefreshScheduleDefault, isLink: false, + className: null, }; get content() { @@ -48,10 +51,11 @@ export class SchedulePhrase extends React.Component { const [short, full] = this.content; const content = full ? {short} : short; + const className = cx('schedule-phrase', this.props.className); return this.props.isLink - ? {content} - : content; + ? {content} + : {content}; } } diff --git a/client/app/pages/alert/Alert.jsx b/client/app/pages/alert/Alert.jsx new file mode 100644 index 0000000000..a6a260e698 --- /dev/null +++ b/client/app/pages/alert/Alert.jsx @@ -0,0 +1,392 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { react2angular } from 'react2angular'; +import { head, includes, template as templateBuilder, trim } from 'lodash'; +import cx from 'classnames'; + +import { $route } from '@/services/ng'; +import { currentUser } from '@/services/auth'; +import navigateTo from '@/services/navigateTo'; +import notification from '@/services/notification'; +import { Alert as AlertService } from '@/services/alert'; +import { Query as QueryService } from '@/services/query'; + +import { HelpTrigger } from '@/components/HelpTrigger'; +import LoadingState from '@/components/items-list/components/LoadingState'; +import { TimeAgo } from '@/components/TimeAgo'; + +import Form from 'antd/lib/form'; +import Button from 'antd/lib/button'; +import Icon from 'antd/lib/icon'; +import Modal from 'antd/lib/modal'; +import Input from 'antd/lib/input'; +import Dropdown from 'antd/lib/dropdown'; +import Menu from 'antd/lib/menu'; + +import Criteria from './components/Criteria'; +import Rearm from './components/Rearm'; +import Query from './components/Query'; +import { STATE_CLASS } from '../alerts/AlertsList'; +import { routesToAngularRoutes } from '@/lib/utils'; + + +const defaultNameBuilder = templateBuilder('<%= query.name %>: <%= options.column %> <%= options.op %> <%= options.value %>'); +const spinnerIcon = ; + +function isNewAlert() { + return $route.current.params.alertId === 'new'; +} + +function HorizontalFormItem({ children, label, className, ...props }) { + const labelCol = { span: 4 }; + const wrapperCol = { span: 16 }; + if (!label) { + wrapperCol.offset = 4; + } + + className = cx('alert-form-item', className); + + return ( + + { children } + + ); +} + +HorizontalFormItem.propTypes = { + children: PropTypes.node, + label: PropTypes.string, + className: PropTypes.string, +}; + +HorizontalFormItem.defaultProps = { + children: null, + label: null, + className: null, +}; + +function AlertState({ state, lastTriggered }) { + return ( +
+ Status: {state} + {state === 'unknown' && ( +
+ Alert condition has not been evaluated. +
+ )} + {lastTriggered && ( +
+ Last triggered +
+ )} +
+ ); +} + +AlertState.propTypes = { + state: PropTypes.string.isRequired, + lastTriggered: PropTypes.string, +}; + +AlertState.defaultProps = { + lastTriggered: null, +}; + +class AlertPage extends React.Component { + state = { + alert: null, + queryResult: null, + pendingRearm: null, + editMode: false, + canEdit: false, + saving: false, + canceling: false, + } + + componentDidMount() { + if (isNewAlert()) { + this.setState({ + alert: new AlertService({ + options: { + op: 'greater than', + value: 1, + rearm: 0, + }, + pendingRearm: 0, + }), + editMode: true, + canEdit: true, + }); + } else { + const { alertId } = $route.current.params; + const { editMode } = $route.current.locals; + AlertService.get({ id: alertId }).$promise.then((alert) => { + const canEdit = currentUser.canEdit(alert); + this.setState({ + alert, + pendingRearm: alert.rearm, + editMode: editMode && canEdit, + canEdit, + }); + this.onQuerySelected(alert.query); + }); + } + } + + getDefaultName = () => { + const { alert } = this.state; + if (!alert.query) { + return 'New Alert'; + } + return defaultNameBuilder(alert); + } + + onQuerySelected = (query) => { + this.setState(({ alert }) => ({ + alert: Object.assign(alert, { query }), + queryResult: null, + })); + + if (query) { + // get cached result for column names and values + new QueryService(query).getQueryResultPromise().then((queryResult) => { + this.setState({ queryResult }); + let { column } = this.state.alert.options; + const columns = queryResult.getColumnNames(); + + // default to first column name if none chosen, or irrelevant in current query + if (!column || !includes(columns, column)) { + column = head(queryResult.getColumnNames()); + } + this.setAlertOptions({ column }); + }); + } + } + + onRearmChange = (pendingRearm) => { + this.setState({ pendingRearm }); + } + + setAlertOptions = (obj) => { + const { alert } = this.state; + const options = { ...alert.options, ...obj }; + this.setState({ + alert: Object.assign(alert, { options }), + }); + } + + setName = (name) => { + const { alert } = this.state; + this.setState({ + alert: Object.assign(alert, { name }), + }); + } + + edit = () => { + const { id } = this.state.alert; + navigateTo(`/alerts/${id}/edit`, true); + } + + save = () => { + const { alert, pendingRearm } = this.state; + + alert.name = trim(alert.name) || this.getDefaultName(); + alert.rearm = pendingRearm || null; + + this.setState({ saving: true, alert }); + + alert.$save().then(() => { + if (isNewAlert()) { + notification.success('Created new Alert.'); + } else { + notification.success('Saved.'); + } + navigateTo(`/alerts/${alert.id}`, true); + }).catch(() => { + notification.error('Failed saving alert.'); + this.setState({ saving: false }); + }); + }; + + cancel = () => { + const { alert } = this.state; + this.setState({ canceling: true }); + navigateTo(`/alerts/${alert.id}`, true); + }; + + delete = () => { + const { alert } = this.state; + + const doDelete = () => { + alert.$delete(() => { + notification.success('Alert deleted successfully.'); + navigateTo('/alerts', true); + }, () => { + notification.error('Failed deleting alert.'); + }); + }; + + Modal.confirm({ + title: 'Delete Alert', + content: 'Are you sure you want to delete this alert?', + okText: 'Delete', + okType: 'danger', + onOk: doDelete, + maskClosable: true, + autoFocusButton: null, + }); + } + + render() { + const { alert } = this.state; + if (!alert) { + return ; + } + + const isNew = isNewAlert(); + const { query, name, options } = alert; + const { queryResult, editMode, pendingRearm, canEdit, saving, canceling } = this.state; + + return ( +
+
+
+

+ {editMode && query ? ( + this.setName(e.target.value)} /> + ) : name || this.getDefaultName() } +

+ + {editMode && ( + <> + {!isNew && ( + <> + + + + )} + + )} + {!editMode && canEdit && ( + + )} + {canEdit && !isNew && ( + + + this.delete()}>Delete Alert + + + )} + > + + + )} + +
+
+
+
+
+ {isNew && ( +
+ Start by selecting the query that you would like to monitor using the search bar. +
+ Keep in mind that Alerts do not work with queries that use parameters. +
+ )} + {!editMode && ( + + + + )} + + + + {query && !queryResult && ( + + Loading query data + + )} + {queryResult && options && ( + <> + + + + {editMode ? ( + <> + + + + + ) : ( + + + + )} + + )} + {isNew && ( + + + + )} +
+ {editMode && ( + + Setup Instructions + + )} +
+ {!editMode && ( +
+

Destinations

+
In next PR
+
+ )} +
+
+ ); + } +} + +export default function init(ngModule) { + ngModule.component('alertPage', react2angular(AlertPage)); + + return routesToAngularRoutes([ + { + path: '/alerts/:alertId', + title: 'Alert', + editMode: false, + }, { + path: '/alerts/:alertId/edit', + title: 'Alert', + editMode: true, + }, + ], { + template: '', + controller($scope, $exceptionHandler) { + 'ngInject'; + + $scope.handleError = $exceptionHandler; + }, + }); +} + +init.init = true; diff --git a/client/app/pages/alert/alert.html b/client/app/pages/alert/alert.html deleted file mode 100644 index cf8c86f474..0000000000 --- a/client/app/pages/alert/alert.html +++ /dev/null @@ -1,95 +0,0 @@ -
- - - - -
-
-
-
-
- - -
- -
- - -
- -
-
- -
- -
- -
-

{{$ctrl.queryResult.getData()[0][$ctrl.alert.options.column]}}

-
-
-
- -
- -
- -
- -
-
-
- -
- -
-
-
-
- - -
-
-
- - -
-
-
-
-
- - - -
-
-
- - -
-
-
-
-
-
-
-
-
- -
-
- -
- - -
-
-
-
- -
-
-
-
diff --git a/client/app/pages/alert/components/Criteria.jsx b/client/app/pages/alert/components/Criteria.jsx new file mode 100644 index 0000000000..eef3bf59d5 --- /dev/null +++ b/client/app/pages/alert/components/Criteria.jsx @@ -0,0 +1,123 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { head, includes, toString, map } from 'lodash'; + +import Input from 'antd/lib/input'; +import Icon from 'antd/lib/icon'; +import Select from 'antd/lib/select'; + +import { AlertOptions as AlertOptionsType } from '@/components/proptypes'; + +import './Criteria.less'; + +const CONDITIONS = { + 'greater than': '>', + 'less than': '<', + equals: '=', +}; + +const VALID_STRING_CONDITIONS = ['equals']; + +function DisabledInput({ children, minWidth }) { + return ( +
{children}
+ ); +} + +DisabledInput.propTypes = { + children: PropTypes.node.isRequired, + minWidth: PropTypes.number.isRequired, +}; + +export default function Criteria({ columnNames, resultValues, alertOptions, onChange, editMode }) { + const columnValue = resultValues && head(resultValues)[alertOptions.column]; + const invalidMessage = (() => { + // bail if condition is valid for strings + if (includes(VALID_STRING_CONDITIONS, alertOptions.op)) { + return null; + } + + if (isNaN(alertOptions.value)) { + return 'Value column type doesn\'t match threshold type.'; + } + + if (isNaN(columnValue)) { + return 'Value column isn\'t supported by condition type.'; + } + + return null; + })(); + + const columnHint = ( + + Top row value is {toString(columnValue) || 'unknown'} + + ); + + return ( + <> +
+ Value column + {editMode ? ( + + ) : ( + {alertOptions.column} + )} +
+
+ Condition + {editMode ? ( + + ) : ( + {CONDITIONS[alertOptions.op]} + )} +
+
+ Threshold + {editMode ? ( + onChange({ value: e.target.value })} /> + ) : ( + {alertOptions.value} + )} +
+
+ {columnHint} +
+ {invalidMessage && ( + + {invalidMessage} + + )} +
+ + ); +} + +Criteria.propTypes = { + columnNames: PropTypes.arrayOf(PropTypes.string).isRequired, + resultValues: PropTypes.arrayOf(PropTypes.object).isRequired, + alertOptions: AlertOptionsType.isRequired, + onChange: PropTypes.func.isRequired, + editMode: PropTypes.bool.isRequired, +}; diff --git a/client/app/pages/alert/components/Criteria.less b/client/app/pages/alert/components/Criteria.less new file mode 100644 index 0000000000..67ef8e0d54 --- /dev/null +++ b/client/app/pages/alert/components/Criteria.less @@ -0,0 +1,53 @@ +.alert-criteria { + margin-top: 40px !important; + + .input-title { + display: inline-block; + width: auto; + margin-right: 8px; + margin-bottom: 17px; // assure no collision when not enough room for horizontal layout + position: relative; + vertical-align: middle; + + & > span { + position: absolute; + top: -16px; + left: 0; + line-height: normal; + font-size: 10px; + color: #00000070; + + & + * { + vertical-align: top; + } + } + } + + .ant-form-explain { + margin-top: -17px; // compensation for .input-title bottom margin + } + + .alert-criteria-hint code { + overflow: hidden; + max-width: 100px; + display: inline-block; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; + } +} + +.criteria-disabled-input { + text-align: center; + line-height: 35px; + height: 35px; + max-width: 200px; + background: #ececec; + border: 1px solid #d9d9d9; + border-radius: 2px; + margin-bottom: 5px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + padding: 0 8px; +} \ No newline at end of file diff --git a/client/app/pages/alert/components/Query.jsx b/client/app/pages/alert/components/Query.jsx new file mode 100644 index 0000000000..e7f160e010 --- /dev/null +++ b/client/app/pages/alert/components/Query.jsx @@ -0,0 +1,56 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { QuerySelector } from '@/components/QuerySelector'; +import { SchedulePhrase } from '@/components/queries/SchedulePhrase'; + +import Tooltip from 'antd/lib/tooltip'; +import Icon from 'antd/lib/icon'; + +import './Query.less'; + +export default function QueryFormItem({ query, onChange, editMode }) { + const queryHint = query && query.schedule ? ( + + Scheduled to refresh + + ) : ( + + {' '} + This query has no refresh schedule.{' '} + Why it's recommended + + ); + + return ( + <> + {editMode ? ( + + ) : ( + + + {query.name} + + + )} +
+ {query && queryHint} +
+ + ); +} + +QueryFormItem.propTypes = { + query: PropTypes.object, // eslint-disable-line react/forbid-prop-types + onChange: PropTypes.func.isRequired, + editMode: PropTypes.bool.isRequired, +}; + +QueryFormItem.defaultProps = { + query: null, +}; diff --git a/client/app/pages/alert/components/Query.less b/client/app/pages/alert/components/Query.less new file mode 100644 index 0000000000..75c940ac09 --- /dev/null +++ b/client/app/pages/alert/components/Query.less @@ -0,0 +1,17 @@ +.alert-query-link { + font-weight: 600; + text-decoration: underline; + display: inline-block; + position: relative; + top: -1px; + + .fa-external-link { + vertical-align: text-bottom; + margin-left: 4px; + } +} + +.alert-query-schedule { + font-style: italic; + text-transform: lowercase; +} \ No newline at end of file diff --git a/client/app/pages/alert/components/Rearm.jsx b/client/app/pages/alert/components/Rearm.jsx new file mode 100644 index 0000000000..8c6fcb6ab1 --- /dev/null +++ b/client/app/pages/alert/components/Rearm.jsx @@ -0,0 +1,138 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { toLower, isNumber } from 'lodash'; + +import InputNumber from 'antd/lib/input-number'; +import Select from 'antd/lib/select'; + +import './Rearm.less'; + +const DURATIONS = [ + ['Second', 1], + ['Minute', 60], + ['Hour', 3600], + ['Day', 86400], + ['Week', 604800], +]; + +function RearmByDuration({ value, onChange, editMode }) { + const [durationIdx, setDurationIdx] = useState(); + const [count, setCount] = useState(); + + useEffect(() => { + for (let i = DURATIONS.length - 1; i >= 0; i -= 1) { + const [, durValue] = DURATIONS[i]; + if (value % durValue === 0) { + setDurationIdx(i); + setCount(value / durValue); + break; + } + } + }, []); + + if (!isNumber(count) || !isNumber(durationIdx)) { + return null; + } + + const onChangeCount = (newCount) => { + setCount(newCount); + onChange(newCount * DURATIONS[durationIdx][1]); + }; + + const onChangeIdx = (newIdx) => { + setDurationIdx(newIdx); + onChange(count * DURATIONS[newIdx][1]); + }; + + const plural = count !== 1 ? 's' : ''; + + if (editMode) { + return ( + <> + + + + ); + } + + const [name] = DURATIONS[durationIdx]; + return count + ' ' + toLower(name) + plural; +} + +RearmByDuration.propTypes = { + onChange: PropTypes.func, + value: PropTypes.number.isRequired, + editMode: PropTypes.bool.isRequired, +}; + +RearmByDuration.defaultProps = { + onChange: () => {}, +}; + +function RearmEditor({ value, onChange }) { + const [selected, setSelected] = useState(value < 2 ? value : 2); + + const _onChange = (newSelected) => { + setSelected(newSelected); + onChange(newSelected < 2 ? newSelected : 3600); + }; + + return ( +
+ + {selected === 2 && value && } +
+ ); +} + +RearmEditor.propTypes = { + onChange: PropTypes.func.isRequired, + value: PropTypes.number.isRequired, +}; + +function RearmViewer({ value }) { + let phrase = ''; + switch (value) { + case 0: + phrase = 'just once, until back to normal'; + break; + case 1: + phrase = 'each time alert is evaluated, until back to normal'; + break; + default: + phrase = ( + <>at most every , when alert is evaluated + ); + } + + return Notifications are sent {phrase}.; +} + +RearmViewer.propTypes = { + value: PropTypes.number.isRequired, +}; + +export default function Rearm({ editMode, ...props }) { + return editMode ? : ; +} + +Rearm.propTypes = { + onChange: PropTypes.func, + value: PropTypes.number.isRequired, + editMode: PropTypes.bool, +}; + +Rearm.defaultProps = { + onChange: null, + editMode: false, +}; diff --git a/client/app/pages/alert/components/Rearm.less b/client/app/pages/alert/components/Rearm.less new file mode 100644 index 0000000000..78fd293f83 --- /dev/null +++ b/client/app/pages/alert/components/Rearm.less @@ -0,0 +1,8 @@ +.alert-rearm > * { + vertical-align: top; + margin-right: 8px !important; + + &.ant-select { + width: auto !important; + } +} \ No newline at end of file diff --git a/client/app/pages/alert/index.js b/client/app/pages/alert/index.js deleted file mode 100644 index 1296468222..0000000000 --- a/client/app/pages/alert/index.js +++ /dev/null @@ -1,138 +0,0 @@ -import { template as templateBuilder } from 'lodash'; -import notification from '@/services/notification'; -import Modal from 'antd/lib/modal'; -import template from './alert.html'; -import AlertTemplate from '@/services/alert-template'; -import { clientConfig } from '@/services/auth'; -import navigateTo from '@/services/navigateTo'; - -function AlertCtrl($scope, $routeParams, $location, $sce, $sanitize, currentUser, Query, Events, Alert) { - this.alertId = $routeParams.alertId; - this.hidePreview = false; - this.alertTemplate = new AlertTemplate(); - this.showExtendedOptions = clientConfig.extendedAlertOptions; - - if (this.alertId === 'new') { - Events.record('view', 'page', 'alerts/new'); - } - - this.trustAsHtml = html => $sce.trustAsHtml(html); - - this.onQuerySelected = (item) => { - this.alert.query = item; - this.selectedQuery = new Query(item); - this.selectedQuery.getQueryResultPromise().then((result) => { - this.queryResult = result; - this.alert.options.column = this.alert.options.column || result.getColumnNames()[0]; - }); - $scope.$applyAsync(); - }; - - if (this.alertId === 'new') { - this.alert = new Alert({ options: {} }); - this.canEdit = true; - } else { - this.alert = Alert.get({ id: this.alertId }, (alert) => { - this.onQuerySelected(alert.query); - this.canEdit = currentUser.canEdit(this.alert); - }); - } - - this.ops = ['greater than', 'less than', 'equals']; - this.selectedQuery = null; - - const defaultNameBuilder = templateBuilder('<%= query.name %>: <%= options.column %> <%= options.op %> <%= options.value %>'); - - this.getDefaultName = () => { - if (!this.alert.query) { - return undefined; - } - return defaultNameBuilder(this.alert); - }; - - this.searchQueries = (term) => { - if (!term || term.length < 3) { - return; - } - - Query.query({ q: term }, (results) => { - this.queries = results.results; - }); - }; - - this.saveChanges = () => { - if (this.alert.name === undefined || this.alert.name === '') { - this.alert.name = this.getDefaultName(); - } - if (this.alert.rearm === '' || this.alert.rearm === 0) { - this.alert.rearm = null; - } - if (this.alert.template === undefined || this.alert.template === '') { - this.alert.template = null; - } - this.alert.$save( - (alert) => { - notification.success('Saved.'); - if (this.alertId === 'new') { - $location.path(`/alerts/${alert.id}`).replace(); - } - }, - () => { - notification.error('Failed saving alert.'); - }, - ); - }; - - this.preview = () => { - const notifyError = () => notification.error('Unable to render description. please confirm your template.'); - try { - const result = this.alertTemplate.render(this.alert, this.queryResult.query_result.data); - this.alert.preview = $sce.trustAsHtml(result.escaped); - this.alert.previewHTML = $sce.trustAsHtml($sanitize(result.raw)); - if (!result.raw) { - notifyError(); - } - } catch (e) { - notifyError(); - this.alert.preview = e.message; - this.alert.previewHTML = e.message; - } - }; - - this.delete = () => { - const doDelete = () => { - this.alert.$delete(() => { - notification.success('Alert destination deleted successfully.'); - navigateTo('/alerts', true); - }, () => { - notification.error('Failed deleting alert.'); - }); - }; - - Modal.confirm({ - title: 'Delete Alert', - content: 'Are you sure you want to delete this alert?', - okText: 'Delete', - okType: 'danger', - onOk: doDelete, - maskClosable: true, - autoFocusButton: null, - }); - }; -} - -export default function init(ngModule) { - ngModule.component('alertPage', { - template, - controller: AlertCtrl, - }); - - return { - '/alerts/:alertId': { - template: '', - title: 'Alerts', - }, - }; -} - -init.init = true; diff --git a/client/app/pages/alerts/AlertsList.jsx b/client/app/pages/alerts/AlertsList.jsx index 178c8efe98..8eac176a94 100644 --- a/client/app/pages/alerts/AlertsList.jsx +++ b/client/app/pages/alerts/AlertsList.jsx @@ -16,7 +16,7 @@ import ItemsTable, { Columns } from '@/components/items-list/components/ItemsTab import { Alert } from '@/services/alert'; import { routesToAngularRoutes } from '@/lib/utils'; -const STATE_CLASS = { +export const STATE_CLASS = { unknown: 'label-warning', ok: 'label-success', triggered: 'label-danger', From a1aebde02fefadafc25d4cb4aea0e83399bbad60 Mon Sep 17 00:00:00 2001 From: Ran Byron Date: Thu, 19 Sep 2019 11:21:53 +0300 Subject: [PATCH 02/13] Added first test --- client/app/assets/less/ant.less | 1 + client/app/components/QuerySelector.jsx | 7 +++--- .../app/pages/alert/components/Criteria.jsx | 4 +-- .../integration/alert/create_alert_spec.js | 25 +++++++++++++++++++ 4 files changed, 32 insertions(+), 5 deletions(-) create mode 100644 client/cypress/integration/alert/create_alert_spec.js diff --git a/client/app/assets/less/ant.less b/client/app/assets/less/ant.less index 51e4514947..5eb913eee7 100644 --- a/client/app/assets/less/ant.less +++ b/client/app/assets/less/ant.less @@ -368,6 +368,7 @@ } } + // for form items that contain text &.form-item-line-height-normal .@{form-prefix-cls}-item-control { line-height: 20px; margin-top: 9px; diff --git a/client/app/components/QuerySelector.jsx b/client/app/components/QuerySelector.jsx index b8fb532a02..aa74c78cb7 100644 --- a/client/app/components/QuerySelector.jsx +++ b/client/app/components/QuerySelector.jsx @@ -147,11 +147,12 @@ export function QuerySelector(props) { filterOption={false} defaultActiveFirstOption={false} className={props.className} + data-test="QuerySelector" > {searchResults && searchResults.map((q) => { const disabled = q.is_draft; return ( - @@ -162,7 +163,7 @@ export function QuerySelector(props) { } return ( - + {selectedQuery ? ( ) : ( @@ -176,7 +177,7 @@ export function QuerySelector(props) {
{searchResults && renderResults()}
-
+
); } diff --git a/client/app/pages/alert/components/Criteria.jsx b/client/app/pages/alert/components/Criteria.jsx index eef3bf59d5..49baf7a939 100644 --- a/client/app/pages/alert/components/Criteria.jsx +++ b/client/app/pages/alert/components/Criteria.jsx @@ -55,7 +55,7 @@ export default function Criteria({ columnNames, resultValues, alertOptions, onCh ); return ( - <> +
Value column {editMode ? ( @@ -110,7 +110,7 @@ export default function Criteria({ columnNames, resultValues, alertOptions, onCh )}
- +
); } diff --git a/client/cypress/integration/alert/create_alert_spec.js b/client/cypress/integration/alert/create_alert_spec.js new file mode 100644 index 0000000000..135d862af8 --- /dev/null +++ b/client/cypress/integration/alert/create_alert_spec.js @@ -0,0 +1,25 @@ +import { createQuery } from '../../support/redash-api'; + +describe('Create Alert', () => { + beforeEach(() => { + cy.login(); + }); + + it('renders the initial page and takes a screenshot', () => { + cy.visit('/alerts/new'); + cy.getByTestId('QuerySelector').should('exist'); + // cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting + cy.percySnapshot('Create Alert initial screen'); + }); + + it('selects query and takes a screenshot', () => { + createQuery({ name: 'Create Alert Query' }).then(({ id: queryId }) => { + cy.visit('/alerts/new'); + cy.getByTestId('QuerySelector').click().type('Create Alert Query'); + cy.get(`.query-selector-result[data-test="QueryId${queryId}"]`).click(); + cy.getByTestId('Criteria').should('exist'); + // cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting + cy.percySnapshot('Create Alert second screen'); + }); + }); +}); From d7d9807d4a2dbcc51ae6d0b49521e5412bc952de Mon Sep 17 00:00:00 2001 From: Ran Byron Date: Thu, 19 Sep 2019 19:31:19 +0300 Subject: [PATCH 03/13] Added view / edit tests --- client/app/pages/alert/Alert.jsx | 2 +- .../integration/alert/create_alert_spec.js | 2 -- .../integration/alert/edit_alert_spec.js | 17 ++++++++++++++ .../integration/alert/view_alert_spec.js | 17 ++++++++++++++ client/cypress/support/redash-api/index.js | 22 +++++++++++++++++++ 5 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 client/cypress/integration/alert/edit_alert_spec.js create mode 100644 client/cypress/integration/alert/view_alert_spec.js diff --git a/client/app/pages/alert/Alert.jsx b/client/app/pages/alert/Alert.jsx index a6a260e698..6ade259144 100644 --- a/client/app/pages/alert/Alert.jsx +++ b/client/app/pages/alert/Alert.jsx @@ -191,7 +191,7 @@ class AlertPage extends React.Component { const { alert, pendingRearm } = this.state; alert.name = trim(alert.name) || this.getDefaultName(); - alert.rearm = pendingRearm || null; + alert.options.rearm = pendingRearm || null; this.setState({ saving: true, alert }); diff --git a/client/cypress/integration/alert/create_alert_spec.js b/client/cypress/integration/alert/create_alert_spec.js index 135d862af8..c345cd9f83 100644 --- a/client/cypress/integration/alert/create_alert_spec.js +++ b/client/cypress/integration/alert/create_alert_spec.js @@ -8,7 +8,6 @@ describe('Create Alert', () => { it('renders the initial page and takes a screenshot', () => { cy.visit('/alerts/new'); cy.getByTestId('QuerySelector').should('exist'); - // cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting cy.percySnapshot('Create Alert initial screen'); }); @@ -18,7 +17,6 @@ describe('Create Alert', () => { cy.getByTestId('QuerySelector').click().type('Create Alert Query'); cy.get(`.query-selector-result[data-test="QueryId${queryId}"]`).click(); cy.getByTestId('Criteria').should('exist'); - // cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting cy.percySnapshot('Create Alert second screen'); }); }); diff --git a/client/cypress/integration/alert/edit_alert_spec.js b/client/cypress/integration/alert/edit_alert_spec.js new file mode 100644 index 0000000000..23877888d3 --- /dev/null +++ b/client/cypress/integration/alert/edit_alert_spec.js @@ -0,0 +1,17 @@ +import { createAlert, createQuery } from '../../support/redash-api'; + +describe('Edit Alert', () => { + beforeEach(() => { + cy.login(); + }); + + it('renders the page and takes a screenshot', () => { + createQuery({ query: 'select 1 as col_name' }) + .then(({ id: queryId }) => createAlert(queryId, { column: 'col_name' })) + .then(({ id: alertId }) => { + cy.visit(`/alerts/${alertId}/edit`); + cy.getByTestId('Criteria').should('exist'); + cy.percySnapshot('Create Alert second screen'); + }); + }); +}); diff --git a/client/cypress/integration/alert/view_alert_spec.js b/client/cypress/integration/alert/view_alert_spec.js new file mode 100644 index 0000000000..eb41008f12 --- /dev/null +++ b/client/cypress/integration/alert/view_alert_spec.js @@ -0,0 +1,17 @@ +import { createAlert, createQuery } from '../../support/redash-api'; + +describe('View Alert', () => { + beforeEach(() => { + cy.login(); + }); + + it('renders the page and takes a screenshot', () => { + createQuery({ query: 'select 1 as col_name' }) + .then(({ id: queryId }) => createAlert(queryId, { column: 'col_name' })) + .then(({ id: alertId }) => { + cy.visit(`/alerts/${alertId}`); + cy.getByTestId('Criteria').should('exist'); + cy.percySnapshot('Create Alert second screen'); + }); + }); +}); diff --git a/client/cypress/support/redash-api/index.js b/client/cypress/support/redash-api/index.js index 8d7905e1dd..015ccaab25 100644 --- a/client/cypress/support/redash-api/index.js +++ b/client/cypress/support/redash-api/index.js @@ -78,3 +78,25 @@ export function addWidget(dashboardId, visualizationId, options = {}) { return body; }); } + +export function createAlert(queryId, options = {}, name) { + const defaultOptions = { + column: '?column?', + op: 'greater than', + rearm: 0, + value: 1, + }; + + const data = { + query_id: queryId, + name: name || 'Alert for query ' + queryId, + options: merge(defaultOptions, options), + }; + + return cy.request('POST', 'api/alerts', data) + .then(({ body }) => { + const id = get(body, 'id'); + assert.isDefined(id, 'Alert api call returns alert id'); + return body; + }); +} From 41781ee4f8693f692755aa30a2cad285b8dfd387 Mon Sep 17 00:00:00 2001 From: Ran Byron Date: Thu, 19 Sep 2019 20:26:20 +0300 Subject: [PATCH 04/13] Some style changes --- client/app/assets/less/inc/alert.less | 4 ++-- client/app/components/queries/SchedulePhrase.jsx | 8 ++------ client/app/pages/alert/components/Criteria.less | 1 - client/app/pages/alert/components/Query.jsx | 2 +- 4 files changed, 5 insertions(+), 10 deletions(-) diff --git a/client/app/assets/less/inc/alert.less b/client/app/assets/less/inc/alert.less index 51b543670a..0c3d89f9d1 100755 --- a/client/app/assets/less/inc/alert.less +++ b/client/app/assets/less/inc/alert.less @@ -14,7 +14,7 @@ } .alert-state { - border-bottom: 1px solid #e8e8e8; + border-bottom: 1px solid @input-border; padding-bottom: 30px; .alert-state-indicator { @@ -24,7 +24,7 @@ } .alert-last-triggered { - color: #333; + color: @headings-color; } } diff --git a/client/app/components/queries/SchedulePhrase.jsx b/client/app/components/queries/SchedulePhrase.jsx index 26339225c0..24c1f65d46 100644 --- a/client/app/components/queries/SchedulePhrase.jsx +++ b/client/app/components/queries/SchedulePhrase.jsx @@ -1,7 +1,6 @@ import { react2angular } from 'react2angular'; import React from 'react'; import PropTypes from 'prop-types'; -import cx from 'classnames'; import Tooltip from 'antd/lib/tooltip'; import { localizeTime, durationHumanize } from '@/filters'; import { RefreshScheduleType, RefreshScheduleDefault } from '../proptypes'; @@ -13,13 +12,11 @@ export class SchedulePhrase extends React.Component { schedule: RefreshScheduleType, isNew: PropTypes.bool.isRequired, isLink: PropTypes.bool, - className: PropTypes.string, }; static defaultProps = { schedule: RefreshScheduleDefault, isLink: false, - className: null, }; get content() { @@ -51,11 +48,10 @@ export class SchedulePhrase extends React.Component { const [short, full] = this.content; const content = full ? {short} : short; - const className = cx('schedule-phrase', this.props.className); return this.props.isLink - ? {content} - : {content}; + ? {content} + : content; } } diff --git a/client/app/pages/alert/components/Criteria.less b/client/app/pages/alert/components/Criteria.less index 67ef8e0d54..4aafd63ba0 100644 --- a/client/app/pages/alert/components/Criteria.less +++ b/client/app/pages/alert/components/Criteria.less @@ -15,7 +15,6 @@ left: 0; line-height: normal; font-size: 10px; - color: #00000070; & + * { vertical-align: top; diff --git a/client/app/pages/alert/components/Query.jsx b/client/app/pages/alert/components/Query.jsx index e7f160e010..a50662e1d9 100644 --- a/client/app/pages/alert/components/Query.jsx +++ b/client/app/pages/alert/components/Query.jsx @@ -12,7 +12,7 @@ import './Query.less'; export default function QueryFormItem({ query, onChange, editMode }) { const queryHint = query && query.schedule ? ( - Scheduled to refresh + Scheduled to refresh ) : ( From c57d3d0f71de50042ee8f06fb405b4bd3ddfa74b Mon Sep 17 00:00:00 2001 From: Ran Byron Date: Thu, 19 Sep 2019 20:58:32 +0300 Subject: [PATCH 05/13] Fixed percyshots names --- client/cypress/integration/alert/edit_alert_spec.js | 2 +- client/cypress/integration/alert/view_alert_spec.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/cypress/integration/alert/edit_alert_spec.js b/client/cypress/integration/alert/edit_alert_spec.js index 23877888d3..cde513a6f2 100644 --- a/client/cypress/integration/alert/edit_alert_spec.js +++ b/client/cypress/integration/alert/edit_alert_spec.js @@ -11,7 +11,7 @@ describe('Edit Alert', () => { .then(({ id: alertId }) => { cy.visit(`/alerts/${alertId}/edit`); cy.getByTestId('Criteria').should('exist'); - cy.percySnapshot('Create Alert second screen'); + cy.percySnapshot('Edit Alert screen'); }); }); }); diff --git a/client/cypress/integration/alert/view_alert_spec.js b/client/cypress/integration/alert/view_alert_spec.js index eb41008f12..69ac9ac18a 100644 --- a/client/cypress/integration/alert/view_alert_spec.js +++ b/client/cypress/integration/alert/view_alert_spec.js @@ -11,7 +11,7 @@ describe('View Alert', () => { .then(({ id: alertId }) => { cy.visit(`/alerts/${alertId}`); cy.getByTestId('Criteria').should('exist'); - cy.percySnapshot('Create Alert second screen'); + cy.percySnapshot('View Alert screen'); }); }); }); From 7ce001949ebbc1f5f82ec934f19b5b9c9081b8d1 Mon Sep 17 00:00:00 2001 From: Ran Byron Date: Tue, 24 Sep 2019 09:16:36 +0300 Subject: [PATCH 06/13] Fixed rearm bug --- client/app/pages/alert/Alert.jsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/app/pages/alert/Alert.jsx b/client/app/pages/alert/Alert.jsx index 6ade259144..5d101ecae9 100644 --- a/client/app/pages/alert/Alert.jsx +++ b/client/app/pages/alert/Alert.jsx @@ -110,7 +110,6 @@ class AlertPage extends React.Component { options: { op: 'greater than', value: 1, - rearm: 0, }, pendingRearm: 0, }), @@ -191,7 +190,7 @@ class AlertPage extends React.Component { const { alert, pendingRearm } = this.state; alert.name = trim(alert.name) || this.getDefaultName(); - alert.options.rearm = pendingRearm || null; + alert.rearm = pendingRearm || null; this.setState({ saving: true, alert }); From 52f9ea846650ae78f65a7816f3fa800838c2bf08 Mon Sep 17 00:00:00 2001 From: Ran Byron Date: Tue, 24 Sep 2019 09:47:14 +0300 Subject: [PATCH 07/13] Attended to review --- client/app/assets/less/inc/base.less | 4 --- client/app/pages/alert/Alert.jsx | 46 ++++++++++++++++++---------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/client/app/assets/less/inc/base.less b/client/app/assets/less/inc/base.less index fb3bcb9ecc..971aa72409 100755 --- a/client/app/assets/less/inc/base.less +++ b/client/app/assets/less/inc/base.less @@ -222,10 +222,6 @@ text.slicetext { } } -.fa-external-link { - font-size: inherit; -} - .warning-icon-danger { color: @red !important; } diff --git a/client/app/pages/alert/Alert.jsx b/client/app/pages/alert/Alert.jsx index 5d101ecae9..1f57d3d718 100644 --- a/client/app/pages/alert/Alert.jsx +++ b/client/app/pages/alert/Alert.jsx @@ -93,6 +93,8 @@ AlertState.defaultProps = { }; class AlertPage extends React.Component { + _isMounted = false; + state = { alert: null, queryResult: null, @@ -104,6 +106,8 @@ class AlertPage extends React.Component { } componentDidMount() { + this._isMounted = true; + if (isNewAlert()) { this.setState({ alert: new AlertService({ @@ -121,17 +125,23 @@ class AlertPage extends React.Component { const { editMode } = $route.current.locals; AlertService.get({ id: alertId }).$promise.then((alert) => { const canEdit = currentUser.canEdit(alert); - this.setState({ - alert, - pendingRearm: alert.rearm, - editMode: editMode && canEdit, - canEdit, - }); - this.onQuerySelected(alert.query); + if (this._isMounted) { + this.setState({ + alert, + pendingRearm: alert.rearm, + editMode: editMode && canEdit, + canEdit, + }); + this.onQuerySelected(alert.query); + } }); } } + componentWillUnmount() { + this._isMounted = false; + } + getDefaultName = () => { const { alert } = this.state; if (!alert.query) { @@ -149,15 +159,17 @@ class AlertPage extends React.Component { if (query) { // get cached result for column names and values new QueryService(query).getQueryResultPromise().then((queryResult) => { - this.setState({ queryResult }); - let { column } = this.state.alert.options; - const columns = queryResult.getColumnNames(); - - // default to first column name if none chosen, or irrelevant in current query - if (!column || !includes(columns, column)) { - column = head(queryResult.getColumnNames()); + if (this._isMounted) { + this.setState({ queryResult }); + let { column } = this.state.alert.options; + const columns = queryResult.getColumnNames(); + + // default to first column name if none chosen, or irrelevant in current query + if (!column || !includes(columns, column)) { + column = head(queryResult.getColumnNames()); + } + this.setAlertOptions({ column }); } - this.setAlertOptions({ column }); }); } } @@ -203,7 +215,9 @@ class AlertPage extends React.Component { navigateTo(`/alerts/${alert.id}`, true); }).catch(() => { notification.error('Failed saving alert.'); - this.setState({ saving: false }); + if (this._isMounted) { + this.setState({ saving: false }); + } }); }; From affb35d24e3b59a75b8abd0451a339bd5bd1719e Mon Sep 17 00:00:00 2001 From: Ran Byron Date: Thu, 19 Sep 2019 20:54:31 +0300 Subject: [PATCH 08/13] Alert redesign #2 - Destinations --- .../images/destinations/hangouts_chat.png | Bin 3603 -> 16333 bytes .../app/components/EmailSettingsWarning.jsx | 53 +++-- client/app/components/HelpTrigger.jsx | 4 + .../alert-subscriptions.html | 27 --- .../alerts/alert-subscriptions/index.js | 116 ---------- client/app/components/proptypes.js | 7 + client/app/pages/alert/Alert.jsx | 14 +- .../alert/components/AlertDestinations.jsx | 211 ++++++++++++++++++ .../alert/components/AlertDestinations.less | 62 +++++ client/app/pages/users/UserProfile.jsx | 4 +- .../integration/alert/view_alert_spec.js | 106 ++++++++- client/cypress/seed-data.js | 11 + client/cypress/support/redash-api/index.js | 56 ++++- 13 files changed, 501 insertions(+), 170 deletions(-) delete mode 100644 client/app/components/alerts/alert-subscriptions/alert-subscriptions.html delete mode 100644 client/app/components/alerts/alert-subscriptions/index.js create mode 100644 client/app/pages/alert/components/AlertDestinations.jsx create mode 100644 client/app/pages/alert/components/AlertDestinations.less diff --git a/client/app/assets/images/destinations/hangouts_chat.png b/client/app/assets/images/destinations/hangouts_chat.png index ef934b0a2ce8716f12e904047f9d9a894427c3cc..5e5a5af5e915fd97de138463acd955c37a2aacb7 100644 GIT binary patch literal 16333 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 Cp^3== literal 3603 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 - {`It looks like your mail server isn't configured. Make sure to configure it for the ${featureName} to work.`} -

- ) : null; +export default function EmailSettingsWarning({ featureName, className, mode, adminOnly }) { + if (!clientConfig.mailSettingsMissing) { + return null; + } + + if (adminOnly && !currentUser.isAdmin) { + return null; + } + + const message = ( + + Your mail server isn't configured correctly, and is needed for {featureName} to work.{' '} + + + ); + + if (mode === 'icon') { + return ( + + + + ); + } + + return ( + + ); } EmailSettingsWarning.propTypes = { featureName: PropTypes.string.isRequired, + className: PropTypes.string, + mode: PropTypes.oneOf(['alert', 'icon']), + adminOnly: PropTypes.bool, }; -export default function init(ngModule) { - ngModule.component('emailSettingsWarning', react2angular(EmailSettingsWarning)); -} - -init.init = true; +EmailSettingsWarning.defaultProps = { + className: null, + mode: 'alert', + adminOnly: false, +}; diff --git a/client/app/components/HelpTrigger.jsx b/client/app/components/HelpTrigger.jsx index 0c5ebdf9e6..deddf4285d 100644 --- a/client/app/components/HelpTrigger.jsx +++ b/client/app/components/HelpTrigger.jsx @@ -72,6 +72,10 @@ export const TYPES = { '/user-guide/alerts/setting-up-an-alert', 'Guide: Setting Up a New Alert', ], + MAIL_CONFIG: [ + '/open-source/setup/#Mail-Configuration', + 'Guide: Mail Configuration', + ], }; export class HelpTrigger extends React.Component { diff --git a/client/app/components/alerts/alert-subscriptions/alert-subscriptions.html b/client/app/components/alerts/alert-subscriptions/alert-subscriptions.html deleted file mode 100644 index 4d3d0a246e..0000000000 --- a/client/app/components/alerts/alert-subscriptions/alert-subscriptions.html +++ /dev/null @@ -1,27 +0,0 @@ -
-

Notifications

- -
- - - - - - -
-
- - - Create New Destination - -
- -
- -
-
- - -
-
-
diff --git a/client/app/components/alerts/alert-subscriptions/index.js b/client/app/components/alerts/alert-subscriptions/index.js deleted file mode 100644 index 2c1bf332d6..0000000000 --- a/client/app/components/alerts/alert-subscriptions/index.js +++ /dev/null @@ -1,116 +0,0 @@ -import { includes, without, compact } from 'lodash'; -import notification from '@/services/notification'; -import template from './alert-subscriptions.html'; - -function controller($scope, $q, $sce, currentUser, AlertSubscription, Destination) { - 'ngInject'; - - $scope.newSubscription = {}; - $scope.subscribers = []; - $scope.destinations = []; - $scope.currentUser = currentUser; - - $q - .all([ - Destination.query().$promise, - AlertSubscription.query({ alertId: $scope.alertId }).$promise, - ]) - .then((responses) => { - const destinations = responses[0]; - const subscribers = responses[1]; - - const mapF = s => s.destination && s.destination.id; - const subscribedDestinations = compact(subscribers.map(mapF)); - - const subscribedUsers = compact(subscribers.map(s => !s.destination && s.user.id)); - - $scope.destinations = destinations.filter(d => !includes(subscribedDestinations, d.id)); - - if (!includes(subscribedUsers, currentUser.id)) { - $scope.destinations.unshift({ user: { name: currentUser.name } }); - } - - $scope.newSubscription.destination = $scope.destinations[0]; - $scope.subscribers = subscribers; - }); - - $scope.destinationsDisplay = (d) => { - if (!d) { - return ''; - } - - let destination = d; - if (d.destination) { - destination = destination.destination; - } else if (destination.user) { - destination = { - name: `${d.user.name} (Email)`, - icon: 'fa-envelope', - type: 'user', - }; - } - - return $sce.trustAsHtml(` ${destination.name}`); - }; - - $scope.saveSubscriber = () => { - const sub = new AlertSubscription({ alert_id: $scope.alertId }); - if ($scope.newSubscription.destination.id) { - sub.destination_id = $scope.newSubscription.destination.id; - } - - sub.$save( - () => { - notification.success('Subscribed.'); - $scope.subscribers.push(sub); - $scope.destinations = without($scope.destinations, $scope.newSubscription.destination); - if ($scope.destinations.length > 0) { - $scope.newSubscription.destination = $scope.destinations[0]; - } else { - $scope.newSubscription.destination = undefined; - } - }, - () => { - notification.error('Failed saving subscription.'); - }, - ); - }; - - $scope.unsubscribe = (subscriber) => { - const destination = subscriber.destination; - const user = subscriber.user; - - subscriber.$delete( - () => { - notification.success('Unsubscribed'); - $scope.subscribers = without($scope.subscribers, subscriber); - if (destination) { - $scope.destinations.push(destination); - } else if (user.id === currentUser.id) { - $scope.destinations.push({ user: { name: currentUser.name } }); - } - - if ($scope.destinations.length === 1) { - $scope.newSubscription.destination = $scope.destinations[0]; - } - }, - () => { - notification.error('Failed unsubscribing.'); - }, - ); - }; -} - -export default function init(ngModule) { - ngModule.directive('alertSubscriptions', () => ({ - restrict: 'E', - replace: true, - scope: { - alertId: '=', - }, - template, - controller, - })); -} - -init.init = true; diff --git a/client/app/components/proptypes.js b/client/app/components/proptypes.js index 761bbd4a26..bb8a314f2e 100644 --- a/client/app/components/proptypes.js +++ b/client/app/components/proptypes.js @@ -86,6 +86,13 @@ export const UserProfile = PropTypes.shape({ isDisabled: PropTypes.bool, }); +export const Destination = PropTypes.shape({ + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + icon: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, +}); + export const AlertOptions = PropTypes.shape({ column: PropTypes.string, op: PropTypes.oneOf(['greater than', 'less than', 'equals']), diff --git a/client/app/pages/alert/Alert.jsx b/client/app/pages/alert/Alert.jsx index 1f57d3d718..935f476348 100644 --- a/client/app/pages/alert/Alert.jsx +++ b/client/app/pages/alert/Alert.jsx @@ -17,6 +17,7 @@ import { TimeAgo } from '@/components/TimeAgo'; import Form from 'antd/lib/form'; import Button from 'antd/lib/button'; +import Tooltip from 'antd/lib/tooltip'; import Icon from 'antd/lib/icon'; import Modal from 'antd/lib/modal'; import Input from 'antd/lib/input'; @@ -26,6 +27,7 @@ import Menu from 'antd/lib/menu'; import Criteria from './components/Criteria'; import Rearm from './components/Rearm'; import Query from './components/Query'; +import AlertDestinations from './components/AlertDestinations'; import { STATE_CLASS } from '../alerts/AlertsList'; import { routesToAngularRoutes } from '@/lib/utils'; @@ -367,10 +369,16 @@ class AlertPage extends React.Component { )} - {!editMode && ( + {!editMode && alert.id && (
-

Destinations

-
In next PR
+

Destinations{' '} + + + + + +

+
)} diff --git a/client/app/pages/alert/components/AlertDestinations.jsx b/client/app/pages/alert/components/AlertDestinations.jsx new file mode 100644 index 0000000000..679b752d9a --- /dev/null +++ b/client/app/pages/alert/components/AlertDestinations.jsx @@ -0,0 +1,211 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { without, find, isEmpty, includes, map } from 'lodash'; + +import SelectItemsDialog from '@/components/SelectItemsDialog'; +import { Destination as DestinationType, UserProfile as UserType } from '@/components/proptypes'; + +import { Destination as DestinationService, IMG_ROOT } from '@/services/destination'; +import { AlertSubscription } from '@/services/alert-subscription'; +import { $q } from '@/services/ng'; +import { clientConfig, currentUser } from '@/services/auth'; +import notification from '@/services/notification'; +import ListItemAddon from '@/components/groups/ListItemAddon'; +import EmailSettingsWarning from '@/components/EmailSettingsWarning'; + +import Icon from 'antd/lib/icon'; +import Tooltip from 'antd/lib/tooltip'; +import Switch from 'antd/lib/switch'; +import Button from 'antd/lib/button'; + +import './AlertDestinations.less'; + +const USER_EMAIL_DEST_ID = -1; + +function normalizeSub(sub) { + if (!sub.destination) { + sub.destination = { + id: USER_EMAIL_DEST_ID, + name: sub.user.email, + icon: 'DEPRECATED', + type: 'email', + }; + } + return sub; +} + +function ListItem({ destination: { name, type }, user, unsubscribe }) { + const canUnsubscribe = currentUser.isAdmin || currentUser.id === user.id; + + return ( +
  • + {name} + {name} + {type === 'email' && } + {canUnsubscribe && ( + + + + )} +
  • + ); +} + +ListItem.propTypes = { + destination: DestinationType.isRequired, + user: UserType.isRequired, + unsubscribe: PropTypes.func.isRequired, +}; + + +export default class AlertDestinations extends React.Component { + static propTypes = { + alertId: PropTypes.number.isRequired, + } + + state = { + dests: [], + subs: null, + } + + componentDidMount() { + const { alertId } = this.props; + $q + .all([ + DestinationService.query().$promise, // get all destinations + AlertSubscription.query({ alertId }).$promise, // get subcriptions per alert + ]) + .then(([dests, subs]) => { + subs = subs.map(normalizeSub); + this.setState({ dests, subs }); + }); + } + + showAddAlertSubDialog = () => { + const { dests, subs } = this.state; + + SelectItemsDialog.showModal({ + dialogTitle: 'Add Existing Alert Destinations', + selectedItemsTitle: 'Pending Destinations', + inputPlaceholder: 'Search destinations...', + searchItems: (searchTerm) => { + searchTerm = searchTerm.toLowerCase(); + const filtered = dests.filter(d => isEmpty(searchTerm) || includes(d.name.toLowerCase(), searchTerm)); + return Promise.resolve(filtered); + }, + renderItem: (item, { isSelected }) => { + const alreadyInGroup = !!find(subs, s => s.destination.id === item.id); + + return { + content: ( +
    + {name} + {item.name} + +
    + ), + isDisabled: alreadyInGroup, + className: isSelected || alreadyInGroup ? 'selected' : '', + }; + }, + renderStagedItem: item => ({ + content: ( +
    + {name} + {item.name} + +
    + ), + }), + save: (items) => { + const promises = map(items, item => this.subscribe(item)); + return Promise.all(promises).then(() => { + notification.success('Subscribed.'); + }).catch(() => { + notification.error('Failed saving subscription.'); + }); + }, + }); + } + + onUserEmailToggle = (sub) => { + if (sub) { + this.unsubscribe(sub); + } else { + this.subscribe(); + } + } + + subscribe = (dest) => { + const { alertId } = this.props; + + const sub = new AlertSubscription({ alert_id: alertId }); + if (dest) { + sub.destination_id = dest.id; + } + + return sub.$save(() => { + const { subs } = this.state; + this.setState({ + subs: [...subs, normalizeSub(sub)], + }); + }); + } + + unsubscribe = (sub) => { + sub.$delete( + () => { + // not showing subscribe notification cause it's redundant here + const { subs } = this.state; + this.setState({ + subs: without(subs, sub), + }); + }, + () => { + notification.error('Failed unsubscribing.'); + }, + ); + }; + + render() { + if (!this.props.alertId) { + return null; + } + + const { subs } = this.state; + const currentUserEmailSub = find(subs, { + destination: { id: USER_EMAIL_DEST_ID }, + user: { id: currentUser.id }, + }); + const filteredSubs = without(subs, currentUserEmailSub); + const { mailSettingsMissing } = clientConfig; + + return ( +
    + + + +
      +
    • + + { currentUser.email } + + {!mailSettingsMissing && ( + this.onUserEmailToggle(currentUserEmailSub)} + data-test="UserEmailToggle" + /> + )} +
    • + {filteredSubs.map(s => this.unsubscribe(s)} {...s} />)} +
    +
    + ); + } +} diff --git a/client/app/pages/alert/components/AlertDestinations.less b/client/app/pages/alert/components/AlertDestinations.less new file mode 100644 index 0000000000..f091e01cd8 --- /dev/null +++ b/client/app/pages/alert/components/AlertDestinations.less @@ -0,0 +1,62 @@ +.alert-destinations { + ul { + list-style: none; + padding: 0; + margin-top: 15px; + + li { + color: rgba(0, 0, 0, 0.85); + height: 46px; + border-bottom: 1px solid #e8e8e8; + + .remove-button { + cursor: pointer; + height: 40px; + width: 40px; + display: flex; + align-items: center; + justify-content: center; + } + + .toggle-button { + margin: 0 7px; + } + + .destination-warning { + color: #f5222d; + + &:last-child { + margin-right: 14px; + } + } + } + } + + .add-button { + position: absolute; + right: 14px; + top: 9px; + } +} + +.destination-wrapper { + padding-left: 8px; + display: flex; + align-items: center; + min-height: 38px; + width: 100%; + + .destination-icon { + height: 25px; + width: 25px; + margin: 2px 5px 0 0; + filter: grayscale(1); + + &.fa { + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + } + } +} diff --git a/client/app/pages/users/UserProfile.jsx b/client/app/pages/users/UserProfile.jsx index c6bc7bfffa..7298b899fd 100644 --- a/client/app/pages/users/UserProfile.jsx +++ b/client/app/pages/users/UserProfile.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { react2angular } from 'react2angular'; -import { EmailSettingsWarning } from '@/components/EmailSettingsWarning'; +import EmailSettingsWarning from '@/components/EmailSettingsWarning'; import UserEdit from '@/components/users/UserEdit'; import UserShow from '@/components/users/UserShow'; import LoadingState from '@/components/items-list/components/LoadingState'; @@ -47,7 +47,7 @@ class UserProfile extends React.Component { const UserComponent = canEdit ? UserEdit : UserShow; return ( - +
    {user ? : }
    diff --git a/client/cypress/integration/alert/view_alert_spec.js b/client/cypress/integration/alert/view_alert_spec.js index 69ac9ac18a..9e1b915070 100644 --- a/client/cypress/integration/alert/view_alert_spec.js +++ b/client/cypress/integration/alert/view_alert_spec.js @@ -1,17 +1,107 @@ -import { createAlert, createQuery } from '../../support/redash-api'; +import { createAlert, createQuery, createUser, addDestinationSubscription } from '../../support/redash-api'; describe('View Alert', () => { - beforeEach(() => { + beforeEach(function () { cy.login(); - }); - - it('renders the page and takes a screenshot', () => { createQuery({ query: 'select 1 as col_name' }) .then(({ id: queryId }) => createAlert(queryId, { column: 'col_name' })) .then(({ id: alertId }) => { - cy.visit(`/alerts/${alertId}`); - cy.getByTestId('Criteria').should('exist'); - cy.percySnapshot('View Alert screen'); + this.alertId = alertId; + this.alertUrl = `/alerts/${alertId}`; }); }); + + it('renders the page and takes a screenshot', function () { + cy.visit(this.alertUrl); + cy.getByTestId('Criteria').should('exist'); + cy.percySnapshot('View Alert screen'); + }); + + it('allows adding new destinations', function () { + cy.visit(this.alertUrl); + cy.getByTestId('AlertDestinations').contains('Test Email Destination').should('not.exist'); + + cy.server(); + cy.route('GET', 'api/destinations').as('Destinations'); + cy.route('GET', 'api/alerts/*/subscriptions').as('Subscriptions'); + + cy.visit(this.alertUrl); + + cy.wait(['@Destinations', '@Subscriptions']); + cy.getByTestId('ShowAddAlertSubDialog').click(); + cy.contains('Test Email Destination').click(); + cy.contains('Save').click(); + + cy.getByTestId('AlertDestinations').contains('Test Email Destination').should('exist'); + }); + + describe('Alert Destination permissions', () => { + before(() => { + cy.login(); + createUser({ + name: 'Example User', + email: 'user@redash.io', + password: 'password', + }); + }); + + it('hides remove button from non-author', function () { + cy.server(); + cy.route('GET', 'api/alerts/*/subscriptions').as('Subscriptions'); + + cy.logout() + .then(() => cy.login()) // as admin + .then(() => addDestinationSubscription(this.alertId, 'Test Email Destination')) + .then(() => { + cy.visit(this.alertUrl); + + // verify remove button appears for author + cy.wait(['@Subscriptions']); + cy.getByTestId('AlertDestinations') + .contains('Test Email Destination') + .parent() + .within(() => { + cy.get('.remove-button').as('RemoveButton').should('exist'); + }); + + return cy.logout().then(() => cy.login('user@redash.io', 'password')); + }) + .then(() => { + cy.visit(this.alertUrl); + + // verify remove button not shown for non-author + cy.wait(['@Subscriptions']); + cy.get('@RemoveButton').should('not.exist'); + }); + }); + + it('shows remove button for non-author admin', function () { + cy.server(); + cy.route('GET', 'api/alerts/*/subscriptions').as('Subscriptions'); + + cy.logout() + .then(() => cy.login('user@redash.io', 'password')) + .then(() => addDestinationSubscription(this.alertId, 'Test Email Destination')) + .then(() => { + cy.visit(this.alertUrl); + + // verify remove button appears for author + cy.wait(['@Subscriptions']); + cy.getByTestId('AlertDestinations') + .contains('Test Email Destination') + .parent().within(() => { + cy.get('.remove-button').as('RemoveButton').should('exist'); + }); + + return cy.logout().then(() => cy.login()); // as admin + }) + .then(() => { + cy.visit(this.alertUrl); + + // verify remove button also appears for admin + cy.wait(['@Subscriptions']); + cy.get('@RemoveButton').should('exist'); + }); + }); + }); }); diff --git a/client/cypress/seed-data.js b/client/cypress/seed-data.js index cd19f849a0..1db8f93bb1 100644 --- a/client/cypress/seed-data.js +++ b/client/cypress/seed-data.js @@ -32,4 +32,15 @@ exports.seedData = [ type: 'pg', }, }, + { + route: '/api/destinations', + type: 'json', + data: { + name: 'Test Email Destination', + options: { + addresses: 'test@example.com', + }, + type: 'email', + }, + }, ]; diff --git a/client/cypress/support/redash-api/index.js b/client/cypress/support/redash-api/index.js index 015ccaab25..e7577a52d5 100644 --- a/client/cypress/support/redash-api/index.js +++ b/client/cypress/support/redash-api/index.js @@ -1,6 +1,6 @@ /* global cy, Cypress */ -const { extend, get, merge } = Cypress._; +const { extend, get, merge, find } = Cypress._; export function createDashboard(name) { return cy.request('POST', 'api/dashboards', { name }) @@ -100,3 +100,57 @@ export function createAlert(queryId, options = {}, name) { return body; }); } + +export function createUser({ name, email, password }) { + return cy.request({ + method: 'POST', + url: 'api/users', + body: { name, email }, + failOnStatusCode: false, + }) + .then((xhr) => { + const { status, body } = xhr; + if (status < 200 || status > 400) { + throw new Error(xhr); + } + + if (status === 400 && body.message === 'Email already taken.') { + // all is good, do nothing + return; + } + + const id = get(body, 'id'); + assert.isDefined(id, 'User api call returns user id'); + + return cy.request({ + url: body.invite_link, + method: 'POST', + form: true, + body: { password }, + }); + }); +} + +export function getDestinations() { + return cy.request('GET', 'api/destinations') + .then(({ body }) => body); +} + +export function addDestinationSubscription(alertId, destinationName) { + return getDestinations() + .then((destinations) => { + const destination = find(destinations, { name: destinationName }); + if (!destination) { + throw new Error('Destination not found'); + } + return cy.request('POST', `api/alerts/${alertId}/subscriptions`, { + alert_id: alertId, + destination_id: destination.id, + }); + }) + .then(({ body }) => { + const id = get(body, 'id'); + assert.isDefined(id, 'Subscription api call returns subscription id'); + return body; + }); +} From bc6697a879710aeb2603584d780d61efe434bdb4 Mon Sep 17 00:00:00 2001 From: Ran Byron Date: Thu, 19 Sep 2019 21:11:39 +0300 Subject: [PATCH 09/13] Alert redesign #3 - Destinations single pane mode --- client/app/components/SelectItemsDialog.jsx | 67 ++++++++++++------- .../app/components/groups/ListItemAddon.jsx | 8 ++- .../alert/components/AlertDestinations.jsx | 23 ++++--- .../alert/components/AlertDestinations.less | 9 +++ 4 files changed, 68 insertions(+), 39 deletions(-) diff --git a/client/app/components/SelectItemsDialog.jsx b/client/app/components/SelectItemsDialog.jsx index df4be5e109..f54dc27773 100644 --- a/client/app/components/SelectItemsDialog.jsx +++ b/client/app/components/SelectItemsDialog.jsx @@ -1,10 +1,11 @@ -import { filter, debounce, find } from 'lodash'; +import { filter, debounce, find, isEmpty, size } from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import Modal from 'antd/lib/modal'; import Input from 'antd/lib/input'; import List from 'antd/lib/list'; +import Button from 'antd/lib/button'; import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper'; import { BigMessage } from '@/components/BigMessage'; @@ -29,6 +30,9 @@ class SelectItemsDialog extends React.Component { // right list; args/results save as for `renderItem`. if not specified - `renderItem` will be used renderStagedItem: PropTypes.func, save: PropTypes.func, // (selectedItems[]) => Promise + width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + extraFooterContent: PropTypes.node, + showCount: PropTypes.bool, }; static defaultProps = { @@ -37,8 +41,11 @@ class SelectItemsDialog extends React.Component { selectedItemsTitle: 'Selected items', itemKey: item => item.id, renderItem: () => '', - renderStagedItem: null, // use `renderItem` by default + renderStagedItem: null, // hidden by default save: items => items, + width: '80%', + extraFooterContent: null, + showCount: false, }; state = { @@ -108,7 +115,7 @@ class SelectItemsDialog extends React.Component { renderItem(item, isStagedList) { const { renderItem, renderStagedItem } = this.props; const isSelected = this.isSelected(item); - const render = isStagedList ? (renderStagedItem || renderItem) : renderItem; + const render = isStagedList ? renderStagedItem : renderItem; const { content, className, isDisabled } = render(item, { isSelected }); @@ -123,23 +130,29 @@ class SelectItemsDialog extends React.Component { } render() { - const { dialog, dialogTitle, inputPlaceholder, selectedItemsTitle } = this.props; + const { dialog, dialogTitle, inputPlaceholder } = this.props; + const { selectedItemsTitle, renderStagedItem, width, showCount } = this.props; const { loading, saveInProgress, items, selected } = this.state; const hasResults = items.length > 0; return ( this.save()} + footer={( +
    + {this.props.extraFooterContent} + + +
    + )} >
    -
    +
    this.search(event.target.value)} @@ -147,13 +160,15 @@ class SelectItemsDialog extends React.Component { autoFocus />
    -
    -
    {selectedItemsTitle}
    -
    + {renderStagedItem && ( +
    +
    {selectedItemsTitle}
    +
    + )}
    -
    +
    {loading && } {!loading && !hasResults && ( @@ -166,15 +181,17 @@ class SelectItemsDialog extends React.Component { /> )}
    -
    - {(selected.length > 0) && ( - this.renderItem(item, true)} - /> - )} -
    + {renderStagedItem && ( +
    + {(selected.length > 0) && ( + this.renderItem(item, true)} + /> + )} +
    + )}
    ); diff --git a/client/app/components/groups/ListItemAddon.jsx b/client/app/components/groups/ListItemAddon.jsx index ec4fd22ade..53a9f0b525 100644 --- a/client/app/components/groups/ListItemAddon.jsx +++ b/client/app/components/groups/ListItemAddon.jsx @@ -2,24 +2,26 @@ import React from 'react'; import PropTypes from 'prop-types'; import Tooltip from 'antd/lib/tooltip'; -export default function ListItemAddon({ isSelected, isStaged, alreadyInGroup }) { +export default function ListItemAddon({ isSelected, isStaged, alreadyInGroup, deselectedIcon }) { if (isStaged) { return ; } if (alreadyInGroup) { - return ; + return ; } - return isSelected ? : ; + return isSelected ? : ; } ListItemAddon.propTypes = { isSelected: PropTypes.bool, isStaged: PropTypes.bool, alreadyInGroup: PropTypes.bool, + deselectedIcon: PropTypes.string, }; ListItemAddon.defaultProps = { isSelected: false, isStaged: false, alreadyInGroup: false, + deselectedIcon: 'fa-angle-double-right', }; diff --git a/client/app/pages/alert/components/AlertDestinations.jsx b/client/app/pages/alert/components/AlertDestinations.jsx index 679b752d9a..37dc659d6b 100644 --- a/client/app/pages/alert/components/AlertDestinations.jsx +++ b/client/app/pages/alert/components/AlertDestinations.jsx @@ -85,8 +85,18 @@ export default class AlertDestinations extends React.Component { const { dests, subs } = this.state; SelectItemsDialog.showModal({ + width: 570, + showCount: true, + extraFooterContent: ( + <> + {' '} + Create new destinations in{' '} + + Alert Destinations + + + ), dialogTitle: 'Add Existing Alert Destinations', - selectedItemsTitle: 'Pending Destinations', inputPlaceholder: 'Search destinations...', searchItems: (searchTerm) => { searchTerm = searchTerm.toLowerCase(); @@ -101,22 +111,13 @@ export default class AlertDestinations extends React.Component {
    {name} {item.name} - +
    ), isDisabled: alreadyInGroup, className: isSelected || alreadyInGroup ? 'selected' : '', }; }, - renderStagedItem: item => ({ - content: ( -
    - {name} - {item.name} - -
    - ), - }), save: (items) => { const promises = map(items, item => this.subscribe(item)); return Promise.all(promises).then(() => { diff --git a/client/app/pages/alert/components/AlertDestinations.less b/client/app/pages/alert/components/AlertDestinations.less index f091e01cd8..54e0b4f6a0 100644 --- a/client/app/pages/alert/components/AlertDestinations.less +++ b/client/app/pages/alert/components/AlertDestinations.less @@ -46,6 +46,10 @@ min-height: 38px; width: 100%; + .select-items-dialog & { + padding: 0; + } + .destination-icon { height: 25px; width: 25px; @@ -58,5 +62,10 @@ justify-content: center; font-size: 12px; } + + .select-items-dialog & { + width: 35px; + height: 35px; + } } } From 7740deb8a91632bbb9911e557af826105d2c86cf Mon Sep 17 00:00:00 2001 From: Ran Byron Date: Sat, 5 Oct 2019 12:07:49 +0300 Subject: [PATCH 10/13] Alert redesign #4 - custom notification template (#4170) --- client/app/components/HelpTrigger.jsx | 4 + client/app/components/proptypes.js | 35 +++++ client/app/pages/alert/Alert.jsx | 15 +++ .../alert/components/NotificationTemplate.jsx | 122 ++++++++++++++++++ .../components/NotificationTemplate.less | 36 ++++++ client/app/services/alert-template.js | 41 ------ .../integration/alert/edit_alert_spec.js | 28 ++++ redash/destinations/chatwork.py | 15 ++- redash/destinations/email.py | 12 +- redash/destinations/hangoutschat.py | 4 +- redash/destinations/mattermost.py | 13 +- redash/destinations/pagerduty.py | 4 +- redash/destinations/slack.py | 5 +- redash/destinations/webhook.py | 2 +- redash/models/__init__.py | 38 ++++-- 15 files changed, 298 insertions(+), 76 deletions(-) create mode 100644 client/app/pages/alert/components/NotificationTemplate.jsx create mode 100644 client/app/pages/alert/components/NotificationTemplate.less delete mode 100644 client/app/services/alert-template.js diff --git a/client/app/components/HelpTrigger.jsx b/client/app/components/HelpTrigger.jsx index deddf4285d..ae57b250da 100644 --- a/client/app/components/HelpTrigger.jsx +++ b/client/app/components/HelpTrigger.jsx @@ -76,6 +76,10 @@ export const TYPES = { '/open-source/setup/#Mail-Configuration', 'Guide: Mail Configuration', ], + ALERT_NOTIF_TEMPLATE_GUIDE: [ + '/user-guide/alerts/custom-alert-notifications', + 'Guide: Custom Alerts Notifications', + ], }; export class HelpTrigger extends React.Component { diff --git a/client/app/components/proptypes.js b/client/app/components/proptypes.js index bb8a314f2e..41151d388e 100644 --- a/client/app/components/proptypes.js +++ b/client/app/components/proptypes.js @@ -93,10 +93,45 @@ export const Destination = PropTypes.shape({ type: PropTypes.string.isRequired, }); +export const Query = PropTypes.shape({ + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + description: PropTypes.string, + data_source_id: PropTypes.number.isRequired, + created_at: PropTypes.string.isRequired, + updated_at: PropTypes.string, + user: UserProfile, + query: PropTypes.string, + queryHash: PropTypes.string, + is_safe: PropTypes.bool.isRequired, + is_draft: PropTypes.bool.isRequired, + is_archived: PropTypes.bool.isRequired, + api_key: PropTypes.string.isRequired, +}); + export const AlertOptions = PropTypes.shape({ column: PropTypes.string, op: PropTypes.oneOf(['greater than', 'less than', 'equals']), value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + custom_subject: PropTypes.string, + custom_body: PropTypes.string, +}); + +export const Alert = PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + created_at: PropTypes.string, + last_triggered_at: PropTypes.string, + updated_at: PropTypes.string, + rearm: PropTypes.number, + state: PropTypes.oneOf(['ok', 'triggered', 'unknown']), + user: UserProfile, + query: Query.isRequired, + options: PropTypes.shape({ + column: PropTypes.string, + op: PropTypes.string, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + }).isRequired, }); function checkMoment(isRequired, props, propName, componentName) { diff --git a/client/app/pages/alert/Alert.jsx b/client/app/pages/alert/Alert.jsx index 935f476348..3fded8d79e 100644 --- a/client/app/pages/alert/Alert.jsx +++ b/client/app/pages/alert/Alert.jsx @@ -25,6 +25,7 @@ import Dropdown from 'antd/lib/dropdown'; import Menu from 'antd/lib/menu'; import Criteria from './components/Criteria'; +import NotificationTemplate from './components/NotificationTemplate'; import Rearm from './components/Rearm'; import Query from './components/Query'; import AlertDestinations from './components/AlertDestinations'; @@ -349,10 +350,24 @@ class AlertPage extends React.Component { + + this.setAlertOptions({ custom_subject: subject })} + body={options.custom_body} + setBody={body => this.setAlertOptions({ custom_body: body })} + /> + ) : ( +
    + Set to {options.custom_subject || options.custom_body ? 'custom' : 'default'} notification template.
    )} diff --git a/client/app/pages/alert/components/NotificationTemplate.jsx b/client/app/pages/alert/components/NotificationTemplate.jsx new file mode 100644 index 0000000000..d3c1178436 --- /dev/null +++ b/client/app/pages/alert/components/NotificationTemplate.jsx @@ -0,0 +1,122 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { head } from 'lodash'; +import Mustache from 'mustache'; + +import { HelpTrigger } from '@/components/HelpTrigger'; +import { Alert as AlertType, Query as QueryType } from '@/components/proptypes'; + +import Input from 'antd/lib/input'; +import Select from 'antd/lib/select'; +import Modal from 'antd/lib/modal'; +import Switch from 'antd/lib/switch'; + +import './NotificationTemplate.less'; + + +function normalizeCustomTemplateData(alert, query, columnNames, resultValues) { + const topValue = resultValues && head(resultValues)[alert.options.column]; + + return { + ALERT_STATUS: 'TRIGGERED', + ALERT_CONDITION: alert.options.op, + ALERT_THRESHOLD: alert.options.value, + ALERT_NAME: alert.name, + ALERT_URL: `${window.location.origin}/alerts/${alert.id}`, + QUERY_NAME: query.name, + QUERY_URL: `${window.location.origin}/queries/${query.id}`, + QUERY_RESULT_VALUE: topValue, + QUERY_RESULT_ROWS: resultValues, + QUERY_RESULT_COLS: columnNames, + }; +} + +function NotificationTemplate({ alert, query, columnNames, resultValues, subject, setSubject, body, setBody }) { + const hasContent = !!(subject || body); + const [enabled, setEnabled] = useState(hasContent ? 1 : 0); + const [showPreview, setShowPreview] = useState(false); + + const renderData = normalizeCustomTemplateData(alert, query, columnNames, resultValues); + + const render = tmpl => Mustache.render(tmpl || '', renderData); + const onEnabledChange = (value) => { + if (value || !hasContent) { + setEnabled(value); + setShowPreview(false); + } else { + Modal.confirm({ + title: 'Are you sure?', + content: 'Switching to default template will discard your custom template.', + onOk: () => { + setSubject(null); + setBody(null); + setEnabled(value); + setShowPreview(false); + }, + maskClosable: true, + autoFocusButton: null, + }); + } + }; + + return ( +
    + + {!!enabled && ( +
    +
    +
    Subject / Body
    + Preview +
    + setSubject(e.target.value)} + disabled={showPreview} + data-test="CustomSubject" + /> + setBody(e.target.value)} + disabled={showPreview} + data-test="CustomBody" + /> + + Formatting guide + +
    + )} +
    + ); +} + +NotificationTemplate.propTypes = { + alert: AlertType.isRequired, + query: QueryType.isRequired, + columnNames: PropTypes.arrayOf(PropTypes.string).isRequired, + resultValues: PropTypes.arrayOf(PropTypes.any).isRequired, + subject: PropTypes.string, + setSubject: PropTypes.func.isRequired, + body: PropTypes.string, + setBody: PropTypes.func.isRequired, +}; + +NotificationTemplate.defaultProps = { + subject: '', + body: '', +}; + +export default NotificationTemplate; diff --git a/client/app/pages/alert/components/NotificationTemplate.less b/client/app/pages/alert/components/NotificationTemplate.less new file mode 100644 index 0000000000..15a4907c34 --- /dev/null +++ b/client/app/pages/alert/components/NotificationTemplate.less @@ -0,0 +1,36 @@ +.alert-template { + display: flex; + flex-direction: column; + + input { + margin-bottom: 10px; + } + + textarea { + margin-bottom: 0 !important; + } + + input, textarea { + font-family: "Roboto Mono", monospace; + font-size: 12px; + letter-spacing: -0.4px ; + + &[disabled] { + color: inherit; + cursor: auto; + } + } + + .alert-custom-template { + margin-top: 10px; + padding: 4px 10px 2px; + background: #fbfbfb; + border: 1px dashed #d9d9d9; + border-radius: 3px; + max-width: 500px; + } + + .alert-template-preview { + margin: 0 0 0 5px !important; + } +} \ No newline at end of file diff --git a/client/app/services/alert-template.js b/client/app/services/alert-template.js deleted file mode 100644 index 74da808326..0000000000 --- a/client/app/services/alert-template.js +++ /dev/null @@ -1,41 +0,0 @@ -// import { $http } from '@/services/ng'; -import Mustache from 'mustache'; - -export default class AlertTemplate { - render(alert, queryResult) { - const view = { - state: alert.state, - rows: queryResult.rows, - cols: queryResult.columns, - }; - const result = Mustache.render(alert.options.template, view); - const escaped = result - .replace(/"/g, '"') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/\n|\r/g, '
    '); - - return { escaped, raw: result }; - } - - constructor() { - this.helpMessage = `using template engine "mustache". - you can build message with latest query result. - variable name "rows" is assigned as result rows. "cols" as result columns, "state" as alert state.`; - - this.editorOptions = { - useWrapMode: true, - showPrintMargin: false, - advanced: { - behavioursEnabled: true, - enableBasicAutocompletion: true, - enableLiveAutocompletion: true, - autoScrollEditorIntoView: true, - }, - onLoad(editor) { - editor.$blockScrolling = Infinity; - }, - }; - } -} diff --git a/client/cypress/integration/alert/edit_alert_spec.js b/client/cypress/integration/alert/edit_alert_spec.js index cde513a6f2..31c8062011 100644 --- a/client/cypress/integration/alert/edit_alert_spec.js +++ b/client/cypress/integration/alert/edit_alert_spec.js @@ -14,4 +14,32 @@ describe('Edit Alert', () => { cy.percySnapshot('Edit Alert screen'); }); }); + + it('edits the notification template and takes a screenshot', () => { + createQuery() + .then(({ id: queryId }) => createAlert(queryId, { custom_subject: 'FOO', custom_body: 'BAR' })) + .then(({ id: alertId }) => { + cy.visit(`/alerts/${alertId}/edit`); + cy.getByTestId('AlertCustomTemplate').should('exist'); + cy.percySnapshot('Alert Custom Template screen'); + }); + }); + + it('previews rendered template correctly', () => { + const options = { + value: '123', + op: 'equals', + custom_subject: '{{ ALERT_CONDITION }}', + custom_body: '{{ ALERT_THRESHOLD }}', + }; + + createQuery() + .then(({ id: queryId }) => createAlert(queryId, options)) + .then(({ id: alertId }) => { + cy.visit(`/alerts/${alertId}/edit`); + cy.get('.alert-template-preview').click(); + cy.getByTestId('CustomSubject').should('have.value', options.op); + cy.getByTestId('CustomBody').should('have.value', options.value); + }); + }); }); diff --git a/redash/destinations/chatwork.py b/redash/destinations/chatwork.py index aea6855a3e..4ea13a20d0 100644 --- a/redash/destinations/chatwork.py +++ b/redash/destinations/chatwork.py @@ -38,20 +38,21 @@ def notify(self, alert, query, user, new_state, app, host, options): # Documentation: http://developer.chatwork.com/ja/endpoint_rooms.html#POST-rooms-room_id-messages url = 'https://api.chatwork.com/v2/rooms/{room_id}/messages'.format(room_id=options.get('room_id')) - alert_url = '{host}/alerts/{alert_id}'.format(host=host, alert_id=alert.id) - query_url = '{host}/queries/{query_id}'.format(host=host, query_id=query.id) - message_template = options.get('message_template', ChatWork.ALERTS_DEFAULT_MESSAGE_TEMPLATE) message = '' if alert.custom_subject: message = alert.custom_subject + '\n' - message += message_template.replace('\\n', '\n').format( + + if alert.custom_body: + message += alert.custom_body + else: + alert_url = '{host}/alerts/{alert_id}'.format(host=host, alert_id=alert.id) + query_url = '{host}/queries/{query_id}'.format(host=host, query_id=query.id) + message_template = options.get('message_template', ChatWork.ALERTS_DEFAULT_MESSAGE_TEMPLATE) + message += message_template.replace('\\n', '\n').format( alert_name=alert.name, new_state=new_state.upper(), alert_url=alert_url, query_url=query_url) - if alert.template: - description = alert.render_template() - message = message + "\n" + description headers = {'X-ChatWorkToken': options.get('api_token')} payload = {'body': message} diff --git a/redash/destinations/email.py b/redash/destinations/email.py index 537ec6bc2b..985014c415 100644 --- a/redash/destinations/email.py +++ b/redash/destinations/email.py @@ -34,12 +34,12 @@ def notify(self, alert, query, user, new_state, app, host, options): if not recipients: logging.warning("No emails given. Skipping send.") - html = """ - Check alert / check query
    . - """.format(host=host, alert_id=alert.id, query_id=query.id) - if alert.template: - description = alert.render_template() - html += "
    " + description + if alert.custom_body: + html = alert.custom_body + else: + html = """ + Check alert / check query
    . + """.format(host=host, alert_id=alert.id, query_id=query.id) logging.debug("Notifying: %s", recipients) try: diff --git a/redash/destinations/hangoutschat.py b/redash/destinations/hangoutschat.py index 5db48e9cf7..0a3063a435 100644 --- a/redash/destinations/hangoutschat.py +++ b/redash/destinations/hangoutschat.py @@ -70,12 +70,12 @@ def notify(self, alert, query, user, new_state, app, host, options): ] } - if alert.template: + if alert.custom_body: data["cards"][0]["sections"].append({ "widgets": [ { "textParagraph": { - "text": alert.render_template() + "text": alert.custom_body } } ] diff --git a/redash/destinations/mattermost.py b/redash/destinations/mattermost.py index 6528032e92..573cb7cf21 100644 --- a/redash/destinations/mattermost.py +++ b/redash/destinations/mattermost.py @@ -35,19 +35,20 @@ def icon(cls): return 'fa-bolt' def notify(self, alert, query, user, new_state, app, host, options): - if new_state == "triggered": + + + if alert.custom_subject: + text = alert.custom_subject + elif new_state == "triggered": text = "#### " + alert.name + " just triggered" else: text = "#### " + alert.name + " went back to normal" - - if alert.custom_subject: - text += '\n' + alert.custom_subject payload = {'text': text} - if alert.template: + if alert.custom_body: payload['attachments'] = [{'fields': [{ "title": "Description", - "value": alert.render_template() + "value": alert.custom_body }]}] if options.get('username'): payload['username'] = options.get('username') diff --git a/redash/destinations/pagerduty.py b/redash/destinations/pagerduty.py index 403901e252..09410a7183 100644 --- a/redash/destinations/pagerduty.py +++ b/redash/destinations/pagerduty.py @@ -60,8 +60,8 @@ def notify(self, alert, query, user, new_state, app, host, options): } } - if alert.template: - data['payload']['custom_details'] = alert.render_template() + if alert.custom_body: + data['payload']['custom_details'] = alert.custom_body if new_state == 'triggered': data['event_action'] = 'trigger' diff --git a/redash/destinations/slack.py b/redash/destinations/slack.py index 18c998cf89..4c10c8e73b 100644 --- a/redash/destinations/slack.py +++ b/redash/destinations/slack.py @@ -52,11 +52,10 @@ def notify(self, alert, query, user, new_state, app, host, options): "short": True } ] - if alert.template: - description = alert.render_template() + if alert.custom_body: fields.append({ "title": "Description", - "value": description + "value": alert.custom_body }) if new_state == "triggered": if alert.custom_subject: diff --git a/redash/destinations/webhook.py b/redash/destinations/webhook.py index eb0cd06e0b..42144ff3fa 100644 --- a/redash/destinations/webhook.py +++ b/redash/destinations/webhook.py @@ -39,7 +39,7 @@ def notify(self, alert, query, user, new_state, app, host, options): 'url_base': host, } - data['alert']['description'] = alert.render_template() + data['alert']['description'] = alert.custom_body data['alert']['title'] = alert.custom_subject headers = {'Content-Type': 'application/json'} diff --git a/redash/models/__init__.py b/redash/models/__init__.py index cc160884c8..f49101fa7e 100644 --- a/redash/models/__init__.py +++ b/redash/models/__init__.py @@ -23,7 +23,7 @@ from redash.metrics import database # noqa: F401 from redash.query_runner import (get_configuration_schema_for_query_runner_type, get_query_runner, TYPE_BOOLEAN, TYPE_DATE, TYPE_DATETIME) -from redash.utils import generate_token, json_dumps, json_loads, mustache_render +from redash.utils import generate_token, json_dumps, json_loads, mustache_render, base_url from redash.utils.configuration import ConfigurationContainer from redash.models.parameterized_query import ParameterizedQuery @@ -821,20 +821,42 @@ def evaluate(self): def subscribers(self): return User.query.join(AlertSubscription).filter(AlertSubscription.alert == self) - def render_template(self): - if not self.template: + def render_template(self, template): + if template is None: return '' + data = json_loads(self.query_rel.latest_query_data.data) - context = {'rows': data['rows'], 'cols': data['columns'], 'state': self.state} - return mustache_render(self.template, context) + host = base_url(self.query_rel.org) + + col_name = self.options['column'] + if data['rows'] and col_name in data['rows'][0]: + result_value = data['rows'][0][col_name] + else: + result_value = None + + context = { + 'ALERT_NAME': self.name, + 'ALERT_URL': '{host}/alerts/{alert_id}'.format(host=host, alert_id=self.id), + 'ALERT_STATUS': self.state.upper(), + 'ALERT_CONDITION': self.options['op'], + 'ALERT_THRESHOLD': self.options['value'], + 'QUERY_NAME': self.query_rel.name, + 'QUERY_URL': '{host}/queries/{query_id}'.format(host=host, query_id=self.query_rel.id), + 'QUERY_RESULT_VALUE': result_value, + 'QUERY_RESULT_ROWS': data['rows'], + 'QUERY_RESULT_COLS': data['columns'], + } + return mustache_render(template, context) @property - def template(self): - return self.options.get('template', '') + def custom_body(self): + template = self.options.get('custom_body', self.options.get('template')) + return self.render_template(template) @property def custom_subject(self): - return self.options.get('subject', '') + template = self.options.get('custom_subject') + return self.render_template(template) @property def groups(self): From 8ff3589815b170de5343390744f60e6d8ae5322e Mon Sep 17 00:00:00 2001 From: Ran Byron Date: Sat, 5 Oct 2019 12:31:02 +0300 Subject: [PATCH 11/13] Alert page 404 (#4180) --- client/app/pages/alert/Alert.jsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/app/pages/alert/Alert.jsx b/client/app/pages/alert/Alert.jsx index 3fded8d79e..917a55ac6b 100644 --- a/client/app/pages/alert/Alert.jsx +++ b/client/app/pages/alert/Alert.jsx @@ -31,6 +31,7 @@ import Query from './components/Query'; import AlertDestinations from './components/AlertDestinations'; import { STATE_CLASS } from '../alerts/AlertsList'; import { routesToAngularRoutes } from '@/lib/utils'; +import PromiseRejectionError from '@/lib/promise-rejection-error'; const defaultNameBuilder = templateBuilder('<%= query.name %>: <%= options.column %> <%= options.op %> <%= options.value %>'); @@ -137,6 +138,10 @@ class AlertPage extends React.Component { }); this.onQuerySelected(alert.query); } + }).catch((err) => { + if (this._isMounted) { + throw new PromiseRejectionError(err); + } }); } } From 8f378ed591ae68b6ca2b0c77122e7fad3b8b95b8 Mon Sep 17 00:00:00 2001 From: Ran Byron Date: Sat, 5 Oct 2019 13:16:39 +0300 Subject: [PATCH 12/13] pep8 fixes --- redash/destinations/chatwork.py | 9 +++++---- redash/destinations/email.py | 3 ++- redash/destinations/mattermost.py | 11 ++++++----- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/redash/destinations/chatwork.py b/redash/destinations/chatwork.py index 4ea13a20d0..d80379998c 100644 --- a/redash/destinations/chatwork.py +++ b/redash/destinations/chatwork.py @@ -41,7 +41,6 @@ def notify(self, alert, query, user, new_state, app, host, options): message = '' if alert.custom_subject: message = alert.custom_subject + '\n' - if alert.custom_body: message += alert.custom_body else: @@ -49,9 +48,11 @@ def notify(self, alert, query, user, new_state, app, host, options): query_url = '{host}/queries/{query_id}'.format(host=host, query_id=query.id) message_template = options.get('message_template', ChatWork.ALERTS_DEFAULT_MESSAGE_TEMPLATE) message += message_template.replace('\\n', '\n').format( - alert_name=alert.name, new_state=new_state.upper(), - alert_url=alert_url, - query_url=query_url) + alert_name=alert.name, + new_state=new_state.upper(), + alert_url=alert_url, + query_url=query_url + ) headers = {'X-ChatWorkToken': options.get('api_token')} payload = {'body': message} diff --git a/redash/destinations/email.py b/redash/destinations/email.py index 985014c415..52ad61fabe 100644 --- a/redash/destinations/email.py +++ b/redash/destinations/email.py @@ -38,7 +38,8 @@ def notify(self, alert, query, user, new_state, app, host, options): html = alert.custom_body else: html = """ - Check alert / check query
    . + Check alert / check + query
    . """.format(host=host, alert_id=alert.id, query_id=query.id) logging.debug("Notifying: %s", recipients) diff --git a/redash/destinations/mattermost.py b/redash/destinations/mattermost.py index 573cb7cf21..2765f4fe55 100644 --- a/redash/destinations/mattermost.py +++ b/redash/destinations/mattermost.py @@ -35,8 +35,6 @@ def icon(cls): return 'fa-bolt' def notify(self, alert, query, user, new_state, app, host, options): - - if alert.custom_subject: text = alert.custom_subject elif new_state == "triggered": @@ -51,9 +49,12 @@ def notify(self, alert, query, user, new_state, app, host, options): "value": alert.custom_body }]}] - if options.get('username'): payload['username'] = options.get('username') - if options.get('icon_url'): payload['icon_url'] = options.get('icon_url') - if options.get('channel'): payload['channel'] = options.get('channel') + if options.get('username'): + payload['username'] = options.get('username') + if options.get('icon_url'): + payload['icon_url'] = options.get('icon_url') + if options.get('channel'): + payload['channel'] = options.get('channel') try: resp = requests.post(options.get('url'), data=json_dumps(payload), timeout=5.0) From be404a466949ad7a22fd4366df078c10edf0bacb Mon Sep 17 00:00:00 2001 From: Ran Byron Date: Mon, 7 Oct 2019 11:45:59 +0300 Subject: [PATCH 13/13] Separated Alert to View / Edit / New page components (#4183) * Separated Alert to View / Edit / New page components * Moved query result loader into * Added missing Query prop * No reload for edit / view / new state changes * Added warn and tooltip when non-editable * Rebased --- client/app/assets/less/inc/alert.less | 2 +- client/app/components/proptypes.js | 2 +- client/app/pages/alert/Alert.jsx | 370 +++++------------- client/app/pages/alert/AlertEdit.jsx | 147 +++++++ client/app/pages/alert/AlertNew.jsx | 109 ++++++ client/app/pages/alert/AlertView.jsx | 135 +++++++ .../app/pages/alert/components/Criteria.jsx | 9 +- .../alert/components/HorizontalFormItem.jsx | 32 ++ client/app/pages/alert/components/Query.jsx | 18 +- client/app/pages/alert/components/Title.jsx | 38 ++ 10 files changed, 576 insertions(+), 286 deletions(-) create mode 100644 client/app/pages/alert/AlertEdit.jsx create mode 100644 client/app/pages/alert/AlertNew.jsx create mode 100644 client/app/pages/alert/AlertView.jsx create mode 100644 client/app/pages/alert/components/HorizontalFormItem.jsx create mode 100644 client/app/pages/alert/components/Title.jsx diff --git a/client/app/assets/less/inc/alert.less b/client/app/assets/less/inc/alert.less index 0c3d89f9d1..009d4e0cba 100755 --- a/client/app/assets/less/inc/alert.less +++ b/client/app/assets/less/inc/alert.less @@ -2,7 +2,7 @@ flex-grow: 1; input { - margin: -0.2em 0; // + margin: -0.2em 0; width: 100%; min-width: 170px; } diff --git a/client/app/components/proptypes.js b/client/app/components/proptypes.js index 41151d388e..814d128b08 100644 --- a/client/app/components/proptypes.js +++ b/client/app/components/proptypes.js @@ -126,7 +126,7 @@ export const Alert = PropTypes.shape({ rearm: PropTypes.number, state: PropTypes.oneOf(['ok', 'triggered', 'unknown']), user: UserProfile, - query: Query.isRequired, + query: Query, options: PropTypes.shape({ column: PropTypes.string, op: PropTypes.string, diff --git a/client/app/pages/alert/Alert.jsx b/client/app/pages/alert/Alert.jsx index 917a55ac6b..69721990d1 100644 --- a/client/app/pages/alert/Alert.jsx +++ b/client/app/pages/alert/Alert.jsx @@ -1,8 +1,6 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { react2angular } from 'react2angular'; -import { head, includes, template as templateBuilder, trim } from 'lodash'; -import cx from 'classnames'; +import { head, includes, trim, template } from 'lodash'; import { $route } from '@/services/ng'; import { currentUser } from '@/services/auth'; @@ -11,91 +9,31 @@ import notification from '@/services/notification'; import { Alert as AlertService } from '@/services/alert'; import { Query as QueryService } from '@/services/query'; -import { HelpTrigger } from '@/components/HelpTrigger'; import LoadingState from '@/components/items-list/components/LoadingState'; -import { TimeAgo } from '@/components/TimeAgo'; +import AlertView from './AlertView'; +import AlertEdit from './AlertEdit'; +import AlertNew from './AlertNew'; -import Form from 'antd/lib/form'; -import Button from 'antd/lib/button'; -import Tooltip from 'antd/lib/tooltip'; -import Icon from 'antd/lib/icon'; import Modal from 'antd/lib/modal'; -import Input from 'antd/lib/input'; -import Dropdown from 'antd/lib/dropdown'; -import Menu from 'antd/lib/menu'; - -import Criteria from './components/Criteria'; -import NotificationTemplate from './components/NotificationTemplate'; -import Rearm from './components/Rearm'; -import Query from './components/Query'; -import AlertDestinations from './components/AlertDestinations'; -import { STATE_CLASS } from '../alerts/AlertsList'; + import { routesToAngularRoutes } from '@/lib/utils'; import PromiseRejectionError from '@/lib/promise-rejection-error'; - -const defaultNameBuilder = templateBuilder('<%= query.name %>: <%= options.column %> <%= options.op %> <%= options.value %>'); -const spinnerIcon = ; - -function isNewAlert() { - return $route.current.params.alertId === 'new'; -} - -function HorizontalFormItem({ children, label, className, ...props }) { - const labelCol = { span: 4 }; - const wrapperCol = { span: 16 }; - if (!label) { - wrapperCol.offset = 4; - } - - className = cx('alert-form-item', className); - - return ( - - { children } - - ); -} - -HorizontalFormItem.propTypes = { - children: PropTypes.node, - label: PropTypes.string, - className: PropTypes.string, +const MODES = { + NEW: 0, + VIEW: 1, + EDIT: 2, }; -HorizontalFormItem.defaultProps = { - children: null, - label: null, - className: null, -}; +const defaultNameBuilder = template('<%= query.name %>: <%= options.column %> <%= options.op %> <%= options.value %>'); -function AlertState({ state, lastTriggered }) { - return ( -
    - Status: {state} - {state === 'unknown' && ( -
    - Alert condition has not been evaluated. -
    - )} - {lastTriggered && ( -
    - Last triggered -
    - )} -
    - ); +export function getDefaultName(alert) { + if (!alert.query) { + return 'New Alert'; + } + return defaultNameBuilder(alert); } -AlertState.propTypes = { - state: PropTypes.string.isRequired, - lastTriggered: PropTypes.string, -}; - -AlertState.defaultProps = { - lastTriggered: null, -}; - class AlertPage extends React.Component { _isMounted = false; @@ -103,39 +41,43 @@ class AlertPage extends React.Component { alert: null, queryResult: null, pendingRearm: null, - editMode: false, canEdit: false, - saving: false, - canceling: false, + mode: null, } componentDidMount() { this._isMounted = true; + const { mode } = $route.current.locals; + this.setState({ mode }); - if (isNewAlert()) { + if (mode === MODES.NEW) { this.setState({ alert: new AlertService({ options: { op: 'greater than', value: 1, }, - pendingRearm: 0, }), - editMode: true, + pendingRearm: 0, canEdit: true, }); } else { const { alertId } = $route.current.params; - const { editMode } = $route.current.locals; AlertService.get({ id: alertId }).$promise.then((alert) => { - const canEdit = currentUser.canEdit(alert); if (this._isMounted) { - this.setState({ - alert, - pendingRearm: alert.rearm, - editMode: editMode && canEdit, - canEdit, - }); + const canEdit = currentUser.canEdit(alert); + + // force view mode if can't edit + if (!canEdit) { + this.setState({ mode: MODES.VIEW }); + notification.warn( + 'You cannot edit this alert', + 'You do not have sufficient permissions to edit this alert, and have been redirected to the view-only page.', + { duration: 0 }, + ); + } + + this.setState({ alert, canEdit, pendingRearm: alert.rearm }); this.onQuerySelected(alert.query); } }).catch((err) => { @@ -150,13 +92,20 @@ class AlertPage extends React.Component { this._isMounted = false; } - getDefaultName = () => { - const { alert } = this.state; - if (!alert.query) { - return 'New Alert'; - } - return defaultNameBuilder(alert); - } + save = () => { + const { alert, pendingRearm } = this.state; + + alert.name = trim(alert.name) || getDefaultName(alert); + alert.rearm = pendingRearm || null; + + return alert.$save().then(() => { + notification.success('Saved.'); + navigateTo(`/alerts/${alert.id}`, true, false); + this.setState({ mode: MODES.VIEW }); + }).catch(() => { + notification.error('Failed saving alert.'); + }); + }; onQuerySelected = (query) => { this.setState(({ alert }) => ({ @@ -182,6 +131,13 @@ class AlertPage extends React.Component { } } + onNameChange = (name) => { + const { alert } = this.state; + this.setState({ + alert: Object.assign(alert, { name }), + }); + } + onRearmChange = (pendingRearm) => { this.setState({ pendingRearm }); } @@ -194,54 +150,13 @@ class AlertPage extends React.Component { }); } - setName = (name) => { - const { alert } = this.state; - this.setState({ - alert: Object.assign(alert, { name }), - }); - } - - edit = () => { - const { id } = this.state.alert; - navigateTo(`/alerts/${id}/edit`, true); - } - - save = () => { - const { alert, pendingRearm } = this.state; - - alert.name = trim(alert.name) || this.getDefaultName(); - alert.rearm = pendingRearm || null; - - this.setState({ saving: true, alert }); - - alert.$save().then(() => { - if (isNewAlert()) { - notification.success('Created new Alert.'); - } else { - notification.success('Saved.'); - } - navigateTo(`/alerts/${alert.id}`, true); - }).catch(() => { - notification.error('Failed saving alert.'); - if (this._isMounted) { - this.setState({ saving: false }); - } - }); - }; - - cancel = () => { - const { alert } = this.state; - this.setState({ canceling: true }); - navigateTo(`/alerts/${alert.id}`, true); - }; - delete = () => { const { alert } = this.state; const doDelete = () => { alert.$delete(() => { notification.success('Alert deleted successfully.'); - navigateTo('/alerts', true); + navigateTo('/alerts'); }, () => { notification.error('Failed deleting alert.'); }); @@ -258,150 +173,43 @@ class AlertPage extends React.Component { }); } + edit = () => { + const { id } = this.state.alert; + navigateTo(`/alerts/${id}/edit`, true, false); + this.setState({ mode: MODES.EDIT }); + } + + cancel = () => { + const { id } = this.state.alert; + navigateTo(`/alerts/${id}`, true, false); + this.setState({ mode: MODES.VIEW }); + } + render() { const { alert } = this.state; if (!alert) { return ; } - const isNew = isNewAlert(); - const { query, name, options } = alert; - const { queryResult, editMode, pendingRearm, canEdit, saving, canceling } = this.state; + const { queryResult, mode, canEdit, pendingRearm } = this.state; + const commonProps = { + alert, + queryResult, + pendingRearm, + delete: this.delete, + save: this.save, + onQuerySelected: this.onQuerySelected, + onRearmChange: this.onRearmChange, + onNameChange: this.onNameChange, + onCriteriaChange: this.setAlertOptions, + onNotificationTemplateChange: this.setAlertOptions, + }; return (
    -
    -
    -

    - {editMode && query ? ( - this.setName(e.target.value)} /> - ) : name || this.getDefaultName() } -

    - - {editMode && ( - <> - {!isNew && ( - <> - - - - )} - - )} - {!editMode && canEdit && ( - - )} - {canEdit && !isNew && ( - - - this.delete()}>Delete Alert - - - )} - > - - - )} - -
    -
    -
    -
    -
    - {isNew && ( -
    - Start by selecting the query that you would like to monitor using the search bar. -
    - Keep in mind that Alerts do not work with queries that use parameters. -
    - )} - {!editMode && ( - - - - )} - - - - {query && !queryResult && ( - - Loading query data - - )} - {queryResult && options && ( - <> - - - - {editMode ? ( - <> - - - - - this.setAlertOptions({ custom_subject: subject })} - body={options.custom_body} - setBody={body => this.setAlertOptions({ custom_body: body })} - /> - - - ) : ( - - -
    - Set to {options.custom_subject || options.custom_body ? 'custom' : 'default'} notification template. -
    - )} - - )} - {isNew && ( - - - - )} -
    - {editMode && ( - - Setup Instructions - - )} -
    - {!editMode && alert.id && ( -
    -

    Destinations{' '} - - - - - -

    - -
    - )} -
    + {mode === MODES.NEW && } + {mode === MODES.VIEW && } + {mode === MODES.EDIT && }
    ); } @@ -411,14 +219,20 @@ export default function init(ngModule) { ngModule.component('alertPage', react2angular(AlertPage)); return routesToAngularRoutes([ + { + path: '/alerts/new', + title: 'New Alert', + mode: MODES.NEW, + }, { path: '/alerts/:alertId', title: 'Alert', - editMode: false, - }, { + mode: MODES.VIEW, + }, + { path: '/alerts/:alertId/edit', title: 'Alert', - editMode: true, + mode: MODES.EDIT, }, ], { template: '', diff --git a/client/app/pages/alert/AlertEdit.jsx b/client/app/pages/alert/AlertEdit.jsx new file mode 100644 index 0000000000..0caa1cc0f9 --- /dev/null +++ b/client/app/pages/alert/AlertEdit.jsx @@ -0,0 +1,147 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { HelpTrigger } from '@/components/HelpTrigger'; +import { Alert as AlertType } from '@/components/proptypes'; + +import Form from 'antd/lib/form'; +import Button from 'antd/lib/button'; +import Icon from 'antd/lib/icon'; +import Dropdown from 'antd/lib/dropdown'; +import Menu from 'antd/lib/menu'; + +import Title from './components/Title'; +import Criteria from './components/Criteria'; +import NotificationTemplate from './components/NotificationTemplate'; +import Rearm from './components/Rearm'; +import Query from './components/Query'; + +import HorizontalFormItem from './components/HorizontalFormItem'; + +const spinnerIcon = ; + +export default class AlertEdit extends React.Component { + _isMounted = false; + + state = { + saving: false, + canceling: false, + } + + componentDidMount() { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + } + + save = () => { + this.setState({ saving: true }); + this.props.save().catch(() => { + if (this._isMounted) { + this.setState({ saving: false }); + } + }); + } + + cancel = () => { + this.setState({ canceling: true }); + this.props.cancel(); + }; + + render() { + const { alert, queryResult, pendingRearm, onNotificationTemplateChange } = this.props; + const { onQuerySelected, onNameChange, onRearmChange, onCriteriaChange } = this.props; + const { query, name, options } = alert; + const { saving, canceling } = this.state; + + return ( + <> + + <Button className="m-r-5" onClick={() => this.cancel()}> + {canceling ? spinnerIcon : <i className="fa fa-times m-r-5" />} + Cancel + </Button> + <Button type="primary" onClick={() => this.save()}> + {saving ? spinnerIcon : <i className="fa fa-check m-r-5" />} + Save Changes + </Button> + <Dropdown + className="m-l-5" + trigger={['click']} + placement="bottomRight" + overlay={( + <Menu> + <Menu.Item> + <a onClick={this.props.delete}>Delete Alert</a> + </Menu.Item> + </Menu> + )} + > + <Button><Icon type="ellipsis" rotate={90} /></Button> + </Dropdown> + +
    +
    +
    + + + + {queryResult && options && ( + <> + + + + + + + + onNotificationTemplateChange({ custom_subject: subject })} + body={options.custom_body} + setBody={body => onNotificationTemplateChange({ custom_body: body })} + /> + + + )} +
    + + Setup Instructions + +
    +
    + + ); + } +} + +AlertEdit.propTypes = { + alert: AlertType.isRequired, + queryResult: PropTypes.object, // eslint-disable-line react/forbid-prop-types, + pendingRearm: PropTypes.number, + delete: PropTypes.func.isRequired, + save: PropTypes.func.isRequired, + cancel: PropTypes.func.isRequired, + onQuerySelected: PropTypes.func.isRequired, + onNameChange: PropTypes.func.isRequired, + onCriteriaChange: PropTypes.func.isRequired, + onRearmChange: PropTypes.func.isRequired, + onNotificationTemplateChange: PropTypes.func.isRequired, +}; + +AlertEdit.defaultProps = { + queryResult: null, + pendingRearm: null, +}; diff --git a/client/app/pages/alert/AlertNew.jsx b/client/app/pages/alert/AlertNew.jsx new file mode 100644 index 0000000000..26b18f9f66 --- /dev/null +++ b/client/app/pages/alert/AlertNew.jsx @@ -0,0 +1,109 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { HelpTrigger } from '@/components/HelpTrigger'; +import { Alert as AlertType } from '@/components/proptypes'; + +import Form from 'antd/lib/form'; +import Button from 'antd/lib/button'; + +import Title from './components/Title'; +import Criteria from './components/Criteria'; +import NotificationTemplate from './components/NotificationTemplate'; +import Rearm from './components/Rearm'; +import Query from './components/Query'; +import HorizontalFormItem from './components/HorizontalFormItem'; + +export default class AlertNew extends React.Component { + state = { + saving: false, + }; + + save = () => { + this.setState({ saving: true }); + this.props.save().catch(() => { + this.setState({ saving: false }); + }); + } + + render() { + const { alert, queryResult, pendingRearm, onNotificationTemplateChange } = this.props; + const { onQuerySelected, onNameChange, onRearmChange, onCriteriaChange } = this.props; + const { query, name, options } = alert; + const { saving } = this.state; + + return ( + <> + + <div className="row bg-white tiled p-20"> + <div className="d-flex"> + <Form className="flex-fill"> + <div className="m-b-30"> + Start by selecting the query that you would like to monitor using the search bar. + <br /> + Keep in mind that Alerts do not work with queries that use parameters. + </div> + <HorizontalFormItem label="Query"> + <Query query={query} queryResult={queryResult} onChange={onQuerySelected} editMode /> + </HorizontalFormItem> + {queryResult && options && ( + <> + <HorizontalFormItem label="Trigger when" className="alert-criteria"> + <Criteria + columnNames={queryResult.getColumnNames()} + resultValues={queryResult.getData()} + alertOptions={options} + onChange={onCriteriaChange} + editMode + /> + </HorizontalFormItem> + <HorizontalFormItem label="When triggered, send notification"> + <Rearm value={pendingRearm || 0} onChange={onRearmChange} editMode /> + </HorizontalFormItem> + <HorizontalFormItem label="Template"> + <NotificationTemplate + alert={alert} + query={query} + columnNames={queryResult.getColumnNames()} + resultValues={queryResult.getData()} + subject={options.custom_subject} + setSubject={subject => onNotificationTemplateChange({ custom_subject: subject })} + body={options.custom_body} + setBody={body => onNotificationTemplateChange({ custom_body: body })} + /> + </HorizontalFormItem> + </> + )} + <HorizontalFormItem> + <Button type="primary" onClick={this.save} disabled={!query} className="btn-create-alert"> + {saving && <i className="fa fa-spinner fa-pulse m-r-5" />} + Create Alert + </Button> + </HorizontalFormItem> + </Form> + <HelpTrigger className="f-13" type="ALERT_SETUP"> + Setup Instructions <i className="fa fa-question-circle" /> + </HelpTrigger> + </div> + </div> + </> + ); + } +} + +AlertNew.propTypes = { + alert: AlertType.isRequired, + queryResult: PropTypes.object, // eslint-disable-line react/forbid-prop-types, + pendingRearm: PropTypes.number, + onQuerySelected: PropTypes.func.isRequired, + save: PropTypes.func.isRequired, + onNameChange: PropTypes.func.isRequired, + onRearmChange: PropTypes.func.isRequired, + onCriteriaChange: PropTypes.func.isRequired, + onNotificationTemplateChange: PropTypes.func.isRequired, +}; + +AlertNew.defaultProps = { + queryResult: null, + pendingRearm: null, +}; diff --git a/client/app/pages/alert/AlertView.jsx b/client/app/pages/alert/AlertView.jsx new file mode 100644 index 0000000000..22fb40343b --- /dev/null +++ b/client/app/pages/alert/AlertView.jsx @@ -0,0 +1,135 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; + +import { TimeAgo } from '@/components/TimeAgo'; +import { Alert as AlertType } from '@/components/proptypes'; + +import Form from 'antd/lib/form'; +import Button from 'antd/lib/button'; +import Icon from 'antd/lib/icon'; +import Dropdown from 'antd/lib/dropdown'; +import Menu from 'antd/lib/menu'; +import Tooltip from 'antd/lib/tooltip'; + +import Title from './components/Title'; +import Criteria from './components/Criteria'; +import Rearm from './components/Rearm'; +import Query from './components/Query'; +import AlertDestinations from './components/AlertDestinations'; +import HorizontalFormItem from './components/HorizontalFormItem'; +import { STATE_CLASS } from '../alerts/AlertsList'; + + +function AlertState({ state, lastTriggered }) { + return ( + <div className="alert-state"> + <span className={`alert-state-indicator label ${STATE_CLASS[state]}`}>Status: {state}</span> + {state === 'unknown' && ( + <div className="ant-form-explain"> + Alert condition has not been evaluated. + </div> + )} + {lastTriggered && ( + <div className="ant-form-explain"> + Last triggered <span className="alert-last-triggered"><TimeAgo date={lastTriggered} /></span> + </div> + )} + </div> + ); +} + +AlertState.propTypes = { + state: PropTypes.string.isRequired, + lastTriggered: PropTypes.string, +}; + +AlertState.defaultProps = { + lastTriggered: null, +}; + +export default class AlertView extends React.Component { + render() { + const { alert, queryResult, canEdit, onEdit } = this.props; + const { query, name, options, rearm } = alert; + + return ( + <> + <Title name={name} alert={alert}> + <Tooltip title={canEdit ? '' : 'You do not have sufficient permissions to edit this alert'}> + <Button type="default" onClick={canEdit ? onEdit : null} className={cx({ disabled: !canEdit })}><i className="fa fa-edit m-r-5" />Edit</Button> + <Dropdown + className={cx('m-l-5', { disabled: !canEdit })} + trigger={[canEdit ? 'click' : undefined]} + placement="bottomRight" + overlay={( + <Menu> + <Menu.Item> + <a onClick={this.props.delete}>Delete Alert</a> + </Menu.Item> + </Menu> + )} + > + <Button><Icon type="ellipsis" rotate={90} /></Button> + </Dropdown> + </Tooltip> + +
    +
    +
    + + + + + + + {query && !queryResult && ( + + Loading query data + + )} + {queryResult && options && ( + <> + + + + + +
    + Set to {options.custom_subject || options.custom_body ? 'custom' : 'default'} notification template. +
    + + )} +
    +
    +
    +

    Destinations{' '} + + + + + +

    + +
    +
    + + ); + } +} + +AlertView.propTypes = { + alert: AlertType.isRequired, + queryResult: PropTypes.object, // eslint-disable-line react/forbid-prop-types, + canEdit: PropTypes.bool.isRequired, + delete: PropTypes.func.isRequired, + onEdit: PropTypes.func.isRequired, +}; + +AlertView.defaultProps = { + queryResult: null, +}; diff --git a/client/app/pages/alert/components/Criteria.jsx b/client/app/pages/alert/components/Criteria.jsx index 49baf7a939..ac1348156d 100644 --- a/client/app/pages/alert/components/Criteria.jsx +++ b/client/app/pages/alert/components/Criteria.jsx @@ -118,6 +118,11 @@ Criteria.propTypes = { columnNames: PropTypes.arrayOf(PropTypes.string).isRequired, resultValues: PropTypes.arrayOf(PropTypes.object).isRequired, alertOptions: AlertOptionsType.isRequired, - onChange: PropTypes.func.isRequired, - editMode: PropTypes.bool.isRequired, + onChange: PropTypes.func, + editMode: PropTypes.bool, +}; + +Criteria.defaultProps = { + onChange: () => {}, + editMode: false, }; diff --git a/client/app/pages/alert/components/HorizontalFormItem.jsx b/client/app/pages/alert/components/HorizontalFormItem.jsx new file mode 100644 index 0000000000..0ad5f809ff --- /dev/null +++ b/client/app/pages/alert/components/HorizontalFormItem.jsx @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import Form from 'antd/lib/form'; + +export default function HorizontalFormItem({ children, label, className, ...props }) { + const labelCol = { span: 4 }; + const wrapperCol = { span: 16 }; + if (!label) { + wrapperCol.offset = 4; + } + + className = cx('alert-form-item', className); + + return ( + + { children } + + ); +} + +HorizontalFormItem.propTypes = { + children: PropTypes.node, + label: PropTypes.string, + className: PropTypes.string, +}; + +HorizontalFormItem.defaultProps = { + children: null, + label: null, + className: null, +}; diff --git a/client/app/pages/alert/components/Query.jsx b/client/app/pages/alert/components/Query.jsx index a50662e1d9..1ad056e57c 100644 --- a/client/app/pages/alert/components/Query.jsx +++ b/client/app/pages/alert/components/Query.jsx @@ -3,13 +3,14 @@ import PropTypes from 'prop-types'; import { QuerySelector } from '@/components/QuerySelector'; import { SchedulePhrase } from '@/components/queries/SchedulePhrase'; +import { Query as QueryType } from '@/components/proptypes'; import Tooltip from 'antd/lib/tooltip'; import Icon from 'antd/lib/icon'; import './Query.less'; -export default function QueryFormItem({ query, onChange, editMode }) { +export default function QueryFormItem({ query, queryResult, onChange, editMode }) { const queryHint = query && query.schedule ? ( Scheduled to refresh @@ -41,16 +42,25 @@ export default function QueryFormItem({ query, onChange, editMode }) {
    {query && queryHint}
    + {query && !queryResult && ( +
    + Loading query data +
    + )} ); } QueryFormItem.propTypes = { - query: PropTypes.object, // eslint-disable-line react/forbid-prop-types - onChange: PropTypes.func.isRequired, - editMode: PropTypes.bool.isRequired, + query: QueryType, + queryResult: PropTypes.object, // eslint-disable-line react/forbid-prop-types + onChange: PropTypes.func, + editMode: PropTypes.bool, }; QueryFormItem.defaultProps = { query: null, + queryResult: null, + onChange: () => {}, + editMode: false, }; diff --git a/client/app/pages/alert/components/Title.jsx b/client/app/pages/alert/components/Title.jsx new file mode 100644 index 0000000000..a11ccc97ef --- /dev/null +++ b/client/app/pages/alert/components/Title.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Input from 'antd/lib/input'; +import { getDefaultName } from '../Alert'; + +import { Alert as AlertType } from '@/components/proptypes'; + + +export default function Title({ alert, editMode, name, onChange, children }) { + const defaultName = getDefaultName(alert); + return ( +
    +
    +

    + {editMode && alert.query ? ( + onChange(e.target.value)} /> + ) : name || defaultName } +

    + { children } +
    +
    + ); +} + +Title.propTypes = { + alert: AlertType.isRequired, + name: PropTypes.string, + children: PropTypes.node, + onChange: PropTypes.func, + editMode: PropTypes.bool, +}; + +Title.defaultProps = { + name: null, + children: null, + onChange: null, + editMode: false, +};