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

@ -559,6 +559,7 @@ class ACEEditor extends Component {
}
}
function withForm(target) {
const inst = target.prototype;
@ -672,20 +673,8 @@ function withForm(target) {
});
};
inst.getFormValuesFromURL = async function(url, mutator) {
setTimeout(() => {
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;
inst.getFormValuesFromEntity = function(entity, mutator) {
const data = Object.assign({}, entity);
data.originalHash = data.hash;
delete data.hash;
@ -697,330 +686,6 @@ function withForm(target) {
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) {
setTimeout(() => {
this.setState(previousState => {

View file

@ -43,4 +43,28 @@
h3.legend {
font-size: 21px;
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 PropTypes from 'prop-types';
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 { withErrorHandling } from './error-handling';
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
import interoperableErrors from '../../../shared/interoperable-errors';
import { DismissibleAlert, Button } from './bootstrap-components';
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 {
static propTypes = {
structure: PropTypes.object.isRequired
route: PropTypes.object.isRequired,
params: PropTypes.object.isRequired,
resolved: PropTypes.object.isRequired
}
renderElement(breadcrumbElem) {
if (breadcrumbElem.isActive) {
return <li key={breadcrumbElem.idx} className="active">{breadcrumbElem.title}</li>;
renderElement(entry, isActive) {
const params = this.props.params;
let title;
if (typeof entry.title === 'function') {
title = entry.title(this.props.resolved);
} else {
title = entry.title;
}
} else if (breadcrumbElem.externalLink) {
return <li key={breadcrumbElem.idx}><a href={breadcrumbElem.externalLink}>{breadcrumbElem.title}</a></li>;
if (isActive) {
return <li key={entry.path} className="active">{title}</li>;
} else if (breadcrumbElem.link) {
let link;
if (typeof breadcrumbElem.link === 'function') {
link = breadcrumbElem.link(this.props.match);
} else if (entry.externalLink) {
let externalLink;
if (typeof entry.externalLink === 'function') {
externalLink = entry.externalLink(params);
} 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 {
return <li key={breadcrumbElem.idx}>{breadcrumbElem.title}</li>;
return <li key={entry.path}>{title}</li>;
}
}
render() {
const location = this.props.location.pathname;
const locationElems = location.split('/');
const route = this.props.route;
let breadcrumbElems = [];
let children = this.props.structure;
for (let idx = 0; idx < locationElems.length; idx++) {
const breadcrumbElem = children[locationElems[idx]];
if (!breadcrumbElem) {
break;
}
breadcrumbElem.isActive = (idx === locationElems.length - 1);
breadcrumbElem.idx = idx;
breadcrumbElems.push(breadcrumbElem);
children = breadcrumbElem.children;
if (!children) {
break;
}
}
const renderedElems = breadcrumbElems.map(x => this.renderElement(x));
const renderedElems = [...route.parents.map(x => this.renderElement(x)), this.renderElement(route, true)];
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
@ -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() {
let routes = this.getRoutes('', {}, [], this.props.structure, []);
return (
<div>
<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>
<Switch>{routes.map(x => this.renderRoute(x))}</Switch>
);
}
}
@ -280,9 +475,18 @@ class Title extends Component {
}
class Toolbar extends Component {
static propTypes = {
className: PropTypes.string,
};
render() {
let className = 'pull-right mt-button-row';
if (this.props.className) {
className += ' ' + this.props.className;
}
return (
<div className="pull-right mt-button-row">
<div className={className}>
{this.props.children}
</div>
);