diff --git a/TODO.md b/TODO.md index 1641a058..e6827c53 100644 --- a/TODO.md +++ b/TODO.md @@ -1,21 +1,21 @@ -### Front page -- Some dashboard - -### Campaigns -- List of sent RSS campaigns (?) - -### Pull requests -- Support ldaps:// - 5325f2ea7864ce5f42a9a6df3408af7ffbd32591 -- Support https - abd788d8f4d18b5a977226ba1224cba7f2b7fa9b -- Support warn of failed login - 4bd1e994b27420ba366d9b0429e9014e5bf01f13 -- Add X-Mailer header option in settings to override or disable it - 44fe8882b876bdfd9990110496d16f819dc64ac3 -- Add custom unsubscribe option in a campaign - 68cb8384f7dfdbcaf2932293ec5a2f1ec0a1554e - -### API -- Add API extensions - -### GDPR -- Refuse editing subscriptions which have been anonymized -- Add field to subscriptions which says till when the consent has been given -- Provide a link (and merge tag) that will update the consent date to now -- Add campaign trigger that triggers if the consent for specific subscription field is about to expire (i.e. it is greater than now - seconds) +### Front page +- Some dashboard + +### Campaigns +- List of sent RSS campaigns (?) + +### Pull requests +- Support ldaps:// - 5325f2ea7864ce5f42a9a6df3408af7ffbd32591 +- Support https - abd788d8f4d18b5a977226ba1224cba7f2b7fa9b +- Support warn of failed login - 4bd1e994b27420ba366d9b0429e9014e5bf01f13 +- Add X-Mailer header option in settings to override or disable it - 44fe8882b876bdfd9990110496d16f819dc64ac3 +- Add custom unsubscribe option in a campaign - 68cb8384f7dfdbcaf2932293ec5a2f1ec0a1554e + +### API +- Add API extensions + +### GDPR +- Refuse editing subscriptions which have been anonymized +- Add field to subscriptions which says till when the consent has been given +- Provide a link (and merge tag) that will update the consent date to now +- Add campaign trigger that triggers if the consent for specific subscription field is about to expire (i.e. it is greater than now - seconds) diff --git a/UPGRADE.md b/UPGRADE.md index 2abf71ce..e98d3d28 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,21 +1,21 @@ -## Migration from Mailtrain v1 to Mailtrain v2 - -The migration should happen almost automatically. There are however the following caveats: - -1. Structure of config files (under `config`) has changed at many places. Revisit the default config (`config/default.toml`) - and update your configs accordingly. - -2. Images uploaded in a template editor (Mosaico, Grapesjs, etc.) need to be manually moved to a new destination (under `client`). - For Mosaico, this means to move folders named by a number from `public/mosaico` to `client/static/mosaico`. - -3. Directory for custom Mosaico templates has changed from `public/mosaico/templates` to `client/static/mosaico/templates`. - -4. Imports are not migrated. If you have any pending imports, complete them before migration to v2. - -5. Zone MTA configuration endpoint (webhooks/zone-mta/sender-config) has changed. The send-configuration CID has to be - part of the URL - e.g. webhooks/zone-mta/sender-config/system. - -6. If there are lists that contain birthday or date fields that were created before - commit `bc73a0df0cab9943d726bd12fc1c6f2ff1279aa7` (on Jan 3, 2018), they still have TIMESTAMP data type in DB instead - of DATETIME. The problem was that that commit did not introduce migration from TIMESTAMP to DATETIME. - Mailtrain v2 does this migration, however in some corner cases, this may shift the date by a day back or forth. +## Migration from Mailtrain v1 to Mailtrain v2 + +The migration should happen almost automatically. There are however the following caveats: + +1. Structure of config files (under `config`) has changed at many places. Revisit the default config (`config/default.toml`) + and update your configs accordingly. + +2. Images uploaded in a template editor (Mosaico, Grapesjs, etc.) need to be manually moved to a new destination (under `client`). + For Mosaico, this means to move folders named by a number from `public/mosaico` to `client/static/mosaico`. + +3. Directory for custom Mosaico templates has changed from `public/mosaico/templates` to `client/static/mosaico/templates`. + +4. Imports are not migrated. If you have any pending imports, complete them before migration to v2. + +5. Zone MTA configuration endpoint (webhooks/zone-mta/sender-config) has changed. The send-configuration CID has to be + part of the URL - e.g. webhooks/zone-mta/sender-config/system. + +6. If there are lists that contain birthday or date fields that were created before + commit `bc73a0df0cab9943d726bd12fc1c6f2ff1279aa7` (on Jan 3, 2018), they still have TIMESTAMP data type in DB instead + of DATETIME. The problem was that that commit did not introduce migration from TIMESTAMP to DATETIME. + Mailtrain v2 does this migration, however in some corner cases, this may shift the date by a day back or forth. diff --git a/client/.gitignore b/client/.gitignore index 0a524405..9b1c8b13 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -1 +1 @@ -/dist +/dist diff --git a/client/package-lock.json b/client/package-lock.json index 6b9ea835..7b6681a0 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -4470,7 +4470,8 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true + "bundled": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -4488,11 +4489,13 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true + "bundled": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4505,15 +4508,18 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "concat-map": { "version": "0.0.1", - "bundled": true + "bundled": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -4616,7 +4622,8 @@ }, "inherits": { "version": "2.0.3", - "bundled": true + "bundled": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -4626,6 +4633,7 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -4638,6 +4646,7 @@ "minimatch": { "version": "3.0.4", "bundled": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -4743,7 +4752,8 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true + "bundled": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -4858,6 +4868,7 @@ "string-width": { "version": "1.0.2", "bundled": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -4875,6 +4886,7 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } diff --git a/client/src/account/styles.scss b/client/src/account/styles.scss index eb25bdb6..f8e9249f 100644 --- a/client/src/account/styles.scss +++ b/client/src/account/styles.scss @@ -1,9 +1,9 @@ -.api { - :global .card h4 { - margin-top: 0px; - } - - h4 { - margin-top: 45px; - } +.api { + :global .card h4 { + margin-top: 0px; + } + + h4 { + margin-top: 45px; + } } \ No newline at end of file diff --git a/client/src/campaigns/CUD.js b/client/src/campaigns/CUD.js index f03561a9..6c81937d 100644 --- a/client/src/campaigns/CUD.js +++ b/client/src/campaigns/CUD.js @@ -608,13 +608,21 @@ export default class CUD extends Component { sendSettings = []; const addOverridable = (id, label) => { - sendSettings.push(); - - if (this.getFormValue(id + '_overriden')) { - sendSettings.push(); - } else { + if(this.state.sendConfiguration[id + '_overridable']){ + if (this.getFormValue(id + '_overriden')) { + sendSettings.push(); + } else { + sendSettings.push( + + {this.state.sendConfiguration[id]} + + ); + } + sendSettings.push(); + } + else{ sendSettings.push( - + {this.state.sendConfiguration[id]} ); @@ -733,20 +741,32 @@ export default class CUD extends Component {
- +
- {sendSettings} + - + {sendSettings} + + +

