Added support for Datatables

Added support for ajax-based server side validation (useful for validation of emails, duplicate usernames, etc.)
User form more or less ready in the basic version (i.e. without permission management)
This commit is contained in:
Tomas Bures 2017-06-21 02:14:14 +02:00
parent f776170854
commit c81f5544e6
26 changed files with 1097 additions and 167 deletions

View file

@ -5,7 +5,7 @@
"main": "index.js",
"scripts": {
"build": "webpack",
"watch": "webpack --watch --watch-poll",
"watch": "webpack --watch",
"locales": "for moFile in ../languages/*.mo; do lng=`basename $moFile .mo`; i18next-conv -s $moFile -t locales/$lng/common.json -l $lng; done"
},
"repository": {
@ -17,9 +17,14 @@
"homepage": "https://mailtrain.org/",
"dependencies": {
"axios": "^0.16.1",
"datatables.net": "^1.10.15",
"datatables.net-bs": "^1.10.15",
"datatables.net-select": "^1.2.2",
"datatables.net-select-bs": "^1.2.2",
"i18next": "^8.3.0",
"i18next-xhr-backend": "^1.4.1",
"immutable": "^3.8.1",
"owasp-password-strength-test": "github:bures/owasp-password-strength-test",
"prop-types": "^15.5.10",
"react": "^15.5.4",
"react-dom": "^15.5.4",

View file

@ -268,12 +268,16 @@ function withForm(target) {
isValidationShown: false,
isDisabled: false,
statusMessageText: '',
data: Immutable.Map()
data: Immutable.Map(),
isServerValidationRunning: false
});
inst.initFormState = function() {
inst.initFormState = function(serverValidationUrl, serverValidationAttrs) {
const state = this.state || {};
state.formState = cleanFormState;
if (serverValidationUrl) {
state.formStateServerValidation = { url: serverValidationUrl, attrs: serverValidationAttrs };
}
this.state = state;
};
@ -294,7 +298,7 @@ function withForm(target) {
});
}, 500);
const response = await axios.get(url)
const response = await axios.get(url);
const data = response.data;
@ -309,6 +313,8 @@ function withForm(target) {
};
inst.validateAndSendFormValuesToURL = async function(method, url, mutator) {
await this.waitForFormServerValidated();
if (this.isFormWithoutErrors()) {
const data = this.getFormValues();
@ -331,28 +337,113 @@ function withForm(target) {
inst.populateFormValues = function(data) {
this.setState(previousState => ({
formState: previousState.formState.withMutations(state => {
state.set('state', FormState.Ready);
formState: previousState.formState.withMutations(mutState => {
mutState.set('state', FormState.Ready);
state.update('data', stateData => stateData.withMutations(mutableStateData => {
mutState.update('data', stateData => stateData.withMutations(mutStateData => {
for (const key in data) {
mutableStateData.set(key, Immutable.Map({
mutStateData.set(key, Immutable.Map({
value: data[key]
}));
}
this.validateFormValues(mutableStateData);
}));
this.validateForm(mutState);
})
}));
};
// formValidateResolve is called by "validateForm" once client receives validation response from server that does not
// trigger another server validation
let formValidateResolve = null;
const scheduleValidateForm = (self) => {
setTimeout(() => {
self.setState(previousState => ({
formState: previousState.formState.withMutations(mutState => {
self.validateForm(mutState);
})
}));
}, 0);
};
inst.waitForFormServerValidated = async function() {
if (!this.isFormServerValidated()) {
await new Promise(resolve => { formValidateResolve = resolve; });
}
};
inst.validateForm = function(mutState) {
const serverValidation = this.state.formStateServerValidation;
if (!mutState.get('isServerValidationRunning') && serverValidation) {
const payload = {};
let payloadNotEmpty = false;
for (const attr of serverValidation.attrs) {
const currValue = mutState.getIn(['data', attr, 'value']);
const serverValue = mutState.getIn(['data', attr, 'serverValue']);
// This really assumes that all form values are preinitialized (i.e. not undef)
if (currValue !== serverValue) {
mutState.setIn(['data', attr, 'serverValidated'], false);
payload[attr] = currValue;
payloadNotEmpty = true;
}
}
if (payloadNotEmpty) {
mutState.set('isServerValidationRunning', true);
axios.post(serverValidation.url, payload)
.then(response => {
this.setState(previousState => ({
formState: previousState.formState.withMutations(mutState => {
mutState.set('isServerValidationRunning', false);
mutState.update('data', stateData => stateData.withMutations(mutStateData => {
for (const attr in payload) {
mutStateData.setIn([attr, 'serverValue'], payload[attr]);
if (payload[attr] === mutState.getIn(['data', attr, 'value'])) {
mutStateData.setIn([attr, 'serverValidated'], true);
mutStateData.setIn([attr, 'serverValidation'], response.data[attr] || true);
}
}
}));
})
}));
scheduleValidateForm(this);
})
.catch(error => {
console.log('Ignoring unhandled error in "validateForm": ' + error);
scheduleValidateForm(this);
});
} else {
if (formValidateResolve) {
const resolve = formValidateResolve;
formValidateResolve = null;
resolve();
}
}
}
if (this.localValidateFormValues) {
mutState.update('data', stateData => stateData.withMutations(mutStateData => {
this.localValidateFormValues(mutStateData);
}));
}
};
inst.updateFormValue = function(key, value) {
this.setState(previousState => ({
formState: previousState.formState.update('data', stateData => stateData.withMutations(mutableStateData => {
mutableStateData.setIn([key, 'value'], value);
this.validateFormValues(mutableStateData);
}))
formState: previousState.formState.withMutations(mutState => {
mutState.setIn(['data', key, 'value'], value);
this.validateForm(mutState);
})
}));
};
@ -417,6 +508,10 @@ function withForm(target) {
return !this.state.formState.get('data').find(attr => attr.get('error'));
};
inst.isFormServerValidated = function() {
return this.state.formStateServerValidation.attrs.every(attr => this.state.formState.getIn(['data', attr, 'serverValidated']));
};
inst.getFormStatusMessageText = function() {
return this.state.formState.get('statusMessageText');
};

0
client/src/lib/table.css Normal file
View file

171
client/src/lib/table.js Normal file
View file

@ -0,0 +1,171 @@
'use strict';
import React, { Component } from 'react';
import ReactDOMServer from 'react-dom/server';
import { translate } from 'react-i18next';
import PropTypes from 'prop-types';
import jQuery from 'jquery';
import '../../public/jquery/jquery-ui-1.12.1.min.js';
import 'datatables.net';
import 'datatables.net-bs';
import 'datatables.net-bs/css/dataTables.bootstrap.css';
import 'datatables.net-select';
import 'datatables.net-select-bs/css/select.bootstrap.css';
import './table.css';
import axios from './axios';
import { withPageHelpers } from '../lib/page'
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
//dtFactory();
//dtSelectFactory();
const TableSelectMode = {
NONE: 0,
SINGLE: 1,
MULTI: 2
};
@translate()
@withPageHelpers
@withErrorHandling
class Table extends Component {
constructor(props) {
super(props);
// Select Mode simply cannot be changed later. This is just to make sure we avoid inconsistencies if someone changes it anyway.
this.selectMode = this.props.selectMode;
}
static defaultProps = {
selectMode: TableSelectMode.NONE
}
static propTypes = {
dataUrl: PropTypes.string,
data: PropTypes.array,
columns: PropTypes.array,
selectMode: PropTypes.number,
selection: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]),
onSelectionChangedAsync: PropTypes.func,
actionLinks: PropTypes.array,
withHeader: PropTypes.bool
}
shouldComponentUpdate(nextProps, nextState) {
return this.props.selection !== nextProps.selection || this.props.data != nextProps.data || this.props.dataUrl != nextProps.dataUrl;
}
componentDidMount() {
const columns = this.props.columns.slice();
if (this.props.actionLinks) {
const actionLinks = this.props.actionLinks;
const createdCellFn = (td, data) => {
const linksContainer = jQuery('<span class="mt-action-links"/>');
for (const {label, link} of actionLinks) {
const dest = link(data);
const lnkHtml = ReactDOMServer.renderToStaticMarkup(<a href={dest}>{label}</a>);
const lnk = jQuery(lnkHtml);
lnk.click((evt) => { evt.preventDefault(); this.navigateTo(dest) });
linksContainer.append(lnk);
}
jQuery(td).html(linksContainer);
};
columns.push({
data: null,
orderable: false,
searchable: false,
type: 'html',
createdCell: createdCellFn
});
}
const dtOptions = {
columns
};
if (this.selectMode === TableSelectMode.MULTI) {
dtOptions.select = {
style: 'os'
}
} else if (this.selectMode === TableSelectMode.SINGLE) {
dtOptions.select = {
style: 'single'
}
}
if (this.props.data) {
dtOptions.data = this.props.data;
} else {
dtOptions.serverSide = true;
dtOptions.ajax = {
url: this.props.dataUrl,
type: 'POST'
};
}
this.table = jQuery(this.domTable).DataTable(dtOptions);
this.table.on('select.dt', ::this.onSelect);
this.table.on('deselect.dt', ::this.onSelect);
this.updateSelection();
}
componentDidUpdate() {
if (this.props.data) {
this.table.clear();
this.table.rows.add(this.props.data);
}
this.updateSelection();
}
updateSelection() {
/*
const tree = this.tree;
if (this.selectMode === TableSelectMode.MULTI) {
const selectSet = new Set(this.props.selection);
tree.enableUpdate(false);
tree.visit(node => node.setSelected(selectSet.has(node.key)));
tree.enableUpdate(true);
} else if (this.selectMode === TableSelectMode.SINGLE) {
this.tree.activateKey(this.props.selection);
}
*/
}
async onSelect(event, data) {
const sel = this.table.rows( { selected: true } ).data();
if (this.props.onSelectionChangedAsync) {
await this.props.onSelectionChangedAsync(sel);
}
}
render() {
const t = this.props.t;
const props = this.props;
return (
<table ref={(domElem) => { this.domTable = domElem; }} className="table table-striped table-bordered" cellSpacing="0" width="100%" />
);
}
}
export {
Table,
TableSelectMode
}

View file

@ -7,7 +7,7 @@ import PropTypes from 'prop-types';
import jQuery from 'jquery';
import '../../public/jquery/jquery-ui-1.12.1.min.js';
import '../../public/fancytree/jquery.fancytree-all.js';
import '../../public/fancytree/jquery.fancytree-all.min.js';
import '../../public/fancytree/skin-bootstrap/ui.fancytree.min.css';
import './tree.css';
import axios from './axios';
@ -108,12 +108,13 @@ class TreeTable extends Component {
const tdList = jQuery(node.tr).find(">td");
const linksContainer = jQuery('<span class="mt-action-links"/>');
const links = actionLinks.map(({label, link}) => {
const lnkHtml = ReactDOMServer.renderToStaticMarkup(<a href="">{label}</a>);
for (const {label, link} of actionLinks) {
const dest = link(node.key);
const lnkHtml = ReactDOMServer.renderToStaticMarkup(<a href={dest}>{label}</a>);
const lnk = jQuery(lnkHtml);
lnk.click((evt) => { evt.preventDefault(); this.navigateTo(link(node.key)) });
lnk.click((evt) => { evt.preventDefault(); this.navigateTo(dest) });
linksContainer.append(lnk);
});
}
tdList.eq(1).html(linksContainer);
};

View file

@ -7,7 +7,7 @@ import { withForm, Form, FormSendMethod, InputField, TextArea, ButtonRow, Button
import axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import interoperableErrors from '../../../shared/interoperable-errors';
import { ModalDialog } from "../lib/bootstrap-components";
import { ModalDialog } from '../lib/bootstrap-components';
@translate()
@withForm
@ -20,7 +20,7 @@ export default class CUD extends Component {
this.state = {};
if (props.edit) {
this.state.nsId = parseInt(props.match.params.nsId);
this.state.entityId = parseInt(props.match.params.id);
}
this.initFormState();
@ -29,7 +29,7 @@ export default class CUD extends Component {
}
isEditGlobal() {
return this.state.nsId === 1;
return this.state.entityId === 1;
}
isDelete() {
@ -40,7 +40,7 @@ export default class CUD extends Component {
for (let idx = 0; idx < data.length; idx++) {
const entry = data[idx];
if (entry.key === this.state.nsId) {
if (entry.key === this.state.entityId) {
if (entry.children.length > 0) {
this.hasChildren = true;
}
@ -57,7 +57,7 @@ export default class CUD extends Component {
@withAsyncErrorHandler
async loadTreeData() {
axios.get("/namespaces/rest/namespacesTree")
axios.get('/namespaces/rest/namespacesTree')
.then(response => {
response.data.expanded = true;
@ -75,7 +75,7 @@ export default class CUD extends Component {
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(`/namespaces/rest/namespaces/${this.state.nsId}`, data => {
await this.getFormValuesFromURL(`/namespaces/rest/namespaces/${this.state.entityId}`, data => {
if (data.parent) data.parent = data.parent.toString();
});
}
@ -96,7 +96,7 @@ export default class CUD extends Component {
}
}
validateFormValues(state) {
localValidateFormValues(state) {
const t = this.props.t;
if (!state.getIn(['name', 'value']).trim()) {
@ -121,7 +121,7 @@ export default class CUD extends Component {
let sendMethod, url;
if (edit) {
sendMethod = FormSendMethod.PUT;
url = `/namespaces/rest/namespaces/${this.state.nsId}`
url = `/namespaces/rest/namespaces/${this.state.entityId}`
} else {
sendMethod = FormSendMethod.POST;
url = '/namespaces/rest/namespaces'
@ -159,11 +159,11 @@ export default class CUD extends Component {
}
async showDeleteModal() {
this.navigateTo(`/namespaces/edit/${this.state.nsId}/delete`);
this.navigateTo(`/namespaces/edit/${this.state.entityId}/delete`);
}
async hideDeleteModal() {
this.navigateTo(`/namespaces/edit/${this.state.nsId}`);
this.navigateTo(`/namespaces/edit/${this.state.entityId}`);
}
async performDelete() {
@ -175,7 +175,7 @@ export default class CUD extends Component {
this.disableForm();
this.setFormStatusMessage('info', t('Deleting namespace...'));
await axios.delete(`/namespaces/rest/namespaces/${this.state.nsId}`);
await axios.delete(`/namespaces/rest/namespaces/${this.state.entityId}`);
this.navigateToWithFlashMessage('/namespaces', 'success', t('Namespace deleted'));

View file

@ -21,7 +21,7 @@ const getStructure = t => ({
children: {
'edit' : {
title: t('Edit Namespace'),
params: [':nsId', ':action?'],
params: [':id', ':action?'],
render: props => (<CUD edit {...props} />)
},
'create' : {

212
client/src/users/CUD.js Normal file
View file

@ -0,0 +1,212 @@
'use strict';
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 axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import interoperableErrors from '../../../shared/interoperable-errors';
import passwordValidator from '../../../shared/password-validator';
import { ModalDialog } from '../lib/bootstrap-components';
@translate()
@withForm
@withPageHelpers
@withErrorHandling
export default class CUD extends Component {
constructor(props) {
super(props);
this.passwordValidator = passwordValidator(props.t);
this.state = {};
if (props.edit) {
this.state.entityId = parseInt(props.match.params.id);
}
this.initFormState('/users/rest/validate', ['username', 'email']);
this.hasChildren = false;
}
isDelete() {
return this.props.match.params.action === 'delete';
}
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(`/users/rest/users/${this.state.entityId}`);
}
componentDidMount() {
if (this.props.edit) {
this.loadFormValues();
} else {
this.populateFormValues({
username: '',
name: '',
email: ''
});
}
}
localValidateFormValues(state) {
const t = this.props.t;
const edit = this.props.edit;
const username = state.getIn(['username', 'value']);
const usernamePattern = /^[a-zA-Z0-9][a-zA-Z0-9_\-.]*$/;
const usernameServerValidation = state.getIn(['username', 'serverValidation']);
if (!username) {
state.setIn(['username', 'error'], t('User name must not be empty'));
} else if (!usernamePattern.test(username)) {
state.setIn(['username', 'error'], t('User name may contain only the following characters: A-Z, a-z, 0-9, "_", "-", "." and may start only with A-Z, a-z, 0-9.'));
} else if (!usernameServerValidation || usernameServerValidation.exists) {
state.setIn(['username', 'error'], t('The user name already exists in the system.'));
} else {
state.setIn(['username', 'error'], null);
}
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 {
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 passwordResults = this.passwordValidator.test(password);
let passwordMsgs = [];
if (!edit && !password) {
passwordMsgs.push(t('Password must not be empty'));
}
if (password) {
passwordMsgs.push(...passwordResults.errors);
}
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;
const edit = this.props.edit;
let sendMethod, url;
if (edit) {
sendMethod = FormSendMethod.PUT;
url = `/users/rest/users/${this.state.entityId}`
} else {
sendMethod = FormSendMethod.POST;
url = '/users/rest/users'
}
try {
this.disableForm();
this.setFormStatusMessage('info', t('Saving user ...'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url);
if (submitSuccessful) {
this.navigateToWithFlashMessage('/users', 'success', t('User saved'));
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
}
} catch (error) {
if (error instanceof interoperableErrors.LoopDetectedError) {
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('The username is already assigned to another user.')}
</span>
);
return;
}
throw error;
}
}
async showDeleteModal() {
this.navigateTo(`/users/edit/${this.state.entityId}/delete`);
}
async hideDeleteModal() {
this.navigateTo(`/users/edit/${this.state.entityId}`);
}
async performDelete() {
const t = this.props.t;
await this.hideDeleteModal();
this.disableForm();
this.setFormStatusMessage('info', t('Deleting user...'));
await axios.delete(`/users/rest/users/${this.state.entityId}`);
this.navigateToWithFlashMessage('/users', 'success', t('User deleted'));
}
render() {
const t = this.props.t;
const edit = this.props.edit;
return (
<div>
{edit &&
<ModalDialog hidden={!this.isDelete()} title={t('Confirm deletion')} onCloseAsync={::this.hideDeleteModal} buttons={[
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteModal },
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performDelete }
]}>
{t('Are you sure you want to delete user "{{username}}"?', {username: this.getFormValue('username')})}
</ModalDialog>
}
<Title>{edit ? t('Edit User') : t('Create User')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="username" label={t('User Name')}/>
<InputField id="name" label={t('Full Name')}/>
<InputField id="email" label={t('Email')}/>
<InputField id="password" label={t('Password')}/>
<InputField id="password2" label={t('Repeat Password')}/>
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{edit && <Button className="btn-danger" icon="remove" label={t('Delete User')}
onClickAsync={::this.showDeleteModal}/>}
</ButtonRow>
</Form>
</div>
);
}
}

38
client/src/users/List.js Normal file
View file

@ -0,0 +1,38 @@
'use strict';
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { Title, Toolbar, NavButton } from '../lib/page';
import { Table, TableSelectMode } from '../lib/table';
@translate()
export default class List extends Component {
render() {
const t = this.props.t;
const actionLinks = [
{
label: 'Edit',
link: data => '/users/edit/' + data[0]
}
];
const columns = [
{ data: 0, title: "#" },
{ data: 1, title: "Username" },
{ data: 2, title: "Full Name" }
];
return (
<div>
<Toolbar>
<NavButton linkTo="/users/create" className="btn-primary" icon="plus" label={t('Create User')}/>
</Toolbar>
<Title>{t('Users')}</Title>
<Table withHeader dataUrl="/users/rest/usersTable" columns={columns} actionLinks={actionLinks} />
</div>
);
}
}

44
client/src/users/root.js Normal file
View file

@ -0,0 +1,44 @@
'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 CUD from './CUD'
import List from './List'
const getStructure = t => ({
'': {
title: t('Home'),
externalLink: '/',
children: {
'users': {
title: t('Users'),
link: '/users',
component: List,
children: {
'edit' : {
title: t('Edit User'),
params: [':id', ':action?'],
render: props => (<CUD edit {...props} />)
},
'create' : {
title: t('Create User'),
render: props => (<CUD {...props} />)
}
}
}
}
}
});
export default function() {
ReactDOM.render(
<I18nextProvider i18n={ i18n }><Section root='/users' structure={getStructure}/></I18nextProvider>,
document.getElementById('root')
);
};

View file

@ -3,7 +3,8 @@ const path = require('path');
module.exports = {
entry: {
namespaces: ['babel-polyfill', './src/namespaces/root.js']
namespaces: ['babel-polyfill', './src/namespaces/root.js'],
users: ['babel-polyfill', './src/users/root.js']
},
output: {
library: 'MailtrainReactBody',
@ -24,5 +25,9 @@ module.exports = {
plugins: [
// new webpack.optimize.UglifyJsPlugin(),
new webpack.optimize.CommonsChunkPlugin('common')
]
],
watchOptions: {
ignored: 'node_modules/',
poll: 1000
}
};