Beginning of work on templates.

This commit is contained in:
Tomas Bures 2018-02-13 23:50:13 +01:00
parent 47b8d80c22
commit 508d6b3b2f
40 changed files with 1685 additions and 1031 deletions

View file

@ -212,6 +212,11 @@
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
"dev": true
},
"attr-accept": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-1.1.0.tgz",
"integrity": "sha1-tc01In8WOTWo8d4Q7T66FpQfa+Y="
},
"autoprefixer": {
"version": "6.7.7",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-6.7.7.tgz",
@ -4858,6 +4863,15 @@
"prop-types": "15.6.0"
}
},
"react-dropzone": {
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-4.2.7.tgz",
"integrity": "sha512-BGEc/UtG0rHBEZjAkGsajPRO85d842LWeaP4CINHvXrSNyKp7Tq7s699NyZwWYHahvXaUNZzNJ17JMrfg5sxVg==",
"requires": {
"attr-accept": "1.1.0",
"prop-types": "15.6.0"
}
},
"react-i18next": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-4.8.0.tgz",

View file

@ -29,13 +29,14 @@
"react": "^15.6.1",
"react-ace": "^5.1.0",
"react-day-picker": "^6.1.0",
"react-dnd-html5-backend": "^2.4.1",
"react-dnd-touch-backend": "^0.3.13",
"react-dom": "^15.6.1",
"react-dropzone": "^4.2.1",
"react-i18next": "^4.6.1",
"react-router-dom": "^4.1.1",
"react-sortable-tree": "^1.2.0",
"slugify": "^1.1.0",
"react-dnd-html5-backend": "^2.4.1",
"react-dnd-touch-backend": "^0.3.13",
"url-parse": "^1.1.9"
},
"devDependencies": {

148
client/src/lib/files.js Normal file
View file

@ -0,0 +1,148 @@
'use strict';
import React, {Component} from "react";
import PropTypes from "prop-types";
import {translate} from "react-i18next";
import {requiresAuthenticatedUser} from "./lib/page";
import {ACEEditor, Button, Form, FormSendMethod, withForm} from "./lib/form";
import {withErrorHandling} from "./lib/error-handling";
import {Table} from "./lib/table";
import Dropzone from "react-dropzone";
import {ModalDialog} from "./lib/modals";
import {Icon} from "./lib/bootstrap-components";
import axios from './axios';
@translate()
@withForm
@withErrorHandling
@requiresAuthenticatedUser
export default class Files extends Component {
constructor(props) {
super(props);
this.state = {
fileToDeleteName: null,
fileToDeleteId: null
};
const t = props.t;
this.initForm();
}
static propTypes = {
title: PropTypes.string,
entity: PropTypes.object,
entityTypeId: PropTypes.string
}
getFilesUploadedMessage(response){
const t = this.props.t;
const details = [];
if (response.data.added) {
details.push(t('{{count}} file(s) added', {count: response.data.added}));
}
if (response.data.replaced) {
details.push(t('{{count}} file(s) replaced', {count: response.data.replaced}));
}
if (response.data.ignored) {
details.push(t('{{count}} file(s) ignored', {count: response.data.ignored}));
}
const detailsMessage = details ? ' (' + details.join(', ') + ')' : '';
return t('{{count}} file(s) uploaded', {count: response.data.uploaded}) + detailsMessage;
}
onDrop(files){
const t = this.props.t;
if (files.length > 0) {
this.setFormStatusMessage('info', t('Uploading {{count}} file(s)', files.length));
const data = new FormData();
for (const file of files) {
data.append('file', file)
}
axios.put(`/rest/files/${this.props.entityTypeId}, ${this.props.entity.id}`, data)
.then(res => {
this.filesTable.refresh();
const message = this.getFilesUploadedMessage(res);
this.setFormStatusMessage('info', message);
})
.catch(res => this.setFormStatusMessage('danger', t('File upload failed: ') + res.message));
}
else{
this.setFormStatusMessage('info', t('No files to upload'));
}
}
deleteFile(fileId, fileName){
this.setState({fileToDeleteId: fileId, fileToDeleteName: fileName})
}
async hideDeleteFile(){
this.setState({fileToDeleteId: null, fileToDeleteName: null})
}
async performDeleteFile() {
const t = this.props.t;
const fileToDeleteId = this.state.fileToDeleteId;
await this.hideDeleteFile();
try {
this.disableForm();
this.setFormStatusMessage('info', t('Deleting file ...'));
await axios.delete(`/rest/files/${this.props.entityTypeId}/${fileToDeleteId}`);
this.filesTable.refresh();
this.setFormStatusMessage('info', t('File deleted'));
this.enableForm();
} catch (err) {
this.filesTable.refresh();
this.setFormStatusMessage('danger', t('Delete file failed: ') + err.message);
this.enableForm();
}
}
render() {
const t = this.props.t;
const columns = [
{ data: 1, title: "Name" },
{ data: 2, title: "Size" },
{
actions: data => {
const actions = [
{
label: <Icon icon="download" title={t('Download')}/>,
href: `/rest/files/${this.props.entityTypeId}/${data[0]}`
},
{
label: <Icon icon="remove" title={t('Delete')}/>,
action: () => this.deleteFile(data[0], data[1])
}
];
return actions;
}
}
];
return (
<div>
<ModalDialog
hidden={this.state.fileToDeleteId === null}
title={t('Confirm file deletion')}
onCloseAsync={::this.hideDeleteFile}
buttons={[
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteFile },
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performDeleteFile }
]}>
{t('Are you sure you want to delete file "{{name}}"?', {name: this.state.fileToDeleteName})}
</ModalDialog>
<Dropzone onDrop={::this.onDrop} className="dropZone" activeClassName="dropZoneActive">
{state => state.isDragActive ? t('Drop {{count}} file(s)', {count:state.draggedFiles.length}) : t('Drop files here')}
</Dropzone>
<Table withHeader ref={node => this.filesTable = node} dataUrl={`/rest/template-files-table/${this.props.entity.id}`} columns={columns} />
</div>
);
}
}

