Beginning of work on templates.
This commit is contained in:
parent
47b8d80c22
commit
508d6b3b2f
40 changed files with 1685 additions and 1031 deletions
14
client/package-lock.json
generated
14
client/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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
148
client/src/lib/files.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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 ...')}
|
||||
|
|
|
@ -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
309
client/src/templates/CUD.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
94
client/src/templates/List.js
Normal file
94
client/src/templates/List.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
35
client/src/templates/helpers.js
Normal file
35
client/src/templates/helpers.js
Normal 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;
|
||||
}
|
64
client/src/templates/root.js
Normal file
64
client/src/templates/root.js
Normal 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')
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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 ...')}
|
||||
|
|
|
@ -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: {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue