Compare commits

...
223 Commits
Author SHA1 Message Date
Alex Turchyn 54454d1dfc Merge from docusealco/wip 2026-08-11 17:16:45 +03:00
Pete Matsyburka 726174deff authorize reminder config 2026-08-11 08:55:17 +03:00
Pete Matsyburka 52eb8ac863 add log 2026-08-10 15:21:22 +03:00
Pete Matsyburka 97d91d8f27 fix view only download 2026-08-10 12:06:42 +03:00
Pete Matsyburka dd358a8401 fix infinite loop 2026-08-10 11:48:05 +03:00
Pete Matsyburka 8e254b7d3c fix infinite loop 2026-08-10 06:01:20 +03:00
Pete Matsyburka b9018fa442 add completed download scope 2026-08-09 20:14:31 +03:00
Pete Matsyburka 09310f3690 guard save template message 2026-08-09 19:53:19 +03:00
Pete Matsyburka 2dd38260ba guard email 2fa send 2026-08-09 19:51:07 +03:00
Pete Matsyburka ffb3247cdc add authorize user 2026-08-09 18:37:53 +03:00
Pete Matsyburka c59c470321 sanitize search query 2026-08-09 16:01:12 +03:00
Pete Matsyburka f35510ffd6 adjust with reason 2026-08-08 09:00:35 +03:00
Pete Matsyburka eeec852b3b adjust preview cache 2026-08-07 21:54:55 +03:00
Pete Matsyburka 485b7f2c8f fix dynamic variable parse 2026-08-07 14:19:28 +03:00
Alex Turchyn b6498ec049 add PayPal translations 2026-08-07 10:12:52 +03:00
Pete Matsyburka dc1de72e18 fix mysql migrations 2026-08-06 17:50:03 +03:00
Pete Matsyburka 7dddef4c94 fix date time formats 2026-08-04 11:07:13 +03:00
Alex Turchyn a2d8b85549 Merge from docusealco/wip 2026-08-03 12:53:50 +03:00
Pete Matsyburka 4d68783b82 add counters 2026-08-03 10:05:09 +03:00
Pete Matsyburka b05c3b2030 fix autocomplete 2026-08-03 09:41:08 +03:00
Pete Matsyburka 784ae9aa4b fix dynamic document preview 2026-08-03 08:19:35 +03:00
Pete Matsyburka 03b5956cbc bcc limit 2026-08-03 07:42:53 +03:00
Pete Matsyburka 2876c9a18e adjust pagination 2026-08-01 08:38:49 +03:00
Pete Matsyburka c4b7be3925 fix mobile tabs 2026-07-31 12:46:34 +03:00
Pete Matsyburka e2ffbdac1d update gem 2026-07-31 11:43:40 +03:00
Pete Matsyburka 0d197bb2eb fix mobile modal 2026-07-31 10:57:37 +03:00
Pete Matsyburka 55089bee7f adjust pagination 2026-07-31 10:53:34 +03:00
Pete Matsyburka b96fe74db1 adjust filters 2026-07-31 10:46:24 +03:00
Pete Matsyburka 2fc1ba3037 fix layout 2026-07-31 09:23:18 +03:00
Pete Matsyburka c67d37207e update deps 2026-07-30 20:51:46 +03:00
Pete Matsyburka dba50cd1bf mobile fixes 2026-07-30 20:21:40 +03:00
Pete Matsyburka edf2c0d9ed update gem 2026-07-30 18:47:44 +03:00
Pete Matsyburka 35f7c36f28 rename settings index 2026-07-30 18:13:55 +03:00
Pete Matsyburka 3a24ecac63 Revert "break long names in template and folder titles"
This reverts commit 59fe8a7917.
2026-07-30 18:13:55 +03:00
Pete Matsyburka 50ec7682b5 fix open modal mobile 2026-07-30 18:13:54 +03:00
Pete Matsyburka 3667993caf center settings footer 2026-07-30 16:54:36 +03:00
Alex Turchyn 68e8cd1188 fix settings nav offset 2026-07-30 16:45:56 +03:00
Alex Turchyn 3eebf6d2cf unify tooltip and breakpoint classes 2026-07-30 16:45:56 +03:00
Alex Turchyn 34d0e00910 use numeric keyboard for otp code inputs 2026-07-30 16:45:56 +03:00
Alex Turchyn b8ab7190d2 use dvh for modal and drawer 2026-07-30 16:45:56 +03:00
Alex Turchyn 59fe8a7917 break long names in template and folder titles 2026-07-30 16:45:56 +03:00
Alex Turchyn d56070827b fix broken svg 2026-07-30 16:45:56 +03:00
Alex Turchyn 74dbd4bed3 fix trusted signature text 2026-07-30 16:45:56 +03:00
Alex Turchyn 807e82b21e open modals as pages on mobile devices 2026-07-30 16:45:56 +03:00
Alex Turchyn 66862e93c8 unify breakpoint classes 2026-07-30 16:45:56 +03:00
Alex Turchyn 270461ad3b add mobile filters 2026-07-30 16:45:56 +03:00
Alex Turchyn 3edd1ab32d fix invalid classes and html attributes 2026-07-30 16:45:56 +03:00
Alex Turchyn e77998e53c scale down settings titles on mobile 2026-07-30 16:45:56 +03:00
Alex Turchyn 1421bc2bce fix horizontal scroll from preferences tooltip 2026-07-30 16:45:56 +03:00
Alex Turchyn cbbbfdda80 add mobile settings navigation 2026-07-30 16:45:56 +03:00
Alex Turchyn 2d510081be fix esign signature row layout 2026-07-30 16:45:56 +03:00
Alex Turchyn 704893ee1d show view archived link in mobile pagination 2026-07-30 16:45:56 +03:00
Alex Turchyn 88612913a6 wrap smtp security radios on small screens 2026-07-30 16:45:56 +03:00
Alex Turchyn ce6823ed38 hide tooltip wrappers with hidden content 2026-07-30 16:45:56 +03:00
Alex Turchyn 9074d23b3c fix uneven template action buttons 2026-07-30 16:45:56 +03:00
Alex Turchyn 76d0e76fd5 no touch tooltips on nav elements 2026-07-30 16:45:56 +03:00
Pete Matsyburka 5ec8e4ccc7 text formula 2026-07-30 11:55:59 +03:00
Alex TurchynandPete Matsyburka b8c29324b8 paypal configs
Co-authored-by: Pete Matsyburka <pete@docuseal.com>
2026-07-30 07:56:07 +03:00
Pete Matsyburka 972f38242e update rails 2026-07-29 21:53:03 +03:00
Alex Turchyn 7ce6c29f4d Merge from docusealco/wip 2026-07-27 15:56:18 +03:00
Pete Matsyburka 2c6ddd7f90 rate limit remove 2fa 2026-07-27 15:48:04 +03:00
Pete Matsyburka ae50b2e323 add i18n 2026-07-27 14:56:08 +03:00
Pete Matsyburka 152cbc11a6 fix specs 2026-07-26 17:44:01 +03:00
Pete Matsyburka 3a406de655 adjust release tag 2026-07-26 09:37:03 +03:00
Pete Matsyburka 2e31cda08c adjust url pattern 2026-07-26 08:26:32 +03:00
Pete Matsyburka 094b7f47bd set forwarded priority 2026-07-25 20:12:18 +03:00
Pete Matsyburka 99ac349ecc sanitize dynamic document attributes 2026-07-25 19:46:41 +03:00
Pete Matsyburka 8830e03cf3 adjust cert config 2026-07-25 14:46:45 +03:00
Pete Matsyburka 706f3d6d65 fix image load 2026-07-25 13:51:55 +03:00
Pete Matsyburka 12b49f5fa7 log os 2026-07-25 10:53:41 +03:00
Pete Matsyburka 85327760b9 refactor mcp 2026-07-25 10:04:44 +03:00
Pete Matsyburka c94ed9c3d4 update gem 2026-07-24 23:05:57 +03:00
Pete Matsyburka bc50735d2a update gem 2026-07-23 14:13:55 +03:00
Pete Matsyburka d05e125fa9 use :has selectors for search input 2026-07-23 11:13:10 +03:00
Alex Turchyn 48d595ca81 full width search input on mobile devices 2026-07-23 10:36:45 +03:00
Pete Matsyburka a156de5e75 optional initials 2026-07-22 08:45:20 +03:00
Pete Matsyburka a7003bf4d0 bump stars 2026-07-21 13:10:36 +03:00
Alex Turchyn 5fe75c84ff Merge from docusealco/wip 2026-07-20 14:01:14 +03:00
Alex Turchyn 4549a04fc5 disable turbo on sign out form 2026-07-18 14:27:45 +03:00
Pete Matsyburka 0ea6b912a3 file download variables 2026-07-17 12:30:15 +03:00
Pete Matsyburka 9b86fa3d40 add date formats config 2026-07-17 11:27:44 +03:00
Pete Matsyburka 374f66c59f send view only 2026-07-17 11:12:47 +03:00
Pete Matsyburka da43d881f1 edit message per 10 parties 2026-07-17 09:34:43 +03:00
Pete Matsyburka 9cbfa3a0ba fix condition on complete 2026-07-17 00:30:06 +03:00
Pete Matsyburka 77a925f33b fix invite form return to fields 2026-07-16 15:55:21 +03:00
Pete Matsyburka 3f7d7beedd fix sharing toggle 2026-07-15 10:55:04 +03:00
Pete Matsyburka 40d91df732 Revert "adjust shared query"
This reverts commit 56da326709.
2026-07-15 10:49:09 +03:00
Pete Matsyburka c8e783a210 multi select context menu readonly and required 2026-07-14 17:30:41 +03:00
Pete Matsyburka 61fc24d35d add with signature id completed config 2026-07-14 12:47:38 +03:00
Pete Matsyburka 73734b0de7 add variables_schema response 2026-07-13 19:34:09 +03:00
Pete Matsyburka 09bd831d54 adjust submission.name variable 2026-07-13 19:13:46 +03:00
Pete Matsyburka 2c9f1b999e update icon 2026-07-13 17:16:42 +03:00
Alex Turchyn 6a3a185310 Merge from docusealco/wip 2026-07-13 13:23:35 +03:00
Pete Matsyburka 7d381b7aee add reset smtp action 2026-07-13 10:58:08 +03:00
Alex Turchyn bba5626dea add ability to edit html email 2026-07-10 21:57:23 +03:00
Pete Matsyburka bdf5e58929 fix autocomplete index use 2026-07-10 10:32:04 +03:00
Pete Matsyburka 035168230e match generated font frontend 2026-07-09 15:44:34 +03:00
Pete Matsyburka 28168f082d adjust view only 2026-07-09 12:46:38 +03:00
Pete Matsyburka a29e09ed2f add submission update api 2026-07-08 11:16:56 +03:00
Pete Matsyburka e88874e757 view only party 2026-07-07 17:34:23 +03:00
Pete Matsyburka b6fb7c6977 fix find blob by checksum 2026-07-07 09:50:11 +03:00
Pete Matsyburka 8a60f17d13 optimize created_at filter 2026-07-07 09:41:37 +03:00
Pete Matsyburka e1d860a42c add submission created_at index 2026-07-07 09:14:15 +03:00
Pete Matsyburka 56da326709 adjust shared query 2026-07-06 18:45:37 +03:00
Pete Matsyburka 672008f8af add shared templates index param 2026-07-06 17:56:35 +03:00
Alex Turchyn d86b16de6b Merge from docusealco/wip 2026-07-06 13:25:54 +03:00
Pete Matsyburka d41710f507 fix spec 2026-07-04 10:12:08 +03:00
Pete Matsyburka 2b53166763 add completed check 2026-07-04 09:59:35 +03:00
Pete Matsyburka 4e56a16a58 adjust filters 2026-07-04 09:59:35 +03:00
Pete Matsyburka ac7cc77017 use submission completed_at 2026-07-04 09:59:35 +03:00
Pete Matsyburka 9194879c43 populate completed_at 2026-07-04 09:58:56 +03:00
Pete Matsyburka 1902cdaa55 submission completed_at 2026-07-04 09:58:56 +03:00
Alex Turchyn 6314987e69 make possible to unarchive completed submission 2026-07-04 09:57:29 +03:00
Pete Matsyburka 65bcbd7737 adjust authorize 2026-07-04 09:30:43 +03:00
Pete Matsyburka 9b25b8538e radio group font settings 2026-07-02 10:14:43 +03:00
Pete Matsyburka bbf8bb2a94 fix autocomplete index use 2026-07-01 13:06:00 +03:00
Pete Matsyburka 2085227cd9 fix dynamic document areas 2026-06-30 17:55:28 +03:00
Alex Turchyn 93e0730210 update docs 2026-06-30 13:31:31 +03:00
Pete Matsyburka 25c5c11ab3 change footer url 2026-06-30 08:39:26 +03:00
Pete Matsyburka 6ed5b5d35d extract email assets 2026-06-29 13:21:03 +03:00
Alex Turchyn 673cc1e0df Merge from docusealco/wip 2026-06-29 13:13:29 +03:00
Pete Matsyburka 065dcf6f9f fix include combined_document_url 2026-06-29 10:28:59 +03:00
Pete Matsyburka a66b16ce1c fix rubocop 2026-06-28 19:35:34 +03:00
Pete Matsyburka e4c0b30284 fix dedup email messages 2026-06-28 17:03:36 +03:00
Pete Matsyburka 3cb37748e9 optimize select 2026-06-28 11:21:04 +03:00
Pete Matsyburka e54d76f0e2 redact and crop on touchscreen 2026-06-27 19:28:20 +03:00
Pete Matsyburka f19e037011 fix input mode 2026-06-27 19:02:18 +03:00
Pete Matsyburka ee3f47d918 adjust edit condition 2026-06-27 17:21:55 +03:00
Pete Matsyburka a49a645022 fix completed_at index 2026-06-27 11:40:02 +03:00
Pete Matsyburka cf30441ac6 mcp use template submitters order 2026-06-27 09:08:23 +03:00
Pete Matsyburka c9bcf866ab optimize query 2026-06-26 21:33:43 +03:00
Pete Matsyburka 506609f3cd optimize search 2026-06-25 20:26:33 +03:00
Pete Matsyburka 721172c980 adjust accessibility 2026-06-25 15:53:15 +03:00
Pete Matsyburka b183db5b80 fix rubocop 2026-06-25 10:35:48 +03:00
Pete Matsyburka c7f277f404 fix search pagination 2026-06-25 10:28:02 +03:00
Pete Matsyburka 8ac03a9f8a fix template archived send event 2026-06-25 10:04:54 +03:00
Pete Matsyburka dc1c386d7e adjust search 2026-06-25 09:27:59 +03:00
Pete Matsyburka 1eaf4e5bab fix email stats 2026-06-25 08:48:34 +03:00
Pete Matsyburka 3c0f1c2e73 default order if countless 2026-06-25 08:07:06 +03:00
Pete Matsyburka b2790776ef show archived shared templates 2026-06-24 19:29:07 +03:00
Pete Matsyburka 2840359396 adjust shared tag 2026-06-24 17:20:20 +03:00
Pete Matsyburka c18ddb99a8 optimize queries 2026-06-24 17:20:20 +03:00
Pete Matsyburka 432435aa23 adjust shared templates 2026-06-24 17:20:19 +03:00
Pete Matsyburka 90c1a6f4ad fix can send emails 2026-06-24 14:42:49 +03:00
Pete Matsyburka 0e8f478c2e optimize query 2026-06-24 11:31:15 +03:00
Pete Matsyburka 5f23d5a5ab optimize query 2026-06-23 18:51:24 +03:00
Pete Matsyburka 76821a4c2d fix opt email footer 2026-06-23 17:40:40 +03:00
Pete Matsyburka e4a8cca90a update deps 2026-06-23 09:24:27 +03:00
Pete Matsyburka 6024ed4cd1 adjust accessibility 2026-06-23 09:09:31 +03:00
Pete Matsyburka 3811f8bbb8 fix submission nil name 2026-06-23 08:48:49 +03:00
Pete Matsyburka 306218e5df update gem 2026-06-22 13:11:04 +03:00
Alex Turchyn cabfe45608 Merge from docusealco/wip 2026-06-22 13:06:54 +03:00
Pete Matsyburka 4ade6a5911 update gem 2026-06-22 09:26:49 +03:00
Pete Matsyburka 10d45c3e20 add checks 2026-06-22 09:26:48 +03:00
Pete Matsyburka 6fb247ec06 adjust can resubmit 2026-06-21 07:58:23 +03:00
Pete Matsyburka 1546258529 add service url time 2026-06-19 19:20:21 +03:00
Pete Matsyburka 7ec83b8af6 adjust upload url 2026-06-19 08:40:39 +03:00
Pete Matsyburka 21d0a87c17 adjust replace button 2026-06-18 18:31:29 +03:00
Pete Matsyburka 528f59c1bf disable previewers 2026-06-17 10:46:47 +03:00
Pete Matsyburka d1535c4cd8 add redact color 2026-06-15 14:45:06 +03:00
Alex Turchyn da44d51a3b Merge from docusealco/wip 2026-06-15 13:37:25 +03:00
Pete Matsyburka 73ce10b26a add cleanup document helper 2026-06-15 12:05:50 +03:00
Pete Matsyburka 13874b8830 add documents editor 2026-06-15 10:21:23 +03:00
Pete Matsyburka ac66809a05 fix date tz 2026-06-12 14:03:20 +03:00
Pete Matsyburka f5229d5dec fix zoom 2026-06-12 12:47:23 +03:00
Pete Matsyburka 222291d11d update gem 2026-06-09 21:51:06 +03:00
Pete Matsyburka 28f47e1093 handle template archived 2026-06-09 19:49:12 +03:00
Pete Matsyburka 7784346a97 update gem 2026-06-09 12:11:16 +03:00
Alex Turchyn ed46af8418 Merge from docusealco/wip 2026-06-08 11:56:14 +03:00
Pete Matsyburka 095f25a6e9 adjust image type 2026-06-08 09:31:38 +03:00
Pete Matsyburka 3cdab30dc8 remove oj 2026-06-05 16:55:55 +03:00
Pete Matsyburka 8e462047ba fix upload callback 2026-06-05 14:45:22 +03:00
Pete Matsyburka b7f8b65d9d adjust button style 2026-06-05 09:55:21 +03:00
Alex Turchyn da21c37254 add Google Drive replace option 2026-06-05 09:55:21 +03:00
Pete Matsyburka a89aef4a89 adjust zoom 2026-06-04 14:39:02 +03:00
Alex Turchyn dd6516fd11 add cmd+scroll zooming to template builder 2026-06-04 08:20:38 +03:00
Pete Matsyburka 344a3ce198 adjust sanitize 2026-06-03 13:24:08 +03:00
Pete Matsyburka 62f8283587 adjust html to text 2026-06-02 13:44:52 +03:00
Alex Turchyn 1f89accac3 Merge from docusealco/wip 2026-06-01 14:58:06 +03:00
Pete Matsyburka 9fcaef4cf7 use new_from_memory_copy 2026-05-31 14:28:35 +03:00
Pete Matsyburka d5738a0631 disable variant_processor 2026-05-31 12:51:52 +03:00
Pete Matsyburka 8bf7a1f95a adjust image detect fields 2026-05-31 09:20:51 +03:00
Pete Matsyburka 04cf36891e fix complete button press enter 2026-05-31 09:09:57 +03:00
Pete Matsyburka b2d9948c30 remove active storage analyzers 2026-05-31 08:06:52 +03:00
Pete Matsyburka a2c9ac1707 remove image_processing 2026-05-30 21:37:00 +03:00
Pete Matsyburka 46a6bd0108 fix stamp 2026-05-30 12:04:23 +03:00
Pete Matsyburka 5f069e7a40 use load vips 2026-05-30 07:49:40 +03:00
Pete Matsyburka ff57e5c6ae fix pdfa 2026-05-29 15:31:32 +03:00
Alex Turchyn f65b6e2d76 add confirm prompt for template upload via URL 2026-05-29 09:29:21 +03:00
Pete Matsyburka 58fd180ae0 update gem 2026-05-29 08:04:53 +03:00
Pete Matsyburka cd6503c4c3 adjust image size 2026-05-28 15:37:20 +03:00
Pete Matsyburka e8b36c2b6d update gh stars 2026-05-28 14:38:34 +03:00
Pete Matsyburka 51743f1359 fix spec 2026-05-28 10:18:45 +03:00
Pete Matsyburka 504c42646b refactor template builder data 2026-05-28 10:06:47 +03:00
Pete Matsyburka c61e84d1b4 adjust archive 2026-05-28 08:40:05 +03:00
Pete Matsyburka b8ab01c46c add dynamic documents to template response 2026-05-28 08:29:10 +03:00
Pete Matsyburka 9558060bde check resubmit config 2026-05-28 08:29:09 +03:00
Pete Matsyburka 0741c879f1 use expires_at with file links 2026-05-27 22:19:18 +03:00
Pete Matsyburka 2e6dd2867e file url ttl env var 2026-05-26 21:54:59 +03:00
Pete Matsyburka 9f3fcccb61 optimize build 2026-05-26 16:50:10 +03:00
Pete Matsyburka ae08d7abad adjust opacity_layer 2026-05-26 13:11:15 +03:00
Pete Matsyburka 16808a7ec6 use pngload_buffer 2026-05-26 13:11:15 +03:00
Pete Matsyburka b1dfcd6283 fix email typo 2026-05-26 13:11:15 +03:00
Pete Matsyburka 369f4c0f08 sync prefillable field changes 2026-05-26 13:11:15 +03:00
Pete Matsyburka 827de03579 fix result generatrion 2026-05-26 08:57:30 +03:00
Alex Turchyn 0c7f4e5b43 fix sidekiq embedded redis connection 2026-05-25 21:52:34 +03:00
Alex Turchyn 9c700a3fb6 Merge from docusealco/wip 2026-05-25 12:28:05 +03:00
Pete Matsyburka 89bf83febb adjust autorot 2026-05-23 11:43:28 +03:00
Pete Matsyburka d44b35cf0a convert images on upload 2026-05-23 08:25:04 +03:00
Pete Matsyburka 891833e273 refactor process doc 2026-05-22 14:06:04 +03:00
Pete Matsyburka 29d3a80d3f fix rubocop 2026-05-21 21:55:30 +03:00
Pete Matsyburka 90fef58684 ruby 4.0.5 2026-05-21 21:46:27 +03:00
Pete Matsyburka 07841722ac adjust image load 2026-05-21 21:44:13 +03:00
Pete Matsyburka 9e4da5948b convert images on upload 2026-05-21 14:50:30 +03:00
Pete Matsyburka dc6e4313a1 fix ci 2026-05-20 16:15:15 +03:00
Pete Matsyburka a68cc0b689 convert images on upload 2026-05-20 15:23:42 +03:00
Pete Matsyburka d057fb0f67 content type priority 2026-05-19 13:27:16 +03:00
Pete Matsyburka 7e8f045a29 cleanup 2026-05-19 11:42:14 +03:00
Pete Matsyburka 5249d8d18d update gem 2026-05-19 11:02:15 +03:00
Pete Matsyburka f0fafbadd4 isolate editor nodes 2026-05-19 10:52:48 +03:00
Pete Matsyburka 8b51079dcc update gem 2026-05-18 18:42:04 +03:00
Pete Matsyburka 354bccd6e8 handle dangerous extensions 2026-05-18 18:05:16 +03:00
Pete Matsyburka 6806772346 add security headers 2026-05-18 16:47:19 +03:00
334 changed files with 17212 additions and 4476 deletions

No files matched your search

