Compare commits

..

53 commits

Author SHA1 Message Date
d563928b0b Update 'Dockerfile' 2020-09-17 08:47:16 +00:00
Andris Reinman
281072ac1b
Merge pull request #910 from Mailtrain-org/GHSL-2020-132
Remove extra variables from format call
2020-07-13 12:51:21 +03:00
Andris Reinman
be55f2ca52 remove extra variables from format call 2020-07-13 12:50:34 +03:00
Andris Reinman
dea056848a
Merge pull request #909 from Mailtrain-org/GHSL-2020-132
Use proper escaping for variable column names
2020-07-13 10:07:16 +03:00
Andris Reinman
b7f94b40d6 Use proper escaping for variable column names 2020-07-13 10:00:25 +03:00
Tomas Bures
3b365701bb
Merge pull request #715 from fwolfst/patch-1
Update README
2019-10-02 23:58:23 +03:00
Tomas Bures
62e63fbb80
Merge pull request #633 from ateuber/bugfix-mosaico-segments
Bugfix: include list segment when saving in Mosaico
2019-07-16 18:31:07 +02:00
Andreas Teuber
0a0eb0db4f Bugfix: include list segment when saving in Mosaico 2019-07-16 18:21:17 +02:00
Tomas Bures
4e298aca7b
Merge pull request #625 from ezzra/german_language
modify german language files
2019-07-03 20:32:40 +02:00
ezzra
50e52207f9 add more translations to german, modify some others 2019-06-28 18:56:41 +02:00
ezzra
cf6c0e6849 modify german language files 2019-06-28 11:44:58 +02:00
Tomas Bures
9ca4767b4e
Merge pull request #614 from dkorts1/patch-3
Update README.md
2019-06-15 14:35:41 +02:00
Tomas Bures
d685c8e61f
Update README.md 2019-06-15 14:34:59 +02:00
dkorts1
cce887361b
Update README.md
minor changes, grammar, and typos
2019-06-14 01:04:34 +03:00
Tomas Bures
6d75ce035d
Merge pull request #610 from softionsrl/patch-1
Standardization of error handling
2019-06-12 17:44:06 +02:00
Softion
0db686d63c
Standardization of error handling
Probably not the best way, but this should make the code more readable as a lot of it is just outputting error messages.
This allows for centralized error messages translation.
2019-06-12 17:38:45 +02:00
Tomas Bures
a376dce0c1
Merge pull request #597 from kuba-orlik/master
Add a Polish translation for 'It looks like you are already subscribed…'
2019-05-12 10:02:34 +02:00
Kuba Orlik
c9383cb6da Update the translation in the missing .mo file, as well
Without that the new translation does not show up
2019-05-09 14:06:00 +02:00
Kuba Orlik
4d25685d69 Add a Polish translation for 'It looks like you are already subscribed to this list.'
Because it looked out of place when it was displayed on the subscription widget
2019-05-09 13:25:55 +02:00
Tomas Bures
db9c3271f5
Merge pull request #578 from paszczus/master
removed moved Polish translation files
2019-04-16 14:58:33 +02:00
Sławomir Paszkiewicz
5eb7976a75 removed moved Polish translation files 2019-04-02 20:25:29 +02:00
Tomas Bures
47802a7933
Merge pull request #575 from paszczus/master
moved PL language files to proper place
2019-04-02 16:24:16 +02:00
Sławomir Paszkiewicz
cda2beb5f5 - moved PL language files to proper place 2019-04-02 11:15:58 +02:00
Felix Wolfsteller
e2a9cc9429
Update README
The sentences are a bit long, but it prevents default installations to have issues with image uploads (#374).
2019-03-19 09:47:58 +01:00
Tomas Bures
c3cf46a717
Merge pull request #477 from ganeshkrishnan1/patch-1
path of conf files
2019-03-09 09:07:50 +01:00
Tomas Bures
4353201ab8
Merge pull request #496 from arnobase/french_typo_correction
update french translation and a typo in rule-edit.hbs
2019-03-09 09:07:21 +01:00
Tomas Bures
d45b1fa762
Merge pull request #479 from devMCpro/polish-translation
Polish translation
2019-03-09 09:06:43 +01:00
Tomas Bures
e74401b25d
Merge pull request #480 from ateuber/master
API: Add the possibility to change the email address of an existing list subscriber
2019-03-09 09:06:19 +01:00
Tomas Bures
33f94034e2
Merge branch 'master' into master 2019-03-09 09:06:06 +01:00
Tomas Bures
fa1bf1c874
Merge pull request #482 from ateuber/custom_field_description
Custom field description
2019-03-09 09:04:38 +01:00
Tomas Bures
de78c587f5
Merge pull request #556 from ateuber/duplicate_campaign
Duplicate campaign
2019-03-09 09:04:08 +01:00
Andreas Teuber
4a446a99f0 Fix Travis 2019-03-07 19:45:59 +01:00
Andreas Teuber
2b11a319b4 Added possibility to duplicate a campaign 2019-03-07 19:40:31 +01:00
Tomas Bures
f2ed0e8ce2
Update README.md 2018-12-25 21:08:24 +01:00
Tomas Bures
ebd7b1fffb
Update README.md 2018-12-25 20:49:18 +01:00
Arno
a49a9b2637 update french translation and a typo in views/lists/segments/rule-edit.hbs 2018-11-29 17:28:33 +01:00
Andreas Teuber
5b24186240 Adapted API description 2018-10-31 16:37:52 +01:00
Andreas Teuber
d3ad3e5d68 Fix Travis 2018-10-31 14:06:30 +01:00
Andreas Teuber
cdaf4b0b16 Added description to custom fields 2018-10-31 11:18:31 +01:00
Andreas Teuber
d5ce6a5d33 Fix Travis 2018-10-23 17:01:08 +02:00
Andreas Teuber
dd696d49ac Added API call to change the email address of an existing list subscriber 2018-10-23 15:51:54 +02:00
Marcin
5394ada6ad
Polish translation 2018-10-22 14:14:31 +02:00
Ganesh Krishnan
ce10388f4f
path of conf files
these conf files have moved from inside of docs to setup folder.
2018-10-18 18:49:01 -04:00
Tomas Bures
f661ba8a6b
Merge pull request #466 from balping/patch-1
Ignore more config files
2018-09-27 18:34:30 +02:00
Tomas Bures
26b3cd44e8
Merge pull request #465 from balping/trans
Hungarian translation
2018-09-27 18:34:04 +02:00
Balázs Kovács
6bdf074c01 translate more strings 2018-09-10 16:10:28 +02:00
Balázs Dura-Kovács
f89ba5a5dd
ignore more config files 2018-09-08 15:52:46 +02:00
Balázs Kovács
5db5bb73c4 hungarian trans continued 2018-09-07 16:54:29 +02:00
Tomas Bures
dcf8330929
Merge pull request #458 from a4i/master
add french translation
2018-09-03 07:26:03 +02:00
Tomas Bures
a774975f45
Merge pull request #457 from Addy90/patch-1
add passport-ldapauth to dependencies
2018-09-03 07:25:23 +02:00
Sylvain Amrani
af8c3202dd add french translation 2018-08-11 19:32:49 +02:00
Adrian
4dd754ff82
add passport-ldapauth to dependencies
when "npm install --production" is called, every time, passport-ldapauth is removed from local repository, with adding the dependency to package.json, the behavior is as expected (see production.yml config for ldap config)
2018-08-07 08:41:13 +02:00
Balázs Kovács
920f833988 Hungarian trans wip 2018-07-29 03:07:10 +02:00
1309 changed files with 118565 additions and 101654 deletions

3
.eslintrc Normal file
View file

@ -0,0 +1,3 @@
{
"extends": "nodemailer"
}

27
.gitignore vendored
View file

@ -1,9 +1,34 @@
/.idea
/.vscode
/last-failed-e2e-test.*
node_modules
npm-debug.log
package-lock.json
.DS_Store
config/development*
config/production*
config/test*
workers/reports/config/development.*
workers/reports/config/production.*
workers/reports/config/test.*
dump.rdb
# generate POT file every time you want to update your PO file
languages/mailtrain.pot
public/mosaico/uploads/*
!public/mosaico/uploads/README.md
public/mosaico/custom/*
!public/mosaico/custom/README.md
public/mosaico/templates/*
!public/mosaico/templates/versafix-1
public/grapejs/uploads/*
!public/grapejs/uploads/README.md
public/grapejs/templates/*
!public/grapejs/templates/demo
!public/grapejs/templates/aves
config/production.toml
workers/reports/config/production.toml
docker-compose.override.yml

3
.gitmodules vendored
View file

@ -1,3 +0,0 @@
[submodule "mvis/ivis-core"]
path = mvis/ivis-core
url = https://github.com/smartarch/ivis-core.git

18
.travis.yml Normal file
View file

@ -0,0 +1,18 @@
dist: trusty
sudo: required
language: node_js
node_js:
- 8
services:
- mysql
before_install:
- sudo apt-get -q -y install pwgen imagemagick
install:
- sudo bash test/e2e/install.sh
- npm install
before_script:
- npm run starttest > /dev/null 2>&1 &
- sleep 10
script:
- grunt
- npm run _e2e

18
BACKERS.md Normal file
View file

@ -0,0 +1,18 @@
# Crowdfunding Backers
Mailtrain received funding from a [crowdfunding campaign](https://www.indiegogo.com/at/mailtrain/8720095). This was to enable me to spend the time required to get automation support into Mailtrain. These are the people who contributed to this fund raiser.
* iRedMail - free, open source mail server solution <[www.iredmail.org](http://www.iredmail.org/)>
* Richard Adleta
* Wes Bos
* Christophe Lombart
* Anselm Hannemann
* Jens Carroll
* Anonymous
* Brett Nelson
* Jason Pelker
* Leif Singer
* Eve Land
* Diana Espino
* Moussa Clarke
* Carl Hauschke

View file

@ -1,5 +1,149 @@
# Changelog
## 2.0.0-rc1 2018-12-25
## 1.23.2 2017-04-04
* This is a complete rewrite of Mailtrain v1 with many features added. Just check it out.
* Allow skipping DNS check for imports
* Added option to use subscription widgets
## 1.23.0 2017-03-19
* Fixed security issue where description tags were able to include script tags. Reported by Andreas Lindh. Fixed with [ae6affda](https://github.com/Mailtrain-org/mailtrain/commit/ae6affda8193f034e06f7e095ee23821a83d5190)
* Fixed security issue where templates that looked like file paths loaded content from arbitrary files. Reported by Andreas Lindh. Fixed with [0879fa41](https://github.com/Mailtrain-org/mailtrain/commit/0879fa412a2d4a417aeca5cd5092a8f86531e7ef)
* Fixed security issue where users were able to use html tags in subscription values. Reported by Andreas Lindh. Fixed with [9d5fb816](https://github.com/Mailtrain-org/mailtrain/commit/9d5fb816c937114966d4f589e1ad4e164ff3a187)
* Support for multiple HTML editors (Mosaico, GrapeJS, Summernote, HTML code)
## 1.22.0 2017-03-02
* Reverted license back to GPL-v3 to support Mosaico
## 1.21.0 2017-02-17
* Changed license from MIT to EUPL-1.1
* Added support for sending mail using AWS SES
## 1.20.0 2016-12-11
* Added option to distribute sending queue between multiple processes to speed up delivery
## 1.19.0 2016-09-15
* Changed license from GPL-V3 to MIT
## 1.18.0 2016-09-08
* Updated installation script to bundle ZoneMTA as the default sending engine
* Added new option to disable clicked and opened tracking
* Store remote IP for subscription confirmations
## 1.17.0 2016-08-29
* Added new custom field for JSON data that is rendered using Handlebars when included in an email
## 1.16.0 2016-08-29
* Render list values using Handlebars templates
* Added new API method to create custom fields
* Added LDAP authentication support
## 1.15.0 2016-07-28
* Check SMTP settings using AJAX instead of posting entire form
## 1.14.0 2016-07-09
* Fixed ANY match segments with range queries
* Added an option to disable un/subscribe confirmation messages
* Added support for throttling when sending messages
* Added preview links in message lists
## 1.13.0 2016-06-23
* Added API method to delete subscribers
* Added a counter to triggers with a view to list all subscribers that caused this trigger to fire
## 1.12.1 2016-06-23
* Fixed invalid base SQL dump
## 1.12.0 2016-06-22
* Automation support. Create triggers that send a campaign once fired
* Fixed an issue with unresolved URL redirects
* Added support for relative date ranges in segments
## 1.11.0 2016-05-31
* Retry transactional mail if failed with soft error (4xx)
* New feature to preview campaigns using selected test users
## 1.10.1 2016-05-26
* Fix a bug with SMTP transport instance where campaign sending stalled until server was restarted
## 1.10.0 2016-05-25
* Fetch multiple unsent messages at once to speed up delivery
* Fixed a bug of counting unsubscribers correctly
* Use LONGTEXT for template text fields (messages might include inlined images which are larger than 64kB)
## 1.9.0 2016-05-16
* New look
* Added views for bounced/unsubscribed/complained etc.
## 1.8.2 2016-05-13
* Added missing views for subscribers who clicked on any link and subscribers who opened the message
## 1.8.1 2016-05-13
* Fixed an issue in API
## 1.8.0 2016-05-13
* Show details about subscribers who clicked on a specific link
## 1.7.0 2016-05-11
* Updated API, added new option **REQUIRE_CONFIRMATION** for subscriptions to send confirmation email before subscribing
## 1.6.0 2016-05-07
* Added simple API support for adding and removing list subscriptions
## 1.5.0 2016-05-05
* Fixed a bug in unsubscribing through the admin interface
* Added individual link click stats
## 1.4.1 2016-05-04
* Added support for RSS templates
## 1.4.0 2016-05-04
* Added support for RSS campaigns
* Subscribers get timezone attached to their profile
* Outgoing messages are preprocessed using juice
* Added installation script for easier setup
## 1.3.0 2016-04-29
* Added option to use an URL as message source (when message needs to be rendered a POST request with Merge Tags as the POST body is made against that URL)
* Added option to schedule sending. You can set optional delay time when starting campaign sending. Once this time is reached sending starts automatically
* Show meaningful MySQL error when connection fails
## 1.2.0 2016-04-25
* Rewrite merge tags in links (allows using links like `http://example.com/?u=[FIRST_NAME]` in messages)
* Added view for Imports to list failed addresses
* Automatic SQL table creation on initial run (no need for the `mysql` command anymore)
* Automatic SQL table updates on startup
* Send welcome and unsubscribe confirmation emails for subscribers
* Added support for GPG encryption for outgoing messages (requires custom field "GPG Key" set up for the list)
* Added new SMTP option: allow self-signed certs
* Added new setting: Disable WYSIWG editor (allows better handling of complex HTML templates)
* Allow downgrading user when server started as root (user is downgraded once all ports are bound)
* Added Nitrous.io one-click install button for easy try-out
* Added Max Post Size option to allow larger payloads from bounce webhooks
* Added VERP support to catch bounces using built in VERP smtp-server (disabled by default)

View file

@ -1,8 +1,12 @@
FROM node:10.14-alpine
# Mutistaged Node.js Build
FROM node:10-alpine as builder
RUN apk add --update pwgen netcat-openbsd python make gcc git g++ bash imagemagick
# Install system dependencies
RUN set -ex; \
apk add --update --no-cache \
make gcc g++ git python
# First install dependencies
# Copy package.json dependencies
COPY server/package.json /app/server/package.json
COPY server/package-lock.json /app/server/package-lock.json
COPY client/package.json /app/client/package.json
@ -12,15 +16,32 @@ COPY shared/package-lock.json /app/shared/package-lock.json
COPY zone-mta/package.json /app/zone-mta/package.json
COPY zone-mta/package-lock.json /app/zone-mta/package-lock.json
WORKDIR /app/
RUN for idx in client shared server zone-mta; do (cd $idx && npm install); done
# Install dependencies in each directory
RUN cd /app/client && npm install
RUN cd /app/shared && npm install --production
RUN cd /app/server && npm install --production
RUN cd /app/zone-mta && npm install --production
# Later, copy the app files. That improves development speed as buiding the Docker image will not have
# to download and install all the NPM dependencies every time there's a change in the source code
COPY . /app
RUN cd client && npm run build
RUN set -ex; \
cd /app/client && \
npm run build && \
rm -rf node_modules
# Final Image
FROM node:10-alpine
WORKDIR /app/
# Install system dependencies
RUN set -ex; \
apk add --update --no-cache \
pwgen netcat-openbsd bash imagemagick
COPY --from=builder /app/ /app/
EXPOSE 3000 3003 3004
ENTRYPOINT ["bash", "/app/docker-entrypoint.sh"]
ENTRYPOINT ["bash", "/app/docker-entrypoint.sh"]

36
Gruntfile.js Normal file
View file

@ -0,0 +1,36 @@
'use strict';
module.exports = function (grunt) {
// Project configuration.
grunt.initConfig({
eslint: {
all: ['lib/**/*.js', 'test/**/*.js', 'config/**/*.js', 'services/**/*.js', 'Gruntfile.js', 'app.js', 'index.js', 'routes/editorapi.js']
},
nodeunit: {
all: ['test/nodeunit/**/*-test.js']
},
jsxgettext: {
test: {
files: [{
src: ['views/**/*.hbs', 'lib/**/*.js', 'routes/**/*.js', 'services/**/*.js', 'app.js', 'index.js', '!ignored'],
output: 'mailtrain.pot',
'output-dir': './languages/'
}],
options: {
keyword: ['translate', '_']
}
}
}
});
// Load the plugin(s)
grunt.loadNpmTasks('grunt-eslint');
grunt.loadNpmTasks('grunt-contrib-nodeunit');
grunt.task.loadTasks('tasks');
// Tasks
grunt.registerTask('default', ['eslint', 'nodeunit', 'jsxgettext']);
};

232
README.md
View file

@ -1,210 +1,64 @@
# Mailtrain v2 (beta)
## Mailtrain v2 beta is available. Check it out here https://github.com/Mailtrain-org/mailtrain/tree/development
Mailtrain is a self hosted newsletter application built on Node.js (v10+) and MySQL (v8+) or MariaDB (v10+).
# Mailtrain
![](https://mailtrain.org/mailtrain.png)
[Mailtrain](http://mailtrain.org) is a self hosted newsletter application built on Node.js (v7+) and MySQL (v5.5+ or MariaDB).
![](http://mailtrain.org/mailtrain.png)
## Features
* Subscriber lists management
* Subscriber list management
* List segmentation
* Custom fields
* Email templates (including MJML-based templates)
* Custom reports
* Automation (triggered and RSS campaigns)
* Multiple users with granular user permissions and flexible sharing
* Hierarchical namespaces for enterprise-level situations
* Builtin Zone-MTA (https://github.com/zone-eu/zone-mta) for close-to-zero setup of mail delivery
* Email templates
* Large CSV list import files
## Recommended minimal hardware Requirements
* 2 vCPU
* 4096 MB RAM
Subscribe to Mailtrain Newsletter [here](https://mailtrain.org/subscription/S18sew2wM) (uses Mailtrain obviously)
## Hardware Requirements
* 1 vCPU
* 1024 MB RAM
## Quick Start
## Quick Start - Deploy with Docker
#### Requirements:
### Preparation
Mailtrain creates three URL endpoints, which are referred to as "trusted", "sandbox" and "public". This allows Mailtrain
to guarantee security and avoid XSS attacks in the multi-user settings. The function of these three endpoints is as follows:
- *trusted* - This is the main endpoint for the UI that a logged-in user uses to manage lists, send campaigns, etc.
- *sandbox* - This is an endpoint not directly visible to a user. It is used to host WYSIWYG template editors.
- *public* - This is an endpoint for subscribers. It is used to host subscription management forms, files and archive.
* [Docker](https://www.docker.com/)
* [Docker Compose](https://docs.docker.com/compose/)
The recommended deployment of Mailtrain would use 3 DNS entries that all points to the **same** IP address. For example as follows:
- *lists.example.com* - public endpoint (A record `lists` under `example.com` domain)
- *mailtrain.example.com* - trusted endpoint (CNAME record `mailtrain` under `example.com` domain that points to `lists`)
- *sbox.mailtrain.example.com* - sandbox endpoint (CNAME record `sbox.mailtrain` under `example.com` domain that points to `lists`)
#### Steps:
Depending on how you have configured your system and Docker you may need to prepend the commands below with `sudo`.
* Download Mailtrain files using git: `git clone git://github.com/Mailtrain-org/mailtrain.git` (or download [zipped repo](https://github.com/Mailtrain-org/mailtrain/archive/master.zip)) and open Mailtrain folder `cd mailtrain`
* Copy the file `docker-compose.override.yml.tmpl` to `docker-compose.override.yml` and modify it if you need to.
* Bring up the stack with: `docker-compose up -d`
* Start: `docker-compose start`
* Open [http://localhost:3000/](http://localhost:3000/) (change the host name `localhost` to the name of the host where you are deploying the system).
* Authenticate as user `admin` with password `test`
* Navigate to [http://localhost:3000/settings](http://localhost:3000/settings) and update service configuration, especially replace the value in `Service Address (URL)` from `localhost` to the actual IP or domain if installed on a server, otherwise e.g. image upload will not work.
* Navigate to [http://localhost:3000/users/account](http://localhost:3000/users/account) and update user information and password.
### Installation on fresh CentOS 7 or Ubuntu 18.04 LTS (public website secured by SSL)
## Quick Start - Manual Install (any OS that supports Node.js)
This will setup a publicly accessible Mailtrain instance. All endpoints (trusted, sandbox, public) will provide both HTTP (on port 80)
and HTTPS (on port 443). The HTTP ports just issue HTTP redirect to their HTTPS counterparts.
### Requirements:
* Mailtrain requires at least **Node.js v7**. If you want to use an older version of Node.js then you should use version v1.24 of Mailtrain. You can either download it [here](https://github.com/Mailtrain-org/mailtrain/archive/v1.24.0.zip) or if using git then run `git checkout v1.24.0` before starting it
The script below will also acquire a valid certificate from [Let's Encrypt](https://letsencrypt.org/).
If you are hosting Mailtrain on AWS or some other cloud provider, make sure that **before** running the installation
script you allow inbound connection to ports 80 (HTTP) and 443 (HTTPS).
1. Download Mailtrain files using git: `git clone git://github.com/Mailtrain-org/mailtrain.git` (or download [zipped repo](https://github.com/Mailtrain-org/mailtrain/archive/master.zip)) and open Mailtrain folder `cd mailtrain`
2. Run `npm install --production` in the Mailtrain folder to install required dependencies
3. Copy [config/default.toml](config/default.toml) as `config/production.toml` and update MySQL and any other settings in it
4. Run the server `NODE_ENV=production npm start`
5. Open [http://localhost:3000/](http://localhost:3000/)
6. Authenticate as `admin`:`test`
7. Navigate to [http://localhost:3000/settings](http://localhost:3000/settings) and update service configuration, especially replace the value in `Service Address (URL)` from `localhost` to the actual IP or domain if installed on a server, otherwise e.g. image upload will not work.
8. Navigate to [http://localhost:3000/users/account](http://localhost:3000/users/account) and update user information and password
**Note,** that this will automatically accept the Let's Encrypt's Terms of Service.
Thus, by running this script below, you agree with the Let's Encrypt's Terms of Service (https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf).
1. Login as root. (I had some problems running npm as root on CentOS 7 on AWS. This seems to be fixed by the seemingly extraneous `su` within `sudo`.)
```
sudo su -
```
2. Install GIT
For Centos 7 type:
```
yum install -y git
```
For Ubuntu 18.04 LTS type
```
apt-get install -y git
```
3. Download Mailtrain using git to the `/opt/mailtrain` directory
```
cd /opt
git clone https://github.com/Mailtrain-org/mailtrain.git
cd mailtrain
git checkout development
```
4. Run the installation script. Replace the urls and your email address with the correct values. **NOTE** that running this script you agree
Let's Encrypt's conditions.
For Centos 7 type:
```
bash setup/install-centos7-https.sh mailtrain.example.com sbox.mailtrain.example.com lists.example.com admin@example.com
```
For Ubuntu 18.04 LTS type:
```
bash setup/install-ubuntu1804-https.sh mailtrain.example.com sbox.mailtrain.example.com lists.example.com admin@example.com
```
5. Start Mailtrain and enable to be started by default when your server starts.
```
systemctl start mailtrain
systemctl enable mailtrain
```
6. Open the trusted endpoint (like `https://mailtrain.example.com`)
7. Authenticate as `admin`:`test`
8. Update your password under admin/Account
9. Update your settings under Administration/Global Settings.
10. If you intend to sign your email by DKIM, set the DKIM key and DKIM selector under Administration/Send Configurations.
### Installation on fresh CentOS 7 or Ubuntu 18.04 LTS (local installation)
This will setup a locally accessible Mailtrain instance (primarily for development and testing).
All endpoints (trusted, sandbox, public) will provide only HTTP as follows:
- http://localhost:3000 - trusted endpoint
- http://localhost:3003 - sandbox endpoint
- http://localhost:3004 - public endpoint
1. Login as root. (I had some problems running npm as root on CentOS 7 on AWS. This seems to be fixed by the seemingly extraneous `su` within `sudo`.)
```
sudo su -
```
2. Install git
For Centos 7 type:
```
yum install -y git
```
For Ubuntu 18.04 LTS type:
```
apt-get install -y git
```
3. Download Mailtrain using git to the `/opt/mailtrain` directory
```
cd /opt
git clone https://github.com/Mailtrain-org/mailtrain.git
cd mailtrain
git checkout development
```
4. Run the installation script. Replace the urls and your email address with the correct values. **NOTE** that running this script you agree
Let's Encrypt's conditions.
For Centos 7 type:
```
bash setup/install-centos7-local.sh
```
For Ubuntu 18.04 LTS type:
```
bash setup/install-ubuntu1804-local.sh
```
5. Start Mailtrain and enable to be started by default when your server starts.
```
systemctl start mailtrain
systemctl enable mailtrain
```
6. Open the trusted endpoint http://localhost:3000
7. Authenticate as `admin`:`test`
### Deployment with Docker and Docker compose
This setup starts a stack composed of Mailtrain, MongoDB, Redis, and MariaDB. It will setup a locally accessible Mailtrain instance with HTTP endpoints as follows.
- http://localhost:3000 - trusted endpoint
- http://localhost:3003 - sandbox endpoint
- http://localhost:3004 - public endpoint
To make this publicly accessible, you should add reverse proxy that makes these endpoints publicly available over HTTPS. If using the proxy, you also need to set the URL bases and `--withProxy` parameter via `MAILTRAIN_SETTING` as shown below.
An example of such proxy would be:
- http://localhost:3000 -> https://mailtrain.example.com
- http://localhost:3003 -> https://sbox.mailtrain.example.com
- http://localhsot:3004 -> https://lists.example.com
To deploy Mailtrain with Docker, you need the following three dependencies installed:
- [Docker](https://www.docker.com/)
- [Docker Compose](https://docs.docker.com/compose/)
These are the steps to start Mailtrain via docker-compose:
1. Download Mailtrain's docker-compose build file
```
curl -O https://raw.githubusercontent.com/Mailtrain-org/mailtrain/development/docker-compose.yml
```
2. Deploy Mailtrain via docker-compose (in the directory to which you downloaded the `docker-compose.yml` file). This will take quite some time when run for the first time. Subsequent executions will be fast.
```
docker-compose up
```
You can specify Mailtrain's URL bases via the `MAILTRAIN_SETTINGS` environment variable as follows. The `--withProxy` parameter is to be used when Mailtrain is put behind a reverse proxy.
```
MAILTRAIN_SETTINGS="--trustedUrlBase https://mailtrain.example.com --sandboxUrlBase https://sbox.mailtrain.example.com --publicUrlBase https://lists.example.com --withProxy" docker-compose up
```
3. Open the trusted endpoint http://localhost:3000
4. Authenticate as `admin`:`test`
The instructions above use an automatically built Docker image on DockerHub (https://hub.docker.com/r/mailtrain/mailtrain). If you want to build the Docker image yourself (e.g. when doing development), use the `docker-compose-local.yml` located in the project's root directory.
## Read The Docs
For more information, please [read the docs](http://docs.mailtrain.org/).
## License
**GPL-V3.0**
* Versions 1.22.0 and up **GPL-V3.0**
* Versions 1.21.0 and up: **EUPL-1.1**
* Versions 1.19.0 and up: **MIT**
* Up to versions 1.18.0 **GPL-V3.0**

26
TODO.md
View file

@ -1,26 +0,0 @@
Note that some of these may be already obsolete...
### 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)
### RSS Campaigns
- Aggregated RSS campaigns

View file

@ -1,21 +0,0 @@
## 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.

265
app.js Normal file
View file

@ -0,0 +1,265 @@
'use strict';
const config = require('config');
const log = require('npmlog');
const _ = require('./lib/translate')._;
const express = require('express');
const bodyParser = require('body-parser');
const path = require('path');
const favicon = require('serve-favicon');
const logger = require('morgan');
const cookieParser = require('cookie-parser');
const session = require('express-session');
const RedisStore = require('connect-redis')(session);
const flash = require('connect-flash');
const hbs = require('hbs');
const handlebarsHelpers = require('./lib/handlebars-helpers');
const compression = require('compression');
const passport = require('./lib/passport');
const tools = require('./lib/tools');
const routes = require('./routes/index');
const users = require('./routes/users');
const lists = require('./routes/lists');
const settings = require('./routes/settings');
const settingsModel = require('./lib/models/settings');
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');
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 blacklist = require('./routes/blacklist');
const editorapi = require('./routes/editorapi');
const grapejs = require('./routes/grapejs');
const mosaico = require('./routes/mosaico');
const reports = require('./routes/reports');
const reportsTemplates = require('./routes/report-templates');
const app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'hbs');
// Handle proxies. Needed to resolve client IP
if (config.www.proxy) {
app.set('trust proxy', config.www.proxy);
}
// Do not expose software used
app.disable('x-powered-by');
hbs.registerPartials(__dirname + '/views/partials');
hbs.registerPartials(__dirname + '/views/subscription/partials/');
hbs.registerPartials(__dirname + '/views/report-templates/partials/');
hbs.registerPartials(__dirname + '/views/reports/partials/');
/**
* We need this helper to make sure that we consume flash messages only
* when we are able to actually display these. Otherwise we might end up
* in a situation where we consume a flash messages but then comes a redirect
* and the message is never displayed
*/
hbs.registerHelper('flash_messages', function () { // eslint-disable-line prefer-arrow-callback
if (typeof this.flash !== 'function') { // eslint-disable-line no-invalid-this
return '';
}
let messages = this.flash(); // eslint-disable-line no-invalid-this
let response = [];
// group messages by type
Object.keys(messages).forEach(key => {
let el = '<div class="alert alert-' + key + ' alert-dismissible" role="alert"><button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>';
if (key === 'danger') {
el += '<span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> ';
}
let rows = [];
messages[key].forEach(message => {
message = hbs.handlebars.escapeExpression(message);
message = message.replace(/(\r\n|\n|\r)/gm, '<br>');
rows.push(message);
});
if (rows.length > 1) {
el += '<p>' + rows.join('</p>\n<p>') + '</p>';
} else {
el += rows.join('');
}
el += '</div>';
response.push(el);
});
return new hbs.handlebars.SafeString(
response.join('\n')
);
});
handlebarsHelpers.registerHelpers(hbs.handlebars);
app.use(compression());
app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger(config.www.log, {
stream: {
write: message => {
message = (message || '').toString();
if (message) {
log.info('HTTP', message.replace('\n', '').trim());
}
}
}
}));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use(session({
store: config.redis.enabled ? new RedisStore(config.redis) : false,
secret: config.www.secret,
saveUninitialized: false,
resave: false
}));
app.use(flash());
app.use((req, res, next) => {
req._ = str => _(str);
next();
});
app.use(bodyParser.urlencoded({
extended: true,
limit: config.www.postsize
}));
app.use(bodyParser.text({
limit: config.www.postsize
}));
app.use(bodyParser.json({
limit: config.www.postsize
}));
passport.setup(app);
// make sure flash messages are available
app.use((req, res, next) => {
res.locals.flash = req.flash.bind(req);
res.locals.user = req.user;
res.locals.ldap = {
enabled: config.ldap.enabled,
passwordresetlink: config.ldap.passwordresetlink
};
let menu = [{
title: _('Home'),
url: '/',
selected: true
}];
res.setSelectedMenu = key => {
menu.forEach(item => {
item.selected = (item.key === key);
});
};
res.locals.menu = menu;
tools.updateMenu(res);
res.locals.customStyles = config.customstyles || [];
res.locals.customScripts = config.customscripts || [];
let bodyClasses = [];
if (req.user) {
bodyClasses.push('logged-in user-' + req.user.username);
}
res.locals.bodyClass = bodyClasses.join(' ');
settingsModel.list(['ua_code', 'shoutout'], (err, configItems) => {
if (err) {
return next(err);
}
Object.keys(configItems).forEach(key => {
res.locals[key] = configItems[key];
});
next();
});
});
app.use('/', routes);
app.use('/users', users);
app.use('/lists', lists);
app.use('/templates', templates);
app.use('/campaigns', campaigns);
app.use('/settings', settings);
app.use('/blacklist', blacklist);
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('/api', api);
app.use('/editorapi', editorapi);
app.use('/grapejs', grapejs);
app.use('/mosaico', mosaico);
if (config.reports && config.reports.enabled === true) {
app.use('/reports', reports);
app.use('/report-templates', reportsTemplates);
}
// catch 404 and forward to error handler
app.use((req, res, next) => {
let err = new Error(_('Not Found'));
err.status = 404;
next(err);
});
// error handlers
// development error handler
// will print stacktrace
if (app.get('env') === 'development') {
app.use((err, req, res, next) => {
if (!err) {
return next();
}
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: err
});
});
}
// production error handler
// no stacktraces leaked to user
app.use((err, req, res, next) => {
if (!err) {
return next();
}
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: {}
});
});
module.exports = app;

1
client/.gitignore vendored
View file

@ -1 +0,0 @@
/dist

12356
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,86 +0,0 @@
{
"name": "mailtrain-client",
"version": "2.0.0",
"description": "Self hosted email newsletter app - client components",
"main": "index.js",
"scripts": {
"js": "webpack",
"watch-js": "webpack --watch",
"css": "npm-run-all --sequential css-compile css-minify",
"css-compile": "node-sass --output-style expanded --source-map true --source-map-contents true --precision 6 src/scss/mailtrain.scss dist/mailtrain.css",
"css-minify": "cleancss --level 1 --source-map --source-map-inline-sources --output dist/mailtrain.min.css dist/mailtrain.css",
"watch-css": "nodemon --watch src/scss -e scss -x \"npm run css\"",
"watch": "npm-run-all --parallel watch-css watch-js",
"build": "npm-run-all --parallel css js"
},
"repository": {
"type": "git",
"url": "git://github.com/Mailtrain-org/mailtrain.git"
},
"license": "GPL-3.0",
"homepage": "https://mailtrain.org/",
"dependencies": {
"@coreui/coreui": "^2.1.12",
"@fortawesome/fontawesome-free": "^5.9.0",
"axios": "^0.18.1",
"bootstrap": "^4.2.1",
"clone": "^2.1.2",
"datatables.net": "^1.10.19",
"datatables.net-bs4": "^1.10.19",
"ellipsize": "^0.1.0",
"grapesjs": "^0.14.62",
"grapesjs-mjml": "0.0.31",
"grapesjs-preset-newsletter": "^0.2.20",
"htmlparser2": "^3.10.1",
"i18next": "^13.1.0",
"i18next-browser-languagedetector": "^2.2.4",
"immutable": "^4.0.0-rc.12",
"juice": "^5.2.0",
"lodash": "^4.17.15",
"mjml4-in-browser": "^1.1.2",
"moment": "^2.23.0",
"moment-timezone": "^0.5.26",
"popper.js": "^1.14.6",
"prop-types": "^15.6.2",
"querystringify": "^2.1.0",
"react": "^16.7.0",
"react-ace": "^6.6.0",
"react-ckeditor-component": "^1.1.0",
"react-color": "^2.17.3",
"react-day-picker": "^7.2.4",
"react-dnd": "^7.7.0",
"react-dnd-html5-backend": "^7.7.0",
"react-dnd-touch-backend": "^0.7.1",
"react-dom": "^16.7.0",
"react-dropzone": "^8.0.3",
"react-google-charts": "^3.0.14",
"react-i18next": "^9.0.1",
"react-router-dom": "^4.3.1",
"react-sortable-tree": "^2.6.0",
"shallowequal": "^1.1.0",
"shortid": "^2.2.14",
"slugify": "^1.3.4",
"url-parse": "^1.4.7"
},
"devDependencies": {
"@babel/cli": "^7.5.5",
"@babel/core": "^7.5.5",
"@babel/plugin-proposal-class-properties": "^7.5.5",
"@babel/plugin-proposal-decorators": "^7.4.4",
"@babel/plugin-proposal-function-bind": "^7.2.0",
"@babel/preset-env": "^7.5.5",
"@babel/preset-react": "^7.0.0",
"babel-loader": "^8.0.6",
"clean-css-cli": "^4.2.1",
"css-loader": "^2.1.0",
"file-loader": "^3.0.1",
"node-sass": "^4.12.0",
"nodemon": "^1.19.1",
"npm-run-all": "^4.1.5",
"sass-loader": "^7.1.0",
"style-loader": "^0.23.1",
"url-loader": "^1.1.2",
"webpack": "^4.36.1",
"webpack-cli": "^3.3.6"
}
}

View file

@ -1,27 +0,0 @@
'use strict';
import React, {Component} from 'react';
import {withTranslation} from './lib/i18n';
import {requiresAuthenticatedUser} from './lib/page';
import {withComponentMixins} from "./lib/decorator-helpers";
@withComponentMixins([
withTranslation,
requiresAuthenticatedUser
])
export default class List extends Component {
constructor(props) {
super(props);
}
render() {
const t = this.props.t;
return (
<div>
<h2>{t('Mailtrain 2 beta')}</h2>
<div>{t('Build') + ' 2019-07-26-1703'}</div>
</div>
);
}
}

View file

@ -1,397 +0,0 @@
'use strict';
import React, {Component} from 'react';
import {withTranslation} from '../lib/i18n';
import {Trans} from 'react-i18next';
import {requiresAuthenticatedUser, Title, withPageHelpers} from '../lib/page'
import {withAsyncErrorHandler, withErrorHandling} from '../lib/error-handling';
import axios from '../lib/axios';
import {Button} from '../lib/bootstrap-components';
import {getUrl} from "../lib/urls";
import {withComponentMixins} from "../lib/decorator-helpers";
import styles from "./styles.scss"
@withComponentMixins([
withTranslation,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class API extends Component {
constructor(props) {
super(props);
this.state = {
accessToken: null
};
}
@withAsyncErrorHandler
async loadAccessToken() {
const response = await axios.get(getUrl('rest/access-token'));
this.setState({
accessToken: response.data
});
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.loadAccessToken();
}
async resetAccessToken() {
const response = await axios.post(getUrl('rest/access-token-reset'));
this.setState({
accessToken: response.data
});
}
render() {
const t = this.props.t;
const accessToken = this.state.accessToken || 'ACCESS_TOKEN';
let accessTokenMsg;
if (this.state.accessToken) {
accessTokenMsg = <div>{t('personalAccessToken') + ': '}<code>{accessToken}</code></div>;
} else {
accessTokenMsg = <div>{t('accessTokenNotYetGenerated')}</div>;
}
return (
<div className={styles.api}>
<Title>{t('api')}</Title>
<div className="card mb-3">
<div className="card-body">
<div className="float-right">
<Button label={this.state.accessToken ? t('resetAccessToken') : t('generateAccessToken')} icon="redo" className="btn-info" onClickAsync={::this.resetAccessToken} />
</div>
{accessTokenMsg}
</div>
</div>
<div className="card mb-3">
<div className="card-body">
<h4 className="card-title">{t('notesAboutTheApi')}</h4>
<ul className="card-text">
<li>
<Trans i18nKey="apiResponseIsAJsonStructureWithErrorAnd">API response is a JSON structure with <code>error</code> and <code>data</code> properties. If the response <code>error</code> has a value set then the request failed.</Trans>
</li>
<li>
<Trans i18nKey="youNeedToDefineProperContentTypeWhen">You need to define proper <code>Content-Type</code> when making a request. You can either use <code>application/x-www-form-urlencoded</code> for normal form data or <code>application/json</code> for a JSON payload. Using <code>multipart/form-data</code> is not supported.</Trans>
</li>
</ul>
</div>
</div>
<div className="card mb-3">
<div className="card-header">
<b>POST /api/subscribe/:listId {t('addSubscription')}</b>
</div>
<div className="card-body">
<p className="card-text">
{t('thisApiCallEitherInsertsANewSubscription')}
</p>
</div>
</div>
<h4>POST /api/subscribe/:listId {t('addSubscription')}</h4>
<p>
{t('thisApiCallEitherInsertsANewSubscription')}
</p>
<p>
{t('Query params')}
</p>
<ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
</ul>
<p>
<strong>POST</strong> {t('arguments')}
</p>
<ul>
<li><strong>EMAIL</strong> {t('subscribersEmailAddress')} (<em>{t('required')}</em>)</li>
<li><strong>FIRST_NAME</strong> {t('subscribersFirstName')}</li>
<li><strong>LAST_NAME</strong> {t('subscribersLastName')}</li>
<li><strong>TIMEZONE</strong> {t('subscribersTimezoneEgEuropeTallinnPstOr')}</li>
<li><strong>MERGE_TAG_VALUE</strong> {t('customFieldValueUseYesnoForOptionGroup')}</li>
</ul>
<p>
{t('additionalPostArguments')}:
</p>
<ul>
<li>
<strong>FORCE_SUBSCRIBE</strong> {t('setToYesIfYouWantToMakeSureTheEmailIs')}
by default.
</li>
<li>
<strong>REQUIRE_CONFIRMATION</strong> {t('setToYesIfYouWantToSendConfirmationEmail')}
</li>
</ul>
<p>
<strong>{t('example')}</strong>
</p>
<pre>curl -XPOST '{getUrl(`api/subscribe/B16uVTdW?access_token=${accessToken}`)}' \<br/>
--data 'EMAIL=test@example.com&amp;MERGE_CHECKBOX=yes&amp;REQUIRE_CONFIRMATION=yes'</pre>
<h4>POST /api/unsubscribe/:listId {t('removeSubscription')}</h4>
<p>
{t('thisApiCallMarksASubscriptionAs')}
</p>
<p>
{t('Query params')}
</p>
<ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
</ul>
<p>
<strong>POST</strong> {t('arguments')}
</p>
<ul>
<li><strong>EMAIL</strong> {t('subscribersEmailAddress')} (<em>{t('required')}</em>)</li>
</ul>
<p>
<strong>{t('example')}</strong>
</p>
<pre>curl -XPOST '{getUrl(`api/unsubscribe/B16uVTdW?access_token=${accessToken}`)}' \<br/>
--data 'EMAIL=test@example.com'</pre>
<h4>POST /api/delete/:listId {t('deleteSubscription')}</h4>
<p>
{t('thisApiCallDeletesASubscription')}
</p>
<p>
{t('Query params')}
</p>
<ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
</ul>
<p>
<strong>POST</strong> {t('arguments')}
</p>
<ul>
<li><strong>EMAIL</strong> {t('subscribersEmailAddress')} (<em>{t('required')}</em>)</li>
</ul>
<p>
<strong>{t('example')}</strong>
</p>
<pre>curl -XPOST '{getUrl(`api/delete/B16uVTdW?access_token=${accessToken}`)}' \<br/>
--data 'EMAIL=test@example.com'</pre>
<h4>POST /api/field/:listId {t('addNewCustomField')}</h4>
<p>
{t('thisApiCallCreatesANewCustomFieldForA')}
</p>
<p>
{t('Query params')}
</p>
<ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
</ul>
<p>
<strong>POST</strong> {t('arguments')}
</p>
<ul>
<li><strong>NAME</strong> {t('fieldName')} (<em>{t('required')}</em>)</li>
<li><strong>TYPE</strong> {t('oneOfTheFollowingTypes')}
<ul>
<li><strong>text</strong> &ndash; Text</li>
<li><strong>website</strong> &ndash; Website</li>
<li><strong>longtext</strong> &ndash; Multi-line text</li>
<li><strong>gpg</strong> &ndash; GPG Public Key</li>
<li><strong>number</strong> &ndash; Number</li>
<li><strong>radio</strong> &ndash; Radio Buttons</li>
<li><strong>checkbox</strong> &ndash; Checkboxes</li>
<li><strong>dropdown</strong> &ndash; Drop Down</li>
<li><strong>date-us</strong> &ndash; Date (MM/DD/YYY)</li>
<li><strong>date-eur</strong> &ndash; Date (DD/MM/YYYY)</li>
<li><strong>birthday-us</strong> &ndash; Birthday (MM/DD)</li>
<li><strong>birthday-eur</strong> &ndash; Birthday (DD/MM)</li>
<li><strong>json</strong> &ndash; JSON value for custom rendering</li>
<li><strong>option</strong> &ndash; Option</li>
</ul>
</li>
<li><strong>GROUP</strong> {t('ifTheTypeIsOptionThenYouAlsoNeedTo')}</li>
<li><strong>GROUP_TEMPLATE</strong> {t('templateForTheGroupElementIfNotSetThen')}</li>
<li><strong>VISIBLE</strong> yes/no, {t('ifNotVisibleThenTheSubscriberCanNotView')}</li>
</ul>
<p>
<strong>{t('example')}</strong>
</p>
<pre>curl -XPOST '{getUrl(`api/field/B16uVTdW?access_token=${accessToken}`)}' \<br/>
--data 'NAME=Birthday&amp;TYPE=birthday-us&amp;VISIBLE=yes'</pre>
<h4>GET /api/blacklist/get {t('getListOfBlacklistedEmails')}</h4>
<p>
{t('thisApiCallGetListOfBlacklistedEmails')}
</p>
<p>
{t('Query params')}
</p>
<ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}
<ul>
<li><strong>start</strong> {t('startPosition')} (<em>{t('optionalDefault0')}</em>)</li>
<li><strong>limit</strong> {t('limitEmailsCountInResponse')} (<em>{t('optionalDefault10000')}</em>)</li>
<li><strong>search</strong> {t('filterByPartOfEmail')} (<em>{t('optionalDefault')}</em>)</li>
</ul>
</li>
</ul>
<p>
<strong>{t('example')}</strong>
</p>
<pre>curl -XGET '{getUrl(`api/blacklist/get?access_token=${accessToken}&limit=10&start=10&search=gmail`)}' </pre>
<h4>POST /api/blacklist/add {t('addEmailToBlacklist')}</h4>
<p>
{t('thisApiCallEitherAddEmailsToBlacklist')}
</p>
<p>
{t('Query params')}
</p>
<ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
</ul>
<p>
<strong>POST</strong> {t('arguments')}
</p>
<ul>
<li><strong>EMAIL</strong> {t('emailAddress')} (<em>{t('required')}</em>)</li>
</ul>
<p>
<strong>{t('example')}</strong>
</p>
<pre>curl -XPOST '{getUrl(`api/blacklist/add?access_token={accessToken}`)}' \<br/>
--data 'EMAIL=test@example.com&amp;'</pre>
<h4>POST /api/blacklist/delete {t('deleteEmailFromBlacklist')}</h4>
<p>
{t('thisApiCallEitherDeleteEmailsFrom')}
</p>
<p>
{t('Query params')}
</p>
<ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
</ul>
<p>
<strong>POST</strong> {t('arguments')}
</p>
<ul>
<li><strong>EMAIL</strong> {t('emailAddress')} (<em>{t('required')}</em>)</li>
</ul>
<p>
<strong>{t('example')}</strong>
</p>
<pre>curl -XPOST '{getUrl(`api/blacklist/delete?access_token=${accessToken}`)}' \<br/>
--data 'EMAIL=test@example.com&amp;'</pre>
<h4>GET /api/lists/:email {t('getTheListsAUserHasSubscribedTo')}</h4>
<p>
{t('retrieveTheListsThatTheUserWithEmailHas')}
</p>
<p>
{t('Query params')}
</p>
<ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
</ul>
<p>
<strong>{t('example')}</strong>
</p>
<pre>curl -XGET '{getUrl(`api/lists/test@example.com?access_token=${accessToken}`)}'</pre>
<h4>GET /api/rss/fetch/:campaignCid {t('triggerFetchOfACampaign')}</h4>
<p>
{t('forcesTheRssFeedCheckToImmediatelyCheck')}
</p>
<p>
{t('Query params')}
</p>
<ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
</ul>
<p>
<strong>{t('example')}</strong>
</p>
<pre>curl -XGET '{getUrl(`api/rss/fetch/5OOnZKrp0?access_token=${accessToken}`)}'</pre>
<h4>POST /api/templates/:templateId/send {t('sendTransactionalEmail')}</h4>
<p>
{t('sendSingleEmailByTemplateWithGiven')}
</p>
<p>
{t('Query params')}
</p>
<ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
</ul>
<p>
<strong>POST</strong> {t('arguments')}
</p>
<ul>
<li><strong>EMAIL</strong> {t('emailAddress')} (<em>{t('required')}</em>)</li>
<li><strong>SEND_CONFIGURATION_ID</strong> {t('idOfConfigurationUsedToCreateMailer')}</li>
<li><strong>SUBJECT</strong> {t('subject')}</li>
<li><strong>TAGS</strong> {t('mapOfTemplatesubjectVariablesToReplace')}</li>
<li><strong>ATTACHMENTS</strong> {t('Attachments (format as consumed by nodemailer)')}</li>
</ul>
<p>
<strong>{t('example')}</strong>
</p>
<pre>curl -XPOST '{getUrl(`api/templates/1/send?access_token=${accessToken}`)}' \<br/>
--data 'EMAIL=test@example.com&amp;SUBJECT=Test&amp;TAGS[FOO]=bar&amp;TAGS[TEST]=example'</pre>
</div>
);
}
}

View file

@ -1,224 +0,0 @@
'use strict';
import React, {Component} from 'react';
import {withTranslation} from '../lib/i18n';
import {Trans} from 'react-i18next';
import {requiresAuthenticatedUser, Title, withPageHelpers} from '../lib/page'
import {
Button,
ButtonRow,
Fieldset,
filterData,
Form,
FormSendMethod,
InputField,
withForm,
withFormErrorHandlers
} from '../lib/form';
import {withAsyncErrorHandler, withErrorHandling} from '../lib/error-handling';
import passwordValidator from '../../../shared/password-validator';
import interoperableErrors from '../../../shared/interoperable-errors';
import mailtrainConfig from 'mailtrainConfig';
import {withComponentMixins} from "../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class Account extends Component {
constructor(props) {
super(props);
this.passwordValidator = passwordValidator(props.t);
this.state = {};
this.initForm({
serverValidation: {
url: 'rest/account-validate',
changed: ['email', 'currentPassword']
}
});
}
getFormValuesMutator(data) {
data.password = '';
data.password2 = '';
data.currentPassword = '';
}
submitFormValuesMutator(data) {
return filterData(data, ['name', 'email', 'password', 'currentPassword']);
}
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL('rest/account');
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.loadFormValues();
}
localValidateFormValues(state) {
const t = this.props.t;
const email = state.getIn(['email', 'value']);
const emailServerValidation = state.getIn(['email', 'serverValidation']);
if (!email) {
state.setIn(['email', 'error'], t('emailMustNotBeEmpty'));
} else if (emailServerValidation && emailServerValidation.invalid) {
state.setIn(['email', 'error'], t('invalidEmailAddress'));
} else if (emailServerValidation && emailServerValidation.exists) {
state.setIn(['email', 'error'], t('theEmailIsAlreadyAssociatedWithAnother'));
} else if (!emailServerValidation) {
state.setIn(['email', 'error'], t('validationIsInProgress'));
} else {
state.setIn(['email', 'error'], null);
}
const name = state.getIn(['name', 'value']);
if (!name) {
state.setIn(['name', 'error'], t('fullNameMustNotBeEmpty'));
} else {
state.setIn(['name', 'error'], null);
}
const password = state.getIn(['password', 'value']) || '';
const password2 = state.getIn(['password2', 'value']) || '';
const currentPassword = state.getIn(['currentPassword', 'value']) || '';
let passwordMsgs = [];
if (password || currentPassword) {
const passwordResults = this.passwordValidator.test(password);
passwordMsgs.push(...passwordResults.errors);
const currentPasswordServerValidation = state.getIn(['currentPassword', 'serverValidation']);
if (!currentPassword) {
state.setIn(['currentPassword', 'error'], t('currentPasswordMustNotBeEmpty'));
} else if (currentPasswordServerValidation && currentPasswordServerValidation.incorrect) {
state.setIn(['currentPassword', 'error'], t('incorrectPassword'));
} else if (!currentPasswordServerValidation) {
state.setIn(['email', 'error'], t('validationIsInProgress'));
} else {
state.setIn(['currentPassword', 'error'], null);
}
}
if (passwordMsgs.length > 1) {
passwordMsgs = passwordMsgs.map((msg, idx) => <div key={idx}>{msg}</div>)
}
state.setIn(['password', 'error'], passwordMsgs.length > 0 ? passwordMsgs : null);
state.setIn(['password2', 'error'], password !== password2 ? t('passwordsMustMatch') : null);
}
@withFormErrorHandlers
async submitHandler() {
const t = this.props.t;
try {
this.disableForm();
this.setFormStatusMessage('info', t('updatingUserProfile'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.POST, 'rest/account');
if (submitSuccessful) {
this.setFlashMessage('success', t('userProfileUpdated'));
this.hideFormValidation();
this.updateFormValue('password', '');
this.updateFormValue('password2', '');
this.updateFormValue('currentPassword', '');
this.clearFormStatusMessage();
this.enableForm();
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
}
} catch (error) {
if (error instanceof interoperableErrors.IncorrectPasswordError) {
this.enableForm();
this.setFormStatusMessage('danger',
<span>
<strong>{t('yourUpdatesCannotBeSaved')}</strong>{' '}
{t('thePasswordIsIncorrectPossiblyJust')}
</span>
);
this.scheduleFormRevalidate();
return;
}
if (error instanceof interoperableErrors.DuplicitEmailError) {
this.enableForm();
this.setFormStatusMessage('danger',
<span>
<strong>{t('yourUpdatesCannotBeSaved')}</strong>{' '}
{t('theEmailIsAlreadyAssignedToAnotherUser')}
</span>
);
this.scheduleFormRevalidate();
return;
}
throw error;
}
}
render() {
const t = this.props.t;
if (mailtrainConfig.isAuthMethodLocal) {
return (
<div>
<Title>{t('account')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<Fieldset label={t('generalSettings')}>
<InputField id="name" label={t('fullName')}/>
<InputField id="email" label={t('email')} help={t('thisAddressIsUsedForAccountRecoveryIn')}/>
</Fieldset>
<Fieldset label={t('passwordChange')}>
<p>{t('youOnlyNeedToFillOutThisFormIfYouWantTo')}</p>
<InputField id="currentPassword" label={t('currentPassword')} type="password" />
<InputField id="password" label={t('newPassword')} type="password" />
<InputField id="password2" label={t('confirmPassword')} type="password" />
</Fieldset>
<ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('update')}/>
</ButtonRow>
</Form>
</div>
);
} else {
return (
<div>
<Title>{t('account')}</Title>
<p>{t('accountManagementIsNotPossibleBecause')}</p>
{mailtrainConfig.externalPasswordResetLink && <p><Trans i18nKey="ifYouWantToChangeThePasswordUseThisLink">If you want to change the password, use <a href={mailtrainConfig.externalPasswordResetLink}>this link</a>.</Trans></p>}
</div>
);
}
}
}

View file

@ -1,28 +0,0 @@
'use strict';
import React from 'react';
import Account from './Account';
import API from './API';
function getMenus(t) {
return {
'account': {
title: t('account'),
link: '/account',
panelComponent: Account,
children: {
api: {
title: t('api'),
link: '/account/api',
panelComponent: API
}
}
}
};
}
export default {
getMenus
}

View file

@ -1,9 +0,0 @@
.api {
:global .card h4 {
margin-top: 0px;
}
h4 {
margin-top: 45px;
}
}

View file

@ -1,142 +0,0 @@
'use strict';
import React, {Component} from "react";
import {withTranslation} from '../lib/i18n';
import {requiresAuthenticatedUser, Title, withPageHelpers} from "../lib/page";
import {withErrorHandling} from "../lib/error-handling";
import {Table} from "../lib/table";
import {ButtonRow, Form, FormSendMethod, InputField, withForm} from "../lib/form";
import {Button} from "../lib/bootstrap-components";
import {HTTPMethod} from "../lib/axios";
import {tableAddRestActionButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals";
import {withComponentMixins} from "../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class List extends Component {
constructor(props) {
super(props);
const t = props.t;
this.state = {};
tableRestActionDialogInit(this);
this.initForm({
leaveConfirmation: false,
serverValidation: {
url: 'rest/blacklist-validate',
changed: ['email']
}
});
}
static propTypes = {
}
clearFields() {
this.populateFormValues({
email: ''
});
}
localValidateFormValues(state) {
const t = this.props.t;
const email = state.getIn(['email', 'value']);
const emailServerValidation = state.getIn(['email', 'serverValidation']);
if (!email) {
state.setIn(['email', 'error'], t('emailMustNotBeEmpty-1'));
} else if (emailServerValidation && emailServerValidation.invalid) {
state.setIn(['email', 'error'], t('invalidEmailAddress'));
} else if (emailServerValidation && emailServerValidation.exists) {
state.setIn(['email', 'error'], t('theEmailIsAlreadyOnBlacklist'));
} else if (!emailServerValidation) {
state.setIn(['email', 'error'], t('validationIsInProgress'));
} else {
state.setIn(['email', 'error'], null);
}
}
async submitHandler() {
const t = this.props.t;
this.disableForm();
this.setFormStatusMessage('info', t('saving'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(FormSendMethod.POST, 'rest/blacklist');
if (submitSuccessful) {
this.hideFormValidation();
this.clearFields();
this.enableForm();
this.clearFormStatusMessage();
this.table.refresh();
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd-1'));
}
}
componentDidMount() {
this.clearFields();
}
render() {
const t = this.props.t;
const columns = [
{ data: 0, title: t('email') },
{
actions: data => {
const actions = [];
const email = data[0];
tableAddRestActionButton(
actions, this,
{ method: HTTPMethod.DELETE, url: `rest/blacklist/${email}`},
{ icon: 'trash-alt', label: t('removeFromBlacklist') },
t('confirmRemovalFromBlacklist'),
t('areYouSureYouWantToRemoveEmailFromThe', {email}),
t('removingEmailFromTheBlacklist', {email}),
t('emailRemovedFromTheBlacklist', {email}),
null
);
return actions;
}
}
];
return (
<div>
{tableRestActionDialogRender(this)}
<Title>{t('blacklist')}</Title>
<h3 className="legend">{t('addEmailToBlacklist-1')}</h3>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="email" label={t('email')}/>
<ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('addToBlacklist')}/>
</ButtonRow>
</Form>
<hr/>
<h3 className="legend">{t('blacklistedEmails')}</h3>
<Table ref={node => this.table = node} withHeader dataUrl="rest/blacklist-table" columns={columns} />
</div>
);
}
}

View file

@ -1,18 +0,0 @@
'use strict';
import React from "react";
import List from "./List";
function getMenus(t) {
return {
'blacklist': {
title: t('blacklist'),
link: '/blacklist',
panelComponent: List,
}
};
}
export default {
getMenus
}

View file

@ -1,815 +0,0 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withTranslation} from '../lib/i18n';
import {LinkButton, requiresAuthenticatedUser, Title, withPageHelpers} from '../lib/page'
import {
AlignedRow,
Button,
ButtonRow,
CheckBox,
Dropdown,
Fieldset,
filterData,
Form,
FormSendMethod,
InputField,
StaticField,
TableSelect,
TextArea,
withForm,
withFormErrorHandlers
} from '../lib/form';
import {withAsyncErrorHandler, withErrorHandling} from '../lib/error-handling';
import {getDefaultNamespace, NamespaceSelect, validateNamespace} from '../lib/namespace';
import {DeleteModalDialog} from "../lib/modals";
import mailtrainConfig from 'mailtrainConfig';
import {getTagLanguages, getTemplateTypes, getTypeForm, ResourceType} from '../templates/helpers';
import axios from '../lib/axios';
import styles from "../lib/styles.scss";
import campaignsStyles from "./styles.scss";
import {getUrl} from "../lib/urls";
import {campaignOverridables, CampaignSource, CampaignStatus, CampaignType} from "../../../shared/campaigns";
import moment from 'moment';
import {getMailerTypes} from "../send-configurations/helpers";
import {getCampaignLabels} from "./helpers";
import {withComponentMixins} from "../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class CUD extends Component {
constructor(props) {
super(props);
const t = props.t;
this.templateTypes = getTemplateTypes(props.t, 'data_sourceCustom_', ResourceType.CAMPAIGN);
this.tagLanguages = getTagLanguages(props.t);
this.mailerTypes = getMailerTypes(props.t);
const { campaignTypeLabels } = getCampaignLabels(t);
this.campaignTypeLabels = campaignTypeLabels;
this.createTitles = {
[CampaignType.REGULAR]: t('createRegularCampaign'),
[CampaignType.RSS]: t('createRssCampaign'),
[CampaignType.TRIGGERED]: t('createTriggeredCampaign'),
};
this.editTitles = {
[CampaignType.REGULAR]: t('editRegularCampaign'),
[CampaignType.RSS]: t('editRssCampaign'),
[CampaignType.TRIGGERED]: t('editTriggeredCampaign'),
};
this.sourceLabels = {
[CampaignSource.TEMPLATE]: t('template'),
[CampaignSource.CUSTOM_FROM_TEMPLATE]: t('customContentClonedFromTemplate'),
[CampaignSource.CUSTOM_FROM_CAMPAIGN]: t('customContentClonedFromAnotherCampaign'),
[CampaignSource.CUSTOM]: t('customContent'),
[CampaignSource.URL]: t('url')
};
this.sourceOptions = [];
for (const key in this.sourceLabels) {
this.sourceOptions.push({key, label: this.sourceLabels[key]});
}
this.customTemplateTypeOptions = [];
for (const key of mailtrainConfig.editors) {
this.customTemplateTypeOptions.push({key, label: this.templateTypes[key].typeName});
}
this.customTemplateTagLanguageOptions = [];
for (const key of mailtrainConfig.tagLanguages) {
this.customTemplateTagLanguageOptions.push({key, label: this.tagLanguages[key].name});
}
this.state = {
sendConfiguration: null
};
this.nextListEntryId = 0;
this.initForm({
onChange: {
send_configuration: ::this.onSendConfigurationChanged
},
onChangeBeforeValidation: ::this.onFormChangeBeforeValidation
});
}
static propTypes = {
action: PropTypes.string.isRequired,
entity: PropTypes.object,
permissions: PropTypes.object,
type: PropTypes.number
}
getNextListEntryId() {
const id = this.nextListEntryId;
this.nextListEntryId += 1;
return id;
}
onFormChangeBeforeValidation(mutStateData, key, oldValue, newValue) {
let match;
if (key === 'data_sourceCustom_type') {
if (newValue) {
this.templateTypes[newValue].afterTypeChange(mutStateData);
}
}
if (key === 'data_sourceCustom_tag_language') {
if (newValue) {
const isEdit = !!this.props.entity;
this.templateTypes[newValue].afterTagLanguageChange(mutStateData, isEdit);
}
}
if (key && (match = key.match(/^(lists_[0-9]+_)list$/))) {
const prefix = match[1];
mutStateData.setIn([prefix + 'segment', 'value'], null);
}
}
onSendConfigurationChanged(newState, key, oldValue, sendConfigurationId) {
newState.sendConfiguration = null;
// noinspection JSIgnoredPromiseFromCall
this.fetchSendConfiguration(sendConfigurationId);
}
@withAsyncErrorHandler
async fetchSendConfiguration(sendConfigurationId) {
if (sendConfigurationId) {
this.fetchSendConfigurationId = sendConfigurationId;
const result = await axios.get(getUrl(`rest/send-configurations-public/${sendConfigurationId}`));
if (sendConfigurationId === this.fetchSendConfigurationId) {
this.setState({
sendConfiguration: result.data
});
}
}
}
getFormValuesMutator(data) {
// The source cannot be changed once campaign is created. Thus we don't have to initialize fields for all other sources
if (data.source === CampaignSource.TEMPLATE) {
data.data_sourceTemplate = data.data.sourceTemplate;
}
if (data.source === CampaignSource.URL) {
data.data_sourceUrl = data.data.sourceUrl;
}
if (data.type === CampaignType.RSS) {
data.data_feedUrl = data.data.feedUrl;
}
for (const overridable of campaignOverridables) {
if (data[overridable + '_override'] === null) {
data[overridable + '_override'] = '';
data[overridable + '_overriden'] = false;
} else {
data[overridable + '_overriden'] = true;
}
}
const lsts = [];
for (const lst of data.lists) {
const lstUid = this.getNextListEntryId();
const prefix = 'lists_' + lstUid + '_';
data[prefix + 'list'] = lst.list;
data[prefix + 'segment'] = lst.segment;
data[prefix + 'useSegmentation'] = !!lst.segment;
lsts.push(lstUid);
}
data.lists = lsts;
// noinspection JSIgnoredPromiseFromCall
this.fetchSendConfiguration(data.send_configuration);
}
submitFormValuesMutator(data) {
const isEdit = !!this.props.entity;
data.source = Number.parseInt(data.source);
data.data = {};
if (data.source === CampaignSource.TEMPLATE || data.source === CampaignSource.CUSTOM_FROM_TEMPLATE) {
data.data.sourceTemplate = data.data_sourceTemplate;
}
if (data.source === CampaignSource.CUSTOM_FROM_CAMPAIGN) {
data.data.sourceCampaign = data.data_sourceCampaign;
}
if (!isEdit && data.source === CampaignSource.CUSTOM) {
this.templateTypes[data.data_sourceCustom_type].beforeSave(data);
data.data.sourceCustom = {
type: data.data_sourceCustom_type,
tag_language: data.data_sourceCustom_tag_language,
data: data.data_sourceCustom_data,
html: data.data_sourceCustom_html,
text: data.data_sourceCustom_text
}
}
if (data.source === CampaignSource.URL) {
data.data.sourceUrl = data.data_sourceUrl;
}
if (data.type === CampaignType.RSS) {
data.data.feedUrl = data.data_feedUrl;
}
for (const overridable of campaignOverridables) {
if (!data[overridable + '_overriden']) {
data[overridable + '_override'] = null;
}
delete data[overridable + '_overriden'];
}
const lsts = [];
for (const lstUid of data.lists) {
const prefix = 'lists_' + lstUid + '_';
const useSegmentation = data[prefix + 'useSegmentation'] && (data.type === CampaignType.REGULAR || data.type === CampaignType.RSS);
lsts.push({
list: data[prefix + 'list'],
segment: useSegmentation ? data[prefix + 'segment'] : null
});
}
data.lists = lsts;
for (const key in data) {
if (key.startsWith('data_') || key.startsWith('lists_')) {
delete data[key];
}
}
return filterData(data, [
'name', 'description', 'segment', 'namespace', 'send_configuration',
'subject', 'from_name_override', 'from_email_override', 'reply_to_override',
'data', 'click_tracking_disabled', 'open_tracking_disabled', 'unsubscribe_url',
'type', 'source', 'parent', 'lists'
]);
}
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity);
if (this.props.entity.status === CampaignStatus.SENDING) {
this.disableForm();
}
} else {
const data = {};
for (const overridable of campaignOverridables) {
data[overridable + '_override'] = '';
data[overridable + '_overriden'] = false;
}
const lstUid = this.getNextListEntryId();
const lstPrefix = 'lists_' + lstUid + '_';
this.populateFormValues({
...data,
type: this.props.type,
name: '',
description: '',
[lstPrefix + 'list']: null,
[lstPrefix + 'segment']: null,
[lstPrefix + 'useSegmentation']: false,
lists: [lstUid],
send_configuration: null,
namespace: getDefaultNamespace(this.props.permissions),
subject: '',
click_tracking_disabled: false,
open_tracking_disabled: false,
unsubscribe_url: '',
source: CampaignSource.TEMPLATE,
// This is for CampaignSource.TEMPLATE and CampaignSource.CUSTOM_FROM_TEMPLATE
data_sourceTemplate: null,
// This is for CampaignSource.CUSTOM_FROM_CAMPAIGN
data_sourceCampaign: null,
// This is for CampaignSource.CUSTOM
data_sourceCustom_type: mailtrainConfig.editors[0],
data_sourceCustom_tag_language: mailtrainConfig.tagLanguages[0],
data_sourceCustom_data: {},
data_sourceCustom_html: '',
data_sourceCustom_text: '',
...this.templateTypes[mailtrainConfig.editors[0]].initData(),
// This is for CampaignSource.URL
data_sourceUrl: '',
// This is for CampaignType.RSS
data_feedUrl: ''
});
}
}
localValidateFormValues(state) {
const t = this.props.t;
const isEdit = !!this.props.entity;
for (const key of state.keys()) {
state.setIn([key, 'error'], null);
}
if (!state.getIn(['name', 'value'])) {
state.setIn(['name', 'error'], t('nameMustNotBeEmpty'));
}
if (!state.getIn(['subject', 'value'])) {
state.setIn(['subject', 'error'], t('"Subject" line must not be empty"'));
}
if (!state.getIn(['send_configuration', 'value'])) {
state.setIn(['send_configuration', 'error'], t('sendConfigurationMustBeSelected'));
}
if (state.getIn(['from_email_overriden', 'value']) && !state.getIn(['from_email_override', 'value'])) {
state.setIn(['from_email_override', 'error'], t('fromEmailMustNotBeEmpty'));
}
const campaignTypeKey = state.getIn(['type', 'value']);
const sourceTypeKey = Number.parseInt(state.getIn(['source', 'value']));
if (sourceTypeKey === CampaignSource.TEMPLATE || (!isEdit && sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE)) {
if (!state.getIn(['data_sourceTemplate', 'value'])) {
state.setIn(['data_sourceTemplate', 'error'], t('templateMustBeSelected'));
}
} else if (!isEdit && sourceTypeKey === CampaignSource.CUSTOM_FROM_CAMPAIGN) {
if (!state.getIn(['data_sourceCampaign', 'value'])) {
state.setIn(['data_sourceCampaign', 'error'], t('campaignMustBeSelected'));
}
} else if (!isEdit && sourceTypeKey === CampaignSource.CUSTOM) {
// The type is used only in create form. In case of CUSTOM_FROM_TEMPLATE or CUSTOM_FROM_CAMPAIGN, it is determined by the source template, so no need to check it here
const customTemplateTypeKey = state.getIn(['data_sourceCustom_type', 'value']);
if (!customTemplateTypeKey) {
state.setIn(['data_sourceCustom_type', 'error'], t('typeMustBeSelected'));
}
if (!state.getIn(['data_sourceCustom_tag_language', 'value'])) {
state.setIn(['data_sourceCustom_tag_language', 'error'], t('Tag language must be selected'));
}
if (customTemplateTypeKey) {
this.templateTypes[customTemplateTypeKey].validate(state);
}
} else if (sourceTypeKey === CampaignSource.URL) {
if (!state.getIn(['data_sourceUrl', 'value'])) {
state.setIn(['data_sourceUrl', 'error'], t('urlMustNotBeEmpty'));
}
}
if (campaignTypeKey === CampaignType.RSS) {
if (!state.getIn(['data_feedUrl', 'value'])) {
state.setIn(['data_feedUrl', 'error'], t('rssFeedUrlMustBeGiven'));
}
}
for (const lstUid of state.getIn(['lists', 'value'])) {
const prefix = 'lists_' + lstUid + '_';
if (!state.getIn([prefix + 'list', 'value'])) {
state.setIn([prefix + 'list', 'error'], t('listMustBeSelected'));
}
if (campaignTypeKey === CampaignType.REGULAR || campaignTypeKey === CampaignType.RSS) {
if (state.getIn([prefix + 'useSegmentation', 'value']) && !state.getIn([prefix + 'segment', 'value'])) {
state.setIn([prefix + 'segment', 'error'], t('segmentMustBeSelected'));
}
}
}
validateNamespace(t, state);
}
static AfterSubmitAction = {
STAY: 0,
LEAVE: 1,
STATUS: 2
}
@withFormErrorHandlers
async submitHandler(afterSubmitAction) {
const t = this.props.t;
let sendMethod, url;
if (this.props.entity) {
sendMethod = FormSendMethod.PUT;
url = `rest/campaigns-settings/${this.props.entity.id}`;
} else {
sendMethod = FormSendMethod.POST;
url = 'rest/campaigns'
}
this.disableForm();
this.setFormStatusMessage('info', t('saving'));
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url);
if (submitResult) {
if (this.props.entity) {
if (afterSubmitAction === CUD.AfterSubmitAction.STATUS) {
this.navigateToWithFlashMessage(`/campaigns/${this.props.entity.id}/status`, 'success', t('campaignUpdated'));
} else if (afterSubmitAction === CUD.AfterSubmitAction.LEAVE) {
this.navigateToWithFlashMessage('/campaigns', 'success', t('campaignUpdated'));
} else {
await this.getFormValuesFromURL(`rest/campaigns-settings/${this.props.entity.id}`);
this.enableForm();
this.setFormStatusMessage('success', t('campaignUpdated'));
}
} else {
const sourceTypeKey = Number.parseInt(this.getFormValue('source'));
if (sourceTypeKey === CampaignSource.CUSTOM || sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE || sourceTypeKey === CampaignSource.CUSTOM_FROM_CAMPAIGN) {
this.navigateToWithFlashMessage(`/campaigns/${submitResult}/content`, 'success', t('campaignCreated'));
} else {
if (afterSubmitAction === CUD.AfterSubmitAction.STATUS) {
this.navigateToWithFlashMessage(`/campaigns/${submitResult}/status`, 'success', t('campaignCreated'));
} else if (afterSubmitAction === CUD.AfterSubmitAction.LEAVE) {
this.navigateToWithFlashMessage(`/campaigns`, 'success', t('campaignCreated'));
} else {
this.navigateToWithFlashMessage(`/campaigns/${submitResult}/edit`, 'success', t('campaignCreated'));
}
}
}
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
}
}
onAddListEntry(orderBeforeIdx) {
this.updateForm(mutState => {
const lsts = mutState.getIn(['lists', 'value']);
let paramId = 0;
const lstUid = this.getNextListEntryId();
const prefix = 'lists_' + lstUid + '_';
mutState.setIn([prefix + 'list', 'value'], null);
mutState.setIn([prefix + 'segment', 'value'], null);
mutState.setIn([prefix + 'useSegmentation', 'value'], false);
mutState.setIn(['lists', 'value'], [...lsts.slice(0, orderBeforeIdx), lstUid, ...lsts.slice(orderBeforeIdx)]);
});
}
onRemoveListEntry(lstUid) {
this.updateForm(mutState => {
const lsts = this.getFormValue('lists');
const prefix = 'lists_' + lstUid + '_';
mutState.delete(prefix + 'list');
mutState.delete(prefix + 'segment');
mutState.delete(prefix + 'useSegmentation');
mutState.setIn(['lists', 'value'], lsts.filter(val => val !== lstUid));
});
}
onListEntryMoveUp(orderIdx) {
const lsts = this.getFormValue('lists');
this.updateFormValue('lists', [...lsts.slice(0, orderIdx - 1), lsts[orderIdx], lsts[orderIdx - 1], ...lsts.slice(orderIdx + 1)]);
}
onListEntryMoveDown(orderIdx) {
const lsts = this.getFormValue('lists');
this.updateFormValue('lists', [...lsts.slice(0, orderIdx), lsts[orderIdx + 1], lsts[orderIdx], ...lsts.slice(orderIdx + 2)]);
}
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
const canDelete = isEdit && this.props.entity.permissions.includes('delete');
let extraSettings = null;
const sourceTypeKey = Number.parseInt(this.getFormValue('source'));
const campaignTypeKey = this.getFormValue('type');
if (campaignTypeKey === CampaignType.RSS) {
extraSettings = <InputField id="data_feedUrl" label={t('rssFeedUrl')}/>
}
const listsColumns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 3, title: t('subscribers') },
{ data: 4, title: t('description') },
{ data: 5, title: t('namespace') }
];
const segmentsColumns = [
{ data: 1, title: t('name') }
];
const lstsEditEntries = [];
const lsts = this.getFormValue('lists') || [];
let lstOrderIdx = 0;
for (const lstUid of lsts) {
const prefix = 'lists_' + lstUid + '_';
const lstOrderIdxClosure = lstOrderIdx;
const selectedList = this.getFormValue(prefix + 'list');
lstsEditEntries.push(
<div key={lstUid} className={campaignsStyles.entry + ' ' + campaignsStyles.entryWithButtons}>
<div className={campaignsStyles.entryButtons}>
{lsts.length > 1 &&
<Button
className="btn-secondary"
icon="trash-alt"
title={t('remove')}
onClickAsync={() => this.onRemoveListEntry(lstUid)}
/>
}
<Button
className="btn-secondary"
icon="plus"
title={t('insertNewEntryBeforeThisOne')}
onClickAsync={() => this.onAddListEntry(lstOrderIdxClosure)}
/>
{lstOrderIdx > 0 &&
<Button
className="btn-secondary"
icon="chevron-up"
title={t('moveUp')}
onClickAsync={() => this.onListEntryMoveUp(lstOrderIdxClosure)}
/>
}
{lstOrderIdx < lsts.length - 1 &&
<Button
className="btn-secondary"
icon="chevron-down"
title={t('moveDown')}
onClickAsync={() => this.onListEntryMoveDown(lstOrderIdxClosure)}
/>
}
</div>
<div className={campaignsStyles.entryContent}>
<TableSelect id={prefix + 'list'} label={t('list')} withHeader dropdown dataUrl='rest/lists-table' columns={listsColumns} selectionLabelIndex={1} />
{(campaignTypeKey === CampaignType.REGULAR || campaignTypeKey === CampaignType.RSS) &&
<div>
<CheckBox id={prefix + 'useSegmentation'} label={t('segment')} text={t('useAParticularSegment')}/>
{selectedList && this.getFormValue(prefix + 'useSegmentation') &&
<TableSelect id={prefix + 'segment'} withHeader dropdown dataUrl={`rest/segments-table/${selectedList}`} columns={segmentsColumns} selectionLabelIndex={1} />
}
</div>
}
</div>
</div>
);
lstOrderIdx += 1;
}
const lstsEdit =
<Fieldset label={t('lists')}>
{lstsEditEntries}
<div key="newEntry" className={campaignsStyles.newEntry}>
<Button
className="btn-secondary"
icon="plus"
label={t('addList')}
onClickAsync={() => this.onAddListEntry(lsts.length)}
/>
</div>
</Fieldset>;
const sendConfigurationsColumns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 3, title: t('description') },
{ data: 4, title: t('type'), render: data => this.mailerTypes[data].typeName },
{ data: 6, title: t('namespace') }
];
let sendSettings;
if (this.getFormValue('send_configuration')) {
if (this.state.sendConfiguration) {
sendSettings = [];
const addOverridable = (id, label) => {
if(this.state.sendConfiguration[id + '_overridable']){
if (this.getFormValue(id + '_overriden')) {
sendSettings.push(<InputField label={label} key={id + '_override'} id={id + '_override'}/>);
} else {
sendSettings.push(
<StaticField key={id + '_original'} label={label} id={id + '_original'} className={styles.formDisabled}>
{this.state.sendConfiguration[id]}
</StaticField>
);
}
sendSettings.push(<CheckBox key={id + '_overriden'} id={id + '_overriden'} text={t('override')} className={campaignsStyles.overrideCheckbox}/>);
}
else{
sendSettings.push(
<StaticField key={id + '_original'} label={label} id={id + '_original'} className={styles.formDisabled}>
{this.state.sendConfiguration[id]}
</StaticField>
);
}
};
addOverridable('from_name', t('fromName'));
addOverridable('from_email', t('fromEmailAddress'));
addOverridable('reply_to', t('replytoEmailAddress'));
} else {
sendSettings = <AlignedRow>{t('loadingSendConfiguration')}</AlignedRow>
}
} else {
sendSettings = null;
}
let sourceEdit = null;
if (isEdit) {
if (!(sourceTypeKey === CampaignSource.CUSTOM || sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE || sourceTypeKey === CampaignSource.CUSTOM_FROM_CAMPAIGN)) {
sourceEdit = <StaticField id="source" className={styles.formDisabled} label={t('contentSource')}>{this.sourceLabels[sourceTypeKey]}</StaticField>;
}
} else {
sourceEdit = <Dropdown id="source" label={t('contentSource')} options={this.sourceOptions}/>
}
let templateEdit = null;
if (sourceTypeKey === CampaignSource.TEMPLATE || (!isEdit && sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE)) {
const templatesColumns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('description') },
{ data: 3, title: t('type'), render: data => this.templateTypes[data].typeName },
{ data: 5, title: t('created'), render: data => moment(data).fromNow() },
{ data: 6, title: t('namespace') },
];
let help = null;
if (sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE) {
help = t('selectingATemplateCreatesACampaign');
}
// The "key" property here and in the TableSelect below is to tell React that these tables are different and should be rendered by different instances. Otherwise, React will use
// only one instance, which fails because Table does not handle updates in "columns" property
templateEdit = <TableSelect key="templateSelect" id="data_sourceTemplate" label={t('template')} withHeader dropdown dataUrl='rest/templates-table' columns={templatesColumns} selectionLabelIndex={1} help={help}/>;
} else if (!isEdit && sourceTypeKey === CampaignSource.CUSTOM_FROM_CAMPAIGN) {
const campaignsColumns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 3, title: t('description') },
{ data: 4, title: t('type'), render: data => this.campaignTypeLabels[data] },
{ data: 5, title: t('created'), render: data => moment(data).fromNow() },
{ data: 6, title: t('namespace') }
];
templateEdit = <TableSelect key="campaignSelect" id="data_sourceCampaign" label={t('campaign')} withHeader dropdown dataUrl='rest/campaigns-with-content-table' columns={campaignsColumns} selectionLabelIndex={1} help={t('contentOfTheSelectedCampaignWillBeCopied')}/>;
} else if (!isEdit && sourceTypeKey === CampaignSource.CUSTOM) {
const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type');
let customTemplateTypeForm = null;
if (customTemplateTypeKey) {
customTemplateTypeForm = getTypeForm(this, customTemplateTypeKey, isEdit);
}
templateEdit = <div>
<Dropdown id="data_sourceCustom_type" label={t('type')} options={this.customTemplateTypeOptions}/>
<Dropdown id="data_sourceCustom_tag_language" label={t('Tag language')} options={this.customTemplateTagLanguageOptions} disabled={isEdit && (!customTemplateTypeKey || this.templateTypes[customTemplateTypeKey].isTagLanguageSelectorDisabledForEdit)}/>
{customTemplateTypeForm}
</div>;
} else if (sourceTypeKey === CampaignSource.URL) {
templateEdit = <InputField id="data_sourceUrl" label={t('renderUrl')} help={t('ifAMessageIsSentThenThisUrlWillBePosTed')}/>
}
return (
<div>
{canDelete &&
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
deleteUrl={`rest/campaigns/${this.props.entity.id}`}
backUrl={`/campaigns/${this.props.entity.id}/edit`}
successUrl="/campaigns"
deletingMsg={t('deletingCampaign')}
deletedMsg={t('campaignDeleted')}/>
}
<Title>{isEdit ? this.editTitles[this.getFormValue('type')] : this.createTitles[this.getFormValue('type')]}</Title>
{isEdit && this.props.entity.status === CampaignStatus.SENDING &&
<div className={`alert alert-info`} role="alert">
{t('formCannotBeEditedBecauseTheCampaignIs')}
</div>
}
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('name')}/>
{isEdit &&
<StaticField id="cid" className={styles.formDisabled} label={t('id')} help={t('thisIsTheCampaignIdDisplayedToThe')}>
{this.getFormValue('cid')}
</StaticField>
}
<TextArea id="description" label={t('description')}/>
{extraSettings}
<NamespaceSelect/>
<hr/>
{lstsEdit}
<hr/>
<Fieldset label={t('sendSettings')}>
<TableSelect id="send_configuration" label={t('sendConfiguration')} withHeader dropdown dataUrl='rest/send-configurations-table' columns={sendConfigurationsColumns} selectionLabelIndex={1} />
{sendSettings}
<InputField label={t('subjectLine')} key="subject" id="subject"/>
<InputField id="unsubscribe_url" label={t('customUnsubscribeUrl')}/>
</Fieldset>
<hr/>
<Fieldset label={t('tracking')}>
<CheckBox id="open_tracking_disabled" text={t('disableOpenedTracking')}/>
<CheckBox id="click_tracking_disabled" text={t('disableClickedTracking')}/>
</Fieldset>
{sourceEdit &&
<>
<hr/>
<Fieldset label={t('template')}>
{sourceEdit}
</Fieldset>
</>
}
{templateEdit}
<ButtonRow>
{!isEdit && (sourceTypeKey === CampaignSource.CUSTOM || sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE || sourceTypeKey === CampaignSource.CUSTOM_FROM_CAMPAIGN) ?
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndEditContent')}/>
:
<>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndLeave')} onClickAsync={async () => await this.submitHandler(CUD.AfterSubmitAction.LEAVE)}/>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndGoToStatus')} onClickAsync={async () => await this.submitHandler(CUD.AfterSubmitAction.STATUS)}/>
</>
}
{canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/campaigns/${this.props.entity.id}/delete`}/> }
</ButtonRow>
</Form>
</div>
);
}
}

View file

@ -1,296 +0,0 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withTranslation} from '../lib/i18n';
import {requiresAuthenticatedUser, Title, withPageHelpers} from '../lib/page'
import {
Button,
ButtonRow,
Dropdown,
filterData,
Form,
FormSendMethod,
StaticField,
withForm,
withFormErrorHandlers
} from '../lib/form';
import {withErrorHandling} from '../lib/error-handling';
import mailtrainConfig from 'mailtrainConfig';
import {getEditForm, getTagLanguages, getTemplateTypes, getTypeForm, ResourceType} from '../templates/helpers';
import axios from '../lib/axios';
import styles from "../lib/styles.scss";
import {getUrl} from "../lib/urls";
import {TestSendModalDialog, TestSendModalDialogMode} from "./TestSendModalDialog";
import {withComponentMixins} from "../lib/decorator-helpers";
import {ContentModalDialog} from "../lib/modals";
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class CustomContent extends Component {
constructor(props) {
super(props);
const t = props.t;
this.templateTypes = getTemplateTypes(props.t, 'data_sourceCustom_', ResourceType.CAMPAIGN);
this.tagLanguages = getTagLanguages(props.t);
this.customTemplateTypeOptions = [];
for (const key of mailtrainConfig.editors) {
this.customTemplateTypeOptions.push({key, label: this.templateTypes[key].typeName});
}
this.customTemplateTagLanguageOptions = [];
for (const key of mailtrainConfig.tagLanguages) {
this.customTemplateTagLanguageOptions.push({key, label: this.tagLanguages[key].name});
}
this.state = {
showMergeTagReference: false,
elementInFullscreen: false,
showTestSendModal: false,
showExportModal: false,
exportModalContentType: null,
exportModalTitle: ''
};
this.initForm({
getPreSubmitUpdater: ::this.getPreSubmitFormValuesUpdater,
onChangeBeforeValidation: {
data_sourceCustom_tag_language: ::this.onTagLanguageChanged
}
});
this.sendModalGetDataHandler = ::this.sendModalGetData;
this.exportModalGetContentHandler = ::this.exportModalGetContent;
// This is needed here because if this is passed as an anonymous function, it will reset the editorNode to null with each render.
// This becomes a problem when Show HTML button is pressed because that one tries to access the editorNode while it is null.
this.editorNodeRefHandler = node => this.editorNode = node;
}
static propTypes = {
entity: PropTypes.object,
setPanelInFullScreen: PropTypes.func
}
onTagLanguageChanged(mutStateData, key, oldTagLanguage, tagLanguage) {
if (tagLanguage) {
const type = mutStateData.getIn(['data_sourceCustom_tag_language', 'value']);
this.tagLanguages[type].afterTagLanguageChange(mutStateData, true);
}
}
getFormValuesMutator(data) {
data.data_sourceCustom_type = data.data.sourceCustom.type;
data.data_sourceCustom_tag_language = data.data.sourceCustom.tag_language;
data.data_sourceCustom_data = data.data.sourceCustom.data;
data.data_sourceCustom_html = data.data.sourceCustom.html;
data.data_sourceCustom_text = data.data.sourceCustom.text;
this.templateTypes[data.data.sourceCustom.type].afterLoad(data);
}
submitFormValuesMutator(data) {
this.templateTypes[data.data_sourceCustom_type].beforeSave(data);
data.data.sourceCustom = {
type: data.data_sourceCustom_type,
tag_language: data.data_sourceCustom_tag_language,
data: data.data_sourceCustom_data,
html: data.data_sourceCustom_html,
text: data.data_sourceCustom_text
};
return filterData(data, ['data']);
}
async getPreSubmitFormValuesUpdater() {
const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type');
const exportedData = await this.templateTypes[customTemplateTypeKey].exportHTMLEditorData(this);
return mutStateData => {
for (const key in exportedData) {
mutStateData.setIn([key, 'value'], exportedData[key]);
}
};
}
componentDidMount() {
this.getFormValuesFromEntity(this.props.entity);
}
localValidateFormValues(state) {
const t = this.props.t;
if (!state.getIn(['data_sourceCustom_tag_language', 'value'])) {
state.setIn(['data_sourceCustom_tag_language', 'error'], t('Tag language must be selected'));
} else {
state.setIn(['data_sourceCustom_tag_language', 'error'], null);
}
const customTemplateTypeKey = state.getIn(['data_sourceCustom_type', 'value']);
if (customTemplateTypeKey) {
this.templateTypes[customTemplateTypeKey].validate(state);
}
}
async save() {
await this.submitHandler(CustomContent.AfterSubmitAction.STAY);
}
static AfterSubmitAction = {
STAY: 0,
LEAVE: 1,
STATUS: 2
}
@withFormErrorHandlers
async submitHandler(afterSubmitAction) {
const t = this.props.t;
const sendMethod = FormSendMethod.PUT;
const url = `rest/campaigns-content/${this.props.entity.id}`;
this.disableForm();
this.setFormStatusMessage('info', t('saving'));
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url);
if (submitResult) {
if (afterSubmitAction === CustomContent.AfterSubmitAction.STATUS) {
this.navigateToWithFlashMessage(`/campaigns/${this.props.entity.id}/status`, 'success', t('campaignUpdated'));
} else if (afterSubmitAction === CustomContent.AfterSubmitAction.LEAVE) {
this.navigateToWithFlashMessage('/campaigns', 'success', t('campaignUpdated'));
} else {
await this.getFormValuesFromURL(`rest/campaigns-content/${this.props.entity.id}`);
this.enableForm();
this.setFormStatusMessage('success', t('campaignUpdated'));
}
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
}
}
async extractPlainText() {
const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type');
const exportedData = await this.templateTypes[customTemplateTypeKey].exportHTMLEditorData(this);
const html = exportedData.data_sourceCustom_html;
if (!html) {
return;
}
if (this.isFormDisabled()) {
return;
}
this.disableForm();
const response = await axios.post(getUrl('rest/html-to-text'), { html });
this.updateFormValue('data_sourceCustom_text', response.data.text);
this.enableForm();
}
async toggleMergeTagReference() {
this.setState({
showMergeTagReference: !this.state.showMergeTagReference
});
}
async setElementInFullscreen(elementInFullscreen) {
this.props.setPanelInFullScreen(elementInFullscreen);
this.setState({
elementInFullscreen
});
}
showTestSendModal() {
this.setState({
showTestSendModal: true
});
}
async sendModalGetData() {
const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type');
const exportedData = await this.templateTypes[customTemplateTypeKey].exportHTMLEditorData(this);
return {
html: exportedData.data_sourceCustom_html,
text: this.getFormValue('data_sourceCustom_text'),
tagLanguage: this.getFormValue('data_sourceCustom_tag_language')
};
}
showExportModal(contentType, title) {
this.setState({
showExportModal: true,
exportModalContentType: contentType,
exportModalTitle: title
});
}
async exportModalGetContent() {
const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type');
return await this.templateTypes[customTemplateTypeKey].exportContent(this, this.state.exportModalContentType);
}
render() {
const t = this.props.t;
// TODO: Toggle HTML preview
const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type');
return (
<div className={this.state.elementInFullscreen ? styles.withElementInFullscreen : ''}>
<TestSendModalDialog
mode={TestSendModalDialogMode.CAMPAIGN_CONTENT}
visible={this.state.showTestSendModal}
onHide={() => this.setState({showTestSendModal: false})}
getDataAsync={this.sendModalGetDataHandler}
campaign={this.props.entity}
/>
<ContentModalDialog
title={this.state.exportModalTitle}
visible={this.state.showExportModal}
onHide={() => this.setState({showExportModal: false})}
getContentAsync={this.exportModalGetContentHandler}
/>
<Title>{t('editCustomContent')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<StaticField id="data_sourceCustom_type" className={styles.formDisabled} label={t('customTemplateEditor')}>
{customTemplateTypeKey && this.templateTypes[customTemplateTypeKey].typeName}
</StaticField>
<Dropdown id="data_sourceCustom_tag_language" label={t('Tag language')} options={this.customTemplateTagLanguageOptions} disabled={!customTemplateTypeKey || this.templateTypes[customTemplateTypeKey].isTagLanguageSelectorDisabledForEdit}/>
{customTemplateTypeKey && getTypeForm(this, customTemplateTypeKey, true)}
{customTemplateTypeKey && getEditForm(this, customTemplateTypeKey, 'data_sourceCustom_')}
<ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndLeave')} onClickAsync={async () => await this.submitHandler(CustomContent.AfterSubmitAction.LEAVE)}/>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndGoToStatus')} onClickAsync={async () => await this.submitHandler(CustomContent.AfterSubmitAction.STATUS)}/>
<Button className="btn-success" icon="at" label={t('Test send')} onClickAsync={async () => this.setState({showTestSendModal: true})}/>
</ButtonRow>
</Form>
</div>
);
}
}

View file

@ -1,159 +0,0 @@
'use strict';
import React, {Component} from 'react';
import {withTranslation} from '../lib/i18n';
import {ButtonDropdown, Icon} from '../lib/bootstrap-components';
import {DropdownLink, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../lib/page';
import {withErrorHandling} from '../lib/error-handling';
import {Table} from '../lib/table';
import moment from 'moment';
import {CampaignSource, CampaignStatus, CampaignType} from "../../../shared/campaigns";
import {getCampaignLabels} from "./helpers";
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals";
import {withComponentMixins} from "../lib/decorator-helpers";
import styles from "./styles.scss";
import PropTypes from 'prop-types';
@withComponentMixins([
withTranslation,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class List extends Component {
constructor(props) {
super(props);
const t = props.t;
const { campaignTypeLabels, campaignStatusLabels } = getCampaignLabels(t);
this.campaignTypeLabels = campaignTypeLabels;
this.campaignStatusLabels = campaignStatusLabels;
this.state = {};
tableRestActionDialogInit(this);
}
static propTypes = {
permissions: PropTypes.object
}
render() {
const t = this.props.t;
const permissions = this.props.permissions;
const createPermitted = permissions.createCampaign;
const columns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code>, className: styles.tblCol_id },
{ data: 3, title: t('description') },
{ data: 4, title: t('type'), render: data => this.campaignTypeLabels[data] },
{
data: 5,
title: t('status'),
render: (data, display, rowData) => {
if (data === CampaignStatus.SCHEDULED) {
const scheduled = rowData[6];
if (scheduled && new Date(scheduled) > new Date()) {
return t('sendingScheduled');
} else {
return t('sending');
}
} else {
return this.campaignStatusLabels[data];
}
}
},
{ data: 8, title: t('created'), render: data => moment(data).fromNow() },
{ data: 9, title: t('namespace') },
{
className: styles.tblCol_buttons,
actions: data => {
const actions = [];
const perms = data[10];
const campaignType = data[4];
const status = data[5];
const campaignSource = data[7];
if (perms.includes('viewStats')) {
actions.push({
label: <Icon icon="envelope" title={t('status')}/>,
link: `/campaigns/${data[0]}/status`
});
actions.push({
label: <Icon icon="signal" title={t('statistics')}/>,
link: `/campaigns/${data[0]}/statistics`
});
}
if (perms.includes('edit')) {
actions.push({
label: <Icon icon="edit" title={t('edit')}/>,
link: `/campaigns/${data[0]}/edit`
});
}
if (perms.includes('edit') && (campaignSource === CampaignSource.CUSTOM || campaignSource === CampaignSource.CUSTOM_FROM_TEMPLATE || campaignSource === CampaignSource.CUSTOM_FROM_CAMPAIGN)) {
actions.push({
label: <Icon icon="align-center" title={t('content')}/>,
link: `/campaigns/${data[0]}/content`
});
}
if (perms.includes('viewFiles') && (campaignSource === CampaignSource.CUSTOM || campaignSource === CampaignSource.CUSTOM_FROM_TEMPLATE || campaignSource === CampaignSource.CUSTOM_FROM_CAMPAIGN)) {
actions.push({
label: <Icon icon="hdd" title={t('files')}/>,
link: `/campaigns/${data[0]}/files`
});
}
if (perms.includes('viewAttachments')) {
actions.push({
label: <Icon icon="paperclip" title={t('attachments')}/>,
link: `/campaigns/${data[0]}/attachments`
});
}
if (campaignType === CampaignType.TRIGGERED && perms.includes('viewTriggers')) {
actions.push({
label: <Icon icon="bell" title={t('triggers')}/>,
link: `/campaigns/${data[0]}/triggers`
});
}
if (perms.includes('share')) {
actions.push({
label: <Icon icon="share" title={t('share')}/>,
link: `/campaigns/${data[0]}/share`
});
}
tableAddDeleteButton(actions, this, perms, `rest/campaigns/${data[0]}`, data[1], t('deletingCampaign'), t('campaignDeleted'));
return actions;
}
}
];
return (
<div>
{tableRestActionDialogRender(this)}
<Toolbar>
{createPermitted &&
<ButtonDropdown buttonClassName="btn-primary" menuClassName="dropdown-menu-right" label={t('createCampaign')}>
<DropdownLink to="/campaigns/create-regular">{t('regular')}</DropdownLink>
<DropdownLink to="/campaigns/create-rss">{t('rss')}</DropdownLink>
<DropdownLink to="/campaigns/create-triggered">{t('triggered')}</DropdownLink>
</ButtonDropdown>
}
</Toolbar>
<Title>{t('campaigns')}</Title>
<Table ref={node => this.table = node} withHeader dataUrl="rest/campaigns-table" columns={columns} />
</div>
);
}
}

View file

@ -1,133 +0,0 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withTranslation} from '../lib/i18n';
import {Trans} from 'react-i18next';
import {requiresAuthenticatedUser, Title, withPageHelpers} from '../lib/page';
import {withAsyncErrorHandler, withErrorHandling} from '../lib/error-handling';
import axios from "../lib/axios";
import {getUrl} from "../lib/urls";
import {AlignedRow} from "../lib/form";
import {Icon} from "../lib/bootstrap-components";
import styles from "./styles.scss";
import {Link} from "react-router-dom";
import {withComponentMixins} from "../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class Statistics extends Component {
constructor(props) {
super(props);
const t = props.t;
this.state = {
entity: props.entity,
};
this.refreshTimeoutHandler = ::this.periodicRefreshTask;
this.refreshTimeoutId = 0;
}
static propTypes = {
entity: PropTypes.object
}
@withAsyncErrorHandler
async refreshEntity() {
let resp;
resp = await axios.get(getUrl(`rest/campaigns-stats/${this.props.entity.id}`));
const entity = resp.data;
this.setState({
entity
});
}
async periodicRefreshTask() {
// The periodic task runs all the time, so that we don't have to worry about starting/stopping it as a reaction to the buttons.
await this.refreshEntity();
if (this.refreshTimeoutHandler) { // For some reason the task gets rescheduled if server is restarted while the page is shown. That why we have this check here.
this.refreshTimeoutId = setTimeout(this.refreshTimeoutHandler, 60000);
}
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.periodicRefreshTask();
}
componentWillUnmount() {
clearTimeout(this.refreshTimeoutId);
this.refreshTimeoutHandler = null;
}
render() {
const t = this.props.t;
const entity = this.state.entity;
const total = entity.total;
const renderMetrics = (key, label, showZoomIn = true) => {
const val = entity[key]
return (
<AlignedRow label={label}><span className={styles.statsMetrics}>{val}</span>{showZoomIn && <span className={styles.zoomIn}><Link to={`/campaigns/${entity.id}/statistics/${key}`}><Icon icon="search-plus"/></Link></span>}</AlignedRow>
);
}
const renderMetricsWithProgress = (key, label, progressBarClass, showZoomIn = true) => {
const val = entity[key]
if (!total) {
return renderMetrics(key, label);
}
const rate = Math.round(val / total * 100);
return (
<AlignedRow label={label}>
{showZoomIn && <span className={styles.statsProgressBarZoomIn}><Link to={`/campaigns/${entity.id}/statistics/${key}`}><Icon icon="search-plus"/></Link></span>}
<div className={`progress ${styles.statsProgressBar}`}>
<div
className={`progress-bar progress-bar-${progressBarClass}`}
role="progressbar"
style={{minWidth: '6em', width: rate + '%'}}>
{val}&nbsp;({rate}%)
</div>
</div>
</AlignedRow>
);
}
return (
<div>
<Title>{t('campaignStatistics')}</Title>
{renderMetrics('total', t('total'), false)}
{renderMetrics('delivered', t('delivered'))}
{renderMetrics('blacklisted', t('blacklisted'), false)}
{renderMetricsWithProgress('bounced', t('bounced'), 'info')}
{renderMetricsWithProgress('complained', t('complaints'), 'danger')}
{renderMetricsWithProgress('unsubscribed', t('unsubscribed'), 'warning')}
{!entity.open_tracking_disabled && renderMetricsWithProgress('opened', t('opened'), 'success')}
{!entity.click_tracking_disabled && renderMetricsWithProgress('clicks', t('clicked'), 'success')}
<hr/>
<h3>{t('quickReports')}</h3>
<small className="text-muted"><Trans i18nKey="belowYouCanDownloadPremadeReportsRelated">Below, you can download pre-made reports related to this campaign. Each link generates a CSV file that can be viewed in a spreadsheet editor. Custom reports and reports that cover more than one campaign can be created through <Link to="/reports">Reports</Link> functionality of Mailtrain.</Trans></small>
<ul className="list-unstyled my-3">
<li><a href={getUrl(`quick-rpts/open-and-click-counts/${entity.id}`)}>Open and click counts per currently subscribed subscriber</a></li>
</ul>
</div>
);
}
}

View file

@ -1,50 +0,0 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withTranslation} from '../lib/i18n';
import {requiresAuthenticatedUser, Title, withPageHelpers} from '../lib/page';
import {withErrorHandling} from '../lib/error-handling';
import {Table} from "../lib/table";
import {withComponentMixins} from "../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class StatisticsLinkClicks extends Component {
constructor(props) {
super(props);
const t = props.t;
this.state = {
};
}
static propTypes = {
entity: PropTypes.object,
title: PropTypes.string
}
render() {
const t = this.props.t;
const linksColumns = [
{ data: 0, title: t('url'), render: data => <code>{data}</code> },
{ data: 1, title: t('uniqueVisitors') },
{ data: 2, title: t('totalClicks') }
];
return (
<div>
<Title>{t('campaignLinks')}</Title>
<Table ref={node => this.table = node} withHeader dataUrl={`rest/campaigns-link-clicks-table/${this.props.entity.id}`} columns={linksColumns} />
</div>
);
}
}

View file

@ -1,225 +0,0 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withTranslation} from '../lib/i18n';
import {requiresAuthenticatedUser, Title, withPageHelpers} from '../lib/page';
import {withAsyncErrorHandler, withErrorHandling} from '../lib/error-handling';
import axios from "../lib/axios";
import {getUrl} from "../lib/urls";
import Chart from 'react-google-charts';
import styles from "./styles.scss";
import {Table} from "../lib/table";
import {Link} from "react-router-dom";
import mailtrainConfig from "mailtrainConfig";
import {withComponentMixins} from "../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class StatisticsOpened extends Component {
constructor(props) {
super(props);
const t = props.t;
this.state = {
entity: props.entity,
statisticsOpened: props.statisticsOpened
};
this.refreshTimeoutHandler = ::this.periodicRefreshTask;
this.refreshTimeoutId = 0;
}
static propTypes = {
entity: PropTypes.object,
statisticsOpened: PropTypes.object,
agg: PropTypes.string
}
@withAsyncErrorHandler
async refreshEntity() {
let resp;
resp = await axios.get(getUrl(`rest/campaigns-settings/${this.props.entity.id}`));
const entity = resp.data;
resp = await axios.get(getUrl(`rest/campaign-statistics/${this.props.entity.id}/opened`));
const statisticsOpened = resp.data;
this.setState({
entity,
statisticsOpened
});
}
async periodicRefreshTask() {
// The periodic task runs all the time, so that we don't have to worry about starting/stopping it as a reaction to the buttons.
await this.refreshEntity();
if (this.refreshTimeoutHandler) { // For some reason the task gets rescheduled if server is restarted while the page is shown. That why we have this check here.
this.refreshTimeoutId = setTimeout(this.refreshTimeoutHandler, 60000);
}
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.periodicRefreshTask();
}
componentWillUnmount() {
clearTimeout(this.refreshTimeoutId);
this.refreshTimeoutHandler = null;
}
render() {
const t = this.props.t;
const entity = this.state.entity;
const agg = this.props.agg;
const stats = this.state.statisticsOpened;
const subscribersColumns = [
{ data: 0, title: t('email') },
{ data: 1, title: t('subscriptionId'), render: data => <code>{data}</code> },
{ data: 2, title: t('listId'), render: data => <code>{data}</code> },
{ data: 3, title: t('list') },
{ data: 4, title: t('listNamespace') },
{ data: 5, title: t('opensCount') }
];
const renderNavPill = (key, label) => (
<li role="presentation" className={agg === key ? 'active' : ''}>
<Link to={`/campaigns/${entity.id}/statistics/opened/${key}`}>{label}</Link>
</li>
);
const navPills = (
<ul className={`nav nav-pills ${styles.navPills}`}>
{renderNavPill('countries', t('countries'))}
{renderNavPill('devices', t('devices'))}
</ul>
);
let charts = null;
const deviceTypes = {
desktop: t('desktop'),
tv: t('tv'),
tablet: t('tablet'),
phone: t('phone'),
bot: t('bot'),
car: t('car'),
console: t('console')
};
if (agg === 'devices') {
charts = (
<div className={styles.charts}>
{navPills}
<h4 className={styles.chartTitle}>{t('distributionByDeviceType')}</h4>
<Chart
width="100%"
height="380px"
chartType="PieChart"
loader={<div>{t('loadingChart')}</div>}
data={[
[t('deviceType'), t('count')],
...stats.devices.map(entry => [deviceTypes[entry.key] || t('unknown'), entry.count])
]}
options={{
chartArea: {
left: "25%",
top: 15,
width: "100%",
height: 350
},
tooltip: {
showColorCode: true
},
legend: {
position: "right",
alignment: "start",
textStyle: {
fontSize: 14
}
}
}}
/>
</div>
);
} else if (agg === 'countries') {
charts = (
<div className={styles.charts}>
{navPills}
<h4 className={styles.sectionTitle}>{t('distributionByCountry')}</h4>
<div className="row">
<div className={`col-md-6 ${styles.chart}`}>
<Chart
width="100%"
height="380px"
chartType="PieChart"
loader={<div>{t('loadingChart')}</div>}
data={[
[t('country'), t('count')],
...stats.countries.map(entry => [entry.key || t('unknown'), entry.count])
]}
options={{
chartArea: {
left: "25%",
top: 15,
width: "100%",
height: 350
},
tooltip: {
showColorCode: true
},
legend: {
position: "right",
alignment: "start",
textStyle: {
fontSize: 14
}
}
}}
/>
</div>
<div className={`col-md-6 ${styles.chart}`}>
<Chart
width="100%"
height="380px"
chartType="GeoChart"
data={[
['Country', 'Count'],
...stats.countries.map(entry => [entry.key || t('unknown'), entry.count])
]}
mapsApiKey={mailtrainConfig.mapsApiKey}
/>
</div>
</div>
</div>
);
}
return (
<div>
<Title>{t('detailedStatistics')}</Title>
{charts}
<hr/>
<h4 className={styles.sectionTitle}>{t('listOfSubscribersThatOpenedTheCampaign')}</h4>
<Table ref={node => this.table = node} withHeader dataUrl={`rest/campaigns-opens-table/${entity.id}`} columns={subscribersColumns} />
</div>
);
}
}

View file

@ -1,53 +0,0 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withTranslation} from '../lib/i18n';
import {requiresAuthenticatedUser, Title, withPageHelpers} from '../lib/page';
import {withErrorHandling} from '../lib/error-handling';
import {Table} from "../lib/table";
import {withComponentMixins} from "../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class StatisticsSubsList extends Component {
constructor(props) {
super(props);
const t = props.t;
this.state = {
};
}
static propTypes = {
entity: PropTypes.object,
status: PropTypes.number,
title: PropTypes.string
}
render() {
const t = this.props.t;
const subscribersColumns = [
{ data: 0, title: t('email') },
{ data: 1, title: t('subscriptionId'), render: data => <code>{data}</code> },
{ data: 2, title: t('listId'), render: data => <code>{data}</code> },
{ data: 3, title: t('list') },
{ data: 4, title: t('listNamespace') }
];
return (
<div>
<Title>{this.props.title}</Title>
<Table ref={node => this.table = node} withHeader dataUrl={`rest/campaigns-subscribers-by-status-table/${this.props.entity.id}/${this.props.status}`} columns={subscribersColumns} />
</div>
);
}
}

View file

@ -1,609 +0,0 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withTranslation} from '../lib/i18n';
import {LinkButton, requiresAuthenticatedUser, Title, withPageHelpers} from '../lib/page';
import {AlignedRow, ButtonRow, CheckBox, DatePicker, Form, InputField, TableSelect, withForm} from '../lib/form';
import {withAsyncErrorHandler, withErrorHandling} from '../lib/error-handling';
import {getCampaignLabels} from './helpers';
import {Table} from "../lib/table";
import {Button, Icon, ModalDialog} from "../lib/bootstrap-components";
import axios from "../lib/axios";
import {getPublicUrl, getUrl} from "../lib/urls";
import interoperableErrors from '../../../shared/interoperable-errors';
import {CampaignStatus, CampaignType} from "../../../shared/campaigns";
import moment from 'moment-timezone';
import campaignsStyles from "./styles.scss";
import {withComponentMixins} from "../lib/decorator-helpers";
import {TestSendModalDialog, TestSendModalDialogMode} from "./TestSendModalDialog";
import styles from "../lib/styles.scss";
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
class PreviewForTestUserModalDialog extends Component {
constructor(props) {
super(props);
this.initForm({
leaveConfirmation: false
});
}
static propTypes = {
visible: PropTypes.bool.isRequired,
onHide: PropTypes.func.isRequired,
entity: PropTypes.object.isRequired,
}
localValidateFormValues(state) {
const t = this.props.t;
if (!state.getIn(['testUser', 'value'])) {
state.setIn(['testUser', 'error'], t('subscriptionHasToBeSelectedToShowThe'))
} else {
state.setIn(['testUser', 'error'], null);
}
}
componentDidMount() {
this.populateFormValues({
testUser: null,
});
}
async previewAsync() {
if (this.isFormWithoutErrors()) {
const campaignCid = this.props.entity.cid;
const [listCid, subscriptionCid] = this.getFormValue('testUser').split(':');
window.open(getPublicUrl(`archive/${campaignCid}/${listCid}/${subscriptionCid}`, {withLocale: true}), '_blank');
} else {
this.showFormValidation();
}
}
async hideModal() {
this.props.onHide();
}
render() {
const t = this.props.t;
const testUsersColumns = [
{ data: 1, title: t('email') },
{ data: 2, title: t('subscriptionId'), render: data => <code>{data}</code> },
{ data: 3, title: t('listId'), render: data => <code>{data}</code> },
{ data: 4, title: t('list') },
{ data: 5, title: t('listNamespace') }
];
return (
<ModalDialog hidden={!this.props.visible} title={t('Preview Campaign')} onCloseAsync={() => this.hideModal()} buttons={[
{ label: t('preview'), className: 'btn-primary', onClickAsync: ::this.previewAsync },
{ label: t('close'), className: 'btn-danger', onClickAsync: ::this.hideModal }
]}>
<Form stateOwner={this}>
<TableSelect id="testUser" label={t('Preview as')} withHeader dropdown dataUrl={`rest/campaigns-test-users-table/${this.props.entity.id}`} columns={testUsersColumns} selectionLabelIndex={1} />
</Form>
</ModalDialog>
);
}
}
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
class SendControls extends Component {
constructor(props) {
super(props);
this.state = {
showTestSendModal: false,
previewForTestUserVisible: false
};
this.initForm({
leaveConfirmation: false
});
this.timezoneOptions = moment.tz.names().map(x => [x]);
}
static propTypes = {
entity: PropTypes.object.isRequired,
refreshEntity: PropTypes.func.isRequired
}
localValidateFormValues(state) {
const t = this.props.t;
state.setIn(['date', 'error'], null);
state.setIn(['time', 'error'], null);
state.setIn(['timezone', 'error'], null);
if (state.getIn(['sendLater', 'value'])) {
const dateValue = state.getIn(['date', 'value']).trim();
if (!dateValue) {
state.setIn(['date', 'error'], t('dateMustNotBeEmpty'));
} else if (!moment(dateValue, 'YYYY-MM-DD', true).isValid()) {
state.setIn(['date', 'error'], t('dateIsInvalid'));
}
const timeValue = state.getIn(['time', 'value']).trim();
if (!timeValue) {
state.setIn(['time', 'error'], t('timeMustNotBeEmpty'));
} else if (!moment(timeValue, 'HH:mm', true).isValid()) {
state.setIn(['time', 'error'], t('timeIsInvalid'));
}
const timezone = state.getIn(['timezone', 'value']);
if (!timezone) {
state.setIn(['timezone', 'error'], t('Timezone must be selected'));
}
}
}
populateSendLater() {
const entity = this.props.entity;
if (entity.scheduled) {
const timezone = entity.data.timezone || moment.tz.guess();
const date = moment.tz(entity.scheduled, timezone);
this.populateFormValues({
sendLater: true,
date: date.format('YYYY-MM-DD'),
time: date.format('HH:mm'),
timezone
});
} else {
this.populateFormValues({
sendLater: false,
date: '',
time: '',
timezone: moment.tz.guess()
});
}
}
componentDidMount() {
this.populateSendLater();
}
componentDidUpdate(prevProps) {
if (prevProps.entity.scheduled !== this.props.entity.scheduled) {
this.populateSendLater();
}
}
async refreshEntity() {
await this.props.refreshEntity();
}
async postAndMaskStateError(url, data) {
try {
await axios.post(getUrl(url), data);
} catch (err) {
if (err instanceof interoperableErrors.InvalidStateError) {
// Just mask the fact that it's not possible to start anything and refresh instead.
} else {
throw err;
}
}
}
async scheduleAsync() {
if (this.isFormWithoutErrors()) {
const data = this.getFormValues();
const dateTime = moment.tz(data.date + ' ' + data.time, 'YYYY-MM-DD HH:mm', data.timezone);
await this.postAndMaskStateError(`rest/campaign-start-at/${this.props.entity.id}`, {
startAt: dateTime.valueOf(),
timezone: data.timezone
});
} else {
this.showFormValidation();
}
await this.refreshEntity();
}
async startAsync() {
await this.postAndMaskStateError(`rest/campaign-start/${this.props.entity.id}`);
await this.refreshEntity();
}
async stopAsync() {
await this.postAndMaskStateError(`rest/campaign-stop/${this.props.entity.id}`);
await this.refreshEntity();
}
async confirmStart() {
const t = this.props.t;
this.actionDialog(
t('confirmLaunch'),
t('doYouWantToLaunchTheCampaign?'),
async () => {
await this.startAsync();
}
);
}
async confirmSchedule() {
const t = this.props.t;
this.actionDialog(
t('confirmLaunch'),
t('Do you want to schedule the campaign for launch?'),
async () => {
await this.scheduleAsync();
}
);
}
async resetAsync() {
const t = this.props.t;
this.actionDialog(
t('confirmReset'),
t('doYouWantToResetTheCampaign?All'),
async () => {
await this.postAndMaskStateError(`rest/campaign-reset/${this.props.entity.id}`);
await this.refreshEntity();
}
);
}
async enableAsync() {
await this.postAndMaskStateError(`rest/campaign-enable/${this.props.entity.id}`);
await this.refreshEntity();
}
async disableAsync() {
await this.postAndMaskStateError(`rest/campaign-disable/${this.props.entity.id}`);
await this.refreshEntity();
}
actionDialog(title, message, callback) {
this.setState({
modalTitle: title,
modalMessage: message,
modalCallback: callback,
modalVisible: true
});
}
modalAction(isYes) {
if (isYes && this.state.modalCallback) {
this.state.modalCallback();
}
this.setState({
modalTitle: '',
modalMessage: '',
modalCallback: null,
modalVisible: false
});
}
render() {
const t = this.props.t;
const entity = this.props.entity;
const dialogs = (
<>
<TestSendModalDialog
mode={TestSendModalDialogMode.CAMPAIGN_STATUS}
visible={this.state.showTestSendModal}
onHide={() => this.setState({showTestSendModal: false})}
campaign={this.props.entity}
/>
<PreviewForTestUserModalDialog
visible={this.state.previewForTestUserVisible}
onHide={() => this.setState({previewForTestUserVisible: false})}
entity={this.props.entity}
/>
<ModalDialog hidden={!this.state.modalVisible} title={this.state.modalTitle} onCloseAsync={() => this.modalAction(false)} buttons={[
{ label: t('no'), className: 'btn-primary', onClickAsync: () => this.modalAction(false) },
{ label: t('yes'), className: 'btn-danger', onClickAsync: () => this.modalAction(true) }
]}>
{this.state.modalMessage}
</ModalDialog>
</>
);
const testButtons = (
<>
<Button className="btn-success" label={t('Preview')} onClickAsync={async () => this.setState({previewForTestUserVisible: true})}/>
<Button className="btn-success" label={t('Test send')} onClickAsync={async () => this.setState({showTestSendModal: true})}/>
</>
);
if (entity.status === CampaignStatus.IDLE || entity.status === CampaignStatus.PAUSED || (entity.status === CampaignStatus.SCHEDULED && entity.scheduled)) {
const timezoneColumns = [
{ data: 0, title: t('Timezone') }
];
const dateValue = (this.getFormValue('date') || '').trim();
const timeValue = (this.getFormValue('time') || '').trim();
const timezone = this.getFormValue('timezone');
let dateTimeHelp = t('Select date, time and a timezone to display the date and time with offset');
let dateTimeAlert = null;
if (moment(dateValue, 'YYYY-MM-DD', true).isValid() && moment(timeValue, 'HH:mm', true).isValid() && timezone) {
const dateTime = moment.tz(dateValue + ' ' + timeValue, 'YYYY-MM-DD HH:mm', timezone);
dateTimeHelp = dateTime.toString();
if (!moment().isBefore(dateTime)) {
dateTimeAlert = <div className="alert alert-danger" role="alert">{t('Scheduled date/time seems to be in the past. If you schedule the send, campaign will be sent immediately.')}</div>;
}
}
return (
<div>{dialogs}
<AlignedRow label={t('sendStatus')}>
{entity.status === CampaignStatus.SCHEDULED ? t('campaignIsScheduledForDelivery') : t('campaignIsReadyToBeSentOut')}
</AlignedRow>
<Form stateOwner={this}>
<CheckBox id="sendLater" label={t('sendLater')} text={t('scheduleDeliveryAtAParticularDatetime')}/>
{this.getFormValue('sendLater') &&
<div>
<DatePicker id="date" label={t('date')} />
<InputField id="time" label={t('time')} help={t('enter24HourTimeInFormatHhmmEg1348')}/>
<TableSelect id="timezone" label={t('Timezone')} dropdown columns={timezoneColumns} selectionKeyIndex={0} selectionLabelIndex={0} data={this.timezoneOptions}
help={dateTimeHelp}
/>
{dateTimeAlert && <AlignedRow>{dateTimeAlert}</AlignedRow>}
</div>
}
</Form>
<ButtonRow className={campaignsStyles.sendButtonRow}>
{this.getFormValue('sendLater') ?
<Button className="btn-primary" icon="play" label={entity.status === CampaignStatus.SCHEDULED ? t('rescheduleSend') : t('scheduleSend')} onClickAsync={::this.confirmSchedule}/>
:
<Button className="btn-primary" icon="play" label={t('send')} onClickAsync={::this.confirmStart}/>
}
{entity.status === CampaignStatus.SCHEDULED && <Button className="btn-primary" icon="pause" label={t('Pause')} onClickAsync={::this.stopAsync}/>}
{entity.status === CampaignStatus.PAUSED && <Button className="btn-primary" icon="redo" label={t('reset')} onClickAsync={::this.resetAsync}/>}
{entity.status === CampaignStatus.PAUSED && <LinkButton className="btn-secondary" icon="signal" label={t('viewStatistics')} to={`/campaigns/${entity.id}/statistics`}/>}
{testButtons}
</ButtonRow>
</div>
);
} else if (entity.status === CampaignStatus.PAUSING) {
return (
<div>{dialogs}
<AlignedRow label={t('sendStatus')}>
{t('Campaign is being paused. Please wait.')}
</AlignedRow>
<ButtonRow>
<Button className="btn-primary" icon="pause" label={t('Pausing')} disabled={true}/>
<LinkButton className="btn-secondary" icon="signal" label={t('viewStatistics')} to={`/campaigns/${entity.id}/statistics`}/>
{testButtons}
</ButtonRow>
</div>
);
} else if (entity.status === CampaignStatus.SENDING || (entity.status === CampaignStatus.SCHEDULED && !entity.scheduled)) {
return (
<div>{dialogs}
<AlignedRow label={t('sendStatus')}>
{t('campaignIsBeingSentOut')}
</AlignedRow>
<ButtonRow>
<Button className="btn-primary" icon="pause" label={t('Pause')} onClickAsync={::this.stopAsync}/>
<LinkButton className="btn-secondary" icon="signal" label={t('viewStatistics')} to={`/campaigns/${entity.id}/statistics`}/>
{testButtons}
</ButtonRow>
</div>
);
} else if (entity.status === CampaignStatus.FINISHED) {
return (
<div>{dialogs}
<AlignedRow label={t('sendStatus')}>
{t('allMessagesSent!HitContinueIfYouYouWant')}
</AlignedRow>
<ButtonRow>
<Button className="btn-primary" icon="play" label={t('continue')} onClickAsync={::this.confirmStart}/>
<Button className="btn-primary" icon="redo" label={t('reset')} onClickAsync={::this.resetAsync}/>
<LinkButton className="btn-secondary" icon="signal" label={t('viewStatistics')} to={`/campaigns/${entity.id}/statistics`}/>
{testButtons}
</ButtonRow>
</div>
);
} else if (entity.status === CampaignStatus.INACTIVE) {
return (
<div>{dialogs}
<AlignedRow label={t('sendStatus')}>
{t('yourCampaignIsCurrentlyDisabledClick')}
</AlignedRow>
<ButtonRow>
<Button className="btn-primary" icon="play" label={t('enable')} onClickAsync={::this.enableAsync}/>
{testButtons}
</ButtonRow>
</div>
);
} else if (entity.status === CampaignStatus.ACTIVE) {
return (
<div>{dialogs}
<AlignedRow label={t('sendStatus')}>
{t('yourCampaignIsEnabledAndSendingMessages')}
</AlignedRow>
<ButtonRow>
<Button className="btn-primary" icon="stop" label={t('disable')} onClickAsync={::this.disableAsync}/>
{testButtons}
</ButtonRow>
</div>
);
} else {
return null;
}
}
}
@withComponentMixins([
withTranslation,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class Status extends Component {
constructor(props) {
super(props);
const t = props.t;
this.state = {
entity: props.entity,
sendConfiguration: null
};
const { campaignTypeLabels, campaignStatusLabels } = getCampaignLabels(t);
this.campaignTypeLabels = campaignTypeLabels;
this.campaignStatusLabels = campaignStatusLabels;
this.refreshTimeoutHandler = ::this.periodicRefreshTask;
this.refreshTimeoutId = 0;
}
static propTypes = {
entity: PropTypes.object
}
@withAsyncErrorHandler
async refreshEntity() {
let resp;
resp = await axios.get(getUrl(`rest/campaigns-stats/${this.props.entity.id}`));
const entity = resp.data;
resp = await axios.get(getUrl(`rest/send-configurations-public/${entity.send_configuration}`));
const sendConfiguration = resp.data;
this.setState({
entity,
sendConfiguration
});
}
async periodicRefreshTask() {
// The periodic task runs all the time, so that we don't have to worry about starting/stopping it as a reaction to the buttons.
await this.refreshEntity();
if (this.refreshTimeoutHandler) { // For some reason the task gets rescheduled if server is restarted while the page is shown. That why we have this check here.
this.refreshTimeoutId = setTimeout(this.refreshTimeoutHandler, 10000);
}
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.periodicRefreshTask();
}
componentWillUnmount() {
clearTimeout(this.refreshTimeoutId);
this.refreshTimeoutHandler = null;
}
render() {
const t = this.props.t;
const entity = this.state.entity;
let sendSettings;
if (this.state.sendConfiguration) {
sendSettings = [];
const addOverridable = (id, label) => {
if(this.state.sendConfiguration[id + '_overridable'] == 1 && entity[id + '_override'] != null){
sendSettings.push(<AlignedRow key={id} label={label}>{entity[id + '_override']}</AlignedRow>);
}
else{
sendSettings.push(<AlignedRow key={id} label={label}>{this.state.sendConfiguration[id]}</AlignedRow>);
}
};
addOverridable('from_name', t('fromName'));
addOverridable('from_email', t('fromEmailAddress'));
addOverridable('reply_to', t('replytoEmailAddress'));
sendSettings.push(<AlignedRow key="subject" label={t('subjectLine')}>{entity.subject}</AlignedRow>);
} else {
sendSettings = <AlignedRow>{t('loadingSendConfiguration')}</AlignedRow>
}
const listsColumns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 4, title: t('segment') },
{ data: 3, title: t('listNamespace') }
];
const campaignsChildrenColumns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 5, title: t('status'), render: (data, display, rowData) => this.campaignStatusLabels[data] },
{ data: 8, title: t('created'), render: data => moment(data).fromNow() },
{
actions: data => {
const actions = [];
const perms = data[10];
const campaignType = data[4];
const campaignSource = data[7];
if (perms.includes('viewStats')) {
actions.push({
label: <Icon icon="send" title={t('status')}/>,
link: `/campaigns/${data[0]}/status`
});
}
return actions;
}
}
];
return (
<div>
<Title>{t('campaignStatus')}</Title>
<AlignedRow label={t('name')}>{entity.name}</AlignedRow>
<AlignedRow label={t('delivered')}>{entity.delivered}</AlignedRow>
<AlignedRow label={t('status')}>{this.campaignStatusLabels[entity.status]}</AlignedRow>
{sendSettings}
<AlignedRow label={t('targetListssegments')}>
<Table withHeader dataUrl={`rest/lists-with-segment-by-campaign-table/${this.props.entity.id}`} columns={listsColumns} />
</AlignedRow>
<hr/>
<SendControls entity={entity} refreshEntity={::this.refreshEntity}/>
{entity.type === CampaignType.RSS &&
<div>
<hr/>
<h3>RSS Entries</h3>
<p>{t('ifANewEntryIsFoundFromCampaignFeedANew')}</p>
<Table withHeader dataUrl={`rest/campaigns-children/${this.props.entity.id}`} columns={campaignsChildrenColumns} />
</div>
}
</div>
);
}
}

View file

@ -1,348 +0,0 @@
'use strict';
import React, {Component} from 'react';
import {withTranslation} from '../lib/i18n';
import PropTypes from 'prop-types';
import {ModalDialog} from "../lib/bootstrap-components";
import {requiresAuthenticatedUser, withPageHelpers} from "../lib/page";
import {CheckBox, Dropdown, Form, InputField, TableSelect, withForm} from "../lib/form";
import {withErrorHandling} from "../lib/error-handling";
import {getMailerTypes} from "../send-configurations/helpers";
import axios from '../lib/axios';
import {getUrl} from '../lib/urls';
import {withComponentMixins} from "../lib/decorator-helpers";
import {CampaignType} from "../../../shared/campaigns";
const Target = {
CAMPAIGN_ONE: 'campaign_one',
CAMPAIGN_ALL: 'campaign_all',
LIST_ONE: 'list_one',
LIST_ALL: 'list_all'
};
export const TestSendModalDialogMode = {
TEMPLATE: 0,
CAMPAIGN_CONTENT: 1,
CAMPAIGN_STATUS: 2
}
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export class TestSendModalDialog extends Component {
constructor(props) {
super(props);
this.mailerTypes = getMailerTypes(props.t);
this.initForm({
leaveConfirmation: false,
onChangeBeforeValidation: {
list: this.onListChanged
}
});
}
static propTypes = {
visible: PropTypes.bool.isRequired,
mode: PropTypes.number.isRequired,
onHide: PropTypes.func.isRequired,
getDataAsync: PropTypes.func,
campaign: PropTypes.object
}
onListChanged(mutStateData, key, oldValue, newValue) {
mutStateData.setIn(['segment', 'value'], null);
}
componentDidMount() {
const t = this.props.t;
this.populateFormValues({
target: Target.CAMPAIGN_ONE,
testUserSubscriptionCid: null,
testUserListAndSubscriptionCid: null,
subjectPrepend: '',
subjectAppend: t(' [Test]'),
sendConfiguration: null,
listCid: null,
list: null,
segment: null,
useSegmentation: false
});
}
async hideModal() {
this.props.onHide();
}
async performAction() {
const props = this.props;
const t = props.t;
const mode = this.props.mode;
if (this.isFormWithoutErrors()) {
try {
this.hideFormValidation();
this.disableForm();
this.setFormStatusMessage('info', t('sendingTestEmail'));
const data = {};
if (mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.TEMPLATE) {
const contentData = await this.props.getDataAsync();
data.html = contentData.html;
data.text = contentData.text;
data.tagLanguage = contentData.tagLanguage;
}
if (mode === TestSendModalDialogMode.TEMPLATE) {
data.listCid = this.getFormValue('listCid');
data.subscriptionCid = this.getFormValue('testUserSubscriptionCid');
data.sendConfigurationId = this.getFormValue('sendConfiguration');
} else if (mode === TestSendModalDialogMode.CAMPAIGN_STATUS || mode === TestSendModalDialogMode.CAMPAIGN_CONTENT) {
data.campaignId = props.campaign.id;
data.subjectPrepend = this.getFormValue('subjectPrepend');
data.subjectAppend = this.getFormValue('subjectAppend');
const target = this.getFormValue('target');
if (target === Target.CAMPAIGN_ONE) {
const [listCid, subscriptionCid] = this.getFormValue('testUserListAndSubscriptionCid').split(':');
data.listCid = listCid;
data.subscriptionCid = subscriptionCid;
} else if (target === Target.LIST_ALL) {
data.listId = this.getFormValue('list');
data.segmentId = this.getFormValue('useSegmentation') ? this.getFormValue('segment') : null;
} else if (target === Target.LIST_ONE) {
data.listCid = this.getFormValue('listCid');
data.subscriptionCid = this.getFormValue('testUserSubscriptionCid');
}
}
await axios.post(getUrl('rest/campaign-test-send'), data);
this.clearFormStatusMessage();
this.enableForm();
await this.hideModal();
} catch (err) {
throw err;
}
} else {
this.showFormValidation();
}
}
localValidateFormValues(state) {
const t = this.props.t;
const props = this.props;
const target = this.getFormValue('target');
const mode = this.props.mode;
state.setIn(['listCid', 'error'], null);
state.setIn(['sendConfiguration', 'error'], null);
state.setIn(['testUserSubscriptionCid', 'error'], null);
state.setIn(['testUserListAndSubscriptionCid', 'error'], null);
state.setIn(['list', 'error'], null);
state.setIn(['segment', 'error'], null);
if (mode === TestSendModalDialogMode.TEMPLATE) {
if (!state.getIn(['listCid', 'value'])) {
state.setIn(['listCid', 'error'], t('listHasToBeSelected'))
}
if (!state.getIn(['sendConfiguration', 'value'])) {
state.setIn(['sendConfiguration', 'error'], t('sendConfigurationHasToBeSelected'))
}
if (!state.getIn(['testUserSubscriptionCid', 'value'])) {
state.setIn(['testUserSubscriptionCid', 'error'], t('subscriptionHasToBeSelected'))
}
}
if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.CAMPAIGN_ONE) {
if (!state.getIn(['testUserListAndSubscriptionCid', 'value'])) {
state.setIn(['testUserListAndSubscriptionCid', 'error'], t('subscriptionHasToBeSelected'))
}
}
if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.LIST_ONE) {
if (!state.getIn(['listCid', 'value'])) {
state.setIn(['listCid', 'error'], t('listHasToBeSelected'))
}
if (!state.getIn(['testUserSubscriptionCid', 'value'])) {
state.setIn(['testUserSubscriptionCid', 'error'], t('subscriptionHasToBeSelected'))
}
}
if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.LIST_ALL) {
if (!state.getIn(['list', 'value'])) {
state.setIn(['list', 'error'], t('listMustBeSelected'));
}
if (state.getIn(['useSegmentation', 'value']) && !state.getIn(['segment', 'value'])) {
state.setIn(['segment', 'error'], t('segmentMustBeSelected'));
}
}
}
render() {
const t = this.props.t;
const props = this.props;
const content = [];
const target = this.getFormValue('target');
const mode = this.props.mode;
if (mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) {
const targetOpts = [
{key: Target.CAMPAIGN_ONE, label: t('Single test user of the campaign')},
{key: Target.CAMPAIGN_ALL, label: t('All test users of the campaign')},
{key: Target.LIST_ONE, label: t('Single test user from a list')},
{key: Target.LIST_ALL, label: t('All test users from a list/segment')}
];
content.push(
<Dropdown key="target" id="target" format="wide" label={t('Select to where you want to send the test')} options={targetOpts}/>
);
}
if (mode === TestSendModalDialogMode.TEMPLATE) {
const listCid = this.getFormValue('listCid');
const testUsersColumns = [
{ data: 1, title: t('subscriptionId'), render: data => <code>{data}</code> },
{ data: 2, title: t('email') }
];
const listsColumns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 3, title: t('subscribers') },
{ data: 4, title: t('description') },
{ data: 5, title: t('namespace') }
];
const sendConfigurationsColumns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 3, title: t('description') },
{ data: 4, title: t('type'), render: data => this.mailerTypes[data].typeName },
{ data: 6, title: t('namespace') }
];
content.push(
<TableSelect key="sendConfiguration" id="sendConfiguration" format="wide" label={t('sendConfiguration')} withHeader dropdown dataUrl='rest/send-configurations-with-send-permission-table' columns={sendConfigurationsColumns} selectionLabelIndex={1} />
);
content.push(
<TableSelect key="listCid" id="listCid" format="wide" label={t('list')} withHeader dropdown dataUrl={`rest/lists-table`} columns={listsColumns} selectionKeyIndex={2} selectionLabelIndex={1} />
);
if (listCid) {
content.push(
<TableSelect key="testUserSubscriptionCid" id="testUserSubscriptionCid" format="wide" label={t('subscription')} withHeader dropdown dataUrl={`rest/subscriptions-test-user-table/${listCid}`} columns={testUsersColumns} selectionKeyIndex={1} selectionLabelIndex={2} />
);
}
}
if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.CAMPAIGN_ONE) {
const testUsersColumns = [
{data: 1, title: t('email')},
{data: 2, title: t('subscriptionId'), render: data => <code>{data}</code>},
{data: 3, title: t('listId'), render: data => <code>{data}</code>},
{data: 4, title: t('list')},
{data: 5, title: t('listNamespace')}
];
content.push(
<TableSelect key="testUserListAndSubscriptionCid" id="testUserListAndSubscriptionCid" format="wide" label={t('subscription')} withHeader dropdown dataUrl={`rest/campaigns-test-users-table/${this.props.campaign.id}`} columns={testUsersColumns} selectionLabelIndex={1} />
);
}
if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.LIST_ONE) {
const listCid = this.getFormValue('listCid');
const listsColumns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 3, title: t('subscribers') },
{ data: 4, title: t('description') },
{ data: 5, title: t('namespace') }
];
const testUsersColumns = [
{ data: 1, title: t('subscriptionId'), render: data => <code>{data}</code> },
{ data: 2, title: t('email') }
];
content.push(
<TableSelect key="listCid" id="listCid" format="wide" label={t('list')} withHeader dropdown dataUrl={`rest/lists-table`} columns={listsColumns} selectionKeyIndex={2} selectionLabelIndex={1} />
);
if (listCid) {
content.push(
<TableSelect key="testUserSubscriptionCid" id="testUserSubscriptionCid" format="wide" label={t('subscription')} withHeader dropdown dataUrl={`rest/subscriptions-test-user-table/${listCid}`} columns={testUsersColumns} selectionKeyIndex={1} selectionLabelIndex={2} />
);
}
}
if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.LIST_ALL) {
const listsColumns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 3, title: t('subscribers') },
{ data: 4, title: t('description') },
{ data: 5, title: t('namespace') }
];
const segmentsColumns = [
{ data: 1, title: t('name') }
];
content.push(
<TableSelect key="list" id="list" format="wide" label={t('list')} withHeader dropdown dataUrl='rest/lists-table' columns={listsColumns} selectionLabelIndex={1} />
);
const selectedList = this.getFormValue('list');
content.push(
<div key="segment">
<CheckBox id="useSegmentation" format="wide" text={t('useAParticularSegment')}/>
{selectedList && this.getFormValue('useSegmentation') &&
<TableSelect id="segment" format="wide" withHeader dropdown dataUrl={`rest/segments-table/${selectedList}`} columns={segmentsColumns} selectionLabelIndex={1} />
}
</div>
);
}
if (mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) {
content.push(
<InputField key="subjectPrepend" id="subjectPrepend" format="wide" label={t('Prepend to subject')}/>
);
content.push(
<InputField key="subjectAppend" id="subjectAppend" format="wide" label={t('Append to subject')}/>
);
}
return (
<ModalDialog hidden={!this.props.visible} title={t('sendTestEmail')} onCloseAsync={() => this.hideModal()} buttons={[
{ label: t('send'), className: 'btn-primary', onClickAsync: ::this.performAction },
{ label: t('close'), className: 'btn-danger', onClickAsync: ::this.hideModal }
]}>
<Form stateOwner={this} format="wide">
{content}
</Form>
</ModalDialog>
);
}
}

View file

@ -1,32 +0,0 @@
'use strict';
import {CampaignStatus, CampaignType} from "../../../shared/campaigns";
export function getCampaignLabels(t) {
const campaignTypeLabels = {
[CampaignType.REGULAR]: t('regular'),
[CampaignType.TRIGGERED]: t('triggered'),
[CampaignType.RSS]: t('rss')
};
const campaignStatusLabels = {
[CampaignStatus.IDLE]: t('idle'),
[CampaignStatus.SCHEDULED]: t('scheduled'),
[CampaignStatus.PAUSED]: t('paused'),
[CampaignStatus.FINISHED]: t('finished'),
[CampaignStatus.PAUSED]: t('paused'),
[CampaignStatus.INACTIVE]: t('inactive'),
[CampaignStatus.ACTIVE]: t('active'),
[CampaignStatus.SENDING]: t('sending'),
[CampaignStatus.PAUSING]: t('Pausing')
};
return {
campaignStatusLabels,
campaignTypeLabels
};
}

View file

@ -1,181 +0,0 @@
'use strict';
import React from 'react';
import Status from './Status';
import Statistics from './Statistics';
import CampaignsCUD from './CUD';
import Content from './Content';
import CampaignsList from './List';
import Share from '../shares/Share';
import Files from "../lib/files";
import {CampaignSource, CampaignType} from "../../../shared/campaigns";
import TriggersCUD from './triggers/CUD';
import TriggersList from './triggers/List';
import StatisticsSubsList from "./StatisticsSubsList";
import {SubscriptionStatus} from "../../../shared/lists";
import StatisticsOpened from "./StatisticsOpened";
import StatisticsLinkClicks from "./StatisticsLinkClicks";
import {ellipsizeBreadcrumbLabel} from "../lib/helpers"
import {namespaceCheckPermissions} from "../lib/namespace";
function getMenus(t) {
const aggLabels = {
'countries': t('countries'),
'devices': t('devices')
};
return {
'campaigns': {
title: t('campaigns'),
link: '/campaigns',
checkPermissions: {
createCampaign: {
entityTypeId: 'namespace',
requiredOperations: ['createCampaign']
},
...namespaceCheckPermissions('createCampaign')
},
panelRender: props => <CampaignsList permissions={props.permissions}/>,
children: {
':campaignId([0-9]+)': {
title: resolved => t('campaignName', {name: ellipsizeBreadcrumbLabel(resolved.campaign.name)}),
resolve: {
campaign: params => `rest/campaigns-settings/${params.campaignId}`
},
link: params => `/campaigns/${params.campaignId}/status`,
navs: {
status: {
title: t('status'),
link: params => `/campaigns/${params.campaignId}/status`,
visible: resolved => resolved.campaign.permissions.includes('viewStats'),
panelRender: props => <Status entity={props.resolved.campaign} />
},
statistics: {
title: t('statistics'),
link: params => `/campaigns/${params.campaignId}/statistics`,
visible: resolved => resolved.campaign.permissions.includes('viewStats'),
panelRender: props => <Statistics entity={props.resolved.campaign} />,
children: {
delivered: {
title: t('delivered'),
link: params => `/campaigns/${params.campaignId}/statistics/delivered`,
panelRender: props => <StatisticsSubsList entity={props.resolved.campaign} title={t('deliveredEmails')} status={SubscriptionStatus.SUBSCRIBED} />
},
complained: {
title: t('complained'),
link: params => `/campaigns/${params.campaignId}/statistics/complained`,
panelRender: props => <StatisticsSubsList entity={props.resolved.campaign} title={t('subscribersThatComplained')} status={SubscriptionStatus.COMPLAINED} />
},
bounced: {
title: t('bounced'),
link: params => `/campaigns/${params.campaignId}/statistics/bounced`,
panelRender: props => <StatisticsSubsList entity={props.resolved.campaign} title={t('emailsThatBounced')} status={SubscriptionStatus.BOUNCED} />
},
unsubscribed: {
title: t('unsubscribed'),
link: params => `/campaigns/${params.campaignId}/statistics/unsubscribed`,
panelRender: props => <StatisticsSubsList entity={props.resolved.campaign} title={t('subscribersThatUnsubscribed')} status={SubscriptionStatus.UNSUBSCRIBED} />
},
'opened': {
title: t('opened'),
resolve: {
statisticsOpened: params => `rest/campaign-statistics/${params.campaignId}/opened`
},
link: params => `/campaigns/${params.campaignId}/statistics/opened/countries`,
children: {
':agg(countries|devices)': {
title: (resolved, params) => aggLabels[params.agg],
link: params => `/campaigns/${params.campaignId}/statistics/opened/${params.agg}`,
panelRender: props => <StatisticsOpened entity={props.resolved.campaign} statisticsOpened={props.resolved.statisticsOpened} agg={props.match.params.agg} />
}
}
},
'clicks': {
title: t('clicks'),
link: params => `/campaigns/${params.campaignId}/statistics/clicks`,
panelRender: props => <StatisticsLinkClicks entity={props.resolved.campaign} />
}
}
},
':action(edit|delete)': {
title: t('edit'),
link: params => `/campaigns/${params.campaignId}/edit`,
visible: resolved => resolved.campaign.permissions.includes('edit'),
panelRender: props => <CampaignsCUD action={props.match.params.action} entity={props.resolved.campaign} permissions={props.permissions} />
},
content: {
title: t('content'),
link: params => `/campaigns/${params.campaignId}/content`,
resolve: {
campaignContent: params => `rest/campaigns-content/${params.campaignId}`
},
visible: resolved => resolved.campaign.permissions.includes('edit') && (resolved.campaign.source === CampaignSource.CUSTOM || resolved.campaign.source === CampaignSource.CUSTOM_FROM_TEMPLATE || resolved.campaign.source === CampaignSource.CUSTOM_FROM_CAMPAIGN),
panelRender: props => <Content entity={props.resolved.campaignContent} setPanelInFullScreen={props.setPanelInFullScreen} />
},
files: {
title: t('files'),
link: params => `/campaigns/${params.campaignId}/files`,
visible: resolved => resolved.campaign.permissions.includes('viewFiles') && (resolved.campaign.source === CampaignSource.CUSTOM || resolved.campaign.source === CampaignSource.CUSTOM_FROM_TEMPLATE || resolved.campaign.source === CampaignSource.CUSTOM_FROM_CAMPAIGN),
panelRender: props => <Files title={t('files')} help={t('theseFilesArePubliclyAvailableViaHttpSo')} entity={props.resolved.campaign} entityTypeId="campaign" entitySubTypeId="file" managePermission="manageFiles"/>
},
attachments: {
title: t('attachments'),
link: params => `/campaigns/${params.campaignId}/attachments`,
visible: resolved => resolved.campaign.permissions.includes('viewAttachments'),
panelRender: props => <Files title={t('attachments')} help={t('theseFilesWillBeAttachedToTheCampaign')} entity={props.resolved.campaign} entityTypeId="campaign" entitySubTypeId="attachment" managePermission="manageAttachments"/>
},
triggers: {
title: t('triggers'),
link: params => `/campaigns/${params.campaignId}/triggers/`,
visible: resolved => resolved.campaign.type === CampaignType.TRIGGERED && resolved.campaign.permissions.includes('viewTriggers'),
panelRender: props => <TriggersList campaign={props.resolved.campaign} />,
children: {
':triggerId([0-9]+)': {
title: resolved => t('triggerName', {name: ellipsizeBreadcrumbLabel(resolved.trigger.name)}),
resolve: {
trigger: params => `rest/triggers/${params.campaignId}/${params.triggerId}`,
},
link: params => `/campaigns/${params.campaignId}/triggers/${params.triggerId}/edit`,
navs: {
':action(edit|delete)': {
title: t('edit'),
link: params => `/campaigns/${params.campaignId}/triggers/${params.triggerId}/edit`,
panelRender: props => <TriggersCUD action={props.match.params.action} entity={props.resolved.trigger} campaign={props.resolved.campaign} />
}
}
},
create: {
title: t('create'),
panelRender: props => <TriggersCUD action="create" campaign={props.resolved.campaign} />
}
}
},
share: {
title: t('share'),
link: params => `/campaigns/${params.campaignId}/share`,
visible: resolved => resolved.campaign.permissions.includes('share'),
panelRender: props => <Share title={t('share')} entity={props.resolved.campaign} entityTypeId="campaign" />
}
}
},
'create-regular': {
title: t('createRegularCampaign'),
panelRender: props => <CampaignsCUD action="create" type={CampaignType.REGULAR} permissions={props.permissions} />
},
'create-rss': {
title: t('createRssCampaign'),
panelRender: props => <CampaignsCUD action="create" type={CampaignType.RSS} permissions={props.permissions} />
},
'create-triggered': {
title: t('createTriggeredCampaign'),
panelRender: props => <CampaignsCUD action="create" type={CampaignType.TRIGGERED} permissions={props.permissions} />
}
}
}
};
}
export default {
getMenus
}

View file

@ -1,103 +0,0 @@
.entry {
border-bottom: 1px solid #e5e5e5;
margin-bottom: 15px;
min-height: 91px;
position: relative;
&:last-child {
border-bottom: 0px none;
margin-bottom: 0px;
}
.entryButtons {
position: absolute;
top: -8px;
right: -8px;
width: 19px;
button {
padding: 2px 3px;
font-size: 11px;
display: block;
margin-bottom: 2px;
}
button:last-child {
margin-bottom: 0px;
}
}
&.entryWithButtons > .entryContent {
margin-right: 26px;
}
}
.newEntry {
text-align: right;
margin-bottom: 15px;
}
.sendButtonRow {
margin-top: 10px;
}
.statsMetrics {
width: 10ex;
display: inline-block;
}
.statsProgressBar {
margin-right: 30px;
margin-bottom: 0px;
}
.statsProgressBarZoomIn {
float: right;
width: 30px;
text-align: right;
display: block;
}
.zoomIn {
padding-left: 15px;
}
.navPills {
margin-top: -3px;
margin-bottom: 5px;
float: right;
& > li {
display: inline-block;
float: none;
& > a {
padding: 3px 10px;
}
}
}
.charts {
margin-bottom: 30px;
.chart {
margin-bottom: 30px;
}
}
.sectionTitle {
margin-bottom: 30px;
}
.overrideCheckbox{
margin-top: -8px !important;
}
.tblCol_id {
min-width: 5ex;
max-width: 8ex;
}
.tblCol_buttons {
min-width: 5.8rem;
}

View file

@ -1,256 +0,0 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withTranslation} from '../../lib/i18n';
import {LinkButton, requiresAuthenticatedUser, Title, withPageHelpers} from '../../lib/page';
import {
AlignedRow,
Button,
ButtonRow,
CheckBox,
Dropdown,
filterData,
Form,
FormSendMethod,
InputField,
TableSelect,
TextArea,
withForm
} from '../../lib/form';
import {withErrorHandling} from '../../lib/error-handling';
import {DeleteModalDialog} from "../../lib/modals";
import {getTriggerTypes} from './helpers';
import {Entity, Event} from '../../../../shared/triggers';
import moment from 'moment';
import {getCampaignLabels} from "../helpers";
import {withComponentMixins} from "../../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class CUD extends Component {
constructor(props) {
super(props);
this.state = {};
this.campaignTypeLabels = getCampaignLabels(props.t);
const {entityLabels, eventLabels} = getTriggerTypes(props.t);
this.entityOptions = [
{key: Entity.SUBSCRIPTION, label: entityLabels[Entity.SUBSCRIPTION]},
{key: Entity.CAMPAIGN, label: entityLabels[Entity.CAMPAIGN]}
];
const SubscriptionEvent = Event[Entity.SUBSCRIPTION];
const CampaignEvent = Event[Entity.CAMPAIGN];
this.eventOptions = {
[Entity.SUBSCRIPTION]: [
{key: SubscriptionEvent.CREATED, label: eventLabels[Entity.SUBSCRIPTION][SubscriptionEvent.CREATED]},
{key: SubscriptionEvent.LATEST_OPEN, label: eventLabels[Entity.SUBSCRIPTION][SubscriptionEvent.LATEST_OPEN]},
{key: SubscriptionEvent.LATEST_CLICK, label: eventLabels[Entity.SUBSCRIPTION][SubscriptionEvent.LATEST_CLICK]}
],
[Entity.CAMPAIGN]: [
{key: CampaignEvent.DELIVERED, label: eventLabels[Entity.CAMPAIGN][CampaignEvent.DELIVERED]},
{key: CampaignEvent.OPENED, label: eventLabels[Entity.CAMPAIGN][CampaignEvent.OPENED]},
{key: CampaignEvent.CLICKED, label: eventLabels[Entity.CAMPAIGN][CampaignEvent.CLICKED]},
{key: CampaignEvent.NOT_OPENED, label: eventLabels[Entity.CAMPAIGN][CampaignEvent.NOT_OPENED]},
{key: CampaignEvent.NOT_CLICKED, label: eventLabels[Entity.CAMPAIGN][CampaignEvent.NOT_CLICKED]}
]
};
this.initForm();
}
static propTypes = {
action: PropTypes.string.isRequired,
campaign: PropTypes.object,
entity: PropTypes.object
}
getFormValuesMutator(data) {
data.daysAfter = (Math.round(data.seconds / (3600 * 24))).toString();
if (data.entity === Entity.SUBSCRIPTION) {
data.subscriptionEvent = data.event;
} else {
data.subscriptionEvent = Event[Entity.SUBSCRIPTION].CREATED;
}
if (data.entity === Entity.CAMPAIGN) {
data.campaignEvent = data.event;
} else {
data.campaignEvent = Event[Entity.CAMPAIGN].DELIVERED;
}
}
submitFormValuesMutator(data) {
data.seconds = Number.parseInt(data.daysAfter) * 3600 * 24;
if (data.entity === Entity.SUBSCRIPTION) {
data.event = data.subscriptionEvent;
} else if (data.entity === Entity.CAMPAIGN) {
data.event = data.campaignEvent;
}
return filterData(data, ['name', 'description', 'entity', 'event', 'seconds', 'enabled', 'source_campaign']);
}
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity);
} else {
this.populateFormValues({
name: '',
description: '',
entity: Entity.SUBSCRIPTION,
subscriptionEvent: Event[Entity.SUBSCRIPTION].CREATED,
campaignEvent: Event[Entity.CAMPAIGN].DELIVERED,
daysAfter: '',
enabled: true,
source_campaign: null
});
}
}
localValidateFormValues(state) {
const t = this.props.t;
const entityKey = state.getIn(['entity', 'value']);
if (!state.getIn(['name', 'value'])) {
state.setIn(['name', 'error'], t('nameMustNotBeEmpty'));
} else {
state.setIn(['name', 'error'], null);
}
const daysAfter = state.getIn(['daysAfter', 'value']).trim();
if (daysAfter === '') {
state.setIn(['daysAfter', 'error'], t('valuesMustNotBeEmpty'));
} else if (isNaN(daysAfter) || Number.parseInt(daysAfter) < 0) {
state.setIn(['daysAfter', 'error'], t('valueMustBeANonnegativeNumber'));
} else {
state.setIn(['daysAfter', 'error'], null);
}
if (entityKey === Entity.CAMPAIGN && !state.getIn(['source_campaign', 'value'])) {
state.setIn(['source_campaign', 'error'], t('sourceCampaignMustNotBeEmpty'));
} else {
state.setIn(['source_campaign', 'error'], null);
}
}
async submitHandler(submitAndLeave) {
const t = this.props.t;
let sendMethod, url;
if (this.props.entity) {
sendMethod = FormSendMethod.PUT;
url = `rest/triggers/${this.props.campaign.id}/${this.props.entity.id}`
} else {
sendMethod = FormSendMethod.POST;
url = `rest/triggers/${this.props.campaign.id}`
}
try {
this.disableForm();
this.setFormStatusMessage('info', t('saving'));
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url);
if (submitResult) {
if (this.props.entity) {
if (submitAndLeave) {
this.navigateToWithFlashMessage(`/campaigns/${this.props.campaign.id}/triggers`, 'success', t('triggerUpdated'));
} else {
await this.getFormValuesFromURL(`rest/triggers/${this.props.campaign.id}/${this.props.entity.id}`);
this.enableForm();
this.setFormStatusMessage('success', t('triggerUpdated'));
}
} else {
if (submitAndLeave) {
this.navigateToWithFlashMessage(`/campaigns/${this.props.campaign.id}/triggers`, 'success', t('triggerCreated'));
} else {
this.navigateToWithFlashMessage(`/campaigns/${this.props.campaign.id}/triggers/${submitResult}/edit`, 'success', t('triggerCreated'));
}
}
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
}
} catch (error) {
throw error;
}
}
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
const entityKey = this.getFormValue('entity');
const campaignsColumns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 3, title: t('description') },
{ data: 4, title: t('type'), render: data => this.campaignTypeLabels[data] },
{ data: 5, title: t('created'), render: data => moment(data).fromNow() },
{ data: 6, title: t('namespace') }
];
const campaignLists = this.props.campaign.lists.map(x => x.list).join(';');
return (
<div>
{isEdit &&
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
deleteUrl={`rest/triggers/${this.props.campaign.id}/${this.props.entity.id}`}
backUrl={`/campaigns/${this.props.campaign.id}/triggers/${this.props.entity.id}/edit`}
successUrl={`/campaigns/${this.props.campaign.id}/triggers`}
deletingMsg={t('deletingTrigger')}
deletedMsg={t('triggerDeleted')}/>
}
<Title>{isEdit ? t('editTrigger') : t('createTrigger')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('name')}/>
<TextArea id="description" label={t('description')}/>
<Dropdown id="entity" label={t('entity')} options={this.entityOptions} help={t('selectTheTypeOfTheTriggerRule')}/>
<InputField id="daysAfter" label={t('triggerFires')}/>
<AlignedRow>days after:</AlignedRow>
{entityKey === Entity.SUBSCRIPTION && <Dropdown id="subscriptionEvent" label={t('event')} options={this.eventOptions[Entity.SUBSCRIPTION]} help={t('selectTheEventThatTriggersSendingThe')}/>}
{entityKey === Entity.CAMPAIGN && <Dropdown id="campaignEvent" label={t('event')} options={this.eventOptions[Entity.CAMPAIGN]} help={t('selectTheEventThatTriggersSendingThe')}/>}
{entityKey === Entity.CAMPAIGN &&
<TableSelect id="source_campaign" label={t('campaign')} withHeader dropdown dataUrl={`rest/campaigns-others-by-list-table/${this.props.campaign.id}/${campaignLists}`} columns={campaignsColumns} selectionLabelIndex={1} />
}
<CheckBox id="enabled" text={t('enabled')}/>
<ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndLeave')} onClickAsync={async () => await this.submitHandler(true)}/>
{isEdit && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/campaigns/${this.props.campaign.id}/triggers/${this.props.entity.id}/delete`}/>}
</ButtonRow>
</Form>
</div>
);
}
}

View file

@ -1,85 +0,0 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withTranslation} from '../../lib/i18n';
import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../../lib/page';
import {withErrorHandling} from '../../lib/error-handling';
import {Table} from '../../lib/table';
import {getTriggerTypes} from './helpers';
import {Icon} from "../../lib/bootstrap-components";
import mailtrainConfig from 'mailtrainConfig';
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../../lib/modals";
import {withComponentMixins} from "../../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class List extends Component {
constructor(props) {
super(props);
const {entityLabels, eventLabels} = getTriggerTypes(props.t);
this.entityLabels = entityLabels;
this.eventLabels = eventLabels;
this.state = {};
tableRestActionDialogInit(this);
}
static propTypes = {
campaign: PropTypes.object
}
componentDidMount() {
}
render() {
const t = this.props.t;
const columns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('description') },
{ data: 3, title: t('entity'), render: data => this.entityLabels[data], searchable: false },
{ data: 4, title: t('event'), render: (data, cmd, rowData) => this.eventLabels[rowData[3]][data], searchable: false },
{ data: 5, title: t('daysAfter'), render: data => Math.round(data / (3600 * 24)) },
{ data: 6, title: t('enabled'), render: data => data ? t('yes') : t('no'), searchable: false},
{
actions: data => {
const actions = [];
if (mailtrainConfig.globalPermissions.setupAutomation && this.props.campaign.permissions.includes('manageTriggers')) {
actions.push({
label: <Icon icon="edit" title={t('edit')}/>,
link: `/campaigns/${this.props.campaign.id}/triggers/${data[0]}/edit`
});
}
if (this.props.campaign.permissions.includes('manageTriggers')) {
tableAddDeleteButton(actions, this, null, `rest/triggers/${this.props.campaign.id}/${data[0]}`, data[1], t('deletingTrigger'), t('triggerDeleted'));
}
return actions;
}
}
];
return (
<div>
{tableRestActionDialogRender(this)}
{mailtrainConfig.globalPermissions.setupAutomation && this.props.campaign.permissions.includes('manageTriggers') &&
<Toolbar>
<LinkButton to={`/campaigns/${this.props.campaign.id}/triggers/create`} className="btn-primary" icon="plus" label={t('createTrigger')}/>
</Toolbar>
}
<Title>{t('triggers')}</Title>
<Table ref={node => this.table = node} withHeader dataUrl={`rest/triggers-by-campaign-table/${this.props.campaign.id}`} columns={columns} />
</div>
);
}
}

View file

@ -1,35 +0,0 @@
'use strict';
import {Entity, Event} from '../../../../shared/triggers';
export function getTriggerTypes(t) {
const entityLabels = {
[Entity.SUBSCRIPTION]: t('subscription'),
[Entity.CAMPAIGN]: t('campaign')
};
const SubscriptionEvent = Event[Entity.SUBSCRIPTION];
const CampaignEvent = Event[Entity.CAMPAIGN];
const eventLabels = {
[Entity.SUBSCRIPTION]: {
[SubscriptionEvent.CREATED]: t('created'),
[SubscriptionEvent.LATEST_OPEN]: t('latestOpen'),
[SubscriptionEvent.LATEST_CLICK]: t('latestClick')
},
[Entity.CAMPAIGN]: {
[CampaignEvent.DELIVERED]: t('delivered'),
[CampaignEvent.OPENED]: t('opened'),
[CampaignEvent.CLICKED]: t('clicked'),
[CampaignEvent.NOT_OPENED]: t('notOpened'),
[CampaignEvent.NOT_CLICKED]: t('notClicked')
}
};
return {
entityLabels,
eventLabels
};
}

View file

@ -1,32 +0,0 @@
'use strict';
import csrfToken from 'csrfToken';
import axios from 'axios';
import interoperableErrors from '../../../shared/interoperable-errors';
const axiosInst = axios.create({
headers: {
'X-CSRF-TOKEN': csrfToken
}
});
const axiosWrapper = {
get: (...args) => axiosInst.get(...args).catch(error => { throw (error.response && interoperableErrors.deserialize(error.response.data)) || error }),
put: (...args) => axiosInst.put(...args).catch(error => { throw (error.response && interoperableErrors.deserialize(error.response.data)) || error }),
post: (...args) => axiosInst.post(...args).catch(error => { throw (error.response && interoperableErrors.deserialize(error.response.data)) || error }),
delete: (...args) => axiosInst.delete(...args).catch(error => { throw (error.response && interoperableErrors.deserialize(error.response.data)) || error })
};
const HTTPMethod = {
GET: axiosWrapper.get,
PUT: axiosWrapper.put,
POST: axiosWrapper.post,
DELETE: axiosWrapper.delete
};
axiosWrapper.method = (method, ...args) => method(...args);
export default axiosWrapper;
export {
HTTPMethod
}

View file

@ -1,329 +0,0 @@
'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 (
<div className={`alert alert-${this.props.severity} alert-dismissible`} role="alert">
<button type="button" className="close" aria-label={t('close')} onClick={::this.onClose}><span aria-hidden="true">&times;</span></button>
{this.props.children}
</div>
)
}
}
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 <i className={`${props.family} fa-${props.icon} ${props.className || ''}`} title={props.title}></i>;
} 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 = <Icon icon={props.icon} title={props.iconTitle}/>
}
let iconSpacer;
if (props.icon && props.label) {
iconSpacer = ' ';
}
return (
<button type={type} className={className} onClick={::this.onClick} title={this.props.title} disabled={this.props.disabled}>{icon}{iconSpacer}{props.label}</button>
);
}
}
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 (
<div className="dropdown" className={className}>
<button type="button" className={buttonClassName} data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{props.label}
</button>
<ul className={menuClassName}>
{props.children}
</ul>
</div>
);
}
}
@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 (
<a href={props.href || ''} className={props.className} onClick={::this.onClick}>{props.children}</a>
);
}
}
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 (
<ActionLink className={clsName} onClickAsync={props.onClickAsync}>{props.children}</ActionLink>
);
}
}
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 (
<div className={className}/>
);
}
}
@withComponentMixins([
withTranslation,
withErrorHandling
])
export class ModalDialog extends Component {
constructor(props) {
super(props);
const t = props.t;
}
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.props.buttons[idx];
if (buttonSpec.onClickAsync) {
await buttonSpec.onClickAsync(idx);
}
}
render() {
const props = this.props;
const t = props.t;
let buttons;
if (this.props.buttons) {
buttons = [];
for (let idx = 0; idx < this.props.buttons.length; idx++) {
const buttonSpec = this.props.buttons[idx];
const button = <Button key={idx} label={buttonSpec.label} className={buttonSpec.className} onClickAsync={async () => await this.onButtonClick(idx)} />
buttons.push(button);
}
}
return (
<div
ref={(domElem) => { this.domModal = domElem; }}
className={'modal fade' + (props.className ? ' ' + props.className : '')}
tabIndex="-1" role="dialog" aria-labelledby="myModalLabel">
<div className="modal-dialog" role="document">
<div className="modal-content">
<div className="modal-header">
<h4 className="modal-title">{this.props.title}</h4>
<button type="button" className="close" aria-label={t('close')} onClick={::this.onClose}><span aria-hidden="true">&times;</span></button>
</div>
<div className="modal-body">{this.props.children}</div>
{buttons &&
<div className="modal-footer">
{buttons}
</div>
}
</div>
</div>
</div>
);
}
}

View file

@ -1,171 +0,0 @@
'use strict';
import React from "react";
export function createComponentMixin(opts) {
return {
contexts: opts.contexts || [],
deps: opts.deps || [],
delegateFuns: opts.delegateFuns || [],
decoratorFn: opts.decoratorFn
};
}
export function withComponentMixins(mixins, delegateFuns) {
const mixinsClosure = new Set();
for (const mixin of mixins) {
console.assert(mixin);
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);
}
for (const mixin of mixinsClosure.values()) {
mixinDelegateFuns.push(...mixin.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.displayName = TargetClass.name;
TargetClassWithCtors.prototype = TargetClass.prototype;
for (const attr in TargetClass) {
TargetClassWithCtors[attr] = TargetClass[attr];
}
function incorporateMixins(DecoratedInner) {
for (const mixin of mixinsClosure.values()) {
if (mixin.decoratorFn) {
const res = mixin.decoratorFn(DecoratedInner, TargetClassWithCtors);
if (res.cls) {
DecoratedInner = res.cls;
}
if (res.ctor) {
ctors.push(res.ctor);
}
}
}
return DecoratedInner;
}
if (mixinDelegateFuns.length > 0) {
class ComponentMixinsInner extends React.Component {
render() {
const props = {
...this.props,
ref: this.props._decoratorInnerInstanceRefFn
};
delete props._decoratorInnerInstanceRefFn;
return (
<TargetClassWithCtors {...props}/>
);
}
}
const DecoratedInner = incorporateMixins(ComponentMixinsInner);
class ComponentMixinsOuter extends React.Component {
constructor(props) {
super(props);
this._decoratorInnerInstanceRefFn = node => this._decoratorInnerInstance = node
}
render() {
let innerFn = parentProps => {
const props = {
...parentProps,
_decoratorInnerInstanceRefFn: this._decoratorInnerInstanceRefFn
};
return <DecoratedInner {...props}/>
};
for (const [propName, Context] of contexts.entries()) {
const existingInnerFn = innerFn;
innerFn = parentProps => (
<Context.Consumer>
{
value => existingInnerFn({
...parentProps,
[propName]: value
})
}
</Context.Consumer>
);
}
return innerFn(this.props);
}
}
for (const fun of mixinDelegateFuns) {
ComponentMixinsOuter.prototype[fun] = function (...args) {
return this._decoratorInnerInstance[fun](...args);
}
}
return ComponentMixinsOuter;
} else {
const DecoratedInner = incorporateMixins(TargetClassWithCtors);
function ComponentContextProvider(props) {
let innerFn = props => {
return <DecoratedInner {...props}/>
};
for (const [propName, Context] of contexts.entries()) {
const existingInnerFn = innerFn;
innerFn = parentProps => (
<Context.Consumer>
{
value => existingInnerFn({
...parentProps,
[propName]: value
})
}
</Context.Consumer>
);
}
return innerFn(props);
}
return ComponentContextProvider;
}
};
}

View file

@ -1,78 +0,0 @@
'use strict';
import React from "react";
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({
contexts: [{context: ParentErrorHandlerContext, propName: 'parentErrorHandler'}],
decoratorFn: (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 (
<ParentErrorHandlerContext.Provider value={this}>
{originalRender.apply(this)}
</ParentErrorHandlerContext.Provider>
);
}
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);
}
};
}

View file

@ -1,176 +0,0 @@
'use strict';
import React, {Component} from "react";
import PropTypes from "prop-types";
import {withTranslation} from './i18n';
import {requiresAuthenticatedUser, Title, withPageHelpers} from "./page";
import {withErrorHandling} from "./error-handling";
import {Table} from "./table";
import Dropzone from "react-dropzone";
import {Icon, ModalDialog} from "./bootstrap-components";
import axios from './axios';
import styles from "./styles.scss";
import {getPublicUrl, getUrl} from "./urls";
import {withComponentMixins} from "./decorator-helpers";
@withComponentMixins([
withTranslation,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class Files extends Component {
constructor(props) {
super(props);
this.state = {
fileToDeleteName: null,
fileToDeleteId: null
};
const t = props.t;
}
static propTypes = {
title: PropTypes.string,
help: PropTypes.string,
entity: PropTypes.object.isRequired,
entityTypeId: PropTypes.string.isRequired,
entitySubTypeId: PropTypes.string.isRequired,
managePermission: PropTypes.string.isRequired,
usePublicDownloadUrls: PropTypes.bool
}
static defaultProps = {
usePublicDownloadUrls: true
}
getFilesUploadedMessage(response){
const t = this.props.t;
const details = [];
if (response.data.added) {
details.push(t('countFileAdded', {count: response.data.added}));
}
if (response.data.replaced) {
details.push(t('countFileReplaced', {count: response.data.replaced}));
}
if (response.data.ignored) {
details.push(t('countFileIgnored', {count: response.data.ignored}));
}
const detailsMessage = details ? ' (' + details.join(', ') + ')' : '';
return t('countFileUploaded', {count: response.data.uploaded}) + detailsMessage;
}
onDrop(files){
const t = this.props.t;
if (files.length > 0) {
this.setFlashMessage('info', t('uploadingCountFile', {count: files.length}));
const data = new FormData();
for (const file of files) {
data.append('files[]', file)
}
axios.post(getUrl(`rest/files/${this.props.entityTypeId}/${this.props.entitySubTypeId}/${this.props.entity.id}`), data)
.then(res => {
this.filesTable.refresh();
const message = this.getFilesUploadedMessage(res);
this.setFlashMessage('info', message);
})
.catch(res => this.setFlashMessage('danger', t('fileUploadFailed') + ' ' + res.message));
}
else{
this.setFlashMessage('info', t('noFilesToUpload'));
}
}
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.setFlashMessage('info', t('deletingFile'));
await axios.delete(getUrl(`rest/files/${this.props.entityTypeId}/${this.props.entitySubTypeId}/${fileToDeleteId}`));
this.filesTable.refresh();
this.setFlashMessage('info', t('fileDeleted'));
} catch (err) {
this.filesTable.refresh();
this.setFlashMessage('danger', t('deleteFileFailed') + ' ' + err.message);
}
}
render() {
const t = this.props.t;
const columns = [
{ data: 1, title: t('name') },
{ data: 3, title: t('size') },
{
actions: data => {
const actions = [];
let downloadUrl;
if (this.props.usePublicDownloadUrls) {
downloadUrl = getPublicUrl(`files/${this.props.entityTypeId}/${this.props.entitySubTypeId}/${this.props.entity.id}/${data[2]}`);
} else {
downloadUrl = getUrl(`rest/files/${this.props.entityTypeId}/${this.props.entitySubTypeId}/${data[0]}`);
}
actions.push({
label: <Icon icon="download" title={t('download')}/>,
href: downloadUrl
});
if (this.props.entity.permissions.includes(this.props.managePermission)) {
actions.push({
label: <Icon icon="trash-alt" title={t('delete')}/>,
action: () => this.deleteFile(data[0], data[1])
});
}
return actions;
}
}
];
return (
<div>
<ModalDialog
hidden={this.state.fileToDeleteId === null}
title={t('confirmFileDeletion')}
onCloseAsync={::this.hideDeleteFile}
buttons={[
{ label: t('no'), className: 'btn-primary', onClickAsync: ::this.hideDeleteFile },
{ label: t('yes'), className: 'btn-danger', onClickAsync: ::this.performDeleteFile }
]}>
{t('areYouSureYouWantToDeleteTheFile?', {name: this.state.fileToDeleteName})}
</ModalDialog>
{this.props.title && <Title>{this.props.title}</Title>}
{this.props.help && <p>{this.props.help}</p>}
{
this.props.entity.permissions.includes(this.props.managePermission) &&
<Dropzone onDrop={::this.onDrop}>
{({getRootProps, getInputProps, isDragActive, draggedFiles}) => (
<div {...getRootProps()} className={styles.dropZone + (isDragActive ? ' ' + styles.dropZoneActive : '')}>
<input {...getInputProps()} />
<p>{isDragActive ? t('dropCountFile', {count: draggedFiles.length}) : t('dropFilesHere')}</p>
</div>
)}
</Dropzone>
}
<Table withHeader ref={node => this.filesTable = node} dataUrl={`rest/files-table/${this.props.entityTypeId}/${this.props.entitySubTypeId}/${this.props.entity.id}`} columns={columns} />
</div>
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,8 +0,0 @@
'use strict';
import ellipsize from "ellipsize";
export function ellipsizeBreadcrumbLabel(label) {
return ellipsize(label, 40)
}

View file

@ -1,92 +0,0 @@
'use strict';
import React from 'react';
import {I18nextProvider, withNamespaces} from 'react-i18next';
import i18n from '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";
import lang_pt_BR_common from "../../../locales/pt-BR/common";
const resourcesCommon = {
'en-US': lang_en_US_common,
'es-ES': lang_es_ES_common,
'pt-BR': lang_pt_BR_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 TranslationContext = React.createContext(null);
export const withTranslation = createComponentMixin({
contexts: [{context: TranslationContext, propName: 't'}]
});
const TranslationContextProvider = withNamespaces()(props => {
return (
<TranslationContext.Provider value={props.t}>
{props.children}
</TranslationContext.Provider>
);
});
export function TranslationRoot(props) {
return (
<I18nextProvider i18n={ i18n }>
<TranslationContextProvider>
{props.children}
</TranslationContextProvider>
</I18nextProvider>
);
}
export function tMark(key) {
return key;
}

View file

@ -1,77 +0,0 @@
'use strict';
import {isArray, mergeWith} from 'lodash';
import kebabCase from 'lodash/kebabCase';
import mjml2html, {BodyComponent, components, defaultSkeleton, dependencies, HeadComponent} from "mjml4-in-browser";
export { BodyComponent, HeadComponent };
const initComponents = {...components};
const initDependencies = {...dependencies};
// MJML uses global state. This class wraps MJML state and provides a custom mjml2html function which sets the right state before calling the original mjml2html
export class MJML {
constructor() {
this.components = initComponents;
this.dependencies = initDependencies;
this.headRaw = [];
}
registerDependencies(dep) {
function mergeArrays(objValue, srcValue) {
if (isArray(objValue) && isArray(srcValue)) {
return objValue.concat(srcValue)
}
}
mergeWith(this.dependencies, dep, mergeArrays);
}
registerComponent(Component) {
this.components[kebabCase(Component.name)] = Component;
}
addToHeader(src) {
this.headRaw.push(src);
}
mjml2html(mjml) {
function setObj(obj, src) {
for (const prop of Object.keys(obj)) {
delete obj[prop];
}
Object.assign(obj, src);
}
const origComponents = {...components};
const origDependencies = {...dependencies};
setObj(components, this.components);
setObj(dependencies, this.dependencies);
const res = mjml2html(mjml, {
skeleton: options => {
const headRaw = options.headRaw || [];
options.headRaw = headRaw.concat(this.headRaw);
return defaultSkeleton(options);
}
});
setObj(components, origComponents);
setObj(dependencies, origDependencies);
return res;
}
}
const mjmlInstance = new MJML();
export default function defaultMjml2html(src) {
return mjmlInstance.mjml2html(src);
}

View file

@ -1,399 +0,0 @@
'use strict';
import React, {Component} from 'react';
import axios, {HTTPMethod} from './axios';
import {withTranslation} from './i18n';
import PropTypes from 'prop-types';
import {Icon, ModalDialog} from "./bootstrap-components";
import {getUrl} from "./urls";
import {withPageHelpers} from "./page";
import styles from './styles.scss';
import interoperableErrors from '../../../shared/interoperable-errors';
import {Link} from "react-router-dom";
import {withComponentMixins} from "./decorator-helpers";
import {withAsyncErrorHandler} from "./error-handling";
import ACEEditorRaw from 'react-ace';
@withComponentMixins([
withTranslation,
withPageHelpers
])
export class RestActionModalDialog extends Component {
static propTypes = {
title: PropTypes.string.isRequired,
message: PropTypes.string.isRequired,
stateOwner: PropTypes.object,
visible: PropTypes.bool.isRequired,
actionMethod: PropTypes.func.isRequired,
actionUrl: PropTypes.string.isRequired,
actionData: PropTypes.object,
backUrl: PropTypes.string,
successUrl: PropTypes.string,
onBack: PropTypes.func,
onPerformingAction: PropTypes.func,
onSuccess: PropTypes.func,
actionInProgressMsg: PropTypes.string.isRequired,
actionDoneMsg: PropTypes.string.isRequired,
onErrorAsync: PropTypes.func
}
async hideModal(isBack) {
if (this.props.backUrl) {
this.navigateTo(this.props.backUrl);
} else {
if (isBack) {
this.props.onBack();
} else {
this.props.onPerformingAction();
}
}
}
async performAction() {
const props = this.props;
const t = props.t;
const owner = props.stateOwner;
await this.hideModal(false);
try {
if (!owner) {
this.setFlashMessage('info', props.actionInProgressMsg);
} else {
owner.disableForm();
owner.setFormStatusMessage('info', props.actionInProgressMsg);
}
await axios.method(props.actionMethod, getUrl(props.actionUrl), props.actionData);
if (props.successUrl) {
this.navigateToWithFlashMessage(props.successUrl, 'success', props.actionDoneMsg);
} else {
props.onSuccess();
this.setFlashMessage('success', props.actionDoneMsg);
}
} catch (err) {
if (props.onErrorAsync) {
await props.onErrorAsync(err);
} else {
throw err;
}
}
}
render() {
const t = this.props.t;
return (
<ModalDialog hidden={!this.props.visible} title={this.props.title} onCloseAsync={async () => await this.hideModal(true)} buttons={[
{ label: t('no'), className: 'btn-primary', onClickAsync: async () => await this.hideModal(true) },
{ label: t('yes'), className: 'btn-danger', onClickAsync: ::this.performAction }
]}>
{this.props.message}
</ModalDialog>
);
}
}
const entityTypeLabels = {
'namespace': t => t('namespace'),
'list': t => t('list'),
'customForm': t => t('customForms'),
'campaign': t => t('campaign'),
'template': t => t('template'),
'sendConfiguration': t => t('sendConfiguration'),
'report': t => t('report'),
'reportTemplate': t => t('reportTemplate'),
'mosaicoTemplate': t => t('mosaicoTemplate'),
'user': t => t('User')
};
function _getDependencyErrorMessage(err, t, name) {
return (
<div>
{err.data.dependencies.length > 0 ?
<>
<p>{t('cannoteDeleteNameDueToTheFollowing', {name})}</p>
<ul className={styles.errorsList}>
{err.data.dependencies.map(dep =>
dep.link ?
<li key={dep.link}><Link
to={dep.link}>{entityTypeLabels[dep.entityTypeId](t)}: {dep.name}</Link></li>
: // if no dep.link is present, it means the user has no permission to view the entity, thus only id without the link is shown
<li key={dep.id}>{entityTypeLabels[dep.entityTypeId](t)}: [{dep.id}]</li>
)}
{err.data.andMore && <li>{t('andMore')}</li>}
</ul>
</>
:
<p>{t('Cannot delete {{name}} due to hidden dependencies', {name})}</p>
}
</div>
);
}
@withComponentMixins([
withTranslation,
withPageHelpers
])
export class DeleteModalDialog extends Component {
constructor(props) {
super(props);
const t = props.t;
}
static propTypes = {
visible: PropTypes.bool.isRequired,
stateOwner: PropTypes.object.isRequired,
deleteUrl: PropTypes.string.isRequired,
backUrl: PropTypes.string.isRequired,
successUrl: PropTypes.string.isRequired,
deletingMsg: PropTypes.string.isRequired,
deletedMsg: PropTypes.string.isRequired,
name: PropTypes.string
}
async onErrorAsync(err) {
const t = this.props.t;
if (err instanceof interoperableErrors.DependencyPresentError) {
const owner = this.props.stateOwner;
const name = owner.getFormValue('name');
this.setFlashMessage('danger', _getDependencyErrorMessage(err, t, name));
window.scrollTo(0, 0); // This is to scroll up because the flash message appears on top and it's quite misleading if the delete fails and the message is not in the viewport
owner.enableForm();
owner.clearFormStatusMessage();
} else {
throw err;
}
}
render() {
const t = this.props.t;
const owner = this.props.stateOwner;
const name = this.props.name || owner.getFormValue('name') || '';
return <RestActionModalDialog
title={t('confirmDeletion')}
message={t('areYouSureYouWantToDeleteName?', {name})}
stateOwner={this.props.stateOwner}
visible={this.props.visible}
actionMethod={HTTPMethod.DELETE}
actionUrl={this.props.deleteUrl}
backUrl={this.props.backUrl}
successUrl={this.props.successUrl}
actionInProgressMsg={this.props.deletingMsg}
actionDoneMsg={this.props.deletedMsg}
onErrorAsync={::this.onErrorAsync}
/>
}
}
export function tableRestActionDialogInit(owner) {
owner.tableRestActionDialogData = {};
owner.state.tableRestActionDialogShown = false;
}
function _hide(owner, dontRefresh = false) {
const refreshTables = owner.tableRestActionDialogData.refreshTables;
owner.setState({ tableRestActionDialogShown: false });
if (!dontRefresh) {
owner.tableRestActionDialogData = {};
if (refreshTables) {
refreshTables();
} else {
owner.table.refresh();
}
} else {
// _hide is called twice: (1) at performing action, and at (2) success. Here we keep the refreshTables
// reference till it is really needed in step #2.
owner.tableRestActionDialogData = { refreshTables };
}
}
export function tableAddDeleteButton(actions, owner, perms, deleteUrl, name, deletingMsg, deletedMsg) {
const t = owner.props.t;
async function onErrorAsync(err) {
if (err instanceof interoperableErrors.DependencyPresentError) {
owner.setFlashMessage('danger', _getDependencyErrorMessage(err, t, name));
window.scrollTo(0, 0); // This is to scroll up because the flash message appears on top and it's quite misleading if the delete fails and the message is not in the viewport
_hide(owner);
} else {
throw err;
}
}
if (!perms || perms.includes('delete')) {
if (owner.tableRestActionDialogData.shown) {
actions.push({
label: <Icon className={styles.iconDisabled} icon="trash-alt" title={t('delete')}/>
});
} else {
actions.push({
label: <Icon icon="trash-alt" title={t('delete')}/>,
action: () => {
owner.tableRestActionDialogData = {
shown: true,
title: t('confirmDeletion'),
message:t('areYouSureYouWantToDeleteName?', {name}),
httpMethod: HTTPMethod.DELETE,
actionUrl: deleteUrl,
actionInProgressMsg: deletingMsg,
actionDoneMsg: deletedMsg,
onErrorAsync: onErrorAsync
};
owner.setState({
tableRestActionDialogShown: true
});
owner.table.refresh();
}
});
}
}
}
export function tableAddRestActionButton(actions, owner, action, button, title, message, actionInProgressMsg, actionDoneMsg, onErrorAsync) {
const t = owner.props.t;
if (owner.tableRestActionDialogData.shown) {
actions.push({
label: <Icon className={styles.iconDisabled} icon={button.icon} title={button.label}/>
});
} else {
actions.push({
label: <Icon icon={button.icon} title={button.label}/>,
action: () => {
owner.tableRestActionDialogData = {
shown: true,
title: title,
message: message,
httpMethod: action.method,
actionUrl: action.url,
actionData: action.data,
actionInProgressMsg: actionInProgressMsg,
actionDoneMsg: actionDoneMsg,
onErrorAsync: onErrorAsync,
refreshTables: action.refreshTables
};
owner.setState({
tableRestActionDialogShown: true
});
if (action.refreshTables) {
action.refreshTables();
} else {
owner.table.refresh();
}
}
});
}
}
export function tableRestActionDialogRender(owner) {
const data = owner.tableRestActionDialogData;
return <RestActionModalDialog
title={data.title || ''}
message={data.message || ''}
visible={owner.state.tableRestActionDialogShown}
actionMethod={data.httpMethod || HTTPMethod.POST}
actionUrl={data.actionUrl || ''}
actionData={data.actionData}
onBack={() => _hide(owner)}
onPerformingAction={() => _hide(owner, true)}
onSuccess={() => _hide(owner)}
actionInProgressMsg={data.actionInProgressMsg || ''}
actionDoneMsg={data.actionDoneMsg || ''}
onErrorAsync={data.onErrorAsync}
/>
}
@withComponentMixins([
withTranslation
])
export class ContentModalDialog extends Component {
constructor(props) {
super(props);
const t = props.t;
this.state = {
content: null
};
}
static propTypes = {
visible: PropTypes.bool.isRequired,
title: PropTypes.string.isRequired,
getContentAsync: PropTypes.func.isRequired,
onHide: PropTypes.func.isRequired
}
@withAsyncErrorHandler
async fetchContent() {
const content = await this.props.getContentAsync();
this.setState({
content
});
}
componentDidMount() {
if (this.props.visible) {
// noinspection JSIgnoredPromiseFromCall
this.fetchContent();
}
}
componentDidUpdate(prevProps) {
if (this.props.visible && !prevProps.visible) {
// noinspection JSIgnoredPromiseFromCall
this.fetchContent();
} else if (!this.props.visible && this.state.content !== null) {
this.setState({
content: null
});
}
}
render() {
const t = this.props.t;
return (
<ModalDialog hidden={!this.props.visible} title={this.props.title} onCloseAsync={() => this.props.onHide()}>
{this.props.visible && this.state.content &&
<ACEEditorRaw
mode='xml'
theme="github"
fontSize={12}
width="100%"
height="600px"
showPrintMargin={false}
value={this.state.content}
tabSize={2}
setOptions={{useWorker: false}} // This disables syntax check because it does not always work well (e.g. in case of JS code in report templates)
readOnly={true}
/>
}
</ModalDialog>
);
}
}

View file

@ -1,47 +0,0 @@
'use strict';
import React, {Component} from 'react';
import {withTranslation} from './i18n';
import {TreeTableSelect} from './form';
import {withComponentMixins} from "./decorator-helpers";
@withComponentMixins([
withTranslation
])
export class NamespaceSelect extends Component {
render() {
const t = this.props.t;
return (
<TreeTableSelect id="namespace" label={t('namespace')} dataUrl="rest/namespaces-tree"/>
);
}
}
export function validateNamespace(t, state) {
if (!state.getIn(['namespace', 'value'])) {
state.setIn(['namespace', 'error'], t('namespaceMustBeSelected'));
} else {
state.setIn(['namespace', 'error'], null);
}
}
export function getDefaultNamespace(permissions) {
return permissions.viewUsersNamespace && permissions.createEntityInUsersNamespace ? mailtrainConfig.user.namespace : null;
}
export function namespaceCheckPermissions(createOperation) {
return {
createEntityInUsersNamespace: {
entityTypeId: 'namespace',
entityId: mailtrainConfig.user.namespace,
requiredOperations: [createOperation]
},
viewUsersNamespace: {
entityTypeId: 'namespace',
entityId: mailtrainConfig.user.namespace,
requiredOperations: ['view']
}
};
}

View file

@ -1,448 +0,0 @@
'use strict';
import React, {Component} from "react";
import PropTypes from "prop-types";
import {Redirect, Route, Switch} from "react-router-dom";
import {withAsyncErrorHandler, withErrorHandling} from "./error-handling";
import axios from "../lib/axios";
import {getUrl} from "./urls";
import {createComponentMixin, withComponentMixins} from "./decorator-helpers";
import {withTranslation} from "./i18n";
import shallowEqual from "shallowequal";
import {checkPermissions} from "./permissions";
async function resolve(route, match, prevResolverState) {
const resolved = {};
const permissions = {};
const resolverState = {
resolvedByUrl: {},
permissionsBySig: {}
};
prevResolverState = prevResolverState || {
resolvedByUrl: {},
permissionsBySig: {}
};
async function processResolve() {
const keysToGo = new Set(Object.keys(route.resolve));
while (keysToGo.size > 0) {
const urlsToResolve = [];
const keysToResolve = [];
for (const key of keysToGo) {
const resolveEntry = route.resolve[key];
let allDepsSatisfied = true;
let urlFn = null;
if (typeof resolveEntry === 'function') {
urlFn = resolveEntry;
} else {
if (resolveEntry.dependencies) {
for (const dep of resolveEntry.dependencies) {
if (!(dep in resolved)) {
allDepsSatisfied = false;
break;
}
}
}
urlFn = resolveEntry.url;
}
if (allDepsSatisfied) {
urlsToResolve.push(urlFn(match.params, resolved));
keysToResolve.push(key);
}
}
if (keysToResolve.length === 0) {
throw new Error('Cyclic dependency in "resolved" entries of ' + route.path);
}
const urlsToResolveByRest = [];
const keysToResolveByRest = [];
for (let idx = 0; idx < keysToResolve.length; idx++) {
const key = keysToResolve[idx];
const url = urlsToResolve[idx];
if (url in prevResolverState.resolvedByUrl) {
const entity = prevResolverState.resolvedByUrl[url];
resolved[key] = entity;
resolverState.resolvedByUrl[url] = entity;
} else {
urlsToResolveByRest.push(url);
keysToResolveByRest.push(key);
}
}
if (keysToResolveByRest.length > 0) {
const promises = urlsToResolveByRest.map(url => {
if (url) {
return axios.get(getUrl(url));
} else {
return Promise.resolve({data: null});
}
});
const resolvedArr = await Promise.all(promises);
for (let idx = 0; idx < keysToResolveByRest.length; idx++) {
resolved[keysToResolveByRest[idx]] = resolvedArr[idx].data;
resolverState.resolvedByUrl[urlsToResolveByRest[idx]] = resolvedArr[idx].data;
}
}
for (const key of keysToResolve) {
keysToGo.delete(key);
}
}
}
async function processCheckPermissions() {
const checkPermsRequest = {};
function getSig(checkPermissionsEntry) {
return `${checkPermissionsEntry.entityTypeId}-${checkPermissionsEntry.entityId || ''}-${checkPermissionsEntry.requiredOperations.join(',')}`;
}
for (const key in route.checkPermissions) {
const checkPermissionsEntry = route.checkPermissions[key];
const sig = getSig(checkPermissionsEntry);
if (sig in prevResolverState.permissionsBySig) {
const perm = prevResolverState.permissionsBySig[sig];
permissions[key] = perm;
resolverState.permissionsBySig[sig] = perm;
} else {
checkPermsRequest[key] = checkPermissionsEntry;
}
}
if (Object.keys(checkPermsRequest).length > 0) {
const result = await checkPermissions(checkPermsRequest);
for (const key in checkPermsRequest) {
const checkPermissionsEntry = checkPermsRequest[key];
const perm = result.data[key];
permissions[key] = perm;
resolverState.permissionsBySig[getSig(checkPermissionsEntry)] = perm;
}
}
}
await Promise.all([processResolve(), processCheckPermissions()]);
return { resolved, permissions, resolverState };
}
export function getRoutes(structure, parentRoute) {
function _getRoutes(urlPrefix, resolve, checkPermissions, 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 entryCheckPermissions;
if (entry.checkPermissions) {
entryCheckPermissions = Object.assign({}, checkPermissions, entry.checkPermissions);
} else {
entryCheckPermissions = checkPermissions;
}
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),
exact: !entry.structure && entry.exact !== false,
structure: entry.structure,
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,
checkPermissions: entryCheckPermissions,
parents,
navs: [...navs, ...entryNavs],
// This is primarily for route embedding via "structure"
routeSpec: entry,
urlPrefix,
siblingNavs: navs,
routeKey
};
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, entryCheckPermissions, childrenParents, { [navKey]: nav }, childNavs, route.primaryMenuComponent, route.secondaryMenuComponent));
}
}
if (entry.children) {
routes = routes.concat(_getRoutes(path + '/', entryResolve, entryCheckPermissions, childrenParents, entry.children, entryNavs, route.primaryMenuComponent, route.secondaryMenuComponent));
}
}
return routes;
}
if (parentRoute) {
// This embeds the structure in the parent route.
const routeSpec = parentRoute.routeSpec;
const extStructure = {
...routeSpec,
structure: undefined,
...structure,
navs: { ...(routeSpec.navs || {}), ...(structure.navs || {}) },
children: { ...(routeSpec.children || {}), ...(structure.children || {}) }
};
return _getRoutes(parentRoute.urlPrefix, parentRoute.resolve, parentRoute.checkPermissions, parentRoute.parents, { [parentRoute.routeKey]: extStructure }, parentRoute.siblingNavs, parentRoute.primaryMenuComponent, parentRoute.secondaryMenuComponent);
} else {
return _getRoutes('', {}, {}, [], { "": structure }, [], null, null);
}
}
@withComponentMixins([
withErrorHandling
])
export class Resolver extends Component {
constructor(props) {
super(props);
this.state = {
resolved: null,
permissions: null,
resolverState: null
};
if (Object.keys(props.route.resolve).length === 0 && Object.keys(props.route.checkPermissions).length === 0) {
this.state.resolved = {};
this.state.permissions = {};
}
}
static propTypes = {
route: PropTypes.object.isRequired,
render: PropTypes.func.isRequired,
location: PropTypes.object,
match: PropTypes.object
}
@withAsyncErrorHandler
async resolve(prevMatch) {
const props = this.props;
if (Object.keys(props.route.resolve).length === 0 && Object.keys(props.route.checkPermissions).length === 0) {
this.setState({
resolved: {},
permissions: {},
resolverState: null
});
} else {
const prevResolverState = this.state.resolverState;
if (this.state.resolverState) {
this.setState({
resolved: null,
permissions: null,
resolverState: null
});
}
const {resolved, permissions, resolverState} = await resolve(props.route, props.match, prevResolverState);
if (!this.disregardResolve) { // This is to prevent the warning about setState on discarded component when we immediatelly redirect.
this.setState({
resolved,
permissions,
resolverState
});
}
}
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.resolve();
}
componentDidUpdate(prevProps) {
if (this.props.location.state !== prevProps.location.state || !shallowEqual(this.props.match.params, prevProps.match.params)) {
// noinspection JSIgnoredPromiseFromCall
this.resolve(prevProps.route, prevProps.match);
}
}
componentWillUnmount() {
this.disregardResolve = true; // This is to prevent the warning about setState on discarded component when we immediatelly redirect.
}
render() {
return this.props.render(this.state.resolved, this.state.permissions, this.props);
}
}
class RedirectRoute extends Component {
static propTypes = {
route: PropTypes.object.isRequired
}
render() {
const route = this.props.route;
const params = this.props.match.params;
let link;
if (typeof route.link === 'function') {
link = route.link(params);
} else {
link = route.link;
}
return <Redirect to={link}/>;
}
}
@withComponentMixins([
withTranslation
])
class SubRoute extends Component {
static propTypes = {
route: PropTypes.object.isRequired,
location: PropTypes.object.isRequired,
match: PropTypes.object.isRequired,
flashMessage: PropTypes.object,
panelRouteCtor: PropTypes.func.isRequired,
loadingMessageFn: PropTypes.func.isRequired
}
render() {
const t = this.props.t;
const route = this.props.route;
const params = this.props.match.params;
const render = (resolved, permissions) => {
if (resolved && permissions) {
const subStructure = route.structure(resolved, permissions, params);
const routes = getRoutes(subStructure, route);
const _renderRoute = route => {
const render = props => renderRoute(route, this.props.panelRouteCtor, this.props.loadingMessageFn, this.props.flashMessage, props);
return <Route key={route.path} exact={route.exact} path={route.path} render={render} />
};
return (
<Switch>{routes.map(x => _renderRoute(x))}</Switch>
);
} else {
return this.props.loadingMessageFn();
}
};
return <Resolver route={route} render={render} location={this.props.location} match={this.props.match} />;
}
}
export function renderRoute(route, panelRouteCtor, loadingMessageFn, flashMessage, props) {
if (route.structure) {
return <SubRoute route={route} flashMessage={flashMessage} panelRouteCtor={panelRouteCtor} loadingMessageFn={loadingMessageFn} {...props}/>;
} else if (!route.panelRender && !route.panelComponent && route.link) {
return <RedirectRoute route={route} {...props}/>;
} else {
const PanelRoute = panelRouteCtor;
return <PanelRoute route={route} flashMessage={flashMessage} {...props}/>;
}
}
export const SectionContentContext = React.createContext(null);
export const withPageHelpers = createComponentMixin({
contexts: [{context: SectionContentContext, propName: 'sectionContent'}],
deps: [withErrorHandling],
decoratorFn: (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);
};
InnerClass.prototype.registerBeforeUnloadHandlers = function (handlers) {
return this.props.sectionContent.registerBeforeUnloadHandlers(handlers);
};
InnerClass.prototype.deregisterBeforeUnloadHandlers = function (handlers) {
return this.props.sectionContent.deregisterBeforeUnloadHandlers(handlers);
};
return {};
}
});

View file

@ -1,727 +0,0 @@
'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, Route, Switch} from "react-router-dom";
import {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, renderRoute, Resolver, 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 <li key={entry.path} className="breadcrumb-item active">{title}</li>;
} else if (entry.externalLink) {
let externalLink;
if (typeof entry.externalLink === 'function') {
externalLink = entry.externalLink(params);
} else {
externalLink = entry.externalLink;
}
return <li key={entry.path} className="breadcrumb-item"><a href={externalLink}>{title}</a></li>;
} else if (entry.link) {
let link;
if (typeof entry.link === 'function') {
link = entry.link(params);
} else {
link = entry.link;
}
return <li key={entry.path} className="breadcrumb-item"><Link to={link}>{title}</Link></li>;
} else {
return <li key={entry.path} className="breadcrumb-item">{title}</li>;
}
}
render() {
const route = this.props.route;
const renderedElems = [...route.parents.map(x => this.renderElement(x)), this.renderElement(route, true)];
return <nav aria-label="breadcrumb"><ol className="breadcrumb">{renderedElems}</ol></nav>;
}
}
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 <li key={key} role="presentation" className={liClassName}><Link className={linkClassName} to={link}>{title}</Link></li>;
} else if (entry.externalLink) {
let externalLink;
if (typeof entry.externalLink === 'function') {
externalLink = entry.externalLink(params);
} else {
externalLink = entry.externalLink;
}
return <li key={key} role="presentation" className={liClassName}><a className={linkClassName} href={externalLink}>{title}</a></li>;
} else {
return <li key={key} role="presentation" className={liClassName}>{title}</li>;
}
}
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 <ul className={className}>{renderedElems}</ul>;
} else {
return null;
}
}
}
function getLoadingMessage(t) {
return (
<div className="container-fluid my-3">
{t('loading')}
</div>
);
}
function renderFrameWithContent(t, panelInFullScreen, showSidebar, primaryMenu, secondaryMenu, content) {
if (panelInFullScreen) {
return (
<div key="app" className="app panel-in-fullscreen">
<div key="appBody" className="app-body">
<main key="main" className="main">
{content}
</main>
</div>
</div>
);
} else {
return (
<div key="app" className={"app " + (showSidebar ? 'sidebar-lg-show' : '')}>
<header key="appHeader" className="app-header">
<nav className="navbar navbar-expand-lg navbar-dark bg-dark">
{showSidebar &&
<button className="navbar-toggler sidebar-toggler" data-toggle="sidebar-show" type="button">
<span className="navbar-toggler-icon"/>
</button>
}
<Link className="navbar-brand" to="/"><div><Icon icon="envelope"/> Mailtrain</div></Link>
<button className="navbar-toggler" type="button" data-toggle="collapse" data-target="#mtMainNavbar" aria-controls="navbarColor01" aria-expanded="false" aria-label="Toggle navigation">
<span className="navbar-toggler-icon"/>
</button>
<div className="collapse navbar-collapse" id="mtMainNavbar">
{primaryMenu}
</div>
</nav>
</header>
<div key="appBody" className="app-body">
{showSidebar &&
<div key="sidebar" className="sidebar">
{secondaryMenu}
</div>
}
<main key="main" className="main">
{content}
</main>
</div>
<footer key="appFooter" className="app-footer">
<div className="text-muted">&copy; 2018 <a href="https://mailtrain.org">Mailtrain.org</a>, <a href="mailto:info@mailtrain.org">info@mailtrain.org</a>. <a href="https://github.com/Mailtrain-org/mailtrain">{t('sourceOnGitHub')}</a></div>
</footer>
</div>
);
}
}
@withComponentMixins([
withTranslation
])
class PanelRoute extends Component {
constructor(props) {
super(props);
this.state = {
panelInFullScreen: props.route.panelInFullScreen
};
this.sidebarAnimationNodeListener = evt => {
if (evt.propertyName === 'left') {
this.forceUpdate();
}
};
this.setPanelInFullScreen = panelInFullScreen => this.setState({ panelInFullScreen });
}
static propTypes = {
route: PropTypes.object.isRequired,
location: PropTypes.object.isRequired,
match: PropTypes.object.isRequired,
flashMessage: PropTypes.object
}
registerSidebarAnimationListener() {
if (this.sidebarAnimationNode) {
this.sidebarAnimationNode.addEventListener("transitionend", this.sidebarAnimationNodeListener);
}
}
componentDidMount() {
this.registerSidebarAnimationListener();
}
componentDidUpdate(prevProps) {
this.registerSidebarAnimationListener();
}
render() {
const t = this.props.t;
const route = this.props.route;
const params = this.props.match.params;
const showSidebar = !!route.secondaryMenuComponent;
const panelInFullScreen = this.state.panelInFullScreen;
const render = (resolved, permissions) => {
let primaryMenu = null;
let secondaryMenu = null;
let content = null;
if (resolved && permissions) {
const compProps = {
match: this.props.match,
location: this.props.location,
resolved,
permissions,
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 = (
<div key="panel" className="container-fluid">
{this.props.flashMessage}
{panel}
</div>
);
if (panelInFullScreen) {
content = panelContent;
} else {
content = (
<>
<div key="tertiaryNav" className="mt-breadcrumb-and-tertiary-navbar">
<Breadcrumb route={route} params={params} resolved={resolved}/>
<TertiaryNavBar route={route} params={params} resolved={resolved}/>
</div>
{panelContent}
</>
);
}
} else {
content = getLoadingMessage(t);
}
return renderFrameWithContent(t, panelInFullScreen, showSidebar, primaryMenu, secondaryMenu, content);
};
return <Resolver route={route} render={render} location={this.props.location} match={this.props.match}/>;
}
}
export class BeforeUnloadListeners {
constructor() {
this.listeners = new Set();
}
register(listener) {
this.listeners.add(listener);
}
deregister(listener) {
this.listeners.delete(listener);
}
shouldUnloadBeCancelled() {
for (const lst of this.listeners) {
if (lst.handler()) return true;
}
return false;
}
async shouldUnloadBeCancelledAsync() {
for (const lst of this.listeners) {
if (await lst.handlerAsync()) return true;
}
return false;
}
}
@withRouter
@withComponentMixins([
withTranslation,
withErrorHandling
], ['onNavigationConfirmationDialog'])
export class SectionContent extends Component {
constructor(props) {
super(props);
this.state = {
flashMessageText: ''
};
this.historyUnlisten = props.history.listen((location, action) => {
// noinspection JSIgnoredPromiseFromCall
this.closeFlashMessage();
});
this.beforeUnloadListeners = new BeforeUnloadListeners();
this.beforeUnloadHandler = ::this.onBeforeUnload;
this.historyUnblock = null;
}
static propTypes = {
structure: PropTypes.object.isRequired,
root: PropTypes.string.isRequired
}
onBeforeUnload(event) {
if (this.beforeUnloadListeners.shouldUnloadBeCancelled()) {
event.preventDefault();
event.returnValue = '';
}
}
onNavigationConfirmationDialog(message, callback) {
this.beforeUnloadListeners.shouldUnloadBeCancelledAsync().then(res => {
if (res) {
const allowTransition = window.confirm(message);
callback(allowTransition);
} else {
callback(true);
}
});
}
componentDidMount() {
window.addEventListener('beforeunload', this.beforeUnloadHandler);
this.historyUnblock = this.props.history.block('Changes you made may not be saved. Are you sure you want to leave this page?');
}
componentWillUnmount() {
window.removeEventListener('beforeunload', this.beforeUnloadHandler);
this.historyUnblock();
}
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));
}
}
registerBeforeUnloadHandlers(handlers) {
this.beforeUnloadListeners.register(handlers);
}
deregisterBeforeUnloadHandlers(handlers) {
this.beforeUnloadListeners.deregister(handlers);
}
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) {
const t = this.props.t;
const render = props => {
let flashMessage;
if (this.state.flashMessageText) {
flashMessage = <DismissibleAlert severity={this.state.flashMessageSeverity} onCloseAsync={::this.closeFlashMessage}>{this.state.flashMessageText}</DismissibleAlert>;
}
return renderRoute(
route,
PanelRoute,
() => renderFrameWithContent(t,false, false, null, null, getLoadingMessage(this.props.t)),
flashMessage,
props
);
};
return <Route key={route.path} exact={route.exact} path={route.path} render={render} />
}
render() {
const routes = getRoutes(this.props.structure);
return (
<SectionContentContext.Provider value={this}>
<Switch>{routes.map(x => this.renderRoute(x))}</Switch>
</SectionContentContext.Provider>
);
}
}
@withComponentMixins([
withTranslation
])
export class Section extends Component {
constructor(props) {
super(props);
this.getUserConfirmationHandler = ::this.onGetUserConfirmation;
this.sectionContent = null;
}
static propTypes = {
structure: PropTypes.oneOfType([PropTypes.object, PropTypes.func]).isRequired,
root: PropTypes.string.isRequired
}
onGetUserConfirmation(message, callback) {
this.sectionContent.onNavigationConfirmationDialog(message, callback);
}
render() {
let structure = this.props.structure;
if (typeof structure === 'function') {
structure = structure(this.props.t);
}
return (
<Router basename={getBaseDir()} getUserConfirmation={this.getUserConfirmationHandler}>
<SectionContent wrappedComponentRef={node => this.sectionContent = node} root={this.props.root} structure={structure} />
</Router>
);
}
}
export class Title extends Component {
render() {
return (
<div>
<h2>{this.props.children}</h2>
<hr/>
</div>
);
}
}
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 (
<div className={className}>
{this.props.children}
</div>
);
}
}
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 (
<Link to={props.to}><Button label={props.label} icon={props.icon} className={props.className}/></Link>
);
}
}
export class DropdownLink extends Component {
static propTypes = {
to: PropTypes.string,
className: PropTypes.string
}
render() {
const props = this.props;
const clsName = "dropdown-item" + (props.className ? " " + props.className : "")
return (
<Link to={props.to} className={clsName}>{props.children}</Link>
);
}
}
export class NavLink extends Component {
static propTypes = {
to: PropTypes.string,
icon: PropTypes.string,
iconFamily: PropTypes.string,
className: PropTypes.string
}
render() {
const props = this.props;
const clsName = "nav-item" + (props.className ? " " + props.className : "")
let icon;
if (props.icon) {
icon = <><Icon icon={props.icon} family={props.iconFamily}/>{' '}</>;
}
return (
<li className={clsName}><Link to={props.to} className="nav-link">{icon}{props.children}</Link></li>
);
}
}
export class NavActionLink extends Component {
static propTypes = {
onClickAsync: PropTypes.func,
icon: PropTypes.string,
iconFamily: PropTypes.string,
className: PropTypes.string
}
render() {
const props = this.props;
const clsName = "nav-item" + (props.className ? " " + props.className : "")
let icon;
if (props.icon) {
icon = <><Icon icon={props.icon} family={props.iconFamily}/>{' '}</>;
}
return (
<li className={clsName}><ActionLink onClickAsync={this.props.onClickAsync} className="nav-link">{icon}{props.children}</ActionLink></li>
);
}
}
export class NavDropdown extends Component {
static propTypes = {
label: PropTypes.string,
icon: PropTypes.string,
className: PropTypes.string,
menuClassName: PropTypes.string
}
render() {
const props = this.props;
const className = 'nav-item dropdown' + (props.className ? ' ' + props.className : '');
const menuClassName = 'dropdown-menu' + (props.menuClassName ? ' ' + props.menuClassName : '');
return (
<li className={className}>
{props.icon ?
<a href="#" className="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<Icon icon={props.icon}/>{' '}{props.label}
</a>
:
<a href="#" className="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
{props.label}
</a>
}
<ul className={menuClassName}>
{props.children}
</ul>
</li>
);
}
}
export const requiresAuthenticatedUser = createComponentMixin({
deps: [withPageHelpers],
decoratorFn: (TargetClass, InnerClass) => {
class RequiresAuthenticatedUser extends React.Component {
constructor(props) {
super(props);
props.sectionContent.ensureAuthenticated();
}
render() {
return <TargetClass {...this.props}/>
}
}
return {
cls: RequiresAuthenticatedUser
};
}
});
export function getLanguageChooser(t) {
const languageOptions = [];
for (const lng of mailtrainConfig.enabledLanguages) {
const langDesc = getLang(lng);
const label = langDesc.getLabel(t);
languageOptions.push(
<DropdownActionLink key={lng} onClickAsync={async () => i18n.changeLanguage(langDesc.longCode)}>{label}</DropdownActionLink>
)
}
const currentLngCode = getLang(i18n.language).getShortLabel(t);
const languageChooser = (
<NavDropdown menuClassName="dropdown-menu-right" label={currentLngCode}>
{languageOptions}
</NavDropdown>
);
return languageChooser;
}

View file

@ -1,8 +0,0 @@
'use strict';
import {getUrl} from "./urls";
import axios from "./axios";
export async function checkPermissions(request) {
return await axios.post(getUrl('rest/permissions-check'), request);
}

View file

@ -1,5 +0,0 @@
'use strict';
import {getUrl} from "./urls";
__webpack_public_path__ = getUrl('client/');

View file

@ -1,90 +0,0 @@
$navbarHeight: 34px;
$editorNormalHeight: 800px !default;
.editor {
.host {
@if $editorNormalHeight {
height: $editorNormalHeight;
}
}
}
.editorFullscreen {
position: fixed;
top: 0px;
bottom: 0px;
left: 0px;
right: 0px;
z-index: 1000;
background: white;
margin-top: $navbarHeight;
.navbar {
margin-top: -$navbarHeight;
}
.host {
height: 100%;
}
}
.navbar {
background: #f86c6b;
width: 100%;
height: $navbarHeight;
display: flex;
justify-content: space-between;
}
.navbarLeft {
.logo {
display: inline-block;
height: $navbarHeight;
padding: 5px 0 5px 10px;
filter: brightness(0) invert(1);
}
.title {
display: inline-block;
padding: 5px 0 5px 10px;
font-size: 18px;
font-weight: bold;
float: left;
color: white;
height: $navbarHeight;
}
}
.navbarRight {
.btn, .btnDisabled {
display: inline-block;
padding: 0px 15px;
line-height: $navbarHeight;
text-align: center;
font-size: 14px;
font-weight: bold;
font-family: sans-serif;
cursor: pointer;
&, &:not([href]):not([tabindex]) { // This is to override reboot.scss in bootstrap
color: white;
}
}
.btn:hover {
background-color: #c05454;
text-decoration: none;
&, &:not([href]):not([tabindex]) { // This is to override reboot.scss in bootstrap
color: white;
}
}
.btnDisabled {
cursor: default;
&, &:not([href]):not([tabindex]) { // This is to override reboot.scss in bootstrap
color: #621d1d;
}
}
}

View file

@ -1,135 +0,0 @@
'use strict';
import './public-path';
import React, {Component} from 'react';
import ReactDOM from 'react-dom';
import {TranslationRoot, withTranslation} from './i18n';
import {parentRPC, UntrustedContentRoot} from './untrusted';
import PropTypes from "prop-types";
import styles from "./sandboxed-ckeditor.scss";
import {getPublicUrl, getSandboxUrl, getTrustedUrl} from "./urls";
import {base, unbase} from "../../../shared/templates";
import CKEditor from "react-ckeditor-component";
import {initialHeight} from "./sandboxed-ckeditor-shared";
import {withComponentMixins} from "./decorator-helpers";
@withComponentMixins([
withTranslation
])
class CKEditorSandbox extends Component {
constructor(props) {
super(props);
const trustedUrlBase = getTrustedUrl();
const sandboxUrlBase = getSandboxUrl();
const publicUrlBase = getPublicUrl();
const source = this.props.initialSource && base(this.props.initialSource, trustedUrlBase, sandboxUrlBase, publicUrlBase);
this.state = {
source
};
}
static propTypes = {
entityTypeId: PropTypes.string,
entityId: PropTypes.number,
initialSource: PropTypes.string
}
async exportState(method, params) {
const trustedUrlBase = getTrustedUrl();
const sandboxUrlBase = getSandboxUrl();
const publicUrlBase = getPublicUrl();
const preHtml = '<!doctype html><html><head><meta charset="utf-8"><title></title></head><body>';
const postHtml = '</body></html>';
const unbasedSource = unbase(this.state.source, trustedUrlBase, sandboxUrlBase, publicUrlBase, true);
return {
source: unbasedSource,
html: preHtml + unbasedSource + postHtml
};
}
async setHeight(methods, params) {
this.node.editorInstance.resize('100%', params);
}
componentDidMount() {
parentRPC.setMethodHandler('exportState', ::this.exportState);
parentRPC.setMethodHandler('setHeight', ::this.setHeight);
}
render() {
const config = {
toolbarGroups: [
{
name: "document",
groups: ["document", "doctools"]
},
{
name: "clipboard",
groups: ["clipboard", "undo"]
},
{name: "styles"},
{
name: "basicstyles",
groups: ["basicstyles", "cleanup"]
},
{
name: "editing",
groups: ["find", "selection", "spellchecker"]
},
{name: "forms"},
{
name: "paragraph",
groups: ["list",
"indent", "blocks", "align", "bidi"]
},
{name: "links"},
{name: "insert"},
{name: "colors"},
{name: "tools"},
{name: "others"},
{
name: "document-mode",
groups: ["mode"]
}
],
removeButtons: 'Underline,Subscript,Superscript,Maximize',
resize_enabled: false,
height: initialHeight
};
return (
<div className={styles.sandbox}>
<CKEditor ref={node => this.node = node}
content={this.state.source}
events={{
change: evt => this.setState({source: evt.editor.getData()}),
}}
config={config}
/>
</div>
);
}
}
export default function() {
parentRPC.init();
ReactDOM.render(
<TranslationRoot>
<UntrustedContentRoot render={props => <CKEditorSandbox {...props} />} />
</TranslationRoot>,
document.getElementById('root')
);
};

View file

@ -1,3 +0,0 @@
'use strict';
export const initialHeight = 600;

View file

@ -1,112 +0,0 @@
'use strict';
import React, {Component} from 'react';
import {withTranslation} from './i18n';
import PropTypes from "prop-types";
import styles from "./sandboxed-ckeditor.scss";
import {UntrustedContentHost} from './untrusted';
import {Icon} from "./bootstrap-components";
import {getTrustedUrl} from "./urls";
import {initialHeight} from "./sandboxed-ckeditor-shared";
import {withComponentMixins} from "./decorator-helpers";
const navbarHeight = 34; // Sync this with navbarheight in sandboxed-ckeditor.scss
@withComponentMixins([
withTranslation
], ['exportState'])
export class CKEditorHost extends Component {
constructor(props) {
super(props);
this.state = {
fullscreen: false
};
this.onWindowResizeHandler = ::this.onWindowResize;
this.contentNodeRefHandler = node => this.contentNode = node;
}
static propTypes = {
entityTypeId: PropTypes.string,
entity: PropTypes.object,
initialSource: PropTypes.string,
title: PropTypes.string,
onSave: PropTypes.func,
canSave: PropTypes.bool,
onTestSend: PropTypes.func,
onShowExport: PropTypes.func,
onFullscreenAsync: PropTypes.func
}
async toggleFullscreenAsync() {
const fullscreen = !this.state.fullscreen;
this.setState({
fullscreen
});
await this.props.onFullscreenAsync(fullscreen);
let newHeight;
if (fullscreen) {
newHeight = window.innerHeight - navbarHeight;
} else {
newHeight = initialHeight;
}
await this.contentNode.ask('setHeight', newHeight);
}
async exportState() {
return await this.contentNode.ask('exportState');
}
onWindowResize() {
if (this.state.fullscreen) {
const newHeight = window.innerHeight - navbarHeight;
// noinspection JSIgnoredPromiseFromCall
this.contentNode.ask('setHeight', newHeight);
}
}
componentDidMount() {
window.addEventListener('resize', this.onWindowResizeHandler, false);
}
componentWillUnmount() {
window.removeEventListener('resize', this.onWindowResizeHandler, false);
}
render() {
const t = this.props.t;
const editorData = {
entityTypeId: this.props.entityTypeId,
entityId: this.props.entity.id,
initialSource: this.props.initialSource
};
const tokenData = {
entityTypeId: this.props.entityTypeId,
entityId: this.props.entity.id
};
return (
<div className={this.state.fullscreen ? styles.editorFullscreen : styles.editor}>
<div className={styles.navbar}>
<div className={styles.navbarLeft}>
{this.state.fullscreen && <img className={styles.logo} src={getTrustedUrl('static/mailtrain-notext.png')}/>}
<div className={styles.title}>{this.props.title}</div>
</div>
<div className={styles.navbarRight}>
{this.props.canSave ? <a className={styles.btn} onClick={this.props.onSave} title={t('save')}><Icon icon="save"/></a> : <span className={styles.btnDisabled}><Icon icon="save"/></span>}
<a className={styles.btn} onClick={this.props.onTestSend} title={t('sendTestEmail-1')}><Icon icon="at"/></a>
<a className={styles.btn} onClick={() => this.props.onShowExport('html', 'HTML')} title={t('showHtml')}><Icon icon="file-code"/></a>
<a className={styles.btn} onClick={::this.toggleFullscreenAsync} title={t('maximizeEditor')}><Icon icon="window-maximize"/></a>
</div>
</div>
<UntrustedContentHost ref={this.contentNodeRefHandler} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="ckeditor/editor" tokenMethod="ckeditor" tokenParams={editorData}/>
</div>
);
}
}

View file

@ -1,7 +0,0 @@
$editorNormalHeight: false;
@import "sandbox-common";
.sandbox {
height: 100%;
overflow: hidden;
}

View file

@ -1,220 +0,0 @@
'use strict';
import './public-path';
import React, {Component} from 'react';
import ReactDOM from 'react-dom';
import {TranslationRoot, withTranslation} from './i18n';
import {parentRPC, UntrustedContentRoot} from './untrusted';
import PropTypes from "prop-types";
import styles from "./sandboxed-codeeditor.scss";
import {getPublicUrl, getSandboxUrl, getTrustedUrl} from "./urls";
import {base, unbase} from "../../../shared/templates";
import ACEEditorRaw from 'react-ace';
import 'brace/theme/github';
import 'brace/ext/searchbox';
import 'brace/mode/html';
import {CodeEditorSourceType} from "./sandboxed-codeeditor-shared";
import mjml2html from "./mjml";
import juice from "juice";
import {withComponentMixins} from "./decorator-helpers";
const refreshTimeout = 1000;
@withComponentMixins([
withTranslation
])
class CodeEditorSandbox extends Component {
constructor(props) {
super(props);
let defaultSource;
if (props.sourceType === CodeEditorSourceType.MJML) {
defaultSource =
'<mjml>\n' +
' <mj-body>\n' +
' <mj-section>\n' +
' <mj-column>\n' +
' <!-- First column content -->\n' +
' </mj-column>\n' +
' <mj-column>\n' +
' <!-- Second column content -->\n' +
' </mj-column>\n' +
' </mj-section>\n' +
' </mj-body>\n' +
'</mjml>';
} else if (props.sourceType === CodeEditorSourceType.HTML) {
defaultSource =
'<!DOCTYPE html>\n' +
'<html>\n' +
'<head>\n' +
' <meta charset="UTF-8">\n' +
' <title>Title of the document</title>\n' +
'</head>\n' +
'<body>\n' +
' Content of the document......\n' +
'</body>\n' +
'</html>';
}
const trustedUrlBase = getTrustedUrl();
const sandboxUrlBase = getSandboxUrl();
const publicUrlBase = getPublicUrl();
const source = this.props.initialSource ? base(this.props.initialSource, trustedUrlBase, sandboxUrlBase, publicUrlBase) : defaultSource;
this.state = {
source,
preview: props.initialPreview,
wrapEnabled: props.initialWrap
};
this.state.previewContents = this.getHtml();
this.onCodeChangedHandler = ::this.onCodeChanged;
this.refreshHandler = ::this.refresh;
this.refreshTimeoutId = null;
this.onMessageFromPreviewHandler = ::this.onMessageFromPreview;
this.previewScroll = {x: 0, y: 0};
}
static propTypes = {
entityTypeId: PropTypes.string,
entityId: PropTypes.number,
initialSource: PropTypes.string,
sourceType: PropTypes.string,
initialPreview: PropTypes.bool,
initialWrap: PropTypes.bool
}
async exportState(method, params) {
const trustedUrlBase = getTrustedUrl();
const sandboxUrlBase = getSandboxUrl();
const publicUrlBase = getPublicUrl();
return {
html: unbase(this.getHtml(), trustedUrlBase, sandboxUrlBase, publicUrlBase, true),
source: unbase(this.state.source, trustedUrlBase, sandboxUrlBase, publicUrlBase, true)
};
}
async setPreview(method, preview) {
this.setState({
preview
});
}
async setWrap(method, wrap) {
this.setState({
wrapEnabled: wrap
});
}
componentDidMount() {
parentRPC.setMethodHandler('exportState', ::this.exportState);
parentRPC.setMethodHandler('setPreview', ::this.setPreview);
parentRPC.setMethodHandler('setWrap', ::this.setWrap);
window.addEventListener('message', this.onMessageFromPreviewHandler, false);
}
componentWillUnmount() {
clearTimeout(this.refreshTimeoutId);
}
getHtml() {
let contents;
if (this.props.sourceType === CodeEditorSourceType.MJML) {
try {
const res = mjml2html(this.state.source);
contents = res.html;
} catch (err) {
contents = '';
}
} else if (this.props.sourceType === CodeEditorSourceType.HTML) {
contents = juice(this.state.source);
}
return contents;
}
onCodeChanged(data) {
this.setState({
source: data
});
if (!this.refreshTimeoutId) {
this.refreshTimeoutId = setTimeout(() => this.refresh(), refreshTimeout);
}
}
onMessageFromPreview(evt) {
if (evt.data.type === 'scroll') {
this.previewScroll = evt.data.data;
}
}
refresh() {
this.refreshTimeoutId = null;
this.setState({
previewContents: this.getHtml()
});
}
render() {
const previewScript =
'(function() {\n' +
' function reportScroll() { window.parent.postMessage({type: \'scroll\', data: {x: window.scrollX, y: window.scrollY}}, \'*\'); }\n' +
' reportScroll();\n' +
' window.addEventListener(\'scroll\', reportScroll);\n' +
' window.addEventListener(\'load\', function(evt) { window.scrollTo(' + this.previewScroll.x + ',' + this.previewScroll.y +'); });\n' +
'})();\n';
const previewContents = this.state.previewContents.replace(/<\s*head\s*>/i, `<head><script>${previewScript}</script>`);
return (
<div className={styles.sandbox}>
<div className={this.state.preview ? styles.aceEditorWithPreview : styles.aceEditorWithoutPreview}>
<ACEEditorRaw
mode="html"
theme="github"
width="100%"
height="100%"
onChange={this.onCodeChangedHandler}
fontSize={12}
showPrintMargin={false}
value={this.state.source}
tabSize={2}
wrapEnabled={this.state.wrapEnabled}
setOptions={{useWorker: false}} // This disables syntax check because it does not always work well (e.g. in case of JS code in report templates)
/>
</div>
{
this.state.preview &&
<div className={styles.preview}>
<iframe ref={node => this.previewNode = node} src={"data:text/html;charset=utf-8," + encodeURIComponent(previewContents)}></iframe>
</div>
}
</div>
);
}
}
export default function() {
parentRPC.init();
ReactDOM.render(
<TranslationRoot>
<UntrustedContentRoot render={props => <CodeEditorSandbox {...props} />} />
</TranslationRoot>,
document.getElementById('root')
);
};

View file

@ -1,11 +0,0 @@
'use strict';
export const CodeEditorSourceType = {
MJML: 'mjml',
HTML: 'html'
};
export const getCodeEditorSourceTypeOptions = t => [
{key: CodeEditorSourceType.MJML, label: t('mjml')},
{key: CodeEditorSourceType.HTML, label: t('html')}
];

View file

@ -1,109 +0,0 @@
'use strict';
import React, {Component} from 'react';
import {withTranslation} from './i18n';
import PropTypes from "prop-types";
import styles from "./sandboxed-codeeditor.scss";
import {UntrustedContentHost} from './untrusted';
import {Icon} from "./bootstrap-components";
import {getTrustedUrl} from "./urls";
import {withComponentMixins} from "./decorator-helpers";
@withComponentMixins([
withTranslation
], ['exportState'])
export class CodeEditorHost extends Component {
constructor(props) {
super(props);
this.state = {
fullscreen: false,
preview: true,
wrap: true
};
this.contentNodeRefHandler = node => this.contentNode = node;
}
static propTypes = {
entityTypeId: PropTypes.string,
entity: PropTypes.object,
initialSource: PropTypes.string,
sourceType: PropTypes.string,
title: PropTypes.string,
onSave: PropTypes.func,
canSave: PropTypes.bool,
onTestSend: PropTypes.func,
onShowExport: PropTypes.func,
onFullscreenAsync: PropTypes.func
}
async toggleFullscreenAsync() {
const fullscreen = !this.state.fullscreen;
this.setState({
fullscreen
});
await this.props.onFullscreenAsync(fullscreen);
}
async togglePreviewAsync() {
const preview = !this.state.preview;
this.setState({
preview
});
await this.contentNode.ask('setPreview', preview);
}
async toggleWrapAsync() {
const wrap = !this.state.wrap;
this.setState({
wrap
});
await this.contentNode.ask('setWrap', wrap);
}
async exportState() {
return await this.contentNode.ask('exportState');
}
render() {
const t = this.props.t;
const editorData = {
entityTypeId: this.props.entityTypeId,
entityId: this.props.entity.id,
initialSource: this.props.initialSource,
sourceType: this.props.sourceType,
initialPreview: this.state.preview,
initialWrap: this.state.wrap
};
const tokenData = {
entityTypeId: this.props.entityTypeId,
entityId: this.props.entity.id
};
return (
<div className={this.state.fullscreen ? styles.editorFullscreen : styles.editor}>
<div className={styles.navbar}>
<div className={styles.navbarLeft}>
{this.state.fullscreen && <img className={styles.logo} src={getTrustedUrl('static/mailtrain-notext.png')}/>}
<div className={styles.title}>{this.props.title}</div>
</div>
<div className={styles.navbarRight}>
<a className={styles.btn} onClick={::this.toggleWrapAsync} title={this.state.wrap ? t('disableWordWrap') : t('enableWordWrap')}>{this.state.wrap ? 'WRAP': 'NOWRAP'}</a>
{this.props.canSave ? <a className={styles.btn} onClick={this.props.onSave} title={t('save')}><Icon icon="save"/></a> : <span className={styles.btnDisabled}><Icon icon="floppy-disk"/></span>}
<a className={styles.btn} onClick={this.props.onTestSend} title={t('sendTestEmail-1')}><Icon icon="at"/></a>
<a className={styles.btn} onClick={() => this.props.onShowExport('html', 'HTML')} title={t('showHtml')}><Icon icon="file-code"/></a>
<a className={styles.btn} onClick={::this.togglePreviewAsync} title={this.state.preview ? t('hidePreview'): t('showPreview')}><Icon icon={this.state.preview ? 'eye-slash': 'eye'}/></a>
<a className={styles.btn} onClick={::this.toggleFullscreenAsync} title={t('maximizeEditor')}><Icon icon="window-maximize"/></a>
</div>
</div>
<UntrustedContentHost ref={this.contentNodeRefHandler} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="codeeditor/editor" tokenMethod="codeeditor" tokenParams={tokenData}/>
</div>
);
}
}

View file

@ -1,35 +0,0 @@
@import "sandbox-common";
.sandbox {
}
.aceEditorWithPreview, .aceEditorWithoutPreview, .preview {
position: absolute;
height: 100%;
}
.aceEditorWithPreview {
border-right: #e8e8e8 solid 2px;
width: 50%;
}
.aceEditorWithoutPreview {
width: 100%;
}
.preview {
border-left: #e8e8e8 solid 2px;
width: 50%;
left: 50%;
overflow: hidden;
iframe {
width: 100%;
height: 100%;
border: 0px none;
body {
margin: 0px;
}
}
}

View file

@ -1,635 +0,0 @@
'use strict';
import './public-path';
import React, {Component} from 'react';
import ReactDOM from 'react-dom';
import {TranslationRoot, withTranslation} from './i18n';
import {parentRPC, UntrustedContentRoot} from './untrusted';
import PropTypes from "prop-types";
import {getPublicUrl, getSandboxUrl, getTrustedUrl} from "./urls";
import {base, unbase} from "../../../shared/templates";
import mjml2html from "./mjml";
import 'grapesjs/dist/css/grapes.min.css';
import grapesjs from 'grapesjs';
import 'grapesjs-mjml';
import 'grapesjs-preset-newsletter';
import 'grapesjs-preset-newsletter/dist/grapesjs-preset-newsletter.css';
import "./sandboxed-grapesjs.scss";
import axios from './axios';
import {GrapesJSSourceType} from "./sandboxed-grapesjs-shared";
import {withComponentMixins} from "./decorator-helpers";
grapesjs.plugins.add('mailtrain-remove-buttons', (editor, opts = {}) => {
// This needs to be done in on-load and after gjs plugin because grapesjs-preset-newsletter tries to set titles to all buttons (including those we remove)
// see https://github.com/artf/grapesjs-preset-newsletter/blob/e0a91636973a5a1481e9d7929e57a8869b1db72e/src/index.js#L248
editor.on('load', () => {
const panelManager = editor.Panels;
panelManager.removeButton('options','fullscreen');
panelManager.removeButton('options','export-template');
});
});
@withComponentMixins([
withTranslation
])
export class GrapesJSSandbox extends Component {
constructor(props) {
super(props);
this.initialized = false;
this.state = {
assets: null
};
}
static propTypes = {
entityTypeId: PropTypes.string,
entityId: PropTypes.number,
initialSource: PropTypes.string,
initialStyle: PropTypes.string,
sourceType: PropTypes.string
}
async exportState(method, params) {
const props = this.props;
const editor = this.editor;
// If exportState comes during text editing (via RichTextEditor), we need to cancel the editing, so that the
// text being edited is stored in the model
const sel = editor.getSelected();
if (sel && sel.view && sel.view.disableEditing) {
sel.view.disableEditing();
}
const trustedUrlBase = getTrustedUrl();
const sandboxUrlBase = getSandboxUrl();
const publicUrlBase = getPublicUrl();
const source = unbase(editor.getHtml(), trustedUrlBase, sandboxUrlBase, publicUrlBase, true);
const style = unbase(editor.getCss(), trustedUrlBase, sandboxUrlBase, publicUrlBase, true);
let html;
if (props.sourceType === GrapesJSSourceType.MJML) {
const preMjml = '<mjml><mj-head></mj-head><mj-body>';
const postMjml = '</mj-body></mjml>';
const mjml = preMjml + source + postMjml;
const mjmlRes = mjml2html(mjml);
html = mjmlRes.html;
} else if (props.sourceType === GrapesJSSourceType.HTML) {
const commandManager = editor.Commands;
const cmdGetCode = commandManager.get('gjs-get-inlined-html');
const htmlBody = cmdGetCode.run(editor);
const preHtml = '<!doctype html><html><head><meta charset="utf-8"><title></title></head><body>';
const postHtml = '</body></html>';
html = preHtml + unbase(htmlBody, trustedUrlBase, sandboxUrlBase, publicUrlBase, true) + postHtml;
}
return {
html,
style: style,
source: source
};
}
async fetchAssets() {
const props = this.props;
const resp = await axios.get(getSandboxUrl(`rest/files-list/${props.entityTypeId}/file/${props.entityId}`));
this.setState({
assets: resp.data.map( f => ({type: 'image', src: getPublicUrl(`files/${props.entityTypeId}/file/${props.entityId}/${f.filename}`)}) )
});
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.fetchAssets();
}
componentDidUpdate() {
if (!this.initialized && this.state.assets !== null) {
this.initGrapesJs();
this.initialized = true;
}
}
initGrapesJs() {
const props = this.props;
parentRPC.setMethodHandler('exportState', ::this.exportState);
const trustedUrlBase = getTrustedUrl();
const sandboxUrlBase = getSandboxUrl();
const publicUrlBase = getPublicUrl();
const config = {
noticeOnUnload: false,
container: this.canvasNode,
height: '100%',
width: '100%',
storageManager:{
type: 'none'
},
assetManager: {
assets: this.state.assets,
upload: getSandboxUrl(`grapesjs/upload/${this.props.entityTypeId}/${this.props.entityId}`),
uploadText: 'Drop images here or click to upload',
headers: {
'X-CSRF-TOKEN': '{{csrfToken}}',
},
autoAdd: true
},
styleManager: {
clearProperties: true,
},
fromElement: false,
components: '',
style: '',
plugins: [
],
pluginsOpts: {
}
};
let defaultSource, defaultStyle;
if (props.sourceType === GrapesJSSourceType.MJML) {
defaultSource =
'<mj-container>\n' +
' <mj-section>\n' +
' <mj-column>\n' +
' <mj-text>Lorem Ipsum...</mj-text>\n' +
' </mj-column>\n' +
' </mj-section>\n' +
'</mj-container>';
defaultStyle = '';
config.plugins.push('gjs-mjml');
config.pluginsOpts['gjs-mjml'] = {
preMjml: '<mjml><mj-head></mj-head><mj-body>',
postMjml: '</mj-body></mjml>'
};
} else if (props.sourceType === GrapesJSSourceType.HTML) {
defaultSource =
'<table class="main-body">\n' +
' <tr class="row">\n' +
' <td class="main-body-cell">\n' +
' <table class="container">\n' +
' <tr>\n' +
' <td class="container-cell">\n' +
' <table class="table100 c1790">\n' +
' <tr>\n' +
' <td class="top-cell" id="c1793">\n' +
' <u class="browser-link" id="c307">View in browser\n' +
' </u>\n' +
' </td>\n' +
' </tr>\n' +
' </table>\n' +
' <table class="c1766">\n' +
' <tr>\n' +
' <td class="cell c1769">\n' +
' <img class="c926" src="http://artf.github.io/grapesjs/img/grapesjs-logo.png" alt="GrapesJS."/>\n' +
' </td>\n' +
' <td class="cell c1776">\n' +
' <div class="c1144">GrapesJS Newsletter Builder\n' +
' <br/>\n' +
' </div>\n' +
' </td>\n' +
' </tr>\n' +
' </table>\n' +
' <table class="card">\n' +
' <tr>\n' +
' <td class="card-cell">\n' +
' <img class="c1271" src="http://artf.github.io/grapesjs/img/tmp-header-txt.jpg" alt="Big image here"/>\n' +
' <table class="table100 c1357">\n' +
' <tr>\n' +
' <td class="card-content">\n' +
' <h1 class="card-title">Build your newsletters faster than ever\n' +
' <br/>\n' +
' </h1>\n' +
' <p class="card-text">Import, build, test and export responsive newsletter templates faster than ever using the GrapesJS Newsletter Builder.\n' +
' </p>\n' +
' <table class="c1542">\n' +
' <tr>\n' +
' <td class="card-footer" id="c1545">\n' +
' <a class="button" href="https://github.com/artf/grapesjs">Free and Open Source\n' +
' </a>\n' +
' </td>\n' +
' </tr>\n' +
' </table>\n' +
' </td>\n' +
' </tr>\n' +
' </table>\n' +
' </td>\n' +
' </tr>\n' +
' </table>\n' +
' <table class="list-item">\n' +
' <tr>\n' +
' <td class="list-item-cell">\n' +
' <table class="list-item-content">\n' +
' <tr class="list-item-row">\n' +
' <td class="list-cell-left">\n' +
' <img class="list-item-image" src="http://artf.github.io/grapesjs/img/tmp-blocks.jpg" alt="Image1"/>\n' +
' </td>\n' +
' <td class="list-cell-right">\n' +
' <h1 class="card-title">Built-in Blocks\n' +
' </h1>\n' +
' <p class="card-text">Drag and drop built-in blocks from the right panel and style them in a matter of seconds\n' +
' </p>\n' +
' </td>\n' +
' </tr>\n' +
' </table>\n' +
' </td>\n' +
' </tr>\n' +
' </table>\n' +
' <table class="list-item">\n' +
' <tr>\n' +
' <td class="list-item-cell">\n' +
' <table class="list-item-content">\n' +
' <tr class="list-item-row">\n' +
' <td class="list-cell-left">\n' +
' <img class="list-item-image" src="http://artf.github.io/grapesjs/img/tmp-tgl-images.jpg" alt="Image2"/>\n' +
' </td>\n' +
' <td class="list-cell-right">\n' +
' <h1 class="card-title">Toggle images\n' +
' </h1>\n' +
' <p class="card-text">Build a good looking newsletter even without images enabled by the email clients\n' +
' </p>\n' +
' </td>\n' +
' </tr>\n' +
' </table>\n' +
' </td>\n' +
' </tr>\n' +
' </table>\n' +
' <table class="grid-item-row">\n' +
' <tr>\n' +
' <td class="grid-item-cell2-l">\n' +
' <table class="grid-item-card">\n' +
' <tr>\n' +
' <td class="grid-item-card-cell">\n' +
' <img class="grid-item-image" src="http://artf.github.io/grapesjs/img/tmp-send-test.jpg" alt="Image1"/>\n' +
' <table class="grid-item-card-body">\n' +
' <tr>\n' +
' <td class="grid-item-card-content">\n' +
' <h1 class="card-title">Test it\n' +
' </h1>\n' +
' <p class="card-text">You can send email tests directly from the editor and check how are looking on your email clients\n' +
' </p>\n' +
' </td>\n' +
' </tr>\n' +
' </table>\n' +
' </td>\n' +
' </tr>\n' +
' </table>\n' +
' </td>\n' +
' <td class="grid-item-cell2-r">\n' +
' <table class="grid-item-card">\n' +
' <tr>\n' +
' <td class="grid-item-card-cell">\n' +
' <img class="grid-item-image" src="http://artf.github.io/grapesjs/img/tmp-devices.jpg" alt="Image2"/>\n' +
' <table class="grid-item-card-body">\n' +
' <tr>\n' +
' <td class="grid-item-card-content">\n' +
' <h1 class="card-title">Responsive\n' +
' </h1>\n' +
' <p class="card-text">Using the device manager you\'ll always send a fully responsive contents\n' +
' </p>\n' +
' </td>\n' +
' </tr>\n' +
' </table>\n' +
' </td>\n' +
' </tr>\n' +
' </table>\n' +
' </td>\n' +
' </tr>\n' +
' </table>\n' +
' <table class="footer">\n' +
' <tr>\n' +
' <td class="footer-cell">\n' +
' <div class="c2577">\n' +
' <p class="footer-info">GrapesJS Newsletter Builder is a free and open source preset (plugin) used on top of the GrapesJS core library.\n' +
' For more information about and how to integrate it inside your applications check<p>\n' +
' <a class="link" href="https://github.com/artf/grapesjs-preset-newsletter">GrapesJS Newsletter Preset</a>\n' +
' <br/>\n' +
' </div>\n' +
' <div class="c2421">\n' +
' MADE BY <a class="link" href="https://github.com/artf">ARTUR ARSENIEV</a>\n' +
' <p>\n' +
' </div>\n' +
' </td>\n' +
' </tr>\n' +
' </table>\n' +
' </td>\n' +
' </tr>\n' +
' </table>\n' +
' </td>\n' +
' </tr>\n' +
'</table>';
defaultStyle =
'.link {\n' +
' color: rgb(217, 131, 166);\n' +
' }\n' +
' .row{\n' +
' vertical-align:top;\n' +
' }\n' +
' .main-body{\n' +
' min-height:150px;\n' +
' padding: 5px;\n' +
' width:100%;\n' +
' height:100%;\n' +
' background-color:rgb(234, 236, 237);\n' +
' }\n' +
' .c926{\n' +
' color:rgb(158, 83, 129);\n' +
' width:100%;\n' +
' font-size:50px;\n' +
' }\n' +
' .cell.c849{\n' +
' width:11%;\n' +
' }\n' +
' .c1144{\n' +
' padding: 10px;\n' +
' font-size:17px;\n' +
' font-weight: 300;\n' +
' }\n' +
' .card{\n' +
' min-height:150px;\n' +
' padding: 5px;\n' +
' margin-bottom:20px;\n' +
' height:0px;\n' +
' }\n' +
' .card-cell{\n' +
' background-color:rgb(255, 255, 255);\n' +
' overflow:hidden;\n' +
' border-radius: 3px;\n' +
' padding: 0;\n' +
' text-align:center;\n' +
' }\n' +
' .card.sector{\n' +
' background-color:rgb(255, 255, 255);\n' +
' border-radius: 3px;\n' +
' border-collapse:separate;\n' +
' }\n' +
' .c1271{\n' +
' width:100%;\n' +
' margin: 0 0 15px 0;\n' +
' font-size:50px;\n' +
' color:rgb(120, 197, 214);\n' +
' line-height:250px;\n' +
' text-align:center;\n' +
' }\n' +
' .table100{\n' +
' width:100%;\n' +
' }\n' +
' .c1357{\n' +
' min-height:150px;\n' +
' padding: 5px;\n' +
' margin: auto;\n' +
' height:0px;\n' +
' }\n' +
' .darkerfont{\n' +
' color:rgb(65, 69, 72);\n' +
' }\n' +
' .button{\n' +
' font-size:12px;\n' +
' padding: 10px 20px;\n' +
' background-color:rgb(217, 131, 166);\n' +
' color:rgb(255, 255, 255);\n' +
' text-align:center;\n' +
' border-radius: 3px;\n' +
' font-weight:300;\n' +
' }\n' +
' .table100.c1437{\n' +
' text-align:left;\n' +
' }\n' +
' .cell.cell-bottom{\n' +
' text-align:center;\n' +
' height:51px;\n' +
' }\n' +
' .card-title{\n' +
' font-size:25px;\n' +
' font-weight:300;\n' +
' color:rgb(68, 68, 68);\n' +
' }\n' +
' .card-content{\n' +
' font-size:13px;\n' +
' line-height:20px;\n' +
' color:rgb(111, 119, 125);\n' +
' padding: 10px 20px 0 20px;\n' +
' vertical-align:top;\n' +
' }\n' +
' .container{\n' +
' font-family: Helvetica, serif;\n' +
' min-height:150px;\n' +
' padding: 5px;\n' +
' margin:auto;\n' +
' height:0px;\n' +
' width:90%;\n' +
' max-width:550px;\n' +
' }\n' +
' .cell.c856{\n' +
' vertical-align:middle;\n' +
' }\n' +
' .container-cell{\n' +
' vertical-align:top;\n' +
' font-size:medium;\n' +
' padding-bottom:50px;\n' +
' }\n' +
' .c1790{\n' +
' min-height:150px;\n' +
' padding: 5px;\n' +
' margin:auto;\n' +
' height:0px;\n' +
' }\n' +
' .table100.c1790{\n' +
' min-height:30px;\n' +
' border-collapse:separate;\n' +
' margin: 0 0 10px 0;\n' +
' }\n' +
' .browser-link{\n' +
' font-size:12px;\n' +
' }\n' +
' .top-cell{\n' +
' text-align:right;\n' +
' color:rgb(152, 156, 165);\n' +
' }\n' +
' .table100.c1357{\n' +
' margin: 0;\n' +
' border-collapse:collapse;\n' +
' }\n' +
' .c1769{\n' +
' width:30%;\n' +
' }\n' +
' .c1776{\n' +
' width:70%;\n' +
' }\n' +
' .c1766{\n' +
' margin: 0 auto 10px 0;\n' +
' padding: 5px;\n' +
' width:100%;\n' +
' min-height:30px;\n' +
' }\n' +
' .cell.c1769{\n' +
' width:11%;\n' +
' }\n' +
' .cell.c1776{\n' +
' vertical-align:middle;\n' +
' }\n' +
' .c1542{\n' +
' margin: 0 auto 10px auto;\n' +
' padding:5px;\n' +
' width:100%;\n' +
' }\n' +
' .card-footer{\n' +
' padding: 20px 0;\n' +
' text-align:center;\n' +
' }\n' +
' .c2280{\n' +
' height:150px;\n' +
' margin:0 auto 10px auto;\n' +
' padding:5px 5px 5px 5px;\n' +
' width:100%;\n' +
' }\n' +
' .c2421{\n' +
' padding:10px;\n' +
' }\n' +
' .c2577{\n' +
' padding:10px;\n' +
' }\n' +
' .footer{\n' +
' margin-top: 50px;\n' +
' color:rgb(152, 156, 165);\n' +
' text-align:center;\n' +
' font-size:11px;\n' +
' padding: 5px;\n' +
' }\n' +
' .quote {\n' +
' font-style: italic;\n' +
' }\n' +
' .list-item{\n' +
' height:auto;\n' +
' width:100%;\n' +
' margin: 0 auto 10px auto;\n' +
' padding: 5px;\n' +
' }\n' +
' .list-item-cell{\n' +
' background-color:rgb(255, 255, 255);\n' +
' border-radius: 3px;\n' +
' overflow: hidden;\n' +
' padding: 0;\n' +
' }\n' +
' .list-cell-left{\n' +
' width:30%;\n' +
' padding: 0;\n' +
' }\n' +
' .list-cell-right{\n' +
' width:70%;\n' +
' color:rgb(111, 119, 125);\n' +
' font-size:13px;\n' +
' line-height:20px;\n' +
' padding: 10px 20px 0px 20px;\n' +
' }\n' +
' .list-item-content{\n' +
' border-collapse: collapse;\n' +
' margin: 0 auto;\n' +
' padding: 5px;\n' +
' height:150px;\n' +
' width:100%;\n' +
' }\n' +
' .list-item-image{\n' +
' color:rgb(217, 131, 166);\n' +
' font-size:45px;\n' +
' width: 100%;\n' +
' }\n' +
' .grid-item-image{\n' +
' line-height:150px;\n' +
' font-size:50px;\n' +
' color:rgb(120, 197, 214);\n' +
' margin-bottom:15px;\n' +
' width:100%;\n' +
' }\n' +
' .grid-item-row {\n' +
' margin: 0 auto 10px;\n' +
' padding: 5px 0;\n' +
' width: 100%;\n' +
' }\n' +
' .grid-item-card {\n' +
' width:100%;\n' +
' padding: 5px 0;\n' +
' margin-bottom: 10px;\n' +
' }\n' +
' .grid-item-card-cell{\n' +
' background-color:rgb(255, 255, 255);\n' +
' overflow: hidden;\n' +
' border-radius: 3px;\n' +
' text-align:center;\n' +
' padding: 0;\n' +
' }\n' +
' .grid-item-card-content{\n' +
' font-size:13px;\n' +
' color:rgb(111, 119, 125);\n' +
' padding: 0 10px 20px 10px;\n' +
' width:100%;\n' +
' line-height:20px;\n' +
' }\n' +
' .grid-item-cell2-l{\n' +
' vertical-align:top;\n' +
' padding-right:10px;\n' +
' width:50%;\n' +
' }\n' +
' .grid-item-cell2-r{\n' +
' vertical-align:top;\n' +
' padding-left:10px;\n' +
' width:50%;\n' +
' }';
config.plugins.push('gjs-preset-newsletter');
}
config.components = props.initialSource ? base(props.initialSource, trustedUrlBase, sandboxUrlBase, publicUrlBase) : defaultSource;
config.style = props.initialStyle ? base(props.initialStyle, trustedUrlBase, sandboxUrlBase, publicUrlBase) : defaultStyle;
config.plugins.push('mailtrain-remove-buttons');
this.editor = grapesjs.init(config);
}
render() {
return (
<div>
<div ref={node => this.canvasNode = node}/>
</div>
);
}
}
export default function() {
parentRPC.init();
ReactDOM.render(
<TranslationRoot>
<UntrustedContentRoot render={props => <GrapesJSSandbox {...props} />} />
</TranslationRoot>,
document.getElementById('root')
);
};

View file

@ -1,11 +0,0 @@
'use strict';
export const GrapesJSSourceType = {
MJML: 'mjml',
HTML: 'html'
};
export const getGrapesJSSourceTypeOptions = t => [
{key: GrapesJSSourceType.MJML, label: t('mjml')},
{key: GrapesJSSourceType.HTML, label: t('html')}
];

View file

@ -1,89 +0,0 @@
'use strict';
import React, {Component} from 'react';
import {withTranslation} from './i18n';
import PropTypes from "prop-types";
import styles from "./sandboxed-grapesjs.scss";
import {UntrustedContentHost} from './untrusted';
import {Icon} from "./bootstrap-components";
import {getTrustedUrl} from "./urls";
import {withComponentMixins} from "./decorator-helpers";
import {GrapesJSSourceType} from "./sandboxed-grapesjs-shared";
@withComponentMixins([
withTranslation
], ['exportState'])
export class GrapesJSHost extends Component {
constructor(props) {
super(props);
this.state = {
fullscreen: false
};
this.contentNodeRefHandler = node => this.contentNode = node;
}
static propTypes = {
entityTypeId: PropTypes.string,
entity: PropTypes.object,
initialSource: PropTypes.string,
initialStyle: PropTypes.string,
sourceType: PropTypes.string,
title: PropTypes.string,
onSave: PropTypes.func,
canSave: PropTypes.bool,
onTestSend: PropTypes.func,
onShowExport: PropTypes.func,
onFullscreenAsync: PropTypes.func
}
async toggleFullscreenAsync() {
const fullscreen = !this.state.fullscreen;
this.setState({
fullscreen
});
await this.props.onFullscreenAsync(fullscreen);
}
async exportState() {
return await this.contentNode.ask('exportState');
}
render() {
const t = this.props.t;
const editorData = {
entityTypeId: this.props.entityTypeId,
entityId: this.props.entity.id,
initialSource: this.props.initialSource,
initialStyle: this.props.initialStyle,
sourceType: this.props.sourceType
};
const tokenData = {
entityTypeId: this.props.entityTypeId,
entityId: this.props.entity.id
};
return (
<div className={this.state.fullscreen ? styles.editorFullscreen : styles.editor}>
<div className={styles.navbar}>
<div className={styles.navbarLeft}>
{this.state.fullscreen && <img className={styles.logo} src={getTrustedUrl('static/mailtrain-notext.png')}/>}
<div className={styles.title}>{this.props.title}</div>
</div>
<div className={styles.navbarRight}>
{this.props.canSave ? <a className={styles.btn} onClick={this.props.onSave} title={t('save')}><Icon icon="save"/></a> : <span className={styles.btnDisabled}><Icon icon="save"/></span>}
<a className={styles.btn} onClick={this.props.onTestSend} title={t('sendTestEmail-1')}><Icon icon="at"/></a>
<a className={styles.btn} onClick={() => this.props.onShowExport('html', 'HTML')} title={t('showHtml')}><Icon icon="file-code"/></a>
{this.props.sourceType === GrapesJSSourceType.MJML && <a className={styles.btn} onClick={() => this.props.onShowExport('mjml', 'MJML')} title={t('showMjml')}>MJML</a>}
<a className={styles.btn} onClick={::this.toggleFullscreenAsync} title={t('maximizeEditor')}><Icon icon="window-maximize"/></a>
</div>
</div>
<UntrustedContentHost ref={this.contentNodeRefHandler} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="grapesjs/editor" tokenMethod="grapesjs" tokenParams={tokenData}/>
</div>
);
}
}

View file

@ -1,18 +0,0 @@
@import "sandbox-common";
:global .grapesjs-body {
margin: 0px;
}
:global .gjs-editor-cont {
position: absolute;
}
:global .gjs-devices-c .gjs-devices {
padding-right: 15px;
}
:global .gjs-pn-devices-c, :global .gjs-pn-views {
padding: 4px;
}

View file

@ -1,158 +0,0 @@
'use strict';
import './public-path';
import React, {Component} from 'react';
import ReactDOM from 'react-dom';
import {TranslationRoot, withTranslation} from './i18n';
import {parentRPC, UntrustedContentRoot} from './untrusted';
import PropTypes from "prop-types";
import {getPublicUrl, getSandboxUrl, getTrustedUrl} from "./urls";
import {base, unbase} from "../../../shared/templates";
import {withComponentMixins} from "./decorator-helpers";
import juice from "juice";
@withComponentMixins([
withTranslation
])
class MosaicoSandbox extends Component {
constructor(props) {
super(props);
this.viewModel = null;
this.state = {
};
}
static propTypes = {
entityTypeId: PropTypes.string,
entityId: PropTypes.number,
templateId: PropTypes.number,
templatePath: PropTypes.string,
initialModel: PropTypes.string,
initialMetadata: PropTypes.string
}
async exportState(method, params) {
const trustedUrlBase = getTrustedUrl();
const sandboxUrlBase = getSandboxUrl();
const publicUrlBase = getPublicUrl();
/* juice is called to inline css styles of situations like this
<style type="text/css" data-inline="true">
[data-ko-block=introBlock] .text p {
font-family: merriweather,georgia,times new roman,serif; font-size: 14px; text-align: justify; line-height: 150%; color: #3A3A3A; margin-top: 8px;
}
</style>
...
<div style="Margin:0px auto;max-width:600px;" data-ko-block="introBlock">
...
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;" data-ko-editable="text" class="text">
<p>XXX</p>
</div>
...
</div>
*/
let html = this.viewModel.export();
html = juice(html);
return {
html: unbase(html, trustedUrlBase, sandboxUrlBase, publicUrlBase, true),
model: unbase(this.viewModel.exportJSON(), trustedUrlBase, sandboxUrlBase, publicUrlBase),
metadata: unbase(this.viewModel.exportMetadata(), trustedUrlBase, sandboxUrlBase, publicUrlBase)
};
}
componentDidMount() {
parentRPC.setMethodHandler('exportState', ::this.exportState);
if (!Mosaico.isCompatible()) {
alert('Update your browser!');
return;
}
const plugins = [...window.mosaicoPlugins];
plugins.push(viewModel => {
this.viewModel = viewModel;
});
// (Custom) HTML postRenderers
plugins.push(viewModel => {
viewModel.originalExportHTML = viewModel.exportHTML;
viewModel.exportHTML = () => {
let html = viewModel.originalExportHTML();
// Chrome workaround begin -----------------------------------------------------------------------------------
// Chrome v. 74 (and likely other versions too) has problem with how KO sets data during export.
// As the result, the images that have been in the template from previous editing (i.e. before page refresh)
// get lost. The code below refreshes the KO binding, thus effectively reloading the images.
const isChrome = !!window.chrome && (!!window.chrome.webstore || !!window.chrome.runtime);
if (isChrome) {
ko.cleanNode(document.body);
ko.applyBindings(viewModel, document.body);
}
// Chrome workaround end -------------------------------------------------------------------------------------
for (const portRender of window.mosaicoHTMLPostRenderers) {
html = postRender(html);
}
return html;
};
});
// Custom convertedUrl (https://github.com/voidlabs/mosaico/blob/a359e263f1af5cf05e2c2d56c771732f2ef6c8c6/src/js/app.js#L42)
// which does not complain about mismatch of domains between TRUSTED and PUBLIC
plugins.push(viewModel => {
ko.bindingHandlers.wysiwygSrc.convertedUrl = (src, method, width, height) => getTrustedUrl(`mosaico/img?src=${encodeURIComponent(src)}&method=${encodeURIComponent(method)}&params=${width},${height}`);
});
plugins.unshift(vm => {
// This is an override of the default paths in Mosaico
vm.logoPath = getTrustedUrl('static/mosaico/rs/img/mosaico32.png');
vm.logoUrl = '#';
});
const config = {
imgProcessorBackend: getTrustedUrl('mosaico/img'),
emailProcessorBackend: getSandboxUrl('mosaico/dl'),
fileuploadConfig: {
url: getSandboxUrl(`mosaico/upload/${this.props.entityTypeId}/${this.props.entityId}`)
},
strings: window.mosaicoLanguageStrings
};
const trustedUrlBase = getTrustedUrl();
const sandboxUrlBase = getSandboxUrl();
const publicUrlBase = getPublicUrl();
const metadata = this.props.initialMetadata && JSON.parse(base(this.props.initialMetadata, trustedUrlBase, sandboxUrlBase, publicUrlBase));
const model = this.props.initialModel && JSON.parse(base(this.props.initialModel, trustedUrlBase, sandboxUrlBase, publicUrlBase));
const template = this.props.templateId ? getSandboxUrl(`mosaico/templates/${this.props.templateId}/index.html`) : this.props.templatePath;
const allPlugins = plugins.concat(window.mosaicoPlugins);
Mosaico.start(config, template, metadata, model, allPlugins);
}
render() {
return <div/>;
}
}
export default function() {
parentRPC.init();
ReactDOM.render(
<TranslationRoot>
<UntrustedContentRoot render={props => <MosaicoSandbox {...props} />} />
</TranslationRoot>,
document.getElementById('root')
);
};

View file

@ -1,90 +0,0 @@
'use strict';
import React, {Component} from 'react';
import {withTranslation} from './i18n';
import PropTypes from "prop-types";
import styles from "./sandboxed-mosaico.scss";
import {UntrustedContentHost} from './untrusted';
import {Icon} from "./bootstrap-components";
import {getTrustedUrl} from "./urls";
import {withComponentMixins} from "./decorator-helpers";
@withComponentMixins([
withTranslation
], ['exportState'])
export class MosaicoHost extends Component {
constructor(props) {
super(props);
this.state = {
fullscreen: false
};
this.contentNodeRefHandler = node => this.contentNode = node;
}
static propTypes = {
entityTypeId: PropTypes.string,
entity: PropTypes.object,
title: PropTypes.string,
onSave: PropTypes.func,
canSave: PropTypes.bool,
onTestSend: PropTypes.func,
onShowExport: PropTypes.func,
onFullscreenAsync: PropTypes.func,
templateId: PropTypes.number,
templatePath: PropTypes.string,
initialModel: PropTypes.string,
initialMetadata: PropTypes.string
}
async toggleFullscreenAsync() {
const fullscreen = !this.state.fullscreen;
this.setState({
fullscreen
});
await this.props.onFullscreenAsync(fullscreen);
}
async exportState() {
return await this.contentNode.ask('exportState');
}
render() {
const t = this.props.t;
const editorData = {
entityTypeId: this.props.entityTypeId,
entityId: this.props.entity.id,
templateId: this.props.templateId,
templatePath: this.props.templatePath,
initialModel: this.props.initialModel,
initialMetadata: this.props.initialMetadata
};
const tokenData = {
entityTypeId: this.props.entityTypeId,
entityId: this.props.entity.id
};
return (
<div className={this.state.fullscreen ? styles.editorFullscreen : styles.editor}>
<div className={styles.navbar}>
<div className={styles.navbarLeft}>
{this.state.fullscreen && <img className={styles.logo} src={getTrustedUrl('static/mailtrain-notext.png')}/>}
<div className={styles.title}>{this.props.title}</div>
</div>
<div className={styles.navbarRight}>
{this.props.canSave ? <a className={styles.btn} onClick={this.props.onSave} title={t('save')}><Icon icon="save"/></a> : <span className={styles.btnDisabled}><Icon icon="save"/></span>}
<a className={styles.btn} onClick={this.props.onTestSend} title={t('sendTestEmail-1')}><Icon icon="at"/></a>
<a className={styles.btn} onClick={() => this.props.onShowExport('html', 'HTML')} title={t('showHtml')}><Icon icon="file-code"/></a>
<a className={styles.btn} onClick={::this.toggleFullscreenAsync} title={t('maximizeEditor')}><Icon icon="window-maximize"/></a>
</div>
</div>
<UntrustedContentHost ref={this.contentNodeRefHandler} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="mosaico/editor" tokenMethod="mosaico" tokenParams={tokenData}/>
</div>
);
}
}

View file

@ -1,8 +0,0 @@
@import "sandbox-common";
:global .mo-standalone {
top: 0px;
bottom: 0px;
width: 100%;
position: absolute;
}

View file

@ -1,185 +0,0 @@
@import "../scss/variables.scss";
.toolbar {
float: right;
margin-bottom: 15px;
}
.form { // This is here to give the styles below higher priority than Bootstrap has
:global .DayPicker {
border: $input-border-width solid $input-border-color;
border-radius: $input-border-radius;
padding: $input-padding-y $input-padding-x;
}
:global .form-horizontal .control-label {
display: block;
}
:global .form-control[disabled] {
cursor: default;
background-color: #eeeeee;
opacity: 1;
}
:global .ace_editor {
border: 1px solid #ccc;
}
.buttonRow:last-child {
// This is to move Save/Delete buttons a bit down
margin-top: 15px;
}
}
.staticFormGroup {
margin-bottom: 15px;
}
.dayPickerWrapper {
text-align: right;
}
.buttonRow {
}
.buttonRow > * {
margin-right: 15px;
}
.buttonRow > *:last-child {
margin-right: 0px;
}
.formDisabled {
background-color: #eeeeee;
opacity: 1;
}
.formStatus {
padding-top: 5px;
padding-bottom: 5px;
}
.dataTableTable {
overflow-x: auto;
}
.actionLinks > * {
margin-right: 8px;
}
.actionLinks > *:last-child {
margin-right: 0px;
}
.tableSelectDropdown {
margin-bottom: 15px;
}
.tableSelectTable.tableSelectTableHidden {
display: none;
height: 0px;
margin-top: -15px;
}
.tableSelectDropdown input[readonly] {
background-color: white;
}
:global h3.legend {
font-size: 21px;
margin-bottom: 20px;
}
.tertiaryNav {
justify-content: flex-end;
flex-grow: 1;
align-self: center;
margin-left: 5px;
margin-right: 5px;
:global .nav-item .nav-link {
padding: 3px 10px;
}
}
.colorPickerSwatchWrapper {
padding: 7px;
background: #fff;
border: 1px solid #AAB2BD;
border-radius: 4px;
display: inline-block;
cursor: pointer;
.colorPickerSwatchColor {
width: 60px;
height: 18px;
borderRadius: 2px;
}
}
.colorPickerWrapper {
text-align: right;
}
.checkboxText{
padding-top: 3px;
}
.dropZone{
padding-top: 20px;
padding-bottom: 20px;
margin-bottom: 3px;
margin-top: 3px;
border: 2px solid #E6E9ED;
border-radius: 5px;
background-color: #FAFAD2;
text-align: center;
font-size: 20px;
color: #808080;
p:last-child {
margin-bottom: 0px;
}
}
.dropZoneActive{
border-color: #90EE90;
color: #000;
background-color: #DDFFDD;
}
.untrustedContent {
border: 0px none;
width: 100%;
overflow: hidden;
}
.withElementInFullscreen {
height: 0px;
overflow: hidden;
}
.iconDisabled {
color: $link-color;
text-decoration: $link-decoration;
}
.errorsList {
margin-bottom: 0px;
}
:global .modal-dialog {
@media (min-width: 768px) {
max-width: 700px;
}
@media (min-width: 1000px) {
max-width: 900px;
}
}

View file

@ -1,424 +0,0 @@
'use strict';
import React, {Component} from 'react';
import ReactDOMServer from 'react-dom/server';
import PropTypes from 'prop-types';
import {withTranslation} from './i18n';
import jQuery from 'jquery';
import 'datatables.net';
import 'datatables.net-bs4';
import 'datatables.net-bs4/css/dataTables.bootstrap4.css';
import axios from './axios';
import {withPageHelpers} from './page'
import {withAsyncErrorHandler, withErrorHandling} from './error-handling';
import styles from "./styles.scss";
import {getUrl} from "./urls";
import {withComponentMixins} from "./decorator-helpers";
//dtFactory();
//dtSelectFactory();
const TableSelectMode = {
NONE: 0,
SINGLE: 1,
MULTI: 2
};
@withComponentMixins([
withTranslation,
withErrorHandling,
withPageHelpers
], ['refresh'])
class Table extends Component {
constructor(props) {
super(props);
this.mounted = false;
this.selectionMap = this.getSelectionMap(props);
}
static propTypes = {
dataUrl: PropTypes.string,
data: PropTypes.array,
columns: PropTypes.array,
selectMode: PropTypes.number,
selection: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]),
selectionKeyIndex: PropTypes.number,
selectionAsArray: PropTypes.bool,
onSelectionChangedAsync: PropTypes.func,
onSelectionDataAsync: PropTypes.func,
withHeader: PropTypes.bool,
refreshInterval: PropTypes.number,
pageLength: PropTypes.number
}
static defaultProps = {
selectMode: TableSelectMode.NONE,
selectionKeyIndex: 0,
pageLength: 50
}
refresh() {
if (this.table) {
this.table.rows().draw('page');
}
}
getSelectionMap(props) {
let selArray = [];
if (props.selectMode === TableSelectMode.SINGLE && !this.props.selectionAsArray) {
if (props.selection !== null && props.selection !== undefined) {
selArray = [props.selection];
} else {
selArray = [];
}
} else if ((props.selectMode === TableSelectMode.SINGLE && this.props.selectionAsArray) || props.selectMode === TableSelectMode.MULTI) {
selArray = props.selection || [];
}
const selMap = new Map();
for (const elem of selArray) {
selMap.set(elem, undefined);
}
if (props.data) {
for (const rowData of props.data) {
const key = rowData[props.selectionKeyIndex];
if (selMap.has(key)) {
selMap.set(key, rowData);
}
}
} else if (this.table) {
this.table.rows().every(function() {
const rowData = this.data();
const key = rowData[props.selectionKeyIndex];
if (selMap.has(key)) {
selMap.set(key, rowData);
}
});
}
return selMap;
}
updateSelectInfo() {
if (!this.jqSelectInfo) {
return; // If the table is updated very quickly after mounting, the datatable may not be initialized yet.
}
const t = this.props.t;
const count = this.selectionMap.size;
if (this.selectionMap.size > 0) {
const jqInfo = jQuery('<span>' + t('countEntriesSelected', { count }) + ' </span>');
const jqDeselectLink = jQuery('<a href="">Deselect all.</a>').on('click', ::this.deselectAll);
this.jqSelectInfo.empty().append(jqInfo).append(jqDeselectLink);
} else {
this.jqSelectInfo.empty();
}
}
@withAsyncErrorHandler
async fetchData(data, callback) {
// This custom ajax fetch function allows us to properly handle the case when the user is not authenticated.
const response = await axios.post(getUrl(this.props.dataUrl), data);
callback(response.data);
}
@withAsyncErrorHandler
async fetchAndNotifySelectionData() {
if (this.props.onSelectionDataAsync) {
if (!this.props.data) {
const keysToFetch = [];
for (const pair of this.selectionMap.entries()) {
if (!pair[1]) {
keysToFetch.push(pair[0]);
}
}
if (keysToFetch.length > 0) {
const response = await axios.post(getUrl(this.props.dataUrl), {
operation: 'getBy',
column: this.props.selectionKeyIndex,
values: keysToFetch
});
for (const row of response.data) {
const key = row[this.props.selectionKeyIndex];
if (this.selectionMap.has(key)) {
this.selectionMap.set(key, row);
}
}
}
}
// noinspection JSIgnoredPromiseFromCall
this.notifySelection(this.props.onSelectionDataAsync, this.selectionMap);
}
}
shouldComponentUpdate(nextProps, nextState) {
const nextSelectionMap = this.getSelectionMap(nextProps);
let updateDueToSelectionChange = false;
if (nextSelectionMap.size !== this.selectionMap.size) {
updateDueToSelectionChange = true;
} else {
for (const key of this.selectionMap.keys()) {
if (!nextSelectionMap.has(key)) {
updateDueToSelectionChange = true;
break;
}
}
}
this.selectionMap = nextSelectionMap;
return updateDueToSelectionChange || this.props.data !== nextProps.data || this.props.dataUrl !== nextProps.dataUrl;
}
componentDidMount() {
this.mounted = true;
const columns = this.props.columns.slice();
// XSS protection and actions rendering
for (const column of columns) {
if (column.actions) {
const createdCellFn = (td, data, rowData) => {
const linksContainer = jQuery(`<span class="${styles.actionLinks}"/>`);
let actions = column.actions(rowData);
let options = {};
if (!Array.isArray(actions)) {
options = actions;
actions = actions.actions;
}
for (const action of actions) {
if (action.action) {
const html = ReactDOMServer.renderToStaticMarkup(<a href="">{action.label}</a>);
const elem = jQuery(html);
elem.click((evt) => { evt.preventDefault(); action.action(this) });
linksContainer.append(elem);
} else if (action.link) {
const html = ReactDOMServer.renderToStaticMarkup(<a href={action.link}>{action.label}</a>);
const elem = jQuery(html);
elem.click((evt) => { evt.preventDefault(); this.navigateTo(action.link) });
linksContainer.append(elem);
} else if (action.href) {
const html = ReactDOMServer.renderToStaticMarkup(<a href={action.href}>{action.label}</a>);
const elem = jQuery(html);
linksContainer.append(elem);
} else {
const html = ReactDOMServer.renderToStaticMarkup(<span>{action.label}</span>);
const elem = jQuery(html);
linksContainer.append(elem);
}
}
if (options.refreshTimeout) {
const currentMS = Date.now();
if (!this.refreshTimeoutAt || this.refreshTimeoutAt > currentMS + options.refreshTimeout) {
clearTimeout(this.refreshTimeoutId);
this.refreshTimeoutAt = currentMS + options.refreshTimeout;
this.refreshTimeoutId = setTimeout(() => {
this.refreshTimeoutAt = 0;
this.refresh();
}, options.refreshTimeout);
}
}
jQuery(td).html(linksContainer);
};
column.type = 'html';
column.createdCell = createdCellFn;
if (!('data' in column)) {
column.data = null;
column.orderable = false;
column.searchable = false;
}
} else {
const originalRender = column.render;
column.render = (data, ...rest) => {
if (originalRender) {
const markup = originalRender(data, ...rest);
return ReactDOMServer.renderToStaticMarkup(<div>{markup}</div>);
} else {
return ReactDOMServer.renderToStaticMarkup(<div>{data}</div>)
}
};
}
column.title = ReactDOMServer.renderToStaticMarkup(<div>{column.title}</div>);
}
const dtOptions = {
columns,
autoWidth: false,
pageLength: this.props.pageLength,
dom: // This overrides Bootstrap 4 settings. It may need to be updated if there are updates in the DataTables Bootstrap 4 plugin.
"<'row'<'col-sm-12 col-md-6'l><'col-sm-12 col-md-6'f>>" +
"<'row'<'col-sm-12'<'" + styles.dataTableTable + "'tr>>>" +
"<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>"
};
const self = this;
dtOptions.createdRow = function(row, data) {
const rowKey = data[self.props.selectionKeyIndex];
if (self.selectionMap.has(rowKey)) {
jQuery(row).addClass('selected');
}
jQuery(row).on('click', () => {
const selectionMap = self.selectionMap;
if (self.props.selectMode === TableSelectMode.SINGLE) {
if (selectionMap.size !== 1 || !selectionMap.has(rowKey)) {
// noinspection JSIgnoredPromiseFromCall
self.notifySelection(self.props.onSelectionChangedAsync, new Map([[rowKey, data]]));
}
} else if (self.props.selectMode === TableSelectMode.MULTI) {
const newSelMap = new Map(selectionMap);
if (selectionMap.has(rowKey)) {
newSelMap.delete(rowKey);
} else {
newSelMap.set(rowKey, data);
}
// noinspection JSIgnoredPromiseFromCall
self.notifySelection(self.props.onSelectionChangedAsync, newSelMap);
}
});
};
dtOptions.initComplete = function() {
self.jqSelectInfo = jQuery('<div class="dataTable_selection_info"/>');
const jqWrapper = jQuery(self.domTable).parents('.dataTables_wrapper');
jQuery('.dataTables_info', jqWrapper).after(self.jqSelectInfo);
self.updateSelectInfo();
};
if (this.props.data) {
dtOptions.data = this.props.data;
} else {
dtOptions.serverSide = true;
dtOptions.ajax = ::this.fetchData;
}
this.table = jQuery(this.domTable).DataTable(dtOptions);
if (this.props.refreshInterval) {
this.refreshIntervalId = setInterval(() => this.refresh(), this.props.refreshInterval);
}
this.table.on('destroy.dt', () => {
clearInterval(this.refreshIntervalId);
clearTimeout(this.refreshTimeoutId);
});
// noinspection JSIgnoredPromiseFromCall
this.fetchAndNotifySelectionData();
}
componentDidUpdate(prevProps, prevState) {
if (this.props.data) {
this.table.clear();
this.table.rows.add(this.props.data);
} else {
// XXX: Changing URL changing from data to dataUrl is not implemented
this.refresh();
}
const self = this;
this.table.rows().every(function() {
const key = this.data()[self.props.selectionKeyIndex];
if (self.selectionMap.has(key)) {
jQuery(this.node()).addClass('selected');
} else {
jQuery(this.node()).removeClass('selected');
}
});
this.updateSelectInfo();
// noinspection JSIgnoredPromiseFromCall
this.fetchAndNotifySelectionData();
}
componentWillUnmount() {
this.mounted = false;
clearInterval(this.refreshIntervalId);
clearTimeout(this.refreshTimeoutId);
}
async notifySelection(eventCallback, newSelectionMap) {
if (this.mounted && eventCallback) {
const selPairs = Array.from(newSelectionMap).sort((l, r) => l[0] - r[0]);
let data = selPairs.map(entry => entry[1]);
let sel = selPairs.map(entry => entry[0]);
if (this.props.selectMode === TableSelectMode.SINGLE && !this.props.selectionAsArray) {
if (sel.length) {
sel = sel[0];
data = data[0];
} else {
sel = null;
data = null;
}
}
await eventCallback(sel, data);
}
}
async deselectAll(evt) {
evt.preventDefault();
// noinspection JSIgnoredPromiseFromCall
this.notifySelection(this.props.onSelectionChangedAsync, new Map());
}
render() {
const t = this.props.t;
const props = this.props;
let className = 'table table-striped table-bordered';
if (this.props.selectMode !== TableSelectMode.NONE) {
className += ' table-hover';
}
return (
<div>
<table ref={(domElem) => { this.domTable = domElem; }} className={className} cellSpacing="0" width="100%" />
</div>
);
}
}
export {
Table,
TableSelectMode
}

View file

@ -1,392 +0,0 @@
'use strict';
import React, {Component} from 'react';
import ReactDOMServer from 'react-dom/server';
import {withTranslation} from './i18n';
import PropTypes from 'prop-types';
import jQuery from 'jquery';
import '../../static/jquery/jquery-ui-1.12.1.min.js';
import '../../static/fancytree/jquery.fancytree-all.min.js';
import '../../static/fancytree/skin-bootstrap/ui.fancytree.min.css';
import './tree.scss';
import axios from './axios';
import {withPageHelpers} from './page'
import {withAsyncErrorHandler, withErrorHandling} from './error-handling';
import styles from "./styles.scss";
import {getUrl} from "./urls";
import {withComponentMixins} from "./decorator-helpers";
const TreeSelectMode = {
NONE: 0,
SINGLE: 1,
MULTI: 2
};
@withComponentMixins([
withTranslation,
withErrorHandling,
withPageHelpers
], ['refresh'])
class TreeTable extends Component {
constructor(props) {
super(props);
this.mounted = false;
this.state = {
treeData: null
};
if (props.data) {
this.state.treeData = props.data;
}
// Select Mode simply cannot be changed later. This is just to make sure we avoid inconsistencies if someone changes it anyway.
this.selectMode = this.props.selectMode;
}
static defaultProps = {
selectMode: TreeSelectMode.NONE
}
refresh() {
if (this.tree && !this.props.data && this.props.dataUrl) {
// noinspection JSIgnoredPromiseFromCall
this.loadData();
}
}
@withAsyncErrorHandler
async loadData() {
const response = await axios.get(getUrl(this.props.dataUrl));
const treeData = response.data;
for (const root of treeData) {
root.expanded = true;
for (const child of root.children) {
child.expanded = true;
}
}
if (this.mounted) {
this.setState({
treeData
});
}
}
static propTypes = {
dataUrl: PropTypes.string,
data: PropTypes.array,
selectMode: PropTypes.number,
selection: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]),
onSelectionChangedAsync: PropTypes.func,
actions: PropTypes.func,
withHeader: PropTypes.bool,
withDescription: PropTypes.bool,
noTable: PropTypes.bool,
withIcons: PropTypes.bool,
className: PropTypes.string
}
shouldComponentUpdate(nextProps, nextState) {
return this.props.selection !== nextProps.selection || this.props.data !== nextProps.data || this.props.dataUrl !== nextProps.dataUrl ||
this.state.treeData != nextState.treeData || this.props.className !== nextProps.className;
}
// XSS protection
sanitizeTreeData(unsafeData) {
const data = [];
if (unsafeData) {
for (const unsafeEntry of unsafeData) {
const entry = Object.assign({}, unsafeEntry);
entry.unsanitizedTitle = entry.title;
entry.title = ReactDOMServer.renderToStaticMarkup(<div>{entry.title}</div>);
entry.description = ReactDOMServer.renderToStaticMarkup(<div>{entry.description}</div>);
if (entry.children) {
entry.children = this.sanitizeTreeData(entry.children);
}
data.push(entry);
}
}
return data;
}
componentDidMount() {
this.mounted = true;
if (!this.props.data && this.props.dataUrl) {
// noinspection JSIgnoredPromiseFromCall
this.loadData();
}
let createNodeFn;
createNodeFn = (event, data) => {
const node = data.node;
const tdList = jQuery(node.tr).find(">td");
let tdIdx = 1;
if (this.props.withDescription) {
const descHtml = node.data.description; // This was already sanitized in sanitizeTreeData when the data was loaded
tdList.eq(tdIdx).html(descHtml);
tdIdx += 1;
}
if (this.props.actions) {
const linksContainer = jQuery(`<span class="${styles.actionLinks}"/>`);
const actions = this.props.actions(node);
for (const action of actions) {
if (action.action) {
const html = ReactDOMServer.renderToStaticMarkup(<a href="">{action.label}</a>);
const elem = jQuery(html);
elem.click((evt) => { evt.preventDefault(); action.action(this) });
linksContainer.append(elem);
} else if (action.link) {
const html = ReactDOMServer.renderToStaticMarkup(<a href={action.link}>{action.label}</a>);
const elem = jQuery(html);
elem.click((evt) => { evt.preventDefault(); this.navigateTo(action.link) });
linksContainer.append(elem);
} else if (action.href) {
const html = ReactDOMServer.renderToStaticMarkup(<a href={action.href}>{action.label}</a>);
const elem = jQuery(html);
linksContainer.append(elem);
} else {
const html = ReactDOMServer.renderToStaticMarkup(<span>{action.label}</span>);
const elem = jQuery(html);
linksContainer.append(elem);
}
}
tdList.eq(tdIdx).html(linksContainer);
tdIdx += 1;
}
};
const treeOpts = {
extensions: ['glyph'],
glyph: {
map: {
expanderClosed: 'fas fa-angle-right',
expanderLazy: 'fas fa-angle-right', // glyphicon-plus-sign
expanderOpen: 'fas fa-angle-down', // glyphicon-collapse-down
checkbox: 'fas fa-square',
checkboxSelected: 'fas fa-check-square',
folder: 'fas fa-folder',
folderOpen: 'fas fa-folder-open',
doc: 'fas fa-file',
docOpen: 'fas fa-file'
}
},
selectMode: (this.selectMode === TreeSelectMode.MULTI ? 2 : 1),
icon: !!this.props.withIcons,
autoScroll: true,
scrollParent: jQuery(this.domTableContainer),
source: this.sanitizeTreeData(this.state.treeData),
toggleEffect: false,
createNode: createNodeFn,
checkbox: this.selectMode === TreeSelectMode.MULTI,
activate: (this.selectMode === TreeSelectMode.SINGLE ? ::this.onActivate : null),
deactivate: (this.selectMode === TreeSelectMode.SINGLE ? ::this.onActivate : null),
select: (this.selectMode === TreeSelectMode.MULTI ? ::this.onSelect : null),
};
if (!this.props.noTable) {
treeOpts.extensions.push('table');
treeOpts.table = {
nodeColumnIdx: 0
};
}
this.tree = jQuery(this.domTable).fancytree(treeOpts).fancytree("getTree");
this.updateSelection();
}
componentDidUpdate(prevProps, prevState) {
if (this.props.data) {
this.setState({
treeData: this.props.data
});
} else if (this.props.dataUrl && prevProps.dataUrl !== this.props.dataUrl) {
// noinspection JSIgnoredPromiseFromCall
this.loadData();
}
if (this.props.selection !== prevProps.selection || this.state.treeData != prevState.treeData) {
if (this.state.treeData != prevState.treeData) {
this.tree.reload(this.sanitizeTreeData(this.state.treeData));
}
this.updateSelection();
}
}
componentWillUnmount() {
this.mounted = false;
}
updateSelection() {
const tree = this.tree;
if (this.selectMode === TreeSelectMode.MULTI) {
const selectSet = new Set(this.props.selection.map(key => this.stringifyKey(key)));
tree.enableUpdate(false);
tree.visit(node => node.setSelected(selectSet.has(node.key)));
tree.enableUpdate(true);
} else if (this.selectMode === TreeSelectMode.SINGLE) {
let selection = this.stringifyKey(this.props.selection);
if (this.state.treeData) {
if (!tree.getNodeByKey(selection)) {
selection = null;
}
if (selection === null && !this.tree.getActiveNode()) {
// This covers the case when we mount the tree and selection is not present in the tree.
// At this point, nothing is selected, so the onActive event won't trigger. So we have to
// call it manually, so that the form can update and set null instead of the invalid selection.
this.onActivate();
} else {
tree.activateKey(selection);
}
}
}
}
@withAsyncErrorHandler
async onSelectionChanged(sel) {
if (this.props.onSelectionChangedAsync) {
await this.props.onSelectionChangedAsync(sel);
}
}
stringifyKey(key) {
if (key !== null && key !== undefined) {
return key.toString();
} else {
return key;
}
}
destringifyKey(key) {
if (/^(\-|\+)?([0-9]+|Infinity)$/.test(key)) {
return Number(key);
} else {
return key;
}
}
// Single-select
onActivate(event, data) {
const activeNode = this.tree.getActiveNode();
const selection = activeNode ? this.destringifyKey(activeNode.key) : null;
if (selection !== this.props.selection) {
// noinspection JSIgnoredPromiseFromCall
this.onSelectionChanged(selection);
}
}
// Multi-select
onSelect(event, data) {
const newSel = this.tree.getSelectedNodes().map(node => this.destringifyKey(node.key)).sort();
const oldSel = this.props.selection;
let updated = false;
const length = oldSel.length;
if (length === newSel.length) {
for (let i = 0; i < length; i++) {
if (oldSel[i] !== newSel[i]) {
updated = true;
break;
}
}
} else {
updated = true;
}
if (updated) {
// noinspection JSIgnoredPromiseFromCall
this.onSelectionChanged(newSel);
}
}
render() {
const t = this.props.t;
const props = this.props;
const actions = props.actions;
const withHeader = props.withHeader;
const withDescription = props.withDescription;
let containerClass = 'mt-treetable-container ' + (this.props.className || '');
if (this.selectMode === TreeSelectMode.NONE) {
containerClass += ' mt-treetable-inactivable';
} else {
if (!props.noTable) {
containerClass += ' table-hover';
}
}
if (!this.withHeader) {
containerClass += ' mt-treetable-noheader';
}
// FIXME: style={{ height: '100px', overflow: 'auto'}}
if (props.noTable) {
return (
<div className={containerClass} ref={(domElem) => { this.domTableContainer = domElem; }} >
<div ref={(domElem) => { this.domTable = domElem; }}>
</div>
</div>
);
} else {
let tableClass = 'table table-striped table-condensed';
if (this.selectMode !== TreeSelectMode.NONE) {
tableClass += ' table-hover';
}
return (
<div className={containerClass} ref={(domElem) => { this.domTableContainer = domElem; }} >
<table ref={(domElem) => { this.domTable = domElem; }} className={tableClass}>
{props.withHeader &&
<thead>
<tr>
<th className="mt-treetable-title">{t('name')}</th>
{withDescription && <th>{t('description')}</th>}
{actions && <th></th>}
</tr>
</thead>
}
<tbody>
<tr>
<td></td>
{withDescription && <td></td>}
{actions && <td></td>}
</tr>
</tbody>
</table>
</div>
);
}
}
}
export {
TreeTable,
TreeSelectMode
}

View file

@ -1,92 +0,0 @@
@import "../scss/variables.scss";
:global {
.mt-treetable-container .fancytree-container {
border: none;
}
.mt-treetable-container span.fancytree-expander {
color: #333333;
}
.mt-treetable-container.mt-treetable-inactivable>table.fancytree-ext-table.fancytree-container>tbody>tr.fancytree-active>td,
.mt-treetable-container.mt-treetable-inactivable>table.fancytree-ext-table.fancytree-container>tbody>tr.fancytree-active:hover>td,
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active span.fancytree-title,
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active span.fancytree-title:hover,
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active:hover span.fancytree-title,
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node span.fancytree-title:hover {
background-color: transparent;
}
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node span.fancytree-title {
cursor: default;
}
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active span.fancytree-title,
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active span.fancytree-title:hover {
border-color: transparent;
}
.mt-treetable-container.mt-treetable-inactivable>table.fancytree-ext-table.fancytree-container>tbody>tr.fancytree-active>td span.fancytree-title,
.mt-treetable-container.mt-treetable-inactivable>table.fancytree-ext-table.fancytree-container>tbody>tr.fancytree-active>td span.fancytree-expander,
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active span.fancytree-title,
.mt-treetable-container.mt-treetable-inactivable .fancytree-container>tbody>tr.fancytree-active>td {
outline: 0px none;
color: #333333;
}
.mt-treetable-container span.fancytree-node span.fancytree-expander:hover {
color: inherit;
}
.mt-treetable-container {
padding-top: 9px;
padding-bottom: 9px;
}
.mt-treetable-container>table.fancytree-ext-table {
margin-bottom: 0px;
}
.mt-treetable-container.mt-treetable-noheader>.table>tbody>tr>td {
border-top: 0px none;
}
.mt-treetable-container .mt-treetable-title {
min-width: 150px;
}
.form-group .mt-treetable-container {
border: $input-border-width solid $input-border-color;
border-radius: $input-border-radius;
padding-top: $input-padding-y;
padding-bottom: $input-padding-y;
-webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
-webkit-transition: border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;
-o-transition: border-color ease-in-out .15s,box-shadow ease-in-out .15s;
transition: border-color ease-in-out .15s,box-shadow ease-in-out .15s;
}
.form-group .mt-treetable-container.is-valid {
border-color: $form-feedback-valid-color;
-webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
}
.form-group .mt-treetable-container.is-invalid {
border-color: $form-feedback-invalid-color;
-webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
}
.mt-treetable-container .table td {
padding-top: 3px;
padding-bottom: 3px;
}
}

View file

@ -1,331 +0,0 @@
'use strict';
import React, {Component} from "react";
import PropTypes from "prop-types";
import {withTranslation} from './i18n';
import {requiresAuthenticatedUser, withPageHelpers} from "./page";
import {withAsyncErrorHandler, withErrorHandling} from "./error-handling";
import axios from "./axios";
import styles from "./styles.scss";
import {getSandboxUrl, getUrl, setRestrictedAccessToken} from "./urls";
import {withComponentMixins} from "./decorator-helpers";
@withComponentMixins([
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
], ['ask'])
export class UntrustedContentHost extends Component {
constructor(props) {
super(props);
this.refreshAccessTokenTimeout = null;
this.accessToken = null;
this.contentNodeIsLoaded = false;
this.state = {
hasAccessToken: false
};
this.receiveMessageHandler = ::this.receiveMessage;
this.contentNodeRefHandler = node => this.contentNode = node;
this.rpcCounter = 0;
this.rpcResolves = new Map();
}
static propTypes = {
contentSrc: PropTypes.string,
contentProps: PropTypes.object,
tokenMethod: PropTypes.string,
tokenParams: PropTypes.object,
className: PropTypes.string,
singleToken: PropTypes.bool,
onMethodAsync: PropTypes.func
}
isInitialized() {
return !!this.accessToken && !!this.props.contentProps;
}
async receiveMessage(evt) {
const msg = evt.data;
if (msg.type === 'initNeeded') {
// It seems that sometime the message that the content node does not arrive. However if the content root notifies us, we just proceed
this.contentNodeIsLoaded = true;
if (this.isInitialized()) {
this.sendMessage('init', {
accessToken: this.accessToken,
contentProps: this.props.contentProps
});
}
} else if (msg.type === 'rpcResponse') {
const resolve = this.rpcResolves.get(msg.data.msgId);
resolve(msg.data.ret);
} else if (msg.type === 'rpcRequest') {
const ret = await this.props.onMethodAsync(msg.data.method, msg.data.params);
this.sendMessage('rpcResponse', {msgId: msg.data.msgId, ret});
} else if (msg.type === 'clientHeight') {
const newHeight = msg.data;
this.contentNode.height = newHeight;
}
}
sendMessage(type, data) {
if (this.contentNodeIsLoaded && this.contentNode) { // This is to avoid errors: Failed to execute 'postMessage' on 'DOMWindow': The target origin provided ('http://localhost:8081') does not match the recipient window's origin ('http://localhost:3000')"
// When the child window is closed during processing of the message, the this.contentNode becomes null and we can't deliver the response
this.contentNode.contentWindow.postMessage({type, data}, getSandboxUrl());
}
}
async ask(method, params) {
if (this.contentNodeIsLoaded) {
this.rpcCounter += 1;
const msgId = this.rpcCounter;
this.sendMessage('rpcRequest', {
method,
params,
msgId
});
return await (new Promise((resolve, reject) => {
this.rpcResolves.set(msgId, resolve);
}));
}
}
@withAsyncErrorHandler
async refreshAccessToken() {
if (this.props.singleToken && this.accessToken) {
await axios.put(getUrl('rest/restricted-access-token'), {
token: this.accessToken
});
} else {
const result = await axios.post(getUrl('rest/restricted-access-token'), {
method: this.props.tokenMethod,
params: this.props.tokenParams
});
this.accessToken = result.data;
if (!this.state.hasAccessToken) {
this.setState({
hasAccessToken: true
})
}
this.sendMessage('accessToken', this.accessToken);
}
}
scheduleRefreshAccessToken() {
this.refreshAccessTokenTimeout = setTimeout(() => {
// noinspection JSIgnoredPromiseFromCall
this.refreshAccessToken();
this.scheduleRefreshAccessToken();
}, 30 * 1000);
}
handleUpdate() {
if (this.isInitialized()) {
this.sendMessage('initAvailable');
}
if (!this.state.hasAccessToken) {
// noinspection JSIgnoredPromiseFromCall
this.refreshAccessToken();
}
}
componentDidMount() {
this.scheduleRefreshAccessToken();
window.addEventListener('message', this.receiveMessageHandler, false);
this.handleUpdate();
}
componentDidUpdate() {
this.handleUpdate();
}
componentWillUnmount() {
clearTimeout(this.refreshAccessTokenTimeout);
window.removeEventListener('message', this.receiveMessageHandler, false);
}
contentNodeLoaded() {
this.contentNodeIsLoaded = true;
}
render() {
return (
// The 40 px below corresponds to the height in .sandbox-loading-message
<iframe className={styles.untrustedContent + ' ' + this.props.className} height="40px" ref={this.contentNodeRefHandler} src={getSandboxUrl(this.props.contentSrc)} onLoad={::this.contentNodeLoaded}></iframe>
);
}
}
@withComponentMixins([
withTranslation
])
export class UntrustedContentRoot extends Component {
constructor(props) {
super(props);
this.state = {
initialized: false,
};
this.receiveMessageHandler = ::this.receiveMessage;
this.periodicTimeoutHandler = ::this.onPeriodicTimeout;
this.periodicTimeoutId = 0;
this.clientHeight = 0;
}
static propTypes = {
render: PropTypes.func
}
onPeriodicTimeout() {
const newHeight = document.body.clientHeight;
if (this.clientHeight !== newHeight) {
this.clientHeight = newHeight;
this.sendMessage('clientHeight', newHeight);
}
this.periodicTimeoutId = setTimeout(this.periodicTimeoutHandler, 250);
}
async receiveMessage(evt) {
const msg = evt.data;
if (msg.type === 'initAvailable') {
this.sendMessage('initNeeded');
} else if (msg.type === 'init') {
setRestrictedAccessToken(msg.data.accessToken);
this.setState({
initialized: true,
contentProps: msg.data.contentProps
});
} else if (msg.type === 'accessToken') {
setRestrictedAccessToken(msg.data);
}
}
sendMessage(type, data) {
window.parent.postMessage({type, data}, '*');
}
componentDidMount() {
window.addEventListener('message', this.receiveMessageHandler, false);
this.periodicTimeoutId = setTimeout(this.periodicTimeoutHandler, 0);
this.sendMessage('initNeeded');
}
componentWillUnmount() {
window.removeEventListener('message', this.receiveMessageHandler, false);
clearTimeout(this.periodicTimeoutId);
}
render() {
const t = this.props.t;
if (this.state.initialized) {
return this.props.render(this.state.contentProps);
} else {
return (
<div className="sandbox-loading-message">
{t('loading')}
</div>
);
}
}
}
class ParentRPC {
constructor(props) {
this.receiveMessageHandler = ::this.receiveMessage;
this.rpcCounter = 0;
this.rpcResolves = new Map();
this.methodHandlers = new Map();
this.initialized = false;
}
init() {
window.addEventListener('message', this.receiveMessageHandler, false);
this.initialized = true;
}
setMethodHandler(method, handler) {
this.enforceInitialized();
this.methodHandlers.set(method, handler);
}
clearMethodHandler(method) {
this.enforceInitialized();
this.methodHandlers.delete(method);
}
async ask(method, params) {
this.enforceInitialized();
this.rpcCounter += 1;
const msgId = this.rpcCounter;
this.sendMessage('rpcRequest', {
method,
params,
msgId
});
return await (new Promise((resolve, reject) => {
this.rpcResolves.set(msgId, resolve);
}));
}
// ---------------------------------------------------------------------------
// Private methods
enforceInitialized() {
if (!this.initialized) {
throw new Error('ParentRPC not initialized');
}
}
async receiveMessage(evt) {
const msg = evt.data;
if (msg.type === 'rpcResponse') {
const resolve = this.rpcResolves.get(msg.data.msgId);
resolve(msg.data.ret);
} else if (msg.type === 'rpcRequest') {
let ret;
const method = msg.data.method;
if (this.methodHandlers.has(method)) {
const handler = this.methodHandlers.get(method);
ret = await handler(method, msg.data.params);
}
this.sendMessage('rpcResponse', {msgId: msg.data.msgId, ret});
}
}
sendMessage(type, data) {
window.parent.postMessage({type, data}, '*');
}
}
export const parentRPC = new ParentRPC();

View file

@ -1,60 +0,0 @@
'use strict';
import {anonymousRestrictedAccessToken} from '../../../shared/urls';
import {AppType} from '../../../shared/app';
import mailtrainConfig from "mailtrainConfig";
import i18n from './i18n';
let restrictedAccessToken = anonymousRestrictedAccessToken;
function setRestrictedAccessToken(token) {
restrictedAccessToken = token;
}
function getTrustedUrl(path) {
return mailtrainConfig.trustedUrlBase + (path || '');
}
function getSandboxUrl(path, customRestrictedAccessToken) {
const localRestrictedAccessToken = customRestrictedAccessToken || restrictedAccessToken;
return mailtrainConfig.sandboxUrlBase + localRestrictedAccessToken + '/' + (path || '');
}
function getPublicUrl(path, opts) {
const url = new URL(path || '', mailtrainConfig.publicUrlBase);
if (opts && opts.withLocale) {
url.searchParams.append('locale', i18n.language);
}
return url.toString();
}
function getUrl(path) {
if (mailtrainConfig.appType === AppType.TRUSTED) {
return getTrustedUrl(path);
} else if (mailtrainConfig.appType === AppType.SANDBOXED) {
return getSandboxUrl(path);
} else if (mailtrainConfig.appType === AppType.PUBLIC) {
return getPublicUrl(path);
}
}
function getBaseDir() {
if (mailtrainConfig.appType === AppType.TRUSTED) {
return mailtrainConfig.trustedUrlBaseDir;
} else if (mailtrainConfig.appType === AppType.SANDBOXED) {
return mailtrainConfig.sandboxUrlBaseDir + restrictedAccessToken;
} else if (mailtrainConfig.appType === AppType.PUBLIC) {
return mailtrainConfig.publicUrlBaseDir;
}
}
export {
getTrustedUrl,
getSandboxUrl,
getPublicUrl,
getUrl,
getBaseDir,
setRestrictedAccessToken
}

View file

@ -1,296 +0,0 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {Trans} from 'react-i18next';
import {withTranslation} from '../lib/i18n';
import {LinkButton, requiresAuthenticatedUser, Title, withPageHelpers} from '../lib/page';
import {
Button,
ButtonRow,
CheckBox,
Dropdown,
filterData,
Form,
FormSendMethod,
InputField,
StaticField,
TableSelect,
TextArea,
withForm,
withFormErrorHandlers
} from '../lib/form';
import {withErrorHandling} from '../lib/error-handling';
import {DeleteModalDialog} from '../lib/modals';
import {getDefaultNamespace, NamespaceSelect, validateNamespace} from '../lib/namespace';
import {FieldWizard, UnsubscriptionMode} from '../../../shared/lists';
import styles from "../lib/styles.scss";
import {getMailerTypes} from "../send-configurations/helpers";
import {withComponentMixins} from "../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class CUD extends Component {
constructor(props) {
super(props);
this.state = {};
this.initForm();
this.mailerTypes = getMailerTypes(props.t);
}
static propTypes = {
action: PropTypes.string.isRequired,
entity: PropTypes.object,
permissions: PropTypes.object
}
getFormValuesMutator(data) {
data.form = data.default_form ? 'custom' : 'default';
data.listunsubscribe_disabled = !!data.listunsubscribe_disabled;
}
submitFormValuesMutator(data) {
if (data.form === 'default') {
data.default_form = null;
}
if (data.fieldWizard === FieldWizard.FIRST_LAST_NAME || data.fieldWizard === FieldWizard.NAME) {
data.to_name = null;
}
return filterData(data, ['name', 'description', 'default_form', 'public_subscribe', 'unsubscription_mode',
'contact_email', 'homepage', 'namespace', 'to_name', 'listunsubscribe_disabled', 'send_configuration',
'fieldWizard'
]);
}
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity);
} else {
this.populateFormValues({
name: '',
description: '',
form: 'default',
default_form: 'default',
public_subscribe: true,
contact_email: '',
homepage: '',
unsubscription_mode: UnsubscriptionMode.ONE_STEP,
namespace: getDefaultNamespace(this.props.permissions),
to_name: '',
fieldWizard: FieldWizard.FIRST_LAST_NAME,
send_configuration: null,
listunsubscribe_disabled: false
});
}
}
localValidateFormValues(state) {
const t = this.props.t;
if (!state.getIn(['name', 'value'])) {
state.setIn(['name', 'error'], t('nameMustNotBeEmpty'));
} else {
state.setIn(['name', 'error'], null);
}
if (!state.getIn(['send_configuration', 'value'])) {
state.setIn(['send_configuration', 'error'], t('sendConfigurationMustBeSelected'));
} else {
state.setIn(['send_configuration', 'error'], null);
}
if (state.getIn(['form', 'value']) === 'custom' && !state.getIn(['default_form', 'value'])) {
state.setIn(['default_form', 'error'], t('customFormMustBeSelected'));
} else {
state.setIn(['default_form', 'error'], null);
}
validateNamespace(t, state);
}
@withFormErrorHandlers
async submitHandler(submitAndLeave) {
const t = this.props.t;
let sendMethod, url;
if (this.props.entity) {
sendMethod = FormSendMethod.PUT;
url = `rest/lists/${this.props.entity.id}`
} else {
sendMethod = FormSendMethod.POST;
url = 'rest/lists'
}
this.disableForm();
this.setFormStatusMessage('info', t('saving'));
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url);
if (submitResult) {
if (this.props.entity) {
if (submitAndLeave) {
this.navigateToWithFlashMessage('/lists', 'success', t('listUpdated'));
} else {
await this.getFormValuesFromURL(`rest/lists/${this.props.entity.id}`);
this.enableForm();
this.setFormStatusMessage('success', t('listUpdated'));
}
} else {
if (submitAndLeave) {
this.navigateToWithFlashMessage('/lists', 'success', t('listCreated'));
} else {
this.navigateToWithFlashMessage(`/lists/${submitResult}/edit`, 'success', t('listCreated'));
}
}
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
}
}
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
const canDelete = isEdit && this.props.entity.permissions.includes('delete');
const unsubcriptionModeOptions = [
{
key: UnsubscriptionMode.ONE_STEP,
label: t('onestepIeNoEmailWithConfirmationLink')
},
{
key: UnsubscriptionMode.ONE_STEP_WITH_FORM,
label: t('onestepWithUnsubscriptionFormIeNoEmail')
},
{
key: UnsubscriptionMode.TWO_STEP,
label: t('twostepIeAnEmailWithConfirmationLinkWill')
},
{
key: UnsubscriptionMode.TWO_STEP_WITH_FORM,
label: t('twostepWithUnsubscriptionFormIeAnEmail')
},
{
key: UnsubscriptionMode.MANUAL,
label: t('manualIeUnsubscriptionHasToBePerformedBy')
}
];
const formsOptions = [
{
key: 'default',
label: t('defaultMailtrainForms')
},
{
key: 'custom',
label: t('customFormsSelectFormBelow')
}
];
const customFormsColumns = [
{data: 0, title: "#"},
{data: 1, title: t('name')},
{data: 2, title: t('description')},
{data: 3, title: t('namespace')}
];
const sendConfigurationsColumns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 3, title: t('description') },
{ data: 4, title: t('type'), render: data => this.mailerTypes[data].typeName },
{ data: 6, title: t('namespace') }
];
let toNameFields;
if (isEdit) {
toNameFields = <InputField id="to_name" label={t('recipientsNameTemplate')} help={t('specifyUsingMergeTagsOfThisListHowTo')}/>;
} else {
const fieldWizardOptions = [
{key: FieldWizard.NONE, label: t('emptyCustomNoFields')},
{key: FieldWizard.NAME, label: t('nameOneField')},
{key: FieldWizard.FIRST_LAST_NAME, label: t('firstNameAndLastNameTwoFields')},
];
const fieldWizardValue = this.getFormValue('fieldWizard');
const fieldWizardSelector = <Dropdown id="fieldWizard" label={t('representationOfSubscribersName')} options={fieldWizardOptions} help={t('selectHowTheNameOfASubscriberWillBe')}/>
if (fieldWizardValue === FieldWizard.NONE) {
toNameFields = (
<>
{fieldWizardSelector}
<InputField id="to_name" label={t('recipientsNameTemplate')} help={t('specifyUsingMergeTagsOfThisListHowTo')}/>
</>
);
} else {
toNameFields = fieldWizardSelector;
}
}
return (
<div>
{canDelete &&
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
deleteUrl={`rest/lists/${this.props.entity.id}`}
backUrl={`/lists/${this.props.entity.id}/edit`}
successUrl="/lists"
deletingMsg={t('deletingList')}
deletedMsg={t('listDeleted')}/>
}
<Title>{isEdit ? t('editList') : t('createList')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('name')}/>
{isEdit &&
<StaticField id="cid" className={styles.formDisabled} label={t('id')} help={t('thisIsTheListIdDisplayedToTheSubscribers')}>
{this.getFormValue('cid')}
</StaticField>
}
<TextArea id="description" label={t('description')}/>
<InputField id="contact_email" label={t('contactEmail')} help={t('contactEmailUsedInSubscriptionFormsAnd')}/>
<InputField id="homepage" label={t('homepage')} help={t('homepageUrlUsedInSubscriptionFormsAnd')}/>
{toNameFields}
<TableSelect id="send_configuration" label={t('sendConfiguration')} withHeader dropdown dataUrl='rest/send-configurations-table' columns={sendConfigurationsColumns} selectionLabelIndex={1} help={t('sendConfigurationThatWillBeUsedFor')}/>
<NamespaceSelect/>
<Dropdown id="form" label={t('forms')} options={formsOptions} help={t('webAndEmailFormsAndTemplatesUsedIn')}/>
{this.getFormValue('form') === 'custom' &&
<TableSelect id="default_form" label={t('customForms')} withHeader dropdown dataUrl='rest/forms-table' columns={customFormsColumns} selectionLabelIndex={1} help={<Trans i18nKey="theCustomFormUsedForThisListYouCanCreate">The custom form used for this list. You can create a form <a href={`/lists/forms/create`}>here</a>.</Trans>}/>
}
<CheckBox id="public_subscribe" label={t('subscription')} text={t('allowPublicUsersToSubscribeThemselves')}/>
<Dropdown id="unsubscription_mode" label={t('unsubscription')} options={unsubcriptionModeOptions} help={t('selectHowAnUnsuscriptionRequestBy')}/>
<CheckBox id="listunsubscribe_disabled" label={t('unsubscribeHeader')} text={t('doNotSendListUnsubscribeHeaders')}/>
<ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndLeave')} onClickAsync={async () => await this.submitHandler(true)}/>
{canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/${this.props.entity.id}/delete`}/>}
</ButtonRow>
</Form>
</div>
);
}
}

View file

@ -1,137 +0,0 @@
'use strict';
import React, {Component} from 'react';
import {withTranslation} from '../lib/i18n';
import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../lib/page';
import {withErrorHandling} from '../lib/error-handling';
import {Table} from '../lib/table';
import {Icon} from "../lib/bootstrap-components";
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals";
import {withComponentMixins} from "../lib/decorator-helpers";
import {withForm} from "../lib/form";
import PropTypes from 'prop-types';
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class List extends Component {
constructor(props) {
super(props);
this.state = {};
tableRestActionDialogInit(this);
}
static propTypes = {
permissions: PropTypes.object
}
render() {
const t = this.props.t;
const permissions = this.props.permissions;
const createPermitted = permissions.createList;
const customFormsPermitted = permissions.createCustomForm || permissions.viewCustomForm;
const columns = [
{
data: 1,
title: t('name'),
actions: data => {
const perms = data[7];
if (perms.includes('viewSubscriptions')) {
return [{label: data[1], link: `/lists/${data[0]}/subscriptions`}];
} else {
return [{label: data[1]}];
}
}
},
{ data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 3, title: t('subscribers') },
{ data: 4, title: t('description') },
{ data: 5, title: t('namespace') },
{
actions: data => {
const actions = [];
const triggersCount = data[6];
const perms = data[7];
if (perms.includes('viewSubscriptions')) {
actions.push({
label: <Icon icon="user" title="Subscribers"/>,
link: `/lists/${data[0]}/subscriptions`
});
}
if (perms.includes('edit')) {
actions.push({
label: <Icon icon="edit" title={t('edit')}/>,
link: `/lists/${data[0]}/edit`
});
}
if (perms.includes('viewFields')) {
actions.push({
label: <Icon icon="th-list" title={t('fields')}/>,
link: `/lists/${data[0]}/fields`
});
}
if (perms.includes('viewSegments')) {
actions.push({
label: <Icon icon="tags" title={t('segments')}/>,
link: `/lists/${data[0]}/segments`
});
}
if (perms.includes('viewImports')) {
actions.push({
label: <Icon icon="file-import" title={t('imports')}/>,
link: `/lists/${data[0]}/imports`
});
}
if (triggersCount > 0) {
actions.push({
label: <Icon icon="bell" title={t('triggers')}/>,
link: `/lists/${data[0]}/triggers`
});
}
if (perms.includes('share')) {
actions.push({
label: <Icon icon="share" title={t('share')}/>,
link: `/lists/${data[0]}/share`
});
}
tableAddDeleteButton(actions, this, perms, `rest/lists/${data[0]}`, data[1], t('deletingList'), t('listDeleted'));
return actions;
}
}
];
return (
<div>
{tableRestActionDialogRender(this)}
<Toolbar>
{ createPermitted &&
<LinkButton to="/lists/create" className="btn-primary" icon="plus" label={t('createList')}/>
}
{ customFormsPermitted &&
<LinkButton to="/lists/forms" className="btn-primary" label={t('customForms-1')}/>
}
</Toolbar>
<Title>{t('lists')}</Title>
<Table ref={node => this.table = node} withHeader dataUrl="rest/lists-table" columns={columns} />
</div>
);
}
}

View file

@ -1,82 +0,0 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withTranslation} from '../lib/i18n';
import {requiresAuthenticatedUser, Title, withPageHelpers} from '../lib/page';
import {withErrorHandling} from '../lib/error-handling';
import {Table} from '../lib/table';
import {getTriggerTypes} from '../campaigns/triggers/helpers';
import {Icon} from "../lib/bootstrap-components";
import mailtrainConfig from 'mailtrainConfig';
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals";
import {withComponentMixins} from "../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class List extends Component {
constructor(props) {
super(props);
const {entityLabels, eventLabels} = getTriggerTypes(props.t);
this.entityLabels = entityLabels;
this.eventLabels = eventLabels;
this.state = {};
tableRestActionDialogInit(this);
}
static propTypes = {
list: PropTypes.object
}
componentDidMount() {
}
render() {
const t = this.props.t;
const columns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('description') },
{ data: 3, title: t('campaign') },
{ data: 4, title: t('entity'), render: data => this.entityLabels[data], searchable: false },
{ data: 5, title: t('event'), render: (data, cmd, rowData) => this.eventLabels[rowData[4]][data], searchable: false },
{ data: 6, title: t('daysAfter'), render: data => Math.round(data / (3600 * 24)) },
{ data: 7, title: t('enabled'), render: data => data ? t('yes') : t('no'), searchable: false},
{
actions: data => {
const actions = [];
const perms = data[9];
const campaignId = data[8];
if (mailtrainConfig.globalPermissions.setupAutomation && perms.includes('manageTriggers')) {
actions.push({
label: <Icon icon="edit" title={t('edit')}/>,
link: `/campaigns/${campaignId}/triggers/${data[0]}/edit`
});
}
if (perms.includes('manageTriggers')) {
tableAddDeleteButton(actions, this, null, `rest/triggers/${campaignId}/${data[0]}`, data[1], t('deletingTrigger'), t('triggerDeleted'));
}
return actions;
}
}
];
return (
<div>
{tableRestActionDialogRender(this)}
<Title>{t('triggers')}</Title>
<Table ref={node => this.table = node} withHeader dataUrl={`rest/triggers-by-list-table/${this.props.list.id}`} columns={columns} />
</div>
);
}
}

View file

@ -1,535 +0,0 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {Trans} from 'react-i18next';
import {withTranslation} from '../../lib/i18n';
import {LinkButton, requiresAuthenticatedUser, Title, withPageHelpers} from '../../lib/page';
import {
ACEEditor,
Button,
ButtonRow,
CheckBox,
Dropdown,
Fieldset,
filterData,
Form,
FormSendMethod,
InputField,
StaticField,
TableSelect,
TextArea,
withForm,
withFormErrorHandlers
} from '../../lib/form';
import {withErrorHandling} from '../../lib/error-handling';
import {DeleteModalDialog} from "../../lib/modals";
import {getFieldTypes} from './helpers';
import validators from '../../../../shared/validators';
import slugify from 'slugify';
import {DateFormat, parseBirthday, parseDate} from '../../../../shared/date';
import styles from "../../lib/styles.scss";
import 'brace/mode/json';
import 'brace/mode/handlebars';
import {withComponentMixins} from "../../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class CUD extends Component {
constructor(props) {
super(props);
this.state = {};
this.fieldTypes = getFieldTypes(props.t);
this.initForm({
serverValidation: {
url: `rest/fields-validate/${this.props.list.id}`,
changed: ['key'],
extra: ['id']
},
onChangeBeforeValidation: {
name: ::this.onChangeName
}
});
}
static propTypes = {
action: PropTypes.string.isRequired,
list: PropTypes.object,
fields: PropTypes.array,
entity: PropTypes.object
}
onChangeName(mutStateData, attr, oldValue, newValue) {
const oldComputedKey = ('MERGE_' + slugify(oldValue, '_')).toUpperCase().replace(/[^A-Z0-9_]/g, '');
const oldKey = mutStateData.getIn(['key', 'value']);
if (oldKey === '' || oldKey === oldComputedKey) {
const newKey = ('MERGE_' + slugify(newValue, '_')).toUpperCase().replace(/[^A-Z0-9_]/g, '');
mutStateData.setIn(['key', 'value'], newKey);
}
}
getFormValuesMutator(data) {
data.settings = data.settings || {};
if (data.default_value === null) {
data.default_value = '';
}
if (data.help === null) {
data.help = '';
}
data.isInGroup = data.group !== null;
data.enumOptions = '';
data.dateFormat = DateFormat.EUR;
data.renderTemplate = '';
switch (data.type) {
case 'checkbox-grouped':
case 'radio-grouped':
case 'dropdown-grouped':
case 'json':
data.renderTemplate = data.settings.renderTemplate;
break;
case 'radio-enum':
case 'dropdown-enum':
data.enumOptions = this.renderEnumOptions(data.settings.options);
data.renderTemplate = data.settings.renderTemplate;
break;
case 'date':
case 'birthday':
data.dateFormat = data.settings.dateFormat;
break;
case 'option':
data.checkedLabel = data.isInGroup ? '' : data.settings.checkedLabel;
data.uncheckedLabel = data.isInGroup ? '' : data.settings.uncheckedLabel;
break;
}
data.orderListBefore = data.orderListBefore.toString();
data.orderSubscribeBefore = data.orderSubscribeBefore.toString();
data.orderManageBefore = data.orderManageBefore.toString();
}
submitFormValuesMutator(data) {
if (data.default_value.trim() === '') {
data.default_value = null;
}
if (data.help.trim() === '') {
data.help = null;
}
if (!data.isInGroup) {
data.group = null;
}
data.settings = {};
switch (data.type) {
case 'checkbox-grouped':
case 'radio-grouped':
case 'dropdown-grouped':
case 'json':
data.settings.renderTemplate = data.renderTemplate;
break;
case 'radio-enum':
case 'dropdown-enum':
data.settings.options = this.parseEnumOptions(data.enumOptions).options;
data.settings.renderTemplate = data.renderTemplate;
break;
case 'date':
case 'birthday':
data.settings.dateFormat = data.dateFormat;
break;
case 'option':
if (!data.isInGroup) {
data.settings.checkedLabel = data.checkedLabel;
data.settings.uncheckedLabel = data.uncheckedLabel;
}
break;
}
if (data.group !== null) {
data.orderListBefore = data.orderSubscribeBefore = data.orderManageBefore = 'none';
} else {
data.orderListBefore = Number.parseInt(data.orderListBefore) || data.orderListBefore;
data.orderSubscribeBefore = Number.parseInt(data.orderSubscribeBefore) || data.orderSubscribeBefore;
data.orderManageBefore = Number.parseInt(data.orderManageBefore) || data.orderManageBefore;
}
return filterData(data, ['name', 'help', 'key', 'default_value', 'type', 'group', 'settings',
'orderListBefore', 'orderSubscribeBefore', 'orderManageBefore']);
}
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity);
} else {
this.populateFormValues({
name: '',
type: 'text',
key: '',
default_value: '',
help: '',
group: null,
isInGroup: false,
renderTemplate: '',
enumOptions: '',
dateFormat: 'eur',
checkedLabel: '',
uncheckedLabel: '',
orderListBefore: 'end', // possible values are <numeric id> / 'end' / 'none'
orderSubscribeBefore: 'end',
orderManageBefore: 'end'
});
}
}
localValidateFormValues(state) {
const t = this.props.t;
if (!state.getIn(['name', 'value'])) {
state.setIn(['name', 'error'], t('nameMustNotBeEmpty'));
} else {
state.setIn(['name', 'error'], null);
}
const keyServerValidation = state.getIn(['key', 'serverValidation']);
if (!validators.mergeTagValid(state.getIn(['key', 'value']))) {
state.setIn(['key', 'error'], t('mergeTagIsInvalidMayMustBeUppercaseAnd'));
} else if (!keyServerValidation) {
state.setIn(['key', 'error'], t('validationIsInProgress'));
} else if (keyServerValidation.exists) {
state.setIn(['key', 'error'], t('anotherFieldWithTheSameMergeTagExists'));
} else {
state.setIn(['key', 'error'], null);
}
const type = state.getIn(['type', 'value']);
const group = state.getIn(['group', 'value']);
const isInGroup = state.getIn(['isInGroup', 'value']);
if (isInGroup && !group) {
state.setIn(['group', 'error'], t('groupHasToBeSelected'));
} else {
state.setIn(['group', 'error'], null);
}
const defaultValue = state.getIn(['default_value', 'value']);
if (defaultValue === '') {
state.setIn(['default_value', 'error'], null);
} else if (type === 'number' && !/^[0-9]*$/.test(defaultValue.trim())) {
state.setIn(['default_value', 'error'], t('defaultValueIsNotIntegerNumber'));
} else if (type === 'date' && !parseDate(state.getIn(['dateFormat', 'value']), defaultValue)) {
state.setIn(['default_value', 'error'], t('defaultValueIsNotAProperlyFormattedDate'));
} else if (type === 'birthday' && !parseBirthday(state.getIn(['dateFormat', 'value']), defaultValue)) {
state.setIn(['default_value', 'error'], t('defaultValueIsNotAProperlyFormatted'));
} else {
state.setIn(['default_value', 'error'], null);
}
if (type === 'radio-enum' || type === 'dropdown-enum') {
const enumOptions = this.parseEnumOptions(state.getIn(['enumOptions', 'value']));
if (enumOptions.errors) {
state.setIn(['enumOptions', 'error'], <div>{enumOptions.errors.map((err, idx) => <div key={idx}>{err}</div>)}</div>);
} else {
state.setIn(['enumOptions', 'error'], null);
if (defaultValue !== '' && !(enumOptions.options.find(x => x.key === defaultValue))) {
state.setIn(['default_value', 'error'], t('defaultValueIsNotOneOfTheAllowedOptions'));
}
}
} else {
state.setIn(['enumOptions', 'error'], null);
}
}
parseEnumOptions(text) {
const t = this.props.t;
const errors = [];
const options = [];
const lines = text.split('\n');
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
const line = lines[lineIdx].trim();
if (line != '') {
const matches = line.match(/^([^|]*)[|](.*)$/);
if (matches) {
const key = matches[1].trim();
const label = matches[2].trim();
options.push({ key, label });
} else {
errors.push(t('errrorOnLineLine', { line: lineIdx + 1}));
}
}
}
if (errors.length) {
return {
errors
};
} else {
return {
options
};
}
}
renderEnumOptions(options) {
return options.map(opt => `${opt.key}|${opt.label}`).join('\n');
}
@withFormErrorHandlers
async submitHandler(submitAndLeave) {
const t = this.props.t;
let sendMethod, url;
if (this.props.entity) {
sendMethod = FormSendMethod.PUT;
url = `rest/fields/${this.props.list.id}/${this.props.entity.id}`
} else {
sendMethod = FormSendMethod.POST;
url = `rest/fields/${this.props.list.id}`
}
try {
this.disableForm();
this.setFormStatusMessage('info', t('saving'));
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url);
if (submitResult) {
if (this.props.entity) {
if (submitAndLeave) {
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/fields`, 'success', t('fieldUpdated'));
} else {
await this.getFormValuesFromURL(`rest/fields/${this.props.list.id}/${this.props.entity.id}`);
this.enableForm();
this.setFormStatusMessage('success', t('fieldUpdated'));
}
} else {
if (submitAndLeave) {
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/fields`, 'success', t('fieldCreated'));
} else {
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/fields/${submitResult}/edit`, 'success', t('fieldCreated'));
}
}
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
}
} catch (error) {
throw error;
}
}
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
const getOrderOptions = fld => {
return [
{key: 'none', label: t('notVisible')},
...this.props.fields.filter(x => (!this.props.entity || x.id !== this.props.entity.id) && x[fld] !== null && x.group === null).sort((x, y) => x[fld] - y[fld]).map(x => ({ key: x.id.toString(), label: `${x.name} (${this.fieldTypes[x.type].label})`})),
{key: 'end', label: t('endOfList')}
];
};
const typeOptions = Object.keys(this.fieldTypes).map(key => ({key, label: this.fieldTypes[key].label}));
const type = this.getFormValue('type');
const isInGroup = this.getFormValue('isInGroup');
let fieldSettings = null;
switch (type) {
case 'text':
case 'website':
case 'longtext':
case 'gpg':
case 'number':
fieldSettings =
<Fieldset label={t('fieldSettings')}>
<InputField id="default_value" label={t('defaultValue')} help={t('defaultValueUsedWhenTheFieldIsEmpty')}/>
</Fieldset>;
break;
case 'checkbox-grouped':
case 'radio-grouped':
case 'dropdown-grouped':
fieldSettings =
<Fieldset label={t('fieldSettings')}>
<ACEEditor
id="renderTemplate"
label={t('template')}
height="250px"
mode="handlebars"
help={<Trans i18nKey="youCanControlTheAppearanceOfTheMergeTag">You can control the appearance of the merge tag with this template. The template
uses handlebars syntax and you can find all values from <code>{'{{values}}'}</code> array, for
example <code>{'{{#each values}} {{this}} {{/each}}'}</code>. If template is not defined then
multiple values are joined with commas.</Trans>}
/>
</Fieldset>;
break;
case 'radio-enum':
case 'dropdown-enum':
fieldSettings =
<Fieldset label={t('fieldSettings')}>
<ACEEditor
id="enumOptions"
label={t('options')}
height="250px"
mode="text"
help={<Trans i18nKey="specifyTheOptionsToSelectFromInThe"><div>Specify the options to select from in the following format:<code>key|label</code>. For example:</div>
<div><code>au|Australia</code></div><div><code>at|Austria</code></div></Trans>}
/>
<InputField id="default_value" label={t('defaultValue')} help={<Trans i18nKey="defaultKeyEgAuUsedWhenTheFieldIsEmpty">Default key (e.g. <code>au</code> used when the field is empty.')</Trans>}/>
<ACEEditor
id="renderTemplate"
label={t('template')}
height="250px"
mode="handlebars"
help={<Trans i18nKey="youCanControlTheAppearanceOfTheMergeTag-1">You can control the appearance of the merge tag with this template. The template
uses handlebars syntax and you can find all values from <code>{'{{values}}'}</code> array.
Each entry in the array is an object with attributes <code>key</code> and <code>label</code>.
For example <code>{'{{#each values}} {{this.value}} {{/each}}'}</code>. If template is not defined then
multiple values are joined with commas.</Trans>}
/>
</Fieldset>;
break;
case 'date':
fieldSettings =
<Fieldset label={t('fieldSettings')}>
<Dropdown id="dateFormat" label={t('dateFormat')}
options={[
{key: DateFormat.US, label: t('mmddyyyy')},
{key: DateFormat.EU, label: t('ddmmyyyy')}
]}
/>
<InputField id="default_value" label={t('defaultValue')} help={<Trans i18nKey="defaultValueUsedWhenTheFieldIsEmpty">Default value used when the field is empty.</Trans>}/>
</Fieldset>;
break;
case 'birthday':
fieldSettings =
<Fieldset label={t('fieldSettings')}>
<Dropdown id="dateFormat" label={t('dateFormat')}
options={[
{key: DateFormat.US, label: t('mmdd')},
{key: DateFormat.EU, label: t('ddmm')}
]}
/>
<InputField id="default_value" label={t('defaultValue')} help={<Trans i18nKey="defaultValueUsedWhenTheFieldIsEmpty">Default value used when the field is empty.</Trans>}/>
</Fieldset>;
break;
case 'json':
fieldSettings = <Fieldset label={t('fieldSettings')}>
<InputField id="default_value" label={t('defaultValue')} help={<Trans i18nKey="defaultKeyEgAuUsedWhenTheFieldIsEmpty">Default key (e.g. <code>au</code> used when the field is empty.')</Trans>}/>
<ACEEditor
id="renderTemplate"
label={t('template')}
height="250px"
mode="json"
help={<Trans i18nKey="youCanUseThisTemplateToRenderJsonValues">You can use this template to render JSON values (if the JSON is an array then the array is
exposed as <code>values</code>, otherwise you can access the JSON keys directly).</Trans>}
/>
</Fieldset>;
break;
case 'option':
const fieldsGroupedColumns = [
{ data: 4, title: "#" },
{ data: 1, title: t('name') },
{ data: 2, title: t('type'), render: data => this.fieldTypes[data].label, sortable: false, searchable: false },
{ data: 3, title: t('mergeTag') }
];
fieldSettings =
<Fieldset label={t('fieldSettings')}>
<CheckBox id="isInGroup" label={t('group')} text={t('belongsToCheckboxDropdownRadioGroup')}/>
{isInGroup &&
<TableSelect id="group" label={t('containingGroup')} withHeader dropdown dataUrl={`rest/fields-grouped-table/${this.props.list.id}`} columns={fieldsGroupedColumns} selectionLabelIndex={1} help={t('selectGroupToWhichTheOptionsShouldBelong')}/>
}
{!isInGroup &&
<>
<InputField id="checkedLabel" label={t('checkedLabel')} help={t('labelThatWillBeDisplayedInListAnd')}/>
<InputField id="uncheckedLabel" label={t('uncheckedLabel')} help={t('labelThatWillBeDisplayedInListAnd-1')}/>
</>
}
<InputField id="default_value" label={t('defaultValue')} help={t('defaultValueUsedWhenTheFieldIsEmpty')}/>
</Fieldset>;
break;
}
return (
<div>
{isEdit &&
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
deleteUrl={`rest/fields/${this.props.list.id}/${this.props.entity.id}`}
backUrl={`/lists/${this.props.list.id}/fields/${this.props.entity.id}/edit`}
successUrl={`/lists/${this.props.list.id}/fields`}
deletingMsg={t('deletingField')}
deletedMsg={t('fieldDeleted')}/>
}
<Title>{isEdit ? t('editField') : t('createField')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('name')}/>
{isEdit ?
<StaticField id="type" className={styles.formDisabled} label={t('type')}>{(this.fieldTypes[this.getFormValue('type')] || {}).label}</StaticField>
:
<Dropdown id="type" label={t('type')} options={typeOptions}/>
}
<InputField id="key" label={t('mergeTag-1')}/>
<TextArea id="help" label={t('Help text')}/>
{fieldSettings}
{type !== 'option' &&
<Fieldset label={t('fieldOrder')}>
<Dropdown id="orderListBefore" label={t('listingsBefore')} options={getOrderOptions('order_list')} help={t('selectTheFieldBeforeWhichThisFieldShould')}/>
<Dropdown id="orderSubscribeBefore" label={t('subscriptionFormBefore')} options={getOrderOptions('order_subscribe')} help={t('selectTheFieldBeforeWhichThisFieldShould-1')}/>
<Dropdown id="orderManageBefore" label={t('managementFormBefore')} options={getOrderOptions('order_manage')} help={t('selectTheFieldBeforeWhichThisFieldShould-2')}/>
</Fieldset>
}
<ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndLeave')} onClickAsync={async () => await this.submitHandler(true)}/>
{isEdit && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/${this.props.list.id}/fields/${this.props.entity.id}/delete`}/>}
</ButtonRow>
</Form>
</div>
);
}
}

View file

@ -1,80 +0,0 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withTranslation} from '../../lib/i18n';
import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../../lib/page';
import {withErrorHandling} from '../../lib/error-handling';
import {Table} from '../../lib/table';
import {getFieldTypes} from './helpers';
import {Icon} from "../../lib/bootstrap-components";
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../../lib/modals";
import {withComponentMixins} from "../../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class List extends Component {
constructor(props) {
super(props);
this.state = {};
tableRestActionDialogInit(this);
this.fieldTypes = getFieldTypes(props.t);
}
static propTypes = {
list: PropTypes.object
}
componentDidMount() {
}
render() {
const t = this.props.t;
const columns = [
{ data: 4, title: "#" },
{ data: 1, title: t('name'),
render: (data, cmd, rowData) => rowData[5] !== null ? <span><Icon icon="dot-circle"/> {data}</span> : data
},
{ data: 2, title: t('type'), render: data => this.fieldTypes[data].label, sortable: false, searchable: false },
{ data: 3, title: t('mergeTag') },
{
actions: data => {
const actions = [];
if (this.props.list.permissions.includes('manageFields')) {
actions.push({
label: <Icon icon="edit" title={t('edit')}/>,
link: `/lists/${this.props.list.id}/fields/${data[0]}/edit`
});
tableAddDeleteButton(actions, this, null, `rest/fields/${this.props.list.id}/${data[0]}`, data[1], t('deletingField'), t('fieldDeleted'));
}
return actions;
}
}
];
return (
<div>
{tableRestActionDialogRender(this)}
{this.props.list.permissions.includes('manageFields') &&
<Toolbar>
<LinkButton to={`/lists/${this.props.list.id}/fields/create`} className="btn-primary" icon="plus" label={t('createField')}/>
</Toolbar>
}
<Title>{t('fields')}</Title>
<Table ref={node => this.table = node} withHeader dataUrl={`rest/fields-table/${this.props.list.id}`} columns={columns} />
</div>
);
}
}

View file

@ -1,54 +0,0 @@
'use strict';
import React from 'react';
export function getFieldTypes(t) {
const fieldTypes = {
text: {
label: t('text'),
},
website: {
label: t('website'),
},
longtext: {
label: t('multilineText'),
},
gpg: {
label: t('gpgPublicKey'),
},
number: {
label: t('number'),
},
'checkbox-grouped': {
label: t('checkboxesFromOptionFields'),
},
'radio-grouped': {
label: t('radioButtonsFromOptionFields')
},
'dropdown-grouped': {
label: t('dropDownFromOptionFields')
},
'radio-enum': {
label: t('radioButtonsEnumerated')
},
'dropdown-enum': {
label: t('dropDownEnumerated')
},
'date': {
label: t('date')
},
'birthday': {
label: t('birthday')
},
json: {
label: t('jsonValueForCustomRendering')
},
option: {
label: t('option')
}
};
return fieldTypes;
}

View file

@ -1,592 +0,0 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {Trans} from 'react-i18next';
import {withTranslation} from '../../lib/i18n';
import {LinkButton, requiresAuthenticatedUser, Title, withPageHelpers} from '../../lib/page';
import {
ACEEditor,
AlignedRow,
Button,
ButtonRow,
CheckBox,
Dropdown,
Fieldset,
filterData,
Form,
FormSendMethod,
InputField,
TableSelect,
TextArea,
withForm,
withFormErrorHandlers
} from '../../lib/form';
import {withErrorHandling} from '../../lib/error-handling';
import {getDefaultNamespace, NamespaceSelect, validateNamespace} from '../../lib/namespace';
import {DeleteModalDialog} from "../../lib/modals";
import mailtrainConfig from 'mailtrainConfig';
import {getTrustedUrl, getUrl} from "../../lib/urls";
import {ActionLink, Icon} from "../../lib/bootstrap-components";
import styles from "../../lib/styles.scss";
import formsStyles from "./styles.scss";
import axios from "../../lib/axios";
import {withComponentMixins} from "../../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class CUD extends Component {
constructor(props) {
super(props);
this.state = {
previewContents: null,
previewFullscreen: false
};
this.serverValidatedFields = [
'layout',
'web_subscribe',
'web_confirm_subscription_notice',
'mail_confirm_subscription_html',
'mail_confirm_subscription_text',
'mail_already_subscribed_html',
'mail_already_subscribed_text',
'web_subscribed_notice',
'mail_subscription_confirmed_html',
'mail_subscription_confirmed_text',
'web_manage',
'web_manage_address',
'web_updated_notice',
'web_unsubscribe',
'web_confirm_unsubscription_notice',
'mail_confirm_unsubscription_html',
'mail_confirm_unsubscription_text',
'mail_confirm_address_change_html',
'mail_confirm_address_change_text',
'web_unsubscribed_notice',
'mail_unsubscription_confirmed_html',
'mail_unsubscription_confirmed_text',
'web_manual_unsubscribe_notice',
'web_privacy_policy_notice'
];
this.initForm({
serverValidation: {
url: 'rest/forms-validate',
changed: this.serverValidatedFields
},
onChange: {
previewList: (newState, key, oldValue, newValue) => {
newState.formState.setIn(['data', 'previewContents', 'value'], null);
}
}
});
const t = props.t;
const helpEmailText = t('thePlaintextVersionForThisEmail');
const helpMjmlGeneral = <Trans i18nKey="customFormsUseMjmlForFormattingSeeThe">Custom forms use MJML for formatting. See the MJML documentation <a className="mjml-documentation" href="https://mjml.io/documentation/">here</a></Trans>;
this.templateSettings = {
layout: {
label: t('layout'),
mode: 'html',
help: helpMjmlGeneral,
isLayout: true
},
form_input_style: {
label: t('formInputStyle'),
mode: 'css',
help: t('thisCssStylesheetDefinesTheAppearanceOf')
},
web_subscribe: {
label: t('webSubscribe'),
mode: 'html',
help: helpMjmlGeneral
},
web_confirm_subscription_notice: {
label: t('webConfirmSubscriptionNotice'),
mode: 'html',
help: helpMjmlGeneral
},
mail_confirm_subscription_html: {
label: t('mailConfirmSubscriptionMjml'),
mode: 'html',
help: helpMjmlGeneral
},
mail_confirm_subscription_text: {
label: t('mailConfirmSubscriptionText'),
mode: 'text',
help: helpEmailText
},
mail_already_subscribed_html: {
label: t('mailAlreadySubscribedMjml'),
mode: 'html',
help: helpMjmlGeneral
},
mail_already_subscribed_text: {
label: t('mailAlreadySubscribedText'),
mode: 'text',
help: helpEmailText
},
web_subscribed_notice: {
label: t('webSubscribedNotice'),
mode: 'html',
help: helpMjmlGeneral
},
mail_subscription_confirmed_html: {
label: t('mailSubscriptionConfirmedMjml'),
mode: 'html',
help: helpMjmlGeneral
},
mail_subscription_confirmed_text: {
label: t('mailSubscriptionConfirmedText'),
mode: 'text',
help: helpEmailText
},
web_manage: {
label: t('webManagePreferences'),
mode: 'html',
help: helpMjmlGeneral
},
web_manage_address: {
label: t('webManageAddress'),
mode: 'html',
help: helpMjmlGeneral
},
mail_confirm_address_change_html: {
label: t('mailConfirmAddressChangeMjml'),
mode: 'html',
help: helpMjmlGeneral
},
mail_confirm_address_change_text: {
label: t('mailConfirmAddressChangeText'),
mode: 'text',
help: helpEmailText
},
web_updated_notice: {
label: t('webUpdatedNotice'),
mode: 'html',
help: helpMjmlGeneral
},
web_unsubscribe: {
label: t('webUnsubscribe'),
mode: 'html',
help: helpMjmlGeneral
},
web_confirm_unsubscription_notice: {
label: t('webConfirmUnsubscriptionNotice'),
mode: 'html',
help: helpMjmlGeneral
},
mail_confirm_unsubscription_html: {
label: t('mailConfirmUnsubscriptionMjml'),
mode: 'html',
help: helpMjmlGeneral
},
mail_confirm_unsubscription_text: {
label: t('mailConfirmUnsubscriptionText'),
mode: 'text',
help: helpEmailText
},
web_unsubscribed_notice: {
label: t('webUnsubscribedNotice'),
mode: 'html',
help: helpMjmlGeneral
},
mail_unsubscription_confirmed_html: {
label: t('mailUnsubscriptionConfirmedMjml'),
mode: 'html',
help: helpMjmlGeneral
},
mail_unsubscription_confirmed_text: {
label: t('mailUnsubscriptionConfirmedText'),
mode: 'text',
help: helpEmailText
},
web_manual_unsubscribe_notice: {
label: t('webManualUnsubscribeNotice'),
mode: 'html',
help: helpMjmlGeneral
},
web_privacy_policy_notice: {
label: t('privacyPolicy'),
mode: 'html',
help: helpMjmlGeneral
}
};
this.templateGroups = {
general: {
label: t('general'),
options: [
'layout',
'form_input_style'
]
},
subscribe: {
label: t('subscribe'),
options: [
'web_subscribe',
'web_confirm_subscription_notice',
'mail_confirm_subscription_html',
'mail_confirm_subscription_text',
'mail_already_subscribed_html',
'mail_already_subscribed_text',
'web_subscribed_notice',
'mail_subscription_confirmed_html',
'mail_subscription_confirmed_text'
]
},
manage: {
label: t('manage'),
options: [
'web_manage',
'web_manage_address',
'mail_confirm_address_change_html',
'mail_confirm_address_change_text',
'web_updated_notice'
]
},
unsubscribe: {
label: t('unsubscribe'),
options: [
'web_unsubscribe',
'web_confirm_unsubscription_notice',
'mail_confirm_unsubscription_html',
'mail_confirm_unsubscription_text',
'web_unsubscribed_notice',
'mail_unsubscription_confirmed_html',
'mail_unsubscription_confirmed_text',
'web_manual_unsubscribe_notice'
]
},
gdpr: {
label: t('dataProtection'),
options: [
'web_privacy_policy_notice'
]
},
};
}
static propTypes = {
action: PropTypes.string.isRequired,
entity: PropTypes.object,
permissions: PropTypes.object
}
supplyDefaults(data) {
for (const key in mailtrainConfig.defaultCustomFormValues) {
if (!data[key]) {
data[key] = mailtrainConfig.defaultCustomFormValues[key];
}
}
}
getFormValuesMutator(data, originalData) {
this.supplyDefaults(data);
data.selectedTemplate = (originalData && originalData.selectedTemplate) || 'layout';
}
submitFormValuesMutator(data) {
return filterData(data, ['name', 'description', 'namespace',
'fromExistingEntity', 'existingEntity',
'layout', 'form_input_style',
'web_subscribe',
'web_confirm_subscription_notice',
'mail_confirm_subscription_html',
'mail_confirm_subscription_text',
'mail_already_subscribed_html',
'mail_already_subscribed_text',
'web_subscribed_notice',
'mail_subscription_confirmed_html',
'mail_subscription_confirmed_text',
'web_manage',
'web_manage_address',
'web_updated_notice',
'web_unsubscribe',
'web_confirm_unsubscription_notice',
'mail_confirm_unsubscription_html',
'mail_confirm_unsubscription_text',
'mail_confirm_address_change_html',
'mail_confirm_address_change_text',
'web_unsubscribed_notice',
'mail_unsubscription_confirmed_html',
'mail_unsubscription_confirmed_text', 'web_manual_unsubscribe_notice', 'web_privacy_policy_notice'
]);
}
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity);
} else {
const data = {
name: '',
description: '',
fromExistingEntity: false,
existingEntity: null,
selectedTemplate: 'layout',
namespace: getDefaultNamespace(this.props.permissions)
};
this.supplyDefaults(data);
this.populateFormValues(data);
}
}
localValidateFormValues(state) {
const t = this.props.t;
if (!state.getIn(['name', 'value'])) {
state.setIn(['name', 'error'], t('nameMustNotBeEmpty'));
} else {
state.setIn(['name', 'error'], null);
}
validateNamespace(t, state);
if (state.getIn(['fromExistingEntity', 'value']) && !state.getIn(['existingEntity', 'value'])) {
state.setIn(['existingEntity', 'error'], t('sourceCustomFormsMustNotBeEmpty'));
} else {
state.setIn(['existingEntity', 'error'], null);
}
let formsServerValidationRunning = false;
const formsErrors = [];
for (const fld of this.serverValidatedFields) {
const serverValidation = state.getIn([fld, 'serverValidation']);
if (serverValidation && serverValidation.errors) {
formsErrors.push(...serverValidation.errors.map(x => <div><em>{this.templateSettings[fld].label}</em>{' '}{' '}{x}</div>));
} else if (!serverValidation) {
formsServerValidationRunning = true;
}
}
if (!formsErrors.length && formsServerValidationRunning) {
formsErrors.push(t('validationIsInProgress'));
}
if (formsErrors.length) {
state.setIn(['selectedTemplate', 'error'],
<div><strong>{t('listOfErrorsInTemplates') + ':'}</strong>
<ul>
{formsErrors.map((msg, idx) => <li key={idx}>{msg}</li>)}
</ul>
</div>);
} else {
state.setIn(['selectedTemplate', 'error'], null);
}
}
@withFormErrorHandlers
async submitHandler(submitAndLeave) {
const t = this.props.t;
let sendMethod, url;
if (this.props.entity) {
sendMethod = FormSendMethod.PUT;
url = `rest/forms/${this.props.entity.id}`;
} else {
sendMethod = FormSendMethod.POST;
url = 'rest/forms';
}
this.disableForm();
this.setFormStatusMessage('info', t('saving'));
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url);
if (submitResult) {
if (this.props.entity) {
if (submitAndLeave) {
this.navigateToWithFlashMessage('/lists/forms', 'success', t('customFormsUpdated'));
} else {
await this.getFormValuesFromURL(`rest/forms/${this.props.entity.id}`);
this.enableForm();
this.setFormStatusMessage('success', t('customFormsUpdated'));
}
} else {
if (submitAndLeave) {
this.navigateToWithFlashMessage('/lists/forms', 'success', t('customFormsCreated'));
} else {
this.navigateToWithFlashMessage(`/lists/forms/${submitResult}/edit`, 'success', t('customFormsCreated'));
}
}
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
}
}
async preview(formKey) {
const data = {
formKey,
template: this.getFormValue(formKey),
layout: this.getFormValue('layout'),
formInputStyle: this.getFormValue('form_input_style'),
listId: this.getFormValue('previewList')
}
const response = await axios.post(getUrl('rest/forms-preview'), data);
this.setState({
previewKey: formKey,
previewContents: response.data.content,
previewLabel: this.templateSettings[formKey].label
});
}
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
const canDelete = isEdit && this.props.entity.permissions.includes('delete');
const templateOptGroups = [];
for (const grpKey in this.templateGroups) {
const grp = this.templateGroups[grpKey];
templateOptGroups.push({
key: grpKey,
label: grp.label,
options: grp.options.map(opt => ({
key: opt,
label: this.templateSettings[opt].label
}))
});
}
const customFormsColumns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('description') },
{ data: 3, title: t('namespace') }
];
const listsColumns = [
{ data: 0, title: "#" },
{ data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 5, title: t('namespace') }
];
const previewListId = this.getFormValue('previewList');
const selectedTemplate = this.getFormValue('selectedTemplate');
return (
<div className={this.state.previewFullscreen ? styles.withElementInFullscreen : ''}>
{canDelete &&
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
deleteUrl={`rest/forms/${this.props.entity.id}`}
backUrl={`/lists/forms/${this.props.entity.id}/edit`}
successUrl="/lists/forms"
deletingMsg={t('deletingForm')}
deletedMsg={t('formDeleted')}/>
}
<Title>{isEdit ? t('editCustomForms') : t('createCustomForms')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('name')}/>
<TextArea id="description" label={t('description')}/>
<NamespaceSelect/>
{!isEdit &&
<CheckBox id="fromExistingEntity" label={t('customForms')} text={t('cloneFromAnExistingCustomForms')}/>
}
{this.getFormValue('fromExistingEntity') ?
<TableSelect id="existingEntity" label={t('Source custom forms')} withHeader dropdown dataUrl='rest/forms-table' columns={customFormsColumns} selectionLabelIndex={1} />
:
<>
<Fieldset label={t('formsPreview')}>
<TableSelect id="previewList" label={t('listToPreviewOn')} withHeader dropdown dataUrl='rest/lists-table' columns={listsColumns} selectionLabelIndex={1} help={t('selectListWhoseFieldsWillBeUsedToPreview')}/>
{ previewListId &&
<div>
<AlignedRow>
<div>
<small>
{t('noteTheseLinksAreSolelyForAQuickPreview')}
</small>
</div>
<p>
<ActionLink onClickAsync={async () => await this.preview('web_subscribe')}>Subscribe</ActionLink>
{' | '}
<ActionLink onClickAsync={async () => await this.preview('web_confirm_subscription_notice')}>Confirm Subscription Notice</ActionLink>
{' | '}
<ActionLink onClickAsync={async () => await this.preview('web_confirm_unsubscription_notice')}>Confirm Unsubscription Notice</ActionLink>
{' | '}
<ActionLink onClickAsync={async () => await this.preview('web_subscribed_notice')}>Subscribed Notice</ActionLink>
{' | '}
<ActionLink onClickAsync={async () => await this.preview('web_updated_notice')}>Updated Notice</ActionLink>
{' | '}
<ActionLink onClickAsync={async () => await this.preview('web_unsubscribed_notice')}>Unsubscribed Notice</ActionLink>
{' | '}
<ActionLink onClickAsync={async () => await this.preview('web_manual_unsubscribe_notice')}>Manual Unsubscribe Notice</ActionLink>
{' | '}
<ActionLink onClickAsync={async () => await this.preview('web_unsubscribe')}>Unsubscribe</ActionLink>
{' | '}
<ActionLink onClickAsync={async () => await this.preview('web_manage')}>Manage</ActionLink>
{' | '}
<ActionLink onClickAsync={async () => await this.preview('web_manage_address')}>Manage Address</ActionLink>
{' | '}
<ActionLink onClickAsync={async () => await this.preview('web_privacy_policy_notice')}>Privacy Policy</ActionLink>
</p>
</AlignedRow>
{this.state.previewContents &&
<div className={this.state.previewFullscreen ? formsStyles.editorFullscreen : formsStyles.editor}>
<div className={formsStyles.navbar}>
<div className={formsStyles.navbarLeft}>
{this.state.fullscreen && <img className={formsStyles.logo} src={getTrustedUrl('static/mailtrain-notext.png')}/>}
<div className={formsStyles.title}>{t('formPreview') + ' ' + this.state.previewLabel}</div>
</div>
<div className={formsStyles.navbarRight}>
<a className={formsStyles.btn} onClick={() => this.preview(this.state.previewKey)} title={t('refresh')}><Icon icon="sync-alt"/></a>
<a className={formsStyles.btn} onClick={() => this.setState({previewFullscreen: !this.state.previewFullscreen})} title={t('maximizeEditor')}><Icon icon="window-maximize"/></a>
<a className={formsStyles.btn} onClick={() => this.setState({previewContents: null, previewFullscreen: false})} title={t('closePreview')}><Icon icon="window-close"/></a>
</div>
</div>
<iframe className={formsStyles.host} src={"data:text/html;charset=utf-8," + encodeURIComponent(this.state.previewContents)}></iframe>
</div>
}
</div>
}
</Fieldset>
{ selectedTemplate &&
<Fieldset label={t('templates')}>
<Dropdown id="selectedTemplate" label={t('edit')} options={templateOptGroups} help={this.templateSettings[selectedTemplate].help}/>
<ACEEditor id={selectedTemplate} height="500px" mode={this.templateSettings[selectedTemplate].mode}/>
</Fieldset>
}
</>
}
<ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndLeave')} onClickAsync={async () => await this.submitHandler(true)}/>
{canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/forms/${this.props.entity.id}/delete`}/>}
</ButtonRow>
</Form>
</div>
);
}
}

View file

@ -1,81 +0,0 @@
'use strict';
import React, {Component} from 'react';
import {withTranslation} from '../../lib/i18n';
import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../../lib/page';
import {withErrorHandling} from '../../lib/error-handling';
import {Table} from '../../lib/table';
import {Icon} from "../../lib/bootstrap-components";
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../../lib/modals";
import {withComponentMixins} from "../../lib/decorator-helpers";
import PropTypes from 'prop-types';
@withComponentMixins([
withTranslation,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class List extends Component {
constructor(props) {
super(props);
this.state = {};
tableRestActionDialogInit(this);
}
static propTypes = {
permissions: PropTypes.object
}
render() {
const t = this.props.t;
const permissions = this.props.permissions;
const createPermitted = permissions.createCustomForm;
const columns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('description') },
{ data: 3, title: t('namespace') },
{
actions: data => {
const actions = [];
const perms = data[4];
if (perms.includes('edit')) {
actions.push({
label: <Icon icon="edit" title={t('edit')}/>,
link: `/lists/forms/${data[0]}/edit`
});
}
if (perms.includes('share')) {
actions.push({
label: <Icon icon="share" title={t('share')}/>,
link: `/lists/forms/${data[0]}/share`
});
}
tableAddDeleteButton(actions, this, perms, `rest/forms/${data[0]}`, data[1], t('deletingForm'), t('formDeleted'));
return actions;
}
}
];
return (
<div>
{tableRestActionDialogRender(this)}
{createPermitted &&
<Toolbar>
<LinkButton to="/lists/forms/create" className="btn-primary" icon="plus" label={t('createCustomForm')}/>
</Toolbar>
}
<Title>{t('forms')}</Title>
<Table ref={node => this.table = node} withHeader dataUrl="rest/forms-table" columns={columns} />
</div>
);
}
}

View file

@ -1,11 +0,0 @@
$editorNormalHeight: 400px;
@import "../../lib/sandbox-common";
.editor {
margin-bottom: 15px;
}
.host {
border: none;
width: 100%;
}

View file

@ -1,472 +0,0 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withTranslation} from '../../lib/i18n';
import {LinkButton, requiresAuthenticatedUser, Title, withPageHelpers} from '../../lib/page';
import {
AlignedRow,
Button,
ButtonRow,
CheckBox,
Dropdown,
Fieldset,
filterData,
Form,
FormSendMethod,
InputField,
StaticField,
TextArea,
withForm,
withFormErrorHandlers
} from '../../lib/form';
import {withAsyncErrorHandler, withErrorHandling} from '../../lib/error-handling';
import {DeleteModalDialog} from "../../lib/modals";
import {getImportLabels} from './helpers';
import {ImportSource, inProgress, MappingType, prepInProgress, prepFinished} from '../../../../shared/imports';
import axios from "../../lib/axios";
import {getUrl} from "../../lib/urls";
import listStyles from "../styles.scss";
import styles from "../../lib/styles.scss";
import interoperableErrors from "../../../../shared/interoperable-errors";
import {withComponentMixins} from "../../lib/decorator-helpers";
function truncate(str, len, ending = '...') {
str = str.trim();
if (str.length > len) {
return str.substring(0, len - ending.length) + ending;
} else {
return str;
}
}
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class CUD extends Component {
constructor(props) {
super(props);
this.state = {};
const {importSourceLabels, mappingTypeLabels} = getImportLabels(props.t);
this.importSourceLabels = importSourceLabels;
this.importSourceOptions = [
{key: ImportSource.CSV_FILE, label: importSourceLabels[ImportSource.CSV_FILE]},
// {key: ImportSource.LIST, label: importSourceLabels[ImportSource.LIST]}
];
this.mappingOptions = [
{key: MappingType.BASIC_SUBSCRIBE, label: mappingTypeLabels[MappingType.BASIC_SUBSCRIBE]},
{key: MappingType.BASIC_UNSUBSCRIBE, label: mappingTypeLabels[MappingType.BASIC_UNSUBSCRIBE]},
];
this.refreshTimeoutHandler = ::this.refreshEntity;
this.refreshTimeoutId = 0;
this.initForm();
}
static propTypes = {
action: PropTypes.string.isRequired,
list: PropTypes.object,
fieldsGrouped: PropTypes.array,
entity: PropTypes.object
}
getFormValuesMutator(data) {
data.settings = data.settings || {};
const mapping = data.mapping || {};
if (data.source === ImportSource.CSV_FILE) {
data.csvFileName = data.settings.csv.originalname;
data.csvDelimiter = data.settings.csv.delimiter;
}
const mappingSettings = mapping.settings || {};
data.mapping_settings_checkEmails = 'checkEmails' in mappingSettings ? !!mappingSettings.checkEmails : true;
const mappingFlds = mapping.fields || {};
for (const field of this.props.fieldsGrouped) {
if (field.column) {
const colMapping = mappingFlds[field.column] || {};
data['mapping_fields_' + field.column + '_column'] = colMapping.column || '';
} else {
for (const option of field.settings.options) {
const col = field.groupedOptions[option.key].column;
const colMapping = mappingFlds[col] || {};
data['mapping_fields_' + col + '_column'] = colMapping.column || '';
}
}
}
const emailMapping = mappingFlds.email || {};
data.mapping_fields_email_column = emailMapping.column || '';
}
submitFormValuesMutator(data, isSubmit) {
const isEdit = !!this.props.entity;
data.source = Number.parseInt(data.source);
data.settings = {};
let formData, csvFileSelected = false;
if (isSubmit) {
formData = new FormData();
}
if (!isEdit) {
if (data.source === ImportSource.CSV_FILE) {
data.settings.csv = {};
// This test needs to be here because this function is also called by the form change detection mechanism
if (this.csvFile && this.csvFile.files && this.csvFile.files.length > 0) {
if (isSubmit) {
formData.append('csvFile', this.csvFile.files[0]);
} else {
csvFileSelected = true;
}
}
data.settings.csv.delimiter = data.csvDelimiter.trim();
}
} else {
data.mapping_type = Number.parseInt(data.mapping_type);
const mapping = {
fields: {},
settings: {}
};
if (data.mapping_type === MappingType.BASIC_SUBSCRIBE) {
mapping.settings.checkEmails = data.mapping_settings_checkEmails;
for (const field of this.props.fieldsGrouped) {
if (field.column) {
const colMapping = data['mapping_fields_' + field.column + '_column'];
if (colMapping) {
mapping.fields[field.column] = {
column: colMapping
};
}
} else {
for (const option of field.settings.options) {
const col = field.groupedOptions[option.key].column;
const colMapping = data['mapping_fields_' + col + '_column'];
if (colMapping) {
mapping.fields[col] = {
column: colMapping
};
}
}
}
}
}
if (data.mapping_type === MappingType.BASIC_SUBSCRIBE || data.mapping_type === MappingType.BASIC_UNSUBSCRIBE) {
mapping.fields.email = {
column: data.mapping_fields_email_column
};
}
data.mapping = mapping;
}
if (isSubmit) {
formData.append('entity', JSON.stringify(
filterData(data, ['name', 'description', 'source', 'settings', 'mapping_type', 'mapping'])
));
return formData;
} else {
const filteredData = filterData(data, ['name', 'description', 'source', 'settings', 'mapping_type', 'mapping']);
if (csvFileSelected) {
filteredData.csvFileSelected = true;
}
return filteredData;
}
}
initFromEntity(entity) {
this.getFormValuesFromEntity(entity);
if (inProgress(entity.status)) {
this.refreshTimeoutId = setTimeout(this.refreshTimeoutHandler, 1000);
}
}
componentDidMount() {
if (this.props.entity) {
this.initFromEntity(this.props.entity);
} else {
this.populateFormValues({
name: '',
description: '',
source: ImportSource.CSV_FILE,
csvFileName: '',
csvDelimiter: ',',
});
}
}
componentWillUnmount() {
clearTimeout(this.refreshTimeoutId);
}
@withAsyncErrorHandler
async refreshEntity() {
const resp = await axios.get(getUrl(`rest/imports/${this.props.list.id}/${this.props.entity.id}`));
this.initFromEntity(resp.data);
}
localValidateFormValues(state) {
const t = this.props.t;
const isEdit = !!this.props.entity;
const source = Number.parseInt(state.getIn(['source', 'value']));
for (const key of state.keys()) {
state.setIn([key, 'error'], null);
}
if (!state.getIn(['name', 'value'])) {
state.setIn(['name', 'error'], t('nameMustNotBeEmpty'));
}
if (!isEdit) {
if (source === ImportSource.CSV_FILE) {
if (!this.csvFile || this.csvFile.files.length === 0) {
state.setIn(['csvFileName', 'error'], t('fileMustBeSelected'));
}
if (!state.getIn(['csvDelimiter', 'value']).trim()) {
state.setIn(['csvDelimiter', 'error'], t('csvDelimiterMustNotBeEmpty'));
}
}
} else {
const mappingType = Number.parseInt(state.getIn(['mapping_type', 'value']));
if (mappingType === MappingType.BASIC_SUBSCRIBE || mappingType === MappingType.BASIC_UNSUBSCRIBE) {
if (!state.getIn(['mapping_fields_email_column', 'value'])) {
state.setIn(['mapping_fields_email_column', 'error'], t('emailMappingHasToBeProvided'));
}
}
}
}
async submitHandler() {
await this.save();
}
@withFormErrorHandlers
async save(runAfterSave) {
const t = this.props.t;
const isEdit = !!this.props.entity;
let sendMethod, url;
if (this.props.entity) {
sendMethod = FormSendMethod.PUT;
url = `rest/imports/${this.props.list.id}/${this.props.entity.id}`
} else {
sendMethod = FormSendMethod.POST;
url = `rest/imports/${this.props.list.id}`
}
try {
this.disableForm();
this.setFormStatusMessage('info', t('saving'));
const submitResponse = await this.validateAndSendFormValuesToURL(sendMethod, url);
if (submitResponse) {
if (!isEdit) {
this.navigateTo(`/lists/${this.props.list.id}/imports/${submitResponse}/edit`);
} else {
if (runAfterSave) {
try {
await axios.post(getUrl(`rest/import-start/${this.props.list.id}/${this.props.entity.id}`));
} catch (err) {
if (err instanceof interoperableErrors.InvalidStateError) {
// Just mask the fact that it's not possible to start anything and refresh instead.
} else {
throw err;
}
}
}
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/imports/${this.props.entity.id}/status`, 'success', t('importSaved'));
}
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
}
} catch (error) {
throw error;
}
}
onFileSelected(evt, x) {
if (!this.getFormValue('name') && this.csvFile.files.length > 0) {
this.updateFormValue('name', this.csvFile.files[0].name);
}
this.scheduleFormRevalidate();
}
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
const source = Number.parseInt(this.getFormValue('source'));
const status = this.getFormValue('status');
const settings = this.getFormValue('settings');
let settingsEdit = null;
if (source === ImportSource.CSV_FILE) {
if (isEdit) {
settingsEdit =
<div>
<StaticField id="csvFileName" className={styles.formDisabled} label={t('file')}>{this.getFormValue('csvFileName')}</StaticField>
<StaticField id="csvDelimiter" className={styles.formDisabled} label={t('delimiter')}>{this.getFormValue('csvDelimiter')}</StaticField>
</div>;
} else {
settingsEdit =
<div>
<AlignedRow label={t('file')}><input ref={node => this.csvFile = node} type="file" className="form-control-file" onChange={::this.onFileSelected}/></AlignedRow>
<InputField id="csvDelimiter" label={t('delimiter')}/>
</div>;
}
}
let mappingEdit;
if (isEdit) {
if (prepInProgress(status)) {
mappingEdit = (
<div>{t('preparationInProgressPleaseWaitTillItIs')}</div>
);
} else {
let mappingSettings = null;
const mappingType = Number.parseInt(this.getFormValue('mapping_type'));
if (mappingType === MappingType.BASIC_SUBSCRIBE || mappingType === MappingType.BASIC_UNSUBSCRIBE) {
const sampleRow = this.getFormValue('sampleRow');
const sourceOpts = [];
sourceOpts.push({key: '', label: t('Select ')});
if (source === ImportSource.CSV_FILE) {
for (const csvCol of settings.csv.columns) {
let help = '';
if (sampleRow) {
help = ' (' + t('eg', {keySeparator: '>', nsSeparator: '|'}) + ' ' + truncate(sampleRow[csvCol.column], 50) + ')';
}
sourceOpts.push({key: csvCol.column, label: csvCol.name + help});
}
}
const settingsRows = [];
const mappingRows = [
<Dropdown key="email" id="mapping_fields_email_column" label={t('email')} options={sourceOpts}/>
];
if (mappingType === MappingType.BASIC_SUBSCRIBE) {
settingsRows.push(<CheckBox key="checkEmails" id="mapping_settings_checkEmails" text={t('checkImportedEmails')}/>)
for (const field of this.props.fieldsGrouped) {
if (field.column) {
mappingRows.push(
<Dropdown key={field.column} id={'mapping_fields_' + field.column + '_column'} label={field.name} options={sourceOpts}/>
);
} else {
for (const option of field.settings.options) {
const col = field.groupedOptions[option.key].column;
mappingRows.push(
<Dropdown key={col} id={'mapping_fields_' + col + '_column'} label={field.groupedOptions[option.key].name} options={sourceOpts}/>
);
}
}
}
}
mappingSettings = (
<div>
{settingsRows}
<Fieldset label={t('mapping')} className={listStyles.mapping}>
{mappingRows}
</Fieldset>
</div>
);
}
mappingEdit = (
<div>
<Dropdown id="mapping_type" label={t('type')} options={this.mappingOptions}/>
{mappingSettings}
</div>
);
}
}
const saveButtons = []
if (!isEdit) {
saveButtons.push(<Button key="default" type="submit" className="btn-primary" icon="check" label={t('saveAndEditSettings')}/>);
} else {
if (prepFinished(status)) {
saveButtons.push(<Button key="default" type="submit" className="btn-primary" icon="check" label={t('save')}/>);
saveButtons.push(<Button key="saveAndRun" className="btn-primary" icon="check" label={t('saveAndRun')} onClickAsync={async () => await this.save(true)}/>);
}
}
return (
<div>
{isEdit &&
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
deleteUrl={`rest/imports/${this.props.list.id}/${this.props.entity.id}`}
backUrl={`/lists/${this.props.list.id}/imports/${this.props.entity.id}/edit`}
successUrl={`/lists/${this.props.list.id}/imports`}
deletingMsg={t('deletingImport')}
deletedMsg={t('importDeleted')}/>
}
<Title>{isEdit ? t('editImport') : t('createImport')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('name')}/>
<TextArea id="description" label={t('description')}/>
{isEdit ?
<StaticField id="source" className={styles.formDisabled} label={t('source')}>{this.importSourceLabels[this.getFormValue('source')]}</StaticField>
:
<Dropdown id="source" label={t('source')} options={this.importSourceOptions}/>
}
{settingsEdit}
{mappingEdit}
<ButtonRow>
{saveButtons}
{isEdit && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/${this.props.list.id}/imports/${this.props.entity.id}/delete`}/>}
</ButtonRow>
</Form>
</div>
);
}
}

View file

@ -1,98 +0,0 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withTranslation} from '../../lib/i18n';
import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../../lib/page';
import {withErrorHandling} from '../../lib/error-handling';
import {Table} from '../../lib/table';
import {getImportLabels} from './helpers';
import {Icon} from "../../lib/bootstrap-components";
import mailtrainConfig from 'mailtrainConfig';
import moment from "moment";
import {inProgress} from '../../../../shared/imports';
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../../lib/modals";
import {withComponentMixins} from "../../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class List extends Component {
constructor(props) {
super(props);
this.state = {};
tableRestActionDialogInit(this);
const {importSourceLabels, importStatusLabels} = getImportLabels(props.t);
this.importSourceLabels = importSourceLabels;
this.importStatusLabels = importStatusLabels;
}
static propTypes = {
list: PropTypes.object
}
componentDidMount() {
}
render() {
const t = this.props.t;
const columns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('description') },
{ data: 3, title: t('source'), render: data => this.importSourceLabels[data], sortable: false, searchable: false },
{ data: 4, title: t('status'), render: data => this.importStatusLabels[data], sortable: false, searchable: false },
{ data: 5, title: t('lastRun'), render: data => data ? moment(data).fromNow() : t('never') },
{
actions: data => {
const actions = [];
const status = data[4];
let refreshTimeout;
if (inProgress(status)) {
refreshTimeout = 1000;
}
if (mailtrainConfig.globalPermissions.setupAutomation && this.props.list.permissions.includes('manageImports')) {
actions.push({
label: <Icon icon="edit" title={t('edit')}/>,
link: `/lists/${this.props.list.id}/imports/${data[0]}/edit`
});
}
actions.push({
label: <Icon icon="eye" title={t('detailedStatus')}/>,
link: `/lists/${this.props.list.id}/imports/${data[0]}/status`
});
if (this.props.list.permissions.includes('manageImports')) {
tableAddDeleteButton(actions, this, null, `rest/imports/${this.props.list.id}/${data[0]}`, data[1], t('deletingImport'), t('importDeleted'));
}
return { refreshTimeout, actions };
}
}
];
return (
<div>
{tableRestActionDialogRender(this)}
{mailtrainConfig.globalPermissions.setupAutomation && this.props.list.permissions.includes('manageImports') &&
<Toolbar>
<LinkButton to={`/lists/${this.props.list.id}/imports/create`} className="btn-primary" icon="plus" label={t('createImport')}/>
</Toolbar>
}
<Title>{t('imports')}</Title>
<Table ref={node => this.table = node} withHeader dataUrl={`rest/imports-table/${this.props.list.id}`} columns={columns} />
</div>
);
}
}

View file

@ -1,109 +0,0 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withTranslation} from '../../lib/i18n';
import {requiresAuthenticatedUser, Title, withPageHelpers} from '../../lib/page';
import {AlignedRow} from '../../lib/form';
import {withAsyncErrorHandler, withErrorHandling} from '../../lib/error-handling';
import {getImportLabels} from './helpers';
import axios from "../../lib/axios";
import {getUrl} from "../../lib/urls";
import moment from "moment";
import {runStatusInProgress} from "../../../../shared/imports";
import {Table} from "../../lib/table";
import {withComponentMixins} from "../../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class Status extends Component {
constructor(props) {
super(props);
this.state = {
entity: props.entity
};
const {importSourceLabels, importStatusLabels, runStatusLabels} = getImportLabels(props.t);
this.importSourceLabels = importSourceLabels;
this.importStatusLabels = importStatusLabels;
this.runStatusLabels = runStatusLabels;
this.refreshTimeoutHandler = ::this.periodicRefreshTask;
this.refreshTimeoutId = 0;
}
static propTypes = {
entity: PropTypes.object,
imprt: PropTypes.object,
list: PropTypes.object
}
@withAsyncErrorHandler
async refreshEntity() {
const resp = await axios.get(getUrl(`rest/import-runs/${this.props.list.id}/${this.props.imprt.id}/${this.props.entity.id}`));
this.setState({
entity: resp.data
});
if (this.failedTableNode) {
this.failedTableNode.refresh();
}
}
async periodicRefreshTask() {
if (runStatusInProgress(this.state.entity.status)) {
await this.refreshEntity();
if (this.refreshTimeoutHandler) { // For some reason the task gets rescheduled if server is restarted while the page is shown. That why we have this check here.
this.refreshTimeoutId = setTimeout(this.refreshTimeoutHandler, 2000);
}
}
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.periodicRefreshTask();
}
componentWillUnmount() {
clearTimeout(this.refreshTimeoutId);
this.refreshTimeoutHandler = null;
}
render() {
const t = this.props.t;
const entity = this.state.entity;
const imprt = this.props.imprt;
const columns = [
{ data: 1, title: t('row') },
{ data: 2, title: t('email') },
{ data: 3, title: t('reason'), render: data => t(...JSON.parse(data)) }
];
return (
<div>
<Title>{t('importRunStatus')}</Title>
<AlignedRow label={t('importName')}>{imprt.name}</AlignedRow>
<AlignedRow label={t('importSource')}>{this.importSourceLabels[imprt.source]}</AlignedRow>
<AlignedRow label={t('runStarted')}>{moment(entity.created).fromNow()}</AlignedRow>
{entity.finished && <AlignedRow label={t('runFinished')}>{moment(entity.finished).fromNow()}</AlignedRow>}
<AlignedRow label={t('runStatus')}>{this.runStatusLabels[entity.status]}</AlignedRow>
<AlignedRow label={t('processedEntries')}>{entity.processed}</AlignedRow>
<AlignedRow label={t('newEntries')}>{entity.new}</AlignedRow>
<AlignedRow label={t('failedEntries')}>{entity.failed}</AlignedRow>
{entity.error && <AlignedRow label={t('error')}><pre>{entity.error}</pre></AlignedRow>}
<hr/>
<h3>{t('failedRows')}</h3>
<Table ref={node => this.failedTableNode = node} withHeader dataUrl={`rest/import-run-failed-table/${this.props.list.id}/${this.props.imprt.id}/${this.props.entity.id}`} columns={columns} />
</div>
);
}
}

View file

@ -1,161 +0,0 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withTranslation} from '../../lib/i18n';
import {requiresAuthenticatedUser, Title, withPageHelpers} from '../../lib/page';
import {AlignedRow, ButtonRow} from '../../lib/form';
import {withAsyncErrorHandler, withErrorHandling} from '../../lib/error-handling';
import {getImportLabels} from './helpers';
import {prepFinishedAndNotInProgress, runInProgress, runStatusInProgress} from '../../../../shared/imports';
import {Table} from "../../lib/table";
import {Button, Icon} from "../../lib/bootstrap-components";
import axios from "../../lib/axios";
import {getUrl} from "../../lib/urls";
import moment from "moment";
import interoperableErrors from '../../../../shared/interoperable-errors';
import {withComponentMixins} from "../../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class Status extends Component {
constructor(props) {
super(props);
this.state = {
entity: props.entity
};
const {importSourceLabels, importStatusLabels, runStatusLabels} = getImportLabels(props.t);
this.importSourceLabels = importSourceLabels;
this.importStatusLabels = importStatusLabels;
this.runStatusLabels = runStatusLabels;
this.refreshTimeoutHandler = ::this.periodicRefreshTask;
this.refreshTimeoutId = 0;
}
static propTypes = {
entity: PropTypes.object,
list: PropTypes.object
}
@withAsyncErrorHandler
async refreshEntity() {
const resp = await axios.get(getUrl(`rest/imports/${this.props.list.id}/${this.props.entity.id}`));
this.setState({
entity: resp.data
});
}
async periodicRefreshTask() {
// The periodic task runs all the time, so that we don't have to worry about starting/stopping it as a reaction to the buttons.
await this.refreshEntity();
if (this.refreshTimeoutHandler) { // For some reason the task gets rescheduled if server is restarted while the page is shown. That why we have this check here.
this.refreshTimeoutId = setTimeout(this.refreshTimeoutHandler, 2000);
}
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.periodicRefreshTask();
}
componentWillUnmount() {
clearTimeout(this.refreshTimeoutId);
this.refreshTimeoutHandler = null;
}
async startRunAsync() {
try {
await axios.post(getUrl(`rest/import-start/${this.props.list.id}/${this.props.entity.id}`));
} catch (err) {
if (err instanceof interoperableErrors.InvalidStateError) {
// Just mask the fact that it's not possible to start anything and refresh instead.
} else {
throw err;
}
}
await this.refreshEntity();
if (this.runsTableNode) {
this.runsTableNode.refresh();
}
}
async stopRunAsync() {
try {
await axios.post(getUrl(`rest/import-stop/${this.props.list.id}/${this.props.entity.id}`));
} catch (err) {
if (err instanceof interoperableErrors.InvalidStateError) {
// Just mask the fact that it's not possible to stop anything and refresh instead.
} else {
throw err;
}
}
await this.refreshEntity();
if (this.runsTableNode) {
this.runsTableNode.refresh();
}
}
render() {
const t = this.props.t;
const entity = this.state.entity;
const columns = [
{ data: 1, title: t('started'), render: data => moment(data).fromNow() },
{ data: 2, title: t('finished'), render: data => data ? moment(data).fromNow() : '' },
{ data: 3, title: t('status'), render: data => this.runStatusLabels[data], sortable: false, searchable: false },
{ data: 4, title: t('processed') },
{ data: 5, title: t('new') },
{ data: 6, title: t('failed') },
{
actions: data => {
const actions = [];
const status = data[3];
let refreshTimeout;
if (runStatusInProgress(status)) {
refreshTimeout = 1000;
}
actions.push({
label: <Icon icon="eye" title={t('runStatus')}/>,
link: `/lists/${this.props.list.id}/imports/${this.props.entity.id}/status/${data[0]}`
});
return { refreshTimeout, actions };
}
}
];
return (
<div>
<Title>{t('importStatus')}</Title>
<AlignedRow label={t('name')}>{entity.name}</AlignedRow>
<AlignedRow label={t('source')}>{this.importSourceLabels[entity.source]}</AlignedRow>
<AlignedRow label={t('status')}>{this.importStatusLabels[entity.status]}</AlignedRow>
{entity.error && <AlignedRow label={t('error')}><pre>{entity.error}</pre></AlignedRow>}
<ButtonRow label={t('actions')}>
{prepFinishedAndNotInProgress(entity.status) && <Button className="btn-primary" icon="play" label={t('start')} onClickAsync={::this.startRunAsync}/>}
{runInProgress(entity.status) && <Button className="btn-primary" icon="stop" label={t('stop')} onClickAsync={::this.stopRunAsync}/>}
</ButtonRow>
<hr/>
<h3>{t('importRuns')}</h3>
<Table ref={node => this.runsTableNode = node} withHeader dataUrl={`rest/import-runs-table/${this.props.list.id}/${this.props.entity.id}`} columns={columns} />
</div>
);
}
}

View file

@ -1,46 +0,0 @@
'use strict';
import React from 'react';
import {ImportSource, ImportStatus, MappingType, RunStatus} from '../../../../shared/imports';
export function getImportLabels(t) {
const importSourceLabels = {
[ImportSource.CSV_FILE]: t('csvFile'),
[ImportSource.LIST]: t('list'),
};
const importStatusLabels = {
[ImportStatus.PREP_SCHEDULED]: t('created'),
[ImportStatus.PREP_RUNNING]: t('preparing'),
[ImportStatus.PREP_STOPPING]: t('stopping'),
[ImportStatus.PREP_FINISHED]: t('ready'),
[ImportStatus.PREP_FAILED]: t('preparationFailed'),
[ImportStatus.RUN_SCHEDULED]: t('scheduled'),
[ImportStatus.RUN_RUNNING]: t('running'),
[ImportStatus.RUN_STOPPING]: t('stopping'),
[ImportStatus.RUN_FINISHED]: t('finished'),
[ImportStatus.RUN_FAILED]: t('failed')
};
const runStatusLabels = {
[RunStatus.SCHEDULED]: t('starting'),
[RunStatus.RUNNING]: t('running'),
[RunStatus.STOPPING]: t('stopping'),
[RunStatus.FINISHED]: t('finished'),
[RunStatus.FAILED]: t('failed')
};
const mappingTypeLabels = {
[MappingType.BASIC_SUBSCRIBE]: t('basicImportOfSubscribers'),
[MappingType.BASIC_UNSUBSCRIBE]: t('unsubscribeEmails'),
}
return {
importStatusLabels,
mappingTypeLabels,
importSourceLabels,
runStatusLabels
};
}

View file

@ -1,255 +0,0 @@
'use strict';
import React from 'react';
import qs from 'querystringify';
import ListsList from './List';
import ListsCUD from './CUD';
import FormsList from './forms/List';
import FormsCUD from './forms/CUD';
import FieldsList from './fields/List';
import FieldsCUD from './fields/CUD';
import SubscriptionsList from './subscriptions/List';
import SubscriptionsCUD from './subscriptions/CUD';
import SegmentsList from './segments/List';
import SegmentsCUD from './segments/CUD';
import ImportsList from './imports/List';
import ImportsCUD from './imports/CUD';
import ImportsStatus from './imports/Status';
import ImportRunsStatus from './imports/RunStatus';
import Share from '../shares/Share';
import TriggersList from './TriggersList';
import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
import {namespaceCheckPermissions} from "../lib/namespace";
function getMenus(t) {
return {
'lists': {
title: t('lists'),
link: '/lists',
checkPermissions: {
createList: {
entityTypeId: 'namespace',
requiredOperations: ['createList']
},
createCustomForm: {
entityTypeId: 'namespace',
requiredOperations: ['createCustomForm']
},
viewCustomForm: {
entityTypeId: 'customForm',
requiredOperations: ['view']
},
...namespaceCheckPermissions('createList')
},
panelRender: props => <ListsList permissions={props.permissions}/>,
children: {
':listId([0-9]+)': {
title: resolved => t('listName', {name: ellipsizeBreadcrumbLabel(resolved.list.name)}),
resolve: {
list: params => `rest/lists/${params.listId}`
},
link: params => `/lists/${params.listId}/subscriptions`,
navs: {
subscriptions: {
title: t('subscribers'),
resolve: {
segments: params => `rest/segments/${params.listId}`,
},
link: params => `/lists/${params.listId}/subscriptions`,
visible: resolved => resolved.list.permissions.includes('viewSubscriptions'),
panelRender: props => <SubscriptionsList list={props.resolved.list} segments={props.resolved.segments} segmentId={qs.parse(props.location.search).segment} />,
children: {
':subscriptionId([0-9]+)': {
title: resolved => resolved.subscription.email,
resolve: {
subscription: params => `rest/subscriptions/${params.listId}/${params.subscriptionId}`,
fieldsGrouped: params => `rest/fields-grouped/${params.listId}`
},
link: params => `/lists/${params.listId}/subscriptions/${params.subscriptionId}/edit`,
navs: {
':action(edit|delete)': {
title: t('edit'),
link: params => `/lists/${params.listId}/subscriptions/${params.subscriptionId}/edit`,
panelRender: props => <SubscriptionsCUD action={props.match.params.action} entity={props.resolved.subscription} list={props.resolved.list} fieldsGrouped={props.resolved.fieldsGrouped} />
}
}
},
create: {
title: t('create'),
resolve: {
fieldsGrouped: params => `rest/fields-grouped/${params.listId}`
},
panelRender: props => <SubscriptionsCUD action="create" list={props.resolved.list} fieldsGrouped={props.resolved.fieldsGrouped} />
}
} },
':action(edit|delete)': {
title: t('edit'),
link: params => `/lists/${params.listId}/edit`,
visible: resolved => resolved.list.permissions.includes('edit'),
panelRender: props => <ListsCUD action={props.match.params.action} entity={props.resolved.list} permissions={props.permissions} />
},
fields: {
title: t('fields'),
link: params => `/lists/${params.listId}/fields/`,
visible: resolved => resolved.list.permissions.includes('viewFields'),
panelRender: props => <FieldsList list={props.resolved.list} />,
children: {
':fieldId([0-9]+)': {
title: resolved => t('fieldName-1', {name: ellipsizeBreadcrumbLabel(resolved.field.name)}),
resolve: {
field: params => `rest/fields/${params.listId}/${params.fieldId}`,
fields: params => `rest/fields/${params.listId}`
},
link: params => `/lists/${params.listId}/fields/${params.fieldId}/edit`,
navs: {
':action(edit|delete)': {
title: t('edit'),
link: params => `/lists/${params.listId}/fields/${params.fieldId}/edit`,
panelRender: props => <FieldsCUD action={props.match.params.action} entity={props.resolved.field} list={props.resolved.list} fields={props.resolved.fields} />
}
}
},
create: {
title: t('create'),
resolve: {
fields: params => `rest/fields/${params.listId}`
},
panelRender: props => <FieldsCUD action="create" list={props.resolved.list} fields={props.resolved.fields} />
}
}
},
segments: {
title: t('segments'),
link: params => `/lists/${params.listId}/segments`,
visible: resolved => resolved.list.permissions.includes('viewSegments'),
panelRender: props => <SegmentsList list={props.resolved.list} />,
children: {
':segmentId([0-9]+)': {
title: resolved => t('segmentName', {name: ellipsizeBreadcrumbLabel(resolved.segment.name)}),
resolve: {
segment: params => `rest/segments/${params.listId}/${params.segmentId}`,
fields: params => `rest/fields/${params.listId}`
},
link: params => `/lists/${params.listId}/segments/${params.segmentId}/edit`,
navs: {
':action(edit|delete)': {
title: t('edit'),
link: params => `/lists/${params.listId}/segments/${params.segmentId}/edit`,
panelRender: props => <SegmentsCUD action={props.match.params.action} entity={props.resolved.segment} list={props.resolved.list} fields={props.resolved.fields} />
}
}
},
create: {
title: t('create'),
resolve: {
fields: params => `rest/fields/${params.listId}`
},
panelRender: props => <SegmentsCUD action="create" list={props.resolved.list} fields={props.resolved.fields} />
}
}
},
imports: {
title: t('imports'),
link: params => `/lists/${params.listId}/imports/`,
visible: resolved => resolved.list.permissions.includes('viewImports'),
panelRender: props => <ImportsList list={props.resolved.list} />,
children: {
':importId([0-9]+)': {
title: resolved => t('importName-1', {name: ellipsizeBreadcrumbLabel(resolved.import.name)}),
resolve: {
import: params => `rest/imports/${params.listId}/${params.importId}`,
},
link: params => `/lists/${params.listId}/imports/${params.importId}/status`,
navs: {
':action(edit|delete)': {
title: t('edit'),
resolve: {
fieldsGrouped: params => `rest/fields-grouped/${params.listId}`
},
link: params => `/lists/${params.listId}/imports/${params.importId}/edit`,
panelRender: props => <ImportsCUD action={props.match.params.action} entity={props.resolved.import} list={props.resolved.list} fieldsGrouped={props.resolved.fieldsGrouped}/>
},
'status': {
title: t('status'),
link: params => `/lists/${params.listId}/imports/${params.importId}/status`,
panelRender: props => <ImportsStatus entity={props.resolved.import} list={props.resolved.list} />,
children: {
':importRunId([0-9]+)': {
title: resolved => t('run'),
resolve: {
importRun: params => `rest/import-runs/${params.listId}/${params.importId}/${params.importRunId}`,
},
link: params => `/lists/${params.listId}/imports/${params.importId}/status/${params.importRunId}`,
panelRender: props => <ImportRunsStatus entity={props.resolved.importRun} imprt={props.resolved.import} list={props.resolved.list} />
}
}
}
}
},
create: {
title: t('create'),
panelRender: props => <ImportsCUD action="create" list={props.resolved.list} />
}
}
},
triggers: {
title: t('triggers'),
link: params => `/lists/${params.listId}/triggers`,
panelRender: props => <TriggersList list={props.resolved.list} />
},
share: {
title: t('share'),
link: params => `/lists/${params.listId}/share`,
visible: resolved => resolved.list.permissions.includes('share'),
panelRender: props => <Share title={t('share')} entity={props.resolved.list} entityTypeId="list" />
}
}
},
create: {
title: t('create'),
panelRender: props => <ListsCUD action="create" permissions={props.permissions} />
},
forms: {
title: t('customForms-1'),
link: '/lists/forms',
checkPermissions: {
...namespaceCheckPermissions('createCustomForm')
},
panelRender: props => <FormsList permissions={props.permissions}/>,
children: {
':formsId([0-9]+)': {
title: resolved => t('customFormsName', {name: ellipsizeBreadcrumbLabel(resolved.forms.name)}),
resolve: {
forms: params => `rest/forms/${params.formsId}`
},
link: params => `/lists/forms/${params.formsId}/edit`,
navs: {
':action(edit|delete)': {
title: t('edit'),
link: params => `/lists/forms/${params.formsId}/edit`,
visible: resolved => resolved.forms.permissions.includes('edit'),
panelRender: props => <FormsCUD action={props.match.params.action} entity={props.resolved.forms} permissions={props.permissions} />
},
share: {
title: t('share'),
link: params => `/lists/forms/${params.formsId}/share`,
visible: resolved => resolved.forms.permissions.includes('share'),
panelRender: props => <Share title={t('share')} entity={props.resolved.forms} entityTypeId="customForm" />
}
}
},
create: {
title: t('create'),
panelRender: props => <FormsCUD action="create" permissions={props.permissions} />
}
}
}
}
}
};
}
export default {
getMenus
}

View file

@ -1,404 +0,0 @@
'use strict';
import React, {Component} from "react";
import PropTypes from "prop-types";
import {withTranslation} from '../../lib/i18n';
import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from "../../lib/page";
import {
ButtonRow,
Dropdown,
filterData,
Form,
FormSendMethod,
InputField,
withForm,
withFormErrorHandlers
} from "../../lib/form";
import {withErrorHandling} from "../../lib/error-handling";
import {DeleteModalDialog} from "../../lib/modals";
import styles from "./CUD.scss";
import {DragDropContext} from "react-dnd";
import HTML5Backend from "react-dnd-html5-backend";
import TouchBackend from "react-dnd-touch-backend";
import SortableTree from "react-sortable-tree";
import 'react-sortable-tree/style.css';
import {ActionLink, Button, Icon} from "../../lib/bootstrap-components";
import {getRuleHelpers} from "./helpers";
import RuleSettingsPane from "./RuleSettingsPane";
import {withComponentMixins} from "../../lib/decorator-helpers";
import clone from "clone";
// https://stackoverflow.com/a/4819886/1601953
const isTouchDevice = !!('ontouchstart' in window || navigator.maxTouchPoints);
@DragDropContext(isTouchDevice ? TouchBackend : HTML5Backend)
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class CUD extends Component {
// The code below keeps the segment settings in form value. However, it uses it as a mutable datastructure.
// After initilization, segment settings is never set using setState. This is OK since we update the state.rulesTree
// from the segment settings on relevant events (changes in the tree and closing the rule settings pane).
constructor(props) {
super(props);
this.ruleHelpers = getRuleHelpers(props.t, props.fields);
this.state = {
rulesTree: this.getTreeFromRules([])
// There is no ruleOptionsVisible here. We have 3 state logic for the visibility:
// Undef - not shown, True - shown with entry animation, False - hidden with exit animation
};
this.initForm();
this.onRuleSettingsPaneUpdatedHandler = ::this.onRuleSettingsPaneUpdated;
this.onRuleSettingsPaneCloseHandler = ::this.onRuleSettingsPaneClose;
this.onRuleSettingsPaneDeleteHandler = ::this.onRuleSettingsPaneDelete;
}
static propTypes = {
action: PropTypes.string.isRequired,
list: PropTypes.object,
fields: PropTypes.array,
entity: PropTypes.object
}
getRulesFromTree(tree) {
const rules = [];
for (const node of tree) {
const rule = node.rule;
if (this.ruleHelpers.isCompositeRuleType(rule.type)) {
rule.rules = this.getRulesFromTree(node.children);
}
rules.push(rule);
}
return rules;
}
getTreeFromRules(rules) {
const ruleHelpers = this.ruleHelpers;
const tree = [];
for (const rule of rules) {
const ruleTypeSettings = ruleHelpers.getRuleTypeSettings(rule);
const title = ruleTypeSettings ? ruleTypeSettings.treeLabel(rule) : this.props.t('newRule');
tree.push({
rule,
title,
expanded: true,
children: this.getTreeFromRules(rule.rules || [])
});
}
return tree;
}
getFormValuesMutator(data, originalData) {
data.rootRuleType = data.settings.rootRule.type;
data.selectedRule = (originalData && originalData.selectedRule) || null; // Validation errors of the selected rule are attached to this which makes sure we don't submit the segment if the opened rule has errors
this.setState({
rulesTree: this.getTreeFromRules(data.settings.rootRule.rules)
});
}
submitFormValuesMutator(data) {
data.settings.rootRule.type = data.rootRuleType;
// We have to clone the data here otherwise the form change detection doesn't work. This is because we use the state as a mutable structure.
data = clone(data);
return filterData(data, ['name', 'settings']);
}
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity);
} else {
this.populateFormValues({
name: '',
settings: {
rootRule: {
type: 'all',
rules: []
}
},
rootRuleType: 'all',
selectedRule: null
});
}
}
localValidateFormValues(state) {
const t = this.props.t;
if (!state.getIn(['name', 'value'])) {
state.setIn(['name', 'error'], t('nameMustNotBeEmpty'));
} else {
state.setIn(['name', 'error'], null);
}
if (state.getIn(['selectedRule', 'value']) === null) {
state.setIn(['selectedRule', 'error'], null);
}
}
@withFormErrorHandlers
async submitHandler(submitAndLeave) {
const t = this.props.t;
let sendMethod, url;
if (this.props.entity) {
sendMethod = FormSendMethod.PUT;
url = `rest/segments/${this.props.list.id}/${this.props.entity.id}`
} else {
sendMethod = FormSendMethod.POST;
url = `rest/segments/${this.props.list.id}`
}
try {
this.disableForm();
this.setFormStatusMessage('info', t('saving'));
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url);
if (submitResult) {
if (this.props.entity) {
if (submitAndLeave) {
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/segments`, 'success', t('segmentUpdated'));
} else {
await this.getFormValuesFromURL(`rest/segments/${this.props.list.id}/${this.props.entity.id}`);
this.enableForm();
this.setFormStatusMessage('success', t('segmentUpdated'));
}
} else {
if (submitAndLeave) {
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/segments`, 'success', t('segmentCreated'));
} else {
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/segments/${submitResult}/edit`, 'success', t('segmentCreated'));
}
}
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
}
} catch (error) {
throw error;
}
}
onRulesChanged(rulesTree) {
// This assumes that !this.state.ruleOptionsVisible
this.getFormValue('settings').rootRule.rules = this.getRulesFromTree(rulesTree);
this.setState({
rulesTree
})
}
showRuleOptions(rule) {
this.updateFormValue('selectedRule', rule);
this.setState({
ruleOptionsVisible: true
});
}
onRuleSettingsPaneClose() {
this.updateFormValue('selectedRule', null);
this.setState({
ruleOptionsVisible: false,
rulesTree: this.getTreeFromRules(this.getFormValue('settings').rootRule.rules)
});
}
onRuleSettingsPaneDelete() {
const selectedRule = this.getFormValue('selectedRule');
this.updateFormValue('selectedRule', null);
this.setState({
ruleOptionsVisible: false,
});
this.deleteRule(selectedRule);
}
onRuleSettingsPaneUpdated(hasErrors) {
this.setState(previousState => ({
formState: previousState.formState.setIn(['data', 'selectedRule', 'error'], hasErrors)
}));
}
addRule(rule) {
if (!this.state.ruleOptionsVisible) {
const rules = this.getFormValue('settings').rootRule.rules;
rules.push(rule);
this.updateFormValue('selectedRule', rule);
this.setState({
ruleOptionsVisible: true,
rulesTree: this.getTreeFromRules(rules)
});
}
}
async addCompositeRule() {
this.addRule({
type: 'all',
rules: []
});
}
async addPrimitiveRule() {
this.addRule({
type: null // Null type means a primitive rule where the type has to be chosen based on the chosen column
});
}
deleteRule(ruleToDelete) {
let finishedSearching = false;
function childrenWithoutRule(rules) {
const newRules = [];
for (const rule of rules) {
if (finishedSearching) {
newRules.push(rule);
} else if (rule !== ruleToDelete) {
const newRule = Object.assign({}, rule);
if (rule.rules) {
newRule.rules = childrenWithoutRule(rule.rules);
}
newRules.push(newRule);
} else {
finishedSearching = true;
}
}
return newRules;
}
const rules = childrenWithoutRule(this.getFormValue('settings').rootRule.rules);
this.getFormValue('settings').rootRule.rules = rules;
this.setState({
rulesTree: this.getTreeFromRules(rules)
});
}
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
const selectedRule = this.getFormValue('selectedRule');
const ruleHelpers = this.ruleHelpers;
let ruleOptionsVisibilityClass = '';
if ('ruleOptionsVisible' in this.state) {
if (this.state.ruleOptionsVisible) {
ruleOptionsVisibilityClass = ' ' + styles.ruleOptionsVisible;
} else {
ruleOptionsVisibilityClass = ' ' + styles.ruleOptionsHidden;
}
}
return (
<div>
{isEdit &&
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
deleteUrl={`rest/segments/${this.props.list.id}/${this.props.entity.id}`}
backUrl={`/lists/${this.props.list.id}/segments/${this.props.entity.id}/edit`}
successUrl={`/lists/${this.props.list.id}/segments`}
deletingMsg={t('deletingSegment')}
deletedMsg={t('segmentDeleted')}/>
}
<Title>{isEdit ? t('editSegment') : t('createSegment')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<h3>{t('segmentOptions')}</h3>
<InputField id="name" label={t('name')} />
<Dropdown id="rootRuleType" label={t('toplevelMatchType')} options={ruleHelpers.getCompositeRuleTypeOptions()} />
</Form>
<hr />
<div className={styles.rulePane + ruleOptionsVisibilityClass}>
<div className={styles.leftPane}>
<div className={styles.leftPaneInner}>
<Toolbar>
<Button className="btn-secondary" label={t('addCompositeRule')} onClickAsync={::this.addCompositeRule}/>
<Button className="btn-secondary" label={t('addRule')} onClickAsync={::this.addPrimitiveRule}/>
</Toolbar>
<h3>{t('rules')}</h3>
<div className="clearfix"/>
<div className={styles.ruleTree}>
<SortableTree
treeData={this.state.rulesTree}
onChange={rulesTree => this.onRulesChanged(rulesTree)}
isVirtualized={false}
canDrop={ data => !data.nextParent || (ruleHelpers.isCompositeRuleType(data.nextParent.rule.type)) }
generateNodeProps={data => ({
buttons: [
<ActionLink onClickAsync={async () => !this.state.ruleOptionsVisible && this.showRuleOptions(data.node.rule)} className={styles.ruleActionLink}><Icon icon="edit" title={t('edit')}/></ActionLink>,
<ActionLink onClickAsync={async () => !this.state.ruleOptionsVisible && this.deleteRule(data.node.rule)} className={styles.ruleActionLink}><Icon icon="trash-alt" title={t('delete')}/></ActionLink>
]
})}
/>
</div>
</div>
<div className={styles.leftPaneOverlay} />
<div className={styles.paneDivider}>
<div className={styles.paneDividerSolidBackground}/>
</div>
</div>
<div className={styles.rightPane}>
<div className={styles.rightPaneInner}>
{selectedRule &&
<RuleSettingsPane rule={selectedRule} fields={this.props.fields} onChange={this.onRuleSettingsPaneUpdatedHandler} onClose={this.onRuleSettingsPaneCloseHandler} onDelete={this.onRuleSettingsPaneDeleteHandler} forceShowValidation={this.isFormValidationShown()}/>}
</div>
</div>
</div>
<hr/>
<ButtonRow format="wide" className={`col-12 ${styles.toolbar}`}>
<Button type="submit" className="btn-primary" icon="check" label={t('save')} onClickAsync={async () => await this.submitHandler(false)}/>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndLeave')} onClickAsync={async () => await this.submitHandler(true)}/>
{isEdit && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/${this.props.list.id}/segments/${this.props.entity.id}/delete`}/> }
</ButtonRow>
</div>
);
}
}

View file

@ -1,152 +0,0 @@
$desktopMinWidth: 768px;
$mobileLeftPaneResidualWidth: 0px;
$mobileAnimationStartPosition: 100px;
$desktopLeftPaneResidualWidth: 200px;
$desktopAnimationStartPosition: 300px;
@mixin optionsHidden {
transform: translateX($mobileAnimationStartPosition);
@media (min-width: $desktopMinWidth) {
transform: translateX($desktopAnimationStartPosition);
}
}
@mixin optionsVisible {
transform: translateX($mobileLeftPaneResidualWidth);
@media (min-width: $desktopMinWidth) {
transform: translateX($desktopLeftPaneResidualWidth);
}
}
.toolbar {
text-align: right;
}
.ruleActionLink {
padding-right: 5px;
}
.rulePane {
position: relative;
width: 100%;
overflow: hidden;
.leftPane {
display: inline-block;
width: 100%;
margin-right: -100%;
.leftPaneInner {
.ruleTree {
background: #fbfbfb;
border: #cfcfcf 1px solid;
border-radius: 4px;
padding: 10px 0px;
margin-top: 15px;
margin-bottom: 30px;
// Without this, the placeholders when rearranging the tree are not shown
position: relative;
z-index: 0;
}
}
.leftPaneOverlay {
display: none;
position: absolute;
left: 0px;
top: 0px;
height: 100%;
z-index: 1;
width: $mobileLeftPaneResidualWidth;
@media (min-width: $desktopMinWidth) {
width: $desktopLeftPaneResidualWidth;
}
}
.paneDivider {
display: block;
position: absolute;
left: 0px;
top: 0px;
width: 100%;
height: 100%;
background: url('./divider.png') repeat-y;
@include optionsHidden;
padding-left: 50px;
z-index: 1;
opacity: 0;
visibility: hidden;
.paneDividerSolidBackground {
position: absolute;
width: 100%;
height: 100%;
background: white;
}
}
}
.rightPane {
display: inline-block;
width: 100%;
vertical-align: top;
z-index: 2;
position: relative;
@include optionsHidden;
opacity: 0;
visibility: hidden;
.rightPaneInner {
margin-right: $mobileLeftPaneResidualWidth;
@media (min-width: $desktopMinWidth) {
margin-right: $desktopLeftPaneResidualWidth;
}
.ruleOptions {
margin-left: 60px;
}
}
}
&.ruleOptionsVisible {
.leftPaneOverlay {
display: block;
}
.paneDivider {
transition: transform 300ms ease-out, opacity 100ms ease-out;
opacity: 1;
visibility: visible;
@include optionsVisible;
}
.rightPane {
transition: transform 300ms ease-out, opacity 100ms ease-out;
opacity: 1;
visibility: visible;
@include optionsVisible;
}
}
&.ruleOptionsHidden {
.paneDivider {
transition: visibility 0s linear 300ms, transform 300ms ease-in, opacity 100ms ease-in 200ms;
}
.rightPane {
transition: visibility 0s linear 300ms, transform 300ms ease-in, opacity 100ms ease-in 200ms;
}
}
}

View file

@ -1,72 +0,0 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withTranslation} from '../../lib/i18n';
import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../../lib/page';
import {withErrorHandling} from '../../lib/error-handling';
import {Table} from '../../lib/table';
import {Icon} from "../../lib/bootstrap-components";
import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../../lib/modals";
import {withComponentMixins} from "../../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class List extends Component {
constructor(props) {
super(props);
this.state = {};
tableRestActionDialogInit(this);
}
static propTypes = {
list: PropTypes.object
}
componentDidMount() {
}
render() {
const t = this.props.t;
const columns = [
{ data: 1, title: t('name') },
{
actions: data => {
const actions = [];
if (this.props.list.permissions.includes('manageSegments')) {
actions.push({
label: <Icon icon="edit" title={t('edit')}/>,
link: `/lists/${this.props.list.id}/segments/${data[0]}/edit`
});
tableAddDeleteButton(actions, this, null, `rest/segments/${this.props.list.id}/${data[0]}`, data[1], t('deletingSegment'), t('segmentDeleted'));
}
return actions;
}
}
];
return (
<div>
{tableRestActionDialogRender(this)}
{this.props.list.permissions.includes('manageSegments') &&
<Toolbar>
<LinkButton to={`/lists/${this.props.list.id}/segments/create`} className="btn-primary" icon="plus" label={t('createSegment')}/>
</Toolbar>
}
<Title>{t('segments')}</Title>
<Table ref={node => this.table = node} withHeader dataUrl={`rest/segments-table/${this.props.list.id}`} columns={columns} />
</div>
);
}
}

View file

@ -1,231 +0,0 @@
'use strict';
import React, {PureComponent} from "react";
import PropTypes from "prop-types";
import {withTranslation} from '../../lib/i18n';
import {requiresAuthenticatedUser, withPageHelpers} from "../../lib/page";
import {Button, ButtonRow, Dropdown, Form, TableSelect, withForm} from "../../lib/form";
import {withErrorHandling} from "../../lib/error-handling";
import {getRuleHelpers} from "./helpers";
import {getFieldTypes} from "../fields/helpers";
import styles from "./CUD.scss";
import {withComponentMixins} from "../../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class RuleSettingsPane extends PureComponent {
constructor(props) {
super(props);
const t = props.t;
this.ruleHelpers = getRuleHelpers(t, props.fields);
this.fieldTypes = getFieldTypes(t);
this.state = {};
this.initForm({
leaveConfirmation: false,
onChangeBeforeValidation: ::this.populateRuleDefaults
});
}
static propTypes = {
rule: PropTypes.object.isRequired,
fields: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
forceShowValidation: PropTypes.bool.isRequired
}
updateStateFromProps(populateForm) {
const props = this.props;
if (populateForm) {
const rule = props.rule;
const ruleHelpers = this.ruleHelpers;
let data;
if (!ruleHelpers.isCompositeRuleType(rule.type)) { // rule.type === null signifies primitive rule where the type has not been determined yet
data = ruleHelpers.primitiveRuleTypesFormDataDefaults;
const settings = ruleHelpers.getRuleTypeSettings(rule);
if (settings) {
Object.assign(data, settings.getFormData(rule));
}
data.type = rule.type || ''; // On '', we display label "--SELECT--" in the type dropdown. Null would not be accepted by React.
data.column = rule.column;
} else {
data = {
type: rule.type
};
}
this.populateFormValues(data);
}
if (props.forceShowValidation) {
this.showFormValidation();
}
}
componentDidMount() {
this.updateStateFromProps(true);
}
componentDidUpdate(prevProps) {
this.updateStateFromProps(this.props.rule !== prevProps.rule);
if (this.isFormWithoutErrors()) {
const rule = this.props.rule;
const ruleHelpers = this.ruleHelpers;
rule.type = this.getFormValue('type');
if (!ruleHelpers.isCompositeRuleType(rule.type)) {
rule.column = this.getFormValue('column');
const settings = this.ruleHelpers.getRuleTypeSettings(rule);
settings.assignRuleSettings(rule, key => this.getFormValue(key));
}
this.props.onChange(false);
} else {
this.props.onChange(true);
}
}
localValidateFormValues(state) {
const t = this.props.t;
const ruleHelpers = this.ruleHelpers;
for (const key of state.keys()) {
state.setIn([key, 'error'], null);
}
const ruleType = state.getIn(['type', 'value']);
if (!ruleHelpers.isCompositeRuleType(ruleType)) {
if (!ruleType) {
state.setIn(['type', 'error'], t('typeMustBeSelected'));
}
const column = state.getIn(['column', 'value']);
if (column) {
const colType = ruleHelpers.getColumnType(column);
if (ruleType) {
const settings = ruleHelpers.primitiveRuleTypes[colType][ruleType];
settings.validate(state);
}
} else {
state.setIn(['column', 'error'], t('fieldMustBeSelected'));
}
}
}
populateRuleDefaults(mutStateData) {
const ruleHelpers = this.ruleHelpers;
const type = mutStateData.getIn(['type','value']);
if (!ruleHelpers.isCompositeRuleType(type)) {
const column = mutStateData.getIn(['column', 'value']);
if (column) {
const colType = ruleHelpers.getColumnType(column);
if (type) {
const settings = ruleHelpers.primitiveRuleTypes[colType][type];
if (!settings) {
// The existing rule type does not fit the newly changed column. This resets the rule type chooser to "--- Select ---"
mutStateData.setIn(['type', 'value'], '');
}
}
}
}
}
async closeForm() {
if (this.isFormWithoutErrors()) {
this.props.onClose();
} else {
this.showFormValidation();
}
}
async deleteRule() {
this.props.onDelete();
}
render() {
const t = this.props.t;
const rule = this.props.rule;
const ruleHelpers = this.ruleHelpers;
let ruleOptions = null;
if (ruleHelpers.isCompositeRuleType(rule.type)) {
ruleOptions = <Dropdown id="type" label={t('type')} options={ruleHelpers.getCompositeRuleTypeOptions()} />
} else {
const ruleColumnOptionsColumns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('type') },
{ data: 3, title: t('mergeTag') }
];
const ruleColumnOptions = ruleHelpers.fields.map(fld => [ fld.column, fld.name, this.fieldTypes[fld.type].label, fld.key || '' ]);
const ruleColumnSelect = <TableSelect id="column" label={t('field')} data={ruleColumnOptions} columns={ruleColumnOptionsColumns} dropdown withHeader selectionLabelIndex={1} />;
let ruleTypeSelect = null;
let ruleSettings = null;
const ruleColumn = this.getFormValue('column');
if (ruleColumn) {
const colType = ruleHelpers.getColumnType(ruleColumn);
if (colType) {
const ruleTypeOptions = ruleHelpers.getPrimitiveRuleTypeOptions(colType);
ruleTypeOptions.unshift({ key: '', label: t('select-1')});
if (ruleTypeOptions) {
ruleTypeSelect = <Dropdown id="type" label={t('type')} options={ruleTypeOptions} />
const ruleType = this.getFormValue('type');
if (ruleType) {
ruleSettings = ruleHelpers.primitiveRuleTypes[colType][ruleType].getForm();
}
}
}
}
ruleOptions =
<div>
{ruleColumnSelect}
{ruleTypeSelect}
{ruleSettings}
</div>;
}
return (
<div className={styles.ruleOptions}>
<h3>{t('ruleOptions')}</h3>
<Form stateOwner={this} onSubmitAsync={::this.closeForm}>
{ruleOptions}
<ButtonRow>
<Button type="submit" className="btn-primary" icon="chevron-left" label={t('ok')}/>
<Button className="btn-primary" icon="trash-alt" label={t('delete')} onClickAsync={::this.deleteRule}/>
</ButtonRow>
</Form>
</div>
);
}
}

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 B

View file

@ -1,459 +0,0 @@
'use strict';
import React from 'react';
import {DatePicker, Dropdown, InputField} from "../../lib/form";
import {DateFormat, formatBirthday, formatDate, parseBirthday, parseDate} from '../../../../shared/date';
import {tMark} from "../../lib/i18n";
export function getRuleHelpers(t, fields) {
const ruleHelpers = {};
ruleHelpers.compositeRuleTypes = {
all: {
dropdownLabel: t('allRulesMustMatch'),
treeLabel: rule => t('allRulesMustMatch')
},
some: {
dropdownLabel: t('atLeastOneRuleMustMatch'),
treeLabel: rule => t('atLeastOneRuleMustMatch')
},
none: {
dropdownLabel: t('noRuleMayMatch'),
treeLabel: rule => t('noRuleMayMatch')
}
};
ruleHelpers.primitiveRuleTypes = {};
ruleHelpers.primitiveRuleTypes.text = {
eq: {
dropdownLabel: t('equalTo'),
treeLabel: rule => t('valueInColumnColNameIsEqualToValue', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
},
like: {
dropdownLabel: t('matchWithSqlLike'),
treeLabel: rule => t('valueInColumnColNameMatchesWithSqlLike', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
},
re: {
dropdownLabel: t('matchWithRegularExpressions'),
treeLabel: rule => t('valueInColumnColNameMatchesWithRegular', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
},
lt: {
dropdownLabel: t('alphabeticallyBefore'),
treeLabel: rule => t('valueInColumnColNameIsAlphabetically', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
},
le: {
dropdownLabel: t('alphabeticallyBeforeOrEqualTo'),
treeLabel: rule => t('valueInColumnColNameIsAlphabetically-1', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
},
gt: {
dropdownLabel: t('alphabeticallyAfter'),
treeLabel: rule => t('valueInColumnColNameIsAlphabetically-2', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
},
ge: {
dropdownLabel: t('alphabeticallyAfterOrEqualTo'),
treeLabel: rule => t('valueInColumnColNameIsAlphabetically-3', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
}
};
ruleHelpers.primitiveRuleTypes.website = {
eq: {
dropdownLabel: t('equalTo'),
treeLabel: rule => t('valueInColumnColNameIsEqualToValue', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
},
like: {
dropdownLabel: t('matchWithSqlLike'),
treeLabel: rule => t('valueInColumnColNameMatchesWithSqlLike', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
},
re: {
dropdownLabel: t('matchWithRegularExpressions'),
treeLabel: rule => t('valueInColumnColNameMatchesWithRegular', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
}
};
ruleHelpers.primitiveRuleTypes.number = {
eq: {
dropdownLabel: t('equalTo'),
treeLabel: rule => t('valueInColumnColNameIsEqualToValue-1', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
},
lt: {
dropdownLabel: t('lessThan'),
treeLabel: rule => t('valueInColumnColNameIsLessThanValue', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
},
le: {
dropdownLabel: t('lessThanOrEqualTo'),
treeLabel: rule => t('valueInColumnColNameIsLessThanOrEqualTo', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
},
gt: {
dropdownLabel: t('greaterThan'),
treeLabel: rule => t('valueInColumnColNameIsGreaterThanValue', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
},
ge: {
dropdownLabel: t('greaterThanOrEqualTo'),
treeLabel: rule => t('valueInColumnColNameIsGreaterThanOrEqual', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
}
};
// FXIME - the localization here is still wrong
function getRelativeDateTreeLabel(rule, variants) {
if (rule.value === 0) {
return t(variants[0], {colName: ruleHelpers.getColumnName(rule.column)})
} else if (rule.value > 0) {
return t(variants[1], {colName: ruleHelpers.getColumnName(rule.column), value: rule.value});
} else {
return t(variants[2], {colName: ruleHelpers.getColumnName(rule.column), value: -rule.value});
}
}
ruleHelpers.primitiveRuleTypes.date = {
eq: {
dropdownLabel: t('on'),
treeLabel: rule => t('dateInColumnColNameIsValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(DateFormat.INTL, rule.value)}),
},
lt: {
dropdownLabel: t('before'),
treeLabel: rule => t('dateInColumnColNameIsBeforeValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(DateFormat.INTL, rule.value)}),
},
le: {
dropdownLabel: t('beforeOrOn'),
treeLabel: rule => t('dateInColumnColNameIsBeforeOrOnValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(DateFormat.INTL, rule.value)}),
},
gt: {
dropdownLabel: t('after'),
treeLabel: rule => t('dateInColumnColNameIsAfterValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(DateFormat.INTL, rule.value)}),
},
ge: {
dropdownLabel: t('afterOrOn'),
treeLabel: rule => t('dateInColumnColNameIsAfterOrOnValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(DateFormat.INTL, rule.value)}),
},
eqTodayPlusDays: {
dropdownLabel: t('onXthDayBeforeafterCurrentDate'),
treeLabel: rule => getRelativeDateTreeLabel(rule, [tMark('dateInColumnColNameIsTheCurrentDate'), tMark('dateInColumnColNameIsTheValuethDayAfter'), tMark('dateInColumnColNameIsTheValuethDayBefore')]),
},
ltTodayPlusDays: {
dropdownLabel: t('beforeXthDayBeforeafterCurrentDate'),
treeLabel: rule => getRelativeDateTreeLabel(rule, [tMark('dateInColumnColNameIsBeforeTheCurrent'), tMark('dateInColumnColNameIsBeforeTheValuethDay'), tMark('dateInColumnColNameIsBeforeTheValuethDay-1')]),
},
leTodayPlusDays: {
dropdownLabel: t('beforeOrOnXthDayBeforeafterCurrentDate'),
treeLabel: rule => getRelativeDateTreeLabel(rule, [tMark('dateInColumnColNameIsBeforeOrOnThe'), tMark('dateInColumnColNameIsBeforeOrOnThe-1'), tMark('dateInColumnColNameIsBeforeOrOnThe-2')]),
},
gtTodayPlusDays: {
dropdownLabel: t('afterXthDayBeforeafterCurrentDate'),
treeLabel: rule => getRelativeDateTreeLabel(rule, [tMark('dateInColumnColNameIsAfterTheCurrentDate'), tMark('dateInColumnColNameIsAfterTheValuethDay'), tMark('dateInColumnColNameIsAfterTheValuethDay-1')]),
},
geTodayPlusDays: {
dropdownLabel: t('afterOrOnXthDayBeforeafterCurrentDate'),
treeLabel: rule => getRelativeDateTreeLabel(rule, [tMark('dateInColumnColNameIsAfterOrOnTheCurrent'), tMark('dateInColumnColNameIsAfterOrOnTheValueth'), tMark('dateInColumnColNameIsAfterOrOnTheValueth-1')]),
}
};
ruleHelpers.primitiveRuleTypes.birthday = {
eq: {
dropdownLabel: t('on'),
treeLabel: rule => t('dateInColumnColNameIsValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatBirthday(DateFormat.INTL, rule.value)}),
},
lt: {
dropdownLabel: t('before'),
treeLabel: rule => t('dateInColumnColNameIsBeforeValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatBirthday(DateFormat.INTL, rule.value)}),
},
le: {
dropdownLabel: t('beforeOrOn'),
treeLabel: rule => t('dateInColumnColNameIsBeforeOrOnValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatBirthday(DateFormat.INTL, rule.value)}),
},
gt: {
dropdownLabel: t('after'),
treeLabel: rule => t('dateInColumnColNameIsAfterValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatBirthday(DateFormat.INTL, rule.value)}),
},
ge: {
dropdownLabel: t('afterOrOn'),
treeLabel: rule => t('dateInColumnColNameIsAfterOrOnValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatBirthday(DateFormat.INTL, rule.value)}),
}
};
ruleHelpers.primitiveRuleTypes.option = {
isTrue: {
dropdownLabel: t('isSelected'),
treeLabel: rule => t('valueInColumnColNameIsSelected', {colName: ruleHelpers.getColumnName(rule.column)}),
},
isFalse: {
dropdownLabel: t('isNotSelected'),
treeLabel: rule => t('valueInColumnColNameIsNotSelected', {colName: ruleHelpers.getColumnName(rule.column)}),
}
};
ruleHelpers.primitiveRuleTypes['dropdown-enum'] = ruleHelpers.primitiveRuleTypes['radio-enum'] = {
eq: {
dropdownLabel: t('keyEqualTo'),
treeLabel: rule => t('theSelectedKeyInColumnColNameIsEqualTo', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
},
like: {
dropdownLabel: t('keyMatchWithSqlLike'),
treeLabel: rule => t('theSelectedKeyInColumnColNameMatchesWith', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
},
re: {
dropdownLabel: t('keyMatchWithRegularExpressions'),
treeLabel: rule => t('theSelectedKeyInColumnColNameMatchesWith-1', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
},
lt: {
dropdownLabel: t('keyAlphabeticallyBefore'),
treeLabel: rule => t('theSelectedKeyInColumnColNameIs', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
},
le: {
dropdownLabel: t('keyAlphabeticallyBeforeOrEqualTo'),
treeLabel: rule => t('theSelectedKeyInColumnColNameIs-1', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
},
gt: {
dropdownLabel: t('keyAlphabeticallyAfter'),
treeLabel: rule => t('theSelectedKeyInColumnColNameIs-2', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
},
ge: {
dropdownLabel: t('keyAlphabeticallyAfterOrEqualTo'),
treeLabel: rule => t('theSelectedKeyInColumnColNameIs-3', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
}
};
const stringValueSettings = allowEmpty => ({
getForm: () => <InputField id="value" label={t('value')} />,
getFormData: rule => ({
value: rule.value
}),
assignRuleSettings: (rule, getter) => {
rule.value = getter('value');
},
validate: state => {
if (!allowEmpty && !state.getIn(['value', 'value'])) {
state.setIn(['value', 'error'], t('valueMustNotBeEmpty'));
} else {
state.setIn(['value', 'error'], null);
}
}
});
const numberValueSettings = {
getForm: () => <InputField id="value" label={t('value')} />,
getFormData: rule => ({
value: rule.value.toString()
}),
assignRuleSettings: (rule, getter) => {
rule.value = parseInt(getter('value'));
},
validate: state => {
const value = state.getIn(['value', 'value']).trim();
if (value === '') {
state.setIn(['value', 'error'], t('valueMustNotBeEmpty'));
} else if (isNaN(value)) {
state.setIn(['value', 'error'], t('valueMustBeANumber'));
} else {
state.setIn(['value', 'error'], null);
}
}
};
const birthdayValueSettings = {
getForm: () => <DatePicker id="birthday" label={t('date')} birthday />,
getFormData: rule => ({
birthday: formatBirthday(DateFormat.INTL, rule.value)
}),
assignRuleSettings: (rule, getter) => {
rule.value = parseBirthday(DateFormat.INTL, getter('birthday')).toISOString();
},
validate: state => {
const value = state.getIn(['birthday', 'value']);
const date = parseBirthday(DateFormat.INTL, value);
if (!value) {
state.setIn(['birthday', 'error'], t('dateMustNotBeEmpty'));
} else if (!date) {
state.setIn(['birthday', 'error'], t('dateIsInvalid'));
} else {
state.setIn(['birthday', 'error'], null);
}
}
};
const dateValueSettings = {
getForm: () => <DatePicker id="date" label={t('date')} />,
getFormData: rule => ({
date: formatDate(DateFormat.INTL, rule.value)
}),
assignRuleSettings: (rule, getter) => {
rule.value = parseDate(DateFormat.INTL, getter('date')).toISOString();
},
validate: state => {
const value = state.getIn(['date', 'value']);
const date = parseDate(DateFormat.INTL, value);
if (!value) {
state.setIn(['date', 'error'], t('dateMustNotBeEmpty'));
} else if (!date) {
state.setIn(['date', 'error'], t('dateIsInvalid'));
} else {
state.setIn(['date', 'error'], null);
}
}
};
const dateRelativeValueSettings = {
getForm: () =>
<div>
<InputField id="daysValue" label={t('numberOfDays')}/>
<Dropdown id="direction" label={t('beforeAfter')} options={[
{ key: 'before', label: t('beforeCurrentDate') },
{ key: 'after', label: t('afterCurrentDate') }
]}/>
</div>,
getFormData: rule => ({
daysValue: Math.abs(rule.value).toString(),
direction: rule.value >= 0 ? 'after' : 'before'
}),
assignRuleSettings: (rule, getter) => {
const direction = getter('direction');
rule.value = parseInt(getter('daysValue')) * (direction === 'before' ? -1 : 1);
},
validate: state => {
const value = state.getIn(['daysValue', 'value']);
if (!value) {
state.setIn(['daysValue', 'error'], t('numberOfDaysMustNotBeEmpty'));
} else if (isNaN(value)) {
state.setIn(['daysValue', 'error'], t('numberOfDaysMustBeANumber'));
} else {
state.setIn(['daysValue', 'error'], null);
}
}
};
const optionValueSettings = {
getForm: () => null,
getFormData: rule => ({}),
assignRuleSettings: (rule, getter) => {},
validate: state => {}
};
function assignSettingsToRuleTypes(ruleTypes, keys, settings) {
for (const key of keys) {
Object.assign(ruleTypes[key], settings);
}
}
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.text, ['eq', 'like', 're'], stringValueSettings(true));
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.text, ['lt', 'le', 'gt', 'ge'], stringValueSettings(false));
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.website, ['eq', 'like', 're'], stringValueSettings(true));
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.number, ['eq', 'lt', 'le', 'gt', 'ge'], numberValueSettings);
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.birthday, ['eq', 'lt', 'le', 'gt', 'ge'], birthdayValueSettings);
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.date, ['eq', 'lt', 'le', 'gt', 'ge'], dateValueSettings);
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.date, ['eqTodayPlusDays', 'ltTodayPlusDays', 'leTodayPlusDays', 'gtTodayPlusDays', 'geTodayPlusDays'], dateRelativeValueSettings);
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.option, ['isTrue', 'isFalse'], optionValueSettings);
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['dropdown-enum'], ['eq', 'like', 're'], stringValueSettings(true));
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['dropdown-enum'], ['lt', 'le', 'gt', 'ge'], stringValueSettings(false));
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['radio-enum'], ['eq', 'like', 're'], stringValueSettings(true));
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['radio-enum'], ['lt', 'le', 'gt', 'ge'], stringValueSettings(false));
ruleHelpers.primitiveRuleTypesFormDataDefaults = {
value: '',
date: '',
daysValue: '',
birthday: '',
direction: 'before'
};
ruleHelpers.getCompositeRuleTypeOptions = () => {
const order = ['all', 'some', 'none'];
return order.map(key => ({ key, label: ruleHelpers.compositeRuleTypes[key].dropdownLabel }));
};
ruleHelpers.getPrimitiveRuleTypeOptions = columnType => {
const order = {
text: ['eq', 'like', 're', 'lt', 'le', 'gt', 'ge'],
website: ['eq', 'like', 're'],
number: ['eq', 'lt', 'le', 'gt', 'ge'],
birthday: ['eq', 'lt', 'le', 'gt', 'ge'],
date: ['eq', 'lt', 'le', 'gt', 'ge', 'eqTodayPlusDays', 'ltTodayPlusDays', 'leTodayPlusDays', 'gtTodayPlusDays', 'geTodayPlusDays'],
option: ['isTrue', 'isFalse'],
'dropdown-enum': ['eq', 'like', 're', 'lt', 'le', 'gt', 'ge'],
'radio-enum': ['eq', 'like', 're', 'lt', 'le', 'gt', 'ge']
};
return order[columnType].map(key => ({ key, label: ruleHelpers.primitiveRuleTypes[columnType][key].dropdownLabel }));
};
const predefColumns = [
{
column: 'email',
name: t('emailAddress-1'),
type: 'text',
key: 'EMAIL'
},
{
column: 'opt_in_country',
name: t('signupCountry'),
type: 'text'
},
{
column: 'created',
name: t('signUpDate'),
type: 'date'
},
{
column: 'latest_open',
name: t('latestOpen'),
type: 'date'
},
{
column: 'latest_click',
name: t('latestClick'),
type: 'date'
},
{
column: 'is_test',
name: t('testUser'),
type: 'option'
}
];
ruleHelpers.fields = [
...predefColumns,
...fields.filter(fld => fld.type in ruleHelpers.primitiveRuleTypes)
];
ruleHelpers.fieldsByColumn = {};
for (const fld of ruleHelpers.fields) {
ruleHelpers.fieldsByColumn[fld.column] = fld;
}
ruleHelpers.getColumnType = column => {
const field = ruleHelpers.fieldsByColumn[column];
if (field) {
return field.type;
}
};
ruleHelpers.getColumnName = column => {
const field = ruleHelpers.fieldsByColumn[column];
if (field) {
return field.name;
}
};
ruleHelpers.getRuleTypeSettings = rule => {
if (ruleHelpers.isCompositeRuleType(rule.type)) {
return ruleHelpers.compositeRuleTypes[rule.type];
} else {
const colType = ruleHelpers.getColumnType(rule.column);
if (colType) {
if (rule.type in ruleHelpers.primitiveRuleTypes[colType]) {
return ruleHelpers.primitiveRuleTypes[colType][rule.type];
}
}
}
};
ruleHelpers.isCompositeRuleType = ruleType => ruleType in ruleHelpers.compositeRuleTypes;
return ruleHelpers;
}

View file

@ -1,7 +0,0 @@
.mapping {
margin-top: 30px;
}
.erased {
color: #808080;
}

View file

@ -1,247 +0,0 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {HTTPMethod} from '../../lib/axios';
import {withTranslation} from '../../lib/i18n';
import {LinkButton, requiresAuthenticatedUser, Title, withPageHelpers} from '../../lib/page';
import {
AlignedRow,
Button,
ButtonRow,
CheckBox,
Dropdown,
filterData,
Form,
FormSendMethod,
InputField,
withForm,
withFormErrorHandlers
} from '../../lib/form';
import {withErrorHandling} from '../../lib/error-handling';
import {RestActionModalDialog} from "../../lib/modals";
import interoperableErrors from '../../../../shared/interoperable-errors';
import {getFieldColumn, SubscriptionStatus} from '../../../../shared/lists';
import {getFieldTypes, getSubscriptionStatusLabels} from './helpers';
import moment from 'moment-timezone';
import {withComponentMixins} from "../../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class CUD extends Component {
constructor(props) {
super(props);
const t = props.t;
this.state = {};
this.subscriptionStatusLabels = getSubscriptionStatusLabels(t);
this.fieldTypes = getFieldTypes(t);
this.initForm({
serverValidation: {
url: `rest/subscriptions-validate/${this.props.list.id}`,
changed: ['email'],
extra: ['id']
},
});
this.timezoneOptions = [
{ key: '', label: t('notSelected') },
...moment.tz.names().map(tz => ({ key: tz.toLowerCase(), label: tz }))
];
}
static propTypes = {
action: PropTypes.string.isRequired,
list: PropTypes.object,
fieldsGrouped: PropTypes.array,
entity: PropTypes.object
}
getFormValuesMutator(data) {
data.status = data.status.toString();
data.tz = data.tz || '';
for (const fld of this.props.fieldsGrouped) {
this.fieldTypes[fld.type].assignFormData(fld, data);
}
}
submitFormValuesMutator(data) {
data.status = parseInt(data.status);
data.tz = data.tz || null;
const allowedCols = ['email', 'tz', 'is_test', 'status'];
for (const fld of this.props.fieldsGrouped) {
this.fieldTypes[fld.type].assignEntity(fld, data);
allowedCols.push(getFieldColumn(fld));
}
return filterData(data, allowedCols);
}
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity);
} else {
const data = {
email: '',
tz: '',
is_test: false,
status: SubscriptionStatus.SUBSCRIBED
};
for (const fld of this.props.fieldsGrouped) {
this.fieldTypes[fld.type].initFormData(fld, data);
}
this.populateFormValues(data);
}
}
localValidateFormValues(state) {
const t = this.props.t;
const emailServerValidation = state.getIn(['email', 'serverValidation']);
if (!state.getIn(['email', 'value'])) {
state.setIn(['email', 'error'], t('emailMustNotBeEmpty-1'));
} else if (!emailServerValidation) {
state.setIn(['email', 'error'], t('validationIsInProgress'));
} else if (emailServerValidation.exists) {
state.setIn(['email', 'error'], t('anotherSubscriptionWithTheSameEmail'));
} else {
state.setIn(['email', 'error'], null);
}
for (const fld of this.props.fieldsGrouped) {
this.fieldTypes[fld.type].validate(fld, state);
}
}
@withFormErrorHandlers
async submitHandler(submitAndLeave) {
const t = this.props.t;
let sendMethod, url;
if (this.props.entity) {
sendMethod = FormSendMethod.PUT;
url = `rest/subscriptions/${this.props.list.id}/${this.props.entity.id}`
} else {
sendMethod = FormSendMethod.POST;
url = `rest/subscriptions/${this.props.list.id}`
}
try {
this.disableForm();
this.setFormStatusMessage('info', t('saving'));
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url);
if (submitResult) {
if (this.props.entity) {
if (submitAndLeave) {
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/subscriptions`, 'success', t('subscriptionUpdated'));
} else {
await this.getFormValuesFromURL(`rest/subscriptions/${this.props.list.id}/${this.props.entity.id}`);
this.enableForm();
this.setFormStatusMessage('success', t('subscriptionUpdated'));
}
} else {
if (submitAndLeave) {
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/subscriptions`, 'success', t('subscriptionCreated'));
} else {
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/subscriptions/${submitResult}/edit`, 'success', t('subscriptionCreated'));
}
}
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
}
} catch (error) {
if (error instanceof interoperableErrors.DuplicitEmailError) {
this.setFormStatusMessage('danger',
<span>
<strong>{t('yourUpdatesCannotBeSaved')}</strong>{' '}
{t('itSeemsThatAnotherSubscriptionWithThe')}
</span>
);
return;
}
throw error;
}
}
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
const fieldsGrouped = this.props.fieldsGrouped;
const statusOptions = Object.keys(this.subscriptionStatusLabels)
.map(key => ({key, label: this.subscriptionStatusLabels[key]}));
const customFields = [];
for (const fld of this.props.fieldsGrouped) {
customFields.push(this.fieldTypes[fld.type].form(fld));
}
return (
<div>
{isEdit &&
<div>
<RestActionModalDialog
title={t('confirmDeletion')}
message={t('areYouSureYouWantToDeleteSubscriptionFor', {email: this.getFormValue('email') || ''})}
stateOwner={this}
visible={this.props.action === 'delete'}
actionMethod={HTTPMethod.DELETE}
actionUrl={`rest/subscriptions/${this.props.list.id}/${this.props.entity.id}`}
backUrl={`/lists/${this.props.list.id}/subscriptions/${this.props.entity.id}/edit`}
successUrl={`/lists/${this.props.list.id}/subscriptions`}
actionInProgressMsg={t('deletingSubscription')}
actionDoneMsg={t('subscriptionDeleted')}/>
</div>
}
<Title>{isEdit ? t('editSubscription') : t('createSubscription')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="email" label={t('email')}/>
{customFields}
<hr />
<Dropdown id="tz" label={t('timezone')} options={this.timezoneOptions}/>
<Dropdown id="status" label={t('subscriptionStatus')} options={statusOptions}/>
<CheckBox id="is_test" text={t('testUser?')} help={t('ifCheckedThenThisSubscriptionCanBeUsed')}/>
{!isEdit &&
<AlignedRow>
<p className="text-warning">
This person will not receive a confirmation email so make sure that you have permission to
email them.
</p>
</AlignedRow>
}
<ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndLeave')} onClickAsync={async () => await this.submitHandler(true)}/>
{isEdit && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/${this.props.list.id}/subscriptions/${this.props.entity.id}/delete`}/>}
</ButtonRow>
</Form>
</div>
);
}
}

View file

@ -1,191 +0,0 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withTranslation} from '../../lib/i18n';
import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../../lib/page';
import {withErrorHandling} from '../../lib/error-handling';
import {Table} from '../../lib/table';
import {SubscriptionStatus} from '../../../../shared/lists';
import moment from 'moment';
import {Dropdown, Form, withForm} from '../../lib/form';
import {Button, Icon} from "../../lib/bootstrap-components";
import {HTTPMethod} from '../../lib/axios';
import {getFieldTypes, getSubscriptionStatusLabels} from './helpers';
import {getPublicUrl, getUrl} from "../../lib/urls";
import {
tableAddDeleteButton,
tableAddRestActionButton,
tableRestActionDialogInit,
tableRestActionDialogRender
} from "../../lib/modals";
import listStyles from "../styles.scss";
import {withComponentMixins} from "../../lib/decorator-helpers";
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class List extends Component {
constructor(props) {
super(props);
const t = props.t;
this.state = {};
tableRestActionDialogInit(this);
this.subscriptionStatusLabels = getSubscriptionStatusLabels(t);
this.fieldTypes = getFieldTypes(t);
this.initForm({
leaveConfirmation: false,
onChange: {
segment: (newState, key, oldValue, value) => {
this.navigateTo(`/lists/${this.props.list.id}/subscriptions` + (value ? '?segment=' + value : ''));
}
}
});
}
static propTypes = {
list: PropTypes.object,
segments: PropTypes.array,
segmentId: PropTypes.string
}
componentDidMount() {
this.populateFormValues({
segment: this.props.segmentId || ''
});
}
componentDidUpdate() {
const segmentId = this.props.segmentId || '';
if (this.getFormValue('segment') !== segmentId) {
// Populate is used here because it does not invoke onChange
this.populateFormValues({
segment: segmentId
});
}
}
render() {
const t = this.props.t;
const list = this.props.list;
const segments = this.props.segments;
const columns = [
{ data: 1, title: t('id'), render: data => <code>{data}</code> },
{ data: 2, title: t('email'), render: data => data === null ? <span className={listStyles.erased}>{t('erased')}</span> : data },
{ data: 3, title: t('status'), render: (data, display, rowData) => this.subscriptionStatusLabels[data] + (rowData[5] ? ', ' + t('blacklisted') : '') },
{ data: 4, title: t('created'), render: data => data ? moment(data).fromNow() : '' }
];
let colIdx = 6;
for (const fld of list.listFields) {
const indexable = this.fieldTypes[fld.type].indexable;
columns.push({
data: colIdx,
title: fld.name,
sortable: indexable,
searchable: indexable
});
colIdx += 1;
}
if (list.permissions.includes('manageSubscriptions')) {
columns.push({
actions: data => {
const actions = [];
const id = data[0];
const email = data[2];
const status = data[3];
actions.push({
label: <Icon icon="edit" title={t('edit')}/>,
link: `/lists/${this.props.list.id}/subscriptions/${id}/edit`
});
if (email && status === SubscriptionStatus.SUBSCRIBED) {
tableAddRestActionButton(
actions, this,
{ method: HTTPMethod.POST, url: `rest/subscriptions-unsubscribe/${this.props.list.id}/${id}`},
{ icon: 'power-off', label: t('unsubscribe') },
t('confirmUnsubscription'),
t('areYouSureYouWantToUnsubscribeEmail?', {email}),
t('unsubscribingEmail', {email}),
t('emailUnsubscribed', {email}),
null
);
}
if (email && !data[5]) {
tableAddRestActionButton(
actions, this,
{ method: HTTPMethod.POST, url: `rest/blacklist`, data: {email} },
{ icon: 'ban', label: t('blacklist') },
t('confirmEmailBlacklisting'),
t('areYouSureYouWantToBlacklistEmail?', {email}),
t('blacklistingEmail', {email}),
t('emailBlacklisted', {email}),
null
);
}
tableAddDeleteButton(actions, this, null, `rest/subscriptions/${this.props.list.id}/${id}`, email, t('deletingSubscription'), t('subscriptionDeleted'));
return actions;
}
});
}
const segmentOptions = [
{key: '', label: t('allSubscriptions')},
...segments.map(x => ({ key: x.id.toString(), label: x.name}))
];
let dataUrl = 'rest/subscriptions-table/' + list.id;
if (this.props.segmentId) {
dataUrl += '/' + this.props.segmentId;
}
// FIXME - presents segments in a data table as in campaign edit
return (
<div>
{tableRestActionDialogRender(this)}
<Toolbar>
<a href={getPublicUrl(`subscription/${this.props.list.cid}`, {withLocale: true})}><Button label={t('subscriptionForm')} className="btn-secondary"/></a>
<a href={getUrl(`subscriptions/export/${this.props.list.id}/`+ (this.props.segmentId || 0))}><Button label={t('exportAsCsv')} className="btn-primary"/></a>
<LinkButton to={`/lists/${this.props.list.id}/subscriptions/create`} className="btn-primary" icon="plus" label={t('addSubscriber')}/>
</Toolbar>
<Title>{t('subscribers')}</Title>
{list.description &&
<div className="well well-sm">{list.description}</div>
}
<div className="card bg-light">
<div className="card-body p-2">
<Form format="inline" stateOwner={this}>
<Dropdown format="inline" className="input-sm" id="segment" label={t('segment')} options={segmentOptions}/>
</Form>
</div>
</div>
<Table ref={node => this.table = node} withHeader dataUrl={dataUrl} columns={columns} />
</div>
);
}
}

View file

@ -1,211 +0,0 @@
'use strict';
import React from "react";
import {getFieldColumn, SubscriptionStatus} from "../../../../shared/lists";
import {
ACEEditor,
CheckBox,
CheckBoxGroup,
DatePicker,
Dropdown,
InputField,
RadioGroup,
TextArea
} from "../../lib/form";
import {formatBirthday, formatDate, parseBirthday, parseDate} from "../../../../shared/date";
import 'brace/mode/json';
export function getSubscriptionStatusLabels(t) {
const subscriptionStatusLabels = {
[SubscriptionStatus.SUBSCRIBED]: t('subscribed'),
[SubscriptionStatus.UNSUBSCRIBED]: t('unubscribed'),
[SubscriptionStatus.BOUNCED]: t('bounced'),
[SubscriptionStatus.COMPLAINED]: t('complained'),
};
return subscriptionStatusLabels;
}
export function getFieldTypes(t) {
const groupedFieldTypes = {};
const stringFieldType = long => ({
form: groupedField => long ? <TextArea key={getFieldColumn(groupedField)} id={getFieldColumn(groupedField)} label={groupedField.name}/> : <InputField key={getFieldColumn(groupedField)} id={getFieldColumn(groupedField)} label={groupedField.name}/>,
assignFormData: (groupedField, data) => {
const value = data[getFieldColumn(groupedField)];
data[getFieldColumn(groupedField)] = value || '';
},
initFormData: (groupedField, data) => {
data[getFieldColumn(groupedField)] = '';
},
assignEntity: (groupedField, data) => {},
validate: (groupedField, state) => {},
indexable: true
});
const numberFieldType = {
form: groupedField => <InputField key={getFieldColumn(groupedField)} id={getFieldColumn(groupedField)} label={groupedField.name}/>,
assignFormData: (groupedField, data) => {
const value = data[getFieldColumn(groupedField)];
data[getFieldColumn(groupedField)] = value ? value.toString() : '';
},
initFormData: (groupedField, data) => {
data[getFieldColumn(groupedField)] = '';
},
assignEntity: (groupedField, data) => {
data[getFieldColumn(groupedField)] = parseInt(data[getFieldColumn(groupedField)]);
},
validate: (groupedField, state) => {
const value = state.getIn([getFieldColumn(groupedField), 'value']).trim();
if (value !== '' && isNaN(value)) {
state.setIn([getFieldColumn(groupedField), 'error'], t('valueMustBeANumber'));
} else {
state.setIn([getFieldColumn(groupedField), 'error'], null);
}
},
indexable: true
};
const dateFieldType = {
form: groupedField => <DatePicker key={getFieldColumn(groupedField)} id={getFieldColumn(groupedField)} label={groupedField.name} dateFormat={groupedField.settings.dateFormat} />,
assignFormData: (groupedField, data) => {
const value = data[getFieldColumn(groupedField)];
data[getFieldColumn(groupedField)] = value ? formatDate(groupedField.settings.dateFormat, value) : '';
},
initFormData: (groupedField, data) => {
data[getFieldColumn(groupedField)] = '';
},
assignEntity: (groupedField, data) => {
const date = parseDate(groupedField.settings.dateFormat, data[getFieldColumn(groupedField)]);
data[getFieldColumn(groupedField)] = date;
},
validate: (groupedField, state) => {
const value = state.getIn([getFieldColumn(groupedField), 'value']);
const date = parseDate(groupedField.settings.dateFormat, value);
if (value !== '' && !date) {
state.setIn([getFieldColumn(groupedField), 'error'], t('dateIsInvalid'));
} else {
state.setIn([getFieldColumn(groupedField), 'error'], null);
}
},
indexable: true
};
const birthdayFieldType = {
form: groupedField => <DatePicker key={getFieldColumn(groupedField)} id={getFieldColumn(groupedField)} label={groupedField.name} dateFormat={groupedField.settings.dateFormat} birthday />,
assignFormData: (groupedField, data) => {
const value = data[getFieldColumn(groupedField)];
data[getFieldColumn(groupedField)] = value ? formatBirthday(groupedField.settings.dateFormat, value) : '';
},
initFormData: (groupedField, data) => {
data[getFieldColumn(groupedField)] = '';
},
assignEntity: (groupedField, data) => {
const date = parseBirthday(groupedField.settings.dateFormat, data[getFieldColumn(groupedField)]);
data[getFieldColumn(groupedField)] = date;
},
validate: (groupedField, state) => {
const value = state.getIn([getFieldColumn(groupedField), 'value']);
const date = parseBirthday(groupedField.settings.dateFormat, value);
if (value !== '' && !date) {
state.setIn([getFieldColumn(groupedField), 'error'], t('dateIsInvalid'));
} else {
state.setIn([getFieldColumn(groupedField), 'error'], null);
}
},
indexable: true
};
const jsonFieldType = {
form: groupedField => <ACEEditor key={getFieldColumn(groupedField)} id={getFieldColumn(groupedField)} label={groupedField.name} mode="json" height="300px"/>,
assignFormData: (groupedField, data) => {
const value = data[getFieldColumn(groupedField)];
data[getFieldColumn(groupedField)] = value || '';
},
initFormData: (groupedField, data) => {
data[getFieldColumn(groupedField)] = '';
},
assignEntity: (groupedField, data) => {},
validate: (groupedField, state) => {},
indexable: false
};
const optionFieldType = {
form: groupedField => <CheckBox key={getFieldColumn(groupedField)} id={getFieldColumn(groupedField)} text={groupedField.settings.checkedLabel} label={groupedField.name}/>,
assignFormData: (groupedField, data) => {
const value = data[getFieldColumn(groupedField)];
data[getFieldColumn(groupedField)] = !!value;
},
initFormData: (groupedField, data) => {
data[getFieldColumn(groupedField)] = false;
},
assignEntity: (groupedField, data) => {},
validate: (groupedField, state) => {},
indexable: true
};
const enumSingleFieldType = componentType => ({
form: groupedField => React.createElement(componentType, { key: getFieldColumn(groupedField), id: getFieldColumn(groupedField), label: groupedField.name, options: groupedField.settings.options }, null),
assignFormData: (groupedField, data) => {
if (data[getFieldColumn(groupedField)] === null) {
if (groupedField.default_value) {
data[getFieldColumn(groupedField)] = groupedField.default_value;
} else if (groupedField.settings.options.length > 0) {
data[getFieldColumn(groupedField)] = groupedField.settings.options[0].key;
} else {
data[getFieldColumn(groupedField)] = '';
}
}
},
initFormData: (groupedField, data) => {
if (groupedField.default_value) {
data[getFieldColumn(groupedField)] = groupedField.default_value;
} else if (groupedField.settings.options.length > 0) {
data[getFieldColumn(groupedField)] = groupedField.settings.options[0].key;
} else {
data[getFieldColumn(groupedField)] = '';
}
},
assignEntity: (groupedField, data) => {},
validate: (groupedField, state) => {},
indexable: false
});
const enumMultipleFieldType = componentType => ({
form: groupedField => React.createElement(componentType, { key: getFieldColumn(groupedField), id: getFieldColumn(groupedField), label: groupedField.name, options: groupedField.settings.options }, null),
assignFormData: (groupedField, data) => {
if (data[getFieldColumn(groupedField)] === null) {
data[getFieldColumn(groupedField)] = [];
}
},
initFormData: (groupedField, data) => {
data[getFieldColumn(groupedField)] = [];
},
assignEntity: (groupedField, data) => {},
validate: (groupedField, state) => {},
indexable: false
});
groupedFieldTypes.text = stringFieldType(false);
groupedFieldTypes.website = stringFieldType(false);
groupedFieldTypes.longtext = stringFieldType(true);
groupedFieldTypes.gpg = stringFieldType(true);
groupedFieldTypes.number = numberFieldType;
groupedFieldTypes.date = dateFieldType;
groupedFieldTypes.birthday = birthdayFieldType;
groupedFieldTypes.json = jsonFieldType;
groupedFieldTypes.option = optionFieldType;
groupedFieldTypes['dropdown-enum'] = enumSingleFieldType(Dropdown);
groupedFieldTypes['radio-enum'] = enumSingleFieldType(RadioGroup);
// Here we rely on the fact the model/groupedFields and model/subscriptions preprocess the groupedField info and subscription
// such that the grouped entries behave the same as the enum entries
groupedFieldTypes['checkbox-grouped'] = enumMultipleFieldType(CheckBoxGroup);
groupedFieldTypes['radio-grouped'] = enumSingleFieldType(RadioGroup);
groupedFieldTypes['dropdown-grouped'] = enumSingleFieldType(Dropdown);
return groupedFieldTypes;
}

Some files were not shown because too many files have changed in this diff Show more