Compare commits

..

377 commits

Author SHA1 Message Date
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
1309 changed files with 101654 additions and 118565 deletions

View file

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

27
.gitignore vendored
View file

@ -1,34 +1,9 @@
/.idea /.idea
/.vscode
/last-failed-e2e-test.* /last-failed-e2e-test.*
node_modules node_modules
npm-debug.log npm-debug.log
package-lock.json
.DS_Store .DS_Store
config/development*
config/production*
config/test*
workers/reports/config/development.*
workers/reports/config/production.*
workers/reports/config/test.*
dump.rdb 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 <[www.iredmail.org](http://www.iredmail.org/)>
* Richard Adleta
* Wes Bos
* Christophe Lombart
* Anselm Hannemann
* Jens Carroll
* Anonymous
* Brett Nelson
* Jason Pelker
* Leif Singer
* Eve Land
* Diana Espino
* Moussa Clarke
* Carl Hauschke

View file

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

View file

@ -1,12 +1,8 @@
# Mutistaged Node.js Build FROM node:10.14-alpine
FROM node:10-alpine as builder
# Install system dependencies RUN apk add --update pwgen netcat-openbsd python make gcc git g++ bash imagemagick
RUN set -ex; \
apk add --update --no-cache \
make gcc g++ git python
# Copy package.json dependencies # First install dependencies
COPY server/package.json /app/server/package.json COPY server/package.json /app/server/package.json
COPY server/package-lock.json /app/server/package-lock.json COPY server/package-lock.json /app/server/package-lock.json
COPY client/package.json /app/client/package.json COPY client/package.json /app/client/package.json
@ -16,32 +12,15 @@ COPY shared/package-lock.json /app/shared/package-lock.json
COPY zone-mta/package.json /app/zone-mta/package.json COPY zone-mta/package.json /app/zone-mta/package.json
COPY zone-mta/package-lock.json /app/zone-mta/package-lock.json COPY zone-mta/package-lock.json /app/zone-mta/package-lock.json
# Install dependencies in each directory WORKDIR /app/
RUN cd /app/client && npm install
RUN cd /app/shared && npm install --production RUN for idx in client shared server zone-mta; do (cd $idx && npm install); done
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 # 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 # to download and install all the NPM dependencies every time there's a change in the source code
COPY . /app COPY . /app
RUN set -ex; \ RUN cd client && npm run build
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 EXPOSE 3000 3003 3004
ENTRYPOINT ["bash", "/app/docker-entrypoint.sh"] ENTRYPOINT ["bash", "/app/docker-entrypoint.sh"]

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

232
README.md
View file

@ -1,64 +1,210 @@
## Mailtrain v2 beta is available. Check it out here https://github.com/Mailtrain-org/mailtrain/tree/development # Mailtrain v2 (beta)
# Mailtrain Mailtrain is a self hosted newsletter application built on Node.js (v10+) and MySQL (v8+) or MariaDB (v10+).
[Mailtrain](http://mailtrain.org) is a self hosted newsletter application built on Node.js (v7+) and MySQL (v5.5+ or MariaDB). ![](https://mailtrain.org/mailtrain.png)
![](http://mailtrain.org/mailtrain.png)
## Features ## Features
* Subscriber list management * Subscriber lists management
* List segmentation * List segmentation
* Custom fields * Custom fields
* Email templates * Email templates (including MJML-based templates)
* Large CSV list import files * 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 ## Quick Start
#### Requirements:
* [Docker](https://www.docker.com/) ### Preparation
* [Docker Compose](https://docs.docker.com/compose/) 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: The recommended deployment of Mailtrain would use 3 DNS entries that all points to the **same** IP address. For example as follows:
Depending on how you have configured your system and Docker you may need to prepend the commands below with `sudo`. - *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, especially replace the value in `Service Address (URL)` from `localhost` to the actual IP or domain if installed on a server, otherwise e.g. image upload will not work.
* Navigate to [http://localhost:3000/users/account](http://localhost:3000/users/account) and update user information and password.
## 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: This will setup a publicly accessible Mailtrain instance. All endpoints (trusted, sandbox, public) will provide both HTTP (on port 80)
* 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 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` The script below will also acquire a valid certificate from [Let's Encrypt](https://letsencrypt.org/).
2. Run `npm install --production` in the Mailtrain folder to install required dependencies If you are hosting Mailtrain on AWS or some other cloud provider, make sure that **before** running the installation
3. Copy [config/default.toml](config/default.toml) as `config/production.toml` and update MySQL and any other settings in it script you allow inbound connection to ports 80 (HTTP) and 443 (HTTPS).
4. Run the server `NODE_ENV=production npm start`
5. Open [http://localhost:3000/](http://localhost:3000/)
6. Authenticate as `admin`:`test`
7. Navigate to [http://localhost:3000/settings](http://localhost:3000/settings) and update service configuration, especially replace the value in `Service Address (URL)` from `localhost` to the actual IP or domain if installed on a server, otherwise e.g. image upload will not work.
8. Navigate to [http://localhost:3000/users/account](http://localhost:3000/users/account) and update user information and password
## Read The Docs **Note,** that this will automatically accept the Let's Encrypt's Terms of Service.
For more information, please [read the docs](http://docs.mailtrain.org/). Thus, by running this script below, you agree with the Let's Encrypt's Terms of Service (https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf).
1. Login as root. (I had some problems running npm as root on CentOS 7 on AWS. This seems to be fixed by the seemingly extraneous `su` within `sudo`.)
```
sudo su -
```
2. Install GIT
For Centos 7 type:
```
yum install -y git
```
For Ubuntu 18.04 LTS type
```
apt-get install -y git
```
3. Download Mailtrain using git to the `/opt/mailtrain` directory
```
cd /opt
git clone https://github.com/Mailtrain-org/mailtrain.git
cd mailtrain
git checkout development
```
4. Run the installation script. Replace the urls and your email address with the correct values. **NOTE** that running this script you agree
Let's Encrypt's conditions.
For Centos 7 type:
```
bash setup/install-centos7-https.sh mailtrain.example.com sbox.mailtrain.example.com lists.example.com admin@example.com
```
For Ubuntu 18.04 LTS type:
```
bash setup/install-ubuntu1804-https.sh mailtrain.example.com sbox.mailtrain.example.com lists.example.com admin@example.com
```
5. Start Mailtrain and enable to be started by default when your server starts.
```
systemctl start mailtrain
systemctl enable mailtrain
```
6. Open the trusted endpoint (like `https://mailtrain.example.com`)
7. Authenticate as `admin`:`test`
8. Update your password under admin/Account
9. Update your settings under Administration/Global Settings.
10. If you intend to sign your email by DKIM, set the DKIM key and DKIM selector under Administration/Send Configurations.
### Installation on fresh CentOS 7 or Ubuntu 18.04 LTS (local installation)
This will setup a locally accessible Mailtrain instance (primarily for development and testing).
All endpoints (trusted, sandbox, public) will provide only HTTP as follows:
- http://localhost:3000 - trusted endpoint
- http://localhost:3003 - sandbox endpoint
- http://localhost:3004 - public endpoint
1. Login as root. (I had some problems running npm as root on CentOS 7 on AWS. This seems to be fixed by the seemingly extraneous `su` within `sudo`.)
```
sudo su -
```
2. Install git
For Centos 7 type:
```
yum install -y git
```
For Ubuntu 18.04 LTS type:
```
apt-get install -y git
```
3. Download Mailtrain using git to the `/opt/mailtrain` directory
```
cd /opt
git clone https://github.com/Mailtrain-org/mailtrain.git
cd mailtrain
git checkout development
```
4. Run the installation script. Replace the urls and your email address with the correct values. **NOTE** that running this script you agree
Let's Encrypt's conditions.
For Centos 7 type:
```
bash setup/install-centos7-local.sh
```
For Ubuntu 18.04 LTS type:
```
bash setup/install-ubuntu1804-local.sh
```
5. Start Mailtrain and enable to be started by default when your server starts.
```
systemctl start mailtrain
systemctl enable mailtrain
```
6. Open the trusted endpoint http://localhost:3000
7. Authenticate as `admin`:`test`
### Deployment with Docker and Docker compose
This setup starts a stack composed of Mailtrain, MongoDB, Redis, and MariaDB. It will setup a locally accessible Mailtrain instance with HTTP endpoints as follows.
- http://localhost:3000 - trusted endpoint
- http://localhost:3003 - sandbox endpoint
- http://localhost:3004 - public endpoint
To make this publicly accessible, you should add reverse proxy that makes these endpoints publicly available over HTTPS. If using the proxy, you also need to set the URL bases and `--withProxy` parameter via `MAILTRAIN_SETTING` as shown below.
An example of such proxy would be:
- http://localhost:3000 -> https://mailtrain.example.com
- http://localhost:3003 -> https://sbox.mailtrain.example.com
- http://localhsot:3004 -> https://lists.example.com
To deploy Mailtrain with Docker, you need the following three dependencies installed:
- [Docker](https://www.docker.com/)
- [Docker Compose](https://docs.docker.com/compose/)
These are the steps to start Mailtrain via docker-compose:
1. Download Mailtrain's docker-compose build file
```
curl -O https://raw.githubusercontent.com/Mailtrain-org/mailtrain/development/docker-compose.yml
```
2. Deploy Mailtrain via docker-compose (in the directory to which you downloaded the `docker-compose.yml` file). This will take quite some time when run for the first time. Subsequent executions will be fast.
```
docker-compose up
```
You can specify Mailtrain's URL bases via the `MAILTRAIN_SETTINGS` environment variable as follows. The `--withProxy` parameter is to be used when Mailtrain is put behind a reverse proxy.
```
MAILTRAIN_SETTINGS="--trustedUrlBase https://mailtrain.example.com --sandboxUrlBase https://sbox.mailtrain.example.com --publicUrlBase https://lists.example.com --withProxy" docker-compose up
```
3. Open the trusted endpoint http://localhost:3000
4. Authenticate as `admin`:`test`
The instructions above use an automatically built Docker image on DockerHub (https://hub.docker.com/r/mailtrain/mailtrain). If you want to build the Docker image yourself (e.g. when doing development), use the `docker-compose-local.yml` located in the project's root directory.
## License ## License
* Versions 1.22.0 and up **GPL-V3.0** **GPL-V3.0**
* Versions 1.21.0 and up: **EUPL-1.1**
* Versions 1.19.0 and up: **MIT**
* Up to versions 1.18.0 **GPL-V3.0**

26
TODO.md 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

12356
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

86
client/package.json Normal file
View file

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

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

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

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

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

View file

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

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

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

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

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

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

View file

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

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

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

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

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

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

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

@ -0,0 +1,92 @@
'use strict';
import React from 'react';
import {I18nextProvider, withNamespaces} from 'react-i18next';
import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import mailtrainConfig from 'mailtrainConfig';
import {convertToFake, getLang} from '../../../shared/langs';
import {createComponentMixin} from "./decorator-helpers";
import lang_en_US_common from "../../../locales/en-US/common";
import lang_es_ES_common from "../../../locales/es-ES/common";
import lang_pt_BR_common from "../../../locales/pt-BR/common";
const resourcesCommon = {
'en-US': lang_en_US_common,
'es-ES': lang_es_ES_common,
'pt-BR': lang_pt_BR_common,
'fk-FK': convertToFake(lang_en_US_common)
};
const resources = {};
for (const lng of mailtrainConfig.enabledLanguages) {
const langDesc = getLang(lng);
resources[langDesc.longCode] = {
common: resourcesCommon[langDesc.longCode]
};
}
i18n
.use(LanguageDetector)
.init({
resources,
fallbackLng: mailtrainConfig.defaultLanguage,
defaultNS: 'common',
interpolation: {
escapeValue: false // not needed for react
},
react: {
wait: true
},
detection: {
order: ['querystring', 'cookie', 'localStorage', 'navigator'],
lookupQuerystring: 'locale',
lookupCookie: 'i18nextLng',
lookupLocalStorage: 'i18nextLng',
caches: ['localStorage', 'cookie']
},
whitelist: mailtrainConfig.enabledLanguages,
load: 'currentOnly',
debug: false
});
export default i18n;
export const TranslationContext = React.createContext(null);
export const withTranslation = createComponentMixin({
contexts: [{context: TranslationContext, propName: 't'}]
});
const TranslationContextProvider = withNamespaces()(props => {
return (
<TranslationContext.Provider value={props.t}>
{props.children}
</TranslationContext.Provider>
);
});
export function TranslationRoot(props) {
return (
<I18nextProvider i18n={ i18n }>
<TranslationContextProvider>
{props.children}
</TranslationContextProvider>
</I18nextProvider>
);
}
export function tMark(key) {
return key;
}

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

View file

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

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

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

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

392
client/src/lib/tree.js Normal file
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;
}
}

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

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

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

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

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

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 B

View file

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

View file

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

View file

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

View file

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

View file

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

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