Compare commits

...
Sign in to create a new pull request.

613 commits

Author SHA1 Message Date
62c4066c76 Update 'server/lib/tools.js'
fix return;
2020-09-17 10:29:33 +00:00
0e580cac12 Update 'server/lib/tools.js' 2020-09-17 10:11:41 +00:00
87dc416f48 Update 'server/lib/tools.js'
Remove HTML entities
2020-09-17 09:42:36 +00:00
8f6e314a3a Update 'docker-compose.yml' 2020-09-17 09:31:07 +00:00
86a885809a Update 'docker-compose.yml' 2020-09-17 09:23:00 +00:00
e430ced380 Update 'docker-compose.yml' 2020-09-17 09:14:17 +00:00
c0c328ad64 Update 'docker-compose.yml' 2020-09-17 09:12:38 +00:00
83a844f690 Update 'docker-compose.yml' 2020-09-17 08:54:03 +00:00
c1d021debe Delete 'docker-compose.yml' 2020-09-17 08:53:41 +00:00
Tomas Bures
c3b968aa10
Merge pull request #953 from elitzer2/required-custom-fields
(v2) Required custom fields (client side validation only)
2020-09-13 06:35:30 +02:00
Tomas Bures
6c89fd08be
Merge pull request #947 from muellermartin/patch-1
Improve German translation
2020-09-09 11:52:28 +02:00
Tomas Bures
4bfe7b4349
Merge pull request #956 from mjmayer/add_global_url_to_transaction_email
Add global urls to transactional email mergeTags
2020-09-03 06:22:39 +02:00
Michael Mayer
a5aed8b20c Add global urls to transactional email mergeTags 2020-09-02 21:15:03 -07:00
Lawrence Elitzer
41096af1c1 Add styling to subscription button to make it appaer as it did before 2020-08-31 19:24:39 -05:00
Lawrence Elitzer
e622c2b7b3 Add translations for required checkbox in custom fields editor 2020-08-31 09:27:18 -05:00
Lawrence Elitzer
98a7a744fc Add client side HTML5 validation of required fields 2020-08-31 09:16:06 -05:00
Lawrence Elitzer
1914591f46 Add required checkbox to custom fields 2020-08-31 07:58:45 -05:00
Tomas Bures
428e558108
Merge pull request #950 from elitzer2/fix-premailer-api
Fix plaintext rendering
2020-08-31 07:21:35 +02:00
Tomas Bures
902f9995d5
Merge pull request #949 from elitzer2/fix-channel-template-autofill
Fixes the autofill of campaign from cloned template when created via channel
2020-08-31 07:18:21 +02:00
Lawrence Elitzer
630ec0bae4 Remove premailer package 2020-08-30 23:44:54 -05:00
Lawrence Elitzer
d47159d47b Update API call to remove premailer 2020-08-30 23:43:59 -05:00
Lawrence Elitzer
1b8eb33979 Update display text for html to text link 2020-08-30 23:43:35 -05:00
Lawrence Elitzer
2e308b2f69 Fixes the autofill of campaign from cloned template when created via channel 2020-08-30 19:34:22 -05:00
Tomas Bures
0f8682087e
Merge pull request #948 from elitzer2/fix-subscription-widget
(v2) Fix subscription widget
2020-08-30 07:50:20 +02:00
Lawrence Elitzer
43837210ed Don't reveal to the API the list of subscribers 2020-08-30 00:48:33 -05:00
Lawrence Elitzer
e3c11476fb Add template rendering to widget 2020-08-29 09:04:59 -05:00
Lawrence Elitzer
7a765018f2 Fix subscription widget 2020-08-29 07:49:18 -05:00
Martin Müller
00aee38b24
Improve German translation
Improve some words, whitespace and punctuation
2020-08-29 01:54:16 +02:00
Tomas Bures
490031c8b5
Merge pull request #945 from dlecan/feat-docker-loglevel
Allow to configure log level of Docker containers (v2)
2020-08-28 15:30:25 +02:00
Damien Lecan
520886b3b4
Allow to configure log level of Docker containers 2020-08-28 15:11:44 +02:00
Tomas Bures
5a42756b12
Merge pull request #929 from Britaliope/fix-enum-fields
(v2) fix: "Enum" fields (drowdown and radio) does not render correctly
2020-08-28 08:22:32 +02:00
Tomas Bures
613a6fb1f0
Merge pull request #935 from podemos-info/api
API Improvements (v2)
2020-08-26 11:58:48 +02:00
Tomas Bures
90fb72fc6f
Merge pull request #934 from podemos-info/development
Added DataTable translations from aplication locales (V2)
2020-08-26 00:54:49 +02:00
Tomas Bures
ef66108cf7
Merge pull request #938 from podemos-info/shortid
Reemplace shortid with nanoid (v2)
2020-08-26 00:09:08 +02:00
joker-x
543f05028e Fix path 2020-08-25 23:09:35 +02:00
joker-x
62f6ef4559 Use the new module and uninstall shortid 2020-08-25 23:03:35 +02:00
joker-x
8b900c94ba Reemplace shortid with nanoid in background with alphabet and length configurable #810 2020-08-25 22:53:53 +02:00
joker-x
844b46e309 Added basic documentation to /api/subscriptions/:listCid #839 2020-08-22 08:13:55 +02:00
joker-x
bec0660d02 Add accordion to API documentation 2020-08-22 07:41:55 +02:00
joker-x
de065f9db7 clean 2020-08-22 06:31:03 +02:00
joker-x
0cf931429a clean 2020-08-22 06:13:27 +02:00
joker-x
cc794d4e52 Return cid instead of id when exists and fix documentation api 2020-08-22 06:09:18 +02:00
joker-x
5e77e3c98a Added description and status:{SUBSCRIBED: 1, UNSUBSCRIBED: 2, BOUNCED: 3, COMPLAINED: 4} to response of the GET /lists/:email API Call (Fix #903) 2020-08-20 17:09:21 +02:00
joker-x
06879bca7a Add translation marks in Users 2020-08-19 08:22:05 +02:00
joker-x
540c55515f Improve Spanish translation 2020-08-18 16:14:01 +02:00
joker-x
9e2bd4a951 Add Channels translations 2020-08-17 05:11:40 +02:00
joker-x
adf1e0d083 Add Channels translations 2020-08-17 05:04:56 +02:00
joker-x
8e270e2ef7 Added DataTable translations 2020-08-16 20:28:03 +02:00
Iván Eixarch
ebd5265997
Translate to spanish 2020-08-16 17:38:34 +02:00
Bruno MATEU
8622208d44 fix enum fields not rendering correctly 2020-08-03 12:01:55 +02:00
Tomas Bures
aed115a64b Merge remote-tracking branch 'origin/development' into development 2020-08-02 15:19:53 +02:00
Tomas Bures
87c6cfa656 Fixes
- Mailtrain would not start if built-in ZoneMTA had to retry the startup
- Campaign list showed "Sending" instead of "Scheduled" for scheduled campaigns
2020-08-02 15:19:24 +02:00
Tomas Bures
d2d3781ae2
Merge pull request #922 from Treora/patch-1
Fix plain text generation
2020-07-28 17:55:53 +02:00
Gerben
a92b08ddb4
Fix plain text generation 2020-07-28 17:26:47 +02:00
Tomas Bures
8dab13d903 Updated version in Home 2020-07-25 10:24:34 +02:00
Tomas Bures
4e8cbf8419 Added Save & Leave button to Channel create. 2020-07-25 10:20:01 +02:00
Tomas Bures
676f20bfa9 Some more options for mj-mosaico-property element in Mosaico Templates. 2020-07-24 22:38:37 +02:00
Tomas Bures
ca615a86a5 Various fixes in RSS campaigns including #916 and #915. 2020-07-24 12:08:40 +02:00
Tomas Bures
564c83720b - Fix - A non-admin user would get permission denied on all pages. 2020-07-18 09:48:03 +02:00
Tomas Bures
3828411faf - Fix - The choice of order column in tables has not been reset when going to another page. This caused crashes if the table in the other page did not have enough columns. 2020-07-18 08:14:34 +02:00
Tomas Bures
8eac78aa3a Merge remote-tracking branch 'origin/development' into development 2020-07-17 14:54:16 +02:00
Tomas Bures
d170548cfa - Fix for #890
- "Channels" feature
- Shoutout config param rendered on the homepage
- "Clone" feature for campaigns
2020-07-17 14:53:48 +02:00
Tomas Bures
00432e6cfe - Fix for #890
- "Channels" feature
- Shoutout config param rendered on the homepage
- "Clone" feature for campaigns
2020-07-16 20:37:16 +02:00
Tomas Bures
186a4ff4d6
Merge pull request #907 from wschopohl/development
Docker Development Container
2020-07-01 17:56:26 +02:00
Wieland Schopohl
6a7029bc3e removed package-lock files 2020-07-01 17:38:54 +02:00
Wieland Schopohl
0a4b835890 Merge branch 'docker-dev-server' into development 2020-07-01 16:51:23 +02:00
Wieland Schopohl
41ff51700e shouldn't exclude package-lock files, I guess 2020-07-01 16:51:05 +02:00
Wieland Schopohl
8cfd8a4f94 'docker-dev-server' branch adds files and README description to easily start a development server 2020-07-01 16:47:10 +02:00
Wieland Schopohl
f1c5d836ac new docker files and readme entry for docker development server 2020-07-01 15:53:54 +02:00
Tomas Bures
82251d1cb9 Some improvements imported from IVIS (https://github.com/smartarch/ivis-core/tree/devel)
Builtin Zone-MTA upgraded
Bug fix - URLs in campaign would not work if they contained non-ASCII character
2020-06-17 17:24:38 +02:00
Tomas Bures
65d3aed29d
Merge pull request #904 from wschopohl/development
small translation improvement for de-DE
2020-06-17 16:24:59 +02:00
Wieland Schopohl
ebf92ef301 correct webhook bounces, issue #716 2020-06-16 22:59:13 +02:00
Wieland Schopohl
d9e438596e small translation improvement for de-DE 2020-06-11 16:09:33 +02:00
Tomas Bures
dad2618739 Possible fix for #879 2020-05-28 01:05:23 +02:00
Tomas Bures
ebfbe30aa0
Merge pull request #842 from andresmrm/list-api-endpoints
Add API enpoints to create, delete and get lists.
2020-05-27 21:02:24 +02:00
Tomas Bures
129ad0c625
Merge pull request #859 from alangecker/fix-shortid-not-defined
fix for 'shortid not defined'
2020-05-27 21:00:51 +02:00
Tomas Bures
790630bc87
Merge pull request #888 from rycks/development
install script for debian 10 (mailtrain v2beta)
2020-05-27 20:59:51 +02:00
Tomas Bures
2e5f596d65
Merge pull request #889 from rycks/v2-french
French locale
2020-05-27 20:59:14 +02:00
Eric Seigne
62a4b1ded1 add french language
(cherry picked from commit 7414815b06)
2020-04-30 14:32:44 +02:00
Eric Seigne
7414815b06 add french language 2020-04-30 14:21:35 +02:00
Eric Seigne
8039c61889 install script for debian 10 (mailtrain v2beta) 2020-04-29 15:08:54 +02:00
Tomas Bures
665a0d0614 Initial draft of installation scripts for CentOS 8
Fixed bug in cancelling scheduled send - If campaign was scheduled to be sent, a checkbox was still shown on the campaign status page. This gave wrong impression that by unchecking the checkbox, the scheduling is cancelled. Checkbox is removed now and the "Pause" button has be renamed to "Cancel scheduling"

Added default role for campaign admin that administer multiple namespaces.
2020-04-12 16:52:19 +02:00
chandi
686972bb30 fix for missing shortid in message-sender.js
ERR! Senders Sending message to 4:1 failed with error: shortid is not defined. Dropping the message.
 verb ReferenceError: shortid is not defined
     at html.replace (/home/mailtrain2/mailtrain/server/lib/message-sender.js:236:29)
     at String.replace (<anonymous>)
     at MessageSender._getMessage (/home/mailtrain2/mailtrain/server/lib/message-sender.js:235:25)
     at MessageSender._sendMessage (/home/mailtrain2/mailtrain/server/lib/message-sender.js:345:34)
2020-03-03 04:03:56 +01:00
Tomas Bures
f323033da7
Merge pull request #853 from SeesePlusPlus/docs/update-subscription-widget-docs
Update subscription widget docs
2020-02-18 05:07:31 +01:00
Mike Seese
5450a92f33 update docs for the subscription widget 2020-02-17 12:03:05 -08:00
Tomas Bures
c9f1028fef
Merge pull request #844 from taye/feat-more-docker-env-variables
Set more config options from docker-entrypoint.sh
2020-02-03 10:15:40 +01:00
Taye Adeyemi
2e139782f6 Expect port to be optionally included in URL_BASE_* docker env vars 2020-02-03 01:31:49 +01:00
Taye Adeyemi
572e1ee78b Use WITH_REDIS and WITH_ZONE_MTA env vars in docker-entrypoint.sh 2020-02-02 19:33:58 +01:00
Taye Adeyemi
cf995fc35c Use PORT_{TRUSTED,SANDBOX,PUBLIC} env vars in docker-entrypoint.sh 2020-02-02 19:33:54 +01:00
Taye Adeyemi
417b208bfe Use WWW_HOST and WWW_SECRET env vars in docker-entrypoint.sh 2020-02-02 19:32:36 +01:00
Tomas Bures
f1cd83b734
Merge pull request #843 from taye/patch-1
Fix typo when getting $MONGO_HOST env variable
2020-02-01 19:38:33 +01:00
taye
92fe57f722
Fix typo when getting $MONGO_HOST env variable 2020-02-01 19:35:50 +01:00
AndresMRM
8271d6675a Add API enpoints to create, delete and get lists. 2020-01-31 10:04:07 -03:00
Tomas Bures
010e4837bc
Merge pull request #840 from andresmrm/fix-server-config-recreation
Fix path to server config file.
2020-01-31 12:32:16 +01:00
AndresMRM
976d788b17 Fix path to server config file. 2020-01-30 16:07:59 -03:00
Tomas Bures
cdcc254294
Merge pull request #838 from andresmrm/patch-3
fix endpoint /api/blacklist/delete
2020-01-25 14:22:55 +01:00
Andrés Martano
ecabc962ef
typo 2020-01-25 12:54:19 +00:00
Tomas Bures
97a40033d6 Added "Sender" role to lists 2020-01-16 21:30:55 +01:00
Tomas Bures
a3eaf72203 Updated dependencies
Version updated
2020-01-12 12:55:03 +01:00
Tomas Bures
7914077acb Added 'sendToTestUsers' permission to templates to control if a user can send a template to test users. (Up till now this was permitted by default.)
Campaigns list is now by default ordered by 'Created' in descending order.

Fixed display bug - two clicks on main menu item made it disappear

Campaign Status is now protected by 'view' permission. (Up till now it was 'viewStats' permission.)

Fixes in campaign status to hide send buttons and test send button if a user does not have necessary permissions.

Templates, Mosaico templates and Campaigns (edit and content) are now displayed to user even if the user does have only 'view' permission (not 'edit'). A banner is displayed that the user cannot save any changes and buttons are removed from the edit pages. This is to allow users to copy settings and content from existing campaigns which they are not supposed to edit. A better solution would be to display the edit and content form in read-only mode, but this seems to be a bit complicated.
2020-01-12 12:07:14 +01:00
Tomas Bures
674399eb74 Merge remote-tracking branch 'origin/development' into development
# Conflicts:
#	README.md
2020-01-12 09:32:05 +01:00
Tomas Bures
eb6afc1783 Merge of of PR #803 and some cleanup. 2020-01-12 09:31:11 +01:00
Tomas Bures
ddd84656b8 Merge branch 'pull/803' into development
# Conflicts:
#	docker-entrypoint.sh
2020-01-12 09:13:55 +01:00
Tomas Bures
c1091b9e42
Merge pull request #825 from dlecan/patch-1
Fix WWWW_PROXY typo in README
2020-01-11 14:01:59 +01:00
Tomas Bures
7552c31d8d Merge remote-tracking branch 'origin/development' into development 2020-01-11 14:01:14 +01:00
Tomas Bures
57907f9260 Merge of of PR #827 2020-01-11 13:59:59 +01:00
Tomas Bures
7482cb2ff0
Merge pull request #830 from GuillaumeRemyCSI/development
[READY] Added LDAP parameters for docker entryoint : mailTag, nameTag and method
2020-01-11 13:48:30 +01:00
Chris
2204bbe70c Fix missing tx refrence, https://github.com/Mailtrain-org/mailtrain/issues/811 2020-01-08 10:58:28 +13:00
Gerhard Sletten
94d3f4d725 Merge branch 'development-upstream' into Override-config
# Conflicts:
#	docker-entrypoint.sh
2020-01-06 10:50:42 +01:00
Damien Lecan
d64ab48d66
Fix WWWW_PROXY typo in README 2020-01-06 10:28:07 +01:00
Guillaume Rémy
3d61d205d7 Added LDAP parameters for docker entryoint : mailTag, nameTag and method + fixed typo in bindPassword ldap parameter 2020-01-05 13:02:07 +01:00
Tomas Bures
9d4506977d Fixes #824 "ReferenceError: os is not defined" 2020-01-03 14:30:44 +01:00
Tomas Bures
6f463b0b82 Merge branch 'development' of github.com:Mailtrain-org/mailtrain into development 2020-01-03 14:28:02 +01:00
Tomas Bures
d7d626cbc0 Addresses #785 "Allow segmentation by Subscription Status" 2020-01-03 14:27:47 +01:00
Tomas Bures
a950d511ec
Merge pull request #823 from Kevinjil/feature/ZoneMTA-pool-name
Add possibility to set pool name for builtin ZoneMTA.
2019-12-31 14:38:56 +01:00
Kevin Jilissen
2733912dda Update documentation for ZoneMTA pool name addition. 2019-12-31 12:58:05 +01:00
Kevin Jilissen
0e7a5dae82 Add possibility to set pool name for builtin ZoneMTA. 2019-12-31 01:28:08 +01:00
Gerhard Sletten
7ccf751c5a Add a note in README.md about passing own env-vars to docker-compose 2019-12-12 14:34:00 +01:00
Gerhard Sletten
2cae00315c Append to own production.yaml and remove log because level is default (and you will get an error if you try to override this in your own production.yaml 2019-12-12 14:20:29 +01:00
Gerhard Sletten
b959bb0812 Set right indentions for merging with own server/config/production.yaml 2019-12-12 14:19:06 +01:00
Tomas Bures
7744f5dc29
Merge pull request #801 from mhulet/patch-1
Replace WITH_PROXY with WWW_PROXY in README
2019-12-09 21:29:34 +01:00
Michael Hulet
fd777e56b9
Replace WITH_PROXY with WWW_PROXY in README
The expected environment variable is WWW_PROXY, not WITH_PROXY
2019-12-09 21:24:42 +01:00
Tomas Bures
d340a803e1 Updated build info. 2019-12-07 14:10:15 +01:00
Tomas Bures
e49630d4f0 Fixed warning about unsaved data when leaving empty campaign edit. 2019-12-07 14:08:12 +01:00
Tomas Bures
3c4fbf2754 Merge branch 'pull/723' into development
# Conflicts:
#	mvis/ivis-core
2019-12-07 13:56:49 +01:00
Tomas Bures
2b60af2605 Merge remote-tracking branch 'origin/development' into development 2019-12-07 13:52:39 +01:00
Tomas Bures
0298a1dcbe Updated packages and fixed problems caused by the update. 2019-12-07 13:51:59 +01:00
Tomas Bures
87b9399a5b Merge branch 'pull/722' into development 2019-12-07 13:49:06 +01:00
Tomas Bures
cc34d89283
Merge pull request #679 from galaxycard/bugfix/missingMergeTags
Add mergeTags to sendMessage
2019-12-07 11:59:29 +01:00
Tomas Bures
6d00ea1e69 Merged PR #724 2019-12-07 11:53:18 +01:00
Tomas Bures
52c638b0b3 Merge branch 'pull/724' into development 2019-12-07 11:48:57 +01:00
Tomas Bures
7df580d7e2 Merged PR #740. 2019-12-07 11:21:19 +01:00
Tomas Bures
e6b1cf8add Merge branch 'pull/740' into development 2019-12-07 11:11:44 +01:00
Tomas Bures
964ff80cfa Merge branch 'pull/741' into development 2019-12-07 10:49:23 +01:00
Tomas Bures
63a79ae36d Updated packages and fixed problems caused by the update. 2019-12-07 10:47:57 +01:00
Tomas Bures
cd2fdb9e23 Updated packages.
Merged #763 + some variable renaming
2019-12-07 09:28:17 +01:00
Tomas Bures
e61e0fb802 Merge branch 'pull/763' into development 2019-12-07 08:24:34 +01:00
Tomas Bures
4cdc4ed833
Merge pull request #789 from notz/fix-trigger-campaign-id
Fix trigger campaign id
2019-12-07 08:19:29 +01:00
Tomas Bures
c44ed4f7fa Merge branch 'development' of github.com:Mailtrain-org/mailtrain into development 2019-12-07 08:08:54 +01:00
Tomas Bures
e19d72ccfc
Merge pull request #792 from martialblog/image-size
Reduce Docker image size
2019-12-07 08:07:17 +01:00
Tomas Bures
7e5440e389
Merge pull request #796 from andresmrm/patch-1
Fix default MYSQL_PASSWORD.
2019-12-07 08:06:18 +01:00
Tomas Bures
72cf153a90
Merge pull request #797 from andresmrm/patch-2
typos
2019-12-07 08:05:48 +01:00
Andrés Martano
023dac88ab
typos 2019-12-06 13:43:48 +00:00
Andrés Martano
595d1f3871
Fix default MYSQL_PASSWORD. 2019-12-06 13:41:04 +00:00
Markus Opolka
43edf35637 Reduce Docker image size
- Removed node_modules from client after compilation
 - Added copy-webpack-plugin to copy required JS and fonts to dist
 - Adjusted server to serve files from client/dist
 - add js-yaml to server packages in order to use npm install --production
2019-12-05 13:16:37 +01:00
Gernot Pansy
e2a69ef76d Added subscription changed trigger
Is very useful if some subscription data is updated by API and you want to trigger after that because segments (filters) have changed.
2019-12-03 09:08:40 +01:00
Gernot Pansy
1145bf36cf fix trigger campaign id 2019-12-03 08:56:12 +01:00
Tomas Bures
ed2655b78e
Merge pull request #773 from sedrubal/add-cid-to-get-lists-for-email-api-call
Add Field `cid` to `/api/lists/:email`
2019-11-26 21:12:07 +01:00
Tomas Bures
6bacde9e3d
Merge pull request #783 from martialblog/refactor-entrypoint
Refactor Docker entrypoint
2019-11-26 21:11:06 +01:00
Markus Opolka
668c98477e Update README and docker-compose files for new entrypoint
- Removed MAILTRAIN_SETTING command
 - Added list of Environment Variables to README
2019-11-22 11:20:10 +01:00
Markus Opolka
b21005df78 Refactor docker-entrypoint.sh to use ENV Variables
- This provides a setup which is more consistens with other Docker images
 - Provides the possibility to deploy configuration files with configuration management tool
 - Throws an error when the MAILTRAIN_SETTINGS is still used
2019-11-22 11:20:10 +01:00
Tomas Bures
0970de7365
Merge pull request #772 from sedrubal/fix-access-token-in-documentation
Fix rendering of access token in API docs
2019-11-20 11:57:37 +01:00
Tomas Bures
87e1f4528a
Merge pull request #781 from martialblog/enhance-german-translation
Fix some typos in German translation
2019-11-17 08:53:55 +01:00
sedrubal
5ffdfa7c96
Fix some typos in German translation 2019-11-14 02:05:03 +01:00
Tomas Bures
7558178bf4
Merge pull request #777 from martialblog/de-DE
Add German Translation for Mailtrain v2
2019-11-13 11:23:42 +02:00
Markus Opolka
aa1baae5b5 Add German Translation for Mailtrain v2 2019-11-13 10:15:45 +01:00
Markus Opolka
10dcfeb749 Add locales key for 'Help text' 2019-11-13 10:15:45 +01:00
Markus Opolka
1ed90941a7 Add locales key for 'Tag language' 2019-11-13 10:15:45 +01:00
Markus Opolka
adbee1cc22 Fix minor typo in english translation 2019-11-13 10:15:45 +01:00
Markus Opolka
73b49e7922 Fix minor typo in english translation 2019-11-13 10:15:45 +01:00
Tomas Bures
97910a2efa
Merge pull request #775 from ericuldall/fix-multiple-campaign-error
Fixes issue sending multiple campaigns
2019-11-12 23:31:34 +02:00
Eric Uldall
b2bb0b642f added campaignId to query for campaign_messages 2019-11-12 13:27:46 -08:00
sedrubal
082901e4e7
Add Field cid to /api/lists/:email
With this `cid` you are able to do further API calls like unsubscribing
the user from all mailing lists.
2019-11-10 00:43:41 +01:00
sedrubal
81fb4be4af
Delete trailing &amp;s in API docs 2019-11-10 00:28:29 +01:00
sedrubal
436e8b069d
Fix rendering of access token in API docs 2019-11-10 00:23:18 +01:00
Eric Uldall
cb4e3c76d3 added code to allow failed messages from bad content url 2019-10-31 20:42:33 -07:00
Gernot Pansy
e7e0f78742 fix trigger delay calculation 2019-10-21 09:39:42 +02:00
Gernot Pansy
6e04f782e3 insert triggered campaign campaign message to campaign_messages table
This fixes that triggered campaign e-mails are not reported in ui and so it's also possible to
create another triggered campaign on this first triggered one.

What i don't understand why their is a difference in handling of a triggered campaign and a normal one in sending.

Also i expected that a triggered campaign can only be send once to an user regardless which trigger fires the send.
But currently it's send for every trigger that is defined in a campaign.
2019-10-21 09:30:00 +02:00
Tomas Bures
3a2d1512ab
Merge pull request #739 from martialblog/fix-posix-resolve
Add Python to Dockerfile package installation
2019-10-20 00:18:34 +03:00
Markus Opolka
c9563ede6a Add python to Dockerfile
- This ensures that the posix pid/gid resolve features are working
2019-10-19 13:23:01 +02:00
Gernot Pansy
fe39ec5134 Send triggered campaigns only to active subscribers 2019-10-10 12:37:31 +02:00
Gernot Pansy
d5ddba2a40 Allow select segments on triggered campaigns
Don't know why this is disabled. Tested everything and it's working as expected.
2019-10-10 12:32:30 +02:00
Tomas Bures
cbf2a6e39d
Merge pull request #714 from martialblog/docker
Optimise Docker Image
2019-10-02 13:19:47 +03:00
Markus Opolka
12210a1bf5 Update Dockerfile to make image smaller
- Added multistaged build
 - Moved production depedencies to final stage
 - Added more verbose output during build
2019-10-02 12:05:32 +02:00
Markus Opolka
77e7df7b83 Add more mysql database parameters to entrypoint 2019-10-02 10:56:50 +02:00
Markus Opolka
7620e1564d Extend .dockerignore file to make image smaller 2019-10-02 09:48:27 +02:00
Tomas Bures
cdd57224f6
Merge pull request #712 from Pistus/fix/711-keep-aws-secret
Fix/711 keep aws secret
2019-09-27 10:57:13 +02:00
Frode Jensen
9914fa33d8 fix(aws-ses): Make AWS SES secret a password field 2019-09-27 10:39:39 +02:00
Frode Jensen
e85b1fcfff fix(aws-ses): Add label for Aws ses secret 2019-09-27 10:38:59 +02:00
Tomas Bures
4c3ff16ee3 Added function to support ellipsization of titles. 2019-09-19 21:11:35 +02:00
Tomas Bures
b6ed5e56b9 Date/time and device type in quick report.
Fix - invalid campaign when one tried to unsubscribe from a test message before campaign was sent
2019-09-05 15:51:17 +02:00
Tomas Bures
2e4dc1bce4 Work in progress on integration of IVIS.
Some fixes.
2019-08-31 12:04:03 +02:00
Tomas Bures
2aaa8f45b3 Work in progress on integration of IVIS.
Some fixes.
2019-08-31 11:46:18 +02:00
Tomas Bures
3a17d7fd75 Merge remote-tracking branch 'origin/development' into development 2019-08-23 13:57:22 +02:00
Tomas Bures
4252a08c39 Fix for #690 2019-08-23 13:56:22 +02:00
Tomas Bures
427f0ec2c2 Some cleanup/small fixes. 2019-08-23 13:50:43 +02:00
Tomas Bures
af2e988ae7
Merge pull request #687 from Charlo270398/development-LDAPfirstLogin
LDAP - Users not created on the fly upon first login fixed #683 (good one)
2019-08-21 14:43:58 +02:00
root
de15ba15da LDAP mailTag fix 2019-08-21 14:41:35 +02:00
Tomas Bures
5abf100776
Merge pull request #686 from Charlo270398/development-statisticsBUG
Statistics not tracked when the user is not a test user fixed
2019-08-21 14:34:55 +02:00
root
cc73c679e5 Statistics not tracked when the user is not a test user fixed 2019-08-21 14:18:43 +02:00
Tomas Bures
a17ee3d9bf Fix for #659 2019-08-21 13:18:03 +02:00
root
6beac55826 LDAP first login BUG fixed 2019-08-21 08:24:33 +02:00
Carlos Zamora Sanz
3e374db722
Merge pull request #6 from Mailtrain-org/development
Development
2019-08-21 08:08:22 +02:00
Tomas Bures
ebb6c2ff74
Merge pull request #677 from galaxycard/bugfix/clearQueue
Clear old message by datetime, not by timestamp)
2019-08-20 14:48:28 +02:00
Tomas Bures
52652d8ac5
Merge pull request #681 from Charlo270398/development-LDAPeditUserBUG
LDAP edit users BUG fixed
2019-08-20 14:42:23 +02:00
Tomas Bures
f5f9426e9c
Merge pull request #684 from GuillaumeRemyCSI/ldap-fixes
Fixes the authMode name for passport-ldapjs and add a parameter for LDAP mail attribute
2019-08-20 10:12:04 +02:00
GuillaumeRemyCSI
2f9a75df9b Fixes the authMode name for passport-ldapjs and add a parameter for LDAP mailTag 2019-08-20 10:00:51 +02:00
Carlos Zamora Sanz
415dbcb14f
Merge pull request #5 from Mailtrain-org/development
Development
2019-08-19 10:28:12 +02:00
root
732a9e4d93 LDAP edit users BUG fixed 2019-08-16 13:00:27 +02:00
amit.kumar
8d27676278 Add mergeTags to sendMessage 2019-08-15 20:16:06 +05:30
amit.kumar
8bda0c05c0 Clear old message by datetime, not by timestamp) 2019-08-15 13:23:54 +05:30
Tomas Bures
71737fa656
Merge pull request #673 from Charlo270398/development-CreateReportBug
Create report BUG
2019-08-14 15:15:59 +02:00
root
70ca9f405a fix 2019-08-14 15:13:27 +02:00
Carlos Zamora Sanz
6a8f139b43
Merge pull request #4 from Mailtrain-org/development
Development
2019-08-14 14:35:19 +02:00
root
7ec6f9e6d0 Report CUD fix 2019-08-14 14:25:07 +02:00
Tomas Bures
1295d83c59
Merge pull request #671 from GuillaumeRemyCSI/new-parameters-in-docker-entrypoint
Added new parameters for LDAP in docker-entrypoint.sh
2019-08-14 14:14:18 +02:00
Guillaume Rémy
de20d8a64c Added new parameters for LDAP in docker-entrypoint.sh 2019-08-14 13:35:46 +02:00
Tomas Bures
5281707b2d
Merge pull request #670 from GuillaumeRemyCSI/secure-ldap
Added secure ldap parameter in default.yaml config file
2019-08-14 11:45:23 +02:00
Guillaume Rémy
98cd14f8be Added ldap secure parameter in default.yaml config file 2019-08-14 11:30:03 +02:00
Tomas Bures
54f7225077
Merge pull request #669 from GuillaumeRemyCSI/secure-ldap
Added secure config parameter to use ldaps protocol
2019-08-14 11:24:15 +02:00
Guillaume Rémy
c8eeeaa9b9 Added secure config parameter to use ldaps protocol 2019-08-14 11:18:38 +02:00
Tomas Bures
ae5faadffa Fix for #665 and additional fix for #663.
If your segemnts are broken or Mailtrain complains about missing 20190726150000_shorten_field_column_names.js, run the following in `server/setup/knex/fixes`:

```NODE_ENV=production node fix-20190726150000_shorten_field_column_names.js```
2019-08-12 09:26:49 +02:00
Tomas Bures
bb237b3da4 Fix - URL bases replacement didn't work for HBS tag language. 2019-08-11 21:50:06 +02:00
Tomas Bures
23e683192f Additional fix for #660
Fix for #662
2019-08-11 21:01:01 +02:00
Tomas Bures
8cb24feca1 Fix for #660
Campaign preview and campaign test send pulls the first entry in the RSS feed and substitutes its data in `[RSS_ENTRY_*]`
2019-08-11 16:28:11 +02:00
Tomas Bures
588cf34810 Fix for #663 2019-08-10 23:17:15 +02:00
Tomas Bures
69ce80ebfd Fix for #663.
Unfortunately, the migration 20190726150000_shorten_field_column_names.js corrupted the segments table. There is no automatic fix. If this affected you, you have to either revert the DB or fix the segments manually.
2019-08-10 23:15:38 +02:00
Carlos Zamora Sanz
6b6fa8b3ef
Merge pull request #2 from Mailtrain-org/development
Merge
2019-08-08 11:41:17 +02:00
Tomas Bures
30e03adf0c Fix for #619
Merged parts of PR #651 and fixed the rest
2019-08-07 14:29:58 +02:00
Tomas Bures
5cae9c849c Merge branch 'pull/651' into development 2019-08-07 12:29:30 +02:00
Tomas Bures
cce998d89e Merge branch 'pull/656' into development 2019-08-07 11:45:20 +02:00
Tomas Bures
2202f228eb Merge of PR #654 and some updates to it. 2019-08-07 11:25:47 +02:00
root
ee3fb1bbd1 Campaign tag language bug fixed 2019-08-06 11:38:07 +02:00
root
c1a9404648 Verp campaign BUG fixed 2019-08-06 09:37:02 +02:00
root
2929048ebe Fix 2019-08-06 09:26:44 +02:00
root
b08fd07909 convertFileURLs fix 2019-08-06 09:25:22 +02:00
root
e55317ec43 Clone existing template BUG fixed 2019-08-05 13:31:56 +02:00
Carlos Zamora Sanz
b7fbc32e1e
Merge pull request #1 from Mailtrain-org/development
Merge
2019-08-05 09:20:13 +02:00
Tomas Bures
5abe809a97 Merge remote-tracking branch 'origin/development' into development 2019-08-01 07:46:55 +02:00
Tomas Bures
712a905518 Fixes of functions around viewTestSubscriptions 2019-08-01 07:46:40 +02:00
Tomas Bures
a4a5a468ea
Merge pull request #647 from deisner/patch-1
Update README.md
2019-08-01 04:51:51 +02:00
David Eisner
836faeff69
Update README.md
Typo fix.
2019-07-31 16:26:35 -04:00
Tomas Bures
ed3ed1a202 Some small updated in UI 2019-07-31 16:50:06 +02:00
Tomas Bures
dba534ba21 Fix - login screen does not show up 2019-07-29 10:00:01 +02:00
Tomas Bures
a876c7b301 Version on the home screen updated. 2019-07-29 09:47:47 +02:00
Tomas Bures
e77038f132 fixup! Various fixes. 2019-07-29 09:25:07 +02:00
Tomas Bures
a258479621 Various fixes in the UI.
Check permissions mechanism reworked to allow specifying permission checks already in menu structure.
2019-07-29 09:24:50 +02:00
Tomas Bures
a46c8fa9c3 Remove button removed from the namespace that contains the current user.
Optimizations in how mixins are composed. The refresh should now be up to 2x faster for deeper hierarchies.
2019-07-27 17:47:25 +02:00
Tomas Bures
6ae9143c22 Added abstraction layer around config.
`roles` in config renamed to `defaultRoles`. These are used if no `roles` are provided in production.yaml
2019-07-26 20:35:49 +05:30
Tomas Bures
8cd01fe99e Fix for #639 2019-07-26 18:32:14 +05:30
Tomas Bures
d247893d31 Refactoring a common pattern for "clone for existing". Applied to custom forms and templates. 2019-07-26 16:48:26 +05:30
Tomas Bures
6eeef7a991 Merge branch 'pull/637' into integration-637
# Conflicts:
#	client/src/lists/forms/CUD.js
2019-07-24 14:54:51 +05:30
Tomas Bures
b65960b528 Merge of PR #641 2019-07-23 22:14:35 +05:30
root
ff9191c206 Add multiple lists in a campaign 2019-07-23 22:11:53 +05:30
root
f987cb1197 Change campaign namespace BUG correction 2019-07-23 22:11:53 +05:30
Tomas Bures
02360be75b Various fixes. 2019-07-23 21:16:55 +05:30
root
1bf37e65a3 Clone from existing custom forms Added 2019-07-23 12:35:41 +02:00
Tomas Bures
4e4b77ca84 Fixes.
Added support for help text in custom fields.
Reimplemented the mechanism how campaign_messages are created.
2019-07-22 23:54:24 +05:30
root
e9bf4a890c Copy custom forms added 2019-07-22 13:01:48 +02:00
Tomas Bures
025600e818 Fixes. Reimplementation of the API transaction sender. 2019-07-16 21:10:33 +05:30
Tomas Bures
8606652101 Fixes. Reimplementation of the API transaction sender. 2019-07-16 21:03:37 +05:30
Tomas Bures
a22187ef12 Merge remote-tracking branch 'origin/development' into development-tb
# Conflicts:
#	server/lib/template-sender.js
#	server/routes/api.js
2019-07-10 02:13:19 +04:00
Tomas Bures
e3a5a3c4eb Fixed some bugs in subscription process
Added timezone selector to campaign scheduling
Fixed problems with pausing campaign.
2019-07-10 02:06:56 +04:00
Tomas Bures
4113cb8476 Work in progress on tag language
Fix - message sent to a list not associated with a campaign couldn't be shown in archive - to know which message to show even if the list is not at the campaign, we store test messages in table test_messages
2019-07-05 23:23:02 +02:00
Tomas Bures
75138f9728
Merge pull request #618 from galaxycard/development
allow attachments from api
2019-07-03 20:33:46 +02:00
Tomas Bures
00e328a914 Work in progress on introducing tag language. Not tested yet. 2019-07-03 11:58:58 +02:00
Tomas Bures
450b930cc5 Work in progress on refactoring all mail sending to use the message sender an sender workers.
Some fixes related to subscriptions and password reset.
2019-06-30 10:47:09 +02:00
Tomas Bures
4e9f6bd57b Work in progress on refactoring all mail sending to use the message sender an sender workers. No yet finished. 2019-06-29 23:19:56 +02:00
Tomas Bures
deb6f1f294
Update README.md 2019-06-26 18:10:21 +02:00
Tomas Bures
355e03900a Fix - subject line is not saved 2019-06-25 12:35:04 +02:00
Tomas Bures
a9039e5760 Fix - subject line is not shown when send configuration is not selected 2019-06-25 12:17:30 +02:00
Tomas Bures
30b361290b - Refactoring of the mail sending part. Mail queue (table 'queued') is now used also for all test emails.
- More options how to send test emails.
- Fixed problems with pausing a campaign (#593)
- Started rework of transactional sender of templates (#606), however this contains functionality regression at the moment because it does not interpret templates as HBS. It needs HBS option for templates as described in https://github.com/Mailtrain-org/mailtrain/issues/611#issuecomment-502345227

TODO:
- detect sending errors connected to not able to contact the mailer and pause/retry campaing and queued sending - don't mark the recipients as BOUNCED
- add FAILED campaign state and fall into it if sending to campaign consistently fails (i.e. the error with sending is not temporary)
- if the same happends for queued email, delete the message
2019-06-25 07:18:06 +02:00
amit.kumar
20603f679c allow attachments from api 2019-06-19 15:55:39 +05:30
Tomas Bures
ff66a6c39e Helper tool for cleaning node_modules 2019-06-15 13:48:14 +02:00
Tomas Bures
f8e9d67568 Fixes for #613
Segment selection invalidated when one changes another list in campaign edit.
2019-06-15 11:12:26 +02:00
Tomas Bures
4736afd5ab Merge branch 'development' of github.com:Mailtrain-org/mailtrain into development-tb 2019-06-15 10:25:21 +02:00
Tomas Bures
afe12cba0f
Merge pull request #609 from trucknet-io/bugfix/#606
Added getting of variables to TemplateSender [Resolves #606]
2019-06-11 18:59:47 +02:00
Alexander Gusev
39f3682c27 fix(transactionalApi): added getting of variables to TemplateSender [Resolves #606] 2019-06-11 19:37:45 +03:00
Tomas Bures
801fb7c81d
Merge pull request #604 from trucknet-io/development
fix: added imagemagick to dockerfile;
2019-06-05 08:23:29 +02:00
Alexander Gusev
7ae463ea72 fix: added imagemagick to dockerfile image; 2019-06-05 08:47:26 +03:00
Tomas Bures
fcd2a61b65 Child processes are now terminated when the parent process dies. This means that if the main mailtrain process gets killed, there are no processes which remain running. 2019-05-25 21:57:11 +02:00
Tomas Bures
1270ca71f8 Some fixes 2019-05-25 21:18:18 +02:00
Tomas Bures
640d3c2f11 Some fixes 2019-05-20 00:38:39 +02:00
Tomas Bures
3c72e778d9 Merged PR #528
Support for detecting MTA by its response. Message IDs are reconstructed based on detected MTA.
Bugfixes for AWS. AWS now seems to work.
2019-05-20 00:21:03 +02:00
Tomas Bures
bbbe671d59 Merge branch 'pull/528' into development-tb 2019-05-19 22:22:29 +02:00
Tomas Bures
97b4bb7574 Mosaico MJML sample updated to showcase conditional display markup. 2019-05-19 19:20:02 +02:00
Tomas Bures
e40793b874 Fixes to detecting changes in forms. 2019-05-19 19:07:10 +02:00
Tomas Bures
cbb29a0840 Fixes to detecting changes in forms. 2019-05-19 19:06:30 +02:00
Tomas Bures
2e9d44c705 Added ability to make a conditional block in MJML Mosaico.
Mosaico switched from master to v0.17.5
Added workaround for Chrome - after save, images in Mosaico disappear
2019-05-19 01:42:26 +02:00
Tomas Bures
a527b80291 Update of source files and integration of Portuguese translation 2019-05-13 01:10:53 +02:00
Tomas Bures
8eae6d6c41 Update of extract.js to process translations in other languages. 2019-05-13 01:07:01 +02:00
Tomas Bures
79164c5fe5 Update of extract.js to process translations in other languages. 2019-05-13 00:45:36 +02:00
Tomas Bures
0e9d192b1f Update of extract.js to process translations in other languages. 2019-05-13 00:35:19 +02:00
Tomas Bures
b1efb95315 Update of extract.js to process translations in other languages. 2019-05-12 23:59:13 +02:00
Tomas Bures
03bcba1667 Merge branch 'pull/595' into development-tb 2019-05-12 10:09:33 +02:00
Tomas Bures
e064948838 RC1 of confirmation dialogs displayed when one navigates from a page with unsaved changes.
Fixes in Share and UserShare.
2019-05-12 10:00:10 +02:00
Tomas Bures
c4b78c4823 Work in progress on confirmation dialogs displayed when one navigates from a page with unsaved changes.
Optimized imports.
2019-05-12 00:18:04 +02:00
Tomas Bures
008fd21b51 Work in progress on confirmation dialogs displayed when one navigates from a page with unsaved changes.
Optimized imports.
2019-05-12 00:00:09 +02:00
Tomas Bures
3921a6b2cb Work in progress on confirmation dialogs displayed when one navigates from a page with unsaved changes.
Optimized imports.
2019-05-08 23:48:13 +02:00
Tomas Bures
48dcf2c701 Mosaico upgraded to 0.17.5
Work started on confirmation dialogs displayed when one navigates from a page with unsaved changes
2019-05-08 19:54:19 +02:00
bbraganca
856636d12e
Update translate.js 2019-05-06 10:19:36 -03:00
bbraganca
f04c549d24
Update translate.js 2019-05-06 10:19:02 -03:00
bbraganca
0618d899c0
Update langs.js 2019-05-06 10:14:52 -03:00
bbraganca
7f979d554e
Update translate.js 2019-05-06 10:14:11 -03:00
bbraganca
91c059144f
Update i18n.js 2019-05-06 10:12:44 -03:00
bbraganca
b0c3858268
Create common.json 2019-05-06 10:10:57 -03:00
Tomas Bures
4f77272042 Updated ivis-core 2019-04-22 22:48:08 +02:00
Tomas Bures
72ffe065d2 Added quick reports (at this moment only one) to campaign statistics page. 2019-04-22 22:46:48 +02:00
Tomas Bures
3e3c3a24fe Further updated on caching. Small change in DB schema to make lookups a bit more performant. Note that if DB migration in the last commit has been run, this commit will need manual update of the database. 2019-04-22 15:41:39 +02:00
Tomas Bures
ef0464bbc9 Further improvements in caching. The state of the cache is now persisted in DB. This persists the cache between server restarts. 2019-04-22 11:41:37 +02:00
Tomas Bures
7bcd6c60e9 The previous commit had some services disabled to speed up testing. The services are back now. 2019-04-22 02:58:10 +02:00
Tomas Bures
66702b5edc Fixes in reports (generating a CSV).
Added caching of generated images in mosaico handler.
Various other fixes.
2019-04-22 02:41:40 +02:00
Tomas Bures
055c4c6b51 package-lock.json is updated and back. The client is hopefully fixed w.r.t. #571. 2019-04-15 20:53:36 +02:00
Tomas Bures
c00bd68123 Removed package-lock.json, such that it is automatically updated during install. It seems that at this point, we might have more problems with stale package-lock.json than with incompatible dependencies. 2019-04-15 19:44:13 +02:00
Tomas Bures
1f1cf81a17 New versino of ivis-core. It is not used at moment, so this should not really affect anything. 2019-04-15 18:52:07 +02:00
Tomas Bures
b8f8750afd Imported some enhancements from IVIS. Candidate for a fix for #571. 2019-04-15 18:47:30 +02:00
Tomas Bures
d0d42ab280
Merge pull request #579 from trucknet-io/development
Dockerfile update
2019-04-15 18:13:33 +02:00
Tomas Bures
94a2cdf89e More elements for mosaico mjml support. Added "MJML Sample" wizard to mosaico templates. 2019-04-03 23:39:10 +02:00
Alexander Gusev
8a879c91bd chore: Dockerfile update
node:10-14-alpine image is used (because jessie is oudated and can not install packages
adding package-lock.json to make sure the same dependencies' versions are used
added an option to pass redisHost, mongoHost, mysqlHost to use in Kubernetes cluster
2019-04-03 14:25:09 +03:00
Tomas Bures
ec0f288d81 Merge remote-tracking branch 'origin/development' into development 2019-04-03 12:14:09 +02:00
Tomas Bures
ad9f5d16bf Added support to define mosaico templates in MJML. (A wizard that shows how to do this is TODO.)
Adopted some core features (router, etc.) from IVIS.
2019-04-03 12:13:49 +02:00
Alexander Gusev
ae461383bd
Merge pull request #4 from Mailtrain-org/development
Merge pull request #573 from trucknet-io/development
2019-04-03 12:06:42 +03:00
Tomas Bures
3a45443b64
Merge pull request #573 from trucknet-io/development
feat(api): Transactional mail rest api
2019-04-02 16:23:35 +02:00
Alexey Zinkevych
8b39a101cd Transactional mail: minor template-sender refactoring 2019-04-02 16:35:57 +03:00
Alexey Zinkevych
e588e218b6 Transactional mail: use tools to format message 2019-04-02 16:15:35 +03:00
Alexey Zinkevych
76b4f8b8c2 Transactional mail: added data rendering 2019-04-02 14:44:07 +03:00
Alexey Zinkevych
80279346f3 Transactional mail: code review fixes 2019-03-31 15:50:40 +03:00
Alexey Zinkevych
ed4a13fef7 Added transactional mail api docs 2019-03-31 13:07:29 +03:00
Alexey Zinkevych
31442453ea Merge branch 'transactional-mail-v2' into development 2019-03-31 12:57:55 +03:00
Alexey Zinkevych
e3e1e7a086 Merge branch 'development' of https://github.com/Mailtrain-org/mailtrain into transactional-mail-v2 2019-03-31 11:52:42 +03:00
Alexander Gusev
dc4f0922ef
Merge pull request #2 from trucknet-io/transactional-mail-v2
Transactional mail v2
2019-03-31 11:40:44 +03:00
Tomas Bures
3b20ac5ce7 Some fixes in expection logging, template files and resizing mosaico images 2019-03-27 23:50:20 +01:00
Tomas Bures
63cfb22025 Merge branch 'development' into development-tb 2019-03-27 09:54:20 +01:00
Tomas Bures
d482d214d9 Line endings fixed so that we don't have CRLF in Git. Better now than later. 2019-03-27 09:49:29 +01:00
Tomas Bures
2fe7f82be3 Merge and cleanup of PR #564
The namespace filter in campaigns was dropped (i.e. "Work with campaign's namespace"). Instead, we need a universal solution. For instance a namespace slector somewhere in the top-right corner, which should apply to everything (not just campaigns).

Nevertheless, I kept the ...-by-namespace rest endpoints and related functions in models because they will be useful for implementing the universal namespace selection feature.
2019-03-27 00:41:18 +01:00
Tomas Bures
dcb7168322 Merge branch 'pull/564' into development 2019-03-26 22:55:00 +01:00
Tomas Bures
3ae4c77fb4 Preparations for mosaico MJML templates 2019-03-26 22:48:31 +01:00
Alexey Zinkevych
4a521a8f0f Implemented basic transactional emails API 2019-03-25 14:48:18 +02:00
Alexey Zinkevych
913c7dc337 Added .vscode to .gitignore 2019-03-24 11:27:51 +02:00
Carlos
53340ad631 fix 2019-03-20 09:03:06 +01:00
Carlos
b583df7e2f fix 2019-03-20 08:58:46 +01:00
Carlos
444717b4d0 Templates namespace filtering & fix 2019-03-20 08:57:06 +01:00
Carlos
b6a896558e fix 2019-03-15 10:10:48 +01:00
Carlos
b5a6167202 common.json filtering namespace labels 2019-03-14 16:34:16 +01:00
Carlos
74fe5e73e2 Namespace filtering when create/edit campaigns 2019-03-14 16:15:37 +01:00
Carlos
7b08e2e97b Spanish lang update 2019-03-13 16:36:35 +01:00
Carlos
41b311c811 Spanish lang update 2019-03-13 15:18:40 +01:00
Carlos
75daaa1a65 Confirmation modal when launching campaign 2019-03-13 15:00:49 +01:00
Carlos
5d08db67b3 Show status bug 2019-03-13 13:23:57 +01:00
Carlos
83267a7e28 Checkbox text alignment and override checkboxes reorganized 2019-03-13 13:09:37 +01:00
Carlos
bdacf92917 Campaigns: Show override checkbox when Send configuration allows it 2019-03-13 10:22:19 +01:00
Carlos
1a61067ff9 Campaign status show send settings Bug 2019-03-13 09:52:02 +01:00
root
0037b39656 Fix for:
Reinstalling modules in mvis/ivis-core/client
/opt/mailtrain/setup/functions: line 222: cd: mvis/ivis-core/client: No such file or directory
2019-03-09 10:34:07 -08:00
Tomas Bures
9b32e59b50 Number of processes and connections of builtin zone MTA is now configurable via Mailtrains config. 2019-03-09 14:07:11 +01:00
Tomas Bures
97bb700334 Various fixes 2019-03-09 07:42:14 +00:00
Carlos
4b1b1a380d deleting useless files 2019-03-06 13:27:53 +01:00
Carlos
25ecd85910 CampaignNamespaceUpdateBugFixed 2019-03-06 09:47:52 +01:00
Carlos
a43302f3ab Users list only for admin added 2019-03-04 15:57:51 +01:00
Carlos
38e277cb7c Spanish lang without translation added 2019-03-04 15:44:52 +01:00
Carlos
aa66c18650 works 2019-03-04 15:17:37 +01:00
Tomas Bures
4a6aed4cf7 All create/edit forms now allow staying on the page after save. 2019-02-24 11:10:23 +00:00
Tomas Bures
d54f941caa Some fixes. 2019-02-24 00:19:49 +00:00
Tomas Bures
41cd01c2b9 Exported CSV now contains status column (fix for #547) 2019-02-23 14:27:28 +00:00
Tomas Bures
0c3510d626 Some fixes 2019-02-18 22:42:57 +00:00
Tomas Bures
8d95f43dbc Added feature to create template from another template. 2019-02-18 20:36:44 +00:00
Tomas Bures
031b346440
Merge pull request #537 from priethor/development
Adds 'type' property to migration of text-based segment rules
2019-02-17 18:48:13 +01:00
Tomas Bures
f8ef57f164 Fixed bug that prevented sending via builtin zone mta. This but was introduced today. 2019-02-17 17:47:27 +00:00
Héctor
433bf31bfa
Adds 'type' property to migration of text-based segment rules
In v2, text-based segment rules need a "type" property. As in v1 the value could contain % wildcards, the default type for migrated rules should be 'like' to support them.
2019-02-17 18:45:35 +01:00
Tomas Bures
f7b5aef0e3 Some more fixes
Warning dialog about missing Javascript removed from subscription dialog. The warning would flash in any case (even when Javascript is activated)
2019-02-17 17:18:59 +00:00
Tomas Bures
df2a8c1cde Fix for #536 2019-02-17 17:18:44 +00:00
Tomas Bures
7f2a9ca940 Link to the v1 removed from v2 readme 2019-02-17 15:00:00 +00:00
Tomas Bures
8a843bcea4 Fix in docker instructions 2019-02-17 14:50:00 +00:00
Tomas Bures
02b42a4982 Updated instructions to use docker image on docker hub. Addresses #521 2019-02-17 13:56:15 +00:00
Tomas Bures
abed0d4af4 Fixes in messages 2019-02-17 12:46:32 +00:00
Tomas Bures
46ad0c7b4f Fix for #531 2019-02-17 12:46:02 +00:00
Tomas Bures
90f289989b
Merge pull request #534 from priethor/development
Support for string based segment rules....
2019-02-17 09:08:09 +01:00
Héctor
d1a1398828
Support for string based segment rules....
...such as email fields.
2019-02-17 00:49:12 +01:00
Gernot Pansy
d52436c566 added webhook support for postal mail server 2019-02-14 22:38:35 +01:00
Tomas Bures
e0bee9ed42 Some preparations for activity log.
Fixed issue #524
Table now displays horizontal scrollbar when the viewport is too narrow (typically on mobile)
2019-02-07 14:38:32 +00:00
Tomas Bures
4f408a26d5 Some fixes 2019-01-20 16:31:17 +00:00
Tomas Bures
d14942da93 Adoptions from ivis-core 2019-01-12 21:33:00 +01:00
Tomas Bures
18eb54037c Adoptions from ivis-core 2019-01-12 21:31:20 +01:00
Tomas Bures
127900a4d4 Services enabled back to normal 2019-01-12 11:53:07 +01:00
Tomas Bures
6f6258e53b Some cosmetic updates
Fix - GrapesJS 0.14.50 does not work with grapesjs-mjml. GrapesJS version downgraded to 0.14.49
2019-01-12 11:49:22 +01:00
Tomas Bures
0d7f962c86 Fix - subscriber custom data were not listed in correct order in the subcribers list
"Test user" field added to segment rules
Configuration option to automatically share arbitrary namespace based on user role.
2019-01-12 11:21:38 +01:00
Tomas Bures
a1e52c2d7a Fix for #516 2019-01-12 11:20:58 +01:00
Tomas Bures
713dfaa278 Fix 2019-01-12 01:04:12 +01:00
Tomas Bures
ab6c6b7d27 Alignments with IVIS 2019-01-12 00:57:10 +01:00
Tomas Bures
c1731bf09f Some refactoring to aling it more with IVIS and coreui theme. 2019-01-05 23:56:16 +01:00
Tomas Bures
397f85dac4 Harmonization with IVIS 2019-01-04 21:31:01 +01:00
Tomas Bures
428fb9db7b Added an API endpoint that triggers an RSS campaign. 2018-12-29 15:12:42 +01:00
Tomas Bures
e786964411 Some fixes in RSS feed processing.
It is now possible to have hierarchical merge tags (separated by "."). The merge tags are now case sensitive.
Mailtrain allows passing element "mt:entries-json" in the RSS item feed. If this is detected, it parses the json structure and makes it available through RSS_ENTRY_CUSTOM_TAGS. Then it can be used as [RSS_ENTRY_CUSTOM_TAGS.field_quote_text.rendered]
2018-12-29 11:21:25 +01:00
Tomas Bures
6eead89fef Save button in Segments moved below rules (as per #511). 2018-12-29 09:20:13 +01:00
Tomas Bures
557ee83705 Removed [TO_NAME] from the reference (to partially address #510). 2018-12-31 10:27:42 +00:00
Tomas Bures
01720ae128 Bugfix - merge tag was incorrectly validate when only name was entered and Save was clicked immediately after 2018-12-31 10:11:44 +00:00
Tomas Bures
b26f5008da Field setup wizard for new list - addresses 1st line of #510
Bugfixes to address #511
2018-12-31 09:45:59 +00:00
Tomas Bures
de55870561 DB driver changed to mysql to address issue #509. 2018-12-30 23:58:17 +00:00
Tomas Bures
2e847460f4 Added the possibility to use "option" field type outside a group. This is convenient to create just a single checkbox. 2018-12-28 20:54:00 +01:00
Tomas Bures
64af46b685 UI migrated to Bootstrap 4, FontAwesome 5 and CoreUI theme. 2018-12-28 10:57:00 +01:00
Tomas Bures
41d74e3cc7 Work in progress on port to Bootstrap 4 2018-12-28 05:33:07 +01:00
Tomas Bures
3425e2c16a Work in progress on migration to Bootstrap 4 and CoreUI admin theme 2018-12-27 14:42:21 +01:00
Tomas Bures
366bd09f2a Fix for #507 2018-12-26 12:52:00 +01:00
Tomas Bures
9044a4ca0b Updates in locale 2018-12-26 05:33:56 +01:00
Tomas Bures
99bd4ad907 Fixed localization which was broken after 19fe5331d2
x_mailer fixed to be '' in mailtrain.sql
2018-12-26 05:24:52 +01:00
Tomas Bures
cfdcaf65d8 Fixed bug - files/uploaded had wrong owner
Upgrade to React 16
2018-12-26 04:38:02 +01:00
Tomas Bures
dce5ba7464 First steps in integration of IVIS. 2018-12-25 20:13:32 +01:00
Tomas Bures
bf626993f4 Updates sample of HTTPS proxy config for Apache 2018-12-25 13:12:11 +01:00
Tomas Bures
3b1986116f Fixes in VERP handling.
VERP disable header option moved from config to send configurations.
Some additional logging for VERP.
2018-12-26 09:24:46 +00:00
root
a769bfb567 Fixes in VERP server. The VERP server now seems to work fine. 2018-12-25 21:46:52 +00:00
root
8a38133ffc Updates in send configurations. 2018-12-25 19:10:28 +01:00
root
b3e220897d Updates in install scripts 2018-12-25 18:07:51 +01:00
root
7b520387d2 Updates in install scripts 2018-12-25 18:07:03 +01:00
root
3f4044c0a9 Updates in install scripts 2018-12-25 14:51:17 +01:00
root
9ca082e26a Updates in install scripts 2018-12-25 14:48:05 +01:00
root
6cdb06efd0 Updates in install scripts 2018-12-25 14:44:28 +01:00
root
c6f148eef4 Updates in install scripts 2018-12-25 14:43:05 +01:00
root
8d9dcd65b2 Updates in install scripts 2018-12-25 14:40:46 +01:00
root
edc8b54e8f Updates in install scripts 2018-12-25 14:39:28 +01:00
root
691b960ba5 Updates in install scripts 2018-12-25 14:03:17 +01:00
root
b873915b93 Updates in install scripts 2018-12-25 13:40:05 +01:00
root
0aa47cae63 Updates in install scripts 2018-12-25 13:35:17 +01:00
root
e81fd8f5b1 Updates in install scripts 2018-12-25 13:32:53 +01:00
root
9e6dfd6d0b Updates in install scripts 2018-12-25 13:18:28 +01:00
root
439b565cd9 Updates in install scripts 2018-12-25 13:10:25 +01:00
root
067fed6d15 Updates in install scripts 2018-12-25 13:05:48 +01:00
root
d72bd73f35 Updates in install scripts 2018-12-25 13:03:06 +01:00
root
0c2e7e0cc3 Updates in install scripts 2018-12-25 12:55:31 +01:00
root
c18f6f8e24 Updates in install scripts 2018-12-25 12:42:00 +01:00
root
9a34d05d36 Updates in install scripts 2018-12-25 12:40:33 +01:00
root
7b7d5ecf2a Updates in install scripts 2018-12-25 12:29:26 +01:00
root
2bfaf344e7 Updates in install scripts 2018-12-25 10:11:47 +01:00
root
7fd20ba641 Merge branch 'development' of github.com:Mailtrain-org/mailtrain into development
Conflicts:
	README.md
2018-12-25 10:04:09 +01:00
root
c3f5e6a628 Updates to install scripts 2018-12-25 10:14:51 +01:00
root
cac9c3ad3b Updates in install scripts 2018-12-25 11:33:34 +01:00
root
1e81b8bb2f Updates to install scripts 2018-12-25 11:17:08 +01:00
root
e7357c1788 Updates to install scripts 2018-12-25 11:11:01 +01:00
root
7510846fcc Updates in install scripts 2018-12-25 11:03:54 +01:00
root
40f85a957f Updates to install scripts 2018-12-25 10:02:30 +01:00
Cloud User
9204954691 Updates in install scripts 2018-12-25 08:32:14 +00:00
Tomas Bures
a6c7e6327b WiP on install scripts 2018-12-24 15:41:10 +00:00
root
dcc5b18058 WiP on centos install 2018-12-24 08:48:19 +00:00
root
4eaedf0ac3 Updates in the install script (not complete yet) 2018-12-23 22:14:34 +00:00
Tomas Bures
4d50432973 Fixes in the UI. 2018-12-23 21:35:16 +00:00
Tomas Bures
19fe5331d2 Localized strings 2018-12-23 19:46:49 +00:00
Tomas Bures
3d956ec141 Localization of device types 2018-12-23 19:44:29 +00:00
Tomas Bures
83ce716d94 Various fixes. 2018-12-23 19:27:29 +00:00
Tomas Bures
dd9b8b464a Merge branch 'development' of github.com:Mailtrain-org/mailtrain into development 2018-12-21 22:44:51 +00:00
Tomas Bures
63c3383cff Fixes in sendconfiguration list 2018-12-21 23:44:12 +01:00
Tomas Bures
1073d03d1b base.sql and mailtrain.sql updated to fresh v2 DB schema and data 2018-12-21 22:37:30 +00:00
Tomas Bures
01f1495d9a Fixes 2018-12-21 23:08:33 +01:00
Tomas Bures
a5b5f3f1dd Fixes to builtin ZoneMTA settings 2018-12-21 23:04:31 +01:00
Tomas Bures
89a2aa15a4 Updates in the setup 2018-12-21 20:21:03 +01:00
Tomas Bures
5a16d789a0 Fixes in reports. Reports seem to work now 2018-12-21 19:09:18 +01:00
Tomas Bures
0be4af5f6c Cleanup of of submodules 2018-12-16 22:45:53 +01:00
Tomas Bures
1ebf808724 Builting ZoneMTA with the plugins 2018-12-16 22:44:50 +01:00
Tomas Bures
77c64f487d Built-in Zone MTA
Plugin for ZoneMTA for per-message DKIM keys.
2018-12-16 22:35:21 +01:00
Tomas Bures
d103a2cc79 Panels with campaign statistics and some fixes in computation of clicks. 2018-12-16 13:47:08 +01:00
Tomas Bures
ba996d845d Forms preview 2018-12-15 20:09:07 +01:00
Tomas Bures
97bbac8698 Merge branch 'development' of github.com:Mailtrain-org/mailtrain into development 2018-12-15 15:16:23 +01:00
Tomas Bures
cb1fc5b28d Further work on localization 2018-12-15 15:15:48 +01:00
Tomas Bures
f21cea2aec Added error message when someone tries to access mailtrain while it is loading.
This prevents one to perform action on services that are still initializing (e.g. senders, where update of a send configuration causes config reload on the sender process, which may not be started yet and thus responds with error that send method is not defined)
2018-11-24 01:49:01 -05:00
Tomas Bures
fa451fc8da Some more fixes in sources for fields 2018-11-24 06:58:14 +01:00
Tomas Bures
106acd0656 Added sample apache conf.
Fixed fields (source column was not created/deleted when a new column was added/removed)
2018-11-24 06:47:16 +01:00
Tomas Bures
43c6b58793 Completely removed CKEditor 5
Some fixes of bugs from testing in production env.
2018-11-24 00:48:41 -05:00
Tomas Bures
d459f7cfed Updates in default config 2018-11-23 21:38:46 +01:00
Tomas Bures
bd20072455 Save button for template editors 2018-11-22 20:53:44 +01:00
Tomas Bures
3bb235a585 Removed CKEditor 5 because it was of little use and doubled the code size of root.js
Word wrap and Save to code editor.
2018-11-22 15:21:15 +01:00
Tomas Bures
a993c06aaf Updates in translations 2018-11-22 11:32:30 +01:00
Tomas Bures
b058169e12 Added confirmations for unsubscribe, blacklist and remove from blacklist 2018-11-22 11:31:16 +01:00
Tomas Bures
aeaaf116d7 Updates in localization 2018-11-22 09:53:08 +03:00
Tomas Bures
35fdae8f73 Fixes in localization 2018-11-22 09:45:49 +03:00
Tomas Bures
b2850d862d Updates in localization 2018-11-22 00:19:26 +03:00
Tomas Bures
c784d2fbb6 Updates in translation 2018-11-22 00:16:14 +03:00
Tomas Bures
92ca1c0f28 Implemented basic support for GDPR 2018-11-22 00:02:14 +03:00
Tomas Bures
9f9cbc4c2b Updates in localization 2018-11-21 01:46:57 +03:00
Tomas Bures
b37ad9863c Finished support for triggered campaigns. So far only smoke-tested for subscription trigger. 2018-11-21 01:41:10 +03:00
Tomas Bures
4f5b2d10e4 Postponing the feature of having custom forms in multiple languages. I'm not exactly sure about the use-case as usually a mailing list is connected with a particular language that is used in the list. The related form can be in the same language. 2018-11-19 07:09:47 +01:00
Tomas Bures
f7cbcf871d Work in progress on supporting languages in custom forms 2018-11-18 22:53:34 +01:00
Tomas Bures
ec299053ba Updates in locale 2018-11-18 21:34:28 +01:00
Tomas Bures
dc7789c17b Extracted strings and fixes on localization support
Language chooser in the UI
2018-11-18 21:31:22 +01:00
Tomas Bures
9f449c0a2f Updates 2018-11-18 16:44:49 +01:00
Tomas Bures
3ad84a6bd5 Updates 2018-11-18 16:43:11 +01:00
Tomas Bures
a72fe3a991 Updates 2018-11-18 15:59:28 +01:00
Tomas Bures
88eef42b01 Updates 2018-11-18 15:47:43 +01:00
Tomas Bures
6eb50f3b31 Updates 2018-11-18 15:44:54 +01:00
Tomas Bures
545a624163 Updates 2018-11-18 15:43:10 +01:00
Tomas Bures
2edbd67205 New project structure
Beta of extract.js for extracting english locale
2018-11-18 15:38:52 +01:00
Tomas Bures
e18d2b2f84 Added fake language option to the client to ease localization 2018-11-18 07:05:27 +01:00
Tomas Bures
4862d6cac4 Upgrade of modules and webpack.
Support for localization in progress.
2018-11-17 23:26:45 +01:00
Tomas Bures
d8b56fff0d Removed test user preview from RSS campaign as it does not make much sense. 2018-11-17 03:00:44 +01:00
Tomas Bures
bf69e633c4 Added CSV export of subscribers
Fixed some bugs in subscriptions
Updated some packages to avoid warnings about vulnerabilities
Completed RSS feed campaigns
2018-11-17 02:54:23 +01:00
Tomas Bures
8683f8c91e Some bugfixes 2018-11-14 23:21:45 +01:00
Tomas Bures
a3983193d3 Added Test send button to campaigns. 2018-11-14 22:31:13 +01:00
Tomas Bures
2c73c536b7 Send test functionality for templates and campaigns 2018-11-14 22:29:31 +01:00
Tomas Bures
7e52000219 Added MJML/HTML codeeditor with a preview for template design. 2018-11-13 21:35:33 +01:00
Tomas Bures
c7d7b1fe0c GrapesJS support more or less finished. 2018-11-11 01:51:10 +01:00
Tomas Bures
b089993360 mjml4 support moved to a separate package
support for file handling in grapesjs
2018-11-10 19:40:20 +01:00
Tomas Bures
9f467762c0 Included MJML4 2018-11-10 02:05:26 +01:00
Tomas Bures
e2093e22fe Some fixes and optimizations in sandboxes.
Start of a sandbox for GrapeJS
2018-11-06 13:30:50 +01:00
Tomas Bures
02a7275ae4 Added sandboxed CKEditor 4 as a template editor 2018-11-04 11:19:34 +01:00
Tomas Bures
eacdc74c29 CKEditor components replaced by CKEditor 5.
Remains of the sandboxed CKEditor - will be removed, but the version here may be useful for another editor that is prone to XSS (like Summernote).
2018-11-03 21:46:23 +01:00
Tomas Bures
213039c141 Fixes of bugs caused by the public endpoint. 2018-09-29 22:07:24 +02:00
Tomas Bures
efbfa2b366 Checks for dependencies during deletion. 2018-09-29 20:08:49 +02:00
Tomas Bures
0a08088893 Removed obsolete dir
Numeric conversions for all ids coming in as route req params.
Infrastructure for proper error message when dependencies prevent entity deletion.
2018-09-29 13:30:29 +02:00
Tomas Bures
2b57396a5d Added delete button to entity lists. 2018-09-28 14:51:55 +02:00
Tomas Bures
bc818aaee2 Bugfixes 2018-09-27 23:37:50 +02:00
Tomas Bures
1448d9e914 Bugfixes in sending campaigns 2018-09-27 21:32:35 +02:00
Tomas Bures
2d667523a1 Campaign preview as a particular user. 2018-09-27 18:30:23 +02:00
Tomas Bures
5670d21e76 Bugfixing. 2018-09-27 12:34:54 +02:00
Tomas Bures
86efa11994 Before renaming imports to tasks 2018-09-23 22:28:58 +02:00
Tomas Bures
a494dc6482 Added list_unsubscribedisabled option
Added TODO file
2018-09-23 21:36:59 +02:00
Tomas Bures
27021e9b2b Webhooks ported. Not tested. 2018-09-23 21:23:12 +02:00
Tomas Bures
dda95ecdb3 Implementation of archive route. Simplified from v1. Not tested. 2018-09-22 18:12:22 +02:00
Tomas Bures
a9e1700dbe Added router for links. Not tested. 2018-09-22 16:21:19 +02:00
Tomas Bures
92d28daa9e Triggers ported. Not tested yet. 2018-09-22 15:59:05 +02:00
Tomas Bures
907d548e02 Por tof the postfix bounce server. Not tested. 2018-09-18 11:03:36 +02:00
Tomas Bures
63765f7222 Client's public folder renamed to static
Regular campaign sender seems to have most of the code in place. (Not tested.)
2018-09-18 10:30:13 +02:00
Tomas Bures
89eabea0de Fixes in selection of subscribers 2018-09-11 10:07:00 +02:00
Tomas Bures
01d1a903a2 Work in progress on Campaign Status 2018-09-10 20:15:59 +02:00
Tomas Bures
d1fa4f4211 Work on sending campaigns. Campaign status page half-way done, but does not work yet. 2018-09-10 00:55:44 +02:00
Tomas Bures
67d7129f7b Campaign UI and model adjusted to allow sending a campaign to multiple lists 2018-09-02 20:17:42 +02:00
Tomas Bures
130c953d94 Updated packages to remove vulnerabilities reported by npm
Implementation of feedcheck - not tested though
2018-09-02 14:59:02 +02:00
Tomas Bures
d74806dde3 Basic import seems to work 2018-09-01 21:29:10 +02:00
Tomas Bures
16519c5353 Some additions to import UI to cover the basic subscribe and unsubscribe cases. 2018-08-26 15:32:03 +02:00
Tomas Bures
739b9452de UI for basic import and preparation phase of CSV. 2018-08-26 11:46:12 +02:00
Tomas Bures
877e0a857d Merge branch 'master' into development 2018-08-06 21:02:41 +05:30
Tomas Bures
cd798b5af7 Preparation of merge with master 2018-08-06 20:24:51 +05:30
Tomas Bures
6648028270 First part of the UI for file import (upload of csv file to the server) 2018-08-05 10:17:05 +05:30
Tomas Bures
965f30cea7 Editing of triggers seems to work.
Some further fixes.
2018-08-04 15:00:37 +05:30
Tomas Bures
ffc26a4836 Migration and model for triggers.
Not tested.
2018-08-03 21:37:46 +05:30
Tomas Bures
7b46c4b4b0 Editing of campaigns seems to work 2018-08-03 17:05:55 +05:30
Tomas Bures
b1c667d13d Merged migrations into one big v1->v2 which is not incremental, thus it does not need code copy-pasting.
Some fixes.
2018-08-02 17:05:57 +05:30
Tomas Bures
32cad03f4f Improved files to distinguish subtypes (allows multiple different files tabs at a entity)
Attachments via the improved files
Block thumbnails at mosaico templates as a separate files tab
Some fixes

All not tested yet
2018-08-02 15:49:27 +05:30
Tomas Bures
ade0fc87f2 work in progress on campaign edit 2018-08-01 15:30:20 +05:30
Tomas Bures
0e0fb944e3 First attempt on campaign editing. Misses attachments. Untested. 2018-07-31 10:04:28 +05:30
Tomas Bures
ee786bc8ad Fixes in migration of templates and campaigns from Mailtrain ver 1 2018-07-22 15:02:43 +05:30
Tomas Bures
189638364c Added migration for campaigns 2018-07-22 00:01:28 +05:30
Tomas Bures
e85c707973 Some small updates coming from IVIS 2018-07-18 18:41:18 +01:00
Tomas Bures
4943b22a51 Subscription tests now pass. 2018-05-21 19:41:10 +02:00
Tomas Bures
1f5566ca9b Some fixes so that the subscription forms work with the new mailer 2018-05-20 20:27:35 +02:00
Tomas Bures
446d75ce71 Support for custom block thumbnails in Mosaico templates 2018-05-13 22:40:34 +02:00
Tomas Bures
7788b0bc67 Fixed sandbox. Multiple tabs work now.
WiP on selectable mosaico templates.

TODO: Make files always point to trusted URL, such that we don't have to rebase them. They are public anyway. The same goes for mosaico endpoints: /mosaico/templates and /mosaico/img
2018-05-09 04:07:01 +02:00
Tomas Bures
a4ee1534cc WiP on mailers 2018-04-29 18:13:40 +02:00
Tomas Bures
e97415c237 Some bugfixes. The configuration management should be now OK. 2018-04-22 20:29:35 +02:00
Tomas Bures
c12efeb97f Configuration split to lists, send configurations and server config.
This is before testing.
2018-04-22 17:33:43 +02:00
Tomas Bures
4fce4b6f81 WiP updates 2018-04-22 09:00:04 +02:00
Tomas Bures
6706d93bc1 Basic support for Mosaico templates.
TODO:
- Allow choosing a mosaico template in a mosaico-based template
- Integrate the custom mosaico templates with templates (endpoint for retrieving a mosaico template, replacement of URL_BASE and PLACEHOLDER tags
- Implement support for MJML-based Mosaico templates
- Implement support for MJML-based templates
- Implement support for GrapeJS-based templates
2018-04-02 19:05:22 +02:00
Tomas Bures
7b5642e911 Basic support for Mosaico-based email templates. 2018-04-02 11:58:32 +02:00
Tomas Bures
b5cdf57f72 Files can be added to templates and managed in a dedicated "Files" view.
Mosaico integration in progress. The files seem to be working for Mosaico.
2018-03-24 23:55:50 +01:00
Tomas Bures
c85f2d4440 Obsoleting some old files
Transition to SPA-style client
Basis for Mosaico template editor
2018-02-25 20:54:15 +01:00
Tomas Bures
7750232716 Merge remote-tracking branch 'upstream/master' into development 2018-02-24 23:05:01 +01:00
Tomas Bures
ba75623f86 Integrated CKEditor for templates. We might need to move it to a sandbox later to make it secure. 2018-02-24 21:59:00 +01:00
Tomas Bures
508d6b3b2f Beginning of work on templates. 2018-02-13 23:50:13 +01:00
Tomas Bures
47b8d80c22 Fixes in subscriptions. It now passes the tests.
API tests still don't work.
2018-01-28 23:59:05 +01:00
Tomas Bures
e9165838dc Subscription/unsubscription seems to work. 2018-01-27 16:37:14 +01:00
Tomas Bures
d8ee364a4b settings keys in DB converted to camel case
callback-based settings model replaced by async-based settings model
2017-12-30 17:27:24 +01:00
Tomas Bures
6c5c47ac2e Refactored subscriptions. Not even executed. 2017-12-30 12:23:16 +01:00
Tomas Bures
b22a87e712 Work in progress on subscriptions 2017-12-10 21:44:35 +01:00
Tomas Bures
eecb3cd067 Merge pull request #1 from witzig/bures-access
Updates from master
2017-09-27 08:46:03 +02:00
witzig
7a93628cc8 Merge branch 'master' into bures-access
# Conflicts:
#	package.json
2017-09-26 15:29:49 +02:00
Tomas Bures
9203b5cee7 Blacklist functionality
Some API improvements
2017-09-17 16:36:23 +02:00
Tomas Bures
c343e4efd3 Rendering of custom fields in subscription list 2017-08-22 08:15:13 +02:00
Tomas Bures
6f5b50e932 WiP on admin interface for subscribers.
TODO:
- format data based on field info in listDTAjax
- integrate with the whole subscription machinery
2017-08-20 23:50:00 +02:00
Tomas Bures
e6bd9cd943 Added ability to delete newly created invalid rule. 2017-08-19 17:26:44 +02:00
Tomas Bures
42338b0afa Beta of segments
UI is not React-based
Segments functionality extended to allow hierarchical rules, negation and more comparisons (regexp).
Added enumerations (see #217)
2017-08-19 15:58:23 +02:00
Tomas Bures
f3ff89c536 WiP on segments 2017-08-19 15:12:22 +02:00
Tomas Bures
6cc34136f5 WiP on segments. 2017-08-18 21:04:31 +02:00
Tomas Bures
d0a714b3d4 Snapshot before refactoring the rule settings to a separate component 2017-08-17 23:32:49 +02:00
Tomas Bures
6fbbe9a497 Stubs for adding and deleting a rule 2017-08-16 21:41:36 +02:00
Tomas Bures
e5cf2962dc Theming for Segment options 2017-08-16 20:48:51 +02:00
Tomas Bures
baf9f61465 This is snapshot of custom node renderer for react-sortable-tree.
It likely won't be needed however.
2017-08-16 16:10:30 +02:00
Tomas Bures
6a7dab52eb Snapshot of incomplete DnD extension to tree.js.
It however is rather unintuitive how nodes can be put to the end. Dropping this direction in favor of https://github.com/fritz-c/react-sortable-tree
2017-08-16 12:10:00 +02:00
Tomas Bures
0bfb30817b work in progress on segments
some cleanup of models - handling dependencies in delete
2017-08-14 22:53:29 +02:00
Tomas Bures
b23529a75b Some initial files for management of segments 2017-08-13 20:24:17 +02:00
Tomas Bures
e73c0a8b28 Work in progress on subscriptions 2017-08-13 20:11:58 +02:00
Tomas Bures
d9211377dd Options always shown below the group no matter how the list is sorted
XSS protection for tables and trees
2017-08-13 11:32:31 +02:00
Tomas Bures
e230510b72 work in progress on custom fields 2017-08-13 01:11:07 +02:00
Tomas Bures
19f0c1bd97 work in progress on custom fields 2017-08-12 00:41:02 +02:00
Tomas Bures
60d3875c00 Fix of the previous 2017-08-11 18:24:49 +02:00
Tomas Bures
850e563362 Secondary nav is shown only if there are 2 or more items. 2017-08-11 18:22:22 +02:00
Tomas Bures
602364caae Fluid layout
Reworked routing and breadcrumb mechanism. It allows resolved parameters in paths, which allows including names of entities in the breadcrumb.
Secondary navigation which is aware of permissions.
2017-08-11 18:16:44 +02:00
Tomas Bures
86fce404a9 work in progress on custom fields 2017-08-11 08:51:30 +02:00
Tomas Bures
361af18384 Custom forms list and CUD. 2017-07-30 16:22:07 +03:00
Tomas Bures
f6e1938ff9 Lists list and CUD
Custom forms list
Updated DB schema (not yet implemented in the server, which means that most of the server is not broken).
- custom forms are independent of a list
- order and visibility of fields is now in custom_fields
- first_name and last_name has been turned to a regular custom field
2017-07-29 22:42:07 +03:00
Tomas Bures
216fe40b53 Merge branch 'master' of github.com:Mailtrain-org/mailtrain into access 2017-07-27 23:19:48 +03:00
Tomas Bures
34823cf0cf Seeming working (though not very thoroughly tested) granular access control for reports, report templates and namespaces.
Should work both in local auth case and LDAP auth case.
2017-07-27 22:41:25 +03:00
Tomas Bures
89256d62bd WiP on permissions
Table of shares per user
2017-07-27 17:11:22 +03:00
Tomas Bures
89c9615592 WiP on permissions
Doesn't run. This commit is just to backup the changes.
2017-07-26 22:42:05 +03:00
Tomas Bures
5df444f641 Computation of permissions seems to somehow work. 2017-07-25 02:14:17 +03:00
Tomas Bures
e7bdfb7745 Namespace selection for users, reports and report-templates 2017-07-24 14:43:32 +03:00
Tomas Bures
4822a50d0b Share report template functionality 2017-07-24 07:03:32 +03:00
Tomas Bures
e6ad0e239e Typo fix 2017-07-14 07:54:06 +02:00
Tomas Bures
d63eed9ca9 Reports ported to ReactJS and Knex
Note that the interface for the custom JS code inside a report template has changed. It now offers promise-based interface and exposes knex.
2017-07-13 13:27:03 +02:00
Tomas Bures
6d95fa515e CUD operations on reports and report templates seem to work
Execution of reports is TBD
2017-07-11 11:28:44 +02:00
Tomas Bures
38cf3e49c0 DataTables-based dropdown 2017-07-10 17:37:56 +02:00
Tomas Bures
0c860456a6 Fixes in the API doc 2017-07-09 23:39:48 +02:00
Tomas Bures
ad1e4c58f5 Merge branch 'master' of github.com:Mailtrain-org/mailtrain into access
Conflicts:
	views/users/api.hbs
2017-07-09 23:34:03 +02:00
Tomas Bures
3f7b428546 Reports halfway through
Datatable now correctly handles the situation when user is not logged in and access protected resources
2017-07-09 23:16:47 +02:00
Tomas Bures
aba42d94ac Reports halfway through.
Editing report parameters and execution/monitoring of reports is TBD.
2017-07-09 22:38:57 +02:00
Tomas Bures
d4cea46f07 Report templates ported to ReactJS and Knex.
Does not run yet because reports have dependencies on the old report templates.
2017-07-09 15:41:53 +02:00
Tomas Bures
be7da791db LDAP auth seems to work too.
Users completely refactored to ReactJS and Knex
Initial draft of call context passing (for the time being only in users:remove
2017-07-08 21:34:26 +02:00
Tomas Bures
9758b4b104 Local auth seems to work 2017-07-08 18:57:41 +02:00
Tomas Bures
d79bbad575 All about user login
Not runnable at the moment
2017-07-08 15:48:34 +02:00
Tomas Bures
fbb8f5799e React-based /account endpoint for editing a user profile 2017-06-30 16:11:02 +02:00
Tomas Bures
09fe27fe2b Fix - initForm can be used also without arguments if server validation is not needed 2017-06-29 23:36:05 +02:00
Tomas Bures
eb2287f6e9 Release candidate of basic user management - currently only CRUD on users, no permission assignment. 2017-06-29 23:22:33 +02:00
Tomas Bures
e7856bfb73 Merge branch 'master' of github.com:Mailtrain-org/mailtrain into access 2017-06-21 02:16:28 +02:00
Tomas Bures
c81f5544e6 Added support for Datatables
Added support for ajax-based server side validation (useful for validation of emails, duplicate usernames, etc.)
User form more or less ready in the basic version (i.e. without permission management)
2017-06-21 02:14:14 +02:00
Tomas Bures
f776170854 Merge branch 'master' of github.com:Mailtrain-org/mailtrain into access
Conflicts:
	package.json
2017-06-17 10:11:07 +02:00
Tomas Bures
1d1355df34 "Delete" action better with browser history (i.e. back button now works correctly with the Delete dialog). 2017-06-09 12:01:01 +02:00
Tomas Bures
ed5b81b6e6 Small changes in the Model dialog logic to make it more React-like. 2017-06-09 09:07:23 +02:00
Tomas Bures
8e54879539 Release candidate of namespace CRUD 2017-06-09 00:23:03 +02:00
Tomas Bures
5b82d3b540 Edit and create seem to more or less work (including selection of the parent). Delete is pending. 2017-06-07 01:13:15 +02:00
Tomas Bures
61893d77f6 Added data mutator to form processing. This allows conversion of data between server and a component (TreeTable in our case). 2017-06-06 00:24:39 +02:00
Tomas Bures
5e4c86f626 Seems that hierarchical error handling works..
TreeTable component seems to work too.
Edit is half-way through. Create / delete are TBD.
2017-06-05 23:59:08 +02:00
Tomas Bures
79ea9e1897 Work in progress on a React-based error reporting mechanism.
The idea is that REST handlers always fail with throwing an Error (subclass of InteroperableError). The InteroperableError contains type and data field which are JSON-serialized and sent to client. It's up to the client to interpret the error and choose an appropriate way to present it.
2017-06-05 00:52:59 +02:00
Tomas Bures
4504d539c5 Some basic components for building forms. 2017-06-04 13:16:29 +02:00
Tomas Bures
d13fc65ce2 Merge branch 'master' of github.com:Mailtrain-org/mailtrain into access 2017-06-03 07:50:09 +02:00
Tomas Bures
d0824fe724 Updates 2017-06-03 07:49:59 +02:00
Tomas Bures
cda93630ea Merge branch 'master' into access
Conflicts:
	test/e2e/lib/worker-counter.js
2017-05-28 19:35:06 +02:00
Tomas Bures
4f52b571c9 Some bits for ReactJS-based client. 2017-05-28 18:49:00 +02:00
Tomas Bures
115d254baf Merge branch 'master' into access
Conflicts:
	config/default.toml
2017-05-27 10:43:31 +02:00
Tomas Bures
1b73282e90 WiP on namespaces and users 2017-05-15 16:22:06 -04:00
1324 changed files with 113049 additions and 105407 deletions

View file

@ -1 +1,9 @@
node_modules
node_modules/
docs/
Dockerfile
*.md
.git
.gitignore
.gitmodules
docker-compose.yml
docker-compose-local.yml

View file

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

27
.gitignore vendored
View file

@ -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
View file

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

View file

@ -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

View file

@ -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 &lt;[www.iredmail.org](http://www.iredmail.org/)&gt;
* Richard Adleta
* Wes Bos
* Christophe Lombart
* Anselm Hannemann
* Jens Carroll
* Anonymous
* Brett Nelson
* Jason Pelker
* Leif Singer
* Eve Land
* Diana Espino
* Moussa Clarke
* Carl Hauschke

View file

@ -1,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.

View file

@ -1,13 +1,47 @@
FROM node:8.6
# Mutistaged Node.js Build
FROM node:10-alpine as builder
# First install dependencies
COPY ./package.json ./app/
WORKDIR /app/
ENV NODE_ENV production
RUN npm install --no-progress --production && npm install --no-progress passport-ldapjs passport-ldapauth
# Later, copy the app files. That improves development speed as buiding the Docker image will not have
# Install system dependencies
RUN set -ex; \
apk add --update --no-cache \
make gcc g++ git python
# Copy package.json dependencies
COPY server/package.json /app/server/package.json
COPY server/package-lock.json /app/server/package-lock.json
COPY client/package.json /app/client/package.json
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
# Install dependencies in each directory
RUN cd /app/client && npm install
RUN cd /app/shared && npm install --production
RUN cd /app/server && npm install --production
RUN cd /app/zone-mta && npm install --production
# Later, copy the app files. That improves development speed as buiding the Docker image will not have
# to download and install all the NPM dependencies every time there's a change in the source code
COPY . /app
EXPOSE 3000
RUN set -ex; \
cd /app/client && \
npm run build && \
rm -rf node_modules
# Final Image
FROM node:10-alpine
WORKDIR /app/
# Install system dependencies
RUN set -ex; \
apk add --update --no-cache \
pwgen netcat-openbsd bash imagemagick
COPY --from=builder /app/ /app/
EXPOSE 3000 3003 3004
ENTRYPOINT ["bash", "/app/docker-entrypoint.sh"]
CMD ["node", "index.js"]

14
Dockerfile-Develop Normal file
View file

@ -0,0 +1,14 @@
# Final Develop Image
FROM node:10-alpine
WORKDIR /app/
# Install system dependencies
RUN set -ex; \
apk add --update --no-cache \
make gcc g++ git python pwgen netcat-openbsd bash imagemagick
EXPOSE 3000 3003 3004
# Keep container running, so you can access it
CMD tail -f /dev/null

View file

@ -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']);
};

303
README.md
View file

@ -1,62 +1,283 @@
# 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+).
![](http://mailtrain.org/mailtrain.png)
![](https://mailtrain.org/mailtrain.png)
## 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://localhost:3004 -> https://lists.example.com
To deploy Mailtrain with Docker, you need the following two 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
```
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.
### Deployment with Docker and Docker compose (for development)
This setup starts a stack like above, but is tweaked to be used for local development using docker containers.
1. Clone this repository
2. Bring up the development stack
```
docker-compose -f docker-compose-develop.yml up -d
```
3. Connect to a shell inside the container
```
docker-compose exec mailtrain bash
```
4. Run these commands once to install all the node modules and build the client webapp
```
cd /app
bash setup/reinstall-modules.sh
cd /app/client && npm run build && cd /app
5. Start the server for the first time with this command, to generate the `server/config/production.yaml`
```
bash docker-entrypoint.sh
```
### Docker Environment Variables
When using Docker, you can override the default Mailtrain settings via the following environment variables. These variables have to be defined in the docker-compose config
file. You can give them a value directly in the `docker-compose.yml` config file.
Alternatively, you can just declare them there leaving their value empty
(see https://docs.docker.com/compose/environment-variables/#pass-environment-variables-to-containers). In that case, the
value can be provided via a file called `.env` or via environment
variables (e.g. `URL_BASE_TRUSTED=https://mailtrain.domain.com (and more env-vars..) docker-compose -f docker-compose.yml build (or up)`)
| Parameter | Description |
| --------- | ----------- |
| PORT_TRUSTED | sets the trusted port of the instance (default: 3000) |
| PORT_SANDBOX | sets the sandbox port of the instance (default: 3003) |
| PORT_PUBLIC | sets the public port of the instance (default: 3004) |
| URL_BASE_TRUSTED | sets the trusted url of the instance (default: http://localhost:3000) |
| URL_BASE_SANDBOX | sets the sandbox url of the instance (default: http://localhost:3003) |
| URL_BASE_PUBLIC | sets the public url of the instance (default: http://localhost:3004) |
| WWW_HOST | sets the address that the server binds to (default: 0.0.0.0) |
| WWW_PROXY | use if Mailtrain is behind an http reverse proxy (default: false) |
| WWW_SECRET | sets the secret for the express session (default: `$(pwgen -1)`) |
| MONGO_HOST | sets mongo host (default: mongo) |
| WITH_REDIS | enables or disables redis (default: true) |
| REDIS_HOST | sets redis host (default: redis) |
| MYSQL_HOST | sets mysql host (default: mysql) |
| MYSQL_DATABASE | sets mysql database (default: mailtrain) |
| MYSQL_USER | sets mysql user (default: mailtrain) |
| MYSQL_PASSWORD | sets mysql password (default: mailtrain) |
| WITH_LDAP | use if you want to enable LDAP authentication |
| LDAP_HOST | LDAP Host for authentication (default: ldap) |
| LDAP_PORT | LDAP port (default: 389) |
| LDAP_SECURE | use if you want to use LDAP with ldaps protocol |
| LDAP_BIND_USER | User for LDAP connexion |
| LDAP_BIND_PASS | Password for LDAP connexion |
| LDAP_FILTER | LDAP filter |
| LDAP_BASEDN | LDAP base DN |
| LDAP_UIDTAG | LDAP UID tag (e.g. uid/cn/username) |
| WITH_ZONE_MTA | enables or disables builtin Zone-MTA (default: true) |
| POOL_NAME | sets builtin Zone-MTA pool name (default: os.hostname()) |
| LOG_LEVEL | sets log level among `silly|verbose|info|http|warn|error|silent` (default: `info`) |
If you don't want to modify the original `docker-compose.yml`, you can put your overrides to another file (e.g. `docker-compose.override.yml`) -- like the one below.
```
version: '3'
services:
mailtrain:
environment:
- URL_BASE_TRUSTED
- URL_BASE_SANDBOX
- URL_BASE_PUBLIC
```
## 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
View 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
View 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
View file

@ -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">&times;</span></button>';
if (key === 'danger') {
el += '<span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> ';
}
let rows = [];
messages[key].forEach(message => {
message = hbs.handlebars.escapeExpression(message);
message = message.replace(/(\r\n|\n|\r)/gm, '<br>');
rows.push(message);
});
if (rows.length > 1) {
el += '<p>' + rows.join('</p>\n<p>') + '</p>';
} else {
el += rows.join('');
}
el += '</div>';
response.push(el);
});
return new hbs.handlebars.SafeString(
response.join('\n')
);
});
handlebarsHelpers.registerHelpers(hbs.handlebars);
app.use(compression());
app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger(config.www.log, {
stream: {
write: message => {
message = (message || '').toString();
if (message) {
log.info('HTTP', message.replace('\n', '').trim());
}
}
}
}));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use(session({
store: config.redis.enabled ? new RedisStore(config.redis) : false,
secret: config.www.secret,
saveUninitialized: false,
resave: false
}));
app.use(flash());
app.use((req, res, next) => {
req._ = str => _(str);
next();
});
app.use(bodyParser.urlencoded({
extended: true,
limit: config.www.postsize
}));
app.use(bodyParser.text({
limit: config.www.postsize
}));
app.use(bodyParser.json({
limit: config.www.postsize
}));
passport.setup(app);
// make sure flash messages are available
app.use((req, res, next) => {
res.locals.flash = req.flash.bind(req);
res.locals.user = req.user;
res.locals.ldap = {
enabled: config.ldap.enabled,
passwordresetlink: config.ldap.passwordresetlink
};
let menu = [{
title: _('Home'),
url: '/',
selected: true
}];
res.setSelectedMenu = key => {
menu.forEach(item => {
item.selected = (item.key === key);
});
};
res.locals.menu = menu;
tools.updateMenu(res);
res.locals.customStyles = config.customstyles || [];
res.locals.customScripts = config.customscripts || [];
let bodyClasses = [];
if (req.user) {
bodyClasses.push('logged-in user-' + req.user.username);
}
res.locals.bodyClass = bodyClasses.join(' ');
settingsModel.list(['ua_code', 'shoutout'], (err, configItems) => {
if (err) {
return next(err);
}
Object.keys(configItems).forEach(key => {
res.locals[key] = configItems[key];
});
next();
});
});
app.use('/', routes);
app.use('/users', users);
app.use('/lists', lists);
app.use('/templates', templates);
app.use('/campaigns', campaigns);
app.use('/settings', settings);
app.use('/blacklist', blacklist);
app.use('/links', links);
app.use('/fields', fields);
app.use('/forms', forms);
app.use('/segments', segments);
app.use('/triggers', triggers);
app.use('/webhooks', webhooks);
app.use('/subscription', subscription);
app.use('/archive', archive);
app.use('/api', api);
app.use('/editorapi', editorapi);
app.use('/grapejs', grapejs);
app.use('/mosaico', mosaico);
if (config.reports && config.reports.enabled === true) {
app.use('/reports', reports);
app.use('/report-templates', reportsTemplates);
}
// catch 404 and forward to error handler
app.use((req, res, next) => {
let err = new Error(_('Not Found'));
err.status = 404;
next(err);
});
// error handlers
// development error handler
// will print stacktrace
if (app.get('env') === 'development') {
app.use((err, req, res, next) => {
if (!err) {
return next();
}
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: err
});
});
}
// production error handler
// no stacktraces leaked to user
app.use((err, req, res, next) => {
if (!err) {
return next();
}
res.status(err.status || 500);
res.render('error', {
message: err.message,
error: {}
});
});
module.exports = app;

1
client/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/dist

11758
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

87
client/package.json Normal file
View file

@ -0,0 +1,87 @@
{
"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.16",
"@fortawesome/fontawesome-free": "^5.12.0",
"axios": "^0.19.1",
"bootstrap": "^4.4.1",
"clone": "^2.1.2",
"datatables.net": "^1.10.20",
"datatables.net-bs4": "^1.10.20",
"ellipsize": "^0.1.0",
"grapesjs": "^0.14.62",
"grapesjs-mjml": "^0.1.15",
"grapesjs-preset-newsletter": "^0.2.20",
"htmlparser2": "^3.10.1",
"i18next": "^19.0.3",
"i18next-browser-languagedetector": "^4.0.1",
"immutable": "^4.0.0-rc.12",
"juice": "^6.0.0",
"lodash": "^4.17.15",
"mjml4-in-browser": "^1.1.2",
"moment": "^2.24.0",
"moment-timezone": "^0.5.27",
"popper.js": "^1.16.0",
"prop-types": "^15.7.2",
"querystringify": "^2.1.1",
"react": "^16.12.0",
"react-ace": "^8.0.0",
"react-ckeditor-component": "^1.1.0",
"react-color": "^2.18.0",
"react-day-picker": "^7.4.0",
"react-dnd": "^9.5.1",
"react-dnd-html5-backend": "^9.5.1",
"react-dnd-touch-backend": "^9.5.1",
"react-dom": "^16.12.0",
"react-dropzone": "^10.2.1",
"react-google-charts": "^3.0.15",
"react-i18next": "^11.2.7",
"react-router-dom": "^5.1.2",
"react-sortable-tree": "^2.7.1",
"shallowequal": "^1.1.0",
"shortid": "^2.2.15",
"slugify": "^1.3.6",
"url-parse": "^1.4.7"
},
"devDependencies": {
"@babel/cli": "^7.8.0",
"@babel/core": "^7.8.0",
"@babel/plugin-proposal-class-properties": "^7.8.0",
"@babel/plugin-proposal-decorators": "^7.8.0",
"@babel/plugin-proposal-function-bind": "^7.8.0",
"@babel/preset-env": "^7.8.0",
"@babel/preset-react": "^7.8.0",
"babel-loader": "^8.0.6",
"clean-css-cli": "^4.2.1",
"copy-webpack-plugin": "^5.1.1",
"css-loader": "^2.1.0",
"file-loader": "^3.0.1",
"node-sass": "^4.13.0",
"nodemon": "^1.19.4",
"npm-run-all": "^4.1.5",
"sass-loader": "^7.3.1",
"style-loader": "^0.23.1",
"url-loader": "^1.1.2",
"webpack": "^4.41.5",
"webpack-cli": "^3.3.10"
}
}

33
client/src/Home.js Normal file
View file

@ -0,0 +1,33 @@
'use strict';
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withTranslation} from './lib/i18n';
import {requiresAuthenticatedUser} from './lib/page';
import {withComponentMixins} from "./lib/decorator-helpers";
import mailtrainConfig from 'mailtrainConfig';
@withComponentMixins([
withTranslation,
requiresAuthenticatedUser
])
export default class List extends Component {
constructor(props) {
super(props);
}
static propTypes = {
}
render() {
const t = this.props.t;
return (
<div>
<h2>{t('Mailtrain 2 beta')}</h2>
<div>{t('Build') + ' 2020-07-25-1024'}</div>
<p>{mailtrainConfig.shoutout}</p>
</div>
);
}
}

631
client/src/account/API.js Normal file
View file

@ -0,0 +1,631 @@
'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 class="accordion" id="apicalls">
<div class="card">
<div class="card-header">
<button type="button" class="btn btn-link" data-toggle="collapse" data-target="#moresubscribers"><h4>GET /api/subscriptions/:listCid {t('Get subscribers')}</h4></button>
</div>
<div id="moresubscribers" class="collapse" data-parent="#apicalls">
<div class="card-body">
<p>
{t('Get subscribers')}
</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>
</ul>
</li>
</ul>
<p>
<strong>{t('example')}</strong>
</p>
<pre>curl -XGET '{getUrl(`api/subscriptions/P5wKkz-e7?access_token=${accessToken}&limit=10&start=10&search=gmail`)}' </pre>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h4><button type="button" class="btn btn-link" data-toggle="collapse" data-target="#moresubscribe"><h4>POST /api/subscribe/:listCid {t('addSubscription')}</h4></button></h4>
</div>
<div id="moresubscribe" class="collapse" data-parent="#apicalls">
<div class="card-body">
<p>
{t('thisApiCallEitherInsertsANewSubscription')}
</p>
<p>
{t('Query params')}
</p>
<ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
</ul>
<p>
<strong>POST</strong> {t('arguments')}
</p>
<ul>
<li><strong>EMAIL</strong> {t('subscribersEmailAddress')} (<em>{t('required')}</em>)</li>
<li><strong>FIRST_NAME</strong> {t('subscribersFirstName')}</li>
<li><strong>LAST_NAME</strong> {t('subscribersLastName')}</li>
<li><strong>TIMEZONE</strong> {t('subscribersTimezoneEgEuropeTallinnPstOr')}</li>
<li><strong>MERGE_TAG_VALUE</strong> {t('customFieldValueUseYesnoForOptionGroup')}</li>
</ul>
<p>
{t('additionalPostArguments')}:
</p>
<ul>
<li>
<strong>FORCE_SUBSCRIBE</strong> {t('setToYesIfYouWantToMakeSureTheEmailIs')}
by default.
</li>
<li>
<strong>REQUIRE_CONFIRMATION</strong> {t('setToYesIfYouWantToSendConfirmationEmail')}
</li>
</ul>
<p>
<strong>{t('example')}</strong>
</p>
<pre>curl -XPOST '{getUrl(`api/subscribe/B16uVTdW?access_token=${accessToken}`)}' \<br/>
--data 'EMAIL=test@example.com&amp;MERGE_CHECKBOX=yes&amp;REQUIRE_CONFIRMATION=yes'</pre>
<p>
{t('Response example')}:
</p>
<pre>"data": ("id":"TTrw41znK")</pre>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<button type="button" class="btn btn-link" data-toggle="collapse" data-target="#moreunsubscribe"><h4>POST /api/unsubscribe/:listCId {t('removeSubscription')}</h4></button>
</div>
<div id="moreunsubscribe" class="collapse" data-parent="#apicalls">
<div class="card-body">
<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>
<p>
{t('Response example')}:
</p>
<pre>"data": ("id":"TTrw41znK", "unsubscribed":true)</pre>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<button type="button" class="btn btn-link" data-toggle="collapse" data-target="#moredelete"><h4>POST /api/delete/:listCId {t('deleteSubscription')}</h4></button>
</div>
<div id="moredelete" class="collapse" data-parent="#apicalls">
<div class="card-body">
<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>
<p>
{t('Response example')}:
</p>
<pre>"data": ("id":"TTrw41znK", "deleted":true)</pre>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<button type="button" class="btn btn-link" data-toggle="collapse" data-target="#morefield"><h4>POST /api/field/:listId {t('addNewCustomField')}</h4></button>
</div>
<div id="morefield" class="collapse" data-parent="#apicalls">
<div class="card-body">
<p>
{t('thisApiCallCreatesANewCustomFieldForA')}
</p>
<p>
{t('Query params')}
</p>
<ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
</ul>
<p>
<strong>POST</strong> {t('arguments')}
</p>
<ul>
<li><strong>NAME</strong> {t('fieldName')} (<em>{t('required')}</em>)</li>
<li><strong>TYPE</strong> {t('oneOfTheFollowingTypes')}
<ul>
<li><strong>text</strong> &ndash; Text</li>
<li><strong>website</strong> &ndash; Website</li>
<li><strong>longtext</strong> &ndash; Multi-line text</li>
<li><strong>gpg</strong> &ndash; GPG Public Key</li>
<li><strong>number</strong> &ndash; Number</li>
<li><strong>radio</strong> &ndash; Radio Buttons</li>
<li><strong>checkbox</strong> &ndash; Checkboxes</li>
<li><strong>dropdown</strong> &ndash; Drop Down</li>
<li><strong>date-us</strong> &ndash; Date (MM/DD/YYY)</li>
<li><strong>date-eur</strong> &ndash; Date (DD/MM/YYYY)</li>
<li><strong>birthday-us</strong> &ndash; Birthday (MM/DD)</li>
<li><strong>birthday-eur</strong> &ndash; Birthday (DD/MM)</li>
<li><strong>json</strong> &ndash; JSON value for custom rendering</li>
<li><strong>option</strong> &ndash; Option</li>
</ul>
</li>
<li><strong>GROUP</strong> {t('ifTheTypeIsOptionThenYouAlsoNeedTo')}</li>
<li><strong>GROUP_TEMPLATE</strong> {t('templateForTheGroupElementIfNotSetThen')}</li>
<li><strong>VISIBLE</strong> yes/no, {t('ifNotVisibleThenTheSubscriberCanNotView')}</li>
</ul>
<p>
<strong>{t('example')}</strong>
</p>
<pre>curl -XPOST '{getUrl(`api/field/B16uVTdW?access_token=${accessToken}`)}' \<br/>
--data 'NAME=Comment&TYPE=text'</pre>
<p>
{t('Response example')}:
</p>
<pre>"data": ("id":22, "tag":"MERGE_COMMENT")</pre>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<button type="button" class="btn btn-link" data-toggle="collapse" data-target="#moreblacklistget"><h4>GET /api/blacklist/get {t('getListOfBlacklistedEmails')}</h4></button>
</div>
<div id="moreblacklistget" class="collapse" data-parent="#apicalls">
<div class="card-body">
<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>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<button type="button" class="btn btn-link" data-toggle="collapse" data-target="#moreblacklistadd"><h4>POST /api/blacklist/add {t('addEmailToBlacklist')}</h4></button>
</div>
<div id="moreblacklistadd" class="collapse" data-parent="#apicalls">
<div class="card-body">
<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>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<button type="button" class="btn btn-link" data-toggle="collapse" data-target="#moreblacklistdelete"><h4>POST /api/blacklist/delete {t('deleteEmailFromBlacklist')}</h4></button>
</div>
<div id="moreblacklistdelete" class="collapse" data-parent="#apicalls">
<div class="card-body">
<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>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<button type="button" class="btn btn-link" data-toggle="collapse" data-target="#morelistsemail"><h4>GET /api/lists/:email {t('getTheListsAUserHasSubscribedTo')}</h4></button>
</div>
<div id="morelistsemail" class="collapse" data-parent="#apicalls">
<div class="card-body">
<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>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<button type="button" class="btn btn-link" data-toggle="collapse" data-target="#morelistsnamespace"><h4>GET /api/lists-by-namespace/:namespaceId {t('getListsInNamespace')}</h4></button>
</div>
<div id="morelistsnamespace" class="collapse" data-parent="#apicalls">
<div class="card-body">
<p>
{t('retrieveTheListsThatTheNamespaceHas')}
</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-by-namespace/1?access_token=${accessToken}`)}'</pre>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<button type="button" class="btn btn-link" data-toggle="collapse" data-target="#morecreatelist"><h4>POST /api/list {t('createList')}</h4></button>
</div>
<div id="morecreatelist" class="collapse" data-parent="#apicalls">
<div class="card-body">
<p>
{t('createListDescription')}
</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>NAMESPACE</strong> {t('namespace')} (<em>{t('required')}</em>)</li>
<li><strong>UNSUBSCRIPTION_MODE</strong> {t('unsubscription')} (<em>{t('required')}</em>):
<ul>
<li><strong>0</strong> - {t('onestepIeNoEmailWithConfirmationLink')}</li>
<li><strong>1</strong> - {t('onestepWithUnsubscriptionFormIeNoEmail')}</li>
<li><strong>2</strong> - {t('twostepIeAnEmailWithConfirmationLinkWill')}</li>
<li><strong>3</strong> - {t('twostepWithUnsubscriptionFormIeAnEmail')}</li>
<li><strong>4</strong> - {t('manualIeUnsubscriptionHasToBePerformedBy')}</li>
</ul>
</li>
<li><strong>NAME</strong> {t('name')}</li>
<li><strong>DESCRIPTION</strong> {t('description')}</li>
<li><strong>HOMEPAGE</strong> {t('homepage')}</li>
<li><strong>CONTACT_EMAIL</strong> {t('contactEmail')}</li>
<li><strong>DEFAULT_FORM</strong> {t('webAndEmailFormsAndTemplatesUsedIn')}</li>
<li><strong>FIELDWIZARD</strong> {t('representationOfSubscribersName')}:
<ul>
<li><strong>none</strong> - {t('emptyCustomNoFields')}</li>
<li><strong>full_name</strong> - {t('nameOneField')}</li>
<li><strong>first_last_name</strong> - {t('firstNameAndLastNameTwoFields')}</li>
</ul>
</li>
<li><strong>TO_NAME</strong> {t('recipientsNameTemplate')}</li>
<li><strong>LISTUNSUBSCRIBE_DISABLED</strong> {t('doNotSendListUnsubscribeHeaders')}</li>
<li><strong>PUBLIC_SUBSCRIBE</strong> {t('allowPublicUsersToSubscribeThemselves')}</li>
<li><strong>SEND_CONFIGURATION</strong> {t('sendConfiguration-1')}</li>
</ul>
<p>
<strong>{t('example')}</strong>
</p>
<pre>curl -XPOST '{getUrl(`api/list?access_token=${accessToken}`)}' \<br/>
-d 'NAMESPACE=1' \<br/>
-d 'UNSUBSCRIPTION_MODE=0' \<br/>
-d 'NAME=list1' \<br/>
-d 'DESCRIPTION=a very nice list' \<br/>
-d 'CONTACT_EMAIL=test@example.com' \<br/>
-d 'HOMEPAGE=example.com' \<br/>
-d 'FIELDWIZARD=first_last_name' \<br/>
-d 'SEND_CONFIGURATION=1' \<br/>
-d 'PUBLIC_SUBSCRIBE=1' \<br/>
-d 'LISTUNSUBSCRIBE_DISABLED=0'
</pre>
<p>
{t('Response example')}:
</p>
<pre>"data": ("id":"WSGjaP1fY")</pre>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<button type="button" class="btn btn-link" data-toggle="collapse" data-target="#moredeletelist"><h4>DELETE /api/list/:listCId {t('deleteList')}</h4></button>
</div>
<div id="moredeletelist" class="collapse" data-parent="#apicalls">
<div class="card-body">
<p>
{t('deleteListDescription')}
</p>
<p>
{t('Query params')}
</p>
<ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
</ul>
<p>
<strong>{t('example')}</strong>
</p>
<pre>curl -XDELETE '{getUrl(`api/list/WSGjaP1fY?access_token=${accessToken}`)}'</pre>
<p>
{t('Response example')}:
</p>
<pre>{t('Empty object')}</pre>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<button type="button" class="btn btn-link" data-toggle="collapse" data-target="#morerss"><h4>GET /api/rss/fetch/:campaignCid {t('triggerFetchOfACampaign')}</h4></button>
</div>
<div id="morerss" class="collapse" data-parent="#apicalls">
<div class="card-body">
<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>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<button type="button" class="btn btn-link" data-toggle="collapse" data-target="#moretemplate"><h4>POST /api/templates/:templateId/send {t('sendTransactionalEmail')}</h4></button>
</div>
<div id="moretemplate" class="collapse" data-parent="#apicalls">
<div class="card-body">
<p>
{t('sendSingleEmailByTemplateWithGiven')}
</p>
<p>
{t('Query params')}
</p>
<ul>
<li><strong>access_token</strong> {t('yourPersonalAccessToken')}</li>
</ul>
<p>
<strong>POST</strong> {t('arguments')}
</p>
<ul>
<li><strong>EMAIL</strong> {t('emailAddress')} (<em>{t('required')}</em>)</li>
<li><strong>SEND_CONFIGURATION_ID</strong> {t('idOfConfigurationUsedToCreateMailer')}</li>
<li><strong>SUBJECT</strong> {t('subject')}</li>
<li><strong>TAGS</strong> {t('mapOfTemplatesubjectVariablesToReplace')}</li>
<li><strong>ATTACHMENTS</strong> {t('Attachments (format as consumed by nodemailer)')}</li>
</ul>
<p>
<strong>{t('example')}</strong>
</p>
<pre>curl -XPOST '{getUrl(`api/templates/1/send?access_token=${accessToken}`)}' \<br/>
--data 'EMAIL=test@example.com&amp;SUBJECT=Test&amp;TAGS[FOO]=bar&amp;TAGS[TEST]=example'</pre>
</div>
</div>
</div>
</div>
</div>
);
}
}

View 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>
);
}
}
}

View 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
}

View file

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

View 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>
);
}
}

View 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
}

792
client/src/campaigns/CUD.js Normal file
View file

@ -0,0 +1,792 @@
'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, ListsSelectorHelper} from "./helpers";
import {withComponentMixins} from "../lib/decorator-helpers";
import interoperableErrors from "../../../shared/interoperable-errors";
import {Trans} from "react-i18next";
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class CUD extends Component {
constructor(props) {
super(props);
const t = props.t;
this.listsSelectorHelper = new ListsSelectorHelper(this, t, 'lists');
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.CUSTOM]: t('customContent'),
[CampaignSource.CUSTOM_FROM_CAMPAIGN]: t('customContentClonedFromAnotherCampaign'),
[CampaignSource.TEMPLATE]: t('template'),
[CampaignSource.CUSTOM_FROM_TEMPLATE]: t('customContentClonedFromTemplate'),
[CampaignSource.URL]: t('url')
};
const sourceLabelsOrder = [
CampaignSource.CUSTOM, CampaignSource.CUSTOM_FROM_CAMPAIGN , CampaignSource.TEMPLATE, CampaignSource.CUSTOM_FROM_TEMPLATE, CampaignSource.URL
];
this.sourceOptions = [];
for (const key of sourceLabelsOrder) {
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.initForm({
leaveConfirmation: !props.entity || props.entity.permissions.includes('edit'),
onChange: {
send_configuration: ::this.onSendConfigurationChanged
},
onChangeBeforeValidation: ::this.onFormChangeBeforeValidation
});
}
static propTypes = {
action: PropTypes.string.isRequired,
entity: PropTypes.object,
createFromChannel: PropTypes.object,
crateFromCampaign: PropTypes.object,
permissions: PropTypes.object,
type: PropTypes.number
}
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 currentType = this.getFormValue('data_sourceCustom_type');
const isEdit = !!this.props.entity;
this.templateTypes[currentType].afterTagLanguageChange(mutStateData, isEdit);
}
}
this.listsSelectorHelper.onFormChangeBeforeValidation(mutStateData, key, oldValue, newValue);
}
onSendConfigurationChanged(newState, key, oldValue, sendConfigurationId) {
newState.sendConfiguration = null;
// noinspection JSIgnoredPromiseFromCall
this.fetchSendConfiguration(sendConfigurationId);
}
@withAsyncErrorHandler
async fetchSendConfiguration(sendConfigurationId) {
if (sendConfigurationId) {
this.fetchSendConfigurationId = sendConfigurationId;
try {
const result = await axios.get(getUrl(`rest/send-configurations-public/${sendConfigurationId}`));
if (sendConfigurationId === this.fetchSendConfigurationId) {
this.setState({
sendConfiguration: result.data
});
}
} catch (err) {
if (err instanceof interoperableErrors.PermissionDeniedError) {
this.setState({
sendConfiguration: null
});
} else {
throw err;
}
}
}
}
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;
}
}
this.listsSelectorHelper.getFormValuesMutator(data);
// 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'];
}
this.listsSelectorHelper.submitFormValuesMutator(data);
return filterData(data, [
'name', 'description', 'channel', '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 = {};
// This is for CampaignSource.TEMPLATE and CampaignSource.CUSTOM_FROM_TEMPLATE
data.data_sourceTemplate = null;
// This is for CampaignSource.CUSTOM_FROM_CAMPAIGN
data.data_sourceCampaign = null;
// This is for CampaignSource.CUSTOM
data.data_sourceCustom_type = mailtrainConfig.editors[0];
data.data_sourceCustom_tag_language = mailtrainConfig.tagLanguages[0];
data.data_sourceCustom_data = {};
data.data_sourceCustom_html = '';
data.data_sourceCustom_text = '';
Object.assign(data, this.templateTypes[mailtrainConfig.editors[0]].initData());
// This is for CampaignSource.URL
data.data_sourceUrl = '';
// This is for CampaignType.RSS
data.data_feedUrl = '';
if (this.props.createFromChannel) {
const channel = this.props.createFromChannel;
data.channel = channel.id;
for (const overridable of campaignOverridables) {
if (channel[overridable + '_override'] === null) {
data[overridable + '_override'] = '';
data[overridable + '_overriden'] = false;
} else {
data[overridable + '_override'] = channel[overridable + '_override'];
data[overridable + '_overriden'] = true;
}
}
this.listsSelectorHelper.populateFrom(data, channel.lists);
data.type = CampaignType.REGULAR;
data.name = channel.cpg_name;
data.description = channel.cpg_description;
data.send_configuration = channel.send_configuration;
if (channel.send_configuration) {
// noinspection JSIgnoredPromiseFromCall
this.fetchSendConfiguration(channel.send_configuration);
}
data.namespace = channel.namespace;
data.subject = channel.subject;
data.click_tracking_disabled = channel.click_tracking_disabled;
data.open_tracking_disabled = channel.open_tracking_disabled;
data.unsubscribe_url = channel.unsubscribe_url;
data.source = channel.source;
if (channel.source === CampaignSource.CUSTOM_FROM_TEMPLATE) {
data.data_sourceTemplate = channel.data.sourceTemplate;
} else if (channel.source === CampaignSource.CUSTOM_FROM_CAMPAIGN) {
data.data_sourceCampaign = channel.data.sourceCampaign;
} else if (channel.source === CampaignSource.CUSTOM) {
data.data_sourceCustom_type = channel.data.sourceCustom.type;
data.data_sourceCustom_tag_language = channel.data.sourceCustom.tag_language;
data.data_sourceCustom_data = channel.data.sourceCustom.data;
this.templateTypes[channel.data.sourceCustom.type].afterLoad(data);
} else if (channel.source === CampaignSource.URL) {
data.data_sourceUrl = channel.data.sourceUrl
}
} else if (this.props.createFromCampaign) {
const sourceCampaign = this.props.createFromCampaign;
data.channel = sourceCampaign.channel;
for (const overridable of campaignOverridables) {
if (sourceCampaign[overridable + '_override'] === null) {
data[overridable + '_override'] = '';
data[overridable + '_overriden'] = false;
} else {
data[overridable + '_override'] = sourceCampaign[overridable + '_override'];
data[overridable + '_overriden'] = true;
}
}
this.listsSelectorHelper.populateFrom(data, sourceCampaign.lists);
data.type = sourceCampaign.type;
data.name = sourceCampaign.name;
data.description = sourceCampaign.description;
data.send_configuration = sourceCampaign.send_configuration;
// noinspection JSIgnoredPromiseFromCall
this.fetchSendConfiguration(sourceCampaign.send_configuration);
data.namespace = sourceCampaign.namespace;
data.subject = sourceCampaign.subject;
data.click_tracking_disabled = sourceCampaign.click_tracking_disabled;
data.open_tracking_disabled = sourceCampaign.open_tracking_disabled;
data.unsubscribe_url = sourceCampaign.unsubscribe_url;
if (sourceCampaign.source === CampaignSource.CUSTOM_FROM_TEMPLATE || sourceCampaign.source === CampaignSource.CUSTOM_FROM_CAMPAIGN || sourceCampaign.source === CampaignSource.CUSTOM) {
data.source = CampaignSource.CUSTOM_FROM_CAMPAIGN;
data.data_sourceCampaign = sourceCampaign.id;
} else if (sourceCampaign.source === CampaignSource.TEMPLATE) {
data.source = CampaignSource.TEMPLATE;
data.data_sourceTemplate = sourceCampaign.data.sourceTemplate;
} else if (sourceCampaign.source === CampaignSource.URL) {
data.source = CampaignSource.URL;
data.data_sourceUrl = sourceCampaign.data.sourceUrl;
}
} else {
for (const overridable of campaignOverridables) {
data[overridable + '_override'] = '';
data[overridable + '_overriden'] = false;
}
data.channel = null;
data.type = this.props.type;
data.name = '';
data.description = '';
this.listsSelectorHelper.populateFrom(data, [{list: null, segment: null}]);
data.send_configuration = null;
data.namespace = getDefaultNamespace(this.props.permissions);
data.subject = '';
data.click_tracking_disabled = false;
data.open_tracking_disabled = false;
data.unsubscribe_url = '';
data.source = CampaignSource.CUSTOM;
}
this.populateFormValues(data);
}
}
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('tagLanguageMustBeSelected'));
}
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'));
}
}
this.listsSelectorHelper.localValidateFormValues(state)
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) {
const channelId = this.getFormValue('channel');
if (channelId) {
this.navigateToWithFlashMessage(`/channels/${channelId}/campaigns`, 'success', t('campaignUpdated'));
} else {
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) {
const channelId = this.getFormValue('channel');
if (channelId) {
this.navigateToWithFlashMessage(`/channels/${channelId}/campaigns`, 'success', t('campaignCreated'));
} else {
this.navigateToWithFlashMessage(`/campaigns`, 'success', t('campaignCreated'));
}
} else {
this.navigateToWithFlashMessage(`/campaigns/${submitResult}/edit`, 'success', t('campaignCreated'));
}
}
}
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
}
}
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
const canModify = !isEdit || this.props.entity.permissions.includes('edit');
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 channelsColumns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 3, title: t('description') },
{ data: 4, 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 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('tagLanguage')} options={this.customTemplateTagLanguageOptions} disabled={isEdit}/>
{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>
{!canModify &&
<div className="alert alert-warning" role="alert">
<Trans><b>Warning!</b> You do not have necessary permissions to edit this campaign. Any changes that you perform here will be lost.</Trans>
</div>
}
{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')}/>
<TableSelect id="channel" label={t('Channel')} withHeader withClear dropdown dataUrl='rest/channels-with-create-campaign-permission-table' columns={channelsColumns} selectionLabelIndex={1} />
{extraSettings}
<NamespaceSelect/>
<hr/>
{this.listsSelectorHelper.render()}
<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>
{canModify &&
<>
{!isEdit && (sourceTypeKey === CampaignSource.CUSTOM || sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE || sourceTypeKey === CampaignSource.CUSTOM_FROM_CAMPAIGN) ?
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndEditContent')}/>
:
<>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndLeave')} onClickAsync={async () => await this.submitHandler(CUD.AfterSubmitAction.LEAVE)}/>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndGoToStatus')} onClickAsync={async () => await this.submitHandler(CUD.AfterSubmitAction.STATUS)}/>
</>
}
</>
}
{canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/campaigns/${this.props.entity.id}/delete`}/> }
</ButtonRow>
</Form>
</div>
);
}
}

View file

@ -0,0 +1,123 @@
'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";
import interoperableErrors from "../../../shared/interoperable-errors";
import {Trans} from "react-i18next";
import {Table} from "../lib/table";
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class Clone 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.initForm({
leaveConfirmation: false,
});
}
static propTypes = {
}
componentDidMount() {
this.populateFormValues({
sourceCampaign: null
});
}
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(['sourceCampaign', 'value'])) {
state.setIn(['sourceCampaign', 'error'], t('campaignMustBeSelected'));
}
}
@withFormErrorHandlers
async submitHandler(afterSubmitAction) {
const t = this.props.t;
const sourceCampaign = this.getFormValue('sourceCampaign');
this.navigateTo(`/campaigns/clone/${sourceCampaign}`);
}
render() {
const t = this.props.t;
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: 9, title: t('created'), render: data => moment(data).fromNow() },
{ data: 10, title: t('namespace') }
];
return (
<div>
<Title>{t('createCampaign')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<TableSelect id="sourceCampaign" label={t('campaign')} withHeader dropdown dataUrl='rest/campaigns-table' columns={campaignsColumns} order={[4, 'desc']} selectionLabelIndex={1} help={t('selectCampaignToBeCloned')}/>
<ButtonRow>
<Button type="submit" className="btn-primary" icon="chevron-right" label={t('next')}/>
</ButtonRow>
</Form>
</div>
);
}
}

