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 namespaces = require('./routes/namespaces');
const interoperableErrors = require('./lib/interoperable-errors');
const interoperableErrors = require('./shared/interoperable-errors');
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"]
}

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';
import React, { Component } from 'react';
import axios from 'axios';
import axios from './axios';
import Immutable from 'immutable';
import { translate } from 'react-i18next';
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 = {
Loading: 0,
@ -12,7 +16,10 @@ const FormState = {
Ready: 2
};
@translate()
@withSectionHelpers
@withErrorHandling
class Form extends Component {
static propTypes = {
stateOwner: PropTypes.object.isRequired,
@ -20,28 +27,44 @@ class Form extends Component {
}
static childContextTypes = {
stateOwner: PropTypes.object
formStateOwner: PropTypes.object
}
getChildContext() {
return {
stateOwner: this.props.stateOwner
formStateOwner: this.props.stateOwner
};
}
@withAsyncErrorHandler
async onSubmit(evt) {
evt.preventDefault();
const t = this.props.t;
if (this.props.onSubmitAsync) {
this.props.stateOwner.disableForm();
this.props.stateOwner.setFormStatusMessage(t('Submitting...'));
try {
evt.preventDefault();
await this.props.onSubmitAsync(evt);
if (this.props.onSubmitAsync) {
this.props.stateOwner.disableForm();
this.props.stateOwner.setFormStatusMessage('info', t('Submitting...'));
this.props.stateOwner.setFormStatusMessage();
this.props.stateOwner.enableForm();
await this.props.onSubmitAsync(evt);
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 owner = this.props.stateOwner;
const props = this.props;
const statusMessage = owner.getFormStatusMessage();
const statusMessageText = owner.getFormStatusMessageText();
const statusMessageSeverity = owner.getFormStatusMessageSeverity();
if (!owner.isFormReady()) {
if (owner.isFormWithLoadingNotice()) {
return <div>{t('Loading ...')}</div>
return <p className={`alert alert-info mt-form-status`} role="alert">{t('Loading ...')}</p>
} else {
return <div></div>;
}
@ -63,7 +87,7 @@ class Form extends Component {
<fieldset disabled={owner.isFormDisabled()}>
{props.children}
</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>
);
}
@ -92,17 +116,17 @@ class InputField extends Component {
}
static contextTypes = {
stateOwner: PropTypes.object.isRequired
formStateOwner: PropTypes.object.isRequired
}
render() {
const props = this.props;
const owner = this.context.stateOwner;
const owner = this.context.formStateOwner;
const id = this.props.id;
const htmlId = 'form_' + id;
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 = {
stateOwner: PropTypes.object.isRequired
formStateOwner: PropTypes.object.isRequired
}
render() {
const props = this.props;
const owner = this.context.stateOwner;
const owner = this.context.formStateOwner;
const id = this.props.id;
const htmlId = 'form_' + id;
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 {
static propTypes = {
onClickAsync: PropTypes.func,
@ -153,9 +178,10 @@ class Button extends Component {
}
static contextTypes = {
stateOwner: PropTypes.object.isRequired
formStateOwner: PropTypes.object.isRequired
}
@withAsyncErrorHandler
async onClick(evt) {
if (this.props.onClick) {
evt.preventDefault();
@ -165,9 +191,9 @@ class Button extends Component {
} else if (this.props.onClickAsync) {
evt.preventDefault();
this.context.stateOwner.disableForm();
this.context.formStateOwner.disableForm();
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) {
const inst = target.prototype;
const cleanFormState = Immutable.Map({
state: FormState.Loading,
isValidationShown: false,
isDisabled: false,
statusMessageText: '',
data: Immutable.Map()
});
inst.initFormState = function() {
const state = this.state || {};
state.formState = Immutable.Map({
state: FormState.Loading,
isValidationShown: false,
isDisabled: false,
statusMessage: '',
data: Immutable.Map()
});
state.formState = cleanFormState;
this.state = state;
};
inst.resetFormState = function() {
this.setState({
formState: cleanFormState
});
};
inst.populateFormValuesFromURL = function(url) {
setTimeout(() => {
this.setState(previousState => {
@ -226,7 +294,12 @@ function withForm(target) {
}, 500);
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);
};
@ -322,12 +395,21 @@ function withForm(target) {
return !this.state.formState.get('data').find(attr => attr.get('error'));
};
inst.getFormStatusMessage = function() {
return this.state.formState.get('statusMessage');
inst.getFormStatusMessageText = function() {
return this.state.formState.get('statusMessageText');
};
inst.setFormStatusMessage = function(message) {
this.setState(previousState => ({formState: previousState.formState.set('statusMessage', message)}));
inst.getFormStatusMessageSeverity = function() {
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() {
@ -342,6 +424,7 @@ function withForm(target) {
return this.state.formState.get('isDisabled');
};
return target;
}
@ -351,5 +434,6 @@ export {
InputField,
TextArea,
ButtonRow,
Button
Button,
TreeTableSelect
}

View file

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

View file

@ -1,4 +1,4 @@
.mt-button-row > button{
.mt-button-row > button {
margin-right: 15px;
}
@ -9,4 +9,12 @@
.mt-form-status {
padding-top: 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 { BrowserRouter as Router, Route, Link, Switch } from 'react-router-dom'
import './page.css';
import { withErrorHandling } from './error-handling';
class PageContent extends Component {
static propTypes = {
structure: PropTypes.object.isRequired
}
getRoutes(urlPrefix, children) {
let routes = [];
for (let routeKey in children) {
@ -49,6 +54,10 @@ class PageContent extends Component {
@withRouter
class Breadcrumb extends Component {
static propTypes = {
structure: PropTypes.object.isRequired
}
renderElement(breadcrumbElem) {
if (breadcrumbElem.isActive) {
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()
class Section extends Component {
constructor(props) {
@ -104,18 +220,21 @@ class Section extends Component {
this.structure = structure;
}
static propTypes = {
structure: PropTypes.oneOfType([PropTypes.object, PropTypes.func]).isRequired,
root: PropTypes.string.isRequired
}
render() {
return (
<Router>
<div>
<Breadcrumb structure={this.structure} />
<PageContent structure={this.structure}/>
</div>
<SectionContent root={this.props.root} structure={this.structure} />
</Router>
);
}
}
class Title extends Component {
render() {
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 {
Section,
Title,
Toolbar,
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 { translate } from 'react-i18next';
import csfrToken from 'csfrToken';
import { withForm, Form, InputField, TextArea, ButtonRow, Button} from '../lib/form';
import { withSectionHelpers } from '../lib/page'
import { withForm, Form, InputField, TextArea, ButtonRow, Button, TreeTableSelect } from '../lib/form';
import { Title } from "../lib/page";
import axios from 'axios';
import axios from '../lib/axios';
@translate()
@withForm
@withSectionHelpers
export default class Edit extends Component {
constructor(props) {
super(props);
this.nsId = parseInt(this.props.match.params.nsId);
console.log('Constructing Edit');
this.initFormState();
this.populateFormValuesFromURL(`/namespaces/rest/namespaces/${this.nsId}`);
}
validateFormValues(state) {
const t = this.props.t;
@ -35,17 +36,15 @@ export default class Edit extends Component {
const data = this.getFormValues();
console.log(data);
const response = await axios.put(`/namespaces/rest/namespaces/${this.nsId}`);
console.log(response);
await axios.put(`/namespaces/rest/namespaces/${this.nsId}`, data);
} else {
this.showFormValidation();
}
}
async deleteHandler() {
this.setFormStatusMessage('Deleting namespace')
this.setFormStatusMessage()
this.setFormStatusMessage('Deleting namespace');
this.setFormStatusMessage();
}
render() {
@ -56,13 +55,15 @@ export default class Edit extends Component {
<Title>{t('Edit Namespace')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('Name')} description={t('Namespace Name')}/>
<TextArea id="description" label={t('Description')} description={t('Description')}/>
<InputField id="name" label={t('Name')}/>
<TextArea id="description" label={t('Description')}/>
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Update')}/>
<Button className="btn-danger" icon="remove" label={t('Delete Namespace')} onClickAsync={::this.deleteHandler}/>
</ButtonRow>
{this.nsId !== 1 && <TreeTableSelect id="parent" label={t('Parent Namespace')} dataUrl="/namespaces/rest/namespacesTree"/>}
</Form>
</div>
);

View file

@ -2,14 +2,21 @@
import React, { Component } from 'react';
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()
export default class List extends Component {
render() {
const t = this.props.t;
const actionLinks = [
{
label: 'Edit',
link: key => '/namespaces/edit/' + key
}
];
return (
<div>
<Toolbar>
@ -18,7 +25,7 @@ export default class List extends Component {
<Title>{t('Namespaces')}</Title>
<NamespacesTreeTable />
<TreeTable withHeader dataUrl="/namespaces/rest/namespacesTree" actionLinks={actionLinks} />
</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() {
ReactDOM.render(
<I18nextProvider i18n={ i18n }><Section structure={getStructure}/></I18nextProvider>,
<I18nextProvider i18n={ i18n }><Section root='/namespaces' structure={getStructure}/></I18nextProvider>,
document.getElementById('root')
);
};

View file

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

View file

@ -24,7 +24,8 @@ module.exports = {
filterCustomFields,
getMjmlTemplate,
rollbackAndReleaseConnection,
filterObject
filterObject,
enforce
};
function getDefaultMergeTags(callback) {
@ -305,3 +306,10 @@ function filterObject(obj, allowedKeys) {
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 hasher = require('node-object-hash')();
const { filterObject } = require('../helpers');
const interoperableErrors = require('../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 { enforce, filterObject } = require('../helpers');
const interoperableErrors = require('../../shared/interoperable-errors');
const allowedKeys = new Set(['id', 'name', 'description', 'parent']);
const allowedUpdateKeys = new Set(['name', 'description', 'parent']);
@ -31,7 +19,7 @@ function hash(ns) {
async function getById(nsId) {
const ns = await knex('namespaces').where('id', nsId).first();
if (!ns) {
throw new NotFoundError();
throw new interoperableErrors.NotFoundError();
}
ns.hash = hash(ns);
@ -45,18 +33,20 @@ async function create(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 => {
const existingNs = await tx('namespaces').where('id', ns.id).first();
if (!ns) {
throw new NotFoundError();
throw new interoperableErrors.NotFoundError();
}
const existingNsHash = hash(existingNs);
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 = {
NotFoundError,
ChangedError,
hash,
list,
getById,

View file

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

View file

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