Compare commits

...

240 Commits

Author SHA1 Message Date
Alex Turchyn 4f94858901 Merge from docusealco/wip 2025-06-30 12:56:05 +03:00
Pete Matsyburka d0ac4ad222 fix sqlite remove template 2025-06-30 11:44:55 +03:00
Pete Matsyburka 10e4476f4a fix signature prefill 2025-06-30 11:41:07 +03:00
Pete Matsyburka 74411d883a adjust link default parties 2025-06-29 12:31:41 +03:00
Alex Turchyn 18ee2d5316 add shared link form fields preferences 2025-06-28 23:06:25 +03:00
Pete Matsyburka b4f80422eb update doc 2025-06-28 13:30:05 +03:00
Pete Matsyburka 7fe9ab7601 update params 2025-06-28 10:37:10 +03:00
Pete Matsyburka 13ad614e6b fix rubocop 2025-06-28 10:17:50 +03:00
Alex Turchyn 7b1b4bcf2c show shared link enable button on disabled link page 2025-06-28 09:58:22 +03:00
Alex Turchyn a6715d4034 add meta description translations 2025-06-27 23:25:52 +03:00
Pete Matsyburka 254995c6c6 fix missing font variant 2025-06-27 14:45:29 +03:00
Pete Matsyburka 0e33e23d1f fix prefill condition 2025-06-27 12:49:25 +03:00
Pete Matsyburka 57503eefd2 adjust search index 2025-06-27 12:08:57 +03:00
Pete Matsyburka cd477c880c render image as file 2025-06-26 21:50:12 +03:00
Pete Matsyburka bf2f5b24dc update docs 2025-06-26 17:19:53 +03:00
Pete Matsyburka 7eba4865bc fix signature remember 2025-06-26 11:28:38 +03:00
Pete Matsyburka 64cd24a24d validate blank images 2025-06-26 11:04:25 +03:00
Pete Matsyburka de5acd0949 fix one-off 2025-06-26 10:05:16 +03:00
Pete Matsyburka 6ebbc3edac pdfium read text 2025-06-26 09:51:09 +03:00
Pete Matsyburka 2020f6c136 make shared templates searchable 2025-06-26 07:47:29 +03:00
Pete Matsyburka ee4acd5a82 validate signature image 2025-06-25 22:31:19 +03:00
Pete Matsyburka 2e487ac936 fix timezone selector 2025-06-25 21:36:03 +03:00
Pete Matsyburka dee5fc7c07 fix preview 2025-06-25 16:34:25 +03:00
Pete Matsyburka e130584fec fix timezone selector 2025-06-25 09:38:52 +03:00
Pete Matsyburka ade5c4832f fix bulk table mappings 2025-06-24 12:49:52 +03:00
Pete Matsyburka 33b14d5278 fix sqlite query 2025-06-23 20:34:30 +03:00
Pete Matsyburka 48e73c2c81 fix png quality 2025-06-23 14:53:35 +03:00
Pete Matsyburka 894d98e92b update widget version 2025-06-23 13:33:44 +03:00
Alex Turchyn 14c84eff93 Merge from docusealco/wip 2025-06-23 13:19:06 +03:00
Pete Matsyburka 01d982b093 embed preview toggle 2025-06-22 14:54:12 +03:00
Pete Matsyburka 35852ef6ab add onprem search reindex 2025-06-21 23:04:11 +03:00
Pete Matsyburka 47b235dc5f fix search 2025-06-21 22:17:54 +03:00
Pete Matsyburka 0e11d2a581 fix tour link 2025-06-21 17:11:26 +03:00
Pete Matsyburka e57e11f206 optimize query 2025-06-21 14:58:20 +03:00
Alex Turchyn 51f639065c add translations 2025-06-20 21:14:22 +03:00
Pete Matsyburka 6c1d4b466f adjust server selector 2025-06-20 13:41:42 +03:00
Pete Matsyburka b940c597dc fix pdfium error 2025-06-20 02:16:31 +03:00
Pete Matsyburka 1ce67bcb39 adjust folder autocomplete 2025-06-19 22:49:47 +03:00
Pete Matsyburka 5050a67d08 add datenow type 2025-06-19 19:58:22 +03:00
Pete Matsyburka 76321cef3c optimize autocomplete 2025-06-19 16:06:35 +03:00
Pete Matsyburka 8f3a203994 markdown link 2025-06-19 12:45:44 +03:00
Pete Matsyburka ea24562861 fix time format 2025-06-19 12:25:35 +03:00
Pete Matsyburka 4a888d048e fix empty file generate 2025-06-19 12:06:08 +03:00
Alex Turchyn 36331bb993 show found submissions when searching on templates 2025-06-19 12:05:43 +03:00
Pete Matsyburka 6f0e7c0ca8 skip autocomplete 2025-06-18 23:53:36 +03:00
Pete Matsyburka 2491b7b7e3 adjust indexes 2025-06-18 11:56:42 +03:00
Pete Matsyburka 7965c17f46 fix serializer 2025-06-17 18:03:28 +03:00
Pete Matsyburka 5519459519 refactor 2025-06-17 12:42:57 +03:00
Pete Matsyburka d8bf2cc101 add index 2025-06-17 11:03:40 +03:00
Pete Matsyburka 2aece98d67 add pro routes 2025-06-16 22:37:50 +03:00
Pete Matsyburka f602497255 require all submitters 2025-06-16 22:17:14 +03:00
Pete Matsyburka 11db68cdc7 fix merge 2025-06-16 18:55:23 +03:00
Pete Matsyburka bed98f4344 optimize query 2025-06-16 17:02:05 +03:00
Pete Matsyburka 055f7e2bc1 Merge from docusealco/wip 2025-06-15 23:53:35 +03:00
Pete Matsyburka dae3b4b6b5 add completed at index 2025-06-15 12:26:11 +03:00
Pete Matsyburka f2888a08dc fix query 2025-06-14 08:53:45 +03:00
Pete Matsyburka 95627c5963 add audit timezone 2025-06-13 22:37:04 +03:00
Pete Matsyburka 4bb3721db0 add submitter timezone 2025-06-13 19:15:05 +03:00
Pete Matsyburka b12111383b fix update oneoff 2025-06-11 23:39:19 +03:00
Pete Matsyburka 263c0ff939 fix migration 2025-06-11 18:50:01 +03:00
Pete Matsyburka efc8ba6b0a fix merge 2025-06-11 17:55:10 +03:00
Pete Matsyburka 4c530b3f88 fix query 2025-06-11 12:10:24 +03:00
Pete Matsyburka f6358c9d47 fix brakeman 2025-06-11 09:32:43 +03:00
Pete Matsyburka b3baea2f6f optimize query 2025-06-11 09:28:10 +03:00
Pete Matsyburka c0594a8b74 fix brakeman 2025-06-11 09:16:57 +03:00
Pete Matsyburka 1403401b03 permit field required 2025-06-10 11:26:12 +03:00
Pete Matsyburka 700184f776 add ngram 2025-06-10 09:19:13 +03:00
Pete Matsyburka b6a1a3ee4d fix archived 2025-06-09 21:46:26 +03:00
Pete Matsyburka 8cbf5802b5 fix signature upload 2025-06-09 17:06:20 +03:00
Pete Matsyburka ab9706afef fix template fields 2025-06-09 14:20:17 +03:00
Pete Matsyburka f07f9d8cf9 fix oneoff edit 2025-06-09 14:20:17 +03:00
Pete Matsyburka 591caecb92 fix redirect 2025-06-09 14:20:17 +03:00
Pete Matsyburka ef4c2b7213 update gem 2025-06-08 16:54:59 +03:00
Pete Matsyburka 18999255a9 remove constant 2025-06-08 13:34:41 +03:00
Pete Matsyburka 0a2a1de6b9 brakeman ignore 2025-06-07 21:28:01 +03:00
Pete Matsyburka 1b857b6eb3 fix preview 2025-06-07 21:12:37 +03:00
Pete Matsyburka e5e6b11c95 fix image name 2025-06-07 16:20:57 +03:00
Pete Matsyburka 3ec3f58d55 fix mysql migration 2025-06-07 09:55:45 +03:00
Pete Matsyburka 3bfce862ec fix reindex 2025-06-07 09:52:26 +03:00
Pete Matsyburka c01b67f7ec index template 2025-06-06 23:14:17 +03:00
Pete Matsyburka ea0efe9314 fix form cells 2025-06-06 22:10:00 +03:00
Pete Matsyburka 28b583691f partial download toggle 2025-06-06 16:28:53 +03:00
Pete Matsyburka 33811945be fulltext search 2025-06-06 16:03:16 +03:00
Pete Matsyburka 9d1860f038 fix specs 2025-06-06 08:47:56 +03:00
Pete Matsyburka f03166b4ea one-off submission 2025-06-06 08:47:56 +03:00
Pete Matsyburka c59d948d41 add stamp to all pages 2025-06-04 08:44:55 +03:00
Pete Matsyburka 57d63dd2f2 fix empty option condition 2025-06-03 22:29:14 +03:00
Alex Turchyn c88715daec Merge from docusealco/wip 2025-06-03 12:22:34 +03:00
Pete Matsyburka 0375dad7c9 smaller audit initial 2025-06-03 10:13:49 +03:00
Pete Matsyburka de3f83db96 use metadata lang 2025-06-03 09:52:46 +03:00
Pete Matsyburka a7bb96464c completed action shared link 2025-06-01 22:42:44 +03:00
Pete Matsyburka 1d48fe9621 fix spec 2025-06-01 15:51:43 +03:00
Pete Matsyburka 9544bb3d76 Revert "shared link true default"
This reverts commit e41fe5fbca.
2025-06-01 15:23:20 +03:00
Pete Matsyburka 4e94664008 refactor start form 2025-06-01 15:23:19 +03:00
Pete Matsyburka e41fe5fbca shared link true default 2025-06-01 13:21:11 +03:00
Pete Matsyburka 24d713dec6 resubmit shared template 2025-06-01 12:27:36 +03:00
Pete Matsyburka f464e1c46a add log 2025-06-01 12:22:16 +03:00
Pete Matsyburka 85d52d1f28 refactor policy 2025-06-01 11:38:08 +03:00
Alex Turchyn e77b5fa2c9 add optional shared link to templates 2025-06-01 11:07:52 +03:00
Alex Turchyn c91b4a765b add Privacy Policy and eSignature Disclosure links 2025-06-01 09:20:42 +03:00
Pete Matsyburka 2a031c14c8 fix js error 2025-05-30 16:58:20 +03:00
Pete Matsyburka f7dfc0b2ed add email event date index 2025-05-30 11:12:33 +03:00
Pete Matsyburka 3b894f5281 fix 2025-05-30 10:36:08 +03:00
Alex Turchyn 9645332e48 add countless pagination 2025-05-30 09:12:42 +03:00
Pete Matsyburka 5670390b37 fix signature reason 2025-05-29 17:31:09 +03:00
Pete Matsyburka 260bd6d5d5 fix signature field size 2025-05-29 17:24:03 +03:00
Alex Turchyn fa0733f480 improve user email validation 2025-05-29 14:24:17 +03:00
Pete Matsyburka 8b9678a434 fix heif 2025-05-28 23:01:34 +03:00
Pete Matsyburka 43669bf095 do not prefill optional signature 2025-05-28 21:51:17 +03:00
Pete Matsyburka 2ef8df3f0c optimize query 2025-05-28 10:23:36 +03:00
Alex Turchyn 1c9d15aa7e add translations for pagination 2025-05-28 10:06:06 +03:00
Alex Turchyn 9fcd8955d3 add 'field-area-active-label' theme class 2025-05-27 21:10:58 +03:00
Pete Matsyburka 5e44ba49b8 refactor 2025-05-26 22:26:14 +03:00
Pete Matsyburka ac1f90e711 do not email smtp settings 2025-05-26 14:46:33 +03:00
Alex Turchyn c6eae66127 Merge from docusealco/wip 2025-05-26 13:26:19 +03:00
Pete Matsyburka 08aa902e07 set from user id 2025-05-26 11:58:47 +03:00
Pete Matsyburka 1690337699 fix pdf image result 2025-05-26 10:11:01 +03:00
Pete Matsyburka f78cf8cc6e pdfium 2025-05-26 10:01:27 +03:00
Pete Matsyburka 343118d00d fix bmp 2025-05-24 11:54:18 +03:00
Alex Turchyn a3bd37c9a2 Improve error message when does not match in completed form 2025-05-24 11:25:49 +03:00
Alex Turchyn 8bc13a5900 fix german translations 2025-05-23 10:52:47 +03:00
Pete Matsyburka 21138c1d02 remove log 2025-05-22 15:41:19 +03:00
Alex Turchyn 38674a9fc9 fix accidental checkbox addition 2025-05-21 22:18:25 +03:00
Pete Matsyburka a7ed0b86a4 dot rspec 2025-05-21 10:20:43 +03:00
Pete Matsyburka 91a96f06c8 remove account config action 2025-05-20 22:50:43 +03:00
Alex Turchyn 23631b75f2 add template_accesses factory 2025-05-20 19:36:45 +03:00
Pete Matsyburka 0fb9c2bbf6 fix gmail normalize 2025-05-20 19:32:23 +03:00
Pete Matsyburka 14fa8fc8e7 fix expired 2025-05-20 11:18:08 +03:00
Pete Matsyburka 047c192988 fix typo 2025-05-20 09:49:17 +03:00
Pete Matsyburka bfa244c8fa add ico and bmp support 2025-05-20 09:49:16 +03:00
Alex Turchyn 019222ae40 Merge from docusealco/wip 2025-05-19 13:07:42 +03:00
Pete Matsyburka 8a10852fe2 docs 2025-05-18 17:38:51 +03:00
Pete Matsyburka 01232aed5a add account access 2025-05-18 15:16:53 +03:00
Alex Turchyn 82f3ff4824 fix narrow signatures 2025-05-18 09:44:55 +03:00
Pete Matsyburka 7be3f5a931 fix android 2025-05-16 18:28:55 +03:00
Pete Matsyburka 9d013a7384 fix email normalize 2025-05-15 22:16:06 +03:00
Alex Turchyn 0c71d7bff8 fix EU server selection note 2025-05-15 18:05:07 +03:00
Pete Matsyburka fdd030652e adjust border 2025-05-15 14:39:25 +03:00
Pete Matsyburka f2bf1461f0 adjust selector 2025-05-15 13:50:02 +03:00
Alex Turchyn fc6f796f30 adjust server selector 2025-05-15 13:16:43 +03:00
Alex Turchyn 5f44806287 adjust dropoze border style 2025-05-15 13:16:40 +03:00
Pete Matsyburka 318b65af18 fix i18n 2025-05-14 21:28:52 +03:00
Pete Matsyburka 91b182b04a adjust email validate 2025-05-14 20:40:59 +03:00
Pete Matsyburka 15b886d4ff update reminder durations 2025-05-14 20:22:34 +03:00
Pete Matsyburka 0122a237e3 remove request helper 2025-05-14 13:21:57 +03:00
Alex Turchyn 2dca7d8a8e add builder 'replace' button specs 2025-05-14 13:06:03 +03:00
Pete Matsyburka 87265e2f22 fix prefill same name field 2025-05-14 13:04:35 +03:00
Pete Matsyburka 3166defa1a add submitter value var 2025-05-13 12:27:27 +03:00
Pete Matsyburka 93ba17f42e expire at webhook 2025-05-12 18:23:17 +03:00
Pete Matsyburka 751be1dae5 fix template filter 2025-05-12 15:38:13 +03:00
Pete Matsyburka 3f8d2831e2 allow double dash email 2025-05-12 13:22:11 +03:00
Alex Turchyn 4d8942803e Merge from docusealco/wip 2025-05-12 12:31:27 +03:00
Pete Matsyburka 46d9bb2c87 fix optimize query 2025-05-12 11:17:26 +03:00
Pete Matsyburka 34433c0575 adjust conditions 2025-05-12 11:05:48 +03:00
Pete Matsyburka 544306eac4 fix replace 2025-05-11 18:02:24 +03:00
Pete Matsyburka 0eeebfd3cf clear cache 2025-05-10 20:03:35 +03:00
Pete Matsyburka 050224a10d fix refactor expire at 2025-05-10 17:39:53 +03:00
Alex Turchyn 7b6fc7d06f add expiration duration to template preferences 2025-05-10 12:12:15 +03:00
Pete Matsyburka 7fb148c85c drag folders 2025-05-10 12:11:57 +03:00
Alex Turchyn dffa59fecd add translations 2025-05-09 22:20:20 +03:00
Pete Matsyburka 24fe2a1192 fix refactor dropzone 2025-05-09 22:12:20 +03:00
Alex Turchyn 97b8ac4444 add builder dashboard dropzone 2025-05-09 21:35:31 +03:00
Pete Matsyburka e34a763dd9 fix field id 2025-05-09 10:04:22 +03:00
Pete Matsyburka 298b1d3e1e update gem 2025-05-08 22:41:25 +03:00
Pete Matsyburka d9978519cc adjust qes link 2025-05-08 12:32:10 +03:00
Pete Matsyburka b4a68cf79e adjust field types prop 2025-05-08 12:22:18 +03:00
Pete Matsyburka 5663e60f92 fix log 2025-05-06 22:18:53 +03:00
Pete Matsyburka 19e8835809 fix timestamp path 2025-05-06 18:44:29 +03:00
Pete Matsyburka 330fae927f skip bounce from audit 2025-05-06 16:48:50 +03:00
Pete Matsyburka c5a5212947 disable archived template actions 2025-05-06 15:50:58 +03:00
Pete Matsyburka 21d81e38bf add email events 2025-05-06 15:10:02 +03:00
Pete Matsyburka 5b1a4ebffd improve log 2025-05-03 16:30:11 +03:00
Pete Matsyburka 5f5dabd0e5 advanced formats 2025-05-02 19:17:19 +03:00
Pete Matsyburka ff46993de6 fix spec 2025-05-02 12:54:00 +03:00
Pete Matsyburka 7676db2196 hex colors 2025-05-02 12:36:33 +03:00
Alex Turchyn 3ef0d28ffe adjust app tour 2025-05-02 10:27:57 +03:00
Pete Matsyburka 231177f057 add log 2025-05-01 00:14:04 +03:00
Pete Matsyburka e5f7739952 use sha 256 for tsa 2025-04-30 23:48:03 +03:00
Pete Matsyburka 4a8e2a82e5 tsa fallback 2025-04-30 22:58:50 +03:00
Pete Matsyburka 6efe8027f1 disable multiple sign 2025-04-30 16:54:56 +03:00
Pete Matsyburka 96bb4eb6e5 fix standalone radio 2025-04-29 20:15:32 +03:00
Pete Matsyburka 38e68814cf fix color 2025-04-29 18:02:19 +03:00
Alex Turchyn 786c80bee7 Merge pull from docusealco/wip 2025-04-28 12:46:30 +03:00
Pete Matsyburka 98dcce299e resend 2025-04-28 08:59:41 +03:00
Pete Matsyburka cbff242a5d adjust condition fields 2025-04-25 12:14:49 +03:00
Pete Matsyburka 1876b3efba update readme 2025-04-24 19:44:49 +03:00
Alex Turchyn 51091f05f0 add Japanese language to the signing form 2025-04-24 19:38:46 +03:00
Pete Matsyburka d76eee2ae4 add valign 2025-04-24 19:33:26 +03:00
Pete Matsyburka d7bfa96d57 hide order on mobile 2025-04-24 12:59:31 +03:00
Pete Matsyburka be60608211 fix remove listener 2025-04-23 21:24:18 +03:00
Pete Matsyburka 4e5abb3c09 fix query 2025-04-23 12:30:06 +03:00
Pete Matsyburka 7fee82ded2 fix order queries 2025-04-23 10:58:40 +03:00
Alex Turchyn e1cd62ff40 add folders/templates sorting 2025-04-22 21:16:17 +03:00
Pete Matsyburka b498291b62 update gem 2025-04-22 16:20:35 +03:00
Pete Matsyburka def3009846 fix cert disabled 2025-04-22 15:52:03 +03:00
Pete Matsyburka 9d43fd9aa5 fix fields 2025-04-18 18:18:57 +03:00
Pete Matsyburka 22a21fb037 fix field focus 2025-04-18 13:02:17 +03:00
Pete Matsyburka 2a9c375273 adjust header 2025-04-18 12:57:43 +03:00
Alex Turchyn 847e70ec1b improve signing form header 2025-04-18 12:16:11 +03:00
Pete Matsyburka 7964d6e0f0 improve signature validation 2025-04-18 11:34:10 +03:00
Pete Matsyburka d0259791bf add field value email variable 2025-04-16 21:19:13 +03:00
Pete Matsyburka a9dcaeccbc fix erblint 2025-04-16 11:35:24 +03:00
Pete Matsyburka 501b91a603 removed email 2025-04-16 11:35:20 +03:00
Pete Matsyburka 2b5f772feb update ai link 2025-04-15 21:40:22 +03:00
Pete Matsyburka 7cada0af8b older devises 2025-04-15 19:58:31 +03:00
Pete Matsyburka 67be785e69 add slug filter 2025-04-15 13:22:02 +03:00
Pete Matsyburka d1cfc64adc adjust api response 2025-04-14 12:14:24 +03:00
Pete Matsyburka 4e5157d1ee add text valign 2025-04-11 16:25:32 +03:00
Pete Matsyburka 5e27cc36c6 fix default font 2025-04-10 13:22:10 +03:00
Pete Matsyburka 6e81a3be3f fix i18n 2025-04-10 11:51:40 +03:00
Pete Matsyburka 858b2f33fd ensure unique recipients 2025-04-10 00:56:16 +03:00
Alex Turchyn 6adcc87b2b add translations 2025-04-09 20:45:17 +03:00
Alex Turchyn e1d5a5b6a6 prevent an infinite loop of field conditions 2025-04-09 14:04:42 +03:00
Pete Matsyburka f090e79002 fix required 2025-04-09 13:51:43 +03:00
Pete Matsyburka 56b32e35a2 make last name optional 2025-04-08 19:12:04 +03:00
Pete Matsyburka b40c27cdbf fix specs 2025-04-08 16:21:45 +03:00
Pete Matsyburka c4505e67f0 require 2fa placeholder 2025-04-08 15:24:17 +03:00
Alex Turchyn d0799b8cc1 Merge from docusealco/wip 2025-04-07 12:58:13 +03:00
Pete Matsyburka 889fcad859 fix drop position 2025-04-05 12:06:29 +03:00
Pete Matsyburka c133fef99d ruby 3.4.2 2025-04-04 17:20:52 +03:00
Alex Turchyn cfdc978a2a add app tour 2025-04-04 11:40:55 +03:00
Pete Matsyburka bff1651966 fix long font preview 2025-04-03 17:59:05 +03:00
Alex Turchyn b97854f2fe improve account deletion confirmation message 2025-04-03 12:56:48 +03:00
Alex Turchyn 7a4629bf79 improve drag'n'drop in the builder 2025-04-02 16:51:39 +03:00
Pete Matsyburka 2573d8cb19 fix n+1 2025-04-01 10:23:31 +03:00
Pete Matsyburka dd559e90f1 add archived filter 2025-04-01 10:23:30 +03:00
Alex Turchyn e5db61e358 add API specs for completed submitter 2025-03-31 22:40:05 +03:00
Pete Matsyburka 69376c0faf fix acro form fields 2025-03-31 21:50:04 +03:00
Alex Turchyn 1f48be135c Merge from docusealco/wip 2025-03-31 13:02:51 +03:00
Pete Matsyburka 1cae12b728 adjust validation 2025-03-29 23:15:45 +02:00
Alex Turchyn 86d2cd93f8 fix signature uploading 2025-03-29 15:44:19 +02:00
Alex Turchyn b0110d9340 update signature validation 2025-03-29 14:50:26 +02:00
Alex Turchyn 7e70f4fb14 update translations 2025-03-29 13:37:21 +02:00
Pete Matsyburka 28e3d65e63 improve required field handling 2025-03-28 22:42:27 +02:00
Pete Matsyburka f1a2b71b59 fix smtp 2025-03-27 19:50:09 +02:00
Pete Matsyburka 47c7270911 enforce signing order 2025-03-27 19:23:20 +02:00
Pete Matsyburka 4e1fa336a2 add personalization options 2025-03-27 19:23:19 +02:00
Pete Matsyburka 244bc32136 fix preview url 2025-03-27 00:52:35 +02:00
Pete Matsyburka 42c30bdfd0 ensure generated doc completed 2025-03-26 13:14:59 +02:00
Alex Turchyn 0abf48ddba update preview image 2025-03-26 13:14:07 +02:00
317 changed files with 56859 additions and 1337 deletions
+8 -5
View File
@@ -11,7 +11,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.1
ruby-version: 3.4.2
- name: Cache gems
uses: actions/cache@v4
with:
@@ -35,7 +35,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.1
ruby-version: 3.4.2
- name: Cache gems
uses: actions/cache@v4
with:
@@ -85,7 +85,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.1
ruby-version: 3.4.2
- name: Cache gems
uses: actions/cache@v4
with:
@@ -127,7 +127,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.1
ruby-version: 3.4.2
- name: Set up Node
uses: actions/setup-node@v1
with:
@@ -157,7 +157,10 @@ jobs:
bundle install --jobs 4 --retry 4
yarn install
sudo apt-get update
sudo apt-get install libvips
sudo apt-get install -y libvips
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"
sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so
rm -f pdfium-linux.tgz
- name: Run
env:
RAILS_ENV: test
+1
View File
@@ -0,0 +1 @@
--require rails_helper
+1 -1
View File
@@ -66,7 +66,7 @@ RSpec/ExampleLength:
Max: 50
RSpec/MultipleMemoizedHelpers:
Max: 9
Max: 15
Metrics/BlockNesting:
Max: 5
+18 -7
View File
@@ -1,12 +1,21 @@
FROM ruby:3.4.1-alpine AS fonts
FROM ruby:3.4.2-alpine AS download
WORKDIR /fonts
RUN apk --no-cache add fontforge wget && 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/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt
RUN apk --no-cache add fontforge wget && \
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/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && \
wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && \
wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \
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" && \
mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
FROM ruby:3.4.1-alpine AS webpack
FROM ruby:3.4.2-alpine AS webpack
ENV RAILS_ENV=production
ENV NODE_ENV=production
@@ -32,7 +41,7 @@ COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:3.4.1-alpine AS app
FROM ruby:3.4.2-alpine AS app
ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test"
@@ -41,7 +50,7 @@ ENV OPENSSL_CONF=/app/openssl_legacy.cnf
WORKDIR /app
RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils redis libheif@edge vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev@edge redis libheif@edge vips-heif@edge gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN echo $'.include = /etc/ssl/openssl.cnf\n\
\n\
@@ -70,8 +79,10 @@ COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru .version ./
COPY .version ./public/version
COPY --from=fonts /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --from=fonts /fonts/FreeSans.ttf /usr/share/fonts/freefont
COPY --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont
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=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts
+1 -1
View File
@@ -2,7 +2,7 @@
source 'https://rubygems.org'
ruby '3.4.1'
ruby '3.4.2'
gem 'arabic-letter-connector', require: 'arabic-letter-connector/logic'
gem 'aws-sdk-s3', require: false
+8 -8
View File
@@ -338,18 +338,18 @@ GEM
net-smtp (0.5.0)
net-protocol
nio4r (2.7.4)
nokogiri (1.18.5)
nokogiri (1.18.8)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.18.5-aarch64-linux-gnu)
nokogiri (1.18.8-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.5-aarch64-linux-musl)
nokogiri (1.18.8-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.5-arm64-darwin)
nokogiri (1.18.8-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.5-x86_64-linux-gnu)
nokogiri (1.18.8-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.5-x86_64-linux-musl)
nokogiri (1.18.8-x86_64-linux-musl)
racc (~> 1.4)
oj (3.16.8)
bigdecimal (>= 3.0)
@@ -387,7 +387,7 @@ GEM
puma (6.5.0)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.1.12)
rack (3.1.16)
rack-proxy (0.7.7)
rack
rack-session (2.0.0)
@@ -652,7 +652,7 @@ DEPENDENCIES
webmock
RUBY VERSION
ruby 3.4.1p0
ruby 3.4.2p28
BUNDLED WITH
2.5.3
+1 -1
View File
@@ -42,7 +42,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- PDF signature verification
- Users management
- Mobile-optimized
- 6 UI languages with signing available in 13 languages
- 6 UI languages with signing available in 14 languages
- API and Webhooks for integrations
- Easy to deploy in minutes
+13 -2
View File
@@ -1,8 +1,10 @@
# frozen_string_literal: true
class AccountConfigsController < ApplicationController
before_action :load_account_config
authorize_resource :account_config
before_action :load_account_config, only: :create
authorize_resource :account_config, only: :create
load_and_authorize_resource :account_config, only: :destroy
ALLOWED_KEYS = [
AccountConfig::ALLOW_TYPED_SIGNATURE,
@@ -15,6 +17,7 @@ class AccountConfigsController < ApplicationController
AccountConfig::DOWNLOAD_LINKS_AUTH_KEY,
AccountConfig::FORCE_SSO_AUTH_KEY,
AccountConfig::FLATTEN_RESULT_PDF_KEY,
AccountConfig::ENFORCE_SIGNING_ORDER_KEY,
AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::COMBINE_PDF_RESULT_KEY,
AccountConfig::REQUIRE_SIGNING_REASON_KEY,
@@ -29,6 +32,14 @@ class AccountConfigsController < ApplicationController
head :ok
end
def destroy
raise InvalidKey unless ALLOWED_KEYS.include?(@account_config.key)
@account_config.destroy!
redirect_back(fallback_location: root_path)
end
private
def load_account_config
+1 -1
View File
@@ -45,7 +45,7 @@ class AccountsController < ApplicationController
def destroy
authorize!(:manage, current_account)
true_user.update!(locked_at: Time.current)
true_user.update!(locked_at: Time.current, email: true_user.email.sub('@', '+removed@'))
# rubocop:disable Layout/LineLength
render turbo_stream: turbo_stream.replace(
@@ -21,7 +21,11 @@ module Api
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid)
authorization_check!(blob) if exp.blank?
attachment = blob.attachments.take
@record = attachment.record
authorization_check!(attachment) if exp.blank?
if request.headers['Range'].present?
send_blob_byte_range_data blob, request.headers['Range']
@@ -37,9 +41,7 @@ module Api
private
def authorization_check!(blob)
attachment = blob.attachments.take
def authorization_check!(attachment)
is_authorized = attachment.name.in?(%w[logo preview_images]) ||
(current_user && attachment.record.account.id == current_user.account_id) ||
(current_user && !Docuseal.multitenant? && current_user.role == 'superadmin') ||
@@ -10,6 +10,12 @@ module Api
def create
submitter = Submitter.find_by!(slug: params[:submitter_slug])
if params[:type].in?(%w[initials signature]) && ImageUtils.blank?(Vips::Image.new_from_file(params[:file].path))
Rollbar.error("Empty signature: #{submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_entity
end
attachment = Submitters.create_attachment!(submitter, params)
if params[:remember_signature] == 'true' && submitter.email.present?
+24 -10
View File
@@ -10,14 +10,8 @@ module Api
end
def index
submissions = Submissions.search(@submissions, params[:q])
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
if params[:template_folder].present?
submissions = submissions.joins(template: :folder).where(folder: { name: params[:template_folder] })
end
submissions = Submissions::Filter.call(submissions, current_user, params)
submissions = Submissions.search(current_user, @submissions, params[:q])
submissions = filter_submissions(submissions, params)
submissions = paginate(submissions.preload(:created_by_user, :submitters,
template: :folder,
@@ -86,6 +80,8 @@ module Api
end
end
SearchEntries.enqueue_reindex(submissions)
render json: build_create_json(submissions)
rescue Submitters::NormalizeValues::BaseError, Submissions::CreateFromSubmitters::BaseError,
DownloadUtils::UnableToDownload => e
@@ -111,6 +107,23 @@ module Api
private
def filter_submissions(submissions, params)
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
submissions = submissions.where(slug: params[:slug]) if params[:slug].present?
if params[:template_folder].present?
folder = TemplateFolder.accessible_by(current_ability).find_by(name: params[:template_folder])
submissions = folder ? submissions.joins(:template).where(template: { folder_id: folder.id }) : submissions.none
end
if params.key?(:archived)
submissions = params[:archived].in?(['true', true]) ? submissions.archived : submissions.active
end
Submissions::Filter.call(submissions, current_user, params)
end
def build_create_json(submissions)
json = submissions.flat_map do |submission|
submission.submitters.map do |s|
@@ -174,14 +187,15 @@ module Api
def submissions_params
permitted_attrs = [
:send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to, :go_to_last,
:expire_at,
:require_phone_2fa, :expire_at, :name,
{
message: %i[subject body],
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id, :reply_to, :go_to_last,
:require_phone_2fa,
{ metadata: {}, values: {}, roles: [], readonly_fields: [], message: %i[subject body],
fields: [:name, :uuid, :default_value, :value, :title, :description,
:readonly, :validation_pattern, :invalid_message,
:readonly, :required, :validation_pattern, :invalid_message,
{ default_value: [], value: [], preferences: {} }] }]]
}
]
@@ -6,10 +6,10 @@ module Api
skip_authorization_check
def create
submitter = Submitter.find_by!(slug: params[:submitter_slug])
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
if params[:t] == SubmissionEvents.build_tracking_param(submitter, 'click_email')
SubmissionEvents.create_with_tracking_data(submitter, 'click_email', request)
if params[:t] == SubmissionEvents.build_tracking_param(@submitter, 'click_email')
SubmissionEvents.create_with_tracking_data(@submitter, 'click_email', request)
end
render json: {}
@@ -6,15 +6,15 @@ module Api
skip_authorization_check
def create
submitter = Submitter.find_by!(slug: params[:submitter_slug])
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
submitter.opened_at = Time.current
submitter.save
@submitter.opened_at = Time.current
@submitter.save
SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request)
SubmissionEvents.create_with_tracking_data(@submitter, 'view_form', request)
WebhookUrls.for_account_id(submitter.account_id, 'form.viewed').each do |webhook_url|
SendFormViewedWebhookRequestJob.perform_async('submitter_id' => submitter.id,
WebhookUrls.for_account_id(@submitter.account_id, 'form.viewed').each do |webhook_url|
SendFormViewedWebhookRequestJob.perform_async('submitter_id' => @submitter.id,
'webhook_url_id' => webhook_url.id)
end
+33 -22
View File
@@ -5,17 +5,9 @@ module Api
load_and_authorize_resource :submitter
def index
submitters = Submitters.search(@submitters, params[:q])
submitters = Submitters.search(current_user, @submitters, params[:q])
submitters = submitters.where(external_id: params[:application_key]) if params[:application_key].present?
submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present?
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
if params[:template_id].present?
submitters = submitters.joins(:submission).where(submission: { template_id: params[:template_id] })
end
submitters = maybe_filder_by_completed_at(submitters, params)
submitters = filter_submitters(submitters, params)
submitters = paginate(
submitters.preload(:template, :submission, :submission_events,
@@ -45,14 +37,16 @@ module Api
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_entity
end
role = @submitter.submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
submission = @submitter.submission
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
normalized_params, new_attachments =
Submissions::NormalizeParamUtils.normalize_submitter_params!(submitter_params.merge(role:), @submitter.template,
for_submitter: @submitter)
normalized_params, new_attachments = Submissions::NormalizeParamUtils.normalize_submitter_params!(
submitter_params.merge(role:),
@submitter.template || Template.new(submitters: submission.template_submitters, account: @submitter.account),
for_submitter: @submitter
)
Submissions::CreateFromSubmitters.maybe_set_template_fields(@submitter.submission,
[normalized_params],
Submissions::CreateFromSubmitters.maybe_set_template_fields(submission, [normalized_params],
default_submitter_uuid: @submitter.uuid)
assign_submitter_attrs(@submitter, normalized_params)
@@ -73,10 +67,10 @@ module Api
Submitters.send_signature_requests([@submitter])
end
render json: Submitters::SerializeForApi.call(@submitter, with_template: false,
with_urls: true,
with_events: false,
params:)
SearchEntries.enqueue_reindex(@submitter)
render json: Submitters::SerializeForApi.call(@submitter, with_template: false, with_urls: true,
with_events: false, params:)
rescue Submitters::NormalizeValues::BaseError, DownloadUtils::UnableToDownload => e
Rollbar.warning(e) if defined?(Rollbar)
@@ -88,9 +82,9 @@ module Api
submitter_params.permit(
:send_email, :send_sms, :reply_to, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id, :go_to_last,
:completed, :phone, :application_key, :external_id, :go_to_last, :require_phone_2fa,
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
fields: [[:name, :uuid, :default_value, :value,
fields: [[:name, :uuid, :default_value, :value, :required,
:readonly, :validation_pattern, :invalid_message,
{ default_value: [], value: [], preferences: {} }]] }
)
@@ -163,6 +157,19 @@ module Api
submitter
end
def filter_submitters(submitters, params)
submitters = submitters.where(external_id: params[:application_key]) if params[:application_key].present?
submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present?
submitters = submitters.where(slug: params[:slug]) if params[:slug].present?
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
if params[:template_id].present?
submitters = submitters.joins(:submission).where(submissions: { template_id: params[:template_id] })
end
maybe_filder_by_completed_at(submitters, params)
end
def assign_external_id(submitter, attrs)
submitter.external_id = attrs[:application_key] if attrs.key?(:application_key)
submitter.external_id = attrs[:external_id] if attrs.key?(:external_id)
@@ -186,6 +193,10 @@ module Api
submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms')
submitter.preferences['reply_to'] = submitter_preferences['reply_to'] if submitter_preferences.key?('reply_to')
if submitter_preferences.key?('require_phone_2fa')
submitter.preferences['require_phone_2fa'] = submitter_preferences['require_phone_2fa']
end
if submitter_preferences.key?('go_to_last')
submitter.preferences['go_to_last'] = submitter_preferences['go_to_last']
end
@@ -5,7 +5,7 @@ module Api
load_and_authorize_resource :template
def create
authorize!(:manage, @template)
authorize!(:create, @template)
ActiveRecord::Associations::Preloader.new(
records: [@template],
@@ -21,17 +21,20 @@ module Api
)
cloned_template.source = :api
cloned_template.save!
schema_documents = Templates::CloneAttachments.call(template: cloned_template,
original_template: @template,
documents: params[:documents])
cloned_template.save!
WebhookUrls.for_account_id(cloned_template.account_id, 'template.created').each do |webhook_url|
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => cloned_template.id,
'webhook_url_id' => webhook_url.id)
end
SearchEntries.enqueue_reindex(cloned_template)
render json: Templates::SerializeForApi.call(cloned_template, schema_documents)
end
end
+11 -2
View File
@@ -65,6 +65,8 @@ module Api
@template.update!(template_params)
SearchEntries.enqueue_reindex(@template)
WebhookUrls.for_account_id(@template.account_id, 'template.updated').each do |webhook_url|
SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id,
'webhook_url_id' => webhook_url.id)
@@ -86,11 +88,17 @@ module Api
private
def filter_templates(templates, params)
templates = Templates.search(templates, params[:q])
templates = Templates.search(current_user, templates, params[:q])
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[:external_id]) if params[:external_id].present?
templates = templates.joins(:folder).where(folder: { name: params[:folder] }) if params[:folder].present?
templates = templates.where(slug: params[:slug]) if params[:slug].present?
if params[:folder].present?
folder = TemplateFolder.accessible_by(current_ability).find_by(name: params[:folder])
templates = folder ? templates.where(folder:) : templates.none
end
templates
end
@@ -99,6 +107,7 @@ module Api
permitted_params = [
:name,
:external_id,
:shared_link,
{
submitters: [%i[name uuid is_requester invite_by_uuid optional_invite_by_uuid linked_to_uuid email]],
fields: [[:uuid, :submitter_uuid, :name, :type,
@@ -55,6 +55,14 @@ class ApplicationController < ActionController::Base
request.session[:impersonated_user_id] = user.uuid
end
def pagy_auto(collection, **keyword_args)
if current_ability.can?(:manage, :countless)
pagy_countless(collection, **keyword_args)
else
pagy(collection, **keyword_args)
end
end
private
def with_locale(&)
@@ -9,7 +9,9 @@ class EmailSmtpSettingsController < ApplicationController
def create
if @encrypted_config.update(email_configs)
SettingsMailer.smtp_successful_setup(@encrypted_config.value['from_email'] || current_user.email).deliver_now!
unless Docuseal.multitenant?
SettingsMailer.smtp_successful_setup(@encrypted_config.value['from_email'] || current_user.email).deliver_now!
end
redirect_to settings_email_index_path, notice: I18n.t('changes_have_been_saved')
else
+4
View File
@@ -5,8 +5,12 @@ class ErrorsController < ActionController::Base
'This feature is available in Pro Edition: https://www.docuseal.com/pricing'
ENTERPRISE_PATHS = [
'/submissions/html',
'/api/submissions/html',
'/templates/html',
'/api/templates/html',
'/submissions/pdf',
'/api/submissions/pdf',
'/templates/pdf',
'/api/templates/pdf',
'/templates/doc',
@@ -12,6 +12,8 @@ class PreviewDocumentPageController < ActionController::API
return head :not_found unless attachment
@template = attachment.record
preview_image = attachment.preview_images.joins(:blob)
.find_by(blob: { filename: ["#{params[:id]}.png", "#{params[:id]}.jpg"] })
@@ -0,0 +1,17 @@
# frozen_string_literal: true
class SearchEntriesReindexController < ApplicationController
def create
authorize!(:manage, EncryptedConfig)
ReindexAllSearchEntriesJob.perform_async
AccountConfig.find_or_initialize_by(account_id: Account.minimum(:id), key: :fulltext_search)
.update!(value: true)
Docuseal.instance_variable_set(:@fulltext_search, nil)
redirect_back(fallback_location: settings_account_path,
notice: "Started building search index. Visit #{root_url}jobs/busy to check progress.")
end
end
@@ -10,27 +10,37 @@ class SendSubmissionEmailController < ApplicationController
SEND_DURATION = 30.minutes
def create
@submitter =
if params[:template_slug]
Submitter.joins(submission: :template).find_by!(email: params[:email].to_s.downcase,
template: { slug: params[:template_slug] })
elsif params[:submission_slug]
Submitter.joins(:submission).find_by!(email: params[:email].to_s.downcase,
submission: { slug: params[:submission_slug] })
else
Submitter.find_by!(slug: params[:submitter_slug])
if params[:template_slug]
template = Template.find_by!(slug: params[:template_slug])
@submitter =
Submitter.completed.where(submission: template.submissions).find_by!(email: params[:email].to_s.downcase)
elsif params[:submission_slug]
submission = Submission.find_by(slug: params[:submission_slug])
if submission
@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
else
@submitter = Submitter.completed.find_by!(slug: params[:submitter_slug])
end
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
unless EmailEvent.exists?(tag: :submitter_documents_copy, email: @submitter.email, emailable: @submitter,
event_type: :send, created_at: SEND_DURATION.ago..Time.current)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later!
end
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! unless already_sent?(@submitter)
respond_to do |f|
f.html { render :success }
f.json { head :ok }
end
end
private
def already_sent?(submitter)
EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
event_type: :send, created_at: SEND_DURATION.ago..Time.current)
end
end
+1
View File
@@ -34,6 +34,7 @@ class SetupController < ApplicationController
{ key: EncryptedConfig::ESIGN_CERTS_KEY, value: GenerateCertificate.call.transform_values(&:to_pem) }
]
@account.encrypted_configs.create!(encrypted_configs)
@account.account_configs.create!(key: :fulltext_search, value: true) if SearchEntry.table_exists?
Docuseal.refresh_default_url_options!
+111 -32
View File
@@ -8,26 +8,37 @@ class StartFormController < ApplicationController
around_action :with_browser_locale, only: %i[show completed]
before_action :maybe_redirect_com, only: %i[show completed]
before_action :load_resubmit_submitter, only: :update
before_action :load_template
before_action :authorize_start!, only: :update
def show
@submitter = @template.submissions.new(account_id: @template.account_id)
.submitters.new(uuid: (filter_undefined_submitters(@template).first ||
@template.submitters.first)['uuid'])
raise ActionController::RoutingError, I18n.t('not_found') if @template.preferences['require_phone_2fa']
if @template.shared_link?
@submitter = @template.submissions.new(account_id: @template.account_id)
.submitters.new(account_id: @template.account_id,
uuid: (filter_undefined_submitters(@template).first ||
@template.submitters.first)['uuid'])
else
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
return render :private if current_user && current_ability.can?(:read, @template)
raise ActionController::RoutingError, I18n.t('not_found')
end
end
def update
return redirect_to start_form_path(@template.slug) if @template.archived_at?
@submitter = find_or_initialize_submitter(@template, submitter_params)
if @submitter.completed_at?
redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email])
redirect_to start_form_completed_path(@template.slug, submitter_params.compact_blank)
else
if filter_undefined_submitters(@template).size > 1 && @submitter.new_record?
@error_message = multiple_submitters_error_message
return render :show
return render :show, status: :unprocessable_entity
end
if (is_new_record = @submitter.new_record?)
@@ -38,55 +49,116 @@ class StartFormController < ApplicationController
@submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent)
end
if @submitter.save
if @submitter.errors.blank? && @submitter.save
if is_new_record
WebhookUrls.for_account_id(@submitter.account_id, 'submission.created').each do |webhook_url|
SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => @submitter.submission_id,
'webhook_url_id' => webhook_url.id)
enqueue_submission_create_webhooks(@submitter)
SearchEntries.enqueue_reindex(@submitter)
if @submitter.submission.expire_at?
ProcessSubmissionExpiredJob.perform_at(@submitter.submission.expire_at,
'submission_id' => @submitter.submission_id)
end
end
redirect_to submit_form_path(@submitter.slug)
else
render :show
render :show, status: :unprocessable_entity
end
end
end
def completed
return redirect_to start_form_path(@template.slug) if !@template.shared_link? || @template.archived_at?
submitter_params = params.permit(:name, :email, :phone).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email])
end
required_fields = @template.preferences.fetch('link_form_fields', ['email'])
required_params = required_fields.index_with { |key| submitter_params[key] }
raise ActionController::RoutingError, I18n.t('not_found') if required_params.any? { |_, v| v.blank? } ||
required_params.except('name').compact_blank.blank?
@submitter = Submitter.where(submission: @template.submissions)
.where.not(completed_at: nil)
.find_by!(email: params[:email])
.find_by!(required_params)
end
private
def load_resubmit_submitter
@resubmit_submitter =
if params[:resubmit].present? && !params[:resubmit].in?([true, 'true'])
Submitter.find_by(slug: params[:resubmit])
end
end
def authorize_start!
return redirect_to start_form_path(@template.slug) if @template.archived_at?
return if @resubmit_submitter
return if @template.shared_link? || (current_user && current_ability.can?(:read, @template))
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
redirect_to start_form_path(@template.slug)
end
def enqueue_submission_create_webhooks(submitter)
WebhookUrls.for_account_id(submitter.account_id, 'submission.created').each do |webhook_url|
SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id,
'webhook_url_id' => webhook_url.id)
end
end
def find_or_initialize_submitter(template, submitter_params)
Submitter.where(submission: template.submissions.where(expire_at: Time.current..)
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
.order(id: :desc)
.where(declined_at: nil)
.where(external_id: nil)
.where(ip: [nil, request.remote_ip])
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
.find_or_initialize_by(email: submitter_params[:email], **submitter_params.compact_blank)
required_fields = template.preferences.fetch('link_form_fields', ['email'])
required_params = required_fields.index_with { |key| submitter_params[key] }
find_params = required_params.except('name')
submitter = Submitter.new if find_params.compact_blank.blank?
submitter ||=
Submitter
.where(submission: template.submissions.where(expire_at: Time.current..)
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
.order(id: :desc)
.where(declined_at: nil)
.where(external_id: nil)
.where(ip: [nil, request.remote_ip])
.then { |rel| params[:resubmit].present? || params[:selfsign].present? ? rel.where(completed_at: nil) : rel }
.find_or_initialize_by(find_params)
submitter.name = required_params['name'] if submitter.new_record?
unless @resubmit_submitter
required_params.each do |key, value|
submitter.errors.add(key.to_sym, :blank) if value.blank?
end
end
submitter
end
def assign_submission_attributes(submitter, template)
resubmit_submitter =
(Submitter.where(submission: template.submissions).find_by(slug: params[:resubmit]) if params[:resubmit].present?)
submitter.assign_attributes(
uuid: (filter_undefined_submitters(template).first || @template.submitters.first)['uuid'],
ip: request.remote_ip,
ua: request.user_agent,
values: resubmit_submitter&.preferences&.fetch('default_values', nil) || {},
preferences: resubmit_submitter&.preferences.presence || { 'send_email' => true },
metadata: resubmit_submitter&.metadata.presence || {}
values: @resubmit_submitter&.preferences&.fetch('default_values', nil) || {},
preferences: @resubmit_submitter&.preferences.presence || { 'send_email' => true },
metadata: @resubmit_submitter&.metadata.presence || {}
)
submitter.assign_attributes(@resubmit_submitter.slice(:name, :email, :phone)) if @resubmit_submitter
if submitter.values.present?
resubmit_submitter.attachments.each do |attachment|
@resubmit_submitter.attachments.each do |attachment|
submitter.attachments << attachment.dup if submitter.values.value?(attachment.uuid)
end
end
@@ -94,6 +166,7 @@ class StartFormController < ApplicationController
submitter.submission ||= Submission.new(template:,
account_id: template.account_id,
template_submitters: template.submitters,
expire_at: Templates.build_default_expire_at(template),
submitters: [submitter],
source: :link)
@@ -103,19 +176,25 @@ class StartFormController < ApplicationController
end
def filter_undefined_submitters(template)
Templates.filter_undefined_submitters(template)
Templates.filter_undefined_submitters(template.submitters)
end
def submitter_params
return current_user.slice(:email) if params[:selfsign]
return @resubmit_submitter.slice(:name, :phone, :email) if @resubmit_submitter.present?
params.require(:submitter).permit(:email, :phone, :name).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email])
end
end
def load_template
slug = params[:slug] || params[:start_form_slug]
@template = Template.find_by!(slug:)
@template =
if @resubmit_submitter
@resubmit_submitter.template
else
Template.find_by!(slug: params[:slug] || params[:start_form_slug])
end
end
def multiple_submitters_error_message
@@ -4,14 +4,12 @@ class SubmissionsArchivedController < ApplicationController
load_and_authorize_resource :submission, parent: false
def index
@submissions = @submissions.joins(:template)
@submissions = @submissions.left_joins(:template)
@submissions = @submissions.where.not(archived_at: nil)
.or(@submissions.where.not(templates: { archived_at: nil }))
.preload(:created_by_user, template: :author)
.preload(:template_accesses, :created_by_user, template: :author)
@submissions = @submissions.preload(:template_accesses) unless current_user.role.in?(%w[admin superadmin])
@submissions = Submissions.search(@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 = if params[:completed_at_from].present? || params[:completed_at_to].present?
@@ -20,6 +18,6 @@ class SubmissionsArchivedController < ApplicationController
@submissions.order(id: :desc)
end
@pagy, @submissions = pagy(@submissions.preload(submitters: :start_form_submission_events))
@pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events))
end
end
+12 -3
View File
@@ -8,6 +8,10 @@ class SubmissionsController < ApplicationController
prepend_before_action :maybe_redirect_com, only: %i[show]
before_action only: :create do
authorize!(:create, Submission)
end
def show
@submission = Submissions.preload_with_pages(@submission)
@@ -26,8 +30,6 @@ class SubmissionsController < ApplicationController
end
def create
authorize!(:create, Submission)
save_template_message(@template, params) if params[:save_message] == '1'
if params[:is_custom_message] != '1'
@@ -56,7 +58,14 @@ class SubmissionsController < ApplicationController
Submissions.send_signature_requests(submissions)
SearchEntries.enqueue_reindex(submissions)
redirect_to template_path(@template), notice: I18n.t('new_recipients_have_been_added')
rescue Submissions::CreateFromSubmitters::BaseError => e
render turbo_stream: turbo_stream.replace(:submitters_error,
partial: 'submissions/error',
locals: { error: e.message }),
status: :unprocessable_entity
end
def destroy
@@ -76,7 +85,7 @@ class SubmissionsController < ApplicationController
I18n.t('submission_has_been_archived')
end
redirect_back(fallback_location: template_path(@submission.template), notice:)
redirect_back(fallback_location: @submission.template_id ? template_path(@submission.template) : root_path, notice:)
end
private
@@ -4,15 +4,13 @@ class SubmissionsDashboardController < ApplicationController
load_and_authorize_resource :submission, parent: false
def index
@submissions = @submissions.joins(:template)
@submissions = @submissions.left_joins(:template)
@submissions = @submissions.where(archived_at: nil)
.where(templates: { archived_at: nil })
.preload(:created_by_user, template: :author)
.preload(:template_accesses, :created_by_user, template: :author)
@submissions = @submissions.preload(:template_accesses) unless current_user.role.in?(%w[admin superadmin])
@submissions = Submissions.search(@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 = if params[:completed_at_from].present? || params[:completed_at_to].present?
@@ -21,6 +19,6 @@ class SubmissionsDashboardController < ApplicationController
@submissions.order(id: :desc)
end
@pagy, @submissions = pagy(@submissions.preload(submitters: :start_form_submission_events))
@pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events))
end
end
@@ -8,25 +8,25 @@ class SubmissionsDownloadController < ApplicationController
FILES_TTL = 5.minutes
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 =
if submitter&.slug == params[:submitter_slug]
if @submitter&.slug == params[:submitter_slug]
true
else
submitter = nil
@submitter = nil
end
submitter ||= Submitter.find_by!(slug: params[:submitter_slug])
@submitter ||= Submitter.find_by!(slug: params[:submitter_slug])
Submissions::EnsureResultGenerated.call(submitter)
Submissions::EnsureResultGenerated.call(@submitter)
last_submitter = submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
return head :not_found unless last_submitter
Submissions::EnsureResultGenerated.call(last_submitter)
return head :not_found unless last_submitter.completed_at?
if last_submitter.completed_at < TTL.ago && !signature_valid && !current_user_submitter?(last_submitter)
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
@@ -34,7 +34,7 @@ class SubmissionsDownloadController < ApplicationController
end
if params[:combined] == 'true'
url = build_combined_url(submitter)
url = build_combined_url(@submitter)
if url
render json: [url]
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class SubmissionsPreviewController < ApplicationController
around_action :with_browser_locale
skip_before_action :authenticate_user!
skip_authorization_check
+32 -21
View File
@@ -7,30 +7,28 @@ class SubmitFormController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
before_action :load_submitter, only: %i[show update completed]
before_action :maybe_render_locked_page, only: :show
CONFIG_KEYS = [].freeze
def show
@submitter = Submitter.find_by!(slug: params[:slug])
submission = @submitter.submission
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
return render :archived if submission.template.archived_at? ||
submission.archived_at? ||
@submitter.account.archived_at?
return render :expired if submission.expired?
return render :declined if @submitter.declined_at?
return render :awaiting if submission.template.preferences['submitters_order'] == 'preserved' &&
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
return render :awaiting if (@form_configs[:enforce_signing_order] ||
submission.template&.preferences&.dig('submitters_order') == 'preserved') &&
!Submitters.current_submitter_order?(@submitter)
Submitters.preload_with_pages(@submitter)
Submissions.preload_with_pages(submission)
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
@attachments_index = build_attachments_index(submission)
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
return unless @form_configs[:prefill_signature]
if (user_signature = UserConfigs.load_signature(current_user))
@@ -48,40 +46,53 @@ class SubmitFormController < ApplicationController
end
def update
submitter = Submitter.find_by!(slug: params[:slug])
if submitter.completed_at?
if @submitter.completed_at?
return render json: { error: I18n.t('form_has_been_completed_already') }, status: :unprocessable_entity
end
if submitter.template.archived_at? || submitter.submission.archived_at?
if @submitter.template&.archived_at? || @submitter.submission.archived_at?
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_entity
end
if submitter.submission.expired?
if @submitter.submission.expired?
return render json: { error: I18n.t('form_has_been_expired') }, status: :unprocessable_entity
end
if submitter.declined_at?
if @submitter.declined_at?
return render json: { error: I18n.t('form_has_been_declined') },
status: :unprocessable_entity
end
Submitters::SubmitValues.call(submitter, params, request)
Submitters::SubmitValues.call(@submitter, params, request)
head :ok
rescue Submitters::SubmitValues::RequiredFieldError => e
Rollbar.warning("Required field #{@submitter.id}: #{e.message}") if defined?(Rollbar)
render json: { field_uuid: e.message }, status: :unprocessable_entity
rescue Submitters::SubmitValues::ValidationError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def completed
@submitter = Submitter.completed.find_by!(slug: params[:submit_form_slug])
end
def completed; end
def success; end
private
def maybe_render_locked_page
return render :archived if @submitter.submission.template&.archived_at? ||
@submitter.submission.archived_at? ||
@submitter.account.archived_at?
return render :expired if @submitter.submission.expired?
render :declined if @submitter.declined_at?
end
def load_submitter
@submitter = Submitter.find_by!(slug: params[:slug] || params[:submit_form_slug])
end
def build_attachments_index(submission)
ActiveStorage::Attachment.where(record: submission.submitters, name: :attachments)
.preload(:blob).index_by(&:uuid)
@@ -11,7 +11,7 @@ class SubmitFormDeclineController < ApplicationController
submitter.completed_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired? ||
submitter.submission.template.archived_at?
submitter.submission.template&.archived_at?
ApplicationRecord.transaction do
submitter.update!(declined_at: Time.current)
@@ -7,25 +7,28 @@ class SubmitFormDownloadController < ApplicationController
FILES_TTL = 5.minutes
def index
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return redirect_to submitter_download_index_path(submitter.slug) if submitter.completed_at?
return redirect_to submitter_download_index_path(@submitter.slug) if @submitter.completed_at?
return head :unprocessable_entity if submitter.declined_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired? ||
submitter.submission.template.archived_at?
return head :unprocessable_entity if @submitter.declined_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
AccountConfig.exists?(account_id: @submitter.account_id,
key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
value: false)
last_completed_submitter = submitter.submission.submitters
.where.not(id: submitter.id)
.where.not(completed_at: nil)
.max_by(&:completed_at)
last_completed_submitter = @submitter.submission.submitters
.where.not(id: @submitter.id)
.where.not(completed_at: nil)
.max_by(&:completed_at)
attachments =
if last_completed_submitter
Submitters.select_attachments_for_download(last_completed_submitter)
else
submitter.submission.template.schema_documents.preload(:blob)
@submitter.submission.schema_documents.preload(:blob)
end
urls = attachments.map do |attachment|
@@ -12,7 +12,7 @@ class SubmitFormDrawSignatureController < ApplicationController
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
if @submitter.submission.template.archived_at? || @submitter.submission.archived_at?
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at?
return redirect_to submit_form_path(@submitter.slug)
end
@@ -45,7 +45,7 @@ class SubmitFormInviteController < ApplicationController
!submitter.completed_at? &&
!submitter.submission.archived_at? &&
!submitter.submission.expired? &&
!submitter.submission.template.archived_at?
!submitter.submission.template&.archived_at?
end
def filter_invite_submitters(submitter, key = 'invite_by_uuid')
@@ -8,7 +8,7 @@ class SubmitFormValuesController < ApplicationController
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return render json: {} if submitter.completed_at? || submitter.declined_at?
return render json: {} if submitter.submission.template.archived_at? ||
return render json: {} if submitter.submission.template&.archived_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired?
@@ -22,13 +22,17 @@ class SubmittersAutocompleteController < ApplicationController
def search_submitters(submitters)
if SELECT_COLUMNS.include?(params[:field])
column = Submitter.arel_table[params[:field].to_sym]
if Docuseal.fulltext_search?
Submitters.fulltext_search_field(current_user, submitters, params[:q], params[:field])
else
column = Submitter.arel_table[params[:field].to_sym]
term = "#{params[:q].downcase}%"
term = "#{params[:q].downcase}%"
submitters.where(column.matches(term))
submitters.where(column.matches(term))
end
else
Submitters.search(submitters, params[:q])
Submitters.search(current_user, submitters, params[:q])
end
end
end
+2
View File
@@ -38,6 +38,8 @@ class SubmittersController < ApplicationController
if @submitter.save
maybe_resend_email_sms(@submitter, params)
SearchEntries.enqueue_reindex(@submitter)
redirect_back fallback_location: submission_path(submission), notice: I18n.t('changes_have_been_saved')
else
redirect_back fallback_location: submission_path(submission), alert: I18n.t('unable_to_save')
@@ -6,13 +6,15 @@ class SubmittersResubmitController < ApplicationController
def update
return redirect_to submit_form_path(slug: @submitter.slug) if @submitter.email != current_user.email
submission = @submitter.template.submissions.new(created_by_user: current_user,
submitters_order: :preserved,
**@submitter.submission.slice(:template_fields,
:account_id,
:template_schema,
:template_submitters,
:preferences))
submission = @submitter.account.submissions.new(created_by_user: current_user,
submitters_order: :preserved,
**@submitter.submission.slice(:template_fields,
:account_id,
:name,
:template_id,
:template_schema,
:template_submitters,
:preferences))
@submitter.submission.submitters.each do |submitter|
new_submitter = submission.submitters.new(submitter.slice(:uuid, :email, :phone, :name,
@@ -27,6 +29,10 @@ class SubmittersResubmitController < ApplicationController
submission.save!
@submitter.submission.documents_attachments.each do |attachment|
submission.documents_attachments.create!(uuid: attachment.uuid, blob_id: attachment.blob_id)
end
redirect_to submit_form_path(slug: @new_submitter.slug)
end
@@ -6,7 +6,9 @@ class TemplateFoldersAutocompleteController < ApplicationController
LIMIT = 100
def index
template_folders = @template_folders.joins(:templates).where(templates: { archived_at: nil }).distinct
templates_query = Template.accessible_by(current_ability).where(archived_at: nil)
template_folders = @template_folders.where(id: templates_query.select(:folder_id))
template_folders = TemplateFolders.search(template_folders, params[:q]).limit(LIMIT)
render json: template_folders.as_json(only: %i[name archived_at])
@@ -5,10 +5,11 @@ class TemplateFoldersController < ApplicationController
def show
@templates = @template_folder.templates.active.accessible_by(current_ability)
.preload(:author, :template_accesses).order(id: :desc)
@templates = Templates.search(@templates, params[:q])
.preload(:author, :template_accesses)
@templates = Templates.search(current_user, @templates, params[:q])
@templates = Templates::Order.call(@templates, current_user, cookies.permanent[:dashboard_templates_order])
@pagy, @templates = pagy(@templates, limit: 12)
@pagy, @templates = pagy_auto(@templates, limit: 12)
end
def edit; end
@@ -5,8 +5,8 @@ class TemplatesArchivedController < ApplicationController
def index
@templates = @templates.where.not(archived_at: nil).preload(:author, :folder, :template_accesses).order(id: :desc)
@templates = Templates.search(@templates, params[:q])
@templates = Templates.search(current_user, @templates, params[:q])
@pagy, @templates = pagy(@templates, limit: 12)
@pagy, @templates = pagy_auto(@templates, limit: 12)
end
end
@@ -6,7 +6,7 @@ class TemplatesArchivedSubmissionsController < ApplicationController
def index
@submissions = @submissions.where.not(archived_at: nil)
@submissions = Submissions.search(@submissions, params[:q], search_values: true)
@submissions = Submissions.search(current_user, @submissions, params[:q], search_values: true)
@submissions = Submissions::Filter.call(@submissions, current_user, params)
@submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
@@ -15,7 +15,7 @@ class TemplatesArchivedSubmissionsController < ApplicationController
@submissions.order(id: :desc)
end
@pagy, @submissions = pagy(@submissions.preload(submitters: :start_form_submission_events))
@pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events))
rescue ActiveRecord::RecordNotFound
redirect_to root_path
end
@@ -0,0 +1,37 @@
# frozen_string_literal: true
class TemplatesCloneAndReplaceController < ApplicationController
load_and_authorize_resource :template
def create
return head :unprocessable_entity if params[:files].blank?
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: :preview_images_attachments]
).call
cloned_template = Templates::Clone.call(@template, author: current_user)
cloned_template.name = File.basename(params[:files].first.original_filename, '.*')
cloned_template.save!
documents = Templates::ReplaceAttachments.call(cloned_template, params, extract_fields: true)
cloned_template.save!
Templates::CloneAttachments.call(template: cloned_template, original_template: @template,
excluded_attachment_uuids: documents.map(&:uuid))
SearchEntries.enqueue_reindex(cloned_template)
respond_to do |f|
f.html { redirect_to edit_template_path(cloned_template) }
f.json { render json: { id: cloned_template.id } }
end
rescue Templates::CreateAttachments::PdfEncrypted
respond_to do |f|
f.html { render turbo_stream: turbo_stream.append(params[:form_id], html: helpers.tag.prompt_password) }
f.json { render json: { error: 'PDF encrypted', status: 'pdf_encrypted' }, status: :unprocessable_entity }
end
end
end
+11 -5
View File
@@ -8,7 +8,7 @@ class TemplatesController < ApplicationController
def show
submissions = @template.submissions.accessible_by(current_ability)
submissions = submissions.active if @template.archived_at.blank?
submissions = Submissions.search(submissions, params[:q], search_values: true)
submissions = Submissions.search(current_user, submissions, params[:q], search_values: true)
submissions = Submissions::Filter.call(submissions, current_user, params.except(:status))
@base_submissions = submissions
@@ -21,9 +21,7 @@ class TemplatesController < ApplicationController
submissions.order(id: :desc)
end
submissions = submissions.preload(:template_accesses) unless current_user.role.in?(%w[admin superadmin])
@pagy, @submissions = pagy(submissions.preload(submitters: :start_form_submission_events))
@pagy, @submissions = pagy_auto(submissions.preload(:template_accesses, submitters: :start_form_submission_events))
rescue ActiveRecord::RecordNotFound
redirect_to root_path
end
@@ -74,6 +72,8 @@ class TemplatesController < ApplicationController
if @template.save
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
SearchEntries.enqueue_reindex(@template)
enqueue_template_created_webhooks(@template)
maybe_redirect_to_template(@template)
@@ -83,7 +83,13 @@ class TemplatesController < ApplicationController
end
def update
@template.update!(template_params)
@template.assign_attributes(template_params)
is_name_changed = @template.name_changed?
@template.save!
SearchEntries.enqueue_reindex(@template) if is_name_changed
enqueue_template_updated_webhooks(@template)
@@ -9,13 +9,15 @@ class TemplatesDashboardController < ApplicationController
FOLDERS_PER_PAGE = 18
def index
@template_folders = @template_folders.where(id: @templates.active.select(:folder_id)).order(id: :desc)
@template_folders = @template_folders.where(id: @templates.active.select(:folder_id))
@template_folders = TemplateFolders.search(@template_folders, params[:q])
@template_folders = sort_template_folders(@template_folders, current_user,
cookies.permanent[:dashboard_templates_order])
@pagy, @template_folders = pagy(
@template_folders,
items: FOLDERS_PER_PAGE,
limit: FOLDERS_PER_PAGE,
page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1
)
@@ -23,7 +25,8 @@ class TemplatesDashboardController < ApplicationController
@templates = @templates.none
else
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME }
@templates = filter_templates(@templates)
@templates = filter_templates(@templates).preload(:author, :template_accesses)
@templates = Templates::Order.call(@templates, current_user, cookies.permanent[:dashboard_templates_order])
limit =
if @template_folders.size < 4
@@ -32,26 +35,84 @@ class TemplatesDashboardController < ApplicationController
(@template_folders.size < 7 ? 9 : 6)
end
@pagy, @templates = pagy(@templates, limit:)
@pagy, @templates = pagy_auto(@templates, limit:)
load_related_submissions if params[:q].present? && @templates.blank?
end
end
private
def filter_templates(templates)
rel = templates.active.preload(:author, :template_accesses).order(id: :desc)
rel = templates.active
if params[:q].blank?
if Docuseal.multitenant? && !current_account.testing?
rel = rel.where(folder_id: current_account.default_template_folder.id)
else
shared_template_ids =
TemplateSharing.where(account_id: [current_account.id, TemplateSharing::ALL_ID]).select(:template_id)
if Docuseal.multitenant? ? current_account.testing? : current_account.linked_account_account
shared_account_ids = [current_user.account_id]
shared_account_ids << TemplateSharing::ALL_ID if !Docuseal.multitenant? && !current_account.testing?
rel = rel.where(folder_id: current_account.default_template_folder.id).or(rel.where(id: shared_template_ids))
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(shared_template_ids.arel)
)
)
else
rel = rel.where(folder_id: current_account.default_template_folder.id)
end
end
Templates.search(rel, params[:q])
Templates.search(current_user, rel, params[:q])
end
def sort_template_folders(template_folders, current_user, order)
case order
when 'used_at'
subquery =
Template.left_joins(:submissions)
.group(:folder_id)
.where(account_id: current_user.account_id)
.select(
:folder_id,
Template.arel_table[:updated_at].maximum.as('updated_at_max'),
Submission.arel_table[:created_at].maximum.as('submission_created_at_max')
)
template_folders = template_folders.joins(
Template.arel_table
.join(subquery.arel.as('templates'), Arel::Nodes::OuterJoin)
.on(TemplateFolder.arel_table[:id].eq(Template.arel_table[:folder_id]))
.join_sources
)
template_folders.order(
Arel::Nodes::Case.new
.when(Template.arel_table[:submission_created_at_max].gt(Template.arel_table[:updated_at_max]))
.then(Template.arel_table[:submission_created_at_max])
.else(Template.arel_table[:updated_at_max])
.desc
)
when 'name'
template_folders.order(name: :asc)
else
template_folders.order(id: :desc)
end
end
def load_related_submissions
@related_submissions = Submission.accessible_by(current_ability)
.left_joins(:template)
.where(archived_at: nil)
.where(templates: { archived_at: nil })
.preload(:template_accesses, :created_by_user,
template: :author,
submitters: :start_form_submission_events)
@related_submissions = Submissions.search(current_user, @related_submissions, params[:q])
.order(id: :desc)
@related_submissions_pagy, @related_submissions = pagy_auto(@related_submissions, limit: 5)
end
end
@@ -13,7 +13,7 @@ class TemplatesFormPreviewController < ApplicationController
@submitter.submission.submitters = @template.submitters.map { |item| Submitter.new(uuid: item['uuid']) }
Submitters.preload_with_pages(@submitter)
Submissions.preload_with_pages(@submitter.submission)
@attachments_index = ActiveStorage::Attachment.where(record: @submitter.submission.submitters, name: :attachments)
.preload(:blob).index_by(&:uuid)
@@ -24,14 +24,22 @@ class TemplatesPreferencesController < ApplicationController
documents_copy_email_enabled documents_copy_email_attach_audit
documents_copy_email_attach_documents documents_copy_email_reply_to
completed_notification_email_attach_documents
completed_redirect_url
submitters_order
completed_redirect_url validate_unique_submitters
require_all_submitters submitters_order require_phone_2fa
default_expire_at_duration
default_expire_at
completed_notification_email_subject completed_notification_email_body
completed_notification_email_enabled completed_notification_email_attach_audit] +
[completed_message: %i[title body],
submitters: [%i[uuid request_email_subject request_email_body]]]
submitters: [%i[uuid request_email_subject request_email_body]], link_form_fields: []]
).tap do |attrs|
attrs[:preferences].delete(:submitters) if params[:request_email_per_submitter] != '1'
if (default_expire_at = attrs.dig(:preferences, :default_expire_at).presence)
attrs[:preferences][:default_expire_at] =
(ActiveSupport::TimeZone[current_account.timezone] || Time.zone).parse(default_expire_at).utc
end
attrs[:preferences] = attrs[:preferences].transform_values do |value|
if %w[true false].include?(value)
value == 'true'
@@ -0,0 +1,25 @@
# frozen_string_literal: true
class TemplatesShareLinkController < ApplicationController
load_and_authorize_resource :template
def show; end
def create
authorize!(:update, @template)
@template.update!(template_params)
if params[:redir].present?
redirect_to params[:redir]
else
head :ok
end
end
private
def template_params
params.require(:template).permit(:shared_link)
end
end
@@ -25,6 +25,8 @@ class TemplatesUploadsController < ApplicationController
enqueue_template_created_webhooks(@template)
SearchEntries.enqueue_reindex(@template)
redirect_to edit_template_path(@template)
rescue Templates::CreateAttachments::PdfEncrypted
render turbo_stream: turbo_stream.append(params[:form_id], html: helpers.tag.prompt_password)
+3 -1
View File
@@ -5,7 +5,8 @@ class UserConfigsController < ApplicationController
authorize_resource :user_config
ALLOWED_KEYS = [
UserConfig::RECEIVE_COMPLETED_EMAIL
UserConfig::RECEIVE_COMPLETED_EMAIL,
UserConfig::SHOW_APP_TOUR
].freeze
InvalidKey = Class.new(StandardError)
@@ -28,6 +29,7 @@ class UserConfigsController < ApplicationController
def user_config_params
params.required(:user_config).permit(:key, :value, { value: {} }, { value: [] }).tap do |attrs|
attrs[:value] = attrs[:value] == '1' if attrs[:value].in?(%w[1 0])
attrs[:value] = attrs[:value] == 'true' if attrs[:value].in?(%w[true false])
end
end
end
+1 -1
View File
@@ -16,7 +16,7 @@ class UsersController < ApplicationController
@users.active.where.not(role: 'integration')
end
@pagy, @users = pagy(@users.where(account: current_account).order(id: :desc))
@pagy, @users = pagy(@users.preload(account: :account_accesses).where(account: current_account).order(id: :desc))
end
def new; end
+11 -3
View File
@@ -24,6 +24,7 @@ import SubmitForm from './elements/submit_form'
import PromptPassword from './elements/prompt_password'
import EmailsTextarea from './elements/emails_textarea'
import ToggleOnSubmit from './elements/toggle_on_submit'
import CheckOnClick from './elements/check_on_click'
import PasswordInput from './elements/password_input'
import SearchInput from './elements/search_input'
import ToggleAttribute from './elements/toggle_attribute'
@@ -32,11 +33,12 @@ import CheckboxGroup from './elements/checkbox_group'
import MaskedInput from './elements/masked_input'
import SetDateButton from './elements/set_date_button'
import IndeterminateCheckbox from './elements/indeterminate_checkbox'
import AppTour from './elements/app_tour'
import DashboardDropzone from './elements/dashboard_dropzone'
import RequiredCheckboxGroup from './elements/required_checkbox_group'
import * as TurboInstantClick from './lib/turbo_instant_click'
import './images/preview.png'
TurboInstantClick.start()
document.addEventListener('turbo:before-cache', () => {
@@ -101,6 +103,10 @@ safeRegisterElement('checkbox-group', CheckboxGroup)
safeRegisterElement('masked-input', MaskedInput)
safeRegisterElement('set-date-button', SetDateButton)
safeRegisterElement('indeterminate-checkbox', IndeterminateCheckbox)
safeRegisterElement('app-tour', AppTour)
safeRegisterElement('dashboard-dropzone', DashboardDropzone)
safeRegisterElement('check-on-click', CheckOnClick)
safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup)
safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () {
@@ -125,8 +131,10 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withSendButton: this.dataset.withSendButton !== 'false',
withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false',
withConditions: this.dataset.withConditions === 'true',
withReplaceAndCloneUpload: true,
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
acceptFileTypes: this.dataset.acceptFileTypes
acceptFileTypes: this.dataset.acceptFileTypes,
showTourStartForm: this.dataset.showTourStartForm === 'true'
})
this.component = this.app.mount(this.appElem)
+20 -17
View File
@@ -1,5 +1,6 @@
import SignaturePad from 'signature_pad'
import { cropCanvasAndExportToPNG } from './submission_form/crop_canvas'
import { isValidSignatureCanvas } from './submission_form/validate_signature'
window.customElements.define('draw-signature', class extends HTMLElement {
connectedCallback () {
@@ -43,6 +44,8 @@ window.customElements.define('draw-signature', class extends HTMLElement {
return response
})
}).catch(error => {
console.log(error)
}).finally(() => {
this.submitButton.disabled = false
})
@@ -65,26 +68,26 @@ window.customElements.define('draw-signature', class extends HTMLElement {
}
async submitImage () {
return new Promise((resolve, reject) => {
cropCanvasAndExportToPNG(this.canvas, { errorOnTooSmall: true }).then(async (blob) => {
const file = new File([blob], 'signature.png', { type: 'image/png' })
if (!isValidSignatureCanvas(this.pad.toData())) {
alert('Signature is too small or simple. Please redraw.')
const formData = new FormData()
return Promise.reject(new Error('Image too small or simple'))
}
formData.append('file', file)
formData.append('submitter_slug', this.dataset.slug)
formData.append('name', 'attachments')
formData.append('remember_signature', 'true')
return cropCanvasAndExportToPNG(this.canvas).then(async (blob) => {
const file = new File([blob], 'signature.png', { type: 'image/png' })
return fetch('/api/attachments', {
method: 'POST',
body: formData
}).then((resp) => resp.json()).then((attachment) => {
return resolve(attachment)
})
}).catch((error) => {
return reject(error)
})
const formData = new FormData()
formData.append('file', file)
formData.append('submitter_slug', this.dataset.slug)
formData.append('name', 'attachments')
formData.append('remember_signature', 'true')
return fetch('/api/attachments', {
method: 'POST',
body: formData
}).then(resp => resp.json())
})
}
+338
View File
@@ -0,0 +1,338 @@
export default class extends HTMLElement {
async connectedCallback () {
this.tourType = this.dataset.type
this.nextPagePath = this.dataset.nextPagePath
this.I18n = JSON.parse(this.dataset.i18n || '{}')
if (this.dataset.showTour === 'true') this.start()
}
async start () {
if (window.innerWidth < 768) return
const [{ driver }] = await Promise.all([
import('driver.js'),
import('driver.js/dist/driver.css')
])
this.driverObj = driver({
showProgress: true,
nextBtnText: this.I18n.next,
prevBtnText: this.I18n.previous,
doneBtnText: this.I18n.done,
onDestroyStarted: () => {
this.disableAppGuide().finally(() => { this.destroy() })
},
onHighlightStarted: (element) => {
if (element) {
const clickHandler = () => {
this.disableAppGuide().finally(() => { this.destroy() })
element.removeEventListener('click', clickHandler)
}
element.addEventListener('click', clickHandler)
}
}
})
if (this.tourType === 'dashboard') {
this.showDashboardTour()
} else if (this.tourType === 'builder') {
this.showTemplateBuilderTour()
} else if (this.tourType === 'account') {
this.showAccountTour()
} else if (this.tourType === 'template') {
this.showTemplateTour()
}
}
disconnectedCallback () {
if (this.driverObj) this.destroy()
}
destroy () {
if (this.builderTemplate) this.builderTemplate.fields.shift()
if (this.driverObj) this.driverObj.destroy()
}
showTemplateTour () {
const steps = [
{
element: '#share_link_clipboard',
popover: {
title: this.I18n.copy_and_share_link,
description: this.I18n.copy_and_share_link_description,
side: 'bottom',
align: 'end'
}
},
{
element: '#sign_yourself_button',
popover: {
title: this.I18n.sign_the_document,
description: this.I18n.sign_the_document_description,
side: 'top',
align: 'center'
}
},
{
element: '#send_to_recipients_button',
popover: {
title: this.I18n.send_for_signing,
description: this.I18n.add_recipients_description,
side: 'top',
align: 'center'
}
},
{
element: '#add_recipients_button',
popover: {
title: this.I18n.add_recipients,
description: this.I18n.add_recipients_description,
side: 'bottom',
align: 'end'
}
},
{
element: '#account_settings_button',
popover: {
title: this.I18n.settings,
description: this.I18n.settings_template_description,
side: 'right',
align: 'start',
showButtons: this.nextPagePath ? ['next', 'previous', 'close'] : ['previous', 'close'],
onNextClick: () => {
if (this.nextPagePath) {
window.Turbo.visit(this.nextPagePath)
}
}
}
}
].filter((step) => document.querySelector(step.element))
this.driverObj.setSteps(steps)
this.driverObj.drive()
}
showDashboardTour () {
this.driverObj.setSteps([
{
element: '#templates_submissions_toggle',
popover: {
title: this.I18n.template_and_submissions,
description: this.I18n.template_and_submissions_description,
side: 'right',
align: 'start'
}
},
{
element: '#templates_upload_button',
popover: {
title: this.I18n.upload_a_pdf_file,
description: this.I18n.upload_a_pdf_file_description,
side: 'left',
align: 'start',
showButtons: this.nextPagePath ? ['next', 'previous', 'close'] : ['previous', 'close'],
onNextClick: () => {
if (this.nextPagePath) {
window.Turbo.visit(this.nextPagePath)
}
}
},
onHighlightStarted: () => {}
}
])
this.driverObj.drive()
}
showAccountTour () {
this.driverObj.setSteps([
{
element: '#account_settings_menu',
popover: {
title: this.I18n.settings,
description: this.I18n.settings_account_description,
side: 'right',
align: 'start'
}
},
{
element: '#support_channels',
popover: {
title: this.I18n.support,
description: this.I18n.support_description,
side: 'left',
align: 'start'
}
}
].filter((step) => document.querySelector(step.element)))
this.driverObj.drive()
}
showTemplateBuilderTour () {
const builderComponent = document.querySelector('template-builder')?.component
this.builderTemplate = builderComponent?.template
if (this.builderTemplate) {
this.builderTemplate.fields.unshift({
uuid: 'b387399b-88dc-4345-9d37-743e97a9b2b3',
submitter_uuid: this.builderTemplate.submitters[0].uuid,
name: 'First Name',
type: 'text'
})
builderComponent.$nextTick(() => {
this.driverObj.setSteps([
{
element: '.roles-dropdown',
popover: {
title: this.I18n.select_a_signer_party,
description: this.I18n.select_a_signer_party_description,
side: 'left',
align: 'start',
onPopoverRender: () => {
const rolesDropdown = document.querySelector('.roles-dropdown')
rolesDropdown.dispatchEvent(new Event('mouseenter', { bubbles: true, cancelable: true }))
rolesDropdown.classList.add('dropdown-open')
}
}
},
{
element: '.roles-dropdown .dropdown-content',
popover: {
title: this.I18n.available_parties,
description: this.I18n.available_parties_description,
side: 'left',
align: 'start',
onPopoverRender: () => {
document.querySelector('.roles-dropdown .dropdown-content').classList.remove('driver-active-element')
},
onNextClick: () => {
document.querySelector('.roles-dropdown').classList.remove('dropdown-open')
this.driverObj.moveNext()
}
}
},
{
element: '#field-types-grid',
popover: {
title: this.I18n.available_field_types,
description: this.I18n.available_field_types_description,
side: 'right',
align: 'start',
onPrevClick: () => {
document.querySelector('.roles-dropdown').classList.add('dropdown-open')
this.driverObj.movePrevious()
}
}
},
{
element: '#text_type_field_button',
popover: {
title: this.I18n.text_input_field,
description: this.I18n.text_input_field_description,
side: 'left',
align: 'start'
}
},
{
element: '#signature_type_field_button',
popover: {
title: this.I18n.signature_field,
description: this.I18n.signature_field_description,
side: 'left',
align: 'start'
}
},
{
element: '.fields',
popover: {
title: this.I18n.added_fields,
description: this.I18n.added_fields_description,
side: 'right',
align: 'start'
}
},
{
element: '.list-field label:has(svg.tabler-icon-settings)',
popover: {
title: this.I18n.open_field_settings,
description: this.I18n.open_field_settings_description,
side: 'bottom',
align: 'end',
onPopoverRender: () => {
const settingsDropdown = document.querySelector('.list-field div:first-child span:has(svg.tabler-icon-settings)')
document.querySelectorAll('.list-field div:first-child .text-transparent').forEach((e) => e.classList.remove('text-transparent'))
settingsDropdown.dispatchEvent(new Event('mouseenter', { bubbles: true, cancelable: true }))
settingsDropdown.classList.add('dropdown-open')
}
}
},
{
element: '.list-field div:first-child span:has(svg.tabler-icon-settings) .dropdown-content',
popover: {
title: this.I18n.field_settings,
description: this.I18n.field_settings_description,
side: 'left',
align: 'start',
onPopoverRender: () => {
document.querySelector('.list-field div:first-child span:has(svg.tabler-icon-settings) .dropdown-content').classList.remove('driver-active-element')
},
onNextClick: () => {
document.querySelector('.list-field div:first-child span:has(svg.tabler-icon-settings)').classList.remove('dropdown-open')
this.driverObj.moveNext()
}
}
},
{
element: '#send_button',
popover: {
title: this.I18n.send_document,
description: this.I18n.send_document_description,
side: 'bottom',
align: 'end',
onPrevClick: () => {
document.querySelector('.list-field div:first-child span:has(svg.tabler-icon-settings)').classList.add('dropdown-open')
this.driverObj.movePrevious()
}
}
},
{
element: '#sign_yourself_button',
popover: {
title: this.I18n.sign_yourself,
description: this.I18n.sign_yourself_description,
side: 'bottom',
align: 'end',
onNextClick: () => {
if (this.nextPagePath) {
window.Turbo.visit(this.nextPagePath)
} else {
this.destroy()
}
}
}
}
])
this.driverObj.drive()
})
}
}
async disableAppGuide () {
return fetch('/user_configs', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ key: 'show_app_tour', value: false })
})
}
}
+14
View File
@@ -0,0 +1,14 @@
export default class extends HTMLElement {
connectedCallback () {
this.addEventListener('click', () => {
if (this.element && !this.element.disabled && !this.element.checked) {
this.element.checked = true
this.element.dispatchEvent(new Event('change', { bubbles: true }))
}
})
}
get element () {
return document.getElementById(this.dataset.elementId)
}
}
@@ -3,8 +3,6 @@ export default class extends HTMLElement {
this.clearChecked()
this.addEventListener('click', (e) => {
e.stopPropagation()
const text = this.dataset.text || this.innerText.trim()
if (navigator.clipboard) {
@@ -0,0 +1,222 @@
import { target, targets, targetable } from '@github/catalyst/lib/targetable'
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 d="M12 3a9 9 0 1 0 9 9" />
</svg>`
export default targetable(class extends HTMLElement {
static [targets.static] = [
'hiddenOnDrag',
'folderCards',
'templateCards'
]
static [target.static] = [
'form',
'fileDropzone',
'fileDropzoneLoading'
]
connectedCallback () {
document.addEventListener('drop', this.onWindowDragdrop)
document.addEventListener('dragover', this.onWindowDropover)
window.addEventListener('dragleave', this.onWindowDragleave)
this.fileDropzone?.addEventListener('drop', this.onDropFile)
this.folderCards.forEach((el) => el.addEventListener('drop', (e) => this.onDropFolder(e, el)))
this.templateCards.forEach((el) => el.addEventListener('drop', this.onDropTemplate))
this.templateCards.forEach((el) => el.addEventListener('dragstart', this.onTemplateDragStart))
return [this.fileDropzone, ...this.folderCards, ...this.templateCards].forEach((el) => {
el?.addEventListener('dragover', this.onDragover)
el?.addEventListener('dragleave', this.onDragleave)
})
}
disconnectedCallback () {
document.removeEventListener('drop', this.onWindowDragdrop)
document.removeEventListener('dragover', this.onWindowDropover)
window.removeEventListener('dragleave', this.onWindowDragleave)
}
onTemplateDragStart = (e) => {
const id = e.target.href.split('/').pop()
e.dataTransfer.effectAllowed = 'move'
if (id) {
e.dataTransfer.setData('template_id', id)
const dragPreview = e.target.cloneNode(true)
const rect = e.target.getBoundingClientRect()
const height = e.target.children[0].getBoundingClientRect().height + 50
dragPreview.children[1].remove()
dragPreview.style.width = `${rect.width}px`
dragPreview.style.height = `${height}px`
dragPreview.style.position = 'absolute'
dragPreview.style.top = '-1000px'
dragPreview.style.pointerEvents = 'none'
dragPreview.style.opacity = '0.9'
document.body.appendChild(dragPreview)
e.dataTransfer.setDragImage(dragPreview, rect.width / 2, height / 2)
setTimeout(() => document.body.removeChild(dragPreview), 0)
}
}
onDropFile = (e) => {
e.preventDefault()
this.fileDropzoneLoading.classList.remove('hidden')
this.fileDropzoneLoading.previousElementSibling.classList.add('hidden')
this.fileDropzoneLoading.classList.add('opacity-50')
this.uploadFiles(e.dataTransfer.files, '/templates_upload')
}
onDropFolder = (e, el) => {
e.preventDefault()
const templateId = e.dataTransfer.getData('template_id')
if (e.dataTransfer.files.length || templateId) {
const loading = document.createElement('div')
const svg = el.querySelector('svg')
loading.innerHTML = loadingIconHtml
loading.children[0].classList.add(...svg.classList)
el.replaceChild(loading.children[0], svg)
el.classList.add('opacity-50')
if (e.dataTransfer.files.length) {
const params = new URLSearchParams({ folder_name: el.innerText }).toString()
this.uploadFiles(e.dataTransfer.files, `/templates_upload?${params}`)
} else {
const formData = new FormData()
formData.append('name', el.innerText)
fetch(`/templates/${templateId}/folder`, {
method: 'PUT',
redirect: 'manual',
body: formData,
headers: {
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
}
}).finally(() => {
window.Turbo.cache.clear()
window.Turbo.visit(location.href)
})
}
}
}
onDropTemplate = (e) => {
e.preventDefault()
if (e.dataTransfer.files.length) {
const loading = document.createElement('div')
loading.classList.add('bottom-5', 'left-0', 'flex', 'justify-center', 'w-full', 'absolute')
loading.innerHTML = loadingIconHtml
e.target.appendChild(loading)
e.target.classList.add('opacity-50')
const id = e.target.href.split('/').pop()
this.uploadFiles(e.dataTransfer.files, `/templates/${id}/clone_and_replace`)
}
}
onWindowDragdrop = (e) => {
e.preventDefault()
if (!this.isLoading) this.hideDraghover()
}
uploadFiles (files, url) {
this.isLoading = true
this.form.action = url
this.form.querySelector('[type="file"]').files = files
this.form.querySelector('[type="submit"]').click()
}
onWindowDropover = (e) => {
e.preventDefault()
if (e.dataTransfer?.types?.includes('Files')) {
this.showDraghover()
}
}
onDragover (e) {
if (e.dataTransfer?.types?.includes('Files') || this.dataset.targets !== 'dashboard-dropzone.templateCards') {
this.style.backgroundColor = '#F7F3F0'
if (this.classList.contains('before:border-base-300')) {
this.classList.remove('before:border-base-300')
this.classList.add('before:border-base-content/30')
} else if (this.classList.contains('border-base-300')) {
this.classList.remove('border-base-300')
this.classList.add('border-base-content/30')
}
}
}
onDragleave () {
this.style.backgroundColor = null
if (this.classList.contains('before:border-base-content/30')) {
this.classList.remove('before:border-base-content/30')
this.classList.add('before:border-base-300')
} else if (this.classList.contains('border-base-content/30')) {
this.classList.remove('border-base-content/30')
this.classList.add('border-base-300')
}
}
onWindowDragleave = (e) => {
if (e.clientX <= 0 || e.clientY <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) {
this.hideDraghover()
}
}
showDraghover = () => {
if (this.isDrag) return
this.isDrag = true
this.fileDropzone?.classList?.remove('hidden')
this.hiddenOnDrag.forEach((el) => { el.style.display = 'none' })
return [...this.folderCards, ...this.templateCards].forEach((el) => {
el.classList.remove('bg-base-200', 'before:hidden')
})
}
hideDraghover = () => {
this.isDrag = false
this.fileDropzone?.classList?.add('hidden')
this.hiddenOnDrag.forEach((el) => { el.style.display = null })
return [...this.folderCards, ...this.templateCards].forEach((el) => {
el.classList.add('bg-base-200', 'before:hidden')
})
}
})
+36
View File
@@ -0,0 +1,36 @@
export default class extends HTMLElement {
connectedCallback () {
this.form.addEventListener('submit', (e) => {
e.preventDefault()
this.submit()
})
if (this.dataset.onload === 'true') {
this.form.querySelector('button').click()
}
}
submit () {
fetch(this.form.action, {
method: this.form.method,
body: new FormData(this.form)
}).then(async (resp) => {
if (!resp.ok) {
try {
const data = JSON.parse(await resp.text())
if (data.error) {
alert(data.error)
}
} catch (err) {
console.error(err)
}
}
})
}
get form () {
return this.querySelector('form')
}
}
+23 -5
View File
@@ -5,19 +5,37 @@ export default actionable(targetable(class extends HTMLElement {
static [target.static] = [
'loading',
'icon',
'input'
'input',
'area'
]
connectedCallback () {
this.addEventListener('drop', this.onDrop)
this.addEventListener('dragover', (e) => e.preventDefault())
this.addEventListener('drop', this.onDrop)
document.addEventListener('turbo:submit-end', this.toggleLoading)
this.area?.addEventListener('dragover', this.onDragover)
this.area?.addEventListener('dragleave', this.onDragleave)
}
disconnectedCallback () {
this.removeEventListener('drop', this.onDrop)
document.removeEventListener('turbo:submit-end', this.toggleLoading)
this.area?.removeEventListener('dragover', this.onDragover)
this.area?.removeEventListener('dragleave', this.onDragleave)
}
onDragover (e) {
if (e.dataTransfer?.types?.includes('Files')) {
this.style.backgroundColor = '#F7F3F0'
this.classList.remove('border-base-300', 'hover:bg-base-200/30')
this.classList.add('border-base-content/30')
}
}
onDragleave () {
this.style.backgroundColor = null
this.classList.remove('border-base-content/30')
this.classList.add('border-base-300', 'hover:bg-base-200/30')
}
onDrop (e) {
@@ -35,7 +53,7 @@ export default actionable(targetable(class extends HTMLElement {
}
toggleLoading = (e) => {
if (e && e.target && !e.target.contains(this)) {
if (e && e.target && (!e.target.contains(this) || !e.detail?.formSubmission?.formElement?.contains(this))) {
return
}
@@ -0,0 +1,17 @@
export default class extends HTMLElement {
connectedCallback () {
this.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
checkbox.addEventListener('change', this.handleChange)
})
}
handleChange = () => {
if (this.checkedCount !== 0) {
this.closest('form')?.requestSubmit()
}
}
get checkedCount () {
return this.querySelectorAll('input[type="checkbox"]:checked').length
}
}
+62
View File
@@ -0,0 +1,62 @@
export default class extends HTMLElement {
connectedCallback () {
this.header = document.querySelector('#signing_form_header')
window.addEventListener('scroll', this.onScroll)
window.addEventListener('resize', this.onResize)
if (!this.isNarrow() && this.isHeaderNotVisible()) {
this.showButtons({ animate: false })
}
}
disconnectedCallback () {
window.removeEventListener('scroll', this.onScroll)
window.removeEventListener('resize', this.onResize)
}
onResize = () => {
if (this.isNarrow()) {
this.hideButtons(true)
} else if (this.isHeaderNotVisible()) {
this.showButtons()
}
}
isNarrow () {
return window.innerWidth < 1230
}
onScroll = () => {
if (this.isHeaderNotVisible() && !this.isNarrow()) {
this.showButtons()
} else {
this.hideButtons()
}
}
isHeaderNotVisible () {
const rect = this.header.getBoundingClientRect()
return rect.bottom <= 0 || rect.top >= window.innerHeight
}
showButtons ({ animate } = { animate: true }) {
if (animate) {
this.classList.add('transition-transform', 'duration-300')
}
this.classList.remove('hidden', '-translate-y-10', 'opacity-0')
this.classList.add('translate-y-0', 'opacity-100')
}
hideButtons () {
this.classList.remove('translate-y-0', 'opacity-100')
this.classList.add('-translate-y-10', 'opacity-0')
setTimeout(() => {
if (this.classList.contains('-translate-y-10')) {
this.classList.add('hidden')
}
}, 300)
}
}
@@ -36,7 +36,9 @@ export default class extends HTMLElement {
}
fetch = (text, resolve) => {
const q = text.split(/[;,\s]+/).pop().trim()
const q = this.dataset.field === 'email'
? text.split(/[;,\s]+/).pop().trim()
: text
if (q) {
const queryParams = new URLSearchParams({ q, field: this.dataset.field })
+1 -1
View File
@@ -5,7 +5,7 @@ export default actionable(class extends HTMLElement {
const elementIds = JSON.parse(this.dataset.elementIds)
elementIds.forEach((elementId) => {
document.getElementById(elementId).classList.toggle('hidden', event.target.value !== elementId)
document.getElementById(elementId).classList.toggle('hidden', (event.target.dataset.toggleId || event.target.value) !== elementId)
})
}
})
+4
View File
@@ -3,11 +3,15 @@ import { createApp, reactive } from 'vue'
import Form from './submission_form/form'
import DownloadButton from './elements/download_button'
import ToggleSubmit from './elements/toggle_submit'
import FetchForm from './elements/fetch_form'
import ScrollButtons from './elements/scroll_buttons'
const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options)
safeRegisterElement('download-button', DownloadButton)
safeRegisterElement('toggle-submit', ToggleSubmit)
safeRegisterElement('fetch-form', FetchForm)
safeRegisterElement('scroll-buttons', ScrollButtons)
safeRegisterElement('submission-form', class extends HTMLElement {
connectedCallback () {
this.appElem = document.createElement('div')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

+17 -8
View File
@@ -23,7 +23,7 @@
</div>
<div
v-if="isActive && withLabel && (!area.option_uuid || !option.value)"
class="absolute -top-7 rounded bg-base-content text-base-100 px-2 text-sm whitespace-nowrap pointer-events-none"
class="absolute -top-7 rounded bg-base-content text-base-100 px-2 text-sm whitespace-nowrap pointer-events-none field-area-active-label"
>
<template v-if="area.option_uuid && !option.value">
{{ optionValue(option) }}
@@ -55,10 +55,12 @@
>
<div
v-else-if="field.type === 'signature' && signature"
class="flex flex-col justify-between h-full overflow-hidden"
class="flex justify-between h-full gap-1 overflow-hidden w-full"
:class="isNarrow && (withSignatureId || field.preferences?.reason_field_uuid) ? 'flex-row' : 'flex-col'"
>
<div
class="flex-grow flex overflow-hidden"
class="flex overflow-hidden"
:class="isNarrow && (withSignatureId || field.preferences?.reason_field_uuid) ? 'w-1/2' : 'flex-grow'"
style="min-height: 50%"
>
<img
@@ -67,8 +69,9 @@
>
</div>
<div
v-if="withSignatureId"
class="w-full mt-1 text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem]"
v-if="withSignatureId || field.preferences?.reason_field_uuid"
class="text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem]"
:class="isNarrow ? 'w-1/2' : 'w-full'"
>
<div class="truncate uppercase">
ID: {{ signature.uuid }}
@@ -183,7 +186,7 @@
v-else
ref="textContainer"
dir="auto"
class="flex items-center px-0.5 w-full"
class="flex px-0.5 w-full"
:class="{ ...alignClasses, ...fontClasses }"
>
<span
@@ -327,13 +330,16 @@ export default {
},
alignClasses () {
if (!this.field.preferences) {
return {}
return { 'items-center': true }
}
return {
'text-center': this.field.preferences.align === 'center',
'text-left': this.field.preferences.align === 'left',
'text-right': this.field.preferences.align === 'right'
'text-right': this.field.preferences.align === 'right',
'items-center': !this.field.preferences.valign || this.field.preferences.valign === 'center',
'items-start': this.field.preferences.valign === 'top',
'items-end': this.field.preferences.valign === 'bottom'
}
},
fontClasses () {
@@ -441,6 +447,9 @@ export default {
}
return style
},
isNarrow () {
return this.area.h > 0 && (this.area.w / this.area.h) > 6
}
},
watch: {
+3 -3
View File
@@ -193,9 +193,9 @@ export default {
})
}
if (window.decline_button) {
window.decline_button.setAttribute('disabled', 'true')
}
document.querySelectorAll('#decline_button').forEach((button) => {
button.setAttribute('disabled', 'true')
})
},
methods: {
sendCopyToEmail () {
+20 -9
View File
@@ -11,7 +11,7 @@
:with-label="!isAnonymousChecboxes && showFieldNames"
:current-step="currentStepFields"
:scroll-padding="scrollPadding"
@focus-step="[saveStep(), goToStep($event, false, true), currentField.type !== 'checkbox' ? isFormVisible = true : '']"
@focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]"
/>
<FieldAreas
:steps="readonlyConditionalFields.map((e) => [e])"
@@ -93,7 +93,7 @@
<div
v-show="isFormVisible"
id="form_container"
class="shadow-md bg-base-100 absolute bottom-0 w-full border-base-200 border p-4 rounded 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="{ 'md:bottom-4': isBreakpointMd }"
:style="{ backgroundColor: backgroundColor }"
>
@@ -1133,7 +1133,7 @@ export default {
parent.style.overflow = 'hidden'
scrollbox.classList.add('h-full', 'overflow-y-auto')
scrollbox.parentNode.classList.add('h-screen', 'overflow-y-auto')
scrollbox.parentNode.classList.add('h-screen', 'h-[100dvh]', 'overflow-y-auto')
scrollbox.parentNode.style.maxHeight = '-webkit-fill-available'
})
}
@@ -1390,6 +1390,7 @@ export default {
if (isLastStep && !emptyRequiredField && !this.inviteSubmitters.length && !this.optionalInviteSubmitters.length) {
formData.append('completed', 'true')
formData.append('timezone', Intl.DateTimeFormat().resolvedOptions().timeZone)
}
let saveStepRequest
@@ -1404,7 +1405,21 @@ export default {
if (response.status === 422 || response.status === 500) {
const data = await response.json()
if (data.error) {
if (data.field_uuid) {
const field = this.fieldsUuidIndex[data.field_uuid]
if (field) {
const step = this.stepFields.findIndex((fields) => fields.includes(field))
if (step !== -1) {
this.goToStep(step, this.autoscrollFields)
this.showFillAllRequiredFields = true
}
}
return Promise.reject(new Error('Required field: ' + data.field_uuid))
} else if (data.error) {
const i18nKey = data.error.replace(/\s+/g, '_').toLowerCase()
alert(this.t(i18nKey) !== i18nKey ? this.t(i18nKey) : data.error)
@@ -1439,11 +1454,7 @@ export default {
this.isSubmittingComplete = false
})
}).catch(error => {
if (error?.message === 'Image too small') {
alert(this.t('signature_is_too_small_please_redraw'))
} else {
console.log(error)
}
console.log(error)
}).finally(() => {
this.isSubmitting = false
this.isSubmittingComplete = false
+129 -18
View File
@@ -93,7 +93,7 @@ const en = {
reupload: 'Reupload',
upload: 'Upload',
files: 'Files',
signature_is_too_small_please_redraw: 'Signature is too small. Please redraw.',
signature_is_too_small_or_simple_please_redraw: 'Signature is too small or simple. Please redraw.',
wait_countdown_seconds: 'Wait {countdown} seconds'
}
@@ -148,6 +148,7 @@ const es = {
sign_now: 'Firmar ahora',
type_here_: 'Escribe aquí...',
optional: 'opcional',
option: 'Opción',
appears_on: 'Aparece en',
page: 'Página',
select_your_option: 'Selecciona tu opción',
@@ -191,7 +192,7 @@ const es = {
reupload: 'Volver a subir',
upload: 'Subir',
files: 'Archivos',
signature_is_too_small_please_redraw: 'La firma es demasiado pequeña. Por favor, dibújala de nuevo.',
signature_is_too_small_or_simple_please_redraw: 'La firma es demasiado pequeña o simple. Por favor, vuelve a dibujarla.',
wait_countdown_seconds: 'Espera {countdown} segundos'
}
@@ -245,6 +246,7 @@ const it = {
sign_now: 'Firma ora',
type_here_: 'Digita qui...',
optional: 'opzionale',
option: 'Opzione',
appears_on: 'Compare su',
page: 'Pagina',
take_photo: 'Scattare una foto',
@@ -265,7 +267,7 @@ const it = {
document_has_been_signed: 'Il documento è stato firmato!',
documents_have_been_signed: 'I documenti sono stati firmati!',
create_a_free_account: 'Crea un Account Gratuito',
powered_by: 'Desarrollado por',
powered_by: 'Fornito da',
please_check_the_box_to_continue: 'Si prega di spuntare la casella per continuare.',
open_source_documents_software: 'software di documenti open source',
verified_phone_number: 'Verifica numero di telefono',
@@ -289,7 +291,7 @@ const it = {
reupload: 'Ricarica',
upload: 'Carica',
files: 'File',
signature_is_too_small_please_redraw: 'La firma è troppo piccola. Ridisegnala per favore.',
signature_is_too_small_or_simple_please_redraw: 'La firma è troppo piccola o semplice. Ridisegnala, per favore.',
wait_countdown_seconds: 'Attendi {countdown} secondi'
}
@@ -343,6 +345,7 @@ const de = {
sign_now: 'Jetzt unterschreiben',
type_here_: 'Hier eingeben...',
optional: 'optional',
option: 'Option',
appears_on: 'Erscheint auf',
page: 'Seite',
take_photo: 'Foto aufnehmen',
@@ -387,14 +390,14 @@ const de = {
reupload: 'Erneut hochladen',
upload: 'Hochladen',
files: 'Dateien',
signature_is_too_small_please_redraw: 'Die Unterschrift ist zu klein. Bitte erneut zeichnen.',
signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte erneut zeichnen.',
wait_countdown_seconds: 'Warte {countdown} Sekunden'
}
const fr = {
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour continuer la vérification de l'identité.",
verif_id: "Vérification de l'ID",
verif_identite: "Vérification de l'identité",
verify_id: "Vérification de l'ID",
identity_verification: "Vérification de l'identité",
complete: 'Terminer',
fill_all_required_fields_to_complete: 'Veuillez remplir tous les champs obligatoires pour compléter',
sign_and_complete: 'Signer et Terminer',
@@ -441,6 +444,7 @@ const fr = {
sign_now: 'Signer maintenant',
type_here_: 'Tapez ici...',
optional: 'facultatif',
option: 'Option',
appears_on: 'Apparaît sur',
page: 'Page',
take_photo: 'Prendre une photo',
@@ -485,7 +489,7 @@ const fr = {
reupload: 'Recharger',
upload: 'Télécharger',
files: 'Fichiers',
signature_is_too_small_please_redraw: 'La signature est trop petite. Veuillez la redessiner.',
signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.',
wait_countdown_seconds: 'Attendez {countdown} secondes'
}
@@ -539,6 +543,7 @@ const pl = {
sign_now: 'Podpisz teraz',
type_here_: 'Wpisz tutaj...',
optional: 'opcjonalny',
option: 'Opcja',
appears_on: 'Pojawia się na',
page: 'Strona',
select_your_option: 'Wybierz swoją opcję',
@@ -583,7 +588,8 @@ const pl = {
reupload: 'Ponowne przesłanie',
upload: 'Przesyłanie',
files: 'Pliki',
signature_is_too_small_please_redraw: 'Podpis jest zbyt mały. Proszę narysować go ponownie.'
signature_is_too_small_or_simple_please_redraw: 'Podpis jest zbyt mały lub zbyt prosty. Proszę narysować go ponownie.',
wait_countdown_seconds: 'Poczekaj {countdown} sekund'
}
const uk = {
@@ -636,6 +642,7 @@ const uk = {
sign_now: 'Підписати зараз',
type_here_: 'Введіть тут',
optional: 'необов’язково',
option: 'Опція',
appears_on: "З'являється на",
page: 'Сторінка',
take_photo: 'Зробити фото',
@@ -658,7 +665,7 @@ const uk = {
create_a_free_account: 'Створити безкоштовний обліковий запис',
powered_by: 'Працює на базі',
please_check_the_box_to_continue: 'Будь ласка, позначте прапорець, щоб продовжити.',
open_source_documents_software: 'відкритий програмний засіб для документів',
open_source_documents_software: 'відкрите програмне забезпечення для документів',
verified_phone_number: 'Підтвердіть номер телефону',
use_international_format: 'Використовуйте міжнародний формат: +1xxx',
six_digits_code: '6-значний код',
@@ -680,7 +687,7 @@ const uk = {
reupload: 'Перезавантажити',
upload: 'Завантажити',
files: 'Файли',
signature_is_too_small_please_redraw: 'Підпис занадто малий. Будь ласка, перемалюйте його.',
signature_is_too_small_or_simple_please_redraw: 'Підпис занадто маленький або надто простий. Будь ласка, перемалюйте.',
wait_countdown_seconds: 'Зачекайте {countdown} секунд'
}
@@ -734,6 +741,7 @@ const cs = {
sign_now: 'Podepsat nyní',
type_here_: 'Zadejte zde',
optional: 'volitelné',
option: 'Možnost',
appears_on: 'Zobrazuje se na',
page: 'Stránka',
select_your_option: 'Vyberte svou volbu',
@@ -778,7 +786,7 @@ const cs = {
reupload: 'Znovu nahrát',
upload: 'Nahrát',
files: 'Soubory',
signature_is_too_small_please_redraw: 'Podpis je příliš malý. Prosím, překreslete ho.',
signature_is_too_small_or_simple_please_redraw: 'Podpis je příliš malý nebo jednoduchý. Nakreslete jej prosím znovu.',
wait_countdown_seconds: 'Počkejte {countdown} sekund'
}
@@ -832,6 +840,7 @@ const pt = {
sign_now: 'Assinar agora',
type_here_: 'Digite aqui',
optional: 'opcional',
option: 'Opção',
appears_on: 'Aparece em',
page: 'Página',
take_photo: 'Tirar foto',
@@ -876,7 +885,7 @@ const pt = {
reupload: 'Reenviar',
upload: 'Carregar',
files: 'Arquivos',
signature_is_too_small_please_redraw: 'A assinatura é muito pequena. Por favor, redesenhe-a.',
signature_is_too_small_or_simple_please_redraw: 'A assinatura é muito pequena ou simples. Por favor, redesenhe.',
wait_countdown_seconds: 'Aguarde {countdown} segundos'
}
@@ -975,7 +984,7 @@ const he = {
reupload: 'העלה שוב',
upload: 'העלאה',
files: 'קבצים',
signature_is_too_small_please_redraw: 'החתימה קטנה מדי. אנא צייר מחדש.',
signature_is_too_small_or_simple_please_redraw: 'החתימה קטנה או פשוטה מדי. אנא חתום מחדש.',
wait_countdown_seconds: 'המתן {countdown} שניות'
}
@@ -1074,7 +1083,7 @@ const nl = {
reupload: 'Opnieuw uploaden',
upload: 'Uploaden',
files: 'Bestanden',
signature_is_too_small_please_redraw: 'De handtekening is te klein. Teken deze opnieuw, alstublieft.',
signature_is_too_small_or_simple_please_redraw: 'De handtekening is te klein of te eenvoudig. Teken opnieuw.',
wait_countdown_seconds: 'Wacht {countdown} seconden'
}
@@ -1097,6 +1106,7 @@ const ar = {
select_a_reason: 'اختر سببًا',
value_is_invalid: 'القيمة غير صالحة',
verification_code_is_invalid: 'رمز التحقق غير صالح',
already_paid: 'تم الدفع بالفعل',
drawn_signature_on_a_touchscreen_device: 'توقيع مرسوم على جهاز بشاشة تعمل باللمس',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'امسح رمز الاستجابة السريعة باستخدام تطبيق الكاميرا لفتح النموذج على الهاتف المحمول ورسم توقيعك',
by_clicking_you_agree_to_the: 'بالنقر فوق "{button}"، أنت توافق على',
@@ -1172,7 +1182,7 @@ const ar = {
reupload: 'إعادة التحميل',
upload: 'تحميل',
files: 'الملفات',
signature_is_too_small_please_redraw: 'التوقيع صغير جدًا. يرجى إعادة الرسم.',
signature_is_too_small_or_simple_please_redraw: 'التوقيع صغير جدًا أو بسيط جدًا. يرجى إعادة رسمه.',
wait_countdown_seconds: 'انتظر {countdown} ثانية'
}
@@ -1198,6 +1208,8 @@ const ko = {
by_clicking_you_agree_to_the: '"{button}"를 클릭함으로써, 다음에 동의하게 됩니다',
electronic_signature_disclosure: '전자 서명 공개',
esignature_disclosure: '전자 서명 공개',
value_is_invalid: '값이 올바르지 않습니다',
verification_code_is_invalid: '인증 코드가 올바르지 않습니다',
already_paid: '이미 지불됨',
text: '텍스트',
signature: '서명',
@@ -1269,10 +1281,109 @@ const ko = {
reupload: '다시 업로드',
upload: '업로드',
files: '파일',
signature_is_too_small_please_redraw: '서명이 너무 작니다. 다시 그려주세요.',
signature_is_too_small_or_simple_please_redraw: '서명이 너무 작거나 단순합니다. 다시 그려주세요.',
wait_countdown_seconds: '{countdown}초 기다리세요'
}
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar, ko }
const ja = {
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
verify_id: '本人確認',
identity_verification: '本人確認',
complete: '完了',
fill_all_required_fields_to_complete: '完了するにはすべての必須項目を入力してください',
sign_and_complete: '署名して完了',
text: 'テキスト',
by_clicking_you_agree_to_the: '"{button}" をクリックすることで、次に同意したことになります:',
electronic_signature_disclosure: '電子署名に関する開示',
esignature_disclosure: '電子署名開示',
signature: '署名',
initials: 'イニシャル',
drawn_signature_on_a_touchscreen_device: 'タッチスクリーンデバイスで描かれた署名',
approved: '承認済み',
reviewed: '確認済み',
other: 'その他',
authored_by_me: '自分が作成',
invite: '招待',
email: 'メール',
approved_by: '承認者',
reviewed_by: '確認者',
authored_by: '作成者',
select_a_reason: '理由を選択',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'QRコードをカメラアプリで読み取り、モバイルでフォームを開いて署名を描いてください',
date: '日付',
number: '数値',
value_is_invalid: '無効な値です',
verification_code_is_invalid: '認証コードが無効です',
already_paid: 'すでに支払い済み',
image: '画像',
pay: '支払う',
take_photo: '写真を撮る',
number_phone_is_invalid: '{number} の電話番号は無効です',
file: 'ファイル',
digitally_signed_by: '電子署名者:',
reason: '理由',
select: '選択',
checkbox: 'チェックボックス',
multiple: '複数選択',
radio: 'ラジオボタン',
cells: 'セル',
stamp: 'スタンプ',
minimize: '最小化',
payment: '支払い',
phone: '電話',
start_now: '今すぐ開始',
continue: '続行',
sign_now: '今すぐ署名',
type_here_: 'ここに入力...',
optional: '任意',
option: 'オプション',
appears_on: '表示ページ',
page: 'ページ',
select_your_option: 'オプションを選択',
complete_hightlighted_checkboxes_and_click: 'ハイライトされたチェックボックスを完了し、クリックしてください',
submit: '送信',
next: '次へ',
click_to_upload: 'クリックしてアップロード',
or_drag_and_drop_files: 'またはファイルをドラッグ&ドロップ',
send_copy_via_email: 'メールでコピーを送信',
download: 'ダウンロード',
clear: 'クリア',
redraw: '描き直す',
draw_initials: 'イニシャルを描く',
type_signature_here: 'ここに署名を入力',
type_initial_here: 'ここにイニシャルを入力',
form_has_been_completed: 'フォームが完了しました!',
document_has_been_signed: 'ドキュメントが署名されました!',
documents_have_been_signed: 'ドキュメントがすべて署名されました!',
create_a_free_account: '無料アカウントを作成',
powered_by: '提供元:',
please_check_the_box_to_continue: '続行するにはボックスにチェックを入れてください。',
open_source_documents_software: 'オープンソースのドキュメントソフトウェア',
verified_phone_number: '電話番号を認証',
use_international_format: '国際形式を使用してください:+1xxx',
six_digits_code: '6桁のコード',
change_phone_number: '電話番号を変更',
sending: '送信中...',
resend_code: 'コードを再送信',
verification_code_has_been_resent: '認証コードがSMSで再送信されました',
please_fill_all_required_fields: 'すべての必須項目を入力してください',
set_today: '今日の日付を設定',
toggle_multiline_text: '複数行テキスト切り替え',
draw_signature: '署名を描く',
type_initial: 'イニシャルを入力',
draw: '描く',
type: '入力',
type_text: 'テキストを入力',
email_has_been_sent: 'メールが送信されました',
processing: '処理中',
pay_with_strip: 'Stripeで支払う',
reupload: '再アップロード',
upload: 'アップロード',
files: 'ファイル',
signature_is_too_small_or_simple_please_redraw: '署名が小さすぎるか単純すぎます。もう一度描いてください。',
wait_countdown_seconds: '{countdown} 秒お待ちください'
}
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar, ko, ja }
export default i18n
@@ -118,11 +118,17 @@
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
class="mx-auto bg-white border border-base-300 rounded max-h-44"
>
<canvas
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
/>
<div class="relative">
<div
v-if="!isDrawInitials"
class="absolute top-0 right-0 left-0 bottom-0"
/>
<canvas
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
/>
</div>
<input
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
id="initials_text_input"
@@ -197,7 +203,7 @@ export default {
emits: ['attached', 'update:model-value', 'start', 'minimize', 'focus'],
data () {
return {
isInitialsStarted: !!this.previousValue,
isInitialsStarted: false,
isUsePreviousValue: true,
isDrawInitials: false,
uploadImageInputKey: Math.random().toString()
@@ -205,13 +211,16 @@ export default {
},
computed: {
computedPreviousValue () {
if (this.isUsePreviousValue) {
if (this.isUsePreviousValue && this.field.required === true) {
return this.previousValue
} else {
return null
}
}
},
created () {
this.isInitialsStarted = !!this.computedPreviousValue
},
async mounted () {
this.$nextTick(() => {
if (this.$refs.canvas) {
@@ -333,8 +342,8 @@ export default {
return Promise.resolve({})
}
return new Promise((resolve) => {
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
return new Promise((resolve, reject) => {
cropCanvasAndExportToPNG(this.$refs.canvas, { errorOnTooSmall: true }).then(async (blob) => {
const file = new File([blob], 'initials.png', { type: 'image/png' })
if (this.dryRun) {
@@ -356,17 +365,34 @@ export default {
formData.append('file', file)
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
formData.append('type', 'initials')
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: formData
}).then((resp) => resp.json()).then((attachment) => {
}).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) {
const data = await resp.json()
return Promise.reject(new Error(data.error))
}
const attachment = await resp.json()
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
return resolve(attachment)
})
}
}).catch((error) => {
if (this.field.required === true) {
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
return reject(error)
} else {
return resolve({})
}
})
})
}
@@ -133,7 +133,7 @@
class="mx-auto bg-white border border-base-300 rounded max-h-44"
>
<FileDropzone
v-if="format === 'upload' && !modelValue"
v-if="format === 'upload' && !modelValue && !computedPreviousValue"
:message="`${t('upload')} ${field.name || t('signature')}`"
:submitter-slug="submitterSlug"
:dry-run="dryRun"
@@ -201,7 +201,7 @@
</div>
</div>
<input
v-if="isTextSignature"
v-if="isTextSignature && !modelValue && !computedPreviousValue"
id="signature_text_input"
ref="textInput"
class="base-input !text-2xl w-full mt-6"
@@ -292,6 +292,7 @@
<script>
import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2, IconQrcode, IconX } from '@tabler/icons-vue'
import { cropCanvasAndExportToPNG } from './crop_canvas'
import { isValidSignatureCanvas } from './validate_signature'
import SignaturePad from 'signature_pad'
import AppearsOn from './appears_on'
import FileDropzone from './dropzone'
@@ -390,7 +391,7 @@ export default {
emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason'],
data () {
return {
isSignatureStarted: !!this.previousValue,
isSignatureStarted: false,
isShowQr: false,
isOtherReason: false,
isUsePreviousValue: true,
@@ -413,7 +414,7 @@ export default {
}
},
computedPreviousValue () {
if (this.isUsePreviousValue) {
if (this.isUsePreviousValue && this.field.required === true) {
return this.previousValue
} else {
return null
@@ -421,6 +422,8 @@ export default {
}
},
created () {
this.isSignatureStarted = !!this.computedPreviousValue
if (this.requireSigningReason) {
this.field.preferences ||= {}
this.field.preferences.reason_field_uuid ||= v4()
@@ -598,6 +601,7 @@ export default {
},
drawImage (event) {
this.remove()
this.clear()
this.isSignatureStarted = true
this.drawOnCanvas(event.target.files[0], this.$refs.canvas)
@@ -680,6 +684,16 @@ export default {
return Promise.resolve({})
}
if (this.isSignatureStarted && this.pad.toData().length > 0 && !isValidSignatureCanvas(this.pad.toData())) {
if (this.field.required === true || this.pad.toData().length > 0) {
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
return Promise.reject(new Error('Image too small or simple'))
} else {
Promise.resolve({})
}
}
return new Promise((resolve, reject) => {
cropCanvasAndExportToPNG(this.$refs.canvas, { errorOnTooSmall: true }).then(async (blob) => {
const file = new File([blob], 'signature.png', { type: 'image/png' })
@@ -704,11 +718,20 @@ export default {
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
formData.append('remember_signature', this.rememberSignature)
formData.append('type', 'signature')
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: formData
}).then((resp) => resp.json()).then((attachment) => {
}).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) {
const data = await resp.json()
return Promise.reject(new Error(data.error))
}
const attachment = await resp.json()
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
@@ -718,10 +741,12 @@ export default {
})
}
}).catch((error) => {
if (error.message === 'Image too small' && this.field.required === false) {
return resolve({})
} else {
if (this.field.required === true) {
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
return reject(error)
} else {
return resolve({})
}
})
})
+4 -2
View File
@@ -151,8 +151,10 @@ export default {
resizeTextarea () {
const textarea = this.$refs.textarea
textarea.style.height = 'auto'
textarea.style.height = Math.min(250, textarea.scrollHeight) + 'px'
if (textarea) {
textarea.style.height = 'auto'
textarea.style.height = Math.min(250, textarea.scrollHeight) + 'px'
}
},
toggleTextArea () {
this.isTextArea = true
@@ -0,0 +1,38 @@
function isValidSignatureCanvas (data) {
if (data.length === 0) return false
const strokes = data.filter(stroke => Array.isArray(stroke.points) && stroke.points.length > 2)
if (strokes.length === 0) return false
let skippedStraightLine = 0
const validStrokes = strokes.filter(stroke => {
const points = stroke.points
const first = points[0]
const last = points[points.length - 1]
const A = last.y - first.y
const B = first.x - last.x
const C = last.x * first.y - first.x * last.y
const lineLength = Math.sqrt(A * A + B * B)
const totalDeviation = points.reduce((sum, p) => {
const distanceToLine = Math.abs(A * p.x + B * p.y + C) / lineLength
return sum + distanceToLine
}, 0)
const avgDeviation = totalDeviation / points.length
if (avgDeviation < 3 && skippedStraightLine < 2) {
skippedStraightLine++
return false
}
return true
})
return validStrokes.length > 0
}
export { isValidSignatureCanvas }
+7 -4
View File
@@ -160,15 +160,15 @@
</div>
<div
ref="touchValueTarget"
class="flex items-center h-full w-full field-area"
class="flex h-full w-full field-area"
dir="auto"
:class="[isValueInput ? 'bg-opacity-50' : 'bg-opacity-80', field.type === 'heading' ? 'bg-gray-50' : bgColors[submitterIndex % bgColors.length], isDefaultValuePresent || isValueInput || (withFieldPlaceholder && field.areas) ? fontClasses : 'justify-center']"
:class="[isValueInput ? 'bg-opacity-50' : 'bg-opacity-80', field.type === 'heading' ? 'bg-gray-50' : bgColors[submitterIndex % bgColors.length], isDefaultValuePresent || isValueInput || (withFieldPlaceholder && field.areas) ? fontClasses : 'justify-center items-center']"
@click="focusValueInput"
>
<span
v-if="field"
class="flex justify-center items-center space-x-1"
:class="{ 'w-full': ['cells', 'checkbox'].includes(field.type), 'h-full': !isValueInput }"
:class="{ 'w-full': ['cells', 'checkbox'].includes(field.type), 'h-full': !isValueInput && !isDefaultValuePresent }"
>
<div
v-if="isDefaultValuePresent || isValueInput || (withFieldPlaceholder && field.areas && field.type !== 'checkbox')"
@@ -403,10 +403,13 @@ export default {
},
fontClasses () {
if (!this.field.preferences) {
return {}
return { 'items-center': true }
}
return {
'items-center': !this.field.preferences.valign || this.field.preferences.valign === 'center',
'items-start': this.field.preferences.valign === 'top',
'items-end': this.field.preferences.valign === 'bottom',
'justify-center': this.field.preferences.align === 'center',
'justify-start': this.field.preferences.align === 'left',
'justify-end': this.field.preferences.align === 'right',
+226 -64
View File
@@ -1,9 +1,30 @@
<template>
<div
ref="dragContainer"
style="max-width: 1600px"
class="mx-auto pl-3 h-full"
:class="isMobile ? 'pl-4' : 'md:pl-4'"
@dragover="onDragover"
@drop="isDragFile = false"
>
<HoverDropzone
v-if="sortedDocuments.length && withUploadButton && editable"
:is-dragging="isDragFile"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
:with-replace-and-clone="withReplaceAndCloneUpload"
@add="[updateFromUpload($event), isDragFile = false]"
@replace="[onDocumentsReplace($event), isDragFile = false]"
@replace-and-clone="onDocumentsReplaceAndTemplateClone($event)"
@error="[onUploadFailed($event), isDragFile = false]"
/>
<DragPlaceholder
ref="dragPlaceholder"
:field="fieldsDragFieldRef.value || toRaw(dragField)"
:is-field="template.fields.includes(fieldsDragFieldRef.value)"
:is-default="defaultFields.includes(toRaw(dragField))"
:is-required="defaultRequiredFields.includes(toRaw(dragField))"
/>
<div
v-if="pendingFieldAttachmentUuids.length && editable"
class="top-1.5 sticky h-0 z-20 max-w-2xl mx-auto"
@@ -58,12 +79,52 @@
name="buttons"
/>
<template v-else>
<form
v-if="withSignYourselfButton && template.submitters.length < 2"
target="_blank"
data-turbo="false"
class="inline"
method="post"
:action="`/d/${template.slug}`"
@submit="maybeShowErrorTemplateAlert"
>
<input
type="hidden"
name="_method"
value="put"
autocomplete="off"
>
<input
type="hidden"
name="authenticity_token"
:value="authenticityToken"
autocomplete="off"
>
<input
type="hidden"
name="selfsign"
value="true"
autocomplete="off"
>
<button
class="btn btn-primary btn-ghost text-base hidden md:flex"
type="submit"
>
<IconWritingSign
width="22"
class="inline"
/>
<span class="hidden md:inline">
{{ t('sign_yourself') }}
</span>
</button>
</form>
<a
v-if="withSignYourselfButton"
:href="template.submitters.length > 1 ? `/templates/${template.id}/submissions/new?selfsign=true` : `/d/${template.slug}`"
v-else-if="withSignYourselfButton"
id="sign_yourself_button"
:href="`/templates/${template.id}/submissions/new?selfsign=true`"
class="btn btn-primary btn-ghost text-base hidden md:flex"
:target="template.submitters.length > 1 ? '' : '_blank'"
:data-turbo-frame="template.submitters.length > 1 ? 'modal' : ''"
data-turbo-frame="modal"
@click="maybeShowErrorTemplateAlert"
>
<IconWritingSign
@@ -76,6 +137,7 @@
</a>
<a
v-if="withSendButton"
id="send_button"
:href="`/templates/${template.id}/submissions/new?with_link=true`"
data-turbo-frame="modal"
class="white-button md:!px-6"
@@ -91,6 +153,7 @@
</a>
<span
v-if="editable"
id="save_button_container"
class="flex"
>
<button
@@ -269,6 +332,7 @@
:allow-draw="!onlyDefinedFields || drawField"
:data-document-uuid="document.uuid"
:default-submitters="defaultSubmitters"
:drag-field-placeholder="fieldsDragFieldRef.value || dragField"
:with-field-placeholder="withFieldPlaceholder"
:draw-field="drawField"
:draw-field-type="drawFieldType"
@@ -374,12 +438,14 @@
:with-sticky-submitters="withStickySubmitters"
:only-defined-fields="onlyDefinedFields"
:editable="editable"
:show-tour-start-form="showTourStartForm"
@add-field="addField"
@set-draw="[drawField = $event.field, drawOption = $event.option]"
@set-draw-type="[drawFieldType = $event, showDrawField = true]"
@set-drag="dragField = $event"
@set-drag-placeholder="$refs.dragPlaceholder.dragPlaceholder = $event"
@change-submitter="selectedSubmitter = $event"
@drag-end="dragField = null"
@drag-end="[dragField = null, $refs.dragPlaceholder.dragPlaceholder = null]"
@scroll-to-area="scrollToArea"
/>
</div>
@@ -418,6 +484,8 @@
<script>
import Upload from './upload'
import Dropzone from './dropzone'
import HoverDropzone from './hover_dropzone'
import DragPlaceholder from './drag_placeholder'
import Fields from './fields'
import MobileDrawField from './mobile_draw_field'
import Document from './document'
@@ -429,13 +497,14 @@ import MobileFields from './mobile_fields'
import FieldSubmitter from './field_submitter'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments } from '@tabler/icons-vue'
import { v4 } from 'uuid'
import { ref, computed } from 'vue'
import { ref, computed, toRaw } from 'vue'
import * as i18n from './i18n'
export default {
name: 'TemplateBuilder',
components: {
Upload,
DragPlaceholder,
Document,
Fields,
IconInfoCircle,
@@ -445,6 +514,7 @@ export default {
MobileFields,
Logo,
Dropzone,
HoverDropzone,
DocumentPreview,
DocumentControls,
IconInnerShadowTop,
@@ -460,6 +530,7 @@ export default {
template: this.template,
save: this.save,
t: this.t,
assignDropAreaSize: this.assignDropAreaSize,
currencies: this.currencies,
locale: this.locale,
baseFetch: this.baseFetch,
@@ -647,6 +718,11 @@ export default {
required: false,
default: true
},
withReplaceAndCloneUpload: {
type: Boolean,
required: false,
default: false
},
withPhone: {
type: Boolean,
required: false,
@@ -686,6 +762,11 @@ export default {
type: Object,
required: false,
default: () => ({ headers: {} })
},
showTourStartForm: {
type: Boolean,
required: false,
default: false
}
},
data () {
@@ -701,7 +782,8 @@ export default {
copiedArea: null,
drawFieldType: null,
drawOption: null,
dragField: null
dragField: null,
isDragFile: false
}
},
computed: {
@@ -813,6 +895,7 @@ export default {
window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('resize', this.onWindowResize)
window.addEventListener('dragleave', this.onWindowDragLeave)
this.$nextTick(() => {
if (document.location.search?.includes('stripe_connect_success')) {
@@ -831,11 +914,30 @@ export default {
window.removeEventListener('keydown', this.onKeyDown)
window.removeEventListener('resize', this.onWindowResize)
window.removeEventListener('dragleave', this.onWindowDragLeave)
},
beforeUpdate () {
this.documentRefs = []
},
methods: {
toRaw,
onDragover (e) {
if (this.$refs.dragPlaceholder?.dragPlaceholder) {
this.$refs.dragPlaceholder.isMask = e.target.id === 'mask'
const ref = this.$refs.dragPlaceholder.dragPlaceholder
ref.x = e.clientX - ref.offsetX
ref.y = e.clientY - ref.offsetY
} else if (e.dataTransfer?.types?.includes('Files')) {
this.isDragFile = true
}
},
onWindowDragLeave (event) {
if (event.clientX <= 0 || event.clientY <= 0 || event.clientX >= window.innerWidth || event.clientY >= window.innerHeight) {
this.isDragFile = false
}
},
reorderFields (item) {
const itemFields = []
const fields = []
@@ -950,6 +1052,12 @@ export default {
field.readonly = true
}
if (type === 'datenow') {
field.type = 'date'
field.readonly = true
field.default_value = '{{date}}'
}
if (type === 'date') {
field.preferences = {
format: this.defaultDateFormat
@@ -1216,7 +1324,7 @@ export default {
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
}
},
onDraw (area) {
onDraw ({ area, isTooSmall }) {
if (this.drawField) {
if (this.drawOption) {
const areaWithoutOption = this.drawField.areas?.find((a) => !a.option_uuid)
@@ -1311,7 +1419,7 @@ export default {
area.y -= area.h / 2
}
if (area.w) {
if (area.w && (type !== 'checkbox' || this.drawFieldType || !isTooSmall)) {
this.addField(type, area)
this.selectedAreaRef.value = area
@@ -1319,6 +1427,10 @@ export default {
}
},
onDropfield (area) {
if (this.$refs.dragPlaceholder) {
this.$refs.dragPlaceholder.dragPlaceholder = null
}
if (!this.editable) {
return
}
@@ -1331,6 +1443,10 @@ export default {
...this.dragField
}
if (!field.type) {
field.type = 'text'
}
if (!this.fieldsDragFieldRef.value) {
if (['select', 'multiple', 'radio'].includes(field.type)) {
if (this.dragField?.options?.length) {
@@ -1340,6 +1456,12 @@ export default {
}
}
if (field.type === 'datenow') {
field.type = 'date'
field.readonly = true
field.default_value = '{{date}}'
}
if (['stamp', 'heading'].includes(field.type)) {
field.readonly = true
}
@@ -1357,70 +1479,18 @@ export default {
attachment_uuid: area.attachment_uuid
}
const previousField = [...this.template.fields].reverse().find((f) => f.type === field.type)
let baseArea
if (this.selectedField?.type === field.type) {
baseArea = this.selectedAreaRef.value
} else if (previousField?.areas?.length) {
baseArea = previousField.areas[previousField.areas.length - 1]
} else {
if (['checkbox'].includes(field.type)) {
baseArea = {
w: area.maskW / 30 / area.maskW,
h: area.maskW / 30 / area.maskW * (area.maskW / area.maskH)
}
} else if (field.type === 'image') {
baseArea = {
w: area.maskW / 5 / area.maskW,
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH)
}
} else if (field.type === 'signature' || field.type === 'stamp' || field.type === 'verification') {
baseArea = {
w: area.maskW / 5 / area.maskW,
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) / 2
}
} else if (field.type === 'initials') {
baseArea = {
w: area.maskW / 10 / area.maskW,
h: area.maskW / 35 / area.maskW
}
} else {
baseArea = {
w: area.maskW / 5 / area.maskW,
h: area.maskW / 35 / area.maskW
}
}
}
fieldArea.w = baseArea.w
fieldArea.h = baseArea.h
fieldArea.y = fieldArea.y - baseArea.h / 2
if (field.type === 'cells') {
fieldArea.cell_w = baseArea.cell_w || (baseArea.w / 5)
}
field.areas ||= []
const lastArea = field.areas[field.areas.length - 1]
if (lastArea) {
fieldArea.w = lastArea.w
fieldArea.h = lastArea.h
}
this.assignDropAreaSize(fieldArea, field, area)
if (field.width) {
fieldArea.w = field.width / area.maskW
delete field.width
}
if (field.height) {
fieldArea.h = field.height / area.maskH
delete field.height
}
field.areas ||= []
field.areas.push(fieldArea)
this.selectedAreaRef.value = fieldArea
@@ -1444,6 +1514,72 @@ export default {
})
}
},
assignDropAreaSize (fieldArea, field, area) {
const fieldType = field.type || 'text'
const previousField = [...this.template.fields].reverse().find((f) => f.type === fieldType)
let baseArea
if (this.selectedField?.type === fieldType) {
baseArea = this.selectedAreaRef.value
} else if (previousField?.areas?.length) {
baseArea = previousField.areas[previousField.areas.length - 1]
} else {
if (['checkbox'].includes(fieldType)) {
baseArea = {
w: area.maskW / 30 / area.maskW,
h: area.maskW / 30 / area.maskW * (area.maskW / area.maskH)
}
} else if (fieldType === 'image') {
baseArea = {
w: area.maskW / 5 / area.maskW,
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH)
}
} else if (fieldType === 'signature' || fieldType === 'stamp' || fieldType === 'verification') {
baseArea = {
w: area.maskW / 5 / area.maskW,
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) / 2
}
} else if (fieldType === 'initials') {
baseArea = {
w: area.maskW / 10 / area.maskW,
h: area.maskW / 35 / area.maskW
}
} else {
baseArea = {
w: area.maskW / 5 / area.maskW,
h: area.maskW / 35 / area.maskW
}
}
}
fieldArea.w = baseArea.w
fieldArea.h = baseArea.h
if (fieldType === 'cells') {
fieldArea.cell_w = baseArea.cell_w || (baseArea.w / 5)
}
if (field.areas?.length) {
const lastArea = field.areas[field.areas.length - 1]
if (lastArea) {
fieldArea.w = lastArea.w
fieldArea.h = lastArea.h
}
}
if (field.width) {
fieldArea.w = field.width / area.maskW
}
if (field.height) {
fieldArea.h = field.height / area.maskH
}
fieldArea.y = fieldArea.y - fieldArea.h / 2
},
addBlankPage () {
this.isLoadingBlankPage = true
@@ -1473,6 +1609,9 @@ export default {
})
}, 'image/png')
},
onUploadFailed (error) {
if (error) alert(error)
},
updateFromUpload (data) {
this.template.schema.push(...data.schema)
this.template.documents.push(...data.documents)
@@ -1593,6 +1732,29 @@ export default {
this.save()
},
onDocumentsReplace (data) {
data.schema.forEach((schemaItem, index) => {
const existingSchemaItem = this.template.schema[index]
if (this.template.schema[index]) {
this.onDocumentReplace({
replaceSchemaItem: existingSchemaItem,
schema: [schemaItem],
documents: [data.documents.find((doc) => doc.uuid === schemaItem.attachment_uuid)]
})
} else {
this.updateFromUpload({
schema: [schemaItem],
documents: [data.documents.find((doc) => doc.uuid === schemaItem.attachment_uuid)],
fields: data.fields,
submitters: data.submitters
})
}
})
},
onDocumentsReplaceAndTemplateClone (template) {
window.Turbo.visit(`/templates/${template.id}/edit`)
},
moveDocument (item, direction) {
const currentIndex = this.template.schema.indexOf(item)
@@ -175,7 +175,7 @@ export default {
fields () {
if (this.item.submitter_uuid) {
return this.template.fields.reduce((acc, f) => {
if (f !== this.item) {
if (f !== this.item && (!f.conditions?.length || !f.conditions.find((c) => c.field_uuid === this.item.uuid))) {
acc.push(f)
}
+7 -1
View File
@@ -13,6 +13,7 @@
:is-drag="isDrag"
:with-field-placeholder="withFieldPlaceholder"
:default-fields="defaultFields"
:drag-field-placeholder="dragFieldPlaceholder"
:default-submitters="defaultSubmitters"
:draw-field="drawField"
:draw-field-type="drawFieldType"
@@ -22,7 +23,7 @@
@drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })"
@remove-area="$emit('remove-area', $event)"
@scroll-to="scrollToArea"
@draw="$emit('draw', {...$event, attachment_uuid: document.uuid })"
@draw="$emit('draw', { area: {...$event.area, attachment_uuid: document.uuid }, isTooSmall: $event.isTooSmall })"
/>
</div>
</template>
@@ -39,6 +40,11 @@ export default {
type: Object,
required: true
},
dragFieldPlaceholder: {
type: Object,
required: false,
default: null
},
inputMode: {
type: Boolean,
required: false,
@@ -0,0 +1,115 @@
<template>
<Field
v-if="dragPlaceholder && isField && !isMask && field"
ref="dragPlaceholder"
:style="dragPlaceholderStyle"
:field="field"
:with-options="false"
class="fixed z-20 pointer-events-none"
:editable="false"
/>
<div
v-else-if="dragPlaceholder && (isDefault || isRequired) && !isMask && field"
ref="dragPlaceholder"
:style="[dragPlaceholderStyle, { backgroundColor: backgroundColor }]"
class="fixed z-20 border border-base-300 rounded group default-field fields-list-item pointer-events-none"
>
<div class="flex items-center justify-between relative cursor-grab">
<div class="flex items-center p-1 space-x-1">
<IconDrag />
<component
:is="fieldIcons[field.type || 'text']"
:stroke-width="1.6"
:width="20"
/>
<span class="block pl-0.5">
{{ field.title || field.name }}
</span>
</div>
<span
v-if="isRequired"
:data-tip="t('required')"
class="text-red-400 text-3xl pr-1.5 tooltip tooltip-left h-8"
>
*
</span>
</div>
</div>
<button
v-else-if="dragPlaceholder && !isMask && field"
ref="dragPlaceholder"
class="fixed field-type-button z-20 flex items-center justify-center border border-dashed w-full rounded border-base-content/20 opacity-90 pointer-events-none"
:style="[dragPlaceholderStyle, { backgroundColor }]"
>
<div
class="flex items-console cursor-grab h-full absolute left-0 bg-base-200/50"
>
<IconDrag class="my-auto" />
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="fieldIcons[field.type || 'text']" />
<span class="text-xs mt-1">
{{ fieldNames[field.type || 'text'] }}
</span>
</div>
</button>
</template>
<script>
import Field from './field'
import IconDrag from './icon_drag'
import FieldType from './field_type'
export default {
name: 'DragPlaceholder',
components: {
Field,
IconDrag
},
inject: ['t', 'backgroundColor'],
props: {
field: {
type: Object,
required: false,
default: null
},
isDefault: {
type: Boolean,
required: false,
default: false
},
isRequired: {
type: Boolean,
required: false,
default: false
},
isField: {
type: Boolean,
required: false,
default: false
}
},
data () {
return {
isMask: false,
dragPlaceholder: null
}
},
computed: {
dragPlaceholderStyle () {
if (this.dragPlaceholder) {
return {
left: this.dragPlaceholder.x + 'px',
top: this.dragPlaceholder.y + 'px',
width: this.dragPlaceholder.w + 'px',
height: this.dragPlaceholder.h + 'px'
}
} else {
return {}
}
},
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons
}
}
</script>
+61 -15
View File
@@ -2,34 +2,42 @@
<div
class="flex h-60 w-full"
@dragover.prevent
@dragenter="isDragEntering = true"
@dragleave="isDragEntering = false"
@drop.prevent="onDropFiles"
>
<label
id="document_dropzone"
class="w-full relative hover:bg-base-200/30 rounded-md border border-2 border-base-content/10 border-dashed"
class="w-full relative rounded-md border-2 border-base-content/10 border-dashed"
:for="inputId"
:class="{ 'opacity-50': isLoading || isProcessing }"
:class="{ 'opacity-50': isLoading, 'hover:bg-base-200/50': withHoverClass && !isDragEntering, 'bg-base-200/50 border-base-content/30': isDragEntering }"
>
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center pointer-events-none">
<div class="flex flex-col items-center">
<IconInnerShadowTop
v-if="isLoading || isProcessing"
v-if="isLoading"
class="animate-spin"
:width="40"
:height="40"
/>
<IconCloudUpload
<component
:is="icon"
v-else
class="stroke-[1.5px]"
:width="40"
:height="40"
/>
<div
v-if="message"
class="font-medium text-lg mb-1"
class="text-lg mb-1"
:class="{ 'mt-1': !withDescription, 'font-medium': withDescription }"
>
{{ message }}
</div>
<div class="text-sm">
<div
v-if="withDescription"
class="text-sm"
>
<span class="font-medium">{{ t('click_to_upload') }}</span> {{ t('or_drag_and_drop_files') }}
</div>
</div>
@@ -54,13 +62,16 @@
<script>
import Upload from './upload'
import { IconCloudUpload, IconInnerShadowTop } from '@tabler/icons-vue'
import { IconCloudUpload, IconFilePlus, IconFileSymlink, IconFiles, IconInnerShadowTop } from '@tabler/icons-vue'
export default {
name: 'FileDropzone',
components: {
IconFilePlus,
IconCloudUpload,
IconInnerShadowTop
IconInnerShadowTop,
IconFileSymlink,
IconFiles
},
inject: ['baseFetch', 't'],
props: {
@@ -68,35 +79,70 @@ export default {
type: [Number, String],
required: true
},
withHoverClass: {
type: Boolean,
required: false,
default: true
},
icon: {
type: String,
required: false,
default: 'IconCloudUpload'
},
cloneTemplateOnUpload: {
type: Boolean,
required: false,
default: false
},
withDescription: {
type: Boolean,
required: false,
default: true
},
title: {
type: String,
required: false,
default: ''
},
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
}
},
emits: ['success'],
emits: ['success', 'error', 'loading'],
data () {
return {
isLoading: false,
isProcessing: false
isDragEntering: false
}
},
computed: {
inputId () {
return 'el' + Math.random().toString(32).split('.')[1]
},
uploadUrl () {
if (this.cloneTemplateOnUpload) {
return `/templates/${this.templateId}/clone_and_replace`
} else {
return `/templates/${this.templateId}/documents`
}
},
message () {
if (this.isLoading) {
return this.t('uploading')
} else if (this.isProcessing) {
return this.t('processing_')
} else if (this.acceptFileTypes === 'image/*, application/pdf') {
return this.t('add_pdf_documents_or_images')
return this.title || this.t('add_pdf_documents_or_images')
} else {
return this.t('add_documents_or_images')
return this.title || this.t('add_documents_or_images')
}
}
},
watch: {
isLoading (value) {
this.$emit('loading', value)
}
},
methods: {
upload: Upload.methods.upload,
onDropFiles (e) {
+7 -2
View File
@@ -3,7 +3,7 @@
class="list-field group mb-2"
>
<div
class="border border-base-300 rounded rounded-tr-none relative group fields-list-item"
class="border border-base-300 rounded relative group fields-list-item"
:style="{ backgroundColor: backgroundColor }"
>
<div class="flex items-center justify-between relative group/contenteditable-container">
@@ -149,7 +149,7 @@
</div>
</div>
<div
v-if="field.options"
v-if="field.options && withOptions"
ref="options"
class="border-t border-base-300 mx-2 pt-2 space-y-1.5"
draggable="true"
@@ -302,6 +302,11 @@ export default {
type: Object,
required: true
},
withOptions: {
type: Boolean,
required: false,
default: true
},
defaultField: {
type: Object,
required: false,
@@ -424,7 +424,7 @@
</a>
</li>
</template>
<li v-if="field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells'].includes(field.type)">
<li v-if="field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells', 'stamp'].includes(field.type)">
<a
href="#"
class="text-sm py-1 px-2"
@@ -30,7 +30,7 @@
v-for="(icon, type) in fieldIconsSorted"
:key="type"
>
<li v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification')">
<li v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification'))">
<a
href="#"
class="text-sm py-1 px-2"
@@ -51,7 +51,7 @@
</template>
<script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1, IconHeading, IconId } from '@tabler/icons-vue'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1, IconHeading, IconId, IconCalendarCheck } from '@tabler/icons-vue'
export default {
name: 'FiledTypeDropdown',
@@ -101,6 +101,7 @@ export default {
signature: this.t('signature'),
initials: this.t('initials'),
date: this.t('date'),
datenow: this.t('date_signed'),
number: this.t('number'),
image: this.t('image'),
file: this.t('file'),
@@ -142,6 +143,7 @@ export default {
signature: IconWritingSign,
initials: IconLetterCaseUpper,
date: IconCalendarEvent,
datenow: IconCalendarCheck,
number: IconSquareNumber1,
image: IconPhoto,
checkbox: IconCheckbox,
@@ -164,7 +166,7 @@ export default {
return acc
}, {})
} else {
return Object.fromEntries(Object.entries(this.fieldIcons).filter(([key]) => key !== 'heading'))
return Object.fromEntries(Object.entries(this.fieldIcons).filter(([key]) => key !== 'heading' && key !== 'datenow'))
}
}
},
+80 -22
View File
@@ -25,11 +25,11 @@
:data-uuid="field.uuid"
:field="field"
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)"
:editable="editable && (!fieldsDragFieldRef.value || fieldsDragFieldRef.value !== field)"
:editable="editable"
:default-field="defaultFieldsIndex[field.name]"
:draggable="editable"
@dragstart="fieldsDragFieldRef.value = field"
@dragend="fieldsDragFieldRef.value = null"
@dragstart="[fieldsDragFieldRef.value = field, removeDragOverlay($event), setDragPlaceholder($event)]"
@dragend="[fieldsDragFieldRef.value = null, $emit('set-drag-placeholder', null)]"
@remove="removeField"
@scroll-to="$emit('scroll-to-area', $event)"
@set-draw="$emit('set-draw', $event)"
@@ -74,8 +74,8 @@
<div
:style="{ backgroundColor }"
draggable="true"
class="border border-base-300 rounded rounded-tr-none relative group mb-2 default-field fields-list-item"
@dragstart="onDragstart({ type: 'text', ...field })"
class="border border-base-300 rounded relative group mb-2 default-field fields-list-item"
@dragstart="onDragstart($event, field)"
@dragend="$emit('drag-end')"
>
<div class="flex items-center justify-between relative cursor-grab">
@@ -104,6 +104,7 @@
</div>
<div
v-if="editable && !onlyDefinedFields"
id="field-types-grid"
class="grid grid-cols-3 gap-1 pb-2 fields-grid"
>
<template
@@ -111,12 +112,13 @@
:key="type"
>
<button
v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification')"
v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification'))"
:id="`${type}_type_field_button`"
draggable="true"
class="field-type-button group flex items-center justify-center border border-dashed w-full rounded relative fields-grid-item"
:style="{ backgroundColor }"
:class="drawFieldType === type ? 'border-base-content/40' : 'border-base-300 hover:border-base-content/20'"
@dragstart="onDragstart({ type: type })"
@dragstart="onDragstart($event, { type: type })"
@dragend="$emit('drag-end')"
@click="['file', 'payment', 'verification'].includes(type) ? $emit('add-field', type) : $emit('set-draw-type', type)"
>
@@ -166,7 +168,7 @@
:data-tip="t('obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more')"
>
<a
href="https://www.docuseal.com/contact"
href="https://www.docuseal.com/qualified-electronic-signature"
target="_blank"
class="opacity-50 flex items-center justify-center border border-dashed border-base-300 w-full rounded relative fields-grid-item"
:style="{ backgroundColor }"
@@ -189,7 +191,7 @@
</template>
</div>
<div
v-if="fields.length < 4 && editable && withHelp"
v-if="fields.length < 4 && editable && withHelp && !showTourStartForm"
class="text-xs p-2 border border-base-200 rounded"
>
<ul class="list-disc list-outside ml-3">
@@ -204,6 +206,23 @@
</li>
</ul>
</div>
<div
v-show="fields.length < 4 && editable && withHelp && showTourStartForm"
class="rounded py-2 px-4 w-full border border-dashed border-base-300"
>
<div class="text-center text-sm">
{{ t('start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document') }}
</div>
<div class="flex justify-center">
<label
for="start_tour_button"
class="btn btn-sm btn-warning w-40 mt-2"
@click="startTour"
>
{{ t('start_tour') }}
</label>
</div>
</div>
</template>
<script>
@@ -290,9 +309,14 @@ export default {
selectedSubmitter: {
type: Object,
required: true
},
showTourStartForm: {
type: Boolean,
required: false,
default: false
}
},
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter'],
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder'],
data () {
return {
defaultFieldsSearch: ''
@@ -323,7 +347,7 @@ export default {
return acc
}, {})
} else {
return Object.fromEntries(Object.entries(this.fieldIcons).filter(([key]) => key !== 'heading'))
return Object.fromEntries(Object.entries(this.fieldIcons).filter(([key]) => key !== 'heading' && key !== 'datenow'))
}
},
submitterFields () {
@@ -343,22 +367,56 @@ export default {
}
},
methods: {
onDragstart (field) {
onDragstart (event, field) {
this.removeDragOverlay(event)
this.setDragPlaceholder(event)
this.$emit('set-drag', field)
},
setDragPlaceholder (event) {
this.$emit('set-drag-placeholder', {
offsetX: event.offsetX,
offsetY: event.offsetY,
x: event.clientX - event.offsetX,
y: event.clientY - event.offsetY,
w: event.currentTarget.clientWidth + 2,
h: event.currentTarget.clientHeight + 2
})
},
removeDragOverlay (event) {
const root = this.$el.getRootNode()
const hiddenEl = document.createElement('div')
hiddenEl.style.width = '1px'
hiddenEl.style.height = '1px'
hiddenEl.style.opacity = '0'
hiddenEl.style.position = 'fixed'
root.querySelector('#docuseal_modal_container').appendChild(hiddenEl)
event.dataTransfer.setDragImage(hiddenEl, 0, 0)
setTimeout(() => { hiddenEl.remove() }, 1000)
},
startTour () {
document.querySelector('app-tour').start()
},
onFieldDragover (e) {
const targetField = e.target.closest('[data-uuid]')
const dragField = this.$refs.fields.querySelector(`[data-uuid="${this.fieldsDragFieldRef.value.uuid}"]`)
if (this.fieldsDragFieldRef.value) {
const targetField = e.target.closest('[data-uuid]')
const dragField = this.$refs.fields.querySelector(`[data-uuid="${this.fieldsDragFieldRef.value.uuid}"]`)
if (dragField && targetField && targetField !== dragField) {
const fields = Array.from(this.$refs.fields.children)
const currentIndex = fields.indexOf(dragField)
const targetIndex = fields.indexOf(targetField)
if (dragField && targetField && targetField !== dragField) {
const fields = Array.from(this.$refs.fields.children)
const currentIndex = fields.indexOf(dragField)
const targetIndex = fields.indexOf(targetField)
if (currentIndex < targetIndex) {
targetField.after(dragField)
} else {
targetField.before(dragField)
if (currentIndex < targetIndex) {
targetField.after(dragField)
} else {
targetField.before(dragField)
}
}
}
},
+44 -8
View File
@@ -57,7 +57,7 @@
<span class="relative">
<select
class="select input-bordered bg-white select-sm text-center pl-2"
style="font-size: 16px; width: 86px; text-align-last: center;"
style="font-size: 16px; line-height: 12px; width: 86px; text-align-last: center;"
@change="$event.target.value ? preferences.font_size = parseInt($event.target.value) : delete preferences.font_size"
>
<option
@@ -77,12 +77,12 @@
</select>
<span
class="border-l pl-1.5 absolute bg-white bottom-0 pointer-events-none text-sm h-5"
style="right: 13px; top: 7px"
style="right: 13px; top: 6px"
>
pt
</span>
</span>
<span>
<span class="flex">
<div
class="join"
style="height: 32px"
@@ -98,7 +98,7 @@
</button>
</div>
</span>
<span>
<span class="flex">
<div
class="join"
style="height: 32px"
@@ -114,6 +114,32 @@
</button>
</div>
</span>
<span class="flex">
<div class="dropdown modal-field-font-dropdown">
<label
tabindex="0"
class="cursor-pointer flex bg-white border input-bordered rounded-md h-8 items-center justify-center px-1"
style="-webkit-appearance: none; -moz-appearance: none;"
>
<component :is="valigns.find((v) => v.value === (preferences.valign || 'center'))?.icon" />
</label>
<div
tabindex="0"
class="dropdown-content p-0 mt-1 block z-10 menu shadow bg-white border border-base-300 rounded-md"
>
<div
v-for="(valign, index) in valigns"
:key="index"
:value="valign.value"
:class="{ 'bg-base-300': preferences.valign == valign.value }"
class="hover:bg-base-300 px-2 py-1.5 cursor-pointer"
@click="[valign.value ? preferences.valign = valign.value : delete preferences.valign, closeDropdown()]"
>
<component :is="valign.icon" />
</div>
</div>
</div>
</span>
<span>
<select
class="input input-bordered bg-white input-sm text-lg rounded-md"
@@ -134,7 +160,7 @@
</div>
<div class="mt-4">
<div
class="flex items-center border border-base-content/20 rounded-xl bg-white px-4 h-16 modal-field-font-preview"
class="flex border border-base-content/20 rounded-xl bg-white px-4 h-16 modal-field-font-preview"
:style="{
color: preferences.color || 'black',
fontSize: (preferences.font_size || 12) + 'pt',
@@ -143,7 +169,7 @@
>
<span
contenteditable="true"
class="outline-none"
class="outline-none whitespace-nowrap truncate"
>
{{ field.default_value || field.name || buildDefaultName(field, template.fields) }}
</span>
@@ -163,7 +189,7 @@
</template>
<script>
import { IconChevronDown, IconBold, IconItalic, IconAlignLeft, IconAlignRight, IconAlignCenter } from '@tabler/icons-vue'
import { IconChevronDown, IconBold, IconItalic, IconAlignLeft, IconAlignRight, IconAlignCenter, IconAlignBoxCenterTop, IconAlignBoxCenterBottom, IconAlignBoxCenterMiddle } from '@tabler/icons-vue'
export default {
name: 'FontModal',
@@ -213,6 +239,13 @@ export default {
{ icon: IconAlignRight, value: 'right' }
]
},
valigns () {
return [
{ icon: IconAlignBoxCenterTop, value: 'top' },
{ icon: IconAlignBoxCenterMiddle, value: 'center' },
{ icon: IconAlignBoxCenterBottom, value: 'bottom' }
]
},
sizes () {
return [...Array(23).keys()].map(i => i + 6)
},
@@ -230,12 +263,15 @@ export default {
'justify-center': this.preferences.align === 'center',
'justify-start': this.preferences.align === 'left',
'justify-end': this.preferences.align === 'right',
'items-center': !this.preferences.valign || this.preferences.valign === 'center',
'items-start': this.preferences.valign === 'top',
'items-end': this.preferences.valign === 'bottom',
'font-bold': ['bold_italic', 'bold'].includes(this.preferences.font_type),
italic: ['bold_italic', 'italic'].includes(this.preferences.font_type)
}
},
keys () {
return ['font_type', 'font_size', 'color', 'align', 'font']
return ['font_type', 'font_size', 'color', 'align', 'valign', 'font']
}
},
created () {
@@ -154,7 +154,7 @@ export default {
computed: {
fields () {
return this.template.fields.reduce((acc, f) => {
if (f !== this.field && f.submitter_uuid === this.field.submitter_uuid && ['number'].includes(f.type) && !f.preferences?.formula) {
if (f !== this.field && ['number'].includes(f.type) && (!f.preferences?.formula || f.submitter_uuid !== this.field.submitter_uuid)) {
acc.push(f)
}
@@ -0,0 +1,89 @@
<template>
<div
v-if="isDragging || isLoading"
class="modal modal-open"
>
<div class="flex flex-col gap-2 p-4 items-center bg-base-100 h-full max-h-[85vh] max-w-6xl rounded-2xl w-full">
<Dropzone
class="flex-1 h-full"
icon="IconFilePlus"
:template-id="templateId"
:accept-file-types="acceptFileTypes"
:with-hover-class="false"
:with-description="false"
:title="t('add_a_new_document')"
type="add_files"
@loading="isLoading = $event"
@success="$emit('add', $event)"
@error="$emit('error', $event)"
/>
<div class="flex-1 flex gap-2 w-full">
<Dropzone
class="flex-1 h-full"
icon="IconFileSymlink"
:template-id="templateId"
:accept-file-types="acceptFileTypes"
:with-hover-class="false"
:with-description="false"
:title="t('replace_existing_document')"
@loading="isLoading = $event"
@success="$emit('replace', $event)"
@error="$emit('error', $event)"
/>
<Dropzone
v-if="withReplaceAndClone"
class="flex-1 h-full"
icon="IconFiles"
:template-id="templateId"
:accept-file-types="acceptFileTypes"
:with-hover-class="false"
:with-description="false"
:clone-template-on-upload="true"
:title="t('clone_and_replace_documents')"
@loading="isLoading = $event"
@success="$emit('replace-and-clone', $event)"
@error="$emit('error', $event)"
/>
</div>
</div>
</div>
</template>
<script>
import Dropzone from './dropzone'
export default {
name: 'HoverDropzone',
components: {
Dropzone
},
inject: ['t'],
props: {
isDragging: {
type: Boolean,
required: true,
default: false
},
templateId: {
type: [Number, String],
required: true
},
withReplaceAndClone: {
type: Boolean,
required: false,
default: true
},
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
}
},
emits: ['add', 'replace', 'replace-and-clone', 'error'],
data () {
return {
isLoading: false
}
}
}
</script>
+42 -6
View File
@@ -1,6 +1,7 @@
const en = {
font: 'Font',
party: 'Party',
date_signed: 'Date Signed',
method: 'Method',
reorder_fields: 'Reorder fields',
verify_id: 'Verify ID',
@@ -63,6 +64,9 @@ const en = {
processing_: 'Processing...',
add_pdf_documents_or_images: 'Add PDF documents or images',
add_documents_or_images: 'Add documents or images',
add_a_new_document: 'Add a new document',
replace_existing_document: 'Replace existing document',
clone_and_replace_documents: 'Clone and replace documents',
required: 'Required',
default_value: 'Default value',
format: 'Format',
@@ -158,10 +162,13 @@ const en = {
some_fields_are_missing_in_the_formula: 'Some fields are missing in the formula.',
learn_more: 'Learn more',
and: 'and',
or: 'or'
or: 'or',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Start a quick tour to learn how to create an send your first document',
start_tour: 'Start Tour'
}
const es = {
date_signed: 'Fecha actual',
fuente: 'Fuente',
party: 'Parte',
method: 'Método',
@@ -223,6 +230,9 @@ const es = {
processing_: 'Procesando...',
add_pdf_documents_or_images: 'Agregar documentos PDF o imágenes',
add_documents_or_images: 'Agregar documentos o imágenes',
add_a_new_document: 'Agregar un nuevo documento',
replace_existing_document: 'Reemplazar documento existente',
clone_and_replace_documents: 'Clonar y reemplazar documentos',
required: 'Requerido',
default_value: 'Valor predeterminado',
format: 'Formato',
@@ -321,10 +331,13 @@ const es = {
some_fields_are_missing_in_the_formula: 'Faltan algunos campos en la fórmula.',
learn_more: 'Aprende más',
and: 'y',
or: 'o'
or: 'o',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Inicia una guía rápida para aprender a crear y enviar tu primer documento.',
start_tour: 'Iniciar guía'
}
const it = {
date_signed: 'Data attuale',
font: 'Carattere',
party: 'Parte',
method: 'Metodo',
@@ -389,6 +402,9 @@ const it = {
processing_: 'Elaborazione...',
add_pdf_documents_or_images: 'Aggiungi documenti PDF o immagini',
add_documents_or_images: 'Aggiungi documenti o immagini',
add_a_new_document: 'Aggiungi un nuovo documento',
replace_existing_document: 'Sostituisci documento esistente',
clone_and_replace_documents: 'Clona e sostituisci documenti',
required: 'Obbligatorio',
default_value: 'Valore predefinito',
format: 'Formato',
@@ -484,10 +500,13 @@ const it = {
some_fields_are_missing_in_the_formula: 'Alcuni campi mancano nella formula.',
learn_more: 'Scopri di più',
and: 'e',
or: 'o'
or: 'o',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Inizia un tour rapido per imparare a creare e inviare il tuo primo documento.',
start_tour: 'Inizia il tour'
}
const pt = {
date_signed: 'Data atual',
fonte: 'Fonte',
party: 'Parte',
method: 'Método',
@@ -549,6 +568,9 @@ const pt = {
processing_: 'Processando...',
add_pdf_documents_or_images: 'Adicionar documentos PDF ou imagens',
add_documents_or_images: 'Adicionar documentos ou imagens',
add_a_new_document: 'Ajouter un nouveau document',
replace_existing_document: 'Substituir documento existente',
clone_and_replace_documents: 'Clonar e substituir documentos',
required: 'Obrigatório',
default_value: 'Valor padrão',
format: 'Formato',
@@ -647,10 +669,13 @@ const pt = {
some_fields_are_missing_in_the_formula: 'Faltam alguns campos na fórmula.',
learn_more: 'Saiba mais',
and: 'e',
or: 'ou'
or: 'ou',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Comece um tour rápido para aprender a criar e enviar seu primeiro documento.',
start_tour: 'Iniciar tour'
}
const fr = {
date_signed: 'Date actuelle',
font: 'Police',
party: 'Partie',
method: 'Méthode',
@@ -711,6 +736,9 @@ const fr = {
processing_: 'Traitement en cours...',
add_pdf_documents_or_images: 'Ajoutez des documents PDF ou des images',
add_documents_or_images: 'Ajoutez des documents ou des images',
add_a_new_document: 'Adicionar um novo documento',
replace_existing_document: 'Remplacer le document existant',
clone_and_replace_documents: 'Cloner et remplacer les documents',
required: 'Requis',
default_value: 'Valeur par défaut',
format: 'Format',
@@ -810,10 +838,13 @@ const fr = {
some_fields_are_missing_in_the_formula: 'Certains champs manquent dans la formule.',
learn_more: 'En savoir plus',
and: 'et',
or: 'ou'
or: 'ou',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Lancez une visite rapide pour apprendre à créer et envoyer votre premier document.',
start_tour: 'Démarrer'
}
const de = {
date_now: 'Akt. Datum',
font: 'Schriftart',
party: 'Partei',
method: 'Verfahren',
@@ -874,6 +905,9 @@ const de = {
processing_: 'Verarbeitung...',
add_pdf_documents_or_images: 'PDF-Dokumente oder Bilder hinzufügen',
add_documents_or_images: 'Dokumente oder Bilder hinzufügen',
add_a_new_document: 'Neues Dokument hinzufügen',
replace_existing_document: 'Vorhandenes Dokument ersetzen',
clone_and_replace_documents: 'Dokumente klonen und ersetzen',
required: 'Erforderlich',
default_value: 'Standardwert',
format: 'Format',
@@ -973,7 +1007,9 @@ const de = {
some_fields_are_missing_in_the_formula: 'Einige Felder fehlen in der Formel.',
learn_more: 'Erfahren Sie mehr',
and: 'und',
or: 'oder'
or: 'oder',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Starte eine kurze Tour, um zu lernen, wie du dein erstes Dokument erstellst und versendest.',
start_tour: 'Starten'
}
export { en, es, it, pt, fr, de }
@@ -406,7 +406,7 @@ export default {
this.mappings.every((m) => m.column_index !== index)
})
if (columnIndex !== -1) {
if (columnIndex !== -1 && this.rows.some((row) => row[columnIndex])) {
this.mappings.push({ uuid: v4(), field_name: field.name, column_index: columnIndex, submitter_uuid: submitter.uuid })
}
})
@@ -54,7 +54,7 @@
v-for="(icon, type) in fieldIconsSorted"
:key="type"
>
<li v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification')">
<li v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification'))">
<a
href="#"
class="text-sm py-1 px-2"
+33 -4
View File
@@ -37,7 +37,7 @@
<FieldArea
v-if="newArea"
:is-draw="true"
:field="{ submitter_uuid: selectedSubmitter.uuid, type: drawField?.type || defaultFieldType }"
:field="{ submitter_uuid: selectedSubmitter.uuid, type: drawField?.type || dragFieldPlaceholder?.type || defaultFieldType }"
:area="newArea"
/>
</div>
@@ -49,7 +49,9 @@
:class="{ 'z-10': !isMobile, 'cursor-grab': isDrag, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
@pointermove="onPointermove"
@pointerdown="onStartDraw"
@dragover.prevent
@dragover.prevent="onDragover"
@dragenter="onDragenter"
@dragleave="newArea = null"
@drop="onDrop"
@pointerup="onPointerup"
/>
@@ -64,12 +66,17 @@ export default {
components: {
FieldArea
},
inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef'],
inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'assignDropAreaSize'],
props: {
image: {
type: Object,
required: true
},
dragFieldPlaceholder: {
type: Object,
required: false,
default: null
},
areas: {
type: Array,
required: false,
@@ -192,7 +199,24 @@ export default {
this.areaRefs.push(el)
}
},
onDragenter (e) {
this.newArea = {}
this.assignDropAreaSize(this.newArea, this.dragFieldPlaceholder, {
maskW: this.$refs.mask.clientWidth,
maskH: this.$refs.mask.clientHeight
})
this.newArea.x = (e.offsetX - 6) / this.$refs.mask.clientWidth
this.newArea.y = e.offsetY / this.$refs.mask.clientHeight - this.newArea.h / 2
},
onDragover (e) {
this.newArea.x = (e.offsetX - 6) / this.$refs.mask.clientWidth
this.newArea.y = e.offsetY / this.$refs.mask.clientHeight - this.newArea.h / 2
},
onDrop (e) {
this.newArea = null
this.$emit('drop-field', {
x: e.offsetX,
y: e.offsetY,
@@ -266,7 +290,12 @@ export default {
area.cell_w = this.newArea.cell_w
}
this.$emit('draw', area)
const dx = Math.abs(e.offsetX - this.$refs.mask.clientWidth * this.newArea.initialX)
const dy = Math.abs(e.offsetY - this.$refs.mask.clientHeight * this.newArea.initialY)
const isTooSmall = dx < 8 && dy < 8
this.$emit('draw', { area, isTooSmall })
}
this.showMask = false
+5 -5
View File
@@ -2,7 +2,7 @@
<label
:for="inputId"
class="btn btn-neutral btn-xs text-white transition-none replace-document-button"
:class="{ 'opacity-100': isLoading || isProcessing }"
:class="{ 'opacity-100': isLoading }"
>
{{ message }}
<form
@@ -41,19 +41,19 @@ export default {
emits: ['success'],
data () {
return {
isLoading: false,
isProcessing: false
isLoading: false
}
},
computed: {
inputId () {
return 'el' + Math.random().toString(32).split('.')[1]
},
uploadUrl () {
return `/templates/${this.templateId}/documents`
},
message () {
if (this.isLoading) {
return this.t('uploading_')
} else if (this.isProcessing) {
return this.t('processing_')
} else {
return this.t('replace')
}
+24 -11
View File
@@ -4,10 +4,10 @@
id="add_document_button"
:for="inputId"
class="btn btn-outline w-full add-document-button"
:class="{ 'btn-disabled': isLoading || isProcessing }"
:class="{ 'btn-disabled': isLoading }"
>
<IconInnerShadowTop
v-if="isLoading || isProcessing"
v-if="isLoading"
width="20"
class="animate-spin"
/>
@@ -18,9 +18,6 @@
<span v-if="isLoading">
{{ t('uploading_') }}
</span>
<span v-else-if="isProcessing">
{{ t('processing_') }}
</span>
<span v-else>
{{ t('add_document') }}
</span>
@@ -63,30 +60,34 @@ export default {
default: 'image/*, application/pdf'
}
},
emits: ['success'],
emits: ['success', 'error'],
data () {
return {
isLoading: false,
isProcessing: false
isLoading: false
}
},
computed: {
inputId () {
return 'el' + Math.random().toString(32).split('.')[1]
},
uploadUrl () {
return `/templates/${this.templateId}/documents`
}
},
methods: {
async upload () {
this.isLoading = true
this.baseFetch(`/templates/${this.templateId}/documents`, {
this.baseFetch(this.uploadUrl, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(this.$refs.form)
}).then((resp) => {
if (resp.ok) {
resp.json().then((data) => {
this.$emit('success', data)
this.$refs.input.value = ''
this.isLoading = false
})
} else if (resp.status === 422) {
resp.json().then((data) => {
@@ -95,21 +96,33 @@ export default {
formData.append('password', prompt(this.t('enter_pdf_password')))
this.baseFetch(`/templates/${this.templateId}/documents`, {
this.baseFetch(this.uploadUrl, {
method: 'POST',
body: formData
}).then(async (resp) => {
if (resp.ok) {
this.$emit('success', await resp.json())
this.$refs.input.value = ''
this.isLoading = false
} else {
alert(this.t('wrong_password'))
this.$emit('error', await resp.json().error)
this.isLoading = false
}
})
} else {
this.$emit('error', data.error)
this.isLoading = false
}
})
} else {
resp.json().then((data) => {
this.$emit('error', data.error)
this.isLoading = false
})
}
}).finally(() => {
}).catch(() => {
this.isLoading = false
})
}
@@ -0,0 +1,21 @@
# frozen_string_literal: true
class GenerateAttachmentPreviewJob
include Sidekiq::Job
InvalidFormat = Class.new(StandardError)
sidekiq_options queue: :images
def perform(params = {})
attachment = ActiveStorage::Attachment.find(params['attachment_id'])
if attachment.content_type == Templates::ProcessDocument::PDF_CONTENT_TYPE
Templates::ProcessDocument.generate_pdf_preview_images(attachment, attachment.download)
elsif attachment.image?
Templates::ProcessDocument.generate_preview_image(attachment, attachment.download)
else
raise InvalidFormat, attachment.id
end
end
end
+1 -1
View File
@@ -7,7 +7,7 @@ class ProcessSubmissionExpiredJob
submission = Submission.find(params['submission_id'])
return if submission.archived_at?
return if submission.template.archived_at?
return if submission.template&.archived_at?
return if submission.submitters.where.not(declined_at: nil).exists?
return unless submission.submitters.exists?(completed_at: nil)
+21 -10
View File
@@ -82,7 +82,7 @@ class ProcessSubmitterCompletionJob
user = submission.created_by_user || submitter.template.author
if submitter.account.users.exists?(id: user.id) && submission.preferences['send_email'] != false &&
submitter.template.preferences['completed_notification_email_enabled'] != false
submitter.template&.preferences&.dig('completed_notification_email_enabled') != false
if submission.submitters.map(&:email).exclude?(user.email) &&
user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false &&
user.role != 'integration'
@@ -94,27 +94,38 @@ class ProcessSubmitterCompletionJob
end
end
to = build_to_addresses(submitter)
maybe_enqueue_copy_emails(submitter)
end
return if to.blank? || submitter.template.preferences['documents_copy_email_enabled'] == false
def maybe_enqueue_copy_emails(submitter)
return if submitter.template&.preferences&.dig('documents_copy_email_enabled') == false
SubmitterMailer.documents_copy_email(submitter, to:).deliver_later!
configs = AccountConfigs.find_or_initialize_for_key(submitter.account,
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
return if configs.value['enabled'] == false
to = submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false }
.sort_by(&:completed_at).select(&:email?).map(&:friendly_name)
return if to.blank?
if configs.value['bcc_recipients'] == true
to.each { |to| SubmitterMailer.documents_copy_email(submitter, to:).deliver_later! }
else
SubmitterMailer.documents_copy_email(submitter, to: to.join(', ')).deliver_later!
end
end
def build_bcc_addresses(submission)
bcc = submission.preferences['bcc_completed'].presence ||
submission.template.preferences['bcc_completed'].presence ||
submission.template&.preferences&.dig('bcc_completed').presence ||
submission.account.account_configs
.find_by(key: AccountConfig::BCC_EMAILS)&.value
bcc.to_s.scan(User::EMAIL_REGEXP)
end
def build_to_addresses(submitter)
submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false }
.sort_by(&:completed_at).select(&:email?).map(&:friendly_name).join(', ')
end
def enqueue_next_submitter_request_notification(submitter)
next_submitter_item =
submitter.submission.template_submitters.find do |e|
@@ -0,0 +1,9 @@
# frozen_string_literal: true
class ReindexAllSearchEntriesJob
include Sidekiq::Job
def perform
SearchEntries.reindex_all
end
end
+13
View File
@@ -0,0 +1,13 @@
# frozen_string_literal: true
class ReindexSearchEntryJob
include Sidekiq::Job
InvalidFormat = Class.new(StandardError)
def perform(params = {})
entry = SearchEntry.find_or_initialize_by(params.slice('record_type', 'record_id'))
SearchEntries.reindex_record(entry.record)
end
end
+7 -3
View File
@@ -20,7 +20,7 @@ class ApplicationMailer < ActionMailer::Base
end
def set_message_metadata
message.instance_variable_set(:@message_metadata, @message_metadata)
message.instance_variable_set(:@message_metadata, @message_metadata || {})
end
def set_message_uuid
@@ -28,10 +28,14 @@ class ApplicationMailer < ActionMailer::Base
end
def assign_message_metadata(tag, record)
@message_metadata = {
@message_metadata = (@message_metadata || {}).merge(
'tag' => tag,
'record_id' => record.id,
'record_type' => record.class.name
}
)
end
def put_metadata(attrs)
@message_metadata = (@message_metadata || {}).merge(attrs)
end
end
+29 -21
View File
@@ -23,11 +23,11 @@ class SubmitterMailer < ApplicationMailer
@body = @email_message&.body.presence ||
template_submitters_index.dig(@submitter.uuid, 'request_email_body').presence ||
@submitter.template.preferences['request_email_body'].presence
@submitter.template&.preferences&.dig('request_email_body').presence
@subject = @email_message&.subject.presence ||
template_submitters_index.dig(@submitter.uuid, 'request_email_subject').presence ||
@submitter.template.preferences['request_email_subject'].presence
@submitter.template&.preferences&.dig('request_email_subject').presence
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY)
@@ -53,6 +53,8 @@ class SubmitterMailer < ApplicationMailer
@submission = submitter.submission
@user = user
template_preferences = @submission.template&.preferences || {}
Submissions::EnsureResultGenerated.call(submitter)
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
@@ -60,15 +62,15 @@ class SubmitterMailer < ApplicationMailer
add_completed_email_attachments!(
submitter,
with_documents: @email_config&.value&.dig('attach_documents') != false &&
@submitter.template.preferences['completed_notification_email_attach_documents'] != false,
template_preferences['completed_notification_email_attach_documents'] != false,
with_audit_log: @email_config&.value&.dig('attach_audit_log') != false &&
@submitter.template.preferences['completed_notification_email_attach_audit'] != false
template_preferences['completed_notification_email_attach_audit'] != false
)
@subject = @submitter.template.preferences['completed_notification_email_subject'].presence
@subject = template_preferences['completed_notification_email_subject'].presence
@subject ||= @email_config.value['subject'] if @email_config
@body = @submitter.template.preferences['completed_notification_email_body'].presence
@body = template_preferences['completed_notification_email_body'].presence
@body ||= @email_config.value['body'] if @email_config
assign_message_metadata('submitter_completed', @submitter)
@@ -97,7 +99,7 @@ class SubmitterMailer < ApplicationMailer
to: user.role == 'integration' ? user.friendly_name.sub(/\+\w+@/, '@') : user.friendly_name,
reply_to: @submitter.friendly_name,
subject: I18n.t(:name_declined_by_submitter,
name: @submission.template.name.truncate(20),
name: (@submission.name || @submission.template.name).truncate(20),
submitter: @submitter.name || @submitter.email || @submitter.phone))
end
end
@@ -107,22 +109,24 @@ class SubmitterMailer < ApplicationMailer
@submitter = submitter
@sig = submitter.signed_id(expires_in: SIGN_TTL, purpose: :download_completed) if sig
template_preferences = @submitter.template&.preferences || {}
Submissions::EnsureResultGenerated.call(@submitter)
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
add_completed_email_attachments!(
submitter,
with_documents: @submitter.template.preferences['documents_copy_email_attach_documents'] != false &&
(@email_config.nil? || @email_config.value['attach_documents'] != false),
with_audit_log: @submitter.template.preferences['documents_copy_email_attach_audit'] != false &&
(@email_config.nil? || @email_config.value['attach_audit_log'] != false)
with_documents: template_preferences['documents_copy_email_attach_documents'] != false &&
(@email_config.nil? || @email_config.value['attach_documents'] != false),
with_audit_log: template_preferences['documents_copy_email_attach_audit'] != false &&
(@email_config.nil? || @email_config.value['attach_audit_log'] != false)
)
@subject = @submitter.template.preferences['documents_copy_email_subject'].presence
@subject = template_preferences['documents_copy_email_subject'].presence
@subject ||= @email_config.value['subject'] if @email_config
@body = @submitter.template.preferences['documents_copy_email_body'].presence
@body = template_preferences['documents_copy_email_body'].presence
@body ||= @email_config.value['body'] if @email_config
assign_message_metadata('submitter_documents_copy', @submitter)
@@ -130,11 +134,7 @@ class SubmitterMailer < ApplicationMailer
I18n.with_locale(@current_account.locale) do
subject =
if @subject.present?
ReplaceEmailVariables.call(@subject, submitter:)
else
I18n.t(:your_document_copy)
end
@subject.present? ? ReplaceEmailVariables.call(@subject, submitter:) : I18n.t(:your_document_copy)
mail(from: from_address_for_submitter(submitter),
to: to || @submitter.friendly_name,
@@ -147,7 +147,7 @@ class SubmitterMailer < ApplicationMailer
def build_submitter_reply_to(submitter, email_config: nil, documents_copy_email: nil)
reply_to = submitter.preferences['reply_to'].presence
reply_to ||= submitter.template.preferences['documents_copy_email_reply_to'].presence if documents_copy_email
reply_to ||= submitter.template&.preferences&.dig('documents_copy_email_reply_to').presence if documents_copy_email
reply_to ||= email_config.value['reply_to'].presence if email_config
if reply_to.blank? && (submitter.submission.created_by_user || submitter.template.author)&.email != submitter.email
@@ -212,7 +212,7 @@ class SubmitterMailer < ApplicationMailer
end
def build_submitter_preferences_index(submitter)
submitter.template.preferences['submitters'].to_a.index_by { |e| e['uuid'] }
submitter.template&.preferences&.dig('submitters').to_a.index_by { |e| e['uuid'] }
end
def add_attachments_with_size_limit(submitter, storage_attachments, current_size, filename_format = nil)
@@ -233,9 +233,17 @@ class SubmitterMailer < ApplicationMailer
def from_address_for_submitter(submitter)
if submitter.submission.source.in?(%w[api embed]) &&
(from_email = AccountConfig.find_by(account: submitter.account, key: 'integration_from_email')&.value.presence)
user = submitter.account.users.find_by(email: from_email)
put_metadata('from_user_id' => user.id)
from_email
else
(submitter.submission.created_by_user || submitter.submission.template.author).friendly_name
user = submitter.submission.created_by_user || submitter.submission.template.author
put_metadata('from_user_id' => user.id)
user.friendly_name
end
end
end

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