From 7bd000d794b3fba07fa7e26dcd7399e6646d63ee Mon Sep 17 00:00:00 2001 From: Andre Vasconcelos Date: Tue, 24 Sep 2019 10:39:15 +0900 Subject: [PATCH] GH-11952 Adding option to convert public channel to private (#3230) * Adding option to convert public channel to private * Added unit tests, feedback alert with error handling, and new alert text * Ensuring baseProps is never changed between channel_info tests * Trimming the display_name on alerts - Preventing extra empty spaces from being displayed on the alerts by trimming the variable --- .../__snapshots__/channel_info.test.js.snap | 570 ++++++++++++++++++ app/screens/channel_info/channel_info.js | 75 +++ app/screens/channel_info/channel_info.test.js | 156 +++++ app/screens/channel_info/index.js | 3 + app/utils/general.js | 4 +- assets/base/i18n/en.json | 5 + 6 files changed, 811 insertions(+), 2 deletions(-) create mode 100644 app/screens/channel_info/__snapshots__/channel_info.test.js.snap create mode 100644 app/screens/channel_info/channel_info.test.js diff --git a/app/screens/channel_info/__snapshots__/channel_info.test.js.snap b/app/screens/channel_info/__snapshots__/channel_info.test.js.snap new file mode 100644 index 000000000..ea562ae80 --- /dev/null +++ b/app/screens/channel_info/__snapshots__/channel_info.test.js.snap @@ -0,0 +1,570 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`channel_info should match snapshot 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/app/screens/channel_info/channel_info.js b/app/screens/channel_info/channel_info.js index bd999dbb9..11b77a2f4 100644 --- a/app/screens/channel_info/channel_info.js +++ b/app/screens/channel_info/channel_info.js @@ -28,6 +28,7 @@ export default class ChannelInfo extends PureComponent { clearPinnedPosts: PropTypes.func.isRequired, closeDMChannel: PropTypes.func.isRequired, closeGMChannel: PropTypes.func.isRequired, + convertChannelToPrivate: PropTypes.func.isRequired, deleteChannel: PropTypes.func.isRequired, getChannelStats: PropTypes.func.isRequired, getChannel: PropTypes.func.isRequired, @@ -60,6 +61,7 @@ export default class ChannelInfo extends PureComponent { isChannelMuted: PropTypes.bool.isRequired, isCurrent: PropTypes.bool.isRequired, isFavorite: PropTypes.bool.isRequired, + canConvertChannel: PropTypes.bool.isRequired, canManageUsers: PropTypes.bool.isRequired, canEditChannel: PropTypes.bool.isRequired, ignoreChannelMentions: PropTypes.bool.isRequired, @@ -176,6 +178,59 @@ export default class ChannelInfo extends PureComponent { this.handleDeleteOrLeave('delete'); }; + handleConfirmConvertToPrivate = preventDoubleTap(async () => { + const {actions, currentChannel} = this.props; + const result = await actions.convertChannelToPrivate(currentChannel.id); + const displayName = {displayName: currentChannel.display_name.trim()}; + const {formatMessage} = this.context.intl; + if (result.error) { + alertErrorWithFallback( + this.context.intl, + result.error, + { + id: t('mobile.channel_info.convert_failed'), + defaultMessage: 'We were unable to convert {displayName} to a private channel.', + }, + { + displayName, + }, + [{ + text: formatMessage({id: 'mobile.share_extension.error_close', defaultMessage: 'Close'}), + }, { + text: formatMessage({id: 'mobile.terms_of_service.alert_retry', defaultMessage: 'Try Again'}), + onPress: this.handleConfirmConvertToPrivate, + }] + ); + } else { + Alert.alert( + '', + formatMessage({id: t('mobile.channel_info.convert_success'), defaultMessage: '{displayName} is now a private channel.'}, displayName), + ); + } + }) + + handleConvertToPrivate = preventDoubleTap(() => { + const {currentChannel} = this.props; + const {formatMessage} = this.context.intl; + const displayName = {displayName: currentChannel.display_name.trim()}; + const title = {id: t('mobile.channel_info.alertTitleConvertChannel'), defaultMessage: 'Convert {displayName} to a private channel?'}; + const message = { + id: t('mobile.channel_info.alertMessageConvertChannel'), + defaultMessage: 'When you convert {displayName} to a private channel, history and membership are preserved. Publicly shared files remain accessible to anyone with the link. Membership in a private channel is by invitation only.\n\nThe change is permanent and cannot be undone.\n\nAre you sure you want to convert {displayName} to a private channel?', + }; + + Alert.alert( + formatMessage(title, displayName), + formatMessage(message, displayName), + [{ + text: formatMessage({id: 'mobile.channel_info.alertNo', defaultMessage: 'No'}), + }, { + text: formatMessage({id: 'mobile.channel_info.alertYes', defaultMessage: 'Yes'}), + onPress: this.handleConfirmConvertToPrivate, + }], + ); + }); + handleDeleteOrLeave = preventDoubleTap((eventType) => { const {formatMessage} = this.context.intl; const channel = this.props.currentChannel; @@ -352,6 +407,13 @@ export default class ChannelInfo extends PureComponent { return isDirectMessage || isGroupMessage; }; + renderConvertToPrivateRow = () => { + const {currentChannel, canConvertChannel} = this.props; + const isDefaultChannel = currentChannel.name === General.DEFAULT_CHANNEL; + const isPublicChannel = currentChannel.type === General.OPEN_CHANNEL; + return !isDefaultChannel && isPublicChannel && canConvertChannel; + } + actionsRows = (style, channelIsArchived) => { const { currentChannelMemberCount, @@ -461,6 +523,19 @@ export default class ChannelInfo extends PureComponent { /> } + {this.renderConvertToPrivateRow() && ( + + + + + )} {canEditChannel && ( diff --git a/app/screens/channel_info/channel_info.test.js b/app/screens/channel_info/channel_info.test.js new file mode 100644 index 000000000..71d0ce1be --- /dev/null +++ b/app/screens/channel_info/channel_info.test.js @@ -0,0 +1,156 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {shallow} from 'enzyme'; + +import Preferences from 'mattermost-redux/constants/preferences'; +import {General} from 'mattermost-redux/constants'; + +import ChannelInfo from './channel_info'; + +// ChannelInfoRow expects to receive the pinIcon as a number +jest.mock('assets/images/channel_info/pin.png', () => { + return 1; +}); + +jest.mock('app/utils/theme', () => { + const original = require.requireActual('app/utils/theme'); + return { + ...original, + changeOpacity: jest.fn(), + }; +}); + +describe('channel_info', () => { + const intlMock = { + formatMessage: jest.fn(), + formatDate: jest.fn(), + formatTime: jest.fn(), + formatRelative: jest.fn(), + formatNumber: jest.fn(), + formatPlural: jest.fn(), + formatHTMLMessage: jest.fn(), + now: jest.fn(), + }; + const baseProps = { + canDeleteChannel: true, + canConvertChannel: true, + canManageUsers: true, + viewArchivedChannels: true, + canEditChannel: true, + currentChannel: { + id: '1234', + display_name: 'Channel Name', + type: General.OPEN_CHANNEL, + create_at: 123, + delete_at: 0, + header: '', + purpose: 'Purpose', + group_constrained: false, + }, + currentChannelCreatorName: 'Creator', + currentChannelMemberCount: 2, + currentChannelGuestCount: 0, + currentUserId: '1234', + currentUserIsGuest: false, + isChannelMuted: false, + ignoreChannelMentions: false, + isCurrent: true, + isFavorite: false, + status: 'status', + theme: Preferences.THEMES.default, + isBot: false, + isLandscape: false, + actions: { + clearPinnedPosts: jest.fn(), + closeDMChannel: jest.fn(), + closeGMChannel: jest.fn(), + convertChannelToPrivate: jest.fn(), + deleteChannel: jest.fn(), + getChannelStats: jest.fn(), + getChannel: jest.fn(), + leaveChannel: jest.fn(), + loadChannelsByTeamName: jest.fn(), + favoriteChannel: jest.fn(), + unfavoriteChannel: jest.fn(), + getCustomEmojisInText: jest.fn(), + selectFocusedPostId: jest.fn(), + updateChannelNotifyProps: jest.fn(), + selectPenultimateChannel: jest.fn(), + setChannelDisplayName: jest.fn(), + handleSelectChannel: jest.fn(), + popTopScreen: jest.fn(), + goToScreen: jest.fn(), + popToRoot: jest.fn(), + dismissModal: jest.fn(), + showModalOverCurrentContext: jest.fn(), + }, + }; + + test('should match snapshot', async () => { + const wrapper = shallow( + , + {context: {intl: intlMock}}, + ); + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should render convert to private button when user has team admin permissions', async () => { + const wrapper = shallow( + , + {context: {intl: intlMock}}, + ); + + const instance = wrapper.instance(); + const render = instance.renderConvertToPrivateRow(); + expect(render).toBeTruthy(); + }); + + test('should not render convert to private button when user is not team admin or above', async () => { + const wrapper = shallow( + , + {context: {intl: intlMock}}, + ); + + const instance = wrapper.instance(); + const render = instance.renderConvertToPrivateRow(); + expect(render).toBeFalsy(); + }); + + test('should not render convert to private button currentChannel is already private', async () => { + const props = Object.assign({}, baseProps); + props.currentChannel.type = General.PRIVATE_CHANNEL; + const wrapper = shallow( + , + {context: {intl: intlMock}}, + ); + + const instance = wrapper.instance(); + const render = instance.renderConvertToPrivateRow(); + expect(render).toBeFalsy(); + }); + + test('should not render convert to private button when currentChannel is a default channel', async () => { + const props = Object.assign({}, baseProps); + props.currentChannel.name = General.DEFAULT_CHANNEL; + const wrapper = shallow( + , + {context: {intl: intlMock}}, + ); + + const instance = wrapper.instance(); + const render = instance.renderConvertToPrivateRow(); + expect(render).toBeFalsy(); + }); +}); diff --git a/app/screens/channel_info/index.js b/app/screens/channel_info/index.js index 74a558da9..bbf55994a 100644 --- a/app/screens/channel_info/index.js +++ b/app/screens/channel_info/index.js @@ -5,6 +5,7 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; import { + convertChannelToPrivate, favoriteChannel, getChannelStats, getChannel, @@ -105,6 +106,7 @@ function mapStateToProps(state) { return { canDeleteChannel: showDeleteOption(state, config, license, currentChannel, isAdmin, isSystemAdmin, isChannelAdmin), + canConvertChannel: isAdmin, viewArchivedChannels, canEditChannel, currentChannel, @@ -132,6 +134,7 @@ function mapDispatchToProps(dispatch) { clearPinnedPosts, closeDMChannel, closeGMChannel, + convertChannelToPrivate, deleteChannel, getChannelStats, getChannel, diff --git a/app/utils/general.js b/app/utils/general.js index d8b962dfe..4352bec08 100644 --- a/app/utils/general.js +++ b/app/utils/general.js @@ -17,12 +17,12 @@ export function toTitleCase(str) { return str.replace(/\w\S*/g, doTitleCase); } -export function alertErrorWithFallback(intl, error, fallback, values) { +export function alertErrorWithFallback(intl, error, fallback, values, buttons) { let msg = error.message; if (!msg || msg === 'Network request failed') { msg = intl.formatMessage(fallback, values); } - Alert.alert('', msg); + Alert.alert('', msg, buttons); } export function alertErrorIfInvalidPermissions(result) { diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 4cef5347d..10715dce2 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -156,12 +156,17 @@ "mobile.camera_video_permission_denied_description": "Take videos and upload them to your Mattermost instance or save them to your device. Open Settings to grant Mattermost Read and Write access to your camera.", "mobile.camera_video_permission_denied_title": "{applicationName} would like to access your camera", "mobile.channel_drawer.search": "Jump to...", + "mobile.channel_info.alertMessageConvertChannel": "When you convert {displayName} to a private channel, history and membership are preserved. Publicly shared files remain accessible to anyone with the link. Membership in a private channel is by invitation only.\n\nThe change is permanent and cannot be undone.\n\nAre you sure you want to convert {displayName} to a private channel?", "mobile.channel_info.alertMessageDeleteChannel": "Are you sure you want to archive the {term} {name}?", "mobile.channel_info.alertMessageLeaveChannel": "Are you sure you want to leave the {term} {name}?", "mobile.channel_info.alertNo": "No", + "mobile.channel_info.alertTitleConvertChannel": "Convert {displayName} to a private channel?", "mobile.channel_info.alertTitleDeleteChannel": "Archive {term}", "mobile.channel_info.alertTitleLeaveChannel": "Leave {term}", "mobile.channel_info.alertYes": "Yes", + "mobile.channel_info.convert": "Convert to Private Channel", + "mobile.channel_info.convert_failed": "We were unable to convert {displayName} to a private channel.", + "mobile.channel_info.convert_success": "{displayName} is now a private channel.", "mobile.channel_info.copy_header": "Copy Header", "mobile.channel_info.copy_purpose": "Copy Purpose", "mobile.channel_info.delete_failed": "We couldn't archive the channel {displayName}. Please check your connection and try again.",