View file

@ -13,9 +13,11 @@ import { Table, TableSelectMode } from './table';
import {Button, Icon} from "./bootstrap-components";
import brace from 'brace';
import AceEditor from 'react-ace';
import ACEEditorRaw from 'react-ace';
import 'brace/theme/github';
import CKEditorRaw from "react-ckeditor-component";
import DayPicker from 'react-day-picker';
import 'react-day-picker/lib/style.css';
import { parseDate, parseBirthday, formatDate, formatBirthday, DateFormat, birthdayYear, getDateFormatString, getBirthdayFormatString } from '../../../shared/date';
@ -823,7 +825,7 @@ class ACEEditor extends Component {
const htmlId = 'form_' + id;
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
<AceEditor
<ACEEditorRaw
id={htmlId}
mode={props.mode}
theme="github"
@ -841,6 +843,35 @@ class ACEEditor extends Component {
}
class CKEditor extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string,
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
height: PropTypes.string
}
static contextTypes = {
formStateOwner: PropTypes.object.isRequired
}
render() {
const props = this.props;
const owner = this.context.formStateOwner;
const id = this.props.id;
const htmlId = 'form_' + id;
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
<CKEditorRaw
onChange={evt => owner.updateFormValue(id, evt.editor.getData())}
content={owner.getFormValue(id)}
config={{width: '100%', height: props.height}}
/>
);
}
}
function withForm(target) {
const inst = target.prototype;
@ -1251,5 +1282,6 @@ export {
TableSelect,
TableSelectMode,
ACEEditor,
CKEditor,
FormSendMethod
}

View file

@ -165,7 +165,7 @@ export default class CUD extends Component {
<InputField id="name" label={t('Name')}/>
{isEdit &&
<StaticField id="cid" className={styles.formDisabled} label="List ID" help={t('This is the list ID displayed to the subscribers')}>
<StaticField id="cid" className={styles.formDisabled} label={t('List ID')} help={t('This is the list ID displayed to the subscribers')}>
{this.getFormValue('cid')}
</StaticField>
}

View file

@ -56,22 +56,19 @@ export default class CUD extends Component {
@withAsyncErrorHandler
async loadTreeData() {
axios.get('/rest/namespaces-tree')
.then(response => {
const response = await axios.get('/rest/namespaces-tree');
const data = response.data;
for (const root of data) {
root.expanded = true;
}
const data = response.data;
for (const root of data) {
root.expanded = true;
}
if (this.props.entity && !this.isEditGlobal()) {
this.removeNsIdSubtree(data);
}
if (this.props.entity && !this.isEditGlobal()) {
this.removeNsIdSubtree(data);
}
this.setState({
treeData: data
});
});
this.setState({
treeData: data
});
}
componentDidMount() {

View file

@ -218,7 +218,7 @@ export default class CUD extends Component {
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
deleteUrl={`/reports/${this.props.entity.id}`}
deleteUrl={`/rest/reports/${this.props.entity.id}`}
cudUrl={`/reports/${this.props.entity.id}/edit`}
listUrl="/reports"
deletingMsg={t('Deleting report ...')}

View file

@ -282,7 +282,7 @@ export default class CUD extends Component {
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
deleteUrl={`/reports/templates/${this.props.entity.id}`}
deleteUrl={`/rest/reports/templates/${this.props.entity.id}`}
cudUrl={`/reports/templates/${this.props.entity.id}/edit`}
listUrl="/reports/templates"
deletingMsg={t('Deleting report template ...')}

309
client/src/templates/CUD.js Normal file
View file

@ -0,0 +1,309 @@
'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,
ACEEditor,
ButtonRow,
Button,
AlignedRow,
StaticField
} from '../lib/form';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import { validateNamespace, NamespaceSelect } from '../lib/namespace';
import {DeleteModalDialog} from "../lib/modals";
import mailtrainConfig from 'mailtrainConfig';
import { getTemplateTypes } from './helpers';
import {ActionLink} from "../lib/bootstrap-components";
import axios from '../lib/axios';
import styles from "../lib/styles.scss";
@translate()
@withForm
@withPageHelpers
@withErrorHandling
@requiresAuthenticatedUser
export default class CUD extends Component {
constructor(props) {
super(props);
this.templateTypes = getTemplateTypes(props.t);
this.state = {
showMergeTagReference: false
};
this.initForm();
}
static propTypes = {
action: PropTypes.string.isRequired,
wizard: PropTypes.string,
entity: PropTypes.object
}
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(`/rest/templates/${this.props.entity.id}`);
}
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity);
} else {
this.populateFormValues({
name: '',
description: '',
namespace: mailtrainConfig.user.namespace,
type: mailtrainConfig.editors[0],
text: '',
html: ''
});
}
}
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(['type', 'value'])) {
state.setIn(['type', 'error'], t('Type must be selected'));
} else {
state.setIn(['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/templates/${this.props.entity.id}`
} else {
sendMethod = FormSendMethod.POST;
url = '/rest/templates'
}
this.disableForm();
this.setFormStatusMessage('info', t('Saving ...'));
const submitResponse = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
});
if (submitResponse) {
if (this.props.entity) {
this.navigateToWithFlashMessage('/templates', 'success', t('Template saved'));
} else {
this.navigateToWithFlashMessage(`/templates/${submitResponse}/edit`, 'success', t('Template saved'));
}
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
}
}
async extractPlainText() {
const html = this.getFormValue('html');
if (!html) {
alert('Missing HTML content');
return;
}
if (this.isFormDisabled()) {
return;
}
this.disableForm();
const response = await axios.post('/rest/html-to-text', { html });
this.updateFormValue('text', response.data.text);
this.enableForm();
}
async toggleMergeTagReference() {
this.setState({
showMergeTagReference: !this.state.showMergeTagReference
});
}
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
const canDelete = isEdit && this.props.entity.permissions.includes('delete');
const typeOptions = [];
for (const key of mailtrainConfig.editors) {
typeOptions.push({key, label: this.templateTypes[key].typeName});
}
// TODO: Toggle HTML preview
const typeKey = this.getFormValue('type');
return (
<div>
{canDelete &&
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
deleteUrl={`/rest/templates/${this.props.entity.id}`}
cudUrl={`/templates/${this.props.entity.id}/edit`}
listUrl="/templates"
deletingMsg={t('Deleting template ...')}
deletedMsg={t('Template deleted')}/>
}
<Title>{isEdit ? t('Edit Template') : t('Create Template')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('Name')}/>
<TextArea id="description" label={t('Description')} help={t('HTML is allowed')}/>
{isEdit
?
<StaticField id="type" className={styles.formDisabled} label={t('Type')}>
{typeKey && this.templateTypes[typeKey].typeName}
</StaticField>
:
<Dropdown id="type" label={t('Type')} options={typeOptions}/>
}
<NamespaceSelect/>
<AlignedRow>
<Button className="btn-default" onClickAsync={::this.toggleMergeTagReference} label={t('Merge tag reference')}/>
{this.state.showMergeTagReference &&
<div style={{marginTop: '15px'}}>
<Trans><p>Merge tags are tags that are replaced before sending out the message. The format of the merge tag is the following: <code>[TAG_NAME]</code> or <code>[TAG_NAME/fallback]</code> where <code>fallback</code> is an optional text value used when <code>TAG_NAME</code> is empty.</p></Trans>
<table className="table table-bordered table-condensed table-striped">
<thead>
<tr>
<th>
<Trans>Merge tag</Trans>
</th>
<th>
<Trans>Description</Trans>
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">
[LINK_UNSUBSCRIBE]
</th>
<td>
<Trans>URL that points to the unsubscribe page</Trans>
</td>
</tr>
<tr>
<th scope="row">
[LINK_PREFERENCES]
</th>
<td>
<Trans>URL that points to the preferences page of the subscriber</Trans>
</td>
</tr>
<tr>
<th scope="row">
[LINK_BROWSER]
</th>
<td>
<Trans>URL to preview the message in a browser</Trans>
</td>
</tr>
<tr>
<th scope="row">
[EMAIL]
</th>
<td>
<Trans>Email address</Trans>
</td>
</tr>
<tr>
<th scope="row">
[FIRST_NAME]
</th>
<td>
<Trans>First name</Trans>
</td>
</tr>
<tr>
<th scope="row">
[LAST_NAME]
</th>
<td>
<Trans>Last name</Trans>
</td>
</tr>
<tr>
<th scope="row">
[FULL_NAME]
</th>
<td>
<Trans>Full name (first and last name combined)</Trans>
</td>
</tr>
<tr>
<th scope="row">
[SUBSCRIPTION_ID]
</th>
<td>
<Trans>Unique ID that identifies the recipient</Trans>
</td>
</tr>
<tr>
<th scope="row">
[LIST_ID]
</th>
<td>
<Trans>Unique ID that identifies the list used for this campaign</Trans>
</td>
</tr>
<tr>
<th scope="row">
[CAMPAIGN_ID]
</th>
<td>
<Trans>Unique ID that identifies current campaign</Trans>
</td>
</tr>
</tbody>
</table>
<Trans><p>In addition to that any custom field can have its own merge tag.</p></Trans>
</div>}
</AlignedRow>
<ACEEditor id="text" height="400px" mode="text" label={t('Template content (plain text)')} help={<Trans>To extract the text from HTML click <ActionLink onClickAsync={::this.extractPlainText}>here</ActionLink>. Please note that your existing plaintext in the field above will be overwritten. This feature uses the <a href="http://premailer.dialect.ca/api">Premailer API</a>, a third party service. Their Terms of Service and Privacy Policy apply.</Trans>}/>
{isEdit && typeKey && this.templateTypes[typeKey].form}
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={isEdit ? t('Save') : t('Save and edit template')}/>
{canDelete && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/templates/${this.props.entity.id}/delete`}/> }
</ButtonRow>
</Form>
</div>
);
}
}

View file

@ -0,0 +1,94 @@
'use strict';
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { Icon } from '../lib/bootstrap-components';
import { requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton } from '../lib/page';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import { Table } from '../lib/table';
import axios from '../lib/axios';
import moment from 'moment';
import { getTemplateTypes } from './helpers';
@translate()
@withPageHelpers
@withErrorHandling
@requiresAuthenticatedUser
export default class List extends Component {
constructor(props) {
super(props);
this.templateTypes = getTemplateTypes(props.t);
this.state = {};
}
@withAsyncErrorHandler
async fetchPermissions() {
const request = {
createTemplate: {
entityTypeId: 'namespace',
requiredOperations: ['createTemplate']
}
};
const result = await axios.post('/rest/permissions-check', request);
this.setState({
createPermitted: result.data.createTemplate
});
}
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.templateTypes[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: `/templates/${data[0]}/edit`
});
}
if (perms.includes('share')) {
actions.push({
label: <Icon icon="share-alt" title={t('Share')}/>,
link: `/templates/${data[0]}/share`
});
}
return actions;
}
}
];
return (
<div>
{this.state.createPermitted &&
<Toolbar>
<NavButton linkTo="/templates/create" className="btn-primary" icon="plus" label={t('Create Template')}/>
<NavButton linkTo="/templates/mosaico" className="btn-primary" label={t('Mosaico Templates')}/>
</Toolbar>
}
<Title>{t(' Templates')}</Title>
<Table withHeader dataUrl="/rest/templates-table" columns={columns} />
</div>
);
}
}

View file

@ -0,0 +1,35 @@
'use strict';
import React from "react";
import {ACEEditor, SummernoteEditor} from "../lib/form";
import 'brace/mode/text';
import 'brace/mode/html'
export function getTemplateTypes(t) {
const templateTypes = {};
templateTypes.mosaico = {
typeName: t('Mosaico')
};
templateTypes.grapejs = {
typeName: t('GrapeJS')
};
templateTypes.ckeditor = {
typeName: t('CKEditor'),
form: <SummernoteEditor id="html" height="600px" label={t('Template content (HTML)')}/>
};
templateTypes.codeeditor = {
typeName: t('Code Editor'),
form: <ACEEditor id="html" height="600px" mode="html" label={t('Template content (HTML)')}/>
};
templateTypes.mjml = {
typeName: t('MJML')
};
return templateTypes;
}

View file

@ -0,0 +1,64 @@
'use strict';
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n';
import { Section } from '../lib/page';
import TemplatesCUD from './CUD';
import TemplatesList from './List';
import Share from '../shares/Share';
const getStructure = t => {
return {
'': {
title: t('Home'),
externalLink: '/',
children: {
'templates': {
title: t('Templates'),
link: '/templates',
panelComponent: TemplatesList,
children: {
':templateId([0-9]+)': {
title: resolved => t('Template "{{name}}"', {name: resolved.template.name}),
resolve: {
template: params => `/rest/templates/${params.templateId}`
},
link: params => `/templates/${params.templateId}/edit`,
navs: {
':action(edit|delete)': {
title: t('Edit'),
link: params => `/templates/${params.templateId}/edit`,
visible: resolved => resolved.template.permissions.includes('edit'),
panelRender: props => <TemplatesCUD action={props.match.params.action} entity={props.resolved.template} />
},
share: {
title: t('Share'),
link: params => `/templates/${params.templateId}/share`,
visible: resolved => resolved.template.permissions.includes('share'),
panelRender: props => <Share title={t('Share')} entity={props.resolved.template} entityTypeId="template" />
}
}
},
create: {
title: t('Create'),
panelRender: props => <TemplatesCUD action="create" />
}
}
}
}
}
}
};
export default function() {
ReactDOM.render(
<I18nextProvider i18n={ i18n }><Section root='/templates' structure={getStructure}/></I18nextProvider>,
document.getElementById('root')
);
};

View file

@ -196,7 +196,7 @@ export default class CUD extends Component {
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
deleteUrl={`/users/${this.props.entity.id}`}
deleteUrl={`/rest/users/${this.props.entity.id}`}
cudUrl={`/users/${this.props.entity.id}/edit`}
listUrl="/users"
deletingMsg={t('Deleting user ...')}

View file

@ -8,6 +8,7 @@ module.exports = {
account: ['babel-polyfill', './src/account/root.js'],
reports: ['babel-polyfill', './src/reports/root.js'],
lists: ['babel-polyfill', './src/lists/root.js'],
templates: ['babel-polyfill', './src/templates/root.js'],
blacklist: ['babel-polyfill', './src/blacklist/root.js']
},
output: {