diff --git a/client/.babelrc b/client/.babelrc index 712064f2..5a460ec4 100644 --- a/client/.babelrc +++ b/client/.babelrc @@ -1,3 +1,4 @@ { - "plugins": ["transform-react-jsx", "transform-decorators-legacy"] + "presets": ["stage-1"], + "plugins": ["transform-react-jsx", "transform-decorators-legacy", "transform-function-bind"] } \ No newline at end of file diff --git a/client/package.json b/client/package.json index 87eafd6d..15f2547d 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/lib/form.js b/client/src/lib/form.js new file mode 100644 index 00000000..c0188dab --- /dev/null +++ b/client/src/lib/form.js @@ -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
{t('Loading ...')}
+ } else { + return
; + } + } else { + return ( +
+ {props.children} +
+ ); + } + } +} + +function wrapInput(id, htmlId, owner, label, input) { + return ( +
+
+ +
+
+ {input} +
+
{owner.getFormValidationMessage(id)}
+
+ ); +} + +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, + + ); + } +} + +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, + + ); + } +} + +class ButtonRow extends Component { + render() { + return ( +
+
+ {this.props.children} +
+
+ ); + } +} + + + + +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 +} diff --git a/client/src/i18n.js b/client/src/lib/i18n.js similarity index 100% rename from client/src/i18n.js rename to client/src/lib/i18n.js diff --git a/client/src/lib/page.css b/client/src/lib/page.css new file mode 100644 index 00000000..8f338941 --- /dev/null +++ b/client/src/lib/page.css @@ -0,0 +1,7 @@ +.mt-button-row > button{ + margin-right: 15px; +} + +.mt-button-row > button:last-child { + margin-right: 0px; +} \ No newline at end of file diff --git a/client/src/lib/page.js b/client/src/lib/page.js new file mode 100644 index 00000000..d00c636a --- /dev/null +++ b/client/src/lib/page.js @@ -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 ; + } + + render() { + let routes = this.getRoutes('', this.props.structure); + return {routes.map(x => this.renderRoute(x))}; + } +} + +@withRouter +class Breadcrumb extends Component { + renderElement(breadcrumbElem) { + if (breadcrumbElem.isActive) { + return
  • {breadcrumbElem.title}
  • ; + } else if (breadcrumbElem.externalLink) { + return
  • {breadcrumbElem.title}
  • ; + } else if (breadcrumbElem.link) { + return
  • {breadcrumbElem.title}
  • ; + } else { + return
  • {breadcrumbElem.title}
  • ; + } + } + + 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
      {renderedElems}
    ; + } +} + +@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 ( + +
    + + +
    +
    + ); + } +} + +class Title extends Component { + render() { + return ( +
    +

    {this.props.children}

    +
    +
    + ); + } +} + +class Toolbar extends Component { + render() { + return ( +
    + {this.props.children} +
    + ); + } +} + +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 = + } + + let iconSpacer; + if (props.icon && props.label) { + iconSpacer = ' '; + } + + return ( + + ); + } +} + +class NavButton extends Component { + static propTypes = { + label: PropTypes.string, + icon: PropTypes.string, + className: PropTypes.string, + linkTo: PropTypes.string + }; + + render() { + const props = this.props; + + return ( +