Compare commits

...

229 Commits

Author SHA1 Message Date
Pete Matsyburka 3ade45f541 add field draw button if without areas 2023-11-05 16:31:32 +02:00
Pete Matsyburka 17da8bde80 add upgrade button 2023-11-05 16:31:13 +02:00
Pete Matsyburka 59d9a59769 add field default values 2023-11-05 01:41:54 +02:00
Pete Matsyburka 7210badde9 drag and drop fields to order 2023-11-04 14:06:59 +02:00
DocuSeal 21659a73f3 adjust new template form 2023-11-04 01:31:27 +02:00
DocuSeal c0e49ebeea add builder editable prop 2023-11-04 01:19:32 +02:00
DocuSeal 2d24eac4a3 fix completed submissions scope query 2023-11-02 20:32:51 +02:00
DocuSeal 14c3c8181b add template application key 2023-11-02 19:33:01 +02:00
DocuSeal 036f6049a2 Revert "add new signup page"
This reverts commit f7132445a1.
2023-11-02 13:37:57 +02:00
DocuSeal 236e4cb35f fix typo 2023-11-01 21:32:56 +02:00
DocuSeal ee77cb0982 use stronger api token 2023-11-01 15:20:39 +02:00
DocuSeal d474ad05e5 prefill shared link with email 2023-11-01 14:10:01 +02:00
DocuSeal dbf1fa37c5 adjust completed webhook sending 2023-11-01 13:11:42 +02:00
DocuSeal 7444bb8b68 add audit log url to api and webhooks 2023-11-01 12:17:35 +02:00
Alex Turchyn f7132445a1 add new signup page 2023-11-01 02:17:56 +02:00
Alex Turchyn 0127dd738e fix navbar mobile layout 2023-10-30 23:03:52 +02:00
DocuSeal df561a6211 update rubocop 2023-10-30 00:56:44 +02:00
DocuSeal 50d3404f3b force ssl only if host passed 2023-10-29 21:36:30 +02:00
DocuSeal 607fc8d338 allow to permanently remove templates 2023-10-29 21:36:30 +02:00
DocuSeal 8c366dfc38 use account timezone in audit trail 2023-10-29 21:36:30 +02:00
DocuSeal 5ed08fa9ab add sso settings placeholder 2023-10-29 21:36:30 +02:00
DocuSeal ddb557ebef upload template from URL 2023-10-29 21:36:30 +02:00
DocuSeal b48615df3f use handwritten font for typed signature 2023-10-29 21:36:29 +02:00
DocuSeal 86c7b92675 apply previous signature and initials on the next field 2023-10-28 01:56:22 +03:00
DocuSeal 041f362cc4 do not allow to edit phone if already specified 2023-10-28 00:53:16 +03:00
DocuSeal 65f491325e adjust configs loading 2023-10-28 00:42:33 +03:00
DocuSeal 57aef5f5e5 refactor date step 2023-10-26 21:26:08 +03:00
Alex Turchyn e704e6fa04 show edit icon on hover the outer box 2023-10-25 20:09:34 +03:00
DocuSeal 4c99bbb58a add onComplete form callback 2023-10-25 02:06:47 +03:00
DocuSeal c0647eef2c process 15 pages after pdf upload 2023-10-24 23:48:26 +03:00
DocuSeal c619b9be93 move plans link 2023-10-24 23:16:02 +03:00
DocuSeal abab3f9688 do not use libvips pdf on large files 2023-10-24 22:31:09 +03:00
DocuSeal 7b15111e8f use temp file for puma pid 2023-10-24 13:36:50 +03:00
DocuSeal 2c7f38c503 optimize mailer attachments 2023-10-24 12:41:12 +03:00
Alex Turchyn ed80df1372 fix audit trail images generation 2023-10-24 00:46:47 +03:00
DocuSeal 3614d1796c do not split signature pad js in form 2023-10-22 23:26:43 +03:00
DocuSeal ce39494f84 optimize for large files 2023-10-22 22:33:54 +03:00
DocuSeal c639a3f733 do not sign in after reset password if 2fa
https://github.com/devise-two-factor/devise-two-factor#disabling-automatic-login-after-password-resets
thanks Greg Molnar
2023-10-22 13:19:47 +03:00
DocuSeal 74bef133b3 add enqueries checkbox 2023-10-21 15:33:10 +03:00
DocuSeal 33142966dc allow to resubmit form via shared link 2023-10-21 13:06:59 +03:00
DocuSeal 42ec8d5fc9 do not allow to skip required fields 2023-10-21 12:09:50 +03:00
DocuSeal 01d752e811 fix webhook serializer 2023-10-21 12:09:37 +03:00
DocuSeal 99b33d4165 allow to resubmit form via shared link 2023-10-20 22:55:25 +03:00
DocuSeal 11a90d797a add user encrypted configs 2023-10-20 13:16:39 +03:00
DocuSeal 9da7463657 add submission status filters 2023-10-20 00:13:17 +03:00
Alex Turchyn b6d350ebbd fix no error messages when sign in with wrong credentials 2023-10-18 22:56:28 +03:00
DocuSeal 9e87a0373f add application key to submitters 2023-10-18 02:14:44 +03:00
DocuSeal aee55165c5 use start page 2023-10-16 21:21:04 +03:00
DocuSeal 9ddbcae153 fix plans link 2023-10-16 20:19:39 +03:00
DocuSeal 10cd3aa72a smtp ssl verify mode none 2023-10-16 20:19:28 +03:00
DocuSeal 6684a27e2f preserve textarea in the form 2023-10-16 02:04:52 +03:00
DocuSeal b6f7a7dd15 fix initials step 2023-10-16 01:40:17 +03:00
DocuSeal 309e43e18e fix set today date timezone 2023-10-16 01:28:04 +03:00
DocuSeal d5b8640a0e force redraw form steps 2023-10-16 00:51:08 +03:00
DocuSeal 49b9064d9f add ability to toggle textarea 2023-10-16 00:41:14 +03:00
DocuSeal 698031cfc0 add current date button 2023-10-15 22:59:32 +03:00
DocuSeal 6dc21e66e1 return to required fields 2023-10-15 21:41:47 +03:00
DocuSeal 60b32fec94 adjust sign up header buttons 2023-10-15 01:03:29 +03:00
DocuSeal c621166b45 use full name form company name if missing 2023-10-14 22:31:32 +03:00
DocuSeal 8e2da83f9e adjust demo 2023-10-14 21:19:21 +03:00
DocuSeal a39ba5b275 add plausible 2023-10-14 16:00:56 +03:00
DocuSeal edf466a6de add plans link 2023-10-14 15:42:50 +03:00
DocuSeal 3b8bb8c5bb fix api when fields without name 2023-10-13 14:17:28 +03:00
DocuSeal 79f1061353 improve signup form links 2023-10-12 22:59:15 +03:00
Alex Turchyn 745f2160c5 update the wording of emails 2023-10-12 22:42:14 +03:00
DocuSeal 2f0c79444b add fields configs param to submissions api 2023-10-12 22:13:46 +03:00
DocuSeal 69a3782903 add readonly fields via api 2023-10-12 17:23:59 +03:00
DocuSeal 3dd7c47558 refactor result generation 2023-10-11 21:05:42 +03:00
DocuSeal 761bb07ee6 fix migration for mysql 2023-10-11 21:05:29 +03:00
Alex Turchyn 92d7bc65f5 replace 'log in' with 'sign in' 2023-10-11 01:11:36 +03:00
DocuSeal 79e158be43 add more icons 2023-10-08 23:22:27 +03:00
Alex Turchyn 41ee2dba6a increase the maximum number of submitters to 10 2023-10-08 17:05:35 +00:00
DocuSeal 8e1354175f add extra settings 2023-10-08 19:33:01 +03:00
DocuSeal 2474adffc2 fix builder color 2023-10-08 19:33:01 +03:00
DocuSeal b8fd5a77d4 adjust cancan permissions 2023-10-08 19:33:01 +03:00
DocuSeal 18bb57aa99 ensure user role is in the list 2023-10-08 19:29:09 +03:00
Alex Turchyn 17ff193e34 use company name 2023-10-08 18:07:16 +03:00
Alex Turchyn 20ac63e524 remove 'Tell about us' 2023-10-08 16:49:09 +03:00
DocuSeal 518bbe3d1c do not allow to submit archived forms 2023-10-07 14:30:12 +03:00
DocuSeal fa5e3e7163 add allow to skip form prop 2023-10-07 10:23:11 +03:00
DocuSeal 2b8280168e move custom completted button to the top 2023-10-07 09:06:09 +03:00
DocuSeal 82854b8ce8 allow to make checkboxes required 2023-10-07 09:04:02 +03:00
Alex Turchyn 276b4f4a5f update the meeting link 2023-10-07 01:56:32 +03:00
DocuSeal b73a730390 update builder hints 2023-10-07 01:55:41 +03:00
DocuSeal 2e98885f41 make builder usable on mobile 2023-10-06 10:46:25 +03:00
DocuSeal bfece574b9 fix audit trail with document images 2023-10-03 02:14:27 +03:00
DocuSeal 0dd5bbe25a fix erblint 2023-10-01 21:31:29 +03:00
DocuSeal 3975c963c5 make SMTP username and password non required 2023-10-01 21:25:54 +03:00
DocuSeal 1f41807412 add more api endpoints 2023-10-01 16:51:00 +03:00
DocuSeal 872fbbc875 add file dropzone to dashboard 2023-10-01 16:50:42 +03:00
DocuSeal b98a874e20 add with logo builder prop 2023-09-30 15:14:29 +03:00
DocuSeal 50e123c221 generate audit trail for templates without documents 2023-09-30 12:36:18 +03:00
DocuSeal 7f97bfb3bd ensure rollbar required with sidekiq 2023-09-30 11:33:51 +03:00
DocuSeal 784665b549 skip pdf reader errors 2023-09-29 18:50:57 +03:00
DocuSeal c651709e45 make form completted button configurable 2023-09-29 01:25:13 +03:00
DocuSeal 881a2acbfc add undo redo to form builder 2023-09-28 10:13:23 +03:00
DocuSeal c49cb4b0c8 improve phone field 2023-09-27 23:28:33 +03:00
Alex Turchyn 17b8354c40 ensure form link is always sent 2023-09-27 09:42:19 +03:00
Alex Turchyn dc7593c70c fix GitHub CI build 2023-09-24 18:21:18 +03:00
Alex Turchyn d2edb2fb69 minor style fixes 2023-09-24 18:16:55 +03:00
Alex Turchyn 154a30b60b hide smtp settings nav link when configured via env 2023-09-24 17:30:56 +03:00
Alex Turchyn 392c5c1c54 allow to use underscore submitter value keys 2023-09-24 12:06:07 +03:00
Alex Turchyn cad0d0f371 do not send emails to completed submitters 2023-09-24 11:52:56 +03:00
Alex Turchyn cb879738c0 add clear search button 2023-09-24 11:21:50 +03:00
Alex Turchyn abfa64140f hide download button in webview 2023-09-24 11:21:50 +03:00
Alex Turchyn db1e2b9aa2 download submitter default value attachments 2023-09-24 11:21:50 +03:00
Alex Turchyn c1ab4fafb2 select submitter name on edit 2023-09-24 11:21:50 +03:00
Alex Turchyn 7ba3a604bd add textarea emails autocomplete 2023-09-24 11:21:50 +03:00
Alex Turchyn cf85bea0df fix dashboard search 2023-09-24 11:21:50 +03:00
Alex Turchyn 5643094a7a add default user signatures 2023-09-24 11:21:50 +03:00
Alex Turchyn 380f553a17 add folders structure 2023-09-24 11:21:50 +03:00
Alex Turchyn f8712a9da9 rescue undefined timezone 2023-09-24 11:21:50 +03:00
Alex Turchyn ef8c3cfe7f add folders migration 2023-09-24 11:21:50 +03:00
Alex Turchyn 9bcf5aacc0 do not add checkbox on click 2023-09-24 11:21:50 +03:00
Alex Turchyn 54592e7264 add submitter fields autocomplete 2023-09-24 11:21:50 +03:00
Alex Turchyn f1f4de53c6 adjust phone form 2023-09-24 11:21:50 +03:00
Alex Turchyn ee350324e5 add initials field 2023-09-24 11:21:50 +03:00
Alex Turchyn dc02964d9b ensure template account is set 2023-09-24 11:21:50 +03:00
Oleksandr Turchyn 0424b93fe7 Merge pull request #112 from jbenguira/patch-1
Added Elestio as a deployment option
2023-09-21 14:06:44 +03:00
Joseph Benguira 905e0b22ed Added Elestio as a deployment option
Hello, I'm Joseph Benguira, Founder of Elest.io
I'm suggesting to add Elestio as a deployment option near the other one-click options in your list.

