Some basic components for building forms.

This commit is contained in:
Tomas Bures 2017-06-04 13:16:29 +02:00
parent d13fc65ce2
commit 4504d539c5
22 changed files with 827 additions and 246 deletions

View file

@ -1,3 +1,4 @@
{
"plugins": ["transform-react-jsx", "transform-decorators-legacy"]
"presets": ["stage-1"],
"plugins": ["transform-react-jsx", "transform-decorators-legacy", "transform-function-bind"]
}

View file

@ -15,8 +15,10 @@
"license": "GPL-3.0",
"homepage": "https://mailtrain.org/",
"dependencies": {
"axios": "^0.16.1",
"i18next": "^8.3.0",
"i18next-xhr-backend": "^1.4.1",
"immutable": "^3.8.1",
"prop-types": "^15.5.10",
"react": "^15.5.4",
"react-dom": "^15.5.4",
@ -27,8 +29,10 @@
"babel-cli": "^6.24.1",
"babel-loader": "^7.0.0",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-plugin-transform-function-bind": "^6.22.0",
"babel-preset-es2015": "^6.24.1",
"babel-preset-react": "^6.24.1",
"babel-preset-stage-1": "^6.24.1",
"css-loader": "^0.28.3",
"i18next-conv": "^3.0.3",
"style-loader": "^0.18.1",

247
client/src/lib/form.js Normal file
View file

@ -0,0 +1,247 @@
'use strict';
import React, { Component } from 'react';
import axios from 'axios';
import Immutable from 'immutable';
import { translate } from 'react-i18next';
import PropTypes from 'prop-types';
import { Button } from './page.js';
const FormState = {
Loading: 0,
LoadingWithNotice: 1,
Ready: 2
};
@translate()
class Form extends Component {
static propTypes = {
stateOwner: PropTypes.object.isRequired,
onSubmit: PropTypes.func
}
static childContextTypes = {
stateOwner: PropTypes.object
}
getChildContext() {
return {
stateOwner: this.props.stateOwner
};
}
render() {
const t = this.props.t;
const owner = this.props.stateOwner;
const props = this.props;
if (!owner.isFormReady()) {
if (owner.isFormWithLoadingNotice()) {
return <div>{t('Loading ...')}</div>
} else {
return <div></div>;
}
} else {
return (
<form className="form-horizontal" onSubmit={props.onSubmit}>
{props.children}
</form>
);
}
}
}
function wrapInput(id, htmlId, owner, label, input) {
return (
<div className={owner.addFormValidationClass('form-group', id)} >
<div className="col-sm-2">
<label htmlFor={htmlId} className="control-label">{label}</label>
</div>
<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>
</div>
);
}
class InputField extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
placeholder: PropTypes.string
}
static contextTypes = {
stateOwner: PropTypes.object.isRequired
}
render() {
const props = this.props;
const owner = this.context.stateOwner;
const id = this.props.id;
const htmlId = 'form_' + id;
return wrapInput(id, htmlId, owner, props.label,
<input type="text" value={owner.getFormState(id)} placeholder={props.placeholder} id={htmlId} className="form-control" aria-describedby={htmlId + '_help'} onChange={owner.bindToFormState(id)}/>
);
}
}
class TextArea extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
placeholder: PropTypes.string
}
static contextTypes = {
stateOwner: PropTypes.object.isRequired
}
render() {
const props = this.props;
const owner = this.context.stateOwner;
const id = this.props.id;
const htmlId = 'form_' + id;
return wrapInput(id, htmlId, owner, props.label,
<textarea id={htmlId} value={owner.getFormState(id)} className="form-control" aria-describedby={htmlId + '_help'} onChange={owner.bindToFormState(id)}></textarea>
);
}
}
class ButtonRow extends Component {
render() {
return (
<div className="form-group">
<div className="col-sm-10 col-sm-offset-2 mt-button-row">
{this.props.children}
</div>
</div>
);
}
}
function withForm(target) {
const inst = target.prototype;
inst.initFormState = function() {
const state = this.state || {};
state.formState = Immutable.Map({
_state: FormState.Loading,
_isValidationShown: false
});
this.state = state;
};
inst.populateFormStateFromURL = function(url) {
setTimeout(() => {
this.setState(previousState => {
if (previousState.formState.get('_state') === FormState.Loading) {
return {
formState: previousState.formState.set('_state', FormState.LoadingWithNotice)
};
}
});
}, 500);
axios.get(url).then(response => {
this.populateFormState(response.data);
});
};
inst.populateFormState = function(data) {
this.setState(previousState => ({
formState: previousState.formState.withMutations(state => {
state.set('_state', FormState.Ready);
for (const key in data) {
state.set(key, Immutable.Map({
value: data[key]
}));
}
this.validateFormState(state);
})
}));
};
inst.updateFormState = function(key, value) {
this.setState(previousState => ({
formState: previousState.formState.withMutations(state => {
state.setIn([key, 'value'], value);
this.validateFormState(state);
})
}));
};
inst.bindToFormState = function(name) {
return evt => this.updateFormState(name, evt.target.value);
};
inst.getFormState = function(name) {
return this.state.formState.getIn([name, 'value']);
};
inst.getFormError = function(name) {
return this.state.formState.getIn([name, 'error']);
};
inst.isFormWithLoadingNotice = function() {
return this.state.formState.get('_state') === FormState.LoadingWithNotice;
};
inst.isFormReady = function() {
return this.state.formState.get('_state') === FormState.Ready;
};
inst.isFormValidationShown = function() {
return this.state.formState.get('_isValidationShown');
};
inst.addFormValidationClass = function(className, name) {
if (this.isFormValidationShown()) {
const error = this.getFormError(name);
if (error) {
return className + ' has-error';
} else {
return className + ' has-success';
}
} else {
return className;
}
};
inst.getFormValidationMessage = function(name) {
if (this.isFormValidationShown()) {
return this.getFormError(name);
} else {
return '';
}
};
inst.showFormValidation = function() {
this.setState(previousState => ({formState: previousState.formState.set('_isValidationShown', true)}));
};
inst.hideFormValidation = function() {
this.setState(previousState => ({formState: previousState.formState.set('_isValidationShown', false)}));
};
}
export {
withForm,
Form,
InputField,
TextArea,
ButtonRow,
Button
}

