MM-12354 Remove Profile Image (#2428)

* save progress

* Add option to remove profile picture

* missed when rebasing

* rename extraOptions to removeProfileImage

* clean up removeProfileImage action

* placeholder after removing profile picture, unit tests
This commit is contained in:
Brad Coughlin 2018-12-16 15:56:39 -08:00 committed by Saturnino Abril
parent f84b56e987
commit 15b44e9d71
12 changed files with 509 additions and 38 deletions

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {updateMe} from 'mattermost-redux/actions/users';
import {updateMe, setDefaultProfileImage} from 'mattermost-redux/actions/users';
import {ViewTypes} from 'app/constants';
@ -25,7 +25,15 @@ export function setProfileImageUri(imageUri = '') {
};
}
export function removeProfileImage(user) {
return async (dispatch) => {
const result = await dispatch(setDefaultProfileImage(user));
return result;
};
}
export default {
updateUser,
setProfileImageUri,
removeProfileImage,
};

View file

@ -0,0 +1,57 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`profile_picture_button should match snapshot 1`] = `
<AttachmentButton
blurTextBox={[MockFunction]}
browseFileTypes="public.item"
canBrowseFiles={true}
canBrowsePhotoLibrary={true}
canBrowseVideoLibrary={true}
canTakePhoto={true}
canTakeVideo={true}
extraOptions={
Array [
null,
]
}
maxFileCount={5}
maxFileSize={20971520}
navigator={
Object {
"dismissModal": [MockFunction],
"push": [MockFunction],
"setButtons": [MockFunction],
"setOnNavigatorEvent": [MockFunction],
}
}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
uploadFiles={[MockFunction]}
/>
`;

View file

