Release candidate of namespace CRUD

This commit is contained in:
Tomas Bures 2017-06-09 00:23:03 +02:00
parent 5b82d3b540
commit 8e54879539
11 changed files with 354 additions and 111 deletions

View file

@ -5,6 +5,7 @@
"main": "index.js",
"scripts": {
"build": "webpack",
"watch": "webpack --watch --watch-poll",
"locales": "for moFile in ../languages/*.mo; do lng=`basename $moFile .mo`; i18next-conv -s $moFile -t locales/$lng/common.json -l $lng; done"
},
"repository": {

173
client/src/lib/bootstrap-components.js vendored Normal file
View file

@ -0,0 +1,173 @@
'use strict';
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import PropTypes from 'prop-types';
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
@translate()
@withErrorHandling
class DismissibleAlert extends Component {
static propTypes = {
severity: PropTypes.string.isRequired,
onCloseAsync: PropTypes.func
}
@withAsyncErrorHandler
onClose() {
if (this.props.onCloseAsync) {
this.props.onCloseAsync();
}
}
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.onClose}><span aria-hidden="true">&times;</span></button>
{this.props.children}
</div>
)
}
}
@withErrorHandling
class Button extends Component {
static propTypes = {
onClickAsync: PropTypes.func,
label: PropTypes.string,
icon: PropTypes.string,
className: PropTypes.string
}
@withAsyncErrorHandler
async onClick(evt) {
if (this.props.onClickAsync) {
evt.preventDefault();
await this.props.onClickAsync(evt);
}
}
render() {
const props = this.props;
let className = 'btn';
if (props.className) {
className = className + ' ' + props.className;
}
let icon;
if (props.icon) {
icon = <span className={'glyphicon glyphicon-' + props.icon}></span>
}
let iconSpacer;
if (props.icon && props.label) {
iconSpacer = ' ';
}
return (
<button type="button" className={className} onClick={::this.onClick}>{icon}{iconSpacer}{props.label}</button>
);
}
}
@translate()
@withErrorHandling
class ModalDialog extends Component {
constructor(props) {
super(props);
const t = props.t;
this.state = {
buttons: this.props.buttons || [ { label: t('Close'), className: 'btn-default', onClickAsync: null } ]
};
this.buttonClicked = null;
}
static propTypes = {
title: PropTypes.string,
onCloseAsync: PropTypes.func,
onButtonClickAsync: PropTypes.func,
buttons: PropTypes.array
}
componentDidMount() {
const jqModal = jQuery(this.domModal);
jqModal.on('shown.bs.modal', () => jqModal.focus());
jqModal.on('hidden.bs.modal', () => this.onHide());
jqModal.modal();
}
close() {
const jqModal = jQuery(this.domModal);
jqModal.modal('hide');
}
@withAsyncErrorHandler
async onHide() {
if (this.buttonClicked === null) {
if (this.props.onCloseAsync) {
await this.props.onCloseAsync();
}
} else {
const idx = this.buttonClicked;
this.buttonClicked = null;
const buttonSpec = this.state.buttons[idx];
if (buttonSpec.onClickAsync) {
await buttonSpec.onClickAsync(idx);
}
}
}
async onButtonClick(idx) {
this.buttonClicked = idx;
const jqModal = jQuery(this.domModal);
jqModal.modal('hide');
}
render() {
const props = this.props;
const t = props.t;
const buttons = [];
for (let idx = 0; idx < this.state.buttons.length; idx++) {
const buttonSpec = this.state.buttons[idx];
const button = <Button key={idx} label={buttonSpec.label} className={buttonSpec.className} onClickAsync={() => this.onButtonClick(idx)} />
buttons.push(button);
}
return (
<div ref={(domElem) => { this.domModal = domElem; }} className="modal fade" tabIndex="-1" role="dialog" aria-labelledby="myModalLabel">
<div className="modal-dialog" role="document">
<div className="modal-content">
<div className="modal-header">
<button type="button" className="close" aria-label={t('Close')} onClick={::this.close}><span aria-hidden="true">&times;</span></button>
<h4 className="modal-title">{this.props.title}</h4>
</div>
<div className="modal-body">{this.props.children}</div>
<div className="modal-footer">
{buttons}
</div>
</div>
</div>
</div>
);
}
}
export {
Button,
DismissibleAlert,
ModalDialog
};