7
client/src/lib/page.css Normal file
View file

@ -0,0 +1,7 @@
.mt-button-row > button{
margin-right: 15px;
}
.mt-button-row > button:last-child {
margin-right: 0px;
}

200
client/src/lib/page.js Normal file
View file

@ -0,0 +1,200 @@
'use strict';
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router';
import { BrowserRouter as Router, Route, Link, Switch } from 'react-router-dom'
import './page.css';
class PageContent extends Component {
getRoutes(urlPrefix, children) {
let routes = [];
for (let routeKey in children) {
const structure = children[routeKey];
let path = urlPrefix + routeKey;
if (structure.params) {
path = path + '/' + structure.params.join('/');
}
if (structure.component) {
const route = {
component: structure.component,
path: (path === '' ? '/' : path)
};
routes.push(route);
}
if (structure.children) {
routes = routes.concat(this.getRoutes(path + '/', structure.children));
}
}
return routes;
}
renderRoute(route) {
return <Route key={route.path} exact path={route.path} component={route.component} />;
}
render() {
let routes = this.getRoutes('', this.props.structure);
return <Switch>{routes.map(x => this.renderRoute(x))}</Switch>;
}
}
@withRouter
class Breadcrumb extends Component {
renderElement(breadcrumbElem) {
if (breadcrumbElem.isActive) {
return <li key={breadcrumbElem.idx} className="active">{breadcrumbElem.title}</li>;
} else if (breadcrumbElem.externalLink) {
return <li key={breadcrumbElem.idx}><a href={breadcrumbElem.externalLink}>{breadcrumbElem.title}</a></li>;
} else if (breadcrumbElem.link) {
return <li key={breadcrumbElem.idx}><Link to={breadcrumbElem.link}>{breadcrumbElem.title}</Link></li>;
} else {
return <li key={breadcrumbElem.idx}>{breadcrumbElem.title}</li>;
}
}
render() {
const location = this.props.location.pathname;
const locationElems = location.split('/');
let breadcrumbElems = [];
let children = this.props.structure;
for (let idx = 0; idx < locationElems.length; idx++) {
const breadcrumbElem = children[locationElems[idx]];
if (!breadcrumbElem) {
break;
}
breadcrumbElem.isActive = (idx === locationElems.length - 1);
breadcrumbElem.idx = idx;
breadcrumbElems.push(breadcrumbElem);
children = breadcrumbElem.children;
if (!children) {
break;
}
}
const renderedElems = breadcrumbElems.map(x => this.renderElement(x));
return <ol className="breadcrumb">{renderedElems}</ol>;
}
}
@translate()
class Section extends Component {
constructor(props) {
super(props);
let structure = props.structure;
if (typeof structure === 'function') {
structure = structure(props.t);
}
this.structure = structure;
}
render() {
return (
<Router>
<div>
<Breadcrumb structure={this.structure} />
<PageContent structure={this.structure}/>
</div>
</Router>
);
}
}
class Title extends Component {
render() {
return (
<div>
<h2>{this.props.children}</h2>
<hr/>
</div>
);
}
}
class Toolbar extends Component {
render() {
return (
<div className="pull-right mt-button-row">
{this.props.children}
</div>
);
}
}
class Button extends Component {
static propTypes = {
onClick: PropTypes.func,
label: PropTypes.string,
icon: PropTypes.string,
className: PropTypes.string,
type: PropTypes.string
}
render() {
const props = this.props;
let className = 'btn';
if (props.className) {
className = className + ' ' + props.className;
}
let type = props.type || 'button';
let icon;
if (props.icon) {
icon = <span className={'glyphicon glyphicon-' + props.icon}></span>
}
let iconSpacer;
if (props.icon && props.label) {
iconSpacer = ' ';
}
return (
<button type={type} className={className} onClick={props.onClick}>{icon}{iconSpacer}{props.label}</button>
);
}
}
class NavButton extends Component {
static propTypes = {
label: PropTypes.string,
icon: PropTypes.string,
className: PropTypes.string,
linkTo: PropTypes.string
};
render() {
const props = this.props;
return (
<Link to={props.linkTo}><Button label={props.label} icon={props.icon} className={props.className}/></Link>
);
}
}
export {
Section,
Title,
Toolbar,
Button,
NavButton
};