View file

@ -0,0 +1,312 @@
'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";
import {Trans} from "react-i18next";
@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({
leaveConfirmation: props.entity.permissions.includes('edit'),
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_type', 'value']);
this.templateTypes[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('tagLanguageMustBeSelected'));
} 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) {
const channelId = this.props.entity.channel;
if (channelId) {
this.navigateToWithFlashMessage(`/channels/${channelId}/campaigns`, 'success', t('campaignUpdated'));
} else {
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;
const canModify = this.props.entity.permissions.includes('edit');
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>
{!canModify &&
<div className="alert alert-warning" role="alert">
<Trans><b>Warning!</b> You do not have necessary permissions to edit this campaign. Any changes that you perform here will be lost.</Trans>
</div>
}
<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('tagLanguage')} options={this.customTemplateTagLanguageOptions} disabled={true}/>
{customTemplateTypeKey && getTypeForm(this, customTemplateTypeKey, true)}
{customTemplateTypeKey && getEditForm(this, customTemplateTypeKey, 'data_sourceCustom_')}
<ButtonRow>
{canModify &&
<>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndLeave')} onClickAsync={async () => await this.submitHandler(CustomContent.AfterSubmitAction.LEAVE)}/>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndGoToStatus')} onClickAsync={async () => await this.submitHandler(CustomContent.AfterSubmitAction.STATUS)}/>
</>
}
<Button className="btn-success" icon="at" label={t('Test send')} onClickAsync={async () => this.setState({showTestSendModal: true})}/>
</ButtonRow>
</Form>
</div>
);
}
}

