React-based /account endpoint for editing a user profile

This commit is contained in:
Tomas Bures 2017-06-30 16:11:02 +02:00
parent 09fe27fe2b
commit fbb8f5799e
14 changed files with 386 additions and 51 deletions

View file

@ -0,0 +1,154 @@
'use strict';
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { withPageHelpers, Title } from '../lib/page'
import {
withForm, Form, Fieldset, FormSendMethod, InputField, ButtonRow, Button
} from '../lib/form';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import passwordValidator from '../../../shared/password-validator';
@translate()
@withForm
@withPageHelpers
@withErrorHandling
export default class Account extends Component {
constructor(props) {
super(props);
this.passwordValidator = passwordValidator(props.t);
this.state = {};
this.initForm({
serverValidation: {
url: '/account/rest/account-validate',
changed: ['email', 'password', 'currentPassword']
}
});
}
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(`/account/rest/account`, data => {
data.password = '';
data.password2 = '';
data.currentPassword = '';
});
}
componentDidMount() {
this.loadFormValues();
}
localValidateFormValues(state) {
const t = this.props.t;
const email = state.getIn(['email', 'value']);
const emailServerValidation = state.getIn(['email', 'serverValidation']);
if (!email) {
state.setIn(['email', 'error'], t('Email must not be empty.'));
} else if (!emailServerValidation || emailServerValidation.invalid) {
state.setIn(['email', 'error'], t('Invalid email address.'));
} else if (!emailServerValidation || emailServerValidation.exists) {
state.setIn(['email', 'error'], t('The email is already associated with another user in the system.'));
} else {
state.setIn(['email', 'error'], null);
}
const name = state.getIn(['name', 'value']);
if (!name) {
state.setIn(['name', 'error'], t('Full name must not be empty'));
} else {
state.setIn(['name', 'error'], null);
}
const password = state.getIn(['password', 'value']) || '';
const password2 = state.getIn(['password2', 'value']) || '';
const currentPassword = state.getIn(['currentPassword', 'value']) || '';
let passwordMsgs = [];
if (password || currentPassword) {
const passwordResults = this.passwordValidator.test(password);
passwordMsgs.push(...passwordResults.errors);
const currentPasswordServerValidation = state.getIn(['currentPassword', 'serverValidation']);
if (!currentPassword) {
state.setIn(['currentPassword', 'error'], t('Current password must not be empty.'));
} else if (!currentPasswordServerValidation || currentPasswordServerValidation.incorrect) {
state.setIn(['currentPassword', 'error'], t('Incorrect password.'));
} else {
state.setIn(['currentPassword', 'error'], null);
}
}
if (passwordMsgs.length > 1) {
passwordMsgs = passwordMsgs.map((msg, idx) => <div key={idx}>{msg}</div>)
}
state.setIn(['password', 'error'], passwordMsgs.length > 0 ? passwordMsgs : null);
state.setIn(['password2', 'error'], password !== password2 ? t('Passwords must match') : null);
}
async submitHandler() {
const t = this.props.t;
this.disableForm();
this.setFormStatusMessage('info', t('Updating user profile ...'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.POST, '/account/rest/account', data => {
delete data.password2;
});
this.enableForm();
if (submitSuccessful) {
this.setFlashMessage('success', t('User profile updated'));
this.hideFormValidation();
this.updateFormValue('password', '');
this.updateFormValue('password2', '');
this.updateFormValue('currentPassword', '');
this.clearFormStatusMessage();
} else {
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
}
}
render() {
const t = this.props.t;
return (
<div>
<Title>{t('Account')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<Fieldset label={t('General Settings')}>
<InputField id="name" label={t('Full Name')}/>
<InputField id="email" label={t('Email')} help={t('This address is used for account recovery in case you loose your password')}/>
</Fieldset>
<Fieldset label={t('Password Change')}>
<p>{t('You only need to fill out this form if you want to change your current password')}</p>
<InputField id="currentPassword" label={t('Current Password')} type="password" />
<InputField id="password" label={t('New Password')} type="password" />
<InputField id="password2" label={t('Confirm Password')} type="password" />
</Fieldset>
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Update')}/>
</ButtonRow>
</Form>
</div>
);
}
}

View file

@ -0,0 +1,32 @@
'use strict';
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n';
import { Section } from '../lib/page'
import Account from './Account'
const getStructure = t => ({
'': {
title: t('Home'),
externalLink: '/',
children: {
'account': {
title: t('Account'),
link: '/account',
component: Account
}
}
}
});
export default function() {
ReactDOM.render(
<I18nextProvider i18n={ i18n }><Section root='/account' structure={getStructure}/></I18nextProvider>,
document.getElementById('root')
);
};

View file

@ -94,7 +94,26 @@ class Form extends Component {
}
}
function wrapInput(id, htmlId, owner, label, input) {
class Fieldset extends Component {
static propTypes = {
label: PropTypes.string
}
render() {
const props = this.props;
return (
<fieldset>
{props.label ? <legend>{props.label}</legend> : null}
{props.children}
</fieldset>
);
}
}
function wrapInput(id, htmlId, owner, label, help, input) {
const helpBlock = help ? <div className="help-block col-sm-offset-2 col-sm-10" id={htmlId + '_help'}>{help}</div> : '';
return (
<div className={owner.addFormValidationClass('form-group', id)} >
<div className="col-sm-2">
@ -103,7 +122,8 @@ function wrapInput(id, htmlId, owner, label, input) {
<div className="col-sm-10">
{input}
</div>
<div className="help-block col-sm-offset-2 col-sm-10" id={htmlId + '_help'}>{owner.getFormValidationMessage(id)}</div>
{helpBlock}
<div className="help-block col-sm-offset-2 col-sm-10" id={htmlId + '_help_validation'}>{owner.getFormValidationMessage(id)}</div>
</div>
);
}
@ -113,7 +133,8 @@ class InputField extends Component {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
placeholder: PropTypes.string,
type: PropTypes.string
type: PropTypes.string,
help: PropTypes.string
}
static defaultProps = {
@ -135,7 +156,7 @@ class InputField extends Component {
type = 'password';
}
return wrapInput(id, htmlId, owner, props.label,
return wrapInput(id, htmlId, owner, props.label, props.help,
<input type={type} value={owner.getFormValue(id)} placeholder={props.placeholder} id={htmlId} className="form-control" aria-describedby={htmlId + '_help'} onChange={evt => owner.updateFormValue(id, evt.target.value)}/>
);
}
@ -145,7 +166,8 @@ class TextArea extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
placeholder: PropTypes.string
placeholder: PropTypes.string,
help: PropTypes.string
}
static contextTypes = {
@ -158,7 +180,7 @@ class TextArea extends Component {
const id = this.props.id;
const htmlId = 'form_' + id;
return wrapInput(id, htmlId, owner, props.label,
return wrapInput(id, htmlId, owner, props.label, props.help,
<textarea id={htmlId} value={owner.getFormValue(id)} className="form-control" aria-describedby={htmlId + '_help'} onChange={evt => owner.updateFormValue(id, evt.target.value)}></textarea>
);
}
@ -238,7 +260,8 @@ class TreeTableSelect extends Component {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
dataUrl: PropTypes.string,
data: PropTypes.array
data: PropTypes.array,
help: PropTypes.string
}
static contextTypes = {
@ -256,16 +279,8 @@ class TreeTableSelect extends Component {
const id = this.props.id;
const htmlId = 'form_' + id;
return (
<div className={owner.addFormValidationClass('form-group', id)} >
<div className="col-sm-2">
<label htmlFor={htmlId} className="control-label">{props.label}</label>
</div>
<div className="col-sm-10">
<TreeTable data={this.props.data} dataUrl={this.props.dataUrl} selectMode={TreeSelectMode.SINGLE} selection={owner.getFormValue(id)} onSelectionChangedAsync={::this.onSelectionChangedAsync}/>
</div>
<div className="help-block col-sm-offset-2 col-sm-10" id={htmlId + '_help'}>{owner.getFormValidationMessage(id)}</div>
</div>
return wrapInput(id, htmlId, owner, props.label, props.help,
<TreeTable data={this.props.data} dataUrl={this.props.dataUrl} selectMode={TreeSelectMode.SINGLE} selection={owner.getFormValue(id)} onSelectionChangedAsync={::this.onSelectionChangedAsync}/>
);
}
}
@ -546,6 +561,14 @@ function withForm(target) {
}));
};
inst.clearFormStatusMessage = function() {
this.setState(previousState => ({
formState: previousState.formState.withMutations(map => {
map.set('statusMessageText', '');
})
}));
};
inst.enableForm = function() {
this.setState(previousState => ({formState: previousState.formState.set('isDisabled', false)}));
};
@ -565,6 +588,7 @@ function withForm(target) {
export {
withForm,
Form,
Fieldset,
InputField,
TextArea,
ButtonRow,

View file

@ -57,7 +57,7 @@ export default class CUD extends Component {
@withAsyncErrorHandler
async loadTreeData() {
axios.get('/namespaces/rest/namespacesTree')
axios.get('/namespaces/rest/namespaces-tree')
.then(response => {
response.data.expanded = true;

View file

@ -25,7 +25,7 @@ export default class List extends Component {
<Title>{t('Namespaces')}</Title>
<TreeTable withHeader dataUrl="/namespaces/rest/namespacesTree" actionLinks={actionLinks} />
<TreeTable withHeader dataUrl="/namespaces/rest/namespaces-tree" actionLinks={actionLinks} />
</div>
);
}

View file

@ -3,7 +3,7 @@
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { withPageHelpers, Title } from '../lib/page'
import { withForm, Form, FormSendMethod, InputField, TextArea, ButtonRow, Button, TreeTableSelect } from '../lib/form';
import { withForm, Form, FormSendMethod, InputField, ButtonRow, Button } from '../lib/form';
import axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import interoperableErrors from '../../../shared/interoperable-errors';
@ -29,12 +29,11 @@ export default class CUD extends Component {
this.initForm({
serverValidation: {
url: '/users/rest/validate',
url: '/users/rest/users-validate',
changed: ['username', 'email'],
extra: ['id']
}
});
this.hasChildren = false;
}
isDelete() {
@ -89,6 +88,8 @@ export default class CUD extends Component {
state.setIn(['email', 'error'], t('Email must not be empty'));
} else if (!emailServerValidation || emailServerValidation.invalid) {
state.setIn(['email', 'error'], t('Invalid email address.'));
} else if (!emailServerValidation || emailServerValidation.exists) {
state.setIn(['email', 'error'], t('The email is already associated with another user in the system.'));
} else {
state.setIn(['email', 'error'], null);
}
@ -106,8 +107,6 @@ export default class CUD extends Component {
const password = state.getIn(['password', 'value']) || '';
const password2 = state.getIn(['password2', 'value']) || '';
const passwordResults = this.passwordValidator.test(password);
let passwordMsgs = [];
if (!edit && !password) {
@ -115,6 +114,7 @@ export default class CUD extends Component {
}
if (password) {
const passwordResults = this.passwordValidator.test(password);
passwordMsgs.push(...passwordResults.errors);
}
@ -154,7 +154,7 @@ export default class CUD extends Component {
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
}
} catch (error) {
if (error instanceof interoperableErrors.LoopDetectedError) {
if (error instanceof interoperableErrors.DuplicitNameError) {
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
@ -164,6 +164,16 @@ export default class CUD extends Component {
return;
}
if (error instanceof interoperableErrors.DuplicitEmailError) {
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('The email is already assigned to another user.')}
</span>
);
return;
}
throw error;
}
}

View file

@ -3,7 +3,7 @@
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { Title, Toolbar, NavButton } from '../lib/page';
import { Table, TableSelectMode } from '../lib/table';
import { Table } from '../lib/table';
@translate()
export default class List extends Component {
@ -31,7 +31,7 @@ export default class List extends Component {
<Title>{t('Users')}</Title>
<Table withHeader dataUrl="/users/rest/usersTable" columns={columns} actionLinks={actionLinks} />
<Table withHeader dataUrl="/users/rest/users-table" columns={columns} actionLinks={actionLinks} />
</div>
);
}

View file

@ -4,7 +4,8 @@ const path = require('path');
module.exports = {
entry: {
namespaces: ['babel-polyfill', './src/namespaces/root.js'],
users: ['babel-polyfill', './src/users/root.js']
users: ['babel-polyfill', './src/users/root.js'],
account: ['babel-polyfill', './src/account/root.js']
},
output: {
library: 'MailtrainReactBody',