# Conflicts:
#	web/ASC.Web.Components/package.json
This commit is contained in:
Daniil Senkiv 2019-09-13 15:52:42 +03:00
commit 88f8818d77
17 changed files with 371 additions and 132 deletions

View File

@ -1,10 +1,11 @@
import React from 'react'
import { withRouter } from 'react-router'
import { connect } from 'react-redux'
import { Avatar, Button, Textarea, Text, toastr, ModalDialog, TextInput } from 'asc-web-components'
import { Avatar, Button, Textarea, Text, toastr, ModalDialog, TextInput, AvatarEditor } from 'asc-web-components'
import { withTranslation } from 'react-i18next';
import { toEmployeeWrapper, getUserRole, getUserContactsPattern, getUserContacts, mapGroupsToGroupSelectorOptions, mapGroupSelectorOptionsToGroups, filterGroupSelectorOptions } from "../../../../../store/people/selectors";
import { updateProfile } from '../../../../../store/profile/actions';
import { updateProfile, updateAvatar } from '../../../../../store/profile/actions';
import { sendInstructionsToChangePassword } from "../../../../../store/services/api";
import { MainContainer, AvatarContainer, MainFieldsContainer } from './FormFields/Form'
import TextField from './FormFields/TextField'
import TextChangeField from './FormFields/TextChangeField'
@ -42,6 +43,12 @@ class UpdateUserForm extends React.Component {
this.onContactsItemTypeChange = this.onContactsItemTypeChange.bind(this);
this.onContactsItemTextChange = this.onContactsItemTextChange.bind(this);
this.openAvatarEditor = this.openAvatarEditor.bind(this);
this.onSaveAvatar = this.onSaveAvatar.bind(this);
this.onCloseAvatarEditor = this.onCloseAvatarEditor.bind(this);
this.onShowGroupSelector = this.onShowGroupSelector.bind(this);
this.onCloseGroupSelector = this.onCloseGroupSelector.bind(this);
this.onSearchGroups = this.onSearchGroups.bind(this);
@ -67,6 +74,7 @@ class UpdateUserForm extends React.Component {
lastName: false,
},
profile: profile,
visibleAvatarEditor: false,
dialog: {
visible: false,
header: "",
@ -189,7 +197,7 @@ class UpdateUserForm extends React.Component {
header: "Change password",
body: (
<Text.Body>
Send the password change instructions to the <a href={`mailto:${this.state.profile.email}`}>${this.state.profile.email}</a> email address
Send the password change instructions to the <a href={`mailto:${this.state.profile.email}`}>{this.state.profile.email}</a> email address
</Text.Body>
),
buttons: [
@ -206,8 +214,10 @@ class UpdateUserForm extends React.Component {
}
onSendPasswordChangeInstructions() {
toastr.success("Context action: Change password");
this.onDialogClose();
sendInstructionsToChangePassword(this.state.profile.email)
.then((res) => toastr.success(res.data.response))
.catch((error) => toastr.error(error.message))
.finally(this.onDialogClose);
}
onPhoneChange() {
@ -272,6 +282,34 @@ class UpdateUserForm extends React.Component {
this.setState(stateCopy);
}
openAvatarEditor(){
this.setState({
visibleAvatarEditor: true,
});
}
onSaveAvatar(result) {
this.props.updateAvatar(this.state.profile.id, result)
.then((result) => {
let stateCopy = Object.assign({}, this.state);
stateCopy.visibleAvatarEditor = false;
if(result.data.response.success){
stateCopy.profile.avatarMax = result.data.response.data.max;
}else{
stateCopy.profile.avatarMax = result.data.response.max && result.data.response.max;
}
toastr.success("Success");
this.setState(stateCopy);
})
.catch((error) => {
toastr.error(error.message);
});
}
onCloseAvatarEditor() {
this.setState({
visibleAvatarEditor: false,
});
}
onShowGroupSelector() {
var stateCopy = Object.assign({}, this.state);
stateCopy.selector.visible = true;
@ -323,7 +361,13 @@ class UpdateUserForm extends React.Component {
userName={profile.displayName}
editing={true}
editLabel={t("EditPhoto")}
editAction={this.openAvatarEditor}
/>
<AvatarEditor
image={profile.avatarDefault ? "data:image/png;base64,"+profile.avatarDefault : null}
visible={this.state.visibleAvatarEditor}
onClose={this.onCloseAvatarEditor}
onSave={this.onSaveAvatar} />
</AvatarContainer>
<MainFieldsContainer>
<TextChangeField
@ -498,6 +542,7 @@ const mapStateToProps = (state) => {
export default connect(
mapStateToProps,
{
updateProfile
updateProfile,
updateAvatar
}
)(withRouter(withTranslation()(UpdateUserForm)));

View File

@ -90,4 +90,26 @@ export function updateProfile(profile) {
return Promise.resolve(result);
});
};
};
export function updateAvatar(profileId, images) {
return (dispatch, getState) => {
if (images.croppedImage) {
return api.updateAvatar(
profileId,
{
autosave: true,
base64CroppedImage: images.croppedImage.split(',')[1],
base64DefaultImage: images.defaultImage.split(',')[1]
}
).then(res => {
checkResponseError(res);
return Promise.resolve(res);
});
} else {
return api.deleteAvatar(profileId).then(res => {
checkResponseError(res);
return Promise.resolve(res);
});
}
};
};

View File

@ -68,6 +68,17 @@ export function updateUser(data) {
? fakeApi.updateUser()
: axios.put(`${API_URL}/people/${data.id}`, data);
}
export function updateAvatar(profileId, data) {
return IS_FAKE
? fakeApi.updateAvatar()
: axios.post(`${API_URL}/people/${profileId}/photo/cropped`, data);
}
export function deleteAvatar(profileId) {
return IS_FAKE
? fakeApi.deleteAvatar()
: axios.delete(`${API_URL}/people/${profileId}/photo`, profileId);
}
export function getInitInfo() {
return axios.all([getUser(), getModulesList(), getSettings(), getPortalPasswordSettings()]).then(

View File

@ -277,6 +277,13 @@ export function updateUser(data) {
return fakeResponse(data);
}
export function updateAvatar(data) {
return fakeResponse(data);
}
export function deleteAvatar(data) {
return fakeResponse(data);
}
export function updateUserStatus(status, userIds) {
return fakeResponse([
{

View File

@ -590,7 +590,110 @@ namespace ASC.Employee.Core.Controllers
return new ThumbnailsDataWrapper(Tenant, user.ID);
}
public FormFile Base64ToImage(string base64String, string fileName)
{
byte[] imageBytes = Convert.FromBase64String(base64String);
MemoryStream ms = new MemoryStream(imageBytes, 0, imageBytes.Length);
ms.Write(imageBytes, 0, imageBytes.Length);
return new FormFile(ms, 0, ms.Length, fileName, fileName);
}
[Create("{userid}/photo/cropped")]
public FileUploadResult UploadCroppedMemberPhoto(string userid, UploadCroppedPhotoModel model)
{
var result = new FileUploadResult();
try
{
Guid userId;
try
{
userId = new Guid(userid);
}
catch
{
userId = SecurityContext.CurrentAccount.ID;
}
SecurityContext.DemandPermissions(Tenant, new UserSecurityProvider(userId), Constants.Action_EditUser);
var userPhoto = Base64ToImage(model.base64CroppedImage, "userPhoto_"+ userId.ToString());
var defaultUserPhoto = Base64ToImage(model.base64DefaultImage, "defaultPhoto" + userId.ToString());
if (userPhoto.Length > SetupInfo.MaxImageUploadSize)
{
result.Success = false;
result.Message = FileSizeComment.FileImageSizeExceptionString;
return result;
}
var data = new byte[userPhoto.Length];
using var inputStream = userPhoto.OpenReadStream();
var br = new BinaryReader(inputStream);
br.Read(data, 0, (int)userPhoto.Length);
br.Close();
var defaultData = new byte[defaultUserPhoto.Length];
using var defaultInputStream = defaultUserPhoto.OpenReadStream();
var defaultBr = new BinaryReader(defaultInputStream);
defaultBr.Read(defaultData, 0, (int)defaultUserPhoto.Length);
defaultBr.Close();
CheckImgFormat(data);
if (model.Autosave)
{
if (data.Length > SetupInfo.MaxImageUploadSize)
throw new ImageSizeLimitException();
var mainPhoto = UserPhotoManager.SaveOrUpdateCroppedPhoto(Tenant, userId, data, defaultData);
result.Data =
new
{
main = mainPhoto,
retina = UserPhotoManager.GetRetinaPhotoURL(Tenant.TenantId, userId),
max = UserPhotoManager.GetMaxPhotoURL(Tenant.TenantId, userId),
big = UserPhotoManager.GetBigPhotoURL(Tenant.TenantId, userId),
medium = UserPhotoManager.GetMediumPhotoURL(Tenant.TenantId, userId),
small = UserPhotoManager.GetSmallPhotoURL(Tenant.TenantId, userId),
};
}
else
{
result.Data = UserPhotoManager.SaveTempPhoto(Tenant.TenantId, data, SetupInfo.MaxImageUploadSize, UserPhotoManager.OriginalFotoSize.Width, UserPhotoManager.OriginalFotoSize.Height);
}
result.Success = true;
}
catch (UnknownImageFormatException)
{
result.Success = false;
result.Message = PeopleResource.ErrorUnknownFileImageType;
}
catch (ImageWeightLimitException)
{
result.Success = false;
result.Message = PeopleResource.ErrorImageWeightLimit;
}
catch (ImageSizeLimitException)
{
result.Success = false;
result.Message = PeopleResource.ErrorImageSizetLimit;
}
catch (Exception ex)
{
result.Success = false;
result.Message = ex.Message.HtmlEncode();
}
return result;
}
[Create("{userid}/photo")]
public FileUploadResult UploadMemberPhoto(string userid, UploadPhotoModel model)
{

View File

@ -86,6 +86,9 @@ namespace ASC.Web.Api.Models
[DataMember(Order = 20)]
public string AvatarMax { get; set; }
[DataMember(Order = 20)]
public string AvatarDefault { get; set; }
[DataMember(Order = 20)]
public string AvatarMedium { get; set; }
@ -185,6 +188,11 @@ namespace ASC.Web.Api.Models
var userInfoLM = userInfo.LastModified.GetHashCode();
if (context.Check("avatarDefault"))
{
AvatarDefault = Convert.ToBase64String(CoreContext.UserManager.GetUserPhoto(context.Tenant.TenantId, userInfo.ID));
}
if (context.Check("avatarMax"))
{
AvatarMax = UserPhotoManager.GetMaxPhotoURL(context.Tenant.TenantId, userInfo.ID, out var isdef) + (isdef ? "" : $"?_={userInfoLM}");

View File

@ -8,6 +8,12 @@ namespace ASC.People.Models
public List<IFormFile> Files { get; set; }
public bool Autosave { get; set; }
}
public class UploadCroppedPhotoModel
{
public string base64CroppedImage { get; set; }
public string base64DefaultImage { get; set; }
public bool Autosave { get; set; }
}
public class FileUploadResult
{

View File

@ -1,6 +1,6 @@
{
"name": "asc-web-components",
"version": "1.0.79",
"version": "1.0.80",
"description": "Ascensio System SIA component library",
"license": "AGPL-3.0",
"main": "dist/asc-web-components.js",

View File

@ -127,7 +127,7 @@ class AvatarEditor extends React.Component {
}
onSaveButtonClick() {
this.props.onSave({
defaultImage: this.state.defaultImage,
defaultImage: this.state.defaultImage ? this.state.defaultImage : this.props.image,
croppedImage: this.state.croppedImage
});
this.setState({ visible: false });

View File

@ -1,5 +1,5 @@
import React from 'react';
import { mount, shallow } from 'enzyme';
import { mount } from 'enzyme';
import Avatar from '.';
const baseProps = {
@ -96,6 +96,7 @@ describe('<Avatar />', () => {
expect(wrapper.prop('editing')).toEqual(true);
});
/*
it('not re-render test', () => {
const wrapper = shallow(<Avatar {...baseProps} />).instance();
@ -119,4 +120,5 @@ describe('<Avatar />', () => {
expect(shouldUpdate).toBe(true);
});
*/
});

View File

@ -1,9 +1,8 @@
import React from 'react'
import React, { memo } from 'react'
import styled, { css } from 'styled-components'
import PropTypes from 'prop-types'
import { Icons } from '../icons'
import Link from '../link'
import isEqual from "lodash/isEqual";
const whiteColor = '#FFFFFF';
const avatarBackground = '#ECEEF1';
@ -159,51 +158,44 @@ Initials.propTypes = {
};
// eslint-disable-next-line react/display-name
class Avatar extends React.Component {
const Avatar = memo(props => {
//console.log("Avatar render");
const { size, source, userName, role, editing, editLabel, editAction } = props;
shouldComponentUpdate(nextProps, nextState) {
return !isEqual(this.props, nextProps) || !isEqual(this.state, nextState);
}
const avatarContent = source
? <ImageStyled src={source} />
: userName
? <Initials userName={userName} size={size} />
: <EmptyIcon size='scale' />;
render() {
//console.log("Avatar render");
const { size, source, userName, role, editing, editLabel, editAction } = this.props;
const roleIcon = getRoleIcon(role);
const avatarContent = source
? <ImageStyled src={source} />
: userName
? <Initials userName={userName} size={size} />
: <EmptyIcon size='scale' />;
const roleIcon = getRoleIcon(role);
return (
<StyledAvatar {...this.props}>
<AvatarWrapper source={source} userName={userName}>
{avatarContent}
</AvatarWrapper>
{editing && (size === 'max') &&
<EditContainer>
<EditLink>
<Link
type='action'
title={editLabel}
isTextOverflow={true}
fontSize={14}
color={whiteColor}
onClick={editAction}
>
{editLabel}
</Link>
</EditLink>
</EditContainer>}
<RoleWrapper size={size}>
{roleIcon}
</RoleWrapper>
</StyledAvatar>
);
}
}
return (
<StyledAvatar {...props}>
<AvatarWrapper source={source} userName={userName}>
{avatarContent}
</AvatarWrapper>
{editing && (size === 'max') &&
<EditContainer>
<EditLink>
<Link
type='action'
title={editLabel}
isTextOverflow={true}
fontSize={14}
color={whiteColor}
onClick={editAction}
>
{editLabel}
</Link>
</EditLink>
</EditContainer>}
<RoleWrapper size={size}>
{roleIcon}
</RoleWrapper>
</StyledAvatar>
);
});
Avatar.propTypes = {
size: PropTypes.oneOf(['max', 'big', 'medium', 'small']),

View File

@ -12,7 +12,7 @@ const StyledOuther = styled.div`
cursor: pointer;
`;
class ContextMenuButton extends React.PureComponent {
class ContextMenuButton extends React.Component {
constructor(props) {
super(props);
@ -70,6 +70,13 @@ class ContextMenuButton extends React.PureComponent {
this.toggle(!this.state.isOpen);
}
shouldComponentUpdate(nextProps, nextState) {
if (this.props.opened === nextProps.opened && this.state.isOpen === nextState.isOpen) {
return false;
}
return true;
}
render() {
//console.log("ContextMenuButton render");
return (

View File

@ -50,10 +50,10 @@ class InputBlock extends React.Component {
}
onIconClick(e) {
this.props.onIconClick(e);
if(typeof this.props.onIconClick === "function") this.props.onIconClick(e);
}
onChange(e) {
this.props.onChange(e);
if(typeof this.props.onChange === "function") this.props.onChange(e);
}
render() {

View File

@ -1,21 +1,23 @@
import React from "react";
import React, { memo } from "react";
import styled, { css } from "styled-components";
import PropTypes from "prop-types";
import { Text } from "../text";
const SimpleLink = ({
rel,
isBold,
fontSize,
isTextOverflow,
isHovered,
isSemitransparent,
type,
color,
title,
containerWidth,
...props
}) => <a {...props} />;
// eslint-disable-next-line no-unused-vars
const SimpleLink = ({ rel, isBold, fontSize, isTextOverflow, isHovered, isSemitransparent, type, color, title, containerWidth, ...props }) => <a {...props} />;
SimpleLink.propTypes = {
color: PropTypes.string,
fontSize: PropTypes.number,
isBold: PropTypes.bool,
isHovered: PropTypes.bool,
isSemitransparent: PropTypes.bool,
isTextOverflow: PropTypes.bool,
rel: PropTypes.string,
title: PropTypes.string,
type: PropTypes.oneOf(["action", "page"]),
containerWidth: PropTypes.string
};
const colorCss = css`
@ -45,7 +47,8 @@ const StyledLink = styled(SimpleLink)`
${props => props.isHovered && hoveredCss}
`;
const Link = props => {
// eslint-disable-next-line react/display-name
const Link = memo(props => {
const {
isBold,
title,
@ -70,7 +73,7 @@ const Link = props => {
</Text.Body>
</StyledLink>
);
};
});
Link.propTypes = {
color: PropTypes.string,
@ -86,6 +89,7 @@ Link.propTypes = {
target: PropTypes.oneOf(["_blank", "_self", "_parent", "_top"]),
title: PropTypes.string,
type: PropTypes.oneOf(["action", "page"]),
children: PropTypes.string
};
Link.defaultProps = {

View File

@ -1,8 +1,9 @@
import React from 'react';
/* eslint-disable react/display-name */
import React, { memo } from 'react';
import styled from 'styled-components';
import PropTypes from 'prop-types';
import CustomScrollbarsVirtualList from '../scrollbar/custom-scrollbars-virtual-list';
import { FixedSizeList as List } from 'react-window';
import { FixedSizeList as List, areEqual } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import ContextMenu from '../context-menu';
@ -35,6 +36,16 @@ class RowContainer extends React.PureComponent {
window.removeEventListener('contextmenu', this.onRowContextClick);
}
renderRow = memo(({ data, index, style }) => {
const options = data[index].props.contextOptions;
return (
<div onContextMenu={this.onRowContextClick.bind(this, options)} style={style}>
{data[index]}
</div>
)
}, areEqual);
render() {
const { manualHeight, itemHeight, children } = this.props;
@ -48,22 +59,10 @@ class RowContainer extends React.PureComponent {
itemData={children}
outerElementType={CustomScrollbarsVirtualList}
>
{RenderRow}
{this.renderRow}
</List>
);
const RenderRow = ({ data, index, style }) => {
const options = data[index].props.contextOptions;
return (
<div onContextMenu={this.onRowContextClick.bind(this, options)} style={style}>
{data[index]}
</div>
)
};
return (
<StyledRowContainer id='rowContainer' manualHeight={manualHeight}>
<AutoSizer>

View File

@ -1,4 +1,4 @@
import React from 'react'
import React, { memo } from 'react'
import styled from 'styled-components'
import PropTypes from 'prop-types'
@ -40,6 +40,7 @@ const StyledElement = styled.div`
flex: 0 0 auto;
display: flex;
margin-right: 8px;
margin-left: 2px;
user-select: none;
`;
@ -50,54 +51,39 @@ const StyledOptionButton = styled.div`
margin-right: 16px;
`;
class Row extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
checked: this.props.checked
}
}
changeCheckbox = (e) => {
this.props.onSelect && this.props.onSelect(e.target.checked, this.props.data);
// eslint-disable-next-line react/display-name
const Row = props => {
const changeCheckbox = (e) => {
props.onSelect && props.onSelect(e.target.checked, props.data);
};
getOptions = () => this.props.contextOptions;
const getOptions = () => props.contextOptions;
//console.log("Row render");
const { checked, element, children, contextOptions } = props;
componentDidUpdate(prevProps) {
if (this.props.checked !== prevProps.checked) {
this.setState({ checked: this.props.checked });
}
}
render() {
//console.log("Row render");
const { checked, element, children, contextOptions } = this.props;
return (
<StyledRow ref={this.rowRef} {...this.props}>
{Object.prototype.hasOwnProperty.call(this.props, 'checked') &&
<StyledCheckbox>
<Checkbox isChecked={checked} onChange={this.changeCheckbox} />
</StyledCheckbox>
return (
<StyledRow {...props}>
{Object.prototype.hasOwnProperty.call(props, 'checked') &&
<StyledCheckbox>
<Checkbox isChecked={checked} onChange={changeCheckbox} />
</StyledCheckbox>
}
{Object.prototype.hasOwnProperty.call(props, 'element') &&
<StyledElement>
{element}
</StyledElement>
}
<StyledContent>
{children}
</StyledContent>
<StyledOptionButton>
{Object.prototype.hasOwnProperty.call(props, 'contextOptions') && contextOptions.length > 0 &&
<ContextMenuButton directionX='right' getData={getOptions} />
}
{Object.prototype.hasOwnProperty.call(this.props, 'element') &&
<StyledElement>
{element}
</StyledElement>
}
<StyledContent>
{children}
</StyledContent>
<StyledOptionButton>
{Object.prototype.hasOwnProperty.call(this.props, 'contextOptions') && contextOptions.length > 0 &&
<ContextMenuButton directionX='right' getData={this.getOptions} />
}
</StyledOptionButton>
</StyledRow>
);
}
}
</StyledOptionButton>
</StyledRow>
);
};
Row.propTypes = {
checked: PropTypes.bool,

View File

@ -462,6 +462,10 @@ namespace ASC.Web.Core.Users
{
return SaveOrUpdatePhoto(tenant, userID, data, -1, OriginalFotoSize, true, out _);
}
public static string SaveOrUpdateCroppedPhoto(Tenant tenant, Guid userID, byte[] data, byte[] defaultData)
{
return SaveOrUpdateCroppedPhoto(tenant, userID, data, defaultData, -1, OriginalFotoSize, true, out _);
}
public static void RemovePhoto(Tenant tenant, Guid idUser)
{
@ -501,6 +505,49 @@ namespace ASC.Web.Core.Users
}
return photoUrl;
}
private static string SaveOrUpdateCroppedPhoto(Tenant tenant, Guid userID, byte[] data, byte[] defaultData, long maxFileSize, Size size, bool saveInCoreContext, out string fileName)
{
data = TryParseImage(data, maxFileSize, size, out var imgFormat, out var width, out var height);
var widening = CommonPhotoManager.GetImgFormatName(imgFormat);
fileName = string.Format("{0}_orig_{1}-{2}.{3}", userID, width, height, widening);
if (saveInCoreContext)
{
CoreContext.UserManager.SaveUserPhoto(tenant, userID, defaultData);
var max = Math.Max(Math.Max(width, height), SmallFotoSize.Width);
var min = Math.Max(Math.Min(width, height), SmallFotoSize.Width);
var pos = (max - min) / 2;
var settings = new UserPhotoThumbnailSettings(
width >= height ? new Point(pos, 0) : new Point(0, pos),
new Size(min, min));
settings.SaveForUser(userID);
ClearCache(userID);
}
var store = GetDataStore(tenant.TenantId);
var photoUrl = GetDefaultPhotoAbsoluteWebPath();
if (data != null && data.Length > 0)
{
using (var stream = new MemoryStream(data))
{
photoUrl = store.Save(fileName, stream).ToString();
}
//Queue resizing
SizePhoto(tenant.TenantId, userID, data, -1, SmallFotoSize, true);
SizePhoto(tenant.TenantId, userID, data, -1, MediumFotoSize, true);
SizePhoto(tenant.TenantId, userID, data, -1, BigFotoSize, true);
SizePhoto(tenant.TenantId, userID, data, -1, MaxFotoSize, true);
SizePhoto(tenant.TenantId, userID, data, -1, RetinaFotoSize, true);
}
return photoUrl;
}
private static void SetUserPhotoThumbnailSettings(Guid userId, int width, int height)
{