View file

@ -0,0 +1,195 @@
'use strict';
import React, {Component} from 'react';
import {withTranslation} from '../lib/i18n';
import {ButtonDropdown, Icon} from '../lib/bootstrap-components';
import {DropdownLink, LinkButton, 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,
channel: PropTypes.object
}
render() {
const t = this.props.t;
const channel = this.props.channel;
const permissions = this.props.permissions;
const createPermitted = permissions.createCampaign && (!channel || channel.permissions.includes('createCampaign'));
const columns = [];
columns.push({
data: 1,
title: t('name'),
actions: data => {
const perms = data[10];
if (perms.includes('view')) {
return [{label: data[1], link: `/campaigns/${data[0]}/status`}];
} else {
return [{label: data[1]}];
}
}
});
columns.push({ data: 2, title: t('id'), render: data => <code>{data}</code>, className: styles.tblCol_id });
columns.push({ data: 3, title: t('description') });
columns.push({ data: 4, title: t('type'), render: data => this.campaignTypeLabels[data] });
if (!channel) {
columns.push({ data: 5, title: t('channel') });
}
columns.push({
data: 6,
title: t('status'),
render: (data, display, rowData) => {
if (data === CampaignStatus.SCHEDULED) {
const scheduled = rowData[7];
if (scheduled && new Date(scheduled) > new Date()) {
return t('sendingScheduled');
} else {
return t('sending');
}
} else {
return this.campaignStatusLabels[data];
}
}
});
columns.push({ data: 9, title: t('created'), render: data => moment(data).fromNow() });
columns.push({ data: 10, title: t('namespace') });
columns.push({
className: styles.tblCol_buttons,
actions: data => {
const actions = [];
const perms = data[11];
const campaignType = data[4];
const campaignSource = data[8];
if (perms.includes('view')) {
actions.push({
label: <Icon icon="envelope" title={t('status')}/>,
link: `/campaigns/${data[0]}/status`
});
}
if (perms.includes('viewStats')) {
actions.push({
label: <Icon icon="signal" title={t('statistics')}/>,
link: `/campaigns/${data[0]}/statistics`
});
}
if (perms.includes('view') || 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;
}
});
let createButton = null;
if (createPermitted) {
if (channel) {
createButton = <LinkButton to={`/channels/${channel.id}/create`} className="btn-primary" icon="plus" label={t('createCampaign')}/>;
} else {
createButton = (
<>
<LinkButton to={`/campaigns/clone`} className="btn-primary" icon="clone" label={t('cloneCampaign')}/>
<ButtonDropdown buttonClassName="btn-primary" menuClassName="dropdown-menu-right" icon="plus" 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>
</>
);
}
}
return (
<div>
{tableRestActionDialogRender(this)}
<Toolbar>
{createButton}
</Toolbar>
<Title>{t('campaigns')}</Title>
{channel ?
<Table ref={node => this.table = node} withHeader dataUrl={`rest/campaigns-by-channel-table/${channel.id}`} columns={columns} order={[5, 'desc']} />
:
<Table ref={node => this.table = node} withHeader dataUrl="rest/campaigns-table" columns={columns} order={[6, 'desc']} />
}
</div>
);
}
}

