Seems that hierarchical error handling works..

TreeTable component seems to work too.
Edit is half-way through. Create / delete are TBD.
This commit is contained in:
Tomas Bures 2017-06-05 23:59:08 +02:00
parent 79ea9e1897
commit 5e4c86f626
24 changed files with 9967 additions and 261 deletions

2
app.js
View file

@ -44,7 +44,7 @@ const reports = require('./routes/reports');
const reportsTemplates = require('./routes/report-templates'); const reportsTemplates = require('./routes/report-templates');
const namespaces = require('./routes/namespaces'); const namespaces = require('./routes/namespaces');
const interoperableErrors = require('./lib/interoperable-errors'); const interoperableErrors = require('./shared/interoperable-errors');
const app = express(); const app = express();

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

18
client/src/lib/axios.js Normal file
View file

@ -0,0 +1,18 @@
'use strict';
import csfrToken from 'csfrToken';
import axios from 'axios';
import interoperableErrors from '../../../shared/interoperable-errors';
const axiosInst = axios.create({
headers: {'X-CSRF-TOKEN': csfrToken}
});
const axiosWrapper = {
get: (...args) => axiosInst.get(...args).catch(error => { throw interoperableErrors.deserialize(error.response.data) || error }),
put: (...args) => axiosInst.put(...args).catch(error => { throw interoperableErrors.deserialize(error.response.data) || error }),
post: (...args) => axiosInst.post(...args).catch(error => { throw interoperableErrors.deserialize(error.response.data) || error }),
delete: (...args) => axiosInst.delete(...args).catch(error => { throw interoperableErrors.deserialize(error.response.data) || error })
};
export default axiosWrapper;

View file

@ -0,0 +1,67 @@
'use strict';
import PropTypes from 'prop-types';
function withErrorHandling(target) {
const inst = target.prototype;
const contextTypes = target.contextTypes || {};
contextTypes.parentErrorHandler = PropTypes.object;
target.contextTypes = contextTypes;
const childContextTypes = target.childContextTypes || {};
childContextTypes.parentErrorHandler = PropTypes.object;
target.childContextTypes = childContextTypes;
const existingGetChildContext = inst.getChildContext;
if (existingGetChildContext) {
inst.getChildContext = function() {
const childContext = (this::existingGetChildContext)();
childContext.parentErrorHandler = this;
return childContext;
}
} else {
inst.getChildContext = function() {
return {
parentErrorHandler: this
};
}
}
return target;
}
function handleError(that, error) {
let errorHandled;
if (that.errorHandler) {
errorHandled = that.errorHandler(error);
}
if (!errorHandled && that.context.parentErrorHandler) {
errorHandled = handleError(that.context.parentErrorHandler, error);
}
if (!errorHandled) {
throw error;
}
}
function withAsyncErrorHandler(target, name, descriptor) {
let fn = descriptor.value;
descriptor.value = async function () {
try {
await fn.apply(this, arguments)
} catch (error) {
handleError(this, error);
}
};
return descriptor;
}
export {
withErrorHandling,
withAsyncErrorHandler
}

View file