@ -42,6 +42,7 @@ export default class AttachmentButton extends PureComponent {
theme: PropTypes.object.isRequired,
uploadFiles: PropTypes.func.isRequired,
wrapper: PropTypes.bool,
extraOptions: PropTypes.arrayOf(PropTypes.object),
};
static defaultProps = {
@ -52,6 +53,7 @@ export default class AttachmentButton extends PureComponent {
canTakePhoto: true,
canTakeVideo: true,
maxFileCount: 5,
extraOptions: null,
};
static contextTypes = {
@ -351,6 +353,7 @@ export default class AttachmentButton extends PureComponent {
fileCount,
maxFileCount,
onShowFileMaxWarning,
extraOptions,
} = this.props;
if (fileCount === maxFileCount) {
@ -416,6 +419,14 @@ export default class AttachmentButton extends PureComponent {
});
}
if (extraOptions) {
extraOptions.forEach((option) => {
if (option !== null) {
items.push(option);
}
});
}
this.props.navigator.showModal({
screen: 'OptionsModal',
title: '',
@ -479,4 +490,3 @@ const style = StyleSheet.create({
justifyContent: 'center',
},
});

View file

@ -31,6 +31,7 @@ export default class ProfilePicture extends PureComponent {
edit: PropTypes.bool,
imageUri: PropTypes.string,
profileImageUri: PropTypes.string,
profileImageRemove: PropTypes.bool,
theme: PropTypes.object.isRequired,
actions: PropTypes.shape({
getStatusForId: PropTypes.func.isRequired,
@ -47,6 +48,7 @@ export default class ProfilePicture extends PureComponent {
state = {
pictureUrl: null,
otherImageProps: {},
};
componentDidMount() {
@ -97,15 +99,34 @@ export default class ProfilePicture extends PureComponent {
}
};
showDefaultImage = () => {
if (this.mounted) {
this.setState({otherImageProps: {defaultSource: placeholder}});
}
};
clearProfileImageUri = () => {
if (this.props.isCurrentUser && this.props.profileImageUri !== '') {
this.props.actions.setProfileImageUri('');
}
}
componentDidUpdate(prevProps, prevState) {
if (!this.props.edit) {
if (this.state.otherImageProps !== prevState.otherImageProps) {
this.showDefaultImage();
}
}
if (this.props.profileImageRemove !== prevProps.profileImageRemove) {
this.setImageURL(null);
this.showDefaultImage();
}
}
render() {
const {edit, showStatus, theme} = this.props;
const {pictureUrl} = this.state;
const {pictureUrl, otherImageProps} = this.state;
const style = getStyleSheet(theme);
let statusIcon;
@ -146,11 +167,6 @@ export default class ProfilePicture extends PureComponent {
};
}
const otherImageProps = {};
if (!this.props.edit) {
otherImageProps.defaultSource = placeholder;
}
return (
<View style={{width: this.props.size + STATUS_BUFFER, height: this.props.size + STATUS_BUFFER}}>
<Image

View file

@ -0,0 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {Client4} from 'mattermost-redux/client';
import {t} from 'app/utils/i18n';
import AttachmentButton from 'app/components/attachment_button';
export default class ProfilePictureButton extends PureComponent {
static propTypes = {
currentUser: PropTypes.object.isRequired,
theme: PropTypes.object.isRequired,
removeProfileImage: PropTypes.func,
};
constructor(props) {
super(props);
this.state = {
extraOptions: [this.getRemoveProfileImageOption()],
};
}
getRemoveProfileImageOption = () => {
let action = null;
const {removeProfileImage} = this.props;
const {id, last_picture_update: lastPictureUpdate} = this.props.currentUser;
const profileImageUrl = Client4.getProfilePictureUrl(id, lastPictureUpdate);
if (removeProfileImage !== null) {
action = removeProfileImage;
}
// Check if image url includes query string for timestamp.
// If so, it means the image has been updated from the default
// i.e. '.../image?_=1544159746868'
if (profileImageUrl.includes('?')) {
return {
action,
text: {
id: t('mobile.edit_profile.remove_profile_photo'),
defaultMessage: 'Remove Photo',
},
textStyle: {
color: '#CC3239',
},
icon: 'trash',
iconStyle: {
color: '#CC3239',
},
};
}
return null;
}
render() {
const {children, ...props} = this.props;
const {extraOptions} = this.state;
// Avoid passing unneeded props
Reflect.deleteProperty(props, 'currentUser');
return (
<AttachmentButton
{...props}
extraOptions={extraOptions}
>
{children}
</AttachmentButton>
);
}
}

View file

@ -0,0 +1,66 @@
// 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 ProfilePictureButton from './profile_picture_button.js';
import {Client4} from 'mattermost-redux/client';
describe('profile_picture_button', () => {
const navigator = {
setOnNavigatorEvent: jest.fn(),
setButtons: jest.fn(),
dismissModal: jest.fn(),
push: jest.fn(),
};
const baseProps = {
theme: Preferences.THEMES.default,
navigator,
currentUser: {
first_name: 'Dwight',
last_name: 'Schrute',
username: 'ieatbeets',
email: 'dwight@schrutefarms.com',
nickname: 'Dragon',
position: 'position',
},
blurTextBox: jest.fn(),
maxFileSize: 20 * 1024 * 1024,
uploadFiles: jest.fn(),
};
test('should match snapshot', async () => {
const wrapper = shallow(
<ProfilePictureButton {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should NOT return option to remove when profile picture is default', () => {
Client4.getProfilePictureUrl = jest.fn(() => 'image.png');
const wrapper = shallow(
<ProfilePictureButton {...baseProps}/>,
);
const instance = wrapper.instance();
// test default image (WITHOUT query param)
instance.getRemoveProfileImageOption();
expect(wrapper.state('extraOptions')).toEqual([null]);
});
test('should return option to remove profile picture if customized', () => {
Client4.getProfilePictureUrl = jest.fn(() => 'image.png?query');
const wrapper = shallow(
<ProfilePictureButton {...baseProps}/>,
);
const instance = wrapper.instance();
// test custom image (WITH query param)
instance.getRemoveProfileImageOption();
expect(wrapper.state('extraOptions')).not.toEqual([null]);
});
});

View file

@ -0,0 +1,112 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`edit_profile should match snapshot 1`] = `
<View
style={
Object {
"alignItems": "center",
"justifyContent": "center",
"padding": 25,
}
}
>
<ProfilePictureButton
blurTextBox={[Function]}
browseFileTypes="public.image"
canBrowseVideoLibrary={false}
canTakeVideo={false}
currentUser={
Object {
"email": "dwight@schrutefarms.com",
"first_name": "Dwight",
"last_name": "Schrute",
"nickname": "Dragon",
"position": "position",
"username": "ieatbeets",
}
}
maxFileSize={20971520}
navigator={
Object {
"dismissModal": [MockFunction],
"push": [MockFunction],
"setButtons": [MockFunction] {
"calls": Array [
Array [
Object {
"rightButtons": Array [
Object {
"disabled": true,
"id": "update-profile",
"showAsAction": "always",
"title": undefined,
},
],
},
],
],
"results": Array [
Object {
"isThrow": false,
"value": undefined,
},
],
},
"setOnNavigatorEvent": [MockFunction] {
"calls": Array [
Array [
[Function],
],
],
"results": Array [
Object {
"isThrow": false,
"value": undefined,
},
],
},
}
}
onShowFileSizeWarning={[Function]}
removeProfileImage={[Function]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
uploadFiles={[Function]}
wrapper={true}
>
<Connect(ProfilePicture)
edit={true}
imageUri={null}
size={150}
statusBorderWidth={6}
statusSize={40}
/>
</ProfilePictureButton>
</View>
`;

View file

@ -21,8 +21,8 @@ import TextSetting from 'app/components/widgets/settings/text_setting';
import Loading from 'app/components/loading';
import ErrorText from 'app/components/error_text';
import StatusBar from 'app/components/status_bar/index';
import ProfilePictureButton from 'app/components/profile_picture_button';
import ProfilePicture from 'app/components/profile_picture';
import AttachmentButton from 'app/components/attachment_button';
import mattermostBucket from 'app/mattermost_bucket';
import LocalConfig from 'assets/config';
import {getFormattedFileSize} from 'mattermost-redux/utils/file_utils';
@ -59,6 +59,7 @@ export default class EditProfile extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
setProfileImageUri: PropTypes.func.isRequired,
removeProfileImage: PropTypes.func.isRequired,
updateUser: PropTypes.func.isRequired,
}).isRequired,
config: PropTypes.object.isRequired,
@ -157,6 +158,7 @@ export default class EditProfile extends PureComponent {
const {
profileImage,
profileImageRemove,
firstName,
lastName,
username,
@ -172,13 +174,17 @@ export default class EditProfile extends PureComponent {
position,
email,
};
const {actions} = this.props;
const {actions, currentUser} = this.props;
if (profileImage) {
actions.setProfileImageUri(profileImage.uri);
this.uploadProfileImage().catch(this.handleUploadError);
}
if (profileImageRemove) {
actions.removeProfileImage(currentUser.id);
}
if (this.canUpdate()) {
const {error} = await actions.updateUser(user);
if (error) {
@ -196,6 +202,14 @@ export default class EditProfile extends PureComponent {
this.emitCanUpdateAccount(true);
};
handleRemoveProfileImage = () => {
this.setState({profileImageRemove: true});
this.emitCanUpdateAccount(true);
this.props.navigator.dismissModal({
animationType: 'none',
});
}
uploadProfileImage = async () => {
const {profileImage} = this.state;
const {currentUser} = this.props;
@ -452,7 +466,7 @@ export default class EditProfile extends PureComponent {
this.scrollView = ref;
};
render() {
renderProfilePicture = () => {
const {
currentUser,
theme,
@ -461,12 +475,51 @@ export default class EditProfile extends PureComponent {
const {
profileImage,
profileImageRemove,
} = this.state;
const style = getStyleSheet(theme);
const uri = profileImage ? profileImage.uri : null;
return (
<View style={style.top}>
<ProfilePictureButton
currentUser={currentUser}
theme={theme}
blurTextBox={emptyFunction}
browseFileTypes={DocumentPickerUtil.images()}
canTakeVideo={false}
canBrowseVideoLibrary={false}
maxFileSize={MAX_SIZE}
navigator={navigator}
wrapper={true}
uploadFiles={this.handleUploadProfileImage}
removeProfileImage={this.handleRemoveProfileImage}
onShowFileSizeWarning={this.onShowFileSizeWarning}
>
<ProfilePicture
userId={currentUser.id}
size={150}
statusBorderWidth={6}
statusSize={40}
edit={true}
imageUri={uri}
profileImageRemove={profileImageRemove}
/>
</ProfilePictureButton>
</View>
);
}
render() {
const {theme} = this.props;
const {
error,
updating,
} = this.state;
const style = getStyleSheet(theme);
const uri = profileImage ? profileImage.uri : null;
if (updating) {
return (
@ -501,29 +554,7 @@ export default class EditProfile extends PureComponent {
>
{displayError}
<View style={[style.scrollView]}>
<View style={style.top}>
<AttachmentButton
blurTextBox={emptyFunction}
browseFileTypes={DocumentPickerUtil.images()}
canTakeVideo={false}
canBrowseVideoLibrary={false}
maxFileSize={MAX_SIZE}
theme={theme}
navigator={navigator}
wrapper={true}
uploadFiles={this.handleUploadProfileImage}
onShowFileSizeWarning={this.onShowFileSizeWarning}
>
<ProfilePicture
userId={currentUser.id}
size={150}
statusBorderWidth={6}
statusSize={40}
edit={true}
imageUri={uri}
/>
</AttachmentButton>
</View>
{this.renderProfilePicture()}
{this.renderFirstNameSettings()}
<View style={style.separator}/>
{this.renderLastNameSettings()}

View file

@ -0,0 +1,84 @@
// 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 EditProfile from './edit_profile.js';
jest.mock('react-intl');
jest.mock('app/utils/theme', () => {
const original = require.requireActual('app/utils/theme');
return {
...original,
changeOpacity: jest.fn(),
};
});
describe('edit_profile', () => {
const navigator = {
setOnNavigatorEvent: jest.fn(),
setButtons: jest.fn(),
dismissModal: jest.fn(),
push: jest.fn(),
};
const actions = {
updateUser: jest.fn(),
setProfileImageUri: jest.fn(),
removeProfileImage: jest.fn(),
};
const baseProps = {
actions,
config: {
ShowEmailAddress: true,
},
theme: Preferences.THEMES.default,
navigator,
currentUser: {
first_name: 'Dwight',
last_name: 'Schrute',
username: 'ieatbeets',
email: 'dwight@schrutefarms.com',
nickname: 'Dragon',
position: 'position',
},
};
test('should match snapshot', async () => {
const wrapper = shallow(
<EditProfile {...baseProps}/>,
{context: {intl: {formatMessage: jest.fn()}}},
);
expect(wrapper.instance().renderProfilePicture()).toMatchSnapshot();
});
test('should match state on handleRemoveProfileImage', () => {
const newNavigator = {
dismissModal: jest.fn(),
setOnNavigatorEvent: jest.fn(),
setButtons: jest.fn(),
};
const wrapper = shallow(
<EditProfile
{...baseProps}
navigator={newNavigator}
/>,
{context: {intl: {formatMessage: jest.fn()}}},
);
wrapper.setProps({profileImageRemove: false});
const instance = wrapper.instance();
instance.emitCanUpdateAccount = jest.fn();
instance.handleRemoveProfileImage();
expect(wrapper.state('profileImageRemove')).toEqual(true);
expect(instance.emitCanUpdateAccount).toHaveBeenCalledTimes(1);
expect(instance.emitCanUpdateAccount).toBeCalledWith(true);
expect(newNavigator.dismissModal).toHaveBeenCalledTimes(1);
expect(newNavigator.dismissModal).toBeCalledWith({animationType: 'none'});
});
});

View file

@ -7,7 +7,7 @@ import {bindActionCreators} from 'redux';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {setProfileImageUri, updateUser} from 'app/actions/views/edit_profile';
import {setProfileImageUri, removeProfileImage, updateUser} from 'app/actions/views/edit_profile';
import EditProfile from './edit_profile';
@ -22,6 +22,7 @@ function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
setProfileImageUri,
removeProfileImage,
updateUser,
}, dispatch),
};

View file

@ -41,6 +41,11 @@ export default class OptionsModalList extends PureComponent {
const options = items.map((item, index) => {
let textComponent;
let optionIconStyle = style.optionIcon;
if (typeof item.iconStyle !== 'undefined') {
optionIconStyle = item.iconStyle;
}
if (item.text.hasOwnProperty('id')) {
textComponent = (
<FormattedText
@ -66,7 +71,7 @@ export default class OptionsModalList extends PureComponent {
<IconFont
name={item.icon}
size={18}
style={style.optionIcon}
style={optionIconStyle}
/>
}
</TouchableOpacity>

View file

@ -45,6 +45,11 @@ export default class OptionsModalList extends PureComponent {
const options = items.map((item, index) => {
let textComponent;
let optionIconStyle = style.optionIcon;
if (typeof item.iconStyle !== 'undefined') {
optionIconStyle = item.iconStyle;
}
if (item.text.hasOwnProperty('id')) {
textComponent = (
<FormattedText
@ -70,7 +75,7 @@ export default class OptionsModalList extends PureComponent {
<IconFont
name={item.icon}
size={18}
style={style.optionIcon}
style={optionIconStyle}
/>
}
</TouchableOpacity>