View 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}&nbsp;({rate}%)
</div>
</div>
</AlignedRow>
);
}
return (
<div>
<Title>{t('campaignStatistics')}</Title>
{renderMetrics('total', t('total'), false)}
{renderMetrics('delivered', t('delivered'))}
{renderMetrics('blacklisted', t('blacklisted'), false)}
{renderMetricsWithProgress('bounced', t('bounced'), 'info')}
{renderMetricsWithProgress('complained', t('complaints'), 'danger')}
{renderMetricsWithProgress('unsubscribed', t('unsubscribed'), 'warning')}
{!entity.open_tracking_disabled && renderMetricsWithProgress('opened', t('opened'), 'success')}
{!entity.click_tracking_disabled && renderMetricsWithProgress('clicks', t('clicked'), 'success')}
<hr/>
<h3>{t('quickReports')}</h3>
<small className="text-muted"><Trans i18nKey="belowYouCanDownloadPremadeReportsRelated">Below, you can download pre-made reports related to this campaign. Each link generates a CSV file that can be viewed in a spreadsheet editor. Custom reports and reports that cover more than one campaign can be created through <Link to="/reports">Reports</Link> functionality of Mailtrain.</Trans></small>
<ul className="list-unstyled my-3">
<li><a href={getUrl(`quick-rpts/open-and-click-counts/${entity.id}`)}>Open and click counts per currently subscribed subscriber</a></li>
</ul>
</div>
);
}
}

