Fluid layout

Reworked routing and breadcrumb mechanism. It allows resolved parameters in paths, which allows including names of entities in the breadcrumb.
Secondary navigation which is aware of permissions.
This commit is contained in:
Tomas Bures 2017-08-11 18:16:44 +02:00
parent 86fce404a9
commit 602364caae
33 changed files with 808 additions and 907 deletions

View file

@ -31,14 +31,14 @@ const getStructure = t => {
if (mailtrainConfig.isAuthMethodLocal) { if (mailtrainConfig.isAuthMethodLocal) {
subPaths.forgot = { subPaths.forgot = {
title: t('Password reset'), title: t('Password reset'),
params: [':username?'], extraParams: [':username?'],
link: '/account/forgot', link: '/account/forgot',
component: Reset component: Reset
}; };
subPaths.reset = { subPaths.reset = {
title: t('Password reset'), title: t('Password reset'),
params: [':username', ':resetToken'], extraParams: [':username', ':resetToken'],
link: '/account/reset', link: '/account/reset',
component: ResetLink component: ResetLink
}; };

View file

@ -559,6 +559,7 @@ class ACEEditor extends Component {
} }
} }
function withForm(target) { function withForm(target) {
const inst = target.prototype; const inst = target.prototype;
@ -672,20 +673,8 @@ function withForm(target) {
}); });
}; };
inst.getFormValuesFromURL = async function(url, mutator) { inst.getFormValuesFromEntity = function(entity, mutator) {
setTimeout(() => { const data = Object.assign({}, entity);
this.setState(previousState => {
if (previousState.formState.get('state') === FormState.Loading) {
return {
formState: previousState.formState.set('state', FormState.LoadingWithNotice)
};
}
});
}, 500);
const response = await axios.get(url);
const data = response.data;
data.originalHash = data.hash; data.originalHash = data.hash;
delete data.hash; delete data.hash;
@ -697,330 +686,6 @@ function withForm(target) {
this.populateFormValues(data); this.populateFormValues(data);
}; };
inst.validateAndSendFormValuesToURL = async function(method, url, mutator) {
await this.waitForFormServerValidated();
if (this.isFormWithoutErrors()) {
const data = this.getFormValues();
if (mutator) {
mutator(data);
}
let response;
if (method === FormSendMethod.PUT) {
response = await axios.put(url, data);
} else if (method === FormSendMethod.POST) {
response = await axios.post(url, data);
}
return response.data || true;
} else {
this.showFormValidation();
return false;
}
};
inst.populateFormValues = function(data) {
this.setState(previousState => ({
formState: previousState.formState.withMutations(mutState => {
mutState.set('state', FormState.Ready);
mutState.update('data', stateData => stateData.withMutations(mutStateData => {
for (const key in data) {
mutStateData.set(key, Immutable.Map({
value: data[key]
}));
}
}));
validateFormState(this, mutState);
})
}));
};
inst.waitForFormServerValidated = async function() {
if (!this.isFormServerValidated()) {
await new Promise(resolve => { formValidateResolve = resolve; });
}
};
inst.scheduleFormRevalidate = function() {
scheduleValidateForm(this);
};
inst.updateFormValue = function(key, value) {
this.setState(previousState => {
const oldValue = previousState.formState.getIn(['data', key, 'value']);
let newState = {
formState: previousState.formState.withMutations(mutState => {
mutState.setIn(['data', key, 'value'], value);
validateFormState(this, mutState);
})
};
const onChangeCallbacks = this.state.formSettings.onChange || {};
if (onChangeCallbacks[key]) {
onChangeCallbacks[key](newState, key, oldValue, value);
}
return newState;
});
};
inst.getFormValue = function(name) {
return this.state.formState.getIn(['data', name, 'value']);
};
inst.getFormValues = function(name) {
return this.state.formState.get('data').map(attr => attr.get('value')).toJS();
};
inst.getFormError = function(name) {
return this.state.formState.getIn(['data', name, 'error']);
};
inst.isFormWithLoadingNotice = function() {
return this.state.formState.get('state') === FormState.LoadingWithNotice;
};
inst.isFormLoading = function() {
return this.state.formState.get('state') === FormState.Loading || this.state.formState.get('state') === FormState.LoadingWithNotice;
};
inst.isFormReady = function() {
return this.state.formState.get('state') === FormState.Ready;
};
inst.isFormValidationShown = function() {
return this.state.formState.get('isValidationShown');
};
inst.addFormValidationClass = function(className, name) {
if (this.isFormValidationShown()) {
const error = this.getFormError(name);
if (error) {
return className + ' has-error';
} else {
return className + ' has-success';
}
} else {
return className;
}
};
inst.getFormValidationMessage = function(name) {
if (this.isFormValidationShown()) {
return this.getFormError(name);
} else {
return '';
}
};
inst.showFormValidation = function() {
this.setState(previousState => ({formState: previousState.formState.set('isValidationShown', true)}));
};
inst.hideFormValidation = function() {
this.setState(previousState => ({formState: previousState.formState.set('isValidationShown', false)}));
};
inst.isFormWithoutErrors = function() {
return !this.state.formState.get('data').find(attr => attr.get('error'));
};
inst.isFormServerValidated = function() {
return !this.state.formSettings.serverValidation || this.state.formSettings.serverValidation.changed.every(attr => this.state.formState.getIn(['data', attr, 'serverValidated']));
};
inst.getFormStatusMessageText = function() {
return this.state.formState.get('statusMessageText');
};
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.clearFormStatusMessage = function() {
this.setState(previousState => ({
formState: previousState.formState.withMutations(map => {
map.set('statusMessageText', '');
})
}));
};
inst.enableForm = function() {
this.setState(previousState => ({formState: previousState.formState.set('isDisabled', false)}));
};
inst.disableForm = function() {
this.setState(previousState => ({formState: previousState.formState.set('isDisabled', true)}));
};
inst.isFormDisabled = function() {
return this.state.formState.get('isDisabled');
};
inst.formHandleChangedError = async function(fn) {
const t = this.props.t;
try {
await fn();
} catch (error) {
if (error instanceof interoperableErrors.ChangedError) {
this.disableForm();
this.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;
}
if (error instanceof interoperableErrors.NotFoundError) {
this.disableForm();
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('It seems that someone else has deleted the entity in the meantime.')}
</span>
);
return;
}
throw error;
}
};
return target;
}
function withForm(target) {
const inst = target.prototype;
const cleanFormState = Immutable.Map({
state: FormState.Loading,
isValidationShown: false,
isDisabled: false,
statusMessageText: '',
data: Immutable.Map(),
isServerValidationRunning: false
});
// formValidateResolve is called by "validateForm" once client receives validation response from server that does not
// trigger another server validation
let formValidateResolve = null;
function scheduleValidateForm(self) {
setTimeout(() => {
self.setState(previousState => ({
formState: previousState.formState.withMutations(mutState => {
validateFormState(self, mutState);
})
}));
}, 0);
}
function validateFormState(self, mutState) {
const settings = self.state.formSettings;
if (!mutState.get('isServerValidationRunning') && settings.serverValidation) {
const payload = {};
let payloadNotEmpty = false;
for (const attr of settings.serverValidation.extra || []) {
payload[attr] = mutState.getIn(['data', attr, 'value']);
}
for (const attr of settings.serverValidation.changed) {
const currValue = mutState.getIn(['data', attr, 'value']);
const serverValue = mutState.getIn(['data', attr, 'serverValue']);
// This really assumes that all form values are preinitialized (i.e. not undef)
if (currValue !== serverValue) {
mutState.setIn(['data', attr, 'serverValidated'], false);
payload[attr] = currValue;
payloadNotEmpty = true;
}
}
if (payloadNotEmpty) {
mutState.set('isServerValidationRunning', true);
axios.post(settings.serverValidation.url, payload)
.then(response => {
self.setState(previousState => ({
formState: previousState.formState.withMutations(mutState => {
mutState.set('isServerValidationRunning', false);
mutState.update('data', stateData => stateData.withMutations(mutStateData => {
for (const attr in payload) {
mutStateData.setIn([attr, 'serverValue'], payload[attr]);
if (payload[attr] === mutState.getIn(['data', attr, 'value'])) {
mutStateData.setIn([attr, 'serverValidated'], true);
mutStateData.setIn([attr, 'serverValidation'], response.data[attr] || true);
}
}
}));
})
}));
scheduleValidateForm(self);
})
.catch(error => {
console.log('Error in "validateFormState": ' + error);
self.setState(previousState => ({
formState: previousState.formState.set('isServerValidationRunning', false)
}));
// TODO: It might be good not to give up immediatelly, but retry a couple of times
// scheduleValidateForm(self);
});
} else {
if (formValidateResolve) {
const resolve = formValidateResolve;
formValidateResolve = null;
resolve();
}
}
}
if (self.localValidateFormValues) {
mutState.update('data', stateData => stateData.withMutations(mutStateData => {
self.localValidateFormValues(mutStateData);
}));
}
}
inst.initForm = function(settings) {
const state = this.state || {};
state.formState = cleanFormState;
state.formSettings = settings || {};
this.state = state;
};
inst.resetFormState = function() {
this.setState({
formState: cleanFormState
});
};
inst.getFormValuesFromURL = async function(url, mutator) { inst.getFormValuesFromURL = async function(url, mutator) {
setTimeout(() => { setTimeout(() => {
this.setState(previousState => { this.setState(previousState => {

View file

@ -43,4 +43,28 @@
h3.legend { h3.legend {
font-size: 21px; font-size: 21px;
margin-bottom: 20px; margin-bottom: 20px;
}
.mt-secondary-nav {
margin-top: 5px;
margin-right: 5px;
text-align: right;
}
@media (max-width: 767px) {
.mt-secondary-nav {
margin: 0px;
background-color: #f5f5f5;
padding: 8px 5px;
border-radius: 4px;
}
}
.mt-secondary-nav > li {
display: inline-block;
float: none;
}
.mt-secondary-nav > li > a {
padding: 3px 10px;
} }

View file

@ -4,120 +4,235 @@ import React, { Component } from 'react';
import { translate } from 'react-i18next'; import { translate } from 'react-i18next';
import PropTypes from 'prop-types'; 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, Redirect} from 'react-router-dom'
import './page.css'; import './page.css';
import { withErrorHandling } from './error-handling'; import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
import interoperableErrors from '../../../shared/interoperable-errors'; import interoperableErrors from '../../../shared/interoperable-errors';
import { DismissibleAlert, Button } from './bootstrap-components'; import { DismissibleAlert, Button } from './bootstrap-components';
import mailtrainConfig from 'mailtrainConfig'; import mailtrainConfig from 'mailtrainConfig';
import axios from '../lib/axios';
class PageContent extends Component {
static propTypes = {
structure: PropTypes.object.isRequired
}
getRoutes(urlPrefix, children) {
let routes = [];
for (let routeKey in children) {
const structure = children[routeKey];
let path = urlPrefix + routeKey;
let pathWithParams = path;
if (structure.params) {
pathWithParams = pathWithParams + '/' + structure.params.join('/');
}
if (structure.component || structure.render) {
const route = {
component: structure.component,
render: structure.render,
path: (pathWithParams === '' ? '/' : pathWithParams)
};
routes.push(route);
}
if (structure.children) {
routes = routes.concat(this.getRoutes(path + '/', structure.children));
}
}
return routes;
}
renderRoute(route) {
if (route.component) {
return <Route key={route.path} exact path={route.path} component={route.component} />;
} else if (route.render) {
return <Route key={route.path} exact path={route.path} render={route.render} />;
}
}
render() {
let routes = this.getRoutes('', this.props.structure);
return <Switch>{routes.map(x => this.renderRoute(x))}</Switch>;
}
}
@withRouter
class Breadcrumb extends Component { class Breadcrumb extends Component {
static propTypes = { static propTypes = {
structure: PropTypes.object.isRequired route: PropTypes.object.isRequired,
params: PropTypes.object.isRequired,
resolved: PropTypes.object.isRequired
} }
renderElement(breadcrumbElem) { renderElement(entry, isActive) {
if (breadcrumbElem.isActive) { const params = this.props.params;
return <li key={breadcrumbElem.idx} className="active">{breadcrumbElem.title}</li>; let title;
if (typeof entry.title === 'function') {
title = entry.title(this.props.resolved);
} else {
title = entry.title;
}
} else if (breadcrumbElem.externalLink) { if (isActive) {
return <li key={breadcrumbElem.idx}><a href={breadcrumbElem.externalLink}>{breadcrumbElem.title}</a></li>; return <li key={entry.path} className="active">{title}</li>;
} else if (breadcrumbElem.link) { } else if (entry.externalLink) {
let link; let externalLink;
if (typeof breadcrumbElem.link === 'function') { if (typeof entry.externalLink === 'function') {
link = breadcrumbElem.link(this.props.match); externalLink = entry.externalLink(params);
} else { } else {
link = breadcrumbElem.link; externalLink = entry.externalLink;
} }
return <li key={breadcrumbElem.idx}><Link to={link}>{breadcrumbElem.title}</Link></li>;
return <li key={entry.path}><a href={externalLink}>{title}</a></li>;
} else if (entry.link) {
let link;
if (typeof entry.link === 'function') {
link = entry.link(params);
} else {
link = entry.link;
}
return <li key={entry.path}><Link to={link}>{title}</Link></li>;
} else { } else {
return <li key={breadcrumbElem.idx}>{breadcrumbElem.title}</li>; return <li key={entry.path}>{title}</li>;
} }
} }
render() { render() {
const location = this.props.location.pathname; const route = this.props.route;
const locationElems = location.split('/');
let breadcrumbElems = []; const renderedElems = [...route.parents.map(x => this.renderElement(x)), this.renderElement(route, true)];
let children = this.props.structure;
for (let idx = 0; idx < locationElems.length; idx++) {
const breadcrumbElem = children[locationElems[idx]];
if (!breadcrumbElem) {
break;
}
breadcrumbElem.isActive = (idx === locationElems.length - 1);
breadcrumbElem.idx = idx;
breadcrumbElems.push(breadcrumbElem);
children = breadcrumbElem.children;
if (!children) {
break;
}
}
const renderedElems = breadcrumbElems.map(x => this.renderElement(x));
return <ol className="breadcrumb">{renderedElems}</ol>; return <ol className="breadcrumb">{renderedElems}</ol>;
} }
} }
class SecondaryNavBar extends Component {
static propTypes = {
route: PropTypes.object.isRequired,
params: PropTypes.object.isRequired,
resolved: PropTypes.object.isRequired,
className: PropTypes.string
}
renderElement(key, entry) {
const params = this.props.params;
let title;
if (typeof entry.title === 'function') {
title = entry.title(this.props.resolved);
} else {
title = entry.title;
}
let className = '';
if (entry.active) {
className += ' active';
}
if (entry.link) {
let link;
if (typeof entry.link === 'function') {
link = entry.link(params);
} else {
link = entry.link;
}
return <li key={key} role="presentation" className={className}><Link to={link}>{title}</Link></li>;
} else if (entry.externalLink) {
let externalLink;
if (typeof entry.externalLink === 'function') {
externalLink = entry.externalLink(params);
} else {
externalLink = entry.externalLink;
}
return <li key={key} role="presentation" className={className}><a href={externalLink}>{title}</a></li>;
} else {
return <li key={key} role="presentation" className={className}>{title}</li>;
}
}
render() {
const route = this.props.route;
const keys = Object.keys(route.navs);
const renderedElems = [];
for (const key in keys) {
const entry = route.navs[key];
let visible = true;
if (typeof entry.visible === 'function') {
visible = entry.visible(this.props.resolved);
}
if (visible) {
renderedElems.push(this.renderElement(key, entry));
}
}
let className = 'mt-secondary-nav nav nav-pills';
if (this.props.className) {
className += ' ' + this.props.className;
}
return <ul className={className}>{renderedElems}</ul>;
}
}
@translate()
@withErrorHandling
class RouteContent extends Component {
constructor(props) {
super(props);
this.state = {};
if (Object.keys(props.route.resolve).length === 0) {
this.state.resolved = {};
}
}
static propTypes = {
route: PropTypes.object.isRequired,
flashMessage: PropTypes.object
}
@withAsyncErrorHandler
async resolve() {
const route = this.props.route;
const keys = Object.keys(route.resolve);
if (keys.length > 0) {
const promises = keys.map(key => axios.get(route.resolve[key](this.props.match.params)));
const resolvedArr = await Promise.all(promises);
const resolved = {};
for (let idx = 0; idx < keys.length; idx++) {
resolved[keys[idx]] = resolvedArr[idx].data;
}
this.setState({
resolved
});
}
}
componentDidMount() {
this.resolve();
}
render() {
const t = this.props.t;
const route = this.props.route;
const params = this.props.match.params;
const resolved = this.state.resolved;
if (!route.render && !route.component && route.link) {
let link;
if (typeof route.link === 'function') {
link = route.link(params);
} else {
link = route.link;
}
return <Redirect to={link}/>;
} else {
if (resolved) {
const compProps = {
match: this.props.match,
location: this.props.location,
resolved
};
let component;
if (route.render) {
component = route.render(compProps);
} else if (route.component) {
component = React.createElement(route.component, compProps, null);
}
return (
<div>
<div>
<SecondaryNavBar className="hidden-xs pull-right" route={route} params={params} resolved={resolved}/>
<Breadcrumb route={route} params={params} resolved={resolved}/>
<SecondaryNavBar className="visible-xs" route={route} params={params} resolved={resolved}/>
</div>
{this.props.flashMessage}
{component}
</div>
);
} else {
return <div>{t('Loading...')}</div>;
}
}
}
}
@withRouter @withRouter
@ -229,13 +344,93 @@ class SectionContent extends Component {
}) })
} }
getRoutes(urlPrefix, resolve, parents, structure, navs) {
let routes = [];
for (let routeKey in structure) {
const entry = structure[routeKey];
let path = urlPrefix + routeKey;
let pathWithParams = path;
if (entry.extraParams) {
pathWithParams = pathWithParams + '/' + entry.extraParams.join('/');
}
let entryResolve;
if (entry.resolve) {
entryResolve = Object.assign({}, resolve, entry.resolve);
} else {
entryResolve = resolve;
}
let navKeys;
const entryNavs = [];
if (entry.navs) {
navKeys = Object.keys(entry.navs);
for (const navKey of navKeys) {
const nav = entry.navs[navKey];
entryNavs.push({
title: nav.title,
visible: nav.visible,
link: nav.link,
externalLink: nav.externalLink
});
}
}
const route = {
path: (pathWithParams === '' ? '/' : pathWithParams),
component: entry.component,
render: entry.render,
title: entry.title,
link: entry.link,
resolve: entryResolve,
parents,
navs: [...navs, ...entryNavs]
};
routes.push(route);
const childrenParents = [...parents, route];
if (entry.navs) {
for (let navKeyIdx = 0; navKeyIdx < navKeys.length; navKeyIdx++) {
const navKey = navKeys[navKeyIdx];
const nav = entry.navs[navKey];
const childNavs = [...entryNavs];
childNavs[navKeyIdx] = Object.assign({}, childNavs[navKeyIdx], { active: true });
routes = routes.concat(this.getRoutes(path + '/', entryResolve, childrenParents, { [navKey]: nav }, childNavs));
}
}
if (entry.children) {
routes = routes.concat(this.getRoutes(path + '/', entryResolve, childrenParents, entry.children, entryNavs));
}
}
return routes;
}
renderRoute(route) {
let flashMessage;
if (this.state.flashMessageText) {
flashMessage = <DismissibleAlert severity={this.state.flashMessageSeverity} onCloseAsync={::this.closeFlashMessage}>{this.state.flashMessageText}</DismissibleAlert>;
}
const render = props => <RouteContent route={route} flashMessage={flashMessage} {...props}/>;
return <Route key={route.path} exact path={route.path} render={render} />
}
render() { render() {
let routes = this.getRoutes('', {}, [], this.props.structure, []);
return ( return (
<div> <Switch>{routes.map(x => this.renderRoute(x))}</Switch>
<Breadcrumb structure={this.props.structure} />
{(this.state.flashMessageText && <DismissibleAlert severity={this.state.flashMessageSeverity} onCloseAsync={::this.closeFlashMessage}>{this.state.flashMessageText}</DismissibleAlert>)}
<PageContent structure={this.props.structure}/>
</div>
); );
} }
} }
@ -280,9 +475,18 @@ class Title extends Component {
} }
class Toolbar extends Component { class Toolbar extends Component {
static propTypes = {
className: PropTypes.string,
};
render() { render() {
let className = 'pull-right mt-button-row';
if (this.props.className) {
className += ' ' + this.props.className;
}
return ( return (
<div className="pull-right mt-button-row"> <div className={className}>
{this.props.children} {this.props.children}
</div> </div>
); );

View file

@ -8,7 +8,7 @@ import {
withForm, Form, FormSendMethod, InputField, TextArea, TableSelect, ButtonRow, Button, withForm, Form, FormSendMethod, InputField, TextArea, TableSelect, ButtonRow, Button,
Dropdown, StaticField, CheckBox Dropdown, StaticField, CheckBox
} from '../lib/form'; } from '../lib/form';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling'; import { withErrorHandling } from '../lib/error-handling';
import { DeleteModalDialog } from '../lib/delete'; import { DeleteModalDialog } from '../lib/delete';
import { validateNamespace, NamespaceSelect } from '../lib/namespace'; import { validateNamespace, NamespaceSelect } from '../lib/namespace';
import { UnsubscriptionMode } from '../../../shared/lists'; import { UnsubscriptionMode } from '../../../shared/lists';
@ -24,27 +24,19 @@ export default class CUD extends Component {
this.state = {}; this.state = {};
if (props.edit) {
this.state.entityId = parseInt(props.match.params.id);
}
this.initForm(); this.initForm();
} }
static propTypes = { static propTypes = {
edit: PropTypes.bool action: PropTypes.string.isRequired,
entity: PropTypes.object
} }
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(`/rest/lists/${this.state.entityId}`, data => {
data.form = data.default_form ? 'custom' : 'default';
});
}
componentDidMount() { componentDidMount() {
if (this.props.edit) { if (this.props.entity) {
this.loadFormValues(); this.getFormValuesFromEntity(this.props.entity, data => {
data.form = data.default_form ? 'custom' : 'default';
});
} else { } else {
this.populateFormValues({ this.populateFormValues({
name: '', name: '',
@ -60,7 +52,6 @@ export default class CUD extends Component {
localValidateFormValues(state) { localValidateFormValues(state) {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit;
if (!state.getIn(['name', 'value'])) { if (!state.getIn(['name', 'value'])) {
state.setIn(['name', 'error'], t('Name must not be empty')); state.setIn(['name', 'error'], t('Name must not be empty'));
@ -79,12 +70,11 @@ export default class CUD extends Component {
async submitHandler() { async submitHandler() {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit;
let sendMethod, url; let sendMethod, url;
if (edit) { if (this.props.entity) {
sendMethod = FormSendMethod.PUT; sendMethod = FormSendMethod.PUT;
url = `/rest/lists/${this.state.entityId}` url = `/rest/lists/${this.props.entity.id}`
} else { } else {
sendMethod = FormSendMethod.POST; sendMethod = FormSendMethod.POST;
url = '/rest/lists' url = '/rest/lists'
@ -110,7 +100,7 @@ export default class CUD extends Component {
render() { render() {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit; const isEdit = !!this.props.entity;
const unsubcriptionModeOptions = [ const unsubcriptionModeOptions = [
{ {
@ -144,7 +134,7 @@ export default class CUD extends Component {
key: 'custom', key: 'custom',
label: t('Custom Forms (select form below)') label: t('Custom Forms (select form below)')
} }
] ];
const customFormsColumns = [ const customFormsColumns = [
{data: 0, title: "#"}, {data: 0, title: "#"},
@ -155,23 +145,23 @@ export default class CUD extends Component {
return ( return (
<div> <div>
{edit && {isEdit &&
<DeleteModalDialog <DeleteModalDialog
stateOwner={this} stateOwner={this}
visible={this.props.match.params.action === 'delete'} visible={this.props.action === 'delete'}
deleteUrl={`/rest/lists/${this.state.entityId}`} deleteUrl={`/rest/lists/${this.props.entity.id}`}
cudUrl={`/lists/edit/${this.state.entityId}`} cudUrl={`/lists/${this.props.entity.id}/edit`}
listUrl="/lists" listUrl="/lists"
deletingMsg={t('Deleting list ...')} deletingMsg={t('Deleting list ...')}
deletedMsg={t('List deleted')}/> deletedMsg={t('List deleted')}/>
} }
<Title>{edit ? t('Edit List') : t('Create List')}</Title> <Title>{isEdit ? t('Edit List') : t('Create List')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}> <Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('Name')}/> <InputField id="name" label={t('Name')}/>
{edit && {isEdit &&
<StaticField id="cid" label="List ID" help={t('This is the list ID displayed to the subscribers')}> <StaticField id="cid" label="List ID" help={t('This is the list ID displayed to the subscribers')}>
{this.getFormValue('cid')} {this.getFormValue('cid')}
</StaticField> </StaticField>
@ -184,7 +174,7 @@ export default class CUD extends Component {
<Dropdown id="form" label={t('Forms')} options={formsOptions} help={t('Web and email forms and templates used in subscription management process.')}/> <Dropdown id="form" label={t('Forms')} options={formsOptions} help={t('Web and email forms and templates used in subscription management process.')}/>
{this.getFormValue('form') === 'custom' && {this.getFormValue('form') === 'custom' &&
<TableSelect id="default_form" label={t('Custom Forms')} withHeader dropdown dataUrl='/rest/forms-table' columns={customFormsColumns} selectionLabelIndex={1} help={<Trans>The custom form used for this list. You can create a form <a href={`/lists/forms/create/${this.state.entityId}`}>here</a>.</Trans>}/> <TableSelect id="default_form" label={t('Custom Forms')} withHeader dropdown dataUrl='/rest/forms-table' columns={customFormsColumns} selectionLabelIndex={1} help={<Trans>The custom form used for this list. You can create a form <a href={`/lists/forms/create/${this.props.entity.id}`}>here</a>.</Trans>}/>
} }
<CheckBox id="public_subscribe" label={t('Subscription')} text={t('Allow public users to subscribe themselves')}/> <CheckBox id="public_subscribe" label={t('Subscription')} text={t('Allow public users to subscribe themselves')}/>
@ -194,7 +184,7 @@ export default class CUD extends Component {
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/> <Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{edit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/lists/edit/${this.state.entityId}/delete`}/>} {isEdit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/lists/${this.props.entity.id}/delete`}/>}
</ButtonRow> </ButtonRow>
</Form> </Form>
</div> </div>

View file

@ -48,21 +48,21 @@ export default class List extends Component {
if (perms.includes('edit')) { if (perms.includes('edit')) {
actions.push({ actions.push({
label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>, label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>,
link: '/lists/edit/' + data[0] link: `/lists/${data[0]}/edit`
});
}
if (perms.includes('share')) {
actions.push({
label: <span className="glyphicon glyphicon-share-alt" aria-hidden="true" title="Share"></span>,
link: '/lists/share/' + data[0]
}); });
} }
if (perms.includes('manageFields')) { if (perms.includes('manageFields')) {
actions.push({ actions.push({
label: <span className="glyphicon glyphicon-th-list" aria-hidden="true" title="Manage Fields"></span>, label: <span className="glyphicon glyphicon-th-list" aria-hidden="true" title="Manage Fields"></span>,
link: '/lists/fields/' + data[0] link: `/lists/${data[0]}/fields`
});
}
if (perms.includes('share')) {
actions.push({
label: <span className="glyphicon glyphicon-share-alt" aria-hidden="true" title="Share"></span>,
link: `/lists/${data[0]}/share`
}); });
} }
@ -70,7 +70,6 @@ export default class List extends Component {
}; };
const columns = [ const columns = [
{ data: 0, title: "#" },
{ data: 1, title: t('Name') }, { data: 1, title: t('Name') },
{ data: 2, title: t('ID'), render: data => `<code>${data}</code>` }, { data: 2, title: t('ID'), render: data => `<code>${data}</code>` },
{ data: 3, title: t('Subscribers') }, { data: 3, title: t('Subscribers') },

View file

@ -26,17 +26,11 @@ export default class CUD extends Component {
this.state = {}; this.state = {};
this.state.listId = parseInt(props.match.params.listId);
if (props.edit) {
this.state.entityId = parseInt(props.match.params.fieldId);
}
this.fieldTypes = getFieldTypes(props.t); this.fieldTypes = getFieldTypes(props.t);
this.initForm({ this.initForm({
serverValidation: { serverValidation: {
url: `/rest/fields-validate/${this.state.listId}`, url: `/rest/fields-validate/${this.props.list.id}`,
changed: ['key'], changed: ['key'],
extra: ['id'] extra: ['id']
} }
@ -44,28 +38,21 @@ export default class CUD extends Component {
} }
static propTypes = { static propTypes = {
edit: PropTypes.bool action: PropTypes.string.isRequired,
} list: PropTypes.object,
entity: PropTypes.object
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(`/rest/fields/${this.state.listId}/${this.state.entityId}`, data => {
if (data.default_value === null) {
data.default_value = '';
}
});
} }
@withAsyncErrorHandler @withAsyncErrorHandler
async loadOrderOptions() { async loadOrderOptions() {
const t = this.props.t; const t = this.props.t;
const flds = await axios.get(`/rest/fields/${this.state.listId}`); const flds = await axios.get(`/rest/fields/${this.props.list.id}`);
const getOrderOptions = fld => { const getOrderOptions = fld => {
return [ return [
{key: 'none', label: t('Not visible')}, {key: 'none', label: t('Not visible')},
...flds.data.filter(x => x.id !== this.state.entityId && x[fld] !== null).sort((x, y) => x[fld] - y[fld]).map(x => ({ key: x.id, label: `${x.name} (${this.fieldTypes[x.type].label})`})), ...flds.data.filter(x => (!this.props.entity || x.id !== this.props.entity.id) && x[fld] !== null).sort((x, y) => x[fld] - y[fld]).map(x => ({ key: x.id, label: `${x.name} (${this.fieldTypes[x.type].label})`})),
{key: 'end', label: t('End of list')} {key: 'end', label: t('End of list')}
]; ];
}; };
@ -78,8 +65,13 @@ export default class CUD extends Component {
} }
componentDidMount() { componentDidMount() {
if (this.props.edit) { if (this.props.entity) {
this.loadFormValues(); this.getFormValuesFromEntity(this.props.entity, data => {
if (data.default_value === null) {
data.default_value = '';
}
});
} else { } else {
this.populateFormValues({ this.populateFormValues({
name: '', name: '',
@ -101,7 +93,6 @@ export default class CUD extends Component {
localValidateFormValues(state) { localValidateFormValues(state) {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit;
if (!state.getIn(['name', 'value'])) { if (!state.getIn(['name', 'value'])) {
state.setIn(['name', 'error'], t('Name must not be empty')); state.setIn(['name', 'error'], t('Name must not be empty'));
@ -123,15 +114,14 @@ export default class CUD extends Component {
async submitHandler() { async submitHandler() {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit;
let sendMethod, url; let sendMethod, url;
if (edit) { if (this.props.entity) {
sendMethod = FormSendMethod.PUT; sendMethod = FormSendMethod.PUT;
url = `/rest/fields/${this.state.listId}/${this.state.entityId}` url = `/rest/fields/${this.props.list.id}/${this.props.entity.id}`
} else { } else {
sendMethod = FormSendMethod.POST; sendMethod = FormSendMethod.POST;
url = `/rest/fields/${this.state.listId}` url = `/rest/fields/${this.props.list.id}`
} }
try { try {
@ -145,7 +135,7 @@ export default class CUD extends Component {
}); });
if (submitSuccessful) { if (submitSuccessful) {
this.navigateToWithFlashMessage(`/rest/fields/${this.state.listId}`, 'success', t('Field saved')); this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/fields`, 'success', t('Field saved'));
} else { } else {
this.enableForm(); this.enableForm();
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.')); this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
@ -167,7 +157,7 @@ export default class CUD extends Component {
render() { render() {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit; const isEdit = !!this.props.entity;
/* /*
const orderColumns = [ const orderColumns = [
@ -183,18 +173,18 @@ export default class CUD extends Component {
return ( return (
<div> <div>
{edit && {isEdit &&
<DeleteModalDialog <DeleteModalDialog
stateOwner={this} stateOwner={this}
visible={this.props.match.params.action === 'delete'} visible={this.props.action === 'delete'}
deleteUrl={`/rest/fields/${this.state.listId}/${this.state.entityId}`} deleteUrl={`/rest/fields/${this.props.list.id}/${this.props.entity.id}`}
cudUrl={`/lists/fields/edit/${this.state.listId}/${this.state.entityId}`} cudUrl={`/lists/fields/${this.props.list.id}/${this.props.entity.id}/edit`}
listUrl={`/lists/fields/${this.state.listId}`} listUrl={`/lists/fields/${this.props.list.id}`}
deletingMsg={t('Deleting field ...')} deletingMsg={t('Deleting field ...')}
deletedMsg={t('Field deleted')}/> deletedMsg={t('Field deleted')}/>
} }
<Title>{edit ? t('Edit Field') : t('Create Field')}</Title> <Title>{isEdit ? t('Edit Field') : t('Create Field')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}> <Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('Name')}/> <InputField id="name" label={t('Name')}/>
@ -215,7 +205,7 @@ export default class CUD extends Component {
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/> <Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{edit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/lists/fields/edit/${this.state.listId}/${this.state.entityId}/delete`}/>} {isEdit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/lists/fields/${this.props.list.id}/${this.props.entity.id}/delete`}/>}
</ButtonRow> </ButtonRow>
</Form> </Form>
</div> </div>

View file

@ -29,7 +29,7 @@ export default class List extends Component {
const actions = data => [{ const actions = data => [{
label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>, label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>,
link: `/lists/fields/edit/${this.state.listId}/${data[0]}` link: `/lists/${this.state.listId}/fields/${data[0]}/edit`
}]; }];
const columns = [ const columns = [
@ -42,7 +42,7 @@ export default class List extends Component {
return ( return (
<div> <div>
<Toolbar> <Toolbar>
<NavButton linkTo={`/lists/fields/${this.state.listId}/create`} className="btn-primary" icon="plus" label={t('Create Field')}/> <NavButton linkTo={`/lists/${this.state.listId}/fields/create`} className="btn-primary" icon="plus" label={t('Create Field')}/>
</Toolbar> </Toolbar>
<Title>{t('Fields')}</Title> <Title>{t('Fields')}</Title>

View file

@ -24,10 +24,6 @@ export default class CUD extends Component {
this.state = {}; this.state = {};
if (props.edit) {
this.state.entityId = parseInt(props.match.params.id);
}
this.serverValidatedFields = [ this.serverValidatedFields = [
'layout', 'layout',
'web_subscribe', 'web_subscribe',
@ -241,11 +237,12 @@ export default class CUD extends Component {
} }
static propTypes = { static propTypes = {
edit: PropTypes.bool action: PropTypes.string.isRequired,
entity: PropTypes.object
} }
@withAsyncErrorHandler
async loadOrPopulateFormValues() { componentDidMount() {
function supplyDefaults(data) { function supplyDefaults(data) {
for (const key in mailtrainConfig.defaultCustomFormValues) { for (const key in mailtrainConfig.defaultCustomFormValues) {
if (!data[key]) { if (!data[key]) {
@ -254,11 +251,12 @@ export default class CUD extends Component {
} }
} }
if (this.props.edit) { if (this.props.entity) {
await this.getFormValuesFromURL(`/rest/forms/${this.state.entityId}`, data => { this.getFormValuesFromEntity(this.props.entity, data => {
data.selectedTemplate = 'layout'; data.selectedTemplate = 'layout';
supplyDefaults(data); supplyDefaults(data);
}); });
} else { } else {
const data = { const data = {
name: '', name: '',
@ -272,13 +270,8 @@ export default class CUD extends Component {
} }
} }
componentDidMount() {
this.loadOrPopulateFormValues();
}
localValidateFormValues(state) { localValidateFormValues(state) {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit;
if (!state.getIn(['name', 'value'])) { if (!state.getIn(['name', 'value'])) {
state.setIn(['name', 'error'], t('Name must not be empty')); state.setIn(['name', 'error'], t('Name must not be empty'));
@ -319,12 +312,11 @@ export default class CUD extends Component {
async submitHandler() { async submitHandler() {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit;
let sendMethod, url; let sendMethod, url;
if (edit) { if (this.props.entity) {
sendMethod = FormSendMethod.PUT; sendMethod = FormSendMethod.PUT;
url = `/rest/forms/${this.state.entityId}` url = `/rest/forms/${this.props.entity.id}`
} else { } else {
sendMethod = FormSendMethod.POST; sendMethod = FormSendMethod.POST;
url = '/rest/forms' url = '/rest/forms'
@ -348,7 +340,7 @@ export default class CUD extends Component {
render() { render() {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit; const isEdit = !!this.props.entity;
const templateOptGroups = []; const templateOptGroups = [];
@ -377,18 +369,18 @@ export default class CUD extends Component {
return ( return (
<div> <div>
{edit && {isEdit &&
<DeleteModalDialog <DeleteModalDialog
stateOwner={this} stateOwner={this}
visible={this.props.match.params.action === 'delete'} visible={this.props.action === 'delete'}
deleteUrl={`/rest/forms/${this.state.entityId}`} deleteUrl={`/rest/forms/${this.props.entity.id}`}
cudUrl={`/lists/forms/edit/${this.state.entityId}`} cudUrl={`/lists/forms/${this.props.entity.id}/edit`}
listUrl="/lists/forms" listUrl="/lists/forms"
deletingMsg={t('Deleting form ...')} deletingMsg={t('Deleting form ...')}
deletedMsg={t('Form deleted')}/> deletedMsg={t('Form deleted')}/>
} }
<Title>{edit ? t('Edit Custom Forms') : t('Create Custom Forms')}</Title> <Title>{isEdit ? t('Edit Custom Forms') : t('Create Custom Forms')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}> <Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('Name')}/> <InputField id="name" label={t('Name')}/>
@ -441,7 +433,7 @@ export default class CUD extends Component {
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/> <Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{edit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/lists/forms/edit/${this.state.entityId}/delete`}/>} {isEdit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/lists/forms/${this.props.entity.id}/delete`}/>}
</ButtonRow> </ButtonRow>
</Form> </Form>
</div> </div>

View file

@ -48,13 +48,13 @@ export default class List extends Component {
if (perms.includes('edit')) { if (perms.includes('edit')) {
actions.push({ actions.push({
label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>, label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>,
link: '/lists/forms/edit/' + data[0] link: `/lists/forms/${data[0]}/edit`
}); });
} }
if (perms.includes('share')) { if (perms.includes('share')) {
actions.push({ actions.push({
label: <span className="glyphicon glyphicon-share-alt" aria-hidden="true" title="Share"></span>, label: <span className="glyphicon glyphicon-share-alt" aria-hidden="true" title="Share"></span>,
link: '/lists/forms/share/' + data[0] link: `/lists/forms/${data[0]}/share`
}); });
} }
@ -62,7 +62,6 @@ export default class List extends Component {
}; };
const columns = [ const columns = [
{ data: 0, title: "#" },
{ data: 1, title: t('Name') }, { data: 1, title: t('Name') },
{ data: 2, title: t('Description') }, { data: 2, title: t('Description') },
{ data: 3, title: t('Namespace') } { data: 3, title: t('Namespace') }

View file

@ -28,79 +28,86 @@ const getStructure = t => {
link: '/lists', link: '/lists',
component: ListsList, component: ListsList,
children: { children: {
/* FIXME ':listId([0-9]+)': {
':listId': {
title: resolved => t('List "{{name}}"', {name: resolved.list.name}), title: resolved => t('List "{{name}}"', {name: resolved.list.name}),
resolve: { resolve: {
list: match => `/rest/lists/${match.params.listId}` list: params => `/rest/lists/${params.listId}`
}, },
actions: { link: params => `/lists/${params.listId}/edit`,
edit: { navs: {
':action(edit|delete)': {
title: t('Edit'), title: t('Edit'),
params: [':action?'], link: params => `/lists/${params.listId}/edit`,
render: props => (<ListsCUD edit entity={resolved.list} {...props} />) visible: resolved => resolved.list.permissions.includes('edit'),
render: props => <ListsCUD action={props.match.params.action} entity={props.resolved.list} />
}, },
create: { fields: {
title: t('Create'), title: t('Fields'),
render: props => (<ListsCUD entity={resolved.list} {...props} />) link: params => `/lists/${params.listId}/fields/`,
visible: resolved => resolved.list.permissions.includes('manageFields'),
component: FieldsList,
children: {
':fieldId([0-9]+)': {
title: resolved => t('Field "{{name}}"', {name: resolved.field.name}),
resolve: {
field: params => `/rest/fields/${params.listId}/${params.fieldId}`
},
link: params => `/lists/${params.listId}/fields/${params.fieldId}/edit`,
navs: {
':action(edit|delete)': {
title: t('Edit'),
link: params => `/lists/${params.listId}/fields/${params.fieldId}/edit`,
render: props => <FieldsCUD action={props.match.params.action} entity={props.resolved.field} list={props.resolved.list} />
}
}
},
create: {
title: t('Create Field'),
render: props => <FieldsCUD action="create" list={props.resolved.list} />
}
}
}, },
share: { share: {
title: t('Share'), title: t('Share'),
render: props => (<Share title={t('Share')} entity={resolved.list} entityTypeId="list" {...props} />) link: params => `/lists/${params.listId}/share`,
visible: resolved => resolved.list.permissions.includes('share'),
render: props => <Share title={t('Share')} entity={props.resolved.list} entityTypeId="list" />
} }
} }
}, },
*/
edit: {
title: t('Edit List'),
params: [':id', ':action?'],
render: props => (<ListsCUD edit {...props} />)
},
create: { create: {
title: t('Create List'), title: t('Create'),
render: props => (<ListsCUD {...props} />) render: props => <ListsCUD action="create" />
},
share: {
title: t('Share List'),
params: [':id'],
render: props => (<Share title={entity => t('Share List "{{name}}"', {name: entity.name})} getUrl={id => `/rest/lists/${id}`} entityTypeId="list" {...props} />)
},
fields: {
title: t('Fields'),
params: [':listId'],
link: match => `/lists/fields/${match.params.listId}`,
component: FieldsList,
children: {
edit: {
title: t('Edit Field'),
params: [':listId', ':fieldId', ':action?'],
render: props => (<FieldsCUD edit {...props} />)
},
create: {
title: t('Create Field'),
params: [':listId'],
render: props => (<FieldsCUD {...props} />)
},
}
}, },
forms: { forms: {
title: t('Custom Forms'), title: t('Custom Forms'),
link: '/lists/forms', link: '/lists/forms',
component: FormsList, component: FormsList,
children: { children: {
edit: { ':formsId([0-9]+)': {
title: t('Edit Custom Forms'), title: resolved => t('Custom Forms "{{name}}"', {name: resolved.forms.name}),
params: [':id', ':action?'], resolve: {
render: props => (<FormsCUD edit {...props} />) forms: params => `/rest/forms/${params.formsId}`
},
link: params => `/lists/forms/${params.formsId}/edit`,
navs: {
':action(edit|delete)': {
title: t('Edit'),
link: params => `/lists/forms/${params.formsId}/edit`,
visible: resolved => resolved.forms.permissions.includes('edit'),
render: props => <FormsCUD action={props.match.params.action} entity={props.resolved.forms} />
},
share: {
title: t('Share'),
link: params => `/lists/forms/${params.formsId}/share`,
visible: resolved => resolved.forms.permissions.includes('share'),
render: props => <Share title={t('Share')} entity={props.resolved.forms} entityTypeId="customForm" />
}
}
}, },
create: { create: {
title: t('Create Custom Forms'), title: t('Create'),
render: props => (<FormsCUD {...props} />) render: props => <FormsCUD action="create" />
},
share: {
title: t('Share Custom Forms'),
params: [':id'],
render: props => (<Share title={entity => t('Custom Forms "{{name}}"', {name: entity.name})} getUrl={id => `/rest/forms/${id}`} entityTypeId="customForm" {...props} />)
} }
} }
} }

View file

@ -21,21 +21,17 @@ export default class CUD extends Component {
this.state = {}; this.state = {};
if (props.edit) {
this.state.entityId = parseInt(props.match.params.id);
}
this.initForm(); this.initForm();
this.hasChildren = false; this.hasChildren = false;
} }
static propTypes = { static propTypes = {
edit: PropTypes.bool action: PropTypes.string.isRequired,
entity: PropTypes.object
} }
isEditGlobal() { isEditGlobal() {
return this.state.entityId === 1; /* Global namespace id */ return this.props.entity && this.props.entity.id === 1; /* Global namespace id */
} }
isDelete() { isDelete() {
@ -46,7 +42,7 @@ export default class CUD extends Component {
for (let idx = 0; idx < data.length; idx++) { for (let idx = 0; idx < data.length; idx++) {
const entry = data[idx]; const entry = data[idx];
if (entry.key === this.state.entityId) { if (entry.key === this.props.entity.id) {
if (entry.children.length > 0) { if (entry.children.length > 0) {
this.hasChildren = true; this.hasChildren = true;
} }
@ -71,7 +67,7 @@ export default class CUD extends Component {
root.expanded = true; root.expanded = true;
} }
if (this.props.edit && !this.isEditGlobal()) { if (this.props.entity && !this.isEditGlobal()) {
this.removeNsIdSubtree(data); this.removeNsIdSubtree(data);
} }
@ -81,14 +77,9 @@ export default class CUD extends Component {
}); });
} }
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(`/rest/namespaces/${this.state.entityId}`);
}
componentDidMount() { componentDidMount() {
if (this.props.edit) { if (this.props.entity) {
this.loadFormValues(); this.getFormValuesFromEntity(this.props.entity);
} else { } else {
this.populateFormValues({ this.populateFormValues({
name: '', name: '',
@ -122,12 +113,11 @@ export default class CUD extends Component {
async submitHandler() { async submitHandler() {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit;
let sendMethod, url; let sendMethod, url;
if (edit) { if (this.props.entity) {
sendMethod = FormSendMethod.PUT; sendMethod = FormSendMethod.PUT;
url = `/rest/namespaces/${this.state.entityId}` url = `/rest/namespaces/${this.props.entity.id}`
} else { } else {
sendMethod = FormSendMethod.POST; sendMethod = FormSendMethod.POST;
url = '/rest/namespaces' url = '/rest/namespaces'
@ -188,23 +178,23 @@ export default class CUD extends Component {
render() { render() {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit; const isEdit = !!this.props.entity;
return ( return (
<div> <div>
{!this.isEditGlobal() && !this.hasChildren && edit && {!this.isEditGlobal() && !this.hasChildren && isEdit &&
<DeleteModalDialog <DeleteModalDialog
stateOwner={this} stateOwner={this}
visible={this.props.match.params.action === 'delete'} visible={this.props.action === 'delete'}
deleteUrl={`/rest/namespaces/${this.state.entityId}`} deleteUrl={`/rest/namespaces/${this.props.entity.id}`}
cudUrl={`/namespaces/edit/${this.state.entityId}`} cudUrl={`/namespaces/${this.props.entity.id}/edit`}
listUrl="/namespaces" listUrl="/namespaces"
deletingMsg={t('Deleting namespace ...')} deletingMsg={t('Deleting namespace ...')}
deletedMsg={t('Namespace deleted')} deletedMsg={t('Namespace deleted')}
onErrorAsync={::this.onDeleteError}/> onErrorAsync={::this.onDeleteError}/>
} }
<Title>{edit ? t('Edit Namespace') : t('Create Namespace')}</Title> <Title>{isEdit ? t('Edit Namespace') : t('Create Namespace')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}> <Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('Name')}/> <InputField id="name" label={t('Name')}/>
@ -215,7 +205,7 @@ export default class CUD extends Component {
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/> <Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{!this.isEditGlobal() && !this.hasChildren && edit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/namespaces/edit/${this.state.entityId}/delete`}/>} {!this.isEditGlobal() && !this.hasChildren && isEdit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/namespaces/${this.props.entity.id}/delete`}/>}
</ButtonRow> </ButtonRow>
</Form> </Form>
</div> </div>

View file

@ -4,13 +4,38 @@ import React, { Component } from 'react';
import { translate } from 'react-i18next'; import { translate } from 'react-i18next';
import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton } from '../lib/page'; import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton } from '../lib/page';
import { TreeTable } from '../lib/tree'; import { TreeTable } from '../lib/tree';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import axios from '../lib/axios';
@translate() @translate()
@withErrorHandling
@withPageHelpers @withPageHelpers
@requiresAuthenticatedUser @requiresAuthenticatedUser
export default class List extends Component { export default class List extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = {};
}
@withAsyncErrorHandler
async fetchPermissions() {
const request = {
createNamespace: {
entityTypeId: 'namespace',
requiredOperations: ['createNamespace']
}
};
const result = await axios.post('/rest/permissions-check', request);
this.setState({
createPermitted: result.data.createNamespace
});
}
componentDidMount() {
this.fetchPermissions();
} }
render() { render() {
@ -21,15 +46,15 @@ export default class List extends Component {
if (node.data.permissions.includes('edit')) { if (node.data.permissions.includes('edit')) {
actions.push({ actions.push({
label: 'Edit', label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>,
link: '/namespaces/edit/' + node.key link: `/namespaces/${node.key}/edit`
}); });
} }
if (node.data.permissions.includes('share')) { if (node.data.permissions.includes('share')) {
actions.push({ actions.push({
label: 'Share', label: <span className="glyphicon glyphicon-share-alt" aria-hidden="true" title="Share"></span>,
link: '/namespaces/share/' + node.key link: `/namespaces/${node.key}/share`
}); });
} }
@ -38,9 +63,11 @@ export default class List extends Component {
return ( return (
<div> <div>
<Toolbar> {this.state.createPermitted &&
<NavButton linkTo="/namespaces/create" className="btn-primary" icon="plus" label={t('Create Namespace')}/> <Toolbar>
</Toolbar> <NavButton linkTo="/namespaces/create" className="btn-primary" icon="plus" label={t('Create Namespace')}/>
</Toolbar>
}
<Title>{t('Namespaces')}</Title> <Title>{t('Namespaces')}</Title>

View file

@ -20,20 +20,31 @@ const getStructure = t => ({
link: '/namespaces', link: '/namespaces',
component: List, component: List,
children: { children: {
edit : { ':namespaceId([0-9]+)': {
title: t('Edit Namespace'), title: resolved => t('Namespace "{{name}}"', {name: resolved.namespace.name}),
params: [':id', ':action?'], resolve: {
render: props => (<CUD edit {...props} />) namespace: params => `/rest/namespaces/${params.namespaceId}`
},
link: params => `/namespaces/${params.namespaceId}/edit`,
navs: {
':action(edit|delete)': {
title: t('Edit'),
link: params => `/namespaces/${params.namespaceId}/edit`,
visible: resolved => resolved.namespace.permissions.includes('edit'),
render: props => <CUD action={props.match.params.action} entity={props.resolved.namespace} />
},
share: {
title: t('Share'),
link: params => `/namespaces/${params.namespaceId}/share`,
visible: resolved => resolved.namespace.permissions.includes('share'),
render: props => <Share title={t('Share')} entity={props.resolved.namespace} entityTypeId="namespace" />
}
}
}, },
create : { create: {
title: t('Create Namespace'), title: t('Create'),
render: props => (<CUD {...props} />) render: props => <CUD action="create" />
}, },
share: {
title: t('Share Namespace'),
params: [':id'],
render: props => (<Share title={entity => t('Share Namespace "{{name}}"', {name: entity.name})} getUrl={id => `/rest/namespaces/${id}`} entityTypeId="namespace" {...props} />)
}
} }
} }
} }

View file

@ -25,10 +25,6 @@ export default class CUD extends Component {
this.state = {}; this.state = {};
if (props.edit) {
this.state.entityId = parseInt(props.match.params.id);
}
this.initForm({ this.initForm({
onChange: { onChange: {
report_template: ::this.onReportTemplateChange report_template: ::this.onReportTemplateChange
@ -37,11 +33,8 @@ export default class CUD extends Component {
} }
static propTypes = { static propTypes = {
edit: PropTypes.bool action: PropTypes.string.isRequired,
} entity: PropTypes.object
isDelete() {
return this.props.match.params.action === 'delete';
} }
@withAsyncErrorHandler @withAsyncErrorHandler
@ -60,18 +53,13 @@ export default class CUD extends Component {
} }
} }
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(`/rest/reports/${this.state.entityId}`, data => {
for (const key in data.params) {
data[`param_${key}`] = data.params[key];
}
});
}
componentDidMount() { componentDidMount() {
if (this.props.edit) { if (this.props.entity) {
this.loadFormValues(); this.getFormValuesFromEntity(this.props.entity, data => {
for (const key in data.params) {
data[`param_${key}`] = data.params[key];
}
});
} else { } else {
this.populateFormValues({ this.populateFormValues({
name: '', name: '',
@ -85,7 +73,7 @@ export default class CUD extends Component {
localValidateFormValues(state) { localValidateFormValues(state) {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit; const edit = this.props.entity;
if (!state.getIn(['name', 'value'])) { if (!state.getIn(['name', 'value'])) {
state.setIn(['name', 'error'], t('Name must not be empty')); state.setIn(['name', 'error'], t('Name must not be empty'));
@ -130,7 +118,6 @@ export default class CUD extends Component {
async submitHandler() { async submitHandler() {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit;
if (!this.getFormValue('user_fields')) { if (!this.getFormValue('user_fields')) {
this.setFormStatusMessage('warning', t('Report parameters are not selected. Wait for them to get displayed and then fill them in.')); this.setFormStatusMessage('warning', t('Report parameters are not selected. Wait for them to get displayed and then fill them in.'));
@ -138,9 +125,9 @@ export default class CUD extends Component {
} }
let sendMethod, url; let sendMethod, url;
if (edit) { if (this.props.entity) {
sendMethod = FormSendMethod.PUT; sendMethod = FormSendMethod.PUT;
url = `/rest/reports/${this.state.entityId}` url = `/rest/reports/${this.props.entity.id}`
} else { } else {
sendMethod = FormSendMethod.POST; sendMethod = FormSendMethod.POST;
url = '/rest/reports' url = '/rest/reports'
@ -172,7 +159,7 @@ export default class CUD extends Component {
render() { render() {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit; const isEdit = !!this.props.entity;
const reportTemplateColumns = [ const reportTemplateColumns = [
{ data: 0, title: "#" }, { data: 0, title: "#" },
@ -226,18 +213,18 @@ export default class CUD extends Component {
return ( return (
<div> <div>
{edit && {isEdit &&
<DeleteModalDialog <DeleteModalDialog
stateOwner={this} stateOwner={this}
visible={this.props.match.params.action === 'delete'} visible={this.props.action === 'delete'}
deleteUrl={`/reports/${this.state.entityId}`} deleteUrl={`/reports/${this.props.entity.id}`}
cudUrl={`/reports/edit/${this.state.entityId}`} cudUrl={`/reports/${this.props.entity.id}/edit`}
listUrl="/reports" listUrl="/reports"
deletingMsg={t('Deleting report ...')} deletingMsg={t('Deleting report ...')}
deletedMsg={t('Report deleted')}/> deletedMsg={t('Report deleted')}/>
} }
<Title>{edit ? t('Edit Report') : t('Create Report')}</Title> <Title>{isEdit ? t('Edit Report') : t('Create Report')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}> <Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('Name')}/> <InputField id="name" label={t('Name')}/>
@ -259,7 +246,7 @@ export default class CUD extends Component {
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/> <Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{edit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/reports/edit/${this.state.entityId}/delete`}/>} {isEdit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/reports/${this.props.entity.id}/delete`}/>}
</ButtonRow> </ButtonRow>
</Form> </Form>
</div> </div>

View file

@ -94,12 +94,12 @@ export default class List extends Component {
if (mimeType === 'text/html') { if (mimeType === 'text/html') {
viewContent = { viewContent = {
label: <span className="glyphicon glyphicon-eye-open" aria-hidden="true" title="View"></span>, label: <span className="glyphicon glyphicon-eye-open" aria-hidden="true" title="View"></span>,
link: `reports/view/${id}` link: `/reports/${id}/view`
}; };
} else if (mimeType === 'text/csv') { } else if (mimeType === 'text/csv') {
viewContent = { viewContent = {
label: <span className="glyphicon glyphicon-download-alt" aria-hidden="true" title="Download"></span>, label: <span className="glyphicon glyphicon-download-alt" aria-hidden="true" title="Download"></span>,
href: `reports/download/${id}` href: `/reports/${id}/download`
}; };
} }
@ -127,7 +127,7 @@ export default class List extends Component {
actions.push( actions.push(
{ {
label: <span className="glyphicon glyphicon-modal-window" aria-hidden="true" title="View console output"></span>, label: <span className="glyphicon glyphicon-modal-window" aria-hidden="true" title="View console output"></span>,
link: `reports/output/${id}` link: `/reports/${id}/output`
} }
); );
} }
@ -139,14 +139,14 @@ export default class List extends Component {
if (perms.includes('edit') && permsReportTemplate.includes('execute')) { if (perms.includes('edit') && permsReportTemplate.includes('execute')) {
actions.push({ actions.push({
label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>, label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>,
link: `/reports/edit/${id}` link: `/reports/${id}/edit`
}); });
} }
if (perms.includes('share')) { if (perms.includes('share')) {
actions.push({ actions.push({
label: <span className="glyphicon glyphicon-share-alt" aria-hidden="true" title="Share"></span>, label: <span className="glyphicon glyphicon-share-alt" aria-hidden="true" title="Share"></span>,
link: `/reports/share/${id}` link: `/reports/${id}/share`
}); });
} }
@ -154,7 +154,6 @@ export default class List extends Component {
}; };
const columns = [ const columns = [
{ data: 0, title: "#" },
{ data: 1, title: t('Name') }, { data: 1, title: t('Name') },
{ data: 2, title: t('Template') }, { data: 2, title: t('Template') },
{ data: 3, title: t('Description') }, { data: 3, title: t('Description') },

View file

@ -13,6 +13,7 @@ import ReportsOutput from './Output';
import ReportTemplatesCUD from './templates/CUD'; import ReportTemplatesCUD from './templates/CUD';
import ReportTemplatesList from './templates/List'; import ReportTemplatesList from './templates/List';
import Share from '../shares/Share'; import Share from '../shares/Share';
import { ReportState } from '../../../shared/reports';
const getStructure = t => { const getStructure = t => {
@ -28,49 +29,78 @@ const getStructure = t => {
link: '/reports', link: '/reports',
component: ReportsList, component: ReportsList,
children: { children: {
edit: { ':reportId([0-9]+)': {
title: t('Edit Report'), title: resolved => t('Report "{{name}}"', {name: resolved.report.name}),
params: [':id', ':action?'], resolve: {
render: props => (<ReportsCUD edit {...props} />) report: params => `/rest/reports/${params.reportId}`
},
link: params => `/reports/${params.reportId}/edit`,
navs: {
':action(edit|delete)': {
title: t('Edit'),
link: params => `/reports/${params.reportId}/edit`,
visible: resolved => resolved.report.permissions.includes('edit'),
render: props => <ReportsCUD action={props.match.params.action} entity={props.resolved.report} />
},
view: {
title: t('View'),
link: params => `/reports/${params.reportId}/view`,
visible: resolved => resolved.report.permissions.includes('viewContent') && resolved.report.state === ReportState.FINISHED && resolved.report.mime_type === 'text/html',
render: props => (<ReportsView {...props} />),
},
download: {
title: t('Download'),
externalLink: params => `/reports/${params.reportId}/download`,
visible: resolved => resolved.report.permissions.includes('viewContent') && resolved.report.state === ReportState.FINISHED && resolved.report.mime_type === 'text/csv'
},
output: {
title: t('Output'),
link: params => `/reports/${params.reportId}/output`,
visible: resolved => resolved.report.permissions.includes('viewOutput'),
render: props => (<ReportsOutput {...props} />)
},
share: {
title: t('Share'),
link: params => `/reports/${params.reportId}/share`,
visible: resolved => resolved.report.permissions.includes('share'),
render: props => <Share title={t('Share')} entity={props.resolved.report} entityTypeId="report" />
}
}
}, },
create: { create: {
title: t('Create Report'), title: t('Create'),
render: props => (<ReportsCUD {...props} />) render: props => <ReportsCUD action="create" />
},
view: {
title: t('View Report'),
params: [':id' ],
render: props => (<ReportsView {...props} />)
},
output: {
title: t('View Report Output'),
params: [':id' ],
render: props => (<ReportsOutput {...props} />)
},
share: {
title: t('Share Report'),
params: [':id'],
render: props => (<Share title={entity => t('Share Report "{{name}}"', {name: entity.name})} getUrl={id => `/rest/reports/${id}`} entityTypeId="report" {...props} />)
}, },
'templates': { 'templates': {
title: t('Templates'), title: t('Templates'),
link: '/reports/templates', link: '/reports/templates',
component: ReportTemplatesList, component: ReportTemplatesList,
children: { children: {
edit: { ':templateId([0-9]+)': {
title: t('Edit Report Template'), title: resolved => t('Template "{{name}}"', {name: resolved.template.name}),
params: [':id', ':action?'], resolve: {
render: props => (<ReportTemplatesCUD edit {...props} />) template: params => `/rest/report-templates/${params.templateId}`
},
link: params => `/reports/templates/${params.templateId}/edit`,
navs: {
':action(edit|delete)': {
title: t('Edit'),
link: params => `/reports/templates/${params.templateId}/edit`,
visible: resolved => resolved.template.permissions.includes('edit'),
render: props => <ReportTemplatesCUD action={props.match.params.action} entity={props.resolved.template} />
},
share: {
title: t('Share'),
link: params => `/reports/templates/${params.templateId}/share`,
visible: resolved => resolved.template.permissions.includes('share'),
render: props => <Share title={t('Share')} entity={props.resolved.template} entityTypeId="reportTemplate" />
}
}
}, },
create: { create: {
title: t('Create Report Template'), title: t('Create'),
params: [':wizard?'], extraParams: [':wizard?'],
render: props => (<ReportTemplatesCUD {...props} />) render: props => <ReportTemplatesCUD action="create" wizard={props.match.params.wizard} />
},
share: {
title: t('Share Report Template'),
params: [':id'],
render: props => (<Share title={entity => t('Share Report Template "{{name}}"', {name: entity.name})} getUrl={id => `/rest/report-templates/${id}`} entityTypeId="reportTemplate" {...props} />)
} }
} }
} }

View file

@ -20,32 +20,21 @@ export default class CUD extends Component {
this.state = {}; this.state = {};
if (props.edit) {
this.state.entityId = parseInt(props.match.params.id);
}
this.initForm(); this.initForm();
} }
static propTypes = { static propTypes = {
edit: PropTypes.bool action: PropTypes.string.isRequired,
} wizard: PropTypes.string,
entity: PropTypes.object
isDelete() {
return this.props.match.params.action === 'delete';
}
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(`/rest/report-templates/${this.state.entityId}`);
} }
componentDidMount() { componentDidMount() {
if (this.props.edit) { if (this.props.entity) {
this.loadFormValues(); this.getFormValuesFromEntity(this.props.entity);
} else { } else {
const wizard = this.props.match.params.wizard; const wizard = this.props.wizard;
if (wizard === 'subscribers-all') { if (wizard === 'subscribers-all') {
this.populateFormValues({ this.populateFormValues({
@ -209,7 +198,6 @@ export default class CUD extends Component {
localValidateFormValues(state) { localValidateFormValues(state) {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit;
if (!state.getIn(['name', 'value'])) { if (!state.getIn(['name', 'value'])) {
state.setIn(['name', 'error'], t('Name must not be empty')); state.setIn(['name', 'error'], t('Name must not be empty'));
@ -245,12 +233,11 @@ export default class CUD extends Component {
async doSubmit(stay) { async doSubmit(stay) {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit;
let sendMethod, url; let sendMethod, url;
if (edit) { if (this.props.entity) {
sendMethod = FormSendMethod.PUT; sendMethod = FormSendMethod.PUT;
url = `/rest/report-templates/${this.state.entityId}` url = `/rest/report-templates/${this.props.entity.id}`
} else { } else {
sendMethod = FormSendMethod.POST; sendMethod = FormSendMethod.POST;
url = '/rest/report-templates' url = '/rest/report-templates'
@ -277,22 +264,22 @@ export default class CUD extends Component {
render() { render() {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit; const isEdit = !!this.props.entity;
return ( return (
<div> <div>
{edit && {isEdit &&
<DeleteModalDialog <DeleteModalDialog
stateOwner={this} stateOwner={this}
visible={this.props.match.params.action === 'delete'} visible={this.props.action === 'delete'}
deleteUrl={`/reports/templates/${this.state.entityId}`} deleteUrl={`/reports/templates/${this.props.entity.id}`}
cudUrl={`/reports/templates/edit/${this.state.entityId}`} cudUrl={`/reports/templates/${this.props.entity.id}/edit`}
listUrl="/reports/templates" listUrl="/reports/templates"
deletingMsg={t('Deleting report template ...')} deletingMsg={t('Deleting report template ...')}
deletedMsg={t('Report template deleted')}/> deletedMsg={t('Report template deleted')}/>
} }
<Title>{edit ? t('Edit Report Template') : t('Create Report Template')}</Title> <Title>{isEdit ? t('Edit Report Template') : t('Create Report Template')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitAndLeave}> <Form stateOwner={this} onSubmitAsync={::this.submitAndLeave}>
<InputField id="name" label={t('Name')}/> <InputField id="name" label={t('Name')}/>
@ -303,11 +290,11 @@ export default class CUD extends Component {
<ACEEditor id="js" height="700px" mode="javascript" label={t('Data processing code')} help={<Trans>Write the body of the JavaScript function with signature <code>function(inputs, callback)</code> that returns an object to be rendered by the Handlebars template below.</Trans>}/> <ACEEditor id="js" height="700px" mode="javascript" label={t('Data processing code')} help={<Trans>Write the body of the JavaScript function with signature <code>function(inputs, callback)</code> that returns an object to be rendered by the Handlebars template below.</Trans>}/>
<ACEEditor id="hbs" height="700px" mode="handlebars" label={t('Rendering template')} help={<Trans>Use HTML with Handlebars syntax. See documentation <a href="http://handlebarsjs.com/">here</a>.</Trans>}/> <ACEEditor id="hbs" height="700px" mode="handlebars" label={t('Rendering template')} help={<Trans>Use HTML with Handlebars syntax. See documentation <a href="http://handlebarsjs.com/">here</a>.</Trans>}/>
{edit ? {isEdit ?
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save and Stay')} onClickAsync={::this.submitAndStay}/> <Button type="submit" className="btn-primary" icon="ok" label={t('Save and Stay')} onClickAsync={::this.submitAndStay}/>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save and Leave')}/> <Button type="submit" className="btn-primary" icon="ok" label={t('Save and Leave')}/>
<NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/reports/templates/edit/${this.state.entityId}/delete`}/> <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/reports/templates/${this.props.entity.id}/delete`}/>
</ButtonRow> </ButtonRow>
: :
<ButtonRow> <ButtonRow>

View file

@ -50,14 +50,14 @@ export default class List extends Component {
if (perms.includes('view')) { if (perms.includes('view')) {
actions.push({ actions.push({
label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>, label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>,
link: '/reports/templates/edit/' + data[0] link: `/reports/templates/${data[0]}/edit`
}); });
} }
if (perms.includes('share')) { if (perms.includes('share')) {
actions.push({ actions.push({
label: <span className="glyphicon glyphicon-share-alt" aria-hidden="true" title="Share"></span>, label: <span className="glyphicon glyphicon-share-alt" aria-hidden="true" title="Share"></span>,
link: '/reports/templates/share/' + data[0] link: `/reports/templates/${data[0]}/share`
}); });
} }
@ -65,7 +65,6 @@ export default class List extends Component {
}; };
const columns = [ const columns = [
{ data: 0, title: "#" },
{ data: 1, title: t('Name') }, { data: 1, title: t('Name') },
{ data: 2, title: t('Description') }, { data: 2, title: t('Description') },
{ data: 3, title: t('Created'), render: data => moment(data).fromNow() }, { data: 3, title: t('Created'), render: data => moment(data).fromNow() },

View file

@ -21,32 +21,20 @@ export default class Share extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = {
entityId: parseInt(props.match.params.id)
};
this.initForm(); this.initForm();
} }
static propTypes = { static propTypes = {
title: PropTypes.func, title: PropTypes.string,
getUrl: PropTypes.func, entity: PropTypes.object,
entityTypeId: PropTypes.string entityTypeId: PropTypes.string
} }
@withAsyncErrorHandler
async loadEntity() {
const response = await axios.get(this.props.getUrl(this.state.entityId));
this.setState({
entity: response.data
});
}
@withAsyncErrorHandler @withAsyncErrorHandler
async deleteShare(userId) { async deleteShare(userId) {
const data = { const data = {
entityTypeId: this.props.entityTypeId, entityTypeId: this.props.entityTypeId,
entityId: this.state.entityId, entityId: this.props.entity.id,
userId userId
}; };
@ -58,14 +46,13 @@ export default class Share extends Component {
clearShareFields() { clearShareFields() {
this.populateFormValues({ this.populateFormValues({
entityTypeId: this.props.entityTypeId, entityTypeId: this.props.entityTypeId,
entityId: this.state.entityId, entityId: this.props.entity.id,
userId: null, userId: null,
role: null role: null
}); });
} }
componentDidMount() { componentDidMount() {
this.loadEntity();
this.clearShareFields(); this.clearShareFields();
} }
@ -151,29 +138,25 @@ export default class Share extends Component {
]; ];
if (this.state.entity) { return (
return ( <div>
<div> <Title>{this.props.title}</Title>
<Title>{this.props.title(this.state.entity)}</Title>
<h3 className="legend">{t('Add User')}</h3> <h3 className="legend">{t('Add User')}</h3>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}> <Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<TableSelect ref={node => this.usersTableSelect = node} id="userId" label={t('User')} withHeader dropdown dataUrl={`/rest/shares-unassigned-users-table/${this.props.entityTypeId}/${this.state.entityId}`} columns={usersColumns} selectionLabelIndex={usersLabelIndex}/> <TableSelect ref={node => this.usersTableSelect = node} id="userId" label={t('User')} withHeader dropdown dataUrl={`/rest/shares-unassigned-users-table/${this.props.entityTypeId}/${this.props.entity.id}`} columns={usersColumns} selectionLabelIndex={usersLabelIndex}/>
<TableSelect id="role" label={t('Role')} withHeader dropdown dataUrl={`/rest/shares-roles-table/${this.props.entityTypeId}`} columns={rolesColumns} selectionLabelIndex={1}/> <TableSelect id="role" label={t('Role')} withHeader dropdown dataUrl={`/rest/shares-roles-table/${this.props.entityTypeId}`} columns={rolesColumns} selectionLabelIndex={1}/>
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Share')}/> <Button type="submit" className="btn-primary" icon="ok" label={t('Share')}/>
</ButtonRow> </ButtonRow>
</Form> </Form>
<hr/> <hr/>
<h3 className="legend">{t('Existing Users')}</h3> <h3 className="legend">{t('Existing Users')}</h3>
<Table ref={node => this.sharesTable = node} withHeader dataUrl={`/rest/shares-table-by-entity/${this.props.entityTypeId}/${this.state.entityId}`} columns={sharesColumns} actions={actions}/> <Table ref={node => this.sharesTable = node} withHeader dataUrl={`/rest/shares-table-by-entity/${this.props.entityTypeId}/${this.props.entity.id}`} columns={sharesColumns} actions={actions}/>
</div> </div>
); );
} else {
return (<p>{t('Loading ...')}</p>)
}
} }
} }

View file

@ -1,6 +1,7 @@
'use strict'; 'use strict';
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { translate } from 'react-i18next'; import { translate } from 'react-i18next';
import { requiresAuthenticatedUser, withPageHelpers, Title } from '../lib/page'; import { requiresAuthenticatedUser, withPageHelpers, Title } from '../lib/page';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling'; import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
@ -17,18 +18,10 @@ export default class UserShares extends Component {
super(props); super(props);
this.sharesTables = {}; this.sharesTables = {};
this.state = {
userId: parseInt(props.match.params.id)
};
} }
@withAsyncErrorHandler static propTypes = {
async loadEntity() { user: PropTypes.object
const response = await axios.get(`/rest/users/${this.state.userId}`);
this.setState({
username: response.data.username
});
} }
@withAsyncErrorHandler @withAsyncErrorHandler
@ -36,7 +29,7 @@ export default class UserShares extends Component {
const data = { const data = {
entityTypeId, entityTypeId,
entityId, entityId,
userId: this.state.userId userId: this.props.user.id
}; };
await axios.put('/rest/shares', data); await axios.put('/rest/shares', data);
@ -46,7 +39,6 @@ export default class UserShares extends Component {
} }
componentDidMount() { componentDidMount() {
this.loadEntity();
} }
render() { render() {
@ -58,7 +50,7 @@ export default class UserShares extends Component {
if (!autoGenerated && perms.includes('share')) { if (!autoGenerated && perms.includes('share')) {
actions.push({ actions.push({
label: 'Delete', label: <span className="glyphicon glyphicon-remove" aria-hidden="true" title="Remove"></span>,
action: () => this.deleteShare(entityTypeId, data[2]) action: () => this.deleteShare(entityTypeId, data[2])
}); });
} }
@ -74,7 +66,7 @@ export default class UserShares extends Component {
return ( return (
<div> <div>
<h3>{title}</h3> <h3>{title}</h3>
<Table ref={node => this.sharesTables[entityTypeId] = node} withHeader dataUrl={`/rest/shares-table-by-user/${entityTypeId}/${this.state.userId}`} columns={columns} actions={actions}/> <Table ref={node => this.sharesTables[entityTypeId] = node} withHeader dataUrl={`/rest/shares-table-by-user/${entityTypeId}/${this.props.user.id}`} columns={columns} actions={actions}/>
</div> </div>
); );
}; };
@ -83,7 +75,7 @@ export default class UserShares extends Component {
return ( return (
<div> <div>
<Title>{t('Shares for user "{{username}}"', {username: this.state.username})}</Title> <Title>{t('Shares for user "{{username}}"', {username: this.props.user.username})}</Title>
{renderSharesTable('namespace', t('Namespaces'))} {renderSharesTable('namespace', t('Namespaces'))}
{renderSharesTable('list', t('Lists'))} {renderSharesTable('list', t('Lists'))}

View file

@ -5,8 +5,7 @@ import PropTypes from 'prop-types';
import { translate } from 'react-i18next'; import { translate } from 'react-i18next';
import {requiresAuthenticatedUser, withPageHelpers, Title, NavButton} from '../lib/page'; import {requiresAuthenticatedUser, withPageHelpers, Title, NavButton} from '../lib/page';
import { withForm, Form, FormSendMethod, InputField, ButtonRow, Button, TableSelect } from '../lib/form'; import { withForm, Form, FormSendMethod, InputField, ButtonRow, Button, TableSelect } from '../lib/form';
import axios from '../lib/axios'; import { withErrorHandling } from '../lib/error-handling';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import interoperableErrors from '../../../shared/interoperable-errors'; import interoperableErrors from '../../../shared/interoperable-errors';
import passwordValidator from '../../../shared/password-validator'; import passwordValidator from '../../../shared/password-validator';
import mailtrainConfig from 'mailtrainConfig'; import mailtrainConfig from 'mailtrainConfig';
@ -26,10 +25,6 @@ export default class CUD extends Component {
this.state = {}; this.state = {};
if (props.edit) {
this.state.entityId = parseInt(props.match.params.id);
}
this.initForm({ this.initForm({
serverValidation: { serverValidation: {
url: '/rest/users-validate', url: '/rest/users-validate',
@ -40,24 +35,16 @@ export default class CUD extends Component {
} }
static propTypes = { static propTypes = {
edit: PropTypes.bool action: PropTypes.string.isRequired,
} entity: PropTypes.object
isDelete() {
return this.props.match.params.action === 'delete';
}
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(`/rest/users/${this.state.entityId}`, data => {
data.password = '';
data.password2 = '';
});
} }
componentDidMount() { componentDidMount() {
if (this.props.edit) { if (this.props.entity) {
this.loadFormValues(); this.getFormValuesFromEntity(this.props.entity, data => {
data.password = '';
data.password2 = '';
});
} else { } else {
this.populateFormValues({ this.populateFormValues({
username: '', username: '',
@ -72,7 +59,7 @@ export default class CUD extends Component {
localValidateFormValues(state) { localValidateFormValues(state) {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit; const isEdit = !!this.props.entity;
const username = state.getIn(['username', 'value']); const username = state.getIn(['username', 'value']);
const usernameServerValidation = state.getIn(['username', 'serverValidation']); const usernameServerValidation = state.getIn(['username', 'serverValidation']);
@ -121,7 +108,7 @@ export default class CUD extends Component {
let passwordMsgs = []; let passwordMsgs = [];
if (!edit && !password) { if (!isEdit && !password) {
passwordMsgs.push(t('Password must not be empty')); passwordMsgs.push(t('Password must not be empty'));
} }
@ -142,12 +129,11 @@ export default class CUD extends Component {
async submitHandler() { async submitHandler() {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit;
let sendMethod, url; let sendMethod, url;
if (edit) { if (this.props.entity) {
sendMethod = FormSendMethod.PUT; sendMethod = FormSendMethod.PUT;
url = `/rest/users/${this.state.entityId}` url = `/rest/users/${this.props.entity.id}`
} else { } else {
sendMethod = FormSendMethod.POST; sendMethod = FormSendMethod.POST;
url = '/rest/users' url = '/rest/users'
@ -192,30 +178,9 @@ export default class CUD extends Component {
} }
} }
async showDeleteModal() {
this.navigateTo(`/users/edit/${this.state.entityId}/delete`);
}
async hideDeleteModal() {
this.navigateTo(`/users/edit/${this.state.entityId}`);
}
async performDelete() {
const t = this.props.t;
await this.hideDeleteModal();
this.disableForm();
this.setFormStatusMessage('info', t('Deleting user...'));
await axios.delete(`/rest/users/${this.state.entityId}`);
this.navigateToWithFlashMessage('/users', 'success', t('User deleted'));
}
render() { render() {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit; const isEdit = !!this.props.entity;
const userId = this.getFormValue('id'); const userId = this.getFormValue('id');
const canDelete = userId !== 1 && mailtrainConfig.userId !== userId; const canDelete = userId !== 1 && mailtrainConfig.userId !== userId;
@ -227,18 +192,18 @@ export default class CUD extends Component {
return ( return (
<div> <div>
{edit && canDelete && {isEdit && canDelete &&
<DeleteModalDialog <DeleteModalDialog
stateOwner={this} stateOwner={this}
visible={this.props.match.params.action === 'delete'} visible={this.props.action === 'delete'}
deleteUrl={`/users/${this.state.entityId}`} deleteUrl={`/users/${this.props.entity.id}`}
cudUrl={`/users/edit/${this.state.entityId}`} cudUrl={`/users/${this.props.entity.id}/edit`}
listUrl="/users" listUrl="/users"
deletingMsg={t('Deleting user ...')} deletingMsg={t('Deleting user ...')}
deletedMsg={t('User deleted')}/> deletedMsg={t('User deleted')}/>
} }
<Title>{edit ? t('Edit User') : t('Create User')}</Title> <Title>{isEdit ? t('Edit User') : t('Create User')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}> <Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="username" label={t('User Name')}/> <InputField id="username" label={t('User Name')}/>
@ -255,7 +220,7 @@ export default class CUD extends Component {
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/> <Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{edit && canDelete && <NavButton className="btn-danger" icon="remove" label={t('Delete User')} linkTo={`/users/edit/${this.state.entityId}/delete`}/>} {isEdit && canDelete && <NavButton className="btn-danger" icon="remove" label={t('Delete User')} linkTo={`/users/${this.props.entity.id}/delete`}/>}
</ButtonRow> </ButtonRow>
</Form> </Form>
</div> </div>

View file

@ -15,21 +15,23 @@ export default class List extends Component {
} }
render() { render() {
// There are no permissions checks here because this page makes no sense for anyone who does not have manageUsers permission
// Once someone has this permission, then all on this page can be used.
const t = this.props.t; const t = this.props.t;
const actions = data => [ const actions = data => [
{ {
label: 'Edit', label: <span className="glyphicon glyphicon-edit" aria-hidden="true" title="Edit"></span>,
link: '/users/edit/' + data[0] link: `/users/${data[0]}/edit`
}, },
{ {
label: 'Shares', label: <span className="glyphicon glyphicon-share" aria-hidden="true" title="Share"></span>,
link: '/users/shares/' + data[0] link: `/users/${data[0]}/shares`
} }
]; ];
const columns = [ const columns = [
{ data: 0, title: "#" },
{ data: 1, title: "Username" } { data: 1, title: "Username" }
]; ];

View file

@ -21,20 +21,29 @@ const getStructure = t => {
link: '/users', link: '/users',
component: List, component: List,
children: { children: {
edit: { ':userId([0-9]+)': {
title: t('Edit User'), title: resolved => t('User "{{name}}"', {name: resolved.user.name}),
params: [':id', ':action?'], resolve: {
render: props => (<CUD edit {...props} />) user: params => `/rest/users/${params.userId}`
},
link: params => `/users/${params.userId}/edit`,
navs: {
':action(edit|delete)': {
title: t('Edit'),
link: params => `/users/${params.userId}/edit`,
render: props => <CUD action={props.match.params.action} entity={props.resolved.user} />
},
shares: {
title: t('Shares'),
link: params => `/users/${params.userId}/shares`,
render: props => <UserShares user={props.resolved.user} />
}
}
}, },
create: { create: {
title: t('Create User'), title: t('Create'),
render: props => (<CUD {...props} />) render: props => <CUD action="create" />
}, },
shares: {
title: t('User Shares'),
params: [':id' ],
component: UserShares
}
} }
} }
} }

View file

@ -10,6 +10,7 @@ const shares = require('./shares');
const fieldsLegacy = require('../lib/models/fields'); const fieldsLegacy = require('../lib/models/fields');
const bluebird = require('bluebird'); const bluebird = require('bluebird');
const validators = require('../shared/validators'); const validators = require('../shared/validators');
const shortid = require('shortid');
const allowedKeysCreate = new Set(['name', 'key', 'default_value', 'type', 'group', 'settings']); const allowedKeysCreate = new Set(['name', 'key', 'default_value', 'type', 'group', 'settings']);
const allowedKeysUpdate = new Set(['name', 'key', 'default_value', 'group', 'settings']); const allowedKeysUpdate = new Set(['name', 'key', 'default_value', 'group', 'settings']);
@ -62,7 +63,7 @@ fieldTypes['radio-enum'] = fieldTypes['dropdown-enum'] = {
}; };
fieldTypes.option = { fieldTypes.option = {
validate: entity => [], validate: entity => {},
addColumn: (table, name) => table.boolean(name), addColumn: (table, name) => table.boolean(name),
indexed: true, indexed: true,
grouped: false grouped: false
@ -156,13 +157,18 @@ async function serverValidate(context, listId, data) {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageFields'); await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageFields');
if (data.key) { if (data.key) {
const existing = await tx('custom_fields').where({ const existingKeyQuery = tx('custom_fields').where({
list: listId, list: listId,
key: data.key key: data.key
}).whereNot('id', data.id).first(); });
if (data.id) {
existingKeyQuery.whereNot('id', data.id);
}
const existingKey = await existingKeyQuery.first();
result.key = { result.key = {
exists: !!existing exists: !!existingKey
}; };
} }
}); });
@ -180,7 +186,6 @@ async function _validateAndPreprocess(tx, listId, entity, isCreate) {
enforce(fieldType, 'Unknown field type'); enforce(fieldType, 'Unknown field type');
const validateErrs = fieldType.validate(entity); const validateErrs = fieldType.validate(entity);
enforce(!validateErrs.length, 'Invalid field');
enforce(validators.mergeTagValid(entity.key), 'Merge tag is not valid.'); enforce(validators.mergeTagValid(entity.key), 'Merge tag is not valid.');
@ -189,7 +194,7 @@ async function _validateAndPreprocess(tx, listId, entity, isCreate) {
key: entity.key key: entity.key
}); });
if (!isCreate) { if (!isCreate) {
existingWithKeyQuery.whereNot('id', data.id); existingWithKeyQuery.whereNot('id', entity.id);
} }
const existingWithKey = await existingWithKeyQuery.first(); const existingWithKey = await existingWithKeyQuery.first();
if (existingWithKey) { if (existingWithKey) {
@ -255,9 +260,11 @@ async function create(context, listId, entity) {
await _validateAndPreprocess(tx, listId, entity, true); await _validateAndPreprocess(tx, listId, entity, true);
const fieldType = fieldTypes[entity.type];
let columnName; let columnName;
if (!fieldType.grouped) { if (!fieldType.grouped) {
columnName = ('custom_' + slugify(name, '_') + '_' + shortid.generate()).toLowerCase().replace(/[^a-z0-9_]/g, ''); columnName = ('custom_' + slugify(entity.name, '_') + '_' + shortid.generate()).toLowerCase().replace(/[^a-z0-9_]/g, '');
} }
const filteredEntity = filterObject(entity, allowedKeysCreate); const filteredEntity = filterObject(entity, allowedKeysCreate);
@ -267,7 +274,7 @@ async function create(context, listId, entity) {
const ids = await tx('custom_fields').insert(filteredEntity); const ids = await tx('custom_fields').insert(filteredEntity);
id = ids[0]; id = ids[0];
_sortIn(tx, listId, id, entity.orderListBefore, entity.orderSubscribeBefore, entity.orderManageBefore); await _sortIn(tx, listId, id, entity.orderListBefore, entity.orderSubscribeBefore, entity.orderManageBefore);
if (columnName) { if (columnName) {
await knex.schema.table('subscription__' + listId, table => { await knex.schema.table('subscription__' + listId, table => {
@ -297,10 +304,10 @@ async function updateWithConsistencyCheck(context, listId, entity) {
} }
enforce(entity.type === existing.type, 'Field type cannot be changed'); enforce(entity.type === existing.type, 'Field type cannot be changed');
await _validateAndPreprocess(tx, listId, entity, true); await _validateAndPreprocess(tx, listId, entity, false);
await tx('custom_fields').where('id', entity.id).update(filterObject(entity, allowedKeysUpdate)); await tx('custom_fields').where('id', entity.id).update(filterObject(entity, allowedKeysUpdate));
_sortIn(tx, listId, entity.id, entity.orderListBefore, entity.orderSubscribeBefore, entity.orderManageBefore); await _sortIn(tx, listId, entity.id, entity.orderListBefore, entity.orderSubscribeBefore, entity.orderManageBefore);
}); });
} }

View file

@ -89,6 +89,7 @@ async function getById(context, id) {
await knex.transaction(async tx => { await knex.transaction(async tx => {
shares.enforceEntityPermissionTx(tx, context, 'customForm', id, 'view'); shares.enforceEntityPermissionTx(tx, context, 'customForm', id, 'view');
entity = await _getById(tx, id); entity = await _getById(tx, id);
entity.permissions = await shares.getPermissions(tx, context, 'customForm', id);
}); });
return entity; return entity;
@ -146,7 +147,7 @@ async function updateWithConsistencyCheck(context, entity) {
const existing = await _getById(tx, entity.id); const existing = await _getById(tx, entity.id);
const existingHash = hash(existing); const existingHash = hash(existing);
if (texistingHash !== entity.originalHash) { if (existingHash !== entity.originalHash) {
throw new interoperableErrors.ChangedError(); throw new interoperableErrors.ChangedError();
} }

View file

@ -31,12 +31,19 @@ async function listDTAjax(context, params) {
} }
async function getById(context, id) { async function getById(context, id) {
shares.enforceEntityPermission(context, 'list', id, 'view'); let entity;
const entity = await knex('lists').where('id', id).first(); await knex.transaction(async tx => {
if (!entity) {
throw new interoperableErrors.NotFoundError(); shares.enforceEntityPermissionTx(tx, context, 'list', id, 'view');
}
entity = await tx('lists').where('id', id).first();
if (!entity) {
throw new interoperableErrors.NotFoundError();
}
entity.permissions = await shares.getPermissions(tx, context, 'list', id);
});
return entity; return entity;
} }
@ -73,7 +80,7 @@ async function updateWithConsistencyCheck(context, entity) {
} }
const existingHash = hash(existing); const existingHash = hash(existing);
if (texistingHash !== entity.originalHash) { if (existingHash !== entity.originalHash) {
throw new interoperableErrors.ChangedError(); throw new interoperableErrors.ChangedError();
} }

View file

@ -6,6 +6,8 @@ const { enforce, filterObject } = require('../lib/helpers');
const interoperableErrors = require('../shared/interoperable-errors'); const interoperableErrors = require('../shared/interoperable-errors');
const shares = require('./shares'); const shares = require('./shares');
const permissions = require('../lib/permissions'); const permissions = require('../lib/permissions');
const namespaceHelpers = require('../lib/namespace-helpers');
const allowedKeys = new Set(['name', 'description', 'namespace']); const allowedKeys = new Set(['name', 'description', 'namespace']);
@ -106,12 +108,19 @@ function hash(entity) {
} }
async function getById(context, id) { async function getById(context, id) {
await shares.enforceEntityPermission(context, 'namespace', id, 'view'); let entity;
const entity = await knex('namespaces').where('id', id).first(); await knex.transaction(async tx => {
if (!entity) {
throw new interoperableErrors.NotFoundError(); await shares.enforceEntityPermissionTx(tx, context, 'namespace', id, 'view');
}
entity = await tx('namespaces').where('id', id).first();
if (!entity) {
throw new interoperableErrors.NotFoundError();
}
entity.permissions = await shares.getPermissions(tx, context, 'namespace', id);
});
return entity; return entity;
} }
@ -145,7 +154,7 @@ async function updateWithConsistencyCheck(context, entity) {
} }
const existingHash = hash(existing); const existingHash = hash(existing);
if (texistingHash !== entity.originalHash) { if (existingHash !== entity.originalHash) {
throw new interoperableErrors.ChangedError(); throw new interoperableErrors.ChangedError();
} }

View file

@ -15,12 +15,19 @@ function hash(entity) {
} }
async function getById(context, id) { async function getById(context, id) {
await shares.enforceEntityPermission(context, 'reportTemplate', id, 'view'); let entity;
const entity = await knex('report_templates').where('id', id).first(); await knex.transaction(async tx => {
if (!entity) {
throw new interoperableErrors.NotFoundError(); await shares.enforceEntityPermissionTx(tx, context, 'reportTemplate', id, 'view');
}
entity = await tx('report_templates').where('id', id).first();
if (!entity) {
throw new interoperableErrors.NotFoundError();
}
entity.permissions = await shares.getPermissions(tx, context, 'reportTemplate', id);
});
return entity; return entity;
} }
@ -60,7 +67,7 @@ async function updateWithConsistencyCheck(context, entity) {
} }
const existingHash = hash(existing); const existingHash = hash(existing);
if (texistingHash !== entity.originalHash) { if (existingHash !== entity.originalHash) {
throw new interoperableErrors.ChangedError(); throw new interoperableErrors.ChangedError();
} }

View file

@ -19,20 +19,27 @@ function hash(entity) {
} }
async function getByIdWithTemplate(context, id) { async function getByIdWithTemplate(context, id) {
await shares.enforceEntityPermission(context, 'report', id, 'view'); let entity;
const entity = await knex('reports') await knex.transaction(async tx => {
.where('reports.id', id)
.innerJoin('report_templates', 'reports.report_template', 'report_templates.id')
.select(['reports.id', 'reports.name', 'reports.description', 'reports.report_template', 'reports.params', 'reports.state', 'reports.namespace', 'report_templates.user_fields', 'report_templates.mime_type', 'report_templates.hbs', 'report_templates.js'])
.first();
if (!entity) { await shares.enforceEntityPermissionTx(tx, context, 'report', id, 'view');
throw new interoperableErrors.NotFoundError();
}
entity.user_fields = JSON.parse(entity.user_fields); entity = await tx('reports')
entity.params = JSON.parse(entity.params); .where('reports.id', id)
.innerJoin('report_templates', 'reports.report_template', 'report_templates.id')
.select(['reports.id', 'reports.name', 'reports.description', 'reports.report_template', 'reports.params', 'reports.state', 'reports.namespace', 'report_templates.user_fields', 'report_templates.mime_type', 'report_templates.hbs', 'report_templates.js'])
.first();
if (!entity) {
throw new interoperableErrors.NotFoundError();
}
entity.user_fields = JSON.parse(entity.user_fields);
entity.params = JSON.parse(entity.params);
entity.permissions = await shares.getPermissions(tx, context, 'report', id);
});
return entity; return entity;
} }
@ -89,7 +96,7 @@ async function updateWithConsistencyCheck(context, entity) {
existing.params = JSON.parse(existing.params); existing.params = JSON.parse(existing.params);
const existingHash = hash(existing); const existingHash = hash(existing);
if (texistingHash !== entity.originalHash) { if (existingHash !== entity.originalHash) {
throw new interoperableErrors.ChangedError(); throw new interoperableErrors.ChangedError();
} }

View file

@ -518,6 +518,17 @@ async function enforceTypePermissionTx(tx, context, entityTypeId, requiredOperat
} }
} }
async function getPermissions(tx, context, entityTypeId, entityId) {
const entityType = permissions.getEntityType(entityTypeId);
const rows = await tx(entityType.permissionsTable)
.select('operation')
.where('entity', entityId)
.where('user', context.user.id);
return rows.map(x => x.operation);
}
module.exports = { module.exports = {
listByEntityDTAjax, listByEntityDTAjax,
@ -535,5 +546,6 @@ module.exports = {
checkTypePermission, checkTypePermission,
enforceGlobalPermission, enforceGlobalPermission,
throwPermissionDenied, throwPermissionDenied,
regenerateRoleNamesTable regenerateRoleNamesTable,
getPermissions
}; };

View file

@ -9,7 +9,7 @@ const contextHelpers = require('../lib/context-helpers');
const router = require('../lib/router-async').create(); const router = require('../lib/router-async').create();
router.getAsync('/download/:id', passport.loggedIn, async (req, res) => { router.getAsync('/:id/download', passport.loggedIn, async (req, res) => {
await shares.enforceEntityPermission(req.context, 'report', req.params.id, 'viewContent'); await shares.enforceEntityPermission(req.context, 'report', req.params.id, 'viewContent');
const report = await reports.getByIdWithTemplate(contextHelpers.getAdminContext(), req.params.id); const report = await reports.getByIdWithTemplate(contextHelpers.getAdminContext(), req.params.id);

View file

@ -53,7 +53,7 @@
<body class="{{bodyClass}}"> <body class="{{bodyClass}}">
<nav class="navbar navbar-default navbar-static-top"> <nav class="navbar navbar-default navbar-static-top">
<div class="container"> <div class="container-fluid">
<!-- Brand and toggle get grouped for better mobile display --> <!-- Brand and toggle get grouped for better mobile display -->
<div class="navbar-header"> <div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1" aria-expanded="false"> <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1" aria-expanded="false">
@ -155,7 +155,7 @@
{{#if indexPage}} {{#if indexPage}}
<div class="jumbotron"> <div class="jumbotron">
<div class="container"> <div class="container-fluid">
<div class="pull-right col-md-4"> <div class="pull-right col-md-4">
{{{shoutout}}} {{{shoutout}}}
</div> </div>
@ -173,14 +173,14 @@
</div> </div>
{{/if}} {{/if}}
<div class="container"> <div class="container-fluid">
{{flash_messages}} {{{body}}} {{flash_messages}} {{{body}}}
</div> </div>
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container-fluid">
<p class="text-muted">&copy; 2016 Kreata OÜ <a href="https://mailtrain.org">Mailtrain.org</a>, <a href="mailto:info@mailtrain.org">info@mailtrain.org</a>. <a href="https://github.com/Mailtrain-org/mailtrain">{{#translate}}Source on GitHub{{/translate}}</a></p> <p class="text-muted">&copy; 2016 Kreata OÜ <a href="https://mailtrain.org">Mailtrain.org</a>, <a href="mailto:info@mailtrain.org">info@mailtrain.org</a>. <a href="https://github.com/Mailtrain-org/mailtrain">{{#translate}}Source on GitHub{{/translate}}</a></p>
</div> </div>
</footer> </footer>