@ -1,10 +1,14 @@
'use strict'; 'use strict';
import React, { Component } from 'react'; import React, { Component } from 'react';
import axios from 'axios'; import axios from './axios';
import Immutable from 'immutable'; import Immutable from 'immutable';
import { translate } from 'react-i18next'; import { translate } from 'react-i18next';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import interoperableErrors from '../../../shared/interoperable-errors';
import { withSectionHelpers } from './page'
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
import { TreeTable } from './tree';
const FormState = { const FormState = {
Loading: 0, Loading: 0,
@ -12,7 +16,10 @@ const FormState = {
Ready: 2 Ready: 2
}; };
@translate() @translate()
@withSectionHelpers
@withErrorHandling
class Form extends Component { class Form extends Component {
static propTypes = { static propTypes = {
stateOwner: PropTypes.object.isRequired, stateOwner: PropTypes.object.isRequired,
@ -20,28 +27,44 @@ class Form extends Component {
} }
static childContextTypes = { static childContextTypes = {
stateOwner: PropTypes.object formStateOwner: PropTypes.object
} }
getChildContext() { getChildContext() {
return { return {
stateOwner: this.props.stateOwner formStateOwner: this.props.stateOwner
}; };
} }
@withAsyncErrorHandler
async onSubmit(evt) { async onSubmit(evt) {
evt.preventDefault();
const t = this.props.t; const t = this.props.t;
if (this.props.onSubmitAsync) { try {
this.props.stateOwner.disableForm(); evt.preventDefault();
this.props.stateOwner.setFormStatusMessage(t('Submitting...'));
await this.props.onSubmitAsync(evt); if (this.props.onSubmitAsync) {
this.props.stateOwner.disableForm();
this.props.stateOwner.setFormStatusMessage('info', t('Submitting...'));
this.props.stateOwner.setFormStatusMessage(); await this.props.onSubmitAsync(evt);
this.props.stateOwner.enableForm();
this.props.stateOwner.setFormStatusMessage();
this.props.stateOwner.enableForm();
}
} catch (error) {
if (error instanceof interoperableErrors.ChangedError) {
this.props.stateOwner.disableForm();
this.props.stateOwner.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('Someone else has introduced modification in the meantime. Refresh your page to start anew with fresh data. Please note that your changes will be lost.')}
</span>
);
return;
}
throw error;
} }
} }
@ -49,11 +72,12 @@ class Form extends Component {
const t = this.props.t; const t = this.props.t;
const owner = this.props.stateOwner; const owner = this.props.stateOwner;
const props = this.props; const props = this.props;
const statusMessage = owner.getFormStatusMessage(); const statusMessageText = owner.getFormStatusMessageText();
const statusMessageSeverity = owner.getFormStatusMessageSeverity();
if (!owner.isFormReady()) { if (!owner.isFormReady()) {
if (owner.isFormWithLoadingNotice()) { if (owner.isFormWithLoadingNotice()) {
return <div>{t('Loading ...')}</div> return <p className={`alert alert-info mt-form-status`} role="alert">{t('Loading ...')}</p>
} else { } else {
return <div></div>; return <div></div>;
} }
@ -63,7 +87,7 @@ class Form extends Component {
<fieldset disabled={owner.isFormDisabled()}> <fieldset disabled={owner.isFormDisabled()}>
{props.children} {props.children}
</fieldset> </fieldset>
{statusMessage && <p className="col-sm-10 col-sm-offset-2 alert alert-info mt-form-status" role="alert">{owner.getFormStatusMessage()}</p>} {statusMessageText && <p className={`col-sm-10 col-sm-offset-2 alert alert-${statusMessageSeverity} mt-form-status`} role="alert">{statusMessageText}</p>}
</form> </form>
); );
} }
@ -92,17 +116,17 @@ class InputField extends Component {
} }
static contextTypes = { static contextTypes = {
stateOwner: PropTypes.object.isRequired formStateOwner: PropTypes.object.isRequired
} }
render() { render() {
const props = this.props; const props = this.props;
const owner = this.context.stateOwner; const owner = this.context.formStateOwner;
const id = this.props.id; const id = this.props.id;
const htmlId = 'form_' + id; const htmlId = 'form_' + id;
return wrapInput(id, htmlId, owner, props.label, return wrapInput(id, htmlId, owner, props.label,
<input type="text" value={owner.getFormValue(id)} placeholder={props.placeholder} id={htmlId} className="form-control" aria-describedby={htmlId + '_help'} onChange={owner.bindToFormValue(id)}/> <input type="text" value={owner.getFormValue(id)} placeholder={props.placeholder} id={htmlId} className="form-control" aria-describedby={htmlId + '_help'} onChange={owner.bindChangeEventToFormValue(id)}/>
); );
} }
} }
@ -115,17 +139,17 @@ class TextArea extends Component {
} }
static contextTypes = { static contextTypes = {
stateOwner: PropTypes.object.isRequired formStateOwner: PropTypes.object.isRequired
} }
render() { render() {
const props = this.props; const props = this.props;
const owner = this.context.stateOwner; const owner = this.context.formStateOwner;
const id = this.props.id; const id = this.props.id;
const htmlId = 'form_' + id; const htmlId = 'form_' + id;
return wrapInput(id, htmlId, owner, props.label, return wrapInput(id, htmlId, owner, props.label,
<textarea id={htmlId} value={owner.getFormValue(id)} className="form-control" aria-describedby={htmlId + '_help'} onChange={owner.bindToFormValue(id)}></textarea> <textarea id={htmlId} value={owner.getFormValue(id)} className="form-control" aria-describedby={htmlId + '_help'} onChange={owner.bindChangeEventToFormValue(id)}></textarea>
); );
} }
} }
@ -142,6 +166,7 @@ class ButtonRow extends Component {
} }
} }
@withErrorHandling
class Button extends Component { class Button extends Component {
static propTypes = { static propTypes = {
onClickAsync: PropTypes.func, onClickAsync: PropTypes.func,
@ -153,9 +178,10 @@ class Button extends Component {
} }
static contextTypes = { static contextTypes = {
stateOwner: PropTypes.object.isRequired formStateOwner: PropTypes.object.isRequired
} }
@withAsyncErrorHandler
async onClick(evt) { async onClick(evt) {
if (this.props.onClick) { if (this.props.onClick) {
evt.preventDefault(); evt.preventDefault();
@ -165,9 +191,9 @@ class Button extends Component {
} else if (this.props.onClickAsync) { } else if (this.props.onClickAsync) {
evt.preventDefault(); evt.preventDefault();
this.context.stateOwner.disableForm(); this.context.formStateOwner.disableForm();
await this.props.onClickAsync(evt); await this.props.onClickAsync(evt);
this.context.stateOwner.enableForm(); this.context.formStateOwner.enableForm();
} }
} }
@ -197,23 +223,65 @@ class Button extends Component {
} }
} }
class TreeTableSelect extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
dataUrl: PropTypes.string.isRequired
}
static contextTypes = {
formStateOwner: PropTypes.object.isRequired
}
async onSelectionChangedAsync(sel) {
const owner = this.context.formStateOwner;
owner.updateFormValue(this.props.id, sel);
}
render() {
const props = this.props;
const owner = this.context.formStateOwner;
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 dataUrl={this.props.dataUrl} selectMode={TreeTable.SelectMode.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>
);
}
}
function withForm(target) { function withForm(target) {
const inst = target.prototype; const inst = target.prototype;
const cleanFormState = Immutable.Map({
state: FormState.Loading,
isValidationShown: false,
isDisabled: false,
statusMessageText: '',
data: Immutable.Map()
});
inst.initFormState = function() { inst.initFormState = function() {
const state = this.state || {}; const state = this.state || {};
state.formState = cleanFormState;
state.formState = Immutable.Map({
state: FormState.Loading,
isValidationShown: false,
isDisabled: false,
statusMessage: '',
data: Immutable.Map()
});
this.state = state; this.state = state;
}; };
inst.resetFormState = function() {
this.setState({
formState: cleanFormState
});
};
inst.populateFormValuesFromURL = function(url) { inst.populateFormValuesFromURL = function(url) {
setTimeout(() => { setTimeout(() => {
this.setState(previousState => { this.setState(previousState => {
@ -226,7 +294,12 @@ function withForm(target) {
}, 500); }, 500);
axios.get(url).then(response => { axios.get(url).then(response => {
this.populateFormValues(response.data); const data = response.data;
data.originalHash = data.hash;
delete data.hash;
this.populateFormValues(data);
}); });
}; };
@ -257,7 +330,7 @@ function withForm(target) {
})); }));
}; };
inst.bindToFormValue = function(name) { inst.bindChangeEventToFormValue = function(name) {
return evt => this.updateFormValue(name, evt.target.value); return evt => this.updateFormValue(name, evt.target.value);
}; };
@ -322,12 +395,21 @@ function withForm(target) {
return !this.state.formState.get('data').find(attr => attr.get('error')); return !this.state.formState.get('data').find(attr => attr.get('error'));
}; };
inst.getFormStatusMessage = function() { inst.getFormStatusMessageText = function() {
return this.state.formState.get('statusMessage'); return this.state.formState.get('statusMessageText');
}; };
inst.setFormStatusMessage = function(message) { inst.getFormStatusMessageSeverity = function() {
this.setState(previousState => ({formState: previousState.formState.set('statusMessage', message)})); return this.state.formState.get('statusMessageSeverity');
};
inst.setFormStatusMessage = function(severity, text) {
this.setState(previousState => ({
formState: previousState.formState.withMutations(map => {
map.set('statusMessageText', text);
map.set('statusMessageSeverity', severity);
})
}));
}; };
inst.enableForm = function() { inst.enableForm = function() {
@ -342,6 +424,7 @@ function withForm(target) {
return this.state.formState.get('isDisabled'); return this.state.formState.get('isDisabled');
}; };
return target;
} }
@ -351,5 +434,6 @@ export {
InputField, InputField,
TextArea, TextArea,
ButtonRow, ButtonRow,
Button Button,
TreeTableSelect
} }