+7 -7
View File
@@ -12,7 +12,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 4.0.1 ruby-version: 4.0.5
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -37,7 +37,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 4.0.1 ruby-version: 4.0.5
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -89,7 +89,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 4.0.1 ruby-version: 4.0.5
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -104,7 +104,7 @@ jobs:
bundle install --jobs 4 --retry 4 bundle install --jobs 4 --retry 4
yarn install yarn install
sudo apt-get update sudo apt-get update
sudo apt-get install libvips sudo apt-get install libvips liblept5
- name: Run Brakeman - name: Run Brakeman
run: bundle exec brakeman -q --exit-on-warn run: bundle exec brakeman -q --exit-on-warn
@@ -132,7 +132,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 4.0.1 ruby-version: 4.0.5
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
@@ -162,8 +162,8 @@ jobs:
bundle install --jobs 4 --retry 4 bundle install --jobs 4 --retry 4
yarn install yarn install
sudo apt-get update sudo apt-get update
sudo apt-get install -y libvips sudo apt-get install -y libvips liblept5
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz"
sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so
rm -f pdfium-linux.tgz rm -f pdfium-linux.tgz
- name: Run - name: Run
+3 -1
View File
@@ -30,7 +30,9 @@ jobs:
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Create .version file - name: Create .version file
run: echo ${{ github.ref_name }} > .version env:
REF_NAME: ${{ github.ref_name }}
run: echo "$REF_NAME" > .version
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v3
+14 -8
View File
@@ -1,19 +1,23 @@
FROM ruby:4.0.1-alpine AS download FROM ruby:4.0.5-alpine AS download
WORKDIR /fonts WORKDIR /fonts
RUN apk --no-cache add wget && \ RUN apk --no-cache add wget unzip && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && \ wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && \ wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && \ wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && \
wget https://raw.githubusercontent.com/impallari/DancingScript/master/OFL.txt && \ wget https://raw.githubusercontent.com/impallari/DancingScript/master/OFL.txt && \
wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \ wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \ wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \
wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-musl-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \ wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260613/pdfium-musl-$(uname -m).zip" && \
case "$(uname -m)" in \
x86_64) echo "2c953ff72ee2dda07e7fc577e25841cc3d6464468a7c5adfaea574efcbc3b90b pdfium-linux.zip" ;; \
aarch64) echo "23bbe287d2753fdb05741c7660647eb0ef0d2e4da2ce0722bfa9d9d455bd64e2 pdfium-linux.zip" ;; \
esac | sha256sum -c - && \
mkdir -p /pdfium-linux && \ mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux unzip -q pdfium-linux.zip -d /pdfium-linux
FROM ruby:4.0.1-alpine AS webpack FROM ruby:4.0.5-alpine AS webpack
ENV RAILS_ENV=production ENV RAILS_ENV=production
ENV NODE_ENV=production ENV NODE_ENV=production
@@ -40,7 +44,7 @@ COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:4.0.1-alpine AS app FROM ruby:4.0.5-alpine AS app
ENV RAILS_ENV=production ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test" ENV BUNDLE_WITHOUT="development:test"
@@ -48,7 +52,8 @@ ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
WORKDIR /app WORKDIR /app
RUN apk add --no-cache libpq vips redis vips-heif onnxruntime RUN apk add --no-cache libpq vips redis onnxruntime leptonica && \
rm -f /usr/bin/onnx_test_runner /usr/bin/onnxruntime_test
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
@@ -81,7 +86,7 @@ COPY --chown=docuseal:docuseal .version ./public/version
COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts/LICENSE /fonts/ COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts/LICENSE /fonts/
COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt COPY --from=download /pdfium-linux/licenses/ /usr/lib/libpdfium-licenses/
COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx
COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs
@@ -94,6 +99,7 @@ WORKDIR /data/docuseal
ENV HOME=/home/docuseal ENV HOME=/home/docuseal
ENV WORKDIR=/data/docuseal ENV WORKDIR=/data/docuseal
ENV VIPS_MAX_COORD=17000 ENV VIPS_MAX_COORD=17000
ENV VIPS_BLOCK_UNTRUSTED=1
EXPOSE 3000 EXPOSE 3000
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"] CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
+1 -3
View File
@@ -2,7 +2,7 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '4.0.1' ruby '4.0.5'
gem 'addressable' gem 'addressable'
gem 'arabic-letter-connector', require: false gem 'arabic-letter-connector', require: false
@@ -21,11 +21,9 @@ gem 'faraday'
gem 'faraday-follow_redirects' gem 'faraday-follow_redirects'
gem 'google-cloud-storage', require: false gem 'google-cloud-storage', require: false
gem 'hexapdf' gem 'hexapdf'
gem 'image_processing'
gem 'jwt', require: false gem 'jwt', require: false
gem 'lograge' gem 'lograge'
gem 'numo-narray-alt', require: false gem 'numo-narray-alt', require: false
gem 'oj'
gem 'onnxruntime', require: false gem 'onnxruntime', require: false
gem 'pagy' gem 'pagy'
gem 'pg', require: false gem 'pg', require: false
+97 -107
View File
@@ -1,31 +1,31 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
action_text-trix (2.1.18) action_text-trix (2.1.19)
railties railties
actioncable (8.1.3) actioncable (8.1.3.1)
actionpack (= 8.1.3) actionpack (= 8.1.3.1)
activesupport (= 8.1.3) activesupport (= 8.1.3.1)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (8.1.3) actionmailbox (8.1.3.1)
actionpack (= 8.1.3) actionpack (= 8.1.3.1)
activejob (= 8.1.3) activejob (= 8.1.3.1)
activerecord (= 8.1.3) activerecord (= 8.1.3.1)
activestorage (= 8.1.3) activestorage (= 8.1.3.1)
activesupport (= 8.1.3) activesupport (= 8.1.3.1)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (8.1.3) actionmailer (8.1.3.1)
actionpack (= 8.1.3) actionpack (= 8.1.3.1)
actionview (= 8.1.3) actionview (= 8.1.3.1)
activejob (= 8.1.3) activejob (= 8.1.3.1)
activesupport (= 8.1.3) activesupport (= 8.1.3.1)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (8.1.3) actionpack (8.1.3.1)
actionview (= 8.1.3) actionview (= 8.1.3.1)
activesupport (= 8.1.3) activesupport (= 8.1.3.1)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
rack (>= 2.2.4) rack (>= 2.2.4)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
@@ -33,36 +33,36 @@ GEM
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (8.1.3) actiontext (8.1.3.1)
action_text-trix (~> 2.1.15) action_text-trix (~> 2.1.15)
actionpack (= 8.1.3) actionpack (= 8.1.3.1)
activerecord (= 8.1.3) activerecord (= 8.1.3.1)
activestorage (= 8.1.3) activestorage (= 8.1.3.1)
activesupport (= 8.1.3) activesupport (= 8.1.3.1)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (8.1.3) actionview (8.1.3.1)
activesupport (= 8.1.3) activesupport (= 8.1.3.1)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activejob (8.1.3) activejob (8.1.3.1)
activesupport (= 8.1.3) activesupport (= 8.1.3.1)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (8.1.3) activemodel (8.1.3.1)
activesupport (= 8.1.3) activesupport (= 8.1.3.1)
activerecord (8.1.3) activerecord (8.1.3.1)
activemodel (= 8.1.3) activemodel (= 8.1.3.1)
activesupport (= 8.1.3) activesupport (= 8.1.3.1)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (8.1.3) activestorage (8.1.3.1)
actionpack (= 8.1.3) actionpack (= 8.1.3.1)
activejob (= 8.1.3) activejob (= 8.1.3.1)
activerecord (= 8.1.3) activerecord (= 8.1.3.1)
activesupport (= 8.1.3) activesupport (= 8.1.3.1)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (8.1.3) activesupport (8.1.3.1)
base64 base64
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1) concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -144,12 +144,12 @@ GEM
cldr-plurals-runtime-rb (1.1.0) cldr-plurals-runtime-rb (1.1.0)
cmdparse (3.0.7) cmdparse (3.0.7)
coderay (1.1.3) coderay (1.1.3)
concurrent-ruby (1.3.6) concurrent-ruby (1.3.8)
connection_pool (3.0.2) connection_pool (3.0.2)
crack (1.0.1) crack (1.0.1)
bigdecimal bigdecimal
rexml rexml
crass (1.0.6) crass (1.0.7)
csv (3.3.5) csv (3.3.5)
csv-safe (3.3.1) csv-safe (3.3.1)
csv (~> 3.0) csv (~> 3.0)
@@ -179,7 +179,7 @@ GEM
dotenv (3.2.0) dotenv (3.2.0)
drb (2.2.3) drb (2.2.3)
email_typo (0.2.3) email_typo (0.2.3)
erb (6.0.4) erb (6.0.6)
erb_lint (0.9.0) erb_lint (0.9.0)
activesupport activesupport
better_html (>= 2.0.1) better_html (>= 2.0.1)
@@ -195,13 +195,13 @@ GEM
railties (>= 6.1.0) railties (>= 6.1.0)
faker (3.6.1) faker (3.6.1)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.14.1) faraday (2.14.3)
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.5)
json json
logger logger
faraday-follow_redirects (0.5.0) faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3) faraday (>= 1, < 3)
faraday-net_http (3.4.2) faraday-net_http (3.4.4)
net-http (~> 0.5) net-http (~> 0.5)
ferrum (0.17.2) ferrum (0.17.2)
addressable (~> 2.5) addressable (~> 2.5)
@@ -217,7 +217,7 @@ GEM
foreman (0.90.0) foreman (0.90.0)
thor (~> 1.4) thor (~> 1.4)
geom2d (0.4.1) geom2d (0.4.1)
globalid (1.3.0) globalid (1.4.0)
activesupport (>= 6.1) activesupport (>= 6.1)
google-apis-core (1.0.2) google-apis-core (1.0.2)
addressable (~> 2.8, >= 2.8.7) addressable (~> 2.8, >= 2.8.7)
@@ -262,11 +262,8 @@ GEM
geom2d (~> 0.4, >= 0.4.1) geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1) openssl (>= 2.2.1)
strscan (>= 3.1.2) strscan (>= 3.1.2)
i18n (1.14.8) i18n (1.15.2)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
io-console (0.8.2) io-console (0.8.2)
irb (1.18.0) irb (1.18.0)
pp (>= 0.6.0) pp (>= 0.6.0)
@@ -274,8 +271,8 @@ GEM
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jmespath (1.6.2) jmespath (1.6.2)
json (2.19.5) json (2.21.1)
jwt (3.1.2) jwt (3.2.0)
base64 base64
language_server-protocol (3.17.0.5) language_server-protocol (3.17.0.5)
launchy (3.1.1) launchy (3.1.1)
@@ -296,29 +293,27 @@ GEM
activesupport (>= 4) activesupport (>= 4)
railties (>= 4) railties (>= 4)
request_store (~> 1.0) request_store (~> 1.0)
loofah (2.25.1) loofah (2.25.2)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.9.0) mail (2.9.1)
logger logger
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
marcel (1.1.0) marcel (1.2.1)
matrix (0.4.3) matrix (0.4.3)
method_source (1.1.0) method_source (1.1.0)
mini_magick (5.3.1)
logger
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (6.0.6) minitest (6.0.6)
drb (~> 2.0) drb (~> 2.0)
prism (~> 1.5) prism (~> 1.5)
msgpack (1.8.0) msgpack (1.8.4)
multi_json (1.19.1) multi_json (1.19.1)
net-http (0.9.1) net-http (0.9.1)
uri (>= 0.11.1) uri (>= 0.11.1)
net-imap (0.6.4) net-imap (0.6.6)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -328,20 +323,17 @@ GEM
net-smtp (0.5.1) net-smtp (0.5.1)
net-protocol net-protocol
nio4r (2.7.5) nio4r (2.7.5)
nokogiri (1.19.3-aarch64-linux-gnu) nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.3-aarch64-linux-musl) nokogiri (1.19.4-aarch64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.3-arm64-darwin) nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu) nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-musl) nokogiri (1.19.4-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
numo-narray-alt (0.10.3) numo-narray-alt (0.10.3)
oj (3.16.16)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
onnxruntime (0.10.1-aarch64-linux) onnxruntime (0.10.1-aarch64-linux)
ffi ffi
onnxruntime (0.10.1-arm64-darwin) onnxruntime (0.10.1-arm64-darwin)
@@ -351,9 +343,8 @@ GEM
openssl (4.0.1) openssl (4.0.1)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
ostruct (0.6.3)
package_json (0.2.0) package_json (0.2.0)
pagy (43.4.4) pagy (43.6.1)
json json
uri uri
yaml yaml
@@ -366,7 +357,7 @@ GEM
pg (1.6.3-arm64-darwin) pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-linux) pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl) pg (1.6.3-x86_64-linux-musl)
pp (0.6.3) pp (0.6.4)
prettyprint prettyprint
pretender (1.0.0) pretender (1.0.0)
actionpack (>= 7.2) actionpack (>= 7.2)
@@ -378,11 +369,8 @@ GEM
reline (>= 0.6.0) reline (>= 0.6.0)
pry-rails (0.3.11) pry-rails (0.3.11)
pry (>= 0.13.0) pry (>= 0.13.0)
psych (5.3.1)
date
stringio
public_suffix (7.0.5) public_suffix (7.0.5)
puma (7.2.0) puma (8.0.2)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.6) rack (3.2.6)
@@ -395,33 +383,33 @@ GEM
rack (>= 1.3) rack (>= 1.3)
rackup (2.3.1) rackup (2.3.1)
rack (>= 3) rack (>= 3)
rails (8.1.3) rails (8.1.3.1)
actioncable (= 8.1.3) actioncable (= 8.1.3.1)
actionmailbox (= 8.1.3) actionmailbox (= 8.1.3.1)
actionmailer (= 8.1.3) actionmailer (= 8.1.3.1)
actionpack (= 8.1.3) actionpack (= 8.1.3.1)
actiontext (= 8.1.3) actiontext (= 8.1.3.1)
actionview (= 8.1.3) actionview (= 8.1.3.1)
activejob (= 8.1.3) activejob (= 8.1.3.1)
activemodel (= 8.1.3) activemodel (= 8.1.3.1)
activerecord (= 8.1.3) activerecord (= 8.1.3.1)
activestorage (= 8.1.3) activestorage (= 8.1.3.1)
activesupport (= 8.1.3) activesupport (= 8.1.3.1)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.1.3) railties (= 8.1.3.1)
rails-dom-testing (2.3.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.7.0) rails-html-sanitizer (1.7.1)
loofah (~> 2.25) loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.1.0) rails-i18n (8.1.0)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9) railties (>= 8.0.0, < 9)
railties (8.1.3) railties (8.1.3.1)
actionpack (= 8.1.3) actionpack (= 8.1.3.1)
activesupport (= 8.1.3) activesupport (= 8.1.3.1)
irb (~> 1.13) irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
@@ -430,11 +418,16 @@ GEM
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.4.2) rake (13.4.2)
rdoc (7.2.0) rbs (4.1.0)
erb logger
psych (>= 4.0.0) prism (>= 1.6.0)
tsort tsort
redis-client (0.28.0) rdoc (8.0.0)
erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
redis-client (0.29.0)
connection_pool connection_pool
regexp_parser (2.11.3) regexp_parser (2.11.3)
reline (0.6.3) reline (0.6.3)
@@ -516,12 +509,12 @@ GEM
rack-proxy (>= 0.6.1) rack-proxy (>= 0.6.1)
railties (>= 5.2) railties (>= 5.2)
semantic_range (>= 2.3.0) semantic_range (>= 2.3.0)
sidekiq (8.1.2) sidekiq (8.1.6)
connection_pool (>= 3.0.0) connection_pool (>= 3.0.0)
json (>= 2.16.0) json (>= 2.16.0)
logger (>= 1.7.0) logger (>= 1.7.0)
rack (>= 3.2.0) rack (>= 3.2.0)
redis-client (>= 0.26.0) redis-client (>= 0.29.0)
signet (0.21.0) signet (0.21.0)
addressable (~> 2.8) addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a) faraday (>= 0.17.5, < 3.a)
@@ -534,12 +527,11 @@ GEM
simplecov-html (0.13.2) simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
smart_properties (1.17.0) smart_properties (1.17.0)
sqlite3 (2.9.2-aarch64-linux-gnu) sqlite3 (2.9.5-aarch64-linux-gnu)
sqlite3 (2.9.2-aarch64-linux-musl) sqlite3 (2.9.5-aarch64-linux-musl)
sqlite3 (2.9.2-arm64-darwin) sqlite3 (2.9.5-arm64-darwin)
sqlite3 (2.9.2-x86_64-linux-gnu) sqlite3 (2.9.5-x86_64-linux-gnu)
sqlite3 (2.9.2-x86_64-linux-musl) sqlite3 (2.9.5-x86_64-linux-musl)
stringio (3.2.0)
strip_attributes (2.0.1) strip_attributes (2.0.1)
activemodel (>= 3.0, < 9.0) activemodel (>= 3.0, < 9.0)
strscan (3.1.8) strscan (3.1.8)
@@ -579,14 +571,14 @@ GEM
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.2) webrick (1.9.2)
websocket-driver (0.8.0) websocket-driver (0.8.2)
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
yaml (0.4.0) yaml (0.4.0)
zeitwerk (2.7.5) zeitwerk (2.8.2)
PLATFORMS PLATFORMS
aarch64-linux aarch64-linux
@@ -624,12 +616,10 @@ DEPENDENCIES
foreman foreman
google-cloud-storage google-cloud-storage
hexapdf hexapdf
image_processing
jwt jwt
letter_opener_web letter_opener_web
lograge lograge
numo-narray-alt numo-narray-alt
oj
onnxruntime onnxruntime
pagy pagy
pg pg
@@ -662,7 +652,7 @@ DEPENDENCIES
webmock webmock
RUBY VERSION RUBY VERSION
ruby 4.0.1 ruby 4.0.5
BUNDLED WITH BUNDLED WITH
4.0.3 4.0.3
@@ -37,7 +37,7 @@ class AccountConfigsController < ApplicationController
end end
def destroy def destroy
raise InvalidKey unless allowed_keys.include?(@account_config.key) raise InvalidKey unless allowed_destroy_keys.include?(@account_config.key)
@account_config.destroy! @account_config.destroy!
@@ -50,6 +50,10 @@ class AccountConfigsController < ApplicationController
ALLOWED_KEYS ALLOWED_KEYS
end end
def allowed_destroy_keys
ALLOWED_KEYS
end
def load_account_config def load_account_config
raise InvalidKey unless allowed_keys.include?(account_config_params[:key]) raise InvalidKey unless allowed_keys.include?(account_config_params[:key])
@@ -9,7 +9,9 @@ module Api
before_action :set_cors_headers before_action :set_cors_headers
before_action :set_noindex_headers before_action :set_noindex_headers
before_action :set_security_headers
# rubocop:disable Metrics
def show def show
blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid]) blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
@@ -21,6 +23,12 @@ module Api
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid) blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid)
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
Rollbar.error('Dangerous extension') if defined?(Rollbar)
return head :unprocessable_content
end
attachment = blob.attachments.take attachment = blob.attachments.take
@record = attachment.record @record = attachment.record
@@ -45,6 +53,7 @@ module Api
end end
end end
end end
# rubocop:enable Metrics
private private
@@ -9,6 +9,7 @@ module Api
before_action :set_cors_headers before_action :set_cors_headers
before_action :set_noindex_headers before_action :set_noindex_headers
before_action :set_security_headers
# rubocop:disable Metrics # rubocop:disable Metrics
def show def show
@@ -18,6 +19,12 @@ module Api
return head :not_found unless blob return head :not_found unless blob
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
Rollbar.error('Dangerous extension') if defined?(Rollbar)
return head :unprocessable_content
end
is_permitted = blob.attachments.any? do |a| is_permitted = blob.attachments.any? do |a|
(current_user && a.record.account.id == current_user.account_id) || (current_user && a.record.account.id == current_user.account_id) ||
a.record.account.account_configs.any? { |e| e.key == 'legacy_blob_proxy' } || a.record.account.account_configs.any? { |e| e.key == 'legacy_blob_proxy' } ||
@@ -102,6 +102,10 @@ module Api
headers['X-Robots-Tag'] = 'noindex' headers['X-Robots-Tag'] = 'noindex'
end end
def set_security_headers
response.headers['X-Content-Type-Options'] = 'nosniff'
end
def set_cors_headers def set_cors_headers
headers['Access-Control-Allow-Origin'] = '*' headers['Access-Control-Allow-Origin'] = '*'
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS' headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
@@ -11,13 +11,13 @@ module Api
@submitter = Submitter.find_by!(slug: params[:submitter_slug]) @submitter = Submitter.find_by!(slug: params[:submitter_slug])
unless can_upload?(@submitter) unless can_upload?(@submitter)
Rollbar.error("Can't upload: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
end end
file = params[:file]
if params[:type].in?(%w[initials signature]) if params[:type].in?(%w[initials signature])
image = Vips::Image.new_from_file(params[:file].path) image = ImageUtils.load_vips(file.read, content_type: file.content_type)
if ImageUtils.blank?(image) if ImageUtils.blank?(image)
Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar) Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar)
@@ -31,9 +31,11 @@ module Api
return render json: { error: "#{params[:type]} error, try to sign on another device" }, return render json: { error: "#{params[:type]} error, try to sign on another device" },
status: :unprocessable_content status: :unprocessable_content
end end
metadata = { analyzed: true, identified: true, width: image.width, height: image.height }
end end
attachment = Submitters.create_attachment!(@submitter, params) attachment = Submitters.create_attachment!(@submitter, file, metadata:)
if params[:remember_signature] == 'true' && @submitter.email.present? if params[:remember_signature] == 'true' && @submitter.email.present?
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment) cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
@@ -9,7 +9,7 @@ module Api
(@submission.schema_documents || @submission.template.schema_documents).size > 1 (@submission.schema_documents || @submission.template.schema_documents).size > 1
documents = documents =
if @submission.submitters.all?(&:completed_at?) if @submission.completed_at?
build_completed_documents(@submission, merge: is_merge) build_completed_documents(@submission, merge: is_merge)
else else
build_preview_documents(@submission, merge: is_merge) build_preview_documents(@submission, merge: is_merge)
@@ -30,7 +30,7 @@ module Api
private private
def build_completed_documents(submission, merge: false) def build_completed_documents(submission, merge: false)
last_submitter = submission.submitters.max_by(&:completed_at) last_submitter = submission.submitters.select(&:completed_at?).max_by(&:completed_at)
if merge if merge
if submission.merged_document_attachment.blank? if submission.merged_document_attachment.blank?
@@ -5,7 +5,7 @@ module Api
load_and_authorize_resource :submission, parent: false load_and_authorize_resource :submission, parent: false
def index def index
submissions = build_completed_query(@submissions) submissions = @submissions.active.where.not(completed_at: nil)
params[:after] = Time.zone.at(params[:after].to_i) if params[:after].present? params[:after] = Time.zone.at(params[:after].to_i) if params[:after].present?
params[:before] = Time.zone.at(params[:before].to_i) if params[:before].present? params[:before] = Time.zone.at(params[:before].to_i) if params[:before].present?
@@ -36,20 +36,5 @@ module Api
} }
} }
end end
private
def build_completed_query(submissions)
submissions = submissions.where(
Submitter.where(completed_at: nil).where(
Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
).select(1).arel.exists.not
)
submissions.joins(:submitters)
.group(:id)
.select(Submission.arel_table[Arel.star],
Submitter.arel_table[:completed_at].maximum.as('completed_at'))
end
end end
end end
+86 -15
View File
@@ -2,21 +2,39 @@
module Api module Api
class SubmissionsController < ApiBaseController class SubmissionsController < ApiBaseController
SUBMISSION_COLUMNS = %i[id name slug source submitters_order expire_at completed_at created_at updated_at
archived_at variables template_id template_submitters created_by_user_id].freeze
TEMPLATE_COLUMNS = %i[id name external_id created_at updated_at folder_id submitters].freeze
load_and_authorize_resource :template, only: :create load_and_authorize_resource :template, only: :create
load_and_authorize_resource :submission, only: %i[show index destroy] load_and_authorize_resource :submission, only: %i[show index update destroy]
before_action only: :create do before_action only: :create do
authorize!(:create, Submission) authorize!(:create, Submission)
end end
before_action :maybe_return_template_error, only: :create
def index def index
submissions = Submissions.search(current_user, @submissions, params[:q]) submissions = Submissions.search(current_user, @submissions, params[:q])
submissions = filter_submissions(submissions, params) submissions = filter_submissions(submissions, params)
submissions = paginate(submissions.preload(:created_by_user, :submitters, with_fields = params[:include].to_s.include?('fields') || params[:include].to_s.include?('combined_document_url')
template: { folder: :parent_folder },
combined_document_attachment: :blob, submissions = paginate(
audit_trail_attachment: :blob)) submissions.select(with_fields ? nil : SUBMISSION_COLUMNS)
.preload(:created_by_user, :submitters, combined_document_attachment: :blob,
audit_trail_attachment: :blob)
)
ActiveRecord::Associations::Preloader.new(
records: submissions,
associations: :template,
scope: with_fields ? nil : Template.select(TEMPLATE_COLUMNS)
).call
ActiveRecord::Associations::Preloader.new(records: submissions.filter_map(&:template),
associations: { folder: :parent_folder }).call
expires_at = Accounts.link_expires_at(current_account) expires_at = Accounts.link_expires_at(current_account)
@@ -42,7 +60,7 @@ module Api
end end
end end
if @submission.audit_trail_attachment.blank? && submitters.all?(&:completed_at?) if @submission.audit_trail_attachment.blank? && @submission.completed_at?
@submission.audit_trail_attachment = Submissions::EnsureAuditGenerated.call(@submission) @submission.audit_trail_attachment = Submissions::EnsureAuditGenerated.call(@submission)
end end
@@ -52,14 +70,6 @@ module Api
def create def create
Params::SubmissionCreateValidator.call(params) Params::SubmissionCreateValidator.call(params)
return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil?
if @template.fields.blank?
Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
return render json: { error: 'Template does not contain fields' }, status: :unprocessable_content
end
params[:send_email] = true unless params.key?(:send_email) params[:send_email] = true unless params.key?(:send_email)
params[:send_sms] = false unless params.key?(:send_sms) params[:send_sms] = false unless params.key?(:send_sms)
@@ -70,10 +80,17 @@ module Api
Submissions.send_signature_requests(submissions) Submissions.send_signature_requests(submissions)
submissions.each do |submission| submissions.each do |submission|
if submission.submitters.all? { |s| s.viewer? || s.completed_at? } &&
Submissions.maybe_update_completed_at(submission)
last_submitter = submission.submitters.reject(&:viewer?).max_by(&:completed_at)
end
submission.submitters.each do |submitter| submission.submitters.each do |submitter|
next unless submitter.completed_at? next unless submitter.completed_at?
ProcessSubmitterCompletionJob.perform_async('submitter_id' => submitter.id, 'send_invitation_email' => false) ProcessSubmitterCompletionJob.perform_async('submitter_id' => submitter.id,
'is_last' => submitter == last_submitter,
'send_invitation_email' => false)
end end
end end
@@ -87,6 +104,25 @@ module Api
render json: { error: e.message }, status: :unprocessable_content render json: { error: e.message }, status: :unprocessable_content
end end
def update
@submission = assign_submission_attrs(@submission, submission_params)
@submission.save!
if @submission.saved_change_to_archived_at? && @submission.archived_at?
WebhookUrls.enqueue_events(@submission, 'submission.archived')
end
if @submission.saved_change_to_expire_at? && @submission.expire_at?
ProcessSubmissionExpiredJob.perform_at(@submission.expire_at, 'submission_id' => @submission.id,
'expire_at' => @submission.expire_at.to_i)
end
SearchEntries.enqueue_reindex(@submission) if @submission.saved_change_to_name?
render json: Submissions::SerializeForApi.call(@submission, nil, params, with_events: false)
end
def destroy def destroy
if params[:permanently].in?(['true', true]) if params[:permanently].in?(['true', true])
@submission.destroy! @submission.destroy!
@@ -101,6 +137,41 @@ module Api
private private
def assign_submission_attrs(submission, attrs)
archived = attrs.key?(:archived) ? attrs[:archived] : attrs[:archived_at]
if archived.in?([true, false, 'true', 'false']) && current_ability.can?(:destroy, submission)
submission.archived_at = archived.in?(Submitters::TRUE_VALUES) ? Time.current : nil
end
submission.name = attrs[:name] if attrs.key?(:name)
submission.expire_at = attrs[:expire_at].presence if attrs.key?(:expire_at)
submission
end
def submission_params
submission_params = params.key?(:submission) ? params.require(:submission) : params
submission_params.permit(:name, :expire_at, :archived, :archived_at)
end
def maybe_return_template_error
return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil?
if @template.archived_at?
Rollbar.warning("Archived template submission: #{@template.id}") if defined?(Rollbar)
return render json: { error: 'Template has been archived' }, status: :unprocessable_content
end
return if @template.fields.present?
Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
render json: { error: 'Template does not contain fields' }, status: :unprocessable_content
end
def filter_submissions(submissions, params) def filter_submissions(submissions, params)
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present? submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
submissions = submissions.where(slug: params[:slug]) if params[:slug].present? submissions = submissions.where(slug: params[:slug]) if params[:slug].present?
+17 -11
View File
@@ -4,6 +4,8 @@ module Api
class SubmittersController < ApiBaseController class SubmittersController < ApiBaseController
load_and_authorize_resource :submitter load_and_authorize_resource :submitter
before_action :maybe_return_submitter_error, only: :update
def index def index
submitters = Submitters.search(current_user, @submitters, params[:q]) submitters = Submitters.search(current_user, @submitters, params[:q])
@@ -36,14 +38,6 @@ module Api
# rubocop:disable Metrics/MethodLength # rubocop:disable Metrics/MethodLength
def update def update
if @submitter.completed_at?
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content
end
if @submitter.declined_at?
return render json: { error: 'Submitter has already declined the submission.' }, status: :unprocessable_content
end
submission = @submitter.submission submission = @submitter.submission
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name'] role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
@@ -73,7 +67,9 @@ module Api
end end
if @submitter.completed_at? if @submitter.completed_at?
ProcessSubmitterCompletionJob.perform_async('submitter_id' => @submitter.id) is_last = Submissions.maybe_update_completed_at(@submitter.submission)
ProcessSubmitterCompletionJob.perform_async('submitter_id' => @submitter.id, 'is_last' => is_last)
elsif normalized_params[:send_email] || normalized_params[:send_sms] elsif normalized_params[:send_email] || normalized_params[:send_sms]
Submitters.send_signature_requests([@submitter]) Submitters.send_signature_requests([@submitter])
end end
@@ -104,7 +100,17 @@ module Api
private private
def maybe_filder_by_completed_at(submitters, params) def maybe_return_submitter_error
if @submitter.completed_at? || @submitter.submission.completed_at?
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content
end
return unless @submitter.declined_at?
render json: { error: 'Submitter has already declined the submission.' }, status: :unprocessable_content
end
def maybe_filter_by_completed_at(submitters, params)
if params[:completed_after].present? if params[:completed_after].present?
submitters = submitters.where(completed_at: Time.zone.parse(params[:completed_after])..) submitters = submitters.where(completed_at: Time.zone.parse(params[:completed_after])..)
end end
@@ -177,7 +183,7 @@ module Api
submitters = submitters.joins(:submission).where(submissions: { template_id: params[:template_id] }) submitters = submitters.joins(:submission).where(submissions: { template_id: params[:template_id] })
end end
maybe_filder_by_completed_at(submitters, params) maybe_filter_by_completed_at(submitters, params)
end end
def assign_external_id(submitter, attrs) def assign_external_id(submitter, attrs)
+52 -18
View File
@@ -5,24 +5,13 @@ module Api
load_and_authorize_resource :template load_and_authorize_resource :template
def index def index
@templates = Templates.shared(current_user) if params[:shared].in?(['true', true])
templates = filter_templates(@templates, params) templates = filter_templates(@templates, params)
templates = paginate(templates.preload(:author, folder: :parent_folder)) templates = paginate(templates.preload(:author, folder: :parent_folder))
schema_documents = schema_documents, dynamic_documents, preview_image_attachments = preload_relations(templates)
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
record_type: 'Template',
name: :documents,
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
.preload(:blob)
preview_image_attachments =
ActiveStorage::Attachment.joins(:blob)
.where(blob: { filename: ['0.png', '0.jpg'] })
.where(record_id: schema_documents.map(&:id),
record_type: 'ActiveStorage::Attachment',
name: :preview_images)
.preload(:blob)
expires_at = Accounts.link_expires_at(current_account) expires_at = Accounts.link_expires_at(current_account)
@@ -30,6 +19,7 @@ module Api
data: templates.map do |t| data: templates.map do |t|
Templates::SerializeForApi.call(t, Templates::SerializeForApi.call(t,
schema_documents: schema_documents.select { |e| e.record_id == t.id }, schema_documents: schema_documents.select { |e| e.record_id == t.id },
dynamic_documents:,
preview_image_attachments:, preview_image_attachments:,
expires_at:) expires_at:)
end, end,
@@ -60,16 +50,19 @@ module Api
archived = params.key?(:archived) ? params[:archived] : params.dig(:template, :archived) archived = params.key?(:archived) ? params[:archived] : params.dig(:template, :archived)
if archived.in?([true, false]) if archived.in?([true, false]) && current_ability.can?(:destroy, @template)
@template.archived_at = archived == true ? Time.current : nil @template.archived_at = archived == true ? Time.current : nil
end end
@template.update!(template_params) @template.update!(template_params)
SearchEntries.enqueue_reindex(@template) SearchEntries.enqueue_reindex(@template) if @template.saved_change_to_name?
WebhookUrls.enqueue_events(@template, 'template.updated') WebhookUrls.enqueue_events(@template, 'template.updated')
WebhookUrls.enqueue_events(@template, 'template.archived') if archived == true
if @template.saved_change_to_archived_at? && @template.archived_at?
WebhookUrls.enqueue_events(@template, 'template.archived')
end
render json: @template.as_json(only: %i[id updated_at]) render json: @template.as_json(only: %i[id updated_at])
end end
@@ -88,8 +81,49 @@ module Api
private private
def preload_relations(templates)
schema_documents =
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
record_type: 'Template',
name: :documents,
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
.preload(:blob)
dynamic_document_uuids =
templates.flat_map { |t| t.schema.select { |item| item['dynamic'] }.pluck('attachment_uuid') }
dynamic_documents =
if dynamic_document_uuids.present?
DynamicDocument.where(template: templates.map(&:id))
.where(uuid: dynamic_document_uuids)
.preload(current_version: { document_attachment: :blob })
.select(:id, :uuid, :template_id, :sha1, :created_at, :updated_at)
else
DynamicDocument.none
end
preview_attachment_ids =
schema_documents.map(&:id) + dynamic_documents.filter_map { |d| d.current_version&.document_attachment&.id }
preview_image_attachments =
ActiveStorage::Attachment.joins(:blob)
.where(blob: { filename: ['0.png', '0.jpg'] })
.where(record_id: preview_attachment_ids,
record_type: 'ActiveStorage::Attachment',
name: :preview_images)
.preload(:blob)
[schema_documents, dynamic_documents, preview_image_attachments]
end
def filter_templates(templates, params) def filter_templates(templates, params)
templates = Templates.search(current_user, templates, params[:q]) templates =
if params[:shared].in?(['true', true])
Templates.search_shared(current_user, templates, params[:q])
else
Templates.search(current_user, templates, params[:q])
end
templates = params[:archived].in?(['true', true]) ? templates.archived : templates.active templates = params[:archived].in?(['true', true]) ? templates.archived : templates.active
templates = templates.where(external_id: params[:application_key]) if params[:application_key].present? templates = templates.where(external_id: params[:application_key]) if params[:application_key].present?
templates = templates.where(external_id: params[:external_id]) if params[:external_id].present? templates = templates.where(external_id: params[:external_id]) if params[:external_id].present?
@@ -3,7 +3,7 @@
class EmailSmtpSettingsController < ApplicationController class EmailSmtpSettingsController < ApplicationController
before_action :load_encrypted_config before_action :load_encrypted_config
authorize_resource :encrypted_config, only: :index authorize_resource :encrypted_config, only: :index
authorize_resource :encrypted_config, parent: false, only: :create authorize_resource :encrypted_config, parent: false, only: %i[create destroy]
def index; end def index; end
@@ -23,6 +23,12 @@ class EmailSmtpSettingsController < ApplicationController
render :index, status: :unprocessable_content render :index, status: :unprocessable_content
end end
def destroy
@encrypted_config.destroy!
redirect_to settings_email_index_path, notice: I18n.t('smtp_settings_have_been_reset')
end
private private
def load_encrypted_config def load_encrypted_config
+1 -2
View File
@@ -14,8 +14,7 @@ class EsignSettingsController < ApplicationController
prepend_before_action :maybe_redirect_com, only: %i[show] prepend_before_action :maybe_redirect_com, only: %i[show]
before_action :load_encrypted_config before_action :load_encrypted_config
authorize_resource :encrypted_config, parent: false, only: %i[new create] authorize_resource :encrypted_config, parent: false
authorize_resource :encrypted_config, only: %i[update destroy show]
def show def show
cert_data = @encrypted_config.value || {} cert_data = @encrypted_config.value || {}
@@ -0,0 +1,90 @@
# frozen_string_literal: true
module Mcp
class CreateTemplateController < McpBaseController
SCHEMA = {
name: 'create_template',
title: 'Create Template',
description: 'Create a document template. Provide a URL to upload a PDF/DOCX file, or provide only a name ' \
'to create an empty template and receive an edit URL where the file can be uploaded via the UI.',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Template name (used as the template name and required when url is not provided)'
},
url: {
type: 'string',
description: 'Optional URL of a PDF or DOCX file to upload. If omitted, an empty template is ' \
'created and the returned edit_url can be used to upload a file via the UI.'
}
},
required: %w[name]
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true
}
}.freeze
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def call
account = current_user.account
@template = Template.new(
account:,
author: current_user,
folder: account.default_template_folder,
source: :mcp,
name: mcp_params['name'].to_s.presence || 'New Template',
fields: [],
schema: []
)
authorize!(:create, @template)
if mcp_params['url'].present?
tempfile = Tempfile.new
tempfile.binmode
tempfile.write(DownloadUtils.call(mcp_params['url'], validate: true).body)
tempfile.rewind
filename = File.basename(URI.decode_www_form_component(mcp_params['url']))
file = ActionDispatch::Http::UploadedFile.new(
tempfile:,
filename:,
type: Marcel::MimeType.for(tempfile)
)
@template.name = mcp_params['name'].presence || File.basename(filename, '.*')
@template.save!
documents, = Templates::CreateAttachments.call(@template, { files: [file] }, extract_fields: true)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
if @template.fields.blank?
@template.fields = Templates::ProcessDocument.normalize_attachment_fields(@template, documents)
end
@template.update!(schema:)
else
@template.save!
end
WebhookUrls.enqueue_events(@template, 'template.created')
SearchEntries.enqueue_reindex(@template)
render_tool_result(
id: @template.id,
name: @template.name,
edit_url: edit_template_url(@template)
)
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
end
end
@@ -0,0 +1,54 @@
# frozen_string_literal: true
module Mcp
class LoadTemplateController < McpBaseController
SCHEMA = {
name: 'load_template',
title: 'Load Template',
description: 'Load a template with its fields. Each field includes name, type, and the signing role name.',
inputSchema: {
type: 'object',
properties: {
template_id: {
type: 'integer',
description: 'Template identifier'
}
},
required: %w[template_id]
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
}.freeze
def call
@template = Template.accessible_by(current_ability).find(mcp_params['template_id'])
authorize!(:read, @template)
submitters_index = @template.submitters.index_by { |s| s['uuid'] }
roles = @template.submitters.pluck('name')
fields = @template.fields.filter_map do |field|
next if field['name'].blank?
{
name: field['name'],
type: field['type'],
role: submitters_index[field['submitter_uuid']]&.dig('name')
}
end
render_tool_result(
id: @template.id,
name: @template.name,
roles: roles,
fields: fields
)
end
end
end
@@ -0,0 +1,81 @@
# frozen_string_literal: true
module Mcp
class McpBaseController < ActionController::API
wrap_parameters false
before_action :authenticate_user!
before_action :verify_mcp_enabled!
check_authorization
before_action do
raise CanCan::AccessDenied unless can?(:manage, :mcp)
end
rescue_from CanCan::AccessDenied do
render_error(-32_603, 'Forbidden', status: :forbidden)
end
rescue_from ActiveRecord::RecordNotFound do
render_tool_error('Not found')
end
private
def default_url_options
Docuseal.default_url_options
end
def mcp_body
request.request_parameters
end
def mcp_params
mcp_body.dig('params', 'arguments') || {}
end
def render_result(result)
render json: { jsonrpc: '2.0', id: mcp_body['id'], result: }
end
def render_error(code, message, id: nil, status: :ok)
render json: { jsonrpc: '2.0', id:, error: { code:, message: } }, status:
end
def render_tool_result(data)
render_result(content: [{ type: 'text', text: data.to_json }])
end
def render_tool_error(message)
render_result(content: [{ type: 'text', text: message }], isError: true)
end
def authenticate_user!
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
end
def verify_mcp_enabled!
return if Docuseal.multitenant?
return if AccountConfig.exists?(account_id: current_user.account_id,
key: AccountConfig::ENABLE_MCP_KEY,
value: true)
render json: { error: 'MCP is disabled' }, status: :forbidden
end
def current_user
@current_user ||= user_from_api_key
end
def user_from_api_key
token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
return if token.blank?
sha256 = Digest::SHA256.hexdigest(token)
User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil })
end
end
end
@@ -0,0 +1,50 @@
# frozen_string_literal: true
module Mcp
class ProtocolController < McpBaseController
skip_authorization_check
def ok
head :ok
end
def initialize_request
render_result(
protocolVersion: '2025-11-25',
serverInfo: {
name: 'DocuSeal',
version: Docuseal.version.to_s
},
capabilities: {
tools: {
listChanged: false
}
}
)
end
def initialized_notification
head :accepted
end
def ping
render_result({})
end
def tools_list
render_result(tools: McpController::TOOLS)
end
def method_not_found
render_error(-32_601, "Method not found: #{mcp_body['method']}", id: mcp_body['id'])
end
def tool_not_found
render_error(-32_602, "Unknown tool: #{mcp_body.dig('params', 'name')}", id: mcp_body['id'])
end
def parse_error
render_error(-32_700, 'Parse error', status: :bad_request)
end
end
end
@@ -0,0 +1,59 @@
# frozen_string_literal: true
module Mcp
class SearchDocumentsController < McpBaseController
SCHEMA = {
name: 'search_documents',
title: 'Search Documents',
description: 'Search signed or pending documents by submitter name, email, phone, or template name',
inputSchema: {
type: 'object',
properties: {
q: {
type: 'string',
description: 'Search by submitter name, email, phone, or template name'
},
limit: {
type: 'integer',
description: 'The number of results to return (default 10)'
}
},
required: %w[q]
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
}.freeze
def call
authorize!(:read, Submission)
submissions = Submissions.search(current_user, Submission.accessible_by(current_ability).active,
mcp_params['q'], search_template: true)
limit = mcp_params.fetch('limit', 10).to_i
limit = 10 if limit <= 0
limit = [limit, 100].min
submissions = submissions.preload(:submitters, :template)
.order(id: :desc)
.limit(limit)
data = submissions.map do |submission|
{
id: submission.id,
template_name: submission.template&.name,
status: Submissions::SerializeForApi.build_status(submission, submission.submitters),
submitters: submission.submitters.map do |s|
{ email: s.email, name: s.name, phone: s.phone, status: s.status }
end,
documents_url: submission_url(submission.id)
}
end
render_tool_result(data)
end
end
end
@@ -0,0 +1,44 @@
# frozen_string_literal: true
module Mcp
class SearchTemplatesController < McpBaseController
SCHEMA = {
name: 'search_templates',
title: 'Search Templates',
description: 'Search document templates by name',
inputSchema: {
type: 'object',
properties: {
q: {
type: 'string',
description: 'Search query to filter templates by name'
},
limit: {
type: 'integer',
description: 'The number of templates to return (default 10)'
}
},
required: %w[q]
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
}.freeze
def call
authorize!(:read, Template)
templates = Templates.search(current_user, Template.accessible_by(current_ability).active, mcp_params['q'])
limit = mcp_params.fetch('limit', 10).to_i
limit = 10 if limit <= 0
limit = [limit, 100].min
templates = templates.order(id: :desc).limit(limit)
render_tool_result(templates.map { |t| { id: t.id, name: t.name } })
end
end
end
@@ -0,0 +1,120 @@
# frozen_string_literal: true
module Mcp
class SendDocumentsController < McpBaseController
SCHEMA = {
name: 'send_documents',
title: 'Send Documents',
description: 'Send a document template for signing to specified submitters',
inputSchema: {
type: 'object',
properties: {
template_id: {
type: 'integer',
description: 'Template identifier'
},
submitters: {
type: 'array',
description: 'The list of submitters (signers)',
items: {
type: 'object',
properties: {
email: {
type: 'string',
description: 'Submitter email address'
},
name: {
type: 'string',
description: 'Submitter name'
},
phone: {
type: 'string',
description: 'Submitter phone number in E.164 format'
},
role: {
type: 'string',
description: 'Signing role name from the template'
},
fields: {
type: 'array',
description: 'Prefill field values for this submitter (fields become readonly)',
items: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Field name'
},
value: {
description: 'Prefilled value for the field'
}
},
required: %w[name value]
}
}
}
}
}
},
required: %w[template_id submitters]
},
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: true
}
}.freeze
# rubocop:disable Metrics
def call
@template = Template.accessible_by(current_ability).find(mcp_params['template_id'])
authorize!(:read, @template)
return render_tool_error('Template has been archived') if @template.archived_at?
authorize!(:create, Submission.new(template: @template, account_id: current_user.account_id))
return render_tool_error('Template has no fields') if @template.fields.blank?
submitters = (mcp_params['submitters'] || []).map do |s|
attrs = s.slice('email', 'name', 'role', 'phone').compact_blank
fields = Array.wrap(s['fields']).filter_map do |f|
next if f['name'].blank?
{ 'name' => f['name'], 'default_value' => f['value'], 'readonly' => true }
end
attrs['fields'] = fields if fields.present?
attrs.with_indifferent_access
end
submissions = Submissions.create_from_submitters(
template: @template,
user: current_user,
source: :mcp,
submitters_order: @template.preferences['submitters_order'].presence || 'random',
submissions_attrs: { submitters: },
params: { 'send_email' => true, 'submitters' => submitters }
)
return render_tool_error('No valid submitters provided') if submissions.blank?
WebhookUrls.enqueue_events(submissions, 'submission.created')
Submissions.send_signature_requests(submissions)
SearchEntries.enqueue_reindex(submissions)
submission = submissions.first
render_tool_result(id: submission.id, status: 'pending')
rescue Submissions::CreateFromSubmitters::BaseError => e
render_tool_error(e.message)
end
# rubocop:enable Metrics
end
end
+31 -45
View File
@@ -1,58 +1,44 @@
# frozen_string_literal: true # frozen_string_literal: true
class McpController < ActionController::API class McpController < ActionController::Metal
before_action :authenticate_user! TOOL_CONTROLLERS = {
before_action :verify_mcp_enabled! 'search_templates' => Mcp::SearchTemplatesController,
'load_template' => Mcp::LoadTemplateController,
'create_template' => Mcp::CreateTemplateController,
'send_documents' => Mcp::SendDocumentsController,
'search_documents' => Mcp::SearchDocumentsController
}.freeze
before_action do TOOLS = TOOL_CONTROLLERS.map { |_, controller| controller::SCHEMA }.freeze
authorize!(:manage, :mcp)
end
def call def call
return head :ok if request.raw_post.blank? return Mcp::ProtocolController.dispatch(:ok, request, response) if request.raw_post.blank?
body = JSON.parse(request.raw_post) body = JSON.parse(request.raw_post)
body = nil unless body.is_a?(Hash)
result = Mcp::HandleRequest.call(body, current_user, current_ability) request.request_parameters = body || {}
if result action =
render json: result case body&.dig('method')
else when 'initialize' then :initialize_request
head :accepted when 'notifications/initialized' then :initialized_notification
end when 'ping' then :ping
rescue CanCan::AccessDenied when 'tools/list' then :tools_list
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_603, message: 'Forbidden' } }, status: :forbidden when 'tools/call'
tool = TOOL_CONTROLLERS[body.dig('params', 'name')]
return tool.dispatch(:call, request, response) if tool
:tool_not_found
else
:method_not_found
end
Mcp::ProtocolController.dispatch(action, request, response)
rescue JSON::ParserError rescue JSON::ParserError
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_700, message: 'Parse error' } }, status: :bad_request request.request_parameters = {}
end
private Mcp::ProtocolController.dispatch(:parse_error, request, response)
def authenticate_user!
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
end
def verify_mcp_enabled!
return if Docuseal.multitenant?
return if AccountConfig.exists?(account_id: current_user.account_id,
key: AccountConfig::ENABLE_MCP_KEY,
value: true)
render json: { error: 'MCP is disabled' }, status: :forbidden
end
def current_user
@current_user ||= user_from_api_key
end
def user_from_api_key
token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
return if token.blank?
sha256 = Digest::SHA256.hexdigest(token)
User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil })
end end
end end
+4
View File
@@ -20,6 +20,8 @@ class MfaSetupController < ApplicationController
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_configured') redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_configured')
else else
RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true)
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name) @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
@error_message = I18n.t('code_is_invalid') @error_message = I18n.t('code_is_invalid')
@@ -34,6 +36,8 @@ class MfaSetupController < ApplicationController
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_removed') redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_removed')
else else
RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true)
@error_message = I18n.t('code_is_invalid') @error_message = I18n.t('code_is_invalid')
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_content render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_content
@@ -8,6 +8,7 @@ class NotificationsSettingsController < ApplicationController
before_action :build_account_config, only: :create before_action :build_account_config, only: :create
authorize_resource :account_config, only: :create authorize_resource :account_config, only: :create
before_action :authorize_email_reminders!, only: :create
def index; end def index; end
@@ -21,6 +22,14 @@ class NotificationsSettingsController < ApplicationController
private private
def authorize_email_reminders!
return unless Docuseal.multitenant?
return if @account_config.key != AccountConfig::SUBMITTER_REMINDERS
return if can?(:manage, :email_reminders)
redirect_back fallback_location: settings_notifications_path, alert: I18n.t('unlock_with_docuseal_pro')
end
def build_account_config def build_account_config
@account_config = @account_config =
AccountConfig.find_or_initialize_by(account: current_account, key: email_config_params[:key]) AccountConfig.find_or_initialize_by(account: current_account, key: email_config_params[:key])
@@ -5,6 +5,10 @@ class PreviewDocumentPageController < ActionController::API
FORMAT = Templates::ProcessDocument::FORMAT FORMAT = Templates::ProcessDocument::FORMAT
TMPFILE_PREFIX = 'attachment-'
TMPFILE_TTL = 5.minutes
TMPFILE_MAX_TOTAL_SIZE = Docuseal.multitenant? ? 400.megabytes : 1.gigabyte
def show def show
result_data = result_data =
ApplicationRecord.signed_id_verifier.verified(params[:signed_key], purpose: :attachment) ApplicationRecord.signed_id_verifier.verified(params[:signed_key], purpose: :attachment)
@@ -25,36 +29,68 @@ class PreviewDocumentPageController < ActionController::API
preview_image = attachment.preview_images.joins(:blob) preview_image = attachment.preview_images.joins(:blob)
.find_by(blob: { filename: ["#{params[:id]}.png", "#{params[:id]}.jpg"] }) .find_by(blob: { filename: ["#{params[:id]}.png", "#{params[:id]}.jpg"] })
return redirect_to preview_image.url, allow_other_host: true if preview_image if preview_image
return redirect_to preview_image.url(time: ActiveStorage::Attachment.service_url_time),
file_path = allow_other_host: true
if attachment.service.name == :disk
ActiveStorage::Blob.service.path_for(attachment.key)
else
find_or_create_document_tempfile_path(attachment)
end
preview_image =
Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
redirect_to preview_image.url, allow_other_host: true
end
def find_or_create_document_tempfile_path(attachment)
file_path = "#{Dir.tmpdir}/attachment-#{Digest::SHA1.hexdigest("#{attachment.id}-#{attachment.uuid}")}"
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f|
f.flock(File::LOCK_EX)
# rubocop:disable Style/ZeroLengthPredicate
if f.size.zero?
f.binmode
f.write(attachment.download)
end
# rubocop:enable Style/ZeroLengthPredicate
end end
file_path preview_image =
open_attachment_io(attachment) do |io|
Templates::ProcessDocument.generate_pdf_preview_from_io(attachment, io, params[:id].to_i)
end
redirect_to preview_image.url(time: ActiveStorage::Attachment.service_url_time), allow_other_host: true
end
def open_attachment_io(attachment, &)
return File.open(ActiveStorage::Blob.service.path_for(attachment.key), 'rb', &) if attachment.service.name == :disk
file_name = "#{TMPFILE_PREFIX}#{Digest::SHA1.hexdigest("#{attachment.id}-#{attachment.uuid}")}"
file_path = File.join(Dir.tmpdir, file_name)
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |file|
file.flock(File::LOCK_EX)
# rubocop:disable Style/ZeroLengthPredicate
if file.size.zero?
cleanup_stale_tempfiles
file.binmode
file.write(attachment.download)
else
FileUtils.touch(file_path)
end
# rubocop:enable Style/ZeroLengthPredicate
file.flock(File::LOCK_UN)
yield file
end
end
def cleanup_stale_tempfiles
entries =
Dir.glob(File.join(Dir.tmpdir, "#{TMPFILE_PREFIX}*")).filter_map do |path|
stat = File.stat(path)
[path, stat.mtime, stat.size]
rescue Errno::ENOENT
nil
end
total_size = entries.sum(&:last)
stale_time = TMPFILE_TTL.ago
entries.sort_by(&:second).each do |path, mtime, size|
break if mtime > stale_time && total_size <= TMPFILE_MAX_TOTAL_SIZE
File.unlink(path)
total_size -= size
rescue Errno::ENOENT
nil
end
end end
end end
@@ -16,18 +16,16 @@ class SendSubmissionEmailController < ApplicationController
@submitter = @submitter =
Submitter.completed.where(submission: template.submissions).find_by(email: params[:email].to_s.downcase) Submitter.completed.where(submission: template.submissions).find_by(email: params[:email].to_s.downcase)
elsif params[:submission_slug] elsif params[:submission_slug]
submission = Submission.find_by(slug: params[:submission_slug]) submission = Submission.find_by!(slug: params[:submission_slug])
if submission @submitter = submission.submitters.order(:completed_at).find_by(email: params[:email].to_s.downcase)
@submitter = Submitter.completed.find_by(submission: submission, email: params[:email].to_s.downcase)
end
return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter
else else
@submitter = Submitter.completed.find_by!(slug: params[:submitter_slug]) @submitter = Submitter.find_by!(slug: params[:submitter_slug])
end end
if @submitter if @submitter && completed_submitter?(@submitter)
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes) RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter) SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
@@ -41,6 +39,10 @@ class SendSubmissionEmailController < ApplicationController
private private
def completed_submitter?(submitter)
submitter.completed_at? || (submitter.viewer? && submitter.submission.completed_at?)
end
def can_send?(submitter) def can_send?(submitter)
return false if submitter.account.archived_at? return false if submitter.account.archived_at?
return false if EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter, return false if EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
+7
View File
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class SettingsController < ApplicationController
skip_authorization_check
def index; end
end
+21 -6
View File
@@ -93,20 +93,34 @@ class StartFormController < ApplicationController
SearchEntries.enqueue_reindex(submitter) SearchEntries.enqueue_reindex(submitter)
return unless submitter.submission.expire_at? expire_at = submitter.submission.expire_at
ProcessSubmissionExpiredJob.perform_at(submitter.submission.expire_at, 'submission_id' => submitter.submission_id) return unless expire_at
ProcessSubmissionExpiredJob.perform_at(expire_at, 'submission_id' => submitter.submission_id,
'expire_at' => expire_at.to_i)
end end
def load_resubmit_submitter def load_resubmit_submitter
@resubmit_submitter = @resubmit_submitter =
if params[:resubmit].present? && !params[:resubmit].in?([true, 'true']) if params[:resubmit].present? && !params[:resubmit].in?([true, 'true'])
Submitter.find_by(slug: params[:resubmit]) submitter = Submitter.find_by(slug: params[:resubmit])
submitter if submitter && can_resubmit?(submitter)
end end
end end
def can_resubmit?(submitter)
submitter.completed_at? && submitter.completed_at > 14.days.ago &&
%w[api embed mcp].exclude?(submitter.submission.source) &&
submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false
end
def authorize_start! def authorize_start!
return redirect_to start_form_path(@template.slug) if @template.archived_at? is_archived = @template.archived_at? || @template.account.archived_at?
return redirect_to submit_form_path(@resubmit_submitter.slug) if @resubmit_submitter && is_archived
return redirect_to start_form_path(@template.slug) if is_archived
return if @resubmit_submitter return if @resubmit_submitter
return if @template.shared_link? || (current_user && current_ability.can?(:read, @template)) return if @template.shared_link? || (current_user && current_ability.can?(:read, @template))
@@ -127,8 +141,7 @@ class StartFormController < ApplicationController
submitter ||= submitter ||=
Submitter Submitter
.where(submission: template.submissions.where(expire_at: Time.current..) .where(submission: template.submissions.non_expired.active)
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
.order(id: :desc) .order(id: :desc)
.where(declined_at: nil) .where(declined_at: nil)
.where(external_id: nil) .where(external_id: nil)
@@ -136,6 +149,8 @@ class StartFormController < ApplicationController
.then { |rel| params[:resubmit].present? || params[:selfsign].present? ? rel.where(completed_at: nil) : rel } .then { |rel| params[:resubmit].present? || params[:selfsign].present? ? rel.where(completed_at: nil) : rel }
.find_or_initialize_by(find_params) .find_or_initialize_by(find_params)
submitter = Submitter.new(find_params) if submitter.submission&.completed_at? && submitter.viewer?
submitter.name = required_params['name'] if submitter.new_record? submitter.name = required_params['name'] if submitter.new_record?
unless @resubmit_submitter unless @resubmit_submitter
@@ -6,9 +6,10 @@ class StartFormEmail2faSendController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_authorization_check skip_authorization_check
def create before_action :load_template
@template = Template.find_by!(slug: params[:slug]) before_action :authorize_start!
def create
@submitter = @template.submissions.new(account_id: @template.account_id) @submitter = @template.submissions.new(account_id: @template.account_id)
.submitters.new(**submitter_params, account_id: @template.account_id) .submitters.new(**submitter_params, account_id: @template.account_id)
@@ -25,6 +26,23 @@ class StartFormEmail2faSendController < ApplicationController
private private
def load_template
@template = Template.find_by!(slug: params[:slug])
end
def authorize_start!
is_archived = @template.archived_at? || @template.account.archived_at?
return redirect_to start_form_path(@template.slug) if is_archived
return if (@template.shared_link? || (current_user && current_ability.can?(:read, @template))) &&
@template.preferences['shared_link_2fa'] == true
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
redirect_to start_form_path(@template.slug)
end
def submitter_params def submitter_params
params.require(:submitter).permit(:name, :email, :phone) params.require(:submitter).permit(:name, :email, :phone)
end end
@@ -24,5 +24,7 @@ class SubmissionEventsController < ApplicationController
load_and_authorize_resource :submission load_and_authorize_resource :submission
def index; end def index
render :index, layout: 'plain'
end
end end
@@ -7,17 +7,27 @@ class SubmissionsArchivedController < ApplicationController
@submissions = @submissions.left_joins(:template) @submissions = @submissions.left_joins(:template)
@submissions = @submissions.where.not(archived_at: nil) @submissions = @submissions.where.not(archived_at: nil)
.or(@submissions.where.not(templates: { archived_at: nil })) .or(@submissions.where.not(templates: { archived_at: nil }))
.preload(:template_accesses, :created_by_user, template: :author) .preload(:template_accesses, :created_by_user)
@submissions = Submissions.search(current_user, @submissions, params[:q], search_template: true) @submissions = Submissions.search(current_user, @submissions, params[:q], search_template: true)
@submissions = Submissions::Filter.call(@submissions, current_user, params) @submissions = Submissions::Filter.call(@submissions, current_user, params)
@submissions = if params[:completed_at_from].present? || params[:completed_at_to].present? @submissions =
@submissions.order(Submitter.arel_table[:completed_at].maximum.desc) if params[:status] == 'completed' || params[:completed_at_from].present? || params[:completed_at_to].present?
else @submissions.order(completed_at: :desc)
@submissions.order(id: :desc) else
end @submissions.order(id: :desc)
end
@pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events)) @pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events))
template_scope = @submissions.all?(&:template_submitters) ? Template.select_for_list : nil
ActiveRecord::Associations::Preloader.new(records: @submissions,
associations: :template,
scope: template_scope).call
ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template),
associations: :author).call
end end
end end
+26 -7
View File
@@ -1,9 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class SubmissionsController < ApplicationController class SubmissionsController < ApplicationController
before_action :load_template, only: %i[new create] load_and_authorize_resource :template, only: %i[new create]
authorize_resource :template, only: %i[new create]
load_and_authorize_resource :submission, only: %i[show destroy] load_and_authorize_resource :submission, only: %i[show destroy]
prepend_before_action :maybe_redirect_com, only: %i[show] prepend_before_action :maybe_redirect_com, only: %i[show]
@@ -23,7 +21,7 @@ class SubmissionsController < ApplicationController
def show def show
@submission = Submissions.preload_with_pages(@submission) @submission = Submissions.preload_with_pages(@submission)
unless @submission.submitters.all?(&:completed_at?) unless @submission.completed_at?
ActiveRecord::Associations::Preloader.new( ActiveRecord::Associations::Preloader.new(
records: [@submission], records: [@submission],
associations: [{ submitters: :start_form_submission_events }] associations: [{ submitters: :start_form_submission_events }]
@@ -35,10 +33,14 @@ class SubmissionsController < ApplicationController
def new def new
authorize!(:new, Submission) authorize!(:new, Submission)
render :new, layout: 'plain'
end end
def create def create
save_template_message(@template, params) if params[:save_message] == '1' return redirect_to template_path(@template), alert: I18n.t('template_has_been_archived') if @template.archived_at?
save_template_message(@template, params) if params[:save_message] == '1' && can?(:update, @template)
[params.delete(:subject), params.delete(:body)] if params[:is_custom_message] != '1' [params.delete(:subject), params.delete(:body)] if params[:is_custom_message] != '1'
@@ -87,6 +89,8 @@ class SubmissionsController < ApplicationController
private private
def create_submissions(template, submissions_params, params) def create_submissions(template, submissions_params, params)
normalize_message_submitter_uuids!(params)
submissions_attrs = submissions_params[:submission].to_h.values submissions_attrs = submissions_params[:submission].to_h.values
submissions_attrs, _, new_fields = submissions_attrs, _, new_fields =
@@ -112,7 +116,22 @@ class SubmissionsController < ApplicationController
params.permit(submission: { submitters: [:uuid, :email, :phone, :name, { values: {} }] }) params.permit(submission: { submitters: [:uuid, :email, :phone, :name, { values: {} }] })
end end
def load_template def normalize_message_submitter_uuids!(params)
@template = Template.accessible_by(current_ability).find(params[:template_id]) return if params[:request_email_per_submitter] == '1'
uuids = params[:email_message_submitter_uuids]
return if uuids.blank?
return if params[:subject].blank? && params[:body].blank?
params[:submitter_preferences] =
Array.wrap(uuids).index_with { { 'subject' => params[:subject], 'body' => params[:body] } }
params[:request_email_per_submitter] = '1'
params.delete(:subject)
params.delete(:body)
params
end end
end end
@@ -8,17 +8,27 @@ class SubmissionsDashboardController < ApplicationController
@submissions = @submissions.where(archived_at: nil) @submissions = @submissions.where(archived_at: nil)
.where(templates: { archived_at: nil }) .where(templates: { archived_at: nil })
.preload(:template_accesses, :created_by_user, template: :author) .preload(:template_accesses, :created_by_user)
@submissions = Submissions.search(current_user, @submissions, params[:q], search_template: true) @submissions = Submissions.search(current_user, @submissions, params[:q], search_template: true)
@submissions = Submissions::Filter.call(@submissions, current_user, params) @submissions = Submissions::Filter.call(@submissions, current_user, params)
@submissions = if params[:completed_at_from].present? || params[:completed_at_to].present? @submissions =
@submissions.order(Submitter.arel_table[:completed_at].maximum.desc) if params[:status] == 'completed' || params[:completed_at_from].present? || params[:completed_at_to].present?
else @submissions.order(completed_at: :desc)
@submissions.order(id: :desc) else
end @submissions.order(id: :desc)
end
@pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events)) @pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events))
template_scope = @submissions.all?(&:template_submitters) ? Template.select_for_list : nil
ActiveRecord::Associations::Preloader.new(records: @submissions,
associations: :template,
scope: template_scope).call
ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template),
associations: :author).call
end end
end end
@@ -25,7 +25,7 @@ class SubmissionsPreviewController < ApplicationController
raise ActionController::RoutingError, I18n.t('not_found') if @submission.account.archived_at? raise ActionController::RoutingError, I18n.t('not_found') if @submission.account.archived_at?
if !@submission.submitters.all?(&:completed_at?) && !signature_valid && if !@submission.completed_at? && !signature_valid &&
(!current_user || !current_ability.can?(:read, @submission)) (!current_user || !current_ability.can?(:read, @submission))
raise ActionController::RoutingError, I18n.t('not_found') raise ActionController::RoutingError, I18n.t('not_found')
end end
@@ -4,7 +4,7 @@ class SubmissionsUnarchiveController < ApplicationController
load_and_authorize_resource :submission load_and_authorize_resource :submission
def create def create
authorize!(:update, @submission) authorize!(:destroy, @submission)
@submission.update!(archived_at: nil) @submission.update!(archived_at: nil)
@@ -10,29 +10,26 @@ class SubmitFormCompletedDownloadController < ApplicationController
def index def index
@submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present? @submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
signature_valid = signature_valid = @submitter&.slug == submitter_slug
if @submitter&.slug == submitter_slug
true
else
@submitter = nil
end
@submitter ||= Submitter.find_by!(slug: submitter_slug) @submitter = Submitter.find_by!(slug: submitter_slug) unless signature_valid
Submissions::EnsureResultGenerated.call(@submitter) unless completed_submitter?(@submitter)
Rollbar.error("Not completed: #{@submitter.id}") if defined?(Rollbar)
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last return head :not_found
end
Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at?
last_submitter = @submitter.submission.submitters.completed.order(:completed_at).last
return head :not_found unless last_submitter return head :not_found unless last_submitter
Submissions::EnsureResultGenerated.call(last_submitter) Submissions::EnsureResultGenerated.call(last_submitter)
if !signature_valid && !current_user_submitter?(last_submitter) if !signature_valid && !current_user_submitter?(last_submitter)
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request) return head :not_found unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
if last_submitter.completed_at < TTL.ago if last_submitter.completed_at < TTL.ago
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar) Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
@@ -64,6 +61,10 @@ class SubmitFormCompletedDownloadController < ApplicationController
end end
end end
def completed_submitter?(submitter)
submitter.completed_at? || (submitter.viewer? && submitter.submission.completed_at?)
end
def current_user_submitter?(submitter) def current_user_submitter?(submitter)
current_user && current_ability.can?(:read, submitter) current_user && current_ability.can?(:read, submitter)
end end
+10 -1
View File
@@ -18,7 +18,10 @@ class SubmitFormController < ApplicationController
submission = @submitter.submission submission = @submitter.submission
return render :email_2fa unless Submitters::AuthorizedForForm.pass_email_2fa?(@submitter, request) return render :email_2fa unless Submitters::AuthorizedForForm.pass_email_2fa?(@submitter, request)
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
if @submitter.completed_at? || submission.completed_at?
return redirect_to submit_form_completed_path(@submitter.slug)
end
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS) @form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
@@ -71,6 +74,12 @@ class SubmitFormController < ApplicationController
status: :unprocessable_content status: :unprocessable_content
end end
if @submitter.viewer?
Rollbar.warning("Submit viewer: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: I18n.t('form_is_view_only') }, status: :unprocessable_content
end
Submitters::SubmitValues.call(@submitter, params, request) Submitters::SubmitValues.call(@submitter, params, request)
head :ok head :ok
@@ -7,11 +7,13 @@ class SubmitFormDeclineController < ApplicationController
before_action :load_submitter before_action :load_submitter
def create def create
return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? || return redirect_to submit_form_path(@submitter.slug) if declining_disabled? ||
@submitter.declined_at? ||
@submitter.completed_at? || @submitter.completed_at? ||
@submitter.submission.archived_at? || @submitter.submission.archived_at? ||
@submitter.submission.expired? || @submitter.submission.expired? ||
@submitter.submission.template&.archived_at? || @submitter.submission.template&.archived_at? ||
@submitter.viewer? ||
!Submitters::AuthorizedForForm.call(@submitter, !Submitters::AuthorizedForForm.call(@submitter,
current_user, current_user,
request) request)
@@ -35,6 +37,10 @@ class SubmitFormDeclineController < ApplicationController
private private
def declining_disabled?
@submitter.account.account_configs.find_by(key: AccountConfig::ALLOW_TO_DECLINE_KEY)&.value == false
end
def load_submitter def load_submitter
@submitter = Submitter.find_by!(slug: params[:submit_form_slug]) @submitter = Submitter.find_by!(slug: params[:submit_form_slug])
end end
@@ -12,6 +12,7 @@ class SubmitFormDelegateController < ApplicationController
@submitter.submission.archived_at? || @submitter.submission.archived_at? ||
@submitter.submission.expired? || @submitter.submission.expired? ||
@submitter.submission.template&.archived_at? || @submitter.submission.template&.archived_at? ||
@submitter.viewer? ||
!Submitters::AuthorizedForForm.call(@submitter, !Submitters::AuthorizedForForm.call(@submitter,
current_user, current_user,
request) request)
@@ -14,6 +14,7 @@ class SubmitFormDownloadController < ApplicationController
return head :unprocessable_content if @submitter.declined_at? || return head :unprocessable_content if @submitter.declined_at? ||
@submitter.submission.archived_at? || @submitter.submission.archived_at? ||
@submitter.submission.expired? || @submitter.submission.expired? ||
@submitter.submission.completed_at? ||
@submitter.submission.template&.archived_at? || @submitter.submission.template&.archived_at? ||
AccountConfig.exists?(account_id: @submitter.account_id, AccountConfig.exists?(account_id: @submitter.account_id,
key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY, key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
@@ -12,6 +12,8 @@ class SubmitFormDrawSignatureController < ApplicationController
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at? return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
return redirect_to submit_form_path(@submitter.slug) if @submitter.viewer?
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? || if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request) !Submitters::AuthorizedForForm.call(@submitter, current_user, request)
return redirect_to submit_form_path(@submitter.slug) return redirect_to submit_form_path(@submitter.slug)
@@ -48,6 +48,7 @@ class SubmitFormInviteController < ApplicationController
!submitter.submission.archived_at? && !submitter.submission.archived_at? &&
!submitter.submission.expired? && !submitter.submission.expired? &&
!submitter.submission.template&.archived_at? && !submitter.submission.template&.archived_at? &&
!submitter.viewer? &&
Submitters::AuthorizedForForm.call(submitter, current_user, request) Submitters::AuthorizedForForm.call(submitter, current_user, request)
end end
@@ -7,13 +7,7 @@ class SubmitFormMetadataController < ApplicationController
def index def index
@submitter = Submitter.find_by!(slug: params[:submit_form_slug]) @submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return head :not_found if @submitter.declined_at? || return head :not_found unless authorized_submitter?(@submitter)
@submitter.completed_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
@submitter.account.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
submission = @submitter.submission submission = @submitter.submission
values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
@@ -34,4 +28,17 @@ class SubmitFormMetadataController < ApplicationController
render json: { text_runs: } render json: { text_runs: }
end end
private
def authorized_submitter?(submitter)
!submitter.declined_at? &&
!submitter.completed_at? &&
!submitter.submission.archived_at? &&
!submitter.submission.completed_at? &&
!submitter.submission.expired? &&
!submitter.submission.template&.archived_at? &&
!submitter.account.archived_at? &&
Submitters::AuthorizedForForm.call(submitter, current_user, request)
end
end end
@@ -36,7 +36,7 @@ class SubmittersAutocompleteController < ApplicationController
else else
column = Submitter.arel_table[field.to_sym] column = Submitter.arel_table[field.to_sym]
term = "#{params[:q].downcase}%" term = "#{ActiveRecord::Base.sanitize_sql_like(params[:q].downcase)}%"
submitters.where(column.matches(term)) submitters.where(column.matches(term))
end end
+10 -2
View File
@@ -8,12 +8,14 @@ class SubmittersController < ApplicationController
if @submitter.preferences['email_message_uuid'].present? if @submitter.preferences['email_message_uuid'].present?
@submitter.account.email_messages.find_by(uuid: @submitter.preferences['email_message_uuid']) @submitter.account.email_messages.find_by(uuid: @submitter.preferences['email_message_uuid'])
end end
render :edit, layout: 'plain'
end end
def update def update
submission = @submitter.submission submission = @submitter.submission
if @submitter.submission_events.exists?(event_type: 'start_form') || submission.archived_at? || submission.expired? unless submitter_editable?(submission)
return redirect_back fallback_location: submission_path(submission), alert: I18n.t('submitter_cannot_be_updated') return redirect_back fallback_location: submission_path(submission), alert: I18n.t('submitter_cannot_be_updated')
end end
@@ -48,11 +50,17 @@ class SubmittersController < ApplicationController
private private
def submitter_editable?(submission)
!@submitter.submission_events.exists?(event_type: 'start_form') &&
!@submitter.completed_at? && !@submitter.declined_at? && !submission.completed_at? &&
!submission.archived_at? && !submission.expired? && !submission.template&.archived_at?
end
def maybe_resend_email_sms(submitter, params) def maybe_resend_email_sms(submitter, params)
if params[:send_email] == '1' && submitter.email.present? if params[:send_email] == '1' && submitter.email.present?
is_sent_recently = Docuseal.multitenant? && is_sent_recently = Docuseal.multitenant? &&
EmailEvent.exists?(email: submitter.email, EmailEvent.exists?(email: submitter.email,
tag: 'submitter_invitation', tag: %w[submitter_invitation submitter_view_invitation],
emailable: submitter, emailable: submitter,
event_type: 'send', event_type: 'send',
created_at: 4.hours.ago..Time.current) created_at: 4.hours.ago..Time.current)
@@ -5,6 +5,8 @@ class SubmittersResubmitController < ApplicationController
def update def update
return redirect_to submit_form_path(slug: @submitter.slug) if @submitter.email != current_user.email return redirect_to submit_form_path(slug: @submitter.slug) if @submitter.email != current_user.email
return redirect_to submit_form_path(slug: @submitter.slug) if @submitter.completed_at.blank? ||
@submitter.completed_at < 1.month.ago
submission = @submitter.account.submissions.new(created_by_user: current_user, submission = @submitter.account.submissions.new(created_by_user: current_user,
submitters_order: :preserved, submitters_order: :preserved,
@@ -0,0 +1,39 @@
# frozen_string_literal: true
class TemplateDocumentsCropController < ApplicationController
load_and_authorize_resource :template
before_action :load_attachment
rescue_from Leptonica::LeptonicaError do
render json: { error: I18n.t(:unable_to_save) }, status: :unprocessable_content
end
def index
render json: { corners: Leptonica.detect_document_corners(@attachment.download) }
end
def create
authorize!(:update, @template)
document = Templates::CreateDocumentCrop.call(@template, @attachment, crop_params)
render json: {
document: document.as_json(
methods: %i[metadata signed_key],
include: {
preview_images: { methods: %i[url metadata filename] }
}
)
}
end
private
def load_attachment
@attachment = @template.documents_attachments.find_by!(uuid: params[:attachment_uuid])
end
def crop_params
params.permit(:scan, :rotate, :flip_h, :flip_v, corners: [%i[x y]])
end
end
@@ -0,0 +1,32 @@
# frozen_string_literal: true
class TemplateDocumentsModifyController < ApplicationController
load_and_authorize_resource :template
def create
authorize!(:update, @template)
documents_layout =
params.require(:documents).map do |item|
item.permit(:attachment_uuid,
pages: [:attachment_uuid, :page, :rotate,
{ redact: [%i[x y w h color]], replaced_page: %i[attachment_uuid page] }]).to_h
end
Templates::ModifyDocuments.call(@template, documents_layout)
render json: {
schema: @template.schema,
fields: @template.fields,
submitters: @template.submitters,
documents: @template.schema_documents.reload.preload(:blob, preview_images_attachments: :blob).as_json(
methods: %i[metadata signed_key],
include: {
preview_images: { methods: %i[url metadata filename] }
}
)
}
rescue Templates::ModifyDocuments::InvalidLayout
render json: { error: I18n.t(:unable_to_save) }, status: :unprocessable_content
end
end
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class TemplateDocumentsPageObjectsController < ApplicationController
load_and_authorize_resource :template
def index
attachment = @template.documents_attachments.find_by!(uuid: params[:attachment_uuid])
render json: Templates::ModifyDocuments.page_objects(attachment, params[:page].to_i)
end
end
+12 -11
View File
@@ -30,9 +30,11 @@ class TemplateFoldersController < ApplicationController
(@template_folders.size < 7 ? 9 : 6) (@template_folders.size < 7 ? 9 : 6)
end end
@pagy, @templates = pagy_auto(@templates, limit:) @pagy, @templates = pagy_auto(@templates.select_for_list, limit:)
load_related_submissions if params[:q].present? && @templates.blank? if params[:q].present? && @templates.blank?
@related_submissions_pagy, @related_submissions = load_related_submissions(@template_folder)
end
else else
@pagy, @template_folders = pagy(@template_folders, limit: FOLDERS_PER_PAGE) @pagy, @template_folders = pagy(@template_folders, limit: FOLDERS_PER_PAGE)
@@ -55,11 +57,10 @@ class TemplateFoldersController < ApplicationController
def selected_order def selected_order
@selected_order ||= @selected_order ||=
if cookies.permanent[:dashboard_templates_order].blank? || if can?(:manage, :countless)
(cookies.permanent[:dashboard_templates_order] == 'used_at' && can?(:manage, :countless))
'created_at' 'created_at'
else else
cookies.permanent[:dashboard_templates_order] cookies.permanent[:dashboard_templates_order].presence || 'created_at'
end end
end end
@@ -67,20 +68,20 @@ class TemplateFoldersController < ApplicationController
params.require(:template_folder).permit(:name) params.require(:template_folder).permit(:name)
end end
def load_related_submissions def load_related_submissions(template_folder)
@related_submissions = related_submissions =
Submission.accessible_by(current_ability) Submission.accessible_by(current_ability)
.where(archived_at: nil) .where(archived_at: nil)
.where(template_id: current_account.templates.active .where(template_id: current_account.templates.active
.where(folder: [@template_folder, *@template_folder.subfolders]) .where(folder: [template_folder, *template_folder.subfolders])
.select(:id)) .select(:id))
.preload(:template_accesses, :created_by_user, .preload(:template_accesses, :created_by_user,
template: :author, template: :author,
submitters: :start_form_submission_events) submitters: :start_form_submission_events)
@related_submissions = Submissions.search(current_user, @related_submissions, params[:q]) related_submissions = Submissions.search(current_user, related_submissions, params[:q])
.order(id: :desc) .order(id: :desc)
@related_submissions_pagy, @related_submissions = pagy_auto(@related_submissions, limit: 5) pagy_auto(related_submissions.select_for_list, limit: 5)
end end
end end
@@ -10,11 +10,17 @@ class TemplatesArchivedController < ApplicationController
@templates = Templates.search(current_user, @templates, params[:q]) @templates = Templates.search(current_user, @templates, params[:q])
@pagy, @templates = pagy_auto(@templates, limit: 12) @pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12)
return unless params[:q].present? && @templates.blank? return unless params[:q].present? && @templates.blank?
@related_submissions = @related_submissions_pagy, @related_submissions = load_related_submissions
end
private
def load_related_submissions
related_submissions =
Submission.accessible_by(current_ability) Submission.accessible_by(current_ability)
.joins(:template) .joins(:template)
.where.not(templates: { archived_at: nil }) .where.not(templates: { archived_at: nil })
@@ -22,9 +28,9 @@ class TemplatesArchivedController < ApplicationController
template: :author, template: :author,
submitters: :start_form_submission_events) submitters: :start_form_submission_events)
@related_submissions = Submissions.search(current_user, @related_submissions, params[:q]) related_submissions = Submissions.search(current_user, related_submissions, params[:q])
.order(id: :desc) .order(id: :desc)
@related_submissions_pagy, @related_submissions = pagy_auto(@related_submissions, limit: 5) pagy_auto(related_submissions.select_for_list, limit: 5)
end end
end end
@@ -10,12 +10,12 @@ class TemplatesArchivedSubmissionsController < ApplicationController
@submissions = Submissions::Filter.call(@submissions, current_user, params) @submissions = Submissions::Filter.call(@submissions, current_user, params)
@submissions = if params[:completed_at_from].present? || params[:completed_at_to].present? @submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
@submissions.order(Submitter.arel_table[:completed_at].maximum.desc) @submissions.order(completed_at: :desc)
else else
@submissions.order(id: :desc) @submissions.order(id: :desc)
end end
@pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events)) @pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events))
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
redirect_to root_path redirect_to root_path
end end
+7 -20
View File
@@ -1,10 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class TemplatesController < ApplicationController class TemplatesController < ApplicationController
TEMPLATE_FIELDS = %i[id author_id folder_id external_id name slug
schema fields submitters variables_schema preferences
shared_link source archived_at created_at updated_at].freeze
load_and_authorize_resource :template load_and_authorize_resource :template
def show def show
@@ -18,32 +14,23 @@ class TemplatesController < ApplicationController
submissions = Submissions::Filter.filter_by_status(submissions, params) submissions = Submissions::Filter.filter_by_status(submissions, params)
submissions = if params[:completed_at_from].present? || params[:completed_at_to].present? submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
submissions.order(Submitter.arel_table[:completed_at].maximum.desc) submissions.order(completed_at: :desc)
else else
submissions.order(id: :desc) submissions.order(id: :desc)
end end
@pagy, @submissions = pagy_auto(submissions.preload(:template_accesses, submitters: :start_form_submission_events)) @pagy, @submissions =
pagy_auto(submissions.select_for_list.preload(:template_accesses, submitters: :start_form_submission_events))
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
redirect_to root_path redirect_to root_path
end end
def new; end def new
render :new, layout: 'plain'
end
def edit def edit
ActiveRecord::Associations::Preloader.new( @template_data = Templates.serialize_for_builder(@template)
records: [@template],
associations: [{ schema_documents: [:blob, { preview_images_attachments: :blob }] }]
).call
@template_data =
@template.as_json(only: TEMPLATE_FIELDS).merge(
documents: @template.schema_documents.as_json(
only: %i[id uuid],
methods: %i[metadata signed_key],
include: { preview_images: { only: %i[id], methods: %i[url metadata filename] } }
)
).to_json
render :edit, layout: 'plain' render :edit, layout: 'plain'
end end
@@ -11,86 +11,126 @@ class TemplatesDashboardController < ApplicationController
helper_method :selected_order helper_method :selected_order
def index def index
@default_folder = current_account.default_template_folder
@template_folders = @template_folders =
TemplateFolders.filter_active_folders(@template_folders.where(parent_folder_id: nil), @templates) TemplateFolders.filter_active_folders(@template_folders.where(parent_folder_id: nil), @templates)
@template_folders = @template_folders.where.not(id: @default_folder.id) if params[:q].blank?
@template_folders = TemplateFolders.search(@template_folders, params[:q]) @template_folders = TemplateFolders.search(@template_folders, params[:q])
@template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order) @template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order)
@pagy, @template_folders = pagy( @shared_templates = Templates.shared(current_user).active
@template_folders,
limit: FOLDERS_PER_PAGE, @pagy, @template_folders, @show_default_folder, @show_shared_folder, @show_shared_inline =
page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1 load_folders(@template_folders, @templates, @shared_templates)
)
if @pagy.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD if @pagy.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD
@templates = @templates.none @templates = @templates.none
else else
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME } if @show_shared_inline
@templates = filter_templates(@templates).preload(:author, :template_accesses) @templates = @shared_templates.preload(:template_sharings)
@templates = Templates::Order.call(@templates, current_user, selected_order) else
@templates = @templates.active
@templates = @templates.where(folder_id: @default_folder.id) if params[:q].blank?
end
limit = @pagy, @templates = load_templates(@templates.select_for_list, @pagy.count,
if @template_folders.size < 4 show_shared_inline: @show_shared_inline)
TEMPLATES_PER_PAGE
else
(@template_folders.size < 7 ? 9 : 6)
end
@pagy, @templates = pagy_auto(@templates, limit:) if params[:q].present? && @templates.blank?
@related_submissions_pagy, @related_submissions = load_related_submissions
load_related_submissions if params[:q].present? && @templates.blank? end
end end
end end
private private
def filter_templates(templates) def load_templates(templates, folders_count, show_shared_inline: false)
rel = templates.active templates = templates.preload(:author, :template_accesses)
if params[:q].blank? templates =
if Docuseal.multitenant? ? current_account.testing? : current_account.linked_account_account if show_shared_inline
shared_account_ids = [current_user.account_id] Templates.search_shared(current_user, templates, params[:q])
shared_account_ids << TemplateSharing::ALL_ID if !Docuseal.multitenant? && !current_account.testing?
shared_template_ids = TemplateSharing.where(account_id: shared_account_ids).select(:template_id)
rel = Template.where(
Template.arel_table[:id].in(
rel.where(folder_id: current_account.default_template_folder.id).select(:id).arel
.union(:all, shared_template_ids.arel)
)
)
else else
rel = rel.where(folder_id: current_account.default_template_folder.id) Templates.search(current_user, templates, params[:q])
end end
end
Templates.search(current_user, rel, params[:q]) templates = Templates::Order.call(templates, current_user, selected_order)
limit =
if folders_count < 4
TEMPLATES_PER_PAGE
else
(folders_count < 7 ? 9 : 6)
end
pagy_auto(templates, limit:)
end
def load_folders(template_folders, templates, shared_templates)
if params[:q].present?
pagy(template_folders, limit: FOLDERS_PER_PAGE,
page: template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1)
else
load_folders_with_pinned(template_folders, templates, shared_templates)
end
end
def load_folders_with_pinned(template_folders, templates, shared_templates)
folders_count = template_folders.count
shared_exists = shared_templates.exists?
default_has_templates = templates.active.exists?(folder_id: current_account.default_template_folder.id)
show_inline_folders =
folders_count + (shared_exists && default_has_templates ? 1 : 0) <= SHOW_TEMPLATES_FOLDERS_THRESHOLD
show_shared_inline = shared_exists && !default_has_templates && show_inline_folders
show_shared_in_grid = shared_exists && !show_shared_inline
show_default_in_grid = !show_inline_folders && default_has_templates
pinned_count = (show_default_in_grid ? 1 : 0) + (show_shared_in_grid ? 1 : 0)
pagy = Pagy::Offset.new(count: folders_count + pinned_count,
page: show_inline_folders ? 1 : [params[:page].to_s.to_i, 1].max,
limit: FOLDERS_PER_PAGE,
raise_range_error: true)
show_default_folder = show_default_in_grid && pagy.page == 1
show_shared_folder = show_shared_in_grid && pagy.page == 1
folder_offset = pagy.page == 1 ? 0 : pagy.offset - pinned_count
folder_limit = pagy.page == 1 ? FOLDERS_PER_PAGE - pinned_count : FOLDERS_PER_PAGE
template_folders = template_folders.offset(folder_offset).limit(folder_limit)
[pagy, template_folders, show_default_folder, show_shared_folder, show_shared_inline]
end end
def selected_order def selected_order
@selected_order ||= @selected_order ||=
if cookies.permanent[:dashboard_templates_order].blank? || if can?(:manage, :countless)
(cookies.permanent[:dashboard_templates_order] == 'used_at' && can?(:manage, :countless))
'created_at' 'created_at'
else else
cookies.permanent[:dashboard_templates_order] cookies.permanent[:dashboard_templates_order].presence || 'created_at'
end end
end end
def load_related_submissions def load_related_submissions
@related_submissions = Submission.accessible_by(current_ability) related_submissions = Submission.accessible_by(current_ability)
.left_joins(:template) .left_joins(:template)
.where(archived_at: nil) .where(archived_at: nil)
.where(templates: { archived_at: nil }) .where(templates: { archived_at: nil })
.preload(:template_accesses, :created_by_user, .preload(:template_accesses, :created_by_user,
template: :author, template: :author,
submitters: :start_form_submission_events) submitters: :start_form_submission_events)
@related_submissions = Submissions.search(current_user, @related_submissions, params[:q]) related_submissions = Submissions.search(current_user, related_submissions, params[:q])
.order(id: :desc) .order(id: :desc)
@related_submissions_pagy, @related_submissions = pagy_auto(@related_submissions, limit: 5) pagy_auto(related_submissions.select_for_list, limit: 5)
end end
end end
@@ -16,7 +16,12 @@ class TemplatesDetectFieldsController < ApplicationController
page_number = params[:page].presence&.to_i page_number = params[:page].presence&.to_i
documents.each do |document| documents.each do |document|
io = StringIO.new(document.download) io =
if document.image?
StringIO.new(document.preview_images.joins(:blob).find_by(blob: { filename: ['0.png', '0.jpg'] }).download)
else
StringIO.new(document.download)
end
Templates::DetectFields.call(io, attachment: document, page_number:) do |(attachment_uuid, page, fields)| Templates::DetectFields.call(io, attachment: document, page_number:) do |(attachment_uuid, page, fields)|
sse.write({ attachment_uuid:, page:, fields: }) sse.write({ attachment_uuid:, page:, fields: })
@@ -13,11 +13,13 @@ class TemplatesFormPreviewController < ApplicationController
@submitter.submission.submitters = @template.submitters.map { |item| Submitter.new(uuid: item['uuid']) } @submitter.submission.submitters = @template.submitters.map { |item| Submitter.new(uuid: item['uuid']) }
Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(@submitter.submission, preview: true)
Submissions.preload_with_pages(@submitter.submission) Submissions.preload_with_pages(@submitter.submission)
@attachments_index = ActiveStorage::Attachment.where(record: @submitter.submission.submitters, name: :attachments) @attachments_index = ActiveStorage::Attachment.where(record: @submitter.submission.submitters, name: :attachments)
.preload(:blob).index_by(&:uuid) .preload(:blob).index_by(&:uuid)
@form_configs = Submitters::FormConfigs.call(@submitter) @form_configs = Submitters::FormConfigs.call(@submitter, SubmitFormController::CONFIG_KEYS)
end end
end end
@@ -5,6 +5,8 @@ class TemplatesPreferencesController < ApplicationController
RESETTABLE_PREFERENCE_KEYS = { RESETTABLE_PREFERENCE_KEYS = {
AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY => %w[request_email_subject request_email_body submitters], AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY => %w[request_email_subject request_email_body submitters],
AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY => %w[invitation_view_email_subject
invitation_view_email_body],
AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => %w[invitation_reminder_email_subject AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => %w[invitation_reminder_email_subject
invitation_reminder_email_body], invitation_reminder_email_body],
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[documents_copy_email_subject documents_copy_email_body], AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[documents_copy_email_subject documents_copy_email_body],
@@ -12,7 +14,9 @@ class TemplatesPreferencesController < ApplicationController
completed_notification_email_body] completed_notification_email_body]
}.freeze }.freeze
def show; end def show
render :show, layout: 'plain'
end
def create def create
authorize!(:update, @template) authorize!(:update, @template)
@@ -48,12 +52,12 @@ class TemplatesPreferencesController < ApplicationController
def template_params def template_params
params.require(:template).permit( params.require(:template).permit(
preferences: %i[bcc_completed request_email_subject request_email_body preferences: %i[bcc_completed request_email_subject request_email_body
invitation_view_email_subject invitation_view_email_body
invitation_reminder_email_subject invitation_reminder_email_body invitation_reminder_email_subject invitation_reminder_email_body
documents_copy_email_subject documents_copy_email_body documents_copy_email_subject documents_copy_email_body
documents_copy_email_enabled documents_copy_email_attach_audit documents_copy_email_enabled documents_copy_email_attach_audit
documents_copy_email_attach_documents documents_copy_email_reply_to documents_copy_email_attach_documents documents_copy_email_reply_to
completed_notification_email_attach_documents completed_notification_email_attach_documents completed_redirect_url validate_unique_submitters
completed_redirect_url validate_unique_submitters
require_all_submitters submitters_order require_phone_2fa require_email_2fa require_all_submitters submitters_order require_phone_2fa require_email_2fa
default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled
completed_notification_email_subject completed_notification_email_body completed_notification_email_subject completed_notification_email_body
@@ -4,18 +4,7 @@ class TemplatesPreviewController < ApplicationController
load_and_authorize_resource :template load_and_authorize_resource :template
def show def show
ActiveRecord::Associations::Preloader.new( @template_data = Templates.serialize_for_builder(@template)
records: [@template],
associations: [{ schema_documents: { preview_images_attachments: :blob } }]
).call
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
render :show, layout: 'plain' render :show, layout: 'plain'
end end
@@ -4,7 +4,7 @@ class TemplatesRestoreController < ApplicationController
load_and_authorize_resource :template load_and_authorize_resource :template
def create def create
authorize!(:update, @template) authorize!(:destroy, @template)
@template.update!(archived_at: nil) @template.update!(archived_at: nil)
@@ -3,7 +3,9 @@
class TemplatesShareLinkController < ApplicationController class TemplatesShareLinkController < ApplicationController
load_and_authorize_resource :template load_and_authorize_resource :template
def show; end def show
render :show, layout: 'plain'
end
def create def create
authorize!(:update, @template) authorize!(:update, @template)
@@ -0,0 +1,47 @@
# frozen_string_literal: true
class TemplatesSharedController < ApplicationController
def index
authorize!(:read, Template)
@is_archived = params[:archived] == 'true'
@templates = Templates.shared(current_user)
@has_archived = !@is_archived && @templates.archived.exists?
@templates = @is_archived ? @templates.archived : @templates.active
@templates = @templates.preload(:author, :template_accesses, :template_sharings)
.order(id: :desc)
@templates = Templates.search_shared(current_user, @templates, params[:q])
@pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12)
return unless params[:q].present? && @templates.blank?
@related_submissions_pagy, @related_submissions = load_related_submissions(is_archived: @is_archived)
end
private
def load_related_submissions(is_archived:)
shared_templates = Templates.shared(current_user)
shared_templates = is_archived ? shared_templates.archived : shared_templates.active
related_submissions =
Submission.accessible_by(current_ability)
.where(template_id: shared_templates.select(:id))
.preload(:template_accesses, :created_by_user,
template: :author,
submitters: :start_form_submission_events)
related_submissions = related_submissions.where(archived_at: nil) unless is_archived
related_submissions = Submissions.search(current_user, related_submissions, params[:q])
.order(id: :desc)
pagy_auto(related_submissions.select_for_list, limit: 5)
end
end
@@ -5,7 +5,9 @@ class TemplatesUploadsController < ApplicationController
layout 'plain' layout 'plain'
def show; end def show
redirect_to root_path if params[:url].blank?
end
def create def create
url_params = create_file_params_from_url if params[:url].present? url_params = create_file_params_from_url if params[:url].present?
@@ -11,6 +11,12 @@ class UserInitialsController < ApplicationController
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_initials') if file.blank? return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_initials') if file.blank?
extension = File.extname(file.original_filename).delete_prefix('.').downcase
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
blob = ActiveStorage::Blob.create_and_upload!(io: file.open, blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename, filename: file.original_filename,
content_type: file.content_type) content_type: file.content_type)
@@ -11,6 +11,12 @@ class UserSignaturesController < ApplicationController
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_signature') if file.blank? return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_signature') if file.blank?
extension = File.extname(file.original_filename).delete_prefix('.').downcase
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
blob = ActiveStorage::Blob.create_and_upload!(io: file.open, blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename, filename: file.original_filename,
content_type: file.content_type) content_type: file.content_type)
+2
View File
@@ -75,6 +75,8 @@ class UsersController < ApplicationController
authorize!(:manage, account) authorize!(:manage, account)
@user.account = account @user.account = account
authorize!(:create, @user)
end end
if @user.update(attrs.except(*(current_user == @user ? %i[password otp_required_for_login role] : %i[password]))) if @user.update(attrs.except(*(current_user == @user ? %i[password otp_required_for_login role] : %i[password])))
+62 -5
View File
@@ -21,6 +21,7 @@ import SubmittersAutocomplete from './elements/submitter_autocomplete'
import FolderAutocomplete from './elements/folder_autocomplete' import FolderAutocomplete from './elements/folder_autocomplete'
import SignatureForm from './elements/signature_form' import SignatureForm from './elements/signature_form'
import SubmitForm from './elements/submit_form' import SubmitForm from './elements/submit_form'
import ConvertUpload from './elements/convert_upload'
import PromptPassword from './elements/prompt_password' import PromptPassword from './elements/prompt_password'
import EmailsTextarea from './elements/emails_textarea' import EmailsTextarea from './elements/emails_textarea'
import ToggleSubmit from './elements/toggle_submit' import ToggleSubmit from './elements/toggle_submit'
@@ -41,6 +42,7 @@ import RequiredCheckboxGroup from './elements/required_checkbox_group'
import PageContainer from './elements/page_container' import PageContainer from './elements/page_container'
import EmailEditor from './elements/email_editor' import EmailEditor from './elements/email_editor'
import MarkdownEditor from './elements/markdown_editor' import MarkdownEditor from './elements/markdown_editor'
import HtmlEditor from './elements/html_editor'
import MountOnClick from './elements/mount_on_click' import MountOnClick from './elements/mount_on_click'
import RemoveOnEvent from './elements/remove_on_event' import RemoveOnEvent from './elements/remove_on_event'
import ScrollTo from './elements/scroll_to' import ScrollTo from './elements/scroll_to'
@@ -53,6 +55,10 @@ import GoogleDriveFilePicker from './elements/google_drive_file_picker'
import OpenModal from './elements/open_modal' import OpenModal from './elements/open_modal'
import BarChart from './elements/bar_chart' import BarChart from './elements/bar_chart'
import FieldCondition from './elements/field_condition' import FieldCondition from './elements/field_condition'
import ConfirmUpload from './elements/confirm_upload'
import ScrollFade from './elements/scroll_fade'
import OpenModalMobile from './elements/open_modal_mobile'
import HistoryBack from './elements/history_back'
import * as TurboInstantClick from './lib/turbo_instant_click' import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -111,6 +117,7 @@ safeRegisterElement('submitters-autocomplete', SubmittersAutocomplete)
safeRegisterElement('folder-autocomplete', FolderAutocomplete) safeRegisterElement('folder-autocomplete', FolderAutocomplete)
safeRegisterElement('signature-form', SignatureForm) safeRegisterElement('signature-form', SignatureForm)
safeRegisterElement('submit-form', SubmitForm) safeRegisterElement('submit-form', SubmitForm)
safeRegisterElement('convert-upload', ConvertUpload)
safeRegisterElement('prompt-password', PromptPassword) safeRegisterElement('prompt-password', PromptPassword)
safeRegisterElement('emails-textarea', EmailsTextarea) safeRegisterElement('emails-textarea', EmailsTextarea)
safeRegisterElement('toggle-cookies', ToggleCookies) safeRegisterElement('toggle-cookies', ToggleCookies)
@@ -132,6 +139,7 @@ safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup)
safeRegisterElement('page-container', PageContainer) safeRegisterElement('page-container', PageContainer)
safeRegisterElement('email-editor', EmailEditor) safeRegisterElement('email-editor', EmailEditor)
safeRegisterElement('markdown-editor', MarkdownEditor) safeRegisterElement('markdown-editor', MarkdownEditor)
safeRegisterElement('html-editor', HtmlEditor)
safeRegisterElement('mount-on-click', MountOnClick) safeRegisterElement('mount-on-click', MountOnClick)
safeRegisterElement('remove-on-event', RemoveOnEvent) safeRegisterElement('remove-on-event', RemoveOnEvent)
safeRegisterElement('scroll-to', ScrollTo) safeRegisterElement('scroll-to', ScrollTo)
@@ -144,10 +152,15 @@ safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker)
safeRegisterElement('open-modal', OpenModal) safeRegisterElement('open-modal', OpenModal)
safeRegisterElement('bar-chart', BarChart) safeRegisterElement('bar-chart', BarChart)
safeRegisterElement('field-condition', FieldCondition) safeRegisterElement('field-condition', FieldCondition)
safeRegisterElement('confirm-upload', ConfirmUpload)
safeRegisterElement('scroll-fade', ScrollFade)
safeRegisterElement('open-modal-mobile', OpenModalMobile)
safeRegisterElement('history-back', HistoryBack)
safeRegisterElement('template-builder', class extends HTMLElement { safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {
document.addEventListener('turbo:submit-end', this.onSubmit) document.addEventListener('turbo:submit-end', this.onSubmit)
document.addEventListener('turbo:before-cache', this.onBeforeCache)
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
@@ -158,6 +171,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
this.app = createApp(TemplateBuilder, { this.app = createApp(TemplateBuilder, {
template, template,
customFields: reactive(JSON.parse(this.dataset.customFields || '[]')), customFields: reactive(JSON.parse(this.dataset.customFields || '[]')),
dateFormats: JSON.parse(this.dataset.dateFormats || '[]'),
dynamicDocuments: reactive(JSON.parse(this.dataset.dynamicDocuments || '[]')), dynamicDocuments: reactive(JSON.parse(this.dataset.dynamicDocuments || '[]')),
backgroundColor: '#faf7f5', backgroundColor: '#faf7f5',
locale: this.dataset.locale, locale: this.dataset.locale,
@@ -174,7 +188,10 @@ safeRegisterElement('template-builder', class extends HTMLElement {
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content, authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withCustomFields: true, withCustomFields: true,
withPayment: this.dataset.withPayment === 'true', withPayment: this.dataset.withPayment === 'true',
isPaymentConnected: this.dataset.isPaymentConnected === 'true', isStripeConnected: this.dataset.isPaymentConnected === 'true' || this.dataset.isStripeConnected === 'true',
withStripe: this.dataset.withStripe !== 'false',
withPaypal: this.dataset.withPaypal === 'true',
isPaypalConnected: this.dataset.isPaypalConnected === 'true',
withFormula: this.dataset.withFormula === 'true', withFormula: this.dataset.withFormula === 'true',
withSendButton: this.dataset.withSendButton !== 'false', withSendButton: this.dataset.withSendButton !== 'false',
withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false', withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false',
@@ -195,15 +212,46 @@ safeRegisterElement('template-builder', class extends HTMLElement {
} }
onSubmit = (e) => { onSubmit = (e) => {
if (e.detail.success && e.detail?.formSubmission?.formElement?.id === 'submitters_form') { if (e.detail.success) {
e.detail.fetchResponse.response.json().then((data) => { if (e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
this.component.template.submitters = data.submitters e.detail.fetchResponse.response.json().then((data) => {
}) this.component.template.submitters = data.submitters
})
}
if (e.detail?.formSubmission?.formElement?.action?.endsWith('/prefillable_fields')) {
e.detail.fetchResponse.response.text().then((data) => {
const doc = new DOMParser().parseFromString(data, 'text/html')
const fragment = doc.querySelector('turbo-stream template').content
const prefillableUuidsIndex = {}
fragment.querySelectorAll('[name="field_uuid"]').forEach((field) => {
prefillableUuidsIndex[field.value] = true
})
this.component.template.fields.forEach((field) => {
if (prefillableUuidsIndex[field.uuid]) {
field.prefillable = true
field.readonly = true
} else if (field.prefillable) {
delete field.prefillable
delete field.readonly
}
})
})
}
} }
} }
onBeforeCache = () => {
this.app?.unmount()
this.appElem?.remove()
}
disconnectedCallback () { disconnectedCallback () {
document.removeEventListener('turbo:submit-end', this.onSubmit) document.removeEventListener('turbo:submit-end', this.onSubmit)
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
this.app?.unmount() this.app?.unmount()
this.appElem?.remove() this.appElem?.remove()
@@ -212,6 +260,8 @@ safeRegisterElement('template-builder', class extends HTMLElement {
safeRegisterElement('import-list', class extends HTMLElement { safeRegisterElement('import-list', class extends HTMLElement {
connectedCallback () { connectedCallback () {
document.addEventListener('turbo:before-cache', this.onBeforeCache)
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
this.app = createApp(ImportList, { this.app = createApp(ImportList, {
@@ -226,7 +276,14 @@ safeRegisterElement('import-list', class extends HTMLElement {
this.appendChild(this.appElem) this.appendChild(this.appElem)
} }
onBeforeCache = () => {
this.app?.unmount()
this.appElem?.remove()
}
disconnectedCallback () { disconnectedCallback () {
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
this.app?.unmount() this.app?.unmount()
this.appElem?.remove() this.appElem?.remove()
} }
+7
View File
@@ -114,6 +114,13 @@ button[disabled] .enabled, button.btn-disabled .enabled {
bottom: auto; bottom: auto;
} }
@media (hover: none), (pointer: coarse) {
.tooltip-no-touch:before,
.tooltip-no-touch:after {
display: none;
}
}
.autocomplete { .autocomplete {
background: white; background: white;
z-index: 1000; z-index: 1000;
+27
View File
@@ -0,0 +1,27 @@
import { target, targetable } from '@github/catalyst/lib/targetable'
export default targetable(class extends HTMLElement {
static [target.static] = [
'prompt',
'processing',
'logo'
]
connectedCallback () {
this.form.addEventListener('submit', this.onSubmit)
}
disconnectedCallback () {
this.form.removeEventListener('submit', this.onSubmit)
}
onSubmit = () => {
this.prompt.classList.add('hidden')
this.processing.classList.remove('hidden')
this.logo.classList.add('animate-bounce')
}
get form () {
return this.querySelector('form')
}
})
+73
View File
@@ -0,0 +1,73 @@
export function convertImage (sourceFile, targetType, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
resolve(newFile)
}, targetType, quality)
}
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(sourceFile)
})
}
export async function convertImagesInInput (input) {
if (!input.files || input.files.length === 0) return
const dt = new DataTransfer()
let didConvert = false
for (const file of Array.from(input.files)) {
let converted = file
try {
if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
converted = await convertImage(file, 'image/png')
didConvert = true
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
converted = await convertImage(file, 'image/jpeg', 0.9)
didConvert = true
}
} catch (e) {
alert(e.message)
}
dt.items.add(converted)
}
if (didConvert) {
input.files = dt.files
}
}
export default class extends HTMLElement {
connectedCallback () {
const input = this.querySelector('input[type="file"]')
const form = input.form
input.addEventListener('change', async () => {
await convertImagesInInput(input)
form.querySelector('[type="submit"]')?.setAttribute('disabled', true)
form.requestSubmit()
})
}
}
@@ -1,4 +1,5 @@
import { target, targets, targetable } from '@github/catalyst/lib/targetable' import { target, targets, targetable } from '@github/catalyst/lib/targetable'
import { convertImagesInInput } from './convert_upload'
const loadingIconHtml = `<svg xmlns="http://www.w3.org/2000/svg" class="animate-spin" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> const loadingIconHtml = `<svg xmlns="http://www.w3.org/2000/svg" class="animate-spin" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none" d="M0 0h24v24H0z" fill="none" />
@@ -150,12 +151,16 @@ export default targetable(class extends HTMLElement {
if (!this.isLoading) this.hideDraghover() if (!this.isLoading) this.hideDraghover()
} }
uploadFiles (files, url) { async uploadFiles (files, url) {
this.isLoading = true this.isLoading = true
this.form.action = url this.form.action = url
this.form.querySelector('[type="file"]').files = files const input = this.form.querySelector('[type="file"]')
input.files = files
await convertImagesInInput(input)
this.form.querySelector('[type="submit"]').click() this.form.querySelector('[type="submit"]').click()
} }
+75 -3
View File
@@ -9,8 +9,9 @@ function loadCodeMirror () {
import(/* webpackChunkName: "email-editor" */ '@codemirror/commands'), import(/* webpackChunkName: "email-editor" */ '@codemirror/commands'),
import(/* webpackChunkName: "email-editor" */ '@codemirror/language'), import(/* webpackChunkName: "email-editor" */ '@codemirror/language'),
import(/* webpackChunkName: "email-editor" */ '@codemirror/lang-html'), import(/* webpackChunkName: "email-editor" */ '@codemirror/lang-html'),
import(/* webpackChunkName: "email-editor" */ '@codemirror/lint'),
import(/* webpackChunkName: "email-editor" */ '@specious/htmlflow') import(/* webpackChunkName: "email-editor" */ '@specious/htmlflow')
]).then(([view, commands, language, html, htmlflow]) => { ]).then(([view, commands, language, html, lint, htmlflow]) => {
return { return {
minimalSetup: [ minimalSetup: [
commands.history(), commands.history(),
@@ -19,6 +20,8 @@ function loadCodeMirror () {
], ],
EditorView: view.EditorView, EditorView: view.EditorView,
html: html.html, html: html.html,
htmlLanguage: html.htmlLanguage,
linter: lint.linter,
htmlflow: htmlflow.default || htmlflow htmlflow: htmlflow.default || htmlflow
} }
}) })
@@ -46,6 +49,70 @@ export default targetable(class extends HTMLElement {
this.previewViewTab.addEventListener('click', this.showPreviewView) this.previewViewTab.addEventListener('click', this.showPreviewView)
this.codeViewTab.addEventListener('click', this.showCodeView) this.codeViewTab.addEventListener('click', this.showCodeView)
this.form = this.closest('form')
this.form?.addEventListener('submit', this.validateOnSubmit)
}
disconnectedCallback () {
this.form?.removeEventListener('submit', this.validateOnSubmit)
}
validateOnSubmit = (e) => {
if (!this.htmlLanguage) return
const bodyType = this.form.querySelector('input[name$="[body_type]"]:checked')?.value
if (bodyType && bodyType !== 'html') return
const diagnostics = this.buildDiagnostics(this.input.value)
if (diagnostics.length === 0) return
e.preventDefault()
this.showCodeView()
const pos = Math.min(diagnostics[0].from, this.editorView.state.doc.length)
this.editorView.dispatch({ selection: { anchor: pos }, scrollIntoView: true })
this.editorView.focus()
alert(diagnostics[0].message)
}
buildDiagnostics (value) {
const diagnostics = []
if (!value.trim()) return diagnostics
if (!/^\s*(<!doctype[^>]*>\s*)?<html/i.test(value)) {
diagnostics.push({
from: 0,
to: Math.min(5, value.length),
severity: 'error',
message: 'The email template must start with the <html> tag'
})
}
const seen = new Set()
this.htmlLanguage.parser.parse(value).iterate({
enter: (node) => {
if (!node.type.isError || seen.has(node.from) || seen.size >= 20) return
seen.add(node.from)
diagnostics.push({
from: node.from,
to: Math.min(node.to + 1, value.length),
severity: 'error',
message: 'The email template contains invalid HTML'
})
}
})
return diagnostics
} }
showCodeView = () => { showCodeView = () => {
@@ -76,7 +143,9 @@ export default targetable(class extends HTMLElement {
this.input = this.querySelector('input[type="hidden"]') this.input = this.querySelector('input[type="hidden"]')
this.input.style.display = 'none' this.input.style.display = 'none'
const { EditorView, minimalSetup, html, htmlflow } = await loadCodeMirror() const { EditorView, minimalSetup, html, htmlLanguage, linter, htmlflow } = await loadCodeMirror()
this.htmlLanguage = htmlLanguage
this.editorView = new EditorView({ this.editorView = new EditorView({
doc: this.input.value, doc: this.input.value,
@@ -85,8 +154,11 @@ export default targetable(class extends HTMLElement {
html(), html(),
minimalSetup, minimalSetup,
EditorView.lineWrapping, EditorView.lineWrapping,
linter((view) => this.buildDiagnostics(view.state.doc.toString()), { delay: 600 }),
EditorView.updateListener.of(update => { EditorView.updateListener.of(update => {
if (update.docChanged) this.input.value = update.state.doc.toString() if (update.docChanged) {
this.input.value = update.state.doc.toString()
}
}), }),
EditorView.theme({ EditorView.theme({
'&': { '&': {
+8 -3
View File
@@ -1,5 +1,6 @@
import { actionable } from '@github/catalyst/lib/actionable' import { actionable } from '@github/catalyst/lib/actionable'
import { target, targetable } from '@github/catalyst/lib/targetable' import { target, targetable } from '@github/catalyst/lib/targetable'
import { convertImagesInInput } from './convert_upload'
export default actionable(targetable(class extends HTMLElement { export default actionable(targetable(class extends HTMLElement {
static [target.static] = [ static [target.static] = [
@@ -38,17 +39,21 @@ export default actionable(targetable(class extends HTMLElement {
this.classList.add('border-base-300', 'hover:bg-base-200/30') this.classList.add('border-base-300', 'hover:bg-base-200/30')
} }
onDrop (e) { async onDrop (e) {
e.preventDefault() e.preventDefault()
this.input.files = e.dataTransfer.files this.input.files = e.dataTransfer.files
this.uploadFiles(e.dataTransfer.files) await convertImagesInInput(this.input)
this.uploadFiles(this.input.files)
} }
onSelectFiles (e) { async onSelectFiles (e) {
e.preventDefault() e.preventDefault()
await convertImagesInInput(this.input)
this.uploadFiles(this.input.files) this.uploadFiles(this.input.files)
} }
+15
View File
@@ -0,0 +1,15 @@
export default class extends HTMLElement {
connectedCallback () {
this.addEventListener('click', this.onClick)
}
disconnectedCallback () {
this.removeEventListener('click', this.onClick)
}
onClick = (e) => {
e.preventDefault()
window.history.back()
}
}
+667
View File
@@ -0,0 +1,667 @@
import { target, targetable } from '@github/catalyst/lib/targetable'
import { actionable } from '@github/catalyst/lib/actionable'
import { LinkTooltip } from './markdown_editor'
async function loadTiptap () {
const [core, document, text, hardBreak, gapcursor, dropcursor, extensions, pmState, pmView] = await Promise.all([
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/core'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-document'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-text'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-hard-break'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-gapcursor'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-dropcursor'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extensions'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/state'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/view')
])
return {
Editor: core.Editor,
Extension: core.Extension,
Node: core.Node,
Mark: core.Mark,
Document: document.default || document,
Text: text.default || text,
HardBreak: hardBreak.default || hardBreak,
Gapcursor: gapcursor.default || gapcursor,
Dropcursor: dropcursor.default || dropcursor,
UndoRedo: extensions.UndoRedo,
Plugin: pmState.Plugin,
Decoration: pmView.Decoration,
DecorationSet: pmView.DecorationSet
}
}
const editorStylesheet = new CSSStyleSheet()
editorStylesheet.replaceSync(`
:host {
display: block;
max-height: 360px;
overflow: auto;
border-radius: 0 0 1rem 1rem;
}
.ProseMirror {
word-wrap: break-word;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
font-feature-settings: "liga" 0;
outline: none;
min-height: 220px;
padding: 12px;
}
img.ProseMirror-separator {
display: inline !important;
border: none !important;
margin: 0 !important;
width: 0 !important;
height: 0 !important;
}
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
margin: 0;
}
.ProseMirror-gapcursor:after {
content: "";
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid black;
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
}
@keyframes ProseMirror-cursor-blink {
to {
visibility: hidden;
}
}
.ProseMirror-hideselection *::selection {
background: transparent;
}
.ProseMirror-hideselection *::-moz-selection {
background: transparent;
}
.ProseMirror-hideselection * {
caret-color: transparent;
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
.variable-highlight {
background-color: #fef3c7;
padding: 1px 2px;
border-radius: 4px;
}
`)
const DROP_ATTRS = [
'srcdoc', 'xlink:href', 'srcset', 'action', 'formaction', 'poster',
'background', 'data', 'cite', 'ping', 'longdesc', 'manifest', 'profile'
]
const SAFE_URL_REGEXP = /^(?:https?:\/\/|data:image\/|blob:|mailto:|tel:|\{|#)/i
function isSafeAttr (name, value) {
const lowerName = name.toLowerCase()
if (lowerName.startsWith('on') || DROP_ATTRS.includes(lowerName)) return false
if ((lowerName === 'href' || lowerName === 'src') && !SAFE_URL_REGEXP.test(value.trim())) return false
return true
}
function collectDomAttrs (dom) {
const attrs = {}
for (let i = 0; i < dom.attributes.length; i++) {
const { name, value } = dom.attributes[i]
if (isSafeAttr(name, value)) attrs[name] = value
}
return { htmlAttrs: attrs }
}
function collectSpanDomAttrs (dom) {
const result = collectDomAttrs(dom)
if (result.htmlAttrs.style) {
const temp = document.createElement('span')
temp.style.cssText = result.htmlAttrs.style
if (['bold', '700'].includes(temp.style.fontWeight)) {
temp.style.removeProperty('font-weight')
}
if (temp.style.fontStyle === 'italic') {
temp.style.removeProperty('font-style')
}
if (temp.style.textDecoration === 'underline') {
temp.style.removeProperty('text-decoration')
}
if (temp.style.cssText) {
result.htmlAttrs.style = temp.style.cssText
} else {
delete result.htmlAttrs.style
}
}
return result
}
function buildExtensions ({ Node, Mark, Extension, Plugin, Decoration, DecorationSet }) {
const blockNode = (name, tag, content, extra = {}) => Node.create({
name,
group: 'block',
content: content || 'block+',
...extra,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag, getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return [tag, node.attrs.htmlAttrs, 0]
}
})
const attrsMark = (name, tag) => Mark.create({
name,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag, getAttrs: collectDomAttrs }]
},
renderHTML ({ mark }) {
return [tag, mark.attrs.htmlAttrs, 0]
}
})
const SpanMark = Mark.create({
name: 'span',
excludes: '',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'span', getAttrs: collectSpanDomAttrs }]
},
renderHTML ({ mark }) {
return ['span', mark.attrs.htmlAttrs, 0]
}
})
const toggleMark = (name, renderTag, parseRules, shortcuts) => Mark.create({
name,
parseHTML () {
return parseRules
},
renderHTML () {
return [renderTag, 0]
},
addCommands () {
const commandName = `toggle${name[0].toUpperCase()}${name.slice(1)}`
return {
[commandName]: () => ({ commands }) => commands.toggleMark(name)
}
},
addKeyboardShortcuts () {
return {
[shortcuts]: () => this.editor.commands.toggleMark(name)
}
}
})
const Heading = Node.create({
name: 'heading',
group: 'block',
content: 'inline*',
addAttributes () {
return {
htmlAttrs: { default: {} },
level: { default: 1 }
}
},
parseHTML () {
return [1, 2, 3, 4, 5, 6].map((level) => ({
tag: `h${level}`,
getAttrs: (dom) => ({ ...collectDomAttrs(dom), level })
}))
},
renderHTML ({ node }) {
return [`h${node.attrs.level}`, node.attrs.htmlAttrs, 0]
}
})
const ImageNode = Node.create({
name: 'image',
inline: true,
group: 'inline',
draggable: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'img', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['img', node.attrs.htmlAttrs]
}
})
const HrNode = Node.create({
name: 'horizontalRule',
group: 'block',
atom: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'hr', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['hr', node.attrs.htmlAttrs]
}
})
const StyleNode = Node.create({
name: 'style',
group: 'block',
atom: true,
selectable: false,
addAttributes () {
return {
htmlAttrs: { default: {} },
css: { default: '' }
}
},
parseHTML () {
return [{ tag: 'style', getAttrs: (dom) => ({ ...collectDomAttrs(dom), css: dom.textContent }) }]
},
renderHTML ({ node }) {
return ['style', node.attrs.htmlAttrs, node.attrs.css]
}
})
const EmptySpanNode = Node.create({
name: 'emptySpan',
inline: true,
group: 'inline',
atom: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{
tag: 'span',
priority: 60,
getAttrs (dom) {
if (dom.childNodes.length === 0 && dom.attributes.length > 0) {
return collectDomAttrs(dom)
}
return false
}
}]
},
renderHTML ({ node }) {
return ['span', node.attrs.htmlAttrs]
}
})
const LinkMark = Mark.create({
name: 'link',
inclusive: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'a', getAttrs: collectDomAttrs }]
},
renderHTML ({ mark }) {
return ['a', mark.attrs.htmlAttrs, 0]
},
addCommands () {
return {
setLink: ({ href }) => ({ editor, commands }) => {
const htmlAttrs = { ...(editor.getAttributes('link').htmlAttrs || {}), href }
return commands.setMark('link', { htmlAttrs })
},
unsetLink: () => ({ commands }) => commands.unsetMark('link', { extendEmptyMarkRange: true })
}
}
})
const buildDecorations = (doc) => {
const decorations = []
const regex = /\{\{?[a-zA-Z0-9_.-]+\}\}?/g
doc.descendants((node, pos) => {
if (!node.isText) return
let match
while ((match = regex.exec(node.text)) !== null) {
decorations.push(
Decoration.inline(pos + match.index, pos + match.index + match[0].length, {
class: 'variable-highlight'
})
)
}
})
return DecorationSet.create(doc, decorations)
}
const VariableHighlight = Extension.create({
name: 'variableHighlight',
addProseMirrorPlugins () {
return [new Plugin({
state: {
init (_, { doc }) {
return buildDecorations(doc)
},
apply (tr, oldSet) {
return tr.docChanged ? buildDecorations(tr.doc) : oldSet
}
},
props: {
decorations (state) {
return this.getState(state)
}
}
})]
}
})
return [
blockNode('paragraph', 'p', 'inline*'),
Heading,
blockNode('section', 'section'),
blockNode('article', 'article', null, { isolating: true }),
blockNode('header', 'header', null, { isolating: true }),
blockNode('footer', 'footer', null, { isolating: true }),
blockNode('div', 'div'),
blockNode('center', 'center'),
blockNode('blockquote', 'blockquote'),
blockNode('pre', 'pre'),
blockNode('orderedList', 'ol', '(listItem | block)+'),
blockNode('bulletList', 'ul', '(listItem | block)+'),
blockNode('listItem', 'li', 'block+', { group: null }),
blockNode('table', 'table', '(colgroup | tableHead | tableBody | tableFoot | tableRow)+'),
blockNode('tableHead', 'thead', 'tableRow+', { group: null }),
blockNode('tableBody', 'tbody', 'tableRow+', { group: null }),
blockNode('tableFoot', 'tfoot', 'tableRow+', { group: null }),
blockNode('tableRow', 'tr', '(tableCell | tableHeader)+', { group: null }),
blockNode('tableCell', 'td', 'block*', { group: null }),
blockNode('tableHeader', 'th', 'block*', { group: null }),
blockNode('colgroup', 'colgroup', 'col*', { group: null }),
Node.create({
name: 'col',
atom: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'col', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['col', node.attrs.htmlAttrs]
}
}),
ImageNode,
HrNode,
StyleNode,
EmptySpanNode,
SpanMark,
LinkMark,
toggleMark('bold', 'strong', [{ tag: 'strong' }, { tag: 'b' }, { style: 'font-weight=bold' }, { style: 'font-weight=700' }], 'Mod-b'),
toggleMark('italic', 'em', [{ tag: 'em' }, { tag: 'i' }, { style: 'font-style=italic' }], 'Mod-i'),
toggleMark('underline', 'u', [{ tag: 'u' }, { style: 'text-decoration=underline' }], 'Mod-u'),
toggleMark('strike', 's', [{ tag: 's' }, { tag: 'del' }, { tag: 'strike' }, { style: 'text-decoration=line-through' }], 'Mod-Shift-s'),
attrsMark('subscript', 'sub'),
attrsMark('superscript', 'sup'),
VariableHighlight
]
}
export default actionable(targetable(class extends HTMLElement {
static [target.static] = [
'textarea',
'editorElement',
'boldButton',
'italicButton',
'underlineButton',
'linkButton',
'linkTooltipTemplate'
]
async connectedCallback () {
if (!this.textarea || !this.editorElement) return
this.textarea.style.display = 'none'
this.adjustShortcutsForPlatform()
const tiptap = await loadTiptap()
const { Editor, Extension, Document, Text, HardBreak, UndoRedo, Gapcursor, Dropcursor } = tiptap
this.emailDocument = new DOMParser().parseFromString(this.textarea.value, 'text/html')
const shadow = this.editorElement.attachShadow({ mode: 'open' })
shadow.adoptedStyleSheets = [editorStylesheet]
this.emailDocument.head.querySelectorAll('style').forEach((style) => {
shadow.appendChild(style.cloneNode(true))
})
const container = document.createElement('div')
const bodyStyle = this.emailDocument.body.getAttribute('style')
if (bodyStyle) container.setAttribute('style', bodyStyle)
shadow.appendChild(container)
const LinkShortcut = Extension.create({
name: 'linkShortcut',
addKeyboardShortcuts: () => ({
'Mod-k': () => {
this.toggleLink()
return true
}
})
})
this.editor = new Editor({
element: container,
extensions: [
Document,
Text,
HardBreak,
UndoRedo,
Gapcursor,
Dropcursor,
...buildExtensions(tiptap),
LinkShortcut
],
content: this.emailDocument.body.innerHTML,
injectCSS: false,
editorProps: {
attributes: {
dir: 'auto'
},
handleDOMEvents: {
click: (_, event) => {
if (event.target.closest('a')) event.preventDefault()
return false
}
}
},
onUpdate: ({ editor }) => {
this.emailDocument.body.innerHTML = editor.getHTML()
this.textarea.value = this.emailDocument.documentElement.outerHTML
this.textarea.dispatchEvent(new Event('input', { bubbles: true }))
},
onSelectionUpdate: ({ editor }) => {
this.updateToolbarState()
this.handleLinkTooltip(editor)
},
onBlur: () => {
setTimeout(() => {
if (!this.linkTooltip.tooltip.contains(document.activeElement)) {
this.linkTooltip.hide()
}
}, 0)
}
})
this.linkTooltip = new LinkTooltip(this, this.editor, this.linkTooltipTemplate)
}
adjustShortcutsForPlatform () {
if ((navigator.userAgentData?.platform)?.toLowerCase()?.includes('mac')) {
this.querySelectorAll('.tooltip[data-tip]').forEach(tooltip => {
const tip = tooltip.getAttribute('data-tip')
if (tip && tip.includes('Ctrl')) {
tooltip.setAttribute('data-tip', tip.replace(/Ctrl/g, '⌘'))
}
})
}
}
bold (e) {
e.preventDefault()
this.editor.chain().focus().toggleBold().run()
this.updateToolbarState()
}
italic (e) {
e.preventDefault()
this.editor.chain().focus().toggleItalic().run()
this.updateToolbarState()
}
underline (e) {
e.preventDefault()
this.editor.chain().focus().toggleUnderline().run()
this.updateToolbarState()
}
linkSelection (e) {
e.preventDefault()
this.toggleLink()
this.updateToolbarState()
}
undo (e) {
e.preventDefault()
this.editor.chain().focus().undo().run()
this.updateToolbarState()
}
redo (e) {
e.preventDefault()
this.editor.chain().focus().redo().run()
this.updateToolbarState()
}
updateToolbarState () {
this.boldButton.classList.toggle('bg-base-200', this.editor.isActive('bold'))
this.italicButton.classList.toggle('bg-base-200', this.editor.isActive('italic'))
this.underlineButton.classList.toggle('bg-base-200', this.editor.isActive('underline'))
this.linkButton.classList.toggle('bg-base-200', this.editor.isActive('link'))
}
handleLinkTooltip (editor) {
const { from } = editor.state.selection
const mark = editor.state.doc.resolve(from).marks().find(m => m.type.name === 'link')
if (!mark) {
if (this.linkTooltip.isVisible()) this.linkTooltip.hide()
return
}
if (this.linkTooltip.isVisible() && this.linkTooltip.currentMark === mark) return
let linkStart = from
const start = editor.state.doc.resolve(from).start()
for (let i = from - 1; i >= start; i--) {
if (editor.state.doc.resolve(i).marks().some(m => m.eq(mark))) {
linkStart = i
} else {
break
}
}
this.linkTooltip.hide()
this.linkTooltip.show(mark.attrs.htmlAttrs?.href, linkStart > start ? linkStart - 1 : linkStart)
this.linkTooltip.currentMark = mark
}
toggleLink () {
if (this.editor.isActive('link')) {
this.linkTooltip.hide()
this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
this.updateToolbarState()
} else {
const { from } = this.editor.state.selection
this.linkTooltip.hide()
this.linkTooltip.show(this.editor.getAttributes('link').htmlAttrs?.href, from, { focus: true })
}
}
insertVariable (e) {
const variable = e.target.closest('[data-variable]')?.dataset.variable
if (variable) {
const { from, to } = this.editor.state.selection
if (variable.includes('link') && from !== to) {
this.editor.chain().focus().setLink({ href: `{${variable}}` }).run()
} else {
this.editor.chain().focus().insertContent(`{${variable}}`).run()
}
}
}
disconnectedCallback () {
this.linkTooltip?.hide()
if (this.editor) {
this.editor.destroy()
}
}
}))
+1 -1
View File
@@ -35,7 +35,7 @@ function loadTiptap () {
})) }))
} }
class LinkTooltip { export class LinkTooltip {
constructor (container, editor, templateEl) { constructor (container, editor, templateEl) {
this.container = container this.container = container
this.editor = editor this.editor = editor
@@ -0,0 +1,17 @@
export default class extends HTMLElement {
connectedCallback () {
if (this.dataset.disabled === 'true') return
if (!this.isMobile()) return
if (window.innerWidth >= 768) return
this.querySelectorAll('a[data-turbo-frame]').forEach((link) => {
link.dataset.turboFrame = '_top'
})
}
isMobile () {
const isTouchWebkit = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit|android/i.test(navigator.userAgent)
return isTouchWebkit || /iPhone|iPad|iPod/i.test(navigator.userAgent)
}
}
+40
View File
@@ -0,0 +1,40 @@
export default class extends HTMLElement {
connectedCallback () {
this.addEventListener('scroll', this.updateFade, { passive: true })
window.addEventListener('resize', this.updateFade)
this.updateFade()
}
disconnectedCallback () {
window.removeEventListener('resize', this.updateFade)
}
updateFade = () => {
const maxScroll = this.scrollWidth - this.clientWidth
const fadeGradient = {
right: 'linear-gradient(to right, black calc(100% - 24px), transparent)',
left: 'linear-gradient(to right, transparent, black 24px)',
both: 'linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent)'
}
let state = 'none'
if (maxScroll > 4) {
if (this.scrollLeft <= 4) {
state = 'right'
} else if (this.scrollLeft >= maxScroll - 4) {
state = 'left'
} else {
state = 'both'
}
}
this.applyFade(fadeGradient[state] || '')
}
applyFade (gradient) {
this.style.maskImage = gradient
this.style.webkitMaskImage = gradient
}
}
+10 -18
View File
@@ -1,19 +1,5 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.input.addEventListener('focus', () => {
if (this.title) {
this.title.classList.add('hidden', 'md:block')
this.input.classList.add('w-60')
}
})
this.input.addEventListener('blur', (e) => {
if (this.title && !e.target.value) {
this.title.classList.remove('hidden')
this.input.classList.remove('w-60')
}
})
this.button.addEventListener('click', (event) => { this.button.addEventListener('click', (event) => {
if (!this.input.value && document.activeElement !== this.input) { if (!this.input.value && document.activeElement !== this.input) {
event.preventDefault() event.preventDefault()
@@ -21,16 +7,22 @@ export default class extends HTMLElement {
this.input.focus() this.input.focus()
} }
}) })
document.addEventListener('turbo:before-cache', this.onBeforeCache)
}
disconnectedCallback () {
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
}
onBeforeCache = () => {
this.input.value = this.input.getAttribute('value') || ''
} }
get input () { get input () {
return this.querySelector('input') return this.querySelector('input')
} }
get title () {
return document.querySelector(this.dataset.title)
}
get button () { get button () {
return this.querySelector('button') return this.querySelector('button')
} }
+8 -2
View File
@@ -6,7 +6,7 @@ export default actionable(class extends HTMLElement {
document.addEventListener('keyup', this.onEscKey) document.addEventListener('keyup', this.onEscKey)
document.addEventListener('turbo:before-cache', this.close) document.addEventListener('turbo:before-cache', this.onBeforeCache)
if (this.dataset.closeAfterSubmit !== 'false') { if (this.dataset.closeAfterSubmit !== 'false') {
document.addEventListener('turbo:submit-end', this.onSubmit) document.addEventListener('turbo:submit-end', this.onSubmit)
@@ -18,7 +18,7 @@ export default actionable(class extends HTMLElement {
document.removeEventListener('keyup', this.onEscKey) document.removeEventListener('keyup', this.onEscKey)
document.removeEventListener('turbo:submit-end', this.onSubmit) document.removeEventListener('turbo:submit-end', this.onSubmit)
document.removeEventListener('turbo:before-cache', this.close) document.removeEventListener('turbo:before-cache', this.onBeforeCache)
} }
onSubmit = (e) => { onSubmit = (e) => {
@@ -33,6 +33,12 @@ export default actionable(class extends HTMLElement {
} }
} }
onBeforeCache = () => {
if (this.closest('turbo-frame')?.src) {
this.remove()
}
}
close = (e) => { close = (e) => {
e?.preventDefault() e?.preventDefault()
+1
View File
@@ -35,6 +35,7 @@ safeRegisterElement('submission-form', class extends HTMLElement {
scrollPadding: this.dataset.scrollPadding || '-80px', scrollPadding: this.dataset.scrollPadding || '-80px',
signatureText: this.dataset.signatureText, signatureText: this.dataset.signatureText,
language: this.dataset.language, language: this.dataset.language,
paymentProvider: this.dataset.paymentProvider,
dryRun: this.dataset.dryRun === 'true', dryRun: this.dataset.dryRun === 'true',
expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null, expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null,
withSignatureId: this.dataset.withSignatureId === 'true', withSignatureId: this.dataset.withSignatureId === 'true',
@@ -69,8 +69,8 @@
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
/> />
<FieldArea <FieldArea
v-else-if="item.type === 'formula_area' && isMathLoaded" v-else-if="item.type === 'formula_area' && (isMathLoaded || item.field.type === 'text')"
:model-value="calculateFormula(item.field)" :model-value="item.field.type === 'text' ? evalTextFormula(item.field) : calculateFormula(item.field)"
:is-inline-size="isInlineSize" :is-inline-size="isInlineSize"
:field="item.field" :field="item.field"
:area="item.area" :area="item.area"
@@ -289,6 +289,7 @@ export default {
methods: { methods: {
normalizeFormula: FormulaAreas.methods.normalizeFormula, normalizeFormula: FormulaAreas.methods.normalizeFormula,
calculateFormula: FormulaAreas.methods.calculateFormula, calculateFormula: FormulaAreas.methods.calculateFormula,
evalTextFormula: FormulaAreas.methods.evalTextFormula,
scrollInContainer: FieldAreas.methods.scrollInContainer, scrollInContainer: FieldAreas.methods.scrollInContainer,
scrollIntoArea: FieldAreas.methods.scrollIntoArea, scrollIntoArea: FieldAreas.methods.scrollIntoArea,
scrollIntoField: FieldAreas.methods.scrollIntoField, scrollIntoField: FieldAreas.methods.scrollIntoField,
+4 -4
View File
@@ -101,7 +101,7 @@
</template> </template>
</div> </div>
<div> <div>
{{ new Date(signature.created_at).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', timeZoneName: 'short' }) }} {{ new Date(signature.created_at).toLocaleString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric', timeZoneName: 'short' }) }}
</div> </div>
</div> </div>
</div> </div>
@@ -528,7 +528,7 @@ export default {
return this.formatDate( return this.formatDate(
this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue), this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue),
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'), this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'),
{ withTimePlaceholders: this.modelValue === '{{date}}' } { withTimePlaceholders: this.modelValue === '{{date}}', utc: this.modelValue !== '{{date}}' }
) )
} catch { } catch {
return this.modelValue return this.modelValue
@@ -651,7 +651,7 @@ export default {
return number return number
} }
}, },
formatDate (date, format, { withTimePlaceholders = false } = {}) { formatDate (date, format, { withTimePlaceholders = false, utc = true } = {}) {
const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' } const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
const dayFormats = { D: 'numeric', DD: '2-digit' } const dayFormats = { D: 'numeric', DD: '2-digit' }
const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' } const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' }
@@ -673,7 +673,7 @@ export default {
if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]] if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]]
if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]] if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]]
if (/z/.test(format)) opts.timeZoneName = 'short' if (/z/.test(format)) opts.timeZoneName = 'short'
if (!hasTime) opts.timeZone = 'UTC' if (!hasTime && utc) opts.timeZone = 'UTC'
const partTypes = { const partTypes = {
M: 'month', M: 'month',
@@ -27,6 +27,7 @@
</label> </label>
<button <button
v-if="withToday" v-if="withToday"
type="button"
class="btn btn-outline btn-sm !normal-case font-normal set-current-date-button" class="btn btn-outline btn-sm !normal-case font-normal set-current-date-button"
@click.prevent="[setCurrentDate(), $emit('focus')]" @click.prevent="[setCurrentDate(), $emit('focus')]"
> >
+38 -28
View File
@@ -51,6 +51,36 @@
<script> <script>
import { IconCloudUpload, IconInnerShadowTop } from '@tabler/icons-vue' import { IconCloudUpload, IconInnerShadowTop } from '@tabler/icons-vue'
function convertImage (sourceFile, targetType, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
resolve(newFile)
}, targetType, quality)
}
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(sourceFile)
})
}
export default { export default {
name: 'FileDropzone', name: 'FileDropzone',
components: { components: {
@@ -153,8 +183,14 @@ export default {
} }
}) })
} else { } else {
if (file.type === 'image/bmp' || file.type === 'image/vnd.microsoft.icon') { try {
file = await this.convertBmpToPng(file) if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
file = await convertImage(file, 'image/png')
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
file = await convertImage(file, 'image/jpeg', 0.9)
}
} catch (e) {
alert(e.message)
} }
formData.append('file', file) formData.append('file', file)
@@ -181,32 +217,6 @@ export default {
}).finally(() => { }).finally(() => {
this.isLoading = false this.isLoading = false
}) })
},
convertBmpToPng (bmpFile) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const newFile = new File([blob], bmpFile.name.replace(/\.\w+$/, '.png'), { type: 'image/png' })
resolve(newFile)
}, 'image/png')
}
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(bmpFile)
})
} }
} }
} }
+32 -5
View File
@@ -95,7 +95,7 @@
v-else v-else
id="complete_form_button" id="complete_form_button"
class="btn btn-sm btn-neutral text-white px-4 w-full flex justify-center" class="btn btn-sm btn-neutral text-white px-4 w-full flex justify-center"
form="steps_form" form="complete_form"
type="submit" type="submit"
name="completed" name="completed"
value="true" value="true"
@@ -120,7 +120,7 @@
> >
<button <button
class="complete-button btn btn-sm btn-neutral text-white px-4" class="complete-button btn btn-sm btn-neutral text-white px-4"
form="steps_form" form="complete_form"
type="submit" type="submit"
name="completed" name="completed"
value="true" value="true"
@@ -138,8 +138,16 @@
</span> </span>
</button> </button>
</Teleport> </Teleport>
<form
v-if="!isCompleted && !isInvite"
id="complete_form"
class="hidden"
:action="submitPath"
method="post"
@submit.prevent="submitStep"
/>
<button <button
v-if="!isFormVisible" v-if="!isFormVisible && currentField"
id="expand_form_button" id="expand_form_button"
class="btn btn-neutral flex text-white absolute bottom-0 w-full mb-3 expand-form-button text-base" class="btn btn-neutral flex text-white absolute bottom-0 w-full mb-3 expand-form-button text-base"
style="width: 96%; margin-left: 2%" style="width: 96%; margin-left: 2%"
@@ -166,6 +174,7 @@
/> />
</button> </button>
<div <div
v-if="currentField"
v-show="isFormVisible" v-show="isFormVisible"
id="form_container" id="form_container"
class="shadow-md bg-base-100 absolute bottom-0 w-full border-base-200 border p-4 rounded form-container overflow-hidden" class="shadow-md bg-base-100 absolute bottom-0 w-full border-base-200 border p-4 rounded form-container overflow-hidden"
@@ -281,6 +290,7 @@
:id="currentField.uuid" :id="currentField.uuid"
dir="auto" dir="auto"
:required="currentField.required" :required="currentField.required"
:aria-label="showFieldNames && (currentField.name || currentField.title) ? undefined : (currentField.name || currentField.title || t('select_your_option'))"
:aria-describedby="currentField.description ? currentField.uuid + '-desc' : undefined" :aria-describedby="currentField.description ? currentField.uuid + '-desc' : undefined"
class="select base-input !text-2xl w-full text-center font-normal" class="select base-input !text-2xl w-full text-center font-normal"
:class="{ 'text-gray-300': !values[currentField.uuid] }" :class="{ 'text-gray-300': !values[currentField.uuid] }"
@@ -309,7 +319,7 @@
<div v-else-if="currentField.type === 'radio'"> <div v-else-if="currentField.type === 'radio'">
<label <label
v-if="showFieldNames && (currentField.name || currentField.title)" v-if="showFieldNames && (currentField.name || currentField.title)"
:for="currentField.uuid" :id="currentField.uuid + '-radio-label'"
dir="auto" dir="auto"
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label" class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
:class="{ 'mb-2': !currentField.description }" :class="{ 'mb-2': !currentField.description }"
@@ -348,6 +358,8 @@
</div> </div>
<div <div
class="space-y-3.5 mx-auto" class="space-y-3.5 mx-auto"
role="radiogroup"
:aria-labelledby="(currentField.name || currentField.title) ? currentField.uuid + '-radio-label' : null"
:class="{ hidden: !showFieldNames || (currentField.options.every((e) => !e.value) && currentField.options.length > 4) }" :class="{ hidden: !showFieldNames || (currentField.options.every((e) => !e.value) && currentField.options.length > 4) }"
> >
<div <div
@@ -548,6 +560,7 @@
:fields="formulaFields" :fields="formulaFields"
:values="values" :values="values"
:readonly-values="readonlyFieldValues" :readonly-values="readonlyFieldValues"
:provider="paymentProvider"
@attached="attachments.push($event)" @attached="attachments.push($event)"
@focus="scrollIntoField(currentField)" @focus="scrollIntoField(currentField)"
@submit="!isSubmitting && submitStep()" @submit="!isSubmitting && submitStep()"
@@ -988,6 +1001,11 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
paymentProvider: {
type: String,
required: false,
default: ''
},
values: { values: {
type: Object, type: Object,
required: false, required: false,
@@ -1161,7 +1179,11 @@ export default {
} }
}, },
isAnonymousChecboxes () { isAnonymousChecboxes () {
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name && !e.required) && this.currentStepFields.length > 4 if (this.currentField) {
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name && !e.required) && this.currentStepFields.length > 4
} else {
return false
}
}, },
isButtonDisabled () { isButtonDisabled () {
if (this.recalculateButtonDisabledKey) { if (this.recalculateButtonDisabledKey) {
@@ -1559,6 +1581,7 @@ export default {
} }
}, },
goToStep (stepIndex, scrollToArea = false, clickUpload = false) { goToStep (stepIndex, scrollToArea = false, clickUpload = false) {
this.isInvite = false
this.currentStep = stepIndex this.currentStep = stepIndex
this.showFillAllRequiredFields = false this.showFillAllRequiredFields = false
@@ -1583,6 +1606,10 @@ export default {
}) })
}, },
saveStep (formData) { saveStep (formData) {
if (!formData && !this.$refs.form) {
return
}
const currentFieldUuids = this.currentStepFields.map((f) => f.uuid) const currentFieldUuids = this.currentStepFields.map((f) => f.uuid)
const currentFieldType = this.currentField.type const currentFieldType = this.currentField.type
@@ -12,8 +12,8 @@
:to="findPageElementForArea(area)" :to="findPageElementForArea(area)"
> >
<FieldArea <FieldArea
v-if="isMathLoaded" v-if="isMathLoaded || field.type === 'text'"
:model-value="calculateFormula(field)" :model-value="field.type === 'text' ? evalTextFormula(field) : calculateFormula(field)"
:is-inline-size="isInlineSize" :is-inline-size="isInlineSize"
:field="field" :field="field"
:area="area" :area="area"
@@ -95,6 +95,25 @@ export default {
}) })
return this.math.evaluate(transformedFormula.toLowerCase()) return this.math.evaluate(transformedFormula.toLowerCase())
},
evalTextFormula (field, depth = 0) {
if (depth > 10) return ''
return field.preferences.formula.replace(/{{(.*?)}}/g, (match, uuid) => {
const formulaField = this.fieldsUuidIndex[uuid]
if (formulaField?.preferences?.formula) {
if (formulaField.type === 'text') {
return this.evalTextFormula(formulaField, depth + 1)
} else if (this.isMathLoaded) {
return this.calculateFormula(formulaField)
}
}
const value = this.readonlyValues[uuid] ?? this.values[uuid]
return Array.isArray(value) ? value.join(', ') : (value ?? '')
})
} }
} }
} }
+42
View File
@@ -4,6 +4,7 @@ const en = {
close: 'Close', close: 'Close',
uploaded_files: 'Uploaded files', uploaded_files: 'Uploaded files',
signature_drawing_area: 'Signature drawing area. Use mouse or touch to draw your signature.', signature_drawing_area: 'Signature drawing area. Use mouse or touch to draw your signature.',
initials_drawing_area: 'Initials drawing area. Use mouse or touch to draw your initials.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Please upload an image file', please_upload_an_image_file: 'Please upload an image file',
must_be_characters_length: 'Must be {number} characters long', must_be_characters_length: 'Must be {number} characters long',
@@ -88,6 +89,7 @@ const en = {
please_check_the_box_to_continue: 'Please check the box to continue.', please_check_the_box_to_continue: 'Please check the box to continue.',
open_source_documents_software: 'open source documents software', open_source_documents_software: 'open source documents software',
verified_phone_number: 'Verify Phone Number', verified_phone_number: 'Verify Phone Number',
country_code: 'Country code',
use_international_format: 'Use international format: +1xxx', use_international_format: 'Use international format: +1xxx',
six_digits_code: '6-digit code', six_digits_code: '6-digit code',
change_phone_number: 'Change phone number', change_phone_number: 'Change phone number',
@@ -105,6 +107,7 @@ const en = {
email_has_been_sent: 'Email has been sent', email_has_been_sent: 'Email has been sent',
processing: 'Processing', processing: 'Processing',
pay_with_stripe: 'Pay with Stripe', pay_with_stripe: 'Pay with Stripe',
pay_with_paypal: 'Pay with PayPal',
reupload: 'Reupload', reupload: 'Reupload',
upload: 'Upload', upload: 'Upload',
files: 'Files', files: 'Files',
@@ -120,6 +123,7 @@ const es = {
close: 'Cerrar', close: 'Cerrar',
uploaded_files: 'Archivos subidos', uploaded_files: 'Archivos subidos',
signature_drawing_area: 'Área de dibujo de firma. Use el ratón o toque para dibujar su firma.', signature_drawing_area: 'Área de dibujo de firma. Use el ratón o toque para dibujar su firma.',
initials_drawing_area: 'Área de dibujo de iniciales. Use el ratón o toque para dibujar sus iniciales.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Por favor, sube un archivo de imagen', please_upload_an_image_file: 'Por favor, sube un archivo de imagen',
must_be_characters_length: 'Debe tener {number} caracteres de longitud', must_be_characters_length: 'Debe tener {number} caracteres de longitud',
@@ -204,6 +208,7 @@ const es = {
please_check_the_box_to_continue: 'Por favor marque la casilla para continuar.', please_check_the_box_to_continue: 'Por favor marque la casilla para continuar.',
open_source_documents_software: 'software de documentos de código abierto', open_source_documents_software: 'software de documentos de código abierto',
verified_phone_number: 'Verificar número de teléfono', verified_phone_number: 'Verificar número de teléfono',
country_code: 'Código de país',
use_international_format: 'Usar formato internacional: +1xxx', use_international_format: 'Usar formato internacional: +1xxx',
six_digits_code: 'Código de 6 dígitos', six_digits_code: 'Código de 6 dígitos',
change_phone_number: 'Cambiar número de teléfono', change_phone_number: 'Cambiar número de teléfono',
@@ -221,6 +226,7 @@ const es = {
email_has_been_sent: 'El correo electrónico ha sido enviado', email_has_been_sent: 'El correo electrónico ha sido enviado',
processing: 'Procesando', processing: 'Procesando',
pay_with_stripe: 'Pagar con Stripe', pay_with_stripe: 'Pagar con Stripe',
pay_with_paypal: 'Pagar con PayPal',
reupload: 'Volver a subir', reupload: 'Volver a subir',
upload: 'Subir', upload: 'Subir',
files: 'Archivos', files: 'Archivos',
@@ -236,6 +242,7 @@ const it = {
close: 'Chiudi', close: 'Chiudi',
uploaded_files: 'File caricati', uploaded_files: 'File caricati',
signature_drawing_area: 'Area di disegno della firma. Usa il mouse o il tocco per disegnare la tua firma.', signature_drawing_area: 'Area di disegno della firma. Usa il mouse o il tocco per disegnare la tua firma.',
initials_drawing_area: 'Area di disegno delle iniziali. Usa il mouse o il tocco per disegnare le tue iniziali.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Per favore carica un file immagine', please_upload_an_image_file: 'Per favore carica un file immagine',
must_be_characters_length: 'Deve essere lungo {number} caratteri', must_be_characters_length: 'Deve essere lungo {number} caratteri',
@@ -320,6 +327,7 @@ const it = {
please_check_the_box_to_continue: 'Si prega di spuntare la casella per continuare.', please_check_the_box_to_continue: 'Si prega di spuntare la casella per continuare.',
open_source_documents_software: 'software di documenti open source', open_source_documents_software: 'software di documenti open source',
verified_phone_number: 'Verifica numero di telefono', verified_phone_number: 'Verifica numero di telefono',
country_code: 'Prefisso internazionale',
use_international_format: 'Usa formato internazionale: +1xxx', use_international_format: 'Usa formato internazionale: +1xxx',
six_digits_code: 'Codice a 6 cifre', six_digits_code: 'Codice a 6 cifre',
change_phone_number: 'Cambia numero di telefono', change_phone_number: 'Cambia numero di telefono',
@@ -337,6 +345,7 @@ const it = {
email_has_been_sent: "L'email è stata inviata", email_has_been_sent: "L'email è stata inviata",
processing: 'Elaborazione', processing: 'Elaborazione',
pay_with_stripe: 'Paga con Stripe', pay_with_stripe: 'Paga con Stripe',
pay_with_paypal: 'Paga con PayPal',
reupload: 'Ricarica', reupload: 'Ricarica',
upload: 'Carica', upload: 'Carica',
files: 'File', files: 'File',
@@ -352,6 +361,7 @@ const de = {
close: 'Schließen', close: 'Schließen',
uploaded_files: 'Hochgeladene Dateien', uploaded_files: 'Hochgeladene Dateien',
signature_drawing_area: 'Unterschriftszeichenbereich. Verwenden Sie die Maus oder Berührung, um Ihre Unterschrift zu zeichnen.', signature_drawing_area: 'Unterschriftszeichenbereich. Verwenden Sie die Maus oder Berührung, um Ihre Unterschrift zu zeichnen.',
initials_drawing_area: 'Zeichenbereich für Initialen. Verwenden Sie die Maus oder Berührung, um Ihre Initialen zu zeichnen.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch', please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch',
must_be_characters_length: 'Muss {number} Zeichen lang sein', must_be_characters_length: 'Muss {number} Zeichen lang sein',
@@ -436,6 +446,7 @@ const de = {
please_check_the_box_to_continue: 'Bitte aktivieren Sie das Kontrollkästchen, um fortzufahren.', please_check_the_box_to_continue: 'Bitte aktivieren Sie das Kontrollkästchen, um fortzufahren.',
open_source_documents_software: 'Open-Source-Dokumentensoftware', open_source_documents_software: 'Open-Source-Dokumentensoftware',
verified_phone_number: 'Telefonnummer verifizieren', verified_phone_number: 'Telefonnummer verifizieren',
country_code: 'Ländervorwahl',
use_international_format: 'Internationales Format verwenden: +1xxx', use_international_format: 'Internationales Format verwenden: +1xxx',
six_digits_code: '6-stelliger Code', six_digits_code: '6-stelliger Code',
change_phone_number: 'Telefonnummer ändern', change_phone_number: 'Telefonnummer ändern',
@@ -453,6 +464,7 @@ const de = {
email_has_been_sent: 'E-Mail wurde gesendet', email_has_been_sent: 'E-Mail wurde gesendet',
processing: 'Verarbeitung', processing: 'Verarbeitung',
pay_with_stripe: 'Mit Stripe bezahlen', pay_with_stripe: 'Mit Stripe bezahlen',
pay_with_paypal: 'Mit PayPal bezahlen',
reupload: 'Erneut hochladen', reupload: 'Erneut hochladen',
upload: 'Hochladen', upload: 'Hochladen',
files: 'Dateien', files: 'Dateien',
@@ -468,6 +480,7 @@ const fr = {
close: 'Fermer', close: 'Fermer',
uploaded_files: 'Fichiers téléchargés', uploaded_files: 'Fichiers téléchargés',
signature_drawing_area: 'Zone de dessin de signature. Utilisez la souris ou le toucher pour dessiner votre signature.', signature_drawing_area: 'Zone de dessin de signature. Utilisez la souris ou le toucher pour dessiner votre signature.',
initials_drawing_area: 'Zone de dessin des initiales. Utilisez la souris ou le toucher pour dessiner vos initiales.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Veuillez téléverser un fichier image', please_upload_an_image_file: 'Veuillez téléverser un fichier image',
must_be_characters_length: 'Doit comporter {number} caractères', must_be_characters_length: 'Doit comporter {number} caractères',
@@ -552,6 +565,7 @@ const fr = {
please_check_the_box_to_continue: 'Veuillez cocher la case pour continuer.', please_check_the_box_to_continue: 'Veuillez cocher la case pour continuer.',
open_source_documents_software: 'logiciel de documents open source', open_source_documents_software: 'logiciel de documents open source',
verified_phone_number: 'Vérifier le numéro de téléphone', verified_phone_number: 'Vérifier le numéro de téléphone',
country_code: 'Indicatif du pays',
use_international_format: 'Utilisez le format international : +1xxx', use_international_format: 'Utilisez le format international : +1xxx',
six_digits_code: 'Code à 6 chiffres', six_digits_code: 'Code à 6 chiffres',
change_phone_number: 'Changer de numéro de téléphone', change_phone_number: 'Changer de numéro de téléphone',
@@ -569,6 +583,7 @@ const fr = {
email_has_been_sent: "L'e-mail a été envoyé", email_has_been_sent: "L'e-mail a été envoyé",
processing: 'Traitement en cours', processing: 'Traitement en cours',
pay_with_stripe: 'Payer avec Stripe', pay_with_stripe: 'Payer avec Stripe',
pay_with_paypal: 'Payer avec PayPal',
reupload: 'Retéléverser', reupload: 'Retéléverser',
upload: 'Téléverser', upload: 'Téléverser',
files: 'Fichiers', files: 'Fichiers',
@@ -584,6 +599,7 @@ const pl = {
close: 'Zamknij', close: 'Zamknij',
uploaded_files: 'Przesłane pliki', uploaded_files: 'Przesłane pliki',
signature_drawing_area: 'Obszar rysowania podpisu. Użyj myszy lub dotyku, aby narysować swój podpis.', signature_drawing_area: 'Obszar rysowania podpisu. Użyj myszy lub dotyku, aby narysować swój podpis.',
initials_drawing_area: 'Obszar rysowania inicjałów. Użyj myszy lub dotyku, aby narysować swoje inicjały.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Proszę przesłać plik obrazu', please_upload_an_image_file: 'Proszę przesłać plik obrazu',
must_be_characters_length: 'Musi mieć długość {number} znaków', must_be_characters_length: 'Musi mieć długość {number} znaków',
@@ -668,6 +684,7 @@ const pl = {
please_check_the_box_to_continue: 'Proszę zaznaczyć pole, aby kontynuować.', please_check_the_box_to_continue: 'Proszę zaznaczyć pole, aby kontynuować.',
open_source_documents_software: 'oprogramowanie do dokumentów open source', open_source_documents_software: 'oprogramowanie do dokumentów open source',
verified_phone_number: 'Zweryfikuj numer telefonu', verified_phone_number: 'Zweryfikuj numer telefonu',
country_code: 'Kod kraju',
use_international_format: 'Użyj międzynarodowego formatu: +1xxx', use_international_format: 'Użyj międzynarodowego formatu: +1xxx',
six_digits_code: '6-cyfrowy kod', six_digits_code: '6-cyfrowy kod',
change_phone_number: 'Zmień numer telefonu', change_phone_number: 'Zmień numer telefonu',
@@ -685,6 +702,7 @@ const pl = {
email_has_been_sent: 'E-mail został wysłany', email_has_been_sent: 'E-mail został wysłany',
processing: 'Przetwarzanie', processing: 'Przetwarzanie',
pay_with_stripe: 'Zapłać za pomocą Stripe', pay_with_stripe: 'Zapłać za pomocą Stripe',
pay_with_paypal: 'Zapłać za pomocą PayPal',
reupload: 'Ponowne przesłanie', reupload: 'Ponowne przesłanie',
upload: 'Przesyłanie', upload: 'Przesyłanie',
files: 'Pliki', files: 'Pliki',
@@ -700,6 +718,7 @@ const uk = {
close: 'Закрити', close: 'Закрити',
uploaded_files: 'Завантажені файли', uploaded_files: 'Завантажені файли',
signature_drawing_area: 'Область малювання підпису. Використовуйте мишу або дотик, щоб намалювати свій підпис.', signature_drawing_area: 'Область малювання підпису. Використовуйте мишу або дотик, щоб намалювати свій підпис.',
initials_drawing_area: 'Область малювання ініціалів. Використовуйте мишу або дотик, щоб намалювати свої ініціали.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Будь ласка, завантажте файл зображення', please_upload_an_image_file: 'Будь ласка, завантажте файл зображення',
must_be_characters_length: 'Має містити {number} символів', must_be_characters_length: 'Має містити {number} символів',
@@ -784,6 +803,7 @@ const uk = {
please_check_the_box_to_continue: 'Будь ласка, позначте прапорець, щоб продовжити.', please_check_the_box_to_continue: 'Будь ласка, позначте прапорець, щоб продовжити.',
open_source_documents_software: 'відкрите програмне забезпечення для документів', open_source_documents_software: 'відкрите програмне забезпечення для документів',
verified_phone_number: 'Підтвердіть номер телефону', verified_phone_number: 'Підтвердіть номер телефону',
country_code: 'Код країни',
use_international_format: 'Використовуйте міжнародний формат: +1xxx', use_international_format: 'Використовуйте міжнародний формат: +1xxx',
six_digits_code: '6-значний код', six_digits_code: '6-значний код',
change_phone_number: 'Змінити номер телефону', change_phone_number: 'Змінити номер телефону',
@@ -801,6 +821,7 @@ const uk = {
email_has_been_sent: 'Електронний лист був відправлений', email_has_been_sent: 'Електронний лист був відправлений',
processing: 'Обробка', processing: 'Обробка',
pay_with_stripe: 'Сплатити за допомогою Stripe', pay_with_stripe: 'Сплатити за допомогою Stripe',
pay_with_paypal: 'Сплатити за допомогою PayPal',
reupload: 'Перезавантажити', reupload: 'Перезавантажити',
upload: 'Завантажити', upload: 'Завантажити',
files: 'Файли', files: 'Файли',
@@ -816,6 +837,7 @@ const cs = {
close: 'Zavřít', close: 'Zavřít',
uploaded_files: 'Nahrané soubory', uploaded_files: 'Nahrané soubory',
signature_drawing_area: 'Oblast pro kreslení podpisu. Použijte myš nebo dotyk k nakreslení podpisu.', signature_drawing_area: 'Oblast pro kreslení podpisu. Použijte myš nebo dotyk k nakreslení podpisu.',
initials_drawing_area: 'Oblast pro kreslení iniciál. Použijte myš nebo dotyk k nakreslení iniciál.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Nahrajte prosím obrázkový soubor', please_upload_an_image_file: 'Nahrajte prosím obrázkový soubor',
must_be_characters_length: 'Musí mít délku {number} znaků', must_be_characters_length: 'Musí mít délku {number} znaků',
@@ -900,6 +922,7 @@ const cs = {
please_check_the_box_to_continue: 'Prosím, zaškrtněte políčko pro pokračování.', please_check_the_box_to_continue: 'Prosím, zaškrtněte políčko pro pokračování.',
open_source_documents_software: 'open source software pro dokumenty', open_source_documents_software: 'open source software pro dokumenty',
verified_phone_number: 'Ověřte telefonní číslo', verified_phone_number: 'Ověřte telefonní číslo',
country_code: 'Předvolba země',
use_international_format: 'Použijte mezinárodní formát: +1xxx', use_international_format: 'Použijte mezinárodní formát: +1xxx',
six_digits_code: '6-místný kód', six_digits_code: '6-místný kód',
change_phone_number: 'Změnit telefonní číslo', change_phone_number: 'Změnit telefonní číslo',
@@ -917,6 +940,7 @@ const cs = {
email_has_been_sent: 'E-mail byl odeslán', email_has_been_sent: 'E-mail byl odeslán',
processing: 'Zpracování', processing: 'Zpracování',
pay_with_stripe: 'Zaplatit přes Stripe', pay_with_stripe: 'Zaplatit přes Stripe',
pay_with_paypal: 'Zaplatit přes PayPal',
reupload: 'Znovu nahrát', reupload: 'Znovu nahrát',
upload: 'Nahrát', upload: 'Nahrát',
files: 'Soubory', files: 'Soubory',
@@ -932,6 +956,7 @@ const pt = {
close: 'Fechar', close: 'Fechar',
uploaded_files: 'Arquivos enviados', uploaded_files: 'Arquivos enviados',
signature_drawing_area: 'Área de desenho da assinatura. Use o mouse ou toque para desenhar sua assinatura.', signature_drawing_area: 'Área de desenho da assinatura. Use o mouse ou toque para desenhar sua assinatura.',
initials_drawing_area: 'Área de desenho das iniciais. Use o mouse ou toque para desenhar suas iniciais.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Por favor, envie um arquivo de imagem', please_upload_an_image_file: 'Por favor, envie um arquivo de imagem',
must_be_characters_length: 'Deve ter {number} caracteres', must_be_characters_length: 'Deve ter {number} caracteres',
@@ -1016,6 +1041,7 @@ const pt = {
please_check_the_box_to_continue: 'Por favor, marque a caixa para continuar.', please_check_the_box_to_continue: 'Por favor, marque a caixa para continuar.',
open_source_documents_software: 'software de documentos de código aberto', open_source_documents_software: 'software de documentos de código aberto',
verified_phone_number: 'Verificar Número de Telefone', verified_phone_number: 'Verificar Número de Telefone',
country_code: 'Código do país',
use_international_format: 'Use formato internacional: +1xxx', use_international_format: 'Use formato internacional: +1xxx',
six_digits_code: 'Código de 6 dígitos', six_digits_code: 'Código de 6 dígitos',
change_phone_number: 'Alterar número de telefone', change_phone_number: 'Alterar número de telefone',
@@ -1033,6 +1059,7 @@ const pt = {
email_has_been_sent: 'Email enviado', email_has_been_sent: 'Email enviado',
processing: 'Processamento', processing: 'Processamento',
pay_with_stripe: 'Pagar com Stripe', pay_with_stripe: 'Pagar com Stripe',
pay_with_paypal: 'Pagar com PayPal',
reupload: 'Recarregar', reupload: 'Recarregar',
upload: 'Carregar', upload: 'Carregar',
files: 'Arquivos', files: 'Arquivos',
@@ -1048,6 +1075,7 @@ const he = {
close: 'סגור', close: 'סגור',
uploaded_files: 'קבצים שהועלו', uploaded_files: 'קבצים שהועלו',
signature_drawing_area: 'אזור ציור חתימה. השתמש בעכבר או במגע כדי לצייר את החתימה שלך.', signature_drawing_area: 'אזור ציור חתימה. השתמש בעכבר או במגע כדי לצייר את החתימה שלך.',
initials_drawing_area: 'אזור ציור ראשי תיבות. השתמש בעכבר או במגע כדי לצייר את ראשי התיבות שלך.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'אנא העלה קובץ תמונה', please_upload_an_image_file: 'אנא העלה קובץ תמונה',
must_be_characters_length: 'חייב להיות באורך של {number} תווים', must_be_characters_length: 'חייב להיות באורך של {number} תווים',
@@ -1132,6 +1160,7 @@ const he = {
please_check_the_box_to_continue: 'אנא סמן את התיבה כדי להמשיך.', please_check_the_box_to_continue: 'אנא סמן את התיבה כדי להמשיך.',
open_source_documents_software: 'תוכנה פתוחה למסמכים', open_source_documents_software: 'תוכנה פתוחה למסמכים',
verified_phone_number: 'אימות מספר טלפון', verified_phone_number: 'אימות מספר טלפון',
country_code: 'קידומת מדינה',
use_international_format: 'השתמש בפורמט בינלאומי: +1xxx', use_international_format: 'השתמש בפורמט בינלאומי: +1xxx',
six_digits_code: 'קוד שש ספרות', six_digits_code: 'קוד שש ספרות',
change_phone_number: 'שינוי מספר טלפון', change_phone_number: 'שינוי מספר טלפון',
@@ -1149,6 +1178,7 @@ const he = {
email_has_been_sent: 'האימייל נשלח', email_has_been_sent: 'האימייל נשלח',
processing: 'מעבד', processing: 'מעבד',
pay_with_stripe: 'שלם עם סטרייפ', pay_with_stripe: 'שלם עם סטרייפ',
pay_with_paypal: 'שלם עם פייפאל',
reupload: 'העלה שוב', reupload: 'העלה שוב',
upload: 'העלאה', upload: 'העלאה',
files: 'קבצים', files: 'קבצים',
@@ -1164,6 +1194,7 @@ const nl = {
close: 'Sluiten', close: 'Sluiten',
uploaded_files: 'Geüploade bestanden', uploaded_files: 'Geüploade bestanden',
signature_drawing_area: 'Handtekening tekengebied. Gebruik de muis of aanraking om uw handtekening te tekenen.', signature_drawing_area: 'Handtekening tekengebied. Gebruik de muis of aanraking om uw handtekening te tekenen.',
initials_drawing_area: 'Tekengebied voor initialen. Gebruik de muis of aanraking om uw initialen te tekenen.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand', please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand',
must_be_characters_length: 'Moet {number} tekens lang zijn', must_be_characters_length: 'Moet {number} tekens lang zijn',
@@ -1248,6 +1279,7 @@ const nl = {
please_check_the_box_to_continue: 'Vink het vakje aan om door te gaan.', please_check_the_box_to_continue: 'Vink het vakje aan om door te gaan.',
open_source_documents_software: 'Open source documenten software', open_source_documents_software: 'Open source documenten software',
verified_phone_number: 'Telefoonnummer verifiëren', verified_phone_number: 'Telefoonnummer verifiëren',
country_code: 'Landcode',
use_international_format: 'Gebruik internationaal formaat: +1xxx', use_international_format: 'Gebruik internationaal formaat: +1xxx',
six_digits_code: '6-cijferige code', six_digits_code: '6-cijferige code',
change_phone_number: 'Wijzig telefoonnummer', change_phone_number: 'Wijzig telefoonnummer',
@@ -1265,6 +1297,7 @@ const nl = {
email_has_been_sent: 'E-mail is verzonden', email_has_been_sent: 'E-mail is verzonden',
processing: 'Verwerken', processing: 'Verwerken',
pay_with_stripe: 'Betalen met Stripe', pay_with_stripe: 'Betalen met Stripe',
pay_with_paypal: 'Betalen met PayPal',
reupload: 'Opnieuw uploaden', reupload: 'Opnieuw uploaden',
upload: 'Uploaden', upload: 'Uploaden',
files: 'Bestanden', files: 'Bestanden',
@@ -1280,6 +1313,7 @@ const ar = {
close: 'إغلاق', close: 'إغلاق',
uploaded_files: 'الملفات المرفوعة', uploaded_files: 'الملفات المرفوعة',
signature_drawing_area: 'منطقة رسم التوقيع. استخدم الماوس أو اللمس لرسم توقيعك.', signature_drawing_area: 'منطقة رسم التوقيع. استخدم الماوس أو اللمس لرسم توقيعك.',
initials_drawing_area: 'منطقة رسم الحروف الأولى. استخدم الماوس أو اللمس لرسم الحروف الأولى.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'يرجى تحميل ملف صورة', please_upload_an_image_file: 'يرجى تحميل ملف صورة',
must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا', must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا',
@@ -1364,6 +1398,7 @@ const ar = {
please_check_the_box_to_continue: 'الرجاء التحقق من الخانة للمتابعة.', please_check_the_box_to_continue: 'الرجاء التحقق من الخانة للمتابعة.',
open_source_documents_software: 'برنامج وثائق مفتوح المصدر', open_source_documents_software: 'برنامج وثائق مفتوح المصدر',
verified_phone_number: 'تحقق من رقم الهاتف', verified_phone_number: 'تحقق من رقم الهاتف',
country_code: 'رمز الدولة',
use_international_format: 'استخدم الشكل الدولي: +1xxx', use_international_format: 'استخدم الشكل الدولي: +1xxx',
six_digits_code: 'رمز مكون من 6 أرقام', six_digits_code: 'رمز مكون من 6 أرقام',
change_phone_number: 'تغيير رقم الهاتف', change_phone_number: 'تغيير رقم الهاتف',
@@ -1381,6 +1416,7 @@ const ar = {
email_has_been_sent: 'تم إرسال البريد الإلكتروني', email_has_been_sent: 'تم إرسال البريد الإلكتروني',
processing: 'جارٍ المعالجة', processing: 'جارٍ المعالجة',
pay_with_stripe: 'الدفع بواسطة Stripe', pay_with_stripe: 'الدفع بواسطة Stripe',
pay_with_paypal: 'الدفع بواسطة PayPal',
reupload: 'إعادة التحميل', reupload: 'إعادة التحميل',
upload: 'تحميل', upload: 'تحميل',
files: 'الملفات', files: 'الملفات',
@@ -1396,6 +1432,7 @@ const ko = {
close: '닫기', close: '닫기',
uploaded_files: '업로드된 파일', uploaded_files: '업로드된 파일',
signature_drawing_area: '서명 그리기 영역. 마우스 또는 터치를 사용하여 서명을 그리세요.', signature_drawing_area: '서명 그리기 영역. 마우스 또는 터치를 사용하여 서명을 그리세요.',
initials_drawing_area: '이니셜 그리기 영역. 마우스 또는 터치를 사용하여 이니셜을 그리세요.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: '이미지 파일을 업로드해 주세요', please_upload_an_image_file: '이미지 파일을 업로드해 주세요',
must_be_characters_length: '{number}자여야 합니다', must_be_characters_length: '{number}자여야 합니다',
@@ -1480,6 +1517,7 @@ const ko = {
please_check_the_box_to_continue: '계속하려면 확인란을 선택하십시오.', please_check_the_box_to_continue: '계속하려면 확인란을 선택하십시오.',
open_source_documents_software: '오픈 소스 문서 소프트웨어', open_source_documents_software: '오픈 소스 문서 소프트웨어',
verified_phone_number: '전화번호 확인됨', verified_phone_number: '전화번호 확인됨',
country_code: '국가 번호',
use_international_format: '국제 포맷 사용: +1xxx', use_international_format: '국제 포맷 사용: +1xxx',
six_digits_code: '6자리 코드', six_digits_code: '6자리 코드',
change_phone_number: '전화번호 변경', change_phone_number: '전화번호 변경',
@@ -1497,6 +1535,7 @@ const ko = {
email_has_been_sent: '이메일이 전송되었습니다', email_has_been_sent: '이메일이 전송되었습니다',
processing: '처리 중', processing: '처리 중',
pay_with_stripe: '스트라이프로 결제', pay_with_stripe: '스트라이프로 결제',
pay_with_paypal: 'PayPal로 결제',
reupload: '다시 업로드', reupload: '다시 업로드',
upload: '업로드', upload: '업로드',
files: '파일', files: '파일',
@@ -1512,6 +1551,7 @@ const ja = {
close: '閉じる', close: '閉じる',
uploaded_files: 'アップロードされたファイル', uploaded_files: 'アップロードされたファイル',
signature_drawing_area: '署名描画エリア。マウスまたはタッチを使用して署名を描いてください。', signature_drawing_area: '署名描画エリア。マウスまたはタッチを使用して署名を描いてください。',
initials_drawing_area: 'イニシャル描画エリア。マウスまたはタッチを使用してイニシャルを描いてください。',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: '画像ファイルをアップロードしてください', please_upload_an_image_file: '画像ファイルをアップロードしてください',
must_be_characters_length: '{number}文字でなければなりません', must_be_characters_length: '{number}文字でなければなりません',
@@ -1596,6 +1636,7 @@ const ja = {
please_check_the_box_to_continue: '続行するにはボックスにチェックを入れてください。', please_check_the_box_to_continue: '続行するにはボックスにチェックを入れてください。',
open_source_documents_software: 'オープンソースのドキュメントソフトウェア', open_source_documents_software: 'オープンソースのドキュメントソフトウェア',
verified_phone_number: '電話番号を認証', verified_phone_number: '電話番号を認証',
country_code: '国番号',
use_international_format: '国際形式を使用してください:+1xxx', use_international_format: '国際形式を使用してください:+1xxx',
six_digits_code: '6桁のコード', six_digits_code: '6桁のコード',
change_phone_number: '電話番号を変更', change_phone_number: '電話番号を変更',
@@ -1613,6 +1654,7 @@ const ja = {
email_has_been_sent: 'メールが送信されました', email_has_been_sent: 'メールが送信されました',
processing: '処理中', processing: '処理中',
pay_with_stripe: 'Stripeで支払う', pay_with_stripe: 'Stripeで支払う',
pay_with_paypal: 'PayPalで支払う',
reupload: '再アップロード', reupload: '再アップロード',
upload: 'アップロード', upload: 'アップロード',
files: 'ファイル', files: 'ファイル',
@@ -15,6 +15,7 @@
</template> </template>
</label> </label>
<button <button
type="button"
class="btn btn-outline btn-sm reupload-button" class="btn btn-outline btn-sm reupload-button"
@click.prevent="remove" @click.prevent="remove"
> >
@@ -141,6 +141,7 @@
<img <img
v-if="modelValue || computedPreviousValue" v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url" :src="attachmentsIndex[modelValue || computedPreviousValue].url"
:alt="field.name || t('initials')"
class="mx-auto bg-white border border-base-300 rounded max-h-44" class="mx-auto bg-white border border-base-300 rounded max-h-44"
> >
<div class="relative"> <div class="relative">
@@ -156,6 +157,8 @@
<canvas <canvas
v-show="!modelValue && !computedPreviousValue" v-show="!modelValue && !computedPreviousValue"
ref="canvas" ref="canvas"
role="img"
:aria-label="t('initials_drawing_area')"
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas" class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
/> />
</div> </div>
@@ -165,7 +168,8 @@
ref="textInput" ref="textInput"
class="base-input !text-2xl w-full mt-6 text-center" class="base-input !text-2xl w-full mt-6 text-center"
:required="field.required && !isInitialsStarted" :required="field.required && !isInitialsStarted"
:placeholder="`${t('type_initial_here')}...`" :aria-label="field.name || t('initials')"
:placeholder="`${t('type_initial_here')}${field.required ? '...' : ' (' + t('optional') + ')'}`"
type="text" type="text"
@focus="$emit('focus')" @focus="$emit('focus')"
@input="updateWrittenInitials" @input="updateWrittenInitials"
@@ -289,7 +293,7 @@ export default {
if (!this.isDrawInitials) { if (!this.isDrawInitials) {
this.$nextTick(() => { this.$nextTick(() => {
if (this.$refs.textInput) { if (this.$refs.textInput && this.field.required === true) {
this.initTextInitial() this.initTextInitial()
} }
}) })
@@ -35,7 +35,7 @@
:placeholder="t('email')" :placeholder="t('email')"
type="email" type="email"
:required="submitters.includes(submitter)" :required="submitters.includes(submitter)"
autofocus="true" :autofocus="index === 0"
name="submission[submitters][][email]" name="submission[submitters][][email]"
> >
</div> </div>
@@ -42,6 +42,7 @@
{{ error }} {{ error }}
</div> </div>
<button <button
type="button"
class="base-button w-full flex justify-center submit-form-button" class="base-button w-full flex justify-center submit-form-button"
@click="restartKba" @click="restartKba"
> >
@@ -1,7 +1,7 @@
<template> <template>
<label <label
v-if="showFieldNames && (field.name || field.title)" v-if="showFieldNames && (field.name || field.title)"
:for="field.uuid" :id="field.uuid + '-group-label'"
dir="auto" dir="auto"
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label" class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
:class="{ 'mb-2': !field.description }" :class="{ 'mb-2': !field.description }"
@@ -44,6 +44,8 @@
<div <div
class="space-y-3.5 mx-auto" class="space-y-3.5 mx-auto"
:class="{ hidden: !showOptions }" :class="{ hidden: !showOptions }"
role="group"
:aria-labelledby="showFieldNames && (field.name || field.title) ? field.uuid + '-group-label' : undefined"
> >
<div <div
v-for="(option, index) in field.options" v-for="(option, index) in field.options"
+55 -13
View File
@@ -1,6 +1,6 @@
<template> <template>
<label <label
v-if="!modelValue && !sessionId" v-if="!modelValue && !isProcessing"
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label" class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
> >
<MarkdownContent <MarkdownContent
@@ -25,14 +25,15 @@
class="hidden" class="hidden"
> >
<div <div
v-if="modelValue && !sessionId" v-if="modelValue && !isProcessing"
class=" text-2xl mb-2" class=" text-2xl mb-2"
> >
{{ t('already_paid') }} {{ t('already_paid') }}
</div> </div>
<div v-else> <div v-else>
<button <button
v-if="sessionId" v-if="isProcessing"
type="button"
disabled disabled
class="base-button w-full modal-save-button" class="base-button w-full modal-save-button"
> >
@@ -47,8 +48,9 @@
<button <button
v-else v-else
:id="field.uuid" :id="field.uuid"
class="btn bg-[#7B73FF] text-white hover:bg-[#0A2540] text-lg w-full" type="button"
:class="{ disabled: isCreatingCheckout }" class="btn text-white text-lg w-full"
:class="[isPaypal ? 'bg-[#003087] hover:bg-[#012169]' : 'bg-[#7B73FF] hover:bg-[#0A2540]', { disabled: isCreatingCheckout }]"
:disabled="isCreatingCheckout" :disabled="isCreatingCheckout"
@click.prevent="postCheckout" @click.prevent="postCheckout"
> >
@@ -57,12 +59,16 @@
width="22" width="22"
class="animate-spin" class="animate-spin"
/> />
<IconBrandPaypal
v-else-if="isPaypal"
width="22"
/>
<IconBrandStripe <IconBrandStripe
v-else v-else
width="22" width="22"
/> />
<span> <span>
{{ t('pay_with_stripe') }} {{ isPaypal ? t('pay_with_paypal') : t('pay_with_stripe') }}
</span> </span>
</button> </button>
</div> </div>
@@ -70,13 +76,14 @@
</template> </template>
<script> <script>
import { IconBrandStripe, IconInnerShadowTop, IconLoader } from '@tabler/icons-vue' import { IconBrandStripe, IconBrandPaypal, IconInnerShadowTop, IconLoader } from '@tabler/icons-vue'
import MarkdownContent from './markdown_content' import MarkdownContent from './markdown_content'
export default { export default {
name: 'PaymentStep', name: 'PaymentStep',
components: { components: {
IconBrandStripe, IconBrandStripe,
IconBrandPaypal,
MarkdownContent, MarkdownContent,
IconInnerShadowTop, IconInnerShadowTop,
IconLoader IconLoader
@@ -109,12 +116,18 @@ export default {
submitterSlug: { submitterSlug: {
type: String, type: String,
required: true required: true
},
provider: {
type: String,
required: false,
default: ''
} }
}, },
emits: ['focus', 'submit', 'update:model-value', 'attached'], emits: ['focus', 'submit', 'update:model-value', 'attached'],
data () { data () {
return { return {
isCreatingCheckout: false, isCreatingCheckout: false,
isProcessing: false,
isMathLoaded: false isMathLoaded: false
} }
}, },
@@ -129,8 +142,14 @@ export default {
queryParams () { queryParams () {
return new URLSearchParams(window.location.search) return new URLSearchParams(window.location.search)
}, },
isPaypal () {
return this.provider === 'paypal'
},
paymentEndpoint () {
return this.isPaypal ? '/api/paypal_payments' : '/api/stripe_payments'
},
sessionId () { sessionId () {
return this.queryParams.get('stripe_session_id') return this.queryParams.get('stripe_session_id') || this.queryParams.get('paypal_order_id') || (this.isPaypal && this.queryParams.get('token'))
}, },
defaultName () { defaultName () {
if (this.field.preferences?.price_id || this.field.preferences?.payment_link_id) { if (this.field.preferences?.price_id || this.field.preferences?.payment_link_id) {
@@ -157,6 +176,8 @@ export default {
}, },
async mounted () { async mounted () {
if (this.sessionId) { if (this.sessionId) {
this.isProcessing = true
this.$emit('submit') this.$emit('submit')
} }
@@ -173,6 +194,21 @@ export default {
} }
}, },
methods: { methods: {
normalizedPaymentUrl () {
const url = new URL(window.location.href)
const keys = ['stripe_session_id', 'paypal_order_id', 'field_uuid']
if (this.isPaypal) {
keys.push('token', 'PayerID')
}
keys.forEach((key) => url.searchParams.delete(key))
return url.toString()
},
normalizedPaymentParams () {
window.history.replaceState({}, document.title, this.normalizedPaymentUrl())
},
calculateFormula () { calculateFormula () {
const transformedFormula = this.normalizeFormula(this.field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => { const transformedFormula = this.normalizeFormula(this.field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => {
return this.readonlyValues[uuid] || this.values[uuid] || 0.0 return this.readonlyValues[uuid] || this.values[uuid] || 0.0
@@ -193,7 +229,7 @@ export default {
}, },
async submit () { async submit () {
if (this.sessionId) { if (this.sessionId) {
return fetch(this.baseUrl + '/api/stripe_payments/' + this.sessionId, { return fetch(this.baseUrl + this.paymentEndpoint + '/' + this.sessionId, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
submitter_slug: this.submitterSlug submitter_slug: this.submitterSlug
@@ -201,16 +237,22 @@ export default {
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => { }).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) { if (resp.status === 422 || resp.status === 500) {
this.isProcessing = false
const data = await resp.json() const data = await resp.json()
alert(data.error || 'Unexpected error') if (data.error) {
alert(data.error)
}
return Promise.reject(new Error(data.error)) return Promise.reject(new Error(data.error))
} }
const attachment = await resp.json() const attachment = await resp.json()
window.history.replaceState({}, document.title, window.location.pathname) this.isProcessing = false
this.normalizedPaymentParams()
this.$emit('update:model-value', attachment.uuid) this.$emit('update:model-value', attachment.uuid)
this.$emit('attached', attachment) this.$emit('attached', attachment)
@@ -224,13 +266,13 @@ export default {
postCheckout ({ checkStatus } = {}) { postCheckout ({ checkStatus } = {}) {
this.isCreatingCheckout = true this.isCreatingCheckout = true
fetch(this.baseUrl + '/api/stripe_payments', { fetch(this.baseUrl + this.paymentEndpoint, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
submitter_slug: this.submitterSlug, submitter_slug: this.submitterSlug,
field_uuid: this.field.uuid, field_uuid: this.field.uuid,
check_status: checkStatus, check_status: checkStatus,
referer: document.location.href referer: this.normalizedPaymentUrl()
}), }),
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => { }).then(async (resp) => {
@@ -39,7 +39,7 @@
required required
maxlength="6" maxlength="6"
autofocus autofocus
inputmode="decimal" inputmode="numeric"
@input="onInputCode" @input="onInputCode"
> >
<div class="flex justify-between mt-2 -mb-2 md:-mb-4"> <div class="flex justify-between mt-2 -mb-2 md:-mb-4">
@@ -81,6 +81,7 @@
<select <select
id="country_code_select" id="country_code_select"
class="absolute top-0 bottom-0 right-0 left-0 opacity-0 w-full h-full cursor-pointer" class="absolute top-0 bottom-0 right-0 left-0 opacity-0 w-full h-full cursor-pointer"
:aria-label="t('country_code')"
:disabled="!!defaultValue" :disabled="!!defaultValue"
@change="onCountrySelect(countries.find((country) => country.flag === $event.target.value))" @change="onCountrySelect(countries.find((country) => country.flag === $event.target.value))"
> >
@@ -27,6 +27,7 @@
> >
<button <button
id="type_text_button" id="type_text_button"
ref="typeTextButton"
type="button" type="button"
:aria-label="t('draw_signature')" :aria-label="t('draw_signature')"
class="btn btn-outline btn-sm font-medium type-text-button" class="btn btn-outline btn-sm font-medium type-text-button"
@@ -49,6 +50,7 @@
> >
<button <button
id="type_text_button" id="type_text_button"
ref="typeTextButton"
type="button" type="button"
:aria-label="t('type_text')" :aria-label="t('type_text')"
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap type-text-button" class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap type-text-button"
@@ -71,7 +73,7 @@
> >
<button <button
type="button" type="button"
:aria-label="t('take_photo')" :aria-label="t('upload')"
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button" class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button"
@click="$refs.takePhotoInput.click()" @click="$refs.takePhotoInput.click()"
> >
@@ -199,7 +201,7 @@
<canvas <canvas
v-show="!modelValue && !computedPreviousValue" v-show="!modelValue && !computedPreviousValue"
ref="canvas" ref="canvas"
role="application" role="img"
:aria-label="t('signature_drawing_area')" :aria-label="t('signature_drawing_area')"
style="padding: 1px; 0" style="padding: 1px; 0"
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas" class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
@@ -228,6 +230,8 @@
> >
<canvas <canvas
ref="qrCanvas" ref="qrCanvas"
role="img"
:aria-label="t('scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature')"
class="h-full" class="h-full"
width="132" width="132"
height="132" height="132"
@@ -242,7 +246,8 @@
ref="textInput" ref="textInput"
class="base-input !text-2xl w-full mt-6" class="base-input !text-2xl w-full mt-6"
:required="field.required && !isSignatureStarted" :required="field.required && !isSignatureStarted"
:placeholder="`${t('type_signature_here')}...`" :aria-label="field.name || t('signature')"
:placeholder="`${t('type_signature_here')}${field.required ? '...' : ' (' + t('optional') + ')'}`"
type="text" type="text"
@input="updateWrittenSignature" @input="updateWrittenSignature"
> >
@@ -251,6 +256,7 @@
class="select base-input !text-2xl w-full mt-6 text-center" class="select base-input !text-2xl w-full mt-6 text-center"
:class="{ 'text-gray-300': !reason }" :class="{ 'text-gray-300': !reason }"
required required
:aria-label="t('select_a_reason')"
:name="`values[${field.preferences.reason_field_uuid}]`" :name="`values[${field.preferences.reason_field_uuid}]`"
@change="$event.target.value === 'other' ? [reason = '', isOtherReason = true] : $emit('update:reason', $event.target.value)" @change="$event.target.value === 'other' ? [reason = '', isOtherReason = true] : $emit('update:reason', $event.target.value)"
> >
@@ -296,6 +302,7 @@
class="base-input !text-2xl w-full mt-6" class="base-input !text-2xl w-full mt-6"
required required
:name="`values[${field.preferences.reason_field_uuid}]`" :name="`values[${field.preferences.reason_field_uuid}]`"
:aria-label="t('select_a_reason')"
:placeholder="t('type_here_')" :placeholder="t('type_here_')"
:value="reason" :value="reason"
type="text" type="text"
@@ -528,7 +535,7 @@ export default {
this.$nextTick(() => this.drawSignatureSrc()) this.$nextTick(() => this.drawSignatureSrc())
} else if (this.isTextSignature) { } else if (this.isTextSignature) {
this.$nextTick(() => { this.$nextTick(() => {
if (this.$refs.textInput) { if (this.$refs.textInput && this.field.required === true) {
this.initTypedSignature() this.initTypedSignature()
} }
}) })
@@ -716,10 +723,12 @@ export default {
this.clear() this.clear()
this.isTextSignature = !this.isTextSignature this.isTextSignature = !this.isTextSignature
if (this.isTextSignature) { this.$nextTick(() => {
this.$nextTick(() => { if (this.isTextSignature) {
if (this.$refs.textInput) { if (this.$refs.textInput) {
if (!this.submitter.name) { if (this.submitter.name) {
this.$refs.typeTextButton?.focus()
} else {
this.$refs.textInput.focus() this.$refs.textInput.focus()
} }
@@ -727,8 +736,10 @@ export default {
this.$emit('start') this.$emit('start')
} }
}) } else {
} this.$refs.typeTextButton?.focus()
}
})
}, },
async initTypedSignature () { async initTypedSignature () {
if (this.signatureText) { if (this.signatureText) {
+9 -2
View File
@@ -197,7 +197,8 @@
v-else-if="!isCheckboxInput" v-else-if="!isCheckboxInput"
width="100%" width="100%"
height="100%" height="100%"
class="max-h-10 opacity-50" class="opacity-50"
:style="{ maxHeight: isInlineSize ? '4.4cqmin' : '40px' }"
/> />
</span> </span>
</div> </div>
@@ -318,7 +319,7 @@ export default {
default: false default: false
} }
}, },
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field', 'click-title'], emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field', 'click-title', 'multi-select'],
data () { data () {
return { return {
isContenteditable: false, isContenteditable: false,
@@ -725,6 +726,10 @@ export default {
return return
} }
if (this.inputMode && (this.isValueInput || this.isCheckboxInput || this.isSelectInput)) {
return
}
document.activeElement?.blur() document.activeElement?.blur()
e.preventDefault() e.preventDefault()
@@ -793,6 +798,8 @@ export default {
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(this.area), 1) this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(this.area), 1)
} }
this.$emit('multi-select', e)
return return
} }
+181 -42
View File
@@ -164,7 +164,7 @@
v-if="withSendButton" v-if="withSendButton"
id="send_button" id="send_button"
:href="`/templates/${template.id}/submissions/new?with_link=true`" :href="`/templates/${template.id}/submissions/new?with_link=true`"
data-turbo-frame="modal" :data-turbo-frame="isMobile && isBreakpointLg ? '_top' : 'modal'"
class="white-button md:!px-6" class="white-button md:!px-6"
@click="maybeShowErrorTemplateAlert" @click="maybeShowErrorTemplateAlert"
> >
@@ -202,7 +202,7 @@
</button> </button>
<div <div
class="dropdown dropdown-end" class="dropdown dropdown-end"
:class="{ 'dropdown-open': isDownloading }" :class="{ 'dropdown-open': isDownloading || isPreviewLoading }"
> >
<label <label
tabindex="0" tabindex="0"
@@ -221,15 +221,23 @@
:href="`/templates/${template.id}/form`" :href="`/templates/${template.id}/form`"
data-turbo="false" data-turbo="false"
class="flex items-center justify-center space-x-2" class="flex items-center justify-center space-x-2"
@click.exact="isPreviewLoading = true"
> >
<IconEye class="w-6 h-6 flex-shrink-0" /> <IconInnerShadowTop
v-if="isPreviewLoading"
class="animate-spin w-6 h-6 flex-shrink-0"
/>
<IconEye
v-else
class="w-6 h-6 flex-shrink-0"
/>
<span class="whitespace-nowrap">{{ t('save_and_preview') }}</span> <span class="whitespace-nowrap">{{ t('save_and_preview') }}</span>
</a> </a>
</li> </li>
<li> <li>
<a <a
:href="`/templates/${template.id}/preferences`" :href="`/templates/${template.id}/preferences`"
data-turbo-frame="modal" :data-turbo-frame="isMobile && isBreakpointLg ? '_top' : 'modal'"
class="flex space-x-2" class="flex space-x-2"
@click="closeDropdown" @click="closeDropdown"
> >
@@ -281,6 +289,10 @@
:href="`/templates/${template.id}`" :href="`/templates/${template.id}`"
class="base-button" class="base-button"
> >
<IconX
width="20"
class="md:hidden stroke-2"
/>
<span class="hidden md:inline"> <span class="hidden md:inline">
{{ t('back') }} {{ t('back') }}
</span> </span>
@@ -309,6 +321,8 @@
:data-document-uuid="item.attachment_uuid" :data-document-uuid="item.attachment_uuid"
:accept-file-types="acceptFileTypes" :accept-file-types="acceptFileTypes"
:with-replace-button="withUploadButton" :with-replace-button="withUploadButton"
:with-google-drive="withGoogleDrive"
:authenticity-token="authenticityToken"
:editable="editable" :editable="editable"
:dynamic-documents="dynamicDocuments" :dynamic-documents="dynamicDocuments"
:with-dynamic-documents="withDynamicDocuments" :with-dynamic-documents="withDynamicDocuments"
@@ -318,6 +332,7 @@
@replace="onDocumentReplace" @replace="onDocumentReplace"
@up="moveDocument(item, -1)" @up="moveDocument(item, -1)"
@reorder="reorderFields" @reorder="reorderFields"
@edit="editModalDocumentUuid = item.attachment_uuid"
@down="moveDocument(item, 1)" @down="moveDocument(item, 1)"
@change="save" @change="save"
/> />
@@ -355,12 +370,18 @@
</div> </div>
<div <div
id="pages_container" id="pages_container"
class="w-full overflow-x-hidden mt-0.5 pt-0.5" ref="pagesContainer"
:class="isMobile ? 'overflow-y-auto' : 'overflow-y-hidden md:overflow-y-auto'" class="w-full mt-0.5 pt-0.5"
:class="[
isMobile ? 'overflow-y-auto' : 'overflow-y-hidden md:overflow-y-auto',
zoomLevel > 1 ? 'overflow-x-auto' : 'overflow-x-hidden'
]"
@wheel="onPagesWheel"
> >
<div <div
ref="documents" ref="documents"
class="pr-3.5 pl-0.5" class="pr-3.5 pl-0.5"
:style="zoomLevel > 1 ? { width: `${zoomLevel * 100}%` } : null"
> >
<template v-if="!sortedDocuments.length && (withUploadButton || withAddPageButton)"> <template v-if="!sortedDocuments.length && (withUploadButton || withAddPageButton)">
<Dropzone <Dropzone
@@ -451,6 +472,8 @@
:with-arrows="template.schema.length > 1" :with-arrows="template.schema.length > 1"
:item="template.schema.find((item) => item.attachment_uuid === document.uuid)" :item="template.schema.find((item) => item.attachment_uuid === document.uuid)"
:with-replace-button="withUploadButton" :with-replace-button="withUploadButton"
:with-google-drive="withGoogleDrive"
:authenticity-token="authenticityToken"
:accept-file-types="acceptFileTypes" :accept-file-types="acceptFileTypes"
:document="document" :document="document"
:template="template" :template="template"
@@ -633,6 +656,36 @@
</div> </div>
</div> </div>
</Transition> </Transition>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="translate-y-4 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-4 opacity-0"
>
<div
v-if="zoomLevel > 1"
class="sticky bottom-0 z-40 pointer-events-none"
>
<div class="absolute left-0 right-0 bottom-4 flex justify-center">
<div class="join shadow pointer-events-auto">
<span class="join-item bg-base-content text-white pl-2 pr-2.5 h-9 items-center text-sm font-medium cursor-default w-16 flex justify-end">
<span>
{{ Math.round(zoomLevel * 100) }}%
</span>
</span>
<button
type="button"
class="join-item bg-base-content text-white h-9 pl-2 pr-3 inline-flex items-center justify-center cursor-pointer hover:opacity-90 border-l border-white/20"
@click="zoomLevel = 1"
>
<IconX class="w-4 h-4 stroke-2" />
</button>
</div>
</div>
</div>
</Transition>
<div <div
id="docuseal_modal_container" id="docuseal_modal_container"
class="modal-container" class="modal-container"
@@ -645,6 +698,17 @@
@close="isRevisionsModalOpen = false" @close="isRevisionsModalOpen = false"
@apply="onRevisionApply" @apply="onRevisionApply"
/> />
<DocumentsEditorModal
v-if="editModalDocumentUuid"
:template="template"
:authenticity-token="authenticityToken"
:accept-file-types="acceptFileTypes"
:base-url="baseUrl"
:page-preview-format="pagePreviewFormat"
:scroll-to-attachment-uuid="editModalDocumentUuid"
@saved="onDocumentsModified"
@close="editModalDocumentUuid = null"
/>
</div> </div>
</div> </div>
</template> </template>
@@ -664,7 +728,8 @@ import DocumentControls from './controls'
import MobileFields from './mobile_fields' import MobileFields from './mobile_fields'
import FieldSubmitter from './field_submitter' import FieldSubmitter from './field_submitter'
import RevisionsModal from './revisions_modal' import RevisionsModal from './revisions_modal'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload, IconHistory } from '@tabler/icons-vue' import DocumentsEditorModal from './documents_editor_modal'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload, IconHistory, IconX } from '@tabler/icons-vue'
import { v4 } from 'uuid' import { v4 } from 'uuid'
import { ref, computed, toRaw, defineAsyncComponent } from 'vue' import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
import * as i18n from './i18n' import * as i18n from './i18n'
@@ -706,7 +771,9 @@ export default {
IconEye, IconEye,
IconHistory, IconHistory,
IconDeviceFloppy, IconDeviceFloppy,
RevisionsModal IconX,
RevisionsModal,
DocumentsEditorModal
}, },
provide () { provide () {
return { return {
@@ -724,7 +791,10 @@ export default {
withVerification: this.withVerification, withVerification: this.withVerification,
withKba: this.withKba, withKba: this.withKba,
withPayment: this.withPayment, withPayment: this.withPayment,
isPaymentConnected: this.isPaymentConnected, isStripeConnected: this.isStripeConnected,
withStripe: this.withStripe,
withPaypal: this.withPaypal,
isPaypalConnected: this.isPaypalConnected,
withFormula: this.withFormula, withFormula: this.withFormula,
withConditions: this.withConditions, withConditions: this.withConditions,
withCustomFields: this.withCustomFields, withCustomFields: this.withCustomFields,
@@ -994,7 +1064,22 @@ export default {
required: false, required: false,
default: false default: false
}, },
isPaymentConnected: { isStripeConnected: {
type: Boolean,
required: false,
default: false
},
withStripe: {
type: Boolean,
required: false,
default: true
},
withPaypal: {
type: Boolean,
required: false,
default: false
},
isPaypalConnected: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false default: false
@@ -1045,6 +1130,7 @@ export default {
documentRefs: [], documentRefs: [],
isBreakpointLg: false, isBreakpointLg: false,
isDownloading: false, isDownloading: false,
isPreviewLoading: false,
isLoadingBlankPage: false, isLoadingBlankPage: false,
isSaving: false, isSaving: false,
isDetectingPageFields: false, isDetectingPageFields: false,
@@ -1062,8 +1148,10 @@ export default {
isDragFile: false, isDragFile: false,
isMathLoaded: false, isMathLoaded: false,
isRevisionsModalOpen: false, isRevisionsModalOpen: false,
editModalDocumentUuid: null,
revisions: [], revisions: [],
beforeRevisionSnapshot: null beforeRevisionSnapshot: null,
zoomLevel: 1
} }
}, },
computed: { computed: {
@@ -1184,7 +1272,7 @@ export default {
return formulaFields.reduce((acc, f) => { return formulaFields.reduce((acc, f) => {
if (this.conditionalFieldIndex[f.uuid] !== false) { if (this.conditionalFieldIndex[f.uuid] !== false) {
acc[f.uuid] = this.calculateFormula(f) acc[f.uuid] = f.type === 'text' ? this.evalTextFormula(f) : this.calculateFormula(f)
} }
return acc return acc
@@ -1306,6 +1394,10 @@ export default {
if (document.location.search?.includes('stripe_connect_success')) { if (document.location.search?.includes('stripe_connect_success')) {
document.querySelector('form[action="/auth/stripe_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus() document.querySelector('form[action="/auth/stripe_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus()
} }
if (document.location.search?.includes('paypal_connect_success')) {
document.querySelector('a[href^="/auth/paypal_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus()
}
}) })
this.template.schema.forEach((item) => { this.template.schema.forEach((item) => {
@@ -1447,6 +1539,25 @@ export default {
return this.math.evaluate(transformedFormula.toLowerCase()) return this.math.evaluate(transformedFormula.toLowerCase())
}, },
evalTextFormula (field, depth = 0) {
if (depth > 10) return ''
return field.preferences.formula.replace(/{{(.*?)}}/g, (match, uuid) => {
const formulaField = this.fieldsUuidIndex[uuid]
if (formulaField?.preferences?.formula) {
if (formulaField.type === 'text') {
return this.evalTextFormula(formulaField, depth + 1)
} else if (this.isMathLoaded) {
return this.calculateFormula(formulaField)
}
}
const value = formulaField?.default_value
return Array.isArray(value) ? value.join(', ') : (value ?? '')
})
},
hasFormulaDependencyValue (field) { hasFormulaDependencyValue (field) {
const normalized = this.normalizeFormula(field.preferences.formula) const normalized = this.normalizeFormula(field.preferences.formula)
@@ -1636,7 +1747,7 @@ export default {
ref.x = e.clientX - ref.offsetX ref.x = e.clientX - ref.offsetX
ref.y = e.clientY - ref.offsetY ref.y = e.clientY - ref.offsetY
} else if (e.dataTransfer?.types?.includes('Files')) { } else if (e.dataTransfer?.types?.includes('Files') && !this.editModalDocumentUuid) {
this.isDragFile = true this.isDragFile = true
} }
}, },
@@ -2018,6 +2129,30 @@ export default {
this.isBreakpointLg = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth < breakpointLg this.isBreakpointLg = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth < breakpointLg
}, },
onPagesWheel (event) {
if (!event.ctrlKey && !event.metaKey) return
event.preventDefault()
const oldZoom = this.zoomLevel
const nextZoom = Math.max(1, Math.min(3, oldZoom - event.deltaY * 0.006))
if (nextZoom === oldZoom) return
const rect = this.$refs.pagesContainer.getBoundingClientRect()
const cursorX = event.clientX - rect.left
const cursorY = event.clientY - rect.top
const ratio = nextZoom / oldZoom
const nextScrollLeft = (this.$refs.pagesContainer.scrollLeft + cursorX) * ratio - cursorX
const nextScrollTop = (this.$refs.pagesContainer.scrollTop + cursorY) * ratio - cursorY
this.zoomLevel = nextZoom
this.$nextTick(() => {
this.$refs.pagesContainer.scrollLeft = nextScrollLeft
this.$refs.pagesContainer.scrollTop = nextScrollTop
})
},
setDocumentRefs (el) { setDocumentRefs (el) {
if (el) { if (el) {
this.documentRefs.push(el) this.documentRefs.push(el)
@@ -2055,6 +2190,10 @@ export default {
} }
}, },
onKeyDown (event) { onKeyDown (event) {
if (this.editModalDocumentUuid) {
return
}
if (event.key === 'Tab' && document.activeElement === document.body) { if (event.key === 'Tab' && document.activeElement === document.body) {
event.stopImmediatePropagation() event.stopImmediatePropagation()
event.preventDefault() event.preventDefault()
@@ -3073,6 +3212,22 @@ export default {
onDocumentsReplaceAndTemplateClone (template) { onDocumentsReplaceAndTemplateClone (template) {
window.Turbo.visit(`/templates/${template.id}/edit`) window.Turbo.visit(`/templates/${template.id}/edit`)
}, },
onDocumentsModified (data) {
this.template.schema = data.schema
this.template.fields = data.fields
this.template.submitters = data.submitters
this.template.documents = data.documents
this.selectedAreasRef.value = []
if (!this.template.submitters.find((s) => s.uuid === this.selectedSubmitter?.uuid)) {
this.selectedSubmitter = this.template.submitters[0]
}
this.editModalDocumentUuid = null
this.save()
},
moveDocument (item, direction) { moveDocument (item, direction) {
const currentIndex = this.template.schema.indexOf(item) const currentIndex = this.template.schema.indexOf(item)
@@ -3105,15 +3260,6 @@ export default {
e.preventDefault() e.preventDefault()
alert(this.t('please_draw_fields_to_prepare_the_document')) alert(this.t('please_draw_fields_to_prepare_the_document'))
} else {
const submitterWithoutFields =
this.template.submitters.find((submitter) => !this.template.fields.some((f) => f.submitter_uuid === submitter.uuid))
if (submitterWithoutFields) {
e.preventDefault()
alert(this.t('please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed').replaceAll('{submitter_name}', submitterWithoutFields.name))
}
} }
}, },
onSaveClick () { onSaveClick () {
@@ -3130,32 +3276,25 @@ export default {
if (!this.template.fields.length) { if (!this.template.fields.length) {
alert(this.t('please_draw_fields_to_prepare_the_document')) alert(this.t('please_draw_fields_to_prepare_the_document'))
} else { } else {
const submitterWithoutFields = this.isSaving = true
this.template.submitters.find((submitter) => !this.template.fields.some((f) => f.submitter_uuid === submitter.uuid))
if (submitterWithoutFields) { const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
alert(this.t('please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed').replaceAll('{submitter_name}', submitterWithoutFields.name))
} else {
this.isSaving = true
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic) dynamicDocumentRefs.map((ref) => ref.update())
dynamicDocumentRefs.map((ref) => ref.update()) this.rebuildVariablesSchema({ disable: false })
this.rebuildVariablesSchema({ disable: false }) const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody())
const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody()) Promise.all([this.save({ force: true }), ...dynamicDocumentSaves]).then(() => {
if (this.withRevisions) {
this.captureRevision()
}
Promise.all([this.save({ force: true }), ...dynamicDocumentSaves]).then(() => { window.Turbo.visit(`/templates/${this.template.id}`)
if (this.withRevisions) { }).finally(() => {
this.captureRevision() this.isSaving = false
} })
window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => {
this.isSaving = false
})
}
} }
}, },
scrollToArea (area) { scrollToArea (area) {
Loaded 100 of 334 files, more files were not shown because too many files have changed in this diff. Show more