WiP updates

This commit is contained in:
Tomas Bures 2018-04-22 09:00:04 +02:00
parent 6706d93bc1
commit 4fce4b6f81
27 changed files with 763 additions and 85 deletions

View file

@ -44,7 +44,7 @@ export default class API extends Component {
const t = this.props.t;
const thisUrl = new URL();
const serviceUrl = thisUrl.origin + '/';
const serviceUrl = thisUrl.origin + '/'; // FIXME - use urls.js and getTrustedUrl
const accessToken = this.state.accessToken || 'ACCESS_TOKEN';
let accessTokenMsg;

View file

@ -123,6 +123,32 @@ class DropdownMenu extends Component {
}
}
class DropdownMenuItem extends Component {
static propTypes = {
label: PropTypes.string,
icon: PropTypes.string,
className: PropTypes.string
}
render() {
const props = this.props;
let className = 'dropdown';
if (props.className) {
className = className + ' ' + props.className;
}
return (
<li className={className}>
<a href="#" className="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">{props.icon && <Icon icon={props.icon}/>}{props.label}{' '}<span className="caret"></span></a>
<ul className="dropdown-menu">
{props.children}
</ul>
</li>
);
}
}
@withErrorHandling
class ActionLink extends Component {
static propTypes = {
@ -261,6 +287,7 @@ class ModalDialog extends Component {
export {
Button,
DropdownMenu,
DropdownMenuItem,
ActionLink,
DismissibleAlert,
ModalDialog,

View file

@ -417,6 +417,7 @@ class TextArea extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
placeholder: PropTypes.string,
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
format: PropTypes.string
}
@ -432,7 +433,7 @@ class TextArea extends Component {
const htmlId = 'form_' + id;
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
<textarea id={htmlId} value={owner.getFormValue(id) || ''} className="form-control" aria-describedby={htmlId + '_help'} onChange={evt => owner.updateFormValue(id, evt.target.value)}></textarea>
<textarea id={htmlId} placeholder={props.placeholder} value={owner.getFormValue(id) || ''} className="form-control" aria-describedby={htmlId + '_help'} onChange={evt => owner.updateFormValue(id, evt.target.value)}></textarea>
);
}
}

View file

@ -437,16 +437,17 @@ class NavButton extends Component {
}
}
class DropdownLink extends Component {
class MenuLink extends Component {
static propTypes = {
to: PropTypes.string
to: PropTypes.string,
className: PropTypes.string
}
render() {
const props = this.props;
return (
<li><Link to={props.to}>{props.children}</Link></li>
<li className={props.className}><Link to={props.to}>{props.children}</Link></li>
);
}
}
@ -473,7 +474,7 @@ export {
Title,
Toolbar,
NavButton,
DropdownLink,
MenuLink,
withPageHelpers,
requiresAuthenticatedUser
};

View file

@ -41,7 +41,7 @@ export class UntrustedContentHost extends Component {
}
isInitialized() {
return !!this.accessToken;
return !!this.accessToken && !!this.props.contentProps;
}
receiveMessage(evt) {
@ -73,7 +73,6 @@ export class UntrustedContentHost extends Component {
const msgId = this.rpcCounter;
this.sendMessage('rpcRequest', {
method,
params,
msgId
});
@ -126,6 +125,10 @@ export class UntrustedContentHost extends Component {
this.handleUpdate();
}
componentDidUpdate() {
this.handleUpdate();
}
componentWillUnmount() {
clearTimeout(this.refreshAccessTokenTimeout);
window.removeEventListener('message', this.receiveMessageHandler, false);

View file

@ -3,7 +3,7 @@
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import {DropdownMenu, Icon} from '../../lib/bootstrap-components';
import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, DropdownLink } from '../../lib/page';
import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, MenuLink } from '../../lib/page';
import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
import { Table } from '../../lib/table';
import axios from '../../lib/axios';
@ -78,10 +78,10 @@ export default class List extends Component {
{this.state.createPermitted &&
<Toolbar>
<DropdownMenu className="btn-primary" label={t('Create Report Template')}>
<DropdownLink to="/reports/templates/create">{t('Blank')}</DropdownLink>
<DropdownLink to="/reports/templates/create/subscribers-all">{t('All Subscribers')}</DropdownLink>
<DropdownLink to="/reports/templates/create/subscribers-grouped">{t('Grouped Subscribers')}</DropdownLink>
<DropdownLink to="/reports/templates/create/export-list-csv">{t('Export List as CSV')}</DropdownLink>
<MenuLink to="/reports/templates/create">{t('Blank')}</MenuLink>
<MenuLink to="/reports/templates/create/subscribers-all">{t('All Subscribers')}</MenuLink>
<MenuLink to="/reports/templates/create/subscribers-grouped">{t('Grouped Subscribers')}</MenuLink>
<MenuLink to="/reports/templates/create/export-list-csv">{t('Export List as CSV')}</MenuLink>
</DropdownMenu>
</Toolbar>
}

View file

@ -15,12 +15,18 @@ import namespaces from './namespaces/root';
import reports from './reports/root';
import templates from './templates/root';
import users from './users/root';
import {Section} from "./lib/page";
import sendConfigurations from './send-configurations/root';
import {
MenuLink,
Section
} from "./lib/page";
import mailtrainConfig from 'mailtrainConfig';
import Home from "./Home";
import {
ActionLink,
DropdownMenuItem,
Icon
} from "./lib/bootstrap-components";
import {Link} from "react-router-dom";
@ -47,9 +53,9 @@ class Root extends Component {
const link = entry.link || entry.externalLink;
if (link && path.startsWith(link)) {
topLevelMenu.push(<li key={entryKey} className="active"><Link to={link}>{entry.title} <span className="sr-only">{t('(current)')}</span></Link></li>);
topLevelMenu.push(<MenuLink key={entryKey} className="active" to={link}>{entry.title} <span className="sr-only">{t('(current)')}</span></MenuLink>);
} else {
topLevelMenu.push(<li key={entryKey}><Link to={link}>{entry.title}</Link></li>);
topLevelMenu.push(<MenuLink key={entryKey} to={link}>{entry.title}</MenuLink>);
}
}
@ -63,51 +69,31 @@ class Root extends Component {
<span className="icon-bar"></span>
<span className="icon-bar"></span>
</button>
<Link className="navbar-brand" to="/"><i className="glyphicon glyphicon-envelope"></i> Mailtrain</Link>
<Link className="navbar-brand" to="/"><Icon icon="envelope"/> Mailtrain</Link>
</div>
{mailtrainConfig.isAuthenticated &&
<div className="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
<ul className="nav navbar-nav">
{topLevelMenu}
<li className="dropdown">
<a href="#" className="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">{t('Administration')}<span className="caret"></span></a>
<ul className="dropdown-menu">
<li>
<Link to="/users"><Icon icon='cog'/> {t('Users')}</Link>
</li>
<li>
<Link to="/namespaces"><Icon icon='cog'/> {t('Namespaces')}</Link>
</li>
<li>
<Link to="/settings"><Icon icon='cog'/> {t('Settings')}</Link>
</li>
<li>
<Link to="/blacklist"><Icon icon='ban-circle'/> {t('Blacklist')}</Link>
</li>
<li>
<Link to="/account/api"><Icon icon='retweet'/> {t('API')}</Link>
</li>
</ul>
</li>
<DropdownMenuItem label={t('Administration')}>
<MenuLink to="/users"><Icon icon='cog'/> {t('Users')}</MenuLink>
<MenuLink to="/namespaces"><Icon icon='cog'/> {t('Namespaces')}</MenuLink>
<MenuLink to="/settings"><Icon icon='cog'/> {t('Settings')}</MenuLink>
<MenuLink to="/send-configurations"><Icon icon='cog'/> {t('Send Configurations')}</MenuLink>
<MenuLink to="/blacklist"><Icon icon='ban-circle'/> {t('Blacklist')}</MenuLink>
<MenuLink to="/account/api"><Icon icon='retweet'/> {t('API')}</MenuLink>
</DropdownMenuItem>
</ul>
<ul className="nav navbar-nav navbar-right">
<li className="dropdown">
<a href="#" className="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<span className="glyphicon glyphicon-user" aria-hidden="true"></span> {mailtrainConfig.user.username}<span className="caret"></span>
</a>
<ul className="dropdown-menu">
<li>
<Link to="/account"><Icon icon='user'/> {t('Account')}</Link>
</li>
<li>
<ActionLink onClickAsync={::self.logout}><Icon icon='log-out'/> {t('Log out')}</ActionLink>
</li>
</ul>
</li>
<DropdownMenuItem label={mailtrainConfig.user.username} icon="user">
<MenuLink to="/account"><Icon icon='user'/> {t('Account')}</MenuLink>
<li>
<ActionLink onClickAsync={::self.logout}><Icon icon='log-out'/> {t('Log out')}</ActionLink>
</li>
</DropdownMenuItem>
</ul>
</div>
}
@ -132,6 +118,7 @@ class Root extends Component {
...users.getMenus(t),
...blacklist.getMenus(t),
...account.getMenus(t),
...sendConfigurations.getMenus(t)
}
}
};

View file

@ -0,0 +1,201 @@
'use strict';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { translate, Trans } from 'react-i18next';
import {requiresAuthenticatedUser, withPageHelpers, Title, NavButton} from '../lib/page'
import {
withForm,
Form,
FormSendMethod,
InputField,
TextArea,
Dropdown,
ButtonRow,
Button,
CheckBox,
Fieldset
} from '../lib/form';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import { validateNamespace, NamespaceSelect } from '../lib/namespace';
import {DeleteModalDialog} from "../lib/modals";
import { getMailerTypes, mailerTypesOrder } from "./helpers";
import {MailerType} from "../../../shared/send-configurations";
@translate()
@withForm
@withPageHelpers
@withErrorHandling
@requiresAuthenticatedUser
export default class CUD extends Component {
constructor(props) {
super(props);
this.mailerTypes = getMailerTypes(props.t);
this.typeOptions = [];
for (const type of mailerTypesOrder) {
this.typeOptions.push({
key: type,
label: this.mailerTypes[type].typeName
});
}
this.state = {};
this.initForm({
onChangeBeforeValidation: {
mailer_type: ::this.onMailerTypeChanged
}
});
}
static propTypes = {
action: PropTypes.string.isRequired,
wizard: PropTypes.string,
entity: PropTypes.object
}
onMailerTypeChanged(mutState, key, oldType, type) {
if (type) {
this.mailerTypes[type].afterTypeChange(mutState);
}
}
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity, data => {
this.mailerTypes[data.type].afterLoad(data);
});
} else {
this.populateFormValues({
name: '',
description: '',
namespace: mailtrainConfig.user.namespace,
from_email_overridable: false,
from_name_overridable: false,
subject_overridable: false,
mailer_type: MailerType.ZONE_MTA,
...this.mailerTypes[MailerType.ZONE_MTA].initData()
});
}
}
localValidateFormValues(state) {
const t = this.props.t;
if (!state.getIn(['name', 'value'])) {
state.setIn(['name', 'error'], t('Name must not be empty'));
} else {
state.setIn(['name', 'error'], null);
}
if (!state.getIn(['mailer_type', 'value'])) {
state.setIn(['mailer_type', 'error'], t('Mailer type must be selected'));
} else {
state.setIn(['mailer_type', 'error'], null);
}
validateNamespace(t, state);
}
async submitHandler() {
const t = this.props.t;
let sendMethod, url;
if (this.props.entity) {
sendMethod = FormSendMethod.PUT;
url = `/rest/send-configurations/${this.props.entity.id}`
} else {
sendMethod = FormSendMethod.POST;
url = '/rest/send-configurations'
}
this.disableForm();
this.setFormStatusMessage('info', t('Saving ...'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
this.mailerTypes[data.type].beforeSave(data);
});
if (submitSuccessful) {
this.navigateToWithFlashMessage('/send-configurations', 'success', t('Send configuration saved'));
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
}
}
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
const canDelete = isEdit && this.props.entity.permissions.includes('delete');
const typeKey = this.getFormValue('mailer_type');
let mailerForm = null;
if (typeKey) {
mailerForm = this.mailerTypes[typeKey].getForm(this);
}
return (
<div>
{canDelete &&
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
deleteUrl={`/rest/send-configurations/${this.props.entity.id}`}
cudUrl={`/send-configurations/${this.props.entity.id}/edit`}
listUrl="/send-configurations"
deletingMsg={t('Deleting send configuration ...')}
deletedMsg={t('Send configuration deleted')}/>
}
<Title>{isEdit ? t('Edit Send Configuration') : t('Create Send Configuration')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('Name')}/>
<TextArea id="description" label={t('Description')} help={t('HTML is allowed')}/>
<NamespaceSelect/>
<Fieldset label={t('Email Header')}>
<InputField id="from_email" label={t('Default "from" email')}/>
<CheckBox id="from_email_overridable" text={t('Overridable')}/>
<InputField id="from_name" label={t('Default "from" name')}/>
<CheckBox id="from_name_overridable" text={t('Overridable')}/>
<InputField id="subject" label={t('Subject')}/>
<CheckBox id="subject_overridable" text={t('Overridable')}/>
</Fieldset>
<Fieldset label={t('Mailer Settings')}>
<Dropdown id="type" label={t('Type')} options={this.typeOptions}/>
{mailerForm}
</Fieldset>
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{canDelete &&
<NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/send-configurations/${this.props.entity.id}/delete`}/>
}
</ButtonRow>
</Form>
</div>
);
}
/*
<Fieldset label={t('GPG Signing')}>
<Trans><p>Only messages that are encrypted can be signed. Subsribers who have not set up a GPG public key in their profile receive normal email messages. Users with GPG key set receive encrypted messages and if you have signing key also set, the messages are signed with this key.</p></Trans>
<Trans><p className="text-warning">Do not use sensitive keys here. The private key and passphrase are not encrypted in the database.</p></Trans>
<InputField id="gpg_signing_key_passphrase" label={t('Private key passphrase')} placeholder={t('Passphrase for the key if set')} help={t('Only fill this if your private key is encrypted with a passphrase')}/>
<TextArea id="gpg_signing_key" label={t('GPG private key')} placeholder={t('Begins with \'-----BEGIN PGP PRIVATE KEY BLOCK-----\'')} help={t('This value is optional. If you do not provide a private key GPG encrypted messages are sent without signing.')}/>
</Fieldset>
*/
}

View file

@ -0,0 +1,103 @@
'use strict';
import React, {Component} from 'react';
import {translate} from 'react-i18next';
import {Icon} from '../lib/bootstrap-components';
import {
NavButton,
requiresAuthenticatedUser,
Title,
Toolbar,
withPageHelpers
} from '../lib/page';
import {
withAsyncErrorHandler,
withErrorHandling
} from '../lib/error-handling';
import {Table} from '../lib/table';
import axios from '../lib/axios';
import moment from 'moment';
import {getMailerTypes} from './helpers';
@translate()
@withPageHelpers
@withErrorHandling
@requiresAuthenticatedUser
export default class List extends Component {
constructor(props) {
super(props);
this.mailerTypes = getMailerTypes(props.t);
this.state = {};
}
@withAsyncErrorHandler
async fetchPermissions() {
const request = {
createSendConfiguration: {
entityTypeId: 'namespace',
requiredOperations: ['createSendConfiguration']
}
};
const result = await axios.post('/rest/permissions-check', request);
this.setState({
createPermitted: result.data.createSendConfiguration
});
}
componentDidMount() {
this.fetchPermissions();
}
render() {
const t = this.props.t;
const columns = [
{ data: 1, title: t('Name') },
{ data: 2, title: t('Description') },
{ data: 3, title: t('Type'), render: data => this.mailerTypes[data].typeName },
{ data: 4, title: t('Created'), render: data => moment(data).fromNow() },
{ data: 5, title: t('Namespace') },
{
actions: data => {
const actions = [];
const perms = data[6];
if (perms.includes('edit')) {
actions.push({
label: <Icon icon="edit" title={t('Edit')}/>,
link: `/send-configurations/${data[0]}/edit`
});
}
if (perms.includes('share')) {
actions.push({
label: <Icon icon="share-alt" title={t('Share')}/>,
link: `/send-configurations/${data[0]}/share`
});
}
return actions;
}
}
];
return (
<div>
{this.state.createPermitted &&
<Toolbar>
<NavButton linkTo="/send-configurations/create" className="btn-primary" icon="plus" label={t('Create Send Configuration')}/>
</Toolbar>
}
<Title>{t('Send Configurations')}</Title>
<Table withHeader dataUrl="/rest/send-configurations-table" columns={columns} />
</div>
);
}
}

View file

@ -0,0 +1,63 @@
'use strict';
import React from "react";
import {MailerType} from "../../../shared/send-configurations";
export const mailerTypesOrder = [
MailerType.ZONE_MTA,
MailerType.GENERIC_SMTP,
MailerType.AWS_SES
];
export function getMailerTypes(t) {
const mailerTypes = {};
function clearBeforeSend(data) {
}
mailerTypes[MailerType.GENERIC_SMTP] = {
typeName: t('Generic SMTP'),
getForm: owner => null,
initData: () => ({
}),
afterLoad: data => {
},
beforeSave: data => {
clearBeforeSend(data);
},
afterTypeChange: mutState => {
// mutState.setIn(['type', 'value'], '');
}
};
mailerTypes[MailerType.ZONE_MTA] = {
typeName: t('Zone MTA'),
getForm: owner => null,
initData: () => ({
}),
afterLoad: data => {
},
beforeSave: data => {
clearBeforeSend(data);
},
afterTypeChange: mutState => {
}
};
mailerTypes[MailerType.AWS_SES] = {
typeName: t('Amazon SES'),
getForm: owner => null,
initData: () => ({
}),
afterLoad: data => {
},
beforeSave: data => {
clearBeforeSend(data);
},
afterTypeChange: mutState => {
}
};
return mailerTypes;
}

View file

@ -0,0 +1,49 @@
'use strict';
import React from 'react';
import CUD from './CUD';
import List from './List';
import Share from '../shares/Share';
function getMenus(t) {
return {
'send-configurations': {
title: t('Send Configurations'),
link: '/send-configurations',
panelComponent: List,
children: {
':sendConfigurationId([0-9]+)': {
title: resolved => t('Template "{{name}}"', {name: resolved.sendConfiguration.name}),
resolve: {
sendConfiguration: params => `/rest/send-configurations/${params.sendConfigurationId}`
},
link: params => `/send-configurations/${params.sendConfigurationId}/edit`,
navs: {
':action(edit|delete)': {
title: t('Edit'),
link: params => `/send-configurations/${params.sendConfigurationId}/edit`,
visible: resolved => resolved.sendConfiguration.permissions.includes('edit'),
panelRender: props => <CUD action={props.match.params.action} entity={props.resolved.sendConfiguration} />
},
share: {
title: t('Share'),
link: params => `/send-configurations/${params.sendConfigurationId}/share`,
visible: resolved => resolved.sendConfiguration.permissions.includes('share'),
panelRender: props => <Share title={t('Share')} entity={props.resolved.sendConfiguration} entityTypeId="sendConfiguration" />
}
}
},
create: {
title: t('Create'),
panelRender: props => <CUD action="create" />
}
}
}
};
}
export default {
getMenus
}

View file

@ -10,7 +10,7 @@ import { validateNamespace, NamespaceSelect } from '../../lib/namespace';
import {DeleteModalDialog} from "../../lib/modals";
import { versafix } from "../../../../shared/mosaico-templates";
import { getTemplateTypes } from "./helpers";
import { getTemplateTypes, getTemplateTypesOrder } from "./helpers";
@translate()
@withForm
@ -23,6 +23,14 @@ export default class CUD extends Component {
this.templateTypes = getTemplateTypes(props.t);
this.typeOptions = [];
for (const type of getTemplateTypesOrder()) {
this.typeOptions.push({
key: type,
label: this.templateTypes[type].typeName
});
}
this.state = {};
this.initForm();
@ -141,14 +149,6 @@ export default class CUD extends Component {
form = this.templateTypes[typeKey].getForm(this);
}
const typeOptions = [];
for (const type of ['html', 'mjml']) {
typeOptions.push({
key: type,
label: this.templateTypes.typeName
});
}
return (
<div>
{canDelete &&
@ -158,7 +158,7 @@ export default class CUD extends Component {
deleteUrl={`/rest/templates/mosaico/${this.props.entity.id}`}
cudUrl={`/templates/mosaico/${this.props.entity.id}/edit`}
listUrl="/templates/mosaico"
deletingMsg={t('Deleting mosaico template ...')}
deletingMsg={t('Deleting Mosaico template ...')}
deletedMsg={t('Mosaico template deleted')}/>
}
@ -167,7 +167,7 @@ export default class CUD extends Component {
<Form stateOwner={this} onSubmitAsync={::this.submitAndLeave}>
<InputField id="name" label={t('Name')}/>
<TextArea id="description" label={t('Description')} help={t('HTML is allowed')}/>
<Dropdown id="type" label={t('Type')} options={typeOptions}/>
<Dropdown id="type" label={t('Type')} options={this.typeOptions}/>
<NamespaceSelect/>
{form}

View file

@ -3,7 +3,7 @@
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import {DropdownMenu, Icon} from '../../lib/bootstrap-components';
import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, DropdownLink } from '../../lib/page';
import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, MenuLink } from '../../lib/page';
import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
import { Table } from '../../lib/table';
import axios from '../../lib/axios';
@ -82,8 +82,8 @@ export default class List extends Component {
{this.state.createPermitted &&
<Toolbar>
<DropdownMenu className="btn-primary" label={t('Create Mosaico Template')}>
<DropdownLink to="/templates/mosaico/create">{t('Blank')}</DropdownLink>
<DropdownLink to="/templates/mosaico/create/versafix">{t('Versafix One')}</DropdownLink>
<MenuLink to="/templates/mosaico/create">{t('Blank')}</MenuLink>
<MenuLink to="/templates/mosaico/create/versafix">{t('Versafix One')}</MenuLink>
</DropdownMenu>
</Toolbar>
}

View file

@ -5,6 +5,10 @@ import {ACEEditor} from "../../lib/form";
import 'brace/mode/html'
import 'brace/mode/xml'
export function getTemplateTypesOrder() {
return ['mjml', 'html'];
}
export function getTemplateTypes(t) {
const templateTypes = {};
@ -17,7 +21,6 @@ export function getTemplateTypes(t) {
typeName: t('HTML'),
getForm: owner => <ACEEditor id="html" height="700px" mode="html" label={t('Template content')}/>,
afterLoad: data => {
console.log(data);
data.html = data.data.html;
},
beforeSave: (data) => {