diff --git a/Makefile b/Makefile index b411d9a7c948..ca6b72f73263 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ BUILD_SERVER_DIR = ../mattermost-server BUILD_WEBAPP_DIR = ../mattermost-webapp +MM_UTILITIES_DIR = ../mattermost-utilities EMOJI_TOOLS_DIR = ./build/emoji check-style: node_modules ## Checks JS file for ESLint confirmity @@ -14,6 +15,10 @@ test: node_modules ## Runs tests npm run test +i18n-extract: ## Extract strings for translation from the source code + @[[ -d $(MM_UTILITIES_DIR) ]] || echo "You must clone github.com/mattermost/mattermost-utilities repo in .. to use this command" + @[[ -d $(MM_UTILITIES_DIR) ]] && cd $(MM_UTILITIES_DIR) && npm install && npm run babel && node mmjstool/build/index.js i18n extract-webapp + node_modules: package.json package-lock.json @echo Getting dependencies using npm @@ -40,10 +45,10 @@ run: node_modules ## Runs app npm run run & -run-fullmap: node_modules ## Runs the app with the JS mapped to source (good for debugger) - @echo FULL SOURCE MAP Running mattermost Webapp for development FULL SOURCE MAP +run-fullmap: node_modules ## Legacy alias to run + @echo Running mattermost Webapp for development - npm run run-fullmap & + npm run run & stop: ## Stops webpack @echo Stopping changes watching diff --git a/NOTICE.txt b/NOTICE.txt index b3e96ceb836f..4f886389ed53 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -212,56 +212,6 @@ THE SOFTWARE. --- -## cypress - -This product contains 'cypress' by Cypress. - -Fast, easy and reliable testing for anything that runs in a browser. - -* HOMEPAGE: - * https://github.com/cypress-io/cypress - -* LICENSE: MIT - -The MIT License (MIT) - -Copyright (c) 2016 Cypress.io, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ---- - -## eslint-plugin-cypress - -This product contains 'eslint-plugin-cypress' by Cypress. - -An ESLint plugin for projects that use Cypress - -* HOMEPAGE: - * https://github.com/cypress-io/eslint-plugin-cypress - -* LICENSE: MIT - -(No license text is stated in the homepage.) - ---- - ## exif2css This product contains 'exif2css' by Anton. @@ -1984,16 +1934,16 @@ SOFTWARE. --- -## rebound-js +## rebound -This product contains 'rebound-js' by Facebook. +This product contains 'rebound' by Facebook. -Rebound is a simple library that models Spring dynamics for the purpose of driving physical animations. +A simple library for modeling spring dynamics * HOMEPAGE: * https://github.com/facebook/rebound-js -* LICENSE: BSD +* LICENSE: BSD-3-Clause BSD License diff --git a/actions/diagnostics_actions.jsx b/actions/diagnostics_actions.jsx index e0e8c3551715..567952ac34a6 100644 --- a/actions/diagnostics_actions.jsx +++ b/actions/diagnostics_actions.jsx @@ -1,8 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {Client4} from 'mattermost-redux/client'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; -import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import store from 'stores/redux_store.jsx'; @@ -16,23 +16,7 @@ const SUPPORTS_MEASURE_METHODS = isSupported([ ]); export function trackEvent(category, event, props) { - if (global.window && global.window.analytics) { - const properties = Object.assign({category, type: event, user_actual_id: getCurrentUserId(store.getState())}, props); - const options = { - context: { - ip: '0.0.0.0', - }, - page: { - path: '', - referrer: '', - search: '', - title: '', - url: '', - }, - anonymousId: '00000000000000000000000000', - }; - global.window.analytics.track('event', properties, options); - } + Client4.trackEvent(category, event, props); } /** diff --git a/actions/global_actions.jsx b/actions/global_actions.jsx index 411773e93402..4ca0744b92c0 100644 --- a/actions/global_actions.jsx +++ b/actions/global_actions.jsx @@ -2,31 +2,30 @@ // See LICENSE.txt for license information. import debounce from 'lodash/debounce'; +import {batchActions} from 'redux-batched-actions'; import { - getChannel, createDirectChannel, getChannelByNameAndTeamName, getChannelStats, getMyChannelMember, - joinChannel, markChannelAsRead, selectChannel, } from 'mattermost-redux/actions/channels'; -import {getPostThread} from 'mattermost-redux/actions/posts'; import {logout} from 'mattermost-redux/actions/users'; import {Client4} from 'mattermost-redux/client'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getCurrentTeamId, getTeam, getMyTeams, getMyTeamMember} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; -import {getCurrentChannelStats, getCurrentChannelId, getChannelByName} from 'mattermost-redux/selectors/entities/channels'; +import {getCurrentChannelStats, getCurrentChannelId, getChannelByName, getMyChannelMember as selectMyChannelMember} from 'mattermost-redux/selectors/entities/channels'; +import {ChannelTypes} from 'mattermost-redux/action_types'; import {browserHistory} from 'utils/browser_history'; -import {loadChannelsForCurrentUser} from 'actions/channel_actions.jsx'; import {handleNewPost} from 'actions/post_actions.jsx'; import {stopPeriodicStatusUpdates} from 'actions/status_actions.jsx'; -import {loadNewDMIfNeeded, loadNewGMIfNeeded, loadProfilesForSidebar} from 'actions/user_actions.jsx'; +import {loadProfilesForSidebar} from 'actions/user_actions.jsx'; import {closeRightHandSide, closeMenu as closeRhsMenu, updateRhsState} from 'actions/views/rhs'; +import {clearUserCookie} from 'actions/views/root'; import {close as closeLhs} from 'actions/views/lhs'; import * as WebsocketActions from 'actions/websocket_actions.jsx'; import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; @@ -37,7 +36,7 @@ import store from 'stores/redux_store.jsx'; import LocalStorageStore from 'stores/local_storage_store'; import WebSocketClient from 'client/web_websocket_client.jsx'; -import {ActionTypes, Constants, ErrorPageTypes, PostTypes, RHSStates} from 'utils/constants.jsx'; +import {ActionTypes, Constants, PostTypes, RHSStates} from 'utils/constants.jsx'; import EventTypes from 'utils/event_types.jsx'; import {filterAndSortTeamsByDisplayName} from 'utils/team_utils.jsx'; import * as Utils from 'utils/utils.jsx'; @@ -66,6 +65,7 @@ export function emitChannelClickEvent(channel) { const teamId = chan.team_id || getCurrentTeamId(state); const isRHSOpened = getIsRhsOpen(state); const isPinnedPostsShowing = getRhsState(state) === RHSStates.PIN; + const member = selectMyChannelMember(state, chan.id); getMyChannelMemberPromise.then(() => { dispatch(getChannelStats(chan.id)); @@ -89,11 +89,15 @@ export function emitChannelClickEvent(channel) { loadProfilesForSidebar(); - AppDispatcher.handleViewAction({ - type: ActionTypes.CLICK_CHANNEL, - id: chan.id, - team_id: teamId, - }); + dispatch(batchActions([{ + type: ChannelTypes.SELECT_CHANNEL, + data: chan.id, + }, { + type: ActionTypes.SELECT_CHANNEL_WITH_MEMBER, + data: chan.id, + channel: chan, + member, + }])); } if (channel.fake) { @@ -111,76 +115,10 @@ export function emitChannelClickEvent(channel) { } } -export async function doFocusPost(channelId, postId) { - dispatch(selectChannel(channelId)); - dispatch({ - type: ActionTypes.RECEIVED_FOCUSED_POST, - data: postId, - }); - - const member = getState().entities.channels.myMembers[channelId]; - if (member == null) { - await dispatch(joinChannel(getCurrentUserId(getState()), null, channelId)); - } - - dispatch(loadChannelsForCurrentUser()); - dispatch(getChannelStats(channelId)); -} - export function emitCloseRightHandSide() { dispatch(closeRightHandSide()); } -export async function emitPostFocusEvent(postId, returnTo = '') { - dispatch(loadChannelsForCurrentUser()); - const {data} = await dispatch(getPostThread(postId)); - - if (!data) { - browserHistory.replace(`/error?type=${ErrorPageTypes.PERMALINK_NOT_FOUND}&returnTo=${returnTo}`); - return; - } - - const channelId = data.posts[data.order[0]].channel_id; - let channel = getState().entities.channels.channels[channelId]; - const teamId = getCurrentTeamId(getState()); - - if (!channel) { - const {data: channelData} = await dispatch(getChannel(channelId)); - if (!channelData) { - browserHistory.replace(`/error?type=${ErrorPageTypes.PERMALINK_NOT_FOUND}&returnTo=${returnTo}`); - return; - } - channel = channelData; - } - - if (channel.team_id && channel.team_id !== teamId) { - browserHistory.replace(`/error?type=${ErrorPageTypes.PERMALINK_NOT_FOUND}&returnTo=${returnTo}`); - return; - } - - if (channel && channel.type === Constants.DM_CHANNEL) { - loadNewDMIfNeeded(channel.id); - } else if (channel && channel.type === Constants.GM_CHANNEL) { - loadNewGMIfNeeded(channel.id); - } - - await doFocusPost(channelId, postId, data); -} - -export function emitUserPostedEvent(post) { - AppDispatcher.handleServerAction({ - type: ActionTypes.CREATE_POST, - post, - }); -} - -export function emitUserCommentedEvent(post) { - AppDispatcher.handleServerAction({ - type: ActionTypes.CREATE_COMMENT, - post, - }); -} - export function toggleShortcutsModal() { AppDispatcher.handleViewAction({ type: ActionTypes.TOGGLE_SHORTCUTS_MODAL, @@ -234,76 +172,6 @@ export function showLeavePrivateChannelModal(channel) { }); } -export function emitSuggestionPretextChanged(suggestionId, pretext) { - AppDispatcher.handleViewAction({ - type: ActionTypes.SUGGESTION_PRETEXT_CHANGED, - id: suggestionId, - pretext, - }); -} - -export function emitSelectNextSuggestion(suggestionId) { - AppDispatcher.handleViewAction({ - type: ActionTypes.SUGGESTION_SELECT_NEXT, - id: suggestionId, - }); -} - -export function emitSelectPreviousSuggestion(suggestionId) { - AppDispatcher.handleViewAction({ - type: ActionTypes.SUGGESTION_SELECT_PREVIOUS, - id: suggestionId, - }); -} - -export function emitCompleteWordSuggestion(suggestionId, term = '') { - AppDispatcher.handleViewAction({ - type: Constants.ActionTypes.SUGGESTION_COMPLETE_WORD, - id: suggestionId, - term, - }); -} - -export function emitClearSuggestions(suggestionId) { - AppDispatcher.handleViewAction({ - type: Constants.ActionTypes.SUGGESTION_CLEAR_SUGGESTIONS, - id: suggestionId, - }); -} - -export function emitPreferenceChangedEvent(preference) { - AppDispatcher.handleServerAction({ - type: Constants.ActionTypes.RECEIVED_PREFERENCE, - preference, - }); - - if (addedNewDmUser(preference)) { - loadProfilesForSidebar(); - } -} - -export function emitPreferencesChangedEvent(preferences) { - AppDispatcher.handleServerAction({ - type: Constants.ActionTypes.RECEIVED_PREFERENCES, - preferences, - }); - - if (preferences.findIndex(addedNewDmUser) !== -1) { - loadProfilesForSidebar(); - } -} - -function addedNewDmUser(preference) { - return preference.category === Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW && preference.value === 'true'; -} - -export function emitPreferencesDeletedEvent(preferences) { - AppDispatcher.handleServerAction({ - type: Constants.ActionTypes.DELETED_PREFERENCES, - preferences, - }); -} - export function sendEphemeralPost(message, channelId, parentId) { const timestamp = Utils.getTimestamp(); const post = { @@ -364,15 +232,6 @@ export function emitLocalUserTypingEvent(channelId, parentPostId) { return dispatch(userTyping); } -export function emitRemoteUserTypingEvent(channelId, userId, postParentId) { - AppDispatcher.handleViewAction({ - type: Constants.ActionTypes.USER_TYPING, - channelId, - userId, - postParentId, - }); -} - export function emitUserLoggedOutEvent(redirectTo = '/', shouldSignalLogout = true, userAction = true) { // If the logout was intentional, discard knowledge about having previously been logged in. // This bit is otherwise used to detect session expirations on the login page. @@ -388,7 +247,9 @@ export function emitUserLoggedOutEvent(redirectTo = '/', shouldSignalLogout = tr BrowserStore.clear(); stopPeriodicStatusUpdates(); WebsocketActions.close(); - document.cookie = 'MMUSERID=;expires=Thu, 01 Jan 1970 00:00:01 GMT;'; + + clearUserCookie(); + browserHistory.push(redirectTo); }).catch(() => { browserHistory.push(redirectTo); @@ -396,9 +257,11 @@ export function emitUserLoggedOutEvent(redirectTo = '/', shouldSignalLogout = tr } export function toggleSideBarRightMenuAction() { - dispatch(closeRightHandSide()); - dispatch(closeLhs()); - dispatch(closeRhsMenu()); + return (doDispatch) => { + doDispatch(closeRightHandSide()); + doDispatch(closeLhs()); + doDispatch(closeRhsMenu()); + }; } export function emitBrowserFocus(focus) { diff --git a/actions/global_actions.test.js b/actions/global_actions.test.js index aa4a7ef9c3b0..30abb5acb70a 100644 --- a/actions/global_actions.test.js +++ b/actions/global_actions.test.js @@ -2,8 +2,19 @@ // See LICENSE.txt for license information. import {browserHistory} from 'utils/browser_history'; +import {closeRightHandSide, closeMenu as closeRhsMenu} from 'actions/views/rhs'; +import {close as closeLhs} from 'actions/views/lhs'; -import {redirectUserToDefaultTeam} from 'actions/global_actions.jsx'; +import {redirectUserToDefaultTeam, toggleSideBarRightMenuAction} from 'actions/global_actions.jsx'; + +jest.mock('actions/views/rhs', () => ({ + closeMenu: jest.fn(), + closeRightHandSide: jest.fn(), +})); + +jest.mock('actions/views/lhs', () => ({ + close: jest.fn(), +})); describe('actions/global_actions', () => { test('redirectUserToDefaultTeam', async () => { @@ -11,4 +22,12 @@ describe('actions/global_actions', () => { await redirectUserToDefaultTeam(); expect(browserHistory.push).toHaveBeenCalledWith('/select_team'); }); + + test('toggleSideBarRightMenuAction', () => { + const dispatchMock = () => {}; + toggleSideBarRightMenuAction()(dispatchMock); + expect(closeRhsMenu).toHaveBeenCalled(); + expect(closeRightHandSide).toHaveBeenCalled(); + expect(closeLhs).toHaveBeenCalled(); + }); }); diff --git a/actions/integration_actions.jsx b/actions/integration_actions.jsx index 59793c7958fb..38edd1e96965 100644 --- a/actions/integration_actions.jsx +++ b/actions/integration_actions.jsx @@ -11,9 +11,9 @@ import {openModal} from 'actions/views/modals'; import InteractiveDialog from 'components/interactive_dialog'; import store from 'stores/redux_store.jsx'; -import {Integrations} from 'utils/constants.jsx'; +const DEFAULT_PAGE_SIZE = 100; -export function loadIncomingHooksAndProfilesForTeam(teamId, page = 0, perPage = Integrations.PAGE_SIZE) { +export function loadIncomingHooksAndProfilesForTeam(teamId, page = 0, perPage = DEFAULT_PAGE_SIZE) { return async (dispatch) => { const {data} = await dispatch(IntegrationActions.getIncomingHooks(teamId, page, perPage)); if (data) { @@ -42,7 +42,7 @@ export function loadProfilesForIncomingHooks(hooks) { }; } -export function loadOutgoingHooksAndProfilesForTeam(teamId, page = 0, perPage = Integrations.PAGE_SIZE) { +export function loadOutgoingHooksAndProfilesForTeam(teamId, page = 0, perPage = DEFAULT_PAGE_SIZE) { return async (dispatch) => { const {data} = await dispatch(IntegrationActions.getOutgoingHooks('', teamId, page, perPage)); if (data) { @@ -100,6 +100,35 @@ export function loadProfilesForCommands(commands) { }; } +export function loadOAuthAppsAndProfiles(page = 0, perPage = DEFAULT_PAGE_SIZE) { + return async (dispatch) => { + const {data} = await dispatch(IntegrationActions.getOAuthApps(page, perPage)); + if (data) { + dispatch(loadProfilesForOAuthApps(data)); + } + }; +} + +export function loadProfilesForOAuthApps(apps) { + return async (dispatch, getState) => { + const state = getState(); + const profilesToLoad = {}; + for (let i = 0; i < apps.length; i++) { + const app = apps[i]; + if (!getUser(state, app.creator_id)) { + profilesToLoad[app.creator_id] = true; + } + } + + const list = Object.keys(profilesToLoad); + if (list.length === 0) { + return; + } + + dispatch(getProfilesByIds(list)); + }; +} + export function getYoutubeVideoInfo(googleKey, videoId, success, error) { request.get('https://www.googleapis.com/youtube/v3/videos'). query({part: 'snippet', id: videoId, key: googleKey}). diff --git a/actions/integration_actions.test.js b/actions/integration_actions.test.js index eb3ea70a672c..08c575da5e57 100644 --- a/actions/integration_actions.test.js +++ b/actions/integration_actions.test.js @@ -93,4 +93,24 @@ describe('actions/integration_actions', () => { expect(getProfilesByIds).not.toHaveBeenCalled(); }); }); + + describe('loadProfilesForOAuthApps', () => { + test('load profiles for apps including user we already have', () => { + const testStore = mockStore(initialState); + testStore.dispatch(Actions.loadProfilesForOAuthApps([{creator_id: 'current_user_id'}, {creator_id: 'user_id2'}])); + expect(getProfilesByIds).toHaveBeenCalledWith(expect.arrayContainingExactly(['user_id2'])); + }); + + test('load profiles for apps including only users we don\'t have', () => { + const testStore = mockStore(initialState); + testStore.dispatch(Actions.loadProfilesForOAuthApps([{creator_id: 'user_id1'}, {creator_id: 'user_id2'}])); + expect(getProfilesByIds).toHaveBeenCalledWith(expect.arrayContainingExactly(['user_id1', 'user_id2'])); + }); + + test('load profiles for empty apps', () => { + const testStore = mockStore(initialState); + testStore.dispatch(Actions.loadProfilesForOAuthApps([])); + expect(getProfilesByIds).not.toHaveBeenCalled(); + }); + }); }); \ No newline at end of file diff --git a/actions/post_actions.jsx b/actions/post_actions.jsx index 72a92ebced42..b6ef1c2e7ee7 100644 --- a/actions/post_actions.jsx +++ b/actions/post_actions.jsx @@ -15,7 +15,6 @@ import {addRecentEmoji} from 'actions/emoji_actions'; import * as StorageActions from 'actions/storage'; import {loadNewDMIfNeeded, loadNewGMIfNeeded} from 'actions/user_actions.jsx'; import * as RhsActions from 'actions/views/rhs'; -import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; import {isEmbedVisible} from 'selectors/posts'; import {getSelectedPostId, getRhsState} from 'selectors/rhs'; import { @@ -62,24 +61,7 @@ export function flagPost(postId) { const rhsState = getRhsState(state); if (rhsState === RHSStates.FLAG) { - const results = state.entities.search.results; - const index = results.indexOf(postId); - if (index === -1) { - const flaggedPost = PostSelectors.getPost(state, postId); - const posts = getPostsForIds(state, results).reduce((acc, post) => { - acc[post.id] = post; - return acc; - }, {}); - posts[flaggedPost.id] = flaggedPost; - - const newResults = [...results, postId]; - newResults.sort((a, b) => comparePosts(posts[a], posts[b])); - - dispatch({ - type: SearchTypes.RECEIVED_SEARCH_POSTS, - data: {posts, order: newResults}, - }); - } + addPostToSearchResults(postId, state, dispatch); } return {data: true}; @@ -93,19 +75,7 @@ export function unflagPost(postId) { const rhsState = getRhsState(state); if (rhsState === RHSStates.FLAG) { - let results = state.entities.search.results; - const index = results.indexOf(postId); - if (index > -1) { - results = [...results]; - results.splice(index, 1); - - const posts = getPostsForIds(state, results); - - dispatch({ - type: SearchTypes.RECEIVED_SEARCH_POSTS, - data: {posts, order: results}, - }); - } + removePostFromSearchResults(postId, state, dispatch); } return {data: true}; @@ -220,25 +190,64 @@ export function searchForTerm(term) { }; } +function addPostToSearchResults(postId, state, dispatch) { + const results = state.entities.search.results; + const index = results.indexOf(postId); + if (index === -1) { + const newPost = PostSelectors.getPost(state, postId); + const posts = getPostsForIds(state, results).reduce((acc, post) => { + acc[post.id] = post; + return acc; + }, {}); + posts[newPost.id] = newPost; + + const newResults = [...results, postId]; + newResults.sort((a, b) => comparePosts(posts[a], posts[b])); + + dispatch({ + type: SearchTypes.RECEIVED_SEARCH_POSTS, + data: {posts, order: newResults}, + }); + } +} + +function removePostFromSearchResults(postId, state, dispatch) { + let results = state.entities.search.results; + const index = results.indexOf(postId); + if (index > -1) { + results = [...results]; + results.splice(index, 1); + + const posts = getPostsForIds(state, results); + + dispatch({ + type: SearchTypes.RECEIVED_SEARCH_POSTS, + data: {posts, order: results}, + }); + } +} + export function pinPost(postId) { - return async (dispatch) => { + return async (dispatch, getState) => { await dispatch(PostActions.pinPost(postId)); + const state = getState(); + const rhsState = getRhsState(state); - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_POST_PINNED, - postId, - }); + if (rhsState === RHSStates.PIN) { + addPostToSearchResults(postId, state, dispatch); + } }; } export function unpinPost(postId) { - return async (dispatch) => { + return async (dispatch, getState) => { await dispatch(PostActions.unpinPost(postId)); + const state = getState(); + const rhsState = getRhsState(state); - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_POST_UNPINNED, - postId, - }); + if (rhsState === RHSStates.PIN) { + removePostFromSearchResults(postId, state, dispatch); + } }; } @@ -305,12 +314,6 @@ export function deleteAndRemovePost(post) { data: post, }); - // Needed for search store - AppDispatcher.handleViewAction({ - type: Constants.ActionTypes.REMOVE_POST, - post, - }); - return {data: true}; }; } diff --git a/actions/post_actions.test.js b/actions/post_actions.test.js index cfbd6b6e22ab..4112032d3bdf 100644 --- a/actions/post_actions.test.js +++ b/actions/post_actions.test.js @@ -5,9 +5,10 @@ import thunk from 'redux-thunk'; import configureStore from 'redux-mock-store'; import {Posts} from 'mattermost-redux/constants'; +import {SearchTypes} from 'mattermost-redux/action_types'; import * as Actions from 'actions/post_actions'; -import {Constants, ActionTypes} from 'utils/constants'; +import {Constants, ActionTypes, RHSStates} from 'utils/constants'; const mockStore = configureStore([thunk]); @@ -17,6 +18,10 @@ jest.mock('mattermost-redux/actions/posts', () => ({ createPostImmediately: (...args) => ({type: 'MOCK_CREATE_POST_IMMEDIATELY', args}), getPosts: (...args) => ({type: 'MOCK_GET_POSTS', args}), getPostsBefore: (...args) => ({type: 'MOCK_GET_POSTS_BEFORE', args}), + flagPost: (...args) => ({type: 'MOCK_FLAG_POST', args}), + unflagPost: (...args) => ({type: 'MOCK_UNFLAG_POST', args}), + pinPost: (...args) => ({type: 'MOCK_PIN_POST', args}), + unpinPost: (...args) => ({type: 'MOCK_UNPIN_POST', args}), })); jest.mock('actions/emoji_actions', () => ({ @@ -35,12 +40,14 @@ jest.mock('utils/user_agent', () => ({ isIosClassic: jest.fn().mockReturnValueOnce(true).mockReturnValue(false), })); +const POST_CREATED_TIME = Date.now(); const RECEIVED_POSTS = { channelId: 'current_channel_id', - data: {order: [], posts: {new_post_id: {channel_id: 'current_channel_id', id: 'new_post_id', message: 'new message', type: ''}}}, + data: {order: [], posts: {new_post_id: {channel_id: 'current_channel_id', id: 'new_post_id', message: 'new message', type: '', user_id: 'some_user_id', create_at: POST_CREATED_TIME}}}, type: 'RECEIVED_POSTS', }; const INCREASED_POST_VISIBILITY = {amount: 1, data: 'current_channel_id', type: 'INCREASE_POST_VISIBILITY'}; +const STOP_TYPING = {type: 'stop_typing', data: {id: 'current_channel_idundefined', now: POST_CREATED_TIME, userId: 'some_user_id'}}; function getReceivedPosts(post) { const receivedPosts = {...RECEIVED_POSTS}; @@ -144,6 +151,7 @@ describe('Actions.Posts', () => { }, }, emojis: {customEmoji: {}}, + search: {results: []}, }, views: { posts: { @@ -159,11 +167,18 @@ describe('Actions.Posts', () => { test('handleNewPost', async () => { const testStore = await mockStore(initialState); - const newPost = {id: 'new_post_id', channel_id: 'current_channel_id', message: 'new message', type: Constants.PostTypes.ADD_TO_CHANNEL}; + const newPost = {id: 'new_post_id', channel_id: 'current_channel_id', message: 'new message', type: Constants.PostTypes.ADD_TO_CHANNEL, user_id: 'some_user_id', create_at: POST_CREATED_TIME}; const msg = {data: {team_id: 'team_id', mentions: ['current_user_id']}}; await testStore.dispatch(Actions.handleNewPost(newPost, msg)); - expect(testStore.getActions()).toEqual([INCREASED_POST_VISIBILITY, getReceivedPosts(newPost)]); + expect(testStore.getActions()).toEqual([ + INCREASED_POST_VISIBILITY, + { + meta: {batch: true}, + payload: [getReceivedPosts(newPost), STOP_TYPING], + type: 'BATCHING_REDUCER.BATCH', + }, + ]); }); test('setEditingPost', async () => { @@ -306,4 +321,52 @@ describe('Actions.Posts', () => { {args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'}, ]); }); + + test('flagPost', async () => { + const testStore = await mockStore({...initialState, views: {rhs: {rhsState: RHSStates.FLAG}}}); + + const post = testStore.getState().entities.posts.posts[latestPost.id]; + + await testStore.dispatch(Actions.flagPost(post.id)); + expect(testStore.getActions()).toEqual([ + {args: [post.id], type: 'MOCK_FLAG_POST'}, + {data: {posts: {[post.id]: post}, order: [post.id]}, type: SearchTypes.RECEIVED_SEARCH_POSTS}, + ]); + }); + + test('unflagPost', async () => { + const testStore = await mockStore({views: {rhs: {rhsState: RHSStates.FLAG}}, entities: {...initialState.entities, search: {results: [latestPost.id]}}}); + + const post = testStore.getState().entities.posts.posts[latestPost.id]; + + await testStore.dispatch(Actions.unflagPost(post.id)); + expect(testStore.getActions()).toEqual([ + {args: [post.id], type: 'MOCK_UNFLAG_POST'}, + {data: {posts: [], order: []}, type: SearchTypes.RECEIVED_SEARCH_POSTS}, + ]); + }); + + test('pinPost', async () => { + const testStore = await mockStore({...initialState, views: {rhs: {rhsState: RHSStates.PIN}}}); + + const post = testStore.getState().entities.posts.posts[latestPost.id]; + + await testStore.dispatch(Actions.pinPost(post.id)); + expect(testStore.getActions()).toEqual([ + {args: [post.id], type: 'MOCK_PIN_POST'}, + {data: {posts: {[post.id]: post}, order: [post.id]}, type: SearchTypes.RECEIVED_SEARCH_POSTS}, + ]); + }); + + test('unpinPost', async () => { + const testStore = await mockStore({views: {rhs: {rhsState: RHSStates.PIN}}, entities: {...initialState.entities, search: {results: [latestPost.id]}}}); + + const post = testStore.getState().entities.posts.posts[latestPost.id]; + + await testStore.dispatch(Actions.unpinPost(post.id)); + expect(testStore.getActions()).toEqual([ + {args: [post.id], type: 'MOCK_UNPIN_POST'}, + {data: {posts: [], order: []}, type: SearchTypes.RECEIVED_SEARCH_POSTS}, + ]); + }); }); diff --git a/actions/post_utils.js b/actions/post_utils.js index 0a7286c6ac7f..c43e9ae635b9 100644 --- a/actions/post_utils.js +++ b/actions/post_utils.js @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {batchActions} from 'redux-batched-actions'; import {PostTypes} from 'mattermost-redux/action_types'; import { markChannelAsRead, @@ -8,6 +9,7 @@ import { markChannelAsViewed, } from 'mattermost-redux/actions/channels'; import * as PostActions from 'mattermost-redux/actions/posts'; +import {WebsocketEvents} from 'mattermost-redux/constants'; import * as PostSelectors from 'mattermost-redux/selectors/entities/posts'; import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; @@ -52,7 +54,7 @@ export function lastPostActions(post, websocketMessageProps) { } // Need manual dispatch to remove pending post - dispatch({ + const actions = [{ type: PostTypes.RECEIVED_POSTS, data: { order: [], @@ -61,7 +63,16 @@ export function lastPostActions(post, websocketMessageProps) { }, }, channelId: post.channel_id, - }); + }, { + type: WebsocketEvents.STOP_TYPING, + data: { + id: post.channel_id + post.root_id, + userId: post.user_id, + now: post.create_at, + }, + }]; + + dispatch(batchActions(actions)); // Still needed to update unreads diff --git a/actions/post_utils.test.js b/actions/post_utils.test.js index 1a9e5dd3486c..ef10c2d9464b 100644 --- a/actions/post_utils.test.js +++ b/actions/post_utils.test.js @@ -30,12 +30,14 @@ const MARK_CHANNEL_AS_VIEWED = { args: ['current_channel_id'], type: 'MOCK_MARK_CHANNEL_AS_VIEWED', }; +const POST_CREATED_TIME = Date.now(); const RECEIVED_POSTS = { channelId: 'current_channel_id', - data: {order: [], posts: {new_post_id: {channel_id: 'current_channel_id', id: 'new_post_id', message: 'new message', type: ''}}}, + data: {order: [], posts: {new_post_id: {channel_id: 'current_channel_id', id: 'new_post_id', message: 'new message', type: '', user_id: 'some_user_id', create_at: POST_CREATED_TIME}}}, type: 'RECEIVED_POSTS', }; const INCREASED_POST_VISIBILITY = {amount: 1, data: 'current_channel_id', type: 'INCREASE_POST_VISIBILITY'}; +const STOP_TYPING = {type: 'stop_typing', data: {id: 'current_channel_idundefined', now: POST_CREATED_TIME, userId: 'some_user_id'}}; function getReceivedPosts(post) { const receivedPosts = {...RECEIVED_POSTS}; @@ -98,20 +100,34 @@ describe('actions/post_utils', () => { test('completePostReceive', async () => { const testStore = await mockStore(initialState); - const newPost = {id: 'new_post_id', channel_id: 'current_channel_id', message: 'new message', type: Constants.PostTypes.ADD_TO_CHANNEL}; + const newPost = {id: 'new_post_id', channel_id: 'current_channel_id', message: 'new message', type: Constants.PostTypes.ADD_TO_CHANNEL, user_id: 'some_user_id', create_at: POST_CREATED_TIME}; const websocketProps = {team_id: 'team_id', mentions: ['current_user_id']}; await testStore.dispatch(PostActionsUtils.completePostReceive(newPost, websocketProps)); - expect(testStore.getActions()).toEqual([INCREASED_POST_VISIBILITY, getReceivedPosts(newPost)]); + expect(testStore.getActions()).toEqual([ + INCREASED_POST_VISIBILITY, + { + meta: {batch: true}, + payload: [getReceivedPosts(newPost), STOP_TYPING], + type: 'BATCHING_REDUCER.BATCH', + }, + ]); }); test('lastPostActions', async () => { const testStore = await mockStore(initialState); - const newPost = {id: 'new_post_id', channel_id: 'current_channel_id', message: 'new message', type: Constants.PostTypes.ADD_TO_CHANNEL}; + const newPost = {id: 'new_post_id', channel_id: 'current_channel_id', message: 'new message', type: Constants.PostTypes.ADD_TO_CHANNEL, user_id: 'some_user_id', create_at: POST_CREATED_TIME}; const websocketProps = {team_id: 'team_id', mentions: ['current_user_id']}; await testStore.dispatch(PostActionsUtils.lastPostActions(newPost, websocketProps)); - expect(testStore.getActions()).toEqual([INCREASED_POST_VISIBILITY, getReceivedPosts(newPost)]); + expect(testStore.getActions()).toEqual([ + INCREASED_POST_VISIBILITY, + { + meta: {batch: true}, + payload: [getReceivedPosts(newPost), STOP_TYPING], + type: 'BATCHING_REDUCER.BATCH', + }, + ]); }); test('setChannelReadAndView', async () => { diff --git a/actions/team_actions.jsx b/actions/team_actions.jsx index 868245a7a941..046ff4b1b465 100644 --- a/actions/team_actions.jsx +++ b/actions/team_actions.jsx @@ -9,7 +9,7 @@ import {getUser} from 'mattermost-redux/actions/users'; import {browserHistory} from 'utils/browser_history'; -export function removeUserFromTeam(teamId, userId) { +export function removeUserFromTeamAndGetStats(teamId, userId) { return async (dispatch, getState) => { const response = await dispatch(TeamActions.removeUserFromTeam(teamId, userId)); dispatch(getUser(userId)); diff --git a/tests/redux/actions/team_actions.test.js b/actions/team_actions.test.js similarity index 95% rename from tests/redux/actions/team_actions.test.js rename to actions/team_actions.test.js index ec083791095d..e37b53289495 100644 --- a/tests/redux/actions/team_actions.test.js +++ b/actions/team_actions.test.js @@ -88,8 +88,8 @@ describe('Actions.Team', () => { expect(TeamActions.addUsersToTeam).toHaveBeenCalledWith('teamId', ['123', '1234']); }); - test('removeUserFromTeam', async () => { - await testStore.dispatch(Actions.removeUserFromTeam('teamId', '123')); + test('removeUserFromTeamAndGetStats', async () => { + await testStore.dispatch(Actions.removeUserFromTeamAndGetStats('teamId', '123')); expect(userActions.getUser).toHaveBeenCalledWith('123'); expect(TeamActions.getTeamStats).toHaveBeenCalledWith('teamId'); expect(channelActions.getChannelStats).toHaveBeenCalledWith('currentChannelId'); diff --git a/actions/user_actions.jsx b/actions/user_actions.jsx index cab8b8ab7bd4..2081d05a73a0 100644 --- a/actions/user_actions.jsx +++ b/actions/user_actions.jsx @@ -39,45 +39,59 @@ export async function switchFromLdapToEmail(email, password, token, ldapPassword } } -export async function loadProfilesAndTeamMembers(page, perPage, teamId = getCurrentTeamId(getState()), success) { - const {data} = await UserActions.getProfilesInTeam(teamId, page, perPage)(dispatch, getState); - loadTeamMembersForProfilesList(data, teamId, success); - dispatch(loadStatusesForProfilesList(data)); -} +export function loadProfilesAndTeamMembers(page, perPage, teamId) { + return async (doDispatch, doGetState) => { + const newTeamId = teamId || getCurrentTeamId(doGetState()); + const {data} = await doDispatch(UserActions.getProfilesInTeam(newTeamId, page, perPage)); + if (data) { + doDispatch(loadTeamMembersForProfilesList(data, newTeamId)); + doDispatch(loadStatusesForProfilesList(data)); + } -export async function loadProfilesAndTeamMembersAndChannelMembers(page, perPage, teamId = getCurrentTeamId(getState()), channelId = getCurrentChannelId(getState()), success, error) { - const {data} = await UserActions.getProfilesInChannel(channelId, page, perPage)(dispatch, getState); + return {data: true}; + }; +} - loadTeamMembersForProfilesList( - data, - teamId, - () => { - loadChannelMembersForProfilesList(data, channelId, success, error); - dispatch(loadStatusesForProfilesList(data)); +export function loadProfilesAndTeamMembersAndChannelMembers(page, perPage, teamId, channelId) { + return async (doDispatch, doGetState) => { + const state = doGetState(); + const teamIdParam = teamId || getCurrentTeamId(state); + const channelIdParam = channelId || getCurrentChannelId(state); + const {data} = await doDispatch(UserActions.getProfilesInChannel(channelIdParam, page, perPage)); + if (data) { + const {data: listData} = await doDispatch(loadTeamMembersForProfilesList(data, teamIdParam)); + if (listData) { + doDispatch(loadChannelMembersForProfilesList(data, channelIdParam)); + doDispatch(loadStatusesForProfilesList(data)); + } } - ); -} -export function loadTeamMembersForProfilesList(profiles, teamId = getCurrentTeamId(getState()), success, error) { - const state = getState(); - const membersToLoad = {}; - for (let i = 0; i < profiles.length; i++) { - const pid = profiles[i].id; + return {data: true}; + }; +} - if (!getTeamMember(state, teamId, pid)) { - membersToLoad[pid] = true; +export function loadTeamMembersForProfilesList(profiles, teamId) { + return async (doDispatch, doGetState) => { + const state = doGetState(); + const teamIdParam = teamId || getCurrentTeamId(state); + const membersToLoad = {}; + for (let i = 0; i < profiles.length; i++) { + const pid = profiles[i].id; + + if (!getTeamMember(state, teamIdParam, pid)) { + membersToLoad[pid] = true; + } } - } - const list = Object.keys(membersToLoad); - if (list.length === 0) { - if (success) { - success({}); + const userIdsToLoad = Object.keys(membersToLoad); + if (userIdsToLoad.length === 0) { + return {data: true}; } - return; - } - loadTeamMembersForProfiles(list, teamId, success, error); + await doDispatch(getTeamMembersByIds(teamIdParam, userIdsToLoad)); + + return {data: true}; + }; } export async function loadProfilesWithoutTeam(page, perPage, success) { @@ -89,78 +103,42 @@ export async function loadProfilesWithoutTeam(page, perPage, success) { } } -async function loadTeamMembersForProfiles(userIds, teamId, success, error) { - const {data, error: err} = await getTeamMembersByIds(teamId, userIds)(dispatch, getState); - - if (data && success) { - success(data); - } else if (err && error) { - error({id: err.server_error_id, ...err}); - } -} - -export function loadChannelMembersForProfilesMap(profiles, channelId = getCurrentChannelId(getState()), success, error) { - const membersToLoad = {}; - for (const pid in profiles) { - if (!profiles.hasOwnProperty(pid)) { - continue; - } - - const members = getChannelMembersInChannels(getState())[channelId]; - if (!members || !members[pid]) { - membersToLoad[pid] = true; - } - } - - const list = Object.keys(membersToLoad); - if (list.length === 0) { - if (success) { - success({}); - } - return; - } - - dispatch(getChannelMembersByIds(channelId, list)).then((result) => { - if (result.error) { - error(result.error); - } else { - success(result.data); +export function loadTeamMembersAndChannelMembersForProfilesList(profiles, teamId, channelId) { + return async (doDispatch, doGetState) => { + const state = doGetState(); + const teamIdParam = teamId || getCurrentTeamId(state); + const channelIdParam = channelId || getCurrentChannelId(state); + const {data} = await doDispatch(loadTeamMembersForProfilesList(profiles, teamIdParam)); + if (data) { + doDispatch(loadChannelMembersForProfilesList(profiles, channelIdParam)); } - }); -} -export function loadTeamMembersAndChannelMembersForProfilesList(profiles, teamId = getCurrentTeamId(getState()), channelId = getCurrentChannelId(getState()), success, error) { - loadTeamMembersForProfilesList(profiles, teamId, () => { - loadChannelMembersForProfilesList(profiles, channelId, success, error); - }, error); + return {data: true}; + }; } -export function loadChannelMembersForProfilesList(profiles, channelId = getCurrentChannelId(getState()), success, error) { - const membersToLoad = {}; - for (let i = 0; i < profiles.length; i++) { - const pid = profiles[i].id; - - const members = getChannelMembersInChannels(getState())[channelId]; - if (!members || !members[pid]) { - membersToLoad[pid] = true; +export function loadChannelMembersForProfilesList(profiles, channelId) { + return async (doDispatch, doGetState) => { + const state = doGetState(); + const channelIdParam = channelId || getCurrentChannelId(state); + const membersToLoad = {}; + for (let i = 0; i < profiles.length; i++) { + const pid = profiles[i].id; + + const members = getChannelMembersInChannels(state)[channelIdParam]; + if (!members || !members[pid]) { + membersToLoad[pid] = true; + } } - } - const list = Object.keys(membersToLoad); - if (list.length === 0) { - if (success) { - success({}); + const list = Object.keys(membersToLoad); + if (list.length === 0) { + return {data: true}; } - return; - } - dispatch(getChannelMembersByIds(channelId, list)).then((result) => { - if (result.error) { - error(result.error); - } else { - success(result.data); - } - }); + await doDispatch(getChannelMembersByIds(channelIdParam, list)); + return {data: true}; + }; } export async function loadNewDMIfNeeded(channelId) { @@ -229,8 +207,8 @@ export async function loadProfilesForGM() { continue; } - const userIds = userIdsInChannels[channel.id] || []; - if (userIds.length >= Constants.MIN_USERS_IN_GM) { + const userIds = userIdsInChannels[channel.id] || new Set(); + if (userIds.size >= Constants.MIN_USERS_IN_GM) { continue; } @@ -331,7 +309,8 @@ function onThemeSaved(teamId, onSuccess) { const toDelete = []; for (const themePreference of themePreferences) { - if (themePreference.name === '' || themePreference.name === teamId) { + const name = themePreference.name; + if (name === '' || name === teamId) { continue; } diff --git a/actions/user_actions.test.js b/actions/user_actions.test.js new file mode 100644 index 000000000000..bd6366ce6962 --- /dev/null +++ b/actions/user_actions.test.js @@ -0,0 +1,177 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import thunk from 'redux-thunk'; +import configureStore from 'redux-mock-store'; + +import * as UserActions from 'actions/user_actions'; + +const mockStore = configureStore([thunk]); + +jest.mock('mattermost-redux/actions/users', () => { + const original = require.requireActual('mattermost-redux/actions/users'); + return { + ...original, + getProfilesInTeam: (...args) => ({type: 'MOCK_GET_PROFILES_IN_TEAM', args}), + getProfilesInChannel: (...args) => ({type: 'MOCK_GET_PROFILES_IN_CHANNEL', args}), + }; +}); + +jest.mock('mattermost-redux/actions/teams', () => { + const original = require.requireActual('mattermost-redux/actions/teams'); + return { + ...original, + getTeamMembersByIds: (...args) => ({type: 'MOCK_GET_TEAM_MEMBERS_BY_IDS', args}), + }; +}); + +jest.mock('mattermost-redux/actions/channels', () => { + const original = require.requireActual('mattermost-redux/actions/channels'); + return { + ...original, + getChannelMembersByIds: (...args) => ({type: 'MOCK_GET_CHANNEL_MEMBERS_BY_IDS', args}), + }; +}); + +describe('Actions.User', () => { + const initialState = { + entities: { + channels: { + currentChannelId: 'current_channel_id', + myMembers: { + current_channel_id: { + channel_id: 'current_channel_id', + user_id: 'current_user_id', + roles: 'channel_role', + mention_count: 1, + msg_count: 9, + }, + }, + channels: { + current_channel_id: { + total_msg_count: 10, + team_id: 'team_id', + }, + }, + channelsInTeam: { + team_id: ['current_channel_id'], + }, + membersInChannel: { + current_channel_id: { + current_user_id: {id: 'current_user_id'}, + }, + }, + }, + teams: { + currentTeamId: 'team_id', + teams: { + team_id: { + id: 'team_id', + name: 'team-1', + displayName: 'Team 1', + }, + }, + myMembers: { + team_id: {roles: 'team_role'}, + }, + membersInTeam: { + team_id: { + current_user_id: {id: 'current_user_id'}, + }, + }, + }, + }, + }; + + test('loadProfilesAndTeamMembers', async () => { + const expectedActions = [{type: 'MOCK_GET_PROFILES_IN_TEAM', args: ['team_id', 0, 60]}]; + + let testStore = await mockStore({}); + await testStore.dispatch(UserActions.loadProfilesAndTeamMembers(0, 60, 'team_id')); + let actualActions = testStore.getActions(); + expect(actualActions[0].args).toEqual(expectedActions[0].args); + expect(actualActions[0].type).toEqual(expectedActions[0].type); + + testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadProfilesAndTeamMembers(0, 60)); + actualActions = testStore.getActions(); + expect(actualActions[0].args).toEqual(expectedActions[0].args); + expect(actualActions[0].type).toEqual(expectedActions[0].type); + }); + + test('loadProfilesAndTeamMembersAndChannelMembers', async () => { + const expectedActions = [{type: 'MOCK_GET_PROFILES_IN_CHANNEL', args: ['current_channel_id', 0, 60]}]; + + let testStore = await mockStore({}); + await testStore.dispatch(UserActions.loadProfilesAndTeamMembersAndChannelMembers(0, 60, 'team_id', 'current_channel_id')); + let actualActions = testStore.getActions(); + expect(actualActions[0].args).toEqual(expectedActions[0].args); + expect(actualActions[0].type).toEqual(expectedActions[0].type); + + testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadProfilesAndTeamMembersAndChannelMembers(0, 60)); + actualActions = testStore.getActions(); + expect(actualActions[0].args).toEqual(expectedActions[0].args); + expect(actualActions[0].type).toEqual(expectedActions[0].type); + }); + + test('loadTeamMembersForProfilesList', async () => { + const expectedActions = [{args: ['team_id', ['other_user_id']], type: 'MOCK_GET_TEAM_MEMBERS_BY_IDS'}]; + + // should call getTeamMembersByIds since 'other_user_id' is not loaded yet + let testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadTeamMembersForProfilesList([{id: 'other_user_id'}], 'team_id')); + expect(testStore.getActions()).toEqual(expectedActions); + + // should not call getTeamMembersByIds since 'current_user_id' is already loaded + testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadTeamMembersForProfilesList([{id: 'current_user_id'}], 'team_id')); + expect(testStore.getActions()).toEqual([]); + + // should not call getTeamMembersByIds since no or empty profile is passed + testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadTeamMembersForProfilesList([], 'team_id')); + expect(testStore.getActions()).toEqual([]); + }); + + test('loadTeamMembersAndChannelMembersForProfilesList', async () => { + const expectedActions = [ + {args: ['team_id', ['other_user_id']], type: 'MOCK_GET_TEAM_MEMBERS_BY_IDS'}, + {args: ['current_channel_id', ['other_user_id']], type: 'MOCK_GET_CHANNEL_MEMBERS_BY_IDS'}, + ]; + + // should call getTeamMembersByIds and getChannelMembersByIds since 'other_user_id' is not loaded yet + let testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadTeamMembersAndChannelMembersForProfilesList([{id: 'other_user_id'}], 'team_id', 'current_channel_id')); + expect(testStore.getActions()).toEqual(expectedActions); + + // should not call getTeamMembersByIds/getChannelMembersByIds since 'current_user_id' is already loaded + testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadTeamMembersForProfilesList([{id: 'current_user_id'}], 'team_id', 'current_channel_id')); + expect(testStore.getActions()).toEqual([]); + + // should not call getTeamMembersByIds/getChannelMembersByIds since no or empty profile is passed + testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadTeamMembersForProfilesList([], 'team_id', 'current_channel_id')); + expect(testStore.getActions()).toEqual([]); + }); + + test('loadChannelMembersForProfilesList', async () => { + const expectedActions = [{args: ['current_channel_id', ['other_user_id']], type: 'MOCK_GET_CHANNEL_MEMBERS_BY_IDS'}]; + + // should call getChannelMembersByIds since 'other_user_id' is not loaded yet + let testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadChannelMembersForProfilesList([{id: 'other_user_id'}], 'current_channel_id')); + expect(testStore.getActions()).toEqual(expectedActions); + + // should not call getChannelMembersByIds since 'current_user_id' is already loaded + testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadChannelMembersForProfilesList([{id: 'current_user_id'}], 'current_channel_id')); + expect(testStore.getActions()).toEqual([]); + + // should not call getChannelMembersByIds since no or empty profile is passed + testStore = await mockStore(initialState); + await testStore.dispatch(UserActions.loadChannelMembersForProfilesList([], 'current_channel_id')); + expect(testStore.getActions()).toEqual([]); + }); +}); diff --git a/actions/views/channel.js b/actions/views/channel.js index cf761b9a4c57..6a24bd18a25a 100644 --- a/actions/views/channel.js +++ b/actions/views/channel.js @@ -1,10 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {leaveChannel as leaveChannelRedux, unfavoriteChannel} from 'mattermost-redux/actions/channels'; -import {getChannel, getChannelByName, getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; +import {leaveChannel as leaveChannelRedux, joinChannel, unfavoriteChannel} from 'mattermost-redux/actions/channels'; +import {getChannel, getChannelByName, getCurrentChannel, getDefaultChannel} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentRelativeTeamUrl, getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; -import {getUserByUsername} from 'mattermost-redux/selectors/entities/users'; +import {getCurrentUserId, getUserByUsername} from 'mattermost-redux/selectors/entities/users'; import {getMyPreferences} from 'mattermost-redux/selectors/entities/preferences'; import {isFavoriteChannel} from 'mattermost-redux/utils/channel_utils'; import {autocompleteUsers} from 'mattermost-redux/actions/users'; @@ -13,7 +13,7 @@ import {openDirectChannelToUserId} from 'actions/channel_actions.jsx'; import {getLastViewedChannelName} from 'selectors/local_storage'; import {browserHistory} from 'utils/browser_history'; -import {ActionTypes} from 'utils/constants.jsx'; +import {Constants, ActionTypes} from 'utils/constants.jsx'; import {isMobile} from 'utils/utils.jsx'; export function checkAndSetMobileView() { @@ -28,8 +28,14 @@ export function checkAndSetMobileView() { export function goToLastViewedChannel() { return async (dispatch, getState) => { const state = getState(); - const lastViewedChannel = getChannelByName(state, getLastViewedChannelName(state)); - return dispatch(switchToChannel(lastViewedChannel)); + const currentChannel = getCurrentChannel(state); + let channelToSwitchTo = getChannelByName(state, getLastViewedChannelName(state)); + + if (currentChannel.id === channelToSwitchTo.id) { + channelToSwitchTo = getDefaultChannel(state); + } + + return dispatch(switchToChannel(channelToSwitchTo)); }; } @@ -58,6 +64,9 @@ export function switchToChannel(channel) { return {error: true}; } browserHistory.push(`${teamUrl}/messages/@${channel.name}`); + } else if (channel.type === Constants.GM_CHANNEL) { + const gmChannel = getChannel(state, channel.id); + browserHistory.push(`${teamUrl}/channels/${gmChannel.name}`); } else { browserHistory.push(`${teamUrl}/channels/${channel.name}`); } @@ -66,6 +75,16 @@ export function switchToChannel(channel) { }; } +export function joinChannelById(channelId) { + return async (dispatch, getState) => { + const state = getState(); + const currentUserId = getCurrentUserId(state); + const currentTeamId = getCurrentTeamId(state); + + return dispatch(joinChannel(currentUserId, currentTeamId, channelId)); + }; +} + export function leaveChannel(channelId) { return async (dispatch, getState) => { const state = getState(); @@ -75,6 +94,9 @@ export function leaveChannel(channelId) { dispatch(unfavoriteChannel(channelId)); } + const teamUrl = getCurrentRelativeTeamUrl(state); + browserHistory.push(teamUrl + '/channels/' + Constants.DEFAULT_CHANNEL); + const {error} = await dispatch(leaveChannelRedux(channelId)); if (error) { return {error}; @@ -86,13 +108,12 @@ export function leaveChannel(channelId) { }; } -export function autocompleteUsersInChannel(prefix) { +export function autocompleteUsersInChannel(prefix, channelId) { return async (dispatch, getState) => { const state = getState(); const currentTeamId = getCurrentTeamId(state); - const currentChannelId = getCurrentChannelId(state); - return dispatch(autocompleteUsers(prefix, currentTeamId, currentChannelId)); + return dispatch(autocompleteUsers(prefix, currentTeamId, channelId)); }; } diff --git a/actions/views/channel.test.js b/actions/views/channel.test.js new file mode 100644 index 000000000000..bd0c531d44b5 --- /dev/null +++ b/actions/views/channel.test.js @@ -0,0 +1,107 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import configureStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import {General} from 'mattermost-redux/constants'; +import {leaveChannel} from 'mattermost-redux/actions/channels'; + +import {browserHistory} from 'utils/browser_history'; +import * as Actions from 'actions/views/channel'; +import {openDirectChannelToUserId} from 'actions/channel_actions.jsx'; + +const mockStore = configureStore([thunk]); + +jest.mock('utils/browser_history', () => ({ + browserHistory: { + push: jest.fn(), + }, +})); + +jest.mock('actions/channel_actions.jsx', () => ({ + openDirectChannelToUserId: jest.fn(() => { + return {type: ''}; + }), +})); + +jest.mock('mattermost-redux/actions/channels', () => ({ + leaveChannel: jest.fn(() => { + return {type: ''}; + }), +})); + +jest.mock('selectors/local_storage', () => ({ + getLastViewedChannelName: () => 'channel1', +})); + +describe('channel view actions', () => { + const channel1 = {id: 'channelid1', name: 'channel1', display_name: 'Channel 1', type: 'O', team_id: 'teamid1'}; + const townsquare = {id: 'channelid2', name: General.DEFAULT_CHANNEL, display_name: 'Town Square', type: 'O', team_id: 'teamid1'}; + const gmChannel = {id: 'gmchannelid', name: 'gmchannel', display_name: 'GM Channel 1', type: 'G'}; + const team1 = {id: 'teamid1', name: 'team1'}; + + const initialState = { + entities: { + users: { + currentUserId: 'userid1', + profiles: {userid1: {id: 'userid1', username: 'username1'}, userid2: {id: 'userid2', username: 'username2'}}, + profilesInChannel: {}, + }, + teams: { + currentTeamId: 'teamid1', + teams: {teamid1: team1}, + }, + channels: { + currentChannelId: 'channelid1', + channels: {channelid1: channel1, channelid2: townsquare, gmchannelid: gmChannel}, + myMembers: {gmchannelid: {channel_id: 'gmchannelid', user_id: 'userid1'}}, + }, + general: { + config: {}, + }, + preferences: { + myPreferences: {}, + }, + }, + }; + + let store; + + beforeEach(() => { + store = mockStore(initialState); + }); + + describe('switchToChannel', () => { + test('switch to public channel', () => { + store.dispatch(Actions.switchToChannel(channel1)); + expect(browserHistory.push).toHaveBeenCalledWith(`/${team1.name}/channels/${channel1.name}`); + }); + + test('switch to fake direct channel', async () => { + await store.dispatch(Actions.switchToChannel({fake: true, userId: 'userid2', name: 'username2'})); + expect(openDirectChannelToUserId).toHaveBeenCalledWith('userid2'); + expect(browserHistory.push).toHaveBeenCalledWith(`/${team1.name}/messages/@username2`); + }); + + test('switch to gm channel', async () => { + await store.dispatch(Actions.switchToChannel(gmChannel)); + expect(browserHistory.push).toHaveBeenCalledWith(`/${team1.name}/channels/${gmChannel.name}`); + }); + }); + + describe('leaveChannel', () => { + test('leave a channel successfully', async () => { + await store.dispatch(Actions.leaveChannel('channelid')); + expect(browserHistory.push).toHaveBeenCalledWith(`/${team1.name}/channels/town-square`); + expect(leaveChannel).toHaveBeenCalledWith('channelid'); + }); + }); + + describe('goToLastViewedChannel', () => { + test('should switch to town square if last viewed channel is current channel', async () => { + await store.dispatch(Actions.goToLastViewedChannel()); + expect(browserHistory.push).toHaveBeenCalledWith(`/${team1.name}/channels/${General.DEFAULT_CHANNEL}`); + }); + }); +}); diff --git a/actions/views/create_comment.jsx b/actions/views/create_comment.jsx index afab3773263b..8264cb88709b 100644 --- a/actions/views/create_comment.jsx +++ b/actions/views/create_comment.jsx @@ -16,7 +16,6 @@ import {Posts} from 'mattermost-redux/constants'; import {isPostPendingOrFailed} from 'mattermost-redux/utils/post_utils'; import * as PostActions from 'actions/post_actions.jsx'; -import * as GlobalActions from 'actions/global_actions.jsx'; import {executeCommand} from 'actions/command'; import {setGlobalItem, actionOnGlobalItemsWithPrefix} from 'actions/storage'; import EmojiMap from 'utils/emoji_map'; @@ -78,8 +77,6 @@ export function submitPost(channelId, rootId, draft) { create_at: time, }; - GlobalActions.emitUserCommentedEvent(post); - dispatch(PostActions.createPost(post, draft.fileInfos)); }; } diff --git a/actions/views/create_comment.test.jsx b/actions/views/create_comment.test.jsx index 0d691824e5e1..c4cd16b17e59 100644 --- a/actions/views/create_comment.test.jsx +++ b/actions/views/create_comment.test.jsx @@ -22,7 +22,6 @@ import { } from 'actions/views/create_comment'; import {setGlobalItem, actionOnGlobalItemsWithPrefix} from 'actions/storage'; import * as PostActions from 'actions/post_actions.jsx'; -import * as GlobalActions from 'actions/global_actions.jsx'; import {executeCommand} from 'actions/command'; import {StoragePrefixes} from 'utils/constants'; @@ -107,15 +106,22 @@ describe('rhs view actions', () => { emojis: { customEmoji: {}, }, + general: { + config: { + EnableCustomEmoji: 'true', + }, + }, }, storage: { - [`${StoragePrefixes.COMMENT_DRAFT}${latestPostId}`]: { - value: { - message: '', - fileInfos: [], - uploadsInProgress: [], + storage: { + [`${StoragePrefixes.COMMENT_DRAFT}${latestPostId}`]: { + value: { + message: '', + fileInfos: [], + uploadsInProgress: [], + }, + timestamp: new Date(), }, - timestamp: new Date(), }, }, }; @@ -207,16 +213,6 @@ describe('rhs view actions', () => { user_id: currentUserId, }; - test('it calls GlobalActions.emitUserCommentedEvent with post', () => { - store.dispatch(submitPost(channelId, rootId, draft)); - - expect(GlobalActions.emitUserCommentedEvent).toHaveBeenCalled(); - - expect(lastCall(GlobalActions.emitUserCommentedEvent.mock.calls)[0]).toEqual( - expect.objectContaining(post) - ); - }); - test('it call PostActions.createPost with post', () => { store.dispatch(submitPost(channelId, rootId, draft)); @@ -351,13 +347,15 @@ describe('rhs view actions', () => { store = mockStore({ ...initialState, storage: { - [`${StoragePrefixes.COMMENT_DRAFT}${latestPostId}`]: { - value: { - message: 'test msg', - fileInfos: [], - uploadsInProgress: [], + storage: { + [`${StoragePrefixes.COMMENT_DRAFT}${latestPostId}`]: { + value: { + message: 'test msg', + fileInfos: [], + uploadsInProgress: [], + }, + timestamp: new Date(), }, - timestamp: new Date(), }, }, }); diff --git a/actions/views/posts.js b/actions/views/posts.js index f1a00a61c4e5..300032d64af7 100644 --- a/actions/views/posts.js +++ b/actions/views/posts.js @@ -3,21 +3,17 @@ import * as PostActions from 'mattermost-redux/actions/posts'; -import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; -import {ActionTypes} from 'utils/constants.jsx'; +import {logError} from 'mattermost-redux/actions/errors'; + +import {ActionTypes, AnnouncementBarTypes} from 'utils/constants.jsx'; export function editPost(post) { return async (dispatch, getState) => { const result = await PostActions.editPost(post)(dispatch, getState); - if (result.error) { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_ERROR, - err: { - id: result.error.server_error_id, - ...result.error, - }, - }); + // Send to error bar if it's an edit post error about time limit. + if (result.error && result.error.server_error_id === 'api.post.update_post.permissions_time_limit.app_error') { + dispatch(logError({type: AnnouncementBarTypes.ANNOUNCEMENT, message: result.error.message}, true)); } return result; diff --git a/actions/views/profile_popover.js b/actions/views/profile_popover.js new file mode 100644 index 000000000000..b42bdf319e06 --- /dev/null +++ b/actions/views/profile_popover.js @@ -0,0 +1,28 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {getTeamMember} from 'mattermost-redux/actions/teams'; +import {getChannelMember} from 'mattermost-redux/actions/channels'; +import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; + +import {getSelectedPost} from 'selectors/rhs'; + +export function getMembershipForCurrentEntities(userId) { + return async (dispatch, getState) => { + const state = getState(); + const currentTeamId = getCurrentTeamId(state); + + const selectedPost = getSelectedPost(state); + const currentChannel = getCurrentChannel(state); + + let channelId; + if (selectedPost.exists === false) { + channelId = currentChannel.id; + } else { + channelId = selectedPost.channel_id; + } + + return Promise.all([dispatch(getTeamMember(currentTeamId, userId)), dispatch(getChannelMember(channelId, userId))]); + }; +} diff --git a/actions/views/root.js b/actions/views/root.js index 12a44591870b..8cc74b88609c 100644 --- a/actions/views/root.js +++ b/actions/views/root.js @@ -8,18 +8,20 @@ import {Client4} from 'mattermost-redux/client'; import {ActionTypes} from 'utils/constants'; export function loadMeAndConfig() { - return (dispatch) => { + return async (dispatch) => { // if any new promise needs to be added please be mindful of the order as it is used in root.jsx for redirection const promises = [ dispatch(getClientConfig()), dispatch(getLicenseConfig()), ]; + // need to await for clientConfig first as it is required for loadMe + const resolvedPromises = await Promise.all(promises); if (document.cookie.indexOf('MMUSERID=') > -1) { - promises.push(dispatch(UserActions.loadMe())); + resolvedPromises.push(await dispatch(UserActions.loadMe())); } - return Promise.all(promises); + return resolvedPromises; }; } @@ -36,3 +38,10 @@ export function loadTranslations(locale, url) { }).catch(() => {}); // eslint-disable-line no-empty-function }; } + +export function clearUserCookie() { + // We need to clear the cookie both with and without the domain set because we can't tell if the server set + // the cookie with it. At this time, the domain will be set if ServiceSettings.EnableCookiesForSubdomains is true. + document.cookie = 'MMUSERID=;expires=Thu, 01 Jan 1970 00:00:01 GMT;path=/'; + document.cookie = `MMUSERID=;expires=Thu, 01 Jan 1970 00:00:01 GMT;domain=${window.location.hostname};path=/`; +} diff --git a/actions/views/root.test.js b/actions/views/root.test.js new file mode 100644 index 000000000000..c3679838a012 --- /dev/null +++ b/actions/views/root.test.js @@ -0,0 +1,58 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import thunk from 'redux-thunk'; +import configureStore from 'redux-mock-store'; + +import * as Actions from 'actions/views/root'; + +const mockStore = configureStore([thunk]); + +jest.mock('mattermost-redux/actions/general', () => { + const original = require.requireActual('mattermost-redux/actions/general'); + return { + ...original, + getClientConfig: () => ({type: 'MOCK_GET_CLIENT_CONFIG'}), + getLicenseConfig: () => ({type: 'MOCK_GET_LICENSE_CONFIG'}), + }; +}); + +jest.mock('mattermost-redux/actions/users', () => { + const original = require.requireActual('mattermost-redux/actions/users'); + return { + ...original, + loadMe: () => ({type: 'MOCK_LOAD_ME'}), + }; +}); + +describe('root view actions', () => { + const origCookies = document.cookie; + const origWasLoggedIn = localStorage.getItem('was_logged_in'); + + beforeAll(() => { + document.cookie = ''; + localStorage.setItem('was_logged_in', ''); + }); + + afterAll(() => { + document.cookie = origCookies; + localStorage.setItem('was_logged_in', origWasLoggedIn); + }); + + test('loadMeAndConfig, without user logged in', async () => { + const testStore = await mockStore({}); + + await testStore.dispatch(Actions.loadMeAndConfig()); + expect(testStore.getActions()).toEqual([{type: 'MOCK_GET_CLIENT_CONFIG'}, {type: 'MOCK_GET_LICENSE_CONFIG'}]); + }); + + test('loadMeAndConfig, with user logged in', async () => { + const testStore = await mockStore({}); + + document.cookie = 'MMUSERID=userid'; + localStorage.setItem('was_logged_in', 'true'); + + await testStore.dispatch(Actions.loadMeAndConfig()); + expect(testStore.getActions()).toEqual([{type: 'MOCK_GET_CLIENT_CONFIG'}, {type: 'MOCK_GET_LICENSE_CONFIG'}, {type: 'MOCK_LOAD_ME'}]); + }); +}); diff --git a/actions/websocket_actions.jsx b/actions/websocket_actions.jsx index aec54c6ed7a2..6f55373d2508 100644 --- a/actions/websocket_actions.jsx +++ b/actions/websocket_actions.jsx @@ -12,6 +12,7 @@ import { GeneralTypes, AdminTypes, IntegrationTypes, + PreferenceTypes, } from 'mattermost-redux/action_types'; import {WebsocketEvents, General} from 'mattermost-redux/constants'; import { @@ -40,7 +41,7 @@ import {loadChannelsForCurrentUser} from 'actions/channel_actions.jsx'; import * as GlobalActions from 'actions/global_actions.jsx'; import {handleNewPost} from 'actions/post_actions.jsx'; import * as StatusActions from 'actions/status_actions.jsx'; -import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; +import {loadProfilesForSidebar} from 'actions/user_actions.jsx'; import store from 'stores/redux_store.jsx'; import WebSocketClient from 'client/web_websocket_client.jsx'; import {loadPlugin, loadPluginsIfNecessary, removePlugin} from 'plugins'; @@ -480,23 +481,11 @@ function handlePostEditEvent(msg) { dispatch(viewChannel(currentChannelId)); } } - - // Needed for search store - AppDispatcher.handleViewAction({ - type: Constants.ActionTypes.POST_UPDATED, - post, - }); } function handlePostDeleteEvent(msg) { const post = JSON.parse(msg.data.post); dispatch({type: PostTypes.POST_DELETED, data: post}); - - // Needed for search store - AppDispatcher.handleViewAction({ - type: Constants.ActionTypes.POST_DELETED, - post, - }); } async function handleTeamAddedEvent(msg) { @@ -532,7 +521,7 @@ function handleLeaveTeamEvent(msg) { } function handleUpdateTeamEvent(msg) { - dispatch({type: TeamTypes.UPDATED_TEAM, data: msg.data.team}); + dispatch({type: TeamTypes.UPDATED_TEAM, data: JSON.parse(msg.data.team)}); } function handleDeleteTeamEvent(msg) { @@ -726,17 +715,29 @@ function handleChannelDeletedEvent(msg) { function handlePreferenceChangedEvent(msg) { const preference = JSON.parse(msg.data.preference); - GlobalActions.emitPreferenceChangedEvent(preference); + dispatch({type: PreferenceTypes.RECEIVED_PREFERENCES, data: [preference]}); + + if (addedNewDmUser(preference)) { + loadProfilesForSidebar(); + } } function handlePreferencesChangedEvent(msg) { const preferences = JSON.parse(msg.data.preferences); - GlobalActions.emitPreferencesChangedEvent(preferences); + dispatch({type: PreferenceTypes.RECEIVED_PREFERENCES, data: preferences}); + + if (preferences.findIndex(addedNewDmUser) !== -1) { + loadProfilesForSidebar(); + } } function handlePreferencesDeletedEvent(msg) { const preferences = JSON.parse(msg.data.preferences); - GlobalActions.emitPreferencesDeletedEvent(preferences); + dispatch({type: PreferenceTypes.DELETED_PREFERENCES, data: preferences}); +} + +function addedNewDmUser(preference) { + return preference.category === Constants.Preferences.CATEGORY_DIRECT_CHANNEL_SHOW && preference.value === 'true'; } function handleUserTypingEvent(msg) { @@ -761,7 +762,7 @@ function handleUserTypingEvent(msg) { dispatch({ type: WebsocketEvents.STOP_TYPING, data, - }, getState); + }); }, parseInt(config.TimeBetweenUserTypingUpdatesMilliseconds, 10)); if (!currentUser && userId !== currentUserId) { @@ -874,4 +875,4 @@ function handleOpenDialogEvent(msg) { } store.dispatch(openModal({modalId: ModalIdentifiers.INTERACTIVE_DIALOG, dialogType: InteractiveDialog})); -} \ No newline at end of file +} diff --git a/components/__snapshots__/loading_image_preview.test.jsx.snap b/components/__snapshots__/loading_image_preview.test.jsx.snap index 864775ccdd3c..93493998e5de 100644 --- a/components/__snapshots__/loading_image_preview.test.jsx.snap +++ b/components/__snapshots__/loading_image_preview.test.jsx.snap @@ -4,9 +4,8 @@ exports[`components/LoadingImagePreview should match snapshot 1`] = `
- - -`; diff --git a/components/__snapshots__/pdf_preview.test.jsx.snap b/components/__snapshots__/pdf_preview.test.jsx.snap index 29031656878d..a6199afa5f56 100644 --- a/components/__snapshots__/pdf_preview.test.jsx.snap +++ b/components/__snapshots__/pdf_preview.test.jsx.snap @@ -4,9 +4,8 @@ exports[`component/PDFPreview should match snapshot, loading 1`] = `
-
`; diff --git a/components/__snapshots__/save_button.test.jsx.snap b/components/__snapshots__/save_button.test.jsx.snap index 8e9444b1b9be..b31ec5b52692 100644 --- a/components/__snapshots__/save_button.test.jsx.snap +++ b/components/__snapshots__/save_button.test.jsx.snap @@ -7,7 +7,12 @@ exports[`components/SaveButton should match snapshot, extraClasses 1`] = ` id="saveSetting" type="submit" > - Save + + Save + `; @@ -18,7 +23,28 @@ exports[`components/SaveButton should match snapshot, on defaultMessage 1`] = ` id="saveSetting" type="submit" > - Save + + Save + + +`; + +exports[`components/SaveButton should match snapshot, on defaultMessage 2`] = ` + `; @@ -29,12 +55,27 @@ exports[`components/SaveButton should match snapshot, on savingMessage 1`] = ` id="saveSetting" type="submit" > - - - Saving - + + Save + + +`; + +exports[`components/SaveButton should match snapshot, on savingMessage 2`] = ` + `; diff --git a/components/__snapshots__/setting_picture.test.jsx.snap b/components/__snapshots__/setting_picture.test.jsx.snap index 0e0d0a4bf884..34768ab097cf 100644 --- a/components/__snapshots__/setting_picture.test.jsx.snap +++ b/components/__snapshots__/setting_picture.test.jsx.snap @@ -73,11 +73,16 @@ exports[`components/SettingItemMin should match snapshot with active Save button className="btn btn-sm btn-primary" onClick={[Function]} > - + + + - + + + -
- + + + + + - + + + - + + + - + + + - + + + - + + + - - + + + + `; exports[`components/SpinnerButton should match snapshot with required props 1`] = ` `; exports[`components/SpinnerButton should match snapshot with spinning 1`] = ` - + `; diff --git a/components/__snapshots__/textbox.test.jsx.snap b/components/__snapshots__/textbox.test.jsx.snap index 757e633e2c98..eac6daf8a128 100644 --- a/components/__snapshots__/textbox.test.jsx.snap +++ b/components/__snapshots__/textbox.test.jsx.snap @@ -13,6 +13,7 @@ exports[`components/TextBox should match snapshot with required props 1`] = ` isRHS={false} listComponent={[Function]} listStyle="top" + listenForMentionKeyClick={false} onBlur={[Function]} onChange={[Function]} onHeightChange={[Function]} @@ -24,7 +25,7 @@ exports[`components/TextBox should match snapshot with required props 1`] = ` providers={ Array [ AtMentionProvider { - "autocompleteUsersInChannel": [MockFunction], + "autocompleteUsersInChannel": [Function], "currentUserId": "currentUserId", "data": null, "disableDispatches": false, @@ -57,7 +58,12 @@ exports[`components/TextBox should match snapshot with required props 1`] = ` "latestPrefix": "", "requestStarted": false, }, - EmoticonProvider {}, + EmoticonProvider { + "disableDispatches": false, + "latestComplete": true, + "latestPrefix": "", + "requestStarted": false, + }, ] } renderDividers={true} @@ -163,6 +169,7 @@ exports[`components/TextBox should throw error when new property is too long 1`] isRHS={false} listComponent={[Function]} listStyle="top" + listenForMentionKeyClick={false} onBlur={[Function]} onChange={[Function]} onHeightChange={[Function]} @@ -174,7 +181,7 @@ exports[`components/TextBox should throw error when new property is too long 1`] providers={ Array [ AtMentionProvider { - "autocompleteUsersInChannel": [MockFunction], + "autocompleteUsersInChannel": [Function], "currentUserId": "currentUserId", "data": null, "disableDispatches": false, @@ -207,7 +214,12 @@ exports[`components/TextBox should throw error when new property is too long 1`] "latestPrefix": "", "requestStarted": false, }, - EmoticonProvider {}, + EmoticonProvider { + "disableDispatches": false, + "latestComplete": true, + "latestPrefix": "", + "requestStarted": false, + }, ] } renderDividers={true} @@ -313,6 +325,7 @@ exports[`components/TextBox should throw error when value is too long 1`] = ` isRHS={false} listComponent={[Function]} listStyle="top" + listenForMentionKeyClick={false} onBlur={[Function]} onChange={[Function]} onHeightChange={[Function]} @@ -324,7 +337,7 @@ exports[`components/TextBox should throw error when value is too long 1`] = ` providers={ Array [ AtMentionProvider { - "autocompleteUsersInChannel": [MockFunction], + "autocompleteUsersInChannel": [Function], "currentUserId": "currentUserId", "data": null, "disableDispatches": false, @@ -357,7 +370,12 @@ exports[`components/TextBox should throw error when value is too long 1`] = ` "latestPrefix": "", "requestStarted": false, }, - EmoticonProvider {}, + EmoticonProvider { + "disableDispatches": false, + "latestComplete": true, + "latestPrefix": "", + "requestStarted": false, + }, ] } renderDividers={true} diff --git a/components/add_user_to_channel_modal/__snapshots__/add_user_to_channel_modal.test.jsx.snap b/components/add_user_to_channel_modal/__snapshots__/add_user_to_channel_modal.test.jsx.snap new file mode 100644 index 000000000000..51a9a525291f --- /dev/null +++ b/components/add_user_to_channel_modal/__snapshots__/add_user_to_channel_modal.test.jsx.snap @@ -0,0 +1,133 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/AddUserToChannelModal should match snapshot 1`] = ` + + + + + + +
+ +
+ +
+ +
+
+
+
+ + + + +
+
+`; diff --git a/components/add_user_to_channel_modal/add_user_to_channel_modal.jsx b/components/add_user_to_channel_modal/add_user_to_channel_modal.jsx new file mode 100644 index 000000000000..a751a5b36827 --- /dev/null +++ b/components/add_user_to_channel_modal/add_user_to_channel_modal.jsx @@ -0,0 +1,316 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import PropTypes from 'prop-types'; +import React from 'react'; +import {Modal} from 'react-bootstrap'; +import {FormattedMessage} from 'react-intl'; + +import {getFullName} from 'mattermost-redux/utils/user_utils'; + +import SearchChannelWithPermissionsProvider from 'components/suggestion/search_channel_with_permissions_provider.jsx'; +import SuggestionBox from 'components/suggestion/suggestion_box.jsx'; +import SuggestionList from 'components/suggestion/suggestion_list.jsx'; + +import {placeCaretAtEnd} from 'utils/utils.jsx'; + +export default class AddUserToChannelModal extends React.Component { + static propTypes = { + + /** + * Function that's called when modal is closed + */ + onHide: PropTypes.func.isRequired, + + /** + * The user that is being added to a channel + */ + user: PropTypes.object.isRequired, + + /** + * Object used to determine if the user + * is a member of a given channel + */ + channelMembers: PropTypes.object.isRequired, + + actions: PropTypes.shape({ + + /** + * Function to add the user to a channel + */ + addChannelMember: PropTypes.func.isRequired, + + /** + * Function to fetch the user's channel membership + */ + getChannelMember: PropTypes.func.isRequired, + }).isRequired, + } + + constructor(props) { + super(props); + + this.state = { + + /** + * Whether or not the modal is visible + */ + show: true, + + /** + * Whether or not a request to add the user is in progress + */ + saving: false, + + /** + * Whether or not a request to check for the user's channel membership + * is in progress + */ + checkingForMembership: false, + + /** + * The user input in the channel search box + */ + text: '', + + /** + * The id for the channel that is selected + */ + selectedChannelId: null, + + /** + * An error to display when the add request fails + */ + submitError: '', + }; + this.suggestionProviders = [new SearchChannelWithPermissionsProvider()]; + this.enableChannelProvider(); + } + + enableChannelProvider = () => { + this.suggestionProviders[0].disableDispatches = false; + } + + focusTextbox = () => { + if (this.channelSearchBox == null) { + return; + } + + const textbox = this.channelSearchBox.getTextbox(); + if (document.activeElement !== textbox) { + textbox.focus(); + placeCaretAtEnd(textbox); + } + } + + onInputChange = (e) => { + this.setState({text: e.target.value, selectedChannelId: null}); + } + + onHide = () => { + this.setState({show: false}); + this.props.onHide(); + } + + setSearchBoxRef = (input) => { + this.channelSearchBox = input; + this.focusTextbox(); + } + + handleSubmitError = (error) => { + if (error) { + this.setState({submitError: error.message, saving: false}); + } + } + + didSelectChannel = (selection) => { + const channel = selection.channel; + const userId = this.props.user.id; + + this.setState({ + text: channel.display_name, + selectedChannelId: channel.id, + checkingForMembership: true, + submitError: '', + }); + + this.props.actions.getChannelMember(channel.id, userId).then(() => { + this.setState({checkingForMembership: false}); + }); + } + + handleSubmit = (e) => { + if (e && e.preventDefault) { + e.preventDefault(); + } + + const channelId = this.state.selectedChannelId; + const user = this.props.user; + + if (!channelId) { + return; + } + + if (this.isUserMemberOfChannel(channelId) || this.state.saving) { + return; + } + + this.setState({saving: true}); + + this.props.actions.addChannelMember(channelId, user.id).then(({error}) => { + if (error) { + this.handleSubmitError(error); + } else { + this.onHide(); + } + }); + } + + isUserMemberOfChannel = (channelId) => { + const user = this.props.user; + const memberships = this.props.channelMembers; + + if (!channelId) { + return false; + } + + if (!memberships[channelId]) { + return false; + } + + return Boolean(memberships[channelId][user.id]); + } + + render() { + const user = this.props.user; + const channelId = this.state.selectedChannelId; + const targetUserIsMemberOfSelectedChannel = this.isUserMemberOfChannel(channelId); + + let name = getFullName(user); + if (!name) { + name = `@${user.username}`; + } + + let errorMsg; + if (!this.state.saving) { + if (this.state.submitError) { + errorMsg = ( + + ); + } else if (targetUserIsMemberOfSelectedChannel) { + errorMsg = ( + + ); + } + } + + const help = ( + + ); + + const content = ( + + ); + + const shouldDisableAddButton = targetUserIsMemberOfSelectedChannel || + this.state.checkingForMembership || + Boolean(!this.state.selectedChannelId) || + this.state.saving; + + return ( + + + + + + +
+ +
+ {help} +
+ {content} +
+ {errorMsg} +
+
+
+ + + + +
+
+ ); + } +} diff --git a/components/add_user_to_channel_modal/add_user_to_channel_modal.test.jsx b/components/add_user_to_channel_modal/add_user_to_channel_modal.test.jsx new file mode 100644 index 000000000000..b13369e6b38c --- /dev/null +++ b/components/add_user_to_channel_modal/add_user_to_channel_modal.test.jsx @@ -0,0 +1,252 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; + +import AddUserToChannelModal from 'components/add_user_to_channel_modal/add_user_to_channel_modal'; + +describe('components/AddUserToChannelModal', () => { + const baseProps = { + channelMembers: {}, + user: { + id: 'someUserId', + first_name: 'Fake', + last_name: 'Person', + }, + onHide: jest.fn(), + actions: { + addChannelMember: jest.fn().mockResolvedValue({}), + getChannelMember: jest.fn().mockResolvedValue({}), + }, + }; + + it('should match snapshot', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find('#add-user-to-channel-modal__add-button').props().disabled).toBe(true); + expect(wrapper.find('#add-user-to-channel-modal__user-is-member').exists()).toBe(false); + expect(wrapper.find('#add-user-to-channel-modal__invite-error').exists()).toBe(false); + expect(wrapper).toMatchSnapshot(); + }); + + it('should enable the add button when a channel is selected', () => { + const wrapper = shallow( + + ); + + wrapper.setState({selectedChannelId: 'someChannelId'}); + expect(wrapper.find('#add-user-to-channel-modal__add-button').props().disabled).toBe(false); + expect(wrapper.find('#add-user-to-channel-modal__invite-error').exists()).toBe(false); + }); + + it('should show invite error when an error message is captured', () => { + const wrapper = shallow( + + ); + + wrapper.setState({submitError: 'some error'}); + expect(wrapper.find('#add-user-to-channel-modal__add-button').props().disabled).toBe(true); + expect(wrapper.find('#add-user-to-channel-modal__invite-error').exists()).toBe(true); + }); + + it('should disable add button when membership is being checked', () => { + const wrapper = shallow( + + ); + + wrapper.setState({ + selectedChannelId: 'someChannelId', + checkingForMembership: true, + }); + + expect(wrapper.find('#add-user-to-channel-modal__add-button').props().disabled).toBe(true); + }); + + it('should display error message if user is a member of the selected channel', () => { + const props = {...baseProps, + channelMembers: { + someChannelId: { + someUserId: {}, + }, + }, + }; + + const wrapper = shallow( + + ); + + wrapper.setState({selectedChannelId: 'someChannelId'}); + expect(wrapper.find('#add-user-to-channel-modal__add-button').props().disabled).toBe(true); + expect(wrapper.find('#add-user-to-channel-modal__user-is-member').exists()).toBe(true); + }); + + it('should disable the add button when saving', () => { + const wrapper = shallow( + + ); + + wrapper.setState({ + selectedChannelId: 'someChannelId', + saving: true, + }); + expect(wrapper.find('#add-user-to-channel-modal__add-button').props().disabled).toBe(true); + }); + + describe('didSelectChannel', () => { + it('should fetch the selected user\'s membership for the selected channel', () => { + const props = {...baseProps}; + + const wrapper = shallow( + + ); + + const selection = {channel: {id: 'someChannelId', display_name: 'channelName'}}; + wrapper.instance().didSelectChannel(selection); + expect(props.actions.getChannelMember).toBeCalledWith('someChannelId', 'someUserId'); + }); + + it('should match state on selection', async () => { + const promise = Promise.resolve({}); + const props = { + ...baseProps, + actions: { + ...baseProps.actions, + getChannelMember: jest.fn(() => { + return promise; + }), + }, + }; + + const wrapper = shallow( + + ); + + expect(wrapper.state().text).toEqual(''); + expect(wrapper.state().checkingForMembership).toEqual(false); + expect(wrapper.state().selectedChannelId).toEqual(null); + expect(wrapper.state().submitError).toEqual(''); + + const selection = {channel: {id: 'someChannelId', display_name: 'channelName'}}; + wrapper.setState({submitError: 'some pre-existing error'}); + + wrapper.instance().didSelectChannel(selection); + expect(wrapper.state().text).toEqual('channelName'); + expect(wrapper.state().checkingForMembership).toEqual(true); + expect(wrapper.state().selectedChannelId).toEqual('someChannelId'); + expect(wrapper.state().submitError).toEqual(''); + + await promise; + expect(wrapper.state().checkingForMembership).toEqual(false); + }); + }); + + describe('handleSubmit', () => { + it('should do nothing if no channel is selected', () => { + const props = {...baseProps}; + + const wrapper = shallow( + + ); + + wrapper.instance().handleSubmit(); + expect(wrapper.state().saving).toBe(false); + expect(props.actions.addChannelMember).not.toBeCalled(); + }); + + it('should do nothing if user is a member of the selected channel', () => { + const props = {...baseProps, + channelMembers: { + someChannelId: { + someUserId: {}, + }, + }, + }; + + const wrapper = shallow( + + ); + + wrapper.setState({selectedChannelId: 'someChannelId'}); + wrapper.instance().handleSubmit(); + expect(wrapper.state().saving).toBe(false); + expect(props.actions.addChannelMember).not.toBeCalled(); + }); + + it('should submit if user is not a member of the selected channel', () => { + const props = {...baseProps, + channelMembers: { + someChannelId: {}, + }, + }; + + const wrapper = shallow( + + ); + + wrapper.setState({selectedChannelId: 'someChannelId'}); + wrapper.instance().handleSubmit(); + expect(wrapper.state().saving).toBe(true); + expect(props.actions.addChannelMember).toBeCalled(); + }); + + test('should match state when save is successful', async () => { + const onHide = jest.fn(); + const promise = Promise.resolve({}); + const props = { + ...baseProps, + onHide, + actions: { + ...baseProps.actions, + addChannelMember: () => promise, + }, + }; + + const wrapper = shallow( + + ); + + expect(wrapper.state().show).toBe(true); + expect(wrapper.state().saving).toBe(false); + wrapper.setState({selectedChannelId: 'someChannelId'}); + + wrapper.instance().handleSubmit(); + expect(wrapper.state().show).toBe(true); + expect(wrapper.state().saving).toBe(true); + + await promise; + expect(wrapper.state().submitError).toEqual(''); + expect(wrapper.state().show).toBe(false); + expect(onHide).toHaveBeenCalled(); + }); + + test('should match state when save fails', async () => { + const onHide = jest.fn(); + const promise = Promise.resolve({error: new Error('some error')}); + const props = { + ...baseProps, + onHide, + actions: { + ...baseProps.actions, + addChannelMember: () => promise, + }, + }; + + const wrapper = shallow( + + ); + + expect(wrapper.state().show).toBe(true); + wrapper.setState({selectedChannelId: 'someChannelId'}); + + wrapper.instance().handleSubmit(); + + await promise; + expect(wrapper.state().submitError).toEqual('some error'); + expect(wrapper.state().show).toBe(true); + expect(onHide).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/components/add_user_to_channel_modal/index.js b/components/add_user_to_channel_modal/index.js new file mode 100644 index 000000000000..20ccabdf936a --- /dev/null +++ b/components/add_user_to_channel_modal/index.js @@ -0,0 +1,27 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {addChannelMember, getChannelMember} from 'mattermost-redux/actions/channels'; +import {getChannelMembersInChannels} from 'mattermost-redux/selectors/entities/channels'; + +import AddUserToChannelModal from './add_user_to_channel_modal.jsx'; + +function mapStateToProps(state) { + const channelMembers = getChannelMembersInChannels(state) || {}; + return { + channelMembers, + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + addChannelMember, + getChannelMember, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(AddUserToChannelModal); diff --git a/components/admin_console/__snapshots__/schema_admin_settings.test.jsx.snap b/components/admin_console/__snapshots__/schema_admin_settings.test.jsx.snap index 154e056a31a3..06c55964c0f0 100644 --- a/components/admin_console/__snapshots__/schema_admin_settings.test.jsx.snap +++ b/components/admin_console/__snapshots__/schema_admin_settings.test.jsx.snap @@ -322,31 +322,36 @@ exports[`components/admin_console/SchemaAdminSettings should match snapshot with }, Object { "order": 9, - "text": "Pусский (Alpha)", - "value": "ru", + "text": "Türkçe", + "value": "tr", }, Object { "order": 10, - "text": "Türkçe", - "value": "tr", + "text": "Pусский (Alpha)", + "value": "ru", }, Object { "order": 11, + "text": "Yкраїнська (Beta)", + "value": "uk", + }, + Object { + "order": 12, "text": "한국어 (Alpha)", "value": "ko", }, Object { - "order": 12, + "order": 13, "text": "中文 (简体)", "value": "zh-CN", }, Object { - "order": 13, + "order": 14, "text": "中文 (繁體)", "value": "zh-TW", }, Object { - "order": 14, + "order": 15, "text": "日本語", "value": "ja", }, @@ -431,31 +436,36 @@ exports[`components/admin_console/SchemaAdminSettings should match snapshot with }, Object { "order": 9, - "text": "Pусский (Alpha)", - "value": "ru", + "text": "Türkçe", + "value": "tr", }, Object { "order": 10, - "text": "Türkçe", - "value": "tr", + "text": "Pусский (Alpha)", + "value": "ru", }, Object { "order": 11, + "text": "Yкраїнська (Beta)", + "value": "uk", + }, + Object { + "order": 12, "text": "한국어 (Alpha)", "value": "ko", }, Object { - "order": 12, + "order": 13, "text": "中文 (简体)", "value": "zh-CN", }, Object { - "order": 13, + "order": 14, "text": "中文 (繁體)", "value": "zh-TW", }, Object { - "order": 14, + "order": 15, "text": "日本語", "value": "ja", }, diff --git a/components/admin_console/admin_definition.jsx b/components/admin_console/admin_definition.jsx index 61498094e55d..cb0944c693b4 100644 --- a/components/admin_console/admin_definition.jsx +++ b/components/admin_console/admin_definition.jsx @@ -1801,6 +1801,7 @@ export default { help_text_markdown: true, help_text_default: 'When true, [multi-factor authentication](!https://docs.mattermost.com/deployment/auth.html) is required for login. New users will be required to configure MFA on signup. Logged in users without MFA configured are redirected to the MFA setup page until configuration is complete.\n \nIf your system has users with login methods other than AD/LDAP and email, MFA must be enforced with the authentication provider outside of Mattermost.', isDisabled: needsUtils.stateValueFalse('ServiceSettings.EnableMultifactorAuthentication'), + isHidden: needsUtils.not(needsUtils.hasLicenseFeature('MFA')), }, ], }, diff --git a/components/admin_console/admin_definition.test.jsx b/components/admin_console/admin_definition.test.jsx index 68d2c61fc88f..6700a432d33f 100644 --- a/components/admin_console/admin_definition.test.jsx +++ b/components/admin_console/admin_definition.test.jsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import yup from 'yup'; +import * as yup from 'yup'; import adminDefinition from 'components/admin_console/admin_definition.jsx'; import {Constants} from 'utils/constants'; diff --git a/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.jsx.snap b/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.jsx.snap index 363d1a31a4da..887289351a17 100644 --- a/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.jsx.snap +++ b/components/admin_console/admin_sidebar/__snapshots__/admin_sidebar.test.jsx.snap @@ -194,6 +194,18 @@ exports[`components/AdminSidebar should match snapshot 1`] = ` /> } /> + + } + /> } /> + + } + /> - } - /> - ); - } - - if (this.props.license.MFA === 'true') { - mfaSettings = ( - } /> @@ -346,7 +331,14 @@ export default class AdminSidebar extends React.Component { const customPlugins = []; if (this.props.config.PluginSettings.Enable) { - Object.values(this.props.plugins).forEach((p) => { + Object.values(this.props.plugins).sort((a, b) => { + const nameCompare = a.name.localeCompare(b.name); + if (nameCompare !== 0) { + return nameCompare; + } + + return a.id.localeCompare(b.id); + }).forEach((p) => { const hasSettings = p.settings_schema && (p.settings_schema.header || p.settings_schema.footer || p.settings_schema.settings.length > 0); if (!hasSettings) { return; @@ -536,7 +528,15 @@ export default class AdminSidebar extends React.Component { {oauthSettings} {ldapSettings} {samlSettings} - {mfaSettings} + + } + /> - - Upload + + Upload + `; @@ -19,12 +23,12 @@ exports[`components/admin_console/brand_image_setting/UploadButton should match id="upload-button" onClick={[Function]} > - - - Uploading.. + + Upload + `; @@ -35,12 +39,18 @@ exports[`components/admin_console/brand_image_setting/UploadButton should match id="upload-button" onClick={[Function]} > - - - Uploaded! + + + + Uploaded! + `; @@ -51,11 +61,17 @@ exports[`components/admin_console/brand_image_setting/UploadButton should match id="upload-button" onClick={[Function]} > - - - Uploaded! + + + + Uploaded! + `; diff --git a/components/admin_console/brand_image_setting/upload_button.jsx b/components/admin_console/brand_image_setting/upload_button.jsx index 88fe368d8d09..f161e7991fea 100644 --- a/components/admin_console/brand_image_setting/upload_button.jsx +++ b/components/admin_console/brand_image_setting/upload_button.jsx @@ -7,6 +7,8 @@ import React from 'react'; import * as Utils from 'utils/utils.jsx'; import {UploadStatuses} from 'utils/constants.jsx'; +import LoadingWrapper from 'components/widgets/loading/loading_wrapper.jsx'; + export default class UploadButton extends React.PureComponent { static propTypes = { @@ -39,30 +41,19 @@ export default class UploadButton extends React.PureComponent { onClick, } = this.props; - let buttonIcon; - let buttonText; - - switch (status) { - case UploadStatuses.LOADING: - buttonIcon = ( - - ); - buttonText = Utils.localizeMessage('admin.team.uploading', 'Uploading..'); - break; - case UploadStatuses.COMPLETE: - buttonIcon = ( + let buttonContent; + if (status === UploadStatuses.COMPLETE) { + buttonContent = [ - ); - buttonText = Utils.localizeMessage('admin.team.uploaded', 'Uploaded!'); - break; - default: - buttonText = Utils.localizeMessage('admin.team.upload', 'Upload'); + />, + ' ', + Utils.localizeMessage('admin.team.uploaded', 'Uploaded!'), + ]; + } else { + buttonContent = Utils.localizeMessage('admin.team.upload', 'Upload'); } return ( @@ -72,9 +63,12 @@ export default class UploadButton extends React.PureComponent { onClick={onClick} id='upload-button' > - {buttonIcon} - {' '} - {buttonText} + + {buttonContent} + ); } diff --git a/components/admin_console/cluster_settings.jsx b/components/admin_console/cluster_settings.jsx index 32d0f6206451..43f7abcb12d0 100644 --- a/components/admin_console/cluster_settings.jsx +++ b/components/admin_console/cluster_settings.jsx @@ -236,7 +236,7 @@ export default class ClusterSettings extends AdminSettings { helpText={ } value={this.state.ReadOnlyConfig} diff --git a/components/admin_console/custom_plugin_settings/custom_plugin_settings.jsx b/components/admin_console/custom_plugin_settings/custom_plugin_settings.jsx index 204350909040..a03abc372ee8 100644 --- a/components/admin_console/custom_plugin_settings/custom_plugin_settings.jsx +++ b/components/admin_console/custom_plugin_settings/custom_plugin_settings.jsx @@ -37,9 +37,11 @@ export default class CustomPluginSettings extends SchemaAdminSettings { const settings = schema.settings || []; settings.forEach((setting) => { const lowerKey = setting.key.toLowerCase(); - const value = this.state[lowerKey] || setting.default; - if (value == null) { + const value = this.state[lowerKey]; + if (value == null && setting.default == null) { Reflect.deleteProperty(configSettings, lowerKey); + } else if (value == null) { + configSettings[lowerKey] = setting.default; } else { configSettings[lowerKey] = value; } diff --git a/components/admin_console/custom_terms_of_service_settings/__snapshots__/custom_terms_of_service_settings.test.jsx.snap b/components/admin_console/custom_terms_of_service_settings/__snapshots__/custom_terms_of_service_settings.test.jsx.snap index 1261f75d6f71..bb102baf7f82 100644 --- a/components/admin_console/custom_terms_of_service_settings/__snapshots__/custom_terms_of_service_settings.test.jsx.snap +++ b/components/admin_console/custom_terms_of_service_settings/__snapshots__/custom_terms_of_service_settings.test.jsx.snap @@ -6,7 +6,7 @@ exports[`components/admin_console/CustomTermsOfServiceSettings should match snap > @@ -66,7 +66,7 @@ exports[`components/admin_console/CustomTermsOfServiceSettings should match snap > @@ -126,7 +126,7 @@ exports[`components/admin_console/CustomTermsOfServiceSettings should match snap > @@ -186,7 +186,7 @@ exports[`components/admin_console/CustomTermsOfServiceSettings should match snap > diff --git a/components/admin_console/custom_terms_of_service_settings/custom_terms_of_service_settings.jsx b/components/admin_console/custom_terms_of_service_settings/custom_terms_of_service_settings.jsx index b5cc72888ad7..cfb3d2328d73 100644 --- a/components/admin_console/custom_terms_of_service_settings/custom_terms_of_service_settings.jsx +++ b/components/admin_console/custom_terms_of_service_settings/custom_terms_of_service_settings.jsx @@ -150,7 +150,7 @@ export default class CustomTermsOfServiceSettings extends AdminSettings { return ( ); } diff --git a/components/admin_console/custom_terms_of_service_settings/custom_terms_of_service_settings.test.jsx b/components/admin_console/custom_terms_of_service_settings/custom_terms_of_service_settings.test.jsx index 15825f27647b..d5e43bb81e64 100644 --- a/components/admin_console/custom_terms_of_service_settings/custom_terms_of_service_settings.test.jsx +++ b/components/admin_console/custom_terms_of_service_settings/custom_terms_of_service_settings.test.jsx @@ -10,7 +10,7 @@ describe('components/admin_console/CustomTermsOfServiceSettings', () => { const baseProps = { actions: { createTermsOfService: jest.fn(), - getTermsOfService: jest.fn(), + getTermsOfService: jest.fn().mockResolvedValue({data: {id: 'tos_id', text: 'tos_text'}}), }, config: { SupportSettings: { diff --git a/components/admin_console/email_connection_test/__snapshots__/email_connection_test.test.jsx.snap b/components/admin_console/email_connection_test/__snapshots__/email_connection_test.test.jsx.snap index fce1adde111d..2ee5fbe834e1 100644 --- a/components/admin_console/email_connection_test/__snapshots__/email_connection_test.test.jsx.snap +++ b/components/admin_console/email_connection_test/__snapshots__/email_connection_test.test.jsx.snap @@ -15,11 +15,16 @@ exports[`components/admin_console/email_connection_test/email_connection_test sh disabled={true} onClick={[Function]} > - + + +
@@ -42,11 +47,16 @@ exports[`components/admin_console/email_connection_test/email_connection_test sh disabled={false} onClick={[Function]} > - + + +
diff --git a/components/admin_console/email_connection_test/email_connection_test.jsx b/components/admin_console/email_connection_test/email_connection_test.jsx index a078ee3bdb27..25097e43ea2f 100644 --- a/components/admin_console/email_connection_test/email_connection_test.jsx +++ b/components/admin_console/email_connection_test/email_connection_test.jsx @@ -5,6 +5,8 @@ import PropTypes from 'prop-types'; import React from 'react'; import {FormattedMessage} from 'react-intl'; +import LoadingWrapper from 'components/widgets/loading/loading_wrapper.jsx'; + import * as Utils from 'utils/utils.jsx'; export default class EmailConnectionTestButton extends React.Component { @@ -89,26 +91,6 @@ export default class EmailConnectionTestButton extends React.Component { ); } - let contents = null; - if (this.state.testing) { - contents = ( - - - {Utils.localizeMessage('admin.email.testing', 'Testing...')} - - ); - } else { - contents = ( - - ); - } - return (
@@ -118,7 +100,15 @@ export default class EmailConnectionTestButton extends React.Component { onClick={this.handleTestConnection} disabled={this.props.disabled} > - {contents} + + +
{testMessage} diff --git a/components/admin_console/license_settings.jsx b/components/admin_console/license_settings.jsx index 5a78e3aa0d7d..bfa911cf8dce 100644 --- a/components/admin_console/license_settings.jsx +++ b/components/admin_console/license_settings.jsx @@ -1,94 +1,77 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import $ from 'jquery'; import PropTypes from 'prop-types'; import React from 'react'; -import ReactDOM from 'react-dom'; -import {defineMessages, FormattedMessage, injectIntl, intlShape} from 'react-intl'; +import {FormattedMessage} from 'react-intl'; import {removeLicenseFile, uploadLicenseFile} from 'actions/admin_actions.jsx'; import * as Utils from 'utils/utils.jsx'; -import {t} from 'utils/i18n'; import FormattedMarkdownMessage from 'components/formatted_markdown_message.jsx'; - import FormattedAdminHeader from 'components/widgets/admin_console/formatted_admin_header.jsx'; -const holders = defineMessages({ - removing: { - id: t('admin.license.removing'), - defaultMessage: 'Removing License...', - }, - uploading: { - id: t('admin.license.uploading'), - defaultMessage: 'Uploading License...', - }, -}); +export default class LicenseSettings extends React.Component { + static propTypes = { + license: PropTypes.object.isRequired, + config: PropTypes.object, + } -class LicenseSettings extends React.Component { constructor(props) { super(props); - this.handleChange = this.handleChange.bind(this); - this.handleSubmit = this.handleSubmit.bind(this); - this.handleRemove = this.handleRemove.bind(this); - this.state = { fileSelected: false, fileName: null, serverError: null, + removing: false, + uploading: false, }; } - handleChange() { - const element = $(ReactDOM.findDOMNode(this.refs.fileInput)); - if (element.prop('files').length > 0) { - this.setState({fileSelected: true, fileName: element.prop('files')[0].name}); + handleChange = () => { + const element = this.refs.fileInput; + if (element && element.files.length > 0) { + this.setState({fileSelected: true, fileName: element.files[0].name}); } } - handleSubmit(e) { + handleSubmit = (e) => { e.preventDefault(); - const element = $(ReactDOM.findDOMNode(this.refs.fileInput)); - if (element.prop('files').length === 0) { + const element = this.refs.fileInput; + if (!element || element.files.length === 0) { return; } - const file = element.prop('files')[0]; + const file = element.files[0]; - $('#upload-button').button('loading'); + this.setState({uploading: true}); uploadLicenseFile( file, () => { - Utils.clearFileInput(element[0]); - $('#upload-button').button('reset'); - this.setState({fileSelected: false, fileName: null, serverError: null}); + this.setState({fileSelected: false, fileName: null, serverError: null, uploading: false}); window.location.reload(true); }, (error) => { Utils.clearFileInput(element[0]); - $('#upload-button').button('reset'); - this.setState({fileSelected: false, fileName: null, serverError: error.message}); + this.setState({fileSelected: false, fileName: null, serverError: error.message, uploading: false}); } ); } - handleRemove(e) { + handleRemove = (e) => { e.preventDefault(); - $('#remove-button').button('loading'); + this.setState({removing: true}); removeLicenseFile( () => { - $('#remove-button').button('reset'); - this.setState({fileSelected: false, fileName: null, serverError: null}); + this.setState({fileSelected: false, fileName: null, serverError: null, removing: false}); window.location.reload(true); }, (error) => { - $('#remove-button').button('reset'); - this.setState({fileSelected: false, fileName: null, serverError: error.message}); + this.setState({fileSelected: false, fileName: null, serverError: error.message, removing: false}); } ); } @@ -104,25 +87,29 @@ class LicenseSettings extends React.Component { btnClass = 'btn btn-primary'; } + const {license} = this.props; + let edition; let licenseType; let licenseKey; - const issued = Utils.displayDate(parseInt(this.props.license.IssuedAt, 10)) + ' ' + Utils.displayTime(parseInt(this.props.license.IssuedAt, 10), true); - const startsAt = Utils.displayDate(parseInt(this.props.license.StartsAt, 10)); - const expiresAt = Utils.displayDate(parseInt(this.props.license.ExpiresAt, 10)); + const issued = Utils.displayDate(parseInt(license.IssuedAt, 10)) + ' ' + Utils.displayTime(parseInt(license.IssuedAt, 10), true); + const startsAt = Utils.displayDate(parseInt(license.StartsAt, 10)); + const expiresAt = Utils.displayDate(parseInt(license.ExpiresAt, 10)); - if (this.props.license.IsLicensed === 'true') { + if (license.IsLicensed === 'true') { // Note: DO NOT LOCALISE THESE STRINGS. Legally we can not since the license is in English. + const sku = license.SkuShortName ? {`Edition: Mattermost Enterprise Edition ${license.SkuShortName}`}
: null; edition = 'Mattermost Enterprise Edition. Enterprise features on this server have been unlocked with a license key and a valid subscription.'; licenseType = (

{'This software is offered under a commercial license.\n\nSee ENTERPRISE-EDITION-LICENSE.txt in your root install directory for details. See NOTICE.txt for information about open source software used in this system.\n\nYour subscription details are as follows:'}

- {`Name: ${this.props.license.Name}`}
- {`Company or organization name: ${this.props.license.Company}`}
- {`Number of users: ${this.props.license.Users}`}
+ {`Name: ${license.Name}`}
+ {`Company or organization name: ${license.Company}`}
+ {sku} + {`Number of users: ${license.Users}`}
{`License issued: ${issued}`}
{`Start date of license: ${startsAt}`}
{`Expiry date of license: ${expiresAt}`}
@@ -131,18 +118,29 @@ class LicenseSettings extends React.Component {
); + let removeButtonText = ( + + ); + if (this.state.removing) { + removeButtonText = ( + + ); + } + licenseKey = (


@@ -180,6 +178,21 @@ class LicenseSettings extends React.Component { ); } + let uploadButtonText = ( + + ); + if (this.state.uploading) { + uploadButtonText = ( + + ); + } + licenseKey = (
@@ -201,12 +214,8 @@ class LicenseSettings extends React.Component { disabled={!this.state.fileSelected} onClick={this.handleSubmit} id='upload-button' - data-loading-text={' ' + this.props.intl.formatMessage(holders.uploading)} > - + {uploadButtonText}
{fileName} @@ -274,12 +283,4 @@ class LicenseSettings extends React.Component {
); } -} - -LicenseSettings.propTypes = { - license: PropTypes.object.isRequired, - intl: intlShape.isRequired, - config: PropTypes.object, -}; - -export default injectIntl(LicenseSettings); +} \ No newline at end of file diff --git a/components/admin_console/manage_teams_modal/index.jsx b/components/admin_console/manage_teams_modal/index.jsx index 546911ef1cc4..c82ef018d708 100644 --- a/components/admin_console/manage_teams_modal/index.jsx +++ b/components/admin_console/manage_teams_modal/index.jsx @@ -4,9 +4,8 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; -import {updateTeamMemberSchemeRoles, getTeamMembersForUser, getTeamsForUser} from 'mattermost-redux/actions/teams'; +import {updateTeamMemberSchemeRoles, getTeamMembersForUser, getTeamsForUser, removeUserFromTeam} from 'mattermost-redux/actions/teams'; -import {removeUserFromTeam} from 'actions/team_actions.jsx'; import {getCurrentLocale} from 'selectors/i18n'; import ManageTeamsModal from './manage_teams_modal'; diff --git a/components/admin_console/manage_teams_modal/manage_teams_modal.jsx b/components/admin_console/manage_teams_modal/manage_teams_modal.jsx index 0923e07925c1..287b316d23f6 100644 --- a/components/admin_console/manage_teams_modal/manage_teams_modal.jsx +++ b/components/admin_console/manage_teams_modal/manage_teams_modal.jsx @@ -101,6 +101,10 @@ export default class ManageTeamsModal extends React.Component { } } + handleMemberChange = () => { + this.getTeamMembers(this.props.user.id); + }; + renderContents = () => { const {user} = this.props; const {teams, teamMembers} = this.state; @@ -141,7 +145,7 @@ export default class ManageTeamsModal extends React.Component { team={team} teamMember={teamMember} onError={this.handleError} - onMemberChange={this.getTeamMembers} + onMemberChange={this.handleMemberChange} updateTeamMemberSchemeRoles={this.props.actions.updateTeamMemberSchemeRoles} handleRemoveUserFromTeam={this.handleRemoveUserFromTeam} /> diff --git a/components/admin_console/multiselect_settings.jsx b/components/admin_console/multiselect_settings.jsx index 3abf668b485e..9c1e10646c15 100644 --- a/components/admin_console/multiselect_settings.jsx +++ b/components/admin_console/multiselect_settings.jsx @@ -57,6 +57,20 @@ export default class MultiSelectSetting extends React.Component { } } + calculateValue = () => { + return this.props.selected.reduce((values, item) => { + const found = this.props.values.find((e) => { + return e.value === item; + }); + if (found !== null) { + values.push(found); + } + return values; + }, []); + }; + + getOptionLabel = ({text}) => text; + render() { return ( diff --git a/components/admin_console/permission_schemes_settings/permission_schemes_settings.jsx b/components/admin_console/permission_schemes_settings/permission_schemes_settings.jsx index 4cc5870a56e1..813358ebb12b 100644 --- a/components/admin_console/permission_schemes_settings/permission_schemes_settings.jsx +++ b/components/admin_console/permission_schemes_settings/permission_schemes_settings.jsx @@ -7,10 +7,12 @@ import {FormattedMessage} from 'react-intl'; import {Link} from 'react-router-dom'; import {t} from 'utils/i18n'; +import * as Utils from 'utils/utils'; import FormattedMarkdownMessage from 'components/formatted_markdown_message'; import LoadingScreen from 'components/loading_screen.jsx'; +import LoadingWrapper from 'components/widgets/loading/loading_wrapper.jsx'; import FormattedAdminHeader from 'components/widgets/admin_console/formatted_admin_header.jsx'; @@ -232,23 +234,21 @@ export default class PermissionSchemesSettings extends React.PureComponent {
} {teamOverrideView} {schemes.length > 0 && schemes} - {!this.state.loadingMore && schemes.length === (PAGE_SIZE * (this.state.page + 1)) && + {schemes.length === (PAGE_SIZE * (this.state.page + 1)) && } - {this.state.loadingMore && - }
diff --git a/components/admin_console/permission_schemes_settings/permissions_scheme_summary/__snapshots__/permissions_scheme_summary.test.jsx.snap b/components/admin_console/permission_schemes_settings/permissions_scheme_summary/__snapshots__/permissions_scheme_summary.test.jsx.snap index b268a1d258fc..0a8a3e148bb7 100644 --- a/components/admin_console/permission_schemes_settings/permissions_scheme_summary/__snapshots__/permissions_scheme_summary.test.jsx.snap +++ b/components/admin_console/permission_schemes_settings/permissions_scheme_summary/__snapshots__/permissions_scheme_summary.test.jsx.snap @@ -11,11 +11,16 @@ exports[`components/admin_console/permission_schemes_settings/permissions_scheme + + + } message={
@@ -125,11 +130,16 @@ exports[`components/admin_console/permission_schemes_settings/permissions_scheme + + + } message={
@@ -314,11 +324,16 @@ exports[`components/admin_console/permission_schemes_settings/permissions_scheme + + + } message={
diff --git a/components/admin_console/permission_schemes_settings/permissions_scheme_summary/permissions_scheme_summary.jsx b/components/admin_console/permission_schemes_settings/permissions_scheme_summary/permissions_scheme_summary.jsx index f6077da79a72..e363c28e1faf 100644 --- a/components/admin_console/permission_schemes_settings/permissions_scheme_summary/permissions_scheme_summary.jsx +++ b/components/admin_console/permission_schemes_settings/permissions_scheme_summary/permissions_scheme_summary.jsx @@ -8,7 +8,9 @@ import {OverlayTrigger, Tooltip} from 'react-bootstrap'; import {FormattedMessage} from 'react-intl'; import ConfirmModal from 'components/confirm_modal.jsx'; +import LoadingWrapper from 'components/widgets/loading/loading_wrapper.jsx'; +import * as Utils from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; const MAX_TEAMS_PER_SCHEME_SUMMARY = 8; @@ -62,25 +64,18 @@ export default class PermissionsSchemeSummary extends React.Component {
); - let confirmButton = ( - + const confirmButton = ( + + + ); - if (this.state.deleting) { - confirmButton = ( - - - - - ); - } - return ( { + this.setState({lastMessage: null, serverError: null}); const element = this.refs.fileInput; if (element.files.length > 0) { - this.setState({fileSelected: true, fileName: element.files[0].name}); + this.setState({fileSelected: true, file: element.files[0]}); } } - handleSubmitUpload = async (e) => { - e.preventDefault(); - - const element = this.refs.fileInput; - if (element.files.length === 0) { - return; - } - const file = element.files[0]; - + helpSubmitUpload = async (file, force) => { this.setState({uploading: true}); - - const {error} = await this.props.actions.uploadPlugin(file); - this.setState({fileSelected: false, fileName: null, uploading: false, serverError: null}); - Utils.clearFileInput(element); + const {error} = await this.props.actions.uploadPlugin(file, force); if (error) { + if (error.server_error_id === 'app.plugin.install_id.app_error' && !force) { + this.setState({confirmModal: true, overwriting: true}); + return; + } + this.setState({ + file: null, + fileSelected: false, + uploading: false, + }); if (error.server_error_id === 'app.plugin.activate.app_error') { this.setState({serverError: Utils.localizeMessage('admin.plugin.error.activate', 'Unable to upload the plugin. It may conflict with another plugin on your server.')}); } else if (error.server_error_id === 'app.plugin.extract.app_error') { @@ -471,10 +475,56 @@ export default class PluginManagement extends AdminSettings { } else { this.setState({serverError: error.message}); } + this.setState({file: null, fileSelected: false}); + return; + } + + let msg = `Successfully uploaded plugin from ${file.name}`; + if (this.state.overwriting) { + msg = `Successfully updated plugin from ${file.name}`; } + + this.setState({ + file: null, + fileSelected: false, + serverError: null, + lastMessage: msg, + overwriting: false, + uploading: false, + }); + } + + handleSubmitUpload = (e) => { + e.preventDefault(); + + const element = this.refs.fileInput; + if (element.files.length === 0) { + return; + } + const file = element.files[0]; + + this.helpSubmitUpload(file, false); + Utils.clearFileInput(element); + } + + handleOverwritePluginCancel = () => { + this.setState({ + file: null, + fileSelected: false, + serverError: null, + confirmModal: false, + lastMessage: null, + uploading: false, + }); + } + + handleOverwritePlugin = () => { + this.setState({confirmModal: false}); + this.helpSubmitUpload(this.state.file, true); } handleRemove = async (e) => { + this.setState({lastMessage: null, serverError: null}); e.preventDefault(); const pluginId = e.currentTarget.getAttribute('data-plugin-id'); this.setState({removing: pluginId}); @@ -489,6 +539,7 @@ export default class PluginManagement extends AdminSettings { handleEnable = async (e) => { e.preventDefault(); + this.setState({lastMessage: null, serverError: null}); const pluginId = e.currentTarget.getAttribute('data-plugin-id'); const {error} = await this.props.actions.enablePlugin(pluginId); @@ -499,6 +550,7 @@ export default class PluginManagement extends AdminSettings { } handleDisable = async (e) => { + this.setState({lastMessage: null, serverError: null}); e.preventDefault(); const pluginId = e.currentTarget.getAttribute('data-plugin-id'); @@ -518,14 +570,53 @@ export default class PluginManagement extends AdminSettings { ); } + renderOverwritePluginModal = () => { + const title = ( + + ); + + const message = ( + + ); + + const overwriteButton = ( + + ); + + return ( + + ); + } + renderSettings() { const {enableUploads} = this.state; const enable = this.props.config.PluginSettings.Enable; let serverError = ''; + let lastMessage = ''; if (this.state.serverError) { serverError =
; } + if (this.state.lastMessage) { + lastMessage =
{this.state.lastMessage}
; + } let btnClass = 'btn'; if (this.state.fileSelected) { @@ -533,8 +624,8 @@ export default class PluginManagement extends AdminSettings { } let fileName; - if (this.state.fileName) { - fileName = this.state.fileName; + if (this.state.file) { + fileName = this.state.file.name; } let uploadButtonText; @@ -655,6 +746,8 @@ export default class PluginManagement extends AdminSettings { const uploadBtnClass = enableUploads ? 'btn btn-primary' : 'btn'; + const overwritePluginModal = this.state.confirmModal && this.renderOverwritePluginModal(); + return (
@@ -716,6 +809,7 @@ export default class PluginManagement extends AdminSettings { {fileName}
{serverError} + {lastMessage}

{uploadHelpText}

@@ -723,6 +817,7 @@ export default class PluginManagement extends AdminSettings {
{pluginsContainer} + {overwritePluginModal}
); } diff --git a/components/admin_console/request_button/__snapshots__/request_button.test.jsx.snap b/components/admin_console/request_button/__snapshots__/request_button.test.jsx.snap index 4df5e9733df7..253a83644986 100644 --- a/components/admin_console/request_button/__snapshots__/request_button.test.jsx.snap +++ b/components/admin_console/request_button/__snapshots__/request_button.test.jsx.snap @@ -13,11 +13,16 @@ exports[`components/admin_console/request_button/request_button.jsx should match disabled={false} onClick={[Function]} > - + + +
- - - Button Text - - + + + Button Text + + +
- - - Button Text - - + + + Button Text + + +
- - - Button Text - - + + + Button Text + + +
- - - Button Text - - + + + Button Text + + +
- - {loadingText} - - ); - } else { - contents = this.props.buttonText; - } - let widgetClassNames = 'col-sm-8'; let label = null; if (this.props.label) { @@ -267,7 +249,12 @@ export default class RequestButton extends React.Component { onClick={this.handleRequest} disabled={this.props.disabled} > - {contents} + + {this.props.buttonText} + {this.props.alternativeActionElement} {message} diff --git a/components/admin_console/system_users/index.js b/components/admin_console/system_users/index.js index eb242ec7aace..fc15a1664aaf 100644 --- a/components/admin_console/system_users/index.js +++ b/components/admin_console/system_users/index.js @@ -3,25 +3,26 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; + import {getTeams, getTeamStats} from 'mattermost-redux/actions/teams'; import {getUser, getUserAccessToken} from 'mattermost-redux/actions/users'; import {getTeamsList} from 'mattermost-redux/selectors/entities/teams'; import {getUsers} from 'mattermost-redux/selectors/entities/users'; -import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {Stats} from 'mattermost-redux/constants'; +import {loadProfilesAndTeamMembers} from 'actions/user_actions.jsx'; + import {setSystemUsersSearch} from 'actions/views/search'; import {SearchUserTeamFilter} from 'utils/constants.jsx'; import SystemUsers from './system_users.jsx'; function mapStateToProps(state) { - const license = getLicense(state); const config = getConfig(state); const siteName = config.SiteName; - const mfaEnabled = (license && license.IsLicensed === 'true' && license.MFA === 'true') && - config.EnableMultifactorAuthentication === 'true'; + const mfaEnabled = config.EnableMultifactorAuthentication === 'true'; const enableUserAccessTokens = config.EnableUserAccessTokens === 'true'; const experimentalEnableAuthenticationTransfer = config.ExperimentalEnableAuthenticationTransfer === 'true'; @@ -64,6 +65,7 @@ function mapDispatchToProps(dispatch) { getTeamStats, getUser, getUserAccessToken, + loadProfilesAndTeamMembers, setSystemUsersSearch, }, dispatch), }; diff --git a/components/admin_console/system_users/system_users.jsx b/components/admin_console/system_users/system_users.jsx index 42fa5d7d7a89..c19c1d8c92fb 100644 --- a/components/admin_console/system_users/system_users.jsx +++ b/components/admin_console/system_users/system_users.jsx @@ -7,7 +7,7 @@ import {FormattedMessage} from 'react-intl'; import {getStandardAnalytics} from 'actions/admin_actions.jsx'; import {reloadIfServerVersionChanged} from 'actions/global_actions.jsx'; -import {loadProfiles, loadProfilesAndTeamMembers, loadProfilesWithoutTeam, searchUsers} from 'actions/user_actions.jsx'; +import {loadProfiles, loadProfilesWithoutTeam, searchUsers} from 'actions/user_actions.jsx'; import {Constants, UserSearchOptions, SearchUserTeamFilter} from 'utils/constants.jsx'; import * as Utils from 'utils/utils.jsx'; @@ -71,6 +71,7 @@ export default class SystemUsers extends React.Component { * Function to get a user access token */ getUserAccessToken: PropTypes.func.isRequired, + loadProfilesAndTeamMembers: PropTypes.func.isRequired, setSystemUsersSearch: PropTypes.func.isRequired, }).isRequired, } @@ -78,12 +79,10 @@ export default class SystemUsers extends React.Component { constructor(props) { super(props); - this.loadDataForTeam = this.loadDataForTeam.bind(this); this.loadComplete = this.loadComplete.bind(this); this.handleTeamChange = this.handleTeamChange.bind(this); this.handleTermChange = this.handleTermChange.bind(this); - this.nextPage = this.nextPage.bind(this); this.doSearch = this.doSearch.bind(this); this.search = this.search.bind(this); @@ -106,7 +105,7 @@ export default class SystemUsers extends React.Component { this.props.actions.setSystemUsersSearch('', ''); } - loadDataForTeam(teamId) { + loadDataForTeam = async (teamId) => { if (this.props.searchTerm) { this.search(this.props.searchTerm, teamId); return; @@ -118,7 +117,11 @@ export default class SystemUsers extends React.Component { } else if (teamId === SearchUserTeamFilter.NO_TEAM) { loadProfilesWithoutTeam(0, Constants.PROFILE_CHUNK_SIZE, this.loadComplete); } else { - loadProfilesAndTeamMembers(0, Constants.PROFILE_CHUNK_SIZE, teamId, this.loadComplete); + const {data} = await this.props.actions.loadProfilesAndTeamMembers(0, Constants.PROFILE_CHUNK_SIZE, teamId); + if (data) { + this.loadComplete(); + } + this.props.actions.getTeamStats(teamId); } } @@ -137,7 +140,7 @@ export default class SystemUsers extends React.Component { this.props.actions.setSystemUsersSearch(term, this.props.teamId); } - nextPage(page) { + nextPage = async (page) => { // Paging isn't supported while searching if (this.props.teamId === SearchUserTeamFilter.ALL_USERS) { @@ -145,7 +148,10 @@ export default class SystemUsers extends React.Component { } else if (this.props.teamId === SearchUserTeamFilter.NO_TEAM) { loadProfilesWithoutTeam(page + 1, USERS_PER_PAGE, this.loadComplete); } else { - loadProfilesAndTeamMembers(page + 1, USERS_PER_PAGE, this.props.teamId, this.loadComplete); + const {data} = await this.props.actions.loadProfilesAndTeamMembers(page + 1, USERS_PER_PAGE, this.props.teamId); + if (data) { + this.loadComplete(); + } } } diff --git a/components/admin_console/system_users/system_users.test.jsx b/components/admin_console/system_users/system_users.test.jsx index 942e2378fec7..b35a5936321f 100644 --- a/components/admin_console/system_users/system_users.test.jsx +++ b/components/admin_console/system_users/system_users.test.jsx @@ -22,6 +22,7 @@ describe('components/admin_console/system_users', () => { getTeamStats: jest.fn().mockImplementation(() => Promise.resolve()), getUser: jest.fn().mockImplementation(() => Promise.resolve()), getUserAccessToken: jest.fn().mockImplementation(() => Promise.resolve()), + loadProfilesAndTeamMembers: jest.fn(), setSystemUsersSearch: jest.fn().mockImplementation(() => Promise.resolve()), }, }; diff --git a/components/announcement_bar/announcement_bar.jsx b/components/announcement_bar/announcement_bar.jsx index 0271c52be9f7..c5314acab0e1 100644 --- a/components/announcement_bar/announcement_bar.jsx +++ b/components/announcement_bar/announcement_bar.jsx @@ -24,6 +24,28 @@ export default class AnnouncementBar extends React.PureComponent { handleClose: null, } + componentDidMount() { + this.setBodyClass(!this.props.showCloseButton); + } + + componentWillUnmount() { + document.body.classList.remove('announcement-bar--fixed'); + } + + componentDidUpdate(prevProps) { + if (this.props.showCloseButton !== prevProps.showCloseButton) { + this.setBodyClass(!this.props.showCloseButton); + } + } + + setBodyClass = (fixed) => { + if (fixed) { + document.body.classList.add('announcement-bar--fixed'); + } else { + document.body.classList.remove('announcement-bar--fixed'); + } + } + handleClose = (e) => { e.preventDefault(); if (this.props.handleClose) { diff --git a/components/announcement_bar/configuration_bar.jsx b/components/announcement_bar/configuration_bar.jsx index 0b8b6847ec54..d57116afc12f 100644 --- a/components/announcement_bar/configuration_bar.jsx +++ b/components/announcement_bar/configuration_bar.jsx @@ -11,6 +11,7 @@ import {isLicenseExpired, isLicenseExpiring, isLicensePastGracePeriod} from 'uti import {AnnouncementBarTypes, AnnouncementBarMessages} from 'utils/constants.jsx'; import {t} from 'utils/i18n'; +import * as Utils from 'utils/utils'; import FormattedMarkdownMessage from 'components/formatted_markdown_message'; @@ -105,11 +106,11 @@ export default class ConfigurationAnnouncementBar extends React.PureComponent { } } - if (!this.props.config.SendEmailNotifications === 'true' && + if (this.props.config.SendEmailNotifications !== 'true' && this.props.config.EnablePreviewModeBanner === 'true') { return ( - } + spinningText={Utils.localizeMessage('announcement_bar.error.sending', 'Sending')} regularText={ - - - - {this.props.spinningText} - - ); - } return ( - - {this.props.regularText} - + + {this.props.regularText} + + ); } diff --git a/components/channel_header/channel_header.js b/components/channel_header/channel_header.js index 4ab795df30a0..7fa1d02797b3 100644 --- a/components/channel_header/channel_header.js +++ b/components/channel_header/channel_header.js @@ -10,6 +10,7 @@ import {memoizeResult} from 'mattermost-redux/utils/helpers'; import 'bootstrap'; +import EditChannelHeaderModal from 'components/edit_channel_header_modal'; import Markdown from 'components/markdown'; import PopoverListMembers from 'components/popover_list_members'; import SearchBar from 'components/search_bar'; @@ -53,6 +54,7 @@ export default class ChannelHeader extends React.PureComponent { rhsState: PropTypes.oneOf( Object.values(RHSStates), ), + isQuickSwitcherOpen: PropTypes.bool, actions: PropTypes.shape({ favoriteChannel: PropTypes.func.isRequired, unfavoriteChannel: PropTypes.func.isRequired, @@ -79,7 +81,6 @@ export default class ChannelHeader extends React.PureComponent { const showSearchBar = Utils.windowWidth() > SEARCH_BAR_MINIMUM_WINDOW_SIZE; this.state = { showSearchBar, - showQuickSwitch: false, }; this.getHeaderMarkdownOptions = memoizeResult((channelNamesMap) => ( @@ -214,17 +215,30 @@ export default class ChannelHeader extends React.PureComponent { } toggleQuickSwitchModal = () => { - const {showQuickSwitch} = this.state; - if (showQuickSwitch) { + const {isQuickSwitcherOpen} = this.props; + if (isQuickSwitcherOpen) { + this.props.actions.closeModal(ModalIdentifiers.QUICK_SWITCH); + } else { this.props.actions.openModal({ modalId: ModalIdentifiers.QUICK_SWITCH, dialogType: QuickSwitchModal, }); - } else { - this.props.actions.closeModal(ModalIdentifiers.QUICK_SWITCH); } + } + + showEditChannelHeaderModal = () => { + if (this.refs.headerOverlay) { + this.refs.headerOverlay.hide(); + } + + const {actions, channel} = this.props; + const modalData = { + modalId: ModalIdentifiers.EDIT_CHANNEL_HEADER, + dialogType: EditChannelHeaderModal, + dialogProps: {channel}, + }; - this.setState({showQuickSwitch: !showQuickSwitch}); + actions.openModal(modalData); } render() { diff --git a/components/channel_header/index.js b/components/channel_header/index.js index f7a9988e2b38..e951d7ed6119 100644 --- a/components/channel_header/index.js +++ b/components/channel_header/index.js @@ -35,6 +35,8 @@ import { updateRhsState, } from 'actions/views/rhs'; import {getRhsState} from 'selectors/rhs'; +import {isModalOpen} from 'selectors/views/modals'; +import {ModalIdentifiers} from 'utils/constants'; import ChannelHeader from './channel_header'; @@ -58,6 +60,7 @@ const mapStateToProps = (state) => { isFavorite: isCurrentChannelFavorite(state), isReadOnly: isCurrentChannelReadOnly(state), isMuted: isCurrentChannelMuted(state), + isQuickSwitcherOpen: isModalOpen(state, ModalIdentifiers.QUICK_SWITCH), }; }; diff --git a/components/channel_header_dropdown/menu_items/__snapshots__/add_members.test.js.snap b/components/channel_header_dropdown/menu_items/__snapshots__/add_members.test.js.snap index 9a302e6edace..d01e5614366d 100644 --- a/components/channel_header_dropdown/menu_items/__snapshots__/add_members.test.js.snap +++ b/components/channel_header_dropdown/menu_items/__snapshots__/add_members.test.js.snap @@ -24,6 +24,7 @@ exports[`components/ChannelHeaderDropdown/MenuItem.AddMembers should match snaps } } dialogType={[Function]} + id="channelAddMembers" modalId="channel_invite" role="menuitem" > @@ -36,3 +37,27 @@ exports[`components/ChannelHeaderDropdown/MenuItem.AddMembers should match snaps `; + +exports[`components/ChannelHeaderDropdown/MenuItem.AddMembers should render correctly if the channel type is DM 1`] = ` +
  • + + + +
  • +`; diff --git a/components/channel_header_dropdown/menu_items/__snapshots__/convert_channel.test.js.snap b/components/channel_header_dropdown/menu_items/__snapshots__/convert_channel.test.js.snap index 0f03bf96cf7a..860411e34842 100644 --- a/components/channel_header_dropdown/menu_items/__snapshots__/convert_channel.test.js.snap +++ b/components/channel_header_dropdown/menu_items/__snapshots__/convert_channel.test.js.snap @@ -20,6 +20,7 @@ exports[`components/ChannelHeaderDropdown/MenuItem.ConvertChannel should match s } } dialogType={[Function]} + id="channelConvert" modalId="convert_channel" role="menuitem" > diff --git a/components/channel_header_dropdown/menu_items/__snapshots__/notification_preferences.test.js.snap b/components/channel_header_dropdown/menu_items/__snapshots__/notification_preferences.test.js.snap index 78f9c7a6d730..cf59f93e3085 100644 --- a/components/channel_header_dropdown/menu_items/__snapshots__/notification_preferences.test.js.snap +++ b/components/channel_header_dropdown/menu_items/__snapshots__/notification_preferences.test.js.snap @@ -14,6 +14,7 @@ exports[`components/ChannelHeaderDropdown/MenuItem.NotificationPreferences shoul } } dialogType={[Function]} + id="channelNotifications" modalId="channel_notifications" role="menuitem" > diff --git a/components/channel_header_dropdown/menu_items/__snapshots__/rename_channel.test.js.snap b/components/channel_header_dropdown/menu_items/__snapshots__/rename_channel.test.js.snap index 4c741b85814c..74a926ce4da3 100644 --- a/components/channel_header_dropdown/menu_items/__snapshots__/rename_channel.test.js.snap +++ b/components/channel_header_dropdown/menu_items/__snapshots__/rename_channel.test.js.snap @@ -24,6 +24,7 @@ exports[`components/ChannelHeaderDropdown/MenuItem.RenameChannel should match sn } } dialogType={[Function]} + id="channelRename" modalId="rename_channel" role="menuitem" > diff --git a/components/channel_header_dropdown/menu_items/__snapshots__/set_channel_header.test.js.snap b/components/channel_header_dropdown/menu_items/__snapshots__/set_channel_header.test.js.snap index 2b16514514dc..fccc7ff24b84 100644 --- a/components/channel_header_dropdown/menu_items/__snapshots__/set_channel_header.test.js.snap +++ b/components/channel_header_dropdown/menu_items/__snapshots__/set_channel_header.test.js.snap @@ -24,7 +24,7 @@ exports[`components/ChannelHeaderDropdown/MenuItem.SetChannelHeader should match } } dialogType={[Function]} - id="editChannelHeader" + id="channelEditHeader" modalId="edit_channel_header" role="menuitem" > diff --git a/components/channel_header_dropdown/menu_items/__snapshots__/set_channel_purpose.test.js.snap b/components/channel_header_dropdown/menu_items/__snapshots__/set_channel_purpose.test.js.snap index f30995d27fa6..aa7a757ebf79 100644 --- a/components/channel_header_dropdown/menu_items/__snapshots__/set_channel_purpose.test.js.snap +++ b/components/channel_header_dropdown/menu_items/__snapshots__/set_channel_purpose.test.js.snap @@ -24,6 +24,7 @@ exports[`components/ChannelHeaderDropdown/MenuItem.SetChannelPurpose should matc } } dialogType={[Function]} + id="channelEditPurpose" modalId="edit_channel_purpose" role="menuitem" > diff --git a/components/channel_header_dropdown/menu_items/__snapshots__/view_and_manage_members.test.js.snap b/components/channel_header_dropdown/menu_items/__snapshots__/view_and_manage_members.test.js.snap index e7983ad03c2d..87c383d3fde2 100644 --- a/components/channel_header_dropdown/menu_items/__snapshots__/view_and_manage_members.test.js.snap +++ b/components/channel_header_dropdown/menu_items/__snapshots__/view_and_manage_members.test.js.snap @@ -13,6 +13,7 @@ exports[`components/ChannelHeaderDropdown/MenuItem.ViewAndManageMembers should m } } dialogType={[Function]} + id="channelManageMembers" modalId="channel_members" role="menuitem" > @@ -60,6 +61,7 @@ exports[`components/ChannelHeaderDropdown/MenuItem.ViewAndManageMembers should m } } dialogType={[Function]} + id="channelManageMembers" modalId="channel_members" role="menuitem" > diff --git a/components/channel_header_dropdown/menu_items/__snapshots__/view_channel_info.test.js.snap b/components/channel_header_dropdown/menu_items/__snapshots__/view_channel_info.test.js.snap index 098ebcf95a80..d45656789c7a 100644 --- a/components/channel_header_dropdown/menu_items/__snapshots__/view_channel_info.test.js.snap +++ b/components/channel_header_dropdown/menu_items/__snapshots__/view_channel_info.test.js.snap @@ -13,6 +13,7 @@ exports[`components/ChannelHeaderDropdown/MenuItem.ViewChannelInfo should match } } dialogType={[Function]} + id="channelViewInfo" modalId="channel_info" role="menuitem" > diff --git a/components/channel_header_dropdown/menu_items/add_members.js b/components/channel_header_dropdown/menu_items/add_members.js index 01c3480901e4..65588bef0aff 100644 --- a/components/channel_header_dropdown/menu_items/add_members.js +++ b/components/channel_header_dropdown/menu_items/add_members.js @@ -9,6 +9,7 @@ import {Permissions} from 'mattermost-redux/constants'; import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate'; import ToggleModalButtonRedux from 'components/toggle_modal_button_redux'; import ChannelInviteModal from 'components/channel_invite_modal'; +import MoreDirectChannels from 'components/more_direct_channels'; import {Constants, ModalIdentifiers} from 'utils/constants'; const AddMembers = ({channel, isDefault, isArchived}) => { @@ -25,7 +26,22 @@ const AddMembers = ({channel, isDefault, isArchived}) => { } if (channel.type === Constants.GM_CHANNEL) { - return null; + return ( +
  • + + + +
  • + ); } const isPrivate = channel.type === Constants.PRIVATE_CHANNEL; @@ -39,6 +55,7 @@ const AddMembers = ({channel, isDefault, isArchived}) => { >
  • { expect(wrapper.isEmptyRender()).toBeTruthy(); }); - it('should be hidden if the channel type is DM or GM', () => { + it('should be hidden if the channel type is DM', () => { const props = { ...baseProps, channel: {...baseProps.channel}, @@ -51,8 +51,18 @@ describe('components/ChannelHeaderDropdown/MenuItem.AddMembers', () => { props.channel.type = Constants.DM_CHANNEL; expect(makeWrapper().isEmptyRender()).toBeTruthy(); + }); - props.channel.type = Constants.GM_CHANNEL; - expect(makeWrapper().isEmptyRender()).toBeTruthy(); + it('should render correctly if the channel type is DM', () => { + const props = { + ...baseProps, + channel: { + ...baseProps.channel, + type: Constants.GM_CHANNEL, + }, + }; + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); }); }); diff --git a/components/channel_header_dropdown/menu_items/close_channel/__snapshots__/close_channel.test.js.snap b/components/channel_header_dropdown/menu_items/close_channel/__snapshots__/close_channel.test.js.snap index c07e37efade1..4300697e8433 100644 --- a/components/channel_header_dropdown/menu_items/close_channel/__snapshots__/close_channel.test.js.snap +++ b/components/channel_header_dropdown/menu_items/close_channel/__snapshots__/close_channel.test.js.snap @@ -6,6 +6,7 @@ exports[`components/ChannelHeaderDropdown/MenuItem.CloseChannel should match sna > ); } else if (this.props.enableUserCreation) { diff --git a/components/leave_team_modal/index.js b/components/leave_team_modal/index.js index b3e7c8516947..e91e59fa7c7d 100644 --- a/components/leave_team_modal/index.js +++ b/components/leave_team_modal/index.js @@ -2,11 +2,12 @@ // See LICENSE.txt for license information. import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; +import {removeUserFromTeam as leaveTeam} from 'mattermost-redux/actions/teams'; import {toggleSideBarRightMenuAction} from 'actions/global_actions.jsx'; -import {removeUserFromTeam} from 'actions/team_actions'; import {ModalIdentifiers} from 'utils/constants'; import {isModalOpen} from 'selectors/views/modals'; @@ -22,11 +23,16 @@ function mapStateToProps(state) { currentUserId, currentTeamId, show, - actions: { - removeUserFromTeam, + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + leaveTeam, toggleSideBarRightMenu: toggleSideBarRightMenuAction, - }, + }, dispatch), }; } -export default connect(mapStateToProps)(LeaveTeamModal); +export default connect(mapStateToProps, mapDispatchToProps)(LeaveTeamModal); diff --git a/components/leave_team_modal/leave_team_modal.jsx b/components/leave_team_modal/leave_team_modal.jsx index 849812b5ea52..e88ddf47a212 100644 --- a/components/leave_team_modal/leave_team_modal.jsx +++ b/components/leave_team_modal/leave_team_modal.jsx @@ -42,7 +42,7 @@ class LeaveTeamModal extends React.PureComponent { * An action to remove user from team */ - removeUserFromTeam: PropTypes.func.isRequired, + leaveTeam: PropTypes.func.isRequired, /** * An action to toggle the right menu @@ -70,7 +70,7 @@ class LeaveTeamModal extends React.PureComponent { handleSubmit = () => { this.props.onHide(); - this.props.actions.removeUserFromTeam(this.props.currentTeamId, this.props.currentUserId); + this.props.actions.leaveTeam(this.props.currentTeamId, this.props.currentUserId); this.props.actions.toggleSideBarRightMenu(); }; diff --git a/components/leave_team_modal/leave_team_modal.test.jsx b/components/leave_team_modal/leave_team_modal.test.jsx index ad1fbb787f27..eaa8e92d6780 100644 --- a/components/leave_team_modal/leave_team_modal.test.jsx +++ b/components/leave_team_modal/leave_team_modal.test.jsx @@ -14,7 +14,7 @@ describe('components/LeaveTeamModal', () => { show: false, isBusy: false, actions: { - removeUserFromTeam: jest.fn(), + leaveTeam: jest.fn(), toggleSideBarRightMenu: jest.fn(), }, @@ -35,16 +35,16 @@ describe('components/LeaveTeamModal', () => { expect(requiredProps.onHide).toHaveBeenCalledTimes(1); }); - it('should call removeUserFromTeam and toggleSideBarRightMenu when ok is clicked', () => { + it('should call leaveTeam and toggleSideBarRightMenu when ok is clicked', () => { const wrapper = shallowWithIntl(). dive({disableLifecycleMethods: true}); const ok = wrapper.find('.btn-danger').first(); ok.simulate('click'); - expect(requiredProps.actions.removeUserFromTeam).toHaveBeenCalledTimes(1); + expect(requiredProps.actions.leaveTeam).toHaveBeenCalledTimes(1); expect(requiredProps.actions.toggleSideBarRightMenu).toHaveBeenCalledTimes(1); expect(requiredProps.onHide).toHaveBeenCalledTimes(1); - expect(requiredProps.actions.removeUserFromTeam). + expect(requiredProps.actions.leaveTeam). toHaveBeenCalledWith(requiredProps.currentTeamId, requiredProps.currentUserId); }); diff --git a/components/loading_image_preview.jsx b/components/loading_image_preview.jsx index 83c1c3f7e149..0eb9a80ef761 100644 --- a/components/loading_image_preview.jsx +++ b/components/loading_image_preview.jsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import React from 'react'; -import loadingGif from 'images/load.gif'; +import LoadingBars from 'components/widgets/loading/loading_bars.jsx'; export default function LoadingImagePreview({loading, progress, containerClass}) { let progressView = null; @@ -18,10 +18,7 @@ export default function LoadingImagePreview({loading, progress, containerClass}) return (
    - + {progressView}
    ); diff --git a/components/login/login_controller/login_controller.jsx b/components/login/login_controller/login_controller.jsx index a5be7c6071c5..9685054a17b6 100644 --- a/components/login/login_controller/login_controller.jsx +++ b/components/login/login_controller/login_controller.jsx @@ -27,6 +27,7 @@ import FormError from 'components/form_error.jsx'; import FormattedMarkdownMessage from 'components/formatted_markdown_message.jsx'; import BackButton from 'components/common/back_button.jsx'; import LoadingScreen from 'components/loading_screen.jsx'; +import LoadingWrapper from 'components/widgets/loading/loading_wrapper.jsx'; import LoginMfa from '../login_mfa.jsx'; class LoginController extends React.Component { @@ -270,12 +271,12 @@ class LoginController extends React.Component { submit = (loginId, password, token) => { this.setState({serverError: null, loading: true}); - this.props.actions.login(loginId, password, token).then(({error}) => { + this.props.actions.login(loginId, password, token).then(async ({error}) => { if (error) { - if (error.id === 'api.user.login.not_verified.app_error') { + if (error.server_error_id === 'api.user.login.not_verified.app_error') { browserHistory.push('/should_verify_email?&email=' + encodeURIComponent(loginId)); - } else if (error.id === 'store.sql_user.get_for_login.app_error' || - error.id === 'ent.ldap.do_login.user_not_registered.app_error') { + } else if (error.server_error_id === 'store.sql_user.get_for_login.app_error' || + error.server_error_id === 'ent.ldap.do_login.user_not_registered.app_error') { this.setState({ showMfa: false, loading: false, @@ -286,7 +287,7 @@ class LoginController extends React.Component { /> ), }); - } else if (error.id === 'api.user.check_user_password.invalid.app_error' || error.id === 'ent.ldap.do_login.invalid_password.app_error') { + } else if (error.server_error_id === 'api.user.check_user_password.invalid.app_error' || error.server_error_id === 'ent.ldap.do_login.invalid_password.app_error') { this.setState({ showMfa: false, loading: false, @@ -310,7 +311,7 @@ class LoginController extends React.Component { const inviteId = params.get('id') || ''; if (inviteId || inviteToken) { - const {data: team} = this.props.actions.addUserToTeamFromInvite(inviteToken, inviteId); + const {data: team} = await this.props.actions.addUserToTeamFromInvite(inviteToken, inviteId); if (team) { this.finishSignin(team); } else { @@ -546,28 +547,6 @@ class LoginController extends React.Component { errorClass = ' has-error'; } - let loginButton = ( - - ); - - if (this.state.loading) { - loginButton = ( - - - - - ); - } - loginControls.push(
    - { loginButton } + + +
  • diff --git a/components/markdown/markdown.jsx b/components/markdown/markdown.jsx index 4503e813d07a..f17317f3f869 100644 --- a/components/markdown/markdown.jsx +++ b/components/markdown/markdown.jsx @@ -67,12 +67,12 @@ export default class Markdown extends React.PureComponent { proxyImages: PropTypes.bool, /** - * Any extra props that should be passed into the MarkdownImage component + * Any extra props that should be passed into the image component */ imageProps: PropTypes.object, /** - * prop for passed down to MarkdownImage component for dimensions + * prop for passed down to image component for dimensions */ imagesMetadata: PropTypes.object, }; diff --git a/components/markdown_image.jsx b/components/markdown_image.jsx deleted file mode 100644 index 91e8f384b283..000000000000 --- a/components/markdown_image.jsx +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import PropTypes from 'prop-types'; -import React from 'react'; - -export default class MarkdownImage extends React.PureComponent { - static propTypes = { - - /* - * dimensions object to create empty space required to prevent scroll pop - */ - dimensions: PropTypes.object, - - /* - * A callback that is called as soon as the image component has a height value - */ - onHeightReceived: PropTypes.func, - } - - handleLoad = () => { - // image is loaded but still havent recived new post webscoket event for metadata - // so meanwhile correct manually - - if (!this.props.dimensions && this.props.onHeightReceived) { - this.props.onHeightReceived(this.refs.image.height); - } - }; - - render() { - const props = {...this.props}; - Reflect.deleteProperty(props, 'onHeightReceived'); - Reflect.deleteProperty(props, 'dimensions'); - - return ( - - ); - } -} diff --git a/components/markdown_image.test.jsx b/components/markdown_image.test.jsx deleted file mode 100644 index 9decf611b9c6..000000000000 --- a/components/markdown_image.test.jsx +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {shallow} from 'enzyme'; - -import MarkdownImage from 'components/markdown_image'; - -describe('components/MarkdownImage', () => { - test('should match snapshot', () => { - const wrapper = shallow( - - ); - - expect(wrapper).toMatchSnapshot(); - }); - - test('should call onHeightReceived', () => { - const onHeightReceived = jest.fn(); - const wrapper = shallow( - - ); - const instance = wrapper.instance(); - - instance.refs = { - image: { - height: 100, - }, - }; - wrapper.find('img').prop('onLoad')(); - expect(onHeightReceived).toHaveBeenCalledWith(100); - }); -}); diff --git a/components/member_list_channel/index.js b/components/member_list_channel/index.js index 1d4047d897ef..350510610940 100644 --- a/components/member_list_channel/index.js +++ b/components/member_list_channel/index.js @@ -11,6 +11,10 @@ import {getChannelStats} from 'mattermost-redux/actions/channels'; import {searchProfiles} from 'mattermost-redux/actions/users'; import {sortByUsername} from 'mattermost-redux/utils/user_utils'; +import { + loadProfilesAndTeamMembersAndChannelMembers, + loadTeamMembersAndChannelMembersForProfilesList, +} from 'actions/user_actions.jsx'; import {loadStatusesForProfilesList} from 'actions/status_actions.jsx'; import {setModalSearchTerm} from 'actions/views/search'; @@ -73,7 +77,9 @@ function mapDispatchToProps(dispatch) { searchProfiles, getChannelStats, setModalSearchTerm, + loadProfilesAndTeamMembersAndChannelMembers, loadStatusesForProfilesList, + loadTeamMembersAndChannelMembersForProfilesList, }, dispatch), }; } diff --git a/components/member_list_channel/member_list_channel.jsx b/components/member_list_channel/member_list_channel.jsx index 86ae1a1eb7fa..c3957c6460d5 100644 --- a/components/member_list_channel/member_list_channel.jsx +++ b/components/member_list_channel/member_list_channel.jsx @@ -4,8 +4,6 @@ import PropTypes from 'prop-types'; import React from 'react'; -import {loadProfilesAndTeamMembersAndChannelMembers, loadTeamMembersAndChannelMembersForProfilesList} from 'actions/user_actions.jsx'; - import Constants from 'utils/constants.jsx'; import * as UserAgent from 'utils/user_agent.jsx'; @@ -27,7 +25,9 @@ export default class MemberListChannel extends React.PureComponent { searchProfiles: PropTypes.func.isRequired, getChannelStats: PropTypes.func.isRequired, setModalSearchTerm: PropTypes.func.isRequired, + loadProfilesAndTeamMembersAndChannelMembers: PropTypes.func.isRequired, loadStatusesForProfilesList: PropTypes.func.isRequired, + loadTeamMembersAndChannelMembersForProfilesList: PropTypes.func.isRequired, }).isRequired, } @@ -42,8 +42,19 @@ export default class MemberListChannel extends React.PureComponent { } componentDidMount() { - loadProfilesAndTeamMembersAndChannelMembers(0, Constants.PROFILE_CHUNK_SIZE, this.props.currentTeamId, this.props.currentChannelId, this.loadComplete); - this.props.actions.getChannelStats(this.props.currentChannelId); + const { + actions, + currentChannelId, + currentTeamId, + } = this.props; + + actions.loadProfilesAndTeamMembersAndChannelMembers(0, Constants.PROFILE_CHUNK_SIZE, currentTeamId, currentChannelId).then(({data}) => { + if (data) { + this.loadComplete(); + } + }); + + actions.getChannelStats(currentChannelId); } componentWillUnmount() { @@ -71,8 +82,12 @@ export default class MemberListChannel extends React.PureComponent { this.setState({loading: true}); - this.props.actions.loadStatusesForProfilesList(data); - loadTeamMembersAndChannelMembersForProfilesList(data, nextProps.currentTeamId, nextProps.currentChannelId, this.loadComplete); + nextProps.actions.loadStatusesForProfilesList(data); + nextProps.actions.loadTeamMembersAndChannelMembersForProfilesList(data, nextProps.currentTeamId, nextProps.currentChannelId).then(({data: membersLoaded}) => { + if (membersLoaded) { + this.loadComplete(); + } + }); }, Constants.SEARCH_TIMEOUT_MILLISECONDS ); @@ -85,8 +100,8 @@ export default class MemberListChannel extends React.PureComponent { this.setState({loading: false}); } - nextPage(page) { - loadProfilesAndTeamMembersAndChannelMembers(page + 1, USERS_PER_PAGE); + nextPage = (page) => { + this.props.actions.loadProfilesAndTeamMembersAndChannelMembers(page + 1, USERS_PER_PAGE); } handleSearch = (term) => { diff --git a/components/member_list_team/index.js b/components/member_list_team/index.js index a2f2ea5f2cb9..3fb18783e8ed 100644 --- a/components/member_list_team/index.js +++ b/components/member_list_team/index.js @@ -3,6 +3,7 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; + import {getTeamStats} from 'mattermost-redux/actions/teams'; import {haveITeamPermission} from 'mattermost-redux/selectors/entities/roles'; import {getMembersInCurrentTeam, getCurrentTeamStats} from 'mattermost-redux/selectors/entities/teams'; @@ -11,6 +12,7 @@ import {Permissions} from 'mattermost-redux/constants'; import {searchProfiles} from 'mattermost-redux/actions/users'; import {loadStatusesForProfilesList} from 'actions/status_actions.jsx'; +import {loadProfilesAndTeamMembers, loadTeamMembersForProfilesList} from 'actions/user_actions.jsx'; import {setModalSearchTerm} from 'actions/views/search'; import MemberListTeam from './member_list_team.jsx'; @@ -44,7 +46,9 @@ function mapDispatchToProps(dispatch) { actions: bindActionCreators({ searchProfiles, getTeamStats, + loadProfilesAndTeamMembers, loadStatusesForProfilesList, + loadTeamMembersForProfilesList, setModalSearchTerm, }, dispatch), }; diff --git a/components/member_list_team/member_list_team.jsx b/components/member_list_team/member_list_team.jsx index d787bfc2d5c9..53fd1e36bd09 100644 --- a/components/member_list_team/member_list_team.jsx +++ b/components/member_list_team/member_list_team.jsx @@ -4,7 +4,6 @@ import PropTypes from 'prop-types'; import React from 'react'; -import {loadProfilesAndTeamMembers, loadTeamMembersForProfilesList} from 'actions/user_actions.jsx'; import Constants from 'utils/constants.jsx'; import * as UserAgent from 'utils/user_agent.jsx'; @@ -24,8 +23,10 @@ export default class MemberListTeam extends React.Component { actions: PropTypes.shape({ searchProfiles: PropTypes.func.isRequired, getTeamStats: PropTypes.func.isRequired, - setModalSearchTerm: PropTypes.func.isRequired, + loadProfilesAndTeamMembers: PropTypes.func.isRequired, loadStatusesForProfilesList: PropTypes.func.isRequired, + loadTeamMembersForProfilesList: PropTypes.func.isRequired, + setModalSearchTerm: PropTypes.func.isRequired, }).isRequired, } @@ -40,7 +41,12 @@ export default class MemberListTeam extends React.Component { } componentDidMount() { - loadProfilesAndTeamMembers(0, Constants.PROFILE_CHUNK_SIZE, this.props.currentTeamId, this.loadComplete); + this.props.actions.loadProfilesAndTeamMembers(0, Constants.PROFILE_CHUNK_SIZE, this.props.currentTeamId).then(({data}) => { + if (data) { + this.loadComplete(); + } + }); + this.props.actions.getTeamStats(this.props.currentTeamId); } @@ -61,7 +67,12 @@ export default class MemberListTeam extends React.Component { const searchTimeoutId = setTimeout( async () => { - const {data} = await this.props.actions.searchProfiles(searchTerm, {team_id: nextProps.currentTeamId}); + const { + loadStatusesForProfilesList, + loadTeamMembersForProfilesList, + searchProfiles, + } = nextProps.actions; + const {data} = await searchProfiles(searchTerm, {team_id: nextProps.currentTeamId}); if (searchTimeoutId !== this.searchTimeoutId) { return; @@ -69,8 +80,12 @@ export default class MemberListTeam extends React.Component { this.setState({loading: true}); - this.props.actions.loadStatusesForProfilesList(data); - loadTeamMembersForProfilesList(data, nextProps.currentTeamId, this.loadComplete); + loadStatusesForProfilesList(data); + loadTeamMembersForProfilesList(data, nextProps.currentTeamId).then(({data: membersLoaded}) => { + if (membersLoaded) { + this.loadComplete(); + } + }); }, Constants.SEARCH_TIMEOUT_MILLISECONDS ); @@ -83,8 +98,8 @@ export default class MemberListTeam extends React.Component { this.setState({loading: false}); } - nextPage(page) { - loadProfilesAndTeamMembers(page + 1, USERS_PER_PAGE); + nextPage = (page) => { + this.props.actions.loadProfilesAndTeamMembers(page + 1, USERS_PER_PAGE); } search = (term) => { diff --git a/components/mfa/mfa_controller/mfa_controller.jsx b/components/mfa/mfa_controller/mfa_controller.jsx index 06fec77d78f7..3d0fc0f6028e 100644 --- a/components/mfa/mfa_controller/mfa_controller.jsx +++ b/components/mfa/mfa_controller/mfa_controller.jsx @@ -19,7 +19,7 @@ export default class MFAController extends React.Component { document.body.classList.add('sticky'); document.getElementById('root').classList.add('container-fluid'); - if (!this.props.mfa || !this.props.enableMultifactorAuthentication) { + if (!this.props.enableMultifactorAuthentication) { this.props.history.push('/'); } } @@ -36,7 +36,7 @@ export default class MFAController extends React.Component { render() { let backButton; - if (this.props.enforceMultifactorAuthentication) { + if (this.props.mfa && this.props.enforceMultifactorAuthentication) { backButton = (
    ); } diff --git a/components/save_button.test.jsx b/components/save_button.test.jsx index 7fb15977493a..7de5f635524b 100644 --- a/components/save_button.test.jsx +++ b/components/save_button.test.jsx @@ -17,11 +17,10 @@ describe('components/SaveButton', () => { ); expect(wrapper).toMatchSnapshot(); - expect(wrapper.find('button').first().text()).toBe('Save'); expect(wrapper.find('button').first().props().disabled).toBe(false); wrapper.setProps({defaultMessage: 'Go'}); - expect(wrapper.find('button').first().text()).toBe('Go'); + expect(wrapper).toMatchSnapshot(); }); test('should match snapshot, on savingMessage', () => { @@ -31,11 +30,10 @@ describe('components/SaveButton', () => { ); expect(wrapper).toMatchSnapshot(); - expect(wrapper.find('button').first().text()).toBe('Saving'); expect(wrapper.find('button').first().props().disabled).toBe(true); wrapper.setProps({savingMessage: 'Saving Config...'}); - expect(wrapper.find('button').first().text()).toBe('Saving Config...'); + expect(wrapper).toMatchSnapshot(); }); test('should match snapshot, extraClasses', () => { diff --git a/components/search_bar/search_bar.jsx b/components/search_bar/search_bar.jsx index ef983307f361..e8b821a1177d 100644 --- a/components/search_bar/search_bar.jsx +++ b/components/search_bar/search_bar.jsx @@ -19,6 +19,7 @@ import SearchHint from 'components/search_hint/search_hint'; import FlagIcon from 'components/svg/flag_icon'; import MentionsIcon from 'components/svg/mentions_icon'; import SearchIcon from 'components/svg/search_icon'; +import LoadingSpinner from 'components/widgets/loading/loading_spinner.jsx'; const {KeyCodes} = Constants; @@ -86,7 +87,7 @@ export default class SearchBar extends React.Component { // when focus is released from the search box. setTimeout(() => { this.setState({focused: false}); - }, 100); + }, 200); } handleClear = () => { @@ -167,16 +168,6 @@ export default class SearchBar extends React.Component { } render() { - var isSearchingTerm = null; - if (this.props.isSearchingTerm) { - isSearchingTerm = ( - - ); - } - let mentionBtn; let flagBtn; if (this.props.showMentionFlagBtns) { @@ -212,10 +203,7 @@ export default class SearchBar extends React.Component { ); } - let clearClass = 'sidebar__search-clear'; - if (!this.props.isSearchingTerm && this.props.searchTerms && this.props.searchTerms.trim() !== '') { - clearClass += ' visible'; - } + const showClear = !this.props.isSearchingTerm && this.props.searchTerms && this.props.searchTerms.trim() !== ''; let searchFormClass = 'search__form'; if (this.state.focused) { @@ -278,26 +266,27 @@ export default class SearchBar extends React.Component { delayInputUpdate={true} renderDividers={true} /> -
    - - - -
    - {isSearchingTerm} + + +
    } + {this.props.isSearchingTerm && } {this.renderHintPopover()}
    diff --git a/components/search_results/search_results.jsx b/components/search_results/search_results.jsx index 9e858062ab61..2fe4a8738c17 100644 --- a/components/search_results/search_results.jsx +++ b/components/search_results/search_results.jsx @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import React from 'react'; -import {FormattedMessage} from 'react-intl'; import Scrollbars from 'react-custom-scrollbars'; import {debounce} from 'mattermost-redux/actions/helpers'; @@ -17,6 +16,7 @@ import SearchHint from 'components/search_hint/search_hint'; import FlagPostSearchHint from 'components/search_hint/flag_post_search_hint'; import NoResultSearchHint from 'components/search_hint/no_result_search_hint'; import PinPostSearchHint from 'components/search_hint/pin_post_search_hint'; +import LoadingSpinner from 'components/widgets/loading/loading_wrapper.jsx'; const GET_MORE_BUFFER = 30; @@ -136,14 +136,7 @@ export default class SearchResults extends React.PureComponent { ctls = (
    - - +
    ); diff --git a/components/search_results_item/__snapshots__/search_results_item.test.jsx.snap b/components/search_results_item/__snapshots__/search_results_item.test.jsx.snap index b81e6bbeb0ca..c9bb7337ebba 100644 --- a/components/search_results_item/__snapshots__/search_results_item.test.jsx.snap +++ b/components/search_results_item/__snapshots__/search_results_item.test.jsx.snap @@ -124,7 +124,7 @@ exports[`components/SearchResultsItem should match snapshot for DM 1`] = `
    - - - - ); - } else { - joinButton = ( - - ); - } - return (
    {channel.purpose}

    - {joinButton} +
    ); diff --git a/components/select_team/__snapshots__/select_team.test.jsx.snap b/components/select_team/__snapshots__/select_team.test.jsx.snap index c0a917c6ab18..c49c17a6407c 100644 --- a/components/select_team/__snapshots__/select_team.test.jsx.snap +++ b/components/select_team/__snapshots__/select_team.test.jsx.snap @@ -82,13 +82,6 @@ exports[`components/select_team/SelectTeam should match snapshot 1`] = ` />
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    - -
    - ); - } - const teamSignUp = (
    @@ -199,7 +188,6 @@ export default class SelectTeam extends React.Component { />
    - {teamHelp}
    ); diff --git a/components/select_team/select_team.test.jsx b/components/select_team/select_team.test.jsx index f885c434c55c..6eb4d1df3e5e 100644 --- a/components/select_team/select_team.test.jsx +++ b/components/select_team/select_team.test.jsx @@ -17,7 +17,7 @@ jest.mock('utils/policy_roles_adapter', () => ({ })); describe('components/select_team/SelectTeam', () => { - const addUserToTeamFromInvite = jest.fn(); + const addUserToTeamFromInvite = jest.fn().mockResolvedValue({data: true}); const baseProps = { currentUserRoles: 'system_admin', isMemberOfTeam: true, @@ -28,6 +28,7 @@ describe('components/select_team/SelectTeam', () => { siteName: 'Mattermost', canCreateTeams: false, canManageSystem: true, + history: {push: jest.fn()}, actions: { getTeams: jest.fn(), loadRolesIfNeeded: jest.fn(), @@ -72,9 +73,9 @@ describe('components/select_team/SelectTeam', () => { expect(wrapper).toMatchSnapshot(); }); - test('should match state and call addUserToTeamFromInvite on handleTeamClick', () => { + test('should match state and call addUserToTeamFromInvite on handleTeamClick', async () => { const wrapper = shallow(); - wrapper.instance().handleTeamClick({id: 'team_id'}); + await wrapper.instance().handleTeamClick({id: 'team_id'}); expect(wrapper.state('loadingTeamId')).toEqual('team_id'); expect(addUserToTeamFromInvite).toHaveBeenCalledTimes(1); }); diff --git a/components/setting_picture.jsx b/components/setting_picture.jsx index ede8007c4283..c44c70d71fe2 100644 --- a/components/setting_picture.jsx +++ b/components/setting_picture.jsx @@ -8,9 +8,9 @@ import exif2css from 'exif2css'; import {OverlayTrigger, Tooltip} from 'react-bootstrap'; import {Constants} from 'utils/constants.jsx'; -import {localizeMessage, fileSizeToString} from 'utils/utils.jsx'; +import {fileSizeToString, localizeMessage} from 'utils/utils.jsx'; -import loadingGif from 'images/load.gif'; +import LoadingWrapper from 'components/widgets/loading/loading_wrapper.jsx'; import FormError from 'components/form_error.jsx'; import FormattedMarkdownMessage from 'components/formatted_markdown_message.jsx'; @@ -255,42 +255,11 @@ export default class SettingPicture extends Component { const img = this.renderImg(); - let confirmButton; - let selectButtonSpinner; - let fileInputDisabled = false; - if (this.props.loadingPicture) { - confirmButton = ( - - ); - selectButtonSpinner = ( - - ); - fileInputDisabled = true; + let confirmButtonClass = 'btn btn-sm'; + if (this.props.submitActive || this.state.removeSrc || this.state.setDefaultSrc) { + confirmButtonClass += ' btn-primary'; } else { - let confirmButtonClass = 'btn btn-sm'; - if (this.props.submitActive || this.state.removeSrc || this.state.setDefaultSrc) { - confirmButtonClass += ' btn-primary'; - } else { - confirmButtonClass += ' btn-inactive disabled'; - } - - confirmButton = ( - - - - ); + confirmButtonClass += ' btn-inactive disabled'; } let helpText; @@ -328,9 +297,8 @@ export default class SettingPicture extends Component { />
    - {selectButtonSpinner}
    - {confirmButton} + true : this.handleSave} + > + + + + { + switch (sectionType) { + case 'public': + return t('sidebar.types.public'); + case 'private': + return t('sidebar.types.private'); + case 'direct': + return t('sidebar.types.direct'); + case 'favorite': + return t('sidebar.types.favorite'); + case 'unreads': + return t('sidebar.types.unreads'); + case 'recent': + return t('sidebar.types.recent'); + default: + return t('sidebar.types.alpha'); + } + } + render() { const {sectionType, channelName, browsePublicDirectChannels} = this.props; @@ -26,9 +46,10 @@ export default class ChannelName extends React.PureComponent { tooltipTriggers = []; } + const formattedMessageId = this.sectionTypeFormatMessageId(sectionType); let name = ( ); diff --git a/components/sidebar/index.js b/components/sidebar/index.js index 4bdcacbac876..a510e5af5a6e 100644 --- a/components/sidebar/index.js +++ b/components/sidebar/index.js @@ -3,11 +3,11 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; +import {createSelector} from 'reselect'; import {Preferences} from 'mattermost-redux/constants/index'; import { getCurrentChannel, - getUnreads, getSortedUnreadChannelIds, getOrderedChannelIds, } from 'mattermost-redux/selectors/entities/channels'; @@ -17,14 +17,29 @@ import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getBool as getBoolPreference, getSidebarPreferences} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; import {haveITeamPermission} from 'mattermost-redux/selectors/entities/roles'; -import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; +import {getCurrentTeam, getMyTeams, getTeamMemberships} from 'mattermost-redux/selectors/entities/teams'; import {switchToChannelById} from 'actions/views/channel'; +import {openModal} from 'actions/views/modals'; import {close} from 'actions/views/lhs'; import {getIsLhsOpen} from 'selectors/lhs'; import Sidebar from './sidebar.jsx'; +const getTotalUnreads = createSelector( + getMyTeams, + getTeamMemberships, + (myTeams, myTeamMemberships) => { + return myTeams.reduce((acc, team) => { + const member = myTeamMemberships[team.id]; + acc.messageCount += member.msg_count; + acc.mentionCount += member.mention_count; + + return acc; + }, {messageCount: 0, mentionCount: 0}); + } +); + function mapStateToProps(state) { const config = getConfig(state); const currentChannel = getCurrentChannel(state); @@ -65,7 +80,7 @@ function mapStateToProps(state) { canCreatePublicChannel, canCreatePrivateChannel, isOpen: getIsLhsOpen(state), - unreads: getUnreads(state), + unreads: getTotalUnreads(state), }; } @@ -74,6 +89,7 @@ function mapDispatchToProps(dispatch) { actions: bindActionCreators({ close, switchToChannelById, + openModal, }, dispatch), }; } diff --git a/components/sidebar/sidebar.jsx b/components/sidebar/sidebar.jsx index 6486d30e771f..c654e43019f6 100644 --- a/components/sidebar/sidebar.jsx +++ b/components/sidebar/sidebar.jsx @@ -13,15 +13,15 @@ import {SpringSystem, MathUtil} from 'rebound'; import {browserHistory} from 'utils/browser_history'; import {trackEvent} from 'actions/diagnostics_actions.jsx'; -import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; import * as ChannelUtils from 'utils/channel_utils.jsx'; -import {ActionTypes, Constants} from 'utils/constants.jsx'; +import {Constants, ModalIdentifiers, SidebarChannelGroups} from 'utils/constants.jsx'; import * as Utils from 'utils/utils.jsx'; import {t} from 'utils/i18n'; import favicon from 'images/favicon/favicon-16x16.png'; import redFavicon from 'images/favicon/redfavicon-16x16.png'; import MoreChannels from 'components/more_channels'; import MoreDirectChannels from 'components/more_direct_channels'; +import QuickSwitchModal from 'components/quick_switch_modal'; import NewChannelFlow from 'components/new_channel_flow'; import UnreadChannelIndicator from 'components/unread_channel_indicator.jsx'; import Pluggable from 'plugins/pluggable'; @@ -136,6 +136,7 @@ export default class Sidebar extends React.PureComponent { actions: PropTypes.shape({ close: PropTypes.func.isRequired, switchToChannelById: PropTypes.func.isRequired, + openModal: PropTypes.func.isRequired, }).isRequired, }; @@ -156,6 +157,7 @@ export default class Sidebar extends React.PureComponent { this.state = { newChannelModalType: '', + orderedChannelIds: props.orderedChannelIds, showDirectChannelsModal: false, showMoreChannelsModal: false, showMorePublicChannelsModal: false, @@ -167,22 +169,34 @@ export default class Sidebar extends React.PureComponent { this.unreadScrollAnimate.addListener({onSpringUpdate: this.handleScrollAnimationUpdate}); } + static getDerivedStateFromProps(nextProps, prevState) { + if (nextProps.orderedChannelIds[0].type === SidebarChannelGroups.UNREADS && + prevState.orderedChannelIds[0].type === SidebarChannelGroups.UNREADS && + prevState.orderedChannelIds[0].items.length === nextProps.orderedChannelIds[0].items.length && + prevState.orderedChannelIds[0].items.includes(nextProps.currentChannel.id) + ) { + return null; + } + + if (nextProps.orderedChannelIds !== prevState.orderedChannelIds) { + return {orderedChannelIds: nextProps.orderedChannelIds}; + } + + return null; + } + componentDidMount() { this.updateUnreadIndicators(); document.addEventListener('keydown', this.navigateChannelShortcut); document.addEventListener('keydown', this.navigateUnreadChannelShortcut); } - UNSAFE_componentWillUpdate() { // eslint-disable-line camelcase - this.updateUnreadIndicators(); - } - componentDidUpdate(prevProps) { // if the active channel disappeared (which can happen when dm channels autoclose), go to town square if (this.props.currentTeam === prevProps.currentTeam && this.props.currentChannel.id === prevProps.currentChannel.id && - !this.channelIdIsDisplayedForProps(this.props, this.props.currentChannel.id) && - this.channelIdIsDisplayedForProps(prevProps, this.props.currentChannel.id) + !this.channelIdIsDisplayedForProps(this.props.orderedChannelIds, this.props.currentChannel.id) && + this.channelIdIsDisplayedForProps(prevProps.orderedChannelIds, this.props.currentChannel.id) ) { this.closedDirectChannel = true; browserHistory.push(`/${this.props.currentTeam.name}/channels/${Constants.DEFAULT_CHANNEL}`); @@ -206,6 +220,7 @@ export default class Sidebar extends React.PureComponent { this.updateTitle(); this.setBadgesActiveAndFavicon(); this.setFirstAndLastUnreadChannels(); + this.updateUnreadIndicators(); } componentWillUnmount() { @@ -219,7 +234,7 @@ export default class Sidebar extends React.PureComponent { setBadgesActiveAndFavicon() { this.lastBadgesActive = this.badgesActive; - this.badgesActive = this.props.unreads.mentions; + this.badgesActive = this.props.unreads.mentionCount; // update the favicon to show if there are any notifications if (this.lastBadgesActive !== this.badgesActive) { @@ -242,12 +257,10 @@ export default class Sidebar extends React.PureComponent { } setFirstAndLastUnreadChannels() { - const { - currentChannel, - unreadChannelIds, - } = this.props; + const {currentChannel, unreadChannelIds} = this.props; + const {orderedChannelIds} = this.state; - this.getDisplayedChannels().map((channelId) => { + this.getDisplayedChannels(orderedChannelIds).map((channelId) => { if (channelId !== currentChannel.id && unreadChannelIds.includes(channelId)) { if (!this.firstUnreadChannel) { this.firstUnreadChannel = channelId; @@ -307,7 +320,7 @@ export default class Sidebar extends React.PureComponent { scrollToFirstUnreadChannel = () => { if (this.firstUnreadChannel) { - const displayedChannels = this.getDisplayedChannels(); + const displayedChannels = this.getDisplayedChannels(this.state.orderedChannelIds); this.unreadScrollAnimate.setCurrentValue(this.refs.scrollbar.getScrollTop()).setAtRest(); let position; if (displayedChannels.length > 0 && displayedChannels[0] === this.firstUnreadChannel) { @@ -359,11 +372,12 @@ export default class Sidebar extends React.PureComponent { showBottomUnread = true; } } - - this.setState({ - showTopUnread, - showBottomUnread, - }); + if (showTopUnread !== this.state.showTopUnread || showBottomUnread !== this.state.showBottomUnread) { + this.setState({ + showTopUnread, + showBottomUnread, + }); + } } updateScrollbarOnChannelChange = (channelId) => { @@ -382,7 +396,7 @@ export default class Sidebar extends React.PureComponent { } this.isSwitchingChannel = true; - const allChannelIds = this.getDisplayedChannels(); + const allChannelIds = this.getDisplayedChannels(this.state.orderedChannelIds); const curChannelId = this.props.currentChannel.id; let curIndex = -1; for (let i = 0; i < allChannelIds.length; i++) { @@ -416,7 +430,7 @@ export default class Sidebar extends React.PureComponent { this.isSwitchingChannel = true; - const allChannelIds = this.getDisplayedChannels(); + const allChannelIds = this.getDisplayedChannels(this.state.orderedChannelIds); let direction = 0; if (Utils.isKeyPressed(e, Constants.KeyCodes.UP)) { @@ -442,18 +456,15 @@ export default class Sidebar extends React.PureComponent { } }; - getDisplayedChannels = (props = this.props) => { - const displayedChannels = []; - - props.orderedChannelIds.forEach((section) => { - displayedChannels.push(...section.items); - }); - - return displayedChannels; + getDisplayedChannels = (orderedChannelIds = []) => { + return orderedChannelIds.reduce((allChannelIds, section) => { + allChannelIds.push(...section.items); + return allChannelIds; + }, []); }; - channelIdIsDisplayedForProps = (props, id) => { - const allChannels = this.getDisplayedChannels(props); + channelIdIsDisplayedForProps = (orderedChannelIds = [], id) => { + const allChannels = this.getDisplayedChannels(orderedChannelIds); for (let i = 0; i < allChannels.length; i++) { if (allChannels[i] === id) { return true; @@ -512,8 +523,9 @@ export default class Sidebar extends React.PureComponent { openQuickSwitcher = (e) => { e.preventDefault(); - AppDispatcher.handleViewAction({ - type: ActionTypes.TOGGLE_QUICK_SWITCH_MODAL, + this.props.actions.openModal({ + modalId: ModalIdentifiers.QUICK_SWITCH, + dialogType: QuickSwitchModal, }); } @@ -531,9 +543,9 @@ export default class Sidebar extends React.PureComponent { } renderOrderedChannels = () => { - const {orderedChannelIds} = this.props; + const {orderedChannelIds} = this.state; - const sectionsToHide = ['unreads', 'favorite']; + const sectionsToHide = [SidebarChannelGroups.UNREADS, SidebarChannelGroups.FAVORITE]; return ( { actions: { close: jest.fn(), switchToChannelById: jest.fn(), + openModal: jest.fn(), }, canCreatePublicChannel: true, canCreatePrivateChannel: true, @@ -470,8 +471,8 @@ describe('component/sidebar/sidebar_channel/SidebarChannel', () => { ); const instance = wrapper.instance(); - expect(instance.channelIdIsDisplayedForProps(instance.props, 'c1')).toBe(true); - expect(instance.channelIdIsDisplayedForProps(instance.props, 'c9')).toBe(false); + expect(instance.channelIdIsDisplayedForProps(instance.props.orderedChannelIds, 'c1')).toBe(true); + expect(instance.channelIdIsDisplayedForProps(instance.props.orderedChannelIds, 'c9')).toBe(false); }); test('should handle correctly open more direct channels toggle', () => { diff --git a/components/sidebar/sidebar_channel/index.js b/components/sidebar/sidebar_channel/index.js index be1a9337dd8a..6782c568d46e 100644 --- a/components/sidebar/sidebar_channel/index.js +++ b/components/sidebar/sidebar_channel/index.js @@ -5,7 +5,6 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import {savePreferences} from 'mattermost-redux/actions/preferences'; -import {leaveChannel} from 'mattermost-redux/actions/channels'; import { getCurrentChannelId, @@ -23,6 +22,7 @@ import {displayUsername} from 'mattermost-redux/utils/user_utils'; import {Constants, NotificationLevels, StoragePrefixes} from 'utils/constants.jsx'; +import {leaveChannel} from 'actions/views/channel'; import {open as openLhs} from 'actions/views/lhs.js'; import {getPostDraft} from 'selectors/rhs'; diff --git a/components/signup/signup_controller/__snapshots__/signup_controller.test.jsx.snap b/components/signup/signup_controller/__snapshots__/signup_controller.test.jsx.snap new file mode 100644 index 000000000000..ae7f39ce1516 --- /dev/null +++ b/components/signup/signup_controller/__snapshots__/signup_controller.test.jsx.snap @@ -0,0 +1,366 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/SignupController should call addUserToTeamFromInvite if id exists in url 1`] = ` + +`; + +exports[`components/SignupController should match snapshot for addUserToTeamFromInvite error 1`] = ` +
    + + +
    +
    + +
    +

    +

    + +

    +
    +
    + + + +
    +
    +
    + +
    +
    + + + + + + + +
    +
    +
    +`; + +exports[`components/SignupController should match snapshot for all signup options enabled with isLicensed disabled 1`] = ` +
    +`; + +exports[`components/SignupController should match snapshot for all signup options enabled with isLicensed enabled 1`] = ` +
    + + +
    +
    + +
    +

    +

    + +

    +
    +
    + + + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SAML + + + +
    + + + + + + + +
    +
    +
    +`; diff --git a/components/signup/signup_controller/signup_controller.jsx b/components/signup/signup_controller/signup_controller.jsx index 4f926d86d153..b54bd2ba7d76 100644 --- a/components/signup/signup_controller/signup_controller.jsx +++ b/components/signup/signup_controller/signup_controller.jsx @@ -95,32 +95,33 @@ export default class SignupController extends React.Component { const userLoggedIn = this.props.loggedIn; if ((inviteId || token) && userLoggedIn) { - const {data: team, error} = this.props.actions.addUserToTeamFromInvite(token, inviteId); - if (team) { - browserHistory.push('/' + team.name + `/channels/${Constants.DEFAULT_CHANNEL}`); - } else if (error) { - this.handleInvalidInvite(error); - } - } - - if (inviteId) { + this.addUserToTeamFromInvite(token, inviteId); + } else if (inviteId) { this.getInviteInfo(inviteId); - } - if (userLoggedIn) { + } else if (userLoggedIn) { GlobalActions.redirectUserToDefaultTeam(); } } } + addUserToTeamFromInvite = async (token, inviteId) => { + const {data: team, error} = await this.props.actions.addUserToTeamFromInvite(token, inviteId); + if (team) { + browserHistory.push('/' + team.name + `/channels/${Constants.DEFAULT_CHANNEL}`); + } else if (error) { + this.handleInvalidInvite(error); + } + } + getInviteInfo = async (inviteId) => { - const {data} = await this.props.actions.getTeamInviteInfo(inviteId); + const {data, error} = await this.props.actions.getTeamInviteInfo(inviteId); if (data) { this.setState({ serverError: '', loading: false, }); - } else { - this.handleInvalidInvite(); + } else if (error) { + this.handleInvalidInvite(error); } } diff --git a/components/signup/signup_controller/signup_controller.test.jsx b/components/signup/signup_controller/signup_controller.test.jsx new file mode 100644 index 000000000000..bef01d955608 --- /dev/null +++ b/components/signup/signup_controller/signup_controller.test.jsx @@ -0,0 +1,122 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; + +import * as GlobalActions from 'actions/global_actions.jsx'; +import {browserHistory} from 'utils/browser_history'; +import {Constants} from 'utils/constants.jsx'; + +import SignupController from './signup_controller.jsx'; + +jest.mock('actions/global_actions', () => ({ + redirectUserToDefaultTeam: jest.fn(), +})); + +jest.mock('utils/browser_history', () => ({ + browserHistory: { + push: jest.fn(), + }, +})); + +describe('components/SignupController', () => { + const baseProps = { + location: { + search: '', + pathname: '/signup_user_complete/', + hash: '', + }, + noAccounts: false, + loggedIn: true, + isLicensed: true, + enableOpenServer: true, + enableSAML: true, + enableLDAP: true, + enableSignUpWithEmail: true, + enableSignUpWithGitLab: true, + enableSignUpWithGoogle: true, + enableSignUpWithOffice365: true, + samlLoginButtonText: 'SAML', + ldapLoginFieldName: '', + actions: { + removeGlobalItem: jest.fn(), + getTeamInviteInfo: jest.fn(), + addUserToTeamFromInvite: jest.fn(), + }, + }; + + test('should match snapshot for all signup options enabled with isLicensed enabled', () => { + const wrapper = shallow( + + ); + expect(wrapper).toMatchSnapshot(); + }); + + test('should match snapshot for all signup options enabled with isLicensed disabled', () => { + const props = { + ...baseProps, + isLicensed: false, + }; + + const wrapper = shallow( + + ); + expect(wrapper).toMatchSnapshot(); + }); + + test('should call addUserToTeamFromInvite if id exists in url', async () => { + browserHistory.push = jest.fn(); + + const addUserToTeamFromInvite = jest.fn().mockImplementation(() => Promise.resolve({data: {name: 'defaultTeam'}})); + const getInviteInfo = jest.fn(); + const props = { + ...baseProps, + location: { + ...baseProps.location, + search: '?id=ppni7a9t87fn3j4d56rwocdctc', + }, + actions: { + ...baseProps.actions, + addUserToTeamFromInvite, + getInviteInfo, + }, + }; + + const wrapper = shallow( + + ); + + expect(wrapper).toMatchSnapshot(); + expect(addUserToTeamFromInvite).toHaveBeenCalled(); + expect(getInviteInfo).not.toHaveBeenCalled(); + expect(GlobalActions.redirectUserToDefaultTeam).not.toHaveBeenCalled(); + + await addUserToTeamFromInvite(); + expect(browserHistory.push).toHaveBeenCalledWith(`/defaultTeam/channels/${Constants.DEFAULT_CHANNEL}`); + }); + + test('should match snapshot for addUserToTeamFromInvite error', async () => { + const addUserToTeamFromInvite = jest.fn().mockImplementation(() => Promise.resolve({error: {message: 'access denied'}})); + const props = { + ...baseProps, + location: { + ...baseProps.location, + search: '?id=ppni7a9t87fn3j4d56rwocdctc', + }, + actions: { + ...baseProps.actions, + addUserToTeamFromInvite, + }, + }; + + const wrapper = shallow( + + ); + + expect(addUserToTeamFromInvite).toHaveBeenCalled(); + + await addUserToTeamFromInvite(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/components/signup/signup_email/signup_email.jsx b/components/signup/signup_email/signup_email.jsx index b0f47e8cd767..2db3be13f86c 100644 --- a/components/signup/signup_email/signup_email.jsx +++ b/components/signup/signup_email/signup_email.jsx @@ -42,78 +42,69 @@ export default class SignupEmail extends React.Component { constructor(props) { super(props); - this.state = this.getInviteInfo(); + const data = (new URLSearchParams(this.props.location.search)).get('d'); + const token = (new URLSearchParams(this.props.location.search)).get('t'); + const inviteId = (new URLSearchParams(this.props.location.search)).get('id'); + + this.state = {}; + if (token && token.length > 0) { + this.state = this.getTokenData(token, data); + } else if (inviteId && inviteId.length > 0) { + this.state = { + loading: true, + inviteId, + }; + } } componentDidMount() { trackEvent('signup', 'signup_user_01_welcome'); - } - getInviteInfo = async () => { - let data = (new URLSearchParams(this.props.location.search)).get('d'); - let token = (new URLSearchParams(this.props.location.search)).get('t'); - const inviteId = (new URLSearchParams(this.props.location.search)).get('id'); - let email = ''; - let teamDisplayName = ''; - let teamName = ''; - let teamId = ''; - let loading = false; - const serverError = ''; - const noOpenServerError = false; - - if (token && token.length > 0) { - const parsedData = JSON.parse(data); - email = parsedData.email; - teamDisplayName = parsedData.display_name; - teamName = parsedData.name; - teamId = parsedData.id; - } else if (inviteId && inviteId.length > 0) { - loading = true; - data = null; - token = null; - const {data: inviteData, error} = await this.props.actions.getTeamInviteInfo(inviteId); - if (inviteData) { - this.setState({ - loading: false, - serverError: '', - teamDisplayName: inviteData.display_name, - teamName: inviteData.name, - teamId: inviteData.id, - }); - } else if (error) { - this.setState({ - loading: false, - noOpenServerError: true, - serverError: ( - - ), - }); - } + const {inviteId} = this.state; + if (inviteId && inviteId.length > 0) { + this.getInviteInfo(inviteId); } + } + + getTokenData = (token, data) => { + const parsedData = JSON.parse(data); return { - data, + loading: false, token, - email, - teamDisplayName, - teamName, - teamId, - inviteId, - loading, - serverError, - noOpenServerError, + email: parsedData.email, + teamName: parsedData.name, }; } + getInviteInfo = async (inviteId) => { + const {data, error} = await this.props.actions.getTeamInviteInfo(inviteId); + if (data) { + this.setState({ + loading: false, + noOpenServerError: false, + serverError: '', + teamName: data.name, + }); + } else if (error) { + this.setState({loading: false, + noOpenServerError: true, + serverError: ( + + ), + }); + } + } + handleSignupSuccess = (user, data) => { trackEvent('signup', 'signup_user_02_complete'); this.props.actions.loginById(data.id, user.password, '').then(({error}) => { if (error) { - if (error.id === 'api.user.login.not_verified.app_error') { + if (error.server_error_id === 'api.user.login.not_verified.app_error') { browserHistory.push('/should_verify_email?email=' + encodeURIComponent(user.email) + '&teamname=' + encodeURIComponent(this.state.teamName)); } else { this.setState({ diff --git a/components/single_image_view/single_image_view.jsx b/components/single_image_view/single_image_view.jsx index 2777864fb3df..aa8d093ee333 100644 --- a/components/single_image_view/single_image_view.jsx +++ b/components/single_image_view/single_image_view.jsx @@ -6,6 +6,7 @@ import React from 'react'; import {getFilePreviewUrl, getFileUrl} from 'mattermost-redux/utils/file_utils'; +import {getFileDimensionsForDisplay} from 'utils/file_utils'; import {FileTypes} from 'utils/constants.jsx'; import { getFileType, @@ -109,28 +110,6 @@ export default class SingleImageView extends React.PureComponent { this.imageLoaded = node; } - computeImageDimensions = () => { - const {fileInfo} = this.props; - const viewPortWidth = this.state.viewPortWidth; - - let previewWidth = fileInfo.width; - let previewHeight = fileInfo.height; - - if (viewPortWidth && previewWidth > viewPortWidth) { - const origRatio = fileInfo.height / fileInfo.width; - previewWidth = Math.min(PREVIEW_IMAGE_MAX_WIDTH, fileInfo.width, viewPortWidth); - previewHeight = previewWidth * origRatio; - } - - if (previewHeight > PREVIEW_IMAGE_MAX_HEIGHT) { - const heightRatio = PREVIEW_IMAGE_MAX_HEIGHT / previewHeight; - previewHeight = PREVIEW_IMAGE_MAX_HEIGHT; - previewWidth *= heightRatio; - } - - return {previewWidth, previewHeight}; - } - toggleEmbedVisibility = () => { this.props.actions.toggleEmbedVisibility(this.props.postId); } @@ -142,7 +121,12 @@ export default class SingleImageView extends React.PureComponent { viewPortWidth, } = this.state; - const {previewHeight, previewWidth} = this.computeImageDimensions(); + const maxWidth = viewPortWidth !== 0 && viewPortWidth < PREVIEW_IMAGE_MAX_WIDTH ? viewPortWidth : PREVIEW_IMAGE_MAX_WIDTH; + const dimensions = getFileDimensionsForDisplay(fileInfo, {maxHeight: PREVIEW_IMAGE_MAX_HEIGHT, maxWidth}); + + const previewHeight = dimensions.height; + const previewWidth = dimensions.width; + let minPreviewClass = ''; if ( previewWidth < PREVIEW_IMAGE_MIN_DIMENSION || diff --git a/components/single_image_view/single_image_view.test.jsx b/components/single_image_view/single_image_view.test.jsx index 1b0c43502aad..c9d0bebfc370 100644 --- a/components/single_image_view/single_image_view.test.jsx +++ b/components/single_image_view/single_image_view.test.jsx @@ -100,35 +100,6 @@ describe('components/SingleImageView', () => { expect(wrapper.state('showPreviewModal')).toEqual(false); }); - test('should match dimensions on computeImageDimensions', () => { - const fileInfo = { - id: 'file_info_id', - post_id: 'post_id', - name: 'name', - extension: 'jpg', - has_preview_image: true, - width: 350, - height: 200, - }; - const props = {...baseProps, fileInfo}; - const wrapper = shallow( - - ); - - expect(wrapper.instance().computeImageDimensions()).toEqual({previewHeight: 200, previewWidth: 350}); - - wrapper.setState({viewPortWidth: 100}); - expect(wrapper.instance().computeImageDimensions()).toEqual({previewHeight: 57.14285714285714, previewWidth: 100}); - - wrapper.setState({viewPortWidth: 500}); - expect(wrapper.instance().computeImageDimensions()).toEqual({previewHeight: 200, previewWidth: 350}); - - fileInfo.height = 600; - wrapper.setProps({fileInfo}); - wrapper.setState({viewPortWidth: 500}); - expect(wrapper.instance().computeImageDimensions()).toEqual({previewHeight: 350, previewWidth: 204.16666666666669}); - }); - test('should call toggleEmbedVisibility with post id', () => { const props = { ...baseProps, diff --git a/components/size_aware_image.jsx b/components/size_aware_image.jsx new file mode 100644 index 000000000000..1bfb84be6330 --- /dev/null +++ b/components/size_aware_image.jsx @@ -0,0 +1,126 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import PropTypes from 'prop-types'; +import React from 'react'; + +import {createPlaceholderImage, loadImage} from 'utils/image_utils'; + +const WAIT_FOR_HEIGHT_TIMEOUT = 100; + +// SizeAwareImage is a component used for rendering images where the dimensions of the image are important for +// ensuring that the page is laid out correctly. +export default class SizeAwareImage extends React.PureComponent { + static propTypes = { + + /* + * dimensions object to create empty space required to prevent scroll pop + */ + dimensions: PropTypes.object, + + /* + * A callback that is called as soon as the image component has a height value + */ + onHeightReceived: PropTypes.func.isRequired, + + /* + * The source URL of the image + */ + src: PropTypes.string.isRequired, + } + + constructor(props) { + super(props); + + this.state = { + loaded: false, + }; + + this.heightTimeout = 0; + } + + componentDidMount() { + this.loadImage(); + } + + componentDidUpdate(prevProps) { + if (this.props.src !== prevProps.src) { + this.loadImage(); + } + } + + componentWillUnmount() { + this.stopWaitingForHeight(); + } + + loadImage = () => { + const image = loadImage(this.props.src, this.handleLoad); + + image.onerror = this.handleError(); + + if (!this.props.dimensions) { + this.waitForHeight(image); + } + } + + waitForHeight = (image) => { + if (image && image.height) { + if (this.props.onHeightReceived) { + this.props.onHeightReceived(image.height); + } + this.heightTimeout = 0; + } else { + this.heightTimeout = setTimeout(() => this.waitForHeight(image), WAIT_FOR_HEIGHT_TIMEOUT); + } + } + + stopWaitingForHeight = () => { + if (this.heightTimeout !== 0) { + clearTimeout(this.heightTimeout); + this.heightTimeout = 0; + return true; + } + return false; + } + + handleLoad = (image) => { + const wasWaiting = this.stopWaitingForHeight(); + + if ((wasWaiting || !this.props.dimensions) && this.props.onHeightReceived) { + this.props.onHeightReceived(image.height); + } + + this.setState({ + loaded: true, + }); + }; + + handleError = () => { + this.stopWaitingForHeight(); + }; + + render() { + const { + dimensions, + ...props + } = this.props; + + Reflect.deleteProperty(props, 'onHeightReceived'); + + let src; + if (!this.state.loaded && dimensions) { + // Generate a blank image as a placeholder because that will scale down to fit the available space + // while maintaining the correct aspect ratio + src = createPlaceholderImage(dimensions.width, dimensions.height); + } else { + src = this.props.src; + } + + return ( + + ); + } +} diff --git a/components/size_aware_image.test.jsx b/components/size_aware_image.test.jsx new file mode 100644 index 000000000000..42ac01f366ee --- /dev/null +++ b/components/size_aware_image.test.jsx @@ -0,0 +1,92 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {mount, shallow} from 'enzyme'; + +import SizeAwareImage from 'components/size_aware_image'; + +jest.mock('utils/image_utils'); + +import {createPlaceholderImage, loadImage} from 'utils/image_utils'; + +describe('components/SizeAwareImage', () => { + const baseProps = { + dimensions: { + height: 200, + width: 300, + }, + onHeightReceived: jest.fn(), + src: 'https://example.com/image.png', + }; + + loadImage.mockReturnValue(() => ({})); + + test('should render a placeholder when first mounted with dimensions', () => { + createPlaceholderImage.mockImplementation(() => 'data:image/png;base64,abc123'); + + const wrapper = mount(); + + const img = wrapper.getDOMNode(); + expect(img.src.startsWith('data:image')).toBeTruthy(); + }); + + test('should render the actual image after it is loaded', () => { + const wrapper = mount(); + wrapper.setState({loaded: true}); + + const img = wrapper.getDOMNode(); + expect(img.src).toEqual(baseProps.src); + }); + + test('should render the actual image when first mounted without dimensions', () => { + const props = {...baseProps}; + Reflect.deleteProperty(props, 'dimensions'); + + const wrapper = mount(); + + const img = wrapper.getDOMNode(); + expect(img.src).toEqual(baseProps.src); + }); + + test('should load image when mounted and when src changes', () => { + const wrapper = shallow(); + + expect(loadImage).toHaveBeenCalledTimes(1); + expect(loadImage.mock.calls[0][0]).toEqual(baseProps.src); + + const newSrc = 'https://example.com/new_image.png'; + wrapper.setProps({src: newSrc}); + + expect(loadImage).toHaveBeenCalledTimes(2); + expect(loadImage.mock.calls[1][0]).toEqual(newSrc); + }); + + test('should call onHeightReceived on load when dimensions are needed', () => { + const height = 123; + loadImage.mockImplementation((src, onLoad) => { + onLoad({height}); + + return {}; + }); + + const props = {...baseProps}; + Reflect.deleteProperty(props, 'dimensions'); + + shallow(); + + expect(baseProps.onHeightReceived).toHaveBeenCalledWith(height); + }); + + test('should not call onHeightReceived when dimensions are provided', () => { + loadImage.mockImplementation((src, onLoad) => { + onLoad({height: 100}); + + return {}; + }); + + shallow(); + + expect(baseProps.onHeightReceived).not.toHaveBeenCalled(); + }); +}); diff --git a/components/spinner_button.jsx b/components/spinner_button.jsx index fd21d1fd2c72..667c45921109 100644 --- a/components/spinner_button.jsx +++ b/components/spinner_button.jsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import React, {PureComponent} from 'react'; -import loadingGif from 'images/load.gif'; +import LoadingWrapper from 'components/widgets/loading/loading_wrapper.jsx'; export default class SpinnerButton extends PureComponent { static defaultProps = { @@ -23,6 +23,11 @@ export default class SpinnerButton extends PureComponent { */ spinning: PropTypes.bool.isRequired, + /** + * Set the text used while spinning + */ + spinningText: PropTypes.string.isRequired, + /** * Callback function when button is clicked */ @@ -30,23 +35,20 @@ export default class SpinnerButton extends PureComponent { } render() { - const {spinning, children, ...props} = this.props; // eslint-disable-line no-use-before-define - - if (spinning) { - return ( - - ); - } + const {spinning, spinningText, children, ...props} = this.props; // eslint-disable-line no-use-before-define return ( ); } diff --git a/components/spinner_button.test.jsx b/components/spinner_button.test.jsx index 6ccd7b472c98..2a7a4a97e313 100644 --- a/components/spinner_button.test.jsx +++ b/components/spinner_button.test.jsx @@ -9,21 +9,30 @@ import SpinnerButton from 'components/spinner_button.jsx'; describe('components/SpinnerButton', () => { test('should match snapshot with required props', () => { const wrapper = shallow( - + ); expect(wrapper).toMatchSnapshot(); }); test('should match snapshot with spinning', () => { const wrapper = shallow( - + ); expect(wrapper).toMatchSnapshot(); }); test('should match snapshot with children', () => { const wrapper = shallow( - + @@ -38,6 +47,7 @@ describe('components/SpinnerButton', () => { ); diff --git a/components/suggestion/command_provider.jsx b/components/suggestion/command_provider.jsx index 6e36f79b17da..50cfb16f082e 100644 --- a/components/suggestion/command_provider.jsx +++ b/components/suggestion/command_provider.jsx @@ -11,6 +11,7 @@ import store from 'stores/redux_store.jsx'; import * as UserAgent from 'utils/user_agent.jsx'; import Suggestion from './suggestion.jsx'; +import Provider from './provider.jsx'; export class CommandSuggestion extends Suggestion { render() { @@ -38,7 +39,7 @@ export class CommandSuggestion extends Suggestion { } } -export default class CommandProvider { +export default class CommandProvider extends Provider { handlePretextChanged(pretext, resultCallback) { if (pretext.startsWith('/')) { const command = pretext.toLowerCase(); diff --git a/components/suggestion/emoticon_provider.jsx b/components/suggestion/emoticon_provider.jsx index 8d6c991bf92f..b5ce677da09e 100644 --- a/components/suggestion/emoticon_provider.jsx +++ b/components/suggestion/emoticon_provider.jsx @@ -13,6 +13,7 @@ import store from 'stores/redux_store.jsx'; import * as Emoticons from 'utils/emoticons.jsx'; import Suggestion from './suggestion.jsx'; +import Provider from './provider.jsx'; const MIN_EMOTICON_LENGTH = 2; const EMOJI_CATEGORY_SUGGESTION_BLACKLIST = ['skintone']; @@ -49,7 +50,7 @@ class EmoticonSuggestion extends Suggestion { } } -export default class EmoticonProvider { +export default class EmoticonProvider extends Provider { handlePretextChanged(pretext, resultsCallback) { // Look for the potential emoticons at the start of the text, after whitespace, and at the start of emoji reaction commands const captured = (/(^|\s|^\+|^-)(:([^:\s]*))$/g).exec(pretext); diff --git a/components/suggestion/provider.jsx b/components/suggestion/provider.jsx index 044aaededca4..595c7a9b6762 100644 --- a/components/suggestion/provider.jsx +++ b/components/suggestion/provider.jsx @@ -40,4 +40,12 @@ export default class Provider { return false; } + + allowDividers() { + return true; + } + + presentationType() { + return 'text'; + } } diff --git a/components/suggestion/search_channel_with_permissions_provider.jsx b/components/suggestion/search_channel_with_permissions_provider.jsx new file mode 100644 index 000000000000..9cdf4feba134 --- /dev/null +++ b/components/suggestion/search_channel_with_permissions_provider.jsx @@ -0,0 +1,229 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import { + getChannelsInCurrentTeam, +} from 'mattermost-redux/selectors/entities/channels'; +import {getMyChannelMemberships} from 'mattermost-redux/selectors/entities/common'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; +import * as ChannelActions from 'mattermost-redux/actions/channels'; +import {getCurrentUserLocale} from 'mattermost-redux/selectors/entities/i18n'; +import {haveICurrentTeamPermission} from 'mattermost-redux/selectors/entities/roles'; +import {Permissions} from 'mattermost-redux/constants'; +import {sortChannelsByTypeAndDisplayName} from 'mattermost-redux/utils/channel_utils'; +import {logError} from 'mattermost-redux/actions/errors'; + +import GlobeIcon from 'components/svg/globe_icon'; +import LockIcon from 'components/svg/lock_icon'; +import ArchiveIcon from 'components/svg/archive_icon'; +import store from 'stores/redux_store.jsx'; +import {Constants} from 'utils/constants.jsx'; + +import Provider from './provider.jsx'; +import Suggestion from './suggestion.jsx'; + +class SearchChannelWithPermissionsSuggestion extends Suggestion { + static get propTypes() { + return { + ...super.propTypes, + }; + } + + render() { + const {item, isSelection} = this.props; + const channel = item.channel; + const channelIsArchived = channel.delete_at && channel.delete_at !== 0; + + let className = 'mentions__name'; + if (isSelection) { + className += ' suggestion--selected'; + } + + const displayName = channel.display_name; + let icon = null; + if (channelIsArchived) { + icon = ( + + ); + } else if (channel.type === Constants.OPEN_CHANNEL) { + icon = ( + + ); + } else if (channel.type === Constants.PRIVATE_CHANNEL) { + icon = ( + + ); + } + + return ( +
    + {icon} + {displayName} +
    + ); + } +} + +let prefix = ''; + +function channelSearchSorter(wrappedA, wrappedB) { + const aIsArchived = wrappedA.channel.delete_at ? wrappedA.channel.delete_at !== 0 : false; + const bIsArchived = wrappedB.channel.delete_at ? wrappedB.channel.delete_at !== 0 : false; + if (aIsArchived && !bIsArchived) { + return 1; + } else if (!aIsArchived && bIsArchived) { + return -1; + } + + const locale = getCurrentUserLocale(store.getState()); + + const a = wrappedA.channel; + const b = wrappedB.channel; + + const aDisplayName = a.display_name.toLowerCase(); + const bDisplayName = b.display_name.toLowerCase(); + + const aStartsWith = aDisplayName.startsWith(prefix); + const bStartsWith = bDisplayName.startsWith(prefix); + if (aStartsWith && bStartsWith) { + return sortChannelsByTypeAndDisplayName(locale, a, b); + } else if (!aStartsWith && !bStartsWith) { + return sortChannelsByTypeAndDisplayName(locale, a, b); + } else if (aStartsWith) { + return -1; + } + + return 1; +} + +export default class SearchChannelWithPermissionsProvider extends Provider { + makeChannelSearchFilter(channelPrefix) { + const channelPrefixLower = channelPrefix.toLowerCase(); + + return (channel) => { + const state = store.getState(); + const canManagePublicChannels = haveICurrentTeamPermission(state, {permission: Permissions.MANAGE_PUBLIC_CHANNEL_MEMBERS}); + const canManagePrivatehannels = haveICurrentTeamPermission(state, {permission: Permissions.MANAGE_PRIVATE_CHANNEL_MEMBERS}); + const searchString = channel.display_name; + + if (canManagePublicChannels && channel.type === Constants.OPEN_CHANNEL) { + return searchString.toLowerCase().includes(channelPrefixLower); + } + if (canManagePrivatehannels && channel.type === Constants.PRIVATE_CHANNEL) { + return searchString.toLowerCase().includes(channelPrefixLower); + } + return false; + }; + } + + handlePretextChanged(channelPrefix, resultsCallback) { + if (channelPrefix) { + prefix = channelPrefix; + this.startNewRequest(channelPrefix); + const state = store.getState(); + + // Dispatch suggestions for local data + const channels = getChannelsInCurrentTeam(state); + this.formatChannelsAndDispatch(channelPrefix, resultsCallback, channels); + + // Fetch data from the server and dispatch + this.fetchChannels(channelPrefix, resultsCallback); + } + + return true; + } + + async fetchChannels(channelPrefix, resultsCallback) { + const state = store.getState(); + const teamId = getCurrentTeamId(state); + if (!teamId) { + return; + } + + const channelsAsync = ChannelActions.autocompleteChannelsForSearch(teamId, channelPrefix)(store.dispatch, store.getState); + + let channelsFromServer = []; + try { + const {data} = await channelsAsync; + channelsFromServer = data; + } catch (err) { + store.dispatch(logError(err)); + } + + if (this.shouldCancelDispatch(channelPrefix)) { + return; + } + + const channels = getChannelsInCurrentTeam(state).concat(channelsFromServer); + this.formatChannelsAndDispatch(channelPrefix, resultsCallback, channels); + } + + formatChannelsAndDispatch(channelPrefix, resultsCallback, allChannels) { + const channels = []; + + const state = store.getState(); + + const members = getMyChannelMemberships(state); + + if (this.shouldCancelDispatch(channelPrefix)) { + return; + } + + const completedChannels = {}; + + const channelFilter = this.makeChannelSearchFilter(channelPrefix); + + const config = getConfig(state); + const viewArchivedChannels = config.ExperimentalViewArchivedChannels === 'true'; + + for (const id of Object.keys(allChannels)) { + const channel = allChannels[id]; + if (!channel) { + continue; + } + + if (completedChannels[channel.id]) { + continue; + } + + if (channelFilter(channel)) { + const newChannel = Object.assign({}, channel); + const channelIsArchived = channel.delete_at !== 0; + + const wrappedChannel = {channel: newChannel, name: newChannel.name, deactivated: false}; + if (!viewArchivedChannels && channelIsArchived) { + continue; + } else if (!members[channel.id]) { + continue; + } else if (channelIsArchived && !members[channel.id]) { + continue; + } else if (channel.type === Constants.OPEN_CHANNEL) { + wrappedChannel.type = Constants.OPEN_CHANNEL; + } else if (channel.type === Constants.PRIVATE_CHANNEL) { + wrappedChannel.type = Constants.PRIVATE_CHANNEL; + } else { + continue; + } + completedChannels[channel.id] = true; + channels.push(wrappedChannel); + } + } + + const channelNames = channels. + sort(channelSearchSorter). + map((wrappedChannel) => wrappedChannel.channel.name); + + resultsCallback({ + matchedPretext: channelPrefix, + terms: channelNames, + items: channels, + component: SearchChannelWithPermissionsSuggestion, + }); + } +} diff --git a/components/suggestion/search_channel_with_permissions_provider.test.jsx b/components/suggestion/search_channel_with_permissions_provider.test.jsx new file mode 100644 index 000000000000..0de67f4f80ba --- /dev/null +++ b/components/suggestion/search_channel_with_permissions_provider.test.jsx @@ -0,0 +1,278 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import configureStore from 'redux-mock-store'; + +import {getState} from 'stores/redux_store'; + +import SearchChannelWithPermissionsProvider from 'components/suggestion/search_channel_with_permissions_provider.jsx'; + +jest.mock('stores/redux_store', () => ({ + dispatch: jest.fn(), + getState: jest.fn(), +})); + +describe('components/SearchChannelWithPermissionsProvider', () => { + const defaultState = { + entities: { + general: { + config: {}, + }, + teams: { + currentTeamId: 'someTeamId', + myMembers: { + someTeamId: { + roles: '', + }, + }, + }, + channels: { + myMembers: { + somePublicMemberChannelId: { + }, + somePrivateMemberChannelId: { + }, + someDirectConversation: { + }, + someGroupConversation: { + }, + }, + channels: { + somePublicMemberChannelId: { + id: 'somePublicMemberChannelId', + type: 'O', + name: 'some-public-member-channel', + display_name: 'Some Public Member Channel', + delete_at: 0, + }, + somePrivateMemberChannelId: { + id: 'somePrivateMemberChannelId', + type: 'P', + name: 'some-private-member-channel', + display_name: 'Some Private Member Channel', + delete_at: 0, + }, + somePublicNonMemberChannelId: { + id: 'somePublicNonMemberChannelId', + type: 'O', + name: 'some-public-non-member-channel', + display_name: 'Some Public Non-Member Channel', + delete_at: 0, + }, + somePrivateNonMemberChannelId: { + id: 'somePrivateNonMemberChannelId', + type: 'P', + name: 'some-private=non-member-channel', + display_name: 'Some Private Non-Member Channel', + delete_at: 0, + }, + someDirectConversation: { + id: 'someDirectConversation', + type: 'D', + name: 'some-direct-conversation', + display_name: 'Some Direct Conversation', + delete_at: 0, + }, + someGroupConversation: { + id: 'someGroupConversation', + type: 'GM', + name: 'some-group-conversation', + display_name: 'Some Group Conversation', + delete_at: 0, + }, + }, + channelsInTeam: { + someTeamId: [ + 'somePublicMemberChannelId', + 'somePrivateMemberChannelId', + 'somePublicNonMemberChannelId', + 'somePrivateNonMemberChannelId', + 'someDirectConversation', + 'someGroupConversation', + ], + }, + }, + roles: { + roles: { + public_channels_manager: { + permissions: ['manage_public_channel_members'], + }, + private_channels_manager: { + permissions: ['manage_private_channel_members'], + }, + }, + }, + users: { + profiles: {}, + }, + }, + }; + + it('should show public channels if user has public channel manage permission', () => { + const mockStore = configureStore(); + const roles = 'public_channels_manager'; + const resultsCallback = jest.fn(); + + const state = { + ...defaultState, + entities: { + ...defaultState.entities, + teams: { + currentTeamId: 'someTeamId', + myMembers: { + someTeamId: { + roles, + }, + }, + }, + }, + }; + + const store = mockStore(state); + const searchProvider = new SearchChannelWithPermissionsProvider(); + + getState.mockImplementation(store.getState); + + const searchText = 'some'; + searchProvider.handlePretextChanged(searchText, resultsCallback); + expect(resultsCallback).toHaveBeenCalled(); + const args = resultsCallback.mock.calls[0][0]; + expect(args.items[0].channel.id).toEqual('somePublicMemberChannelId'); + expect(args.items.length).toEqual(1); + }); + + it('should show private channels if user has private channel manage permission', () => { + const mockStore = configureStore(); + const roles = 'private_channels_manager'; + const resultsCallback = jest.fn(); + + const state = { + ...defaultState, + entities: { + ...defaultState.entities, + teams: { + currentTeamId: 'someTeamId', + myMembers: { + someTeamId: { + roles, + }, + }, + }, + }, + }; + + const store = mockStore(state); + const searchProvider = new SearchChannelWithPermissionsProvider(); + + getState.mockImplementation(store.getState); + + const searchText = 'some'; + searchProvider.handlePretextChanged(searchText, resultsCallback); + expect(resultsCallback).toHaveBeenCalled(); + const args = resultsCallback.mock.calls[0][0]; + + expect(args.items[0].channel.id).toEqual('somePrivateMemberChannelId'); + expect(args.items.length).toEqual(1); + }); + + it('should show both public and private channels if user has public and private channel manage permission', () => { + const mockStore = configureStore(); + const roles = 'public_channels_manager private_channels_manager'; + const resultsCallback = jest.fn(); + + const state = { + ...defaultState, + entities: { + ...defaultState.entities, + teams: { + currentTeamId: 'someTeamId', + myMembers: { + someTeamId: { + roles, + }, + }, + }, + }, + }; + + const store = mockStore(state); + const searchProvider = new SearchChannelWithPermissionsProvider(); + + getState.mockImplementation(store.getState); + + const searchText = 'some'; + searchProvider.handlePretextChanged(searchText, resultsCallback); + expect(resultsCallback).toHaveBeenCalled(); + const args = resultsCallback.mock.calls[0][0]; + + expect(args.items[0].channel.id).toEqual('somePublicMemberChannelId'); + expect(args.items[1].channel.id).toEqual('somePrivateMemberChannelId'); + expect(args.items.length).toEqual(2); + }); + + it('should show nothing if the user does not have permissions to manage channels', () => { + const mockStore = configureStore(); + const roles = ''; + const resultsCallback = jest.fn(); + + const state = { + ...defaultState, + entities: { + ...defaultState.entities, + teams: { + currentTeamId: 'someTeamId', + myMembers: { + someTeamId: { + roles, + }, + }, + }, + }, + }; + + const store = mockStore(state); + const searchProvider = new SearchChannelWithPermissionsProvider(); + + getState.mockImplementation(store.getState); + + const searchText = 'some'; + searchProvider.handlePretextChanged(searchText, resultsCallback); + expect(resultsCallback).toHaveBeenCalled(); + const args = resultsCallback.mock.calls[0][0]; + + expect(args.items.length).toEqual(0); + }); + + it('should show nothing if the search does not match', () => { + const mockStore = configureStore(); + const roles = 'public_channels_manager private_channels_manager'; + const resultsCallback = jest.fn(); + + const state = { + ...defaultState, + entities: { + ...defaultState.entities, + teams: { + currentTeamId: 'someTeamId', + myMembers: { + someTeamId: { + roles, + }, + }, + }, + }, + }; + + const store = mockStore(state); + const searchProvider = new SearchChannelWithPermissionsProvider(); + + getState.mockImplementation(store.getState); + + const searchText = 'not matching text'; + searchProvider.handlePretextChanged(searchText, resultsCallback); + expect(resultsCallback).toHaveBeenCalled(); + const args = resultsCallback.mock.calls[0][0]; + + expect(args.items.length).toEqual(0); + }); +}); diff --git a/components/suggestion/search_date_provider.jsx b/components/suggestion/search_date_provider.jsx index 509eff1761f2..926e74e210cb 100644 --- a/components/suggestion/search_date_provider.jsx +++ b/components/suggestion/search_date_provider.jsx @@ -52,4 +52,12 @@ export default class SearchDateProvider extends Provider { return Boolean(captured); } + + allowDividers() { + return false; + } + + presentationType() { + return 'date'; + } } diff --git a/components/suggestion/search_user_provider.jsx b/components/suggestion/search_user_provider.jsx index 5f12963a9376..639d7c8817fe 100644 --- a/components/suggestion/search_user_provider.jsx +++ b/components/suggestion/search_user_provider.jsx @@ -87,4 +87,8 @@ export default class SearchUserProvider extends Provider { return Boolean(captured); } + + allowDividers() { + return false; + } } diff --git a/components/suggestion/suggestion_box.jsx b/components/suggestion/suggestion_box.jsx index 963bb9f5380f..86bb9be55f30 100644 --- a/components/suggestion/suggestion_box.jsx +++ b/components/suggestion/suggestion_box.jsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import React from 'react'; -import {debounce} from 'mattermost-redux/actions/helpers'; +import EventEmitter from 'mattermost-redux/utils/event_emitter'; import QuickInput from 'components/quick_input.jsx'; import Constants from 'utils/constants.jsx'; @@ -115,6 +115,17 @@ export default class SuggestionBox extends React.Component { * If true, replace all input in the suggestion box with the selected option after a select, defaults to false */ replaceAllInputOnSelect: PropTypes.bool, + + /** + * An optional, opaque identifier that distinguishes the context in which the suggestion + * box is rendered. This allows the reused component to otherwise respond to changes. + */ + contextId: PropTypes.string, + + /** + * If true, listen for clicks on a mention and populate the input with said mention, defaults to false + */ + listenForMentionKeyClick: PropTypes.bool, } static defaultProps = { @@ -128,6 +139,7 @@ export default class SuggestionBox extends React.Component { openOnFocus: false, openWhenEmpty: false, replaceAllInputOnSelect: false, + listenForMentionKeyClick: false, } constructor(props) { @@ -136,16 +148,10 @@ export default class SuggestionBox extends React.Component { // Keep track of whether we're composing a CJK character so we can make suggestions for partial characters this.composing = false; - // Keep track of whether a list based or date based suggestion provider has been triggered - this.presentationType = 'text'; - this.pretext = ''; - // An override of the provided prop to indicate whether dividers should be shown in the autocomplete results. - // This isn't ideal, because the component accepts a `renderDividers` prop which this is being used to override - // on a per-provider basis. There's probably a better solution by re-architecting providers to control how their - // dividers work on a per-provider basis so this wouldn't be necessary. - this.allowDividers = true; + // Used for debouncing pretext changes + this.timeoutId = ''; // pretext: the text before the cursor // matchedPretext: a list of the text before the cursor that will be replaced if the corresponding autocomplete term is selected @@ -161,9 +167,45 @@ export default class SuggestionBox extends React.Component { terms: [], components: [], selection: '', + allowDividers: true, + presentationType: 'text', }; } + componentDidMount() { + if (this.props.listenForMentionKeyClick) { + EventEmitter.addListener('mention_key_click', this.handleMentionKeyClick); + } + } + + componentWillUnmount() { + EventEmitter.removeListener('mention_key_click', this.handleMentionKeyClick); + } + + componentDidUpdate(prevProps) { + if (prevProps.contextId !== this.props.contextId) { + const textbox = this.getTextbox(); + const pretext = textbox.value.substring(0, textbox.selectionEnd).toLowerCase(); + + this.handlePretextChanged(pretext); + } + } + + handleMentionKeyClick = (mentionKey, isRHS) => { + if (this.props.isRHS !== isRHS) { + return; + } + + let insertText = '@' + mentionKey; + + // if the current text does not end with a whitespace, then insert a space + if (this.props.value && (/[^\s]$/).test(this.props.value)) { + insertText = ' ' + insertText; + } + + this.addTextAtCaret(insertText, ''); + } + getTextbox = () => { if (!this.refs.input) { return null; @@ -427,14 +469,23 @@ export default class SuggestionBox extends React.Component { matchedPretext = this.state.matchedPretext[i]; } } - this.handleCompleteWord(this.state.selection, matchedPretext); + + // If these don't match, the user typed quickly and pressed enter before we could + // update the pretext, so update the pretext before completing + if (this.pretext.endsWith(matchedPretext)) { + this.handleCompleteWord(this.state.selection, matchedPretext); + } else { + clearTimeout(this.timeoutId); + this.nonDebouncedPretextChanged(this.pretext, true); + } + if (this.props.onKeyDown) { this.props.onKeyDown(e); } e.preventDefault(); } else if (Utils.isKeyPressed(e, KeyCodes.ESCAPE)) { this.clear(); - this.presentationType = 'text'; + this.setState({presentationType: 'text'}); e.preventDefault(); e.stopPropagation(); } else if (this.props.onKeyDown) { @@ -472,26 +523,32 @@ export default class SuggestionBox extends React.Component { components: newComponents, matchedPretext: newPretext, }); + + return {selection, matchedPretext: suggestions.matchedPretext}; } - handlePretextChanged = debounce((pretext) => { + handleReceivedSuggestionsAndComplete = (suggestions) => { + const {selection, matchedPretext} = this.handleReceivedSuggestions(suggestions); + if (selection) { + this.handleCompleteWord(selection, matchedPretext); + } + } + + nonDebouncedPretextChanged = (pretext, complete = false) => { this.pretext = pretext; let handled = false; + let callback = this.handleReceivedSuggestions; + if (complete) { + callback = this.handleReceivedSuggestionsAndComplete; + } for (const provider of this.props.providers) { - handled = provider.handlePretextChanged(pretext, this.handleReceivedSuggestions) || handled; + handled = provider.handlePretextChanged(pretext, callback) || handled; if (handled) { - if (provider.constructor.name === 'SearchDateProvider') { - this.presentationType = 'date'; - } else { - this.presentationType = 'text'; - } - - if (provider.constructor.name === 'SearchUserProvider') { - this.allowDividers = false; - } else { - this.allowDividers = true; - } + this.setState({ + presentationType: provider.presentationType(), + allowDividers: provider.allowDividers(), + }); break; } @@ -499,7 +556,17 @@ export default class SuggestionBox extends React.Component { if (!handled) { this.clear(); } - }, Constants.SEARCH_TIMEOUT_MILLISECONDS) + } + + debouncedPretextChanged = (pretext) => { + clearTimeout(this.timeoutId); + this.timeoutId = setTimeout(() => this.nonDebouncedPretextChanged(pretext), Constants.SEARCH_TIMEOUT_MILLISECONDS); + }; + + handlePretextChanged = (pretext) => { + this.pretext = pretext; + this.debouncedPretextChanged(pretext); + } blur = () => { this.refs.input.blur(); @@ -530,7 +597,7 @@ export default class SuggestionBox extends React.Component { ...props } = this.props; - const renderDividers = this.props.renderDividers && this.allowDividers; + const renderDividers = this.props.renderDividers && this.state.allowDividers; // Don't pass props used by SuggestionBox Reflect.deleteProperty(props, 'providers'); @@ -546,6 +613,8 @@ export default class SuggestionBox extends React.Component { Reflect.deleteProperty(props, 'containerClass'); Reflect.deleteProperty(props, 'replaceAllInputOnSelect'); Reflect.deleteProperty(props, 'renderDividers'); + Reflect.deleteProperty(props, 'contextId'); + Reflect.deleteProperty(props, 'listenForMentionKeyClick'); // This needs to be upper case so React doesn't think it's an html tag const SuggestionListComponent = listComponent; @@ -566,7 +635,7 @@ export default class SuggestionBox extends React.Component { onCompositionEnd={this.handleCompositionEnd} onKeyDown={this.handleKeyDown} /> - {(this.props.openWhenEmpty || this.props.value.length >= this.props.requiredCharacters) && this.presentationType === 'text' && + {(this.props.openWhenEmpty || this.props.value.length >= this.props.requiredCharacters) && this.state.presentationType === 'text' && } - {(this.props.openWhenEmpty || this.props.value.length >= this.props.requiredCharacters) && this.presentationType === 'date' && + {(this.props.openWhenEmpty || this.props.value.length >= this.props.requiredCharacters) && this.state.presentationType === 'date' && { expect(wrapper.state('focused')).toEqual(false); expect(onBlur).toBeCalledTimes(1); }); + + test('should force pretext change on context change', () => { + const wrapper = shallow( + + ); + const instance = wrapper.instance(); + instance.handlePretextChanged = jest.fn(); + instance.getTextbox = jest.fn().mockReturnValue({value: 'value'}); + + wrapper.setProps({...baseProps}); + expect(instance.handlePretextChanged).not.toBeCalled(); + + wrapper.setProps({...baseProps, contextId: 'new'}); + expect(instance.handlePretextChanged).toBeCalledWith('value'); + + wrapper.setProps({...baseProps, contextId: 'new'}); + expect(instance.handlePretextChanged.mock.calls.length).toBe(1); + }); }); diff --git a/components/suggestion/suggestion_list.jsx b/components/suggestion/suggestion_list.jsx index d0fdf851756c..775e7d1e5ad0 100644 --- a/components/suggestion/suggestion_list.jsx +++ b/components/suggestion/suggestion_list.jsx @@ -7,9 +7,8 @@ import React from 'react'; import ReactDOM from 'react-dom'; import {FormattedMessage} from 'react-intl'; -import {localizeMessage} from 'utils/utils.jsx'; - import FormattedMarkdownMessage from 'components/formatted_markdown_message'; +import LoadingSpinner from 'components/widgets/loading/loading_spinner.jsx'; export default class SuggestionList extends React.PureComponent { static propTypes = { @@ -96,20 +95,6 @@ export default class SuggestionList extends React.PureComponent { ); } - renderLoading(type) { - return ( -
    - -
    - ); - } - renderNoResults() { return (
    ); continue; } @@ -179,6 +164,7 @@ export default class SuggestionList extends React.PureComponent { return (
    diff --git a/components/suggestion/switch_channel_provider.jsx b/components/suggestion/switch_channel_provider.jsx index 96bfb8ff37cf..47e924f3c3ac 100644 --- a/components/suggestion/switch_channel_provider.jsx +++ b/components/suggestion/switch_channel_provider.jsx @@ -18,12 +18,14 @@ import {getBool} from 'mattermost-redux/selectors/entities/preferences'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import { + getCurrentUser, getCurrentUserId, getUserIdsInChannels, getUser, searchProfiles, } from 'mattermost-redux/selectors/entities/users'; import * as ChannelActions from 'mattermost-redux/actions/channels'; +import {logError} from 'mattermost-redux/actions/errors'; import {sortChannelsByTypeAndDisplayName} from 'mattermost-redux/utils/channel_utils'; @@ -31,12 +33,10 @@ import DraftIcon from 'components/svg/draft_icon'; import GlobeIcon from 'components/svg/globe_icon'; import LockIcon from 'components/svg/lock_icon'; import ArchiveIcon from 'components/svg/archive_icon'; -import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; import {getPostDraft} from 'selectors/rhs'; import store from 'stores/redux_store.jsx'; -import {ActionTypes, Constants, StoragePrefixes} from 'utils/constants.jsx'; +import {Constants, StoragePrefixes} from 'utils/constants.jsx'; import * as Utils from 'utils/utils.jsx'; -import UserStore from 'stores/user_store.jsx'; import Provider from './provider.jsx'; import Suggestion from './suggestion.jsx'; @@ -48,7 +48,7 @@ function getChannelDisplayName(channel) { return channel.display_name; } - const currentUser = UserStore.getCurrentUser(); + const currentUser = getCurrentUser(getState()); if (currentUser) { return channel.display_name. @@ -280,10 +280,7 @@ export default class SwitchChannelProvider extends Provider { const {data} = await channelsAsync; channelsFromServer = data; } catch (err) { - AppDispatcher.handleServerAction({ - type: ActionTypes.RECEIVED_ERROR, - err, - }); + store.dispatch(logError(err)); } if (this.shouldCancelDispatch(channelPrefix)) { diff --git a/components/team_general_tab/team_general_tab.jsx b/components/team_general_tab/team_general_tab.jsx index 8d6a04a22509..d8ad4f64e820 100644 --- a/components/team_general_tab/team_general_tab.jsx +++ b/components/team_general_tab/team_general_tab.jsx @@ -342,7 +342,7 @@ export default class GeneralTab extends React.Component { } else { this.setState({ teamIconFile: null, - clientError: Utils.localizeMessage('general_tab.teamIconError', 'An error occured while selecting the image.'), + clientError: Utils.localizeMessage('general_tab.teamIconError', 'An error occurred while selecting the image.'), }); } } diff --git a/components/team_general_tab/team_general_tab.test.jsx b/components/team_general_tab/team_general_tab.test.jsx index 6cc95a51ebca..ffd5069e8b1d 100644 --- a/components/team_general_tab/team_general_tab.test.jsx +++ b/components/team_general_tab/team_general_tab.test.jsx @@ -30,7 +30,7 @@ describe('components/TeamSettings', () => { wrapper.instance().updateTeamIcon(null); - expect(wrapper.state('clientError')).toEqual('An error occured while selecting the image.'); + expect(wrapper.state('clientError')).toEqual('An error occurred while selecting the image.'); }); test('should handle invalid file selection', () => { diff --git a/components/team_members_dropdown/index.js b/components/team_members_dropdown/index.js index b9b928318c5c..32d271b2948f 100644 --- a/components/team_members_dropdown/index.js +++ b/components/team_members_dropdown/index.js @@ -11,7 +11,7 @@ import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentRelativeTeamUrl} from 'mattermost-redux/selectors/entities/teams'; -import {removeUserFromTeam} from 'actions/team_actions.jsx'; +import {removeUserFromTeamAndGetStats} from 'actions/team_actions.jsx'; import TeamMembersDropdown from './team_members_dropdown.jsx'; @@ -30,7 +30,7 @@ function mapDispatchToProps(dispatch) { getTeamStats, getChannelStats, updateTeamMemberSchemeRoles, - removeUserFromTeam, + removeUserFromTeamAndGetStats, }, dispatch), }; } diff --git a/components/team_members_dropdown/team_members_dropdown.jsx b/components/team_members_dropdown/team_members_dropdown.jsx index e737bfb89f33..4e2df178fa93 100644 --- a/components/team_members_dropdown/team_members_dropdown.jsx +++ b/components/team_members_dropdown/team_members_dropdown.jsx @@ -55,10 +55,8 @@ export default class TeamMembersDropdown extends React.Component { } handleRemoveFromTeam = async () => { - const {data, error} = await this.props.actions.removeUserFromTeam(this.props.teamMember.team_id, this.props.user.id); - if (data) { - this.props.actions.getTeamStats(this.props.teamMember.team_id); - } else if (error) { + const {error} = await this.props.actions.removeUserFromTeam(this.props.teamMember.team_id, this.props.user.id); + if (error) { this.setState({serverError: error.message}); } } diff --git a/components/team_sidebar/components/team_button.jsx b/components/team_sidebar/components/team_button.jsx index c7ed682700ac..6fd29657b294 100644 --- a/components/team_sidebar/components/team_button.jsx +++ b/components/team_sidebar/components/team_button.jsx @@ -161,5 +161,5 @@ TeamButton.propTypes = { mentions: PropTypes.number, placement: PropTypes.oneOf(['left', 'right', 'top', 'bottom']), teamIconUrl: PropTypes.string, - switchTeam: PropTypes.func, + switchTeam: PropTypes.func.isRequired, }; diff --git a/components/team_sidebar/team_sidebar_controller.jsx b/components/team_sidebar/team_sidebar_controller.jsx index 9d55fc18690b..1d271b32c07d 100644 --- a/components/team_sidebar/team_sidebar_controller.jsx +++ b/components/team_sidebar/team_sidebar_controller.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import React from 'react'; +import Scrollbars from 'react-custom-scrollbars'; import {FormattedMessage} from 'react-intl'; import Permissions from 'mattermost-redux/constants/permissions'; import classNames from 'classnames'; @@ -16,6 +17,30 @@ import Pluggable from 'plugins/pluggable'; import TeamButton from './components/team_button.jsx'; +export function renderView(props) { + return ( +
    ); +} + +export function renderThumbHorizontal(props) { + return ( +
    ); +} + +export function renderThumbVertical(props) { + return ( +
    ); +} + export default class TeamSidebar extends React.PureComponent { static propTypes = { myTeams: PropTypes.array.isRequired, @@ -74,6 +99,7 @@ export default class TeamSidebar extends React.PureComponent { /> } content={'+'} + switchTeam={this.props.actions.switchTeam} /> ); } else { @@ -92,6 +118,7 @@ export default class TeamSidebar extends React.PureComponent { /> } content={'+'} + switchTeam={this.props.actions.switchTeam} /> ); @@ -109,7 +136,17 @@ export default class TeamSidebar extends React.PureComponent { return (
    - {teams} + + {teams} +
    ); diff --git a/components/terms_of_service/terms_of_service.jsx b/components/terms_of_service/terms_of_service.jsx index 4d978e4c8e47..c3336e310172 100644 --- a/components/terms_of_service/terms_of_service.jsx +++ b/components/terms_of_service/terms_of_service.jsx @@ -11,6 +11,7 @@ import {memoizeResult} from 'mattermost-redux/utils/helpers'; import * as GlobalActions from 'actions/global_actions.jsx'; import AnnouncementBar from 'components/announcement_bar'; import LoadingScreen from 'components/loading_screen.jsx'; +import LoadingSpinner from 'components/widgets/loading/loading_spinner.jsx'; import {browserHistory} from 'utils/browser_history'; import messageHtmlToComponent from 'utils/message_html_to_component'; @@ -20,6 +21,7 @@ import {Constants} from 'utils/constants.jsx'; export default class TermsOfService extends React.PureComponent { static propTypes = { + location: PropTypes.object, termsEnabled: PropTypes.bool.isRequired, actions: PropTypes.shape({ getTermsOfService: PropTypes.func.isRequired, @@ -168,12 +170,7 @@ export default class TermsOfService extends React.PureComponent { onClick={this.handleAcceptTerms} type='submit' > - {this.state.loadingAgree && ( - - )} + {this.state.loadingAgree && } - {this.state.loadingDisagree && ( - - )} + {this.state.loadingDisagree && } ({ emitUserLoggedOutEvent: jest.fn(), + redirectUserToDefaultTeam: jest.fn(), })); describe('components/terms_of_service/TermsOfService', () => { - const getTermsOfService = jest.fn(); - const updateMyTermsOfServiceStatus = jest.fn(); + const getTermsOfService = jest.fn().mockResolvedValue({data: {id: 'tos_id', text: 'tos_text'}}); + const updateMyTermsOfServiceStatus = jest.fn().mockResolvedValue({data: true}); const baseProps = { actions: { getTermsOfService, updateMyTermsOfServiceStatus, }, + location: {search: ''}, termsEnabled: true, }; @@ -57,9 +59,9 @@ describe('components/terms_of_service/TermsOfService', () => { expect(wrapper).toMatchSnapshot(); }); - test('should call updateTermsOfServiceStatus on registerUserAction', () => { + test('should call updateTermsOfServiceStatus on registerUserAction', async () => { const wrapper = shallow(); - wrapper.instance().registerUserAction({accepted: 'true', success: jest.fn()}); + await wrapper.instance().registerUserAction(true, jest.fn()); expect(baseProps.actions.updateMyTermsOfServiceStatus).toHaveBeenCalledTimes(1); }); diff --git a/components/textbox/index.js b/components/textbox/index.js index f152c055e2a0..f3199909c61d 100644 --- a/components/textbox/index.js +++ b/components/textbox/index.js @@ -4,17 +4,22 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {getCurrentUserId, getProfilesInCurrentChannel, getProfilesNotInCurrentChannel} from 'mattermost-redux/selectors/entities/users'; +import {getCurrentUserId, makeGetProfilesInChannel, makeGetProfilesNotInChannel} from 'mattermost-redux/selectors/entities/users'; import {autocompleteUsersInChannel} from 'actions/views/channel'; import Textbox from './textbox.jsx'; -const mapStateToProps = (state) => ({ - currentUserId: getCurrentUserId(state), - profilesInChannel: getProfilesInCurrentChannel(state), - profilesNotInChannel: getProfilesNotInCurrentChannel(state), -}); +const makeMapStateToProps = () => { + const getProfilesInChannel = makeGetProfilesInChannel(); + const getProfilesNotInChannel = makeGetProfilesNotInChannel(); + + return (state, ownProps) => ({ + currentUserId: getCurrentUserId(state), + profilesInChannel: getProfilesInChannel(state, ownProps.channelId, true), + profilesNotInChannel: getProfilesNotInChannel(state, ownProps.channelId, true), + }); +}; const mapDispatchToProps = (dispatch) => ({ actions: bindActionCreators({ @@ -22,4 +27,4 @@ const mapDispatchToProps = (dispatch) => ({ }, dispatch), }); -export default connect(mapStateToProps, mapDispatchToProps, null, {withRef: true})(Textbox); +export default connect(makeMapStateToProps, mapDispatchToProps, null, {withRef: true})(Textbox); diff --git a/components/textbox/textbox.jsx b/components/textbox/textbox.jsx index 7fa2d5fcaa03..41accb26a345 100644 --- a/components/textbox/textbox.jsx +++ b/components/textbox/textbox.jsx @@ -16,6 +16,7 @@ import EmoticonProvider from 'components/suggestion/emoticon_provider.jsx'; import SuggestionBox from 'components/suggestion/suggestion_box.jsx'; import SuggestionList from 'components/suggestion/suggestion_list.jsx'; import Constants from 'utils/constants.jsx'; +import {postListScrollChange} from 'actions/global_actions'; import * as Utils from 'utils/utils.jsx'; const PreReleaseFeatures = Constants.PRE_RELEASE_FEATURES; @@ -39,6 +40,7 @@ export default class Textbox extends React.Component { characterLimit: PropTypes.number.isRequired, disabled: PropTypes.bool, badConnection: PropTypes.bool, + listenForMentionKeyClick: PropTypes.bool, currentUserId: PropTypes.string.isRequired, profilesInChannel: PropTypes.arrayOf(PropTypes.object).isRequired, profilesNotInChannel: PropTypes.arrayOf(PropTypes.object).isRequired, @@ -50,6 +52,7 @@ export default class Textbox extends React.Component { static defaultProps = { supportsCommands: true, isRHS: false, + listenForMentionKeyClick: false, }; constructor(props) { @@ -62,7 +65,7 @@ export default class Textbox extends React.Component { currentUserId: this.props.currentUserId, profilesInChannel: this.props.profilesInChannel, profilesNotInChannel: this.props.profilesNotInChannel, - autocompleteUsersInChannel: this.props.actions.autocompleteUsersInChannel, + autocompleteUsersInChannel: (prefix) => this.props.actions.autocompleteUsersInChannel(prefix, props.channelId), }), new ChannelMentionProvider(), new EmoticonProvider(), @@ -79,19 +82,6 @@ export default class Textbox extends React.Component { this.props.onChange(e); } - handlePopoverMentionKeyClick = (mentionKey) => { - const textbox = this.refs.message.getTextbox(); - let insertText = '@' + mentionKey; - const oldValue = textbox.value; - - // if the current text does not end with a whitespace, then insert a space - if (oldValue && (/[^\s]$/).test(oldValue)) { - insertText = ' ' + insertText; - } - - textbox.value = oldValue + insertText; - } - checkMessageLength = (message) => { if (this.props.handlePostError) { if (message.length > this.props.characterLimit) { @@ -125,6 +115,7 @@ export default class Textbox extends React.Component { handleHeightChange = (height, maxHeight) => { const wrapper = $(this.refs.wrapper); + postListScrollChange(); // Move over attachment icon to compensate for the scrollbar if (height > maxHeight) { @@ -178,7 +169,7 @@ export default class Textbox extends React.Component { currentUserId: nextProps.currentUserId, profilesInChannel: nextProps.profilesInChannel, profilesNotInChannel: nextProps.profilesNotInChannel, - autocompleteUsersInChannel: nextProps.actions.autocompleteUsersInChannel, + autocompleteUsersInChannel: (prefix) => nextProps.actions.autocompleteUsersInChannel(prefix, nextProps.channelId), }); } } @@ -334,6 +325,8 @@ export default class Textbox extends React.Component { renderDividers={true} isRHS={this.props.isRHS} disabled={this.props.disabled} + contextId={this.props.channelId} + listenForMentionKeyClick={this.props.listenForMentionKeyClick} /> {preview}
    diff --git a/components/user_profile.jsx b/components/user_profile.jsx index 766377d8a6c6..247f8134bd71 100644 --- a/components/user_profile.jsx +++ b/components/user_profile.jsx @@ -56,6 +56,11 @@ export default class UserProfile extends React.Component { render() { let name = '...'; let profileImg = ''; + let placement = 'right'; + + if (this.props.isRHS && !Utils.isMobile()) { + placement = 'left'; + } if (this.props.user && this.props.user.id) { name = Utils.getDisplayNameByUserId(this.props.user.id); @@ -74,7 +79,7 @@ export default class UserProfile extends React.Component { diff --git a/components/user_settings/display/__snapshots__/user_settings_display.test.jsx.snap b/components/user_settings/display/__snapshots__/user_settings_display.test.jsx.snap index 68f1ee903629..a71addab0cc9 100644 --- a/components/user_settings/display/__snapshots__/user_settings_display.test.jsx.snap +++ b/components/user_settings/display/__snapshots__/user_settings_display.test.jsx.snap @@ -209,7 +209,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps submit={[Function]} title={ @@ -452,7 +452,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps section="channel_display_mode" title={ @@ -695,7 +695,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps section="channel_display_mode" title={ @@ -883,7 +883,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps section="channel_display_mode" title={ @@ -1056,7 +1056,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps section="channel_display_mode" title={ @@ -1299,7 +1299,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps section="channel_display_mode" title={ @@ -1562,7 +1562,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps section="channel_display_mode" title={ @@ -1750,7 +1750,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps section="channel_display_mode" title={ @@ -1938,7 +1938,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps section="channel_display_mode" title={ @@ -2126,7 +2126,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps section="channel_display_mode" title={ @@ -2325,7 +2325,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps section="channel_display_mode" title={ @@ -2513,7 +2513,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps section="channel_display_mode" title={ diff --git a/components/user_settings/display/user_settings_display.jsx b/components/user_settings/display/user_settings_display.jsx index d61749a254f8..0c22ac8c86ed 100644 --- a/components/user_settings/display/user_settings_display.jsx +++ b/components/user_settings/display/user_settings_display.jsx @@ -601,7 +601,7 @@ export default class UserSettingsDisplay extends React.Component { defaultDisplay: Preferences.CHANNEL_DISPLAY_MODE_FULL_SCREEN, title: { id: t('user.settings.display.channelDisplayTitle'), - message: 'Channel Display Mode', + message: 'Channel Display', }, firstOption: { value: Preferences.CHANNEL_DISPLAY_MODE_FULL_SCREEN, @@ -741,4 +741,4 @@ export default class UserSettingsDisplay extends React.Component {
    ); } -} \ No newline at end of file +} diff --git a/components/user_settings/display/user_settings_theme/__snapshots__/custom_theme_chooser.test.jsx.snap b/components/user_settings/display/user_settings_theme/__snapshots__/custom_theme_chooser.test.jsx.snap new file mode 100644 index 000000000000..c2a642c3943f --- /dev/null +++ b/components/user_settings/display/user_settings_theme/__snapshots__/custom_theme_chooser.test.jsx.snap @@ -0,0 +1,441 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/user_settings/display/CustomThemeChooser should match, init 1`] = ` +
    +
    +
    + +
    + + +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + +
    + + +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    + + + + + } + placement="top" + trigger={ + Array [ + "hover", + "focus", + ] + } + > + + + + +
    +
    +
    +
    +
    +
    + +
    + + +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    + +