From e17eb6c237538591d4dc09d8649a5ef738bdadb5 Mon Sep 17 00:00:00 2001 From: Chris Duarte Date: Sun, 12 Mar 2017 10:42:02 -0700 Subject: [PATCH] PLT-5709 Global error handler (#187) * WIP Global error handler Adding components Adding actions * Add general errors where errors are caught * Filter out non displayable errors * Initial setup to email errors * Add current user/team to error email * Fix a few lints * Ensure errors are cleared when reporting a problem * Add clear all and internationalization * Fix trailing comma issue * fix lints * Use Formatted Text component * Move error list out of channel * Add mobile prefix to i18n id for connection error * Set shouldRender to true in RightMenuDrawerItem.defaultProps * Update component name for RightMenuDrawerItem * Clean up propTypes in RightMenuDrawerItem * Clean up styles in RightMenuDrawerItem * Use selector to filter displayable errors * Clarify code for constructing wrapper style in ErrorList * Avoid indenting email body for error report * Remove extraneous params from errors actions * Rename action type for logging errors * Rename action for logging errors * Set shouldRender to true in RightMenuDrawerItem.defaultProps * Make logged errors displayable by default * Don't log error when websocket connection is closed * Hide button to dismiss all errors if ShowErrorsList isn't enabled * Display only last error in alert bar when ShowErrorsList is disabled * Add error logging where missing in actions * Always show button to report problem * Only include errors in report problem email if any have been logged * Clarify subject of report problem email * Set ShowErrorsList to false in default config.json * Remove unused component + translation for connection errors * Capture entire error object not just message * Display fallback if error.message is blank * Log errors in login actions * Fix construction of errors email body --- app/actions/storage/index.js | 14 +- app/components/error_list/error_list.js | 127 ++++++++++++++++++ .../error_list/error_list_container.js | 27 ++++ app/components/error_list/general_error.js | 71 ++++++++++ app/components/error_list/index.js | 6 + app/navigation/router.js | 3 +- .../right_menu_drawer/right_menu_drawer.js | 34 ++++- .../right_menu_drawer_container.js | 7 +- .../right_menu_drawer_item.js | 32 ++--- assets/base/config.json | 3 +- assets/base/i18n/en.json | 1 + service/actions/channels.js | 114 +++++++++++----- service/actions/errors.js | 40 ++++++ service/actions/files.js | 6 +- service/actions/general.js | 13 +- service/actions/helpers.js | 7 +- service/actions/posts.js | 31 ++++- service/actions/preferences.js | 11 +- service/actions/teams.js | 34 +++-- service/actions/users.js | 66 +++++++-- service/constants/errors.js | 12 ++ service/constants/index.js | 2 + service/reducers/errors/index.js | 27 ++++ service/reducers/index.js | 2 + service/selectors/errors.js | 6 + 25 files changed, 599 insertions(+), 97 deletions(-) create mode 100644 app/components/error_list/error_list.js create mode 100644 app/components/error_list/error_list_container.js create mode 100644 app/components/error_list/general_error.js create mode 100644 app/components/error_list/index.js create mode 100644 service/actions/errors.js create mode 100644 service/constants/errors.js create mode 100644 service/reducers/errors/index.js create mode 100644 service/selectors/errors.js diff --git a/app/actions/storage/index.js b/app/actions/storage/index.js index 88199755d..e4d14eabd 100644 --- a/app/actions/storage/index.js +++ b/app/actions/storage/index.js @@ -3,8 +3,8 @@ import {AsyncStorage} from 'react-native'; import {batchActions} from 'redux-batched-actions'; - import {ViewTypes} from 'app/constants'; +import {logError, getLogErrorAction} from 'service/actions/errors'; import {ChannelTypes, GeneralTypes, TeamsTypes, UsersTypes} from 'service/constants'; export function loadStorage() { @@ -49,7 +49,13 @@ export function loadStorage() { dispatch(batchActions(actions), getState); } catch (error) { // Error loading data - dispatch({type: GeneralTypes.REMOVED_APP_CREDENTIALS, error}, getState); + dispatch(batchActions([ + { + type: ChannelTypes.REMOVED_APP_CREDENTIALS, + error + }, + getLogErrorAction(error) + ]), getState); } }; } @@ -90,7 +96,7 @@ export async function updateStorage(key, data) { return mergedStorageData; } catch (error) { - // TODO: Need to handle this error + logError(error); return null; } } @@ -114,7 +120,7 @@ export function removeStorage() { await AsyncStorage.removeItem('storage'); } } catch (error) { - // TODO: Error removing data + logError(error); } dispatch({type: UsersTypes.RESET_LOGOUT_STATE}, getState); }; diff --git a/app/components/error_list/error_list.js b/app/components/error_list/error_list.js new file mode 100644 index 000000000..c51ee0a46 --- /dev/null +++ b/app/components/error_list/error_list.js @@ -0,0 +1,127 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PropTypes, PureComponent} from 'react'; +import { + Dimensions, + StyleSheet, + View, + TouchableOpacity +} from 'react-native'; +import Icon from 'react-native-vector-icons/FontAwesome'; +import FormattedText from 'app/components/formatted_text'; +import GeneralError from './general_error'; +import Config from 'assets/config.json'; + +const {width: deviceWidth} = Dimensions.get('window'); + +const style = StyleSheet.create({ + closeButton: { + height: 20, + width: 20, + borderRadius: 10, + marginBottom: 5, + borderColor: '#fff', + borderWidth: 1, + alignItems: 'center', + justifyContent: 'center' + }, + closeButtonContainer: { + alignItems: 'center', + justifyContent: 'center', + marginTop: 5 + }, + closeButtonText: { + color: '#fff' + }, + container: { + paddingTop: 15, + paddingBottom: 15, + alignItems: 'center', + justifyContent: 'center', + minHeight: 75 + }, + wrapper: { + position: 'absolute', + top: 0, + left: 0, + width: deviceWidth, + overflow: 'hidden', + backgroundColor: 'rgba(255, 116, 92, 1)', + zIndex: 99999 + } +}); + +export default class ErrorList extends PureComponent { + static propTypes = { + actions: PropTypes.shape({ + dismissError: PropTypes.func.isRequired, + clearErrors: PropTypes.func.isRequired + }).isRequired, + errors: PropTypes.array.isRequired + } + + renderErrorsList() { + const {errors} = this.props; + if (Config.ShowErrorsList) { + return errors.map((error, index) => ( + this.props.actions.dismissError(index)} + error={error.error} + /> + )); + } + const lastErrorIndex = errors.length - 1; + const lastError = errors[lastErrorIndex]; + if (lastError) { + return ( + this.props.actions.dismissError(lastErrorIndex)} + error={lastError.error} + /> + ); + } + return null; + } + + renderDismissAll() { + if (this.props.errors.length <= 1 || !Config.ShowErrorsList) { + return null; + } + return ( + this.props.actions.clearErrors()} + > + + + + + + ); + } + + render() { + const wrapperStyle = [style.wrapper]; + if (!this.props.errors.length) { + wrapperStyle.push({height: 0}); + } + return ( + + + {this.renderErrorsList()} + {this.renderDismissAll()} + + + ); + } +} diff --git a/app/components/error_list/error_list_container.js b/app/components/error_list/error_list_container.js new file mode 100644 index 000000000..4270d4abb --- /dev/null +++ b/app/components/error_list/error_list_container.js @@ -0,0 +1,27 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {bindActionCreators} from 'redux'; +import {connect} from 'react-redux'; + +import {getDisplayableErrors} from 'service/selectors/errors'; +import {dismissError, clearErrors} from 'service/actions/errors'; + +import ErrorList from './error_list'; + +function mapStateToProps(state) { + return { + errors: getDisplayableErrors(state) + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + dismissError, + clearErrors + }, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(ErrorList); diff --git a/app/components/error_list/general_error.js b/app/components/error_list/general_error.js new file mode 100644 index 000000000..3b0e3e556 --- /dev/null +++ b/app/components/error_list/general_error.js @@ -0,0 +1,71 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PropTypes} from 'react'; +import { + StyleSheet, + View, + Text, + TouchableOpacity +} from 'react-native'; +import Icon from 'react-native-vector-icons/FontAwesome'; + +const style = StyleSheet.create({ + buttonContainer: { + width: 25, + height: 25, + alignItems: 'center', + justifyContent: 'center' + }, + buttons: { + marginHorizontal: 15 + }, + container: { + alignSelf: 'stretch', + paddingHorizontal: 15, + paddingVertical: 8, + flexDirection: 'row', + alignItems: 'center' + }, + message: { + flex: 1, + color: '#fff' + } +}); + +function GeneralError(props) { + const {error, dismiss} = props; + let message = error.message; + if (!message) { + if (error instanceof Error) { + message = error.toString(); + } else { + message = 'An error occurred.'; + } + } + + return ( + + + {error.message} + + + + + + ); +} + +GeneralError.propTypes = { + dismiss: PropTypes.func.isRequired, + error: PropTypes.object.isRequired +}; + +export default GeneralError; diff --git a/app/components/error_list/index.js b/app/components/error_list/index.js new file mode 100644 index 000000000..66fea78ff --- /dev/null +++ b/app/components/error_list/index.js @@ -0,0 +1,6 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import ErrorListContainer from './error_list_container'; + +export default ErrorListContainer; diff --git a/app/navigation/router.js b/app/navigation/router.js index 7e26fbfce..7140a8835 100644 --- a/app/navigation/router.js +++ b/app/navigation/router.js @@ -16,7 +16,7 @@ import Drawer from 'app/components/drawer'; import FormattedText from 'app/components/formatted_text'; import {RouteTransitions} from 'app/navigation/routes'; import {getTheme} from 'service/selectors/entities/preferences'; - +import ErrorList from 'app/components/error_list'; import NavigationModal from './navigation_modal'; const navigationPanResponder = NavigationExperimental.Card.CardStackPanResponder; @@ -128,6 +128,7 @@ class Router extends React.Component { return ( + {renderedScenes} {navBar} diff --git a/app/scenes/right_menu_drawer/right_menu_drawer.js b/app/scenes/right_menu_drawer/right_menu_drawer.js index d48dad7a8..3532e4c87 100644 --- a/app/scenes/right_menu_drawer/right_menu_drawer.js +++ b/app/scenes/right_menu_drawer/right_menu_drawer.js @@ -6,7 +6,8 @@ import { ScrollView, StyleSheet, Platform, - View + View, + Linking } from 'react-native'; import Icon from 'react-native-vector-icons/FontAwesome'; @@ -17,14 +18,43 @@ import RightMenuDrawerItem from './right_menu_drawer_item'; export default class RightMenuDrawer extends React.Component { static propTypes = { + errors: React.PropTypes.array.isRequired, + currentUserId: React.PropTypes.string.isRequired, + currentTeamId: React.PropTypes.string.isRequired, actions: React.PropTypes.shape({ goToModalAccountSettings: React.PropTypes.func.isRequired, goToModalSelectTeam: React.PropTypes.func.isRequired, + clearErrors: React.PropTypes.func.isRequired, logout: React.PropTypes.func.isRequired }).isRequired, theme: React.PropTypes.object }; + openErrorEmail = () => { + const recipient = 'feedback@mattermost.com'; + const subject = 'Problem with Mattermost React Native app'; + Linking.openURL( + `mailto:${recipient}?subject=${subject}&body=${this.errorEmailBody()}` + ); + this.props.actions.clearErrors(); + }; + + errorEmailBody = () => { + const {currentUserId, currentTeamId, errors} = this.props; + let contents = [ + `Current User Id: ${currentUserId}`, + `Current Team Id: ${currentTeamId}` + ]; + if (errors.length) { + contents = contents.concat([ + '', + 'Errors:', + JSON.stringify(errors.map((e) => e.error)) + ]); + } + return contents.join('\n'); + }; + render() { const Styles = getStyleSheet(this.props.theme); @@ -66,7 +96,7 @@ export default class RightMenuDrawer extends React.Component { defaultMessage='Help' /> - + - - {this.props.children} + + {children} ); diff --git a/assets/base/config.json b/assets/base/config.json index e1ba2e15d..571a76abe 100644 --- a/assets/base/config.json +++ b/assets/base/config.json @@ -2,5 +2,6 @@ "DefaultServerUrl": "http://localhost:8065", "TestServerUrl": "http://localhost:8065", "DefaultLocale": "en", - "DefaultTheme": "default" + "DefaultTheme": "default", + "ShowErrorsList": false } diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 2a117fbbe..907d87400 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -1502,6 +1502,7 @@ "mobile.channel_list.privateChannel": "Private Channel", "mobile.channel_list.publicChannel": "Public Channel", "mobile.components.channels_list_view.yourChannels": "Your channels:", + "mobile.components.error_list.dismiss_all": "Dismiss All", "mobile.components.select_server_view.enterServerUrl": "Enter Server URL", "mobile.components.select_server_view.continue": "Continue", "mobile.components.select_server_view.siteUrlPlaceholder": "https://mattermost.example.com", diff --git a/service/actions/channels.js b/service/actions/channels.js index d91355eab..bf56913af 100644 --- a/service/actions/channels.js +++ b/service/actions/channels.js @@ -8,16 +8,23 @@ import { PreferencesTypes, UsersTypes } from 'service/constants'; -import {forceLogoutIfNecessary} from './helpers'; import {batchActions} from 'redux-batched-actions'; + import Client from 'service/client'; +import {logError, getLogErrorAction} from './errors'; +import {forceLogoutIfNecessary} from './helpers'; + export function selectChannel(channelId) { return async (dispatch, getState) => { - dispatch({ - type: ChannelTypes.SELECT_CHANNEL, - data: channelId - }, getState); + try { + dispatch({ + type: ChannelTypes.SELECT_CHANNEL, + data: channelId + }, getState); + } catch (error) { + logError(error)(dispatch); + } }; } @@ -45,7 +52,8 @@ export function createChannel(channel, userId) { { type: ChannelTypes.CHANNEL_MEMBERS_FAILURE, error - } + }, + getLogErrorAction(error) ]), getState); return null; } @@ -96,14 +104,9 @@ export function createDirectChannel(teamId, userId, otherUserId) { } catch (error) { forceLogoutIfNecessary(error, dispatch); dispatch(batchActions([ - { - type: ChannelTypes.CREATE_CHANNEL_FAILURE, - error - }, - { - type: ChannelTypes.CHANNEL_MEMBERS_FAILURE, - error - } + {type: ChannelTypes.CREATE_CHANNEL_FAILURE, error}, + {type: ChannelTypes.CHANNEL_MEMBERS_FAILURE, error}, + getLogErrorAction(error) ]), getState); return null; } @@ -150,7 +153,11 @@ export function updateChannel(channel) { updated = await Client.updateChannel(channel); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: ChannelTypes.UPDATE_CHANNEL_FAILURE, error}, getState); + + dispatch(batchActions([ + {type: ChannelTypes.UPDATE_CHANNEL_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -181,7 +188,11 @@ export function updateChannelNotifyProps(userId, teamId, channelId, props) { notifyProps = await Client.updateChannelNotifyProps(teamId, data); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: ChannelTypes.NOTIFY_PROPS_FAILURE, error}, getState); + + dispatch(batchActions([ + {type: ChannelTypes.NOTIFY_PROPS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -209,7 +220,10 @@ export function getChannel(teamId, channelId) { data = await Client.getChannel(teamId, channelId); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: ChannelTypes.CHANNELS_FAILURE, error}, getState); + dispatch(batchActions([ + {type: ChannelTypes.CHANNELS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -251,14 +265,9 @@ export function fetchMyChannelsAndMembers(teamId) { } catch (error) { forceLogoutIfNecessary(error, dispatch); dispatch(batchActions([ - { - type: ChannelTypes.CHANNELS_FAILURE, - error - }, - { - type: ChannelTypes.CHANNEL_MEMBERS_FAILURE, - error - } + {type: ChannelTypes.CHANNELS_FAILURE, error}, + {type: ChannelTypes.CHANNEL_MEMBERS_FAILURE, error}, + getLogErrorAction(error) ]), getState); return; } @@ -293,7 +302,10 @@ export function getMyChannelMembers(teamId) { channelMembers = await channelMembersRequest; } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: ChannelTypes.CHANNEL_MEMBERS_FAILURE, error}, getState); + dispatch(batchActions([ + {type: ChannelTypes.CHANNEL_MEMBERS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -317,7 +329,10 @@ export function leaveChannel(teamId, channelId) { await Client.leaveChannel(teamId, channelId); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: ChannelTypes.LEAVE_CHANNEL_FAILURE, error}, getState); + dispatch(batchActions([ + {type: ChannelTypes.LEAVE_CHANNEL_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -346,7 +361,10 @@ export function joinChannel(userId, teamId, channelId, channelName) { } } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: ChannelTypes.JOIN_CHANNEL_FAILURE, error}, getState); + dispatch(batchActions([ + {type: ChannelTypes.JOIN_CHANNEL_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -385,7 +403,10 @@ export function deleteChannel(teamId, channelId) { await Client.deleteChannel(teamId, channelId); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: ChannelTypes.DELETE_CHANNEL_FAILURE, error}, getState); + dispatch(batchActions([ + {type: ChannelTypes.DELETE_CHANNEL_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -430,7 +451,10 @@ export function viewChannel(teamId, channelId) { await Client.viewChannel(teamId, channelId, prevChannelId); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: ChannelTypes.UPDATE_LAST_VIEWED_FAILURE, error}, getState); + dispatch(batchActions([ + {type: ChannelTypes.UPDATE_LAST_VIEWED_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -447,7 +471,10 @@ export function getMoreChannels(teamId, offset, limit = Constants.CHANNELS_CHUNK channels = await Client.getMoreChannels(teamId, offset, limit); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: ChannelTypes.MORE_CHANNELS_FAILURE, error}, getState); + dispatch(batchActions([ + {type: ChannelTypes.MORE_CHANNELS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return null; } @@ -474,7 +501,10 @@ export function searchMoreChannels(teamId, term) { channels = await Client.searchMoreChannels(teamId, term); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: ChannelTypes.MORE_CHANNELS_FAILURE, error}, getState); + dispatch(batchActions([ + {type: ChannelTypes.MORE_CHANNELS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -499,7 +529,10 @@ export function getChannelStats(teamId, channelId) { stat = await Client.getChannelStats(teamId, channelId); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: ChannelTypes.CHANNEL_STATS_FAILURE, error}, getState); + dispatch(batchActions([ + {type: ChannelTypes.CHANNEL_STATS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -523,7 +556,10 @@ export function addChannelMember(teamId, channelId, userId) { await Client.addChannelMember(teamId, channelId, userId); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: ChannelTypes.ADD_CHANNEL_MEMBER_FAILURE, error}, getState); + dispatch(batchActions([ + {type: ChannelTypes.ADD_CHANNEL_MEMBER_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -548,7 +584,10 @@ export function removeChannelMember(teamId, channelId, userId) { await Client.removeChannelMember(teamId, channelId, userId); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: ChannelTypes.REMOVE_CHANNEL_MEMBER_FAILURE, error}, getState); + dispatch(batchActions([ + {type: ChannelTypes.REMOVE_CHANNEL_MEMBER_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -668,7 +707,10 @@ export function autocompleteChannels(teamId, term) { data = await Client.autocompleteChannels(teamId, term); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: ChannelTypes.AUTOCOMPLETE_CHANNELS_FAILURE, error}, getState); + dispatch(batchActions([ + {type: ChannelTypes.AUTOCOMPLETE_CHANNELS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } diff --git a/service/actions/errors.js b/service/actions/errors.js new file mode 100644 index 000000000..8d8e068b6 --- /dev/null +++ b/service/actions/errors.js @@ -0,0 +1,40 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {ErrorTypes} from 'service/constants'; + +export function dismissErrorObject(index) { + return { + type: ErrorTypes.DISMISS_ERROR, + index + }; +} + +export function dismissError(index) { + return async (dispatch) => { + dispatch(dismissErrorObject(index)); + }; +} + +export function getLogErrorAction(error, displayable = true) { + return { + type: ErrorTypes.LOG_ERROR, + displayable, + error + }; +} + +export function logError(error, displayable = true) { + return async (dispatch) => { + // do something with the incoming error + // like sending it to analytics + + dispatch(getLogErrorAction(error, displayable)); + }; +} + +export function clearErrors() { + return async (dispatch) => { + dispatch({type: ErrorTypes.CLEAR_ERRORS}); + }; +} diff --git a/service/actions/files.js b/service/actions/files.js index 8f03cdb2a..8eebc1659 100644 --- a/service/actions/files.js +++ b/service/actions/files.js @@ -5,6 +5,7 @@ import {batchActions} from 'redux-batched-actions'; import Client from 'service/client'; import {FilesTypes} from 'service/constants'; +import {getLogErrorAction} from 'service/actions/errors'; import {forceLogoutIfNecessary} from './helpers'; export function getFilesForPost(teamId, channelId, postId) { @@ -16,7 +17,10 @@ export function getFilesForPost(teamId, channelId, postId) { files = await Client.getFileInfosForPost(teamId, channelId, postId); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: FilesTypes.FETCH_FILES_FOR_POST_FAILURE, error}, getState); + dispatch(batchActions([ + {type: FilesTypes.FETCH_FILES_FOR_POST_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } diff --git a/service/actions/general.js b/service/actions/general.js index 29beb594c..f4c1c01ca 100644 --- a/service/actions/general.js +++ b/service/actions/general.js @@ -1,10 +1,13 @@ // Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import {batchActions} from 'redux-batched-actions'; + import Client from 'service/client'; import {bindClientFunc, FormattedError} from './helpers.js'; import {GeneralTypes} from 'service/constants'; import {getMyChannelMembers} from './channels'; +import {getLogErrorAction} from 'service/actions/errors'; export function getPing() { return async (dispatch, getState) => { @@ -19,11 +22,17 @@ export function getPing() { data = await Client.getPing(); if (!data.version) { // successful ping but not the right return data - dispatch({type: GeneralTypes.PING_FAILURE, error: pingError}, getState); + dispatch(batchActions([ + {type: GeneralTypes.PING_FAILURE, error: pingError}, + getLogErrorAction(pingError) + ]), getState); return; } } catch (error) { - dispatch({type: GeneralTypes.PING_FAILURE, error: pingError}, getState); + dispatch(batchActions([ + {type: GeneralTypes.PING_FAILURE, error: pingError}, + getLogErrorAction(error) + ]), getState); return; } diff --git a/service/actions/helpers.js b/service/actions/helpers.js index 58c9cc636..c97d9d0d8 100644 --- a/service/actions/helpers.js +++ b/service/actions/helpers.js @@ -1,8 +1,10 @@ // Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import {batchActions} from 'redux-batched-actions'; import Client from 'service/client'; import {UsersTypes} from 'service/constants'; +import {getLogErrorAction} from './errors'; const HTTP_UNAUTHORIZED = 401; export async function forceLogoutIfNecessary(err, dispatch) { @@ -50,7 +52,10 @@ export function bindClientFunc(clientFunc, request, success, failure, ...args) { data = await clientFunc(...args); } catch (err) { forceLogoutIfNecessary(err, dispatch); - dispatch(requestFailure(failure, err), getState); + dispatch(batchActions([ + requestFailure(failure, err), + getLogErrorAction(err) + ]), getState); return; } diff --git a/service/actions/posts.js b/service/actions/posts.js index 4f26acfa4..7e4fe873f 100644 --- a/service/actions/posts.js +++ b/service/actions/posts.js @@ -5,6 +5,7 @@ import Client from 'service/client'; import {batchActions} from 'redux-batched-actions'; import {bindClientFunc, forceLogoutIfNecessary} from './helpers'; import {Constants, PostsTypes} from 'service/constants'; +import {getLogErrorAction} from './errors'; import {getProfilesByIds, getStatusesByIds} from './users'; async function getProfilesAndStatusesForPosts(list, dispatch, getState) { @@ -65,7 +66,10 @@ export function deletePost(teamId, post) { await Client.deletePost(teamId, post.channel_id, post.id); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: PostsTypes.DELETE_POST_FAILURE, error}, getState); + dispatch(batchActions([ + {type: PostsTypes.DELETE_POST_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -100,7 +104,10 @@ export function getPost(teamId, channelId, postId) { getProfilesAndStatusesForPosts(post, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: PostsTypes.GET_POST_FAILURE, error}, getState); + dispatch(batchActions([ + {type: PostsTypes.GET_POST_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -127,7 +134,10 @@ export function getPosts(teamId, channelId, offset = 0, limit = Constants.POST_C getProfilesAndStatusesForPosts(posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: PostsTypes.GET_POSTS_FAILURE, error}, getState); + dispatch(batchActions([ + {type: PostsTypes.GET_POSTS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return null; } @@ -156,7 +166,10 @@ export function getPostsSince(teamId, channelId, since) { getProfilesAndStatusesForPosts(posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: PostsTypes.GET_POSTS_SINCE_FAILURE, error}, getState); + dispatch(batchActions([ + {type: PostsTypes.GET_POSTS_SINCE_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return null; } @@ -185,7 +198,10 @@ export function getPostsBefore(teamId, channelId, postId, offset = 0, limit = Co getProfilesAndStatusesForPosts(posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: PostsTypes.GET_POSTS_BEFORE_FAILURE, error}, getState); + dispatch(batchActions([ + {type: PostsTypes.GET_POSTS_BEFORE_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return null; } @@ -214,7 +230,10 @@ export function getPostsAfter(teamId, channelId, postId, offset = 0, limit = Con getProfilesAndStatusesForPosts(posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: PostsTypes.GET_POSTS_AFTER_FAILURE, error}, getState); + dispatch(batchActions([ + {type: PostsTypes.GET_POSTS_AFTER_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return null; } diff --git a/service/actions/preferences.js b/service/actions/preferences.js index 69934e2f5..54db35372 100644 --- a/service/actions/preferences.js +++ b/service/actions/preferences.js @@ -11,6 +11,7 @@ import {getPreferenceKey} from 'service/utils/preference_utils'; import {bindClientFunc, forceLogoutIfNecessary} from './helpers'; +import {getLogErrorAction} from './errors'; export function getMyPreferences() { return bindClientFunc( Client.getMyPreferences, @@ -28,7 +29,10 @@ export function savePreferences(preferences) { await Client.savePreferences(preferences); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: PreferencesTypes.SAVE_PREFERENCES_FAILURE, error}, getState); + dispatch(batchActions([ + {type: PreferencesTypes.SAVE_PREFERENCES_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -52,7 +56,10 @@ export function deletePreferences(preferences) { await Client.deletePreferences(preferences); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: PreferencesTypes.DELETE_PREFERENCES_FAILURE, error}, getState); + dispatch(batchActions([ + {type: PreferencesTypes.DELETE_PREFERENCES_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } diff --git a/service/actions/teams.js b/service/actions/teams.js index 00b608c8b..3c3c6dae1 100644 --- a/service/actions/teams.js +++ b/service/actions/teams.js @@ -4,6 +4,7 @@ import Client from 'service/client'; import {batchActions} from 'redux-batched-actions'; import {Constants, TeamsTypes} from 'service/constants'; +import {getLogErrorAction} from './errors'; import {bindClientFunc, forceLogoutIfNecessary} from './helpers'; import {getProfilesByIds, getStatusesByIds} from './users'; @@ -65,7 +66,10 @@ export function createTeam(userId, team) { created = await Client.createTeam(team); } catch (err) { forceLogoutIfNecessary(err, dispatch); - dispatch({type: TeamsTypes.CREATE_TEAM_FAILURE, error: err}, getState); + dispatch(batchActions([ + {type: TeamsTypes.CREATE_TEAM_FAILURE, error: err}, + getLogErrorAction(err) + ]), getState); return; } @@ -127,7 +131,10 @@ export function getTeamMember(teamId, userId) { getProfilesAndStatusesForMembers([userId], dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: TeamsTypes.TEAM_MEMBERS_FAILURE, error}, getState); + dispatch(batchActions([ + {type: TeamsTypes.TEAM_MEMBERS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -153,7 +160,10 @@ export function getTeamMembersByIds(teamId, userIds) { getProfilesAndStatusesForMembers(userIds, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: TeamsTypes.TEAM_MEMBERS_FAILURE}, getState); + dispatch(batchActions([ + {type: TeamsTypes.TEAM_MEMBERS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); } dispatch(batchActions([ @@ -184,9 +194,12 @@ export function addUserToTeam(teamId, userId) { try { await Client.addUserToTeam(teamId, userId); - } catch (err) { - forceLogoutIfNecessary(err, dispatch); - dispatch({type: TeamsTypes.ADD_TEAM_MEMBER_FAILURE, error: err}, getState); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: TeamsTypes.ADD_TEAM_MEMBER_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -213,9 +226,12 @@ export function removeUserFromTeam(teamId, userId) { try { await Client.removeUserFromTeam(teamId, userId); - } catch (err) { - forceLogoutIfNecessary(err, dispatch); - dispatch({type: TeamsTypes.REMOVE_TEAM_MEMBER_FAILURE, error: err}, getState); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch(batchActions([ + {type: TeamsTypes.REMOVE_TEAM_MEMBER_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } diff --git a/service/actions/users.js b/service/actions/users.js index 5dd0c728b..d192efeda 100644 --- a/service/actions/users.js +++ b/service/actions/users.js @@ -5,6 +5,7 @@ import {batchActions} from 'redux-batched-actions'; import Client from 'service/client'; import {Constants, PreferencesTypes, UsersTypes, TeamsTypes} from 'service/constants'; import {fetchTeams} from 'service/actions/teams'; +import {getLogErrorAction} from 'service/actions/errors'; import {bindClientFunc, forceLogoutIfNecessary, debounce} from './helpers'; export function checkMfa(loginId) { @@ -15,7 +16,10 @@ export function checkMfa(loginId) { dispatch({type: UsersTypes.CHECK_MFA_SUCCESS}, getState); return mfa; } catch (error) { - dispatch({type: UsersTypes.CHECK_MFA_FAILURE, error}, getState); + dispatch(batchActions([ + {type: UsersTypes.CHECK_MFA_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return null; } }; @@ -36,7 +40,10 @@ export function login(loginId, password, mfaToken = '') { teamMembers = await teamMembersRequest; preferences = await preferencesRequest; } catch (error) { - dispatch({type: UsersTypes.LOGIN_FAILURE, error}, getState); + dispatch(batchActions([ + {type: UsersTypes.LOGIN_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -44,7 +51,10 @@ export function login(loginId, password, mfaToken = '') { await fetchTeams()(dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: UsersTypes.LOGIN_FAILURE, error}, getState); + dispatch(batchActions([ + {type: UsersTypes.LOGIN_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -67,7 +77,10 @@ export function login(loginId, password, mfaToken = '') { ]), getState); }). catch((error) => { - dispatch({type: UsersTypes.LOGIN_FAILURE, error}, getState); + dispatch(batchActions([ + {type: UsersTypes.LOGIN_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; }); }; @@ -81,7 +94,10 @@ export function loadMe() { user = await Client.getMe(); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: UsersTypes.LOGIN_FAILURE, error}, getState); + dispatch(batchActions([ + {type: UsersTypes.LOGIN_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -91,7 +107,10 @@ export function loadMe() { preferences = await Client.getMyPreferences(); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: PreferencesTypes.MY_PREFERENCES_FAILURE, error}, getState); + dispatch(batchActions([ + {type: PreferencesTypes.MY_PREFERENCES_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -99,7 +118,10 @@ export function loadMe() { await fetchTeams()(dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: TeamsTypes.FETCH_TEAMS_FAILURE, error}, getState); + dispatch(batchActions([ + {type: TeamsTypes.FETCH_TEAMS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -109,7 +131,10 @@ export function loadMe() { teamMembers = await Client.getMyTeamMembers(); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: TeamsTypes.MY_TEAM_MEMBERS_FAILURE, error}, getState); + dispatch(batchActions([ + {type: TeamsTypes.MY_TEAM_MEMBERS_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -157,7 +182,10 @@ export function getProfiles(offset, limit = Constants.PROFILE_CHUNK_SIZE) { profiles = await Client.getProfiles(offset, limit); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: UsersTypes.PROFILES_FAILURE, error}, getState); + dispatch(batchActions([ + {type: UsersTypes.PROFILES_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return null; } @@ -194,7 +222,10 @@ export function getProfilesInTeam(teamId, offset, limit = Constants.PROFILE_CHUN profiles = await Client.getProfilesInTeam(teamId, offset, limit); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: UsersTypes.PROFILES_IN_TEAM_FAILURE, error}, getState); + dispatch(batchActions([ + {type: UsersTypes.PROFILES_IN_TEAM_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -224,7 +255,10 @@ export function getProfilesInChannel(teamId, channelId, offset, limit = Constant profiles = await Client.getProfilesInChannel(teamId, channelId, offset, limit); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: UsersTypes.PROFILES_IN_CHANNEL_FAILURE, error}, getState); + dispatch(batchActions([ + {type: UsersTypes.PROFILES_IN_CHANNEL_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -254,7 +288,10 @@ export function getProfilesNotInChannel(teamId, channelId, offset, limit = Const profiles = await Client.getProfilesNotInChannel(teamId, channelId, offset, limit); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: UsersTypes.PROFILES_NOT_IN_CHANNEL_FAILURE, error}, getState); + dispatch(batchActions([ + {type: UsersTypes.PROFILES_NOT_IN_CHANNEL_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } @@ -341,7 +378,10 @@ export function autocompleteUsersInChannel(teamId, channelId, term) { data = await Client.autocompleteUsersInChannel(teamId, channelId, term); } catch (error) { forceLogoutIfNecessary(error, dispatch); - dispatch({type: UsersTypes.AUTOCOMPLETE_IN_CHANNEL_FAILURE, error}, getState); + dispatch(batchActions([ + {type: UsersTypes.AUTOCOMPLETE_IN_CHANNEL_FAILURE, error}, + getLogErrorAction(error) + ]), getState); return; } diff --git a/service/constants/errors.js b/service/constants/errors.js new file mode 100644 index 000000000..5f070f968 --- /dev/null +++ b/service/constants/errors.js @@ -0,0 +1,12 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import keyMirror from 'service/utils/key_mirror'; + +const ErrorTypes = keyMirror({ + DISMISS_ERROR: null, + LOG_ERROR: null, + CLEAR_ERRORS: null +}); + +export default ErrorTypes; diff --git a/service/constants/index.js b/service/constants/index.js index 9d07cdfb9..4d45ea958 100644 --- a/service/constants/index.js +++ b/service/constants/index.js @@ -3,6 +3,7 @@ import Constants from './constants'; import ChannelTypes from './channels'; +import ErrorTypes from './errors'; import GeneralTypes from './general'; import UsersTypes from './users'; import TeamsTypes from './teams'; @@ -19,6 +20,7 @@ const Preferences = { export { Constants, + ErrorTypes, GeneralTypes, UsersTypes, TeamsTypes, diff --git a/service/reducers/errors/index.js b/service/reducers/errors/index.js new file mode 100644 index 000000000..6ceeaa08b --- /dev/null +++ b/service/reducers/errors/index.js @@ -0,0 +1,27 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {ErrorTypes} from 'service/constants'; + +export default (state = [], action) => { + switch (action.type) { + case ErrorTypes.DISMISS_ERROR: { + const nextState = [...state]; + nextState.splice(action.index, 1); + + return nextState; + } + case ErrorTypes.LOG_ERROR: { + const nextState = [...state]; + const {displayable, error} = action; + nextState.push({displayable, error}); + + return nextState; + } + case ErrorTypes.CLEAR_ERRORS: { + return []; + } + default: + return state; + } +}; diff --git a/service/reducers/index.js b/service/reducers/index.js index f8a1b3db2..77b028cb4 100644 --- a/service/reducers/index.js +++ b/service/reducers/index.js @@ -2,9 +2,11 @@ // See License.txt for license information. import entities from './entities'; +import errors from './errors'; import requests from './requests'; export default { entities, + errors, requests }; diff --git a/service/selectors/errors.js b/service/selectors/errors.js new file mode 100644 index 000000000..89d61787c --- /dev/null +++ b/service/selectors/errors.js @@ -0,0 +1,6 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +export function getDisplayableErrors(state) { + return state.errors.filter((error) => error.displayable); +}