View file

@ -2,9 +2,30 @@
import PropTypes from 'prop-types';
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;
}
return errorHandled;
}
function withErrorHandling(target) {
const inst = target.prototype;
if (inst._withErrorHandlingApplied) return;
inst._withErrorHandlingApplied = true;
const contextTypes = target.contextTypes || {};
contextTypes.parentErrorHandler = PropTypes.object;
target.contextTypes = contextTypes;
@ -28,24 +49,23 @@ function withErrorHandling(target) {
}
}
/* Example of use:
this.getFormValuesFromURL(....).catch(error => this.handleError(error));
It's equivalent to:
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(...);
}
*/
inst.handleError = function(error) {
handleError(this, error);
};
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;

View file

@ -6,7 +6,7 @@ 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 { withPageHelpers } from './page'
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
import { TreeTable, TreeSelectMode } from './tree';
@ -22,7 +22,7 @@ const FormSendMethod = {
};
@translate()
@withSectionHelpers
@withPageHelpers
@withErrorHandling
class Form extends Component {
static propTypes = {
@ -57,9 +57,9 @@ class Form extends Component {
owner.disableForm();
owner.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
<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>
</span>
);
return;
}

View file

@ -8,6 +8,7 @@ import { BrowserRouter as Router, Route, Link, Switch } from 'react-router-dom'
import './page.css';
import { withErrorHandling } from './error-handling';
import interoperableErrors from '../../../shared/interoperable-errors';
import { DismissibleAlert, Button } from './bootstrap-components';
class PageContent extends Component {
@ -106,31 +107,6 @@ 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
@ -146,6 +122,30 @@ class SectionContent extends Component {
this.historyUnlisten = props.history.listen((location, action) => {
this.closeFlashMessage();
})
// -------------------------------------------------------------------------------------------------------
/* FIXME - remove this once we migrate fully to React
This part transforms the flash notice rendered by the server to flash notice managed by React client.
It is used primarily for the login info, but there may be some other cases.
*/
const alrt = jQuery('.container>.alert');
alrt.find('button').remove();
const alrtText = alrt.text();
if (alrtText) {
this.state.flashMessageText = alrtText;
const severityRegex = /alert-([^ ]*)/;
const match = alrt.attr('class').match(severityRegex);
if (match) {
this.state.flashMessageSeverity = match[1];
}
}
alrt.remove();
// -------------------------------------------------------------------------------------------------------
}
static propTypes = {
@ -189,7 +189,7 @@ class SectionContent extends Component {
errorHandler(error) {
if (error instanceof interoperableErrors.NotLoggedInError) {
this.navigateTo('/users/login?next=' + encodeURIComponent(this.props.root));
window.location = '/users/login?next=' + encodeURIComponent(this.props.root);
} else if (error.response && error.response.data && error.response.data.message) {
this.navigateToWithFlashMessage(this.props.root, 'danger', error.response.data.message);
} else {
@ -198,7 +198,7 @@ class SectionContent extends Component {
return true;
}
closeFlashMessage() {
async closeFlashMessage() {
this.setState({
flashMessageText: ''
})
@ -208,7 +208,7 @@ class SectionContent extends Component {
return (
<div>
<Breadcrumb structure={this.props.structure} />
{(this.state.flashMessageText && <DismissibleAlert severity={this.state.flashMessageSeverity} onClose={::this.closeFlashMessage}>{this.state.flashMessageText}</DismissibleAlert>)}
{(this.state.flashMessageText && <DismissibleAlert severity={this.state.flashMessageSeverity} onCloseAsync={::this.closeFlashMessage}>{this.state.flashMessageText}</DismissibleAlert>)}
<PageContent structure={this.props.structure}/>
</div>
);
@ -281,46 +281,10 @@ class NavButton extends Component {
}
}
class Button extends Component {
static propTypes = {
onClick: PropTypes.func,
label: PropTypes.string,
icon: PropTypes.string,
className: PropTypes.string
}
async onClick(evt) {
if (this.props.onClick) {
evt.preventDefault();
onClick(evt);
}
}
function withPageHelpers(target) {
withErrorHandling(target);
render() {
const props = this.props;
let className = 'btn';
if (props.className) {
className = className + ' ' + props.className;
}
let icon;
if (props.icon) {
icon = <span className={'glyphicon glyphicon-' + props.icon}></span>
}
let iconSpacer;
if (props.icon && props.label) {
iconSpacer = ' ';
}
return (
<button type="button" className={className} onClick={::this.onClick}>{icon}{iconSpacer}{props.label}</button>
);
}
}
function withSectionHelpers(target) {
const inst = target.prototype;
const contextTypes = target.contextTypes || {};
@ -349,6 +313,8 @@ function withSectionHelpers(target) {
return this.context.sectionContent.navigateToWithFlashMessage(path, severity, text);
}
inst.axios
return target;
}
@ -356,7 +322,6 @@ export {
Section,
Title,
Toolbar,
Button,
NavButton,
withSectionHelpers
withPageHelpers
};

View file

@ -7,6 +7,10 @@
color: #333333;
}
.mt-treetable-container span.fancytree-node span.fancytree-expander:hover {
color: inherit;
}
.mt-treetable-container {
padding-top: 9px;
padding-bottom: 9px;

View file

@ -12,7 +12,7 @@ import '../../public/fancytree/skin-bootstrap/ui.fancytree.min.css';
import './tree.css';
import axios from './axios';
import { withSectionHelpers } from '../lib/page'
import { withPageHelpers } from '../lib/page'
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
const TreeSelectMode = {
@ -22,7 +22,7 @@ const TreeSelectMode = {
};
@translate()
@withSectionHelpers
@withPageHelpers
@withErrorHandling
class TreeTable extends Component {
constructor(props) {
@ -46,12 +46,17 @@ class TreeTable extends Component {
@withAsyncErrorHandler
async loadData(dataUrl) {
axios.get(dataUrl)
.then(response => {
this.setState({
treeData: [ response.data ]
});
});
const response = await axios.get(dataUrl);
const treeData = response.data;
treeData.expanded = true;
for (const child of treeData.children) {
child.expanded = true;
}
this.setState({
treeData: [ response.data ]
});
}
static propTypes = {

View file

@ -2,21 +2,23 @@
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { withSectionHelpers, Title } from '../lib/page'
import { withPageHelpers, Title } from '../lib/page'
import { withForm, Form, FormSendMethod, InputField, TextArea, ButtonRow, Button, TreeTableSelect } from '../lib/form';
import axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import interoperableErrors from '../../../shared/interoperable-errors';
import { ModalDialog } from "../lib/bootstrap-components";
@translate()
@withForm
@withSectionHelpers
@withPageHelpers
@withErrorHandling
export default class CreateOrEdit extends Component {
export default class CUD extends Component {
constructor(props) {
super(props);
this.initFormState();
this.hasChildren = false;
}
isEditGlobal() {
@ -28,6 +30,10 @@ export default class CreateOrEdit extends Component {
const entry = data[idx];
if (entry.key === this.nsId) {
if (entry.children.length > 0) {
this.hasChildren = true;
}
data.splice(idx, 1);
return true;
}
@ -43,6 +49,7 @@ export default class CreateOrEdit extends Component {
axios.get("/namespaces/rest/namespacesTree")
.then(response => {
response.data.expanded = true;
const data = [response.data];
if (this.props.edit && !this.isEditGlobal()) {
@ -55,14 +62,19 @@ export default class CreateOrEdit extends Component {
});
}
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(`/namespaces/rest/namespaces/${this.nsId}`, data => {
if (data.parent) data.parent = data.parent.toString();
});
}
componentDidMount() {
const edit = this.props.edit;
if (edit) {
this.nsId = parseInt(this.props.match.params.nsId);
this.getFormValuesFromURL(`/namespaces/rest/namespaces/${this.nsId}`, data => {
if (data.parent) data.parent = data.parent.toString();
});
this.loadFormValues();
} else {
this.populateFormValues({
name: '',
@ -138,17 +150,63 @@ export default class CreateOrEdit extends Component {
}
}
async deleteHandler() {
this.setFormStatusMessage('Deleting namespace');
this.setFormStatusMessage();
async showDeleteModal() {
this.setState({
deleteConfirmationShown: true
});
}
async hideDeleteModal() {
this.setState({
deleteConfirmationShown: false
});
}
async performDelete() {
await this.hideDeleteModal();
const t = this.props.t;
try {
this.disableForm();
this.setFormStatusMessage('info', t('Deleting namespace...'));
await axios.delete(`/namespaces/rest/namespaces/${this.nsId}`);
this.navigateToWithFlashMessage('/namespaces', 'success', t('Namespace deleted'));
} catch (error) {
if (error instanceof interoperableErrors.ChildDetectedError) {
this.disableForm();
this.setFormStatusMessage('danger',
<span>
<strong>{t('The namespace cannot be deleted.')}</strong>{' '}
{t('There has been a child namespace found. This is most likely because someone else has changed the parent of some namespace in the meantime. Refresh your page to start anew with fresh data.')}
</span>
);
return;
}
throw error;
}
}
render() {
const t = this.props.t;
const edit = this.props.edit;
const deleteConfirmationShown = this.state.deleteConfirmationShown;
return (
<div>
{!this.isEditGlobal() && deleteConfirmationShown &&
<ModalDialog title={t('Confirm deletion')} onCloseAsync={::this.hideDeleteModal} buttons={[
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteModal },
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performDelete }
]}>
{t('Are you sure you want to delete namespace "{{namespace}}"?', {namespace: this.getFormValue('name')})}
</ModalDialog>
}
<Title>{edit ? t('Edit Namespace') : t('Create Namespace')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
@ -160,8 +218,8 @@ export default class CreateOrEdit extends Component {
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{edit && <Button className="btn-danger" icon="remove" label={t('Delete Namespace')}
onClickAsync={::this.deleteHandler}/>}
{!this.isEditGlobal() && !this.hasChildren && edit && <Button className="btn-danger" icon="remove" label={t('Delete Namespace')}
onClickAsync={::this.showDeleteModal}/>}
</ButtonRow>
</Form>
</div>

View file

@ -6,7 +6,7 @@ import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n';
import { Section } from '../lib/page'
import CreateOrEdit from './CreateOrEdit'
import CUD from './CUD'
import List from './List'
const getStructure = t => ({
@ -22,12 +22,12 @@ const getStructure = t => ({
'edit' : {
title: t('Edit Namespace'),
params: [':nsId'],
render: props => (<CreateOrEdit edit {...props} />)
render: props => (<CUD edit {...props} />)
},
'create' : {
title: t('Create Namespace'),
link: '/namespaces/create',
render: props => (<CreateOrEdit {...props} />)
render: props => (<CUD {...props} />)
}
}
}

View file

@ -59,7 +59,16 @@ async function updateWithConsistencyCheck(ns) {
}
async function remove(nsId) {
await knex('namespaces').where('id', nsId).del();
enforce(nsId !== 1, 'Cannot delete the root namespace.');
await knex.transaction(async tx => {
const childNs = await tx('namespaces').where('parent', nsId).first();
if (childNs) {
throw new interoperableErrors.ChildDetectedError();
}
await tx('namespaces').where('id', nsId).del();
});
}
module.exports = {

View file

@ -32,12 +32,20 @@ class LoopDetectedError extends InteroperableError {
}
}
class ChildDetectedError extends InteroperableError {
constructor(msg, data) {
super('ChildDetectedError', msg, data);
}
}
const errorTypes = {
InteroperableError,
NotLoggedInError,
ChangedError,
NotFoundError,
LoopDetectedError
LoopDetectedError,
ChildDetectedError
};
function deserialize(errorObj) {