Compare commits
377 commits
master
...
developmen
Author | SHA1 | Date | |
---|---|---|---|
|
a258479621 | ||
|
a46c8fa9c3 | ||
|
6ae9143c22 | ||
|
8cd01fe99e | ||
|
d247893d31 | ||
|
6eeef7a991 | ||
|
b65960b528 | ||
|
ff9191c206 | ||
|
f987cb1197 | ||
|
02360be75b | ||
|
1bf37e65a3 | ||
|
4e4b77ca84 | ||
|
e9bf4a890c | ||
|
025600e818 | ||
|
8606652101 | ||
|
a22187ef12 | ||
|
e3a5a3c4eb | ||
|
4113cb8476 | ||
|
75138f9728 | ||
|
00e328a914 | ||
|
450b930cc5 | ||
|
4e9f6bd57b | ||
|
deb6f1f294 | ||
|
355e03900a | ||
|
a9039e5760 | ||
|
30b361290b | ||
|
20603f679c | ||
|
ff66a6c39e | ||
|
f8e9d67568 | ||
|
4736afd5ab | ||
|
afe12cba0f | ||
|
39f3682c27 | ||
|
801fb7c81d | ||
|
7ae463ea72 | ||
|
fcd2a61b65 | ||
|
1270ca71f8 | ||
|
640d3c2f11 | ||
|
3c72e778d9 | ||
|
bbbe671d59 | ||
|
97b4bb7574 | ||
|
e40793b874 | ||
|
cbb29a0840 | ||
|
2e9d44c705 | ||
|
a527b80291 | ||
|
8eae6d6c41 | ||
|
79164c5fe5 | ||
|
0e9d192b1f | ||
|
b1efb95315 | ||
|
03bcba1667 | ||
|
e064948838 | ||
|
c4b78c4823 | ||
|
008fd21b51 | ||
|
3921a6b2cb | ||
|
48dcf2c701 | ||
|
856636d12e | ||
|
f04c549d24 | ||
|
0618d899c0 | ||
|
7f979d554e | ||
|
91c059144f | ||
|
b0c3858268 | ||
|
4f77272042 | ||
|
72ffe065d2 | ||
|
3e3c3a24fe | ||
|
ef0464bbc9 | ||
|
7bcd6c60e9 | ||
|
66702b5edc | ||
|
055c4c6b51 | ||
|
c00bd68123 | ||
|
1f1cf81a17 | ||
|
b8f8750afd | ||
|
d0d42ab280 | ||
|
94a2cdf89e | ||
|
8a879c91bd | ||
|
ec0f288d81 | ||
|
ad9f5d16bf | ||
|
ae461383bd | ||
|
3a45443b64 | ||
|
8b39a101cd | ||
|
e588e218b6 | ||
|
76b4f8b8c2 | ||
|
80279346f3 | ||
|
ed4a13fef7 | ||
|
31442453ea | ||
|
e3e1e7a086 | ||
|
dc4f0922ef | ||
|
3b20ac5ce7 | ||
|
63cfb22025 | ||
|
d482d214d9 | ||
|
2fe7f82be3 | ||
|
dcb7168322 | ||
|
3ae4c77fb4 | ||
|
4a521a8f0f | ||
|
913c7dc337 | ||
|
53340ad631 | ||
|
b583df7e2f | ||
|
444717b4d0 | ||
|
b6a896558e | ||
|
b5a6167202 | ||
|
74fe5e73e2 | ||
|
7b08e2e97b | ||
|
41b311c811 | ||
|
75daaa1a65 | ||
|
5d08db67b3 | ||
|
83267a7e28 | ||
|
bdacf92917 | ||
|
1a61067ff9 | ||
|
0037b39656 | ||
|
9b32e59b50 | ||
|
97bb700334 | ||
|
4b1b1a380d | ||
|
25ecd85910 | ||
|
a43302f3ab | ||
|
38e277cb7c | ||
|
aa66c18650 | ||
|
4a6aed4cf7 | ||
|
d54f941caa | ||
|
41cd01c2b9 | ||
|
0c3510d626 | ||
|
8d95f43dbc | ||
|
031b346440 | ||
|
f8ef57f164 | ||
|
433bf31bfa | ||
|
f7b5aef0e3 | ||
|
df2a8c1cde | ||
|
7f2a9ca940 | ||
|
8a843bcea4 | ||
|
02b42a4982 | ||
|
abed0d4af4 | ||
|
46ad0c7b4f | ||
|
90f289989b | ||
|
d1a1398828 | ||
|
d52436c566 | ||
|
e0bee9ed42 | ||
|
4f408a26d5 | ||
|
d14942da93 | ||
|
18eb54037c | ||
|
127900a4d4 | ||
|
6f6258e53b | ||
|
0d7f962c86 | ||
|
a1e52c2d7a | ||
|
713dfaa278 | ||
|
ab6c6b7d27 | ||
|
c1731bf09f | ||
|
397f85dac4 | ||
|
428fb9db7b | ||
|
e786964411 | ||
|
6eead89fef | ||
|
557ee83705 | ||
|
01720ae128 | ||
|
b26f5008da | ||
|
de55870561 | ||
|
2e847460f4 | ||
|
64af46b685 | ||
|
41d74e3cc7 | ||
|
3425e2c16a | ||
|
366bd09f2a | ||
|
9044a4ca0b | ||
|
99bd4ad907 | ||
|
cfdcaf65d8 | ||
|
dce5ba7464 | ||
|
bf626993f4 | ||
|
3b1986116f | ||
|
a769bfb567 | ||
|
8a38133ffc | ||
|
b3e220897d | ||
|
7b520387d2 | ||
|
3f4044c0a9 | ||
|
9ca082e26a | ||
|
6cdb06efd0 | ||
|
c6f148eef4 | ||
|
8d9dcd65b2 | ||
|
edc8b54e8f | ||
|
691b960ba5 | ||
|
b873915b93 | ||
|
0aa47cae63 | ||
|
e81fd8f5b1 | ||
|
9e6dfd6d0b | ||
|
439b565cd9 | ||
|
067fed6d15 | ||
|
d72bd73f35 | ||
|
0c2e7e0cc3 | ||
|
c18f6f8e24 | ||
|
9a34d05d36 | ||
|
7b7d5ecf2a | ||
|
2bfaf344e7 | ||
|
7fd20ba641 | ||
|
c3f5e6a628 | ||
|
cac9c3ad3b | ||
|
1e81b8bb2f | ||
|
e7357c1788 | ||
|
7510846fcc | ||
|
40f85a957f | ||
|
9204954691 | ||
|
a6c7e6327b | ||
|
dcc5b18058 | ||
|
4eaedf0ac3 | ||
|
4d50432973 | ||
|
19fe5331d2 | ||
|
3d956ec141 | ||
|
83ce716d94 | ||
|
dd9b8b464a | ||
|
63c3383cff | ||
|
1073d03d1b | ||
|
01f1495d9a | ||
|
a5b5f3f1dd | ||
|
89a2aa15a4 | ||
|
5a16d789a0 | ||
|
0be4af5f6c | ||
|
1ebf808724 | ||
|
77c64f487d | ||
|
d103a2cc79 | ||
|
ba996d845d | ||
|
97bbac8698 | ||
|
cb1fc5b28d | ||
|
f21cea2aec | ||
|
fa451fc8da | ||
|
106acd0656 | ||
|
43c6b58793 | ||
|
d459f7cfed | ||
|
bd20072455 | ||
|
3bb235a585 | ||
|
a993c06aaf | ||
|
b058169e12 | ||
|
aeaaf116d7 | ||
|
35fdae8f73 | ||
|
b2850d862d | ||
|
c784d2fbb6 | ||
|
92ca1c0f28 | ||
|
9f9cbc4c2b | ||
|
b37ad9863c | ||
|
4f5b2d10e4 | ||
|
f7cbcf871d | ||
|
ec299053ba | ||
|
dc7789c17b | ||
|
9f449c0a2f | ||
|
3ad84a6bd5 | ||
|
a72fe3a991 | ||
|
88eef42b01 | ||
|
6eb50f3b31 | ||
|
545a624163 | ||
|
2edbd67205 | ||
|
e18d2b2f84 | ||
|
4862d6cac4 | ||
|
d8b56fff0d | ||
|
bf69e633c4 | ||
|
8683f8c91e | ||
|
a3983193d3 | ||
|
2c73c536b7 | ||
|
7e52000219 | ||
|
c7d7b1fe0c | ||
|
b089993360 | ||
|
9f467762c0 | ||
|
e2093e22fe | ||
|
02a7275ae4 | ||
|
eacdc74c29 | ||
|
213039c141 | ||
|
efbfa2b366 | ||
|
0a08088893 | ||
|
2b57396a5d | ||
|
bc818aaee2 | ||
|
1448d9e914 | ||
|
2d667523a1 | ||
|
5670d21e76 | ||
|
86efa11994 | ||
|
a494dc6482 | ||
|
27021e9b2b | ||
|
dda95ecdb3 | ||
|
a9e1700dbe | ||
|
92d28daa9e | ||
|
907d548e02 | ||
|
63765f7222 | ||
|
89eabea0de | ||
|
01d1a903a2 | ||
|
d1fa4f4211 | ||
|
67d7129f7b | ||
|
130c953d94 | ||
|
d74806dde3 | ||
|
16519c5353 | ||
|
739b9452de | ||
|
877e0a857d | ||
|
cd798b5af7 | ||
|
6648028270 | ||
|
965f30cea7 | ||
|
ffc26a4836 | ||
|
7b46c4b4b0 | ||
|
b1c667d13d | ||
|
32cad03f4f | ||
|
ade0fc87f2 | ||
|
0e0fb944e3 | ||
|
ee786bc8ad | ||
|
189638364c | ||
|
e85c707973 | ||
|
4943b22a51 | ||
|
1f5566ca9b | ||
|
446d75ce71 | ||
|
7788b0bc67 | ||
|
a4ee1534cc | ||
|
e97415c237 | ||
|
c12efeb97f | ||
|
4fce4b6f81 | ||
|
6706d93bc1 | ||
|
7b5642e911 | ||
|
b5cdf57f72 | ||
|
c85f2d4440 | ||
|
7750232716 | ||
|
ba75623f86 | ||
|
508d6b3b2f | ||
|
47b8d80c22 | ||
|
e9165838dc | ||
|
d8ee364a4b | ||
|
6c5c47ac2e | ||
|
b22a87e712 | ||
|
eecb3cd067 | ||
|
7a93628cc8 | ||
|
9203b5cee7 | ||
|
c343e4efd3 | ||
|
6f5b50e932 | ||
|
e6bd9cd943 | ||
|
42338b0afa | ||
|
f3ff89c536 | ||
|
6cc34136f5 | ||
|
d0a714b3d4 | ||
|
6fbbe9a497 | ||
|
e5cf2962dc | ||
|
baf9f61465 | ||
|
6a7dab52eb | ||
|
0bfb30817b | ||
|
b23529a75b | ||
|
e73c0a8b28 | ||
|
d9211377dd | ||
|
e230510b72 | ||
|
19f0c1bd97 | ||
|
60d3875c00 | ||
|
850e563362 | ||
|
602364caae | ||
|
86fce404a9 | ||
|
361af18384 | ||
|
f6e1938ff9 | ||
|
216fe40b53 | ||
|
34823cf0cf | ||
|
89256d62bd | ||
|
89c9615592 | ||
|
5df444f641 | ||
|
e7bdfb7745 | ||
|
4822a50d0b | ||
|
e6ad0e239e | ||
|
d63eed9ca9 | ||
|
6d95fa515e | ||
|
38cf3e49c0 | ||
|
0c860456a6 | ||
|
ad1e4c58f5 | ||
|
3f7b428546 | ||
|
aba42d94ac | ||
|
d4cea46f07 | ||
|
be7da791db | ||
|
9758b4b104 | ||
|
d79bbad575 | ||
|
fbb8f5799e | ||
|
09fe27fe2b | ||
|
eb2287f6e9 | ||
|
e7856bfb73 | ||
|
c81f5544e6 | ||
|
f776170854 | ||
|
1d1355df34 | ||
|
ed5b81b6e6 | ||
|
8e54879539 | ||
|
5b82d3b540 | ||
|
61893d77f6 | ||
|
5e4c86f626 | ||
|
79ea9e1897 | ||
|
4504d539c5 | ||
|
d13fc65ce2 | ||
|
d0824fe724 | ||
|
cda93630ea | ||
|
4f52b571c9 | ||
|
115d254baf | ||
|
1b73282e90 |
1303 changed files with 101659 additions and 105395 deletions
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"extends": "nodemailer"
|
||||
}
|
27
.gitignore
vendored
27
.gitignore
vendored
|
@ -1,34 +1,9 @@
|
|||
/.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
Normal file
3
.gitmodules
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
[submodule "mvis/ivis-core"]
|
||||
path = mvis/ivis-core
|
||||
url = https://github.com/smartarch/ivis-core.git
|
18
.travis.yml
18
.travis.yml
|
@ -1,18 +0,0 @@
|
|||
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
18
BACKERS.md
|
@ -1,18 +0,0 @@
|
|||
# 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
|
148
CHANGELOG.md
148
CHANGELOG.md
|
@ -1,149 +1,5 @@
|
|||
# Changelog
|
||||
|
||||
## 1.23.2 2017-04-04
|
||||
## 2.0.0-rc1 2018-12-25
|
||||
|
||||
* 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)
|
||||
* This is a complete rewrite of Mailtrain v1 with many features added. Just check it out.
|
||||
|
|
25
Dockerfile
25
Dockerfile
|
@ -1,13 +1,26 @@
|
|||
FROM node:8.6
|
||||
FROM node:10.14-alpine
|
||||
|
||||
RUN apk add --update pwgen netcat-openbsd python make gcc git g++ bash imagemagick
|
||||
|
||||
# First install dependencies
|
||||
COPY ./package.json ./app/
|
||||
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
|
||||
COPY client/package-lock.json /app/client/package-lock.json
|
||||
COPY shared/package.json /app/shared/package.json
|
||||
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/
|
||||
ENV NODE_ENV production
|
||||
RUN npm install --no-progress --production && npm install --no-progress passport-ldapjs passport-ldapauth
|
||||
|
||||
RUN for idx in client shared server zone-mta; do (cd $idx && npm install); done
|
||||
|
||||
# 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
|
||||
EXPOSE 3000
|
||||
|
||||
RUN cd client && npm run build
|
||||
|
||||
EXPOSE 3000 3003 3004
|
||||
ENTRYPOINT ["bash", "/app/docker-entrypoint.sh"]
|
||||
CMD ["node", "index.js"]
|
||||
|
|
36
Gruntfile.js
36
Gruntfile.js
|
@ -1,36 +0,0 @@
|
|||
'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']);
|
||||
};
|
230
README.md
230
README.md
|
@ -1,62 +1,210 @@
|
|||
# Mailtrain
|
||||
# Mailtrain v2 (beta)
|
||||
|
||||
[Mailtrain](http://mailtrain.org) is a self hosted newsletter application built on Node.js (v7+) and MySQL (v5.5+ or MariaDB).
|
||||
Mailtrain is a self hosted newsletter application built on Node.js (v10+) and MySQL (v8+) or MariaDB (v10+).
|
||||
|
||||

|
||||

|
||||
|
||||
## Features
|
||||
|
||||
* Subscriber list management
|
||||
* Subscriber lists management
|
||||
* List segmentation
|
||||
* Custom fields
|
||||
* Email templates
|
||||
* Large CSV list import files
|
||||
* 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
|
||||
|
||||
Subscribe to Mailtrain Newsletter [here](https://mailtrain.org/subscription/S18sew2wM) (uses Mailtrain obviously)
|
||||
## Recommended minimal hardware Requirements
|
||||
* 2 vCPU
|
||||
* 4096 MB RAM
|
||||
|
||||
## Hardware Requirements
|
||||
* 1 vCPU
|
||||
* 1024 MB RAM
|
||||
|
||||
## Quick Start - Deploy with Docker
|
||||
#### Requirements:
|
||||
## Quick Start
|
||||
|
||||
* [Docker](https://www.docker.com/)
|
||||
* [Docker Compose](https://docs.docker.com/compose/)
|
||||
### 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.
|
||||
|
||||
#### Steps:
|
||||
Depending on how you have configured your system and Docker you may need to prepend the commands below with `sudo`.
|
||||
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`)
|
||||
|
||||
* 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.
|
||||
* Navigate to [http://localhost:3000/users/account](http://localhost:3000/users/account) and update user information and password.
|
||||
|
||||
## Quick Start - Manual Install (any OS that supports Node.js)
|
||||
### Installation on fresh CentOS 7 or Ubuntu 18.04 LTS (public website secured by SSL)
|
||||
|
||||
### 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
|
||||
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.
|
||||
|
||||
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
|
||||
8. Navigate to [http://localhost:3000/users/account](http://localhost:3000/users/account) and update user information and password
|
||||
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).
|
||||
|
||||
## Read The Docs
|
||||
For more information, please [read the docs](http://docs.mailtrain.org/).
|
||||
**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.
|
||||
|
||||
|
||||
## License
|
||||
|
||||
* 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**
|
||||
**GPL-V3.0**
|
||||
|
|
26
TODO.md
Normal file
26
TODO.md
Normal file
|
@ -0,0 +1,26 @@
|
|||
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
|
21
UPGRADE.md
Normal file
21
UPGRADE.md
Normal file
|
@ -0,0 +1,21 @@
|
|||
## 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
265
app.js
|
@ -1,265 +0,0 @@
|
|||
'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">×</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
Normal file
1
client/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/dist
|
12356
client/package-lock.json
generated
Normal file
12356
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
86
client/package.json
Normal file
86
client/package.json
Normal file
|
@ -0,0 +1,86 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
27
client/src/Home.js
Normal file
27
client/src/Home.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
397
client/src/account/API.js
Normal file
397
client/src/account/API.js
Normal file
|
@ -0,0 +1,397 @@
|
|||
'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&MERGE_CHECKBOX=yes&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> – Text</li>
|
||||
<li><strong>website</strong> – Website</li>
|
||||
<li><strong>longtext</strong> – Multi-line text</li>
|
||||
<li><strong>gpg</strong> – GPG Public Key</li>
|
||||
<li><strong>number</strong> – Number</li>
|
||||
<li><strong>radio</strong> – Radio Buttons</li>
|
||||
<li><strong>checkbox</strong> – Checkboxes</li>
|
||||
<li><strong>dropdown</strong> – Drop Down</li>
|
||||
<li><strong>date-us</strong> – Date (MM/DD/YYY)</li>
|
||||
<li><strong>date-eur</strong> – Date (DD/MM/YYYY)</li>
|
||||
<li><strong>birthday-us</strong> – Birthday (MM/DD)</li>
|
||||
<li><strong>birthday-eur</strong> – Birthday (DD/MM)</li>
|
||||
<li><strong>json</strong> – JSON value for custom rendering</li>
|
||||
<li><strong>option</strong> – 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&TYPE=birthday-us&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&'</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&'</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&SUBJECT=Test&TAGS[FOO]=bar&TAGS[TEST]=example'</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
224
client/src/account/Account.js
Normal file
224
client/src/account/Account.js
Normal file
|
@ -0,0 +1,224 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
28
client/src/account/root.js
Normal file
28
client/src/account/root.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
'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
|
||||
}
|
9
client/src/account/styles.scss
Normal file
9
client/src/account/styles.scss
Normal file
|
@ -0,0 +1,9 @@
|
|||
.api {
|
||||
:global .card h4 {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-top: 45px;
|
||||
}
|
||||
}
|
142
client/src/blacklist/List.js
Normal file
142
client/src/blacklist/List.js
Normal file
|
@ -0,0 +1,142 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
18
client/src/blacklist/root.js
Normal file
18
client/src/blacklist/root.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
'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
|
||||
}
|
815
client/src/campaigns/CUD.js
Normal file
815
client/src/campaigns/CUD.js
Normal file
|
@ -0,0 +1,815 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
296
client/src/campaigns/Content.js
Normal file
296
client/src/campaigns/Content.js
Normal file
|
@ -0,0 +1,296 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
159
client/src/campaigns/List.js
Normal file
159
client/src/campaigns/List.js
Normal file
|
@ -0,0 +1,159 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
133
client/src/campaigns/Statistics.js
Normal file
133
client/src/campaigns/Statistics.js
Normal file
|
@ -0,0 +1,133 @@
|
|||
'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} ({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>
|
||||
);
|
||||
}
|
||||
}
|
50
client/src/campaigns/StatisticsLinkClicks.js
Normal file
50
client/src/campaigns/StatisticsLinkClicks.js
Normal file
|
@ -0,0 +1,50 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
225
client/src/campaigns/StatisticsOpened.js
Normal file
225
client/src/campaigns/StatisticsOpened.js
Normal file
|
@ -0,0 +1,225 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
53
client/src/campaigns/StatisticsSubsList.js
Normal file
53
client/src/campaigns/StatisticsSubsList.js
Normal file
|
@ -0,0 +1,53 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
609
client/src/campaigns/Status.js
Normal file
609
client/src/campaigns/Status.js
Normal file
|
@ -0,0 +1,609 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
348
client/src/campaigns/TestSendModalDialog.js
Normal file
348
client/src/campaigns/TestSendModalDialog.js
Normal file
|
@ -0,0 +1,348 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
32
client/src/campaigns/helpers.js
Normal file
32
client/src/campaigns/helpers.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
'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
|
||||
};
|
||||
}
|
||||
|
||||
|
181
client/src/campaigns/root.js
Normal file
181
client/src/campaigns/root.js
Normal file
|
@ -0,0 +1,181 @@
|
|||
'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
|
||||
}
|
103
client/src/campaigns/styles.scss
Normal file
103
client/src/campaigns/styles.scss
Normal file
|
@ -0,0 +1,103 @@
|
|||
.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;
|
||||
}
|
256
client/src/campaigns/triggers/CUD.js
Normal file
256
client/src/campaigns/triggers/CUD.js
Normal file
|
@ -0,0 +1,256 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
85
client/src/campaigns/triggers/List.js
Normal file
85
client/src/campaigns/triggers/List.js
Normal file
|
@ -0,0 +1,85 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
35
client/src/campaigns/triggers/helpers.js
Normal file
35
client/src/campaigns/triggers/helpers.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
'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
|
||||
};
|
||||
}
|
||||
|
32
client/src/lib/axios.js
Normal file
32
client/src/lib/axios.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
'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
|
||||
}
|
329
client/src/lib/bootstrap-components.js
vendored
Normal file
329
client/src/lib/bootstrap-components.js
vendored
Normal file
|
@ -0,0 +1,329 @@
|
|||
'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">×</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">×</span></button>
|
||||
</div>
|
||||
<div className="modal-body">{this.props.children}</div>
|
||||
{buttons &&
|
||||
<div className="modal-footer">
|
||||
{buttons}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
171
client/src/lib/decorator-helpers.js
Normal file
171
client/src/lib/decorator-helpers.js
Normal file
|
@ -0,0 +1,171 @@
|
|||
'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;
|
||||
}
|
||||
|
||||
};
|
||||
}
|
||||
|
78
client/src/lib/error-handling.js
Normal file
78
client/src/lib/error-handling.js
Normal file
|
@ -0,0 +1,78 @@
|
|||
'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);
|
||||
}
|
||||
};
|
||||
}
|
176
client/src/lib/files.js
Normal file
176
client/src/lib/files.js
Normal file
|
@ -0,0 +1,176 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
1615
client/src/lib/form.js
Normal file
1615
client/src/lib/form.js
Normal file
File diff suppressed because it is too large
Load diff
8
client/src/lib/helpers.js
Normal file
8
client/src/lib/helpers.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
'use strict';
|
||||
|
||||
import ellipsize from "ellipsize";
|
||||
|
||||
|
||||
export function ellipsizeBreadcrumbLabel(label) {
|
||||
return ellipsize(label, 40)
|
||||
}
|
92
client/src/lib/i18n.js
Normal file
92
client/src/lib/i18n.js
Normal file
|
@ -0,0 +1,92 @@
|
|||
'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;
|
||||
}
|
77
client/src/lib/mjml.js
Normal file
77
client/src/lib/mjml.js
Normal file
|
@ -0,0 +1,77 @@
|
|||
'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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
399
client/src/lib/modals.js
Normal file
399
client/src/lib/modals.js
Normal file
|
@ -0,0 +1,399 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
47
client/src/lib/namespace.js
Normal file
47
client/src/lib/namespace.js
Normal file
|
@ -0,0 +1,47 @@
|
|||
'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']
|
||||
}
|
||||
};
|
||||
}
|
448
client/src/lib/page-common.js
Normal file
448
client/src/lib/page-common.js
Normal file
|
@ -0,0 +1,448 @@
|
|||
'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 {};
|
||||
}
|
||||
});
|
727
client/src/lib/page.js
Normal file
727
client/src/lib/page.js
Normal file
|
@ -0,0 +1,727 @@
|
|||
'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">© 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;
|
||||
}
|
8
client/src/lib/permissions.js
Normal file
8
client/src/lib/permissions.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
'use strict';
|
||||
|
||||
import {getUrl} from "./urls";
|
||||
import axios from "./axios";
|
||||
|
||||
export async function checkPermissions(request) {
|
||||
return await axios.post(getUrl('rest/permissions-check'), request);
|
||||
}
|
5
client/src/lib/public-path.js
Normal file
5
client/src/lib/public-path.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
'use strict';
|
||||
|
||||
import {getUrl} from "./urls";
|
||||
|
||||
__webpack_public_path__ = getUrl('client/');
|
90
client/src/lib/sandbox-common.scss
Normal file
90
client/src/lib/sandbox-common.scss
Normal file
|
@ -0,0 +1,90 @@
|
|||
$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;
|
||||
}
|
||||
}
|
||||
}
|
135
client/src/lib/sandboxed-ckeditor-root.js
Normal file
135
client/src/lib/sandboxed-ckeditor-root.js
Normal file
|
@ -0,0 +1,135 @@
|
|||
'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')
|
||||
);
|
||||
};
|
||||
|
||||
|
3
client/src/lib/sandboxed-ckeditor-shared.js
Normal file
3
client/src/lib/sandboxed-ckeditor-shared.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
'use strict';
|
||||
|
||||
export const initialHeight = 600;
|
112
client/src/lib/sandboxed-ckeditor.js
Normal file
112
client/src/lib/sandboxed-ckeditor.js
Normal file
|
@ -0,0 +1,112 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
7
client/src/lib/sandboxed-ckeditor.scss
Normal file
7
client/src/lib/sandboxed-ckeditor.scss
Normal file
|
@ -0,0 +1,7 @@
|
|||
$editorNormalHeight: false;
|
||||
@import "sandbox-common";
|
||||
|
||||
.sandbox {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
220
client/src/lib/sandboxed-codeeditor-root.js
Normal file
220
client/src/lib/sandboxed-codeeditor-root.js
Normal file
|
@ -0,0 +1,220 @@
|
|||
'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')
|
||||
);
|
||||
};
|
||||
|
||||
|
11
client/src/lib/sandboxed-codeeditor-shared.js
Normal file
11
client/src/lib/sandboxed-codeeditor-shared.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
'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')}
|
||||
];
|
109
client/src/lib/sandboxed-codeeditor.js
Normal file
109
client/src/lib/sandboxed-codeeditor.js
Normal file
|
@ -0,0 +1,109 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
35
client/src/lib/sandboxed-codeeditor.scss
Normal file
35
client/src/lib/sandboxed-codeeditor.scss
Normal file
|
@ -0,0 +1,35 @@
|
|||
@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;
|
||||
}
|
||||
}
|
||||
}
|
635
client/src/lib/sandboxed-grapesjs-root.js
Normal file
635
client/src/lib/sandboxed-grapesjs-root.js
Normal file
|
@ -0,0 +1,635 @@
|
|||
'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')
|
||||
);
|
||||
};
|
||||
|
||||
|
11
client/src/lib/sandboxed-grapesjs-shared.js
Normal file
11
client/src/lib/sandboxed-grapesjs-shared.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
'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')}
|
||||
];
|
89
client/src/lib/sandboxed-grapesjs.js
Normal file
89
client/src/lib/sandboxed-grapesjs.js
Normal file
|
@ -0,0 +1,89 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
18
client/src/lib/sandboxed-grapesjs.scss
Normal file
18
client/src/lib/sandboxed-grapesjs.scss
Normal file
|
@ -0,0 +1,18 @@
|
|||
@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;
|
||||
}
|
||||
|
158
client/src/lib/sandboxed-mosaico-root.js
Normal file
158
client/src/lib/sandboxed-mosaico-root.js
Normal file
|
@ -0,0 +1,158 @@
|
|||
'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)}¶ms=${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')
|
||||
);
|
||||
};
|
||||
|
||||
|
90
client/src/lib/sandboxed-mosaico.js
Normal file
90
client/src/lib/sandboxed-mosaico.js
Normal file
|
@ -0,0 +1,90 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
8
client/src/lib/sandboxed-mosaico.scss
Normal file
8
client/src/lib/sandboxed-mosaico.scss
Normal file
|
@ -0,0 +1,8 @@
|
|||
@import "sandbox-common";
|
||||
|
||||
:global .mo-standalone {
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
}
|
185
client/src/lib/styles.scss
Normal file
185
client/src/lib/styles.scss
Normal file
|
@ -0,0 +1,185 @@
|
|||
@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;
|
||||
}
|
||||
}
|
||||
|
424
client/src/lib/table.js
Normal file
424
client/src/lib/table.js
Normal file
|
@ -0,0 +1,424 @@
|
|||
'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
|
||||
}
|
392
client/src/lib/tree.js
Normal file
392
client/src/lib/tree.js
Normal file
|
@ -0,0 +1,392 @@
|
|||
'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
|
||||
}
|
92
client/src/lib/tree.scss
Normal file
92
client/src/lib/tree.scss
Normal file
|
@ -0,0 +1,92 @@
|
|||
@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;
|
||||
}
|
||||
|
||||
}
|
331
client/src/lib/untrusted.js
Normal file
331
client/src/lib/untrusted.js
Normal file
|
@ -0,0 +1,331 @@
|
|||
'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();
|
60
client/src/lib/urls.js
Normal file
60
client/src/lib/urls.js
Normal file
|
@ -0,0 +1,60 @@
|
|||
'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
|
||||
}
|
296
client/src/lists/CUD.js
Normal file
296
client/src/lists/CUD.js
Normal file
|
@ -0,0 +1,296 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
137
client/src/lists/List.js
Normal file
137
client/src/lists/List.js
Normal file
|
@ -0,0 +1,137 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
82
client/src/lists/TriggersList.js
Normal file
82
client/src/lists/TriggersList.js
Normal file
|
@ -0,0 +1,82 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
535
client/src/lists/fields/CUD.js
Normal file
535
client/src/lists/fields/CUD.js
Normal file
|
@ -0,0 +1,535 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
80
client/src/lists/fields/List.js
Normal file
80
client/src/lists/fields/List.js
Normal file
|
@ -0,0 +1,80 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
54
client/src/lists/fields/helpers.js
Normal file
54
client/src/lists/fields/helpers.js
Normal file
|
@ -0,0 +1,54 @@
|
|||
'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;
|
||||
}
|
||||
|
592
client/src/lists/forms/CUD.js
Normal file
592
client/src/lists/forms/CUD.js
Normal file
|
@ -0,0 +1,592 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
81
client/src/lists/forms/List.js
Normal file
81
client/src/lists/forms/List.js
Normal file
|
@ -0,0 +1,81 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
11
client/src/lists/forms/styles.scss
Normal file
11
client/src/lists/forms/styles.scss
Normal file
|
@ -0,0 +1,11 @@
|
|||
$editorNormalHeight: 400px;
|
||||
@import "../../lib/sandbox-common";
|
||||
|
||||
.editor {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.host {
|
||||
border: none;
|
||||
width: 100%;
|
||||
}
|
472
client/src/lists/imports/CUD.js
Normal file
472
client/src/lists/imports/CUD.js
Normal file
|
@ -0,0 +1,472 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
98
client/src/lists/imports/List.js
Normal file
98
client/src/lists/imports/List.js
Normal file
|
@ -0,0 +1,98 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
109
client/src/lists/imports/RunStatus.js
Normal file
109
client/src/lists/imports/RunStatus.js
Normal file
|
@ -0,0 +1,109 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
161
client/src/lists/imports/Status.js
Normal file
161
client/src/lists/imports/Status.js
Normal file
|
@ -0,0 +1,161 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
46
client/src/lists/imports/helpers.js
Normal file
46
client/src/lists/imports/helpers.js
Normal file
|
@ -0,0 +1,46 @@
|
|||
'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
|
||||
};
|
||||
}
|
||||
|
255
client/src/lists/root.js
Normal file
255
client/src/lists/root.js
Normal file
|
@ -0,0 +1,255 @@
|
|||
'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
|
||||
}
|
404
client/src/lists/segments/CUD.js
Normal file
404
client/src/lists/segments/CUD.js
Normal file
|
@ -0,0 +1,404 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
152
client/src/lists/segments/CUD.scss
Normal file
152
client/src/lists/segments/CUD.scss
Normal file
|
@ -0,0 +1,152 @@
|
|||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
72
client/src/lists/segments/List.js
Normal file
72
client/src/lists/segments/List.js
Normal file
|
@ -0,0 +1,72 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
231
client/src/lists/segments/RuleSettingsPane.js
Normal file
231
client/src/lists/segments/RuleSettingsPane.js
Normal file
|
@ -0,0 +1,231 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
1539
client/src/lists/segments/divider.ai
Normal file
1539
client/src/lists/segments/divider.ai
Normal file
File diff suppressed because one or more lines are too long
BIN
client/src/lists/segments/divider.png
Normal file
BIN
client/src/lists/segments/divider.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 338 B |
459
client/src/lists/segments/helpers.js
Normal file
459
client/src/lists/segments/helpers.js
Normal file
|
@ -0,0 +1,459 @@
|
|||
'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;
|
||||
}
|
||||
|
7
client/src/lists/styles.scss
Normal file
7
client/src/lists/styles.scss
Normal file
|
@ -0,0 +1,7 @@
|
|||
.mapping {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.erased {
|
||||
color: #808080;
|
||||
}
|
247
client/src/lists/subscriptions/CUD.js
Normal file
247
client/src/lists/subscriptions/CUD.js
Normal file
|
@ -0,0 +1,247 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
191
client/src/lists/subscriptions/List.js
Normal file
191
client/src/lists/subscriptions/List.js
Normal file
|
@ -0,0 +1,191 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
}
|
211
client/src/lists/subscriptions/helpers.js
Normal file
211
client/src/lists/subscriptions/helpers.js
Normal file
|
@ -0,0 +1,211 @@
|
|||
'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
Loading…
Add table
Add a link
Reference in a new issue