View file

@ -14,7 +14,7 @@ i18n
ns: ['common'], ns: ['common'],
defaultNS: 'common', defaultNS: 'common',
debug: true, debug: false,
// cache: { // cache: {
// enabled: true // enabled: true

View file

@ -1,4 +1,4 @@
.mt-button-row > button{ .mt-button-row > button {
margin-right: 15px; margin-right: 15px;
} }
@ -9,4 +9,12 @@
.mt-form-status { .mt-form-status {
padding-top: 5px; padding-top: 5px;
padding-bottom: 5px; padding-bottom: 5px;
} }
.mt-action-links > a {
margin-right: 8px;
}
.mt-action-links > a:last-child {
margin-right: 0px;
}

View file

@ -6,9 +6,14 @@ import PropTypes from 'prop-types';
import { withRouter } from 'react-router'; import { withRouter } from 'react-router';
import { BrowserRouter as Router, Route, Link, Switch } from 'react-router-dom' import { BrowserRouter as Router, Route, Link, Switch } from 'react-router-dom'
import './page.css'; import './page.css';
import { withErrorHandling } from './error-handling';
class PageContent extends Component { class PageContent extends Component {
static propTypes = {
structure: PropTypes.object.isRequired
}
getRoutes(urlPrefix, children) { getRoutes(urlPrefix, children) {
let routes = []; let routes = [];
for (let routeKey in children) { for (let routeKey in children) {
@ -49,6 +54,10 @@ class PageContent extends Component {
@withRouter @withRouter
class Breadcrumb extends Component { class Breadcrumb extends Component {
static propTypes = {
structure: PropTypes.object.isRequired
}
renderElement(breadcrumbElem) { renderElement(breadcrumbElem) {
if (breadcrumbElem.isActive) { if (breadcrumbElem.isActive) {
return <li key={breadcrumbElem.idx} className="active">{breadcrumbElem.title}</li>; return <li key={breadcrumbElem.idx} className="active">{breadcrumbElem.title}</li>;
@ -91,6 +100,113 @@ class Breadcrumb extends Component {
} }
} }
@translate()
class DismissibleAlert extends Component {
static propTypes = {
severity: PropTypes.string.isRequired,
onClose: PropTypes.func
}
close() {
if (this.props.onClose) {
this.props.onClose();
}
}
render() {
const t = this.props.t;
return (
<div className={`alert alert-${this.props.severity} alert-dismissible`} role="alert">
<button type="button" className="close" aria-label={t('Close')} onClick={::this.close}><span aria-hidden="true">&times;</span></button>
{this.props.children}
</div>
)
}
}
@withRouter
@withErrorHandling
class SectionContent extends Component {
constructor(props) {
super(props);
this.state = {
flashMessageText: ''
}
this.historyUnlisten = props.history.listen((location, action) => {
this.closeFlashMessage();
})
}
static propTypes = {
structure: PropTypes.object.isRequired,
root: PropTypes.string.isRequired
}
static childContextTypes = {
sectionContent: PropTypes.object
}
getChildContext() {
return {
sectionContent: this
};
}
getFlashMessageText() {
return this.state.flashMessageText;
}
getFlashMessageSeverity() {
return this.state.flashMessageSeverity;
}
setFlashMessage(severity, text) {
this.setState({
flashMessageText: text,
flashMessageSeverity: severity
});
}
navigateTo(path) {
this.props.history.push(path);
}
navigateToWithFlashMessage(path, severity, text) {
this.props.history.push(path);
this.setFlashMessage(severity, text);
}
errorHandler(error) {
if (error.response && error.response.data && error.response.data.message) {
this.navigateToWithFlashMessage(this.props.root, 'danger', error.response.data.message);
} else {
this.navigateToWithFlashMessage(this.props.root, 'danger', error.message);
}
return true;
}
closeFlashMessage() {
this.setState({
flashMessageText: ''
})
}
render() {
return (
<div>
<Breadcrumb structure={this.props.structure} />
{(this.state.flashMessageText && <DismissibleAlert severity={this.state.flashMessageSeverity} onClose={::this.closeFlashMessage}>{this.state.flashMessageText}</DismissibleAlert>)}
<PageContent structure={this.props.structure}/>
</div>
);
}
}
@translate() @translate()
class Section extends Component { class Section extends Component {
constructor(props) { constructor(props) {
@ -104,18 +220,21 @@ class Section extends Component {
this.structure = structure; this.structure = structure;
} }
static propTypes = {
structure: PropTypes.oneOfType([PropTypes.object, PropTypes.func]).isRequired,
root: PropTypes.string.isRequired
}
render() { render() {
return ( return (
<Router> <Router>
<div> <SectionContent root={this.props.root} structure={this.structure} />
<Breadcrumb structure={this.structure} />
<PageContent structure={this.structure}/>
</div>
</Router> </Router>
); );
} }
} }
class Title extends Component { class Title extends Component {
render() { render() {
return ( return (
@ -194,11 +313,43 @@ class Button extends Component {
} }
} }
function withSectionHelpers(target) {
const inst = target.prototype;
const contextTypes = target.contextTypes || {};
contextTypes.sectionContent = PropTypes.object.isRequired;
target.contextTypes = contextTypes;
inst.getFlashMessageText = function() {
return this.context.sectionContent.getFlashMessageText();
};
inst.getFlashMessageSeverity = function() {
return this.context.sectionContent.getFlashMessageSeverity();
};
inst.setFlashMessage = function(severity, text) {
return this.context.sectionContent.setFlashMessage(severity, text);
};
inst.navigateTo = function(path) {
return this.context.sectionContent.navigateTo(path);
}
inst.navigateToWithFlashMessage = function(path, severity, text) {
return this.context.sectionContent.navigateToWithFlashMessage(path, severity, text);
}
return target;
}
export { export {
Section, Section,
Title, Title,
Toolbar, Toolbar,
Button, Button,
NavButton NavButton,
withSectionHelpers
}; };

16
client/src/lib/tree.css Normal file
View file

@ -0,0 +1,16 @@
.mt-treetable-container.mt-treetable-inactivable>table.fancytree-ext-table.fancytree-container>tbody>tr.fancytree-active>td,
.mt-treetable-container.mt-treetable-inactivable>table.fancytree-ext-table.fancytree-container>tbody>tr.fancytree-active:hover>td {
background-color: transparent;
}
.mt-treetable-container.mt-treetable-inactivable>table.fancytree-ext-table.fancytree-container>tbody>tr.fancytree-active>td span.fancytree-title {
color: #333333;
}
.mt-treetable-container {
padding-top: 9px;
}
.mt-treetable-container.mt-treetable-noheader>.table>tbody>tr>td {
border-top: 0px none;
}

228
client/src/lib/tree.js Normal file
View file

@ -0,0 +1,228 @@
'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 '../../public/fancytree/jquery.fancytree-all.js';
import '../../public/fancytree/skin-bootstrap/ui.fancytree.min.css';
import './tree.css';
import axios from 'axios';
import { withSectionHelpers } from '../lib/page'
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
@translate()
@withSectionHelpers
@withErrorHandling
class TreeTable extends Component {
constructor(props) {
super(props);
this.selectMode = this.props.selectMode || TreeTable.SelectMode.NONE;
let selection = props.selection;
if (this.selectMode == TreeTable.SelectMode.MULTI) {
selection = selection.slice().sort();
}
this.state = {
treeData: [],
selection: selection
};
this.loadData();
}
@withAsyncErrorHandler
async loadData() {
axios.get(this.props.dataUrl)
.then(response => {
this.setState({
treeData: [ response.data ]
});
});
}
static propTypes = {
dataUrl: PropTypes.string.isRequired,
selectMode: PropTypes.number,
selection: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]),
onSelectionChangedAsync: PropTypes.func,
actionLinks: PropTypes.array,
withHeader: PropTypes.bool
}
static SelectMode = {
NONE: 0,
SINGLE: 1,
MULTI: 2
}
componentDidMount() {
const glyphOpts = {
map: {
expanderClosed: 'glyphicon glyphicon-menu-right',
expanderLazy: 'glyphicon glyphicon-menu-right', // glyphicon-plus-sign
expanderOpen: 'glyphicon glyphicon-menu-down', // glyphicon-collapse-down
checkbox: 'glyphicon glyphicon-unchecked',
checkboxSelected: 'glyphicon glyphicon-check',
checkboxUnknown: 'glyphicon glyphicon-share',
}
};
let createNodeFn;
if (this.props.actionLinks) {
const actionLinks = this.props.actionLinks;
createNodeFn = (event, data) => {
const node = data.node;
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>);
const lnk = jQuery(lnkHtml);
lnk.click(() => this.navigateTo(link(node.key)));
linksContainer.append(lnk);
});
tdList.eq(1).html(linksContainer);
};
} else {
createNodeFn = (event, data) => {};
}
this.tree = jQuery(this.domTable).fancytree({
extensions: ['glyph', 'table'],
glyph: glyphOpts,
selectMode: (this.selectMode == TreeTable.SelectMode.MULTI ? 2 : 1),
icon: false,
autoScroll: true,
scrollParent: jQuery(this.domTableContainer),
source: this.state.treeData,
table: {
nodeColumnIdx: 0
},
createNode: createNodeFn,
checkbox: this.selectMode == TreeTable.SelectMode.MULTI,
activate: (this.selectMode == TreeTable.SelectMode.SINGLE ? ::this.onActivate : null),
select: (this.selectMode == TreeTable.SelectMode.MULTI ? ::this.onSelect : null)
}).fancytree("getTree");
this.updateSelection();
}
componentDidUpdate() {
this.tree.reload(this.state.treeData);
this.updateSelection();
}
updateSelection() {
const tree = this.tree;
if (this.selectMode == TreeTable.SelectMode.MULTI) {
const selectSet = new Set(this.state.selection);
tree.enableUpdate(false);
tree.visit(node => node.setSelected(selectSet.has(node.key)));
tree.enableUpdate(true);
} else if (this.selectMode == TreeTable.SelectMode.SINGLE) {
this.tree.activateKey(this.state.selection);
}
}
@withAsyncErrorHandler
async onSelectionChanged(sel) {
if (this.props.onSelectionChangedAsync) {
await this.props.onSelectionChangedAsync(sel);
}
}
// Single-select
onActivate(event, data) {
const selection = this.tree.getActiveNode().key;
if (selection !== this.state.selection) {
this.setState({
selection
});
this.onSelectionChanged(selection);
}
}
// Multi-select
onSelect(event, data) {
const newSel = this.tree.getSelectedNodes().map(node => node.key).sort();
const oldSel = this.state.selection;
let updated = false;
const length = oldSel.length
if (length === newSel.length) {
for (let i = 0; i < length; i++) {
if (oldSel[i] !== newSel[i]) {
updated = true;
break;
}
}
} else {
updated = true;
}
if (updated) {
this.setState({
selection: newSel
});
this.onSelectionChanged(selection);
}
}
render() {
const t = this.props.t;
const props = this.props;
const actionLinks = props.actionLinks;
const withHeader = props.withHeader;
let containerClass = 'mt-treetable-container';
if (this.selectMode == TreeTable.SelectMode.NONE) {
containerClass += ' mt-treetable-inactivable';
}
if (!this.withHeader) {
containerClass += ' mt-treetable-noheader';
}
const container =
<div className={containerClass} ref={(domElem) => { this.domTableContainer = domElem; }} style={{ height: '100px', overflow: 'auto'}}>
<table ref={(domElem) => { this.domTable = domElem; }} className="table table-hover table-striped table-condensed">
{props.withHeader &&
<thead>
<tr>
<th>{t('Name')}</th>
{actionLinks && <th></th>}
</tr>
</thead>
}
<tbody>
<tr>
<td></td>
{actionLinks && <td></td>}
</tr>
</tbody>
</table>
</div>;
return (
container
);
}
}
export {
TreeTable
}

View file

@ -2,24 +2,25 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { translate } from 'react-i18next'; import { translate } from 'react-i18next';
import csfrToken from 'csfrToken'; import { withSectionHelpers } from '../lib/page'
import { withForm, Form, InputField, TextArea, ButtonRow, Button} from '../lib/form'; import { withForm, Form, InputField, TextArea, ButtonRow, Button, TreeTableSelect } from '../lib/form';
import { Title } from "../lib/page"; import { Title } from "../lib/page";
import axios from 'axios'; import axios from '../lib/axios';
@translate() @translate()
@withForm @withForm
@withSectionHelpers
export default class Edit extends Component { export default class Edit extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.nsId = parseInt(this.props.match.params.nsId); this.nsId = parseInt(this.props.match.params.nsId);
console.log('Constructing Edit');
this.initFormState(); this.initFormState();
this.populateFormValuesFromURL(`/namespaces/rest/namespaces/${this.nsId}`); this.populateFormValuesFromURL(`/namespaces/rest/namespaces/${this.nsId}`);
} }
validateFormValues(state) { validateFormValues(state) {
const t = this.props.t; const t = this.props.t;
@ -35,17 +36,15 @@ export default class Edit extends Component {
const data = this.getFormValues(); const data = this.getFormValues();
console.log(data); console.log(data);
const response = await axios.put(`/namespaces/rest/namespaces/${this.nsId}`); await axios.put(`/namespaces/rest/namespaces/${this.nsId}`, data);
console.log(response);
} else { } else {
this.showFormValidation(); this.showFormValidation();
} }
} }
async deleteHandler() { async deleteHandler() {
this.setFormStatusMessage('Deleting namespace') this.setFormStatusMessage('Deleting namespace');
this.setFormStatusMessage() this.setFormStatusMessage();
} }
render() { render() {
@ -56,13 +55,15 @@ export default class Edit extends Component {
<Title>{t('Edit Namespace')}</Title> <Title>{t('Edit Namespace')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}> <Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('Name')} description={t('Namespace Name')}/> <InputField id="name" label={t('Name')}/>
<TextArea id="description" label={t('Description')} description={t('Description')}/> <TextArea id="description" label={t('Description')}/>
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Update')}/> <Button type="submit" className="btn-primary" icon="ok" label={t('Update')}/>
<Button className="btn-danger" icon="remove" label={t('Delete Namespace')} onClickAsync={::this.deleteHandler}/> <Button className="btn-danger" icon="remove" label={t('Delete Namespace')} onClickAsync={::this.deleteHandler}/>
</ButtonRow> </ButtonRow>
{this.nsId !== 1 && <TreeTableSelect id="parent" label={t('Parent Namespace')} dataUrl="/namespaces/rest/namespacesTree"/>}
</Form> </Form>
</div> </div>
); );

View file

@ -2,14 +2,21 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { translate } from 'react-i18next'; import { translate } from 'react-i18next';
import NamespacesTreeTable from './NamespacesTreeTable'; import { Title, Toolbar, NavButton } from '../lib/page';
import { Title, Toolbar, NavButton } from "../lib/page"; import { TreeTable } from '../lib/tree';
@translate() @translate()
export default class List extends Component { export default class List extends Component {
render() { render() {
const t = this.props.t; const t = this.props.t;
const actionLinks = [
{
label: 'Edit',
link: key => '/namespaces/edit/' + key
}
];
return ( return (
<div> <div>
<Toolbar> <Toolbar>
@ -18,7 +25,7 @@ export default class List extends Component {
<Title>{t('Namespaces')}</Title> <Title>{t('Namespaces')}</Title>
<NamespacesTreeTable /> <TreeTable withHeader dataUrl="/namespaces/rest/namespacesTree" actionLinks={actionLinks} />
</div> </div>
); );
} }

View file

@ -1,92 +0,0 @@
'use strict';
import React, { Component } from 'react';
import { translate } from 'react-i18next';
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()
export default class NamespacesTreeTable extends Component {
constructor(props) {
super(props);
this.state = {
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',
expanderLazy: 'glyphicon glyphicon-menu-right', // glyphicon-plus-sign
expanderOpen: 'glyphicon glyphicon-menu-down', // glyphicon-collapse-down
}
};
this.tree = jQuery(this.domTable).fancytree({
extensions: ['glyph', 'table'],
glyph: glyphOpts,
selectMode: 1,
icon: false,
autoScroll: true,
scrollParent: jQuery(this.domTableContainer),
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() {
this.tree.reload(this.state.treeData);
}
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>{t('Name')}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td></td>
<td></td>
</tr>
</tbody>
</table>
</div>;
return (
container
);
}
}

View file

@ -38,7 +38,7 @@ const getStructure = t => ({
export default function() { export default function() {
ReactDOM.render( ReactDOM.render(
<I18nextProvider i18n={ i18n }><Section structure={getStructure}/></I18nextProvider>, <I18nextProvider i18n={ i18n }><Section root='/namespaces' structure={getStructure}/></I18nextProvider>,
document.getElementById('root') document.getElementById('root')
); );
}; };

View file

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

View file

@ -24,7 +24,8 @@ module.exports = {
filterCustomFields, filterCustomFields,
getMjmlTemplate, getMjmlTemplate,
rollbackAndReleaseConnection, rollbackAndReleaseConnection,
filterObject filterObject,
enforce
}; };
function getDefaultMergeTags(callback) { function getDefaultMergeTags(callback) {
@ -305,3 +306,10 @@ function filterObject(obj, allowedKeys) {
return result; return result;
} }
function enforce(condition, message) {
console.log(condition);
if (!condition) {
throw new Error(message);
}
}

View file

@ -1,21 +0,0 @@
'use strict';
class InteroperableError extends Error {
constructor(type, msg, data) {
super(msg);
this.type = type;
this.data = data;
}
}
class NotLoggedInError extends InteroperableError {
constructor(msg, data) {
super('NotLoggedIn', msg, data);
}
}
module.exports = {
InteroperableError,
NotLoggedInError
};

View file

@ -2,20 +2,8 @@
const knex = require('../knex'); const knex = require('../knex');
const hasher = require('node-object-hash')(); const hasher = require('node-object-hash')();
const { filterObject } = require('../helpers'); const { enforce, filterObject } = require('../helpers');
const interoperableErrors = require('../interoperable-errors'); const interoperableErrors = require('../../shared/interoperable-errors');
class ChangedError extends interoperableErrors.InteroperableError {
constructor(msg, data) {
super('namespaces.ChangedError', msg, data);
}
}
class NotFoundError extends interoperableErrors.InteroperableError {
constructor(msg, data) {
super('namespaces.NotFoundError', msg, data);
}
}
const allowedKeys = new Set(['id', 'name', 'description', 'parent']); const allowedKeys = new Set(['id', 'name', 'description', 'parent']);
const allowedUpdateKeys = new Set(['name', 'description', 'parent']); const allowedUpdateKeys = new Set(['name', 'description', 'parent']);
@ -31,7 +19,7 @@ function hash(ns) {
async function getById(nsId) { async function getById(nsId) {
const ns = await knex('namespaces').where('id', nsId).first(); const ns = await knex('namespaces').where('id', nsId).first();
if (!ns) { if (!ns) {
throw new NotFoundError(); throw new interoperableErrors.NotFoundError();
} }
ns.hash = hash(ns); ns.hash = hash(ns);
@ -45,18 +33,20 @@ async function create(ns) {
} }
async function updateWithConsistencyCheck(ns) { async function updateWithConsistencyCheck(ns) {
enforce(ns.id !== 1 || ns.parent === null, 'Cannot assign a parent to the root namespace.');
await knex.transaction(async tx => { await knex.transaction(async tx => {
const existingNs = await tx('namespaces').where('id', ns.id).first(); const existingNs = await tx('namespaces').where('id', ns.id).first();
if (!ns) { if (!ns) {
throw new NotFoundError(); throw new interoperableErrors.NotFoundError();
} }
const existingNsHash = hash(existingNs); const existingNsHash = hash(existingNs);
if (existingNsHash != ns.originalHash) { if (existingNsHash != ns.originalHash) {
throw new ChangedError(); throw new interoperableErrors.ChangedError();
} }
tx('namespaces').where('id', ns.id).update(filterObject(ns, allowedUpdateKeys)); await tx('namespaces').where('id', ns.id).update(filterObject(ns, allowedUpdateKeys));
}); });
} }
@ -65,8 +55,6 @@ async function remove(nsId) {
} }
module.exports = { module.exports = {
NotFoundError,
ChangedError,
hash, hash,
list, list,
getById, getById,

View file

@ -2,32 +2,24 @@
const express = require('express'); const express = require('express');
function safeAsyncHandler(handler) { function replaceLastBySafeHandler(handlers) {
return function(req, res, next) { if (handlers.length === 0) {
handler(req, res).catch(error => next(error)); return [];
};
}
function replaceLast(elems, replaceFn) {
if (elems.length === 0) {
return elems;
} }
const lastElem = elems[elems.size - 1]; const lastHandler = handlers[handlers.length - 1];
const replacement = replaceFn(lastElem); const ret = handlers.slice();
ret[handlers.length - 1] = (req, res, next) => lastHandler(req, res).catch(error => next(error));
elems[elems.size - 1] = replacement; return ret;
return elems;
} }
function create() { function create() {
const router = new express.Router(); const router = new express.Router();
router.getAsync = (path, ...handlers) => router.get(path, ...replaceLast(handlers, safeAsyncHandler)); router.getAsync = (path, ...handlers) => router.get(path, ...replaceLastBySafeHandler(handlers));
router.postAsync = (path, ...handlers) => router.post(path, ...replaceLast(handlers, safeAsyncHandler)); router.postAsync = (path, ...handlers) => router.post(path, ...replaceLastBySafeHandler(handlers));
router.putAsync = (path, ...handlers) => router.put(path, ...replaceLast(handlers, safeAsyncHandler)); router.putAsync = (path, ...handlers) => router.put(path, ...replaceLastBySafeHandler(handlers));
router.deleteAsync = (path, ...handlers) => router.delete(path, ...replaceLast(handlers, safeAsyncHandler)); router.deleteAsync = (path, ...handlers) => router.delete(path, ...replaceLastBySafeHandler(handlers));
return router; return router;
} }

View file

@ -4,7 +4,7 @@ const passport = require('../lib/passport');
const router = require('../lib/router-async').create(); const router = require('../lib/router-async').create();
const _ = require('../lib/translate')._; const _ = require('../lib/translate')._;
const namespaces = require('../lib/models/namespaces'); const namespaces = require('../lib/models/namespaces');
const interoperableErrors = require('../lib/interoperable-errors'); const interoperableErrors = require('../shared/interoperable-errors');
router.all('/rest/*', (req, res, next) => { router.all('/rest/*', (req, res, next) => {
req.needsJSONResponse = true; req.needsJSONResponse = true;
@ -16,7 +16,6 @@ router.all('/rest/*', (req, res, next) => {
next(); next();
}); });
router.getAsync('/rest/namespaces/:nsId', async (req, res) => { router.getAsync('/rest/namespaces/:nsId', async (req, res) => {
const ns = await namespaces.getById(req.params.nsId); const ns = await namespaces.getById(req.params.nsId);
return res.json(ns); return res.json(ns);
@ -29,11 +28,10 @@ router.postAsync('/rest/namespaces', passport.csrfProtection, async (req, res) =
}); });
router.putAsync('/rest/namespaces/:nsId', passport.csrfProtection, async (req, res) => { router.putAsync('/rest/namespaces/:nsId', passport.csrfProtection, async (req, res) => {
console.log(req.body); const ns = req.body;
ns = req.body; ns.id = parseInt(req.params.nsId);
ns.id = req.params.nsId;
// await namespaces.updateWithConsistencyCheck(ns); await namespaces.updateWithConsistencyCheck(ns);
return res.json(); return res.json();
}); });
@ -45,24 +43,7 @@ router.deleteAsync('/rest/namespaces/:nsId', passport.csrfProtection, async (req
router.getAsync('/rest/namespacesTree', async (req, res) => { router.getAsync('/rest/namespacesTree', async (req, res) => {
const entries = {}; const entries = {};
let root; // Only the Root namespace is without a parent
/* Example of roots:
[
{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}
]}
]}
]
*/
const roots = [];
const rows = await namespaces.list(); const rows = await namespaces.list();
for (let row of rows) { for (let row of rows) {
@ -86,14 +67,14 @@ router.getAsync('/rest/namespacesTree', async (req, res) => {
entries[row.parent].children.push(entry); entries[row.parent].children.push(entry);
} else { } else {
roots.push(entry); root = entry;
} }
entry.title = row.name; entry.title = row.name;
entry.key = row.id; entry.key = row.id;
} }
return res.json(roots); return res.json(root);
}); });
router.all('/*', (req, res, next) => { router.all('/*', (req, res, next) => {
@ -101,7 +82,7 @@ router.all('/*', (req, res, next) => {
req.flash('danger', _('Need to be logged in to access restricted content')); req.flash('danger', _('Need to be logged in to access restricted content'));
return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl)); return res.redirect('/users/login?next=' + encodeURIComponent(req.originalUrl));
} }
// res.setSelectedMenu('namespaces'); // res.setSelectedMenu('namespaces'); FIXME
next(); next();
}); });

View file

@ -0,0 +1,44 @@
'use strict';
class InteroperableError extends Error {
constructor(type, msg, data) {
super(msg);
this.type = type;
this.data = data;
}
}
class NotLoggedInError extends InteroperableError {
constructor(msg, data) {
super('NotLoggedIn', msg, data);
}
}
class ChangedError extends InteroperableError {
constructor(msg, data) {
super('ChangedError', msg, data);
}
}
class NotFoundError extends InteroperableError {
constructor(msg, data) {
super('NotFoundError', msg, data);
}
}
const errorTypes = {
InteroperableError,
NotLoggedInError,
ChangedError,
NotFoundError
};
function deserialize(errorObj) {
if (errorObj.type) {
return new errorTypes[errorObj.type](errorObj.message, errorObj.data)
}
}
module.exports = Object.assign({}, errorTypes, {
deserialize
});