View file

@ -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>
);
}
}

View 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>
);
}
}

View 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>
);
}
}

View file

@ -0,0 +1,669 @@
'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, getSandboxUrl, 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 entity = this.props.entity;
const campaignCid = entity.cid;
const [listCid, subscriptionCid] = this.getFormValue('testUser').split(':');
if (entity.type === CampaignType.RSS) {
const result = await axios.post(getUrl('rest/restricted-access-token'), {
method: 'rssPreview',
params: {
campaignCid,
listCid
}
});
const accessToken = result.data;
window.open(getSandboxUrl(`cpgs/rss-preview/${campaignCid}/${listCid}/${subscriptionCid}`, accessToken, {withLocale: true}), '_blank');
} else if (entity.type === CampaignType.REGULAR || entity.type === CampaignType.RSS_ENTRY) {
window.open(getPublicUrl(`archive/${campaignCid}/${listCid}/${subscriptionCid}`, {withLocale: true}), '_blank');
} else {
throw new Error('Preview not supported');
}
} 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 testSendPermitted = entity.permissions.includes('sendToTestUsers');
const sendPermitted = entity.permissions.includes('send');
const dialogs = (
<>
PreviewForTestUserModalDialog
<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})}/>
{testSendPermitted && <Button className="btn-success" label={t('Test send')} onClickAsync={async () => this.setState({showTestSendModal: true})}/>}
</>
);
let sendStatus = null;
if (entity.status === CampaignStatus.IDLE || entity.status === CampaignStatus.PAUSED || (entity.status === CampaignStatus.SCHEDULED && entity.scheduled)) {
sendStatus = (
<AlignedRow label={t('sendStatus')}>
{entity.status === CampaignStatus.SCHEDULED ? t('campaignIsScheduledForDelivery') : t('campaignIsReadyToBeSentOut')}
</AlignedRow>
);
} else if (entity.status === CampaignStatus.PAUSING) {
sendStatus = (
<AlignedRow label={t('sendStatus')}>
{t('Campaign is being paused. Please wait.')}
</AlignedRow>
);
} else if (entity.status === CampaignStatus.SENDING || (entity.status === CampaignStatus.SCHEDULED && !entity.scheduled)) {
sendStatus = (
<AlignedRow label={t('sendStatus')}>
{t('campaignIsBeingSentOut')}
</AlignedRow>
);
} else if (entity.status === CampaignStatus.FINISHED) {
sendStatus = (
<AlignedRow label={t('sendStatus')}>
{sendPermitted ? t('allMessagesSent!HitContinueIfYouYouWant') : t('All messages sent!')}
</AlignedRow>
);
} else if (entity.status === CampaignStatus.INACTIVE) {
sendStatus = (
<AlignedRow label={t('sendStatus')}>
{sendPermitted ? t('yourCampaignIsCurrentlyDisabledClick') : t('Your campaign is currently disabled.')}
</AlignedRow>
);
} else if (entity.status === CampaignStatus.ACTIVE) {
sendStatus = (
<AlignedRow label={t('sendStatus')}>
{t('yourCampaignIsEnabledAndSendingMessages')}
</AlignedRow>
);
}
let content = null;
let sendButtons = null;
if (sendPermitted) {
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>;
}
}
content = (
<Form stateOwner={this}>
{entity.status !== CampaignStatus.SCHEDULED &&
<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>
);
sendButtons = (
<>
{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('Cancel scheduling')} 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`}/>}
</>
);
} else if (entity.status === CampaignStatus.PAUSING) {
sendButtons = (
<>
<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`}/>
</>
);
} else if (entity.status === CampaignStatus.SENDING || (entity.status === CampaignStatus.SCHEDULED && !entity.scheduled)) {
sendButtons = (
<>
<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`}/>
</>
);
} else if (entity.status === CampaignStatus.FINISHED) {
sendButtons = (
<>
<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`}/>
</>
);
} else if (entity.status === CampaignStatus.INACTIVE) {
sendButtons = (
<>
<Button className="btn-primary" icon="play" label={t('enable')} onClickAsync={::this.enableAsync}/>
</>
);
} else if (entity.status === CampaignStatus.ACTIVE) {
sendButtons = (
<>
<Button className="btn-primary" icon="stop" label={t('disable')} onClickAsync={::this.disableAsync}/>
</>
);
}
}
return (
<div>
{dialogs}
{sendStatus}
{content}
<ButtonRow className={campaignsStyles.sendButtonRow}>
{sendButtons}
{testButtons}
</ButtonRow>
</div>
);
}
}
@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,
sendConfigurationNotPermitted: false
};
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() {
const newState = {};
let resp;
resp = await axios.get(getUrl(`rest/campaigns-settings/${this.props.entity.id}`));
newState.entity = resp.data;
try {
resp = await axios.get(getUrl(`rest/send-configurations-public/${newState.entity.send_configuration}`));
newState.sendConfiguration = resp.data;
} catch (err) {
if (err instanceof interoperableErrors.PermissionDeniedError) {
newState.sendConfiguration = null;
newState.sendConfigurationNotPermitted = true;
} else {
throw err;
}
}
this.setState(newState);
}
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 {
if (this.state.sendConfigurationNotPermitted) {
sendSettings = null;
} 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('view')) {
actions.push({
label: <Icon icon="envelope" 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('Sent')}>{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} order={[3, 'desc']}/>
</div>
}
</div>
);
}
}

