diff --git a/app.js b/app.js index 9d0f2a94..2be1d14a 100644 --- a/app.js +++ b/app.js @@ -27,15 +27,10 @@ const routes = require('./routes/index'); const lists = require('./routes/lists-legacy'); //const settings = require('./routes/settings'); const getSettings = nodeifyFunction(require('./models/settings').get); -const templates = require('./routes/templates'); const campaigns = require('./routes/campaigns'); const links = require('./routes/links'); -const fields = require('./routes/fields'); -const forms = require('./routes/forms-legacy'); -const segments = require('./routes/segments'); const triggers = require('./routes/triggers'); const webhooks = require('./routes/webhooks'); -const subscription = require('./routes/subscription'); const archive = require('./routes/archive'); const api = require('./routes/api'); const editorapi = require('./routes/editorapi'); @@ -44,6 +39,7 @@ const mosaico = require('./routes/mosaico'); // These are routes for the new React-based client const reports = require('./routes/reports'); +const subscription = require('./routes/subscription'); const namespacesRest = require('./routes/rest/namespaces'); const usersRest = require('./routes/rest/users'); @@ -57,13 +53,16 @@ const fieldsRest = require('./routes/rest/fields'); const sharesRest = require('./routes/rest/shares'); const segmentsRest = require('./routes/rest/segments'); const subscriptionsRest = require('./routes/rest/subscriptions'); +const templatesRest = require('./routes/rest/templates'); const blacklistRest = require('./routes/rest/blacklist'); +const editorsRest = require('./routes/rest/editors'); const namespacesLegacyIntegration = require('./routes/namespaces-legacy-integration'); const usersLegacyIntegration = require('./routes/users-legacy-integration'); const accountLegacyIntegration = require('./routes/account-legacy-integration'); const reportsLegacyIntegration = require('./routes/reports-legacy-integration'); const listsLegacyIntegration = require('./routes/lists-legacy-integration'); +const templatesLegacyIntegration = require('./routes/templates-legacy-integration'); const blacklistLegacyIntegration = require('./routes/blacklist-legacy-integration'); const interoperableErrors = require('./shared/interoperable-errors'); @@ -254,22 +253,20 @@ app.use((req, res, next) => { // Regular endpoints app.use('/', routes); app.use('/lists', lists); -app.use('/templates', templates); app.use('/campaigns', campaigns); //app.use('/settings', settings); app.use('/links', links); -app.use('/fields', fields); -app.use('/forms', forms); -app.use('/segments', segments); app.use('/triggers', triggers); app.use('/webhooks', webhooks); -app.use('/subscription', subscription); app.use('/archive', archive); app.use('/editorapi', editorapi); app.use('/grapejs', grapejs); app.use('/mosaico', mosaico); +app.use('/subscription', subscription); + + // API endpoints app.use('/api', api); @@ -283,6 +280,7 @@ app.use('/users', usersLegacyIntegration); app.use('/namespaces', namespacesLegacyIntegration); app.use('/account', accountLegacyIntegration); app.use('/lists', listsLegacyIntegration); +app.use('/templates', templatesLegacyIntegration); app.use('/blacklist', blacklistLegacyIntegration); if (config.reports && config.reports.enabled === true) { @@ -302,7 +300,9 @@ app.use('/rest', fieldsRest); app.use('/rest', sharesRest); app.use('/rest', segmentsRest); app.use('/rest', subscriptionsRest); +app.use('/rest', templatesRest); app.use('/rest', blacklistRest); +app.use('/rest', editorsRest); if (config.reports && config.reports.enabled === true) { app.use('/rest', reportTemplatesRest); diff --git a/client/package-lock.json b/client/package-lock.json index 453d36aa..941c1c4a 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index 6898e1ff..9919bb63 100644 --- a/client/package.json +++ b/client/package.json @@ -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": { diff --git a/client/src/lib/files.js b/client/src/lib/files.js new file mode 100644 index 00000000..24fb6948 --- /dev/null +++ b/client/src/lib/files.js @@ -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: , + href: `/rest/files/${this.props.entityTypeId}/${data[0]}` + }, + { + label: , + action: () => this.deleteFile(data[0], data[1]) + } + ]; + + return actions; + } + } + ]; + + return ( +
+ + + {state => state.isDragActive ? t('Drop {{count}} file(s)', {count:state.draggedFiles.length}) : t('Drop files here')} + + this.filesTable = node} dataUrl={`/rest/template-files-table/${this.props.entity.id}`} columns={columns} /> + + ); + } +} diff --git a/client/src/lib/form.js b/client/src/lib/form.js index 349da8f5..dc65bd1d 100644 --- a/client/src/lib/form.js +++ b/client/src/lib/form.js @@ -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, - 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 } diff --git a/client/src/lists/CUD.js b/client/src/lists/CUD.js index 78b23187..eed9035a 100644 --- a/client/src/lists/CUD.js +++ b/client/src/lists/CUD.js @@ -165,7 +165,7 @@ export default class CUD extends Component { {isEdit && - + {this.getFormValue('cid')} } diff --git a/client/src/namespaces/CUD.js b/client/src/namespaces/CUD.js index b4d4036f..f1e82c65 100644 --- a/client/src/namespaces/CUD.js +++ b/client/src/namespaces/CUD.js @@ -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() { diff --git a/client/src/reports/CUD.js b/client/src/reports/CUD.js index 53e054f6..d51fc5ec 100644 --- a/client/src/reports/CUD.js +++ b/client/src/reports/CUD.js @@ -218,7 +218,7 @@ export default class CUD extends Component { { + }); + + 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 ( +
+ {canDelete && + + } + + {isEdit ? t('Edit Template') : t('Create Template')} + +
+ +