From 1376ff7ad6b44d44040ef5cb275da3091cb01ed4 Mon Sep 17 00:00:00 2001 From: catalintomai <56169943+catalintomai@users.noreply.github.com> Date: Wed, 22 Jul 2020 20:28:50 -0700 Subject: [PATCH] Add metric warning support (announcement bar and DM) (#5447) * Admin Advisory: Add warning for number of active users metric status Co-authored-by: Catalin Tomai --- actions/websocket_actions.jsx | 25 ++ .../announcement_bar.test.jsx.snap | 30 +++ .../announcement_bar_controller.jsx | 2 + .../configuration_bar/configuration_bar.jsx | 100 ++++++- .../configuration_bar/index.js | 2 + .../announcement_bar.jsx | 43 ++- components/announcement_bar/index.js | 7 +- .../__snapshots__/action_button.test.jsx.snap | 25 +- .../action_button/action_button.jsx | 26 +- .../message_attachment.test.jsx.snap | 6 + .../message_attachment/message_attachment.jsx | 44 +++- .../message_attachment.test.jsx | 3 +- components/root/index.js | 4 +- components/root/root.jsx | 3 + components/root/root.test.jsx | 4 + .../warn_metric_ack_modal.test.tsx.snap | 205 +++++++++++++++ components/warn_metric_ack_modal/index.ts | 57 ++++ .../warn_metric_ack_modal.test.tsx | 103 ++++++++ .../warn_metric_ack_modal.tsx | 244 ++++++++++++++++++ i18n/en.json | 9 + images/error-icon.png | Bin 0 -> 573 bytes images/icons/check-circle-outline.svg | 1 + images/icons/error-icon.svg | 10 + images/icons/round-error-icon.svg | 10 + images/icons/round-info-icon.svg | 10 + images/icons/round-white-info-icon.svg | 10 + package-lock.json | 4 +- package.json | 2 +- reducers/views/notice.js | 2 + sass/base/_typography.scss | 12 + sass/components/_announcement-bar.scss | 19 ++ utils/constants.jsx | 14 + 32 files changed, 1004 insertions(+), 32 deletions(-) create mode 100644 components/warn_metric_ack_modal/__snapshots__/warn_metric_ack_modal.test.tsx.snap create mode 100644 components/warn_metric_ack_modal/index.ts create mode 100644 components/warn_metric_ack_modal/warn_metric_ack_modal.test.tsx create mode 100644 components/warn_metric_ack_modal/warn_metric_ack_modal.tsx create mode 100644 images/error-icon.png create mode 100644 images/icons/check-circle-outline.svg create mode 100644 images/icons/error-icon.svg create mode 100644 images/icons/round-error-icon.svg create mode 100644 images/icons/round-info-icon.svg create mode 100644 images/icons/round-white-info-icon.svg diff --git a/actions/websocket_actions.jsx b/actions/websocket_actions.jsx index 030863a44f2f..e0fd1c42ce8d 100644 --- a/actions/websocket_actions.jsx +++ b/actions/websocket_actions.jsx @@ -449,6 +449,14 @@ export function handleEvent(msg) { handleGroupNotAssociatedToChannelEvent(msg); break; + case SocketEvents.WARN_METRIC_STATUS_RECEIVED: + handleWarnMetricStatusReceivedEvent(msg); + break; + + case SocketEvents.WARN_METRIC_STATUS_REMOVED: + handleWarnMetricStatusRemovedEvent(msg); + break; + default: } @@ -1220,3 +1228,20 @@ function handleGroupNotAssociatedToChannelEvent(msg) { data: {channelID: msg.broadcast.channel_id, groups: [{id: msg.data.group_id}]}, }); } + +function handleWarnMetricStatusReceivedEvent(msg) { + store.dispatch(batchActions([ + { + type: GeneralTypes.WARN_METRIC_STATUS_RECEIVED, + data: JSON.parse(msg.data.warnMetricStatus), + }, + { + type: ActionTypes.SHOW_NOTICE, + data: [AnnouncementBarMessages.NUMBER_OF_ACTIVE_USERS_WARN_METRIC_STATUS], + }, + ])); +} + +function handleWarnMetricStatusRemovedEvent(msg) { + store.dispatch({type: GeneralTypes.WARN_METRIC_STATUS_REMOVED, data: {id: msg.data.warnMetricId}}); +} diff --git a/components/announcement_bar/__snapshots__/announcement_bar.test.jsx.snap b/components/announcement_bar/__snapshots__/announcement_bar.test.jsx.snap index 1e6e6d54da5a..0d339dc23048 100644 --- a/components/announcement_bar/__snapshots__/announcement_bar.test.jsx.snap +++ b/components/announcement_bar/__snapshots__/announcement_bar.test.jsx.snap @@ -31,6 +31,9 @@ exports[`components/AnnouncementBar should match snapshot, bar not showing 1`] = + @@ -67,6 +70,9 @@ exports[`components/AnnouncementBar should match snapshot, bar showing 1`] = ` + @@ -103,6 +109,9 @@ exports[`components/AnnouncementBar should match snapshot, bar showing, no dismi + @@ -139,6 +148,9 @@ exports[`components/AnnouncementBar should match snapshot, dismissal 1`] = ` + @@ -175,6 +187,9 @@ exports[`components/AnnouncementBar should match snapshot, dismissal 2`] = ` + @@ -211,6 +226,9 @@ exports[`components/AnnouncementBar should match snapshot, dismissal 3`] = ` + @@ -247,6 +265,9 @@ exports[`components/AnnouncementBar should match snapshot, props change 1`] = ` + @@ -283,6 +304,9 @@ exports[`components/AnnouncementBar should match snapshot, props change 2`] = ` + @@ -319,6 +343,9 @@ exports[`components/AnnouncementBar should match snapshot, props change 3`] = ` + @@ -355,6 +382,9 @@ exports[`components/AnnouncementBar should match snapshot, props change 4`] = ` + diff --git a/components/announcement_bar/announcement_bar_controller.jsx b/components/announcement_bar/announcement_bar_controller.jsx index 4aad064f5189..a32a21878431 100644 --- a/components/announcement_bar/announcement_bar_controller.jsx +++ b/components/announcement_bar/announcement_bar_controller.jsx @@ -20,6 +20,7 @@ export default class AnnouncementBarController extends React.PureComponent { canViewSystemErrors: PropTypes.bool.isRequired, latestError: PropTypes.object, totalUsers: PropTypes.number, + warnMetricsStatus: PropTypes.object, actions: PropTypes.shape({ dismissError: PropTypes.func.isRequired, }).isRequired, @@ -61,6 +62,7 @@ export default class AnnouncementBarController extends React.PureComponent { canViewSystemErrors={this.props.canViewSystemErrors} totalUsers={this.props.totalUsers} user={this.props.user} + warnMetricsStatus={this.props.warnMetricsStatus} /> ); diff --git a/components/announcement_bar/configuration_bar/configuration_bar.jsx b/components/announcement_bar/configuration_bar/configuration_bar.jsx index 31f5376815da..ffe39b3fc26a 100644 --- a/components/announcement_bar/configuration_bar/configuration_bar.jsx +++ b/components/announcement_bar/configuration_bar/configuration_bar.jsx @@ -7,7 +7,7 @@ import PropTypes from 'prop-types'; import {FormattedMessage, injectIntl} from 'react-intl'; import {isLicenseExpired, isLicenseExpiring, isLicensePastGracePeriod} from 'utils/license_utils.jsx'; -import {AnnouncementBarTypes, AnnouncementBarMessages} from 'utils/constants'; +import {AnnouncementBarTypes, AnnouncementBarMessages, WarnMetricTypes} from 'utils/constants'; import {intlShape} from 'utils/react_intl'; import {t} from 'utils/i18n'; @@ -17,6 +17,9 @@ import FormattedMarkdownMessage from 'components/formatted_markdown_message'; import AnnouncementBar from '../default_announcement_bar'; import TextDismissableBar from '../text_dismissable_bar'; +import ackIcon from 'images/icons/check-circle-outline.svg'; +import alertIcon from 'images/icons/round-white-info-icon.svg'; + const RENEWAL_LINK = 'https://mattermost.com/renew/'; class ConfigurationAnnouncementBar extends React.PureComponent { @@ -28,7 +31,10 @@ class ConfigurationAnnouncementBar extends React.PureComponent { canViewSystemErrors: PropTypes.bool.isRequired, totalUsers: PropTypes.number, dismissedExpiringLicense: PropTypes.bool, + dismissedNumberOfActiveUsersWarnMetricStatus: PropTypes.bool, + dismissedNumberOfActiveUsersWarnMetricStatusAck: PropTypes.bool, siteURL: PropTypes.string.isRequired, + warnMetricsStatus: PropTypes.object, actions: PropTypes.shape({ dismissNotice: PropTypes.func.isRequired, }).isRequired, @@ -38,6 +44,77 @@ class ConfigurationAnnouncementBar extends React.PureComponent { this.props.actions.dismissNotice(AnnouncementBarMessages.LICENSE_EXPIRING); } + dismissNumberOfActiveUsersWarnMetric = () => { + this.props.actions.dismissNotice(AnnouncementBarMessages.NUMBER_OF_ACTIVE_USERS_WARN_METRIC_STATUS); + } + + dismissNumberOfActiveUsersWarnMetricAck = () => { + this.props.actions.dismissNotice(AnnouncementBarMessages.NUMBER_OF_ACTIVE_USERS_WARN_METRIC_STATUS_ACK); + } + + getNoticeForWarnMetric = (warnMetricStatus) => { + if (!warnMetricStatus) { + return null; + } + + var message = ''; + var type = ''; + var showModal = false; + var dismissFunc = null; + var isDismissed = null; + + switch (warnMetricStatus.id) { + case WarnMetricTypes.SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_500: + if (warnMetricStatus.acked) { + message = ( + + + + + ); + type = AnnouncementBarTypes.ADVISOR_ACK; + showModal = false; + dismissFunc = this.dismissNumberOfActiveUsersWarnMetricAck; + isDismissed = this.props.dismissedNumberOfActiveUsersWarnMetricStatusAck; + } else { + message = ( + + + + + ); + type = AnnouncementBarTypes.ADVISOR; + showModal = true; + dismissFunc = this.dismissNumberOfActiveUsersWarnMetric; + isDismissed = this.props.dismissedNumberOfActiveUsersWarnMetricStatus; + } + return { + Message: message, + DismissFunc: dismissFunc, + IsDismissed: isDismissed, + Type: type, + ShowModal: showModal, + }; + default: + return null; + } + } + render() { // System administrators if (this.props.canViewSystemErrors) { @@ -95,6 +172,27 @@ class ConfigurationAnnouncementBar extends React.PureComponent { /> ); } + if (this.props.license.IsLicensed === 'false' && this.props.warnMetricsStatus) { + for (const status of Object.values(this.props.warnMetricsStatus)) { + var notice = this.getNoticeForWarnMetric(status); + if (!notice || notice.IsDismissed) { + continue; + } + + return ( + + ); + } + } } else { // Regular users if (isLicensePastGracePeriod(this.props.license)) { //eslint-disable-line no-lonely-if diff --git a/components/announcement_bar/configuration_bar/index.js b/components/announcement_bar/configuration_bar/index.js index d2d30d56b9df..3c0bc7b3defd 100644 --- a/components/announcement_bar/configuration_bar/index.js +++ b/components/announcement_bar/configuration_bar/index.js @@ -14,6 +14,8 @@ function mapStateToProps(state) { return { siteURL: getSiteURL(state), dismissedExpiringLicense: Boolean(state.views.notice.hasBeenDismissed[AnnouncementBarMessages.LICENSE_EXPIRING]), + dismissedNumberOfActiveUsersWarnMetricStatus: Boolean(state.views.notice.hasBeenDismissed[AnnouncementBarMessages.NUMBER_OF_ACTIVE_USERS_WARN_METRIC_STATUS]), + dismissedNumberOfActiveUsersWarnMetricStatusAck: Boolean(state.views.notice.hasBeenDismissed[AnnouncementBarMessages.NUMBER_OF_ACTIVE_USERS_WARN_METRIC_STATUS_ACK]), }; } diff --git a/components/announcement_bar/default_announcement_bar/announcement_bar.jsx b/components/announcement_bar/default_announcement_bar/announcement_bar.jsx index d9a6e101a92e..7be6c43253b6 100644 --- a/components/announcement_bar/default_announcement_bar/announcement_bar.jsx +++ b/components/announcement_bar/default_announcement_bar/announcement_bar.jsx @@ -2,12 +2,20 @@ // See LICENSE.txt for license information. import React from 'react'; + +import {FormattedMessage} from 'react-intl'; + import PropTypes from 'prop-types'; import {Tooltip} from 'react-bootstrap'; -import {Constants, AnnouncementBarTypes} from 'utils/constants'; +import {Constants, AnnouncementBarTypes, ModalIdentifiers} from 'utils/constants'; + import FormattedMarkdownMessage from 'components/formatted_markdown_message'; import OverlayTrigger from 'components/overlay_trigger'; +import WarnMetricAckModal from 'components/warn_metric_ack_modal'; +import ToggleModalButtonRedux from 'components/toggle_modal_button_redux'; + +import {trackEvent} from 'actions/diagnostics_actions.jsx'; export default class AnnouncementBar extends React.PureComponent { static propTypes = { @@ -18,6 +26,10 @@ export default class AnnouncementBar extends React.PureComponent { message: PropTypes.node.isRequired, handleClose: PropTypes.func, announcementBarCount: PropTypes.number.isRequired, + showModal: PropTypes.bool, + modalButtonText: PropTypes.string, + modalButtonDefaultText: PropTypes.string, + warnMetricStatus: PropTypes.object, actions: PropTypes.shape({ incrementAnnouncementBarCount: PropTypes.func.isRequired, decrementAnnouncementBarCount: PropTypes.func.isRequired, @@ -71,6 +83,10 @@ export default class AnnouncementBar extends React.PureComponent { barClass = 'announcement-bar announcement-bar-critical'; } else if (this.props.type === AnnouncementBarTypes.SUCCESS) { barClass = 'announcement-bar announcement-bar-success'; + } else if (this.props.type === AnnouncementBarTypes.ADVISOR) { + barClass = 'announcement-bar announcement-bar-advisor'; + } else if (this.props.type === AnnouncementBarTypes.ADVISOR_ACK) { + barClass = 'announcement-bar announcement-bar-advisor-ack'; } let closeButton; @@ -93,7 +109,6 @@ export default class AnnouncementBar extends React.PureComponent { ); } - const announcementTooltip = ( {message} @@ -112,6 +127,30 @@ export default class AnnouncementBar extends React.PureComponent { > {message} + + {this.props.showModal && + + {(linkmessage) => ( + trackEvent('admin', 'click_warn_metric_learn_more')} + modalId={ModalIdentifiers.WARN_METRIC_ACK} + dialogProps={{ + warnMetricStatus: this.props.warnMetricStatus, + closeParentComponent: this.props.handleClose, + }} + > + {linkmessage} + + )} + + } + {closeButton} diff --git a/components/announcement_bar/index.js b/components/announcement_bar/index.js index c950f0b04867..5d41c684df47 100644 --- a/components/announcement_bar/index.js +++ b/components/announcement_bar/index.js @@ -3,10 +3,10 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; +import {Permissions} from 'mattermost-redux/constants'; import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; import {haveISystemPermission} from 'mattermost-redux/selectors/entities/roles'; -import {Permissions} from 'mattermost-redux/constants'; -import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; +import {getConfig, getLicense, warnMetricsStatus as getWarnMetricsStatus} from 'mattermost-redux/selectors/entities/general'; import {getDisplayableErrors} from 'mattermost-redux/selectors/errors'; import {dismissError} from 'mattermost-redux/actions/errors'; import {getStandardAnalytics} from 'mattermost-redux/actions/admin'; @@ -21,6 +21,8 @@ function mapStateToProps(state) { const config = getConfig(state); const user = getCurrentUser(state); const errors = getDisplayableErrors(state); + const warnMetricsStatus = getWarnMetricsStatus(state); + const totalUsers = state.entities.admin.analytics.TOTAL_USERS; let latestError = null; if (errors && errors.length >= 1) { @@ -34,6 +36,7 @@ function mapStateToProps(state) { canViewSystemErrors, latestError, totalUsers, + warnMetricsStatus, }; } diff --git a/components/post_view/message_attachments/action_button/__snapshots__/action_button.test.jsx.snap b/components/post_view/message_attachments/action_button/__snapshots__/action_button.test.jsx.snap index 4e7fe1a6ca93..f73dc5ab36b2 100644 --- a/components/post_view/message_attachments/action_button/__snapshots__/action_button.test.jsx.snap +++ b/components/post_view/message_attachments/action_button/__snapshots__/action_button.test.jsx.snap @@ -5,17 +5,22 @@ exports[`components/post_view/message_attachments/action_button.jsx should match data-action-cookie="cookie-contents" data-action-id="action_id_1" key="action_id_1" - onClick={[MockFunction]} + onClick={[Function]} > - + + /> + `; diff --git a/components/post_view/message_attachments/action_button/action_button.jsx b/components/post_view/message_attachments/action_button/action_button.jsx index 191a8ca5e69b..3d06bb1d7bf6 100644 --- a/components/post_view/message_attachments/action_button/action_button.jsx +++ b/components/post_view/message_attachments/action_button/action_button.jsx @@ -5,6 +5,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import {changeOpacity} from 'mattermost-redux/utils/theme_utils'; +import LoadingWrapper from 'components/widgets/loading/loading_wrapper'; import Markdown from 'components/markdown'; export default class ActionButton extends React.PureComponent { @@ -13,6 +14,8 @@ export default class ActionButton extends React.PureComponent { handleAction: PropTypes.func.isRequired, disabled: PropTypes.bool, theme: PropTypes.object.isRequired, + actionExecuting: PropTypes.bool, + actionExecutingMessage: PropTypes.string, } getStatusColors(theme) { @@ -53,17 +56,22 @@ export default class ActionButton extends React.PureComponent { data-action-cookie={action.cookie} disabled={disabled} key={action.id} - onClick={handleAction} + onClick={(e) => handleAction(e, this.props.action.options)} style={customButtonStyle} > - + + + ); } diff --git a/components/post_view/message_attachments/message_attachment/__snapshots__/message_attachment.test.jsx.snap b/components/post_view/message_attachments/message_attachment/__snapshots__/message_attachment.test.jsx.snap index ba933d810fb3..a37ba79961da 100644 --- a/components/post_view/message_attachments/message_attachment/__snapshots__/message_attachment.test.jsx.snap +++ b/components/post_view/message_attachments/message_attachment/__snapshots__/message_attachment.test.jsx.snap @@ -109,6 +109,8 @@ exports[`components/post_view/MessageAttachment should call actions.doPostAction "name": "action_name_1", } } + actionExecuting={false} + actionExecutingMessage={null} handleAction={[Function]} key="action_id_1" /> @@ -664,6 +666,8 @@ exports[`components/post_view/MessageAttachment should match value on renderPost "name": "action_name_1", } } + actionExecuting={false} + actionExecutingMessage={null} handleAction={[Function]} /> , ); break; @@ -145,14 +151,46 @@ export default class MessageAttachment extends React.PureComponent { ); }; - handleAction = (e) => { + handleAction = (e, actionOptions) => { e.preventDefault(); + + var actionExecutingMessage = this.getActionOption(actionOptions, 'ActionExecutingMessage'); + if (actionExecutingMessage) { + this.setState({actionExecuting: true, actionExecutingMessage: actionExecutingMessage.value}); + } + + var trackOption = this.getActionOption(actionOptions, 'TrackEventId'); + if (trackOption) { + trackEvent('admin', 'click_warn_metric_bot_id', {metric: trackOption.value}); + } + const actionId = e.currentTarget.getAttribute('data-action-id'); const actionCookie = e.currentTarget.getAttribute('data-action-cookie'); - this.props.actions.doPostActionWithCookie(this.props.postId, actionId, actionCookie); + this.props.actions.doPostActionWithCookie(this.props.postId, actionId, actionCookie).then(() => { + this.handleCustomActions(actionOptions); + if (actionExecutingMessage) { + this.setState({actionExecuting: false, actionExecutingMessage: null}); + } + }); }; + handleCustomActions = (actionOptions) => { + var extUrlOption = this.getActionOption(actionOptions, 'WarnMetricMailtoUrl'); + if (extUrlOption) { + const mailtoPayload = JSON.parse(extUrlOption.value); + window.location.href = 'mailto:' + mailtoPayload.mail_recipient + '?cc=' + mailtoPayload.mail_cc + '&subject=' + encodeURIComponent(mailtoPayload.mail_subject) + '&body=' + encodeURIComponent(mailtoPayload.mail_body); + } + } + + getActionOption = (actionOptions, optionName) => { + var opt = null; + if (actionOptions) { + opt = actionOptions.find((option) => option.text === optionName); + } + return opt; + } + getFieldsTable = () => { const fields = this.props.attachment.fields; if (!fields || !fields.length) { diff --git a/components/post_view/message_attachments/message_attachment/message_attachment.test.jsx b/components/post_view/message_attachments/message_attachment/message_attachment.test.jsx index 00f9da747b15..936c7d83261b 100644 --- a/components/post_view/message_attachments/message_attachment/message_attachment.test.jsx +++ b/components/post_view/message_attachments/message_attachment/message_attachment.test.jsx @@ -80,7 +80,8 @@ describe('components/post_view/MessageAttachment', () => { }); test('should call actions.doPostActionWithCookie on handleAction', () => { - const doPostActionWithCookie = jest.fn(); + const promise = Promise.resolve(123); + const doPostActionWithCookie = jest.fn(() => promise); const actionId = 'action_id_1'; const newAttachment = { ...attachment, diff --git a/components/root/index.js b/components/root/index.js index 5ea5e8dbd6f1..283650e39f35 100644 --- a/components/root/index.js +++ b/components/root/index.js @@ -6,9 +6,10 @@ import {connect} from 'react-redux'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {shouldShowTermsOfService, getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; - import {getTeam} from 'mattermost-redux/selectors/entities/teams'; +import {getWarnMetricsStatus} from 'mattermost-redux/actions/general'; + import {loadMeAndConfig} from 'actions/views/root'; import LocalStorageStore from 'stores/local_storage_store'; @@ -36,6 +37,7 @@ function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ loadMeAndConfig, + getWarnMetricsStatus, }, dispatch), }; } diff --git a/components/root/root.jsx b/components/root/root.jsx index a767fb20f07d..78f7e5a52b5c 100644 --- a/components/root/root.jsx +++ b/components/root/root.jsx @@ -91,6 +91,7 @@ export default class Root extends React.PureComponent { permalinkRedirectTeamName: PropTypes.string, actions: PropTypes.shape({ loadMeAndConfig: PropTypes.func.isRequired, + getWarnMetricsStatus: PropTypes.func.isRequired, }).isRequired, plugins: PropTypes.array, } @@ -242,6 +243,8 @@ export default class Root extends React.PureComponent { } this.onConfigLoaded(); }); + + this.props.actions.getWarnMetricsStatus(); trackLoadTime(); } diff --git a/components/root/root.test.jsx b/components/root/root.test.jsx index 3f7f6070cfa7..06523bb25524 100644 --- a/components/root/root.test.jsx +++ b/components/root/root.test.jsx @@ -29,6 +29,9 @@ jest.mock('utils/utils', () => ({ enableDevModeFeatures: jest.fn(), })); +jest.mock('mattermost-redux/actions/general', () => ({ + setUrl: () => {}, +})); jest.mock('mattermost-redux/client', () => { const original = require.requireActual('mattermost-redux/client'); @@ -50,6 +53,7 @@ describe('components/Root', () => { showTermsOfService: false, actions: { loadMeAndConfig: async () => [{}, {}, {data: true}], // eslint-disable-line no-empty-function + getWarnMetricsStatus: async () => {}, }, location: { pathname: '/', diff --git a/components/warn_metric_ack_modal/__snapshots__/warn_metric_ack_modal.test.tsx.snap b/components/warn_metric_ack_modal/__snapshots__/warn_metric_ack_modal.test.tsx.snap new file mode 100644 index 000000000000..32e37aa1714a --- /dev/null +++ b/components/warn_metric_ack_modal/__snapshots__/warn_metric_ack_modal.test.tsx.snap @@ -0,0 +1,205 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/WarnMetricAckModal error display 1`] = ` + + + + + + + +
+ +
+
+
+ +
+
+
+ + + +
+`; + +exports[`components/WarnMetricAckModal should match snapshot, init 1`] = ` + + + + + + + +
+ +
+
+
+ + + +
+`; diff --git a/components/warn_metric_ack_modal/index.ts b/components/warn_metric_ack_modal/index.ts new file mode 100644 index 000000000000..2c614a2d199a --- /dev/null +++ b/components/warn_metric_ack_modal/index.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux'; +import {connect} from 'react-redux'; + +import {GlobalState} from 'mattermost-redux/types/store'; +import {ActionFunc} from 'mattermost-redux/types/actions'; +import {getStandardAnalytics, sendWarnMetricAck} from 'mattermost-redux/actions/admin'; + +import {getCurrentUser} from 'mattermost-redux/selectors/entities/common'; +import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; + +import {closeModal} from 'actions/views/modals'; + +import {isModalOpen} from '../../selectors/views/modals'; +import {ModalIdentifiers} from '../../utils/constants'; + +import WarnMetricAckModal from './warn_metric_ack_modal'; + +type Props = { + closeParentComponent: () => Promise; +}; + +function mapStateToProps(state: GlobalState, ownProps: Props) { + const config = getConfig(state); + + return { + stats: state.entities.admin.analytics, + user: getCurrentUser(state), + license: getLicense(state), + diagnosticId: config.DiagnosticId, + show: isModalOpen(state, ModalIdentifiers.WARN_METRIC_ACK), + closeParentComponent: ownProps.closeParentComponent, + }; +} + +type Actions = { + closeModal: (arg: string) => void; + getStandardAnalytics: () => any; + sendWarnMetricAck: (arg0: string, arg1: boolean) => ActionFunc & Partial<{error?: string}>; +}; + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators, Actions>( + { + closeModal, + getStandardAnalytics, + sendWarnMetricAck, + }, + dispatch, + ), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(WarnMetricAckModal); diff --git a/components/warn_metric_ack_modal/warn_metric_ack_modal.test.tsx b/components/warn_metric_ack_modal/warn_metric_ack_modal.test.tsx new file mode 100644 index 000000000000..5dec343dee5c --- /dev/null +++ b/components/warn_metric_ack_modal/warn_metric_ack_modal.test.tsx @@ -0,0 +1,103 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; + +import {Modal} from 'react-bootstrap'; +import {UserProfile} from 'mattermost-redux/src/types/users'; + +import WarnMetricAckModal from 'components/warn_metric_ack_modal/warn_metric_ack_modal'; + +jest.mock('react-dom', () => ({ + findDOMNode: () => ({ + blur: jest.fn(), + }), +})); + +describe('components/WarnMetricAckModal', () => { + const serverError = 'some error'; + + const baseProps = { + stats: { + registered_users: 200, + }, + user: { + id: 'someUserId', + first_name: 'Fake', + last_name: 'Person', + email: 'a@test.com', + } as UserProfile, + show: false, + diagnosticId: 'diag_0', + closeParentComponent: jest.fn(), + warnMetricStatus: { + id: 'metric1', + limit: 500, + acked: false, + }, + actions: { + closeModal: jest.fn(), + getStandardAnalytics: jest.fn(), + sendWarnMetricAck: jest.fn(), + }, + }; + + test('should match snapshot, init', () => { + const wrapper = shallow( + , + ); + expect(wrapper).toMatchSnapshot(); + }); + + test('error display', () => { + const wrapper = shallow( + , + ); + + wrapper.setState({serverError}); + expect(wrapper).toMatchSnapshot(); + }); + + test('should match state when onHide is called', () => { + const wrapper = shallow( + , + ); + + wrapper.setState({saving: true}); + wrapper.instance().onHide(); + expect(wrapper.state('saving')).toEqual(false); + }); + + test('should match state when onHideWithParent is called', () => { + const wrapper = shallow( + , + ); + + wrapper.setState({saving: true}); + wrapper.instance().onHideWithParent(); + + expect(baseProps.closeParentComponent).toHaveBeenCalledTimes(1); + expect(wrapper.state('saving')).toEqual(false); + }); + + test('send ack on acknowledge button click', () => { + const wrapper = shallow( + , + ); + + wrapper.setState({saving: false}); + wrapper.find('.save-button').simulate('click'); + expect(baseProps.actions.sendWarnMetricAck).toHaveBeenCalledTimes(1); + }); + + test('should have called props.onHide when Modal.onExited is called', () => { + const props = {...baseProps}; + const wrapper = shallow( + , + ); + + wrapper.find(Modal).props().onExited!(document.createElement('div')); + expect(baseProps.actions.closeModal).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/components/warn_metric_ack_modal/warn_metric_ack_modal.tsx b/components/warn_metric_ack_modal/warn_metric_ack_modal.tsx new file mode 100644 index 000000000000..5b930a8657aa --- /dev/null +++ b/components/warn_metric_ack_modal/warn_metric_ack_modal.tsx @@ -0,0 +1,244 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Modal} from 'react-bootstrap'; +import {FormattedMessage} from 'react-intl'; + +import {UserProfile} from 'mattermost-redux/src/types/users'; +import {Dictionary} from 'mattermost-redux/src/types/utilities'; +import {AnalyticsRow} from 'mattermost-redux/types/admin'; +import {ActionFunc} from 'mattermost-redux/types/actions'; + +import {getSiteURL} from 'utils/url'; +import {Constants, ModalIdentifiers} from 'utils/constants'; +import {t} from 'utils/i18n'; + +import {trackEvent} from 'actions/diagnostics_actions'; +import * as AdminActions from 'actions/admin_actions.jsx'; + +const StatTypes = Constants.StatTypes; + +import * as Utils from 'utils/utils.jsx'; + +import LoadingWrapper from 'components/widgets/loading/loading_wrapper'; + +type Props = { + user: UserProfile; + license?: Record; + diagnosticId?: string; + show: boolean; + closeParentComponent?: () => Promise; + stats?: Dictionary; + warnMetricStatus: any; + actions: { + closeModal: (arg: string) => void; + getStandardAnalytics: () => any; + sendWarnMetricAck: (arg0: string, arg1: boolean) => ActionFunc & Partial<{error?: string}>; + }; +} + +type State = { + serverError: string | null; + saving: boolean; +} + +export default class WarnMetricAckModal extends React.PureComponent { + public constructor(props: Props) { + super(props); + this.state = { + saving: false, + serverError: null, + }; + } + + componentDidMount() { + AdminActions.getStandardAnalytics(); + } + + onContactUsClick = async (e: any) => { + if (this.state.saving) { + return; + } + + this.setState({saving: true, serverError: null}); + + let forceAck = false; + if (e && e.target && e.target.dataset && e.target.dataset.forceack) { + forceAck = true; + trackEvent('admin', 'click_warn_metric_mailto', {metric: this.props.warnMetricStatus.id}); + } else { + trackEvent('admin', 'click_warn_metric_contact_us', {metric: this.props.warnMetricStatus.id}); + } + + const {error} = await this.props.actions.sendWarnMetricAck(this.props.warnMetricStatus.id, forceAck); + if (error) { + this.setState({serverError: error, saving: false}); + } else { + this.onHideWithParent(); + } + } + + onHide = () => { + this.setState({serverError: null, saving: false}); + this.props.actions.closeModal(ModalIdentifiers.WARN_METRIC_ACK); + } + + onHideWithParent = () => { + this.onHide(); + if (this.props.closeParentComponent) { + this.props.closeParentComponent(); + } + } + + renderError = () => { + const {serverError} = this.state; + if (!serverError) { + return ''; + } + + const mailRecipient = 'support@mattermost.com'; + const mailSubject = 'Mattermost Contact Us request'; + let mailBody = 'Mattermost Contact Us request. My team now has ' + this.props.warnMetricStatus.limit + ' users and I am considering Mattermost Enterprise Edition.'; + mailBody += '\r\n'; + mailBody += 'Contact ' + this.props.user.first_name + ' ' + this.props.user.last_name; + mailBody += '\r\n'; + mailBody += 'Email ' + this.props.user.email; + mailBody += '\r\n'; + + if (this.props.stats && this.props.stats[StatTypes.TOTAL_USERS]) { + mailBody += 'Registered Users ' + this.props.stats[StatTypes.TOTAL_USERS]; + mailBody += '\r\n'; + } + mailBody += 'Site URL ' + getSiteURL(); + mailBody += '\r\n'; + + mailBody += 'Diagnostic Id ' + this.props.diagnosticId; + mailBody += '\r\n'; + + mailBody += 'If you have any additional inquiries, please contact support@mattermost.com'; + + const mailToLinkText = 'mailto:' + mailRecipient + '?cc=' + this.props.user.email + '&subject=' + encodeURIComponent(mailSubject) + '&body=' + encodeURIComponent(mailBody); + + return ( +
+
+ +
+ ); + } + + render() { + const headerTitle = ( + + ); + const descriptionText = ( + + ); + + const buttonText = ( + + ); + + return ( + + + + {headerTitle} + + + +
+ {descriptionText} +
+ {this.renderError()} +
+
+ + + +
+ ); + } +} + +type ErrorLinkProps = { + defaultMessage: string; + messageId: string; + onClickHandler: (e: React.MouseEvent) => Promise; + url: string; + forceAck: boolean; +} + +const WarnMetricAckErrorLink: React.FC = ({defaultMessage, messageId, onClickHandler, url, forceAck}: ErrorLinkProps) => { + return ( + { + onClickHandler(e); + } + } + > + + + ); +}; \ No newline at end of file diff --git a/i18n/en.json b/i18n/en.json index f3fc9dafbc01..64d5583c8825 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1859,6 +1859,7 @@ "admin.userManagement.userDetail.username": "Username", "admin.viewArchivedChannelsHelpText": "(Beta) When true, allows users to view, share and search for content of channels that have been archived. Users can only view the content in channels of which they were a member before the channel was archived.", "admin.viewArchivedChannelsTitle": "Allow users to view archived channels:", + "admin.warn_metric.sending-email": "Sending email", "admin.webserverModeDisabled": "Disabled", "admin.webserverModeDisabledDescription": "The Mattermost server will not serve static files.", "admin.webserverModeGzip": "gzip", @@ -1907,10 +1908,13 @@ "announcement_bar.error.email_verification_required": "Check your email inbox to verify the address.", "announcement_bar.error.license_expired": "Enterprise license is expired and some features may be disabled. [Please renew](!{link}).", "announcement_bar.error.license_expiring": "Enterprise license expires on {date, date, long}. [Please renew](!{link}).", + "announcement_bar.error.number_active_users_warn_metric_status_ack.text": "Thank you for contacting Mattermost. We will follow up with you soon.", + "announcement_bar.error.number_active_users_warn_metric_status.text": "You now have over {limit} users. We strongly recommend that you upgrade to our Enterprise edition.", "announcement_bar.error.past_grace": "Enterprise license is expired and some features may be disabled. Please contact your System Administrator for details.", "announcement_bar.error.preview_mode": "Preview Mode: Email notifications have not been configured.", "announcement_bar.error.site_url_gitlab.full": "Please configure your [site URL](https://docs.mattermost.com/administration/config-settings.html#site-url) either on the [System Console](/admin_console/environment/web_server) or, if you're using GitLab Mattermost, in gitlab.rb.", "announcement_bar.error.site_url.full": "Please configure your [site URL](https://docs.mattermost.com/administration/config-settings.html#site-url) on the [System Console](/admin_console/environment/web_server).", + "announcement_bar.error.warn_metric_status.link": "Learn more", "announcement_bar.notification.email_verified": "Email verified", "api.channel.add_guest.added": "{addedUsername} added to the channel as a guest by {username}.", "api.channel.add_member.added": "{addedUsername} added to the channel by {username}.", @@ -3965,6 +3969,11 @@ "view_image_popover.open": "Open", "view_image_popover.publicLink": "Get a public link", "view_image.loading": "Loading ", + "warn_metric_ack_modal.contact_support": "Contact us", + "warn_metric_ack_modal.header.title": "Upgrade to Mattermost Enterprise Edition", + "warn_metric_ack_modal.mailto.link": "email us", + "warn_metric_ack_modal.mailto.message": "Support could not be reached. Please {link}.", + "warn_metric_ack_modal.number_of_active_users.description": "Mattermost strongly recommends that deployments of over {limit} users upgrade to Mattermost Enterprise E20, which offers features such as user management, server clustering, and performance monitoring", "web.footer.about": "About", "web.footer.help": "Help", "web.footer.privacy": "Privacy", diff --git a/images/error-icon.png b/images/error-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9f23589b94504cf5f2f77a8f0d3b41a7b277f5ab GIT binary patch literal 573 zcmV-D0>b@?P)W%d{m|h07G{Z>C)qRIjd(D`r~JuG%I-bJ9Nx58|+L4bAM;OdH^SPA6SV*z4NBX zIiT#E9;(iVHGogawku$#FdiIyTU_eOEC#?MjFW|A+webE&rEsgtGp1X zTCt0RP4zH-$3^S-5q8BYTXqU20EGq z6BwVXYo(_EW)|Qq09dxt6CjV6`qJONs|K)EUA+P9H`#bkmMVZ8MT{cAi4@;WOaSdG zRIYCUWCxmCk=+7Po$27&rUSh<@nPfLnF(ir6tdgdf#%jffY@5>4$vZL%s?B3L?)b0 zWWwo9K;dx{Xo*&0;{aCZ-+RER;Um~=S2`Hle4t#?za-G1bohL$AAbcJ^lZJ`bMhvE z7#?HPNW&&B*Vw+UnPwgK(;5oI~pb7d1}~6(>YsTbzZJ_ere?=(wxPM2!ugy00000 LNkvXXu0mjf*^&K1 literal 0 HcmV?d00001 diff --git a/images/icons/check-circle-outline.svg b/images/icons/check-circle-outline.svg new file mode 100644 index 000000000000..e732e9aa748e --- /dev/null +++ b/images/icons/check-circle-outline.svg @@ -0,0 +1 @@ + diff --git a/images/icons/error-icon.svg b/images/icons/error-icon.svg new file mode 100644 index 000000000000..47bff1bd5396 --- /dev/null +++ b/images/icons/error-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/images/icons/round-error-icon.svg b/images/icons/round-error-icon.svg new file mode 100644 index 000000000000..6541fa43d080 --- /dev/null +++ b/images/icons/round-error-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/images/icons/round-info-icon.svg b/images/icons/round-info-icon.svg new file mode 100644 index 000000000000..d8a19fb6ec59 --- /dev/null +++ b/images/icons/round-info-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/images/icons/round-white-info-icon.svg b/images/icons/round-white-info-icon.svg new file mode 100644 index 000000000000..60b2d32376a7 --- /dev/null +++ b/images/icons/round-white-info-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/package-lock.json b/package-lock.json index b54841027c04..606538bff711 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18109,8 +18109,8 @@ "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==" }, "mattermost-redux": { - "version": "github:mattermost/mattermost-redux#bf0742ed9a1a92daacaed443a5651dd459c84067", - "from": "github:mattermost/mattermost-redux#bf0742ed9a1a92daacaed443a5651dd459c84067", + "version": "github:mattermost/mattermost-redux#e0703ea564c61caf83183a7c5625ee2241d374b1", + "from": "github:mattermost/mattermost-redux#e0703ea564c61caf83183a7c5625ee2241d374b1", "requires": { "core-js": "3.6.4", "form-data": "3.0.0", diff --git a/package.json b/package.json index e202c45f74bf..5c6db5355ec8 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "localforage-observable": "2.0.1", "mark.js": "8.11.1", "marked": "github:mattermost/marked#87769262aa02e1784570f61f4f962050e07cc335", - "mattermost-redux": "github:mattermost/mattermost-redux#bf0742ed9a1a92daacaed443a5651dd459c84067", + "mattermost-redux": "github:mattermost/mattermost-redux#e0703ea564c61caf83183a7c5625ee2241d374b1", "moment-timezone": "0.5.31", "p-queue": "^6.4.0", "pdfjs-dist": "2.0.489", diff --git a/reducers/views/notice.js b/reducers/views/notice.js index 496a5c77fd78..50608907f84f 100644 --- a/reducers/views/notice.js +++ b/reducers/views/notice.js @@ -9,6 +9,8 @@ function hasBeenDismissed(state = {}, action) { switch (action.type) { case ActionTypes.DISMISS_NOTICE: return {...state, [action.data]: true}; + case ActionTypes.SHOW_NOTICE: + return {...state, [action.data]: false}; default: return state; } diff --git a/sass/base/_typography.scss b/sass/base/_typography.scss index 3cdfe8cbbe6e..e9b5885ba602 100644 --- a/sass/base/_typography.scss +++ b/sass/base/_typography.scss @@ -197,4 +197,16 @@ ol { .app__body.font--open_sans { font-family: 'Open Sans', sans-serif; +} + +.color--link--adminack { + color: $white; + padding-left: 20px; + text-decoration: underline; + cursor: pointer; + + &:hover, + &:focus { + text-decoration: underline; + } } \ No newline at end of file diff --git a/sass/components/_announcement-bar.scss b/sass/components/_announcement-bar.scss index 1750021d25c5..0ed67781031e 100644 --- a/sass/components/_announcement-bar.scss +++ b/sass/components/_announcement-bar.scss @@ -73,3 +73,22 @@ color: #3c763d; background-color: #dff0d8; } + +.announcement-bar-advisor { + background-color: #579EFF; +} + +.announcement-bar-advisor-ack { + background-color: #06D6A0; +} + +.announcement-bar__link { + margin-left: 4px; +} + +.advisor-icon { + height: 16px; + width: 16px; + margin-right: 8px; + margin-bottom: 2px; +} \ No newline at end of file diff --git a/utils/constants.jsx b/utils/constants.jsx index 279b678ee079..cc655210c38c 100644 --- a/utils/constants.jsx +++ b/utils/constants.jsx @@ -174,6 +174,7 @@ export const ActionTypes = keyMirror({ STORE_REHYDRATION_FAILED: null, DISMISS_NOTICE: null, + SHOW_NOTICE: null, SELECT_ATTACHMENT_MENU_ACTION: null, @@ -214,6 +215,12 @@ export const PostRequestTypes = keyMirror({ AFTER_ID: null, }); +export const WarnMetricTypes = { + SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_200: 'warn_metric_number_of_active_users_200', + SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_400: 'warn_metric_number_of_active_users_400', + SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_500: 'warn_metric_number_of_active_users_500', +}; + export const ModalIdentifiers = { ABOUT: 'about', TEAM_SETTINGS: 'team_settings', @@ -251,6 +258,7 @@ export const ModalIdentifiers = { EDIT_CATEGORY: 'edit_category', DELETE_CATEGORY: 'delete_category', SIDEBAR_WHATS_NEW_MODAL: 'sidebar_whats_new_modal', + WARN_METRIC_ACK: 'warn_metric_acknowledgement', }; export const UserStatuses = { @@ -351,6 +359,8 @@ export const SocketEvents = { RECEIVED_GROUP_NOT_ASSOCIATED_TO_TEAM: 'received_group_not_associated_to_team', RECEIVED_GROUP_ASSOCIATED_TO_CHANNEL: 'received_group_associated_to_channel', RECEIVED_GROUP_NOT_ASSOCIATED_TO_CHANNEL: 'received_group_not_associated_to_channel', + WARN_METRIC_STATUS_RECEIVED: 'warn_metric_status_received', + WARN_METRIC_STATUS_REMOVED: 'warn_metric_status_removed', }; export const TutorialSteps = { @@ -516,6 +526,8 @@ export const AnnouncementBarTypes = { CRITICAL: 'critical', DEVELOPER: 'developer', SUCCESS: 'success', + ADVISOR: 'advisor', + ADVISOR_ACK: 'advisor-ack', }; export const AnnouncementBarMessages = { @@ -526,6 +538,8 @@ export const AnnouncementBarMessages = { LICENSE_PAST_GRACE: t('announcement_bar.error.past_grace'), PREVIEW_MODE: t('announcement_bar.error.preview_mode'), WEBSOCKET_PORT_ERROR: t('channel_loader.socketError'), + NUMBER_OF_ACTIVE_USERS_WARN_METRIC_STATUS: t('announcement_bar.error.number_active_users_warn_metric_status.text'), + NUMBER_OF_ACTIVE_USERS_WARN_METRIC_STATUS_ACK: t('announcement_bar.error.number_active_users_warn_metric_status_ack.text'), }; export const VerifyEmailErrors = {