View file

@ -0,0 +1,350 @@
'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,
template: 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.templateId = props.template.id;
data.listCid = this.getFormValue('listCid');
data.subscriptionCid = this.getFormValue('testUserSubscriptionCid');
data.sendConfigurationId = this.getFormValue('sendConfiguration');
} else if (mode === TestSendModalDialogMode.CAMPAIGN_STATUS || mode === TestSendModalDialogMode.CAMPAIGN_CONTENT) {
data.campaignId = props.campaign.id;
data.subjectPrepend = this.getFormValue('subjectPrepend');
data.subjectAppend = this.getFormValue('subjectAppend');
const target = this.getFormValue('target');
if (target === Target.CAMPAIGN_ONE) {
const [listCid, subscriptionCid] = this.getFormValue('testUserListAndSubscriptionCid').split(':');
data.listCid = listCid;
data.subscriptionCid = subscriptionCid;
} else if (target === Target.LIST_ALL) {
data.listId = this.getFormValue('list');
data.segmentId = this.getFormValue('useSegmentation') ? this.getFormValue('segment') : null;
} else if (target === Target.LIST_ONE) {
data.listCid = this.getFormValue('listCid');
data.subscriptionCid = this.getFormValue('testUserSubscriptionCid');
}
}
await axios.post(getUrl('rest/campaign-test-send'), data);
this.clearFormStatusMessage();
this.enableForm();
await this.hideModal();
} catch (err) {
throw err;
}
} else {
this.showFormValidation();
}
}
localValidateFormValues(state) {
const t = this.props.t;
const props = this.props;
const target = this.getFormValue('target');
const mode = this.props.mode;
state.setIn(['listCid', 'error'], null);
state.setIn(['sendConfiguration', 'error'], null);
state.setIn(['testUserSubscriptionCid', 'error'], null);
state.setIn(['testUserListAndSubscriptionCid', 'error'], null);
state.setIn(['list', 'error'], null);
state.setIn(['segment', 'error'], null);
if (mode === TestSendModalDialogMode.TEMPLATE) {
if (!state.getIn(['listCid', 'value'])) {
state.setIn(['listCid', 'error'], t('listHasToBeSelected'))
}
if (!state.getIn(['sendConfiguration', 'value'])) {
state.setIn(['sendConfiguration', 'error'], t('sendConfigurationHasToBeSelected'))
}
if (!state.getIn(['testUserSubscriptionCid', 'value'])) {
state.setIn(['testUserSubscriptionCid', 'error'], t('subscriptionHasToBeSelected'))
}
}
if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.CAMPAIGN_ONE) {
if (!state.getIn(['testUserListAndSubscriptionCid', 'value'])) {
state.setIn(['testUserListAndSubscriptionCid', 'error'], t('subscriptionHasToBeSelected'))
}
}
if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.LIST_ONE) {
if (!state.getIn(['listCid', 'value'])) {
state.setIn(['listCid', 'error'], t('listHasToBeSelected'))
}
if (!state.getIn(['testUserSubscriptionCid', 'value'])) {
state.setIn(['testUserSubscriptionCid', 'error'], t('subscriptionHasToBeSelected'))
}
}
if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.LIST_ALL) {
if (!state.getIn(['list', 'value'])) {
state.setIn(['list', 'error'], t('listMustBeSelected'));
}
if (state.getIn(['useSegmentation', 'value']) && !state.getIn(['segment', 'value'])) {
state.setIn(['segment', 'error'], t('segmentMustBeSelected'));
}
}
}
render() {
const t = this.props.t;
const props = this.props;
const content = [];
const target = this.getFormValue('target');
const mode = this.props.mode;
if (mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) {
const targetOpts = [
{key: Target.CAMPAIGN_ONE, label: t('Single test user of the campaign')},
{key: Target.CAMPAIGN_ALL, label: t('All test users of the campaign')},
{key: Target.LIST_ONE, label: t('Single test user from a list')},
{key: Target.LIST_ALL, label: t('All test users from a list/segment')}
];
content.push(
<Dropdown key="target" id="target" format="wide" label={t('Select to where you want to send the test')} options={targetOpts}/>
);
}
if (mode === TestSendModalDialogMode.TEMPLATE) {
const listCid = this.getFormValue('listCid');
const testUsersColumns = [
{ data: 1, title: t('subscriptionId'), render: data => <code>{data}</code> },
{ data: 2, title: t('email') }
];
const listsColumns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 3, title: t('subscribers') },
{ data: 4, title: t('description') },
{ data: 5, title: t('namespace') }
];
const sendConfigurationsColumns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 3, title: t('description') },
{ data: 4, title: t('type'), render: data => this.mailerTypes[data].typeName },
{ data: 6, title: t('namespace') }
];
content.push(
<TableSelect key="sendConfiguration" id="sendConfiguration" format="wide" label={t('sendConfiguration')} withHeader dropdown dataUrl='rest/send-configurations-with-send-permission-table' columns={sendConfigurationsColumns} selectionLabelIndex={1} />
);
content.push(
<TableSelect key="listCid" id="listCid" format="wide" label={t('list')} withHeader dropdown dataUrl={`rest/lists-table`} columns={listsColumns} selectionKeyIndex={2} selectionLabelIndex={1} />
);
if (listCid) {
content.push(
<TableSelect key="testUserSubscriptionCid" id="testUserSubscriptionCid" format="wide" label={t('subscription')} withHeader dropdown dataUrl={`rest/subscriptions-test-user-table/${listCid}`} columns={testUsersColumns} selectionKeyIndex={1} selectionLabelIndex={2} />
);
}
}
if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.CAMPAIGN_ONE) {
const testUsersColumns = [
{data: 1, title: t('email')},
{data: 2, title: t('subscriptionId'), render: data => <code>{data}</code>},
{data: 3, title: t('listId'), render: data => <code>{data}</code>},
{data: 4, title: t('list')},
{data: 5, title: t('listNamespace')}
];
content.push(
<TableSelect key="testUserListAndSubscriptionCid" id="testUserListAndSubscriptionCid" format="wide" label={t('subscription')} withHeader dropdown dataUrl={`rest/campaigns-test-users-table/${this.props.campaign.id}`} columns={testUsersColumns} selectionLabelIndex={1} />
);
}
if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.LIST_ONE) {
const listCid = this.getFormValue('listCid');
const listsColumns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 3, title: t('subscribers') },
{ data: 4, title: t('description') },
{ data: 5, title: t('namespace') }
];
const testUsersColumns = [
{ data: 1, title: t('subscriptionId'), render: data => <code>{data}</code> },
{ data: 2, title: t('email') }
];
content.push(
<TableSelect key="listCid" id="listCid" format="wide" label={t('list')} withHeader dropdown dataUrl={`rest/lists-table`} columns={listsColumns} selectionKeyIndex={2} selectionLabelIndex={1} />
);
if (listCid) {
content.push(
<TableSelect key="testUserSubscriptionCid" id="testUserSubscriptionCid" format="wide" label={t('subscription')} withHeader dropdown dataUrl={`rest/subscriptions-test-user-table/${listCid}`} columns={testUsersColumns} selectionKeyIndex={1} selectionLabelIndex={2} />
);
}
}
if ((mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) && target === Target.LIST_ALL) {
const listsColumns = [
{ data: 1, title: t('name') },
{ data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 3, title: t('subscribers') },
{ data: 4, title: t('description') },
{ data: 5, title: t('namespace') }
];
const segmentsColumns = [
{ data: 1, title: t('name') }
];
content.push(
<TableSelect key="list" id="list" format="wide" label={t('list')} withHeader dropdown dataUrl='rest/lists-table' columns={listsColumns} selectionLabelIndex={1} />
);
const selectedList = this.getFormValue('list');
content.push(
<div key="segment">
<CheckBox id="useSegmentation" format="wide" text={t('useAParticularSegment')}/>
{selectedList && this.getFormValue('useSegmentation') &&
<TableSelect id="segment" format="wide" withHeader dropdown dataUrl={`rest/segments-table/${selectedList}`} columns={segmentsColumns} selectionLabelIndex={1} />
}
</div>
);
}
if (mode === TestSendModalDialogMode.CAMPAIGN_CONTENT || mode === TestSendModalDialogMode.CAMPAIGN_STATUS) {
content.push(
<InputField key="subjectPrepend" id="subjectPrepend" format="wide" label={t('Prepend to subject')}/>
);
content.push(
<InputField key="subjectAppend" id="subjectAppend" format="wide" label={t('Append to subject')}/>
);
}
return (
<ModalDialog hidden={!this.props.visible} title={t('sendTestEmail')} onCloseAsync={() => this.hideModal()} buttons={[
{ label: t('send'), className: 'btn-primary', onClickAsync: ::this.performAction },
{ label: t('close'), className: 'btn-danger', onClickAsync: ::this.hideModal }
]}>
<Form stateOwner={this} format="wide">
{content}
</Form>
</ModalDialog>
);
}
}

View file

@ -0,0 +1,284 @@
'use strict';
import {CampaignStatus, CampaignType} from "../../../shared/campaigns";
import campaignsStyles from "./styles.scss";
import {Button} from "../lib/bootstrap-components";
import {CheckBox, Fieldset, TableSelect} from "../lib/form";
import React from "react";
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
};
}
export class ListsSelectorHelper {
constructor(owner, t, id, allowEmpty = false) {
this.owner = owner;
this.t = t;
this.id = id;
this.nextEntryId = 0;
this.allowEmpty = allowEmpty;
this.keyRegex = new RegExp(`^(${id}_[0-9]+_)list$`);
}
getNextEntryId() {
const id = this.nextEntryId;
this.nextEntryId += 1;
return id;
}
getPrefix(lstUid) {
return this.id + '_' + lstUid + '_';
}
onAddListEntry(orderBeforeIdx) {
const owner = this.owner;
const id = this.id;
owner.updateForm(mutState => {
const lsts = mutState.getIn([id, 'value']);
let paramId = 0;
const lstUid = this.getNextEntryId();
const prefix = this.getPrefix(lstUid);
mutState.setIn([prefix + 'list', 'value'], null);
mutState.setIn([prefix + 'segment', 'value'], null);
mutState.setIn([prefix + 'useSegmentation', 'value'], false);
mutState.setIn([id, 'value'], [...lsts.slice(0, orderBeforeIdx), lstUid, ...lsts.slice(orderBeforeIdx)]);
});
}
onRemoveListEntry(lstUid) {
const owner = this.owner;
const id = this.id;
owner.updateForm(mutState => {
const lsts = owner.getFormValue(id);
const prefix = this.getPrefix(lstUid);
mutState.delete(prefix + 'list');
mutState.delete(prefix + 'segment');
mutState.delete(prefix + 'useSegmentation');
mutState.setIn([id, 'value'], lsts.filter(val => val !== lstUid));
});
}
onListEntryMoveUp(orderIdx) {
const owner = this.owner;
const id = this.id;
const lsts = owner.getFormValue(id);
owner.updateFormValue(id, [...lsts.slice(0, orderIdx - 1), lsts[orderIdx], lsts[orderIdx - 1], ...lsts.slice(orderIdx + 1)]);
}
onListEntryMoveDown(orderIdx) {
const owner = this.owner;
const id = this.id;
const lsts = owner.getFormValue(id);
owner.updateFormValue(id, [...lsts.slice(0, orderIdx), lsts[orderIdx + 1], lsts[orderIdx], ...lsts.slice(orderIdx + 2)]);
}
// Public methods
onFormChangeBeforeValidation(mutStateData, key, oldValue, newValue) {
let match;
if (key && (match = key.match(this.keyRegex))) {
const prefix = match[1];
mutStateData.setIn([prefix + 'segment', 'value'], null);
}
}
getFormValuesMutator(data) {
const id = this.id;
const lsts = [];
for (const lst of data[id]) {
const lstUid = this.getNextEntryId();
const prefix = this.getPrefix(lstUid);
data[prefix + 'list'] = lst.list;
data[prefix + 'segment'] = lst.segment;
data[prefix + 'useSegmentation'] = !!lst.segment;
lsts.push(lstUid);
}
data[id] = lsts;
}
submitFormValuesMutator(data) {
const id = this.id;
const lsts = [];
for (const lstUid of data[id]) {
const prefix = this.getPrefix(lstUid);
const useSegmentation = data[prefix + 'useSegmentation'];
lsts.push({
list: data[prefix + 'list'],
segment: useSegmentation ? data[prefix + 'segment'] : null
});
}
data[id] = lsts;
for (const key in data) {
if (key.startsWith('data_') || key.startsWith(id + '_')) {
delete data[key];
}
}
}
populateFrom(data, lists) {
const id = this.id;
const lsts = [];
for (const lst of lists) {
const lstUid = this.getNextEntryId();
const prefix = this.getPrefix(lstUid);
data[prefix + 'list'] = lst.list;
data[prefix + 'segment'] = lst.segment;
data[prefix + 'useSegmentation'] = !!lst.segment;
lsts.push(lstUid);
}
data[id] = lsts;
}
localValidateFormValues(state) {
const id = this.id;
const t = this.t;
for (const lstUid of state.getIn([id, 'value'])) {
const prefix = this.getPrefix(lstUid);
if (!state.getIn([prefix + 'list', 'value'])) {
state.setIn([prefix + 'list', 'error'], t('listMustBeSelected'));
}
if (state.getIn([prefix + 'useSegmentation', 'value']) && !state.getIn([prefix + 'segment', 'value'])) {
state.setIn([prefix + 'segment', 'error'], t('segmentMustBeSelected'));
}
}
}
render() {
const t = this.t;
const owner = this.owner;
const id = this.id;
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 = owner.getFormValue(id) || [];
let lstOrderIdx = 0;
for (const lstUid of lsts) {
const prefix = this.getPrefix(lstUid);
const lstOrderIdxClosure = lstOrderIdx;
const selectedList = owner.getFormValue(prefix + 'list');
lstsEditEntries.push(
<div key={lstUid} className={campaignsStyles.entry + ' ' + campaignsStyles.entryWithButtons}>
<div className={campaignsStyles.entryButtons}>
{(this.allowEmpty || 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} />
<div>
<CheckBox id={prefix + 'useSegmentation'} label={t('segment')} text={t('useAParticularSegment')}/>
{selectedList && owner.getFormValue(prefix + 'useSegmentation') &&
<TableSelect id={prefix + 'segment'} withHeader dropdown dataUrl={`rest/segments-table/${selectedList}`} columns={segmentsColumns} selectionLabelIndex={1} />
}
</div>
</div>
</div>
);
lstOrderIdx += 1;
}
return (
<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>
);
}
}

View file

@ -0,0 +1,202 @@
'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";
import Clone from "./Clone";
function getMenus(t) {
const aggLabels = {
'countries': t('countries'),
'devices': t('devices')
};
const createLabels = {
[CampaignType.REGULAR]: t('createRegularCampaign'),
[CampaignType.RSS]: t('createRssCampaign'),
[CampaignType.TRIGGERED]: t('createTriggeredCampaign')
};
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('view'),
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('view') || 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('view') || 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: createLabels[CampaignType.REGULAR],
panelRender: props => <CampaignsCUD action="create" type={CampaignType.REGULAR} permissions={props.permissions} />
},
'create-rss': {
title: createLabels[CampaignType.RSS],
panelRender: props => <CampaignsCUD action="create" type={CampaignType.RSS} permissions={props.permissions} />
},
'create-triggered': {
title: createLabels[CampaignType.TRIGGERED],
panelRender: props => <CampaignsCUD action="create" type={CampaignType.TRIGGERED} permissions={props.permissions} />
},
'clone': {
title: t('createCampaign'),
link: params => `/campaigns/clone`,
panelRender: props => <Clone />,
children: {
':existingCampaignId([0-9]+)': {
title: resolved => createLabels[resolved.existingCampaign.type],
resolve: {
existingCampaign: params => `rest/campaigns-settings/${params.existingCampaignId}`
},
panelRender: props => <CampaignsCUD action="create" createFromCampaign={props.resolved.existingCampaign} permissions={props.permissions} />
}
}
}
}
}
};
}
export default {
getMenus
}

View 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;
}

View file

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

View file

@ -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>
);
}
}

View file

@ -0,0 +1,36 @@
'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.UPDATED]: t('updated'),
[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
};
}

585
client/src/channels/CUD.js Normal file
View file