- - +
+ + +
+ + {sourceEdit && + <> +
+
+ {sourceEdit} +
+ + } - {sourceEdit &&
} - {sourceEdit} {templateEdit} diff --git a/client/src/campaigns/Status.js b/client/src/campaigns/Status.js index afa700ea..4af80eca 100644 --- a/client/src/campaigns/Status.js +++ b/client/src/campaigns/Status.js @@ -225,6 +225,18 @@ class SendControls extends Component { await this.refreshEntity(); } + async confirmStart() { + const t = this.props.t; + this.actionDialog( + t('confirmLaunch'), + t('doYouWantToLaunchTheCampaign?All'), + async () => { + await this.startAsync(); + await this.refreshEntity(); + } + ); + } + async resetAsync() { const t = this.props.t; this.actionDialog( @@ -306,7 +318,7 @@ class SendControls extends Component { {this.getFormValue('sendLater') ? - {this.props.children} - - ) - } -} - -export class Icon extends Component { - static propTypes = { - icon: PropTypes.string.isRequired, - family: PropTypes.string, - title: PropTypes.string, - className: PropTypes.string - } - - static defaultProps = { - family: 'fas' - } - - render() { - const props = this.props; - - if (props.family === 'fas' || props.family === 'far') { - return ; - } else { - console.error(`Icon font family ${props.family} not supported. (icon: ${props.icon}, title: ${props.title})`) - return null; - } - } -} - -@withComponentMixins([ - withErrorHandling -]) -export class Button extends Component { - static propTypes = { - onClickAsync: PropTypes.func, - label: PropTypes.string, - icon: PropTypes.string, - iconTitle: PropTypes.string, - className: PropTypes.string, - title: PropTypes.string, - type: PropTypes.string, - disabled: PropTypes.bool - } - - @withAsyncErrorHandler - async onClick(evt) { - if (this.props.onClickAsync) { - evt.preventDefault(); - await this.props.onClickAsync(evt); - } - } - - render() { - const props = this.props; - - let className = 'btn'; - if (props.className) { - className = className + ' ' + props.className; - } - - let type = props.type || 'button'; - - let icon; - if (props.icon) { - icon = - } - - let iconSpacer; - if (props.icon && props.label) { - iconSpacer = ' '; - } - - return ( - - ); - } -} - -export class ButtonDropdown extends Component { - static propTypes = { - label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), - className: PropTypes.string, - buttonClassName: PropTypes.string, - menuClassName: PropTypes.string - } - - render() { - const props = this.props; - - const className = 'dropdown' + (props.className ? ' ' + props.className : ''); - const buttonClassName = 'btn dropdown-toggle' + (props.buttonClassName ? ' ' + props.buttonClassName : ''); - const menuClassName = 'dropdown-menu' + (props.menuClassName ? ' ' + props.menuClassName : ''); - - return ( -
- -
    - {props.children} -
- -
- ); - } -} - -@withComponentMixins([ - withErrorHandling -]) -export class ActionLink extends Component { - static propTypes = { - onClickAsync: PropTypes.func, - className: PropTypes.string, - href: PropTypes.string - } - - @withAsyncErrorHandler - async onClick(evt) { - if (this.props.onClickAsync) { - evt.preventDefault(); - evt.stopPropagation(); - - await this.props.onClickAsync(evt); - } - } - - render() { - const props = this.props; - - return ( - {props.children} - ); - } -} - - -export class DropdownActionLink extends Component { - static propTypes = { - onClickAsync: PropTypes.func, - className: PropTypes.string, - disabled: PropTypes.bool - } - - render() { - const props = this.props; - - let clsName = "dropdown-item "; - if (props.disabled) { - clsName += "disabled "; - } - - clsName += props.className; - - return ( - {props.children} - ); - } -} - - -export class DropdownDivider extends Component { - static propTypes = { - className: PropTypes.string - } - - render() { - const props = this.props; - - let className = 'dropdown-divider'; - if (props.className) { - className = className + ' ' + props.className; - } - - return ( -
- ); - } -} - - -@withComponentMixins([ - withTranslation, - withErrorHandling -]) -export class ModalDialog extends Component { - constructor(props) { - super(props); - - const t = props.t; - - this.state = { - buttons: this.props.buttons || [ { label: t('close'), className: 'btn-secondary', onClickAsync: null } ] - }; - } - - static propTypes = { - title: PropTypes.string, - onCloseAsync: PropTypes.func, - onButtonClickAsync: PropTypes.func, - buttons: PropTypes.array, - hidden: PropTypes.bool, - className: PropTypes.string - } - - /* - this.props.hidden - this is the desired state of the modal - this.hidden - this is the actual state of the modal - this is because there is no public API on Bootstrap modal to know whether the modal is shown or not - */ - - componentDidMount() { - const jqModal = jQuery(this.domModal); - - jqModal.on('shown.bs.modal', () => jqModal.focus()); - jqModal.on('hide.bs.modal', ::this.onHide); - - this.hidden = this.props.hidden; - jqModal.modal({ - show: !this.props.hidden - }); - } - - componentDidUpdate() { - if (this.props.hidden != this.hidden) { - const jqModal = jQuery(this.domModal); - this.hidden = this.props.hidden; - jqModal.modal(this.props.hidden ? 'hide' : 'show'); - } - } - - componentWillUnmount() { - // We discard the modal in a hard way (without hiding it). Thus we have to take care of the backgrop too. - jQuery('.modal-backdrop').remove(); - } - - onHide(evt) { - // Hide event is emited is both when hidden through user action or through API. We have to let the API - // calls through, otherwise the modal would never hide. The user actions, which change the desired state, - // are capture, converted to onClose callback and prevented. It's up to the parent to decide whether to - // hide the modal or not. - if (!this.props.hidden) { - // noinspection JSIgnoredPromiseFromCall - this.onClose(); - evt.preventDefault(); - } - } - - @withAsyncErrorHandler - async onClose() { - if (this.props.onCloseAsync) { - await this.props.onCloseAsync(); - } - } - - async onButtonClick(idx) { - const buttonSpec = this.state.buttons[idx]; - if (buttonSpec.onClickAsync) { - await buttonSpec.onClickAsync(idx); - } - } - - render() { - const props = this.props; - const t = props.t; - - const buttons = []; - for (let idx = 0; idx < this.state.buttons.length; idx++) { - const buttonSpec = this.state.buttons[idx]; - const button = -
-
{this.props.children}
-
- {buttons} -
- - - - ); - } -} - +'use strict'; + +import React, {Component} from 'react'; +import {withTranslation} from './i18n'; +import PropTypes + from 'prop-types'; +import { + withAsyncErrorHandler, + withErrorHandling +} from './error-handling'; +import {withComponentMixins} from "./decorator-helpers"; + +@withComponentMixins([ + withTranslation, + withErrorHandling +]) +export class DismissibleAlert extends Component { + static propTypes = { + severity: PropTypes.string.isRequired, + onCloseAsync: PropTypes.func + } + + @withAsyncErrorHandler + onClose() { + if (this.props.onCloseAsync) { + this.props.onCloseAsync(); + } + } + + render() { + const t = this.props.t; + + return ( +
+ + {this.props.children} +
+ ) + } +} + +export class Icon extends Component { + static propTypes = { + icon: PropTypes.string.isRequired, + family: PropTypes.string, + title: PropTypes.string, + className: PropTypes.string + } + + static defaultProps = { + family: 'fas' + } + + render() { + const props = this.props; + + if (props.family === 'fas' || props.family === 'far') { + return ; + } else { + console.error(`Icon font family ${props.family} not supported. (icon: ${props.icon}, title: ${props.title})`) + return null; + } + } +} + +@withComponentMixins([ + withErrorHandling +]) +export class Button extends Component { + static propTypes = { + onClickAsync: PropTypes.func, + label: PropTypes.string, + icon: PropTypes.string, + iconTitle: PropTypes.string, + className: PropTypes.string, + title: PropTypes.string, + type: PropTypes.string, + disabled: PropTypes.bool + } + + @withAsyncErrorHandler + async onClick(evt) { + if (this.props.onClickAsync) { + evt.preventDefault(); + await this.props.onClickAsync(evt); + } + } + + render() { + const props = this.props; + + let className = 'btn'; + if (props.className) { + className = className + ' ' + props.className; + } + + let type = props.type || 'button'; + + let icon; + if (props.icon) { + icon = + } + + let iconSpacer; + if (props.icon && props.label) { + iconSpacer = ' '; + } + + return ( + + ); + } +} + +export class ButtonDropdown extends Component { + static propTypes = { + label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + className: PropTypes.string, + buttonClassName: PropTypes.string, + menuClassName: PropTypes.string + } + + render() { + const props = this.props; + + const className = 'dropdown' + (props.className ? ' ' + props.className : ''); + const buttonClassName = 'btn dropdown-toggle' + (props.buttonClassName ? ' ' + props.buttonClassName : ''); + const menuClassName = 'dropdown-menu' + (props.menuClassName ? ' ' + props.menuClassName : ''); + + return ( +
+ +
    + {props.children} +
+ +
+ ); + } +} + +@withComponentMixins([ + withErrorHandling +]) +export class ActionLink extends Component { + static propTypes = { + onClickAsync: PropTypes.func, + className: PropTypes.string, + href: PropTypes.string + } + + @withAsyncErrorHandler + async onClick(evt) { + if (this.props.onClickAsync) { + evt.preventDefault(); + evt.stopPropagation(); + + await this.props.onClickAsync(evt); + } + } + + render() { + const props = this.props; + + return ( + {props.children} + ); + } +} + + +export class DropdownActionLink extends Component { + static propTypes = { + onClickAsync: PropTypes.func, + className: PropTypes.string, + disabled: PropTypes.bool + } + + render() { + const props = this.props; + + let clsName = "dropdown-item "; + if (props.disabled) { + clsName += "disabled "; + } + + clsName += props.className; + + return ( + {props.children} + ); + } +} + + +export class DropdownDivider extends Component { + static propTypes = { + className: PropTypes.string + } + + render() { + const props = this.props; + + let className = 'dropdown-divider'; + if (props.className) { + className = className + ' ' + props.className; + } + + return ( +
+ ); + } +} + + +@withComponentMixins([ + withTranslation, + withErrorHandling +]) +export class ModalDialog extends Component { + constructor(props) { + super(props); + + const t = props.t; + + this.state = { + buttons: this.props.buttons || [ { label: t('close'), className: 'btn-secondary', onClickAsync: null } ] + }; + } + + static propTypes = { + title: PropTypes.string, + onCloseAsync: PropTypes.func, + onButtonClickAsync: PropTypes.func, + buttons: PropTypes.array, + hidden: PropTypes.bool, + className: PropTypes.string + } + + /* + this.props.hidden - this is the desired state of the modal + this.hidden - this is the actual state of the modal - this is because there is no public API on Bootstrap modal to know whether the modal is shown or not + */ + + componentDidMount() { + const jqModal = jQuery(this.domModal); + + jqModal.on('shown.bs.modal', () => jqModal.focus()); + jqModal.on('hide.bs.modal', ::this.onHide); + + this.hidden = this.props.hidden; + jqModal.modal({ + show: !this.props.hidden + }); + } + + componentDidUpdate() { + if (this.props.hidden != this.hidden) { + const jqModal = jQuery(this.domModal); + this.hidden = this.props.hidden; + jqModal.modal(this.props.hidden ? 'hide' : 'show'); + } + } + + componentWillUnmount() { + // We discard the modal in a hard way (without hiding it). Thus we have to take care of the backgrop too. + jQuery('.modal-backdrop').remove(); + } + + onHide(evt) { + // Hide event is emited is both when hidden through user action or through API. We have to let the API + // calls through, otherwise the modal would never hide. The user actions, which change the desired state, + // are capture, converted to onClose callback and prevented. It's up to the parent to decide whether to + // hide the modal or not. + if (!this.props.hidden) { + // noinspection JSIgnoredPromiseFromCall + this.onClose(); + evt.preventDefault(); + } + } + + @withAsyncErrorHandler + async onClose() { + if (this.props.onCloseAsync) { + await this.props.onCloseAsync(); + } + } + + async onButtonClick(idx) { + const buttonSpec = this.state.buttons[idx]; + if (buttonSpec.onClickAsync) { + await buttonSpec.onClickAsync(idx); + } + } + + render() { + const props = this.props; + const t = props.t; + + const buttons = []; + for (let idx = 0; idx < this.state.buttons.length; idx++) { + const buttonSpec = this.state.buttons[idx]; + const button = +
+
{this.props.children}
+
+ {buttons} +
+ + + + ); + } +} + diff --git a/client/src/lib/decorator-helpers.js b/client/src/lib/decorator-helpers.js index b3f248c3..7ac9ac24 100644 --- a/client/src/lib/decorator-helpers.js +++ b/client/src/lib/decorator-helpers.js @@ -1,128 +1,128 @@ -'use strict'; - -import React from "react"; - -export function createComponentMixin(contexts, deps, decoratorFn) { - return { - contexts, - deps, - decoratorFn - }; -} - -export function withComponentMixins(mixins, delegateFuns) { - const mixinsClosure = new Set(); - for (const mixin of mixins) { - mixinsClosure.add(mixin); - for (const dep of mixin.deps) { - mixinsClosure.add(dep); - } - } - - const contexts = new Map(); - for (const mixin of mixinsClosure.values()) { - for (const ctx of mixin.contexts) { - contexts.set(ctx.propName, ctx.context); - } - } - - return TargetClass => { - const ctors = []; - const mixinDelegateFuns = []; - - if (delegateFuns) { - mixinDelegateFuns.push(...delegateFuns); - } - - function TargetClassWithCtors(props) { - if (!new.target) { - throw new TypeError(); - } - - const self = Reflect.construct(TargetClass, [props], new.target); - - for (const ctor of ctors) { - ctor(self, props); - } - - return self; - } - - TargetClassWithCtors.prototype = TargetClass.prototype; - - for (const attr in TargetClass) { - TargetClassWithCtors[attr] = TargetClass[attr]; - } - - - class ComponentMixinsInner extends React.Component { - render() { - const props = { - ...this.props, - ref: this.props._decoratorInnerInstanceRefFn - }; - delete props._decoratorInnerInstanceRefFn; - - return ( - - ); - } - } - - let DecoratedInner = ComponentMixinsInner; - - for (const mixin of mixinsClosure.values()) { - const res = mixin.decoratorFn(DecoratedInner, TargetClassWithCtors); - - if (res.cls) { - DecoratedInner = res.cls; - } - - if (res.ctor) { - ctors.push(res.ctor); - } - - if (res.delegateFuns) { - mixinDelegateFuns.push(...res.delegateFuns); - } - } - - class ComponentMixinsOuter extends React.Component { - render() { - let innerFn = parentProps => { - const props = { - ...parentProps, - _decoratorInnerInstanceRefFn: node => this._decoratorInnerInstance = node - }; - - return - } - - for (const [propName, Context] of contexts.entries()) { - const existingInnerFn = innerFn; - innerFn = parentProps => ( - - { - value => existingInnerFn({ - ...parentProps, - [propName]: value - }) - } - - ); - } - - return innerFn(this.props); - } - } - - for (const fun of mixinDelegateFuns) { - ComponentMixinsOuter.prototype[fun] = function (...args) { - return this._decoratorInnerInstance[fun](...args); - } - } - - return ComponentMixinsOuter; - }; -} - +'use strict'; + +import React from "react"; + +export function createComponentMixin(contexts, deps, decoratorFn) { + return { + contexts, + deps, + decoratorFn + }; +} + +export function withComponentMixins(mixins, delegateFuns) { + const mixinsClosure = new Set(); + for (const mixin of mixins) { + mixinsClosure.add(mixin); + for (const dep of mixin.deps) { + mixinsClosure.add(dep); + } + } + + const contexts = new Map(); + for (const mixin of mixinsClosure.values()) { + for (const ctx of mixin.contexts) { + contexts.set(ctx.propName, ctx.context); + } + } + + return TargetClass => { + const ctors = []; + const mixinDelegateFuns = []; + + if (delegateFuns) { + mixinDelegateFuns.push(...delegateFuns); + } + + function TargetClassWithCtors(props) { + if (!new.target) { + throw new TypeError(); + } + + const self = Reflect.construct(TargetClass, [props], new.target); + + for (const ctor of ctors) { + ctor(self, props); + } + + return self; + } + + TargetClassWithCtors.prototype = TargetClass.prototype; + + for (const attr in TargetClass) { + TargetClassWithCtors[attr] = TargetClass[attr]; + } + + + class ComponentMixinsInner extends React.Component { + render() { + const props = { + ...this.props, + ref: this.props._decoratorInnerInstanceRefFn + }; + delete props._decoratorInnerInstanceRefFn; + + return ( + + ); + } + } + + let DecoratedInner = ComponentMixinsInner; + + for (const mixin of mixinsClosure.values()) { + const res = mixin.decoratorFn(DecoratedInner, TargetClassWithCtors); + + if (res.cls) { + DecoratedInner = res.cls; + } + + if (res.ctor) { + ctors.push(res.ctor); + } + + if (res.delegateFuns) { + mixinDelegateFuns.push(...res.delegateFuns); + } + } + + class ComponentMixinsOuter extends React.Component { + render() { + let innerFn = parentProps => { + const props = { + ...parentProps, + _decoratorInnerInstanceRefFn: node => this._decoratorInnerInstance = node + }; + + return + } + + for (const [propName, Context] of contexts.entries()) { + const existingInnerFn = innerFn; + innerFn = parentProps => ( + + { + value => existingInnerFn({ + ...parentProps, + [propName]: value + }) + } + + ); + } + + return innerFn(this.props); + } + } + + for (const fun of mixinDelegateFuns) { + ComponentMixinsOuter.prototype[fun] = function (...args) { + return this._decoratorInnerInstance[fun](...args); + } + } + + return ComponentMixinsOuter; + }; +} + diff --git a/client/src/lib/error-handling.js b/client/src/lib/error-handling.js index 68752e4b..d70b4f34 100644 --- a/client/src/lib/error-handling.js +++ b/client/src/lib/error-handling.js @@ -1,76 +1,76 @@ -'use strict'; - -import React from "react"; -import PropTypes from 'prop-types'; -import {createComponentMixin} from "./decorator-helpers"; - -function handleError(that, error) { - let errorHandled; - if (that.errorHandler) { - errorHandled = that.errorHandler(error); - } - - if (!errorHandled && that.props.parentErrorHandler) { - errorHandled = handleError(that.props.parentErrorHandler, error); - } - - if (!errorHandled) { - throw error; - } - - return errorHandled; -} - -export const ParentErrorHandlerContext = React.createContext(null); -export const withErrorHandling = createComponentMixin([{context: ParentErrorHandlerContext, propName: 'parentErrorHandler'}], [], (TargetClass, InnerClass) => { - /* Example of use: - this.getFormValuesFromURL(....).catch(error => this.handleError(error)); - - It's equivalent to: - - @withAsyncErrorHandler - async loadFormValues() { - await this.getFormValuesFromURL(...); - } - */ - - const originalRender = InnerClass.prototype.render; - - InnerClass.prototype.render = function() { - return ( - - {originalRender.apply(this)} - - ); - } - - InnerClass.prototype.handleError = function(error) { - handleError(this, error); - }; - - return {}; -}); - -export function withAsyncErrorHandler(target, name, descriptor) { - let fn = descriptor.value; - - descriptor.value = async function () { - try { - await fn.apply(this, arguments) - } catch (error) { - handleError(this, error); - } - }; - - return descriptor; -} - -export function wrapWithAsyncErrorHandler(self, fn) { - return async function () { - try { - await fn.apply(this, arguments) - } catch (error) { - handleError(self, error); - } - }; -} +'use strict'; + +import React from "react"; +import PropTypes from 'prop-types'; +import {createComponentMixin} from "./decorator-helpers"; + +function handleError(that, error) { + let errorHandled; + if (that.errorHandler) { + errorHandled = that.errorHandler(error); + } + + if (!errorHandled && that.props.parentErrorHandler) { + errorHandled = handleError(that.props.parentErrorHandler, error); + } + + if (!errorHandled) { + throw error; + } + + return errorHandled; +} + +export const ParentErrorHandlerContext = React.createContext(null); +export const withErrorHandling = createComponentMixin([{context: ParentErrorHandlerContext, propName: 'parentErrorHandler'}], [], (TargetClass, InnerClass) => { + /* Example of use: + this.getFormValuesFromURL(....).catch(error => this.handleError(error)); + + It's equivalent to: + + @withAsyncErrorHandler + async loadFormValues() { + await this.getFormValuesFromURL(...); + } + */ + + const originalRender = InnerClass.prototype.render; + + InnerClass.prototype.render = function() { + return ( + + {originalRender.apply(this)} + + ); + } + + InnerClass.prototype.handleError = function(error) { + handleError(this, error); + }; + + return {}; +}); + +export function withAsyncErrorHandler(target, name, descriptor) { + let fn = descriptor.value; + + descriptor.value = async function () { + try { + await fn.apply(this, arguments) + } catch (error) { + handleError(this, error); + } + }; + + return descriptor; +} + +export function wrapWithAsyncErrorHandler(self, fn) { + return async function () { + try { + await fn.apply(this, arguments) + } catch (error) { + handleError(self, error); + } + }; +} diff --git a/client/src/lib/form.js b/client/src/lib/form.js index d14b63fd..747a4801 100644 --- a/client/src/lib/form.js +++ b/client/src/lib/form.js @@ -339,7 +339,8 @@ class CheckBox extends Component { text: PropTypes.string.isRequired, label: PropTypes.string, help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), - format: PropTypes.string + format: PropTypes.string, + className: PropTypes.string } render() { @@ -348,12 +349,12 @@ class CheckBox extends Component { const id = this.props.id; const htmlId = 'form_' + id; - const className = owner.addFormValidationClass('form-check-input', id); + const inputClassName = owner.addFormValidationClass('form-check-input', id); return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help, -
- owner.updateFormValue(id, !owner.getFormValue(id))}/> - +
+ owner.updateFormValue(id, !owner.getFormValue(id))}/> +
); } diff --git a/client/src/lib/helpers.js b/client/src/lib/helpers.js index f2db92e3..8c2f8e27 100644 --- a/client/src/lib/helpers.js +++ b/client/src/lib/helpers.js @@ -1,8 +1,8 @@ -'use strict'; - -import ellipsize from "ellipsize"; - - -export function ellipsizeBreadcrumbLabel(label) { - return ellipsize(label, 40) +'use strict'; + +import ellipsize from "ellipsize"; + + +export function ellipsizeBreadcrumbLabel(label) { + return ellipsize(label, 40) } \ No newline at end of file diff --git a/client/src/lib/i18n.js b/client/src/lib/i18n.js index 50955cab..315c7a42 100644 --- a/client/src/lib/i18n.js +++ b/client/src/lib/i18n.js @@ -1,72 +1,75 @@ -'use strict'; - -import React, {Component} from 'react'; -import i18n - from 'i18next'; -import {withNamespaces} from "react-i18next"; -import LanguageDetector - from 'i18next-browser-languagedetector'; -import mailtrainConfig - from 'mailtrainConfig'; - -import {convertToFake, getLang} from '../../../shared/langs'; -import {createComponentMixin} from "./decorator-helpers"; - -import lang_en_US_common from "../../../locales/en-US/common"; - -const resourcesCommon = { - 'en-US': lang_en_US_common, - 'fk-FK': convertToFake(lang_en_US_common) -}; - -const resources = {}; -for (const lng of mailtrainConfig.enabledLanguages) { - const langDesc = getLang(lng); - resources[langDesc.longCode] = { - common: resourcesCommon[langDesc.longCode] - }; -} - -i18n - .use(LanguageDetector) - .init({ - resources, - - fallbackLng: mailtrainConfig.defaultLanguage, - defaultNS: 'common', - - interpolation: { - escapeValue: false // not needed for react - }, - - react: { - wait: true - }, - - detection: { - order: ['querystring', 'cookie', 'localStorage', 'navigator'], - lookupQuerystring: 'locale', - lookupCookie: 'i18nextLng', - lookupLocalStorage: 'i18nextLng', - caches: ['localStorage', 'cookie'] - }, - - whitelist: mailtrainConfig.enabledLanguages, - load: 'currentOnly', - - debug: false - }); - - -export default i18n; - - -export const withTranslation = createComponentMixin([], [], (TargetClass, InnerClass) => { - return { - cls: withNamespaces()(TargetClass) - }; -}); - -export function tMark(key) { - return key; -} +'use strict'; + +import React, {Component} from 'react'; +import i18n + from 'i18next'; +import {withNamespaces} from "react-i18next"; +import LanguageDetector + from 'i18next-browser-languagedetector'; +import mailtrainConfig + from 'mailtrainConfig'; + +import {convertToFake, getLang} from '../../../shared/langs'; +import {createComponentMixin} from "./decorator-helpers"; + +import lang_en_US_common from "../../../locales/en-US/common"; +import lang_es_ES_common from "../../../locales/es-ES/common"; + + +const resourcesCommon = { + 'en-US': lang_en_US_common, + 'es-ES': lang_es_ES_common, + 'fk-FK': convertToFake(lang_en_US_common) +}; + +const resources = {}; +for (const lng of mailtrainConfig.enabledLanguages) { + const langDesc = getLang(lng); + resources[langDesc.longCode] = { + common: resourcesCommon[langDesc.longCode] + }; +} + +i18n + .use(LanguageDetector) + .init({ + resources, + + fallbackLng: mailtrainConfig.defaultLanguage, + defaultNS: 'common', + + interpolation: { + escapeValue: false // not needed for react + }, + + react: { + wait: true + }, + + detection: { + order: ['querystring', 'cookie', 'localStorage', 'navigator'], + lookupQuerystring: 'locale', + lookupCookie: 'i18nextLng', + lookupLocalStorage: 'i18nextLng', + caches: ['localStorage', 'cookie'] + }, + + whitelist: mailtrainConfig.enabledLanguages, + load: 'currentOnly', + + debug: false + }); + + +export default i18n; + + +export const withTranslation = createComponentMixin([], [], (TargetClass, InnerClass) => { + return { + cls: withNamespaces()(TargetClass) + }; +}); + +export function tMark(key) { + return key; +} diff --git a/client/src/lib/namespace.js b/client/src/lib/namespace.js index 24b0f911..40599c1c 100644 --- a/client/src/lib/namespace.js +++ b/client/src/lib/namespace.js @@ -1,33 +1,33 @@ -'use strict'; - -import React, {Component} from 'react'; -import {withTranslation} from './i18n'; -import {TreeTableSelect} from './form'; -import {withComponentMixins} from "./decorator-helpers"; - - -@withComponentMixins([ - withTranslation -]) -class NamespaceSelect extends Component { - render() { - const t = this.props.t; - - return ( - - ); - } -} - -function validateNamespace(t, state) { - if (!state.getIn(['namespace', 'value'])) { - state.setIn(['namespace', 'error'], t('namespacemustBeSelected')); - } else { - state.setIn(['namespace', 'error'], null); - } -} - -export { - NamespaceSelect, - validateNamespace +'use strict'; + +import React, {Component} from 'react'; +import {withTranslation} from './i18n'; +import {TreeTableSelect} from './form'; +import {withComponentMixins} from "./decorator-helpers"; + + +@withComponentMixins([ + withTranslation +]) +class NamespaceSelect extends Component { + render() { + const t = this.props.t; + + return ( + + ); + } +} + +function validateNamespace(t, state) { + if (!state.getIn(['namespace', 'value'])) { + state.setIn(['namespace', 'error'], t('namespacemustBeSelected')); + } else { + state.setIn(['namespace', 'error'], null); + } +} + +export { + NamespaceSelect, + validateNamespace }; \ No newline at end of file diff --git a/client/src/lib/page-common.js b/client/src/lib/page-common.js index 9cfe2cb5..aac29542 100644 --- a/client/src/lib/page-common.js +++ b/client/src/lib/page-common.js @@ -1,146 +1,146 @@ -'use strict'; - -import React - from "react"; -import {withRouter} from "react-router"; -import {withErrorHandling} from "./error-handling"; -import axios - from "../lib/axios"; -import {getUrl} from "./urls"; -import {createComponentMixin} from "./decorator-helpers"; - -export function needsResolve(route, nextRoute, match, nextMatch) { - const resolve = route.resolve; - const nextResolve = nextRoute.resolve; - - // This compares whether two objects have the same content and returns TRUE if they don't - if (Object.keys(resolve).length === Object.keys(nextResolve).length) { - for (const key in nextResolve) { - if (!(key in resolve) || - resolve[key](match.params) !== nextResolve[key](nextMatch.params)) { - return true; - } - } - } else { - return true; - } - - return false; -} - -export async function resolve(route, match) { - const keys = Object.keys(route.resolve); - - const promises = keys.map(key => { - const url = route.resolve[key](match.params); - if (url) { - return axios.get(getUrl(url)); - } else { - return Promise.resolve({data: null}); - } - }); - const resolvedArr = await Promise.all(promises); - - const resolved = {}; - for (let idx = 0; idx < keys.length; idx++) { - resolved[keys[idx]] = resolvedArr[idx].data; - } - - return resolved; -} - -export function getRoutes(urlPrefix, resolve, parents, structure, navs, primaryMenuComponent, secondaryMenuComponent) { - 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), - panelComponent: entry.panelComponent, - panelRender: entry.panelRender, - primaryMenuComponent: entry.primaryMenuComponent || primaryMenuComponent, - secondaryMenuComponent: entry.secondaryMenuComponent || secondaryMenuComponent, - title: entry.title, - link: entry.link, - panelInFullScreen: entry.panelInFullScreen, - insideIframe: entry.insideIframe, - 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(getRoutes(path + '/', entryResolve, childrenParents, { [navKey]: nav }, childNavs, route.primaryMenuComponent, route.secondaryMenuComponent)); - } - } - - if (entry.children) { - routes = routes.concat(getRoutes(path + '/', entryResolve, childrenParents, entry.children, entryNavs, route.primaryMenuComponent, route.secondaryMenuComponent)); - } - } - - return routes; -} - -export const SectionContentContext = React.createContext(null); -export const withPageHelpers = createComponentMixin([{context: SectionContentContext, propName: 'sectionContent'}], [withErrorHandling], (TargetClass, InnerClass) => { - InnerClass.prototype.setFlashMessage = function(severity, text) { - return this.props.sectionContent.setFlashMessage(severity, text); - }; - - InnerClass.prototype.navigateTo = function(path) { - return this.props.sectionContent.navigateTo(path); - } - - InnerClass.prototype.navigateBack = function() { - return this.props.sectionContent.navigateBack(); - } - - InnerClass.prototype.navigateToWithFlashMessage = function(path, severity, text) { - return this.props.sectionContent.navigateToWithFlashMessage(path, severity, text); - } - - return {}; -}); +'use strict'; + +import React + from "react"; +import {withRouter} from "react-router"; +import {withErrorHandling} from "./error-handling"; +import axios + from "../lib/axios"; +import {getUrl} from "./urls"; +import {createComponentMixin} from "./decorator-helpers"; + +export function needsResolve(route, nextRoute, match, nextMatch) { + const resolve = route.resolve; + const nextResolve = nextRoute.resolve; + + // This compares whether two objects have the same content and returns TRUE if they don't + if (Object.keys(resolve).length === Object.keys(nextResolve).length) { + for (const key in nextResolve) { + if (!(key in resolve) || + resolve[key](match.params) !== nextResolve[key](nextMatch.params)) { + return true; + } + } + } else { + return true; + } + + return false; +} + +export async function resolve(route, match) { + const keys = Object.keys(route.resolve); + + const promises = keys.map(key => { + const url = route.resolve[key](match.params); + if (url) { + return axios.get(getUrl(url)); + } else { + return Promise.resolve({data: null}); + } + }); + const resolvedArr = await Promise.all(promises); + + const resolved = {}; + for (let idx = 0; idx < keys.length; idx++) { + resolved[keys[idx]] = resolvedArr[idx].data; + } + + return resolved; +} + +export function getRoutes(urlPrefix, resolve, parents, structure, navs, primaryMenuComponent, secondaryMenuComponent) { + 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), + panelComponent: entry.panelComponent, + panelRender: entry.panelRender, + primaryMenuComponent: entry.primaryMenuComponent || primaryMenuComponent, + secondaryMenuComponent: entry.secondaryMenuComponent || secondaryMenuComponent, + title: entry.title, + link: entry.link, + panelInFullScreen: entry.panelInFullScreen, + insideIframe: entry.insideIframe, + 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(getRoutes(path + '/', entryResolve, childrenParents, { [navKey]: nav }, childNavs, route.primaryMenuComponent, route.secondaryMenuComponent)); + } + } + + if (entry.children) { + routes = routes.concat(getRoutes(path + '/', entryResolve, childrenParents, entry.children, entryNavs, route.primaryMenuComponent, route.secondaryMenuComponent)); + } + } + + return routes; +} + +export const SectionContentContext = React.createContext(null); +export const withPageHelpers = createComponentMixin([{context: SectionContentContext, propName: 'sectionContent'}], [withErrorHandling], (TargetClass, InnerClass) => { + InnerClass.prototype.setFlashMessage = function(severity, text) { + return this.props.sectionContent.setFlashMessage(severity, text); + }; + + InnerClass.prototype.navigateTo = function(path) { + return this.props.sectionContent.navigateTo(path); + } + + InnerClass.prototype.navigateBack = function() { + return this.props.sectionContent.navigateBack(); + } + + InnerClass.prototype.navigateToWithFlashMessage = function(path, severity, text) { + return this.props.sectionContent.navigateToWithFlashMessage(path, severity, text); + } + + return {}; +}); diff --git a/client/src/lib/page.js b/client/src/lib/page.js index e05a7d1e..995b58d7 100644 --- a/client/src/lib/page.js +++ b/client/src/lib/page.js @@ -1,699 +1,699 @@ -'use strict'; - -import React, {Component} from "react"; -import i18n, {withTranslation} from './i18n'; -import PropTypes - from "prop-types"; -import {withRouter} from "react-router"; -import { - BrowserRouter as Router, - Link, - Redirect, - Route, - Switch -} from "react-router-dom"; -import { - withAsyncErrorHandler, - withErrorHandling -} from "./error-handling"; -import interoperableErrors - from "../../../shared/interoperable-errors"; -import { - ActionLink, - Button, - DismissibleAlert, - DropdownActionLink, - Icon -} from "./bootstrap-components"; -import mailtrainConfig - from "mailtrainConfig"; -import styles - from "./styles.scss"; -import { - getRoutes, - needsResolve, - resolve, - SectionContentContext, - withPageHelpers -} from "./page-common"; -import {getBaseDir} from "./urls"; -import { - createComponentMixin, - withComponentMixins -} from "./decorator-helpers"; -import {getLang} from "../../../shared/langs"; - -export { withPageHelpers } - -class Breadcrumb extends Component { - constructor(props) { - super(props); - } - - static propTypes = { - route: PropTypes.object.isRequired, - params: PropTypes.object.isRequired, - resolved: PropTypes.object.isRequired - } - - renderElement(entry, isActive) { - const params = this.props.params; - let title; - if (typeof entry.title === 'function') { - title = entry.title(this.props.resolved, params); - } else { - title = entry.title; - } - - if (isActive) { - return
  • {title}
  • ; - - } else if (entry.externalLink) { - let externalLink; - if (typeof entry.externalLink === 'function') { - externalLink = entry.externalLink(params); - } else { - externalLink = entry.externalLink; - } - - return
  • {title}
  • ; - - } else if (entry.link) { - let link; - if (typeof entry.link === 'function') { - link = entry.link(params); - } else { - link = entry.link; - } - return
  • {title}
  • ; - - } else { - return
  • {title}
  • ; - } - } - - render() { - const route = this.props.route; - - const renderedElems = [...route.parents.map(x => this.renderElement(x)), this.renderElement(route, true)]; - - return ; - } -} - -class TertiaryNavBar 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 liClassName = 'nav-item'; - let linkClassName = 'nav-link'; - if (entry.active) { - linkClassName += ' active'; - } - - if (entry.link) { - let link; - - if (typeof entry.link === 'function') { - link = entry.link(params); - } else { - link = entry.link; - } - - return
  • {title}
  • ; - - } else if (entry.externalLink) { - let externalLink; - if (typeof entry.externalLink === 'function') { - externalLink = entry.externalLink(params); - } else { - externalLink = entry.externalLink; - } - - return
  • {title}
  • ; - - } else { - return
  • {title}
  • ; - } - } - - render() { - const route = this.props.route; - - const keys = Object.keys(route.navs); - const renderedElems = []; - - for (const key of 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)); - } - } - - if (renderedElems.length > 1) { - let className = styles.tertiaryNav + ' nav nav-pills'; - if (this.props.className) { - className += ' ' + this.props.className; - } - - return
      {renderedElems}
    ; - } else { - return null; - } - } -} - -@withComponentMixins([ - withTranslation, - withErrorHandling -]) -class RouteContent extends Component { - constructor(props) { - super(props); - this.state = { - panelInFullScreen: props.route.panelInFullScreen - }; - - if (Object.keys(props.route.resolve).length === 0) { - this.state.resolved = {}; - } - - this.sidebarAnimationNodeListener = evt => { - if (evt.propertyName === 'left') { - this.forceUpdate(); - } - }; - - this.setPanelInFullScreen = panelInFullScreen => this.setState({ panelInFullScreen }); - } - - static propTypes = { - route: PropTypes.object.isRequired, - flashMessage: PropTypes.object - } - - @withAsyncErrorHandler - async resolve() { - const props = this.props; - - if (Object.keys(props.route.resolve).length === 0) { - this.setState({ - resolved: {} - }); - - } else { - this.setState({ - resolved: null - }); - - const resolved = await resolve(props.route, props.match); - - if (!this.disregardResolve) { // This is to prevent the warning about setState on discarded component when we immediatelly redirect. - this.setState({ - resolved - }); - } - } - } - - registerSidebarAnimationListener() { - if (this.sidebarAnimationNode) { - this.sidebarAnimationNode.addEventListener("transitionend", this.sidebarAnimationNodeListener); - } - } - - componentDidMount() { - // noinspection JSIgnoredPromiseFromCall - this.resolve(); - this.registerSidebarAnimationListener(); - } - - componentDidUpdate(prevProps) { - this.registerSidebarAnimationListener(); - - if (this.props.location.state !== prevProps.location.state || (this.props.match.params !== prevProps.match.params && needsResolve(prevProps.route, this.props.route, prevProps.match, this.props.match))) { - // noinspection JSIgnoredPromiseFromCall - this.resolve(); - } - } - - componentWillUnmount() { - this.disregardResolve = true; // This is to prevent the warning about setState on discarded component when we immediatelly redirect. - } - - render() { - const t = this.props.t; - const route = this.props.route; - const params = this.props.match.params; - const resolved = this.state.resolved; - - const showSidebar = !!route.secondaryMenuComponent; - - const panelInFullScreen = this.state.panelInFullScreen; - - if (!route.panelRender && !route.panelComponent && route.link) { - let link; - if (typeof route.link === 'function') { - link = route.link(params); - } else { - link = route.link; - } - - return ; - - } else { - let primaryMenu = null; - let secondaryMenu = null; - let content = null; - - if (resolved) { - const compProps = { - match: this.props.match, - location: this.props.location, - resolved, - setPanelInFullScreen: this.setPanelInFullScreen, - panelInFullScreen: this.state.panelInFullScreen - }; - - let panel; - if (route.panelComponent) { - panel = React.createElement(route.panelComponent, compProps); - } else if (route.panelRender) { - panel = route.panelRender(compProps); - } - - if (route.primaryMenuComponent) { - primaryMenu = React.createElement(route.primaryMenuComponent, compProps); - } - - if (route.secondaryMenuComponent) { - secondaryMenu = React.createElement(route.secondaryMenuComponent, compProps); - } - - const panelContent = ( -
    - {this.props.flashMessage} - {panel} -
    - ); - - if (panelInFullScreen) { - content = panelContent; - } else { - content = ( - <> -
    - - -
    - {panelContent} - - ); - } - - } else { - content = ( -
    - {t('loading')} -
    - ); - } - - if (panelInFullScreen) { - return ( -
    -
    -
    - {content} -
    -
    -
    - ); - - } else { - return ( -
    -
    - -
    - -
    - {showSidebar && -
    - {secondaryMenu} -
    - } -
    - {content} -
    -
    - - -
    - ); - } - } - } -} - - -@withRouter -@withComponentMixins([ - withErrorHandling -]) -export class SectionContent extends Component { - constructor(props) { - super(props); - - this.state = { - flashMessageText: '' - }; - - this.historyUnlisten = props.history.listen((location, action) => { - // noinspection JSIgnoredPromiseFromCall - this.closeFlashMessage(); - }); - } - - static propTypes = { - structure: PropTypes.object.isRequired, - root: PropTypes.string.isRequired - } - - setFlashMessage(severity, text) { - this.setState({ - flashMessageText: text, - flashMessageSeverity: severity - }); - } - - navigateTo(path) { - this.props.history.push(path); - } - - navigateBack() { - this.props.history.goBack(); - } - - navigateToWithFlashMessage(path, severity, text) { - this.props.history.push(path); - this.setFlashMessage(severity, text); - } - - ensureAuthenticated() { - if (!mailtrainConfig.isAuthenticated) { - this.navigateTo('/login?next=' + encodeURIComponent(window.location.pathname)); - } - } - - errorHandler(error) { - if (error instanceof interoperableErrors.NotLoggedInError) { - if (window.location.pathname !== '/login') { // There may be multiple async requests failing at the same time. So we take the pathname only from the first one. - this.navigateTo('/login?next=' + encodeURIComponent(window.location.pathname)); - } - } else if (error.response && error.response.data && error.response.data.message) { - console.error(error); - this.navigateToWithFlashMessage(this.props.root, 'danger', error.response.data.message); - } else { - console.error(error); - this.navigateToWithFlashMessage(this.props.root, 'danger', error.message); - } - return true; - } - - async closeFlashMessage() { - this.setState({ - flashMessageText: '' - }); - } - - renderRoute(route) { - let flashMessage; - if (this.state.flashMessageText) { - flashMessage = {this.state.flashMessageText}; - } - - const render = props => ; - - return - } - - render() { - let routes = getRoutes('', {}, [], this.props.structure, [], null, null); - - return ( - - {routes.map(x => this.renderRoute(x))} - - ); - } -} - -@withComponentMixins([ - withTranslation -]) -export class Section extends Component { - constructor(props) { - super(props); - } - - static propTypes = { - structure: PropTypes.oneOfType([PropTypes.object, PropTypes.func]).isRequired, - root: PropTypes.string.isRequired - } - - render() { - let structure = this.props.structure; - if (typeof structure === 'function') { - structure = structure(this.props.t); - } - - return ( - - - - ); - } -} - - -export class Title extends Component { - render() { - return ( -
    -

    {this.props.children}

    -
    -
    - ); - } -} - -export class Toolbar extends Component { - static propTypes = { - className: PropTypes.string, - }; - - render() { - let className = styles.toolbar + ' ' + styles.buttonRow; - if (this.props.className) { - className += ' ' + this.props.className; - } - - return ( -
    - {this.props.children} -
    - ); - } -} - -export class LinkButton extends Component { - static propTypes = { - label: PropTypes.string, - icon: PropTypes.string, - className: PropTypes.string, - to: PropTypes.string - }; - - render() { - const props = this.props; - - return ( - + } + +
    Mailtrain
    + + + +
    + {primaryMenu} +
    + + + +
    + {showSidebar && +
    + {secondaryMenu} +
    + } +
    + {content} +
    +
    + + +
    + ); + } + } + } +} + + +@withRouter +@withComponentMixins([ + withErrorHandling +]) +export class SectionContent extends Component { + constructor(props) { + super(props); + + this.state = { + flashMessageText: '' + }; + + this.historyUnlisten = props.history.listen((location, action) => { + // noinspection JSIgnoredPromiseFromCall + this.closeFlashMessage(); + }); + } + + static propTypes = { + structure: PropTypes.object.isRequired, + root: PropTypes.string.isRequired + } + + setFlashMessage(severity, text) { + this.setState({ + flashMessageText: text, + flashMessageSeverity: severity + }); + } + + navigateTo(path) { + this.props.history.push(path); + } + + navigateBack() { + this.props.history.goBack(); + } + + navigateToWithFlashMessage(path, severity, text) { + this.props.history.push(path); + this.setFlashMessage(severity, text); + } + + ensureAuthenticated() { + if (!mailtrainConfig.isAuthenticated) { + this.navigateTo('/login?next=' + encodeURIComponent(window.location.pathname)); + } + } + + errorHandler(error) { + if (error instanceof interoperableErrors.NotLoggedInError) { + if (window.location.pathname !== '/login') { // There may be multiple async requests failing at the same time. So we take the pathname only from the first one. + this.navigateTo('/login?next=' + encodeURIComponent(window.location.pathname)); + } + } else if (error.response && error.response.data && error.response.data.message) { + console.error(error); + this.navigateToWithFlashMessage(this.props.root, 'danger', error.response.data.message); + } else { + console.error(error); + this.navigateToWithFlashMessage(this.props.root, 'danger', error.message); + } + return true; + } + + async closeFlashMessage() { + this.setState({ + flashMessageText: '' + }); + } + + renderRoute(route) { + let flashMessage; + if (this.state.flashMessageText) { + flashMessage = {this.state.flashMessageText}; + } + + const render = props => ; + + return + } + + render() { + let routes = getRoutes('', {}, [], this.props.structure, [], null, null); + + return ( + + {routes.map(x => this.renderRoute(x))} + + ); + } +} + +@withComponentMixins([ + withTranslation +]) +export class Section extends Component { + constructor(props) { + super(props); + } + + static propTypes = { + structure: PropTypes.oneOfType([PropTypes.object, PropTypes.func]).isRequired, + root: PropTypes.string.isRequired + } + + render() { + let structure = this.props.structure; + if (typeof structure === 'function') { + structure = structure(this.props.t); + } + + return ( + + + + ); + } +} + + +export class Title extends Component { + render() { + return ( +
    +

    {this.props.children}

    +
    +
    + ); + } +} + +export class Toolbar extends Component { + static propTypes = { + className: PropTypes.string, + }; + + render() { + let className = styles.toolbar + ' ' + styles.buttonRow; + if (this.props.className) { + className += ' ' + this.props.className; + } + + return ( +
    + {this.props.children} +
    + ); + } +} + +export class LinkButton extends Component { + static propTypes = { + label: PropTypes.string, + icon: PropTypes.string, + className: PropTypes.string, + to: PropTypes.string + }; + + render() { + const props = this.props; + + return ( +