View file

@ -1,86 +0,0 @@
'use strict';
import ReactDOM from 'react-dom';
import React, { Component } from 'react';
import {
BrowserRouter as Router,
Route,
Link
} from 'react-router-dom'
import { I18nextProvider, translate } from 'react-i18next';
import i18n from './i18n';
import NamespacesTreeTable from './namespaces/NamespacesTreeTable';
@translate()
class List extends Component {
render() {
console.log(this.props);
console.log(this.props.routes);
const t = this.props.t;
return (
<div>
<h2>{t('Namespaces')}</h2>
<hr />
<NamespacesTreeTable />
</div>
);
}
}
@translate()
class Create extends Component {
render() {
console.log(this.props);
console.log(this.props.routes);
const t = this.props.t;
return (
<div>
<h2>{t('Create Namespace')}</h2>
<hr />
</div>
);
}
}
@translate()
class Namespaces extends Component {
render() {
const t = this.props.t;
return (
<Router>
<div>
<ol className="breadcrumb">
<li><a href="/">{t('Home')}</a></li>
<li className="active">{t('Namespaces')}</li>
</ol>
<div className="pull-right">
<Link to="/namespaces/create"><span className="btn btn-primary" role="button"><i className="glyphicon glyphicon-plus"></i> {t('Create Namespace')}</span></Link>
</div>
<Route exact path="/namespaces" component={List} />
<Route exact path="/namespaces/create" component={Create} />
</div>
</Router>
);
}
}
export default function() {
ReactDOM.render(
<I18nextProvider i18n={ i18n }><Namespaces/></I18nextProvider>,
document.getElementById('root')
);
};

View file

@ -0,0 +1,20 @@
'use strict';
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { Title } from "../lib/page";
import csfrToken from 'csfrToken';
@translate()
export default class Create extends Component {
render() {
const t = this.props.t;
console.log('csfrToken = ' + csfrToken);
return (
<div>
<Title>{t('Create Namespace')}</Title>
</div>
);
}
}

View file

@ -0,0 +1,60 @@
'use strict';
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import csfrToken from 'csfrToken';
import { withForm, Form, InputField, TextArea, ButtonRow, Button } from '../lib/form';
import { Title } from "../lib/page";
@translate()
@withForm
export default class Edit extends Component {
constructor(props) {
super(props);
this.nsId = parseInt(this.props.match.params.nsId);
this.initFormState();
this.populateFormStateFromURL(`/namespaces/rest/namespaces/${this.nsId}`);
}
validateFormState(state) {
const t = this.props.t;
if (!state.getIn(['name','value']).trim()) {
state.setIn(['name', 'error'], t('Name must not be empty'));
} else {
state.setIn(['name', 'error'], null);
}
}
submitHandler(evt) {
evt.preventDefault();
this.showFormValidation();
}
deleteHandler() {
this.hideFormValidation();
}
render() {
const t = this.props.t;
return (
<div>
<Title>{t('Edit Namespace')}</Title>
<Form stateOwner={this} onSubmit={::this.submitHandler}>
<InputField id="name" label={t('Name')} description={t('Namespace Name')}/>
<TextArea id="description" label={t('Description')} description={t('Description')}/>
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Update')}/>
<Button className="btn-danger" icon="remove" label={t('Delete Namespace')} onClick={::this.deleteHandler}/>
</ButtonRow>
</Form>
</div>
);
}
}