@ -0,0 +1,585 @@
'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} from "../../../shared/campaigns";
import moment from 'moment';
import {getMailerTypes} from "../send-configurations/helpers";
import {getCampaignLabels, ListsSelectorHelper} from "../campaigns/helpers";
import {withComponentMixins} from "../lib/decorator-helpers";
import interoperableErrors from "../../../shared/interoperable-errors";
import {Trans} from "react-i18next";
@withComponentMixins([
withTranslation,
withForm,
withErrorHandling,
withPageHelpers,
requiresAuthenticatedUser
])
export default class CUD extends Component {
constructor(props) {
super(props);
const t = props.t;
this.listsSelectorHelper = new ListsSelectorHelper(this, t, 'lists', true);
this.templateTypes = getTemplateTypes(props.t, 'data_sourceCustom_', ResourceType.CAMPAIGN, true);
this.tagLanguages = getTagLanguages(props.t);
this.mailerTypes = getMailerTypes(props.t);
const { campaignTypeLabels } = getCampaignLabels(t);
this.campaignTypeLabels = campaignTypeLabels;
this.sourceLabels = {
[CampaignSource.CUSTOM]: t('customContent'),
[CampaignSource.CUSTOM_FROM_CAMPAIGN]: t('customContentClonedFromAnotherCampaign'),
[CampaignSource.TEMPLATE]: t('template'),
[CampaignSource.CUSTOM_FROM_TEMPLATE]: t('customContentClonedFromTemplate'),
[CampaignSource.URL]: t('url')
};
const sourceLabelsOrder = [
CampaignSource.CUSTOM, CampaignSource.CUSTOM_FROM_CAMPAIGN , CampaignSource.TEMPLATE, CampaignSource.CUSTOM_FROM_TEMPLATE, CampaignSource.URL
];
this.sourceOptions = [];
for (const key of sourceLabelsOrder) {
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.initForm({
onChange: {
send_configuration: ::this.onSendConfigurationChanged
},
onChangeBeforeValidation: ::this.onFormChangeBeforeValidation
});
}
static propTypes = {
action: PropTypes.string.isRequired,
entity: PropTypes.object,
permissions: PropTypes.object,
type: PropTypes.number
}
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 currentType = this.getFormValue('data_sourceCustom_type');
const isEdit = !!this.props.entity;
this.templateTypes[currentType].afterTagLanguageChange(mutStateData, isEdit);
}
}
this.listsSelectorHelper.onFormChangeBeforeValidation(mutStateData, key, oldValue, newValue);
}
onSendConfigurationChanged(newState, key, oldValue, sendConfigurationId) {
newState.sendConfiguration = null;
// noinspection JSIgnoredPromiseFromCall
this.fetchSendConfiguration(sendConfigurationId);
}
@withAsyncErrorHandler
async fetchSendConfiguration(sendConfigurationId) {
if (sendConfigurationId) {
this.fetchSendConfigurationId = sendConfigurationId;
try {
const result = await axios.get(getUrl(`rest/send-configurations-public/${sendConfigurationId}`));
if (sendConfigurationId === this.fetchSendConfigurationId) {
this.setState({
sendConfiguration: result.data
});
}
} catch (err) {
if (err instanceof interoperableErrors.PermissionDeniedError) {
this.setState({
sendConfiguration: null
});
} else {
throw err;
}
}
}
}
populateTemplateDefaults(data) {
// This is for CampaignSource.TEMPLATE and CampaignSource.CUSTOM_FROM_TEMPLATE
data.data_sourceTemplate = null;
// This is for CampaignSource.CUSTOM_FROM_CAMPAIGN
data.data_sourceCampaign = null;
// This is for CampaignSource.CUSTOM
data.data_sourceCustom_type = mailtrainConfig.editors[0];
data.data_sourceCustom_tag_language = mailtrainConfig.tagLanguages[0];
data.data_sourceCustom_data = {};
Object.assign(data, this.templateTypes[mailtrainConfig.editors[0]].initData());
// This is for CampaignSource.URL
data.data_sourceUrl = '';
}
getFormValuesMutator(data) {
this.populateTemplateDefaults(data);
if (data.source === CampaignSource.TEMPLATE || data.source === CampaignSource.CUSTOM_FROM_TEMPLATE) {
data.data_sourceTemplate = data.data.sourceTemplate;
} else if (data.source === CampaignSource.CUSTOM_FROM_CAMPAIGN) {
data.data_sourceCampaign = data.data.sourceCampaign;
} else if (data.source === CampaignSource.CUSTOM) {
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;
this.templateTypes[data.data.sourceCustom.type].afterLoad(data);
} else if (data.source === CampaignSource.URL) {
data.data_sourceUrl = data.data.sourceUrl
}
for (const overridable of campaignOverridables) {
if (data[overridable + '_override'] === null) {
data[overridable + '_override'] = '';
data[overridable + '_overriden'] = false;
} else {
data[overridable + '_overriden'] = true;
}
}
this.listsSelectorHelper.getFormValuesMutator(data);
// 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;
} else if (data.source === CampaignSource.CUSTOM_FROM_CAMPAIGN) {
data.data.sourceCampaign = data.data_sourceCampaign;
} else if (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,
}
} else if (data.source === CampaignSource.URL) {
data.data.sourceUrl = data.data_sourceUrl;
}
for (const overridable of campaignOverridables) {
if (!data[overridable + '_overriden']) {
data[overridable + '_override'] = null;
}
delete data[overridable + '_overriden'];
}
this.listsSelectorHelper.submitFormValuesMutator(data);
return filterData(data, [
'name', 'description', 'namespace', 'cpg_name', 'cpg_description', 'send_configuration',
'subject', 'from_name_override', 'from_email_override', 'reply_to_override',
'data', 'click_tracking_disabled', 'open_tracking_disabled', 'unsubscribe_url',
'source', 'lists'
]);
}
componentDidMount() {
if (this.props.entity) {
this.getFormValuesFromEntity(this.props.entity);
} else {
const data = {};
for (const overridable of campaignOverridables) {
data[overridable + '_override'] = '';
data[overridable + '_overriden'] = false;
}
data.type = this.props.type;
data.name = '';
data.description = '';
data.cpg_name = '';
data.cpg_description = '';
this.listsSelectorHelper.populateFrom(data, []);
data.send_configuration = null;
data.namespace = getDefaultNamespace(this.props.permissions);
data.subject = '';
data.click_tracking_disabled = false;
data.open_tracking_disabled = false;
data.unsubscribe_url = '';
data.source = CampaignSource.CUSTOM;
this.populateTemplateDefaults(data);
this.populateFormValues(data);
}
}
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'));
}
const sourceTypeKey = Number.parseInt(state.getIn(['source', 'value']));
if (sourceTypeKey === CampaignSource.TEMPLATE || sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE) {
if (!state.getIn(['data_sourceTemplate', 'value'])) {
state.setIn(['data_sourceTemplate', 'error'], t('templateMustBeSelected'));
}
} else if (sourceTypeKey === CampaignSource.CUSTOM_FROM_CAMPAIGN) {
if (!state.getIn(['data_sourceCampaign', 'value'])) {
state.setIn(['data_sourceCampaign', 'error'], t('campaignMustBeSelected'));
}
} else if (sourceTypeKey === CampaignSource.CUSTOM) {
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('tagLanguageMustBeSelected'));
}
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'));
}
}
this.listsSelectorHelper.localValidateFormValues(state)
validateNamespace(t, state);
}
async save() {
await this.submitHandler();
}
@withFormErrorHandlers
async submitHandler(submitAndLeave) {
const t = this.props.t;
let sendMethod, url;
if (this.props.entity) {
sendMethod = FormSendMethod.PUT;
url = `rest/channels/${this.props.entity.id}`;
} else {
sendMethod = FormSendMethod.POST;
url = 'rest/channels'
}
this.disableForm();
this.setFormStatusMessage('info', t('saving'));
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url);
if (submitResult) {
if (this.props.entity) {
if (submitAndLeave) {
this.navigateToWithFlashMessage('/channels', 'success', t('channelUpdated'));
} else {
await this.getFormValuesFromURL(`rest/channels/${this.props.entity.id}`);
this.enableForm();
this.setFormStatusMessage('success', t('channelUpdated'));
}
} else {
if (submitAndLeave) {
this.navigateToWithFlashMessage('/channels', 'success', t('channelCreated'));
} else {
this.navigateToWithFlashMessage(`/channels/${submitResult}/edit`, 'success', t('channelCreated'));
}
}
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
}
}
render() {
const t = this.props.t;
const isEdit = !!this.props.entity;
const canModify = !isEdit || this.props.entity.permissions.includes('edit');
const canDelete = isEdit && this.props.entity.permissions.includes('delete');
const sourceTypeKey = Number.parseInt(this.getFormValue('source'));
const campaignTypeKey = this.getFormValue('type');
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 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 (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 (sourceTypeKey === CampaignSource.CUSTOM) {
const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type');
let customTemplateTypeForm = null;
if (customTemplateTypeKey) {
customTemplateTypeForm = getTypeForm(this, customTemplateTypeKey, false);
}
templateEdit = <div>
<Dropdown id="data_sourceCustom_type" label={t('type')} options={this.customTemplateTypeOptions}/>
<Dropdown id="data_sourceCustom_tag_language" label={t('tagLanguage')} options={this.customTemplateTagLanguageOptions}/>
{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/channels/${this.props.entity.id}`}
backUrl={`/channels/${this.props.entity.id}/edit`}
successUrl="/channels"
deletingMsg={t('deletingChannel')}
deletedMsg={t('channelDeleted')}/>
}
<Title>{isEdit ? t('editChannel') : t('createChannel')}</Title>
{!canModify &&
<div className="alert alert-warning" role="alert">
<Trans><b>Warning!</b> You do not have necessary permissions to edit this channel. Any changes that you perform here will be lost.</Trans>
</div>
}
{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')}>
{this.getFormValue('cid')}
</StaticField>
}
<TextArea id="description" label={t('description')}/>
<NamespaceSelect/>
<hr/>
<Fieldset label={t('Campaign defaults')}>
<InputField id="cpg_name" label={t('Campaign name')}/>
<TextArea id="cpg_description" label={t('Campaign description')}/>
</Fieldset>
<hr/>
{this.listsSelectorHelper.render()}
<hr/>
<Fieldset label={t('sendSettings')}>
<TableSelect id="send_configuration" label={t('sendConfiguration')} withHeader withClear 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>
<hr/>
<Fieldset label={t('template')}>
<Dropdown id="source" label={t('contentSource')} options={this.sourceOptions}/>
</Fieldset>
{templateEdit}
<ButtonRow>
{canModify &&
<>
<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={`/channels/${this.props.entity.id}/delete`}/> }
</ButtonRow>
</Form>
</div>
);
}
}

105
client/src/channels/List.js Normal file
View file

@ -0,0 +1,105 @@
'use strict';
import React, {Component} from 'react';
import {withTranslation} from '../lib/i18n';
import {Icon} from '../lib/bootstrap-components';
import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../lib/page';
import {withErrorHandling} from '../lib/error-handling';
import {Table} from '../lib/table';
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;
this.state = {};
tableRestActionDialogInit(this);
}
static propTypes = {
permissions: PropTypes.object
}
render() {
const t = this.props.t;
const permissions = this.props.permissions;
const createPermitted = permissions.createChannel;
const columns = [
{
data: 1,
title: t('name'),
actions: data => {
const perms = data[5];
if (perms.includes('view')) {
return [{label: data[1], link: `/channels/${data[0]}/campaigns`}];
} else {
return [{label: data[1]}];
}
}
},
{ data: 2, title: t('id'), render: data => <code>{data}</code> },
{ data: 3, title: t('description') },
{ data: 4, title: t('namespace') },
{
className: styles.tblCol_buttons,
actions: data => {
const actions = [];
const perms = data[5];
if (perms.includes('view')) {
actions.push({
label: <Icon icon="inbox" title={t('campaigns')}/>,
link: `/channels/${data[0]}/campaigns`
});
}
if (perms.includes('view') || perms.includes('edit')) {
actions.push({
label: <Icon icon="edit" title={t('edit')}/>,
link: `/channels/${data[0]}/edit`
});
}
if (perms.includes('share')) {
actions.push({
label: <Icon icon="share" title={t('share')}/>,
link: `/channels/${data[0]}/share`
});
}
tableAddDeleteButton(actions, this, perms, `rest/channels/${data[0]}`, data[1], t('deletingChannel'), t('channelDeleted'));
return actions;
}
}
];
return (
<div>
{tableRestActionDialogRender(this)}
<Toolbar>
{createPermitted &&
<LinkButton to="/channels/create" className="btn-primary" icon="plus" label={t('createChannel')}/>
}
</Toolbar>
<Title>{t('channels')}</Title>
<Table ref={node => this.table = node} withHeader dataUrl="rest/channels-table" columns={columns} />
</div>
);
}
}

View file

@ -0,0 +1,77 @@
'use strict';
import React from 'react';
import CampaignsList from '../campaigns/List';
import CampaignsCUD from '../campaigns/CUD';
import ChannelsList from './List';
import ChannelsCUD from './CUD';
import Share from '../shares/Share';
import {ellipsizeBreadcrumbLabel} from "../lib/helpers"
import {namespaceCheckPermissions} from "../lib/namespace";
function getMenus(t) {
return {
'channels': {
title: t('channels'),
link: '/channels',
checkPermissions: {
createChannel: {
entityTypeId: 'namespace',
requiredOperations: ['createChannel']
},
createCampaign: {
entityTypeId: 'namespace',
requiredOperations: ['createCampaign']
},
...namespaceCheckPermissions('createChannel'),
},
panelRender: props => <ChannelsList permissions={props.permissions}/>,
children: {
':channelId([0-9]+)': {
title: resolved => t('channelName', {name: ellipsizeBreadcrumbLabel(resolved.channel.name)}),
resolve: {
channel: params => `rest/channels/${params.channelId}`
},
link: params => `/channels/${params.channelId}/campaigns`,
navs: {
campaigns: {
title: t('campaigns'),
link: params => `/channels/${params.channelId}/campaigns`,
visible: resolved => resolved.channel.permissions.includes('view'),
panelRender: props => <CampaignsList channel={props.resolved.channel} permissions={props.permissions} />
},
':action(edit|delete)': {
title: t('edit'),
link: params => `/channels/${params.channelId}/edit`,
visible: resolved => resolved.channel.permissions.includes('view') || resolved.channel.permissions.includes('edit'),
panelRender: props => <ChannelsCUD action={props.match.params.action} entity={props.resolved.channel} permissions={props.permissions} />
},
share: {
title: t('share'),
link: params => `/channels/${params.channelId}/share`,
visible: resolved => resolved.channel.permissions.includes('share'),
panelRender: props => <Share title={t('share')} entity={props.resolved.channel} entityTypeId="channel" />
}
},
children: {
create: {
title: t('createCampaign'),
link: params => `/channels/${params.channelId}/create`,
visible: resolved => resolved.channel.permissions.includes('createCampaign'),
panelRender: props => <CampaignsCUD action="create" createFromChannel={props.resolved.channel} permissions={props.permissions} />,
}
}
},
'create': {
title: t('createChannel'),
panelRender: props => <ChannelsCUD action="create" permissions={props.permissions} />
}
}
}
};
}
export default {
getMenus
}

View file

32
client/src/lib/axios.js Normal file
View 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
}

335
client/src/lib/bootstrap-components.js vendored Normal file
View file

@ -0,0 +1,335 @@
'use strict';
import React, {Component} from 'react';
import {withTranslation} from './i18n';
import PropTypes from 'prop-types';
import {withAsyncErrorHandler, withErrorHandling} from './error-handling';
import {withComponentMixins} from "./decorator-helpers";
@withComponentMixins([
withTranslation,
withErrorHandling
])
export class DismissibleAlert extends Component {
static propTypes = {
severity: PropTypes.string.isRequired,
onCloseAsync: PropTypes.func
}
@withAsyncErrorHandler
onClose() {
if (this.props.onCloseAsync) {
this.props.onCloseAsync();
}
}
render() {
const t = this.props.t;
return (
<div className={`alert alert-${this.props.severity} alert-dismissible`} role="alert">
<button type="button" className="close" aria-label={t('close')} onClick={::this.onClose}><span aria-hidden="true">&times;</span></button>
{this.props.children}
</div>
)
}
}
export class Icon extends Component {
static propTypes = {
icon: PropTypes.string.isRequired,
family: PropTypes.string,
title: PropTypes.string,
className: PropTypes.string
}
static defaultProps = {
family: 'fas'
}
render() {
const props = this.props;
if (props.family === 'fas' || props.family === 'far') {
return <i className={`${props.family} fa-${props.icon} ${props.className || ''}`} title={props.title}></i>;
} else {
console.error(`Icon font family ${props.family} not supported. (icon: ${props.icon}, title: ${props.title})`)
return null;
}
}
}
@withComponentMixins([
withErrorHandling
])
export class Button extends Component {
static propTypes = {
onClickAsync: PropTypes.func,
label: PropTypes.string,
icon: PropTypes.string,
iconTitle: PropTypes.string,
className: PropTypes.string,
title: PropTypes.string,
type: PropTypes.string,
disabled: PropTypes.bool
}
@withAsyncErrorHandler
async onClick(evt) {
if (this.props.onClickAsync) {
evt.preventDefault();
await this.props.onClickAsync(evt);
}
}
render() {
const props = this.props;
let className = 'btn';
if (props.className) {
className = className + ' ' + props.className;
}
let type = props.type || 'button';
let icon;
if (props.icon) {
icon = <Icon icon={props.icon} title={props.iconTitle}/>
}
let iconSpacer;
if (props.icon && props.label) {
iconSpacer = ' ';
}
return (
<button type={type} className={className} onClick={::this.onClick} title={this.props.title} disabled={this.props.disabled}>{icon}{iconSpacer}{props.label}</button>
);
}
}
export class ButtonDropdown extends Component {
static propTypes = {
label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
icon: PropTypes.string,
className: PropTypes.string,
buttonClassName: PropTypes.string,
menuClassName: PropTypes.string
}
render() {
const props = this.props;
const className = 'btn-group' + (props.className ? ' ' + props.className : '');
const buttonClassName = 'btn dropdown-toggle' + (props.buttonClassName ? ' ' + props.buttonClassName : '');
const menuClassName = 'dropdown-menu' + (props.menuClassName ? ' ' + props.menuClassName : '');
let icon;
if (props.icon) {
icon = <Icon icon={props.icon}/>
}
let iconSpacer;
if (props.icon && props.label) {
iconSpacer = ' ';
}
return (
<div className={className}>
<button type="button" className={buttonClassName} data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">{icon}{iconSpacer}{props.label}</button>
<ul className={menuClassName}>{props.children}</ul>
</div>
);
}
}
@withComponentMixins([
withErrorHandling
])
export class ActionLink extends Component {
static propTypes = {
onClickAsync: PropTypes.func,
className: PropTypes.string,
href: PropTypes.string
}
@withAsyncErrorHandler
async onClick(evt) {
if (this.props.onClickAsync) {
evt.preventDefault();
evt.stopPropagation();
await this.props.onClickAsync(evt);
}
}
render() {
const props = this.props;
return (
<a href={props.href || ''} className={props.className} onClick={::this.onClick}>{props.children}</a>
);
}
}
export class DropdownActionLink extends Component {
static propTypes = {
onClickAsync: PropTypes.func,
className: PropTypes.string,
disabled: PropTypes.bool
}
render() {
const props = this.props;
let clsName = "dropdown-item ";
if (props.disabled) {
clsName += "disabled ";
}
clsName += props.className;
return (
<ActionLink className={clsName} onClickAsync={props.onClickAsync}>{props.children}</ActionLink>
);
}
}
export class DropdownDivider extends Component {
static propTypes = {
className: PropTypes.string
}
render() {
const props = this.props;
let className = 'dropdown-divider';
if (props.className) {
className = className + ' ' + props.className;
}
return (
<div className={className}/>
);
}
}
@withComponentMixins([
withTranslation,
withErrorHandling
])
export class ModalDialog extends Component {
constructor(props) {
super(props);
const t = props.t;
}
static propTypes = {
title: PropTypes.string,
onCloseAsync: PropTypes.func,
onButtonClickAsync: PropTypes.func,
buttons: PropTypes.array,
hidden: PropTypes.bool,
className: PropTypes.string
}
/*
this.props.hidden - this is the desired state of the modal
this.hidden - this is the actual state of the modal - this is because there is no public API on Bootstrap modal to know whether the modal is shown or not
*/
componentDidMount() {
const jqModal = jQuery(this.domModal);
jqModal.on('shown.bs.modal', () => jqModal.focus());
jqModal.on('hide.bs.modal', ::this.onHide);
this.hidden = this.props.hidden;
jqModal.modal({
show: !this.props.hidden
});
}
componentDidUpdate() {
if (this.props.hidden != this.hidden) {
const jqModal = jQuery(this.domModal);
this.hidden = this.props.hidden;
jqModal.modal(this.props.hidden ? 'hide' : 'show');
}
}
componentWillUnmount() {
// We discard the modal in a hard way (without hiding it). Thus we have to take care of the backgrop too.
jQuery('.modal-backdrop').remove();
}
onHide(evt) {
// Hide event is emited is both when hidden through user action or through API. We have to let the API
// calls through, otherwise the modal would never hide. The user actions, which change the desired state,
// are capture, converted to onClose callback and prevented. It's up to the parent to decide whether to
// hide the modal or not.
if (!this.props.hidden) {
// noinspection JSIgnoredPromiseFromCall
this.onClose();
evt.preventDefault();
}
}
@withAsyncErrorHandler
async onClose() {
if (this.props.onCloseAsync) {
await this.props.onCloseAsync();
}
}
async onButtonClick(idx) {
const buttonSpec = this.props.buttons[idx];
if (buttonSpec.onClickAsync) {
await buttonSpec.onClickAsync(idx);
}
}
render() {
const props = this.props;
const t = props.t;
let buttons;
if (this.props.buttons) {
buttons = [];
for (let idx = 0; idx < this.props.buttons.length; idx++) {
const buttonSpec = this.props.buttons[idx];
const button = <Button key={idx} label={buttonSpec.label} className={buttonSpec.className} onClickAsync={async () => await this.onButtonClick(idx)} />
buttons.push(button);
}
}
return (
<div
ref={(domElem) => { this.domModal = domElem; }}
className={'modal fade' + (props.className ? ' ' + props.className : '')}
tabIndex="-1" role="dialog" aria-labelledby="myModalLabel">
<div className="modal-dialog" role="document">
<div className="modal-content">
<div className="modal-header">
<h4 className="modal-title">{this.props.title}</h4>
<button type="button" className="close" aria-label={t('close')} onClick={::this.onClose}><span aria-hidden="true">&times;</span></button>
</div>
<div className="modal-body">{this.props.children}</div>
{buttons &&
<div className="modal-footer">
{buttons}
</div>
}
</div>
</div>
</div>
);
}
}

View file

@ -0,0 +1,189 @@
'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, delegateStaticFuns) {
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 addStaticMethodsToClass(clazz) {
if (delegateStaticFuns) {
for (const staticFuncName of delegateStaticFuns) {
if (!clazz[staticFuncName]) {
Object.defineProperty(
clazz,
staticFuncName,
Object.getOwnPropertyDescriptor(TargetClass, staticFuncName)
);
}
}
}
}
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);
}
}
addStaticMethodsToClass(ComponentMixinsOuter);
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);
}
addStaticMethodsToClass(ComponentContextProvider);
return ComponentContextProvider;
}
};
}

View 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);
}
};
}

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

@ -0,0 +1,175 @@
'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>
);
}
}

1783
client/src/lib/form.js Normal file

File diff suppressed because it is too large Load diff

View file

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

96
client/src/lib/i18n.js Normal file
View file

@ -0,0 +1,96 @@
'use strict';
import React from 'react';
import * as ri18n from 'react-i18next';
import {I18nextProvider} 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";
import lang_de_DE_common from "../../../locales/de-DE/common";
import lang_fr_FR_common from "../../../locales/fr-FR/common";
const resourcesCommon = {
'en-US': lang_en_US_common,
'es-ES': lang_es_ES_common,
'pt-BR': lang_pt_BR_common,
'de-DE': lang_de_DE_common,
'fr-FR': lang_fr_FR_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 = ri18n.withTranslation()(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
View 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
View 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>
);
}
}

View file

@ -0,0 +1,52 @@
'use strict';
import React, {Component} from 'react';
import {withTranslation} from './i18n';
import {TreeTableSelect} from './form';
import {withComponentMixins} from "./decorator-helpers";
import mailtrainConfig from 'mailtrainConfig';
@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) {
if (mailtrainConfig.user) {
return {
createEntityInUsersNamespace: {
entityTypeId: 'namespace',
entityId: mailtrainConfig.user.namespace,
requiredOperations: [createOperation]
},
viewUsersNamespace: {
entityTypeId: 'namespace',
entityId: mailtrainConfig.user.namespace,
requiredOperations: ['view']
}
};
} else {
return {};
}
}

View file

@ -0,0 +1,455 @@
'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 entryResolveWithLocal;
if (entry.localResolve) {
entryResolveWithLocal = Object.assign({}, entryResolve, entry.localResolve);
} else {
entryResolveWithLocal = entryResolve;
}
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 || entry.primaryMenuComponent === null) ? entry.primaryMenuComponent : primaryMenuComponent,
secondaryMenuComponent: (entry.secondaryMenuComponent || entry.secondaryMenuComponent === null) ? entry.secondaryMenuComponent : secondaryMenuComponent,
title: entry.title,
link: entry.link,
panelInFullScreen: entry.panelInFullScreen,
insideIframe: entry.insideIframe,
resolve: entryResolveWithLocal,
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 {};
}
});

732
client/src/lib/page.js Normal file
View file

@ -0,0 +1,732 @@
'use strict';
import React, {Component} from "react";
import i18n, {withTranslation} from './i18n';
import PropTypes from "prop-types";
import {withRouter} from "react-router";
import {BrowserRouter as Router, Link, Route, Switch} from "react-router-dom";
import {withErrorHandling} from "./error-handling";
import interoperableErrors from "../../../shared/interoperable-errors";
import {ActionLink, Button, DismissibleAlert, DropdownActionLink, Icon} from "./bootstrap-components";
import mailtrainConfig from "mailtrainConfig";
import styles from "./styles.scss";
import {getRoutes, renderRoute, Resolver, SectionContentContext, withPageHelpers} from "./page-common";
import {getBaseDir} from "./urls";
import {createComponentMixin, withComponentMixins} from "./decorator-helpers";
import {getLang} from "../../../shared/langs";
export { withPageHelpers }
class Breadcrumb extends Component {
constructor(props) {
super(props);
}
static propTypes = {
route: PropTypes.object.isRequired,
params: PropTypes.object.isRequired,
resolved: PropTypes.object.isRequired
}
renderElement(entry, isActive) {
const params = this.props.params;
let title;
if (typeof entry.title === 'function') {
title = entry.title(this.props.resolved, params);
} else {
title = entry.title;
}
if (isActive) {
return <li key={entry.path} className="breadcrumb-item active">{title}</li>;
} else if (entry.externalLink) {
let externalLink;
if (typeof entry.externalLink === 'function') {
externalLink = entry.externalLink(params);
} else {
externalLink = entry.externalLink;
}
return <li key={entry.path} className="breadcrumb-item"><a href={externalLink}>{title}</a></li>;
} else if (entry.link) {
let link;
if (typeof entry.link === 'function') {
link = entry.link(params);
} else {
link = entry.link;
}
return <li key={entry.path} className="breadcrumb-item"><Link to={link}>{title}</Link></li>;
} else {
return <li key={entry.path} className="breadcrumb-item">{title}</li>;
}
}
render() {
const route = this.props.route;
const renderedElems = [...route.parents.map(x => this.renderElement(x)), this.renderElement(route, true)];
return <nav aria-label="breadcrumb"><ol className="breadcrumb">{renderedElems}</ol></nav>;
}
}
class TertiaryNavBar extends Component {
static propTypes = {
route: PropTypes.object.isRequired,
params: PropTypes.object.isRequired,
resolved: PropTypes.object.isRequired,
className: PropTypes.string
}
renderElement(key, entry) {
const params = this.props.params;
let title;
if (typeof entry.title === 'function') {
title = entry.title(this.props.resolved);
} else {
title = entry.title;
}
let liClassName = 'nav-item';
let linkClassName = 'nav-link';
if (entry.active) {
linkClassName += ' active';
}
if (entry.link) {
let link;
if (typeof entry.link === 'function') {
link = entry.link(params);
} else {
link = entry.link;
}
return <li key={key} role="presentation" className={liClassName}><Link className={linkClassName} to={link}>{title}</Link></li>;
} else if (entry.externalLink) {
let externalLink;
if (typeof entry.externalLink === 'function') {
externalLink = entry.externalLink(params);
} else {
externalLink = entry.externalLink;
}
return <li key={key} role="presentation" className={liClassName}><a className={linkClassName} href={externalLink}>{title}</a></li>;
} else {
return <li key={key} role="presentation" className={liClassName}>{title}</li>;
}
}
render() {
const route = this.props.route;
const keys = Object.keys(route.navs);
const renderedElems = [];
for (const key of keys) {
const entry = route.navs[key];
let visible = true;
if (typeof entry.visible === 'function') {
visible = entry.visible(this.props.resolved);
}
if (visible) {
renderedElems.push(this.renderElement(key, entry));
}
}
if (renderedElems.length > 1) {
let className = styles.tertiaryNav + ' nav nav-pills';
if (this.props.className) {
className += ' ' + this.props.className;
}
return <ul className={className}>{renderedElems}</ul>;
} else {
return null;
}
}
}
function getLoadingMessage(t) {
return (
<div className="container-fluid my-3">
{t('loading')}
</div>
);
}
function renderFrameWithContent(t, panelInFullScreen, showSidebar, primaryMenu, secondaryMenu, content) {
if (panelInFullScreen) {
return (
<div key="app" className="app panel-in-fullscreen">
<div key="appBody" className="app-body">
<main key="main" className="main">
{content}
</main>
</div>
</div>
);
} else {
return (
<div key="app" className={"app " + (showSidebar ? 'sidebar-lg-show' : '')}>
<header key="appHeader" className="app-header">
<nav className="navbar navbar-expand-lg navbar-dark bg-dark">
{showSidebar &&
<button className="navbar-toggler sidebar-toggler" data-toggle="sidebar-show" type="button">
<span className="navbar-toggler-icon"/>
</button>
}
<Link className="navbar-brand" to="/"><div><Icon icon="envelope"/> Mailtrain</div></Link>
<button className="navbar-toggler" type="button" data-toggle="collapse" data-target="#mtMainNavbar" aria-controls="navbarColor01" aria-expanded="false" aria-label="Toggle navigation">
<span className="navbar-toggler-icon"/>
</button>
<div className="collapse navbar-collapse" id="mtMainNavbar">
{primaryMenu}
</div>
</nav>
</header>
<div key="appBody" className="app-body">
{showSidebar &&
<div key="sidebar" className="sidebar">
{secondaryMenu}
</div>
}
<main key="main" className="main">
{content}
</main>
</div>
<footer key="appFooter" className="app-footer">
<div className="text-muted">&copy; 2020 <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) => {
// I don't think it is ever needed on replace action, or at least it will be better than not showing the msg,
// and without it this won't work because first it goes to '/' -> '/workspaces' so replacing immediately
if (action === "REPLACE") return;
if (location.state && location.state.preserveFlashMessage) return;
// 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, {preserveFlashMessage: true});
this.setFlashMessage(severity, text);
}
ensureAuthenticated() {
if (!mailtrainConfig.isAuthenticated) {
this.navigateTo('/login?next=' + encodeURIComponent(window.location.pathname));
}
}
registerBeforeUnloadHandlers(handlers) {
this.beforeUnloadListeners.register(handlers);
}
deregisterBeforeUnloadHandlers(handlers) {
this.beforeUnloadListeners.deregister(handlers);
}
errorHandler(error) {
if (error instanceof interoperableErrors.NotLoggedInError) {
if (window.location.pathname !== '/login') { // There may be multiple async requests failing at the same time. So we take the pathname only from the first one.
this.navigateTo('/login?next=' + encodeURIComponent(window.location.pathname));
}
} else if (error.response && error.response.data && error.response.data.message) {
console.error(error);
this.navigateToWithFlashMessage(this.props.root, 'danger', error.response.data.message);
} else {
console.error(error);
this.navigateToWithFlashMessage(this.props.root, 'danger', error.message);
}
return true;
}
async closeFlashMessage() {
this.setState({
flashMessageText: ''
});
}
renderRoute(route) {
const t = this.props.t;
const render = props => {
let flashMessage;
if (this.state.flashMessageText) {
flashMessage = <DismissibleAlert severity={this.state.flashMessageSeverity} onCloseAsync={::this.closeFlashMessage}>{this.state.flashMessageText}</DismissibleAlert>;
}
return renderRoute(
route,
PanelRoute,
() => renderFrameWithContent(t, false, false, null, null, getLoadingMessage(this.props.t)),
flashMessage,
props
);
};
return <Route key={route.path} exact={route.exact} path={route.path} render={render} />
}
render() {
const routes = getRoutes(this.props.structure);
return (
<SectionContentContext.Provider value={this}>
<Switch>{routes.map(x => this.renderRoute(x))}</Switch>
</SectionContentContext.Provider>
);
}
}
@withComponentMixins([
withTranslation
])
export class Section extends Component {
constructor(props) {
super(props);
this.getUserConfirmationHandler = ::this.onGetUserConfirmation;
this.sectionContent = null;
}
static propTypes = {
structure: PropTypes.oneOfType([PropTypes.object, PropTypes.func]).isRequired,
root: PropTypes.string.isRequired
}
onGetUserConfirmation(message, callback) {
this.sectionContent.onNavigationConfirmationDialog(message, callback);
}
render() {
let structure = this.props.structure;
if (typeof structure === 'function') {
structure = structure(this.props.t);
}
return (
<Router basename={getBaseDir()} getUserConfirmation={this.getUserConfirmationHandler}>
<SectionContent wrappedComponentRef={node => this.sectionContent = node} root={this.props.root} structure={structure} />
</Router>
);
}
}
export class Title extends Component {
render() {
return (
<div>
<h2>{this.props.children}</h2>
<hr/>
</div>
);
}
}
export class Toolbar extends Component {
static propTypes = {
className: PropTypes.string,
};
render() {
let className = styles.toolbar + ' ' + styles.buttonRow;
if (this.props.className) {
className += ' ' + this.props.className;
}
return (
<div className={className}>
{this.props.children}
</div>
);
}
}
export class LinkButton extends Component {
static propTypes = {
label: PropTypes.string,
icon: PropTypes.string,
className: PropTypes.string,
to: PropTypes.string
};
render() {
const props = this.props;
return (
<Link to={props.to}><Button label={props.label} icon={props.icon} className={props.className}/></Link>
);
}
}
export class DropdownLink extends Component {
static propTypes = {
to: PropTypes.string,
className: PropTypes.string
}
render() {
const props = this.props;
const clsName = "dropdown-item" + (props.className ? " " + props.className : "")
return (
<Link to={props.to} className={clsName}>{props.children}</Link>
);
}
}
export class NavLink extends Component {
static propTypes = {
to: PropTypes.string,
icon: PropTypes.string,
iconFamily: PropTypes.string,
className: PropTypes.string
}
render() {
const props = this.props;
const clsName = "nav-item" + (props.className ? " " + props.className : "")
let icon;
if (props.icon) {
icon = <><Icon icon={props.icon} family={props.iconFamily}/>{' '}</>;
}
return (
<li className={clsName}><Link to={props.to} className="nav-link">{icon}{props.children}</Link></li>
);
}
}
export class NavActionLink extends Component {
static propTypes = {
onClickAsync: PropTypes.func,
icon: PropTypes.string,
iconFamily: PropTypes.string,
className: PropTypes.string
}
render() {
const props = this.props;
const clsName = "nav-item" + (props.className ? " " + props.className : "")
let icon;
if (props.icon) {
icon = <><Icon icon={props.icon} family={props.iconFamily}/>{' '}</>;
}
return (
<li className={clsName}><ActionLink onClickAsync={this.props.onClickAsync} className="nav-link">{icon}{props.children}</ActionLink></li>
);
}
}
export class NavDropdown extends Component {
static propTypes = {
label: PropTypes.string,
icon: PropTypes.string,
className: PropTypes.string,
menuClassName: PropTypes.string
}
render() {
const props = this.props;
const className = 'nav-item dropdown' + (props.className ? ' ' + props.className : '');
const menuClassName = 'dropdown-menu' + (props.menuClassName ? ' ' + props.menuClassName : '');
return (
<li className={className}>
{props.icon ?
<a href="#" className="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<Icon icon={props.icon}/>{' '}{props.label}
</a>
:
<a href="#" className="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
{props.label}
</a>
}
<ul className={menuClassName}>
{props.children}
</ul>
</li>
);
}
}
export const requiresAuthenticatedUser = createComponentMixin({
deps: [withPageHelpers],
decoratorFn: (TargetClass, InnerClass) => {
class RequiresAuthenticatedUser extends React.Component {
constructor(props) {
super(props);
props.sectionContent.ensureAuthenticated();
}
render() {
return <TargetClass {...this.props}/>
}
}
return {
cls: RequiresAuthenticatedUser
};
}
});
export function getLanguageChooser(t) {
const languageOptions = [];
for (const lng of mailtrainConfig.enabledLanguages) {
const langDesc = getLang(lng);
const label = langDesc.getLabel(t);
languageOptions.push(
<DropdownActionLink key={lng} onClickAsync={async () => i18n.changeLanguage(langDesc.longCode)}>{label}</DropdownActionLink>
)
}
const currentLngCode = getLang(i18n.language).getShortLabel(t);
const languageChooser = (
<NavDropdown menuClassName="dropdown-menu-right" label={currentLngCode}>
{languageOptions}
</NavDropdown>
);
return languageChooser;
}

View file

@ -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);
}

View file

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

View file

@ -0,0 +1,9 @@
'use strict';
export function getTagLanguageFromEntity(entity, entityTypeId) {
if (entityTypeId === 'template') {
return entity.tag_language;
} else if (entityTypeId === 'campaign') {
return entity.data.sourceCustom.tag_language;
}
}

View 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;
}
}
}

View file

@ -0,0 +1,136 @@
'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, this.props.tagLanguage, trustedUrlBase, sandboxUrlBase, publicUrlBase);
this.state = {
source
};
}
static propTypes = {
entityTypeId: PropTypes.string,
entityId: PropTypes.number,
tagLanguage: PropTypes.string,
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, this.props.tagLanguage, trustedUrlBase, sandboxUrlBase, publicUrlBase, true);
return {
source: unbasedSource,
html: preHtml + unbasedSource + postHtml
};
}
async setHeight(methods, params) {
this.node.editorInstance.resize('100%', params);
}
componentDidMount() {
parentRPC.setMethodHandler('exportState', ::this.exportState);
parentRPC.setMethodHandler('setHeight', ::this.setHeight);
}
render() {
const config = {
toolbarGroups: [
{
name: "document",
groups: ["document", "doctools"]
},
{
name: "clipboard",
groups: ["clipboard", "undo"]
},
{name: "styles"},
{
name: "basicstyles",
groups: ["basicstyles", "cleanup"]
},
{
name: "editing",
groups: ["find", "selection", "spellchecker"]
},
{name: "forms"},
{
name: "paragraph",
groups: ["list",
"indent", "blocks", "align", "bidi"]
},
{name: "links"},
{name: "insert"},
{name: "colors"},
{name: "tools"},
{name: "others"},
{
name: "document-mode",
groups: ["mode"]
}
],
removeButtons: 'Underline,Subscript,Superscript,Maximize',
resize_enabled: false,
height: initialHeight
};
return (
<div className={styles.sandbox}>
<CKEditor ref={node => this.node = node}
content={this.state.source}
events={{
change: evt => this.setState({source: evt.editor.getData()}),
}}
config={config}
/>
</div>
);
}
}
export default function() {
parentRPC.init();
ReactDOM.render(
<TranslationRoot>
<UntrustedContentRoot render={props => <CKEditorSandbox {...props} />} />
</TranslationRoot>,
document.getElementById('root')
);
};

View file

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

View file

@ -0,0 +1,114 @@
'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";
import {getTagLanguageFromEntity} from "./sandbox-common";
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,
tagLanguage: getTagLanguageFromEntity(this.props.entity, this.props.entityTypeId),
initialSource: this.props.initialSource
};
const tokenData = {
entityTypeId: this.props.entityTypeId,
entityId: this.props.entity.id
};
return (
<div className={this.state.fullscreen ? styles.editorFullscreen : styles.editor}>
<div className={styles.navbar}>
<div className={styles.navbarLeft}>
{this.state.fullscreen && <img className={styles.logo} src={getTrustedUrl('static/mailtrain-notext.png')}/>}
<div className={styles.title}>{this.props.title}</div>
</div>
<div className={styles.navbarRight}>
{this.props.canSave ? <a className={styles.btn} onClick={this.props.onSave} title={t('save')}><Icon icon="save"/></a> : <span className={styles.btnDisabled}><Icon icon="save"/></span>}
<a className={styles.btn} onClick={this.props.onTestSend} title={t('sendTestEmail-1')}><Icon icon="at"/></a>
<a className={styles.btn} onClick={() => this.props.onShowExport('html', 'HTML')} title={t('showHtml')}><Icon icon="file-code"/></a>
<a className={styles.btn} onClick={::this.toggleFullscreenAsync} title={t('maximizeEditor')}><Icon icon="window-maximize"/></a>
</div>
</div>
<UntrustedContentHost ref={this.contentNodeRefHandler} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="ckeditor/editor" tokenMethod="ckeditor" tokenParams={editorData}/>
</div>
);
}
}

View file

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

View file

@ -0,0 +1,221 @@
'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 'ace-builds/src-noconflict/theme-github';
import 'ace-builds/src-noconflict/ext-searchbox';
import 'ace-builds/src-noconflict/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, this.props.tagLanguage, 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,
tagLanguage: PropTypes.string,
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(), this.props.tagLanguage, trustedUrlBase, sandboxUrlBase, publicUrlBase, true),
source: unbase(this.state.source, this.props.tagLanguage, 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.refreshHandler, refreshTimeout);
}
}
onMessageFromPreview(evt) {
if (evt.data.type === 'scroll') {
this.previewScroll = evt.data.data;
}
}
refresh() {
this.refreshTimeoutId = null;
this.setState({
previewContents: this.getHtml()
});
}
render() {
const previewScript =
'(function() {\n' +
' function reportScroll() { window.parent.postMessage({type: \'scroll\', data: {x: window.scrollX, y: window.scrollY}}, \'*\'); }\n' +
' reportScroll();\n' +
' window.addEventListener(\'scroll\', reportScroll);\n' +
' window.addEventListener(\'load\', function(evt) { window.scrollTo(' + this.previewScroll.x + ',' + this.previewScroll.y +'); });\n' +
'})();\n';
const previewContents = this.state.previewContents.replace(/<\s*head\s*>/i, `<head><script>${previewScript}</script>`);
return (
<div className={styles.sandbox}>
<div className={this.state.preview ? styles.aceEditorWithPreview : styles.aceEditorWithoutPreview}>
<ACEEditorRaw
mode="html"
theme="github"
width="100%"
height="100%"
onChange={this.onCodeChangedHandler}
fontSize={12}
showPrintMargin={false}
value={this.state.source}
tabSize={2}
wrapEnabled={this.state.wrapEnabled}
setOptions={{useWorker: false}} // This disables syntax check because it does not always work well (e.g. in case of JS code in report templates)
/>
</div>
{
this.state.preview &&
<div className={styles.preview}>
<iframe ref={node => this.previewNode = node} src={"data:text/html;charset=utf-8," + encodeURIComponent(previewContents)}></iframe>
</div>
}
</div>
);
}
}
export default function() {
parentRPC.init();
ReactDOM.render(
<TranslationRoot>
<UntrustedContentRoot render={props => <CodeEditorSandbox {...props} />} />
</TranslationRoot>,
document.getElementById('root')
);
};

View file

@ -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')}
];

View file

@ -0,0 +1,111 @@
'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";
import {getTagLanguageFromEntity} from "./sandbox-common";
@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,
tagLanguage: getTagLanguageFromEntity(this.props.entity, this.props.entityTypeId),
initialSource: this.props.initialSource,
sourceType: this.props.sourceType,
initialPreview: this.state.preview,
initialWrap: this.state.wrap
};
const tokenData = {
entityTypeId: this.props.entityTypeId,
entityId: this.props.entity.id
};
return (
<div className={this.state.fullscreen ? styles.editorFullscreen : styles.editor}>
<div className={styles.navbar}>
<div className={styles.navbarLeft}>
{this.state.fullscreen && <img className={styles.logo} src={getTrustedUrl('static/mailtrain-notext.png')}/>}
<div className={styles.title}>{this.props.title}</div>
</div>
<div className={styles.navbarRight}>
<a className={styles.btn} onClick={::this.toggleWrapAsync} title={this.state.wrap ? t('disableWordWrap') : t('enableWordWrap')}>{this.state.wrap ? 'WRAP': 'NOWRAP'}</a>
{this.props.canSave ? <a className={styles.btn} onClick={this.props.onSave} title={t('save')}><Icon icon="save"/></a> : <span className={styles.btnDisabled}><Icon icon="floppy-disk"/></span>}
<a className={styles.btn} onClick={this.props.onTestSend} title={t('sendTestEmail-1')}><Icon icon="at"/></a>
<a className={styles.btn} onClick={() => this.props.onShowExport('html', 'HTML')} title={t('showHtml')}><Icon icon="file-code"/></a>
<a className={styles.btn} onClick={::this.togglePreviewAsync} title={this.state.preview ? t('hidePreview'): t('showPreview')}><Icon icon={this.state.preview ? 'eye-slash': 'eye'}/></a>
<a className={styles.btn} onClick={::this.toggleFullscreenAsync} title={t('maximizeEditor')}><Icon icon="window-maximize"/></a>
</div>
</div>
<UntrustedContentHost ref={this.contentNodeRefHandler} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="codeeditor/editor" tokenMethod="codeeditor" tokenParams={tokenData}/>
</div>
);
}
}

View file

@ -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;
}
}
}

View file

@ -0,0 +1,636 @@
'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,
tagLanguage: PropTypes.string,
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(), this.props.tagLanguage, trustedUrlBase, sandboxUrlBase, publicUrlBase, true);
const style = unbase(editor.getCss(), this.props.tagLanguage, 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, this.props.tagLanguage, 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, this.props.tagLanguage, trustedUrlBase, sandboxUrlBase, publicUrlBase) : defaultSource;
config.style = props.initialStyle ? base(props.initialStyle, this.props.tagLanguage, trustedUrlBase, sandboxUrlBase, publicUrlBase) : defaultStyle;
config.plugins.push('mailtrain-remove-buttons');
this.editor = grapesjs.init(config);
}
render() {
return (
<div>
<div ref={node => this.canvasNode = node}/>
</div>
);
}
}
export default function() {
parentRPC.init();
ReactDOM.render(
<TranslationRoot>
<UntrustedContentRoot render={props => <GrapesJSSandbox {...props} />} />
</TranslationRoot>,
document.getElementById('root')
);
};

View file

@ -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')}
];

View file

@ -0,0 +1,91 @@
'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";
import {getTagLanguageFromEntity} from "./sandbox-common";
@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,
tagLanguage: getTagLanguageFromEntity(this.props.entity, this.props.entityTypeId),
initialSource: this.props.initialSource,
initialStyle: this.props.initialStyle,
sourceType: this.props.sourceType
};
const tokenData = {
entityTypeId: this.props.entityTypeId,
entityId: this.props.entity.id
};
return (
<div className={this.state.fullscreen ? styles.editorFullscreen : styles.editor}>
<div className={styles.navbar}>
<div className={styles.navbarLeft}>
{this.state.fullscreen && <img className={styles.logo} src={getTrustedUrl('static/mailtrain-notext.png')}/>}
<div className={styles.title}>{this.props.title}</div>
</div>
<div className={styles.navbarRight}>
{this.props.canSave ? <a className={styles.btn} onClick={this.props.onSave} title={t('save')}><Icon icon="save"/></a> : <span className={styles.btnDisabled}><Icon icon="save"/></span>}
<a className={styles.btn} onClick={this.props.onTestSend} title={t('sendTestEmail-1')}><Icon icon="at"/></a>
<a className={styles.btn} onClick={() => this.props.onShowExport('html', 'HTML')} title={t('showHtml')}><Icon icon="file-code"/></a>
{this.props.sourceType === GrapesJSSourceType.MJML && <a className={styles.btn} onClick={() => this.props.onShowExport('mjml', 'MJML')} title={t('showMjml')}>MJML</a>}
<a className={styles.btn} onClick={::this.toggleFullscreenAsync} title={t('maximizeEditor')}><Icon icon="window-maximize"/></a>
</div>
</div>
<UntrustedContentHost ref={this.contentNodeRefHandler} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="grapesjs/editor" tokenMethod="grapesjs" tokenParams={tokenData}/>
</div>
);
}
}

View file

@ -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;
}

View file

@ -0,0 +1,159 @@
'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,
tagLanguage: PropTypes.string,
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, this.props.tagLanguage, trustedUrlBase, sandboxUrlBase, publicUrlBase, true),
model: unbase(this.viewModel.exportJSON(), this.props.tagLanguage, trustedUrlBase, sandboxUrlBase, publicUrlBase),
metadata: unbase(this.viewModel.exportMetadata(), this.props.tagLanguage, trustedUrlBase, sandboxUrlBase, publicUrlBase)
};
}
componentDidMount() {
parentRPC.setMethodHandler('exportState', ::this.exportState);
if (!Mosaico.isCompatible()) {
alert('Update your browser!');
return;
}
const plugins = [...window.mosaicoPlugins];
plugins.push(viewModel => {
this.viewModel = viewModel;
});
// (Custom) HTML postRenderers
plugins.push(viewModel => {
viewModel.originalExportHTML = viewModel.exportHTML;
viewModel.exportHTML = () => {
let html = viewModel.originalExportHTML();
// Chrome workaround begin -----------------------------------------------------------------------------------
// Chrome v. 74 (and likely other versions too) has problem with how KO sets data during export.
// As the result, the images that have been in the template from previous editing (i.e. before page refresh)
// get lost. The code below refreshes the KO binding, thus effectively reloading the images.
const isChrome = !!window.chrome && (!!window.chrome.webstore || !!window.chrome.runtime);
if (isChrome) {
ko.cleanNode(document.body);
ko.applyBindings(viewModel, document.body);
}
// Chrome workaround end -------------------------------------------------------------------------------------
for (const portRender of window.mosaicoHTMLPostRenderers) {
html = postRender(html);
}
return html;
};
});
// Custom convertedUrl (https://github.com/voidlabs/mosaico/blob/a359e263f1af5cf05e2c2d56c771732f2ef6c8c6/src/js/app.js#L42)
// which does not complain about mismatch of domains between TRUSTED and PUBLIC
plugins.push(viewModel => {
ko.bindingHandlers.wysiwygSrc.convertedUrl = (src, method, width, height) => getTrustedUrl(`mosaico/img?src=${encodeURIComponent(src)}&method=${encodeURIComponent(method)}&params=${width},${height}`);
});
plugins.unshift(vm => {
// This is an override of the default paths in Mosaico
vm.logoPath = getTrustedUrl('static/mosaico/rs/img/mosaico32.png');
vm.logoUrl = '#';
});
const config = {
imgProcessorBackend: getTrustedUrl('mosaico/img'),
emailProcessorBackend: getSandboxUrl('mosaico/dl'),
fileuploadConfig: {
url: getSandboxUrl(`mosaico/upload/${this.props.entityTypeId}/${this.props.entityId}`)
},
strings: window.mosaicoLanguageStrings
};
const trustedUrlBase = getTrustedUrl();
const sandboxUrlBase = getSandboxUrl();
const publicUrlBase = getPublicUrl();
const metadata = this.props.initialMetadata && JSON.parse(base(this.props.initialMetadata, this.props.tagLanguage, trustedUrlBase, sandboxUrlBase, publicUrlBase));
const model = this.props.initialModel && JSON.parse(base(this.props.initialModel, this.props.tagLanguage, trustedUrlBase, sandboxUrlBase, publicUrlBase));
const template = this.props.templateId ? getSandboxUrl(`mosaico/templates/${this.props.templateId}/index.html`) : this.props.templatePath;
const allPlugins = plugins.concat(window.mosaicoPlugins);
Mosaico.start(config, template, metadata, model, allPlugins);
}
render() {
return <div/>;
}
}
export default function() {
parentRPC.init();
ReactDOM.render(
<TranslationRoot>
<UntrustedContentRoot render={props => <MosaicoSandbox {...props} />} />
</TranslationRoot>,
document.getElementById('root')
);
};

View file

@ -0,0 +1,92 @@
'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";
import {getTagLanguageFromEntity} from "./sandbox-common";
@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,
tagLanguage: getTagLanguageFromEntity(this.props.entity, this.props.entityTypeId),
templateId: this.props.templateId,
templatePath: this.props.templatePath,
initialModel: this.props.initialModel,
initialMetadata: this.props.initialMetadata
};
const tokenData = {
entityTypeId: this.props.entityTypeId,
entityId: this.props.entity.id
};
return (
<div className={this.state.fullscreen ? styles.editorFullscreen : styles.editor}>
<div className={styles.navbar}>
<div className={styles.navbarLeft}>
{this.state.fullscreen && <img className={styles.logo} src={getTrustedUrl('static/mailtrain-notext.png')}/>}
<div className={styles.title}>{this.props.title}</div>
</div>
<div className={styles.navbarRight}>
{this.props.canSave ? <a className={styles.btn} onClick={this.props.onSave} title={t('save')}><Icon icon="save"/></a> : <span className={styles.btnDisabled}><Icon icon="save"/></span>}
<a className={styles.btn} onClick={this.props.onTestSend} title={t('sendTestEmail-1')}><Icon icon="at"/></a>
<a className={styles.btn} onClick={() => this.props.onShowExport('html', 'HTML')} title={t('showHtml')}><Icon icon="file-code"/></a>
<a className={styles.btn} onClick={::this.toggleFullscreenAsync} title={t('maximizeEditor')}><Icon icon="window-maximize"/></a>
</div>
</div>
<UntrustedContentHost ref={this.contentNodeRefHandler} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="mosaico/editor" tokenMethod="mosaico" tokenParams={tokenData}/>
</div>
);
}
}

View file

@ -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
View 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;
}
}

458
client/src/lib/table.js Normal file
View file

@ -0,0 +1,458 @@
'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,
order: PropTypes.array
}
static defaultProps = {
selectMode: TableSelectMode.NONE,
selectionKeyIndex: 0,
pageLength: 50,
order: [[0, 'asc']]
}
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
});
const oldSelectionMap = this.selectionMap;
this.selectionMap = new Map();
for (const row of response.data) {
const key = row[this.props.selectionKeyIndex];
if (oldSelectionMap.has(key)) {
this.selectionMap.set(key, row);
}
}
if (this.selectionMap.size !== oldSelectionMap.size) {
this.notifySelection(this.props.onSelectionChangedAsync, this.selectionMap);
}
}
}
// 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,
order: [...this.props.order],
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);
}
});
};
const t = this.props.t;
dtOptions.language = {
"sEmptyTable": t("dTsEmptyTable"),
"sInfo": t("dTsInfo"),
"sInfoEmpty": t("dTsInfoEmpty"),
"sInfoFiltered": t("dTsInfoFiltered"),
"sInfoPostFix": t("dTsInfoPostFix"),
"sInfoThousands": t("dTsInfoThousands"),
"sLengthMenu": t("dTsLengthMenu"),
"sLoadingRecords": t("dTsLoadingRecords"),
"sProcessing": t("dTsProcessing"),
"sSearch": t("dTsSearch"),
"sZeroRecords": t("dTsZeroRecords"),
"oPaginate": {
"sFirst": t("dTsFirst"),
"sLast": t("dTsLast"),
"sNext": t("dTsNext"),
"sPrevious": t("dTsPrevious")
},
"oAria": {
"sSortAscending": t("dTsSortAscending"),
"sSortDescending": t("dTsSortDescending")
}
}
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
View 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
View 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;
}
}

334
client/src/lib/untrusted.js Normal file
View file

@ -0,0 +1,334 @@
'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();
this.unmounted = false;
}
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.unmounted) {
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() {
this.unmounted = true;
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();

57
client/src/lib/urls.js Normal file
View file

@ -0,0 +1,57 @@
'use strict';
import {anonymousRestrictedAccessToken} from '../../../shared/urls';
import {AppType} from '../../../shared/app';
import mailtrainConfig from "mailtrainConfig";
import i18n from './i18n';
let restrictedAccessToken = anonymousRestrictedAccessToken;
export function setRestrictedAccessToken(token) {
restrictedAccessToken = token;
}
export function getTrustedUrl(path) {
return mailtrainConfig.trustedUrlBase + (path || '');
}
export function getSandboxUrl(path, customRestrictedAccessToken, opts) {
const localRestrictedAccessToken = customRestrictedAccessToken || restrictedAccessToken;
const url = new URL(localRestrictedAccessToken + '/' + (path || ''), mailtrainConfig.sandboxUrlBase);
if (opts && opts.withLocale) {
url.searchParams.append('locale', i18n.language);
}
return url.toString();
}
export function getPublicUrl(path, opts) {
const url = new URL(path || '', mailtrainConfig.publicUrlBase);
if (opts && opts.withLocale) {
url.searchParams.append('locale', i18n.language);
}
return url.toString();
}
export 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);
}
}
export 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;
}
}

296
client/src/lists/CUD.js Normal file
View 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
View 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>
);
}
}

View 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>
);
}
}

View file

@ -0,0 +1,538 @@
'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 'ace-builds/src-noconflict/mode-json';
import 'ace-builds/src-noconflict/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', 'required', '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: '',
required: false,
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('errorOnLineLine', { 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('helpText')}/>
<CheckBox id="required" label={t('requiredClientSideValidation')}/>
{fieldSettings}
{type !== 'option' &&
<Fieldset label={t('fieldOrder')}>
<Dropdown id="orderListBefore" label={t('listingsBefore')} options={getOrderOptions('order_list')} help={t('selectTheFieldBeforeWhichThisFieldShould')}/>
<Dropdown id="orderSubscribeBefore" label={t('subscriptionFormBefore')} options={getOrderOptions('order_subscribe')} help={t('selectTheFieldBeforeWhichThisFieldShould-1')}/>
<Dropdown id="orderManageBefore" label={t('managementFormBefore')} options={getOrderOptions('order_manage')} help={t('selectTheFieldBeforeWhichThisFieldShould-2')}/>
</Fieldset>
}
<ButtonRow>
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndLeave')} onClickAsync={async () => await this.submitHandler(true)}/>
{isEdit && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/${this.props.list.id}/fields/${this.props.entity.id}/delete`}/>}
</ButtonRow>
</Form>
</div>
);
}
}

View file

@ -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>
);
}
}

View 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;
}

View 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>
);
}
}

View 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>
);
}
}

View file

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

View 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>
);
}
}

View 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>
);
}
}

View 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>
);
}
}

View 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>
);
}
}

View 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
View 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
}

View 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 { DndProvider } 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);
@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 ruleTreeLabel = ruleHelpers.getTreeLabel(rule);
const title = ruleTreeLabel || 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 (
<DndProvider backend={isTouchDevice ? TouchBackend : HTML5Backend}>
<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>
</DndProvider>
);
}
}

View 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;
}
}
}

View 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>
);
}
}

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