FYI I'm also trying to contact you through Linkedin to check if you are interested in a revenue sharing partnership (we will share with you part of profits generated on Elestio)
2023-09-21 02:03:05 +03:00
Alex Turchyn d757f06ff4 ensure template account is set 2023-09-19 00:01:07 +03:00
Alex Turchyn 132999176c add ability to customize message before sending 2023-09-18 23:23:51 +03:00
Alex Turchyn 2f74caf16a add notifications settings 2023-09-18 11:42:54 +03:00
Alex Turchyn 41d3eaf582 skip pdf reader errors 2023-09-17 17:00:21 +03:00
Alex Turchyn 907aaa9c30 use pg 15 in docker compose 2023-09-17 11:54:47 +03:00
Alex Turchyn 7bb241815f adjust submissions page 2023-09-17 10:38:29 +03:00
Alex Turchyn 84e879bad0 fix email typos 2023-09-17 10:28:08 +03:00
Alex Turchyn a1a0205d33 narrow devise forms 2023-09-17 10:28:07 +03:00
Alex Turchyn 6f8e88cdbd store template_submitters on form start 2023-09-17 09:09:15 +03:00
Alex Turchyn 2f1843151d add cancan for authorization 2023-09-17 00:45:57 +03:00
Alex Turchyn f89c50d096 allow to add multiple emails per submitter 2023-09-16 13:49:05 +03:00
Alex Turchyn ef618f8f49 add 2FA 2023-09-16 02:27:36 +03:00
Alex Turchyn b3662ddea8 fix webhook serializer 2023-09-15 12:40:32 +03:00
Alex Turchyn 53fa35fd01 add templates and submissions search 2023-09-15 02:11:48 +03:00
Alex Turchyn 7f6c0af0e8 add archived template and submission pages 2023-09-15 00:47:35 +03:00
Alex Turchyn 3faec9d174 add started and viewed webhook events 2023-09-15 00:47:25 +03:00
Alex Turchyn 9d9e134bb4 use default date format in pdf result 2023-09-14 10:35:43 +03:00
Alex Turchyn ec4f399fcc add template upload button 2023-09-14 10:35:43 +03:00
Alex Turchyn effa7b0fc7 add audit log button 2023-09-14 10:35:43 +03:00
Oleksandr Turchyn 016d9de87a Merge pull request #105 from imchairmanm/add_koyeb_target
add Koyeb to README as deployment target
2023-09-14 01:25:46 +03:00
Justin Ellingwood 71ff064356 add Koyeb to README as deployment target 2023-09-13 13:52:31 +01:00
Alex Turchyn 9100d48cc3 fix audit trail generation 2023-09-12 22:36:04 +03:00
Alex Turchyn 21b207cb1a fix submissions api 2023-09-11 09:49:59 +03:00
Alex Turchyn 3aa703032f adjust audit trail 2023-09-11 01:24:43 +03:00
Alex Turchyn fcfd2e13bd track form open after link click 2023-09-10 20:46:45 +03:00
Alex Turchyn cf6cacdc92 add audit trail 2023-09-10 20:46:44 +03:00
Alex Turchyn 8cabe31827 add submission tracking events 2023-09-10 17:49:10 +03:00
Alex Turchyn 62cea05084 add preserve submitters order option 2023-09-10 11:24:28 +03:00
Alex Turchyn 07e49f9f07 adjust default email 2023-09-09 23:17:41 +03:00
Alex Turchyn baad784818 send docs copy only if email present 2023-09-09 14:00:14 +03:00
Alex Turchyn 6eecd3afa4 fix email template submitters list 2023-09-09 11:44:04 +03:00
Alex Turchyn 8e90100871 fix submission download 2023-09-09 02:00:11 +03:00
Alex Turchyn 12c5138364 adjust add recipients view 2023-09-09 01:25:44 +03:00
Alex Turchyn 80ad166fd9 automatically send emails when all parties completed the form 2023-09-09 00:43:32 +03:00
Alex Turchyn 2fab6984ea fix form button loading indicator 2023-09-06 22:21:15 +03:00
Alex Turchyn 422639114b add bcc emails config 2023-09-06 21:41:23 +03:00
Alex Turchyn fca441878a send completed notification emails to a single user 2023-09-06 20:10:55 +03:00
Alex Turchyn 76578077e8 fix pdf rendering with rotate prop 2023-09-06 17:27:54 +03:00
Alex Turchyn bbd3618f0e add js alert translation 2023-09-05 22:28:50 +03:00
Alex Turchyn 47ed8db955 fix typo 2023-09-05 21:53:17 +03:00
Alex Turchyn fec6e2e35d add portuguese form translations 2023-09-05 21:49:12 +03:00
Alex Turchyn 7c5c8779ec fix form translations 2023-09-05 21:39:04 +03:00
Alex Turchyn 37ed110ad4 better submitter select icon 2023-09-05 20:50:25 +03:00
Alex Turchyn 2ad5f8a316 handle unsupported file types 2023-09-05 20:40:52 +03:00
Alex Turchyn e7594d81b6 fix signature step next button disabled 2023-09-05 01:35:48 +03:00
Alex Turchyn e7c5f14860 fix webhook serializer 2023-09-05 01:09:46 +03:00
Alex Turchyn c38830dd1c add phone type placeholder 2023-09-05 01:00:21 +03:00
Alex Turchyn 2cbf429293 better if readability 2023-09-04 19:06:16 +03:00
Alex Turchyn 517c292c17 add submitter name email variable 2023-09-04 10:08:32 +03:00
Alex Turchyn 14bd039a88 fix email template variable 2023-09-04 02:12:03 +03:00
Alex Turchyn 8c8c34cb75 add server switch 2023-09-04 01:39:16 +03:00
Alex Turchyn 0cc53066eb refactor 2023-09-03 20:44:54 +03:00
Alex Turchyn 3d13d28e05 add czech form translations 2023-09-03 19:07:18 +03:00
Alex Turchyn a80e20234f add name and phone to submitter details 2023-09-03 19:07:18 +03:00
Alex Turchyn b1b46cf998 use proxy urls for webhook attachments 2023-09-01 19:08:35 +03:00
Alex Turchyn 52ef0ae929 make pdf links clickable 2023-09-01 10:08:27 +03:00
Alex Turchyn c4daf8ed49 fix pdf signature validation and certs 2023-08-31 02:10:10 +03:00
Alex Turchyn c098e61947 fix redirect 2023-08-31 02:10:10 +03:00
Alex Turchyn ceb052b158 start translating the form 2023-08-31 02:10:10 +03:00
Alex Turchyn 61b79d5b24 fix submissions api 2023-08-31 02:10:10 +03:00
Alex Turchyn 5a9cffc6fc fix subdomain urls 2023-08-31 02:10:10 +03:00
Alex Turchyn 08447ec4cf fix builder field button bg color 2023-08-31 02:10:10 +03:00
Alex Turchyn d3a5f36555 add ability to use own signing cert 2023-08-31 02:10:09 +03:00
Alex Turchyn c9f39e7b1a fix sign up redirect 2023-08-27 23:34:00 +03:00
Alex Turchyn 1060aa84ba adjust placeholder 2023-08-27 23:33:55 +03:00
Alex Turchyn b6f68ddde3 remove redundant validation 2023-08-27 16:08:25 +03:00
Alex Turchyn 12dd96e34c bring back sign up path 2023-08-26 01:24:14 +03:00
Alex Turchyn 52ba415429 use active job test queue adapter in rspec 2023-08-26 01:20:43 +03:00
Alex Turchyn 601c984f3f do not display registration routes for signed up users 2023-08-25 23:54:55 +03:00
Alex Turchyn 9d90304d9a fix builder width handler 2023-08-25 23:44:14 +03:00
Alex Turchyn 788493c0a6 support UTF-8 text via ttf 2023-08-25 00:28:02 +03:00
Alex Turchyn e1342860c4 add redirect handler 2023-08-24 00:50:57 +03:00
Alex Turchyn 88968897e9 update gems 2023-08-21 20:09:56 +03:00
Alex Turchyn 966b786a30 store template submitters on submission creation 2023-08-21 16:32:39 +03:00
Alex Turchyn bc3b1b477e add export button 2023-08-20 15:18:15 +03:00
Alex Turchyn cbc6f4497a add base feature specs 2023-08-20 15:15:48 +03:00
Alex Turchyn 6b4e7c2264 allow to edit email templates 2023-08-20 00:11:11 +03:00
Alex Turchyn 8e6639d891 add a link to the console in navbar 2023-08-20 00:05:00 +03:00
Alex Turchyn 8e39f0078d add source to templates 2023-08-19 22:08:17 +03:00
Alex Turchyn 46f350217a refactor and fixes 2023-08-19 21:30:36 +03:00
Alex Turchyn 077c55f7e8 fix rubocop 2023-08-17 21:30:50 +03:00
Alex Turchyn e5c5620b20 collect ua and ip on form completion 2023-08-17 21:30:49 +03:00
Alex Turchyn 187b354605 add personalization settings 2023-08-16 23:51:55 +03:00
Alex Turchyn 545b18086a rescue all email smtp errors 2023-08-15 21:51:46 +03:00
Alex Turchyn 130c91178f fix templates api page when new installation 2023-08-15 21:39:25 +03:00
Alex Turchyn 47c57158f4 refactor and fixes 2023-08-14 00:48:42 +03:00
Alex Turchyn 3b745a650d optimize dockerfile 2023-08-12 15:48:50 +03:00
Alex Turchyn 6871a3f840 send emails when configured smtp configured from env 2023-08-11 18:32:43 +03:00
Alex Turchyn c57148eada allow to set default field values via api 2023-08-11 18:32:33 +03:00
Alex Turchyn d875477ee6 improve SMTP configs form 2023-08-10 23:44:49 +03:00
Alex Turchyn 1daee8214c fix anonymous checkboxes form 2023-08-09 21:10:07 +03:00
Alex Turchyn 9cc85595e3 fix font size on large pdfs 2023-08-09 10:09:36 +03:00
Alex Turchyn f6879d7f94 fix GitHub CI 2023-08-08 23:38:53 +03:00
Alex Turchyn f6247e68b5 fix typo 2023-08-08 23:28:26 +03:00
Alex Turchyn 454acb63db update readme 2023-08-08 23:28:26 +03:00
Alex Turchyn 6d78d5430c add access tokens 2023-08-08 23:28:26 +03:00
Alex Turchyn e75e7b18b4 developer console fixes 2023-08-08 23:28:26 +03:00
Alex Turchyn 644b142798 add source to submissions 2023-08-08 23:28:25 +03:00
Alex Turchyn e4bb5466f5 add webhook settings 2023-08-08 23:28:25 +03:00
Alex Turchyn 8929adbe83 add settings api page 2023-08-08 23:28:25 +03:00
Alex Turchyn 896b149b25 add console redirect 2023-08-08 23:28:25 +03:00
Alex Turchyn 7697b94f73 show alert when upload non pdf or image 2023-08-08 23:28:25 +03:00
Alex Turchyn 7912c9a1ee add submitters api 2023-08-08 23:28:25 +03:00
Alex Turchyn 63e1246a11 adjust readme 2023-08-08 23:27:44 +03:00
Alex Turchyn cbca34a7d7 add reply to into submission invitation email 2023-08-02 23:22:31 +03:00
Alex Turchyn 9471a510c9 adjust email footer 2023-08-02 23:22:31 +03:00
Alex Turchyn e754c5d414 update README 2023-08-02 14:25:23 +03:00
Alex Turchyn 182f593d86 fix form previous day date bug 2023-08-01 00:26:37 +03:00
Alex Turchyn 7f4548e5c7 use company name for email friendly from name 2023-07-31 22:53:49 +03:00
339 changed files with 10276 additions and 1532 deletions
+2
View File
@@ -1,5 +1,6 @@
/db/*.sqlite3
**/node_modules
/node_modules
/coverage
/doc
@@ -27,3 +28,4 @@
/attachments
/docuseal
.DS_Store
.env
+2
View File
@@ -127,6 +127,7 @@ jobs:
bundle config path vendor/bundle
bundle install --jobs 4 --retry 4
yarn install
sudo apt-get update
sudo apt-get install libvips
- name: Run
env:
@@ -137,4 +138,5 @@ jobs:
run: |
bundle exec rake db:create
bundle exec rake db:migrate
bundle exec rake assets:precompile
bundle exec rspec
+1
View File
@@ -35,3 +35,4 @@ yarn-debug.log*
/coverage
/attachments
/docuseal
/ee
+15 -3
View File
@@ -26,8 +26,11 @@ Style/Documentation:
Lint/MissingSuper:
Enabled: false
Metrics/ParameterLists:
Max: 10
Metrics/MethodLength:
Max: 20
Max: 25
Exclude:
- 'db/migrate/**'
@@ -53,16 +56,25 @@ RSpec/NestedGroups:
Max: 6
RSpec/MultipleExpectations:
Max: 7
Max: 20
RSpec/ExampleLength:
Max: 15
Max: 40
RSpec/MultipleMemoizedHelpers:
Max: 6
Rails/I18nLocaleTexts:
Enabled: false
Rails/FindEach:
Enabled: false
Rails/SkipsModelValidations:
Enabled: false
Rails/ApplicationController:
Enabled: false
Capybara/ClickLinkOrButtonStyle:
Enabled: false
+11 -2
View File
@@ -31,16 +31,25 @@ ENV BUNDLE_WITHOUT="development:test"
WORKDIR /app
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler vips-heif libc6-compat ttf-freefont
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont ttf-liberation && mkdir /fonts && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf /fonts && apk del ttf-liberation && wget -O /fonts/DancingScript.otf "https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf" && wget -O /fonts/DancingScript-License.txt https://github.com/impallari/DancingScript/blob/master/OFL.txt
COPY ./Gemfile ./Gemfile.lock ./
RUN bundle update --bundler && bundle install && rm -rf ~/.bundle
COPY . ./
COPY ./bin ./bin
COPY ./app ./app
COPY ./config ./config
COPY ./db ./db
COPY ./log ./log
COPY ./lib ./lib
COPY ./public ./public
COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru ./
COPY --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts
RUN bundle exec bootsnap precompile --gemfile app/ lib/
WORKDIR /data/docuseal
+10
View File
@@ -7,25 +7,35 @@ ruby '3.2.2'
gem 'aws-sdk-s3', require: false
gem 'azure-storage-blob', require: false
gem 'bootsnap', require: false
gem 'cancancan'
gem 'devise'
gem 'devise-two-factor'
gem 'dotenv', require: false
gem 'email_typo'
gem 'faraday'
gem 'faraday-follow_redirects'
gem 'google-cloud-storage', require: false
gem 'hexapdf'
gem 'image_processing'
gem 'jwt'
gem 'lograge'
gem 'mysql2', require: false
gem 'oj'
gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection'
gem 'pagy'
gem 'pdf-reader'
gem 'pg', require: false
gem 'premailer-rails'
gem 'puma'
gem 'rails'
gem 'rails_autolink'
gem 'rails-i18n'
gem 'rollbar', require: ENV.key?('ROLLBAR_ACCESS_TOKEN')
gem 'rotp'
gem 'rqrcode'
gem 'ruby-vips'
gem 'rubyXL'
gem 'shakapacker'
gem 'sidekiq', require: ENV.key?('REDIS_URL')
gem 'sqlite3', require: false
+161 -111
View File
@@ -1,92 +1,94 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (7.0.5)
actionpack (= 7.0.5)
activesupport (= 7.0.5)
Ascii85 (1.1.0)
actioncable (7.0.7)
actionpack (= 7.0.7)
activesupport (= 7.0.7)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (7.0.5)
actionpack (= 7.0.5)
activejob (= 7.0.5)
activerecord (= 7.0.5)
activestorage (= 7.0.5)
activesupport (= 7.0.5)
actionmailbox (7.0.7)
actionpack (= 7.0.7)
activejob (= 7.0.7)
activerecord (= 7.0.7)
activestorage (= 7.0.7)
activesupport (= 7.0.7)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.0.5)
actionpack (= 7.0.5)
actionview (= 7.0.5)
activejob (= 7.0.5)
activesupport (= 7.0.5)
actionmailer (7.0.7)
actionpack (= 7.0.7)
actionview (= 7.0.7)
activejob (= 7.0.7)
activesupport (= 7.0.7)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.5)
actionview (= 7.0.5)
activesupport (= 7.0.5)
actionpack (7.0.7)
actionview (= 7.0.7)
activesupport (= 7.0.7)
rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.5)
actionpack (= 7.0.5)
activerecord (= 7.0.5)
activestorage (= 7.0.5)
activesupport (= 7.0.5)
actiontext (7.0.7)
actionpack (= 7.0.7)
activerecord (= 7.0.7)
activestorage (= 7.0.7)
activesupport (= 7.0.7)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.0.5)
activesupport (= 7.0.5)
actionview (7.0.7)
activesupport (= 7.0.7)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
activejob (7.0.5)
activesupport (= 7.0.5)
activejob (7.0.7)
activesupport (= 7.0.7)
globalid (>= 0.3.6)
activemodel (7.0.5)
activesupport (= 7.0.5)
activerecord (7.0.5)
activemodel (= 7.0.5)
activesupport (= 7.0.5)
activestorage (7.0.5)
actionpack (= 7.0.5)
activejob (= 7.0.5)
activerecord (= 7.0.5)
activesupport (= 7.0.5)
activemodel (7.0.7)
activesupport (= 7.0.7)
activerecord (7.0.7)
activemodel (= 7.0.7)
activesupport (= 7.0.7)
activestorage (7.0.7)
actionpack (= 7.0.7)
activejob (= 7.0.7)
activerecord (= 7.0.7)
activesupport (= 7.0.7)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (7.0.5)
activesupport (7.0.7)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
addressable (2.8.4)
addressable (2.8.5)
public_suffix (>= 2.0.2, < 6.0)
afm (0.2.2)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
ast (2.4.2)
aws-eventstream (1.2.0)
aws-partitions (1.781.0)
aws-sdk-core (3.175.0)
aws-partitions (1.807.0)
aws-sdk-core (3.180.3)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.67.0)
aws-sdk-core (~> 3, >= 3.174.0)
aws-sdk-kms (1.71.0)
aws-sdk-core (~> 3, >= 3.177.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.126.0)
aws-sdk-core (~> 3, >= 3.174.0)
aws-sdk-s3 (1.132.1)
aws-sdk-core (~> 3, >= 3.179.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
aws-sigv4 (1.5.2)
aws-sigv4 (~> 1.6)
aws-sigv4 (1.6.0)
aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.3)
azure-storage-common (~> 2.0)
@@ -97,7 +99,7 @@ GEM
net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8)
bcrypt (3.1.19)
better_html (2.0.1)
better_html (2.0.2)
actionview (>= 6.0)
activesupport (>= 6.0)
ast (~> 2.0)
@@ -105,12 +107,13 @@ GEM
parser (>= 2.4)
smart_properties
bindex (0.8.1)
bootsnap (1.15.0)
bootsnap (1.16.0)
msgpack (~> 1.2)
builder (3.2.4)
bullet (7.0.7)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
cancancan (3.5.0)
capybara (3.39.2)
addressable
matrix
@@ -120,6 +123,7 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
chunky_png (1.4.0)
cmdparse (3.0.7)
coderay (1.1.3)
concurrent-ruby (1.2.2)
@@ -143,11 +147,17 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise-two-factor (5.0.0)
activesupport (~> 7.0)
devise (~> 4.0)
railties (~> 7.0)
rotp (~> 6.0)
diff-lcs (1.5.0)
digest-crc (0.6.4)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
docile (1.4.0)
dotenv (2.8.1)
email_typo (0.2.3)
erb_lint (0.4.0)
activesupport
better_html (>= 2.0.1)
@@ -161,7 +171,7 @@ GEM
factory_bot_rails (6.2.0)
factory_bot (~> 6.2.0)
railties (>= 5.0.0)
faker (3.2.0)
faker (3.2.1)
i18n (>= 1.8.11, < 2)
faraday (1.10.3)
faraday-em_http (~> 1.0)
@@ -178,6 +188,8 @@ GEM
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
@@ -194,10 +206,10 @@ GEM
webrick (~> 1.7)
websocket-driver (>= 0.6, < 0.8)
ffi (1.15.5)
geom2d (0.3.1)
geom2d (0.4.1)
globalid (1.1.0)
activesupport (>= 5.0)
google-apis-core (0.11.0)
google-apis-core (0.11.1)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
@@ -224,7 +236,7 @@ GEM
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.6.0)
googleauth (1.7.0)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
@@ -232,10 +244,11 @@ GEM
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
hashdiff (1.0.1)
hashery (2.1.2)
hashie (5.0.0)
hexapdf (0.32.2)
hexapdf (0.33.0)
cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.3)
geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1)
htmlentities (4.3.4)
httpclient (2.8.3)
@@ -245,8 +258,8 @@ GEM
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
io-console (0.6.0)
irb (1.7.0)
reline (>= 0.3.0)
irb (1.7.4)
reline (>= 0.3.6)
jmespath (1.6.2)
json (2.6.3)
jwt (2.7.1)
@@ -260,7 +273,7 @@ GEM
letter_opener (~> 1.7)
railties (>= 5.2)
rexml
lograge (0.12.0)
lograge (0.13.0)
actionpack (>= 4)
activesupport (>= 4)
railties (>= 4)
@@ -278,17 +291,17 @@ GEM
memoist (0.16.2)
method_source (1.0.0)
mini_magick (4.12.0)
mini_mime (1.1.2)
mini_portile2 (2.8.2)
minitest (5.18.1)
msgpack (1.7.1)
mini_mime (1.1.5)
mini_portile2 (2.8.4)
minitest (5.20.0)
msgpack (1.7.2)
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.3.0)
mysql2 (0.5.5)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.3.6)
net-imap (0.3.7)
date
net-protocol
net-pop (0.1.2)
@@ -298,7 +311,10 @@ GEM
net-smtp (0.3.3)
net-protocol
nio4r (2.5.9)
nokogiri (1.15.2-arm64-darwin)
nokogiri (1.15.4)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.15.4-arm64-darwin)
racc (~> 1.4)
oauth2 (2.0.9)
faraday (>= 0.17.3, < 3.0)
@@ -307,7 +323,7 @@ GEM
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
oj (3.15.0)
oj (3.16.0)
omniauth (2.1.1)
hashie (>= 3.4.6)
rack (>= 2.2.3)
@@ -328,9 +344,15 @@ GEM
os (1.1.4)
pagy (6.0.4)
parallel (1.23.0)
parser (3.2.2.3)
parser (3.2.2.4)
ast (~> 2.4.1)
racc
pdf-reader (2.11.0)
Ascii85 (~> 1.0)
afm (~> 0.2.1)
hashery (~> 2.0)
ruby-rc4
ttfunk
pg (1.5.3)
premailer (1.21.0)
addressable
@@ -345,53 +367,58 @@ GEM
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (5.0.1)
puma (6.3.0)
public_suffix (5.0.3)
puma (6.3.1)
nio4r (~> 2.0)
racc (1.7.1)
rack (2.2.7)
rack-protection (3.0.6)
rack
rack (2.2.8)
rack-protection (3.1.0)
rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.6)
rack
rack-test (2.1.0)
rack (>= 1.3)
rails (7.0.5)
actioncable (= 7.0.5)
actionmailbox (= 7.0.5)
actionmailer (= 7.0.5)
actionpack (= 7.0.5)
actiontext (= 7.0.5)
actionview (= 7.0.5)
activejob (= 7.0.5)
activemodel (= 7.0.5)
activerecord (= 7.0.5)
activestorage (= 7.0.5)
activesupport (= 7.0.5)
rails (7.0.7)
actioncable (= 7.0.7)
actionmailbox (= 7.0.7)
actionmailer (= 7.0.7)
actionpack (= 7.0.7)
actiontext (= 7.0.7)
actionview (= 7.0.7)
activejob (= 7.0.7)
activemodel (= 7.0.7)
activerecord (= 7.0.7)
activestorage (= 7.0.7)
activesupport (= 7.0.7)
bundler (>= 1.15.0)
railties (= 7.0.5)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
railties (= 7.0.7)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
rails-i18n (7.0.7)
rails-i18n (7.0.8)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
railties (7.0.5)
actionpack (= 7.0.5)
activesupport (= 7.0.5)
rails_autolink (1.1.8)
actionview (> 3.1)
activesupport (> 3.1)
railties (> 3.1)
railties (7.0.7)
actionpack (= 7.0.7)
activesupport (= 7.0.7)
method_source
rake (>= 12.2)
thor (~> 1.0)
zeitwerk (~> 2.5)
rainbow (3.1.1)
rake (13.0.6)
redis-client (0.14.1)
redis-client (0.16.0)
connection_pool
regexp_parser (2.8.1)
reline (0.3.5)
regexp_parser (2.8.2)
reline (0.3.8)
io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)
@@ -403,14 +430,19 @@ GEM
actionpack (>= 5.2)
railties (>= 5.2)
retriable (3.1.2)
rexml (3.2.5)
rexml (3.2.6)
rollbar (3.4.0)
rotp (6.3.0)
rqrcode (2.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
rqrcode_core (1.2.0)
rspec-core (3.12.2)
rspec-support (~> 3.12.0)
rspec-expectations (3.12.3)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-mocks (3.12.5)
rspec-mocks (3.12.6)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-rails (6.0.3)
@@ -421,41 +453,46 @@ GEM
rspec-expectations (~> 3.12)
rspec-mocks (~> 3.12)
rspec-support (~> 3.12)
rspec-support (3.12.0)
rubocop (1.53.0)
rspec-support (3.12.1)
rubocop (1.57.2)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.2.2.3)
parser (>= 3.2.2.4)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.28.0, < 2.0)
rubocop-ast (>= 1.28.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.29.0)
rubocop-ast (1.30.0)
parser (>= 3.2.1.0)
rubocop-capybara (2.18.0)
rubocop-capybara (2.19.0)
rubocop (~> 1.41)
rubocop-factory_bot (2.23.1)
rubocop-factory_bot (2.24.0)
rubocop (~> 1.33)
rubocop-performance (1.18.0)
rubocop-performance (1.19.1)
rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0)
rubocop-rails (2.20.2)
rubocop-rails (2.22.1)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0)
rubocop-rspec (2.22.0)
rubocop (~> 1.33)
rubocop-rspec (2.25.0)
rubocop (~> 1.40)
rubocop-capybara (~> 2.17)
rubocop-factory_bot (~> 2.22)
ruby-progressbar (1.13.0)
ruby-rc4 (0.1.5)
ruby-vips (2.1.4)
ffi (~> 1.12)
ruby2_keywords (0.0.5)
rubyXL (3.4.25)
nokogiri (>= 1.10.8)
rubyzip (>= 1.3.0)
rubyzip (2.3.2)
semantic_range (3.0.0)
shakapacker (7.0.0)
shakapacker (7.0.3)
activesupport (>= 5.2)
rack-proxy (>= 0.6.1)
railties (>= 5.2)
@@ -480,13 +517,15 @@ GEM
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
sqlite3 (1.5.4)
sqlite3 (1.6.3)
mini_portile2 (~> 2.8.0)
sqlite3 (1.6.3-arm64-darwin)
strip_attributes (1.13.0)
activemodel (>= 3.0, < 8.0)
thor (1.2.2)
timeout (0.4.0)
trailblazer-option (0.1.2)
ttfunk (1.7.0)
turbo-rails (1.4.0)
actionpack (>= 6.0.0)
activejob (>= 6.0.0)
@@ -496,7 +535,7 @@ GEM
tzinfo-data (1.2023.3)
tzinfo (>= 1.0.0)
uber (0.1.0)
unicode-display_width (2.4.2)
unicode-display_width (2.5.0)
uniform_notifier (1.16.0)
version_gem (1.1.3)
warden (1.2.9)
@@ -511,15 +550,16 @@ GEM
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.8.1)
websocket-driver (0.7.5)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.6.8)
zeitwerk (2.6.11)
PLATFORMS
arm64-darwin-22
ruby
DEPENDENCIES
annotate
@@ -528,18 +568,23 @@ DEPENDENCIES
better_html
bootsnap
bullet
cancancan
capybara
cuprite
debug
devise
devise-two-factor
dotenv
email_typo
erb_lint
factory_bot_rails
faker
faraday
faraday-follow_redirects
google-cloud-storage
hexapdf
image_processing
jwt
letter_opener_web
lograge
mysql2
@@ -547,19 +592,24 @@ DEPENDENCIES
omniauth-google-oauth2
omniauth-rails_csrf_protection
pagy
pdf-reader
pg
premailer-rails
pry-rails
puma
rails
rails-i18n
rails_autolink
rollbar
rotp
rqrcode
rspec-rails
rubocop
rubocop-performance
rubocop-rails
rubocop-rspec
ruby-vips
rubyXL
shakapacker
sidekiq
simplecov
+7 -3
View File
@@ -42,6 +42,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- [x] PDF signature verification
- [x] Users management
- [x] Mobile-optimized
- [x] API and Webhooks for integrations
- [x] Easy to deploy in minutes
## Deploy
@@ -51,6 +52,8 @@ DocuSeal is an open source platform that provides secure and efficient digital d
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
|**DigitalOcean**|**Render**|
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
|**Koyeb**|**Elestio**|
| [<img alt="Deploy on Koyeb" src="https://www.koyeb.com/static/images/deploy/button.svg" height="40">](https://app.koyeb.com/deploy?name=docuseal&type=docker&image=docker.io/docuseal/docuseal&env[PORT]=8000&env[DATABASE_URL]=CHANGE_ME&env[SECRET_KEY_BASE]=CHANGE_ME&ports=8000;http;/) | [<img alt="Deploy on Elestio" src="https://pub-da36157c854648669813f3f76c526c2b.r2.dev/deploy-on-elestio-black.png">](https://dash.elest.io/deploy?soft=DocuSeal&id=339) |
#### Docker
@@ -76,10 +79,11 @@ HOST=your-domain-name.com docker-compose up
## For Companies
### Integrate seamless document signing into your web or mobile apps with DocuSeal!
At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws.
At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, Real Estate, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws.
[![Book an Integration Demo](https://cal.com/book-with-cal-dark.svg)](https://cal.com/docuseal)
[Book a Meeting](https://calendly.com/kriti-docuseal/30min)
## License
DocuSeal is released under the GNU Affero General Public License v3.0.
Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information.
Unless otherwise noted, all files © 2023 DocuSeal LLC.
+7
View File
@@ -9,6 +9,9 @@ class AccountsController < ApplicationController
'de-DE' => 'German (Germany)'
}.freeze
before_action :load_account
authorize_resource :account
def show; end
def update
@@ -35,6 +38,10 @@ class AccountsController < ApplicationController
private
def load_account
@account = current_account
end
def account_params
params.require(:account).permit(:name, :timezone, :locale)
end
@@ -3,11 +3,36 @@
module Api
class ApiBaseController < ActionController::API
include ActiveStorage::SetCurrent
include Pagy::Backend
DEFAULT_LIMIT = 10
MAX_LIMIT = 100
wrap_parameters false
before_action :authenticate_user!
check_authorization
if Rails.env.production?
rescue_from CanCan::AccessDenied do |e|
Rollbar.error(e) if defined?(Rollbar)
render json: { error: e.message }, status: :forbidden
end
end
private
def paginate(relation)
result = relation.order(id: :desc)
.limit([params[:limit] || DEFAULT_LIMIT, MAX_LIMIT].min)
result = result.where('id < ?', params[:after]) if params[:after].present?
result = result.where('id > ?', params[:before]) if params[:before].present?
result
end
def current_account
current_user&.account
end
+2 -14
View File
@@ -3,24 +3,12 @@
module Api
class AttachmentsController < ApiBaseController
skip_before_action :authenticate_user!
skip_authorization_check
def create
submitter = Submitter.find_by!(slug: params[:submitter_slug])
blob =
if (file = params[:file])
ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
else
ActiveStorage::Blob.find_signed(params[:blob_signed_id])
end
attachment = ActiveStorage::Attachment.create!(
blob:,
name: params[:name],
record: submitter
)
attachment = Submitters.create_attachment!(submitter, params)
render json: attachment.as_json(only: %i[uuid], methods: %i[url filename content_type])
end
@@ -0,0 +1,160 @@
# frozen_string_literal: true
module Api
class SubmissionsController < ApiBaseController
load_and_authorize_resource :template, only: :create
load_and_authorize_resource :submission, only: %i[show index]
before_action only: :create do
authorize!(:create, Submission)
end
def index
submissions = Submissions.search(@submissions, params[:q])
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
submissions = paginate(submissions.preload(:created_by_user, :template, :submitters,
audit_trail_attachment: :blob))
render json: {
data: submissions.as_json(serialize_params),
pagination: {
count: submissions.size,
next: submissions.last&.id,
prev: submissions.first&.id
}
}
end
def show
serialized_subbmitters =
@submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob).map do |submitter|
Submissions::EnsureResultGenerated.call(submitter) if submitter.completed_at?
Submitters::SerializeForApi.call(submitter)
end
json = @submission.as_json(
serialize_params.deep_merge(
include: {
submission_events: {
only: %i[id submitter_id event_type event_timestamp]
}
}
)
)
json[:submitters] = serialized_subbmitters
render json:
end
def create
is_send_email = !params[:send_email].in?(['false', false])
submissions =
if (emails = (params[:emails] || params[:email]).presence) && params[:submission].blank?
Submissions.create_from_emails(template: @template,
user: current_user,
source: :api,
mark_as_sent: is_send_email,
emails:)
else
submissions_attrs, attachments = normalize_submissions_params!(submissions_params[:submission], @template)
Submissions.create_from_submitters(
template: @template,
user: current_user,
source: :api,
mark_as_sent: is_send_email,
submitters_order: params[:submitters_order] || 'preserved',
submissions_attrs:
)
end
Submissions.send_signature_requests(submissions, send_email: is_send_email)
submitters = submissions.flat_map(&:submitters)
save_default_value_attachments!(attachments, submitters)
render json: submitters
rescue Submitters::NormalizeValues::UnknownFieldName, Submitters::NormalizeValues::UnknownSubmitterName => e
render json: { error: e.message }, status: :unprocessable_entity
end
def destroy
@submission.update!(deleted_at: Time.current)
render json: @submission.as_json(only: %i[id deleted_at])
end
private
def serialize_params
{
only: %i[id source submitters_order created_at updated_at],
methods: %i[audit_log_url],
include: {
submitters: { only: %i[id slug uuid name email phone
completed_at opened_at sent_at
created_at updated_at],
methods: %i[status] },
template: { only: %i[id name created_at updated_at] },
created_by_user: { only: %i[id email first_name last_name] }
}
}
end
def submissions_params
params.permit(submission: [{
submitters: [[:uuid, :name, :email, :role, :completed, :phone, :application_key,
{ values: {}, readonly_fields: [],
fields: [%i[name default_value readonly validation_pattern invalid_message]] }]]
}])
end
def normalize_submissions_params!(submissions_params, template)
attachments = []
Array.wrap(submissions_params).each do |submission|
submission[:submitters].each_with_index do |submitter, index|
default_values = submitter[:values] || {}
submitter[:fields]&.each { |f| default_values[f[:name]] = f[:default_value] if f[:default_value].present? }
next if default_values.blank?
values, new_attachments =
Submitters::NormalizeValues.call(template,
default_values,
submitter[:role] || template.submitters[index]['name'])
attachments.push(*new_attachments)
submitter[:values] = values
end
end
[submissions_params, attachments]
end
def save_default_value_attachments!(attachments, submitters)
return if attachments.blank?
attachments_index = attachments.index_by(&:uuid)
submitters.each do |submitter|
submitter.values.to_a.each do |_, value|
attachment = attachments_index[value]
next unless attachment
attachment.record = submitter
attachment.save!
end
end
end
end
end
@@ -0,0 +1,18 @@
# frozen_string_literal: true
module Api
class SubmitterEmailClicksController < ApiBaseController
skip_before_action :authenticate_user!
skip_authorization_check
def create
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)
end
render json: {}
end
end
end
@@ -0,0 +1,18 @@
# frozen_string_literal: true
module Api
class SubmitterFormViewsController < ApiBaseController
skip_before_action :authenticate_user!
skip_authorization_check
def create
submitter = Submitter.find_by!(slug: params[:submitter_slug])
SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request)
SendFormViewedWebhookRequestJob.perform_later(submitter)
render json: {}
end
end
end
@@ -0,0 +1,35 @@
# frozen_string_literal: true
module Api
class SubmittersAutocompleteController < ApiBaseController
load_and_authorize_resource :submitter, parent: false
SELECT_COLUMNS = %w[email phone name].freeze
LIMIT = 100
def index
submitters = search_submitters(@submitters)
values = submitters.limit(LIMIT).group(SELECT_COLUMNS.join(', ')).pluck(SELECT_COLUMNS.join(', '))
attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h }
attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present?
render json: attrs
end
private
def search_submitters(submitters)
if SELECT_COLUMNS.include?(params[:field])
column = Submitter.arel_table[params[:field].to_sym]
term = "%#{params[:q].downcase}%"
submitters.where(column.lower.matches(term))
else
Submitters.search(submitters, params[:q])
end
end
end
end
@@ -0,0 +1,34 @@
# frozen_string_literal: true
module Api
class SubmittersController < ApiBaseController
load_and_authorize_resource :submitter
def index
submitters = Submitters.search(@submitters, params[:q])
submitters = submitters.where(application_key: params[:application_key]) if params[:application_key].present?
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
submitters = paginate(
submitters.preload(:template, :submission, :submission_events,
documents_attachments: :blob, attachments_attachments: :blob)
)
render json: {
data: submitters.map { |s| Submitters::SerializeForApi.call(s, with_template: true, with_events: true) },
pagination: {
count: submitters.size,
next: submitters.last&.id,
prev: submitters.first&.id
}
}
end
def show
Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at?
render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true)
end
end
end
@@ -0,0 +1,16 @@
# frozen_string_literal: true
module Api
class TemplateFoldersAutocompleteController < ApiBaseController
load_and_authorize_resource :template_folder, parent: false
LIMIT = 100
def index
template_folders = @template_folders.joins(:templates).where(templates: { deleted_at: nil }).distinct
template_folders = TemplateFolders.search(template_folders, params[:q]).limit(LIMIT)
render json: template_folders.as_json(only: %i[name deleted_at])
end
end
end
+48 -7
View File
@@ -2,22 +2,63 @@
module Api
class TemplatesController < ApiBaseController
load_and_authorize_resource :template
def index
templates = Templates.search(@templates, params[:q])
templates = params[:archived] ? templates.archived : templates.active
templates = templates.where(application_key: params[:application_key]) if params[:application_key].present?
templates = paginate(templates.preload(:author, documents_attachments: :blob))
render json: {
data: templates.as_json(serialize_params),
pagination: {
count: templates.size,
next: templates.last&.id,
prev: templates.first&.id
}
}
end
def show
render json: @template.as_json(serialize_params)
end
def update
@template = current_account.templates.find(params[:id])
if (folder_name = params.dig(:template, :folder_name))
@template.folder = TemplateFolders.find_or_create_by_name(current_user, folder_name)
end
@template.update!(template_params)
render :ok
render json: @template.as_json(only: %i[id updated_at])
end
def destroy
@template.update!(deleted_at: Time.current)
render json: @template.as_json(only: %i[id deleted_at])
end
private
def serialize_params
{
include: { author: { only: %i[id email first_name last_name] },
documents: { only: %i[id uuid], methods: %i[url filename] } }
}
end
def template_params
params.require(:template).permit(:name,
schema: [%i[attachment_uuid name]],
submitters: [%i[name uuid]],
fields: [[:uuid, :submitter_uuid, :name, :type, :required,
{ options: [], areas: [%i[x y w h cell_w attachment_uuid page]] }]])
params.require(:template).permit(
:name,
schema: [%i[attachment_uuid name]],
submitters: [%i[name uuid]],
fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value,
{ options: [], areas: [%i[x y w h cell_w attachment_uuid page]] }]]
)
end
end
end
@@ -2,17 +2,12 @@
module Api
class TemplatesDocumentsController < ApiBaseController
load_and_authorize_resource :template
def create
return head :unprocessable_entity if params[:blobs].blank? && params[:files].blank?
@template = current_account.templates.find(params[:template_id])
documents =
find_or_create_blobs.map do |blob|
document = @template.documents.create!(blob:)
Templates::ProcessDocument.call(document)
end
documents = Templates::CreateAttachments.call(@template, params)
schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base }
@@ -21,25 +16,12 @@ module Api
render json: {
schema:,
documents: documents.as_json(
methods: [:metadata],
include: {
preview_images: { methods: %i[url metadata filename] }
}
)
}
end
private
def find_or_create_blobs
blobs = params[:blobs]&.map do |attrs|
ActiveStorage::Blob.find_signed(attrs[:signed_id])
end
blobs || params[:files].map do |file|
ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
end
end
end
end
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class ApiSettingsController < ApplicationController
def index
authorize!(:read, current_user.access_token)
end
end
+13 -3
View File
@@ -4,6 +4,8 @@ class ApplicationController < ActionController::Base
include ActiveStorage::SetCurrent
include Pagy::Backend
check_authorization unless: :devise_controller?
before_action :sign_in_for_demo, if: -> { Docuseal.demo? }
before_action :maybe_redirect_to_setup, unless: :signed_in?
before_action :authenticate_user!, unless: :devise_controller?
@@ -16,6 +18,14 @@ class ApplicationController < ActionController::Base
redirect_to request.path
end
if Rails.env.production?
rescue_from CanCan::AccessDenied do |e|
Rollbar.error(e) if defined?(Rollbar)
redirect_back(fallback_location: root_path, alert: e.message)
end
end
def default_url_options
Docuseal.default_url_options
end
@@ -23,7 +33,7 @@ class ApplicationController < ActionController::Base
private
def sign_in_for_demo
sign_in(User.order('random()').take) unless signed_in?
sign_in(User.active.order('random()').take) unless signed_in?
end
def current_account
@@ -34,8 +44,8 @@ class ApplicationController < ActionController::Base
redirect_to setup_index_path unless User.exists?
end
def button_title(title: 'Submit', disabled_with: 'Submitting', icon: nil)
render_to_string(partial: 'shared/button_title', locals: { title:, disabled_with:, icon: })
def button_title(title: 'Submit', disabled_with: 'Submitting', icon: nil, icon_disabled: nil)
render_to_string(partial: 'shared/button_title', locals: { title:, disabled_with:, icon:, icon_disabled: })
end
def svg_icon(icon_name, class: '')
@@ -0,0 +1,18 @@
# frozen_string_literal: true
class ConsoleRedirectController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def index
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if current_user.blank?
auth = JsonWebToken.encode(uuid: current_user.uuid,
scope: :console,
exp: 1.minute.from_now.to_i)
path = Addressable::URI.parse(params[:redir]).path if params[:redir].to_s.starts_with?(Docuseal::CONSOLE_URL)
redirect_to("#{Docuseal::CONSOLE_URL}#{path}?#{{ auth: }.to_query}", allow_other_host: true)
end
end
+60 -4
View File
@@ -3,12 +3,68 @@
class DashboardController < ApplicationController
skip_before_action :authenticate_user!, only: %i[index]
before_action :maybe_redirect_product_url
before_action :maybe_render_landing
load_and_authorize_resource :template_folder, parent: false
load_and_authorize_resource :template, parent: false
SHOW_TEMPLATES_FOLDERS_THRESHOLD = 9
TEMPLATES_PER_PAGE = 12
FOLDERS_PER_PAGE = 18
def index
return redirect_to Docuseal::PRODUCT_URL, allow_other_host: true if Docuseal.multitenant? && !signed_in?
return render 'pages/landing' unless signed_in?
@template_folders = filter_template_folders(@template_folders)
templates = current_account.templates.active.preload(:author).order(id: :desc)
@pagy, @template_folders = pagy(
@template_folders,
items: FOLDERS_PER_PAGE,
page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1
)
@pagy, @templates = pagy(templates, items: 12)
if @pagy.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD
@templates = @templates.none
else
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME }
@templates = filter_templates(@templates)
items =
if @template_folders.size < 4
TEMPLATES_PER_PAGE
else
(@template_folders.size < 7 ? 9 : 6)
end
@pagy, @templates = pagy(@templates, items:)
end
end
private
def filter_template_folders(template_folders)
rel = template_folders.joins(:active_templates)
.order(id: :desc)
.distinct
TemplateFolders.search(rel, params[:q])
end
def filter_templates(templates)
rel = templates.active.preload(:author).order(id: :desc)
rel = rel.where(folder_id: current_account.default_template_folder.id) if params[:q].blank?
Templates.search(rel, params[:q])
end
def maybe_redirect_product_url
return if !Docuseal.multitenant? || signed_in?
redirect_to Docuseal::PRODUCT_URL, allow_other_host: true
end
def maybe_render_landing
return if signed_in?
render 'pages/landing'
end
end
@@ -1,16 +1,24 @@
# frozen_string_literal: true
class EmailSettingsController < ApplicationController
class EmailSmtpSettingsController < ApplicationController
before_action :load_encrypted_config
authorize_resource :encrypted_config, only: :index
authorize_resource :encrypted_config, parent: false, only: :create
def index; end
def create
if @encrypted_config.update(storage_configs)
if @encrypted_config.update(email_configs)
SettingsMailer.smtp_successful_setup(@encrypted_config.value['from_email']).deliver_now!
redirect_to settings_email_index_path, notice: 'Changes have been saved'
else
render :index, status: :unprocessable_entity
end
rescue StandardError => e
flash[:alert] = e.message
render :index, status: :unprocessable_entity
end
private
@@ -20,7 +28,7 @@ class EmailSettingsController < ApplicationController
EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::EMAIL_SMTP_KEY)
end
def storage_configs
def email_configs
params.require(:encrypted_config).permit(value: {}).tap do |e|
e[:value].compact_blank!
end
+22
View File
@@ -0,0 +1,22 @@
# frozen_string_literal: true
class EnquiriesController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def create
if params[:talk_to_sales] == 'on'
Faraday.post(Docuseal::ENQUIRIES_URL,
enquiry_params.merge(type: :talk_to_sales).to_json,
'Content-Type' => 'application/json')
end
head :ok
end
private
def enquiry_params
params.require(:user).permit(:email)
end
end
+104 -10
View File
@@ -1,19 +1,113 @@
# frozen_string_literal: true
class EsignSettingsController < ApplicationController
DEFAULT_CERT_NAME = 'DocuSeal Self-Host Autogenerated'
CertFormRecord = Struct.new(:name, :file, :password, keyword_init: true) do
include ActiveModel::Validations
def to_key
[]
end
end
before_action :load_encrypted_config
authorize_resource :encrypted_config, parent: false, only: %i[new create]
authorize_resource :encrypted_config, only: %i[update destroy show]
def show
cert_data = @encrypted_config.value || {}
default_pkcs = GenerateCertificate.load_pkcs(cert_data) if cert_data['cert'].present?
custom_pkcs_list = (cert_data['custom'] || []).map do |e|
{ 'pkcs' => OpenSSL::PKCS12.new(Base64.urlsafe_decode64(e['data']), e['password'].to_s),
'name' => e['name'],
'status' => e['status'] }
end
@pkcs_list = [
if default_pkcs
{
'pkcs' => default_pkcs,
'name' => DEFAULT_CERT_NAME,
'status' => custom_pkcs_list.any? { |e| e['status'] == 'default' } ? 'validate' : 'default'
}
end,
*custom_pkcs_list
].compact.reverse
end
def new
@cert_record = CertFormRecord.new
end
def create
pdfs =
params[:files].map do |file|
HexaPDF::Document.new(io: file.open)
end
@cert_record = CertFormRecord.new(**cert_params)
certs = Accounts.load_signing_certs(current_account)
if (@encrypted_config.value && @encrypted_config.value['custom']&.any? { |e| e['name'] == @cert_record.name }) ||
@cert_record.name == DEFAULT_CERT_NAME
trusted_certs = [certs[:cert], certs[:sub_ca], certs[:root_ca]]
@cert_record.errors.add(:name, 'already exists')
render turbo_stream: turbo_stream.replace('result', partial: 'result',
locals: { pdfs:, files: params[:files], trusted_certs: })
rescue HexaPDF::MalformedPDFError
render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div('Invalid PDF', id: 'result'))
return render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'),
status: :unprocessable_entity
end
save_new_cert!(@encrypted_config, @cert_record)
redirect_to settings_esign_path, notice: 'Certificate has been successfully added!'
rescue OpenSSL::PKCS12::PKCS12Error
@cert_record.errors.add(:password, "is invalid. Make sure you're uploading a valid .p12 file")
render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'), status: :unprocessable_entity
end
def update
@encrypted_config.value['custom'].each { |e| e['status'] = 'validate' }
custom_cert_data = @encrypted_config.value['custom'].find { |e| e['name'] == params[:name] }
custom_cert_data['status'] = 'default' if custom_cert_data
@encrypted_config.save!
redirect_to settings_esign_path, notice: 'Default certificate has been selected'
end
def destroy
@encrypted_config.value['custom'].reject! { |e| e['name'] == params[:name] }
@encrypted_config.save!
redirect_to settings_esign_path, notice: 'Certificate has been removed'
end
private
def load_encrypted_config
@encrypted_config = EncryptedConfig.find_or_initialize_by(account: current_account,
key: EncryptedConfig::ESIGN_CERTS_KEY)
end
def save_new_cert!(cert_configs, cert_record)
pkcs = OpenSSL::PKCS12.new(cert_record.file.read, cert_record.password)
cert_configs.value ||= {}
cert_configs.value['custom'] ||= []
cert_configs.value['custom'].each { |e| e['status'] = 'validate' }
cert_configs.value['custom'] << {
data: Base64.urlsafe_encode64(pkcs.to_der),
password: cert_record.password,
name: cert_record.name,
status: 'default'
}
cert_configs.save!
end
def cert_params
return {} if params[:esign_settings_controller_cert_form_record].blank?
params.require(:esign_settings_controller_cert_form_record).permit(:name, :file, :password)
end
end
+44
View File
@@ -0,0 +1,44 @@
# frozen_string_literal: true
class MfaSetupController < ApplicationController
before_action do
authorize!(:update, current_user)
end
def new
current_user.otp_secret ||= User.generate_otp_secret
current_user.save!
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
end
def edit; end
def create
if current_user.validate_and_consume_otp!(params[:otp_attempt])
current_user.otp_required_for_login = true
current_user.save!
redirect_to settings_profile_index_path, notice: '2FA has been configured'
else
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
@error_message = 'Code is invalid'
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/new'), status: :unprocessable_entity
end
end
def destroy
if current_user.validate_and_consume_otp!(params[:otp_attempt])
current_user.update!(otp_required_for_login: false, otp_secret: nil)
redirect_to settings_profile_index_path, notice: '2FA has been removed'
else
@error_message = 'Code is invalid'
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_entity
end
end
end
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class NewslettersController < ApplicationController
skip_authorization_check
def show; end
def update
@@ -0,0 +1,46 @@
# frozen_string_literal: true
class NotificationsSettingsController < ApplicationController
before_action :load_bcc_config, only: :index
before_action :load_reminder_config, only: :index
authorize_resource :bcc_config, only: :index
authorize_resource :reminder_config, only: :index
before_action :build_account_config, only: :create
authorize_resource :account_config, only: :create
def index; end
def create
if @account_config.save
redirect_back fallback_location: settings_notifications_path, notice: 'Changes have been saved'
else
redirect_back fallback_location: settings_notifications_path, alert: 'Unable to save'
end
end
private
def build_account_config
@account_config =
AccountConfig.find_or_initialize_by(account: current_account, key: email_config_params[:key])
@account_config.assign_attributes(email_config_params)
end
def load_bcc_config
@bcc_config =
AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::BCC_EMAILS)
end
def load_reminder_config
@reminder_config =
AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::SUBMITTER_REMAILERS)
end
def email_config_params
params.require(:account_config).permit!.tap do |attrs|
attrs[:key] = nil unless attrs[:key].in?([AccountConfig::BCC_EMAILS, AccountConfig::SUBMITTER_REMAILERS])
end
end
end
+13
View File
@@ -0,0 +1,13 @@
# frozen_string_literal: true
class PasswordsController < Devise::PasswordsController
class Current < ActiveSupport::CurrentAttributes
attribute :user
end
def update
super do |resource|
Current.user = resource
end
end
end
@@ -0,0 +1,24 @@
# frozen_string_literal: true
class PersonalizationSettingsController < ApplicationController
def show
authorize!(:read, AccountConfig)
end
def create
account_config =
current_account.account_configs.find_or_initialize_by(key: encrypted_config_params[:key])
authorize!(:create, account_config)
account_config.update!(encrypted_config_params)
redirect_back(fallback_location: settings_personalization_path, notice: 'Settings have been saved.')
end
private
def encrypted_config_params
params.require(:account_config).permit!
end
end
@@ -0,0 +1,46 @@
# frozen_string_literal: true
class PreviewDocumentPageController < ActionController::API
include ActiveStorage::SetCurrent
FORMAT = Templates::ProcessDocument::FORMAT
def show
attachment = ActiveStorage::Attachment.find_by(uuid: params[:attachment_uuid])
return head :not_found unless attachment
preview_image = attachment.preview_images.joins(:blob).find_by(blob: { filename: "#{params[:id]}#{FORMAT}" })
return redirect_to preview_image.url, allow_other_host: true if preview_image
file_path =
if attachment.service.name == :disk
ActiveStorage::Blob.service.path_for(attachment.key)
else
find_or_create_document_tempfile_path(attachment)
end
io = Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
render plain: io.tap(&:rewind).read
end
def find_or_create_document_tempfile_path(attachment)
file_path = "#{Dir.tmpdir}/#{attachment.uuid}"
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f|
f.flock(File::LOCK_EX)
# rubocop:disable Style/ZeroLengthPredicate
if f.size.zero?
f.binmode
f.write(attachment.download)
end
# rubocop:enable Style/ZeroLengthPredicate
end
file_path
end
end
+4
View File
@@ -1,6 +1,10 @@
# frozen_string_literal: true
class ProfileController < ApplicationController
before_action do
authorize!(:manage, current_user)
end
def index; end
def update_contact
+22 -4
View File
@@ -2,6 +2,7 @@
class RegistrationsController < Devise::RegistrationsController
prepend_before_action :require_no_authentication, only: [:show]
prepend_before_action :maybe_redirect_if_signed_in, only: [:show]
def show; end
@@ -13,10 +14,27 @@ class RegistrationsController < Devise::RegistrationsController
private
def require_no_authentication
super
def after_sign_up_path_for(...)
if params[:redir].present?
return console_redirect_index_path(redir: params[:redir]) if params[:redir].starts_with?(Docuseal::CONSOLE_URL)
flash.clear
return params[:redir]
end
super
end
def maybe_redirect_if_signed_in
return unless signed_in?
return if params[:redir].blank?
redirect_to after_sign_up_path_for(current_user), allow_other_host: true
end
def set_flash_message(key, kind, options = {})
return if key == :alert && kind == 'already_authenticated'
super
end
def build_resource(_hash = {})
@@ -25,7 +43,7 @@ class RegistrationsController < Devise::RegistrationsController
self.resource = account.users.new(user_params)
account.name ||= "#{resource.full_name}'s Company" if params[:action] == 'create'
account.name ||= resource.full_name if params[:action] == 'create'
end
def user_params
@@ -5,6 +5,7 @@ class SendSubmissionEmailController < ApplicationController
skip_before_action :authenticate_user!
skip_before_action :verify_authenticity_token
skip_authorization_check
def success; end
+23 -3
View File
@@ -1,20 +1,40 @@
# frozen_string_literal: true
class SessionsController < Devise::SessionsController
before_action :configure_permitted_parameters
def create
if Docuseal.multitenant? && !User.exists?(email: sign_in_params[:email])
return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
notice: 'Create a new account'
end
if User.exists?(email: sign_in_params[:email], otp_required_for_login: true) && sign_in_params[:otp_attempt].blank?
return render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_entity
end
super
end
private
def require_no_authentication
super
def after_sign_in_path_for(...)
if params[:redir].present?
return console_redirect_index_path(redir: params[:redir]) if params[:redir].starts_with?(Docuseal::CONSOLE_URL)
flash.clear
return params[:redir]
end
super
end
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
end
def set_flash_message(key, kind, options = {})
return if key == :alert && kind == 'already_authenticated'
super
end
end
+4 -2
View File
@@ -3,6 +3,7 @@
class SetupController < ApplicationController
skip_before_action :maybe_redirect_to_setup
skip_before_action :authenticate_user!
skip_authorization_check
before_action :redirect_to_root_if_signed, if: :signed_in?
before_action :ensure_first_user_not_created!
@@ -16,16 +17,17 @@ class SetupController < ApplicationController
def create
@account = Account.new(account_params)
@account.timezone = Accounts.normalize_timezone(@account.timezone)
@user = @account.users.new(user_params)
@encrypted_config = EncryptedConfig.new(encrypted_config_params)
unless URI.parse(encrypted_config_params[:value].to_s).class.in?([URI::HTTP, URI::HTTPS])
@encrypted_config = EncryptedConfig.new(encrypted_config_params)
@encrypted_config.errors.add(:value, 'should be a valid URL')
return render :index, status: :unprocessable_entity
end
return render :index, status: :unprocessable_entity unless @account.valid?
if @user.save
encrypted_configs = [
{ key: EncryptedConfig::APP_URL_KEY, value: encrypted_config_params[:value] },
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class SmsSettingsController < ApplicationController
before_action :load_encrypted_config
authorize_resource :encrypted_config, only: :index
authorize_resource :encrypted_config, parent: false, except: :index
def index; end
private
def load_encrypted_config
@encrypted_config =
EncryptedConfig.find_or_initialize_by(account: current_account, key: 'sms_configs')
end
end
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class SsoSettingsController < ApplicationController
before_action :load_encrypted_config
authorize_resource :encrypted_config, only: :index
authorize_resource :encrypted_config, parent: false, except: :index
def index; end
private
def load_encrypted_config
@encrypted_config =
EncryptedConfig.find_or_initialize_by(account: current_account, key: 'saml_configs')
end
end
+12 -5
View File
@@ -4,6 +4,7 @@ class StartFormController < ApplicationController
layout 'form'
skip_before_action :authenticate_user!
skip_authorization_check
before_action :load_template
@@ -13,19 +14,21 @@ class StartFormController < ApplicationController
def update
@submitter = Submitter.where(submission: @template.submissions.where(deleted_at: nil))
.find_or_initialize_by(email: submitter_params[:email])
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
.find_or_initialize_by(**submitter_params.compact_blank)
if @submitter.completed_at?
redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email])
else
@submitter.assign_attributes(
uuid: @template.submitters.first['uuid'],
opened_at: Time.current,
ip: request.remote_ip,
ua: request.user_agent
)
@submitter.submission ||= Submission.new(template: @template)
@submitter.submission ||= Submission.new(template: @template,
template_submitters: @template.submitters,
source: :link)
if @submitter.save
redirect_to submit_form_path(@submitter.slug)
@@ -36,13 +39,17 @@ class StartFormController < ApplicationController
end
def completed
@submitter = Submitter.where(submission: @template.submissions).find_by!(email: params[:email])
@submitter = Submitter.where(submission: @template.submissions)
.where.not(completed_at: nil)
.find_by!(email: params[:email])
end
private
def submitter_params
params.require(:submitter).permit(:email)
params.require(:submitter).permit(:email, :phone).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email])
end
end
def load_template
@@ -2,6 +2,8 @@
class StorageSettingsController < ApplicationController
before_action :load_encrypted_config
authorize_resource :encrypted_config, only: :index
authorize_resource :encrypted_config, parent: false, only: :create
def index; end
+40 -46
View File
@@ -2,75 +2,69 @@
class SubmissionsController < ApplicationController
before_action :load_template, only: %i[new create]
authorize_resource :template, only: %i[new create]
load_and_authorize_resource :submission, only: %i[show destroy]
PRELOAD_ALL_PAGES_AMOUNT = 200
def show
@submission =
Submission.joins(:template).where(template: { account_id: current_account.id })
.preload(template: { documents_attachments: { preview_images_attachments: :blob } })
.find(params[:id])
ActiveRecord::Associations::Preloader.new(
records: [@submission],
associations: [:template, { template_schema_documents: :blob }]
).call
total_pages =
@submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
ActiveRecord::Associations::Preloader.new(
records: @submission.template_schema_documents,
associations: [:blob, { preview_images_attachments: :blob }]
).call
end
render :show, layout: 'plain'
end
def new; end
def new
authorize!(:new, Submission)
end
def create
authorize!(:create, Submission)
submissions =
if params[:emails].present?
create_submissions_from_emails
Submissions.create_from_emails(template: @template,
user: current_user,
source: :invite,
mark_as_sent: params[:send_email] == '1',
emails: params[:emails])
else
create_submissions_from_submitters
Submissions.create_from_submitters(template: @template,
user: current_user,
source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
mark_as_sent: params[:send_email] == '1',
submissions_attrs: submissions_params[:submission].to_h.values)
end
submitters = submissions.flat_map(&:submitters)
Submissions.send_signature_requests(submissions, params)
if params[:send_email] == '1'
submitters.each do |submitter|
SubmitterMailer.invitation_email(submitter, message: params[:message]).deliver_later!
end
end
redirect_to template_path(@template),
notice: "#{submitters.size} #{'recipient'.pluralize(submitters.size)} added"
redirect_to template_path(@template), notice: 'New recipients have been added'
end
def destroy
submission = Submission.joins(:template).where(template: { account_id: current_account.id })
.find(params[:id])
@submission.update!(deleted_at: Time.current)
submission.update!(deleted_at: Time.current)
redirect_back(fallback_location: template_path(submission.template), notice: 'Submission has been archived')
redirect_back(fallback_location: template_path(@submission.template), notice: 'Submission has been archived')
end
private
def create_submissions_from_emails
emails = params[:emails].to_s.scan(User::EMAIL_REGEXP)
emails.map do |email|
submission = @template.submissions.new(created_by_user: current_user)
submission.submitters.new(email:, uuid: @template.submitters.first['uuid'],
sent_at: params[:send_email] == '1' ? Time.current : nil)
submission.tap(&:save!)
end
end
def create_submissions_from_submitters
submissions_params[:submission].to_h.map do |_, attrs|
submission = @template.submissions.new(created_by_user: current_user)
attrs[:submitters].each do |submitter_attrs|
submission.submitters.new(**submitter_attrs, sent_at: params[:send_email] == '1' ? Time.current : nil)
end
submission.tap(&:save!)
end
end
def submissions_params
params.permit(submission: { submitters: [%i[uuid email]] })
params.permit(submission: { submitters: [%i[uuid email phone name]] })
end
def load_template
@@ -4,6 +4,7 @@ class SubmissionsDebugController < ApplicationController
layout 'plain'
skip_before_action :authenticate_user!
skip_authorization_check
def index
@submitter = Submitter.preload({ attachments_attachments: :blob },
@@ -15,9 +16,13 @@ class SubmissionsDebugController < ApplicationController
render 'submit_form/show'
end
f.pdf do
Submissions::GenerateResultAttachments.call(@submitter)
if params[:audit]
Submissions::GenerateAuditTrail.call(@submitter.submission)
else
Submissions::GenerateResultAttachments.call(@submitter)
end
send_data ActiveStorage::Attachment.where(name: :documents).last.download,
send_data ActiveStorage::Attachment.where(name: params[:audit] ? :audit_trail : :documents).last.download,
filename: 'debug.pdf',
disposition: 'inline',
type: 'application/pdf'
@@ -2,14 +2,19 @@
class SubmissionsDownloadController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def index
submitter = Submitter.find_by(slug: params[:submitter_slug])
Submissions::EnsureResultGenerated.call(submitter)
last_submitter = submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
Submissions::EnsureResultGenerated.call(last_submitter)
urls =
Submitters.select_attachments_for_download(submitter).map do |attachment|
Submitters.select_attachments_for_download(last_submitter).map do |attachment|
helpers.rails_blob_url(attachment)
end
@@ -0,0 +1,23 @@
# frozen_string_literal: true
class SubmissionsExportController < ApplicationController
load_and_authorize_resource :template
load_and_authorize_resource :submission, through: :template, parent: false, only: :index
def index
submissions = @submissions.active
.preload(submitters: { documents_attachments: :blob,
attachments_attachments: :blob })
.order(id: :asc)
if params[:format] == 'csv'
send_data Submissions::GenerateExportFiles.call(submissions, format: params[:format]),
filename: "#{@template.name}.csv"
elsif params[:format] == 'xlsx'
send_data Submissions::GenerateExportFiles.call(submissions, format: params[:format]),
filename: "#{@template.name}.xlsx"
end
end
def new; end
end
+26 -31
View File
@@ -4,53 +4,48 @@ class SubmitFormController < ApplicationController
layout 'form'
skip_before_action :authenticate_user!
skip_authorization_check
PRELOAD_ALL_PAGES_AMOUNT = 200
def show
@submitter =
Submitter.preload(submission: { template: { documents_attachments: { preview_images_attachments: :blob } } })
.find_by!(slug: params[:slug])
@submitter = Submitter.find_by!(slug: params[:slug])
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
ActiveRecord::Associations::Preloader.new(
records: [@submitter],
associations: [submission: [:template, { template_schema_documents: :blob }]]
).call
total_pages =
@submitter.submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
ActiveRecord::Associations::Preloader.new(
records: @submitter.submission.template_schema_documents,
associations: [:blob, { preview_images_attachments: :blob }]
).call
end
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
cookies[:submitter_sid] = @submitter.signed_id
render @submitter.submission.template.deleted_at? ? :archived : :show
end
def update
submitter = Submitter.find_by!(slug: params[:slug])
submitter.values.merge!(normalized_values)
submitter.completed_at = Time.current if params[:completed] == 'true'
submitter.opened_at ||= Time.current
submitter.save!
Submissions.update_template_fields!(submitter.submission) if submitter.submission.template_fields.blank?
submitter.submission.save!
if submitter.completed_at?
GenerateSubmitterResultAttachmentsJob.perform_later(submitter)
submitter.submission.template.account.users.active.each do |user|
SubmitterMailer.completed_email(submitter, user).deliver_later!
end
end
Submitters::SubmitValues.call(submitter, params, request)
head :ok
rescue Submitters::SubmitValues::ValidationError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def completed
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
end
private
def normalized_values
params.fetch(:values, {}).to_unsafe_h.transform_values do |v|
if params[:cast_boolean] == 'true'
v == 'true'
else
v.is_a?(Array) ? v.compact_blank : v
end
end
end
end
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class SubmittersSendEmailController < ApplicationController
load_and_authorize_resource :submitter, id_param: :submitter_slug, find_by: :slug
def create
SubmitterMailer.invitation_email(@submitter).deliver_later!
SubmissionEvent.create!(submitter: @submitter, event_type: 'send_email')
@submitter.sent_at ||= Time.current
@submitter.save!
redirect_back(fallback_location: submission_path(@submitter.submission), notice: 'Email has been sent')
end
end
@@ -0,0 +1,29 @@
# frozen_string_literal: true
class TemplateFoldersController < ApplicationController
load_and_authorize_resource :template_folder
def show
@templates = @template_folder.templates.active.preload(:author).order(id: :desc)
@templates = Templates.search(@templates, params[:q])
@pagy, @templates = pagy(@templates, items: 12)
end
def edit; end
def update
if @template_folder != current_account.default_template_folder &&
@template_folder.update(template_folder_params)
redirect_to folder_path(@template_folder), notice: 'Folder name has been updated'
else
redirect_to folder_path(@template_folder), alert: 'Unable to rename folder'
end
end
private
def template_folder_params
params.require(:template_folder).permit(:name)
end
end
@@ -0,0 +1,12 @@
# frozen_string_literal: true
class TemplatesArchivedController < ApplicationController
load_and_authorize_resource :template, parent: false
def index
@templates = @templates.where.not(deleted_at: nil).preload(:author, :folder).order(id: :desc)
@templates = Templates.search(@templates, params[:q])
@pagy, @templates = pagy(@templates, items: 12)
end
end
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class TemplatesArchivedSubmissionsController < ApplicationController
load_and_authorize_resource :template
load_and_authorize_resource :submission, through: :template, parent: false
def index
@submissions = @submissions.where.not(deleted_at: nil)
@submissions = Submissions.search(@submissions, params[:q])
@pagy, @submissions = pagy(@submissions.preload(:submitters).order(id: :desc))
rescue ActiveRecord::RecordNotFound
redirect_to root_path
end
end
+37 -12
View File
@@ -1,31 +1,50 @@
# frozen_string_literal: true
class TemplatesController < ApplicationController
load_and_authorize_resource :template
before_action :load_base_template, only: %i[new create]
def show
@template = current_account.templates.active.find(params[:id])
submissions = @template.submissions
submissions = submissions.active if @template.deleted_at.blank?
submissions = Submissions.search(submissions, params[:q])
@pagy, @submissions = pagy(@template.submissions.active.preload(:submitters).order(id: :desc))
@base_submissions = submissions
submissions = submissions.pending if params[:status] == 'pending'
submissions = submissions.completed if params[:status] == 'completed'
@pagy, @submissions = pagy(submissions.preload(:submitters).order(id: :desc))
rescue ActiveRecord::RecordNotFound
redirect_to root_path
end
def new
@template = current_account.templates.new
@template.name = "#{@base_template.name} (Clone)" if @base_template
end
def edit
@template = current_account.templates.preload(documents_attachments: { preview_images_attachments: :blob })
.find(params[:id])
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: { preview_images_attachments: :blob }]
).call
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: [:metadata],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
render :edit, layout: 'plain'
end
def create
@template = current_account.templates.new(template_params)
@template.account = current_account
@template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
@template.assign_attributes(@base_template.slice(:fields, :schema, :submitters)) if @base_template
if @template.save
@@ -38,10 +57,18 @@ class TemplatesController < ApplicationController
end
def destroy
@template = current_account.templates.find(params[:id])
@template.update!(deleted_at: Time.current)
notice =
if !Docuseal.multitenant? && params[:permanently].present?
@template.destroy!
redirect_back(fallback_location: root_path, notice: 'Template has been archived.')
'Template has been removed.'
else
@template.update!(deleted_at: Time.current)
'Template has been archived.'
end
redirect_back(fallback_location: root_path, notice:)
end
private
@@ -53,8 +80,6 @@ class TemplatesController < ApplicationController
def load_base_template
return if params[:base_template_id].blank?
@base_template = current_account.templates
.preload(documents_attachments: :preview_images_attachments)
.find_by(id: params[:base_template_id])
@base_template = current_account.templates.find_by(id: params[:base_template_id])
end
end
@@ -0,0 +1,23 @@
# frozen_string_literal: true
class TemplatesFoldersController < ApplicationController
load_and_authorize_resource :template
def edit; end
def update
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:name])
if @template.save
redirect_back(fallback_location: template_path(@template), notice: 'Document template has been moved')
else
redirect_back(fallback_location: template_path(@template), notice: 'Unable to move template into folder')
end
end
private
def template_folder_params
params.require(:template_folder).permit(:name)
end
end
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class TemplatesRestoreController < ApplicationController
load_and_authorize_resource :template
def create
@template.update!(deleted_at: nil)
redirect_to template_path(@template), notice: 'Template has been unarchived'
end
end
@@ -0,0 +1,57 @@
# frozen_string_literal: true
class TemplatesUploadsController < ApplicationController
load_and_authorize_resource :template, parent: false
layout 'plain'
def show; end
def create
url_params = create_file_params_from_url if params[:url].present?
@template.account = current_account
@template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
@template.name = File.basename((url_params || params)[:files].first.original_filename, '.*')
@template.save!
documents = Templates::CreateAttachments.call(@template, url_params || params)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
@template.update!(schema:)
redirect_to edit_template_path(@template)
rescue StandardError => e
Rollbar.error(e) if defined?(Rollbar)
redirect_to root_path, alert: 'Unable to upload file'
end
private
def create_file_params_from_url
tempfile = Tempfile.new
tempfile.binmode
tempfile.write(conn.get(params[:url]).body)
tempfile.rewind
file = ActionDispatch::Http::UploadedFile.new(
tempfile:,
filename: File.basename(
URI.decode_www_form_component(params[:filename].presence || params[:url])
),
type: Marcel::MimeType.for(tempfile)
)
{ files: [file] }
end
def conn
Faraday.new do |faraday|
faraday.response :follow_redirects
end
end
end
@@ -0,0 +1,37 @@
# frozen_string_literal: true
class UserSignaturesController < ApplicationController
before_action :load_user_config
authorize_resource :user_config
def edit; end
def update
file = params[:file]
return redirect_to settings_profile_index_path, notice: 'Unable to save signature' if file.blank?
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
attachment = ActiveStorage::Attachment.create!(
blob:,
name: 'signature',
record: current_user
)
if @user_config.update(value: attachment.uuid)
redirect_to settings_profile_index_path, notice: 'Signature has been saved'
else
redirect_to settings_profile_index_path, notice: 'Unable to save signature'
end
end
private
def load_user_config
@user_config =
UserConfig.find_or_initialize_by(user: current_user, key: UserConfig::SIGNATURE_KEY)
end
end
+20 -16
View File
@@ -1,26 +1,20 @@
# frozen_string_literal: true
class UsersController < ApplicationController
before_action :load_user, only: %i[edit update destroy]
load_and_authorize_resource :user, only: %i[index edit new update destroy]
before_action :build_user, only: :create
authorize_resource :user, only: :create
def index
@pagy, @users = pagy(current_account.users.active.order(id: :desc))
@pagy, @users = pagy(@users.active.order(id: :desc))
end
def new
@user = current_account.users.new
end
def new; end
def edit; end
def create
@user = current_account.users.find_by(email: user_params[:email])&.tap do |user|
user.assign_attributes(user_params)
user.deleted_at = nil
end
@user ||= current_account.users.new(user_params)
if @user.save
UserMailer.invitation_email(@user).deliver_later!
@@ -33,7 +27,10 @@ class UsersController < ApplicationController
def update
return redirect_to settings_users_path, notice: 'Unable to update user.' if Docuseal.demo?
if @user.update(user_params.compact_blank)
attrs = user_params.compact_blank
attrs.delete(:role) if User::ROLES.exclude?(attrs[:role])
if @user.update(attrs.except(current_user == @user ? :role : nil))
redirect_to settings_users_path, notice: 'User has been updated'
else
render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_entity
@@ -52,11 +49,18 @@ class UsersController < ApplicationController
private
def load_user
@user = current_account.users.find(params[:id])
def build_user
@user = current_account.users.find_by(email: user_params[:email])&.tap do |user|
user.assign_attributes(user_params)
user.deleted_at = nil
end
@user ||= current_account.users.new(user_params)
@user
end
def user_params
params.require(:user).permit(:email, :first_name, :last_name, :password)
params.require(:user).permit(:email, :first_name, :last_name, :password, :role)
end
end
@@ -0,0 +1,34 @@
# frozen_string_literal: true
class VerifyPdfSignatureController < ApplicationController
skip_authorization_check
def create
pdfs =
params[:files].map do |file|
HexaPDF::Document.new(io: file.open)
end
cert_data = if Docuseal.multitenant?
Docuseal::CERTS
else
EncryptedConfig.find_by(key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
end
default_pkcs = GenerateCertificate.load_pkcs(cert_data)
custom_certs = cert_data.fetch('custom', []).map do |e|
OpenSSL::PKCS12.new(Base64.urlsafe_decode64(e['data']), e['password'].to_s)
end
trusted_certs = [default_pkcs.certificate,
*default_pkcs.ca_certs,
*custom_certs.map(&:certificate),
*custom_certs.flat_map(&:ca_certs).compact]
render turbo_stream: turbo_stream.replace('result', partial: 'result',
locals: { pdfs:, files: params[:files], trusted_certs: })
rescue HexaPDF::MalformedPDFError
render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div('Invalid PDF', id: 'result'))
end
end
@@ -0,0 +1,33 @@
# frozen_string_literal: true
class WebhookSettingsController < ApplicationController
before_action :load_encrypted_config
authorize_resource :encrypted_config, parent: false
def show; end
def create
@encrypted_config.update!(encrypted_config_params)
redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook URL has been saved.')
end
def update
submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last
SendFormCompletedWebhookRequestJob.perform_later(submitter)
redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook request has been sent.')
end
private
def load_encrypted_config
@encrypted_config =
current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
end
def encrypted_config_params
params.require(:encrypted_config).permit(:value)
end
end
+33
View File
@@ -15,6 +15,10 @@ import DownloadButton from './elements/download_button'
import SetOriginUrl from './elements/set_origin_url'
import SetTimezone from './elements/set_timezone'
import AutoresizeTextarea from './elements/autoresize_textarea'
import SubmittersAutocomplete from './elements/submitter_autocomplete'
import FolderAutocomplete from './elements/folder_autocomplete'
import SignatureForm from './elements/signature_form'
import SubmitForm from './elements/submit_form'
import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -43,8 +47,33 @@ window.customElements.define('download-button', DownloadButton)
window.customElements.define('set-origin-url', SetOriginUrl)
window.customElements.define('set-timezone', SetTimezone)
window.customElements.define('autoresize-textarea', AutoresizeTextarea)
window.customElements.define('submitters-autocomplete', SubmittersAutocomplete)
window.customElements.define('folder-autocomplete', FolderAutocomplete)
window.customElements.define('signature-form', SignatureForm)
window.customElements.define('submit-form', SubmitForm)
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
document.addEventListener('turbo:submit-end', async (event) => {
const resp = event.detail?.formSubmission?.result?.fetchResponse?.response
if (!resp?.headers?.get('content-disposition')?.includes('attachment')) {
return
}
const url = URL.createObjectURL(await resp.blob())
const link = document.createElement('a')
link.href = url
link.setAttribute('download', decodeURIComponent(resp.headers.get('content-disposition').split('"')[1]))
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
})
window.customElements.define('template-builder', class extends HTMLElement {
connectedCallback () {
@@ -52,6 +81,10 @@ window.customElements.define('template-builder', class extends HTMLElement {
this.app = createApp(TemplateBuilder, {
template: reactive(JSON.parse(this.dataset.template)),
backgroundColor: '#faf7f5',
withPhone: this.dataset.withPhone === 'true',
withLogo: this.dataset.withLogo !== 'false',
acceptFileTypes: this.dataset.acceptFileTypes,
isDirectUpload: this.dataset.isDirectUpload === 'true'
})
+55
View File
@@ -70,3 +70,58 @@ button[disabled] .enabled {
.base-radio {
@apply radio bg-white radio-sm no-animation;
}
.base-select {
@apply select base-input w-full font-normal;
}
.tooltip-bottom-end:before {
transform: translateX(-95%);
top: var(--tooltip-offset);
left: 100%;
right: auto;
bottom: auto;
}
.tooltip-bottom-end:after {
transform: translateX(-25%);
border-color: transparent transparent var(--tooltip-color) transparent;
top: var(--tooltip-tail-offset);
left: 50%;
right: auto;
bottom: auto;
}
.autocomplete {
background: white;
z-index: 1000;
font: 14px/22px "-apple-system", BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
overflow: auto;
box-sizing: border-box;
@apply border border-base-300 mt-1 rounded-md;
}
.autocomplete * {
font: inherit;
}
.autocomplete > div {
@apply px-2 py-1 font-normal text-sm;
}
.autocomplete .group {
background: #eee;
}
.autocomplete > div:hover:not(.group),
.autocomplete > div.selected {
@apply bg-base-300;
cursor: pointer;
}
.input-outlined {
outline-style: solid;
outline-width: 1px;
outline-offset: 3px;
outline-color: hsl(var(--bc) / 0.2);
}
+9 -3
View File
@@ -38,7 +38,11 @@ export default actionable(targetable(class extends HTMLElement {
this.uploadFiles(this.input.files)
}
toggleLoading = () => {
toggleLoading = (e) => {
if (e && e.target && !e.target.contains(this)) {
return
}
this.loading.classList.toggle('hidden')
this.icon.classList.toggle('hidden')
this.classList.toggle('opacity-50')
@@ -87,11 +91,13 @@ export default actionable(targetable(class extends HTMLElement {
this.append(input)
})
if (this.dataset.submitOnUpload) {
if (this.dataset.submitOnUpload === 'true') {
this.closest('form').querySelector('button[type="submit"]').click()
}
}).finally(() => {
this.toggleLoading()
if (this.dataset.submitOnUpload !== 'true') {
this.toggleLoading()
}
})
} else {
if (this.dataset.submitOnUpload) {
@@ -0,0 +1,43 @@
import autocomplete from 'autocompleter'
export default class extends HTMLElement {
connectedCallback () {
autocomplete({
input: this.input,
preventSubmit: this.dataset.submitOnSelect === 'true' ? 0 : 1,
minLength: 0,
showOnFocus: true,
onSelect: this.onSelect,
render: this.render,
fetch: this.fetch
})
}
onSelect = (item) => {
this.input.value = item.name
}
fetch = (text, resolve) => {
const queryParams = new URLSearchParams({ q: text })
fetch('/api/template_folders_autocomplete?' + queryParams).then(async (resp) => {
const items = await resp.json()
resolve(items)
}).catch(() => {
resolve([])
})
}
render = (item) => {
const div = document.createElement('div')
div.textContent = item.name
return div
}
get input () {
return this.querySelector('input')
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
export default class extends HTMLElement {
connectedCallback () {
this.querySelectorAll('a').forEach((link) => {
if (document.location.pathname.startsWith(link.pathname)) {
if (document.location.pathname.startsWith(link.pathname) && !link.getAttribute('href').startsWith('http')) {
link.classList.add('bg-base-300')
}
})
+46
View File
@@ -0,0 +1,46 @@
import { target, targetable } from '@github/catalyst/lib/targetable'
import { cropCanvasAndExportToPNG } from '../submission_form/crop_canvas'
export default targetable(class extends HTMLElement {
static [target.static] = ['canvas', 'input', 'clear', 'button']
async connectedCallback () {
this.canvas.width = this.canvas.parentNode.parentNode.clientWidth
this.canvas.height = this.canvas.parentNode.parentNode.clientWidth / 3
const { default: SignaturePad } = await import('signature_pad')
this.pad = new SignaturePad(this.canvas)
this.clear.addEventListener('click', (e) => {
e.preventDefault()
this.pad.clear()
})
this.button.addEventListener('click', (e) => {
e.preventDefault()
this.button.disabled = true
this.submit()
})
}
async submit () {
const blob = await cropCanvasAndExportToPNG(this.canvas)
const file = new File([blob], 'signature.png', { type: 'image/png' })
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
this.input.files = dataTransfer.files
if (this.input.webkitEntries.length) {
this.input.dataset.file = `${dataTransfer.files[0].name}`
}
this.closest('form').requestSubmit()
}
})
+5
View File
@@ -0,0 +1,5 @@
export default class extends HTMLElement {
connectedCallback () {
this.querySelector('form').requestSubmit()
}
}
@@ -0,0 +1,67 @@
import autocomplete from 'autocompleter'
export default class extends HTMLElement {
connectedCallback () {
autocomplete({
input: this.input,
preventSubmit: 1,
minLength: 1,
showOnFocus: true,
onSelect: this.onSelect,
render: this.render,
fetch: this.fetch
})
}
onSelect = (item) => {
const fields = ['email', 'name', 'phone']
const submitterItemEl = this.closest('submitter-item')
fields.forEach((field) => {
const input = submitterItemEl.querySelector(`submitters-autocomplete[data-field="${field}"] input`)
const textarea = submitterItemEl.querySelector(`submitters-autocomplete[data-field="${field}"] textarea`)
if (input && item[field]) {
input.value = item[field]
}
if (textarea && item[field]) {
textarea.value = textarea.value.replace(/[^;,\s]+$/, item[field] + ' ')
}
})
}
fetch = (text, resolve) => {
const q = text.split(/[;,\s]+/).pop().trim()
if (q) {
const queryParams = new URLSearchParams({ q, field: this.dataset.field })
fetch('/api/submitters_autocomplete?' + queryParams).then(async (resp) => {
const items = await resp.json()
if (q.length < 3) {
resolve(items.filter((e) => e[this.dataset.field].startsWith(q)))
} else {
resolve(items)
}
}).catch(() => {
resolve([])
})
} else {
resolve([])
}
}
render = (item) => {
const div = document.createElement('div')
div.textContent = item[this.dataset.field]
return div
}
get input () {
return this.querySelector('input') || this.querySelector('textarea')
}
}
+5 -1
View File
@@ -5,8 +5,12 @@ export default actionable(class extends HTMLElement {
document.body.classList.add('overflow-hidden')
document.addEventListener('keyup', this.onEscKey)
document.addEventListener('turbo:submit-end', this.onSubmit)
document.addEventListener('turbo:before-cache', this.close)
if (this.dataset.closeAfterSubmit !== 'false') {
document.addEventListener('turbo:submit-end', this.onSubmit)
}
}
disconnectedCallback () {
+5 -2
View File
@@ -9,13 +9,16 @@ window.customElements.define('submission-form', class extends HTMLElement {
this.appElem = document.createElement('div')
this.app = createApp(Form, {
submitterSlug: this.dataset.submitterSlug,
submitterUuid: this.dataset.submitterUuid,
submitter: JSON.parse(this.dataset.submitter),
authenticityToken: this.dataset.authenticityToken,
canSendEmail: this.dataset.canSendEmail === 'true',
isDirectUpload: this.dataset.isDirectUpload === 'true',
goToLast: this.dataset.goToLast === 'true',
isDemo: this.dataset.isDemo === 'true',
attribution: this.dataset.attribution !== 'false',
withConfetti: true,
values: reactive(JSON.parse(this.dataset.values)),
completedButton: JSON.parse(this.dataset.completedButton),
attachments: reactive(JSON.parse(this.dataset.attachments)),
fields: JSON.parse(this.dataset.fields)
})
+4
View File
@@ -47,6 +47,10 @@ select:required:invalid {
@apply border-base-content/20;
}
.base-textarea {
@apply textarea textarea-bordered bg-white rounded-3xl;
}
.btn {
@apply no-animation;
}
+24 -7
View File
@@ -47,6 +47,11 @@
class="object-contain mx-auto"
:src="signature.url"
>
<img
v-else-if="field.type === 'initials' && initials"
class="object-contain mx-auto"
:src="initials.url"
>
<div
v-else-if="field.type === 'file'"
class="px-0.5 flex flex-col justify-center"
@@ -105,15 +110,16 @@
<span v-else-if="field.type === 'date'">
{{ formattedDate }}
</span>
<span v-else>
{{ modelValue }}
</span>
<span
v-else
class="whitespace-pre-wrap"
>{{ modelValue }}</span>
</div>
</div>
</template>
<script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3 } from '@tabler/icons-vue'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper } from '@tabler/icons-vue'
export default {
name: 'FieldArea',
@@ -175,11 +181,13 @@ export default {
signature: 'Signature',
date: 'Date',
image: 'Image',
initials: 'Initials',
file: 'File',
select: 'Select',
checkbox: 'Checkbox',
radio: 'Radio',
multiple: 'Multiple Select'
multiple: 'Multiple Select',
phone: 'Phone'
}
},
fieldIcons () {
@@ -188,12 +196,14 @@ export default {
signature: IconWritingSign,
date: IconCalendarEvent,
image: IconPhoto,
initials: IconLetterCaseUpper,
file: IconPaperclip,
select: IconSelect,
checkbox: IconCheckbox,
radio: IconCircleDot,
cells: IconColumns3,
multiple: IconChecks
multiple: IconChecks,
phone: IconPhoneCheck
}
},
image () {
@@ -210,9 +220,16 @@ export default {
return null
}
},
initials () {
if (this.field.type === 'initials') {
return this.attachmentsIndex[this.modelValue]
} else {
return null
}
},
formattedDate () {
if (this.field.type === 'date' && this.modelValue) {
return new Intl.DateTimeFormat([], { year: 'numeric', month: 'long', day: 'numeric' }).format(new Date(this.modelValue))
return new Intl.DateTimeFormat([], { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }).format(new Date(this.modelValue))
} else {
return ''
}
+16 -4
View File
@@ -12,7 +12,7 @@
:key="areaIndex"
>
<Teleport
:to="`#page-${area.attachment_uuid}-${area.page}`"
:to="findPageElementForArea(area)"
>
<FieldArea
:ref="setAreaRef"
@@ -78,10 +78,16 @@ export default {
this.areaRefs = []
},
methods: {
findPageElementForArea (area) {
return (this.$root.$el?.parentNode?.getRootNode() || document).getElementById(`page-${area.attachment_uuid}-${area.page}`)
},
scrollIntoField (field) {
this.areaRefs.find((area) => {
if (area.field === field) {
if (document.body.style.overflow === 'hidden') {
const root = this.$root.$el.parentNode.getRootNode()
const container = root.body || root.querySelector('div')
if (container.style.overflow === 'hidden') {
this.scrollInContainer(area.$el)
} else {
area.$refs.scrollToElem.scrollIntoView({ behavior: 'smooth', block: 'start' })
@@ -94,13 +100,19 @@ export default {
})
},
scrollInContainer (target) {
const root = this.$root.$el.parentNode.getRootNode()
const scrollbox = root.getElementById('scrollbox')
const formContainer = root.getElementById('form_container')
const container = root.body || root.querySelector('div')
const padding = 64
const boxRect = window.scrollbox.children[0].getBoundingClientRect()
const boxRect = scrollbox.children[0].getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const targetTopRelativeToBox = targetRect.top - boxRect.top
window.scrollbox.scrollTop = targetTopRelativeToBox - document.body.offsetHeight + window.form_container.offsetHeight + target.offsetHeight + padding
scrollbox.scrollTop = targetTopRelativeToBox - container.offsetHeight + formContainer.offsetHeight + target.offsetHeight + padding
},
setAreaRef (el) {
if (el) {
+54 -18
View File
@@ -7,13 +7,22 @@
:height="30"
/>
<span>
Form has been completed!
{{ t('form_has_been_completed') }}
</span>
</p>
<div class="space-y-3 mt-5">
<a
v-if="completedButton.url"
:href="completedButton.url"
class="white-button flex items-center w-full"
>
<span>
{{ completedButton.title || 'Back to Website' }}
</span>
</a>
<button
v-if="canSendEmail && !isDemo"
class="white-button flex items-center space-x-1 w-full"
class="white-button !h-auto flex items-center space-x-1 w-full"
:disabled="isSendingCopy"
@click.prevent="sendCopyToEmail"
>
@@ -23,10 +32,11 @@
/>
<IconMail v-else />
<span>
Send copy via email
{{ t('send_copy_via_email') }}
</span>
</button>
<button
v-if="!isWebView"
class="base-button flex items-center space-x-1 w-full"
:disabled="isDownloading"
@click.prevent="download"
@@ -37,7 +47,7 @@
/>
<IconDownload v-else />
<span>
Download
{{ t('download') }}
</span>
</button>
<a
@@ -58,17 +68,20 @@
>
<IconLogin />
<span>
Create a Free Account
{{ t('create_a_free_account') }}
</span>
</a>
</div>
<div class="text-center mt-4">
Signed with
<div
v-if="attribution"
class="text-center mt-4"
>
{{ t('signed_with') }}
<a
href="https://www.docuseal.co"
href="https://www.docuseal.co/start"
target="_blank"
class="underline"
>DocuSeal</a> - open source documents software
>DocuSeal</a> - {{ t('open_source_documents_software') }}
</div>
</div>
</template>
@@ -86,6 +99,7 @@ export default {
IconLogin,
IconDownload
},
inject: ['baseUrl', 't'],
props: {
submitterSlug: {
type: String,
@@ -96,10 +110,25 @@ export default {
required: false,
default: false
},
attribution: {
type: Boolean,
required: false,
default: true
},
withConfetti: {
type: Boolean,
required: false,
default: false
},
canSendEmail: {
type: Boolean,
required: false,
default: false
},
completedButton: {
type: Object,
required: false,
default: () => ({})
}
},
data () {
@@ -108,23 +137,30 @@ export default {
isDownloading: false
}
},
computed: {
isWebView () {
return /webview|wv|ip((?!.*Safari)|(?=.*like Safari))/i.test(window.navigator.userAgent)
}
},
async mounted () {
const { default: confetti } = await import('canvas-confetti')
if (this.withConfetti) {
const { default: confetti } = await import('canvas-confetti')
confetti({
particleCount: 50,
startVelocity: 30,
spread: 140
})
confetti({
particleCount: 50,
startVelocity: 30,
spread: 140
})
}
},
methods: {
sendCopyToEmail () {
this.isSendingCopy = true
fetch(`/send_submission_email.json?submitter_slug=${this.submitterSlug}`, {
fetch(this.baseUrl + `/send_submission_email.json?submitter_slug=${this.submitterSlug}`, {
method: 'POST'
}).then(() => {
alert('Email has been sent')
alert(this.t('email_has_been_sent'))
}).finally(() => {
this.isSendingCopy = false
})
@@ -132,7 +168,7 @@ export default {
download () {
this.isDownloading = true
fetch(`/submitters/${this.submitterSlug}/download`).then((response) => response.json()).then((urls) => {
fetch(this.baseUrl + `/submitters/${this.submitterSlug}/download`).then((response) => response.json()).then((urls) => {
const fileRequests = urls.map((url) => {
return () => {
return fetch(url).then(async (resp) => {
@@ -0,0 +1,49 @@
function cropCanvasAndExportToPNG (canvas) {
const ctx = canvas.getContext('2d')
const width = canvas.width
const height = canvas.height
let topmost = height
let bottommost = 0
let leftmost = width
let rightmost = 0
const imageData = ctx.getImageData(0, 0, width, height)
const pixels = imageData.data
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const pixelIndex = (y * width + x) * 4
const alpha = pixels[pixelIndex + 3]
if (alpha !== 0) {
topmost = Math.min(topmost, y)
bottommost = Math.max(bottommost, y)
leftmost = Math.min(leftmost, x)
rightmost = Math.max(rightmost, x)
}
}
}
const croppedWidth = rightmost - leftmost + 1
const croppedHeight = bottommost - topmost + 1
const croppedCanvas = document.createElement('canvas')
croppedCanvas.width = croppedWidth
croppedCanvas.height = croppedHeight
const croppedCtx = croppedCanvas.getContext('2d')
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
return new Promise((resolve, reject) => {
croppedCanvas.toBlob((blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('Failed to create a PNG blob.'))
}
}, 'image/png')
})
}
export { cropCanvasAndExportToPNG }
@@ -0,0 +1,73 @@
<template>
<div>
<div class="flex justify-between items-center w-full mb-2">
<label
:for="field.uuid"
class="label text-2xl"
>{{ field.name || t('date') }}
<template v-if="!field.required">({{ t('optional') }})</template>
</label>
<button
class="btn btn-outline btn-sm !normal-case font-normal"
@click.prevent="setCurrentDate"
>
<IconCalendarCheck :width="16" />
{{ t('set_today') }}
</button>
</div>
<div class="text-center">
<input
ref="input"
v-model="value"
class="base-input !text-2xl text-center w-full"
:required="field.required"
type="date"
:name="`values[${field.uuid}]`"
@focus="$emit('focus')"
>
</div>
</div>
</template>
<script>
import { IconCalendarCheck } from '@tabler/icons-vue'
export default {
name: 'DateStep',
components: {
IconCalendarCheck
},
inject: ['t'],
props: {
field: {
type: Object,
required: true
},
modelValue: {
type: String,
required: false,
default: ''
}
},
emits: ['update:model-value', 'focus'],
computed: {
value: {
set (value) {
this.$emit('update:model-value', value)
},
get () {
return this.modelValue
}
}
},
methods: {
setCurrentDate () {
const inputEl = this.$refs.input
inputEl.valueAsDate = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000)
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
}
}
}
</script>
+4 -3
View File
@@ -29,7 +29,7 @@
{{ message }}
</div>
<div class="text-xs">
<span class="font-medium">Click to upload</span> or drag and drop files
<span class="font-medium">{{ t('click_to_upload') }}</span> {{ t('or_drag_and_drop_files') }}
</div>
</div>
</div>
@@ -55,6 +55,7 @@ export default {
IconCloudUpload,
IconInnerShadowTop
},
inject: ['baseUrl', 't'],
props: {
message: {
type: String,
@@ -141,7 +142,7 @@ export default {
return await Promise.all(
blobs.map((blob) => {
return fetch('/api/attachments', {
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: JSON.stringify({
name: 'attachments',
@@ -166,7 +167,7 @@ export default {
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
return fetch('/api/attachments', {
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: formData
}).then(resp => resp.json()).then((data) => {
+321 -92
View File
@@ -10,10 +10,10 @@
/>
<button
v-if="!isFormVisible"
class="btn btn-neutral text-white absolute rounded-none border-x-0 md:border md:rounded-full bottom-0 w-full md:mb-4 text-base"
class="btn btn-neutral flex text-white absolute rounded-none border-x-0 md:border md:rounded-full bottom-0 w-full md:mb-4 text-base"
@click.prevent="isFormVisible = true"
>
Submit Form
{{ t('submit_form') }}
<IconArrowsDiagonal
class="absolute right-0 mr-4"
:width="20"
@@ -24,6 +24,7 @@
v-show="isFormVisible"
id="form_container"
class="shadow-md bg-base-100 absolute bottom-0 md:bottom-4 w-full border-base-200 border p-4 rounded"
:style="{ backgroundColor: backgroundColor }"
>
<button
v-if="!isCompleted"
@@ -57,61 +58,27 @@
>
<div class="md:mt-4">
<div v-if="['cells', 'text'].includes(currentField.type)">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
</label>
<div
v-else
class="py-1"
<TextStep
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
@focus="$refs.areas.scrollIntoField(currentField)"
/>
<div>
<input
:id="currentField.uuid"
v-model="values[currentField.uuid]"
class="base-input !text-2xl w-full"
:required="currentField.required"
:placeholder="`Type here...${currentField.required ? '' : ' (optional)'}`"
type="text"
:name="`values[${currentField.uuid}]`"
@focus="$refs.areas.scrollIntoField(currentField)"
>
</div>
</div>
<div v-else-if="currentField.type === 'date'">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
</label>
<div
v-else
class="py-1"
/>
<div class="text-center">
<input
:id="currentField.uuid"
v-model="values[currentField.uuid]"
class="base-input !text-2xl text-center w-full"
:required="currentField.required"
type="date"
:name="`values[${currentField.uuid}]`"
@focus="$refs.areas.scrollIntoField(currentField)"
>
</div>
</div>
<DateStep
v-else-if="currentField.type === 'date'"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
@focus="$refs.areas.scrollIntoField(currentField)"
/>
<div v-else-if="currentField.type === 'select'">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
<template v-if="!currentField.required">({{ t('optional') }})</template>
</label>
<div
v-else
@@ -129,7 +96,7 @@
value=""
:selected="!values[currentField.uuid]"
>
Select your option
{{ t('select_your_option') }}
</option>
<option
v-for="(option, index) in currentField.options"
@@ -147,7 +114,7 @@
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
<template v-if="!currentField.required">({{ t('optional') }})</template>
</label>
<div class="flex w-full">
<div class="space-y-3.5 mx-auto">
@@ -178,6 +145,7 @@
</div>
<MultiSelectStep
v-else-if="currentField.type === 'multiple'"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
/>
@@ -194,7 +162,16 @@
class="space-y-3.5 mx-auto"
>
<template v-if="isAnonymousChecboxes">
Complete hightlighted checkboxes and click <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? 'submit' : 'next' }}</span>.
<span class="text-xl">
{{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>.
</span>
<input
v-for="field in currentStepFields"
:key="field.uuid"
type="hidden"
:name="`values[${field.uuid}]`"
:value="!!values[field.uuid]"
>
</template>
<template v-else>
<div
@@ -214,6 +191,9 @@
:id="field.uuid"
type="checkbox"
class="base-checkbox !h-7 !w-7"
:oninvalid="`this.setCustomValidity('${t('please_check_the_box_to_continue')}')`"
:onchange="`this.setCustomValidity(validity.valueMissing ? '${t('please_check_the_box_to_continue')}' : '');`"
:required="field.required"
:checked="!!values[field.uuid]"
@click="[$refs.areas.scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
>
@@ -227,6 +207,7 @@
</div>
<ImageStep
v-else-if="currentField.type === 'image'"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
:is-direct-upload="isDirectUpload"
@@ -237,8 +218,10 @@
<SignatureStep
v-else-if="currentField.type === 'signature'"
ref="currentStep"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
:previous-value="previousSignatureValue"
:is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@@ -246,8 +229,24 @@
@start="$refs.areas.scrollIntoField(currentField)"
@minimize="isFormVisible = false"
/>
<InitialsStep
v-else-if="currentField.type === 'initials'"
ref="currentStep"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
:previous-value="previousInitialsValue"
:is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@attached="attachments.push($event)"
@start="$refs.areas.scrollIntoField(currentField)"
@focus="$refs.areas.scrollIntoField(currentField)"
@minimize="isFormVisible = false"
/>
<AttachmentStep
v-else-if="currentField.type === 'file'"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:is-direct-upload="isDirectUpload"
:field="currentField"
@@ -255,9 +254,21 @@
:submitter-slug="submitterSlug"
@attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]"
/>
<PhoneStep
v-else-if="currentField.type === 'phone'"
ref="currentStep"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
:default-value="submitter.phone"
:submitter-slug="submitterSlug"
@focus="$refs.areas.scrollIntoField(currentField)"
@submit="submitStep"
/>
</div>
<div class="mt-6 md:mt-8">
<button
ref="submitButton"
type="submit"
class="base-button w-full flex justify-center"
:disabled="isButtonDisabled"
@@ -268,22 +279,31 @@
class="mr-1 animate-spin"
/>
<span v-if="stepFields.length === currentStep + 1">
Submit
{{ t('submit') }}
</span>
<span v-else>
Next
{{ t('next') }}
</span><span
v-if="isSubmitting"
class="w-6 flex justify-start mr-1"
><span>...</span></span>
</span>
</button>
<div
v-if="showFillAllRequiredFields"
class="text-center mt-1"
>
{{ t('please_fill_all_required_fields') }}
</div>
</div>
</form>
<FormCompleted
v-else
:is-demo="isDemo"
:can-send-email="canSendEmail"
:attribution="attribution"
:completed-button="completedButton"
:with-confetti="withConfetti"
:can-send-email="canSendEmail && !!submitter.email"
:submitter-slug="submitterSlug"
/>
<div class="flex justify-center">
@@ -293,7 +313,7 @@
:key="step[0].uuid"
href="#"
class="inline border border-base-300 h-3 w-3 rounded-full mx-1"
:class="{ 'bg-base-300': index === currentStep, 'bg-base-content': index < currentStep || isCompleted, 'bg-white': index > currentStep }"
:class="{ 'bg-base-300': index === currentStep, 'bg-base-content': (index < currentStep && stepFields[index].every((f) => !f.required || ![null, undefined, ''].includes(values[f.uuid]))) || isCompleted, 'bg-white': index > currentStep }"
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(step, true)]"
/>
</div>
@@ -306,10 +326,15 @@
import FieldAreas from './areas'
import ImageStep from './image_step'
import SignatureStep from './signature_step'
import InitialsStep from './initials_step'
import AttachmentStep from './attachment_step'
import MultiSelectStep from './multi_select_step'
import PhoneStep from './phone_step'
import TextStep from './text_step'
import DateStep from './date_step'
import FormCompleted from './completed'
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import { t } from './i18n'
export default {
name: 'SubmissionForm',
@@ -318,15 +343,25 @@ export default {
ImageStep,
SignatureStep,
AttachmentStep,
InitialsStep,
MultiSelectStep,
IconInnerShadowTop,
DateStep,
IconArrowsDiagonal,
TextStep,
PhoneStep,
IconArrowsDiagonalMinimize2,
FormCompleted
},
provide () {
return {
baseUrl: this.baseUrl,
t: this.t
}
},
props: {
submitterSlug: {
type: String,
submitter: {
type: Object,
required: true
},
canSendEmail: {
@@ -334,15 +369,28 @@ export default {
required: false,
default: false
},
submitterUuid: {
type: String,
required: true
},
attachments: {
type: Array,
required: false,
default: () => []
},
onComplete: {
type: Function,
required: false,
default () {
return () => {}
}
},
withConfetti: {
type: Boolean,
required: false,
default: false
},
baseUrl: {
type: String,
required: false,
default: ''
},
fields: {
type: Array,
required: false,
@@ -350,49 +398,96 @@ export default {
},
authenticityToken: {
type: String,
required: true
required: false,
default: ''
},
backgroundColor: {
type: String,
required: false,
default: ''
},
isDirectUpload: {
type: Boolean,
required: true,
required: false,
default: false
},
allowToSkip: {
type: Boolean,
required: false,
default: false
},
goToLast: {
type: Boolean,
required: false,
default: true
},
isDemo: {
type: Boolean,
required: true,
required: false,
default: false
},
attribution: {
type: Boolean,
required: false,
default: true
},
values: {
type: Object,
required: false,
default: () => ({})
},
completedButton: {
type: Object,
required: false,
default: () => ({})
}
},
data () {
return {
isCompleted: false,
isFormVisible: true,
showFillAllRequiredFields: false,
currentStep: 0,
isSubmitting: false
isSubmitting: false,
submittedValues: {},
recalculateButtonDisabledKey: ''
}
},
computed: {
currentStepFields () {
return this.stepFields[this.currentStep]
},
submitterSlug () {
return this.submitter.slug
},
previousSignatureValue () {
const signatureField = [...this.fields].reverse().find((field) => field.type === 'signature' && !!this.values[field.uuid])
return this.values[signatureField?.uuid]
},
previousInitialsValue () {
const initialsField = [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
return this.values[initialsField?.uuid]
},
isAnonymousChecboxes () {
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name) && this.currentStepFields.length > 4
},
isButtonDisabled () {
return this.isSubmitting ||
if (this.recalculateButtonDisabledKey) {
return this.isSubmitting ||
(this.currentField.required && ['image', 'file'].includes(this.currentField.type) && !this.values[this.currentField.uuid]?.length) ||
(this.currentField.required && this.currentField.type === 'signature' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isSignatureStarted)
(this.currentField.required && this.currentField.type === 'signature' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isSignatureStarted) ||
(this.currentField.required && this.currentField.type === 'initials' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isInitialsStarted)
} else {
return false
}
},
currentField () {
return this.currentStepFields[0]
},
stepFields () {
return this.fields.reduce((acc, f) => {
return this.fields.filter((f) => !f.readonly).reduce((acc, f) => {
const prevStep = acc[acc.length - 1]
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox') {
@@ -416,23 +511,124 @@ export default {
}
},
mounted () {
this.currentStep = Math.min(
this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.values[f.uuid]))) + 1,
this.stepFields.length - 1
)
this.submittedValues = JSON.parse(JSON.stringify(this.values))
this.fields.forEach((field) => {
if (field.default_value && !field.readonly) {
this.values[field.uuid] = field.default_value
}
})
if (this.goToLast) {
const requiredEmptyStepIndex = this.stepFields.indexOf(this.stepFields.find((fields) => fields.some((f) => f.required && !this.submittedValues[f.uuid])))
const lastFilledStepIndex = this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))) + 1
const indexesList = [this.stepFields.length - 1]
if (requiredEmptyStepIndex !== -1) {
indexesList.push(requiredEmptyStepIndex)
}
if (lastFilledStepIndex !== -1) {
indexesList.push(lastFilledStepIndex)
}
this.currentStep = Math.min(...indexesList)
}
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
document.body.style.overflow = 'hidden'
this.$nextTick(() => {
const root = this.$root.$el.parentNode.getRootNode()
const scrollbox = root.getElementById('scrollbox')
const parent = root.body || root.querySelector('div')
window.scrollbox.classList.add('h-full', 'overflow-y-auto')
window.scrollbox.parentNode.classList.add('h-screen', 'overflow-y-auto')
parent.style.overflow = 'hidden'
scrollbox.classList.add('h-full', 'overflow-y-auto')
scrollbox.parentNode.classList.add('h-screen', 'overflow-y-auto')
scrollbox.parentNode.style.maxHeight = '-webkit-fill-available'
})
}
this.$nextTick(() => {
this.recalculateButtonDisabledKey = Math.random()
Promise.all([
this.maybeTrackEmailClick(),
this.maybeTrackSmsClick()
]).finally(() => {
this.trackViewForm()
})
})
},
methods: {
t,
maybeTrackEmailClick () {
const queryParams = new URLSearchParams(window.location.search)
if (queryParams.has('t')) {
const t = queryParams.get('t')
queryParams.delete('t')
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
window.history.replaceState({}, document.title, newUrl)
return fetch(this.baseUrl + '/api/submitter_email_clicks', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
t,
submitter_slug: this.submitterSlug
})
})
} else {
return Promise.resolve({})
}
},
maybeTrackSmsClick () {
const queryParams = new URLSearchParams(window.location.search)
if (queryParams.has('c')) {
const c = queryParams.get('c')
queryParams.delete('c')
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
window.history.replaceState({}, document.title, newUrl)
return fetch(this.baseUrl + '/api/submitter_sms_clicks', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
c,
submitter_slug: this.submitterSlug
})
})
} else {
return Promise.resolve({})
}
},
trackViewForm () {
fetch(this.baseUrl + '/api/submitter_form_views', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
submitter_slug: this.submitterSlug
})
})
},
goToStep (step, scrollToArea = false, clickUpload = false) {
this.currentStep = this.stepFields.indexOf(step)
this.showFillAllRequiredFields = false
this.$nextTick(() => {
this.recalculateButtonDisabledKey = Math.random()
if (scrollToArea) {
this.$refs.areas.scrollIntoField(step[0])
}
@@ -448,7 +644,7 @@ export default {
if (this.isCompleted) {
return Promise.resolve({})
} else {
return fetch(this.submitPath, {
return fetch(this.baseUrl + this.submitPath, {
method: 'POST',
body: formData || new FormData(this.$refs.form)
})
@@ -457,28 +653,61 @@ export default {
async submitStep () {
this.isSubmitting = true
const stepPromise = this.currentField.type === 'signature'
const stepPromise = ['signature', 'phone', 'initials'].includes(this.currentField.type)
? this.$refs.currentStep.submit
: () => Promise.resolve({})
await stepPromise()
stepPromise().then(async () => {
const emptyRequiredField = this.stepFields.find((fields, index) => {
return index < this.currentStep && fields[0].required && (fields[0].type === 'phone' || !this.allowToSkip) && !this.submittedValues[fields[0].uuid]
})
const formData = new FormData(this.$refs.form)
const formData = new FormData(this.$refs.form)
const isLastStep = this.currentStep === this.stepFields.length - 1
if (this.currentStep === this.stepFields.length - 1) {
formData.append('completed', 'true')
}
this.saveStep(formData).then(response => {
const nextStep = this.stepFields[this.currentStep + 1]
if (nextStep) {
this.goToStep(this.stepFields[this.currentStep + 1], true)
} else {
this.isCompleted = true
if (isLastStep && !emptyRequiredField) {
formData.append('completed', 'true')
}
await this.saveStep(formData).then(async (response) => {
if (response.status === 422) {
const data = await response.json()
alert(data.error || 'Value is invalid')
return Promise.reject(new Error(data.error))
}
this.submittedValues[this.currentField.uuid] = this.values[this.currentField.uuid]
if (isLastStep) {
this.isSecondWalkthrough = true
}
const nextStep = (isLastStep && emptyRequiredField) || this.stepFields[this.currentStep + 1]
if (nextStep) {
this.goToStep(nextStep, true)
if (emptyRequiredField === nextStep) {
this.showFillAllRequiredFields = true
}
} else {
this.isCompleted = true
const respData = await response.text()
if (respData) {
this.onComplete(JSON.parse(respData))
}
}
}).catch(error => {
console.error('Error submitting form:', error)
}).finally(() => {
this.isSubmitting = false
})
}).catch(error => {
console.error('Error submitting form:', error)
console.log(error)
}).finally(() => {
this.isSubmitting = false
})
+350
View File
@@ -0,0 +1,350 @@
const en = {
submit_form: 'Submit Form',
type_here: 'Type here',
optional: 'optional',
select_your_option: 'Select your option',
complete_hightlighted_checkboxes_and_click: 'Complete hightlighted checkboxes and click',
submit: 'submit',
next: 'next',
click_to_upload: 'Click to upload',
or_drag_and_drop_files: 'or drag and drop files',
send_copy_via_email: 'Send copy via email',
download: 'Download',
signature: 'Signature',
initials: 'Initials',
clear: 'Clear',
redraw: 'Redraw',
draw_initials: 'Draw initials',
type_signature_here: 'Type signature here',
type_initial_here: 'Type initials here',
form_has_been_completed: 'Form has been completed!',
create_a_free_account: 'Create a Free Account',
signed_with: 'Signed with',
please_check_the_box_to_continue: 'Please check the box to continue',
open_source_documents_software: 'open source documents software',
verified_phone_number: 'Verify Phone Number',
use_international_format: 'Use internatioanl format: +1xxx',
six_digits_code: '6-digit code',
change_phone_number: 'Change phone number',
sending: 'Sending...',
resend_code: 'Re-send code',
verification_code_has_been_resent: 'Verification code has been re-sent via SMS',
please_fill_all_required_fields: 'Please fill all required fields',
set_today: 'Set Today',
toggle_multiline_text: 'Toggle Multiline Text',
date: 'Date',
email_has_been_sent: 'Email has been sent'
}
const es = {
submit_form: 'Enviar Formulario',
type_here: 'Escribe aquí',
optional: 'opcional',
select_your_option: 'Selecciona tu opción',
complete_hightlighted_checkboxes_and_click: 'Completa las casillas resaltadas y haz clic',
submit: 'enviar',
next: 'siguiente',
click_to_upload: 'Haz clic para cargar',
or_drag_and_drop_files: 'o arrastra y suelta archivos',
send_copy_via_email: 'Enviar copia por correo electrónico',
download: 'Descargar',
signature: 'Firma',
initials: 'Iniciales',
clear: 'Borrar',
redraw: 'Redibujar',
draw_initials: 'Dibujar iniciales',
type_signature_here: 'Escribe la firma aquí',
type_initial_here: 'Escribe las iniciales aquí',
form_has_been_completed: '¡El formulario ha sido completado!',
create_a_free_account: 'Crear una Cuenta Gratuita',
signed_with: 'Firmado con',
please_check_the_box_to_continue: 'Por favor marque la casilla para continuar',
open_source_documents_software: 'software de documentos de código abierto',
verified_phone_number: 'Verificar número de teléfono',
use_international_format: 'Usar formato internacional: +1xxx',
six_digits_code: 'Código de 6 dígitos',
change_phone_number: 'Cambiar número de teléfono',
sending: 'Enviando...',
resend_code: 'Reenviar código',
verification_code_has_been_resent: 'El código de verificación ha sido reenviado por SMS',
please_fill_all_required_fields: 'Por favor, complete todos los campos obligatorios',
set_today: 'Establecer Hoy',
date: 'Fecha',
toggle_multiline_text: 'Alternar Texto Multilínea',
email_has_been_sent: 'El correo electrónico ha sido enviado'
}
const it = {
submit_form: 'Invia Modulo',
type_here: 'Digita qui',
optional: 'opzionale',
select_your_option: 'Seleziona la tua opzione',
complete_hightlighted_checkboxes_and_click: 'Completa le caselle evidenziate e fai clic',
submit: 'invia',
next: 'avanti',
click_to_upload: 'Clicca per caricare',
or_drag_and_drop_files: 'oppure trascina e rilascia i file',
send_copy_via_email: 'Invia copia via email',
download: 'Scarica',
signature: 'Firma',
initials: 'Iniziali',
clear: 'Cancella',
redraw: 'Ridisegna',
draw_initials: 'Disegna iniziali',
type_signature_here: 'Scrivi la firma qui',
type_initial_here: 'Scrivi le iniziali qui',
form_has_been_completed: 'Il modulo è stato completato!',
create_a_free_account: 'Crea un Account Gratuito',
signed_with: 'Firmato con',
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',
use_international_format: 'Usa formato internazionale: +1xxx',
six_digits_code: 'Codice a 6 cifre',
change_phone_number: 'Cambia numero di telefono',
sending: 'Invio in corso...',
resend_code: 'Rinvia codice',
verification_code_has_been_resent: 'Il codice di verifica è stato rinviato tramite SMS',
please_fill_all_required_fields: 'Si prega di compilare tutti i campi obbligatori',
set_today: 'Imposta Oggi',
date: 'Data',
toggle_multiline_text: 'Attiva Testo Multilinea',
email_has_been_sent: "L'email è stata inviata"
}
const de = {
submit_form: 'Formular absenden',
type_here: 'Hier eingeben',
optional: 'optional',
select_your_option: 'Wähle deine Option',
complete_hightlighted_checkboxes_and_click: 'Markierte Kontrollkästchen ausfüllen und klicken',
submit: 'absenden',
next: 'weiter',
click_to_upload: 'Klicken zum Hochladen',
or_drag_and_drop_files: 'oder Dateien hierher ziehen und ablegen',
send_copy_via_email: 'Kopie per E-Mail senden',
download: 'Herunterladen',
signature: 'Unterschrift',
initials: 'Initialen',
clear: 'Löschen',
redraw: 'Neu zeichnen',
draw_initials: 'Initialen zeichnen',
type_signature_here: 'Unterschrift hier eingeben',
type_initial_here: 'Initialen hier eingeben',
form_has_been_completed: 'Formular wurde ausgefüllt!',
create_a_free_account: 'Kostenloses Konto erstellen',
signed_with: 'Unterschrieben mit',
please_check_the_box_to_continue: 'Bitte setzen Sie das Häkchen, um fortzufahren',
open_source_documents_software: 'Open-Source-Dokumentensoftware',
verified_phone_number: 'Telefonnummer überprüfen',
use_international_format: 'Internationales Format verwenden: +1xxx',
six_digits_code: '6-stelliger Code',
change_phone_number: 'Telefonnummer ändern',
sending: 'Senden...',
resend_code: 'Code erneut senden',
verification_code_has_been_resent: 'Die Verifizierungscode wurde erneut per SMS gesendet',
please_fill_all_required_fields: 'Bitte füllen Sie alle erforderlichen Felder aus',
set_today: 'Heute einstellen',
date: 'Datum',
toggle_multiline_text: 'Mehrzeiligen Text umschalten',
email_has_been_sent: 'Die E-Mail wurde gesendet'
}
const fr = {
submit_form: 'Envoyer le Formulaire',
type_here: 'Tapez ici',
optional: 'facultatif',
select_your_option: 'Sélectionnez votre option',
complete_hightlighted_checkboxes_and_click: 'Complétez les cases à cocher en surbrillance et cliquez',
submit: 'envoyer',
next: 'suivant',
click_to_upload: 'Cliquez pour télécharger',
or_drag_and_drop_files: 'ou faites glisser-déposer les fichiers',
send_copy_via_email: 'Envoyer une copie par e-mail',
download: 'Télécharger',
signature: 'Signature',
initials: 'Initiales',
clear: 'Effacer',
redraw: 'Redessiner',
draw_initials: 'Dessiner les initiales',
type_signature_here: 'Tapez la signature ici',
type_initial_here: 'Tapez les initiales ici',
form_has_been_completed: 'Le formulaire a été complété !',
create_a_free_account: 'Créer un Compte Gratuit',
signed_with: 'Signé avec',
please_check_the_box_to_continue: 'Veuillez cocher la case pour continuer',
open_source_documents_software: 'logiciel de documents open source',
verified_phone_number: 'Vérifier le numéro de téléphone',
use_international_format: 'Utiliser le format international : +1xxx',
six_digits_code: 'Code à 6 chiffres',
change_phone_number: 'Changer le numéro de téléphone',
sending: 'Envoi en cours...',
resend_code: 'Renvoyer le code',
verification_code_has_been_resent: 'Le code de vérification a été renvoyé par SMS',
please_fill_all_required_fields: 'Veuillez remplir tous les champs obligatoires',
set_today: "Définir Aujourd'hui",
date: 'Date',
toggle_multiline_text: 'Basculer le Texte Multiligne',
email_has_been_sent: "L'email a été envoyé"
}
const pl = {
submit_form: 'Wyślij Formularz',
type_here: 'Wpisz tutaj',
optional: 'opcjonalny',
select_your_option: 'Wybierz swoją opcję',
complete_hightlighted_checkboxes_and_click: 'Wypełnij zaznaczone pola wyboru i kliknij',
submit: 'wyślij',
next: 'dalej',
click_to_upload: 'Kliknij, aby przesłać',
or_drag_and_drop_files: 'lub przeciągnij i upuść pliki',
send_copy_via_email: 'Wyślij kopię drogą mailową',
download: 'Pobierz',
signature: 'Podpis',
initials: 'Inicjały',
clear: 'Wyczyść',
redraw: 'Przerysuj',
draw_initials: 'Narysuj inicjały',
type_signature_here: 'Wpisz podpis tutaj',
type_initial_here: 'Wpisz inicjały tutaj',
form_has_been_completed: 'Formularz został wypełniony!',
create_a_free_account: 'Utwórz darmowe konto',
signed_with: 'Podpisane za pomocą',
please_check_the_box_to_continue: 'Proszę zaznaczyć pole, aby kontynuować',
open_source_documents_software: 'oprogramowanie do dokumentów open source',
verified_phone_number: 'Zweryfikuj numer telefonu',
use_international_format: 'Użyj międzynarodowego formatu: +1xxx',
six_digits_code: '6-cyfrowy kod',
change_phone_number: 'Zmień numer telefonu',
sending: 'Wysyłanie...',
resend_code: 'Ponownie wyślij kod',
verification_code_has_been_resent: 'Kod weryfikacyjny został ponownie wysłany',
please_fill_all_required_fields: 'Proszę wypełnić wszystkie wymagane pola',
set_today: 'Ustaw Dziś',
date: 'Data',
toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy',
email_has_been_sent: 'E-mail został wysłany'
}
const uk = {
submit_form: 'Надіслати Форму',
type_here: 'Введіть тут',
optional: 'необов’язково',
select_your_option: 'Виберіть варіант',
complete_hightlighted_checkboxes_and_click: 'Заповніть позначені прапорці та натисніть',
submit: 'надіслати',
next: 'далі',
click_to_upload: 'Клацніть, щоб завантажити',
or_drag_and_drop_files: 'або перетягніть файли сюди',
send_copy_via_email: 'Надіслати копію електронною поштою',
download: 'Завантажити',
signature: 'Підпис',
initials: 'Ініціали',
clear: 'Очистити',
redraw: 'Перемалювати',
draw_initials: 'Намалювати ініціали',
type_signature_here: 'Введіть підпис тут',
type_initial_here: 'Введіть ініціали тут',
form_has_been_completed: 'Форму заповнено!',
create_a_free_account: 'Створити безкоштовний обліковий запис',
signed_with: 'Підписано за допомогою',
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: 'Код підтвердження був повторно надісланий',
please_fill_all_required_fields: "Будь ласка, заповніть всі обов'язкові поля",
set_today: 'Задати Сьогодні',
date: 'Дата',
toggle_multiline_text: 'Перемкнути Багаторядковий Текст',
email_has_been_sent: 'Електронний лист був відправлений'
}
const cs = {
submit_form: 'Odeslat formulář',
type_here: 'Zadejte zde',
optional: 'volitelné',
select_your_option: 'Vyberte svou volbu',
complete_hightlighted_checkboxes_and_click: 'Označte zvýrazněné zaškrtávací políčka a klikněte na',
submit: 'odeslat',
next: 'další',
click_to_upload: 'Klikněte pro nahrání',
or_drag_and_drop_files: 'nebo přetáhněte soubory sem',
send_copy_via_email: 'Odeslat kopii e-mailem',
download: 'Stáhnout',
signature: 'Podpis',
initials: 'Iniciály',
clear: 'Smazat',
redraw: 'Překreslit',
draw_initials: 'Nakreslit iniciály',
type_signature_here: 'Sem zadejte podpis',
type_initial_here: 'Sem zadejte iniciály',
form_has_been_completed: 'Formulář byl dokončen!',
create_a_free_account: 'Vytvořit bezplatný účet',
signed_with: 'Podepsáno pomocí',
please_check_the_box_to_continue: 'Prosím, zaškrtněte políčko pro pokračování',
open_source_documents_software: 'open source software pro dokumenty',
verified_phone_number: 'Ověřte telefonní číslo',
use_international_format: 'Použijte mezinárodní formát: +1xxx',
six_digits_code: '6-místný kód',
change_phone_number: 'Změnit telefonní číslo',
sending: 'Odesílání...',
resend_code: 'Znovu odeslat kód',
verification_code_has_been_resent: 'Ověřovací kód byl znovu odeslán',
please_fill_all_required_fields: 'Prosím vyplňte všechny povinné položky',
set_today: 'Nastavit Dnes',
date: 'Datum',
toggle_multiline_text: 'Přepnout Víceřádkový Text',
email_has_been_sent: 'E-mail byl odeslán'
}
const pt = {
submit_form: 'Enviar Formulário',
type_here: 'Digite aqui',
optional: 'opcional',
select_your_option: 'Selecione sua opção',
complete_hightlighted_checkboxes_and_click: 'Complete as caixas de seleção destacadas e clique',
submit: 'enviar',
next: 'próximo',
click_to_upload: 'Clique para fazer o upload',
or_drag_and_drop_files: 'ou arraste e solte arquivos',
send_copy_via_email: 'Enviar cópia por e-mail',
download: 'Baixar',
signature: 'Assinatura',
initials: 'Iniciais',
clear: 'Limpar',
redraw: 'Redesenhar',
draw_initials: 'Desenhar iniciais',
type_signature_here: 'Digite a assinatura aqui',
type_initial_here: 'Digite as iniciais aqui',
form_has_been_completed: 'O formulário foi concluído!',
create_a_free_account: 'Criar uma Conta Gratuita',
signed_with: 'Assinado com',
please_check_the_box_to_continue: 'Por favor, marque a caixa para continuar',
open_source_documents_software: 'software de documentos de código aberto',
verified_phone_number: 'Verificar Número de Telefone',
use_international_format: 'Use formato internacional: +1xxx',
six_digits_code: 'Código de 6 dígitos',
change_phone_number: 'Alterar número de telefone',
sending: 'Enviando...',
resend_code: 'Reenviar código',
verification_code_has_been_resent: 'O código de verificação foi reenviado via SMS',
please_fill_all_required_fields: 'Por favor, preencha todos os campos obrigatórios',
set_today: 'Definir Hoje',
date: 'Data',
toggle_multiline_text: 'Alternar Texto Multilinha',
email_has_been_sent: 'Email enviado'
}
const i18n = { en, es, it, de, fr, pl, uk, cs, pt }
const browserLanguage = (navigator.language || navigator.userLanguage || 'en').split('-')[0]
const t = (key) => i18n[browserLanguage]?.[key] || i18n.en[key] || key
export default i18n
export { t }
@@ -0,0 +1,269 @@
<template>
<div>
<div class="flex justify-between items-center w-full mb-2">
<label
class="label text-2xl"
>{{ field.name || t('initials') }}</label>
<div class="space-x-2 flex">
<span
class="tooltip"
:data-tip="t('draw_initials')"
>
<a
id="type_text_button"
href="#"
class="btn btn-sm btn-circle"
:class="{ 'btn-neutral': isDrawInitials, 'btn-outline': !isDrawInitials }"
@click.prevent="toggleTextInput"
>
<IconSignature :width="16" />
</a>
</span>
<a
v-if="modelValue || computedPreviousValue"
href="#"
class="btn btn-outline btn-sm"
@click.prevent="remove"
>
<IconReload :width="16" />
{{ t('clear') }}
</a>
<a
v-else
href="#"
class="btn btn-outline btn-sm"
@click.prevent="clear"
>
<IconReload :width="16" />
{{ t('clear') }}
</a>
<a
title="Minimize"
href="#"
class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')"
>
<IconArrowsDiagonalMinimize2
:width="20"
:height="20"
/>
</a>
</div>
</div>
<input
:value="modelValue || computedPreviousValue"
type="hidden"
:name="`values[${field.uuid}]`"
>
<img
v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
class="mx-auto bg-white border border-base-300 rounded max-h-72"
>
<canvas
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
class="bg-white border border-base-300 rounded"
/>
<input
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
id="initials_text_input"
ref="textInput"
class="base-input !text-2xl w-full mt-6 text-center"
:required="field.required && !isInitialsStarted"
:placeholder="`${t('type_initial_here')}...`"
type="text"
@focus="$emit('focus')"
@input="updateWrittenInitials"
>
</div>
</template>
<script>
import { cropCanvasAndExportToPNG } from './crop_canvas'
import { IconReload, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import SignaturePad from 'signature_pad'
export default {
name: 'InitialsStep',
components: {
IconReload,
IconSignature,
IconArrowsDiagonalMinimize2
},
inject: ['baseUrl', 't'],
props: {
field: {
type: Object,
required: true
},
submitterSlug: {
type: String,
required: true
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
},
attachmentsIndex: {
type: Object,
required: false,
default: () => ({})
},
previousValue: {
type: String,
required: false,
default: ''
},
modelValue: {
type: String,
required: false,
default: ''
}
},
emits: ['attached', 'update:model-value', 'start', 'minimize', 'focus'],
data () {
return {
isInitialsStarted: !!this.previousValue,
isUsePreviousValue: true,
isDrawInitials: false
}
},
computed: {
computedPreviousValue () {
if (this.isUsePreviousValue) {
return this.previousValue
} else {
return null
}
}
},
async mounted () {
this.$nextTick(() => {
if (this.$refs.canvas) {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 5
}
this.$refs.textInput?.focus()
})
if (this.isDirectUpload) {
import('@rails/activestorage')
}
if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas)
this.pad.addEventListener('beginStroke', () => {
this.isInitialsStarted = true
this.$emit('start')
})
}
},
methods: {
remove () {
this.$emit('update:model-value', '')
this.isUsePreviousValue = false
this.isInitialsStarted = false
},
clear () {
this.pad.clear()
this.isInitialsStarted = false
if (this.$refs.textInput) {
this.$refs.textInput.value = ''
}
},
updateWrittenInitials (e) {
this.isInitialsStarted = true
const canvas = this.$refs.canvas
const context = canvas.getContext('2d')
const fontFamily = 'Arial'
const fontSize = '44px'
const fontStyle = 'italic'
const fontWeight = ''
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily
context.textAlign = 'center'
context.clearRect(0, 0, canvas.width, canvas.height)
context.fillText(e.target.value, canvas.width / 2, canvas.height / 2 + 11)
},
toggleTextInput () {
this.remove()
this.clear()
this.isDrawInitials = !this.isDrawInitials
if (!this.isDrawInitials) {
this.$nextTick(() => {
this.$refs.textInput.focus()
this.$emit('start')
})
}
},
async submit () {
if (this.modelValue || this.computedPreviousValue) {
if (this.computedPreviousValue) {
this.$emit('update:model-value', this.computedPreviousValue)
}
return Promise.resolve({})
}
return new Promise((resolve) => {
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
const file = new File([blob], 'initials.png', { type: 'image/png' })
if (this.isDirectUpload) {
const { DirectUpload } = await import('@rails/activestorage')
new DirectUpload(
file,
'/direct_uploads'
).create((_error, data) => {
fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: JSON.stringify({
submitter_slug: this.submitterSlug,
blob_signed_id: data.signed_id,
name: 'attachments'
}),
headers: { 'Content-Type': 'application/json' }
}).then((resp) => resp.json()).then((attachment) => {
this.$emit('update:model-value', attachment.uuid)
this.$emit('attached', attachment)
return resolve(attachment)
})
})
} else {
const formData = new FormData()
formData.append('file', file)
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: formData
}).then((resp) => resp.json()).then((attachment) => {
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
return resolve(attachment)
})
}
})
})
}
}
}
</script>
@@ -21,7 +21,7 @@
:name="`values[${field.uuid}][]`"
:value="option"
class="base-checkbox !h-7 !w-7"
:checked="modelValue.includes(option)"
:checked="(modelValue || []).includes(option)"
@change="onChange"
>
<span class="text-xl">
@@ -0,0 +1,157 @@
<template>
<div>
<label
:for="isCodeSent ? 'one_time_code' : field.uuid"
class="label text-2xl mb-2"
>{{ field.name || t('verified_phone_number') }}
<template v-if="!field.required">({{ t('optional') }})</template>
</label>
<div>
<input
type="hidden"
name="normalize_phone"
value="true"
>
<div v-if="isCodeSent">
<input
id="one_time_code"
class="base-input !text-2xl w-full text-center"
name="one_time_code"
type="text"
autocomplete="one-time-code"
:placeholder="t('six_digits_code')"
required
maxlength="6"
autofocus
inputmode="decimal"
@input="onInputCode"
>
<div class="flex justify-between mt-2 -mb-2 md:-mb-4">
<a
v-if="!defaultValue"
href="#"
class="link"
@click.prevent="isCodeSent = false"
>
{{ t('change_phone_number') }}
</a>
<a
href="#"
class="link"
@click.prevent="resendCode"
>
{{ isResendLoading ? t('sending') : t('resend_code') }}
</a>
</div>
</div>
<input
v-show="!isCodeSent"
:id="field.uuid"
ref="phone"
:value="modelValue || defaultValue"
:readonly="!!defaultValue"
class="base-input !text-2xl w-full"
autocomplete="tel"
pattern="^\+[0-9\s\-]+$"
:oninvalid="`this.value ? this.setCustomValidity('${t('use_international_format')}...') : ''`"
oninput="this.setCustomValidity('')"
type="tel"
inputmode="tel"
:required="field.required"
placeholder="+1 234 567-8900"
:name="`values[${field.uuid}]`"
@input="$emit('update:model-value', $event.target.value)"
@focus="$emit('focus')"
>
</div>
</div>
</template>
<script>
function throttle (func, delay) {
let lastCallTime = 0
return function (...args) {
const now = Date.now()
if (now - lastCallTime >= delay) {
func.apply(this, args)
lastCallTime = now
}
}
}
export default {
name: 'PhoneStep',
inject: ['t', 'baseUrl'],
props: {
field: {
type: Object,
required: true
},
submitterSlug: {
type: String,
required: true
},
modelValue: {
type: String,
required: false,
default: ''
},
defaultValue: {
type: String,
required: false,
default: ''
}
},
emits: ['update:model-value', 'focus', 'submit'],
data () {
return {
isCodeSent: false,
isResendLoading: false
}
},
methods: {
emitSubmit: throttle(function (e) {
this.$emit('submit')
}, 1000),
onInputCode (e) {
if (e.target.value.length === 6) {
this.emitSubmit()
}
},
resendCode () {
this.isResendLoading = true
this.sendVerificationCode().finally(() => {
alert(this.t('verification_code_has_been_resent'))
this.isResendLoading = false
})
},
sendVerificationCode () {
return fetch(this.baseUrl + '/api/send_phone_verification_code', {
method: 'POST',
body: JSON.stringify({
submitter_slug: this.submitterSlug,
phone: this.$refs.phone.value
}),
headers: { 'Content-Type': 'application/json' }
})
},
async submit () {
if (!this.isCodeSent) {
this.sendVerificationCode()
this.$emit('update:model-value', this.$refs.phone.value)
this.isCodeSent = true
return Promise.reject(new Error('verify with code'))
} else {
return Promise.resolve({})
}
}
}
}
</script>
@@ -3,19 +3,21 @@
<div class="flex justify-between items-center w-full mb-2">
<label
class="label text-2xl"
>{{ field.name || 'Signature' }}</label>
>{{ field.name || t('signature') }}</label>
<div class="space-x-2 flex">
<span
class="tooltip"
data-tip="Type text"
>
<button
<a
id="type_text_button"
href="#"
class="btn btn-sm btn-circle"
:class="{ 'btn-neutral': isTextSignature, 'btn-outline': !isTextSignature }"
@click.prevent="toggleTextInput"
>
<IconTextSize :width="16" />
</button>
</a>
</span>
<span
class="tooltip"
@@ -33,23 +35,26 @@
>
</label>
</span>
<button
v-if="modelValue"
<a
v-if="modelValue || computedPreviousValue"
href="#"
class="btn btn-outline btn-sm"
@click.prevent="remove"
>
<IconReload :width="16" />
Redraw
</button>
<button
{{ t('redraw') }}
</a>
<a
v-else
href="#"
class="btn btn-outline btn-sm"
@click.prevent="clear"
>
<IconReload :width="16" />
Clear
</button>
<button
{{ t('clear') }}
</a>
<a
href="#"
title="Minimize"
class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')"
@@ -58,30 +63,31 @@
:width="20"
:height="20"
/>
</button>
</a>
</div>
</div>
<input
:value="modelValue"
:value="modelValue || computedPreviousValue"
type="hidden"
:name="`values[${field.uuid}]`"
>
<img
v-if="modelValue"
:src="attachmentsIndex[modelValue].url"
v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
class="mx-auto bg-white border border-base-300 rounded max-h-72"
>
<canvas
v-show="!modelValue"
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
class="bg-white border border-base-300 rounded"
/>
<input
v-if="isTextSignature"
id="signature_text_input"
ref="textInput"
class="base-input !text-2xl w-full mt-6"
:required="field.required"
:placeholder="`Type signature here...`"
:placeholder="`${t('type_signature_here')}...`"
type="text"
@input="updateWrittenSignature"
>
@@ -90,6 +96,10 @@
<script>
import { IconReload, IconCamera, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import { cropCanvasAndExportToPNG } from './crop_canvas'
import SignaturePad from 'signature_pad'
let isFontLoaded = false
export default {
name: 'SignatureStep',
@@ -99,6 +109,7 @@ export default {
IconTextSize,
IconArrowsDiagonalMinimize2
},
inject: ['baseUrl', 't'],
props: {
field: {
type: Object,
@@ -118,6 +129,11 @@ export default {
required: false,
default: () => ({})
},
previousValue: {
type: String,
required: false,
default: ''
},
modelValue: {
type: String,
required: false,
@@ -127,33 +143,61 @@ export default {
emits: ['attached', 'update:model-value', 'start', 'minimize'],
data () {
return {
isSignatureStarted: false,
isSignatureStarted: !!this.previousValue,
isUsePreviousValue: true,
isTextSignature: false
}
},
computed: {
computedPreviousValue () {
if (this.isUsePreviousValue) {
return this.previousValue
} else {
return null
}
}
},
async mounted () {
this.$nextTick(() => {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 3
if (this.$refs.canvas) {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 3
}
})
if (this.isDirectUpload) {
import('@rails/activestorage')
}
const { default: SignaturePad } = await import('signature_pad')
if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas)
this.pad = new SignaturePad(this.$refs.canvas)
this.pad.addEventListener('beginStroke', () => {
this.isSignatureStarted = true
this.pad.addEventListener('beginStroke', () => {
this.isSignatureStarted = true
this.$emit('start')
})
this.$emit('start')
})
}
},
methods: {
remove () {
this.$emit('update:model-value', '')
this.isUsePreviousValue = false
this.isSignatureStarted = false
},
loadFont () {
if (!isFontLoaded) {
const font = new FontFace('Dancing Script', `url(${this.baseUrl}/fonts/DancingScript.otf) format("opentype")`)
font.load().then((loadedFont) => {
document.fonts.add(loadedFont)
isFontLoaded = true
}).catch((error) => {
console.error('Font loading failed:', error)
})
}
},
clear () {
this.pad.clear()
@@ -170,8 +214,8 @@ export default {
const canvas = this.$refs.canvas
const context = canvas.getContext('2d')
const fontFamily = 'Arial'
const fontSize = '44px'
const fontFamily = 'Dancing Script'
const fontSize = '38px'
const fontStyle = 'italic'
const fontWeight = ''
@@ -189,6 +233,8 @@ export default {
this.$nextTick(() => {
this.$refs.textInput.focus()
this.loadFont()
this.$emit('start')
})
}
@@ -241,60 +287,17 @@ export default {
reader.readAsDataURL(file)
}
},
cropCanvasAndExportToPNG (canvas) {
const ctx = canvas.getContext('2d')
const width = canvas.width
const height = canvas.height
let topmost = height
let bottommost = 0
let leftmost = width
let rightmost = 0
const imageData = ctx.getImageData(0, 0, width, height)
const pixels = imageData.data
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const pixelIndex = (y * width + x) * 4
const alpha = pixels[pixelIndex + 3]
if (alpha !== 0) {
topmost = Math.min(topmost, y)
bottommost = Math.max(bottommost, y)
leftmost = Math.min(leftmost, x)
rightmost = Math.max(rightmost, x)
}
}
}
const croppedWidth = rightmost - leftmost + 1
const croppedHeight = bottommost - topmost + 1
const croppedCanvas = document.createElement('canvas')
croppedCanvas.width = croppedWidth
croppedCanvas.height = croppedHeight
const croppedCtx = croppedCanvas.getContext('2d')
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
return new Promise((resolve, reject) => {
croppedCanvas.toBlob((blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('Failed to create a PNG blob.'))
}
}, 'image/png')
})
},
async submit () {
if (this.modelValue) {
if (this.modelValue || this.computedPreviousValue) {
if (this.computedPreviousValue) {
this.$emit('update:model-value', this.computedPreviousValue)
}
return Promise.resolve({})
}
return new Promise((resolve) => {
this.cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
const file = new File([blob], 'signature.png', { type: 'image/png' })
if (this.isDirectUpload) {
@@ -304,7 +307,7 @@ export default {
file,
'/direct_uploads'
).create((_error, data) => {
fetch('/api/attachments', {
fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: JSON.stringify({
submitter_slug: this.submitterSlug,
@@ -326,12 +329,12 @@ export default {
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
return fetch('/api/attachments', {
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: formData
}).then((resp) => resp.json()).then((attachment) => {
this.$emit('update:model-value', attachment.uuid)
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
return resolve(attachment)
})
@@ -0,0 +1,120 @@
<template>
<label
v-if="field.name"
:for="field.uuid"
class="label text-2xl mb-2"
>{{ field.name }}
<template v-if="!field.required">({{ t('optional') }})</template>
</label>
<div
v-else
class="py-1"
/>
<div class="items-center flex">
<input
v-if="!isTextArea"
:id="field.uuid"
v-model="text"
class="base-input !text-2xl w-full !pr-11 -mr-10"
:required="field.required"
:pattern="field.validation?.pattern"
:oninvalid="field.validation?.message ? `this.setCustomValidity(${JSON.stringify(field.validation.message)})` : ''"
:oninput="field.validation?.message ? `this.setCustomValidity('')` : ''"
:placeholder="`${t('type_here')}...${field.required ? '' : ` (${t('optional')})`}`"
type="text"
:name="`values[${field.uuid}]`"
@focus="$emit('focus')"
>
<textarea
v-if="isTextArea"
:id="field.uuid"
ref="textarea"
v-model="text"
class="base-textarea !text-2xl w-full"
:placeholder="`${t('type_here')}...${field.required ? '' : ` (${t('optional')})`}`"
:required="field.required"
:name="`values[${field.uuid}]`"
@input="resizeTextarea"
@focus="$emit('focus')"
/>
<div
v-if="!isTextArea"
class="tooltip"
:data-tip="t('toggle_multiline_text')"
>
<a
href="#"
class="btn btn-ghost btn-circle btn-sm"
@click.prevent="toggleTextArea"
>
<IconAlignBoxLeftTop />
</a>
</div>
</div>
</template>
<script>
import { IconAlignBoxLeftTop } from '@tabler/icons-vue'
export default {
name: 'TextStep',
components: {
IconAlignBoxLeftTop
},
inject: ['t'],
props: {
field: {
type: Object,
required: true
},
modelValue: {
type: String,
required: false,
default: ''
}
},
emits: ['update:model-value', 'focus'],
data () {
return {
isTextArea: false
}
},
computed: {
text: {
set (value) {
this.$emit('update:model-value', value)
},
get () {
return this.modelValue
}
}
},
mounted () {
this.isTextArea = this.modelValue?.includes('\n')
if (this.isTextArea) {
this.$nextTick(() => {
this.resizeTextarea()
})
}
},
methods: {
resizeTextarea () {
const textarea = this.$refs.textarea
textarea.style.height = 'auto'
textarea.style.height = textarea.scrollHeight + 'px'
},
toggleTextArea () {
this.isTextArea = true
this.$nextTick(() => {
this.$refs.textarea.focus()
this.$refs.textarea.setSelectionRange(this.$refs.textarea.value.length, this.$refs.textarea.value.length)
this.resizeTextarea()
})
}
}
}
</script>
+138 -33
View File
@@ -4,6 +4,7 @@
:style="positionStyle"
@pointerdown.stop
@mousedown.stop="startDrag"
@touchstart="startTouchDrag"
>
<div
v-if="isSelected || isDraw"
@@ -22,7 +23,7 @@
:style="{ left: (cellW / area.w * 100) + '%' }"
>
<span
v-if="index === 0"
v-if="index === 0 && editable"
class="h-2.5 w-2.5 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-ew-resize z-10"
style="left: -4px"
@mousedown.stop="startResizeCell"
@@ -32,7 +33,7 @@
<div
v-if="field?.type"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap group-hover:flex group-hover:z-10"
:class="{ 'flex z-10': isNameFocus || isSelected, hidden: !isNameFocus && !isSelected }"
:class="{ 'flex z-10': isNameFocus || isSelected, invisible: !isNameFocus && !isSelected }"
style="top: -25px; height: 25px"
@mousedown.stop
@pointerdown.stop
@@ -41,6 +42,7 @@
v-model="field.submitter_uuid"
class="border-r"
:compact="true"
:editable="editable"
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px]'"
:submitters="template.submitters"
@update:model-value="save"
@@ -49,6 +51,7 @@
<FieldType
v-model="field.type"
:button-width="27"
:editable="editable"
:button-classes="'px-1'"
:menu-classes="'bg-white rounded-t-none'"
@update:model-value="[maybeUpdateOptions(), save()]"
@@ -57,7 +60,7 @@
<span
v-if="field.type !== 'checkbox' || field.name"
ref="name"
contenteditable
:contenteditable="editable"
class="pr-1 cursor-text outline-none block"
style="min-width: 2px"
@keydown.enter.prevent="onNameEnter"
@@ -83,7 +86,7 @@
>Required</label>
</div>
<button
v-else
v-else-if="editable"
class="pr-1"
title="Remove"
@click.prevent="$emit('remove')"
@@ -92,28 +95,39 @@
</button>
</div>
<div
class="opacity-50 flex items-center justify-center h-full w-full"
:class="bgColors[submitterIndex]"
class="flex items-center h-full w-full"
:class="[bgColors[submitterIndex], field?.default_value ? '' : 'justify-center']"
>
<span
v-if="field"
class="flex justify-center items-center space-x-1 h-full"
>
<div
v-if="field?.default_value"
class="text-[1.5vw] lg:text-base"
>
<div class="flex items-center px-0.5">
<span class="whitespace-pre-wrap">{{ field.default_value }}</span>
</div>
</div>
<component
:is="fieldIcons[field.type]"
v-else
width="100%"
height="100%"
class="max-h-10"
class="max-h-10 opacity-50"
/>
</span>
</div>
<div
ref="touchTarget"
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer"
/>
<span
v-if="field?.type"
class="h-2.5 w-2.5 -right-1 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-nwse-resize"
v-if="field?.type && editable"
class="h-4 w-4 md:h-2.5 md:w-2.5 -right-1 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-nwse-resize"
@mousedown.stop="startResize"
@touchstart="startTouchResize"
/>
</div>
</template>
@@ -142,6 +156,11 @@ export default {
required: false,
default: false
},
editable: {
type: Boolean,
required: false,
default: true
},
field: {
type: Object,
required: false,
@@ -182,20 +201,30 @@ export default {
},
borderColors () {
return [
'border-red-500',
'border-sky-500',
'border-emerald-500',
'border-yellow-300',
'border-purple-600'
'border-red-500/50',
'border-sky-500/50',
'border-emerald-500/50',
'border-yellow-300/50',
'border-purple-600/50',
'border-pink-500/50',
'border-cyan-500/50',
'border-orange-500/50',
'border-lime-500/50',
'border-indigo-500/50'
]
},
bgColors () {
return [
'bg-red-100',
'bg-sky-100',
'bg-emerald-100',
'bg-yellow-100',
'bg-purple-100'
'bg-red-100/50',
'bg-sky-100/50',
'bg-emerald-100/50',
'bg-yellow-100/50',
'bg-purple-100/50',
'bg-pink-100/50',
'bg-cyan-100/50',
'bg-orange-100/50',
'bg-lime-100/50',
'bg-indigo-100/50'
]
},
isSelected () {
@@ -226,14 +255,14 @@ export default {
}
},
startResizeCell (e) {
document.addEventListener('mousemove', this.onResizeCell)
document.addEventListener('mouseup', this.stopResizeCell)
this.$el.getRootNode().addEventListener('mousemove', this.onResizeCell)
this.$el.getRootNode().addEventListener('mouseup', this.stopResizeCell)
this.$emit('start-resize', 'ew')
},
stopResizeCell (e) {
document.removeEventListener('mousemove', this.onResizeCell)
document.removeEventListener('mouseup', this.stopResizeCell)
this.$el.getRootNode().removeEventListener('mousemove', this.onResizeCell)
this.$el.getRootNode().removeEventListener('mouseup', this.stopResizeCell)
this.$emit('stop-resize')
@@ -249,6 +278,8 @@ export default {
}
},
maybeUpdateOptions () {
delete this.field.default_value
if (!['radio', 'multiple', 'select'].includes(this.field.type)) {
delete this.field.options
}
@@ -266,11 +297,13 @@ export default {
})
},
onNameBlur (e) {
const text = this.$refs.name.innerText.trim()
this.isNameFocus = false
this.$refs.name.style.minWidth = ''
if (e.target.innerText.trim()) {
this.field.name = e.target.innerText.trim()
if (text) {
this.field.name = text
} else {
this.field.name = ''
this.$refs.name.innerText = this.defaultName
@@ -298,18 +331,63 @@ export default {
startDrag (e) {
this.selectedAreaRef.value = this.area
if (!this.editable) {
return
}
const rect = e.target.getBoundingClientRect()
this.dragFrom = { x: e.clientX - rect.left, y: e.clientY - rect.top }
document.addEventListener('mousemove', this.drag)
document.addEventListener('mouseup', this.stopDrag)
this.$el.getRootNode().addEventListener('mousemove', this.drag)
this.$el.getRootNode().addEventListener('mouseup', this.stopDrag)
this.$emit('start-drag')
},
startTouchDrag (e) {
if (e.target !== this.$refs.touchTarget) {
return
}
this.$refs?.name?.blur()
e.preventDefault()
this.isDragged = true
const rect = e.target.getBoundingClientRect()
this.selectedAreaRef.value = this.area
this.dragFrom = { x: rect.left - e.touches[0].clientX, y: rect.top - e.touches[0].clientY }
this.$el.getRootNode().addEventListener('touchmove', this.touchDrag)
this.$el.getRootNode().addEventListener('touchend', this.stopTouchDrag)
this.$emit('start-drag')
},
touchDrag (e) {
const page = this.$parent.$refs.mask.previousSibling
const rect = page.getBoundingClientRect()
this.area.x = (this.dragFrom.x + e.touches[0].clientX - rect.left) / rect.width
this.area.y = (this.dragFrom.y + e.touches[0].clientY - rect.top) / rect.height
},
stopTouchDrag () {
this.$el.getRootNode().removeEventListener('touchmove', this.touchDrag)
this.$el.getRootNode().removeEventListener('touchend', this.stopTouchDrag)
if (this.isDragged) {
this.save()
}
this.isDragged = false
this.$emit('stop-drag')
},
stopDrag () {
document.removeEventListener('mousemove', this.drag)
document.removeEventListener('mouseup', this.stopDrag)
this.$el.getRootNode().removeEventListener('mousemove', this.drag)
this.$el.getRootNode().removeEventListener('mouseup', this.stopDrag)
if (this.isDragged) {
this.save()
@@ -322,14 +400,41 @@ export default {
startResize () {
this.selectedAreaRef.value = this.area
document.addEventListener('mousemove', this.resize)
document.addEventListener('mouseup', this.stopResize)
this.$el.getRootNode().addEventListener('mousemove', this.resize)
this.$el.getRootNode().addEventListener('mouseup', this.stopResize)
this.$emit('start-resize', 'nwse')
},
stopResize () {
document.removeEventListener('mousemove', this.resize)
document.removeEventListener('mouseup', this.stopResize)
this.$el.getRootNode().removeEventListener('mousemove', this.resize)
this.$el.getRootNode().removeEventListener('mouseup', this.stopResize)
this.$emit('stop-resize')
this.save()
},
startTouchResize (e) {
this.selectedAreaRef.value = this.area
this.$refs?.name?.blur()
e.preventDefault()
this.$el.getRootNode().addEventListener('touchmove', this.touchResize)
this.$el.getRootNode().addEventListener('touchend', this.stopTouchResize)
this.$emit('start-resize', 'nwse')
},
touchResize (e) {
const page = this.$parent.$refs.mask.previousSibling
const rect = page.getBoundingClientRect()
this.area.w = (e.touches[0].clientX - rect.left) / rect.width - this.area.x
this.area.h = (e.touches[0].clientY - rect.top) / rect.height - this.area.y
},
stopTouchResize () {
this.$el.getRootNode().removeEventListener('touchmove', this.touchResize)
this.$el.getRootNode().removeEventListener('touchend', this.stopTouchResize)
this.$emit('stop-resize')
+359 -95
View File
@@ -1,61 +1,73 @@
<template>
<div
style="max-width: 1600px"
class="mx-auto pl-4"
class="mx-auto pl-3 md:pl-4 h-full"
>
<div class="flex justify-between py-1.5 items-center pr-4">
<div
class="flex justify-between py-1.5 items-center pr-4 sticky top-0 z-10"
:style="{ backgroundColor }"
>
<div class="flex space-x-3">
<a href="/">
<a
v-if="withLogo"
href="/"
>
<Logo />
</a>
<Contenteditable
:model-value="template.name"
:editable="editable"
class="text-3xl font-semibold focus:text-clip"
:icon-stroke-width="2.3"
@update:model-value="updateName"
/>
</div>
<div class="space-x-3 flex items-center">
<a
:href="`/templates/${template.id}/submissions/new`"
data-turbo-frame="modal"
class="btn btn-primary"
>
<IconUsersPlus
width="20"
class="inline"
/>
<span class="hidden md:inline">
Recipients
</span>
</a>
<button
class="base-button"
:class="{ disabled: isSaving }"
v-bind="isSaving ? { disabled: true } : {}"
@click.prevent="onSaveClick"
>
<IconInnerShadowTop
v-if="isSaving"
width="20"
class="animate-spin"
/>
<IconDeviceFloppy
v-else
width="20"
/>
<span class="hidden md:inline">
Save
</span>
</button>
<slot
v-if="$slots.buttons"
name="buttons"
/>
<template v-else>
<a
:href="`/templates/${template.id}/submissions/new`"
data-turbo-frame="modal"
class="btn btn-primary text-base"
@click="maybeShowEmptyTemplateAlert"
>
<IconUsersPlus
width="20"
class="inline"
/>
<span class="hidden md:inline">
Recipients
</span>
</a>
<button
class="base-button"
:class="{ disabled: isSaving }"
v-bind="isSaving ? { disabled: true } : {}"
@click.prevent="onSaveClick"
>
<IconInnerShadowTop
v-if="isSaving"
width="22"
class="animate-spin"
/>
<IconDeviceFloppy
v-else
width="22"
/>
<span class="hidden md:inline">
Save
</span>
</button>
</template>
</div>
</div>
<div
class="flex"
style="max-height: calc(100vh - 60px)"
>
<div class="flex md:max-h-[calc(100vh-60px)]">
<div
ref="previews"
:style="{ 'display': isBreakpointLg ? 'none' : 'initial' }"
class="overflow-y-auto overflow-x-hidden w-52 flex-none pr-3 mt-0.5 pt-0.5 hidden lg:block"
>
<DocumentPreview
@@ -64,6 +76,8 @@
:with-arrows="template.schema.length > 1"
:item="item"
:document="sortedDocuments[index]"
:accept-file-types="acceptFileTypes"
:editable="editable"
:template="template"
:is-direct-upload="isDirectUpload"
@scroll-to="scrollIntoDocument(item)"
@@ -73,16 +87,20 @@
@down="moveDocument(item, 1)"
@change="save"
/>
<div class="sticky bottom-0 bg-base-100 py-2">
<div
class="sticky bottom-0 py-2"
:class="{ 'bg-base-100': withStickySubmitters }"
>
<Upload
v-if="sortedDocuments.length"
v-if="sortedDocuments.length && editable"
:accept-file-types="acceptFileTypes"
:template-id="template.id"
:is-direct-upload="isDirectUpload"
@success="updateFromUpload"
/>
</div>
</div>
<div class="w-full overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5">
<div class="w-full overflow-y-hidden md:overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5">
<div
ref="documents"
class="pr-3.5 pl-0.5"
@@ -90,39 +108,105 @@
<Dropzone
v-if="!sortedDocuments.length"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
:is-direct-upload="isDirectUpload"
@success="updateFromUpload"
/>
<template v-else>
<Document
<template
v-for="document in sortedDocuments"
:key="document.uuid"
:ref="setDocumentRefs"
:areas-index="fieldAreasIndex[document.uuid]"
:selected-submitter="selectedSubmitter"
:document="document"
:is-drag="!!dragFieldType"
:draw-field="drawField"
@draw="onDraw"
@drop-field="onDropfield"
@remove-area="removeArea"
/>
>
<Document
:ref="setDocumentRefs"
:areas-index="fieldAreasIndex[document.uuid]"
:selected-submitter="selectedSubmitter"
:document="document"
:is-drag="!!dragFieldType"
:draw-field="drawField"
:editable="editable"
@draw="onDraw"
@drop-field="onDropfield"
@remove-area="removeArea"
/>
<DocumentControls
v-if="isBreakpointLg && editable"
:with-arrows="template.schema.length > 1"
:item="template.schema.find((item) => item.attachment_uuid === document.uuid)"
:document="document"
:template="template"
:is-direct-upload="isDirectUpload"
class="pb-2 mb-2 border-b border-base-300 border-dashed"
@remove="onDocumentRemove"
@replace="onDocumentReplace"
@up="moveDocument(template.schema.find((item) => item.attachment_uuid === document.uuid), -1)"
@down="moveDocument(template.schema.find((item) => item.attachment_uuid === document.uuid), 1)"
@change="save"
/>
</template>
<div
v-if="sortedDocuments.length && isBreakpointLg && editable"
class="pb-4"
>
<Upload
:template-id="template.id"
:is-direct-upload="isDirectUpload"
@success="updateFromUpload"
/>
</div>
</template>
</div>
<div
v-if="sortedDocuments.length"
class="sticky md:hidden"
style="bottom: 100px"
<span
v-if="drawField"
class="fixed text-center w-full left-1/2 bottom-0 transform -translate-x-1/2"
>
<div class="px-4 py-3 rounded-2xl bg-base-200 flex items-center justify-between ml-4 mr-6">
<span class="w-full text-center text-lg">
You need a larger screen to use builder tools.
<span
class="rounded bg-base-200 px-4 py-2 rounded-full inline-flex space-x-2 mx-auto items-center mb-4 z-20 md:hidden"
>
<component
:is="fieldIcons[drawField.type]"
:width="20"
:height="20"
class="inline"
:stroke-width="1.6"
/>
<span>
Draw {{ fieldNames[drawField.type] }} Field
</span>
</div>
</div>
<a
href="#"
class="link block text-center"
@click.prevent="drawField = null"
>
Cancel
</a>
</span>
</span>
<FieldType
v-if="sortedDocuments.length && !drawField && editable"
class="dropdown-top dropdown-end fixed bottom-4 right-4 z-10 md:hidden"
:model-value="''"
@update:model-value="startFieldDraw($event)"
>
<label
class="btn btn-neutral text-white btn-circle btn-lg group"
tabindex="0"
>
<IconPlus
class="group-focus:hidden"
width="28"
height="28"
/>
<IconX
class="hidden group-focus:inline"
width="28"
height="28"
/>
</label>
</FieldType>
</div>
<div
class="relative w-80 flex-none pt-0.5 pr-4 pl-0.5 hidden md:block"
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block"
:class="drawField ? 'overflow-hidden' : 'overflow-auto'"
>
<div
@@ -149,6 +233,8 @@
:fields="template.fields"
:submitters="template.submitters"
:selected-submitter="selectedSubmitter"
:with-sticky-submitters="withStickySubmitters"
:editable="editable"
@set-draw="drawField = $event"
@set-drag="dragFieldType = $event"
@change-submitter="selectedSubmitter = $event"
@@ -169,7 +255,9 @@ import Document from './document'
import Logo from './logo'
import Contenteditable from './contenteditable'
import DocumentPreview from './preview'
import { IconUsersPlus, IconDeviceFloppy, IconInnerShadowTop } from '@tabler/icons-vue'
import DocumentControls from './controls'
import FieldType from './field_type'
import { IconUsersPlus, IconDeviceFloppy, IconInnerShadowTop, IconPlus, IconX } from '@tabler/icons-vue'
import { v4 } from 'uuid'
import { ref, computed } from 'vue'
@@ -179,9 +267,13 @@ export default {
Upload,
Document,
Fields,
IconPlus,
FieldType,
IconX,
Logo,
Dropzone,
DocumentPreview,
DocumentControls,
IconInnerShadowTop,
Contenteditable,
IconUsersPlus,
@@ -191,6 +283,9 @@ export default {
return {
template: this.template,
save: this.save,
baseFetch: this.baseFetch,
backgroundColor: this.backgroundColor,
withPhone: this.withPhone,
selectedAreaRef: computed(() => this.selectedAreaRef)
}
},
@@ -201,13 +296,54 @@ export default {
},
isDirectUpload: {
type: Boolean,
required: true,
required: false,
default: false
},
backgroundColor: {
type: String,
required: false,
default: ''
},
editable: {
type: Boolean,
required: false,
default: true
},
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
baseUrl: {
type: String,
required: false,
default: ''
},
withLogo: {
type: Boolean,
required: false,
default: true
},
withStickySubmitters: {
type: Boolean,
required: false,
default: true
},
withPhone: {
type: Boolean,
required: false,
default: false
},
fetchOptions: {
type: Object,
required: false,
default: () => ({ headers: {} })
}
},
data () {
return {
documentRefs: [],
isBreakpointLg: false,
isSaving: false,
selectedSubmitter: null,
drawField: null,
@@ -215,6 +351,8 @@ export default {
}
},
computed: {
fieldIcons: FieldType.computed.fieldIcons,
fieldNames: FieldType.computed.fieldNames,
selectedAreaRef: () => ref(),
fieldAreasIndex () {
const areas = {}
@@ -244,15 +382,79 @@ export default {
this.selectedSubmitter = this.template.submitters[0]
},
mounted () {
this.undoStack = [JSON.stringify(this.template)]
this.redoStack = []
this.$nextTick(() => {
this.onWindowResize()
})
document.addEventListener('keyup', this.onKeyUp)
window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('resize', this.onWindowResize)
},
unmounted () {
document.removeEventListener('keyup', this.onKeyUp)
window.removeEventListener('keydown', this.onKeyDown)
window.removeEventListener('resize', this.onWindowResize)
},
beforeUpdate () {
this.documentRefs = []
},
methods: {
startFieldDraw (type) {
const field = {
name: '',
uuid: v4(),
required: type !== 'checkbox',
areas: [],
submitter_uuid: this.selectedSubmitter.uuid,
type
}
if (['select', 'multiple', 'radio'].includes(type)) {
field.options = ['']
}
this.drawField = field
},
undo () {
if (this.undoStack.length > 1) {
this.undoStack.pop()
const stringData = this.undoStack[this.undoStack.length - 1]
const currentStringData = JSON.stringify(this.template)
if (stringData && stringData !== currentStringData) {
this.redoStack.push(currentStringData)
Object.assign(this.template, JSON.parse(stringData))
this.save()
}
}
},
redo () {
const stringData = this.redoStack.pop()
this.lastRedoData = stringData
const currentStringData = JSON.stringify(this.template)
if (stringData && stringData !== currentStringData) {
if (this.undoStack[this.undoStack.length - 1] !== currentStringData) {
this.undoStack.push(currentStringData)
}
Object.assign(this.template, JSON.parse(stringData))
this.save()
}
},
onWindowResize (e) {
const breakpointLg = 1024
this.isBreakpointLg = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth < breakpointLg
},
setDocumentRefs (el) {
if (el) {
this.documentRefs.push(el)
@@ -275,6 +477,19 @@ export default {
this.selectedAreaRef.value = null
}
},
onKeyDown (event) {
if ((event.metaKey && event.shiftKey && event.key === 'z') || (event.ctrlKey && event.key === 'Z')) {
event.stopImmediatePropagation()
event.preventDefault()
this.redo()
} else if ((event.ctrlKey || event.metaKey) && event.key === 'z') {
event.stopImmediatePropagation()
event.preventDefault()
this.undo()
}
},
removeArea (area) {
const field = this.template.fields.find((f) => f.areas?.includes(area))
@@ -286,12 +501,31 @@ export default {
this.save()
},
pushUndo () {
const stringData = JSON.stringify(this.template)
if (this.undoStack[this.undoStack.length - 1] !== stringData) {
this.undoStack.push(stringData)
if (this.lastRedoData !== stringData) {
this.redoStack = []
}
}
},
onDraw (area) {
if (this.drawField) {
this.drawField.areas ||= []
this.drawField.areas.push(area)
if (this.template.fields.indexOf(this.drawField) === -1) {
this.template.fields.push(this.drawField)
}
this.drawField = null
this.selectedAreaRef.value = area
this.save()
} else {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
const pageMask = documentRef.pageRefs[area.page].$refs.mask
@@ -302,33 +536,37 @@ export default {
const previousField = [...this.template.fields].reverse().find((f) => f.type === type)
const previousArea = previousField?.areas?.[previousField.areas.length - 1]
const areaW = previousArea?.w || (30 / pageMask.clientWidth)
const areaH = previousArea?.h || (30 / pageMask.clientHeight)
if (previousArea || area.w) {
const areaW = previousArea?.w || (30 / pageMask.clientWidth)
const areaH = previousArea?.h || (30 / pageMask.clientHeight)
if ((pageMask.clientWidth * area.w) < 5) {
area.x = area.x - (areaW / 2)
area.y = area.y - (areaH / 2)
if ((pageMask.clientWidth * area.w) < 5) {
area.x = area.x - (areaW / 2)
area.y = area.y - (areaH / 2)
}
area.w = areaW
area.h = areaH
}
}
if (area.w) {
const field = {
name: '',
uuid: v4(),
required: type !== 'checkbox',
type,
submitter_uuid: this.selectedSubmitter.uuid,
areas: [area]
}
area.w = areaW
area.h = areaH
}
this.template.fields.push(field)
const field = {
name: '',
uuid: v4(),
required: type !== 'checkbox',
type,
submitter_uuid: this.selectedSubmitter.uuid,
areas: [area]
}
this.selectedAreaRef.value = area
this.template.fields.push(field)
this.save()
}
}
this.selectedAreaRef.value = area
this.save()
},
onDropfield (area) {
const field = {
@@ -374,6 +612,11 @@ export default {
w: area.maskW / 5 / area.maskW,
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) / 2
}
} else if (this.dragFieldType === 'initials') {
baseArea = {
w: area.maskW / 10 / area.maskW,
h: area.maskW / 35 / area.maskW
}
} else {
baseArea = {
w: area.maskW / 5 / area.maskW,
@@ -458,14 +701,25 @@ export default {
this.save()
},
onSaveClick () {
this.isSaving = true
maybeShowEmptyTemplateAlert (e) {
if (!this.template.fields.length) {
e.preventDefault()
this.save().then(() => {
window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => {
this.isSaving = false
})
alert('Please draw fields to prepare the document.')
}
},
onSaveClick () {
if (this.template.fields.length) {
this.isSaving = true
this.save().then(() => {
window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => {
this.isSaving = false
})
} else {
alert('Please draw fields to prepare the document.')
}
},
scrollToArea (area) {
const documentRef = this.documentRefs.find((a) => a.document.uuid === area.attachment_uuid)
@@ -474,10 +728,20 @@ export default {
this.selectedAreaRef.value = area
},
baseFetch (path, options = {}) {
return fetch(this.baseUrl + path, {
...options,
headers: { ...this.fetchOptions.headers, ...options.headers }
})
},
save () {
this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template)
if (this.$el.closest('template-builder')) {
this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template)
}
return fetch(`/api/templates/${this.template.id}`, {
this.pushUndo()
return this.baseFetch(`/api/templates/${this.template.id}`, {
method: 'PUT',
body: JSON.stringify({
template: {
@@ -5,7 +5,7 @@
>
<span
ref="contenteditable"
contenteditable
:contenteditable="editable"
style="min-width: 2px"
:class="iconInline ? 'inline' : 'block'"
class="peer outline-none focus:block"
@@ -24,13 +24,14 @@
*
</span>
<IconPencil
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden"
v-if="editable"
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden"
:style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
title="Edit"
:class="{ 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
:width="iconWidth"
:stroke-width="iconStrokeWidth"
@click="focusContenteditable"
@click="[focusContenteditable(), selectOnEditClick && selectContent()]"
/>
</div>
</template>
@@ -64,6 +65,16 @@ export default {
required: false,
default: false
},
selectOnEditClick: {
type: Boolean,
required: false,
default: false
},
editable: {
type: Boolean,
required: false,
default: true
},
iconStrokeWidth: {
type: Number,
required: false,
@@ -85,6 +96,19 @@ export default {
}
},
methods: {
selectContent () {
const el = this.$refs.contenteditable
const range = document.createRange()
range.selectNodeContents(el)
const sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(range)
},
onBlur (e) {
setTimeout(() => {
this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue
@@ -0,0 +1,91 @@
<template>
<div class="flex space-x-2">
<Contenteditable
class="w-full block mr-6"
:model-value="item.name"
:icon-width="16"
@update:model-value="onUpdateName"
/>
<ReplaceButton
:is-direct-upload="isDirectUpload"
:template-id="template.id"
@click.stop
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
/>
<button
v-if="withArrows"
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors"
style="width: 24px; height: 24px"
@click.stop="$emit('up', item)"
>
&uarr;
</button>
<button
v-if="withArrows"
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors"
style="width: 24px; height: 24px"
@click.stop="$emit('down', item)"
>
&darr;
</button>
<button
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors"
style="width: 24px; height: 24px"
@click.stop="$emit('remove', item)"
>
&times;
</button>
</div>
</template>
<script>
import Contenteditable from './contenteditable'
import Upload from './upload'
import ReplaceButton from './replace'
export default {
name: 'DocumentControls',
components: {
Contenteditable,
ReplaceButton
},
props: {
item: {
type: Object,
required: true
},
template: {
type: Object,
required: true
},
document: {
type: Object,
required: true
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
},
withArrows: {
type: Boolean,
required: false,
default: true
}
},
emits: ['change', 'remove', 'up', 'down', 'replace'],
mounted () {
if (this.isDirectUpload) {
import('@rails/activestorage')
}
},
methods: {
upload: Upload.methods.upload,
onUpdateName (value) {
this.item.name = value
this.$emit('change')
}
}
}
</script>
+25 -1
View File
@@ -5,6 +5,7 @@
:key="image.id"
:ref="setPageRefs"
:number="index"
:editable="editable"
:areas="areasIndex[index]"
:is-drag="isDrag"
:draw-field="drawField"
@@ -38,6 +39,11 @@ export default {
type: Object,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
drawField: {
type: Object,
required: false,
@@ -56,8 +62,26 @@ export default {
}
},
computed: {
numberOfPages () {
return this.document.metadata?.pdf?.number_of_pages || this.document.preview_images.length
},
sortedPreviewImages () {
return [...this.document.preview_images].sort((a, b) => parseInt(a.filename) - parseInt(b.filename))
const lazyloadMetadata = this.document.preview_images[this.document.preview_images.length - 1].metadata
return [...Array(this.numberOfPages).keys()].map((i) => {
return this.previewImagesIndex[i] || {
metadata: lazyloadMetadata,
id: Math.random().toString(),
url: `/preview/${this.document.uuid}/${i}.jpg`
}
})
},
previewImagesIndex () {
return this.document.preview_images.reduce((acc, e) => {
acc[parseInt(e.filename)] = e
return acc
}, {})
}
},
beforeUpdate () {
+16 -4
View File
@@ -42,7 +42,7 @@
ref="input"
type="file"
name="files[]"
accept="image/*, application/pdf"
:accept="acceptFileTypes"
multiple
@change="upload"
>
@@ -61,11 +61,17 @@ export default {
IconCloudUpload,
IconInnerShadowTop
},
inject: ['baseFetch'],
props: {
templateId: {
type: [Number, String],
required: true
},
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
isDirectUpload: {
type: Boolean,
required: true,
@@ -88,8 +94,10 @@ export default {
return 'Uploading...'
} else if (this.isProcessing) {
return 'Processing...'
} else {
} else if (this.acceptFileTypes === 'image/*, application/pdf') {
return 'Add PDF documents or images'
} else {
return 'Add documents or images'
}
}
},
@@ -101,9 +109,13 @@ export default {
methods: {
upload: Upload.methods.upload,
onDropFiles (e) {
this.$refs.input.files = e.dataTransfer.files
if (this.acceptFileTypes !== 'image/*, application/pdf' || [...e.dataTransfer.files].every((f) => f.type.match(/(?:image\/)|(?:application\/pdf)/))) {
this.$refs.input.files = e.dataTransfer.files
this.upload()
this.upload()
} else {
alert('Only PDF and images are supported.')
}
}
}
}
+129 -57
View File
@@ -4,8 +4,9 @@
>
<div
class="border border-base-300 rounded rounded-tr-none relative group"
:style="{ backgroundColor: backgroundColor }"
>
<div class="flex items-center justify-between relative">
<div class="flex items-center justify-between relative group/contenteditable-container">
<div
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer"
@click="scrollToFirstArea"
@@ -13,6 +14,7 @@
<div class="flex items-center p-1 space-x-1">
<FieldType
v-model="field.type"
:editable="editable"
:button-width="20"
@update:model-value="[maybeUpdateOptions(), save()]"
@click="scrollToFirstArea"
@@ -20,6 +22,7 @@
<Contenteditable
ref="name"
:model-value="field.name || defaultName"
:editable="editable"
:icon-inline="true"
:icon-width="18"
:icon-stroke-width="1.6"
@@ -31,36 +34,44 @@
v-if="isNameFocus"
class="flex items-center relative"
>
<template v-if="field.type !== 'checkbox'">
<input
:id="`required-checkbox-${field.uuid}`"
v-model="field.required"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
@mousedown.prevent
>
<label
:for="`required-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.required = !field.required"
@mousedown.prevent
>Required</label>
</template>
<input
:id="`required-checkbox-${field.uuid}`"
v-model="field.required"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
@mousedown.prevent
>
<label
:for="`required-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.required = !field.required"
@mousedown.prevent
>Required</label>
</div>
<div
v-else
v-else-if="editable"
class="flex items-center space-x-1"
>
<button
v-if="field && !field.areas.length"
title="Draw"
class="relative cursor-pointer text-transparent group-hover:text-base-content"
@click="$emit('set-draw', field)"
>
<IconNewSection
:width="18"
:stroke-width="1.6"
/>
</button>
<span
v-if="field.areas?.length"
class="dropdown dropdown-end"
>
<label
tabindex="0"
title="Areas"
class="cursor-pointer text-base-100 group-hover:text-base-content"
title="Settings"
class="cursor-pointer text-transparent group-hover:text-base-content"
>
<IconShape
<IconSettings
:width="18"
:stroke-width="1.6"
/>
@@ -68,8 +79,57 @@
<ul
tabindex="0"
class="mt-1.5 dropdown-content menu menu-xs p-2 shadow bg-base-100 rounded-box w-52 z-10"
draggable="true"
@dragstart.prevent.stop
@click="closeDropdown"
>
<div
v-if="field.type === 'text'"
class="py-1.5 px-1 relative"
@click.stop
>
<input
v-model="field.default_value"
type="text"
placeholder="Default value"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
@blur="save"
>
<label
v-if="field.default_value"
:style="{ backgroundColor: backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
Default value
</label>
</div>
<li @click.stop>
<label class="cursor-pointer py-1.5">
<input
v-model="field.required"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="save"
>
<span class="label-text">Required</span>
</label>
</li>
<li
v-if="field.type === 'text'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.readonly"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="save"
>
<span class="label-text">Read-only</span>
</label>
</li>
<hr class="pb-0.5 mt-0.5">
<li
v-for="(area, index) in field.areas || []"
:key="index"
@@ -99,21 +159,23 @@
Draw New Area
</a>
</li>
<li v-if="field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells'].includes(field.type)">
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="copyToAllPages(field)"
>
<IconCopy
:width="20"
:stroke-width="1.6"
/>
Copy to All Pages
</a>
</li>
</ul>
</span>
<button
v-else
title="Areas"
class="relative cursor-pointer text-base-100 group-hover:text-base-content"
@click="$emit('set-draw', field)"
>
<IconShape
:width="18"
:stroke-width="1.6"
/>
</button>
<button
class="relative text-base-100 group-hover:text-base-content"
class="relative text-transparent group-hover:text-base-content pr-1"
title="Remove"
@click="$emit('remove', field)"
>
@@ -122,24 +184,6 @@
:stroke-width="1.6"
/>
</button>
<div class="flex flex-col pr-1 text-base-100 group-hover:text-base-content">
<button
title="Up"
class="relative"
style="font-size: 10px; margin-bottom: -2px"
@click="$emit('move-up')"
>
</button>
<button
title="Down"
class="relative"
style="font-size: 10px; margin-top: -2px"
@click="$emit('move-down')"
>
</button>
</div>
</div>
</div>
<div
@@ -156,7 +200,7 @@
</span>
<input
v-model="field.options[index]"
class="w-full input input-primary input-xs text-sm"
class="w-full input input-primary input-xs text-sm bg-transparent"
type="text"
required
@blur="save"
@@ -183,25 +227,32 @@
<script>
import Contenteditable from './contenteditable'
import FieldType from './field_type'
import { IconShape, IconNewSection, IconTrashX } from '@tabler/icons-vue'
import { IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
export default {
name: 'TemplateField',
components: {
Contenteditable,
IconSettings,
IconShape,
IconNewSection,
IconTrashX,
IconCopy,
FieldType
},
inject: ['template', 'save'],
inject: ['template', 'save', 'backgroundColor'],
props: {
field: {
type: Object,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
}
},
emits: ['set-draw', 'remove', 'move-up', 'move-down', 'scroll-to'],
emits: ['set-draw', 'remove', 'scroll-to'],
data () {
return {
isNameFocus: false
@@ -221,6 +272,23 @@ export default {
}
},
methods: {
copyToAllPages (field) {
const areaString = JSON.stringify(field.areas[0])
this.template.documents.forEach((attachment) => {
attachment.preview_images.forEach((page) => {
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === parseInt(page.filename))) {
field.areas.push({ ...JSON.parse(areaString), page: parseInt(page.filename) })
}
})
})
this.$nextTick(() => {
this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1])
})
this.save()
},
onNameFocus (e) {
this.isNameFocus = true
@@ -237,6 +305,8 @@ export default {
document.activeElement.blur()
},
maybeUpdateOptions () {
delete this.field.default_value
if (!['radio', 'multiple', 'select'].includes(this.field.type)) {
delete this.field.options
}
@@ -254,8 +324,10 @@ export default {
})
},
onNameBlur (e) {
if (e.target.innerText.trim()) {
this.field.name = e.target.innerText.trim()
const text = this.$refs.name.$refs.contenteditable.innerText.trim()
if (text) {
this.field.name = text
} else {
this.field.name = ''
this.$refs.name.$refs.contenteditable.innerText = this.defaultName
@@ -14,7 +14,7 @@
<label
v-else
tabindex="0"
class="cursor-pointer rounded-md p-2 border border-base-300 w-full flex justify-between"
class="cursor-pointer group/contenteditable-container rounded-md p-2 border border-base-300 w-full flex justify-between"
>
<div class="flex items-center space-x-2">
<span
@@ -25,15 +25,21 @@
v-model="selectedSubmitter.name"
class="cursor-text"
:icon-inline="true"
:editable="editable"
:select-on-edit-click="true"
:icon-width="18"
@update:model-value="$emit('name-change', selectedSubmitter)"
/>
</div>
<span>
<span class="flex items-center">
<IconPlus
width="18"
height="18"
/>
</span>
</label>
<ul
v-if="editable || !compact"
tabindex="0"
:class="menuClasses"
@click="closeDropdown"
@@ -58,7 +64,7 @@
</span>
</span>
<button
v-if="!compact && submitters.length > 1"
v-if="!compact && submitters.length > 1 && editable"
class="hidden group-hover:block px-2"
@click.stop="remove(submitter)"
>
@@ -66,7 +72,7 @@
</button>
</a>
</li>
<li v-if="submitters.length < 5">
<li v-if="submitters.length < 10 && editable">
<a
href="#"
class="flex px-2"
@@ -86,7 +92,7 @@
</template>
<script>
import { IconUserPlus, IconTrashX } from '@tabler/icons-vue'
import { IconUserPlus, IconTrashX, IconPlus } from '@tabler/icons-vue'
import Contenteditable from './contenteditable'
import { v4 } from 'uuid'
@@ -95,6 +101,7 @@ export default {
components: {
IconUserPlus,
Contenteditable,
IconPlus,
IconTrashX
},
props: {
@@ -102,6 +109,11 @@ export default {
type: Array,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
compact: {
type: Boolean,
required: false,
@@ -125,7 +137,12 @@ export default {
'bg-sky-500',
'bg-emerald-500',
'bg-yellow-300',
'bg-purple-600'
'bg-purple-600',
'bg-pink-500',
'bg-cyan-500',
'bg-orange-500',
'bg-lime-500',
'bg-indigo-500'
]
},
names () {
@@ -134,7 +151,12 @@ export default {
'Second Submitter',
'Third Submitter',
'Fourth Submitter',
'Fifth Submitter'
'Fifth Submitter',
'Sixth Submitter',
'Seventh Submitter',
'Eighth Submitter',
'Ninth Submitter',
'Tenth Submitter'
]
},
selectedSubmitter () {
+47 -32
View File
@@ -1,50 +1,56 @@
<template>
<span class="dropdown">
<label
tabindex="0"
:title="fieldNames[modelValue]"
class="cursor-pointer"
>
<component
:is="fieldIcons[modelValue]"
:width="buttonWidth"
:class="buttonClasses"
:stroke-width="1.6"
/>
</label>
<slot>
<label
tabindex="0"
:title="fieldNames[modelValue]"
class="cursor-pointer"
>
<component
:is="fieldIcons[modelValue]"
:width="buttonWidth"
:class="buttonClasses"
:stroke-width="1.6"
/>
</label>
</slot>
<ul
v-if="editable"
tabindex="0"
class="dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10"
class="dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10 mb-3"
:class="menuClasses"
@click="closeDropdown"
>
<li
<template
v-for="(icon, type) in fieldIcons"
:key="type"
>
<a
href="#"
class="text-sm py-1 px-2"
:class="{ 'active': type === modelValue }"
@click.prevent="$emit('update:model-value', type)"
>
<component
:is="icon"
:stroke-width="1.6"
:width="20"
/>
{{ fieldNames[type] }}
</a>
</li>
<li v-if="withPhone || type !== 'phone'">
<a
href="#"
class="text-sm py-1 px-2"
:class="{ 'active': type === modelValue }"
@click.prevent="$emit('update:model-value', type)"
>
<component
:is="icon"
:stroke-width="1.6"
:width="20"
/>
{{ fieldNames[type] }}
</a>
</li>
</template>
</ul>
</span>
</template>
<script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3 } from '@tabler/icons-vue'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper } from '@tabler/icons-vue'
export default {
name: 'FiledTypeDropdown',
inject: ['withPhone'],
props: {
modelValue: {
type: String,
@@ -60,6 +66,11 @@ export default {
required: false,
default: ''
},
editable: {
type: Boolean,
required: false,
default: true
},
buttonWidth: {
type: Number,
required: false,
@@ -72,6 +83,7 @@ export default {
return {
text: 'Text',
signature: 'Signature',
initials: 'Initials',
date: 'Date',
image: 'Image',
file: 'File',
@@ -79,21 +91,24 @@ export default {
checkbox: 'Checkbox',
multiple: 'Multiple',
radio: 'Radio',
cells: 'Cells'
cells: 'Cells',
phone: 'Phone'
}
},
fieldIcons () {
return {
text: IconTextSize,
signature: IconWritingSign,
initials: IconLetterCaseUpper,
date: IconCalendarEvent,
image: IconPhoto,
file: IconPaperclip,
select: IconSelect,
checkbox: IconCheckbox,
cells: IconColumns3,
radio: IconCircleDot,
multiple: IconChecks,
radio: IconCircleDot
cells: IconColumns3,
phone: IconPhoneCheck
}
}
},
+124 -69
View File
@@ -1,83 +1,123 @@
<template>
<div class="sticky -top-1 bg-base-100 pt-1 -mt-1 z-10">
<div :class="withStickySubmitters ? 'sticky top-0 z-10' : ''">
<FieldSubmitter
:model-value="selectedSubmitter.uuid"
class="w-full bg-base-100"
class="w-full rounded-lg"
:class="{ 'bg-base-100': withStickySubmitters }"
:submitters="submitters"
:editable="editable"
@new-submitter="save"
@remove="removeSubmitter"
@name-change="save"
@update:model-value="$emit('change-submitter', submitters.find((s) => s.uuid === $event))"
/>
</div>
<div class="mb-1 mt-2">
<div
class="mb-1 mt-2"
@dragover.prevent="onFieldDragover"
@drop="save"
>
<Field
v-for="field in submitterFields"
:key="field.uuid"
:data-uuid="field.uuid"
:field="field"
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)"
:editable="editable && !dragField"
:draggable="editable"
@dragstart="dragField = field"
@dragend="dragField = null"
@remove="removeField"
@move-up="move(field, -1)"
@move-down="move(field, 1)"
@scroll-to="$emit('scroll-to-area', $event)"
@set-draw="$emit('set-draw', $event)"
/>
</div>
<div class="grid grid-cols-3 gap-1 pb-2">
<button
<div
v-if="editable"
class="grid grid-cols-3 gap-1 pb-2"
>
<template
v-for="(icon, type) in fieldIcons"
:key="type"
draggable="true"
class="flex items-center justify-center border border-dashed border-base-300 bg-base-100 w-full rounded relative"
@dragstart="onDragstart(type)"
@dragend="$emit('drag-end')"
@click="addField(type)"
>
<div class="w-0 absolute left-0">
<svg
xmlns="http://www.w3.org/2000/svg"
class="cursor-grab"
width="18"
height="18"
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"
<button
v-if="withPhone || type != 'phone'"
draggable="true"
class="flex items-center justify-center border border-dashed border-base-300 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }"
@dragstart="onDragstart(type)"
@dragend="$emit('drag-end')"
@click="addField(type)"
>
<div class="w-0 absolute left-0">
<svg
xmlns="http://www.w3.org/2000/svg"
class="cursor-grab"
width="18"
height="18"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
/>
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
stroke-linecap="round"
stroke-linejoin="round"
>
<path
stroke="none"
d="M0 0h24v24H0z"
fill="none"
/>
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="icon" />
<span class="text-xs mt-1">
{{ fieldNames[type] }}
</span>
</div>
</button>
<div
v-else
class="tooltip tooltip-bottom-end flex"
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
>
<a
href="https://www.docuseal.co/pricing"
target="_blank"
class="opacity-50 flex items-center justify-center border border-dashed border-base-300 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }"
>
<div class="w-0 absolute left-0">
<IconLock
width="18"
height="18"
stroke-width="1.5"
/>
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="icon" />
<span class="text-xs mt-1">
{{ fieldNames[type] }}
</span>
</div>
</a>
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="icon" />
<span class="text-xs mt-1">
{{ fieldNames[type] }}
</span>
</div>
</button>
</template>
</div>
<div
v-if="fields.length < 4"
v-if="fields.length < 4 && editable"
class="text-xs p-2 border border-base-200 rounded"
>
<ul class="list-disc list-outside ml-3">
<li>
Draw a text field on the page with a mouse
</li>
<li>
Single click of the page to add a checkbox
</li>
<li>
Drag &amp; drop any other field type on the page
</li>
@@ -93,19 +133,31 @@ import Field from './field'
import { v4 } from 'uuid'
import FieldType from './field_type'
import FieldSubmitter from './field_submitter'
import { IconLock } from '@tabler/icons-vue'
export default {
name: 'TemplateFields',
components: {
Field,
FieldSubmitter
FieldSubmitter,
IconLock
},
inject: ['save'],
inject: ['save', 'backgroundColor', 'withPhone'],
props: {
fields: {
type: Array,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
withStickySubmitters: {
type: Boolean,
required: false,
default: true
},
submitters: {
type: Array,
required: true
@@ -116,6 +168,11 @@ export default {
}
},
emits: ['set-draw', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter'],
data () {
return {
dragField: null
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons,
@@ -127,6 +184,24 @@ export default {
onDragstart (fieldType) {
this.$emit('set-drag', fieldType)
},
onFieldDragover (e) {
const targetFieldUuid = e.target.closest('[data-uuid]')?.dataset?.uuid
if (this.dragField && targetFieldUuid && this.dragField.uuid !== targetFieldUuid) {
const field = this.fields.find((f) => f.uuid === targetFieldUuid)
const currentIndex = this.fields.indexOf(this.dragField)
const targetIndex = this.fields.indexOf(field)
if (currentIndex < targetIndex) {
this.fields.splice(targetIndex + 1, 0, this.dragField)
this.fields.splice(currentIndex, 1)
} else {
this.fields.splice(targetIndex, 0, this.dragField)
this.fields.splice(currentIndex + 1, 1)
}
}
},
removeSubmitter (submitter) {
[...this.fields].forEach((field) => {
if (field.submitter_uuid === submitter.uuid) {
@@ -142,26 +217,6 @@ export default {
this.save()
},
move (field, direction) {
const currentIndex = this.submitterFields.indexOf(field)
const fieldsIndex = this.fields.indexOf(field)
this.fields.splice(fieldsIndex, 1)
if (currentIndex + direction > this.submitterFields.length) {
const firstIndex = this.fields.indexOf(this.submitterFields[0])
this.fields.splice(firstIndex, 0, field)
} else if (currentIndex + direction < 0) {
const lastIndex = this.fields.indexOf(this.submitterFields[this.submitterFields.length - 1])
this.fields.splice(lastIndex + 1, 0, field)
} else {
this.fields.splice(fieldsIndex + direction, 0, field)
}
this.save()
},
removeField (field) {
this.fields.splice(this.fields.indexOf(field), 1)
+30 -4
View File
@@ -1,12 +1,16 @@
<template>
<div class="relative cursor-crosshair select-none">
<div
class="relative cursor-crosshair select-none"
:style="drawField ? 'touch-action: none' : ''"
>
<img
ref="image"
loading="lazy"
:src="image.url"
:width="width"
class="shadow-md mb-4"
:height="height"
loading="lazy"
class="border rounded mb-4"
@load="onImageLoad"
>
<div
class="top-0 bottom-0 left-0 right-0 absolute"
@@ -18,6 +22,7 @@
:ref="setAreaRefs"
:area="item.area"
:field="item.field"
:editable="editable"
@start-resize="resizeDirection = $event"
@stop-resize="resizeDirection = null"
@start-drag="isMove = true"
@@ -32,12 +37,13 @@
/>
</div>
<div
v-show="resizeDirection || isMove || isDrag || showMask"
v-show="resizeDirection || isMove || isDrag || showMask || (drawField && isMobile)"
id="mask"
ref="mask"
class="top-0 bottom-0 left-0 right-0 absolute z-10"
:class="{ 'cursor-grab': isDrag || isMove, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
@pointermove="onPointermove"
@pointerdown="onStartDraw"
@dragover.prevent
@drop="onDrop"
@pointerup="onPointerup"
@@ -72,6 +78,11 @@ export default {
required: false,
default: null
},
editable: {
type: Boolean,
required: false,
default: true
},
isDrag: {
type: Boolean,
required: false,
@@ -93,6 +104,9 @@ export default {
}
},
computed: {
isMobile () {
return /android|iphone|ipad/i.test(navigator.userAgent)
},
resizeDirectionClasses () {
return {
nwse: 'cursor-nwse-resize',
@@ -110,6 +124,10 @@ export default {
this.areaRefs = []
},
methods: {
onImageLoad (e) {
e.target.setAttribute('width', e.target.naturalWidth)
e.target.setAttribute('height', e.target.naturalHeight)
},
setAreaRefs (el) {
if (el) {
this.areaRefs.push(el)
@@ -125,6 +143,14 @@ export default {
})
},
onStartDraw (e) {
if (this.isMobile && !this.drawField) {
return
}
if (!this.editable) {
return
}
this.showMask = true
this.$nextTick(() => {
+16 -2
View File
@@ -12,12 +12,16 @@
class="group flex justify-end cursor-pointer top-0 bottom-0 left-0 right-0 absolute p-1"
@click="$emit('scroll-to', item)"
>
<div class="flex justify-between w-full">
<div
v-if="editable"
class="flex justify-between w-full"
>
<div style="width: 26px" />
<div class="">
<ReplaceButton
:is-direct-upload="isDirectUpload"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
class="opacity-0 group-hover:opacity-100"
@click.stop
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
@@ -94,6 +98,16 @@ export default {
type: Object,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
isDirectUpload: {
type: Boolean,
required: true,
@@ -108,7 +122,7 @@ export default {
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace'],
computed: {
previewImage () {
return this.document.preview_images[0]
return [...this.document.preview_images].sort((a, b) => parseInt(a.filename) - parseInt(b.filename))[0]
}
},
mounted () {
+7 -1
View File
@@ -14,7 +14,7 @@
ref="input"
name="files[]"
type="file"
accept="image/*, application/pdf"
:accept="acceptFileTypes"
@change="upload"
>
</form>
@@ -26,11 +26,17 @@ import Upload from './upload'
export default {
name: 'ReplaceDocument',
inject: ['baseFetch'],
props: {
templateId: {
type: [Number, String],
required: true
},
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
isDirectUpload: {
type: Boolean,
required: true,
+9 -3
View File
@@ -33,7 +33,7 @@
ref="input"
name="files[]"
type="file"
accept="image/*, application/pdf"
:accept="acceptFileTypes"
multiple
@change="upload"
>
@@ -50,11 +50,17 @@ export default {
IconUpload,
IconInnerShadowTop
},
inject: ['baseFetch'],
props: {
templateId: {
type: [Number, String],
required: true
},
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
isDirectUpload: {
type: Boolean,
required: true,
@@ -113,7 +119,7 @@ export default {
this.isProcessing = true
fetch(`/api/templates/${this.templateId}/documents`, {
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
method: 'POST',
body: JSON.stringify({ blobs }),
headers: { 'Content-Type': 'application/json' }
@@ -124,7 +130,7 @@ export default {
this.isProcessing = false
})
} else {
fetch(`/api/templates/${this.templateId}/documents`, {
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
method: 'POST',
body: new FormData(this.$refs.form)
}).then(resp => resp.json()).then((data) => {
@@ -1,7 +0,0 @@
# frozen_string_literal: true
class GenerateSubmitterResultAttachmentsJob < ApplicationJob
def perform(submitter)
Submissions::EnsureResultGenerated.call(submitter)
end
end
@@ -0,0 +1,53 @@
# frozen_string_literal: true
class ProcessSubmitterCompletionJob < ApplicationJob
def perform(submitter)
is_all_completed = !submitter.submission.submitters.exists?(completed_at: nil)
if !is_all_completed && submitter.submission.submitters_order_preserved?
enqueue_next_submitter_request_notification(submitter)
end
Submissions::EnsureResultGenerated.call(submitter)
if is_all_completed && submitter.completed_at == submitter.submission.submitters.maximum(:completed_at)
Submissions::GenerateAuditTrail.call(submitter.submission)
enqueue_completed_emails(submitter)
end
return if Accounts.load_webhook_configs(submitter.account).blank?
SendFormCompletedWebhookRequestJob.perform_later(submitter)
end
def enqueue_completed_emails(submitter)
user = submitter.submission.created_by_user || submitter.template.author
if submitter.template.account.users.exists?(id: user.id)
bcc = submitter.submission.template.account.account_configs
.find_by(key: AccountConfig::BCC_EMAILS)&.value
SubmitterMailer.completed_email(submitter, user, bcc:).deliver_later!
end
to = submitter.submission.submitters.sort_by(&:completed_at).select(&:email?).map(&:friendly_name).join(', ')
SubmitterMailer.documents_copy_email(submitter, to:).deliver_later! if to.present?
end
def enqueue_next_submitter_request_notification(submitter)
next_submitter_item =
submitter.submission.template_submitters.find do |e|
sub = submitter.submission.submitters.find { |s| s.uuid == e['uuid'] }
sub.completed_at.blank? && sub.sent_at.blank?
end
return unless next_submitter_item
next_submitter = submitter.submission.submitters.find { |s| s.uuid == next_submitter_item['uuid'] }
Submitters.send_signature_requests([next_submitter], send_email: true)
end
end
@@ -0,0 +1,24 @@
# frozen_string_literal: true
class SendFormCompletedWebhookRequestJob < ApplicationJob
USER_AGENT = 'DocuSeal.co Webhook'
def perform(submitter)
config = Accounts.load_webhook_configs(submitter.submission.account)
return if config.blank? || config.value.blank?
Submissions::EnsureResultGenerated.call(submitter)
ActiveStorage::Current.url_options = Docuseal.default_url_options
Faraday.post(config.value,
{
event_type: 'form.completed',
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
end
end
@@ -0,0 +1,22 @@
# frozen_string_literal: true
class SendFormStartedWebhookRequestJob < ApplicationJob
USER_AGENT = 'DocuSeal.co Webhook'
def perform(submitter)
config = Accounts.load_webhook_configs(submitter.submission.account)
return if config.blank? || config.value.blank?
ActiveStorage::Current.url_options = Docuseal.default_url_options
Faraday.post(config.value,
{
event_type: 'form.started',
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
end
end

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