View file

@ -0,0 +1,25 @@
'use strict';
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import NamespacesTreeTable from './NamespacesTreeTable';
import { Title, Toolbar, NavButton } from "../lib/page";
@translate()
export default class List extends Component {
render() {
const t = this.props.t;
return (
<div>
<Toolbar>
<NavButton linkTo="/namespaces/create" className="btn-primary" icon="plus" label={t('Create Namespace')}/>
</Toolbar>
<Title>{t('Namespaces')}</Title>
<NamespacesTreeTable />
</div>
);
}
}

View file

@ -7,30 +7,29 @@ import jQuery from 'jquery';
import '../../public/jquery/jquery-ui-1.12.1.min.js';
import '../../public/fancytree/jquery.fancytree-all.min.js';
import '../../public/fancytree/skin-bootstrap/ui.fancytree.min.css';
import axios from 'axios';
translate();
class NamespacesTreeTable extends Component {
@translate()
export default class NamespacesTreeTable extends Component {
constructor(props) {
super(props);
this.state = {
treeData: [
{title: 'A', key: '1', expanded: true},
{title: 'B', key: '2', expanded: true, folder: true, children: [
{title: 'BA', key: '3', expanded: true, folder: true, children: [
{title: 'BAA', key: '4', expanded: true},
{title: 'BAB', key: '5', expanded: true}
]},
{title: 'BB', key: '6', expanded: true, folder: true, children: [
{title: 'BBA', key: '7', expanded: true},
{title: 'BBB', key: '8', expanded: true}
]}
]}
]
treeData: []
};
axios.get('/namespaces/rest/namespacesTree')
.then(response => {
this.setState({
treeData: response.data
});
});
}
componentDidMount() {
const history = this.props.history;
const glyphOpts = {
map: {
expanderClosed: 'glyphicon glyphicon-menu-right',
@ -49,8 +48,15 @@ class NamespacesTreeTable extends Component {
source: this.state.treeData,
table: {
nodeColumnIdx: 0
},
createNode: (event, data) => {
const node = data.node;
const tdList = jQuery(node.tr).find(">td");
tdList.eq(1).html('<a href="#">Edit</a>').click(() => {
history.push('/namespaces/edit/' + node.key);
});
}
});
}).fancytree("getTree");
}
componentDidUpdate() {
@ -58,13 +64,15 @@ class NamespacesTreeTable extends Component {
}
render() {
const t = this.props.t;
const container =
<div ref={(domElem) => { this.domTableContainer = domElem; }} style={{ height: '100px', overflow: 'auto'}}>
<table ref={(domElem) => { this.domTable = domElem; }} className="table table-hover table-striped table-condensed">
<thead>
<tr>
<th>Name</th>
<th>B</th>
<th>{t('Name')}</th>
<th></th>
</tr>
</thead>
<tbody>
@ -81,6 +89,4 @@ class NamespacesTreeTable extends Component {
);
}
}
export default NamespacesTreeTable;
}

View file

@ -0,0 +1,46 @@
'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 Create from './Create'
import Edit from './Edit'
import List from './List'
const getStructure = t => ({
'': {
title: t('Home'),
externalLink: '/',
children: {
'namespaces': {
title: t('Namespaces'),
link: '/namespaces',
component: List,
children: {
'edit' : {
title: t('Edit Namespace'),
params: [':nsId'],
component: Edit
},
'create' : {
title: t('Create Namespace'),
link: '/namespaces/create',
component: Create
}
}
}
}
}
});
export default function() {
ReactDOM.render(
<I18nextProvider i18n={ i18n }><Section structure={getStructure}/></I18nextProvider>,
document.getElementById('root')
);
};

View file

@ -3,7 +3,7 @@ const path = require('path');
module.exports = {
entry: {
namespaces: './src/namespaces.js'
namespaces: './src/namespaces/root.js'
},
output: {
library: 'MailtrainReactBody',
@ -18,7 +18,8 @@ module.exports = {
]
},
externals: {
jquery: 'jQuery'
jquery: 'jQuery',
csfrToken: 'csfrToken'
},
plugins: [
// new webpack.optimize.UglifyJsPlugin(),