Compare commits

..
358 Commits
Author SHA1 Message Date
Pete Matsyburka 53d924be0b fix build 2024-05-20 20:07:17 +03:00
Pete Matsyburka d6cf027d5d fix erblint 2024-05-19 19:44:38 +03:00
Alex Turchyn ec332bab88 flatten pdf for preview images 2024-05-19 19:25:53 +03:00
Alex Turchyn 8b3b90a7e2 replace emojis 2024-05-19 19:12:17 +03:00
Pete Matsyburka 9047e3bed3 add dry run prop 2024-05-18 22:54:26 +03:00
Pete Matsyburka 72b7391245 add documents copy email to template preferences 2024-05-18 22:03:20 +03:00
Pete Matsyburka 2df05f0327 adjust initials height 2024-05-18 17:51:22 +03:00
Pete Matsyburka 95f3eb0e5d display filters 2024-05-18 13:58:21 +03:00
Pete Matsyburka ce8d964ce6 fix ensure result generated 2024-05-17 16:07:08 +03:00
Pete Matsyburka f0d5fc4e2d add submission created webhook event 2024-05-17 13:28:10 +03:00
Pete Matsyburka b4b17d847e add view archived to template page 2024-05-17 12:08:46 +03:00
Alex Turchyn fd537e7ac0 upgrade rack 3 2024-05-16 23:26:30 +03:00
Pete Matsyburka 8c271104e1 single digital signature by default 2024-05-16 23:18:48 +03:00
Pete Matsyburka 51edd60d96 update gem 2024-05-16 15:16:59 +03:00
Pete Matsyburka a265ca1716 remove pdf processor 2024-05-16 13:37:23 +03:00
Pete Matsyburka a773f63a65 remove pdf reader 2024-05-16 11:56:41 +03:00
Pete Matsyburka 576c8a2e24 fix ensure result generated 2024-05-15 23:49:36 +03:00
Pete Matsyburka 5829de9ee4 adjust required fields alert 2024-05-15 11:42:51 +03:00
Pete Matsyburka b202941b79 fix tsa settings 2024-05-15 00:21:52 +03:00
Pete Matsyburka 6f6fb74988 adjust plans settings nav 2024-05-14 23:32:11 +03:00
Pete Matsyburka 0137bdbe4c optimize tsa settings controller 2024-05-12 19:32:09 +03:00
Pete Matsyburka 55d9511c01 add disclosure message 2024-05-12 12:35:20 +03:00
Pete Matsyburka 929d29576d add template webhooks 2024-05-12 11:37:33 +03:00
Pete Matsyburka 6f7128a51c add number format 2024-05-12 11:12:07 +03:00
Pete Matsyburka fc5c4ff2fc adjust reminder options 2024-05-11 16:24:13 +03:00
Pete Matsyburka a81595db2b allow legacy algos 2024-05-10 22:55:21 +03:00
Pete Matsyburka a5615d500d fix missing field steps 2024-05-10 22:24:44 +03:00
Pete Matsyburka da9266befc fix time format 2024-05-10 09:24:09 +03:00
Pete Matsyburka 5b3cffeb73 fix submission archived webhook 2024-05-08 09:53:37 +03:00
Pete Matsyburka 8f7b8a5fdf fix signature size 2024-05-08 09:38:16 +03:00
Pete Matsyburka 6d93116993 fix checkbox add on missclick 2024-05-05 18:18:07 +03:00
Pete Matsyburka a0f4916d3b add field settings dropdown to area 2024-05-05 16:58:19 +03:00
Pete Matsyburka ace897eaf9 update SECURITY.md 2024-05-04 23:15:04 +03:00
Pete Matsyburka b8667c5408 fix audit log on dev 2024-05-04 11:09:48 +03:00
Pete Matsyburka d87d66632e add field-area-active class 2024-05-02 23:33:44 +03:00
Alex Turchyn 3015c2d156 convert BMP images to PNG before uploading in the signing form 2024-05-02 22:10:23 +03:00
Pete Matsyburka 52b9e9ee8e fix underscore field name 2024-05-02 16:33:41 +03:00
Pete Matsyburka 5bfad0271c fix audit log font 2024-05-02 13:50:43 +03:00
Pete Matsyburka 58797891c7 fix trusted cert env 2024-05-02 13:39:47 +03:00
Pete Matsyburka 897750ba1e fix build 2024-05-02 12:43:13 +03:00
Pete Matsyburka 99341e773f refactor sign params 2024-05-01 23:40:09 +03:00
Pete Matsyburka 71319c1548 fix pdf generation 2024-05-01 11:51:40 +03:00
Pete Matsyburka c0a42f6bfc do now show default fields on preview 2024-04-29 15:42:11 +03:00
Pete Matsyburka d5abb46112 fix phone value assignment 2024-04-28 22:09:02 +03:00
Pete Matsyburka 2508b5fc8a adjust template account clone 2024-04-28 16:43:31 +03:00
Pete Matsyburka 4bd1ef0da3 fix params normalize 2024-04-28 16:33:11 +03:00
Pete Matsyburka 4efd748571 fix 2024-04-28 16:32:23 +03:00
Pete Matsyburka 859892d1fd adjust template preferences 2024-04-28 16:26:47 +03:00
Pete Matsyburka 7adeaf93cd only active linked accounts 2024-04-28 15:01:15 +03:00
Pete Matsyburka 5dbe155ecb shorten call 2024-04-28 14:44:33 +03:00
Pete Matsyburka 667d065bee populate linked accounts 2024-04-28 14:27:23 +03:00
Pete Matsyburka bb613059de add archived at to accounts 2024-04-28 13:43:53 +03:00
Pete Matsyburka 1b2dec4c2a adjust create submission resp 2024-04-27 23:08:16 +03:00
Pete Matsyburka 2c2ef97c30 validate params 2024-04-27 21:35:20 +03:00
Pete Matsyburka 6df1d68237 addept value param 2024-04-27 21:35:09 +03:00
Pete Matsyburka 20c9faedff update dep 2024-04-27 20:15:39 +03:00
Pete Matsyburka d931a40ea9 adjust log 2024-04-27 11:11:00 +03:00
Pete Matsyburka 1097b98b85 add log 2024-04-26 23:37:04 +03:00
Pete Matsyburka 2e597fcf43 ruby 3.3.1 2024-04-26 12:39:45 +03:00
Pete Matsyburka bb2fa6a261 add reuse signature prop 2024-04-25 17:51:36 +03:00
Pete Matsyburka 1dc8369487 fix template clone 2024-04-24 21:02:19 +03:00
Pete Matsyburka e6d60f1985 better error message 2024-04-24 13:18:57 +03:00
Pete Matsyburka 82e3cb8a4d fix audit log toggle 2024-04-24 01:19:07 +03:00
Pete Matsyburka aeec0cbf8e phone step langauge 2024-04-23 21:33:40 +03:00
Pete Matsyburka 7a2d1fc6c5 allow to remove personalization configs 2024-04-23 20:47:12 +03:00
Pete Matsyburka 68033ab7d2 fix files link position 2024-04-23 20:09:39 +03:00
Pete Matsyburka 7595c60ea8 remove pattern validation 2024-04-23 15:02:42 +03:00
Pete Matsyburka d9318c04f8 fix builder bg color 2024-04-23 01:57:45 +03:00
Pete Matsyburka 2d76198290 add flatten pdf toggle 2024-04-22 21:34:55 +03:00
Pete Matsyburka 02a7b6ea55 do not allow to skip required field with whitespace 2024-04-22 21:15:57 +03:00
Pete Matsyburka ab57076b8f add class 2024-04-22 20:01:30 +03:00
Pete Matsyburka 82fba47c98 rotate pdf 2024-04-22 19:10:58 +03:00
Pete Matsyburka 5f32e2a5af adjust create event 2024-04-21 17:01:44 +03:00
Pete Matsyburka 70012fbd7c fix form 2024-04-21 16:40:28 +03:00
Pete Matsyburka 668f6ca975 fix cells draw 2024-04-21 00:32:40 +03:00
Pete Matsyburka e78dd301ef fix download ttl 2024-04-20 23:58:43 +03:00
Pete Matsyburka f8ec3daf10 add minimize prop 2024-04-20 18:53:16 +03:00
Pete Matsyburka 75c929639d fix condition last field error 2024-04-20 17:23:28 +03:00
Pete Matsyburka e9c1242b8d verify field only once 2024-04-20 17:07:31 +03:00
Pete Matsyburka e7eb1d4f12 add noindex 2024-04-20 15:47:48 +03:00
Pete Matsyburka abbd3e809a remove sign now from archived 2024-04-20 12:54:14 +03:00
Pete Matsyburka f430b4f5a6 change default email 2024-04-20 11:25:04 +03:00
Pete Matsyburka e65234b4c8 add archived users view 2024-04-19 16:19:02 +03:00
Pete Matsyburka fe04d22c94 fix email normalize 2024-04-19 12:30:10 +03:00
Pete Matsyburka 44a7a5ebdc fix go to last 2024-04-19 10:33:31 +03:00
Pete Matsyburka 119ef0d0c7 add force sso key 2024-04-19 02:04:35 +03:00
Pete Matsyburka cdb8eb7931 fix pdf number of pages 2024-04-18 22:25:07 +03:00
Pete Matsyburka aef176c0cc downgrade net smtp 2024-04-18 18:43:47 +03:00
Pete Matsyburka 0c21b55865 adjust fixed email log 2024-04-18 17:02:53 +03:00
Pete Matsyburka 964b1444a5 adjust completed action 2024-04-18 16:45:09 +03:00
Pete Matsyburka 48de343948 add fix email log 2024-04-18 10:44:45 +03:00
Pete Matsyburka 2640934c15 skip .om tld typo fix 2024-04-18 10:42:54 +03:00
Pete Matsyburka 442dd4d6db add submitters template_id filter 2024-04-18 01:48:14 +03:00
Pete Matsyburka 3168feb3d6 clone template preferences 2024-04-17 21:09:35 +03:00
Pete Matsyburka fbae4933f6 fix multiple bcc addresses 2024-04-17 20:51:34 +03:00
Pete Matsyburka 78bb66c344 adjust wording 2024-04-17 20:24:31 +03:00
Pete Matsyburka 5af65ef74f adjust drawer 2024-04-17 17:27:43 +03:00
Pete Matsyburka 4d354ae9ff do not load storage configs in dev 2024-04-17 16:51:42 +03:00
Pete Matsyburka 1e57c1ace7 add template preferences 2024-04-17 16:09:09 +03:00
Pete Matsyburka fefd138dfd add devise log 2024-04-17 00:12:05 +03:00
Pete Matsyburka 16a77d3536 add log 2024-04-16 14:07:38 +03:00
Pete Matsyburka e03857bdb7 mark required field on mobile 2024-04-16 10:42:11 +03:00
Pete Matsyburka fc679bcd72 add require file links auth toggle 2024-04-15 20:34:00 +03:00
Pete Matsyburka 652b0da021 add fields param 2024-04-15 19:36:16 +03:00
Pete Matsyburka 2d54b0db2f update deps 2024-04-15 18:33:55 +03:00
Pete Matsyburka 7a29894926 add required fields prop 2024-04-15 16:34:44 +03:00
Pete Matsyburka f9ae79dd5b add go to last 2024-04-13 17:41:49 +03:00
Pete Matsyburka cadb78ca35 check on email events 2024-04-13 12:37:48 +03:00
Pete Matsyburka 36a6b7ff1d fix typo 2024-04-13 11:35:04 +03:00
Pete Matsyburka efee46f113 add bulk source 2024-04-12 17:20:00 +03:00
Pete Matsyburka b6639738c3 adjust dockerfile 2024-04-11 22:52:12 +03:00
Alex Turchyn aab07c5f49 add icons 2024-04-11 22:52:11 +03:00
Pete Matsyburka 33c0784bc6 add folder name to submission resp 2024-04-11 16:36:53 +03:00
Pete Matsyburka 6f5ebfc8a6 fix preview completed 2024-04-11 02:45:38 +03:00
Pete Matsyburka 811091ff14 adjust text signature size 2024-04-10 22:24:19 +03:00
Pete Matsyburka 6666f39928 add redacted param 2024-04-10 19:56:35 +03:00
Pete Matsyburka f67db9fe5f add field uuid param 2024-04-10 13:30:27 +03:00
Pete Matsyburka 45f002803e adjust padding 2024-04-10 10:55:51 +03:00
Pete Matsyburka 8c19f90e88 remove debug 2024-04-10 00:44:36 +03:00
Pete Matsyburka a8f5d5cea7 fix typo 2024-04-09 19:08:05 +03:00
Pete Matsyburka 6788071eec add webhook event preferences 2024-04-09 18:11:58 +03:00
Pete Matsyburka 0c690b3033 custom submit button 2024-04-09 01:54:39 +03:00
Pete Matsyburka dcb963b623 add autoscrollFields prop 2024-04-08 18:37:10 +03:00
Pete Matsyburka 04e30dbdd6 add submitter update params 2024-04-08 10:51:18 +03:00
Pete Matsyburka ac6d5a7aed update readme 2024-04-08 01:18:31 +03:00
Pete Matsyburka 3166a31b67 add icon 2024-04-08 01:02:43 +03:00
Pete Matsyburka fc3283eb3a add Korean i18n 2024-04-08 00:33:54 +03:00
Pete Matsyburka d6a96d4aa3 fix base64 unicode 2024-04-07 18:06:26 +03:00
Pete Matsyburka cda349d71c adjust send email views 2024-04-07 17:58:18 +03:00
Pete Matsyburka 87345a3b3d fix rubocop 2024-04-07 11:14:34 +03:00
Pete Matsyburka b791d0699c fix replace button file types 2024-04-06 21:57:35 +03:00
Pete Matsyburka 2cb15f3282 optimize document upload 2024-04-06 21:57:34 +03:00
Pete Matsyburka 8b823ca0d0 do not fix gob tld 2024-04-06 19:41:17 +03:00
Pete Matsyburka f3f08797f7 optimize document upload 2024-04-06 17:08:45 +03:00
Pete Matsyburka cab9672d25 remove direct upload 2024-04-06 15:00:30 +03:00
Pete Matsyburka c8d3b1bace reorder settings 2024-04-06 12:56:05 +03:00
Pete Matsyburka 5ee51b2b57 fix testing account 2024-04-06 01:20:02 +03:00
Pete Matsyburka 525dc503a6 accept default value array 2024-04-06 01:02:24 +03:00
Pete Matsyburka 12cf28792b add uuid to accounts 2024-04-05 19:56:34 +03:00
Pete Matsyburka 33466c1d07 adjust sso 2024-04-05 19:28:57 +03:00
Pete Matsyburka 226ae2172e word break underscore 2024-04-05 19:14:02 +03:00
Pete Matsyburka 8e2700b8b8 break long titles 2024-04-05 16:44:17 +03:00
Pete Matsyburka 753cbd4d27 allow to add custom form completed message 2024-04-05 16:16:30 +03:00
Pete Matsyburka f16405ca70 assign first party i18n name 2024-04-05 02:21:14 +03:00
Pete Matsyburka 23f052c400 add Korean 2024-04-05 02:02:23 +03:00
Pete Matsyburka 90a0a03e15 validate custom certs in the cloud 2024-04-04 14:08:14 +03:00
Pete Matsyburka 2dba8ae366 add submissions dashboard view 2024-04-04 13:45:11 +03:00
Pete Matsyburka 5bb7ad571c display default date only of the signing party 2024-04-03 10:27:19 +03:00
Pete Matsyburka 0cdb7c2c4f fix date normalization 2024-04-02 11:30:52 +03:00
Pete Matsyburka 11bdc522cd add spreadsheet download 2024-04-01 17:56:08 +03:00
Pete Matsyburka 014835f90d adjust download button state 2024-04-01 15:15:55 +03:00
Pete Matsyburka 05e66b60ee fix result generation with annots 2024-04-01 13:16:54 +03:00
Pete Matsyburka 505b185cf2 fix download indicator 2024-03-31 18:31:09 +03:00
Pete Matsyburka 50c32ac6ff replace date value 2024-03-31 18:20:33 +03:00
Pete Matsyburka 878ab25fd4 access account via submission 2024-03-31 17:40:51 +03:00
Pete Matsyburka 1b31772ff6 show default date on the page 2024-03-31 17:34:23 +03:00
Pete Matsyburka d4940234b7 add bulk placeholder 2024-03-30 23:36:40 +02:00
Pete Matsyburka 9bdc5d4863 adjust batch limit 2024-03-30 14:42:42 +02:00
Pete Matsyburka a73fd7929c add smtp timeout 2024-03-30 12:43:15 +02:00
Pete Matsyburka 8269a61751 adjust payment connected 2024-03-29 23:24:51 +02:00
Pete Matsyburka de072d1437 scroll to multiple checkbox field 2024-03-29 21:43:13 +02:00
Pete Matsyburka 5b24793f2b render links with markdown in emails 2024-03-29 21:21:04 +02:00
Pete Matsyburka 660f7ab8f3 password reset neutral on-prem 2024-03-29 14:50:18 +02:00
Pete Matsyburka 6e3fea5fa0 validate email param 2024-03-28 11:25:32 +02:00
Pete Matsyburka 29ecbecbc2 flatten annots 2024-03-27 18:24:41 +02:00
Pete Matsyburka ad3bbe9892 sctoll to radio group 2024-03-27 18:24:35 +02:00
Pete Matsyburka b570ebee69 remember builder selected field type 2024-03-27 14:21:30 +02:00
Pete Matsyburka 207780fa3f clear fields on remove doc 2024-03-27 00:11:10 +02:00
Pete Matsyburka 8aa219433b render blank option checkbox 2024-03-26 19:28:41 +02:00
Pete Matsyburka cb1e9f2be4 do not send completed emails to integration 2024-03-26 18:01:35 +02:00
Pete Matsyburka 202943f58d fix pidfile issue 2024-03-26 01:17:55 +02:00
Pete Matsyburka 6dd15b2091 use letter page size for us 2024-03-25 20:20:38 +02:00
Pete Matsyburka 1c55e4f18b always display default value 2024-03-25 19:16:12 +02:00
Pete Matsyburka 6cc36b1728 allow to update template roles 2024-03-25 17:37:46 +02:00
Pete Matsyburka 37751cd5c1 update template submitters 2024-03-25 16:34:45 +02:00
Pete Matsyburka 80d882cd20 add reply to param 2024-03-24 14:09:28 +02:00
Pete Matsyburka 8bef75e570 move autocomplete controllers 2024-03-23 23:52:50 +02:00
Pete Matsyburka 077eab7005 refactor templates controller 2024-03-23 23:52:49 +02:00
Pete Matsyburka 715c70d422 rename to users 2024-03-23 21:23:59 +02:00
Pete Matsyburka a3c23f9d53 update readme 2024-03-22 17:35:52 +02:00
Pete Matsyburka c1e67a8af0 add submission id var 2024-03-22 13:11:33 +02:00
Pete Matsyburka 2fdc294293 fix annot 2024-03-21 16:25:49 +02:00
Pete Matsyburka 1f49ba069e disable puma pidfile 2024-03-21 15:03:50 +02:00
Pete Matsyburka 43ef0fc0d0 use FORCE_SSL=false 2024-03-21 14:12:11 +02:00
Pete Matsyburka a1a920e536 allow to remove default value 2024-03-21 00:04:53 +02:00
Pete Matsyburka dec33b4331 add extra small field font size 2024-03-20 16:52:53 +02:00
Pete Matsyburka 432f290633 assign template fields on completed via API 2024-03-20 00:13:16 +02:00
Pete Matsyburka cf98616a27 remove log 2024-03-19 13:57:21 +02:00
Pete Matsyburka 1a720cf1ad allow to clone template from testing 2024-03-19 01:44:16 +02:00
Pete Matsyburka 557c6c34f2 search default fields 2024-03-19 01:21:44 +02:00
Pete Matsyburka 943c76505d remove api error log 2024-03-18 23:18:35 +02:00
Pete Matsyburka 66eb83e6a7 allow to search on value 2024-03-18 20:16:26 +02:00
Pete Matsyburka 0844427357 add rollbar queue 2024-03-18 19:35:19 +02:00
Pete Matsyburka 72e9d9ea2d update readme 2024-03-17 23:16:27 +02:00
Pete Matsyburka 3db7d47746 fix option remove 2024-03-17 19:40:30 +02:00
Pete Matsyburka 95899336b8 align number field 2024-03-17 19:06:00 +02:00
Pete Matsyburka f6691c110e adjust field settings 2024-03-17 17:04:39 +02:00
Pete Matsyburka 749722a9df add rate limit 2024-03-16 23:22:11 +02:00
Pete Matsyburka 265b668cb4 show bulk send message 2024-03-16 22:21:23 +02:00
Pete Matsyburka 0527e6a5c1 fix form font size 2024-03-16 01:59:00 +02:00
Pete Matsyburka d989b61d19 fix font size 2024-03-15 23:06:16 +02:00
Pete Matsyburka c1f3e6ff13 reduce image size 2024-03-15 21:16:30 +02:00
Pete Matsyburka 3c0fff2d82 skip on missing font character error 2024-03-15 20:24:12 +02:00
Pete Matsyburka bd599c264f rescue Encoding::CompatibilityError 2024-03-15 13:44:16 +02:00
Pete Matsyburka 0274093c89 save multiple document attachments 2024-03-15 13:03:10 +02:00
Pete Matsyburka 5845b5bdfd process malformed PDF 2024-03-15 12:17:52 +02:00
Pete Matsyburka 3dd0ba6b0b fix pdf font width 2024-03-14 23:10:21 +02:00
Pete Matsyburka e79306f8a3 optimize build annotations 2024-03-14 21:30:57 +02:00
Pete Matsyburka 0171b0c2dd allow to disable stamp logo 2024-03-14 14:09:43 +02:00
Pete Matsyburka a963ef53d3 adjust submitter role assign 2024-03-14 01:21:42 +02:00
Pete Matsyburka 344f302c76 refactor field validation 2024-03-13 20:25:41 +02:00
Pete Matsyburka 17ded282b2 add symbols font 2024-03-13 14:58:32 +02:00
Pete Matsyburka 2ce57df485 sanitize href 2024-03-13 11:54:56 +02:00
Pete Matsyburka 849289287e add html escape replace variable option 2024-03-12 23:47:58 +02:00
Pete Matsyburka 704fbc2393 try get log params 2024-03-11 21:34:59 +02:00
Pete Matsyburka 168fc8bd03 fix testing acocunt 2024-03-11 14:55:50 +02:00
Pete Matsyburka c5598d9efe fix replace email vars 2024-03-11 10:05:43 +02:00
Pete Matsyburka f02315726d error on too small signature 2024-03-10 19:55:45 +02:00
Pete Matsyburka e56dcb59c0 add cors headers on API error 2024-03-10 15:54:44 +02:00
Pete Matsyburka c74f350ea2 fix typo 2024-03-10 12:03:46 +02:00
Pete Matsyburka af9d015cf2 refactor mailer custom content 2024-03-10 00:41:18 +02:00
Pete Matsyburka 633e5ac5e4 allow to disable confetti 2024-03-10 00:13:35 +02:00
Pete Matsyburka 2d0f323854 adjust sender email var 2024-03-09 22:55:55 +02:00
Pete Matsyburka cfe5610ec9 add field description and title 2024-03-09 22:55:54 +02:00
Pete Matsyburka 55616ac321 sort field areas 2024-03-08 00:34:16 +02:00
Pete Matsyburka 8f2d1deb38 do not show console link on testing 2024-03-07 21:36:41 +02:00
Pete Matsyburka e2763ceb80 add builder selectors 2024-03-07 19:01:34 +02:00
Pete Matsyburka 10462f3140 fix existing submitter uuids 2024-03-07 16:28:14 +02:00
Pete Matsyburka 3aef92486b silence active storage error 2024-03-06 23:14:07 +02:00
Pete Matsyburka c1a9ee0bbb return error on file download 400+ resp 2024-03-06 22:36:15 +02:00
Pete Matsyburka 398fae82cb optimize api pagination 2024-03-06 18:58:27 +02:00
Pete Matsyburka a16443f961 add completed at submitter filter 2024-03-06 18:44:13 +02:00
Pete Matsyburka ad4e0e1d27 adjust email variables 2024-03-06 00:27:16 +02:00
Pete Matsyburka 493a2108e9 fix safari builder scaling 2024-03-05 20:53:54 +02:00
Pete Matsyburka c2c9bb64b1 fix audit log original sha 2024-03-05 17:16:18 +02:00
Pete Matsyburka bb1426c0af log generation error 2024-03-05 13:56:47 +02:00
Pete Matsyburka 2505768e4d SMTP auto option 2024-03-04 22:30:07 +02:00
Pete Matsyburka 23a1b70534 turbo false console links 2024-03-03 21:21:27 +02:00
Pete Matsyburka b34616ecbc fix default date value 2024-03-03 11:50:22 +02:00
Pete Matsyburka 0111252f6f add timeserver spec info 2024-03-02 23:16:43 +02:00
Pete Matsyburka a561cc5f73 adjust invalid date error 2024-03-02 19:50:58 +02:00
Pete Matsyburka 519e6f1f79 cleanup 2024-03-02 12:09:35 +02:00
Pete Matsyburka da871b5d3a adjust builder page numbers 2024-03-02 11:24:04 +02:00
Pete Matsyburka ae3f939d23 add field conditions placeholder 2024-03-02 11:13:39 +02:00
Pete Matsyburka fb6ede564e retry webhooks on faraday error 2024-03-01 18:51:11 +02:00
Pete Matsyburka 1c701d4fe8 adjust FR i18n 2024-03-01 16:34:11 +02:00
Pete Matsyburka c743cc689d validate account config keys 2024-02-29 23:59:49 +02:00
Pete Matsyburka 8432bec3d6 fix result generation with cell 2024-02-29 21:39:52 +02:00
Pete Matsyburka 8e72b0b0e4 show testing shared templates 2024-02-29 21:39:46 +02:00
Pete Matsyburka 19e4bbc01e parse emails user 2024-02-28 21:54:27 +02:00
Pete Matsyburka 56a9e412c8 autocomplete debounce 2024-02-28 14:13:23 +02:00
Pete Matsyburka 4b75623b86 add index on blob checksum 2024-02-28 10:35:51 +02:00
Pete Matsyburka e1ea42a997 add index on submission events 2024-02-28 09:49:59 +02:00
Pete Matsyburka ee37710e57 adjust expand form 2024-02-28 01:54:10 +02:00
Pete Matsyburka ae99218789 adjust field options preview mode 2024-02-28 00:40:42 +02:00
Pete Matsyburka 9fe8214ca0 adjust blobs proxy 2024-02-28 00:40:19 +02:00
Pete Matsyburka 6ca44a5cc3 optimize autocomplete 2024-02-27 15:24:28 +02:00
Pete Matsyburka 801abd197b optimize account submissions assoc 2024-02-27 13:29:18 +02:00
Pete Matsyburka 970977e469 allow to drag existing field on the page 2024-02-27 01:41:57 +02:00
Pete Matsyburka f83674b934 share template only hosted 2024-02-26 18:02:24 +02:00
Pete Matsyburka 57c7dc1b5e adjust template clone response 2024-02-25 22:53:35 +02:00
Pete Matsyburka e9277f4eeb use node 20 in GH actions 2024-02-25 21:17:50 +02:00
Pete Matsyburka 4052b6fe13 puma pid /dev/null 2024-02-25 21:15:00 +02:00
Pete Matsyburka 608347b1e6 adjust pdf processor 2024-02-25 17:19:51 +02:00
Pete Matsyburka 345fe674d4 store original doc uuid in metadata 2024-02-25 16:25:04 +02:00
Pete Matsyburka 92f8901a67 better log 2024-02-25 13:11:13 +02:00
Pete Matsyburka fcd40308ee allow to resent copy email 2024-02-25 12:53:36 +02:00
Pete Matsyburka 2bd023e5d0 fix i18n 2024-02-25 00:33:34 +02:00
Pete Matsyburka a697d6330e allow to disable completed submission emails 2024-02-25 00:08:59 +02:00
Pete Matsyburka ab56e8be20 retry webhooks on timeout 2024-02-24 20:24:43 +02:00
Pete Matsyburka b9a906b569 optimize templates api 2024-02-24 19:59:50 +02:00
Pete Matsyburka 02adbae758 add expand prop 2024-02-24 17:35:38 +02:00
Pete Matsyburka 866aab7fac fix submission preview page 2024-02-24 15:36:57 +02:00
Pete Matsyburka c81b4d855b check signed blob data purpose 2024-02-24 13:04:14 +02:00
Pete Matsyburka 4ad58fc285 not found on wrong blob signature 2024-02-24 13:00:09 +02:00
Pete Matsyburka 8227dd4f6a add formula placeholder 2024-02-24 12:04:08 +02:00
Pete Matsyburka beb677734f do not skip required checkboxes 2024-02-23 19:38:53 +02:00
Pete Matsyburka 03db636d51 add number field type 2024-02-23 15:58:49 +02:00
Pete Matsyburka b83abc9499 fix copy field to multiple pages 2024-02-23 10:50:53 +02:00
Pete Matsyburka 9738cdcf8f fix preview page images 2024-02-23 02:15:33 +02:00
Pete Matsyburka 179ae01551 remove rollbar 2024-02-21 20:16:10 +02:00
Pete Matsyburka 295860a9de update readme 2024-02-21 17:07:56 +02:00
Pete Matsyburka 0a6c15b6b8 add AR form i18n 2024-02-21 16:45:34 +02:00
Pete Matsyburka 18f80020ee update readme 2024-02-21 10:44:19 +02:00
Pete Matsyburka 3768fb94bf prefill stamp field 2024-02-21 02:32:31 +02:00
Pete Matsyburka 7b2cadcb91 add default draw field prop 2024-02-21 02:04:44 +02:00
Alex Turchyn 4eb4e8c1f8 fix file link generation 2024-02-21 00:38:33 +02:00
Pete Matsyburka 984df15949 pass account local var in attribution 2024-02-20 21:30:00 +02:00
Pete Matsyburka aa3bd24332 add cors headers 2024-02-20 18:25:41 +02:00
Pete Matsyburka 250889623d sort field types 2024-02-20 12:00:16 +02:00
Alex Turchyn 862fa55986 delay signature rendering 2024-02-20 01:13:43 +02:00
Pete Matsyburka 0f2889904a allow to pass signature as base64 2024-02-20 00:22:24 +02:00
Pete Matsyburka 49f14c7929 adjust rollbar configs 2024-02-19 23:53:16 +02:00
Pete Matsyburka c3f3c5d5df add missing i18n 2024-02-19 23:52:47 +02:00
Pete Matsyburka 274259f305 adjust page preview loading 2024-02-19 23:46:24 +02:00
Pete Matsyburka a15d7a9cf3 adjust csv export 2024-02-19 22:53:49 +02:00
Pete Matsyburka 7d154fc28d use message verifier purpose 2024-02-19 22:36:48 +02:00
Pete Matsyburka 6727d3be83 adjust blob proxy 2024-02-19 17:07:37 +02:00
Alex Turchyn 4bbd5c3bcb fix signature re-uploading issue 2024-02-18 23:13:15 +02:00
Pete Matsyburka 5995f2e427 show multiple signatures toggle on cloud 2024-02-18 23:13:15 +02:00
Pete Matsyburka 0910c8e100 redirect on archived template 2024-02-18 23:13:15 +02:00
Pete Matsyburka 8df76dfa90 download completed ttl 2024-02-18 23:13:14 +02:00
Pete Matsyburka 78e9e66cb0 use uuid blobs controller 2024-02-18 16:38:43 +02:00
Pete Matsyburka 71c61fc4b3 allow to use separate encryption key via env 2024-02-18 12:32:08 +02:00
Pete Matsyburka a728aa8689 add blobs proxy controller 2024-02-18 11:36:50 +02:00
Pete Matsyburka 5a3174a720 add uuid to active storage blob 2024-02-18 10:42:11 +02:00
Pete Matsyburka 652072d660 cache page url 2024-02-18 10:04:56 +02:00
Pete Matsyburka 323f5c0c15 use signed uuid in previews 2024-02-17 22:32:52 +02:00
Pete Matsyburka 229d78c78b add security.md 2024-02-17 18:22:36 +02:00
Pete Matsyburka 9d9a955149 do not use image variants deletion 2024-02-17 13:36:58 +02:00
Pete Matsyburka b40a047190 update readme 2024-02-17 12:13:56 +02:00
Pete Matsyburka ff9e415823 generate default signature initials from font 2024-02-17 01:41:07 +02:00
Pete Matsyburka 2f7a4fb2b3 fix i18n 2024-02-16 20:01:21 +02:00
Pete Matsyburka f8a654a5b6 reduce error log 2024-02-16 19:44:59 +02:00
Pete Matsyburka 4449475467 fix audit log currency 2024-02-16 19:00:18 +02:00
Pete Matsyburka bcbbf0dd42 fix large images in audit log 2024-02-16 18:59:10 +02:00
Pete Matsyburka 19b9ba52d4 adjust webhook retries 2024-02-16 18:47:02 +02:00
Pete Matsyburka bdc30db2dc fix typed signature field 2024-02-16 18:22:40 +02:00
Pete Matsyburka 74634ba96b mark as opened on form view 2024-02-16 17:59:33 +02:00
Pete Matsyburka 77feb05bad change font 2024-02-16 11:34:30 +02:00
Pete Matsyburka a1f05d006e fix the same file in default values 2024-02-16 01:33:35 +02:00
Pete Matsyburka cc3dae1335 do not send completed email to self-signed 2024-02-16 01:02:39 +02:00
Pete Matsyburka d9debcfb0d process annotations 2024-02-15 23:20:29 +02:00
Pete Matsyburka 8770d16d52 update pdf gem 2024-02-15 22:26:05 +02:00
Pete Matsyburka 84953481e4 adjust form i18n 2024-02-15 22:13:14 +02:00
Pete Matsyburka df7aa59f6f allow to clone templates to linked accounts 2024-02-15 11:32:08 +02:00
Pete Matsyburka 008b83119d better invalid role error message 2024-02-15 10:34:10 +02:00
Pete Matsyburka 15dafcb6ff downcase email when sign in 2024-02-15 10:27:04 +02:00
Pete Matsyburka 7d291ed888 add linked_account_accounts 2024-02-15 09:40:57 +02:00
Pete Matsyburka a7221f0750 fix form field size 2024-02-15 00:58:47 +02:00
Pete Matsyburka 9a5b2a5d30 add receive completed email config 2024-02-14 22:13:07 +02:00
Pete Matsyburka 7361a00d42 use universal font 2024-02-14 20:00:45 +02:00
Pete Matsyburka e07d402782 fix testing account email message 2024-02-13 23:05:31 +02:00
Pete Matsyburka a34354bee1 fix checkmark 2024-02-13 22:23:42 +02:00
Pete Matsyburka 84b762b529 report errors 2024-02-13 22:02:15 +02:00
Pete Matsyburka 2a839951b4 clone tenant tempaltes 2024-02-13 18:24:51 +02:00
Pete Matsyburka 26d7c2f047 add signature format settings 2024-02-12 21:36:06 +02:00
Pete Matsyburka 151a5b85f3 configure rollbar 2024-02-12 17:36:47 +02:00
Pete Matsyburka 3f506f53b4 add encrypted user configs controller 2024-02-12 12:26:22 +02:00
Pete Matsyburka 95f734e4b8 adjust cloud logs 2024-02-11 15:25:10 +02:00
Pete Matsyburka ff74574e84 adjust api completion 2024-02-10 21:03:12 +02:00
Pete Matsyburka 843f6c405f max 20 signer parties 2024-02-10 11:31:20 +02:00
Pete Matsyburka f8b24ebfce retry webhooks on non 200 status 2024-02-10 11:31:19 +02:00
Pete Matsyburka d1d26b5424 increase canvas resolution 2024-02-09 22:33:18 +02:00
Pete Matsyburka 8aa6f45da8 fix signature image reupload 2024-02-09 21:24:30 +02:00
Pete Matsyburka 48d5bee95b parse unix date 2024-02-09 15:03:20 +02:00
Pete Matsyburka 98c79d2dec skip anotation if nil 2024-02-09 00:43:13 +02:00
Pete Matsyburka 0f51238f62 fix default date 2024-02-08 23:29:18 +02:00
Pete Matsyburka b7323b929f set signing date option 2024-02-08 22:55:13 +02:00
Pete Matsyburka 05eb1537f7 replace checkmark emoji 2024-02-08 21:44:27 +02:00
Pete Matsyburka bc9f27cfed allow to aunarchive template 2024-02-08 20:49:10 +02:00
Pete Matsyburka 8f457be719 fix default values 2024-02-08 20:12:08 +02:00
Pete Matsyburka 6d590e13c9 flatten pdf form when signing 2024-02-08 18:06:55 +02:00
Pete Matsyburka c987ec172a fix canvas intersection observer 2024-02-08 12:11:10 +02:00
Pete Matsyburka 49f1637c47 add more submitter parties 2024-02-08 11:54:54 +02:00
Pete Matsyburka 13379bd699 resize canvas on visibility change 2024-02-07 01:47:57 +02:00
Pete Matsyburka 5ee762c08c adjust update tempalte api 2024-02-06 20:07:15 +02:00
Pete Matsyburka edb6d561f1 allow to add checked checkboxes in builder 2024-02-05 23:03:45 +02:00
276 changed files with 8053 additions and 2666 deletions

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@
"rules": { "rules": {
"vue/no-deprecated-html-element-is": 0, "vue/no-deprecated-html-element-is": 0,
"vue/no-mutating-props": 0, "vue/no-mutating-props": 0,
"vue/no-v-html": 0 "vue/one-component-per-file": 0
}, },
"parserOptions": { "parserOptions": {
"ecmaVersion": 2022, "ecmaVersion": 2022,
+5 -5
View File
@@ -11,7 +11,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 3.2.2 ruby-version: 3.3.1
- name: Cache gems - name: Cache gems
uses: actions/cache@v1 uses: actions/cache@v1
with: with:
@@ -35,7 +35,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 3.2.2 ruby-version: 3.3.1
- name: Cache gems - name: Cache gems
uses: actions/cache@v1 uses: actions/cache@v1
with: with:
@@ -59,7 +59,7 @@ jobs:
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 16.13.1 node-version: 20.9.0
- name: Cache directory path - name: Cache directory path
id: yarn-cache-dir-path id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)" run: echo "::set-output name=dir::$(yarn cache dir)"
@@ -100,11 +100,11 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 3.2.2 ruby-version: 3.3.1
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 16.13.1 node-version: 20.9.0
- name: Install Chrome - name: Install Chrome
uses: browser-actions/setup-chrome@latest uses: browser-actions/setup-chrome@latest
- name: Cache node_modules - name: Cache node_modules
+3 -3
View File
@@ -10,7 +10,7 @@ AllCops:
- node_modules/**/* - node_modules/**/*
- bin/* - bin/*
- vendor/**/* - vendor/**/*
TargetRubyVersion: '3.2' TargetRubyVersion: '3.3'
Metrics/BlockLength: Metrics/BlockLength:
Exclude: Exclude:
@@ -30,7 +30,7 @@ Metrics/ParameterLists:
Max: 10 Max: 10
Metrics/MethodLength: Metrics/MethodLength:
Max: 25 Max: 30
Exclude: Exclude:
- 'db/migrate/**' - 'db/migrate/**'
@@ -44,7 +44,7 @@ Layout/LineLength:
AllowedPatterns: ['\A\s*#'] AllowedPatterns: ['\A\s*#']
Metrics/AbcSize: Metrics/AbcSize:
Max: 40 Max: 45
Metrics/ModuleLength: Metrics/ModuleLength:
Max: 500 Max: 500
+28 -13
View File
@@ -1,12 +1,12 @@
FROM ruby:3.2.2-alpine3.18 as fonts FROM ruby:3.3.1-alpine3.18 as fonts
WORKDIR /fonts WORKDIR /fonts
RUN apk --no-cache add fontforge wget ttf-liberation && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf . && wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansArabic/hinted/ttf/NotoSansArabic-Regular.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt RUN apk --no-cache add fontforge wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt
RUN fontforge -lang=py -c 'font1 = fontforge.open("LiberationSans-Regular.ttf"); font2 = fontforge.open("NotoSansArabic-Regular.ttf"); font1.mergeFonts(font2); font1.generate("LiberationSans-Regular.ttf")' RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
FROM ruby:3.2.2-alpine3.18 as webpack FROM ruby:3.3.1-alpine3.18 as webpack
ENV RAILS_ENV=production ENV RAILS_ENV=production
ENV NODE_ENV=production ENV NODE_ENV=production
@@ -23,27 +23,41 @@ RUN yarn install --network-timeout 1000000
COPY ./bin/shakapacker ./bin/shakapacker COPY ./bin/shakapacker ./bin/shakapacker
COPY ./config/webpack ./config/webpack COPY ./config/webpack ./config/webpack
COPY ./config/shakapacker.yml ./config/shakapacker.yml COPY ./config/shakapacker.yml ./config/shakapacker.yml
COPY ./postcss.config.js ./postcss.config.js ./ COPY ./postcss.config.js ./postcss.config.js
COPY ./tailwind.config.js ./tailwind.config.js ./ COPY ./tailwind.config.js ./tailwind.config.js
COPY ./tailwind.form.config.js ./tailwind.form.config.js ./ COPY ./tailwind.form.config.js ./tailwind.form.config.js
COPY ./tailwind.application.config.js ./tailwind.application.config.js ./ COPY ./tailwind.application.config.js ./tailwind.application.config.js
COPY ./app/javascript ./app/javascript COPY ./app/javascript ./app/javascript
COPY ./app/views ./app/views COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:3.2.2-alpine3.18 as app FROM ruby:3.3.1-alpine3.18 as app
ENV RAILS_ENV=production ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test" ENV BUNDLE_WITHOUT="development:test"
ENV LD_PRELOAD=/lib/libgcompat.so.0
ENV OPENSSL_CONF=/app/openssl_legacy.cnf
WORKDIR /app WORKDIR /app
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 && mkdir /fonts RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN echo $'.include = /etc/ssl/openssl.cnf\n\
\n\
[provider_sect]\n\
default = default_sect\n\
legacy = legacy_sect\n\
\n\
[default_sect]\n\
activate = 1\n\
\n\
[legacy_sect]\n\
activate = 1' >> /app/openssl_legacy.cnf
COPY ./Gemfile ./Gemfile.lock ./ COPY ./Gemfile ./Gemfile.lock ./
RUN bundle update --bundler && bundle install && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf RUN apk add --no-cache build-base && bundle install && apk del build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf
COPY ./bin ./bin COPY ./bin ./bin
COPY ./app ./app COPY ./app ./app
@@ -55,7 +69,8 @@ COPY ./public ./public
COPY ./tmp ./tmp COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru .version ./ COPY LICENSE README.md Rakefile config.ru .version ./
COPY --from=fonts /fonts/LiberationSans-Regular.ttf /fonts/LiberationSans-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts COPY --from=fonts /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --from=fonts /fonts/FreeSans.ttf /usr/share/fonts/freefont
COPY --from=webpack /app/public/packs ./public/packs COPY --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts RUN ln -s /fonts /app/public/fonts
@@ -65,4 +80,4 @@ WORKDIR /data/docuseal
ENV WORKDIR=/data/docuseal ENV WORKDIR=/data/docuseal
EXPOSE 3000 EXPOSE 3000
CMD ["/app/bin/rails", "server"] CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
+3 -4
View File
@@ -2,13 +2,14 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '3.2.2' ruby '3.3.1'
gem 'arabic-letter-connector', require: 'arabic-letter-connector/logic' gem 'arabic-letter-connector', require: 'arabic-letter-connector/logic'
gem 'aws-sdk-s3', require: false gem 'aws-sdk-s3', require: false
gem 'azure-storage-blob', require: false gem 'azure-storage-blob', require: false
gem 'bootsnap', require: false gem 'bootsnap', require: false
gem 'cancancan' gem 'cancancan'
gem 'csv'
gem 'devise' gem 'devise'
gem 'devise-two-factor' gem 'devise-two-factor'
gem 'dotenv', require: false gem 'dotenv', require: false
@@ -23,7 +24,6 @@ gem 'lograge'
gem 'mysql2', require: false gem 'mysql2', require: false
gem 'oj' gem 'oj'
gem 'pagy' gem 'pagy'
gem 'pdf-reader'
gem 'pg', require: false gem 'pg', require: false
gem 'premailer-rails' gem 'premailer-rails'
gem 'pretender' gem 'pretender'
@@ -32,14 +32,13 @@ gem 'rack'
gem 'rails' gem 'rails'
gem 'rails_autolink' gem 'rails_autolink'
gem 'rails-i18n' gem 'rails-i18n'
gem 'rollbar', require: ENV.key?('ROLLBAR_ACCESS_TOKEN')
gem 'rotp' gem 'rotp'
gem 'rqrcode' gem 'rqrcode'
gem 'ruby-vips' gem 'ruby-vips'
gem 'rubyXL' gem 'rubyXL'
gem 'shakapacker' gem 'shakapacker'
gem 'sidekiq', require: ENV.key?('REDIS_URL') gem 'sidekiq', require: ENV.key?('REDIS_URL')
gem 'sqlite3', require: false gem 'sqlite3', require: false, force_ruby_platform: true
gem 'strip_attributes' gem 'strip_attributes'
gem 'turbo-rails' gem 'turbo-rails'
gem 'twitter_cldr', require: false gem 'twitter_cldr', require: false
+186 -186
View File
@@ -1,36 +1,35 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
Ascii85 (1.1.0) actioncable (7.1.3.2)
actioncable (7.1.2) actionpack (= 7.1.3.2)
actionpack (= 7.1.2) activesupport (= 7.1.3.2)
activesupport (= 7.1.2)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (7.1.2) actionmailbox (7.1.3.2)
actionpack (= 7.1.2) actionpack (= 7.1.3.2)
activejob (= 7.1.2) activejob (= 7.1.3.2)
activerecord (= 7.1.2) activerecord (= 7.1.3.2)
activestorage (= 7.1.2) activestorage (= 7.1.3.2)
activesupport (= 7.1.2) activesupport (= 7.1.3.2)
mail (>= 2.7.1) mail (>= 2.7.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
actionmailer (7.1.2) actionmailer (7.1.3.2)
actionpack (= 7.1.2) actionpack (= 7.1.3.2)
actionview (= 7.1.2) actionview (= 7.1.3.2)
activejob (= 7.1.2) activejob (= 7.1.3.2)
activesupport (= 7.1.2) activesupport (= 7.1.3.2)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (7.1.2) actionpack (7.1.3.2)
actionview (= 7.1.2) actionview (= 7.1.3.2)
activesupport (= 7.1.2) activesupport (= 7.1.3.2)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
racc racc
rack (>= 2.2.4) rack (>= 2.2.4)
@@ -38,35 +37,35 @@ GEM
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
actiontext (7.1.2) actiontext (7.1.3.2)
actionpack (= 7.1.2) actionpack (= 7.1.3.2)
activerecord (= 7.1.2) activerecord (= 7.1.3.2)
activestorage (= 7.1.2) activestorage (= 7.1.3.2)
activesupport (= 7.1.2) activesupport (= 7.1.3.2)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.1.2) actionview (7.1.3.2)
activesupport (= 7.1.2) activesupport (= 7.1.3.2)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activejob (7.1.2) activejob (7.1.3.2)
activesupport (= 7.1.2) activesupport (= 7.1.3.2)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.1.2) activemodel (7.1.3.2)
activesupport (= 7.1.2) activesupport (= 7.1.3.2)
activerecord (7.1.2) activerecord (7.1.3.2)
activemodel (= 7.1.2) activemodel (= 7.1.3.2)
activesupport (= 7.1.2) activesupport (= 7.1.3.2)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (7.1.2) activestorage (7.1.3.2)
actionpack (= 7.1.2) actionpack (= 7.1.3.2)
activejob (= 7.1.2) activejob (= 7.1.3.2)
activerecord (= 7.1.2) activerecord (= 7.1.3.2)
activesupport (= 7.1.2) activesupport (= 7.1.3.2)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (7.1.2) activesupport (7.1.3.2)
base64 base64
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
@@ -78,24 +77,23 @@ GEM
tzinfo (~> 2.0) tzinfo (~> 2.0)
addressable (2.8.6) addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0) public_suffix (>= 2.0.2, < 6.0)
afm (0.2.2)
annotate (3.2.0) annotate (3.2.0)
activerecord (>= 3.2, < 8.0) activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0) rake (>= 10.4, < 14.0)
arabic-letter-connector (0.1.1) arabic-letter-connector (0.1.1)
ast (2.4.2) ast (2.4.2)
aws-eventstream (1.3.0) aws-eventstream (1.3.0)
aws-partitions (1.874.0) aws-partitions (1.913.0)
aws-sdk-core (3.190.1) aws-sdk-core (3.191.6)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0) aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8) aws-sigv4 (~> 1.8)
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.75.0) aws-sdk-kms (1.79.0)
aws-sdk-core (~> 3, >= 3.188.0) aws-sdk-core (~> 3, >= 3.191.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.142.0) aws-sdk-s3 (1.146.1)
aws-sdk-core (~> 3, >= 3.189.0) aws-sdk-core (~> 3, >= 3.191.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.8) aws-sigv4 (~> 1.8)
aws-sigv4 (1.8.0) aws-sigv4 (1.8.0)
@@ -110,52 +108,55 @@ GEM
nokogiri (~> 1, >= 1.10.8) nokogiri (~> 1, >= 1.10.8)
base64 (0.2.0) base64 (0.2.0)
bcrypt (3.1.20) bcrypt (3.1.20)
better_html (2.0.2) better_html (2.1.1)
actionview (>= 6.0) actionview (>= 6.0)
activesupport (>= 6.0) activesupport (>= 6.0)
ast (~> 2.0) ast (~> 2.0)
erubi (~> 1.4) erubi (~> 1.4)
parser (>= 2.4) parser (>= 2.4)
smart_properties smart_properties
bigdecimal (3.1.5) bigdecimal (3.1.8)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.17.0) bootsnap (1.18.3)
msgpack (~> 1.2) msgpack (~> 1.2)
builder (3.2.4) builder (3.2.4)
bullet (7.1.4) bullet (7.1.6)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
uniform_notifier (~> 1.11) uniform_notifier (~> 1.11)
camertron-eprun (1.1.1) camertron-eprun (1.1.1)
cancancan (3.5.0) cancancan (3.5.0)
capybara (3.39.2) capybara (3.40.0)
addressable addressable
matrix matrix
mini_mime (>= 0.1.3) mini_mime (>= 0.1.3)
nokogiri (~> 1.8) nokogiri (~> 1.11)
rack (>= 1.6.0) rack (>= 1.6.0)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0) regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2) xpath (~> 3.2)
childprocess (5.0.0)
chunky_png (1.4.0) chunky_png (1.4.0)
cldr-plurals-runtime-rb (1.1.0) cldr-plurals-runtime-rb (1.1.0)
cmdparse (3.0.7) cmdparse (3.0.7)
coderay (1.1.3) coderay (1.1.3)
concurrent-ruby (1.2.2) concurrent-ruby (1.2.3)
connection_pool (2.4.1) connection_pool (2.4.1)
crack (0.4.5) crack (1.0.0)
bigdecimal
rexml rexml
crass (1.0.6) crass (1.0.6)
css_parser (1.16.0) css_parser (1.17.1)
addressable addressable
csv (3.3.0)
cuprite (0.15) cuprite (0.15)
capybara (~> 3.0) capybara (~> 3.0)
ferrum (~> 0.14.0) ferrum (~> 0.14.0)
date (3.3.4) date (3.3.4)
debug (1.9.1) debug (1.9.2)
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
declarative (0.0.20) declarative (0.0.20)
devise (4.9.3) devise (4.9.4)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
railties (>= 4.1.0) railties (>= 4.1.0)
@@ -166,13 +167,12 @@ GEM
devise (~> 4.0) devise (~> 4.0)
railties (~> 7.0) railties (~> 7.0)
rotp (~> 6.0) rotp (~> 6.0)
diff-lcs (1.5.0) diff-lcs (1.5.1)
digest-crc (0.6.5) digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0) rake (>= 12.0.0, < 14.0.0)
docile (1.4.0) docile (1.4.0)
dotenv (2.8.1) dotenv (3.1.0)
drb (2.2.0) drb (2.2.1)
ruby2_keywords
email_typo (0.2.3) email_typo (0.2.3)
erb_lint (0.5.0) erb_lint (0.5.0)
activesupport activesupport
@@ -182,12 +182,12 @@ GEM
rubocop rubocop
smart_properties smart_properties
erubi (1.12.0) erubi (1.12.0)
factory_bot (6.4.4) factory_bot (6.4.6)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
factory_bot_rails (6.4.2) factory_bot_rails (6.4.3)
factory_bot (~> 6.4) factory_bot (~> 6.4)
railties (>= 5.0.0) railties (>= 5.0.0)
faker (3.2.2) faker (3.3.1)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (1.10.3) faraday (1.10.3)
faraday-em_http (~> 1.0) faraday-em_http (~> 1.0)
@@ -225,34 +225,34 @@ GEM
geom2d (0.4.1) geom2d (0.4.1)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
google-apis-core (0.11.2) google-apis-core (0.14.1)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a) googleauth (~> 1.9)
httpclient (>= 2.8.1, < 3.a) httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0) mini_mime (~> 1.0)
representable (~> 3.0) representable (~> 3.0)
retriable (>= 2.0, < 4.a) retriable (>= 2.0, < 4.a)
rexml rexml
webrick google-apis-iamcredentials_v1 (0.20.0)
google-apis-iamcredentials_v1 (0.17.0) google-apis-core (>= 0.14.0, < 2.a)
google-apis-core (>= 0.11.0, < 2.a) google-apis-storage_v1 (0.37.0)
google-apis-storage_v1 (0.29.0) google-apis-core (>= 0.14.0, < 2.a)
google-apis-core (>= 0.11.0, < 2.a) google-cloud-core (1.7.0)
google-cloud-core (1.6.1)
google-cloud-env (>= 1.0, < 3.a) google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-cloud-env (2.1.0) google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a) faraday (>= 1.0, < 3.a)
google-cloud-errors (1.3.1) google-cloud-errors (1.4.0)
google-cloud-storage (1.45.0) google-cloud-storage (1.49.0)
addressable (~> 2.8) addressable (~> 2.8)
digest-crc (~> 0.4) digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1) google-apis-core (~> 0.13)
google-apis-storage_v1 (~> 0.29.0) google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (~> 0.33)
google-cloud-core (~> 1.6) google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a) googleauth (~> 1.9)
mini_mime (~> 1.0) mini_mime (~> 1.0)
googleauth (1.9.1) googleauth (1.11.0)
faraday (>= 1.0, < 3.a) faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1) google-cloud-env (~> 2.1)
jwt (>= 1.4, < 3.0) jwt (>= 1.4, < 3.0)
@@ -260,30 +260,31 @@ GEM
os (>= 0.9, < 2.0) os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a) signet (>= 0.16, < 2.a)
hashdiff (1.1.0) hashdiff (1.1.0)
hashery (2.1.2) hexapdf (0.42.0)
hexapdf (0.34.1)
cmdparse (~> 3.0, >= 3.0.3) cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1) geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1) openssl (>= 2.2.1)
htmlentities (4.3.4) htmlentities (4.3.4)
httpclient (2.8.3) httpclient (2.8.3)
i18n (1.14.1) i18n (1.14.5)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.12.2) image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5) mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3) ruby-vips (>= 2.0.17, < 3)
io-console (0.7.1) io-console (0.7.2)
irb (1.11.0) irb (1.13.1)
rdoc rdoc (>= 4.0.0)
reline (>= 0.3.8) reline (>= 0.4.2)
jmespath (1.6.2) jmespath (1.6.2)
json (2.7.1) json (2.7.2)
jwt (2.7.1) jwt (2.8.1)
base64
language_server-protocol (3.17.0.3) language_server-protocol (3.17.0.3)
launchy (2.5.2) launchy (3.0.0)
addressable (~> 2.8) addressable (~> 2.8)
letter_opener (1.8.1) childprocess (~> 5.0)
launchy (>= 2.2, < 3) letter_opener (1.10.0)
launchy (>= 2.2, < 4)
letter_opener_web (2.0.0) letter_opener_web (2.0.0)
actionmailer (>= 5.2) actionmailer (>= 5.2)
letter_opener (~> 1.7) letter_opener (~> 1.7)
@@ -302,21 +303,21 @@ GEM
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
marcel (1.0.2) marcel (1.0.4)
matrix (0.4.2) matrix (0.4.2)
method_source (1.0.0) method_source (1.1.0)
mini_magick (4.12.0) mini_magick (4.12.0)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.5) mini_portile2 (2.8.6)
minitest (5.20.0) minitest (5.23.0)
msgpack (1.7.2) msgpack (1.7.2)
multi_json (1.15.0) multi_json (1.15.0)
multipart-post (2.3.0) multipart-post (2.4.0)
mutex_m (0.2.0) mutex_m (0.2.0)
mysql2 (0.5.5) mysql2 (0.5.6)
net-http-persistent (4.0.2) net-http-persistent (4.0.2)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-imap (0.4.9) net-imap (0.4.10)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -325,28 +326,27 @@ GEM
timeout timeout
net-smtp (0.4.0) net-smtp (0.4.0)
net-protocol net-protocol
nio4r (2.7.0) nio4r (2.7.1)
nokogiri (1.15.5) nokogiri (1.16.5)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.5-aarch64-linux)
racc (~> 1.4)
nokogiri (1.16.5-x86_64-linux)
racc (~> 1.4)
oj (3.16.3) oj (3.16.3)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
openssl (3.2.0) openssl (3.2.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
pagy (6.2.0) package_json (0.1.0)
pagy (8.1.2)
parallel (1.24.0) parallel (1.24.0)
parser (3.2.2.4) parser (3.3.1.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pdf-reader (2.12.0) pg (1.5.6)
Ascii85 (~> 1.0) premailer (1.23.0)
afm (~> 0.2.1)
hashery (~> 2.0)
ruby-rc4
ttfunk
pg (1.5.4)
premailer (1.21.0)
addressable addressable
css_parser (>= 1.12.0) css_parser (>= 1.12.0)
htmlentities (>= 4.0.0) htmlentities (>= 4.0.0)
@@ -363,34 +363,34 @@ GEM
pry (>= 0.10.4) pry (>= 0.10.4)
psych (5.1.2) psych (5.1.2)
stringio stringio
public_suffix (5.0.4) public_suffix (5.0.5)
puma (6.4.0) puma (6.4.2)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.7.3) racc (1.8.0)
rack (2.2.8) rack (3.0.11)
rack-proxy (0.7.7) rack-proxy (0.7.7)
rack rack
rack-session (1.0.2) rack-session (2.0.0)
rack (< 3) rack (>= 3.0.0)
rack-test (2.1.0) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rackup (1.0.0) rackup (2.1.0)
rack (< 3) rack (>= 3)
webrick webrick (~> 1.8)
rails (7.1.2) rails (7.1.3.2)
actioncable (= 7.1.2) actioncable (= 7.1.3.2)
actionmailbox (= 7.1.2) actionmailbox (= 7.1.3.2)
actionmailer (= 7.1.2) actionmailer (= 7.1.3.2)
actionpack (= 7.1.2) actionpack (= 7.1.3.2)
actiontext (= 7.1.2) actiontext (= 7.1.3.2)
actionview (= 7.1.2) actionview (= 7.1.3.2)
activejob (= 7.1.2) activejob (= 7.1.3.2)
activemodel (= 7.1.2) activemodel (= 7.1.3.2)
activerecord (= 7.1.2) activerecord (= 7.1.3.2)
activestorage (= 7.1.2) activestorage (= 7.1.3.2)
activesupport (= 7.1.2) activesupport (= 7.1.3.2)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.1.2) railties (= 7.1.3.2)
rails-dom-testing (2.2.0) rails-dom-testing (2.2.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
@@ -398,114 +398,116 @@ GEM
rails-html-sanitizer (1.6.0) rails-html-sanitizer (1.6.0)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (~> 1.14) nokogiri (~> 1.14)
rails-i18n (7.0.8) rails-i18n (7.0.9)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8) railties (>= 6.0.0, < 8)
rails_autolink (1.1.8) rails_autolink (1.1.8)
actionview (> 3.1) actionview (> 3.1)
activesupport (> 3.1) activesupport (> 3.1)
railties (> 3.1) railties (> 3.1)
railties (7.1.2) railties (7.1.3.2)
actionpack (= 7.1.2) actionpack (= 7.1.3.2)
activesupport (= 7.1.2) activesupport (= 7.1.3.2)
irb irb
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0, >= 1.2.2) thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.1.0) rake (13.2.1)
rdoc (6.6.2) rdoc (6.6.3.1)
psych (>= 4.0.0) psych (>= 4.0.0)
redis-client (0.19.1) redis-client (0.22.0)
connection_pool connection_pool
regexp_parser (2.8.3) regexp_parser (2.9.0)
reline (0.4.1) reline (0.5.7)
io-console (~> 0.5) io-console (~> 0.5)
representable (3.2.0) representable (3.2.0)
declarative (< 0.1.0) declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0) trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0) uber (< 0.2.0)
request_store (1.5.1) request_store (1.6.0)
rack (>= 1.4) rack (>= 1.4)
responders (3.1.1) responders (3.1.1)
actionpack (>= 5.2) actionpack (>= 5.2)
railties (>= 5.2) railties (>= 5.2)
retriable (3.1.2) retriable (3.1.2)
rexml (3.2.6) rexml (3.2.6)
rollbar (3.4.2)
rotp (6.3.0) rotp (6.3.0)
rqrcode (2.2.0) rqrcode (2.2.0)
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 1.0) rqrcode_core (~> 1.0)
rqrcode_core (1.2.0) rqrcode_core (1.2.0)
rspec-core (3.12.2) rspec-core (3.13.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.13.0)
rspec-expectations (3.12.3) rspec-expectations (3.13.0)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.13.0)
rspec-mocks (3.12.6) rspec-mocks (3.13.0)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.13.0)
rspec-rails (6.1.0) rspec-rails (6.1.2)
actionpack (>= 6.1) actionpack (>= 6.1)
activesupport (>= 6.1) activesupport (>= 6.1)
railties (>= 6.1) railties (>= 6.1)
rspec-core (~> 3.12) rspec-core (~> 3.13)
rspec-expectations (~> 3.12) rspec-expectations (~> 3.13)
rspec-mocks (~> 3.12) rspec-mocks (~> 3.13)
rspec-support (~> 3.12) rspec-support (~> 3.13)
rspec-support (3.12.1) rspec-support (3.13.1)
rubocop (1.59.0) rubocop (1.63.3)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (>= 3.17.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.2.2.4) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0) regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0) rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.30.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0) unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.30.0) rubocop-ast (1.31.2)
parser (>= 3.2.1.0) parser (>= 3.3.0.4)
rubocop-capybara (2.19.0) rubocop-capybara (2.20.0)
rubocop (~> 1.41) rubocop (~> 1.41)
rubocop-factory_bot (2.24.0) rubocop-factory_bot (2.25.1)
rubocop (~> 1.33) rubocop (~> 1.41)
rubocop-performance (1.20.1) rubocop-performance (1.21.0)
rubocop (>= 1.48.1, < 2.0) rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.30.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails (2.23.1) rubocop-rails (2.24.1)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0) rubocop (>= 1.33.0, < 2.0)
rubocop-ast (>= 1.30.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rspec (2.25.0) rubocop-rspec (2.29.1)
rubocop (~> 1.40) rubocop (~> 1.40)
rubocop-capybara (~> 2.17) rubocop-capybara (~> 2.17)
rubocop-factory_bot (~> 2.22) rubocop-factory_bot (~> 2.22)
rubocop-rspec_rails (~> 2.28)
rubocop-rspec_rails (2.28.3)
rubocop (~> 1.40)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby-rc4 (0.1.5) ruby-vips (2.2.1)
ruby-vips (2.2.0)
ffi (~> 1.12) ffi (~> 1.12)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
rubyXL (3.4.25) rubyXL (3.4.27)
nokogiri (>= 1.10.8) nokogiri (>= 1.10.8)
rubyzip (>= 1.3.0) rubyzip (>= 1.3.0)
rubyzip (2.3.2) rubyzip (2.3.2)
semantic_range (3.0.0) semantic_range (3.0.0)
shakapacker (7.1.0) shakapacker (8.0.0)
activesupport (>= 5.2) activesupport (>= 5.2)
package_json
rack-proxy (>= 0.6.1) rack-proxy (>= 0.6.1)
railties (>= 5.2) railties (>= 5.2)
semantic_range (>= 2.3.0) semantic_range (>= 2.3.0)
sidekiq (7.2.0) sidekiq (7.2.2)
concurrent-ruby (< 2) concurrent-ruby (< 2)
connection_pool (>= 2.3.0) connection_pool (>= 2.3.0)
rack (>= 2.2.4) rack (>= 2.2.4)
redis-client (>= 0.14.0) redis-client (>= 0.19.0)
signet (0.18.0) signet (0.19.0)
addressable (~> 2.8) addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a) faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0) jwt (>= 1.5, < 3.0)
@@ -517,17 +519,15 @@ GEM
simplecov-html (0.12.3) simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
smart_properties (1.17.0) smart_properties (1.17.0)
sqlite3 (1.7.0) sqlite3 (1.7.3)
mini_portile2 (~> 2.8.0) mini_portile2 (~> 2.8.0)
sqlite3 (1.7.0-arm64-darwin)
stringio (3.1.0) stringio (3.1.0)
strip_attributes (1.13.0) strip_attributes (1.13.0)
activemodel (>= 3.0, < 8.0) activemodel (>= 3.0, < 8.0)
thor (1.3.0) thor (1.3.1)
timeout (0.4.1) timeout (0.4.1)
trailblazer-option (0.1.2) trailblazer-option (0.1.2)
ttfunk (1.7.0) turbo-rails (2.0.5)
turbo-rails (1.5.0)
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activejob (>= 6.0.0) activejob (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
@@ -537,7 +537,7 @@ GEM
tzinfo tzinfo
tzinfo (2.0.6) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
tzinfo-data (1.2023.4) tzinfo-data (1.2024.1)
tzinfo (>= 1.0.0) tzinfo (>= 1.0.0)
uber (0.1.0) uber (0.1.0)
unicode-display_width (2.5.0) unicode-display_width (2.5.0)
@@ -549,7 +549,7 @@ GEM
activemodel (>= 6.0.0) activemodel (>= 6.0.0)
bindex (>= 0.4.0) bindex (>= 0.4.0)
railties (>= 6.0.0) railties (>= 6.0.0)
webmock (3.19.1) webmock (3.23.0)
addressable (>= 2.8.0) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
@@ -559,11 +559,12 @@ GEM
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.6.12) zeitwerk (2.6.14)
PLATFORMS PLATFORMS
arm64-darwin-22 aarch64-linux
ruby ruby
x86_64-linux
DEPENDENCIES DEPENDENCIES
annotate annotate
@@ -575,6 +576,7 @@ DEPENDENCIES
bullet bullet
cancancan cancancan
capybara capybara
csv
cuprite cuprite
debug debug
devise devise
@@ -595,7 +597,6 @@ DEPENDENCIES
mysql2 mysql2
oj oj
pagy pagy
pdf-reader
pg pg
premailer-rails premailer-rails
pretender pretender
@@ -605,7 +606,6 @@ DEPENDENCIES
rails rails
rails-i18n rails-i18n
rails_autolink rails_autolink
rollbar
rotp rotp
rqrcode rqrcode
rspec-rails rspec-rails
@@ -627,7 +627,7 @@ DEPENDENCIES
webmock webmock
RUBY VERSION RUBY VERSION
ruby 3.2.2p53 ruby 3.3.1p55
BUNDLED WITH BUNDLED WITH
2.5.3 2.5.3
+33 -15
View File
@@ -33,17 +33,32 @@ DocuSeal is an open source platform that provides secure and efficient digital d
[![Demo](https://github.com/docusealco/docuseal/assets/5418788/d8703ea3-361a-423f-8bfe-eff1bd9dbe14)](https://demo.docuseal.co) [![Demo](https://github.com/docusealco/docuseal/assets/5418788/d8703ea3-361a-423f-8bfe-eff1bd9dbe14)](https://demo.docuseal.co)
## Features ## Features
- [x] PDF form fields builder (WYSIWYG) - PDF form fields builder (WYSIWYG)
- [x] 11 field types available (Signature, Date, File, Checkbox etc.) - 12 field types available (Signature, Date, File, Checkbox etc.)
- [x] Multiple submitters per document - Multiple submitters per document
- [x] Automated emails via SMTP - Automated emails via SMTP
- [x] Files storage on disk or AWS S3, Google Storage, Azure Cloud - Files storage on disk or AWS S3, Google Storage, Azure Cloud
- [x] Automatic PDF eSignature - Automatic PDF eSignature
- [x] PDF signature verification - PDF signature verification
- [x] Users management - Users management
- [x] Mobile-optimized - Mobile-optimized
- [x] API and Webhooks for integrations - Signing available in 13 languages
- [x] Easy to deploy in minutes - API and Webhooks for integrations
- Easy to deploy in minutes
## Pro Features
- Company logo and white-label
- User roles
- Automated reminders
- Invitation and identify verification via SMS
- Conditional fields and formulas
- Bulk send with CSV, XLSX spreadsheet import
- SSO / SAML
- Template creation with HTML API ([Guide](https://www.docuseal.co/guides/create-pdf-document-fillable-form-with-html-api))
- Template creation with PDF or DOCX and field tags API ([Guide](https://www.docuseal.co/guides/use-embedded-text-field-tags-in-the-pdf-to-create-a-fillable-form))
- Embedded signing form ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue), [Angular](https://github.com/docusealco/docuseal-angular) or [JavaScript](https://www.docuseal.co/docs/embedded))
- Embedded document form builder ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue), [Angular](https://github.com/docusealco/docuseal-angular) or [JavaScript](https://www.docuseal.co/docs/embedded))
- [Learn more](https://www.docuseal.co/pricing)
## Deploy ## Deploy
@@ -52,9 +67,6 @@ 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)| | [<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**| |**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) | [<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 #### Docker
@@ -77,7 +89,7 @@ sudo HOST=your-domain-name.com docker compose up
``` ```
## For Businesses ## For Businesses
### Integrate seamless document signing into your web or mobile apps with DocuSeal! ### 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, 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. 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.
@@ -87,3 +99,9 @@ At DocuSeal we have expertise and technologies to make documents creation, filli
Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information. 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. Unless otherwise noted, all files © 2023 DocuSeal LLC.
## Tools
- [Signature Maker](https://www.docuseal.co/online-signature)
- [Sign Document Online](https://www.docuseal.co/sign-documents-online)
- [Fill PDF Online](https://www.docuseal.co/fill-pdf)
+14
View File
@@ -0,0 +1,14 @@
# Reporting a Vulnerability
If you come across any security concern or vulnarability, please report the information via email to security@docuseal.co instead of opening a GitHub issue. We will promptly respond and will collaborate with you to validate the issue, and resolve it ASAP.
**We have a bug bounty program to reward security researchers.**
Out of scope vulnerabilities:
- CSRF
- DNSSEC, CAA, CSP headers
- DNS or email security related
- Rate Limiting
Note: We reserve the right to classify any reported vulnerability as out of scope for the bug bounty program.
@@ -8,9 +8,15 @@ class AccountConfigsController < ApplicationController
AccountConfig::ALLOW_TYPED_SIGNATURE, AccountConfig::ALLOW_TYPED_SIGNATURE,
AccountConfig::FORCE_MFA, AccountConfig::FORCE_MFA,
AccountConfig::ALLOW_TO_RESUBMIT, AccountConfig::ALLOW_TO_RESUBMIT,
AccountConfig::ESIGNING_PREFERENCE_KEY AccountConfig::ESIGNING_PREFERENCE_KEY,
AccountConfig::FORM_WITH_CONFETTI_KEY,
AccountConfig::DOWNLOAD_LINKS_AUTH_KEY,
AccountConfig::FORCE_SSO_AUTH_KEY,
AccountConfig::FLATTEN_RESULT_PDF_KEY
].freeze ].freeze
InvalidKey = Class.new(StandardError)
def create def create
@account_config.update!(account_config_params) @account_config.update!(account_config_params)
@@ -20,7 +26,7 @@ class AccountConfigsController < ApplicationController
private private
def load_account_config def load_account_config
return head :not_found unless ALLOWED_KEYS.include?(account_config_params[:key]) raise InvalidKey unless ALLOWED_KEYS.include?(account_config_params[:key])
@account_config = @account_config =
AccountConfig.find_or_initialize_by(account: current_account, key: account_config_params[:key]) AccountConfig.find_or_initialize_by(account: current_account, key: account_config_params[:key])
@@ -0,0 +1,55 @@
# frozen_string_literal: true
module Api
class ActiveStorageBlobsProxyController < ApiBaseController
include ActiveStorage::Streaming
skip_before_action :authenticate_user!
skip_authorization_check
before_action :set_cors_headers
before_action :set_noindex_headers
def show
blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
if blob_uuid.blank? || (purp.present? && purp != 'blob') || (exp && exp < Time.current.to_i)
Rollbar.error('Blob not found') if defined?(Rollbar)
return head :not_found
end
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid)
authorization_check!(blob) if exp.blank?
if request.headers['Range'].present?
send_blob_byte_range_data blob, request.headers['Range']
else
http_cache_forever public: true do
response.headers['Accept-Ranges'] = 'bytes'
response.headers['Content-Length'] = blob.byte_size.to_s
send_blob_stream blob, disposition: params[:disposition]
end
end
end
private
def authorization_check!(blob)
is_authorized =
blob.attachments.all? do |a|
a.name.in?(%w[logo preview_images]) ||
(current_user && a.record.account.id == current_user.account_id) ||
!a.record.account.account_configs.find_or_initialize_by(key: AccountConfig::DOWNLOAD_LINKS_AUTH_KEY).value
end
return if is_authorized
Rollbar.error('Blob aunauthorized') if defined?(Rollbar)
raise CanCan::AccessDenied
end
end
end
@@ -0,0 +1,46 @@
# frozen_string_literal: true
module Api
class ActiveStorageBlobsProxyLegacyController < ApiBaseController
include ActiveStorage::Streaming
skip_before_action :authenticate_user!
skip_authorization_check
before_action :set_cors_headers
before_action :set_noindex_headers
# rubocop:disable Metrics
def show
Rollbar.info('Blob legacy') if defined?(Rollbar)
blob = ActiveStorage::Blob.find_signed(params[:signed_blob_id] || params[:signed_id])
return head :not_found unless blob
is_permitted = blob.attachments.any? do |a|
(current_user && a.record.account.id == current_user.account_id) ||
a.record.account.account_configs.any? { |e| e.key == 'legacy_blob_proxy' } ||
a.name == 'logo'
end
unless is_permitted
Rollbar.error("Blob account not found: #{blob.id}") if defined?(Rollbar)
return render json: { error: 'Not authenticated' }, status: :unauthorized
end
if request.headers['Range'].present?
send_blob_byte_range_data blob, request.headers['Range']
else
http_cache_forever public: true do
response.headers['Accept-Ranges'] = 'bytes'
response.headers['Content-Length'] = blob.byte_size.to_s
send_blob_stream blob, disposition: params[:disposition]
end
end
end
# rubocop:enable Metrics
end
end
+30 -12
View File
@@ -19,15 +19,19 @@ module Api
render json: { error: e.message }, status: :unprocessable_entity render json: { error: e.message }, status: :unprocessable_entity
end end
rescue_from RateLimit::LimitApproached do |e|
Rollbar.error(e) if defined?(Rollbar)
render json: { error: 'Too many requests' }, status: :too_many_requests
end
if Rails.env.production? if Rails.env.production?
rescue_from CanCan::AccessDenied do |e| rescue_from CanCan::AccessDenied do |e|
Rollbar.error(e) if defined?(Rollbar)
render json: { error: e.message }, status: :forbidden render json: { error: e.message }, status: :forbidden
end end
rescue_from JSON::ParserError do |e| rescue_from JSON::ParserError do |e|
Rollbar.error(e) if defined?(Rollbar) Rollbar.warning(e) if defined?(Rollbar)
render json: { error: "JSON parse error: #{e.message}" }, status: :unprocessable_entity render json: { error: "JSON parse error: #{e.message}" }, status: :unprocessable_entity
end end
@@ -39,25 +43,39 @@ module Api
result = relation.order(id: :desc) result = relation.order(id: :desc)
.limit([params.fetch(:limit, DEFAULT_LIMIT).to_i, MAX_LIMIT].min) .limit([params.fetch(:limit, DEFAULT_LIMIT).to_i, MAX_LIMIT].min)
result = result.where(relation.arel_table[:id].lt(params[:after])) if params[:after].present? result = result.where(id: ...params[:after].to_i) if params[:after].present?
result = result.where(relation.arel_table[:id].gt(params[:before])) if params[:before].present? result = result.where(id: (params[:before].to_i + 1)...) if params[:before].present?
result result
end end
def authenticate_user! def authenticate_user!
@current_user ||=
if request.headers['X-Auth-Token'].present?
sha256 = Digest::SHA256.hexdigest(request.headers['X-Auth-Token'])
User.joins(:access_token).active.find_by(access_token: { sha256: })
end
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
end end
def current_user
super || @current_user ||=
if request.headers['X-Auth-Token'].present?
sha256 = Digest::SHA256.hexdigest(request.headers['X-Auth-Token'])
User.joins(:access_token).active.find_by(access_token: { sha256: })
end
end
def current_account def current_account
current_user&.account current_user&.account
end end
def set_noindex_headers
headers['X-Robots-Tag'] = 'noindex'
end
def set_cors_headers
headers['Access-Control-Allow-Origin'] = '*'
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
headers['Access-Control-Allow-Headers'] = '*'
headers['Access-Control-Max-Age'] = '1728000'
headers['Access-Control-Allow-Credentials'] = true
end
end end
end end
+46 -50
View File
@@ -17,11 +17,12 @@ module Api
submissions = submissions.joins(template: :folder).where(folder: { name: params[:template_folder] }) submissions = submissions.joins(template: :folder).where(folder: { name: params[:template_folder] })
end end
submissions = paginate(submissions.preload(:created_by_user, :template, :submitters, submissions = paginate(submissions.preload(:created_by_user, :submitters,
template: :folder,
audit_trail_attachment: :blob)) audit_trail_attachment: :blob))
render json: { render json: {
data: submissions.as_json(serialize_params), data: submissions.as_json(Submissions::SerializeForApi::SERIALIZE_PARAMS),
pagination: { pagination: {
count: submissions.size, count: submissions.size,
next: submissions.last&.id, next: submissions.last&.id,
@@ -33,42 +34,26 @@ module Api
def show def show
submitters = @submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob) submitters = @submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob)
serialized_submitters = submitters.map do |submitter| submitters.each do |submitter|
Submissions::EnsureResultGenerated.call(submitter) if submitter.completed_at? if submitter.completed_at? && submitter.documents_attachments.blank?
submitter.documents_attachments = Submissions::EnsureResultGenerated.call(submitter)
Submitters::SerializeForApi.call(submitter) end
end end
json = @submission.as_json( if @submission.audit_trail_attachment.blank? && submitters.all?(&:completed_at?)
serialize_params.deep_merge( @submission.audit_trail_attachment = Submissions::GenerateAuditTrail.call(@submission)
include: { submission_events: { only: %i[id submitter_id event_type event_timestamp] } }
)
)
if submitters.all?(&:completed_at?)
last_submitter = submitters.max_by(&:completed_at)
json[:documents] = serialized_submitters.find { |e| e['id'] == last_submitter.id }['documents']
json[:status] = 'completed'
json[:completed_at] = last_submitter.completed_at
else
json[:documents] = []
json[:status] = 'pending'
json[:completed_at] = nil
end end
json[:submitters] = serialized_submitters render json: Submissions::SerializeForApi.call(@submission, submitters)
render json:
end end
def create def create
Params::SubmissionCreateValidator.call(params, dry_run: !Docuseal.multitenant?) Params::SubmissionCreateValidator.call(params)
return render json: { error: 'Template not found' }, status: :unprocessable_entity if @template.nil? return render json: { error: 'Template not found' }, status: :unprocessable_entity if @template.nil?
if @template.fields.blank? if @template.fields.blank?
Rollbar.error("Template does not contain fields: #{@template.id}") if defined?(Rollbar) Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
return render json: { error: 'Template does not contain fields' }, status: :unprocessable_entity return render json: { error: 'Template does not contain fields' }, status: :unprocessable_entity
end end
@@ -78,16 +63,36 @@ module Api
submissions = create_submissions(@template, params) submissions = create_submissions(@template, params)
submissions.each do |submission|
SendSubmissionCreatedWebhookRequestJob.perform_later(submission)
end
Submissions.send_signature_requests(submissions) Submissions.send_signature_requests(submissions)
render json: submissions.flat_map(&:submitters) submissions.each do |submission|
rescue Submitters::NormalizeValues::UnknownFieldName, Submitters::NormalizeValues::UnknownSubmitterName => e if submission.submitters.all?(&:completed_at?) && submission.submitters.last
ProcessSubmitterCompletionJob.perform_later(submission.submitters.last)
end
end
json = submissions.flat_map do |submission|
submission.submitters.map do |s|
Submitters::SerializeForApi.call(s, with_documents: false, with_urls: true)
end
end
render json:
rescue Submitters::NormalizeValues::BaseError => e
Rollbar.warning(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_entity render json: { error: e.message }, status: :unprocessable_entity
end end
def destroy def destroy
@submission.update!(archived_at: Time.current) @submission.update!(archived_at: Time.current)
SendSubmissionArchivedWebhookRequestJob.perform_later(@submission)
render json: @submission.as_json(only: %i[id], methods: %i[archived_at]) render json: @submission.as_json(only: %i[id], methods: %i[archived_at])
end end
@@ -118,38 +123,29 @@ module Api
params: params:
) )
Submissions::NormalizeParamUtils.save_default_value_attachments!(attachments, submitters = submissions.flat_map(&:submitters)
submissions.flat_map(&:submitters))
Submissions::NormalizeParamUtils.save_default_value_attachments!(attachments, submitters)
submitters.each do |submitter|
SubmissionEvents.create_with_tracking_data(submitter, 'api_complete_form', request) if submitter.completed_at?
end
submissions submissions
end end
end end
def serialize_params
{
only: %i[id source submitters_order created_at updated_at archived_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 external_id metadata],
methods: %i[status application_key] },
template: { only: %i[id name created_at updated_at] },
created_by_user: { only: %i[id email first_name last_name] }
}
}
end
def submissions_params def submissions_params
permitted_attrs = [ permitted_attrs = [
:send_email, :send_sms, :bcc_completed, :completed_redirect_url, :send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to, :go_to_last,
{ {
message: %i[subject body], message: %i[subject body],
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role, submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id, :completed, :phone, :application_key, :external_id, :reply_to, :go_to_last,
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body], { metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
fields: [%i[name default_value title description fields: [:name, :uuid, :default_value, :value, :title, :description,
readonly validation_pattern invalid_message]] }]] :readonly, :redacted, :validation_pattern, :invalid_message,
{ default_value: [], value: [] }] }]]
} }
] ]
@@ -8,6 +8,9 @@ module Api
def create def create
submitter = Submitter.find_by!(slug: params[:submitter_slug]) submitter = Submitter.find_by!(slug: params[:submitter_slug])
submitter.opened_at = Time.current
submitter.save
SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request) SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request)
SendFormViewedWebhookRequestJob.perform_later(submitter) SendFormViewedWebhookRequestJob.perform_later(submitter)
@@ -1,35 +0,0 @@
# 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
+64 -11
View File
@@ -11,6 +11,12 @@ module Api
submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present? submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present?
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present? submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
if params[:template_id].present?
submitters = submitters.joins(:submission).where(submission: { template_id: params[:template_id] })
end
submitters = maybe_filder_by_completed_at(submitters, params)
submitters = paginate( submitters = paginate(
submitters.preload(:template, :submission, :submission_events, submitters.preload(:template, :submission, :submission_events,
documents_attachments: :blob, attachments_attachments: :blob) documents_attachments: :blob, attachments_attachments: :blob)
@@ -55,6 +61,8 @@ module Api
@submitter.save! @submitter.save!
@submitter.submission.save! @submitter.submission.save!
SubmissionEvents.create_with_tracking_data(@submitter, 'api_complete_form', request) if @submitter.completed_at?
end end
if @submitter.completed_at? if @submitter.completed_at?
@@ -63,37 +71,74 @@ module Api
Submitters.send_signature_requests([@submitter]) Submitters.send_signature_requests([@submitter])
end end
render json: Submitters::SerializeForApi.call(@submitter, with_template: false, with_events: false) render json: Submitters::SerializeForApi.call(@submitter, with_template: false,
with_urls: true,
with_events: false)
end end
def submitter_params def submitter_params
submitter_params = params.key?(:submitter) ? params.require(:submitter) : params submitter_params = params.key?(:submitter) ? params.require(:submitter) : params
submitter_params.permit( submitter_params.permit(
:send_email, :send_sms, :uuid, :name, :email, :role, :send_email, :send_sms, :reply_to, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id, :completed, :phone, :application_key, :external_id, :go_to_last,
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body], { metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
fields: [%i[name default_value readonly validation_pattern invalid_message]] } fields: [[:name, :uuid, :default_value, :value,
:readonly, :redacted, :validation_pattern, :invalid_message,
{ default_value: [], value: [] }]] }
) )
end end
private private
def maybe_filder_by_completed_at(submitters, params)
if params[:completed_after].present?
submitters = submitters.where(completed_at: Time.zone.parse(params[:completed_after])..)
end
if params[:completed_before].present?
submitters = submitters.where(completed_at: ..Time.zone.parse(params[:completed_before]))
end
submitters
end
def assign_submitter_attrs(submitter, attrs) def assign_submitter_attrs(submitter, attrs)
submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email) values = attrs[:values]&.to_unsafe_h || {}
submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '') if attrs.key?(:phone)
submitter.values = submitter.values.merge(attrs[:values].to_unsafe_h) if attrs[:values].present?
submitter.completed_at = attrs[:completed] ? Time.current : submitter.completed_at
submitter.external_id = attrs[:application_key] if attrs.key?(:application_key)
submitter.external_id = attrs[:external_id] if attrs.key?(:external_id)
submitter.metadata = attrs[:metadata] if attrs.key?(:metadata)
assign_submission_fields(submitter.submission) assign_submission_fields(submitter.submission)
phone_field_uuid = submitter.submission.template_fields.find do |f|
values[f['uuid']].present? && f['type'] == 'phone'
end&.dig('uuid')
submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email)
if attrs.key?(:phone)
submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '')
elsif values[phone_field_uuid].present?
submitter.phone = values[phone_field_uuid].to_s.gsub(/[^0-9+]/, '')
end
values = values.except(phone_field_uuid)
submitter.values = submitter.values.merge(values) if values.present?
submitter.completed_at = attrs[:completed] ? Time.current : submitter.completed_at
submitter.metadata = attrs[:metadata] if attrs.key?(:metadata)
assign_external_id(submitter, attrs)
assign_preferences(submitter, attrs) assign_preferences(submitter, attrs)
submitter submitter
end end
def assign_external_id(submitter, attrs)
submitter.external_id = attrs[:application_key] if attrs.key?(:application_key)
submitter.external_id = attrs[:external_id] if attrs.key?(:external_id)
submitter
end
def assign_submission_fields(submission) def assign_submission_fields(submission)
submission.template_fields ||= submission.template.fields submission.template_fields ||= submission.template.fields
submission.template_schema ||= submission.template.schema submission.template_schema ||= submission.template.schema
@@ -109,6 +154,14 @@ module Api
end end
submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms') submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms')
submitter.preferences['reply_to'] = submitter_preferences['reply_to'] if submitter_preferences.key?('reply_to')
if submitter_preferences.key?('go_to_last')
submitter.preferences['go_to_last'] = submitter_preferences['go_to_last']
end
if submitter_preferences.key?('completed_redirect_url')
submitter.preferences['completed_redirect_url'] = submitter_preferences['completed_redirect_url']
end
return unless submitter_preferences.key?('email_message_uuid') return unless submitter_preferences.key?('email_message_uuid')
@@ -1,16 +0,0 @@
# 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: { archived_at: nil }).distinct
template_folders = TemplateFolders.search(template_folders, params[:q]).limit(LIMIT)
render json: template_folders.as_json(only: %i[name archived_at])
end
end
end
@@ -20,16 +20,9 @@ module Api
Templates::CloneAttachments.call(template: cloned_template, original_template: @template) Templates::CloneAttachments.call(template: cloned_template, original_template: @template)
render json: cloned_template.as_json(serialize_params) SendTemplateCreatedWebhookRequestJob.perform_later(cloned_template)
end
private render json: Templates::SerializeForApi.call(cloned_template)
def serialize_params
{
include: { author: { only: %i[id email first_name last_name] },
documents: { only: %i[id uuid], methods: %i[url filename] } }
}
end end
end end
end end
+60 -20
View File
@@ -7,10 +7,31 @@ module Api
def index def index
templates = filter_templates(@templates, params) templates = filter_templates(@templates, params)
templates = paginate(templates.preload(:author, documents_attachments: :blob)) templates = paginate(templates.preload(:author, :folder))
schema_documents =
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
record_type: 'Template',
name: :documents,
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
.preload(:blob)
preview_image_attachments =
ActiveStorage::Attachment.joins(:blob)
.where(blob: { filename: '0.jpg' })
.where(record_id: schema_documents.map(&:id),
record_type: 'ActiveStorage::Attachment',
name: :preview_images)
.preload(:blob)
render json: { render json: {
data: templates.as_json(serialize_params), data: templates.map do |t|
Templates::SerializeForApi.call(
t,
schema_documents.select { |e| e.record_id == t.id },
preview_image_attachments
)
end,
pagination: { pagination: {
count: templates.size, count: templates.size,
next: templates.last&.id, next: templates.last&.id,
@@ -20,16 +41,32 @@ module Api
end end
def show def show
render json: @template.as_json(serialize_params) render json: Templates::SerializeForApi.call(@template)
end end
def update def update
if (folder_name = params.dig(:template, :folder_name)) if (folder_name = params[:folder_name] || params.dig(:template, :folder_name))
@template.folder = TemplateFolders.find_or_create_by_name(current_user, folder_name) @template.folder = TemplateFolders.find_or_create_by_name(current_user, folder_name)
end end
Array.wrap(params[:roles].presence || params.dig(:template, :roles).presence).each_with_index do |role, index|
if (item = @template.submitters[index])
item['name'] = role
else
@template.submitters << { 'name' => role, 'uuid' => SecureRandom.uuid }
end
end
archived = params.key?(:archived) ? params[:archived] : params.dig(:template, :archived)
if archived.in?([true, false])
@template.archived_at = archived == true ? Time.current : nil
end
@template.update!(template_params) @template.update!(template_params)
SendTemplateUpdatedWebhookRequestJob.perform_later(@template)
render json: @template.as_json(only: %i[id updated_at]) render json: @template.as_json(only: %i[id updated_at])
end end
@@ -55,24 +92,27 @@ module Api
templates templates
end end
def serialize_params
{
methods: %i[application_key],
include: { author: { only: %i[id email first_name last_name] },
documents: { only: %i[id uuid], methods: %i[url preview_image_url filename] } }
}
end
def template_params def template_params
params.require(:template).permit( permitted_params = [
:name, :name,
schema: [%i[attachment_uuid name]], {
submitters: [%i[name uuid]], submitters: [%i[name uuid]],
fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value, fields: [[:uuid, :submitter_uuid, :name, :type,
{ preferences: {}, :required, :readonly, :default_value,
options: [%i[value uuid]], :title, :description,
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] { preferences: {},
) conditions: [%i[field_uuid value action]],
options: [%i[value uuid]],
validation: %i[message pattern],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
}
]
if params.key?(:template)
params.require(:template).permit(permitted_params)
else
params.permit(permitted_params)
end
end end
end end
end end
@@ -1,29 +0,0 @@
# frozen_string_literal: true
module Api
class TemplatesDocumentsController < ApiBaseController
load_and_authorize_resource :template
def create
return head :unprocessable_entity if params[:blobs].blank? && params[:files].blank?
documents = Templates::CreateAttachments.call(@template, params)
schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base }
end
render json: {
schema:,
documents: documents.as_json(
methods: [:metadata],
include: {
preview_images: { methods: %i[url metadata filename] }
}
)
}
rescue Templates::CreateAttachments::PdfEncrypted
render json: { error: 'PDF encrypted' }, status: :unprocessable_entity
end
end
end
+7 -1
View File
@@ -22,9 +22,15 @@ class ApplicationController < ActionController::Base
redirect_to request.path redirect_to request.path
end end
rescue_from RateLimit::LimitApproached do |e|
Rollbar.error(e) if defined?(Rollbar)
redirect_to request.referer, alert: 'Too many requests', status: :too_many_requests
end
if Rails.env.production? if Rails.env.production?
rescue_from CanCan::AccessDenied do |e| rescue_from CanCan::AccessDenied do |e|
Rollbar.error(e) if defined?(Rollbar) Rollbar.warning(e) if defined?(Rollbar)
redirect_to root_path, alert: e.message redirect_to root_path, alert: e.message
end end
@@ -5,6 +5,10 @@ class ConsoleRedirectController < ApplicationController
skip_authorization_check skip_authorization_check
def index def index
if request.path == '/upgrade'
params[:redir] = Docuseal.multitenant? ? "#{Docuseal::CONSOLE_URL}/plans" : "#{Docuseal::CONSOLE_URL}/on_premise"
end
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if true_user.blank? return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if true_user.blank?
auth = JsonWebToken.encode(uuid: true_user.uuid, auth = JsonWebToken.encode(uuid: true_user.uuid,
+4 -48
View File
@@ -7,62 +7,18 @@ class DashboardController < ApplicationController
before_action :maybe_render_landing before_action :maybe_render_landing
before_action :maybe_redirect_mfa_setup before_action :maybe_redirect_mfa_setup
load_and_authorize_resource :template_folder, parent: false skip_authorization_check
load_and_authorize_resource :template, parent: false
SHOW_TEMPLATES_FOLDERS_THRESHOLD = 9
TEMPLATES_PER_PAGE = 12
FOLDERS_PER_PAGE = 18
def index def index
@template_folders = filter_template_folders(@template_folders) if cookies.permanent[:dashboard_view] == 'submissions'
SubmissionsDashboardController.dispatch(:index, request, response)
@pagy, @template_folders = pagy(
@template_folders,
items: FOLDERS_PER_PAGE,
page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1
)
if @pagy.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD
@templates = @templates.none
else else
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME } TemplatesDashboardController.dispatch(:index, request, response)
@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
end end
private 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)
if params[:q].blank?
shared_template_ids =
TemplateSharing.where(account_id: [current_account.id, TemplateSharing::ALL_ID]).select(:template_id)
rel = rel.where(folder_id: current_account.default_template_folder.id).or(rel.where(id: shared_template_ids))
end
Templates.search(rel, params[:q])
end
def maybe_redirect_product_url def maybe_redirect_product_url
return if !Docuseal.multitenant? || signed_in? return if !Docuseal.multitenant? || signed_in?
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class EncryptedUserConfigsController < ApplicationController
load_and_authorize_resource :encrypted_user_config
def destroy
@encrypted_user_config.destroy!
redirect_back(fallback_location: root_path)
end
end
+20 -1
View File
@@ -15,6 +15,11 @@ class ErrorsController < ActionController::Base
'/api/templates/docx' '/api/templates/docx'
].freeze ].freeze
SAFE_ERROR_MESSAGE_CLASSES = [
ActionDispatch::Http::Parameters::ParseError,
JSON::ParserError
].freeze
def show def show
if request.original_fullpath.in?(ENTERPRISE_PATHS) && error_status_code == 404 if request.original_fullpath.in?(ENTERPRISE_PATHS) && error_status_code == 404
return render json: { status: 404, message: ENTERPRISE_FEATURE_MESSAGE }, status: :not_found return render json: { status: 404, message: ENTERPRISE_FEATURE_MESSAGE }, status: :not_found
@@ -22,7 +27,13 @@ class ErrorsController < ActionController::Base
respond_to do |f| respond_to do |f|
f.json do f.json do
render json: { status: error_status_code }, status: error_status_code set_cors_headers
exception = request.env['action_dispatch.exception']
error = exception.message if exception.class.in?(SAFE_ERROR_MESSAGE_CLASSES)
render json: { status: error_status_code, error: }.compact, status: error_status_code
end end
f.html { render error_status_code.to_s, status: error_status_code } f.html { render error_status_code.to_s, status: error_status_code }
@@ -31,6 +42,14 @@ class ErrorsController < ActionController::Base
private private
def set_cors_headers
headers['Access-Control-Allow-Origin'] = '*'
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
headers['Access-Control-Allow-Headers'] = '*'
headers['Access-Control-Max-Age'] = '1728000'
headers['Access-Control-Allow-Credentials'] = true
end
def error_status_code def error_status_code
@error_status_code ||= @error_status_code ||=
ActionDispatch::ExceptionWrapper.new(request.env, ActionDispatch::ExceptionWrapper.new(request.env,
+2 -2
View File
@@ -57,8 +57,8 @@ class EsignSettingsController < ApplicationController
save_new_cert!(@encrypted_config, @cert_record) save_new_cert!(@encrypted_config, @cert_record)
redirect_to settings_esign_path, notice: 'Certificate has been successfully added!' redirect_to settings_esign_path, notice: 'Certificate has been successfully added!'
rescue OpenSSL::PKCS12::PKCS12Error rescue OpenSSL::PKCS12::PKCS12Error => e
@cert_record.errors.add(:password, "is invalid. Make sure you're uploading a valid .p12 file") @cert_record.errors.add(:password, e.message)
render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'), status: :unprocessable_entity render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'), status: :unprocessable_entity
end end
@@ -35,12 +35,12 @@ class NotificationsSettingsController < ApplicationController
def load_reminder_config def load_reminder_config
@reminder_config = @reminder_config =
AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::SUBMITTER_REMAILERS) AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::SUBMITTER_REMINDERS)
end end
def email_config_params def email_config_params
params.require(:account_config).permit!.tap do |attrs| params.require(:account_config).permit!.tap do |attrs|
attrs[:key] = nil unless attrs[:key].in?([AccountConfig::BCC_EMAILS, AccountConfig::SUBMITTER_REMAILERS]) attrs[:key] = nil unless attrs[:key].in?([AccountConfig::BCC_EMAILS, AccountConfig::SUBMITTER_REMINDERS])
end end
end end
end end
+6
View File
@@ -5,6 +5,12 @@ class PasswordsController < Devise::PasswordsController
attribute :user attribute :user
end end
def create
super do |resource|
resource.errors.clear unless Docuseal.multitenant?
end
end
def update def update
super do |resource| super do |resource|
Current.user = resource Current.user = resource
@@ -1,26 +1,58 @@
# frozen_string_literal: true # frozen_string_literal: true
class PersonalizationSettingsController < ApplicationController class PersonalizationSettingsController < ApplicationController
ALLOWED_KEYS = [
AccountConfig::FORM_COMPLETED_BUTTON_KEY,
AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY,
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY,
AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY,
AccountConfig::FORM_COMPLETED_MESSAGE_KEY
].freeze
InvalidKey = Class.new(StandardError)
before_action :load_and_authorize_account_config, only: :create
def show def show
authorize!(:read, AccountConfig) authorize!(:read, AccountConfig)
end end
def create def create
account_config = if @account_config.value.is_a?(Hash)
current_account.account_configs.find_or_initialize_by(key: account_config_params[:key]) @account_config.value = @account_config.value.reject do |_, v|
v.blank? && v != false
end
end
authorize!(:create, account_config) if @account_config.value != false && @account_config.value.blank?
@account_config.destroy!
account_config.update!(account_config_params) else
@account_config.save!
end
redirect_back(fallback_location: settings_personalization_path, notice: 'Settings have been saved.') redirect_back(fallback_location: settings_personalization_path, notice: 'Settings have been saved.')
end end
private private
def load_and_authorize_account_config
@account_config =
current_account.account_configs.find_or_initialize_by(key: account_config_params[:key])
@account_config.assign_attributes(account_config_params)
authorize!(:create, @account_config)
raise InvalidKey unless ALLOWED_KEYS.include?(@account_config.key)
@account_config
end
def account_config_params def account_config_params
attrs = params.require(:account_config).permit! attrs = params.require(:account_config).permit!
return attrs if attrs[:value].is_a?(String)
attrs[:value]&.transform_values! do |value| attrs[:value]&.transform_values! do |value|
if value.in?(%w[true false]) if value.in?(%w[true false])
value == 'true' value == 'true'
@@ -6,7 +6,9 @@ class PreviewDocumentPageController < ActionController::API
FORMAT = Templates::ProcessDocument::FORMAT FORMAT = Templates::ProcessDocument::FORMAT
def show def show
attachment = ActiveStorage::Attachment.find_by(uuid: params[:attachment_uuid]) attachment_uuid = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid], purpose: :attachment)
attachment = ActiveStorage::Attachment.find_by(uuid: attachment_uuid) if attachment_uuid
return head :not_found unless attachment return head :not_found unless attachment
@@ -21,9 +23,10 @@ class PreviewDocumentPageController < ActionController::API
find_or_create_document_tempfile_path(attachment) find_or_create_document_tempfile_path(attachment)
end end
io = Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i) preview_image =
Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
render plain: io.tap(&:rewind).read, content_type: 'image/jpeg' redirect_to preview_image.url, allow_other_host: true
end end
def find_or_create_document_tempfile_path(attachment) def find_or_create_document_tempfile_path(attachment)
@@ -1,62 +0,0 @@
# frozen_string_literal: true
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
def create
super
Accounts.create_default_template(resource.account) if resource.account.persisted?
end
private
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)
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 = {})
account = Account.new(account_params)
account.timezone = Accounts.normalize_timezone(account.timezone)
self.resource = account.users.new(user_params)
account.name ||= resource.full_name if params[:action] == 'create'
end
def user_params
return {} if params[:user].blank?
params.require(:user).permit(:first_name, :last_name, :email, :password).compact_blank.tap do |attrs|
attrs[:password] ||= SecureRandom.hex if params[:action] == 'create'
end
end
def account_params
return {} if params[:account].blank?
params.require(:account).permit(:name, :timezone).compact_blank
end
end
@@ -12,13 +12,18 @@ class SendSubmissionEmailController < ApplicationController
def create def create
@submitter = @submitter =
if params[:template_slug] if params[:template_slug]
Submitter.joins(submission: :template).find_by!(email: params[:email], Submitter.joins(submission: :template).find_by!(email: params[:email].to_s.downcase,
template: { slug: params[:template_slug] }) template: { slug: params[:template_slug] })
elsif params[:submission_slug]
Submitter.joins(:submission).find_by!(email: params[:email].to_s.downcase,
submission: { slug: params[:submission_slug] })
else else
Submitter.find_by!(slug: params[:submitter_slug]) Submitter.find_by!(slug: params[:submitter_slug])
end end
SubmitterMailer.documents_copy_email(@submitter).deliver_later! RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later!
respond_to do |f| respond_to do |f|
f.html { redirect_to success_send_submission_email_index_path } f.html { redirect_to success_send_submission_email_index_path }
+6 -2
View File
@@ -4,12 +4,16 @@ class SessionsController < Devise::SessionsController
before_action :configure_permitted_parameters before_action :configure_permitted_parameters
def create def create
if Docuseal.multitenant? && !User.exists?(email: sign_in_params[:email]) email = sign_in_params[:email].to_s.downcase
if Docuseal.multitenant? && !User.exists?(email:)
Rollbar.warning('Sign in new user') if defined?(Rollbar)
return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)), return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
notice: 'Create a new account' notice: 'Create a new account'
end end
if User.exists?(email: sign_in_params[:email], otp_required_for_login: true) && sign_in_params[:otp_attempt].blank? if User.exists?(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 return render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_entity
end end
@@ -3,7 +3,6 @@
class SsoSettingsController < ApplicationController class SsoSettingsController < ApplicationController
before_action :load_encrypted_config before_action :load_encrypted_config
authorize_resource :encrypted_config, only: :index authorize_resource :encrypted_config, only: :index
authorize_resource :encrypted_config, parent: false, except: :index
def index; end def index; end
+6
View File
@@ -15,6 +15,8 @@ class StartFormController < ApplicationController
end end
def update def update
return redirect_to start_form_path(@template.slug) if @template.archived_at?
@submitter = Submitter.where(submission: @template.submissions.where(archived_at: nil)) @submitter = Submitter.where(submission: @template.submissions.where(archived_at: nil))
.order(id: :desc) .order(id: :desc)
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel } .then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
@@ -31,7 +33,11 @@ class StartFormController < ApplicationController
assign_submission_attributes(@submitter, @template) if @submitter.new_record? assign_submission_attributes(@submitter, @template) if @submitter.new_record?
is_new_record = @submitter.new_record?
if @submitter.save if @submitter.save
SendSubmissionCreatedWebhookRequestJob.perform_later(@submitter.submission) if is_new_record
redirect_to submit_form_path(@submitter.slug) redirect_to submit_form_path(@submitter.slug)
else else
render :show render :show
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class SubmissionsArchivedController < ApplicationController
load_and_authorize_resource :submission, parent: false
def index
@submissions = @submissions.joins(:template)
@submissions = @submissions.where.not(archived_at: nil)
.or(@submissions.where.not(templates: { archived_at: nil }))
.preload(:created_by_user, template: :author)
@submissions = Submissions.search(@submissions, params[:q], search_template: true)
@pagy, @submissions = pagy(@submissions.preload(:submitters).order(id: :desc))
end
end
+16 -16
View File
@@ -6,23 +6,8 @@ class SubmissionsController < ApplicationController
load_and_authorize_resource :submission, only: %i[show destroy] load_and_authorize_resource :submission, only: %i[show destroy]
PRELOAD_ALL_PAGES_AMOUNT = 200
def show def show
ActiveRecord::Associations::Preloader.new( @submission = Submissions.preload_with_pages(@submission)
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' render :show, layout: 'plain'
end end
@@ -34,6 +19,8 @@ class SubmissionsController < ApplicationController
def create def create
authorize!(:create, Submission) authorize!(:create, Submission)
save_template_message(@template, params) if params[:save_message] == '1'
if params[:is_custom_message] != '1' if params[:is_custom_message] != '1'
params.delete(:subject) params.delete(:subject)
params.delete(:body) params.delete(:body)
@@ -57,6 +44,10 @@ class SubmissionsController < ApplicationController
params: params.merge('send_completed_email' => true)) params: params.merge('send_completed_email' => true))
end end
submissions.each do |submission|
SendSubmissionCreatedWebhookRequestJob.perform_later(submission)
end
Submissions.send_signature_requests(submissions) Submissions.send_signature_requests(submissions)
redirect_to template_path(@template), notice: 'New recipients have been added' redirect_to template_path(@template), notice: 'New recipients have been added'
@@ -65,11 +56,20 @@ class SubmissionsController < ApplicationController
def destroy def destroy
@submission.update!(archived_at: Time.current) @submission.update!(archived_at: Time.current)
SendSubmissionArchivedWebhookRequestJob.perform_later(@submission)
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 end
private private
def save_template_message(template, params)
template.preferences['request_email_subject'] = params[:subject] if params[:subject].present?
template.preferences['request_email_body'] = params[:body] if params[:body].present?
template.save!
end
def submissions_params def submissions_params
params.permit(submission: { submitters: [%i[uuid email phone name]] }) params.permit(submission: { submitters: [%i[uuid email phone name]] })
end end
@@ -0,0 +1,20 @@
# frozen_string_literal: true
class SubmissionsDashboardController < ApplicationController
load_and_authorize_resource :submission, parent: false
def index
@submissions = @submissions.joins(:template)
@submissions = @submissions.where(archived_at: nil)
.where(templates: { archived_at: nil })
.preload(:created_by_user, template: :author)
@submissions = Submissions.search(@submissions, params[:q], search_template: true)
@submissions = @submissions.pending if params[:status] == 'pending'
@submissions = @submissions.completed if params[:status] == 'completed'
@pagy, @submissions = pagy(@submissions.preload(:submitters).order(id: :desc))
end
end
@@ -4,8 +4,20 @@ class SubmissionsDownloadController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_authorization_check skip_authorization_check
TTL = 40.minutes
FILES_TTL = 5.minutes
def index def index
submitter = Submitter.find_by(slug: params[:submitter_slug]) submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
signature_valid =
if submitter&.slug == params[:submitter_slug]
true
else
submitter = nil
end
submitter ||= Submitter.find_by!(slug: params[:submitter_slug])
Submissions::EnsureResultGenerated.call(submitter) Submissions::EnsureResultGenerated.call(submitter)
@@ -13,11 +25,26 @@ class SubmissionsDownloadController < ApplicationController
Submissions::EnsureResultGenerated.call(last_submitter) Submissions::EnsureResultGenerated.call(last_submitter)
urls = return head :not_found unless last_submitter.completed_at?
Submitters.select_attachments_for_download(last_submitter).map do |attachment|
helpers.rails_blob_url(attachment)
end
render json: urls if last_submitter.completed_at < TTL.ago && !signature_valid && !current_user_submitter?(last_submitter)
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
render json: build_urls(last_submitter)
end
private
def current_user_submitter?(submitter)
current_user && current_user.account.submitters.exists?(id: submitter.id)
end
def build_urls(submitter)
Submitters.select_attachments_for_download(submitter).map do |attachment|
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: FILES_TTL.from_now.to_i)
end
end end
end end
@@ -4,26 +4,48 @@ class SubmissionsPreviewController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_authorization_check skip_authorization_check
PRELOAD_ALL_PAGES_AMOUNT = 200 TTL = 40.minutes
def show def show
@submission = Submission.find_by!(slug: params[:slug]) submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
ActiveRecord::Associations::Preloader.new( signature_valid =
records: [@submission], if submitter && submitter.submission.slug == params[:slug]
associations: [:template, { template_schema_documents: :blob }] @submission = submitter.submission
).call
total_pages = true
@submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i } end
if total_pages < PRELOAD_ALL_PAGES_AMOUNT @submission ||= Submission.find_by!(slug: params[:slug])
ActiveRecord::Associations::Preloader.new(
records: @submission.template_schema_documents, if !@submission.submitters.all?(&:completed_at?) && current_user.blank?
associations: [:blob, { preview_images_attachments: :blob }] raise ActionController::RoutingError, 'Not Found'
).call
end end
if !submission_valid_ttl?(@submission) && !signature_valid
Rollbar.info("TTL: #{@submission.id}") if defined?(Rollbar)
return redirect_to submissions_preview_completed_path(@submission.slug)
end
@submission = Submissions.preload_with_pages(@submission)
render 'submissions/show', layout: 'plain' render 'submissions/show', layout: 'plain'
end end
def completed
@submission = Submission.find_by!(slug: params[:submissions_preview_slug])
render :completed, layout: 'form'
end
private
def submission_valid_ttl?(submission)
return true if current_user && current_user.account.submissions.exists?(id: submission.id)
last_submitter = submission.submitters.select(&:completed_at?).max_by(&:completed_at)
last_submitter && last_submitter.completed_at > TTL.ago
end
end end
+1 -3
View File
@@ -31,8 +31,6 @@ class SubmitFormController < ApplicationController
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user) Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
cookies[:submitter_sid] = @submitter.signed_id
render(@submitter.submission.template.archived_at? || @submitter.submission.archived_at? ? :archived : :show) render(@submitter.submission.template.archived_at? || @submitter.submission.archived_at? ? :archived : :show)
end end
@@ -57,7 +55,7 @@ class SubmitFormController < ApplicationController
end end
def completed def completed
@submitter = Submitter.find_by!(slug: params[:submit_form_slug]) @submitter = Submitter.completed.find_by!(slug: params[:submit_form_slug])
end end
def success; end def success; end
@@ -0,0 +1,33 @@
# frozen_string_literal: true
class SubmittersAutocompleteController < ApplicationController
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.matches(term, false, true))
else
Submitters.search(submitters, params[:q])
end
end
end
@@ -7,6 +7,8 @@ class SubmittersSendEmailController < ApplicationController
if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter, if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter,
event_type: 'send_email', event_type: 'send_email',
created_at: 24.hours.ago..Time.current) created_at: 24.hours.ago..Time.current)
Rollbar.warning("Already sent: #{@submitter.id}") if defined?(Rollbar)
return redirect_back(fallback_location: submission_path(@submitter.submission), return redirect_back(fallback_location: submission_path(@submitter.submission),
alert: 'Email has been sent already.') alert: 'Email has been sent already.')
end end
@@ -0,0 +1,31 @@
# frozen_string_literal: true
class TemplateDocumentsController < ApplicationController
load_and_authorize_resource :template
def create
return head :unprocessable_entity if params[:blobs].blank? && params[:files].blank?
old_fields_hash = @template.fields.hash
documents = Templates::CreateAttachments.call(@template, params)
schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base }
end
render json: {
schema:,
fields: old_fields_hash == @template.fields.hash ? nil : @template.fields,
submitters: old_fields_hash == @template.fields.hash ? nil : @template.submitters,
documents: documents.as_json(
methods: %i[metadata signed_uuid],
include: {
preview_images: { methods: %i[url metadata filename] }
}
)
}
rescue Templates::CreateAttachments::PdfEncrypted
render json: { error: 'PDF encrypted' }, status: :unprocessable_entity
end
end
@@ -0,0 +1,14 @@
# frozen_string_literal: true
class TemplateFoldersAutocompleteController < ApplicationController
load_and_authorize_resource :template_folder, parent: false
LIMIT = 100
def index
template_folders = @template_folders.joins(:templates).where(templates: { archived_at: nil }).distinct
template_folders = TemplateFolders.search(template_folders, params[:q]).limit(LIMIT)
render json: template_folders.as_json(only: %i[name archived_at])
end
end
@@ -6,7 +6,7 @@ class TemplatesArchivedSubmissionsController < ApplicationController
def index def index
@submissions = @submissions.where.not(archived_at: nil) @submissions = @submissions.where.not(archived_at: nil)
@submissions = Submissions.search(@submissions, params[:q]) @submissions = Submissions.search(@submissions, params[:q], search_values: true)
@pagy, @submissions = pagy(@submissions.preload(:submitters).order(id: :desc)) @pagy, @submissions = pagy(@submissions.preload(:submitters).order(id: :desc))
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
+44 -5
View File
@@ -8,7 +8,7 @@ class TemplatesController < ApplicationController
def show def show
submissions = @template.submissions.accessible_by(current_ability) submissions = @template.submissions.accessible_by(current_ability)
submissions = submissions.active if @template.archived_at.blank? submissions = submissions.active if @template.archived_at.blank?
submissions = Submissions.search(submissions, params[:q]) submissions = Submissions.search(submissions, params[:q], search_values: true)
@base_submissions = submissions @base_submissions = submissions
@@ -33,7 +33,7 @@ class TemplatesController < ApplicationController
@template_data = @template_data =
@template.as_json.merge( @template.as_json.merge(
documents: @template.schema_documents.as_json( documents: @template.schema_documents.as_json(
methods: [:metadata], methods: %i[metadata signed_uuid],
include: { preview_images: { methods: %i[url metadata filename] } } include: { preview_images: { methods: %i[url metadata filename] } }
) )
).to_json ).to_json
@@ -51,17 +51,32 @@ class TemplatesController < ApplicationController
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name]) @template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
end end
@template.account = current_account if params[:account_id].present? && authorized_clone_account_id?(params[:account_id])
@template.account_id = params[:account_id]
@template.folder = @template.account.default_template_folder
else
@template.account = current_account
end
if @template.save if @template.save
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
redirect_to edit_template_path(@template) SendTemplateUpdatedWebhookRequestJob.perform_later(@template)
maybe_redirect_to_template(@template)
else else
render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_entity render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_entity
end end
end end
def update
@template.update!(template_params)
SendTemplateUpdatedWebhookRequestJob.perform_later(@template)
head :ok
end
def destroy def destroy
notice = notice =
if params[:permanently].present? if params[:permanently].present?
@@ -82,7 +97,31 @@ class TemplatesController < ApplicationController
private private
def template_params def template_params
params.require(:template).permit(:name) params.require(:template).permit(
:name,
{ schema: [%i[attachment_uuid name]],
submitters: [%i[name uuid]],
fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value,
:title, :description,
{ preferences: {},
conditions: [%i[field_uuid value action]],
options: [%i[value uuid]],
validation: %i[message pattern],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
)
end
def authorized_clone_account_id?(account_id)
true_user.account_id.to_s == account_id.to_s || true_user.account.linked_accounts.exists?(id: account_id)
end
def maybe_redirect_to_template(template)
if template.account == current_account
redirect_to(edit_template_path(@template))
else
redirect_back(fallback_location: root_path, notice: 'Template has been clonned')
end
end end
def load_base_template def load_base_template
@@ -0,0 +1,63 @@
# frozen_string_literal: true
class TemplatesDashboardController < ApplicationController
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
@template_folders = filter_template_folders(@template_folders)
@pagy, @template_folders = pagy(
@template_folders,
items: FOLDERS_PER_PAGE,
page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1
)
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)
if params[:q].blank?
if Docuseal.multitenant? && !current_account.testing?
rel = rel.where(folder_id: current_account.default_template_folder.id)
else
shared_template_ids =
TemplateSharing.where(account_id: [current_account.id, TemplateSharing::ALL_ID]).select(:template_id)
rel = rel.where(folder_id: current_account.default_template_folder.id).or(rel.where(id: shared_template_ids))
end
end
Templates.search(rel, params[:q])
end
end
@@ -0,0 +1,25 @@
# frozen_string_literal: true
class TemplatesPreferencesController < ApplicationController
load_and_authorize_resource :template
def show; end
def create
authorize!(:update, @template)
@template.preferences = @template.preferences.merge(template_params[:preferences])
@template.save!
head :ok
end
private
def template_params
params.require(:template).permit(
preferences: %i[bcc_completed request_email_subject request_email_body
documents_copy_email_subject documents_copy_email_body]
)
end
end
@@ -12,7 +12,7 @@ class TemplatesPreviewController < ApplicationController
@template_data = @template_data =
@template.as_json.merge( @template.as_json.merge(
documents: @template.schema_documents.as_json( documents: @template.schema_documents.as_json(
methods: [:metadata], methods: %i[metadata signed_uuid],
include: { preview_images: { methods: %i[url metadata filename] } } include: { preview_images: { methods: %i[url metadata filename] } }
) )
).to_json ).to_json
@@ -18,6 +18,8 @@ class TemplatesUploadsController < ApplicationController
@template.update!(schema:) @template.update!(schema:)
SendTemplateCreatedWebhookRequestJob.perform_later(@template)
redirect_to edit_template_path(@template) redirect_to edit_template_path(@template)
rescue Templates::CreateAttachments::PdfEncrypted rescue Templates::CreateAttachments::PdfEncrypted
render turbo_stream: turbo_stream.append(params[:form_id], html: helpers.tag.prompt_password) render turbo_stream: turbo_stream.append(params[:form_id], html: helpers.tag.prompt_password)
+19 -11
View File
@@ -1,9 +1,13 @@
# frozen_string_literal: true # frozen_string_literal: true
class TimestampServerController < ApplicationController class TimestampServerController < ApplicationController
HASH_ALGORITHM = 'SHA256'
before_action :build_encrypted_config before_action :build_encrypted_config
authorize_resource :encrypted_config authorize_resource :encrypted_config
TimestampError = Class.new(StandardError)
def create def create
return head :not_found if Docuseal.multitenant? return head :not_found if Docuseal.multitenant?
@@ -14,25 +18,29 @@ class TimestampServerController < ApplicationController
else else
redirect_back fallback_location: settings_notifications_path, alert: 'Unable to save' redirect_back fallback_location: settings_notifications_path, alert: 'Unable to save'
end end
rescue HexaPDF::Error, SocketError, Submissions::TimestampHandler::TimestampError, OpenSSL::Timestamp::TimestampError rescue SocketError, TimestampError, OpenSSL::Timestamp::TimestampError
redirect_back fallback_location: settings_notifications_path, alert: 'Invalid Timeserver' redirect_back fallback_location: settings_notifications_path, alert: 'Invalid Timeserver'
end end
private private
def test_timeserver_url(url) def test_timeserver_url(url)
pdf = HexaPDF::Document.new req = OpenSSL::Timestamp::Request.new
pdf.pages.add req.algorithm = HASH_ALGORITHM
req.message_imprint = OpenSSL::Digest.digest(HASH_ALGORITHM, 'test')
pkcs = Accounts.load_signing_pkcs(current_account) uri = Addressable::URI.parse(url)
pdf.sign(StringIO.new, conn = Faraday.new(uri.origin) do |c|
reason: 'Test', c.basic_auth(uri.user, uri.password) if uri.password.present?
certificate: pkcs.certificate, end
key: pkcs.key,
signature_size: 10_000, response = conn.post(uri.path, req.to_der,
certificate_chain: pkcs.ca_certs || [], 'content-type' => 'application/timestamp-query')
timestamp_handler: Submissions::TimestampHandler.new(tsa_url: url))
raise TimestampError if response.status != 200 || response.body.blank?
response
end end
def load_encrypted_config def load_encrypted_config
@@ -0,0 +1,33 @@
# frozen_string_literal: true
class UserConfigsController < ApplicationController
before_action :load_user_config
authorize_resource :user_config
ALLOWED_KEYS = [
UserConfig::RECEIVE_COMPLETED_EMAIL
].freeze
InvalidKey = Class.new(StandardError)
def create
@user_config.update!(user_config_params)
head :ok
end
private
def load_user_config
raise InvalidKey unless ALLOWED_KEYS.include?(user_config_params[:key])
@user_config =
UserConfig.find_or_initialize_by(user: current_user, key: user_config_params[:key])
end
def user_config_params
params.required(:user_config).permit!.tap do |attrs|
attrs[:value] = attrs[:value] == '1' if attrs[:value].in?(%w[1 0])
end
end
end
+30 -9
View File
@@ -1,13 +1,20 @@
# frozen_string_literal: true # frozen_string_literal: true
class UsersController < ApplicationController class UsersController < ApplicationController
load_and_authorize_resource :user, only: %i[index edit new update destroy] load_and_authorize_resource :user, only: %i[index edit update destroy]
before_action :build_user, only: :create before_action :build_user, only: %i[new create]
authorize_resource :user, only: :create authorize_resource :user, only: %i[new create]
def index def index
@pagy, @users = pagy(@users.active.order(id: :desc)) @users =
if params[:status] == 'archived'
@users.archived
else
@users.active
end
@pagy, @users = pagy(@users.where(account: current_account).order(id: :desc))
end end
def new; end def new; end
@@ -15,10 +22,19 @@ class UsersController < ApplicationController
def edit; end def edit; end
def create def create
existing_user = User.accessible_by(current_ability).find_by(email: @user.email)
if existing_user
existing_user.archived_at = nil
existing_user.assign_attributes(user_params)
@user = existing_user
end
if @user.save if @user.save
UserMailer.invitation_email(@user).deliver_later! UserMailer.invitation_email(@user).deliver_later!
redirect_to settings_users_path, notice: 'User has been invited' redirect_back fallback_location: settings_users_path, notice: 'User has been invited'
else else
render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_entity render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_entity
end end
@@ -27,11 +43,11 @@ class UsersController < ApplicationController
def update def update
return redirect_to settings_users_path, notice: 'Unable to update user.' if Docuseal.demo? return redirect_to settings_users_path, notice: 'Unable to update user.' if Docuseal.demo?
attrs = user_params.compact_blank attrs = user_params.compact_blank.merge(user_params.slice(:archived_at))
attrs.delete(:role) if !role_valid?(attrs[:role]) || current_user == @user attrs.delete(:role) if !role_valid?(attrs[:role]) || current_user == @user
if @user.update(attrs) if @user.update(attrs)
redirect_to settings_users_path, notice: 'User has been updated' redirect_back fallback_location: settings_users_path, notice: 'User has been updated'
else else
render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_entity render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_entity
end end
@@ -44,7 +60,7 @@ class UsersController < ApplicationController
@user.update!(archived_at: Time.current) @user.update!(archived_at: Time.current)
redirect_to settings_users_path, notice: 'User has been removed' redirect_back fallback_location: settings_users_path, notice: 'User has been removed'
end end
private private
@@ -65,6 +81,11 @@ class UsersController < ApplicationController
end end
def user_params def user_params
params.require(:user).permit(:email, :first_name, :last_name, :password, :role) if params.key?(:user)
params.require(:user).permit(:email, :first_name, :last_name, :password,
:role, :archived_at, :account_id)
else
{}
end
end end
end end
@@ -9,11 +9,14 @@ class VerifyPdfSignatureController < ApplicationController
HexaPDF::Document.new(io: file.open) HexaPDF::Document.new(io: file.open)
end end
cert_data = if Docuseal.multitenant? cert_data =
Docuseal::CERTS if Docuseal.multitenant?
else value = EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
EncryptedConfig.find_by(key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
end Docuseal::CERTS.merge(value)
else
EncryptedConfig.find_by(key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
end
default_pkcs = GenerateCertificate.load_pkcs(cert_data) default_pkcs = GenerateCertificate.load_pkcs(cert_data)
@@ -24,7 +27,8 @@ class VerifyPdfSignatureController < ApplicationController
trusted_certs = [default_pkcs.certificate, trusted_certs = [default_pkcs.certificate,
*default_pkcs.ca_certs, *default_pkcs.ca_certs,
*custom_certs.map(&:certificate), *custom_certs.map(&:certificate),
*custom_certs.flat_map(&:ca_certs).compact] *custom_certs.flat_map(&:ca_certs).compact,
*Docuseal.trusted_certs]
render turbo_stream: turbo_stream.replace('result', partial: 'result', render turbo_stream: turbo_stream.replace('result', partial: 'result',
locals: { pdfs:, files: params[:files], trusted_certs: }) locals: { pdfs:, files: params[:files], trusted_certs: })
@@ -0,0 +1,36 @@
# frozen_string_literal: true
class WebhookPreferencesController < ApplicationController
EVENTS = %w[
form.viewed
form.started
form.completed
template.created
template.updated
submission.created
submission.archived
].freeze
before_action :load_account_config
authorize_resource :account_config, parent: false
def create
@account_config.value[account_config_params[:event]] = account_config_params[:value] == '1'
@account_config.save!
head :ok
end
private
def load_account_config
@account_config =
current_account.account_configs.find_or_initialize_by(key: AccountConfig::WEBHOOK_PREFERENCES_KEY)
@account_config.value ||= {}
end
def account_config_params
params.permit(:event, :value)
end
end
+32 -2
View File
@@ -3,8 +3,10 @@ import { encodeMethodIntoRequestBody } from '@hotwired/turbo-rails/app/javascrip
import { createApp, reactive } from 'vue' import { createApp, reactive } from 'vue'
import TemplateBuilder from './template_builder/builder' import TemplateBuilder from './template_builder/builder'
import ImportList from './template_builder/import_list'
import ToggleVisible from './elements/toggle_visible' import ToggleVisible from './elements/toggle_visible'
import ToggleCookies from './elements/toggle_cookies'
import DisableHidden from './elements/disable_hidden' import DisableHidden from './elements/disable_hidden'
import TurboModal from './elements/turbo_modal' import TurboModal from './elements/turbo_modal'
import FileDropzone from './elements/file_dropzone' import FileDropzone from './elements/file_dropzone'
@@ -20,6 +22,8 @@ import FolderAutocomplete from './elements/folder_autocomplete'
import SignatureForm from './elements/signature_form' import SignatureForm from './elements/signature_form'
import SubmitForm from './elements/submit_form' import SubmitForm from './elements/submit_form'
import PromptPassword from './elements/prompt_password' import PromptPassword from './elements/prompt_password'
import EmailsTextarea from './elements/emails_textarea'
import ToggleOnSubmit from './elements/toggle_on_submit'
import * as TurboInstantClick from './lib/turbo_instant_click' import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -53,6 +57,9 @@ window.customElements.define('folder-autocomplete', FolderAutocomplete)
window.customElements.define('signature-form', SignatureForm) window.customElements.define('signature-form', SignatureForm)
window.customElements.define('submit-form', SubmitForm) window.customElements.define('submit-form', SubmitForm)
window.customElements.define('prompt-password', PromptPassword) window.customElements.define('prompt-password', PromptPassword)
window.customElements.define('emails-textarea', EmailsTextarea)
window.customElements.define('toggle-cookies', ToggleCookies)
window.customElements.define('toggle-on-submit', ToggleOnSubmit)
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody) document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
document.addEventListener('turbo:submit-end', async (event) => { document.addEventListener('turbo:submit-end', async (event) => {
@@ -89,10 +96,33 @@ window.customElements.define('template-builder', class extends HTMLElement {
withPhone: this.dataset.withPhone === 'true', withPhone: this.dataset.withPhone === 'true',
withLogo: this.dataset.withLogo !== 'false', withLogo: this.dataset.withLogo !== 'false',
editable: this.dataset.editable !== 'false', editable: this.dataset.editable !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withPayment: this.dataset.withPayment === 'true', withPayment: this.dataset.withPayment === 'true',
isPaymentConnected: this.dataset.isPaymentConnected === 'true',
withFormula: this.dataset.withFormula === 'true',
withConditions: this.dataset.withConditions === 'true',
currencies: (this.dataset.currencies || '').split(',').filter(Boolean), currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
acceptFileTypes: this.dataset.acceptFileTypes, acceptFileTypes: this.dataset.acceptFileTypes
isDirectUpload: this.dataset.isDirectUpload === 'true' })
this.app.mount(this.appElem)
this.appendChild(this.appElem)
}
disconnectedCallback () {
this.app?.unmount()
this.appElem?.remove()
}
})
window.customElements.define('import-list', class extends HTMLElement {
connectedCallback () {
this.appElem = document.createElement('div')
this.app = createApp(ImportList, {
template: JSON.parse(this.dataset.template),
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content
}) })
this.app.mount(this.appElem) this.app.mount(this.appElem)
+13 -4
View File
@@ -75,16 +75,21 @@ button[disabled] .enabled {
@apply select base-input w-full font-normal; @apply select base-input w-full font-normal;
} }
.tooltip-bottom-start:before { .tooltip-bottom-end:before {
transform: translateX(-30%); transform: translateX(-95%);
top: var(--tooltip-offset); top: var(--tooltip-offset);
left: 100%; left: 100%;
right: auto; right: auto;
bottom: auto; bottom: auto;
} }
.tooltip-bottom-start:after { .tooltip-pre:before {
transform: translateX(-75%); white-space: pre;
text-align: left;
}
.tooltip-bottom-end:after {
transform: translateX(-25%);
border-color: transparent transparent var(--tooltip-color) transparent; border-color: transparent transparent var(--tooltip-color) transparent;
top: var(--tooltip-tail-offset); top: var(--tooltip-tail-offset);
left: 50%; left: 50%;
@@ -125,3 +130,7 @@ button[disabled] .enabled {
outline-offset: 3px; outline-offset: 3px;
outline-color: hsl(var(--bc) / 0.2); outline-color: hsl(var(--bc) / 0.2);
} }
select:required:invalid {
color: gray !important;
}
@@ -7,7 +7,7 @@ export default class extends HTMLElement {
resize () { resize () {
if (this.textarea.clientHeight < this.textarea.scrollHeight) { if (this.textarea.clientHeight < this.textarea.scrollHeight) {
this.textarea.style.height = `${this.textarea.scrollHeight}px` this.textarea.style.height = `${this.textarea.scrollHeight + 5}px`
} }
} }
+3 -3
View File
@@ -48,9 +48,9 @@ export default targetable(class extends HTMLElement {
fileRequests.reduce( fileRequests.reduce(
(prevPromise, request) => prevPromise.then(() => request()), (prevPromise, request) => prevPromise.then(() => request()),
Promise.resolve() Promise.resolve()
) ).finally(() => {
this.toggleState()
this.toggleState() })
} }
downloadSafariIos (urls) { downloadSafariIos (urls) {
@@ -0,0 +1,56 @@
const emailRegexp = /([^@;,<>\s]+@[^@;,<>\s]+)/g
export default class extends HTMLElement {
connectedCallback () {
if (this.dataset.limit) {
this.textarea.addEventListener('input', () => {
const emails = this.textarea.value.match(emailRegexp) || []
this.updateCounter(emails.length)
})
}
}
updateCounter (count) {
let counter = document.getElementById('emails_counter')
let bulkMessage = document.getElementById('bulk_message')
if (count < 2) {
counter?.remove()
return
}
if ((count + 10) > this.dataset.limit) {
if (!counter) {
counter = document.createElement('span')
counter.id = 'emails_counter'
counter.classList.add('text-xs', 'right-0', 'absolute')
counter.style.bottom = '-15px'
this.textarea.parentNode.append(counter)
}
counter.innerText = `${count} / ${this.dataset.limit}`
}
if (this.dataset.bulkEnabled !== 'true') {
if (!bulkMessage) {
bulkMessage = document.createElement('span')
bulkMessage.id = 'bulk_message'
bulkMessage.classList.add('text-xs', 'left-0', 'absolute')
bulkMessage.style.bottom = '-15px'
this.textarea.parentNode.append(bulkMessage)
}
bulkMessage.innerHTML = '<a class="link" data-turbo="false" href="/upgrade">Upgrade</a> to bulk send multiple recipients'
}
}
get textarea () {
return this.querySelector('textarea')
}
}
+3 -57
View File
@@ -9,10 +9,6 @@ export default actionable(targetable(class extends HTMLElement {
] ]
connectedCallback () { connectedCallback () {
if (this.dataset.isDirectUpload === 'true') {
import('@rails/activestorage')
}
this.addEventListener('drop', this.onDrop) this.addEventListener('drop', this.onDrop)
this.addEventListener('dragover', (e) => e.preventDefault()) this.addEventListener('dragover', (e) => e.preventDefault())
@@ -48,61 +44,11 @@ export default actionable(targetable(class extends HTMLElement {
this.classList.toggle('opacity-50') this.classList.toggle('opacity-50')
} }
async uploadFiles (files) { uploadFiles () {
this.toggleLoading() this.toggleLoading()
if (this.dataset.isDirectUpload === 'true') { if (this.dataset.submitOnUpload) {
const { DirectUpload } = await import('@rails/activestorage') this.closest('form').querySelector('button[type="submit"]').click()
await Promise.all(
Array.from(files).map(async (file) => {
const upload = new DirectUpload(
file,
'/direct_uploads',
this.input
)
return new Promise((resolve, reject) => {
upload.create((error, blob) => {
if (error) {
console.error(error)
return reject(error)
} else {
return resolve(blob)
}
})
}).catch((error) => {
console.error(error)
})
})
).then((blobs) => {
if (this.dataset.submitOnUpload) {
this.querySelectorAll('[name="blob_signed_ids[]"]').forEach((e) => e.remove())
}
blobs.forEach((blob) => {
const input = document.createElement('input')
input.type = 'hidden'
input.name = 'blob_signed_ids[]'
input.value = blob.signed_id
this.append(input)
})
if (this.dataset.submitOnUpload === 'true') {
this.closest('form').querySelector('button[type="submit"]').click()
}
}).finally(() => {
if (this.dataset.submitOnUpload !== 'true') {
this.toggleLoading()
}
})
} else {
if (this.dataset.submitOnUpload) {
this.closest('form').querySelector('button[type="submit"]').click()
}
} }
} }
})) }))
@@ -20,7 +20,7 @@ export default class extends HTMLElement {
fetch = (text, resolve) => { fetch = (text, resolve) => {
const queryParams = new URLSearchParams({ q: text }) const queryParams = new URLSearchParams({ q: text })
fetch('/api/template_folders_autocomplete?' + queryParams).then(async (resp) => { fetch('/template_folders_autocomplete?' + queryParams).then(async (resp) => {
const items = await resp.json() const items = await resp.json()
resolve(items) resolve(items)
@@ -7,6 +7,7 @@ export default class extends HTMLElement {
preventSubmit: 1, preventSubmit: 1,
minLength: 1, minLength: 1,
showOnFocus: true, showOnFocus: true,
debounceWaitMs: 200,
onSelect: this.onSelect, onSelect: this.onSelect,
render: this.render, render: this.render,
fetch: this.fetch fetch: this.fetch
@@ -27,6 +28,8 @@ export default class extends HTMLElement {
if (textarea && item[field]) { if (textarea && item[field]) {
textarea.value = textarea.value.replace(/[^;,\s]+$/, item[field] + ' ') textarea.value = textarea.value.replace(/[^;,\s]+$/, item[field] + ' ')
textarea.dispatchEvent(new Event('input', { bubbles: true }))
} }
}) })
} }
@@ -37,7 +40,9 @@ export default class extends HTMLElement {
if (q) { if (q) {
const queryParams = new URLSearchParams({ q, field: this.dataset.field }) const queryParams = new URLSearchParams({ q, field: this.dataset.field })
fetch('/api/submitters_autocomplete?' + queryParams).then(async (resp) => { this.currentFetch ||= fetch('/submitters_autocomplete?' + queryParams)
this.currentFetch.then(async (resp) => {
const items = await resp.json() const items = await resp.json()
if (q.length < 3) { if (q.length < 3) {
@@ -47,6 +52,8 @@ export default class extends HTMLElement {
} }
}).catch(() => { }).catch(() => {
resolve([]) resolve([])
}).finally(() => {
this.currentFetch = null
}) })
} else { } else {
resolve([]) resolve([])
+17
View File
@@ -0,0 +1,17 @@
export default class extends HTMLElement {
connectedCallback () {
this.button.addEventListener('click', () => {
const expirationDate = new Date()
expirationDate.setFullYear(expirationDate.getFullYear() + 10)
const expires = expirationDate.toUTCString()
document.cookie = this.dataset.key + '=' + this.dataset.value + '; expires=' + expires + '; path=/'
})
}
get button () {
return this.querySelector('button')
}
}
@@ -0,0 +1,35 @@
export default class extends HTMLElement {
connectedCallback () {
document.addEventListener('turbo:submit-end', this.onSubmitEnd)
this.form.addEventListener('submit', this.onSubmit)
}
disconnectedCallback () {
document.removeEventListener('turbo:submit-end', this.onSubmitEnd)
this.form.removeEventListener('submit', this.onSubmit)
}
onSubmit = () => {
this.element.classList.add('invisible')
}
onSubmitEnd = (event) => {
if (event.target === this.form) {
const resp = event.detail?.formSubmission?.result?.fetchResponse?.response
if (resp?.status / 100 === 2) {
this.element.classList.remove('invisible')
}
}
}
get element () {
return document.getElementById(this.dataset.elementId)
}
get form () {
return this.closest('form')
}
}
+4 -2
View File
@@ -11,14 +11,16 @@ window.customElements.define('submission-form', class extends HTMLElement {
this.app = createApp(Form, { this.app = createApp(Form, {
submitter: JSON.parse(this.dataset.submitter), submitter: JSON.parse(this.dataset.submitter),
canSendEmail: this.dataset.canSendEmail === 'true', canSendEmail: this.dataset.canSendEmail === 'true',
isDirectUpload: this.dataset.isDirectUpload === 'true',
goToLast: this.dataset.goToLast === 'true', goToLast: this.dataset.goToLast === 'true',
isDemo: this.dataset.isDemo === 'true', isDemo: this.dataset.isDemo === 'true',
attribution: this.dataset.attribution !== 'false', attribution: this.dataset.attribution !== 'false',
withConfetti: this.dataset.withConfetti !== 'false', withConfetti: this.dataset.withConfetti !== 'false',
withDisclosure: this.dataset.withDisclosure === 'true',
withTypedSignature: this.dataset.withTypedSignature !== 'false', withTypedSignature: this.dataset.withTypedSignature !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
values: reactive(JSON.parse(this.dataset.values)), values: reactive(JSON.parse(this.dataset.values)),
completedButton: JSON.parse(this.dataset.completedButton), completedButton: JSON.parse(this.dataset.completedButton || '{}'),
completedMessage: JSON.parse(this.dataset.completedMessage || '{}'),
completedRedirectUrl: this.dataset.completedRedirectUrl, completedRedirectUrl: this.dataset.completedRedirectUrl,
attachments: reactive(JSON.parse(this.dataset.attachments)), attachments: reactive(JSON.parse(this.dataset.attachments)),
fields: JSON.parse(this.dataset.fields) fields: JSON.parse(this.dataset.fields)
+40 -12
View File
@@ -1,9 +1,9 @@
<template> <template>
<div <div
class="flex absolute lg:text-base -outline-offset-1" class="field-area flex absolute lg:text-base -outline-offset-1"
dir="auto" dir="auto"
:style="computedStyle" :style="computedStyle"
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars, 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'outline-red-500 outline-dashed outline-2 z-10': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }" :class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars, 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'field-area-active outline-red-500 outline-dashed outline-2 z-10': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
> >
<div <div
v-if="!isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid" v-if="!isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid"
@@ -157,6 +157,7 @@
ref="textContainer" ref="textContainer"
dir="auto" dir="auto"
class="flex items-center px-0.5 w-full" class="flex items-center px-0.5 w-full"
:class="alignClasses[field.preferences?.align]"
> >
<span v-if="Array.isArray(modelValue)"> <span v-if="Array.isArray(modelValue)">
{{ modelValue.join(', ') }} {{ modelValue.join(', ') }}
@@ -164,16 +165,20 @@
<span v-else-if="field.type === 'date'"> <span v-else-if="field.type === 'date'">
{{ formattedDate }} {{ formattedDate }}
</span> </span>
<span v-else-if="field.type === 'number'">
{{ formatNumber(modelValue, field.preferences?.format) }}
</span>
<span <span
v-else v-else
class="whitespace-pre-wrap" class="whitespace-pre-wrap"
:class="{ 'w-full': field.preferences?.align }"
>{{ modelValue }}</span> >{{ modelValue }}</span>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp } from '@tabler/icons-vue' import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1 } from '@tabler/icons-vue'
export default { export default {
name: 'FieldArea', name: 'FieldArea',
@@ -240,6 +245,7 @@ export default {
signature: this.t('signature'), signature: this.t('signature'),
initials: this.t('initials'), initials: this.t('initials'),
date: this.t('date'), date: this.t('date'),
number: this.t('number'),
image: this.t('image'), image: this.t('image'),
file: this.t('file'), file: this.t('file'),
select: this.t('select'), select: this.t('select'),
@@ -252,6 +258,13 @@ export default {
phone: this.t('phone') phone: this.t('phone')
} }
}, },
alignClasses () {
return {
center: 'text-center',
left: 'text-left',
right: 'text-right'
}
},
option () { option () {
return this.field.options.find((o) => o.uuid === this.area.option_uuid) return this.field.options.find((o) => o.uuid === this.area.option_uuid)
}, },
@@ -260,6 +273,7 @@ export default {
text: IconTextSize, text: IconTextSize,
signature: IconWritingSign, signature: IconWritingSign,
date: IconCalendarEvent, date: IconCalendarEvent,
number: IconSquareNumber1,
image: IconPhoto, image: IconPhoto,
initials: IconLetterCaseUpper, initials: IconLetterCaseUpper,
file: IconPaperclip, file: IconPaperclip,
@@ -307,7 +321,7 @@ export default {
formattedDate () { formattedDate () {
if (this.field.type === 'date' && this.modelValue) { if (this.field.type === 'date' && this.modelValue) {
return this.formatDate( return this.formatDate(
new Date(this.modelValue), this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue),
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY') this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY')
) )
} else { } else {
@@ -336,17 +350,19 @@ export default {
}, },
watch: { watch: {
modelValue () { modelValue () {
if (this.field.type === 'text' && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.modelValue.length)) { this.$nextTick(() => {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? this.modelValue.length : 0 if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.modelValue}`.length)) {
} this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue}`.length : 0
}
})
} }
}, },
mounted () { mounted () {
if (this.field.type === 'text' && this.$refs.textContainer) { this.$nextTick(() => {
this.$nextTick(() => { if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer) {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? this.modelValue.length : 0 this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue}`.length : 0
}) }
} })
}, },
methods: { methods: {
optionValue (option) { optionValue (option) {
@@ -360,6 +376,17 @@ export default {
} }
} }
}, },
formatNumber (number, format) {
if (format === 'comma') {
return new Intl.NumberFormat('en-US').format(number)
} else if (format === 'dot') {
return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number)
} else {
return number
}
},
formatDate (date, format) { formatDate (date, format) {
const monthFormats = { const monthFormats = {
M: 'numeric', M: 'numeric',
@@ -375,6 +402,7 @@ export default {
const yearFormats = { const yearFormats = {
YYYY: 'numeric', YYYY: 'numeric',
YYY: 'numeric',
YY: '2-digit' YY: '2-digit'
} }
@@ -41,10 +41,16 @@
:name="`values[${field.uuid}][]`" :name="`values[${field.uuid}][]`"
> >
</template> </template>
<div
v-if="field.description && !modelValue.length"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<FileDropzone <FileDropzone
:message="`${t('upload')} ${field.name || t('files')}${field.required ? '' : ` (${t('optional')})`}`" :message="`${t('upload')} ${field.name || t('files')}${field.required ? '' : ` (${t('optional')})`}`"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
:is-direct-upload="isDirectUpload"
:multiple="true" :multiple="true"
@upload="onUpload" @upload="onUpload"
/> />
@@ -53,12 +59,14 @@
<script> <script>
import FileDropzone from './dropzone' import FileDropzone from './dropzone'
import MarkdownContent from './markdown_content'
import { IconPaperclip, IconTrashX } from '@tabler/icons-vue' import { IconPaperclip, IconTrashX } from '@tabler/icons-vue'
export default { export default {
name: 'AttachmentStep', name: 'AttachmentStep',
components: { components: {
FileDropzone, FileDropzone,
MarkdownContent,
IconPaperclip, IconPaperclip,
IconTrashX IconTrashX
}, },
@@ -77,11 +85,6 @@ export default {
required: false, required: false,
default: () => ({}) default: () => ({})
}, },
isDirectUpload: {
type: Boolean,
required: true,
default: false
},
modelValue: { modelValue: {
type: Array, type: Array,
required: false, required: false,
+28 -7
View File
@@ -3,20 +3,29 @@
class="mx-auto max-w-md flex flex-col" class="mx-auto max-w-md flex flex-col"
dir="auto" dir="auto"
> >
<p class="font-medium text-2xl flex items-center space-x-1.5 mx-auto"> <div class="font-medium text-2xl flex items-center space-x-1.5 mx-auto">
<IconCircleCheck <IconCircleCheck
class="inline text-green-600" class="inline text-green-600"
:width="30" :width="30"
:height="30" :height="30"
/> />
<span> <span>
{{ t('form_has_been_completed') }} {{ completedMessage.title || t('form_has_been_completed') }}
</span> </span>
</p> </div>
<div
v-if="completedMessage.body"
class="mt-2"
>
<MarkdownContent
:string="completedMessage.body"
/>
</div>
<div class="space-y-3 mt-5"> <div class="space-y-3 mt-5">
<a <a
v-if="completedButton.url" v-if="completedButton.url"
:href="completedButton.url" :href="sanitizeHref(completedButton.url)"
rel="noopener noreferrer nofollow"
class="white-button flex items-center w-full" class="white-button flex items-center w-full"
> >
<span> <span>
@@ -91,10 +100,12 @@
<script> <script>
import { IconCircleCheck, IconBrandGithub, IconMail, IconDownload, IconInnerShadowTop, IconLogin } from '@tabler/icons-vue' import { IconCircleCheck, IconBrandGithub, IconMail, IconDownload, IconInnerShadowTop, IconLogin } from '@tabler/icons-vue'
import MarkdownContent from './markdown_content'
export default { export default {
name: 'FormCompleted', name: 'FormCompleted',
components: { components: {
MarkdownContent,
IconCircleCheck, IconCircleCheck,
IconInnerShadowTop, IconInnerShadowTop,
IconBrandGithub, IconBrandGithub,
@@ -142,6 +153,11 @@ export default {
type: Object, type: Object,
required: false, required: false,
default: () => ({}) default: () => ({})
},
completedMessage: {
type: Object,
required: false,
default: () => ({})
} }
}, },
data () { data () {
@@ -211,9 +227,14 @@ export default {
fileRequests.reduce( fileRequests.reduce(
(prevPromise, request) => prevPromise.then(() => request()), (prevPromise, request) => prevPromise.then(() => request()),
Promise.resolve() Promise.resolve()
) ).finally(() => {
this.isDownloading = false
this.isDownloading = false })
},
sanitizeHref (href) {
if (href && href.trim().match(/^((?:https?:\/\/)|\/)/)) {
return href.replace(/javascript:/g, '')
}
}, },
downloadSafariIos (urls) { downloadSafariIos (urls) {
const fileRequests = urls.map((url) => { const fileRequests = urls.map((url) => {
@@ -1,4 +1,4 @@
function cropCanvasAndExportToPNG (canvas) { function cropCanvasAndExportToPNG (canvas, { errorOnTooSmall } = { errorOnTooSmall: false }) {
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
const width = canvas.width const width = canvas.width
@@ -33,6 +33,10 @@ function cropCanvasAndExportToPNG (canvas) {
croppedCanvas.height = croppedHeight croppedCanvas.height = croppedHeight
const croppedCtx = croppedCanvas.getContext('2d') const croppedCtx = croppedCanvas.getContext('2d')
if (errorOnTooSmall && (croppedWidth < 20 || croppedHeight < 20)) {
return Promise.reject(new Error('Image too small'))
}
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight) croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
+23 -4
View File
@@ -1,20 +1,37 @@
<template> <template>
<div dir="auto"> <div dir="auto">
<div class="flex justify-between items-center w-full mb-2"> <div
class="flex justify-between items-center w-full"
:class="{ 'mb-2': !field.description }"
>
<label <label
:for="field.uuid" :for="field.uuid"
class="label text-2xl" class="label text-2xl"
>{{ field.name && showFieldNames ? field.name : t('date') }} >
<template v-if="!field.required">({{ t('optional') }})</template> <MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>
{{ field.name && showFieldNames ? field.name : t('date') }}
<template v-if="!field.required">({{ t('optional') }})</template>
</template>
</label> </label>
<button <button
class="btn btn-outline btn-sm !normal-case font-normal" class="btn btn-outline btn-sm !normal-case font-normal"
@click.prevent="setCurrentDate" @click.prevent="[setCurrentDate(), $emit('focus')]"
> >
<IconCalendarCheck :width="16" /> <IconCalendarCheck :width="16" />
{{ t('set_today') }} {{ t('set_today') }}
</button> </button>
</div> </div>
<div
v-if="field.description"
class="mb-3 px-1"
dir="auto"
>
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" /> <AppearsOn :field="field" />
<div class="text-center"> <div class="text-center">
<input <input
@@ -33,11 +50,13 @@
<script> <script>
import { IconCalendarCheck } from '@tabler/icons-vue' import { IconCalendarCheck } from '@tabler/icons-vue'
import AppearsOn from './appears_on' import AppearsOn from './appears_on'
import MarkdownContent from './markdown_content'
export default { export default {
name: 'DateStep', name: 'DateStep',
components: { components: {
IconCalendarCheck, IconCalendarCheck,
MarkdownContent,
AppearsOn AppearsOn
}, },
inject: ['t'], inject: ['t'],
+42 -69
View File
@@ -70,11 +70,6 @@ export default {
required: false, required: false,
default: '*/*' default: '*/*'
}, },
isDirectUpload: {
type: Boolean,
required: true,
default: false
},
multiple: { multiple: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -92,11 +87,6 @@ export default {
return 'el' + Math.random().toString(32).split('.')[1] return 'el' + Math.random().toString(32).split('.')[1]
} }
}, },
mounted () {
if (this.isDirectUpload) {
import('@rails/activestorage')
}
},
methods: { methods: {
onDropFiles (e) { onDropFiles (e) {
this.uploadFiles(e.dataTransfer.files) this.uploadFiles(e.dataTransfer.files)
@@ -113,72 +103,55 @@ export default {
async uploadFiles (files) { async uploadFiles (files) {
this.isLoading = true this.isLoading = true
if (this.isDirectUpload) { return await Promise.all(
const { DirectUpload } = await import('@rails/activestorage') Array.from(files).map(async (file) => {
const formData = new FormData()
const blobs = await Promise.all( if (file.type === 'image/bmp') {
Array.from(files).map(async (file) => { file = await this.convertBmpToPng(file)
const upload = new DirectUpload( }
file,
'/direct_uploads',
this.$refs.input
)
return new Promise((resolve, reject) => { formData.append('file', file)
upload.create((error, blob) => { formData.append('submitter_slug', this.submitterSlug)
if (error) { formData.append('name', 'attachments')
console.error(error)
return reject(error) return fetch(this.baseUrl + '/api/attachments', {
} else { method: 'POST',
return resolve(blob) body: formData
} }).then(resp => resp.json()).then((data) => {
}) return data
}).catch((error) => {
console.error(error)
})
}) })
) })).then((result) => {
this.$emit('upload', result)
}).finally(() => {
this.isLoading = false
})
},
convertBmpToPng (bmpFile) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
return await Promise.all( reader.onload = function (event) {
blobs.map((blob) => { const img = new Image()
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: JSON.stringify({
name: 'attachments',
blob_signed_id: blob.signed_id,
submitter_slug: this.submitterSlug
}),
headers: { 'Content-Type': 'application/json' }
}).then(resp => resp.json()).then((data) => {
return data
})
})).then((result) => {
this.$emit('upload', result)
}).finally(() => {
this.isLoading = false
})
} else {
return await Promise.all(
Array.from(files).map((file) => {
const formData = new FormData()
formData.append('file', file) img.onload = function () {
formData.append('submitter_slug', this.submitterSlug) const canvas = document.createElement('canvas')
formData.append('name', 'attachments') const ctx = canvas.getContext('2d')
return fetch(this.baseUrl + '/api/attachments', { canvas.width = img.width
method: 'POST', canvas.height = img.height
body: formData ctx.drawImage(img, 0, 0)
}).then(resp => resp.json()).then((data) => { canvas.toBlob(function (blob) {
return data const newFile = new File([blob], bmpFile.name.replace(/\.\w+$/, '.png'), { type: 'image/png' })
}) resolve(newFile)
})).then((result) => { }, 'image/png')
this.$emit('upload', result) }
}).finally(() => {
this.isLoading = false img.src = event.target.result
}) }
} reader.onerror = reject
reader.readAsDataURL(bmpFile)
})
} }
} }
} }
+370 -112
View File
@@ -8,13 +8,28 @@
:current-step="currentStepFields" :current-step="currentStepFields"
@focus-step="[saveStep(), goToStep($event, false, true), currentField.type !== 'checkbox' ? isFormVisible = true : '']" @focus-step="[saveStep(), goToStep($event, false, true), currentField.type !== 'checkbox' ? isFormVisible = true : '']"
/> />
<FormulaFieldAreas
v-if="formulaFields.length"
:fields="formulaFields"
:values="values"
/>
<button <button
v-if="!isFormVisible" v-if="!isFormVisible"
id="expand_form_button" id="expand_form_button"
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" class="btn btn-neutral flex text-white absolute bottom-0 w-full mb-3"
@click.prevent="isFormVisible = true" style="width: 96%; margin-left: 2%"
@click.prevent="[isFormVisible = true, scrollIntoField(currentField)]"
> >
{{ t('submit_form') }} <template v-if="['initials', 'signature'].includes(currentField.type)">
<IconWritingSign stroke-width="1.5" />
{{ t('sign_now') }}
</template>
<template v-else-if="alwaysMinimize">
{{ t('next') }}
</template>
<template v-else>
{{ t('submit_form') }}
</template>
<IconArrowsDiagonal <IconArrowsDiagonal
class="absolute right-0 mr-4" class="absolute right-0 mr-4"
:width="20" :width="20"
@@ -32,7 +47,7 @@
v-if="!isCompleted" v-if="!isCompleted"
id="minimize_form_button" id="minimize_form_button"
class="absolute right-0 mr-2 mt-2 top-0 hidden md:block" class="absolute right-0 mr-2 mt-2 top-0 hidden md:block"
title="Minimize" :title="t('minimize')"
@click.prevent="isFormVisible = false" @click.prevent="isFormVisible = false"
> >
<IconArrowsDiagonalMinimize2 <IconArrowsDiagonalMinimize2
@@ -40,13 +55,16 @@
:height="20" :height="20"
/> />
</button> </button>
<div> <div
:class="{ 'md:px-4': isBreakpointMd }"
>
<form <form
v-if="!isCompleted" v-if="!isCompleted"
ref="form" ref="form"
:action="submitPath" :action="submitPath"
method="post" method="post"
:class="{ 'md:mx-16': isBreakpointMd }" class="mx-auto"
:style="{ maxWidth: isBreakpointMd ? '582px' : '' }"
@submit.prevent="submitStep" @submit.prevent="submitStep"
> >
<input <input
@@ -69,6 +87,14 @@
@focus="scrollIntoField(currentField)" @focus="scrollIntoField(currentField)"
/> />
</div> </div>
<NumberStep
v-else-if="currentField.type === 'number'"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:show-field-names="showFieldNames"
:field="currentField"
@focus="scrollIntoField(currentField)"
/>
<DateStep <DateStep
v-else-if="currentField.type === 'date'" v-else-if="currentField.type === 'date'"
:key="currentField.uuid" :key="currentField.uuid"
@@ -79,17 +105,32 @@
/> />
<div v-else-if="currentField.type === 'select'"> <div v-else-if="currentField.type === 'select'">
<label <label
v-if="showFieldNames && currentField.name" v-if="showFieldNames && (currentField.name || currentField.title)"
:for="currentField.uuid" :for="currentField.uuid"
dir="auto" dir="auto"
class="label text-2xl mb-2" class="label text-2xl"
>{{ currentField.name }} :class="{ 'mb-2': !currentField.description }"
<template v-if="!currentField.required">({{ t('optional') }})</template> >
<MarkdownContent
v-if="currentField.title"
:string="currentField.title"
/>
<template v-else>
{{ currentField.name }}
<template v-if="!currentField.required">({{ t('optional') }})</template>
</template>
</label> </label>
<div <div
v-else v-else
class="py-1" class="py-1"
/> />
<div
v-if="currentField.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="currentField.description" />
</div>
<AppearsOn :field="currentField" /> <AppearsOn :field="currentField" />
<select <select
:id="currentField.uuid" :id="currentField.uuid"
@@ -118,13 +159,28 @@
</div> </div>
<div v-else-if="currentField.type === 'radio'"> <div v-else-if="currentField.type === 'radio'">
<label <label
v-if="showFieldNames && currentField.name" v-if="showFieldNames && (currentField.name || currentField.title)"
:for="currentField.uuid" :for="currentField.uuid"
dir="auto" dir="auto"
class="label text-2xl mb-2" class="label text-2xl"
>{{ currentField.name }} :class="{ 'mb-2': !currentField.description }"
<template v-if="!currentField.required">({{ t('optional') }})</template> >
<MarkdownContent
v-if="currentField.title"
:string="currentField.title"
/>
<template v-else>
{{ currentField.name }}
<template v-if="!currentField.required">({{ t('optional') }})</template>
</template>
</label> </label>
<div
v-if="currentField.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="currentField.description" />
</div>
<div class="flex w-full max-h-44 overflow-y-auto"> <div class="flex w-full max-h-44 overflow-y-auto">
<div <div
v-if="!showFieldNames || (currentField.options.every((e) => !e.value) && currentField.options.length > 4)" v-if="!showFieldNames || (currentField.options.every((e) => !e.value) && currentField.options.length > 4)"
@@ -156,6 +212,7 @@
:name="`values[${currentField.uuid}]`" :name="`values[${currentField.uuid}]`"
:value="option.value || `${t('option')} ${index + 1}`" :value="option.value || `${t('option')} ${index + 1}`"
:required="currentField.required" :required="currentField.required"
@click="scrollIntoField(currentField)"
> >
<span class="text-xl"> <span class="text-xl">
{{ option.value || `${t('option')} ${index + 1}` }} {{ option.value || `${t('option')} ${index + 1}` }}
@@ -175,66 +232,77 @@
/> />
<div <div
v-else-if="currentField.type === 'checkbox'" v-else-if="currentField.type === 'checkbox'"
class="flex w-full max-h-44 overflow-y-auto"
> >
<input
type="hidden"
name="cast_boolean"
value="true"
>
<div <div
class="space-y-3.5 mx-auto" v-if="currentField.description"
dir="auto"
class="mb-3 px-1"
> >
<template v-if="isAnonymousChecboxes || !showFieldNames"> <MarkdownContent :string="currentField.description" />
<span class="text-xl"> </div>
{{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>. <div
</span> class="flex w-full max-h-44 overflow-y-auto"
<input >
v-for="field in currentStepFields" <input
:key="field.uuid" type="hidden"
type="hidden" name="cast_boolean"
:name="`values[${field.uuid}]`" value="true"
:value="!!values[field.uuid]" >
> <div
</template> class="space-y-3.5 mx-auto"
<template v-else> >
<div <template v-if="isAnonymousChecboxes || !showFieldNames">
v-for="(field, index) in currentStepFields" <span class="text-xl">
:key="field.uuid" {{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>.
> </span>
<label <input
:for="field.uuid" v-for="field in currentStepFields"
class="flex items-center space-x-3" :key="field.uuid"
type="hidden"
:name="`values[${field.uuid}]`"
:value="!!values[field.uuid]"
> >
<input </template>
type="hidden" <template v-else>
:name="`values[${field.uuid}]`" <div
:value="!!values[field.uuid]" v-for="(field, index) in currentStepFields"
:key="field.uuid"
>
<label
:for="field.uuid"
class="flex items-center space-x-3"
> >
<input <input
:id="field.uuid" type="hidden"
type="checkbox" :name="`values[${field.uuid}]`"
class="base-checkbox !h-7 !w-7" :value="!!values[field.uuid]"
:oninvalid="`this.setCustomValidity('${t('please_check_the_box_to_continue')}')`" >
:onchange="`this.setCustomValidity(validity.valueMissing ? '${t('please_check_the_box_to_continue')}' : '');`" <input
:required="field.required" :id="field.uuid"
:checked="!!values[field.uuid]" type="checkbox"
@click="[scrollIntoField(field), values[field.uuid] = !values[field.uuid]]" class="base-checkbox !h-7 !w-7"
> :oninvalid="`this.setCustomValidity('${t('please_check_the_box_to_continue')}')`"
<span :onchange="`this.setCustomValidity(validity.valueMissing ? '${t('please_check_the_box_to_continue')}' : '');`"
v-if="field.title" :required="field.required"
class="text-xl" :checked="!!values[field.uuid]"
v-html="field.title" @click="[scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
/> >
<span <span
v-else v-if="field.title"
class="text-xl" class="text-xl"
> >
{{ field.name || field.type + ' ' + (index + 1) }} <MarkdownContent :string="field.title" />
</span> </span>
</label> <span
</div> v-else
</template> class="text-xl"
>
{{ field.name || field.type + ' ' + (index + 1) }}
</span>
</label>
</div>
</template>
</div>
</div> </div>
</div> </div>
<ImageStep <ImageStep
@@ -242,7 +310,6 @@
:key="currentField.uuid" :key="currentField.uuid"
v-model="values[currentField.uuid]" v-model="values[currentField.uuid]"
:field="currentField" :field="currentField"
:is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
:show-field-names="showFieldNames" :show-field-names="showFieldNames"
@@ -254,10 +321,11 @@
:key="currentField.uuid" :key="currentField.uuid"
v-model="values[currentField.uuid]" v-model="values[currentField.uuid]"
:field="currentField" :field="currentField"
:previous-value="previousSignatureValue" :previous-value="previousSignatureValueFor(currentField)"
:is-direct-upload="isDirectUpload"
:with-typed-signature="withTypedSignature" :with-typed-signature="withTypedSignature"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
:button-text="buttonText"
:with-disclosure="withDisclosure"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
:show-field-names="showFieldNames" :show-field-names="showFieldNames"
@attached="attachments.push($event)" @attached="attachments.push($event)"
@@ -271,7 +339,6 @@
v-model="values[currentField.uuid]" v-model="values[currentField.uuid]"
:field="currentField" :field="currentField"
:previous-value="previousInitialsValue" :previous-value="previousInitialsValue"
:is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
:show-field-names="showFieldNames" :show-field-names="showFieldNames"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
@@ -284,7 +351,6 @@
v-else-if="currentField.type === 'file'" v-else-if="currentField.type === 'file'"
:key="currentField.uuid" :key="currentField.uuid"
v-model="values[currentField.uuid]" v-model="values[currentField.uuid]"
:is-direct-upload="isDirectUpload"
:field="currentField" :field="currentField"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
@@ -296,7 +362,9 @@
:key="currentField.uuid" :key="currentField.uuid"
v-model="values[currentField.uuid]" v-model="values[currentField.uuid]"
:field="currentField" :field="currentField"
:locale="language?.toLowerCase() || browserLanguage"
:show-field-names="showFieldNames" :show-field-names="showFieldNames"
:verified-value="phoneVerifiedValues[currentField.uuid]"
:default-value="submitter.phone" :default-value="submitter.phone"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
@focus="scrollIntoField(currentField)" @focus="scrollIntoField(currentField)"
@@ -316,7 +384,7 @@
</div> </div>
<div <div
v-if="currentField.type !== 'payment' || submittedValues[currentField.uuid]" v-if="currentField.type !== 'payment' || submittedValues[currentField.uuid]"
class="mt-6 md:mt-8" :class="withDisclosure && currentField.type === 'signature' ? 'mt-2' : 'mt-6 md:mt-8'"
> >
<button <button
id="submit_form_button" id="submit_form_button"
@@ -330,11 +398,8 @@
v-if="isSubmitting" v-if="isSubmitting"
class="mr-1 animate-spin" class="mr-1 animate-spin"
/> />
<span v-if="stepFields.length === currentStep + 1"> <span>
{{ t('submit') }} {{ buttonText }}
</span>
<span v-else>
{{ t('next') }}
</span><span </span><span
v-if="isSubmitting" v-if="isSubmitting"
class="w-6 flex justify-start mr-1" class="w-6 flex justify-start mr-1"
@@ -354,6 +419,7 @@
:is-demo="isDemo" :is-demo="isDemo"
:attribution="attribution" :attribution="attribution"
:completed-button="completedRedirectUrl ? {} : completedButton" :completed-button="completedRedirectUrl ? {} : completedButton"
:completed-message="completedRedirectUrl ? {} : completedMessage"
:with-send-copy-button="withSendCopyButton && !completedRedirectUrl" :with-send-copy-button="withSendCopyButton && !completedRedirectUrl"
:with-download-button="withDownloadButton && !completedRedirectUrl" :with-download-button="withDownloadButton && !completedRedirectUrl"
:with-confetti="withConfetti" :with-confetti="withConfetti"
@@ -385,6 +451,7 @@
<script> <script>
import FieldAreas from './areas' import FieldAreas from './areas'
import FormulaFieldAreas from './formula_areas'
import ImageStep from './image_step' import ImageStep from './image_step'
import SignatureStep from './signature_step' import SignatureStep from './signature_step'
import InitialsStep from './initials_step' import InitialsStep from './initials_step'
@@ -393,12 +460,36 @@ import MultiSelectStep from './multi_select_step'
import PhoneStep from './phone_step' import PhoneStep from './phone_step'
import PaymentStep from './payment_step' import PaymentStep from './payment_step'
import TextStep from './text_step' import TextStep from './text_step'
import NumberStep from './number_step'
import DateStep from './date_step' import DateStep from './date_step'
import MarkdownContent from './markdown_content'
import FormCompleted from './completed' import FormCompleted from './completed'
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue' import { IconInnerShadowTop, IconArrowsDiagonal, IconWritingSign, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import AppearsOn from './appears_on' import AppearsOn from './appears_on'
import i18n from './i18n' import i18n from './i18n'
const isEmpty = (obj) => {
if (obj == null) return true
if (Array.isArray(obj)) {
return obj.length === 0
}
if (typeof obj === 'string') {
return obj.trim().length === 0
}
if (typeof obj === 'object') {
return Object.keys(obj).length === 0
}
if (obj === false) {
return true
}
return false
}
export default { export default {
name: 'SubmissionForm', name: 'SubmissionForm',
components: { components: {
@@ -406,6 +497,7 @@ export default {
ImageStep, ImageStep,
SignatureStep, SignatureStep,
AppearsOn, AppearsOn,
IconWritingSign,
AttachmentStep, AttachmentStep,
InitialsStep, InitialsStep,
MultiSelectStep, MultiSelectStep,
@@ -413,7 +505,10 @@ export default {
DateStep, DateStep,
IconArrowsDiagonal, IconArrowsDiagonal,
TextStep, TextStep,
NumberStep,
FormulaFieldAreas,
PhoneStep, PhoneStep,
MarkdownContent,
PaymentStep, PaymentStep,
IconArrowsDiagonalMinimize2, IconArrowsDiagonalMinimize2,
FormCompleted FormCompleted
@@ -448,11 +543,36 @@ export default {
return () => {} return () => {}
} }
}, },
expand: {
type: Boolean,
required: false,
default: null
},
minimize: {
type: Boolean,
required: false,
default: false
},
withDisclosure: {
type: Boolean,
required: false,
default: false
},
reuseSignature: {
type: Boolean,
required: false,
default: true
},
withConfetti: { withConfetti: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false default: false
}, },
autoscrollFields: {
type: Boolean,
required: false,
default: true
},
showFieldNames: { showFieldNames: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -483,11 +603,6 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
isDirectUpload: {
type: Boolean,
required: false,
default: false
},
allowToSkip: { allowToSkip: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -503,6 +618,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
dryRun: {
type: Boolean,
required: false,
default: false
},
attribution: { attribution: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -518,6 +638,16 @@ export default {
required: false, required: false,
default: () => ({}) default: () => ({})
}, },
authenticityToken: {
type: String,
required: false,
default: ''
},
i18n: {
type: Object,
required: false,
default: () => ({})
},
withSendCopyButton: { withSendCopyButton: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -537,22 +667,42 @@ export default {
type: Object, type: Object,
required: false, required: false,
default: () => ({}) default: () => ({})
},
completedMessage: {
type: Object,
required: false,
default: () => ({})
} }
}, },
data () { data () {
return { return {
isCompleted: false, isCompleted: false,
isFormVisible: true, isFormVisible: this.expand !== false,
showFillAllRequiredFields: false, showFillAllRequiredFields: false,
currentStep: 0, currentStep: 0,
phoneVerifiedValues: {},
orientation: screen?.orientation?.type,
isSubmitting: false, isSubmitting: false,
submittedValues: {}, submittedValues: {},
recalculateButtonDisabledKey: '' recalculateButtonDisabledKey: ''
} }
}, },
computed: { computed: {
isMobile () {
return /android|iphone|ipad/i.test(navigator.userAgent)
},
buttonText () {
if (this.alwaysMinimize || this.stepFields.length === this.currentStep + 1) {
return this.t('submit')
} else {
return this.t('next')
}
},
alwaysMinimize () {
return this.minimize || (this.orientation?.includes('landscape') && this.isMobile && parseInt(window.innerHeight) < 550)
},
currentStepFields () { currentStepFields () {
return this.stepFields[this.currentStep] return this.stepFields[this.currentStep] || []
}, },
browserLanguage () { browserLanguage () {
return (navigator.language || navigator.userLanguage || 'en').split('-')[0] return (navigator.language || navigator.userLanguage || 'en').split('-')[0]
@@ -560,24 +710,27 @@ export default {
queryParams () { queryParams () {
return new URLSearchParams(window.location.search) return new URLSearchParams(window.location.search)
}, },
authenticityToken () {
return document.querySelector('meta[name="csrf-token"]')?.content
},
submitterSlug () { submitterSlug () {
return this.submitter.slug return this.submitter.slug
}, },
previousSignatureValue () { fieldsUuidIndex () {
const signatureField = [...this.fields].reverse().find((field) => field.type === 'signature' && !!this.values[field.uuid]) return this.fields.reduce((acc, f) => {
acc[f.uuid] = f
return this.values[signatureField?.uuid] return acc
}, {})
}, },
previousInitialsValue () { previousInitialsValue () {
const initialsField = [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid]) if (this.reuseSignature !== false) {
const initialsField = [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
return this.values[initialsField?.uuid] return this.values[initialsField?.uuid]
} else {
return null
}
}, },
isAnonymousChecboxes () { isAnonymousChecboxes () {
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name) && this.currentStepFields.length > 4 return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name && !e.required) && this.currentStepFields.length > 4
}, },
isButtonDisabled () { isButtonDisabled () {
if (this.recalculateButtonDisabledKey) { if (this.recalculateButtonDisabledKey) {
@@ -596,15 +749,20 @@ export default {
return this.fields.filter((f) => !f.readonly).reduce((acc, f) => { return this.fields.filter((f) => !f.readonly).reduce((acc, f) => {
const prevStep = acc[acc.length - 1] const prevStep = acc[acc.length - 1]
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox') { if (this.checkFieldConditions(f)) {
prevStep.push(f) if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox' && !f.description) {
} else { prevStep.push(f)
acc.push([f]) } else {
acc.push([f])
}
} }
return acc return acc
}, []) }, [])
}, },
formulaFields () {
return this.fields.filter((f) => f.preferences?.formula)
},
attachmentsIndex () { attachmentsIndex () {
return this.attachments.reduce((acc, a) => { return this.attachments.reduce((acc, a) => {
acc[a.uuid] = a acc[a.uuid] = a
@@ -616,12 +774,27 @@ export default {
return `/s/${this.submitterSlug}` return `/s/${this.submitterSlug}`
} }
}, },
watch: {
expand (value) {
this.isFormVisible = value
},
currentStepFields (value) {
if (isEmpty(value)) {
this.currentStep -= 1
}
}
},
beforeUnmount () {
screen?.orientation?.removeEventListener('change', this.onOrientationChange)
},
mounted () { mounted () {
this.submittedValues = JSON.parse(JSON.stringify(this.values)) this.submittedValues = JSON.parse(JSON.stringify(this.values))
screen?.orientation.addEventListener('change', this.onOrientationChange)
this.fields.forEach((field) => { this.fields.forEach((field) => {
if (field.default_value && !field.readonly) { if (field.default_value && !field.readonly) {
this.values[field.uuid] = field.default_value this.values[field.uuid] ||= field.default_value
} }
}) })
@@ -648,6 +821,14 @@ export default {
this.currentStep = Math.min(...indexesList) this.currentStep = Math.min(...indexesList)
} }
if (document.body?.clientWidth >= 768 && this.expand !== true && ['signature', 'initials', 'file', 'image'].includes(this.currentField?.type)) {
this.isFormVisible = false
}
if (this.alwaysMinimize) {
this.isFormVisible = false
}
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) { if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
this.$nextTick(() => { this.$nextTick(() => {
const root = this.$root.$el.parentNode.getRootNode() const root = this.$root.$el.parentNode.getRootNode()
@@ -675,7 +856,44 @@ export default {
}, },
methods: { methods: {
t (key) { t (key) {
return i18n[this.language?.toLowerCase()]?.[key] || i18n[this.browserLanguage]?.[key] || i18n.en[key] || key return this.i18n[key] || i18n[this.language?.toLowerCase()]?.[key] || i18n[this.browserLanguage]?.[key] || i18n.en[key] || key
},
onOrientationChange (event) {
this.orientation = event.target.type
},
checkFieldConditions (field) {
if (field.conditions?.length) {
return field.conditions.reduce((acc, c) => {
if (['empty', 'unchecked'].includes(c.action)) {
return acc && isEmpty(this.values[c.field_uuid])
} else if (['not_empty', 'checked'].includes(c.action)) {
return acc && !isEmpty(this.values[c.field_uuid])
} else if (['equal', 'contains'].includes(c.action)) {
const field = this.fieldsUuidIndex[c.field_uuid]
const option = field.options.find((o) => o.uuid === c.value)
const values = [this.values[c.field_uuid]].flat()
return acc && values.includes(this.optionValue(option, field.options.indexOf(option)))
} else if (['not_equal', 'does_not_contain'].includes(c.action)) {
const field = this.fieldsUuidIndex[c.field_uuid]
const option = field.options.find((o) => o.uuid === c.value)
const values = [this.values[c.field_uuid]].flat()
return acc && !values.includes(this.optionValue(option, field.options.indexOf(option)))
} else {
return acc
}
}, true)
} else {
return true
}
},
optionValue (option, index) {
if (option.value) {
return option.value
} else {
return `${this.t('option')} ${index + 1}`
}
}, },
maybeTrackEmailClick () { maybeTrackEmailClick () {
const { queryParams } = this const { queryParams } = this
@@ -736,6 +954,17 @@ export default {
}) })
}) })
}, },
previousSignatureValueFor (field) {
if (this.reuseSignature !== false) {
const signatureField = [...this.fields].reverse().find((f) =>
f.type === 'signature' && field.preferences?.format === f.preferences?.format && !!this.values[f.uuid]
)
return this.values[signatureField?.uuid]
} else {
return null
}
},
goToStep (step, scrollToArea = false, clickUpload = false) { goToStep (step, scrollToArea = false, clickUpload = false) {
this.currentStep = this.stepFields.indexOf(step) this.currentStep = this.stepFields.indexOf(step)
this.showFillAllRequiredFields = false this.showFillAllRequiredFields = false
@@ -745,9 +974,9 @@ export default {
if (scrollToArea) { if (scrollToArea) {
this.scrollIntoField(step[0]) this.scrollIntoField(step[0])
}
this.$refs.form.querySelector('input[type="date"], input[type="text"], select')?.focus() this.$refs.form.querySelector('input[type="date"], input[type="number"], input[type="text"], select')?.focus()
}
if (clickUpload && !this.values[this.currentField.uuid] && ['file', 'image'].includes(this.currentField.type)) { if (clickUpload && !this.values[this.currentField.uuid] && ['file', 'image'].includes(this.currentField.type)) {
this.$refs.form.querySelector('input[type="file"]')?.click() this.$refs.form.querySelector('input[type="file"]')?.click()
@@ -755,7 +984,8 @@ export default {
}) })
}, },
saveStep (formData) { saveStep (formData) {
const currentFieldUuid = this.currentField.uuid const currentFieldUuids = this.currentStepFields.map((f) => f.uuid)
const currentFieldType = this.currentField.type
if (this.isCompleted) { if (this.isCompleted) {
return Promise.resolve({}) return Promise.resolve({})
@@ -765,7 +995,13 @@ export default {
body: formData || new FormData(this.$refs.form) body: formData || new FormData(this.$refs.form)
}).then((response) => { }).then((response) => {
if (response.status === 200) { if (response.status === 200) {
this.submittedValues[currentFieldUuid] = this.values[currentFieldUuid] currentFieldUuids.forEach((fieldUuid) => {
this.submittedValues[fieldUuid] = this.values[fieldUuid]
if (currentFieldType === 'phone') {
this.phoneVerifiedValues[fieldUuid] = this.values[fieldUuid]
}
})
} }
return response return response
@@ -787,17 +1023,31 @@ export default {
stepPromise().then(async () => { stepPromise().then(async () => {
const emptyRequiredField = this.stepFields.find((fields, index) => { 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] === true ? false : !this.submittedValues[fields[0].uuid]?.length) if (index >= this.currentStep) {
return false
}
return fields.some((f) => {
return f.required && (f.type === 'phone' || !this.allowToSkip) && isEmpty(this.submittedValues[f.uuid])
})
}) })
const formData = new FormData(this.$refs.form) const formData = new FormData(this.$refs.form)
const isLastStep = this.currentStep === this.stepFields.length - 1 const isLastStep = this.currentStep === this.stepFields.length - 1
if (isLastStep && !emptyRequiredField) { if (isLastStep && !emptyRequiredField && !this.dryRun) {
formData.append('completed', 'true') formData.append('completed', 'true')
} }
await this.saveStep(formData).then(async (response) => { let saveStepRequest
if (!isLastStep && this.phoneVerifiedValues[this.currentField.uuid] && this.phoneVerifiedValues[this.currentField.uuid] === this.values[this.currentField.uuid]) {
saveStepRequest = Promise.resolve({})
} else {
saveStepRequest = this.saveStep(formData)
}
await saveStepRequest.then(async (response) => {
if (response.status === 422 || response.status === 500) { if (response.status === 422 || response.status === 500) {
const data = await response.json() const data = await response.json()
@@ -813,7 +1063,11 @@ export default {
const nextStep = (isLastStep && emptyRequiredField) || this.stepFields[this.currentStep + 1] const nextStep = (isLastStep && emptyRequiredField) || this.stepFields[this.currentStep + 1]
if (nextStep) { if (nextStep) {
this.goToStep(nextStep, true) if (this.alwaysMinimize) {
this.isFormVisible = false
}
this.goToStep(nextStep, this.autoscrollFields)
if (emptyRequiredField === nextStep) { if (emptyRequiredField === nextStep) {
this.showFillAllRequiredFields = true this.showFillAllRequiredFields = true
@@ -837,7 +1091,11 @@ export default {
this.isSubmitting = false this.isSubmitting = false
}) })
}).catch(error => { }).catch(error => {
console.log(error) if (error?.message === 'Image too small') {
alert('Signature is too small - please redraw.')
} else {
console.log(error)
}
}).finally(() => { }).finally(() => {
this.isSubmitting = false this.isSubmitting = false
}) })
@@ -0,0 +1,97 @@
<template>
<template
v-for="(field, fieldIndex) in fields"
:key="field.uuid"
>
<template
v-for="(area, areaIndex) in field.areas"
:key="areaIndex"
>
<Teleport
v-if="findPageElementForArea(area)"
:to="findPageElementForArea(area)"
>
<FieldArea
v-if="isMathLoaded"
:model-value="calculateFormula(field)"
:field="field"
:area="area"
:submittable="false"
:field-index="fieldIndex"
/>
</Teleport>
</template>
</template>
</template>
<script>
import FieldArea from './area'
export default {
name: 'FormulaFieldAreas',
components: {
FieldArea
},
props: {
fields: {
type: Array,
required: false,
default: () => []
},
values: {
type: Object,
required: false,
default: () => ({})
}
},
data () {
return {
isMathLoaded: false
}
},
async mounted () {
const {
create,
evaluateDependencies,
addDependencies,
subtractDependencies,
divideDependencies,
multiplyDependencies,
powDependencies,
roundDependencies,
absDependencies,
sinDependencies,
tanDependencies,
cosDependencies
} = await import('mathjs')
this.math = create({
evaluateDependencies,
addDependencies,
subtractDependencies,
divideDependencies,
multiplyDependencies,
powDependencies,
roundDependencies,
absDependencies,
sinDependencies,
tanDependencies,
cosDependencies
})
this.isMathLoaded = true
},
methods: {
findPageElementForArea (area) {
return (this.$root.$el?.parentNode?.getRootNode() || document).getElementById(`page-${area.attachment_uuid}-${area.page}`)
},
calculateFormula (field) {
const transformedFormula = field.preferences.formula.replace(/{{(.*?)}}/g, (match, uuid) => {
return this.values[uuid] || 0.0
})
return this.math.evaluate(transformedFormula.toLowerCase())
}
}
}
</script>
+208 -4
View File
@@ -1,8 +1,12 @@
const en = { const en = {
text: 'Text', text: 'Text',
by_clicking_you_agree_to_the: 'By clicking "{button}", you agree to the',
electronic_signature_disclosure: 'Electronic Signature Disclosure',
esignature_disclosure: 'eSignature Disclosure',
signature: 'Signature', signature: 'Signature',
initials: 'Initials', initials: 'Initials',
date: 'Date', date: 'Date',
number: 'Number',
image: 'Image', image: 'Image',
take_photo: 'Take photo', take_photo: 'Take photo',
file: 'File', file: 'File',
@@ -12,16 +16,18 @@ const en = {
radio: 'Radio', radio: 'Radio',
cells: 'Cells', cells: 'Cells',
stamp: 'Stamp', stamp: 'Stamp',
minimize: 'Minimize',
payment: 'Payment', payment: 'Payment',
phone: 'Phone', phone: 'Phone',
submit_form: 'Submit Form', submit_form: 'Submit Form',
sign_now: 'Sign Now',
type_here_: 'Type here...', type_here_: 'Type here...',
optional: 'optional', optional: 'optional',
option: 'Option', option: 'Option',
appears_on: 'Appears on', appears_on: 'Appears on',
page: 'Page', page: 'Page',
select_your_option: 'Select your option', select_your_option: 'Select your option',
complete_hightlighted_checkboxes_and_click: 'Complete hightlighted checkboxes and click', complete_hightlighted_checkboxes_and_click: 'Complete highlighted checkboxes and click',
submit: 'submit', submit: 'submit',
next: 'next', next: 'next',
click_to_upload: 'Click to upload', click_to_upload: 'Click to upload',
@@ -39,7 +45,7 @@ const en = {
please_check_the_box_to_continue: 'Please check the box to continue', please_check_the_box_to_continue: 'Please check the box to continue',
open_source_documents_software: 'open source documents software', open_source_documents_software: 'open source documents software',
verified_phone_number: 'Verify Phone Number', verified_phone_number: 'Verify Phone Number',
use_international_format: 'Use internatioanl format: +1xxx', use_international_format: 'Use international format: +1xxx',
six_digits_code: '6-digit code', six_digits_code: '6-digit code',
change_phone_number: 'Change phone number', change_phone_number: 'Change phone number',
sending: 'Sending...', sending: 'Sending...',
@@ -62,10 +68,15 @@ const en = {
} }
const es = { const es = {
by_clicking_you_agree_to_the: 'Al hacer clic en "{button}", usted acepta el',
electronic_signature_disclosure: 'Divulgación de Firma Electrónica',
esignature_disclosure: 'Divulgación de eFirma',
minimize: 'Minimizar',
text: 'Texto', text: 'Texto',
signature: 'Firma', signature: 'Firma',
initials: 'Iniciales', initials: 'Iniciales',
date: 'Fecha', date: 'Fecha',
number: 'Número',
image: 'Imagen', image: 'Imagen',
file: 'Archivo', file: 'Archivo',
select: 'Seleccionar', select: 'Seleccionar',
@@ -78,6 +89,7 @@ const es = {
phone: 'Teléfono', phone: 'Teléfono',
take_photo: 'Tomar foto', take_photo: 'Tomar foto',
submit_form: 'Enviar Formulario', submit_form: 'Enviar Formulario',
sign_now: 'Firmar ahora',
type_here_: 'Escribe aquí...', type_here_: 'Escribe aquí...',
optional: 'opcional', optional: 'opcional',
appears_on: 'Aparece en', appears_on: 'Aparece en',
@@ -124,10 +136,15 @@ const es = {
} }
const it = { const it = {
by_clicking_you_agree_to_the: 'Cliccando su "{button}", accetti il',
electronic_signature_disclosure: 'Divulgazione della Firma Elettronica',
esignature_disclosure: 'Divulgazione della eFirma',
minimize: 'Minimizza',
text: 'Testo', text: 'Testo',
signature: 'Firma', signature: 'Firma',
initials: 'Iniziali', initials: 'Iniziali',
date: 'Data', date: 'Data',
number: 'Numero',
image: 'Immagine', image: 'Immagine',
file: 'File', file: 'File',
select: 'Seleziona', select: 'Seleziona',
@@ -139,6 +156,7 @@ const it = {
payment: 'Pagamento', payment: 'Pagamento',
phone: 'Telefono', phone: 'Telefono',
submit_form: 'Invia Modulo', submit_form: 'Invia Modulo',
sign_now: 'Firma ora',
type_here_: 'Digita qui...', type_here_: 'Digita qui...',
optional: 'opzionale', optional: 'opzionale',
appears_on: 'Compare su', appears_on: 'Compare su',
@@ -186,10 +204,15 @@ const it = {
} }
const de = { const de = {
by_clicking_you_agree_to_the: 'Durch Klicken auf "{button}" stimmen Sie zu, dass Sie die',
electronic_signature_disclosure: 'Elektronische Unterschriftenoffenlegung',
esignature_disclosure: 'eSignatur Offenlegung',
minimize: 'Minimieren',
text: 'Text', text: 'Text',
signature: 'Unterschrift', signature: 'Unterschrift',
initials: 'Initialen', initials: 'Initialen',
date: 'Datum', date: 'Datum',
number: 'Nummer',
image: 'Bild', image: 'Bild',
file: 'Datei', file: 'Datei',
select: 'Auswählen', select: 'Auswählen',
@@ -201,6 +224,7 @@ const de = {
payment: 'Zahlung', payment: 'Zahlung',
phone: 'Telefon', phone: 'Telefon',
submit_form: 'Formular absenden', submit_form: 'Formular absenden',
sign_now: 'Jetzt unterschreiben',
type_here_: 'Hier eingeben...', type_here_: 'Hier eingeben...',
optional: 'optional', optional: 'optional',
appears_on: 'Erscheint auf', appears_on: 'Erscheint auf',
@@ -248,10 +272,15 @@ const de = {
} }
const fr = { const fr = {
by_clicking_you_agree_to_the: 'En cliquant sur "{button}", vous acceptez la',
electronic_signature_disclosure: 'Divulgation de Signature Électronique',
esignature_disclosure: 'Divulgation de la eSignature',
minimize: 'Réduire',
text: 'Texte', text: 'Texte',
signature: 'Signature', signature: 'Signature',
initials: 'Initiales', initials: 'Initiales',
date: 'Date', date: 'Date',
number: 'Numéro',
image: 'Image', image: 'Image',
file: 'Fichier', file: 'Fichier',
select: 'Choisir', select: 'Choisir',
@@ -263,6 +292,7 @@ const fr = {
payment: 'Paiement', payment: 'Paiement',
phone: 'Téléphone', phone: 'Téléphone',
submit_form: 'Envoyer le Formulaire', submit_form: 'Envoyer le Formulaire',
sign_now: 'Signer maintenant',
type_here_: 'Tapez ici...', type_here_: 'Tapez ici...',
optional: 'facultatif', optional: 'facultatif',
appears_on: 'Apparaît sur', appears_on: 'Apparaît sur',
@@ -310,10 +340,15 @@ const fr = {
} }
const pl = { const pl = {
by_clicking_you_agree_to_the: 'Klikając na "{button}", zgadzasz się na',
electronic_signature_disclosure: 'Ujawnienie Elektronicznej Sygnatury',
esignature_disclosure: 'Ujawnienie ePodpisu',
minimize: 'Zminimalizuj',
text: 'Tekst', text: 'Tekst',
signature: 'Podpis', signature: 'Podpis',
initials: 'Inicjały', initials: 'Inicjały',
date: 'Data', date: 'Data',
number: 'Numer',
image: 'Obraz', image: 'Obraz',
file: 'Plik', file: 'Plik',
select: 'Wybierz', select: 'Wybierz',
@@ -325,6 +360,7 @@ const pl = {
payment: 'Płatność', payment: 'Płatność',
phone: 'Telefon', phone: 'Telefon',
submit_form: 'Wyślij Formularz', submit_form: 'Wyślij Formularz',
sign_now: 'Podpisz teraz',
type_here_: 'Wpisz tutaj...', type_here_: 'Wpisz tutaj...',
optional: 'opcjonalny', optional: 'opcjonalny',
appears_on: 'Pojawia się na', appears_on: 'Pojawia się na',
@@ -372,10 +408,15 @@ const pl = {
} }
const uk = { const uk = {
by_clicking_you_agree_to_the: 'Натиснувши на "{button}", ви погоджуєтеся з',
electronic_signature_disclosure: 'Розголошення Електронного Підпису',
esignature_disclosure: 'Розголошення еПідпису',
minimize: 'Зменшити',
text: 'Текст', text: 'Текст',
signature: 'Підпис', signature: 'Підпис',
initials: 'Ініціали', initials: 'Ініціали',
date: 'Дата', date: 'Дата',
number: 'Число',
image: 'Зображення', image: 'Зображення',
file: 'Файл', file: 'Файл',
select: 'Вибрати', select: 'Вибрати',
@@ -387,6 +428,7 @@ const uk = {
payment: 'Платіж', payment: 'Платіж',
phone: 'Телефон', phone: 'Телефон',
submit_form: 'Надіслати Форму', submit_form: 'Надіслати Форму',
sign_now: 'Підписати зараз',
type_here_: 'Введіть тут', type_here_: 'Введіть тут',
optional: 'необов’язково', optional: 'необов’язково',
appears_on: "З'являється на", appears_on: "З'являється на",
@@ -434,10 +476,15 @@ const uk = {
} }
const cs = { const cs = {
by_clicking_you_agree_to_the: 'Kliknutím na "{button}" souhlasíte s',
electronic_signature_disclosure: 'Zveřejněním Elektronického Podpisu',
esignature_disclosure: 'Zveřejnění ePodpisu',
minimize: 'Minimalizovat',
text: 'Text', text: 'Text',
signature: 'Podpis', signature: 'Podpis',
initials: 'Iniciály', initials: 'Iniciály',
date: 'Datum', date: 'Datum',
number: 'Číslo',
image: 'Obrázek', image: 'Obrázek',
file: 'Soubor', file: 'Soubor',
select: 'Vybrat', select: 'Vybrat',
@@ -449,6 +496,7 @@ const cs = {
payment: 'Platba', payment: 'Platba',
phone: 'Telefon', phone: 'Telefon',
submit_form: 'Odeslat formulář', submit_form: 'Odeslat formulář',
sign_now: 'Podepsat nyní',
type_here_: 'Zadejte zde', type_here_: 'Zadejte zde',
optional: 'volitelné', optional: 'volitelné',
appears_on: 'Zobrazuje se na', appears_on: 'Zobrazuje se na',
@@ -496,10 +544,15 @@ const cs = {
} }
const pt = { const pt = {
by_clicking_you_agree_to_the: 'Ao clicar em "{button}", você concorda com o',
electronic_signature_disclosure: 'Divulgação de Assinatura Eletrônica',
esignature_disclosure: 'Divulgação da eAssinatura',
minimize: 'Minimizar',
text: 'Texto', text: 'Texto',
signature: 'Assinatura', signature: 'Assinatura',
initials: 'Iniciais', initials: 'Iniciais',
date: 'Data', date: 'Data',
number: 'Número',
image: 'Imagem', image: 'Imagem',
file: 'Arquivo', file: 'Arquivo',
select: 'Selecionar', select: 'Selecionar',
@@ -511,6 +564,7 @@ const pt = {
payment: 'Pagamento', payment: 'Pagamento',
phone: 'Telefone', phone: 'Telefone',
submit_form: 'Enviar Formulário', submit_form: 'Enviar Formulário',
sign_now: 'Assinar agora',
type_here_: 'Digite aqui', type_here_: 'Digite aqui',
optional: 'opcional', optional: 'opcional',
appears_on: 'Aparece em', appears_on: 'Aparece em',
@@ -558,10 +612,15 @@ const pt = {
} }
const he = { const he = {
by_clicking_you_agree_to_the: 'על ידי לחיצה על "{button}", אתה מסכים ל',
electronic_signature_disclosure: 'חשיפת חתימה אלקטרונית',
esignature_disclosure: 'חשיפת ה-eחתימה',
minimize: 'לקטן',
text: 'טקסט', text: 'טקסט',
signature: 'חתימה', signature: 'חתימה',
initials: 'ראשי תיקיות', initials: 'ראשי תיבות',
date: 'תאריך', date: 'תאריך',
number: 'מספר',
image: 'תמונה', image: 'תמונה',
file: 'קובץ', file: 'קובץ',
select: 'בחר', select: 'בחר',
@@ -573,6 +632,7 @@ const he = {
payment: 'תשלום', payment: 'תשלום',
phone: 'טלפון', phone: 'טלפון',
submit_form: 'שלח טופס', submit_form: 'שלח טופס',
sign_now: 'חתום כעת',
type_here_: 'הקלד כאן', type_here_: 'הקלד כאן',
optional: 'אופציונלי', optional: 'אופציונלי',
option: 'אפשרות', option: 'אפשרות',
@@ -621,10 +681,15 @@ const he = {
} }
const nl = { const nl = {
by_clicking_you_agree_to_the: 'Door op "{button}" te klikken, gaat u akkoord met de',
electronic_signature_disclosure: 'Openbaarmaking van Elektronische Handtekening',
esignature_disclosure: 'Openbaarmaking van eHandtekening',
minimize: 'Minimaliseren',
text: 'Tekst', text: 'Tekst',
signature: 'Handtekening', signature: 'Handtekening',
initials: 'Initialen', initials: 'Initialen',
date: 'Datum', date: 'Datum',
number: 'Nummer',
image: 'Afbeelding', image: 'Afbeelding',
take_photo: 'Maak een foto', take_photo: 'Maak een foto',
file: 'Bestand', file: 'Bestand',
@@ -637,6 +702,7 @@ const nl = {
payment: 'Betaling', payment: 'Betaling',
phone: 'Telefoon', phone: 'Telefoon',
submit_form: 'Formulier verzenden', submit_form: 'Formulier verzenden',
sign_now: 'Nu ondertekenen',
type_here_: 'Typ hier...', type_here_: 'Typ hier...',
optional: 'Optioneel', optional: 'Optioneel',
option: 'Optie', option: 'Optie',
@@ -683,6 +749,144 @@ const nl = {
files: 'Bestanden' files: 'Bestanden'
} }
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl } const ar = {
by_clicking_you_agree_to_the: 'بالنقر فوق "{button}"، أنت توافق على',
electronic_signature_disclosure: 'كشف التوقيع الإلكتروني',
esignature_disclosure: 'كشف التوقيع الإلكتروني',
text: 'نص',
signature: 'توقيع',
initials: 'الاختصارات',
date: 'تاريخ',
number: 'رقم',
image: 'صورة',
take_photo: 'التقاط صورة',
file: 'ملف',
select: 'اختيار',
checkbox: 'خانة اختيار',
multiple: 'متعدد',
radio: 'راديو',
cells: 'خلايا',
stamp: 'ختم',
minimize: 'تصغير',
payment: 'الدفع',
phone: 'هاتف',
submit_form: 'إرسال النموذج',
sign_now: 'وقع الآن',
type_here_: 'اكتب هنا',
optional: 'اختياري',
option: 'خيار',
appears_on: 'يظهر على',
page: 'صفحة',
select_your_option: 'اختر خيارك',
complete_hightlighted_checkboxes_and_click: 'أكمل الخانات المميزة وانقر',
submit: 'إرسال',
next: 'التالي',
click_to_upload: 'انقر للتحميل',
or_drag_and_drop_files: 'أو اسحب وأسقط الملفات',
send_copy_via_email: 'إرسال نسخة عبر البريد الإلكتروني',
download: 'تحميل',
clear: 'مسح',
redraw: 'إعادة الرسم',
draw_initials: 'ارسم الاختصارات',
type_signature_here: 'اكتب التوقيع هنا',
type_initial_here: 'اكتب الاختصارات هنا',
form_has_been_completed: 'تم إكمال النموذج!',
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: 'تعيين اليوم',
toggle_multiline_text: 'تبديل النصوص متعددة الأسطر',
draw_signature: 'ارسم التوقيع',
type_initial: 'اكتب الاختصارات',
draw: 'ارسم',
type: 'اكتب',
type_text: 'اكتب نصًا',
email_has_been_sent: 'تم إرسال البريد الإلكتروني',
processing: 'جارٍ المعالجة',
pay_with_strip: 'الدفع بواسطة Stripe',
reupload: 'إعادة التحميل',
upload: 'تحميل',
files: 'الملفات'
}
const ko = {
by_clicking_you_agree_to_the: '"{button}"를 클릭함으로써, 다음에 동의하게 됩니다',
electronic_signature_disclosure: '전자 서명 공개',
esignature_disclosure: '전자 서명 공개',
text: '텍스트',
signature: '서명',
initials: '이니셜',
date: '날짜',
number: '숫자',
image: '이미지',
take_photo: '사진 찍기',
file: '파일',
select: '선택',
checkbox: '체크박스',
multiple: '다중',
radio: '라디오',
cells: '셀',
stamp: '스탬프',
minimize: '최소화',
payment: '결제',
phone: '전화',
submit_form: '양식 제출',
sign_now: '지금 서명',
type_here_: '여기에 입력...',
optional: '선택 사항',
option: '옵션',
appears_on: '다음에 나타납니다',
page: '페이지',
select_your_option: '옵션 선택',
complete_hightlighted_checkboxes_and_click: '강조된 체크박스를 완료하고 클릭',
submit: '제출',
next: '다음',
click_to_upload: '업로드하려면 클릭',
or_drag_and_drop_files: '또는 파일을 드래그하여 놓으십시오',
send_copy_via_email: '이메일로 사본 보내기',
download: '다운로드',
clear: '지우기',
redraw: '다시 그리기',
draw_initials: '이니셜 그리기',
type_signature_here: '여기에 서명 입력',
type_initial_here: '여기에 이니셜 입력',
form_has_been_completed: '양식이 완료되었습니다!',
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: '인증 코드가 SMS로 다시 전송되었습니다',
please_fill_all_required_fields: '필수 필드를 모두 입력하십시오',
set_today: '오늘 날짜로 설정',
toggle_multiline_text: '다중 텍스트 전환',
draw_signature: '서명 그리기',
type_initial: '이니셜 입력',
draw: '그리기',
type: '입력',
type_text: '텍스트 입력',
email_has_been_sent: '이메일이 전송되었습니다',
processing: '처리 중',
pay_with_strip: '스트라이프로 결제',
reupload: '다시 업로드',
upload: '업로드',
files: '파일'
}
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar, ko }
export default i18n export default i18n
+13 -9
View File
@@ -24,13 +24,20 @@
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
> >
</div> </div>
<div> <div
v-if="!modelValue"
>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<FileDropzone <FileDropzone
v-if="!modelValue"
:message="`${t('upload')} ${field.name || t('image')}${field.required ? '' : ` (${t('optional')})`}`" :message="`${t('upload')} ${field.name || t('image')}${field.required ? '' : ` (${t('optional')})`}`"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
:accept="'image/*'" :accept="'image/*'"
:is-direct-upload="isDirectUpload"
@upload="onImageUpload" @upload="onImageUpload"
/> />
</div> </div>
@@ -39,12 +46,14 @@
<script> <script>
import FileDropzone from './dropzone' import FileDropzone from './dropzone'
import { IconReload } from '@tabler/icons-vue' import { IconReload } from '@tabler/icons-vue'
import MarkdownContent from './markdown_content'
export default { export default {
name: 'ImageStep', name: 'ImageStep',
components: { components: {
FileDropzone, FileDropzone,
IconReload IconReload,
MarkdownContent
}, },
inject: ['t'], inject: ['t'],
props: { props: {
@@ -52,11 +61,6 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
isDirectUpload: {
type: Boolean,
required: true,
default: false
},
showFieldNames: { showFieldNames: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -1,14 +1,25 @@
<template> <template>
<div dir="auto"> <div dir="auto">
<div class="flex justify-between items-center w-full mb-2"> <div
class="flex justify-between items-center w-full"
:class="{ 'mb-2': !field.description }"
>
<label <label
class="label text-2xl" class="label text-2xl"
>{{ showFieldNames && field.name ? field.name : t('initials') }}</label> >
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>
{{ showFieldNames && field.name ? field.name : t('initials') }}
</template>
</label>
<div class="space-x-2 flex"> <div class="space-x-2 flex">
<span <span
v-if="isDrawInitials" v-if="isDrawInitials"
class="tooltip" class="tooltip"
:data-tip="t('type_initials')" :data-tip="t('type_initial')"
> >
<a <a
id="type_text_button" id="type_text_button"
@@ -58,7 +69,7 @@
{{ t('clear') }} {{ t('clear') }}
</a> </a>
<a <a
title="Minimize" :title="t('minimize')"
href="#" href="#"
class="py-1.5 inline md:hidden" class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')" @click.prevent="$emit('minimize')"
@@ -70,6 +81,13 @@
</a> </a>
</div> </div>
</div> </div>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" /> <AppearsOn :field="field" />
<input <input
:value="modelValue || computedPreviousValue" :value="modelValue || computedPreviousValue"
@@ -84,7 +102,7 @@
<canvas <canvas
v-show="!modelValue && !computedPreviousValue" v-show="!modelValue && !computedPreviousValue"
ref="canvas" ref="canvas"
class="bg-white border border-base-300 rounded-2xl" class="bg-white border border-base-300 rounded-2xl w-full"
/> />
<input <input
v-if="!isDrawInitials && !modelValue && !computedPreviousValue" v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
@@ -105,6 +123,9 @@ import { cropCanvasAndExportToPNG } from './crop_canvas'
import { IconReload, IconTextSize, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue' import { IconReload, IconTextSize, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import SignaturePad from 'signature_pad' import SignaturePad from 'signature_pad'
import AppearsOn from './appears_on' import AppearsOn from './appears_on'
import MarkdownContent from './markdown_content'
const scale = 3
export default { export default {
name: 'InitialsStep', name: 'InitialsStep',
@@ -113,6 +134,7 @@ export default {
IconReload, IconReload,
IconTextSize, IconTextSize,
IconSignature, IconSignature,
MarkdownContent,
IconArrowsDiagonalMinimize2 IconArrowsDiagonalMinimize2
}, },
inject: ['baseUrl', 't'], inject: ['baseUrl', 't'],
@@ -130,11 +152,6 @@ export default {
required: false, required: false,
default: true default: true
}, },
isDirectUpload: {
type: Boolean,
required: true,
default: false
},
attachmentsIndex: { attachmentsIndex: {
type: Object, type: Object,
required: false, required: false,
@@ -171,17 +188,13 @@ export default {
async mounted () { async mounted () {
this.$nextTick(() => { this.$nextTick(() => {
if (this.$refs.canvas) { if (this.$refs.canvas) {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 5 this.$refs.canvas.height = (this.$refs.canvas.parentNode.clientWidth / 4.5) * scale
this.$refs.canvas.getContext('2d').scale(scale, scale)
} }
this.$refs.textInput?.focus()
}) })
if (this.isDirectUpload) {
import('@rails/activestorage')
}
if (this.$refs.canvas) { if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas) this.pad = new SignaturePad(this.$refs.canvas)
@@ -190,8 +203,26 @@ export default {
this.$emit('start') this.$emit('start')
}) })
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
this.$refs.canvas.height = (this.$refs.canvas.parentNode.clientWidth / 4.5) * scale
this.$refs.canvas.getContext('2d').scale(scale, scale)
this.intersectionObserver?.disconnect()
}
})
})
this.intersectionObserver.observe(this.$refs.canvas)
} }
}, },
beforeUnmount () {
this.intersectionObserver?.disconnect()
},
methods: { methods: {
remove () { remove () {
this.$emit('update:model-value', '') this.$emit('update:model-value', '')
@@ -209,7 +240,7 @@ export default {
} }
}, },
updateWrittenInitials (e) { updateWrittenInitials (e) {
this.isInitialsStarted = true this.isInitialsStarted = !!e.target.value
const canvas = this.$refs.canvas const canvas = this.$refs.canvas
const context = canvas.getContext('2d') const context = canvas.getContext('2d')
@@ -222,8 +253,8 @@ export default {
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily
context.textAlign = 'center' context.textAlign = 'center'
context.clearRect(0, 0, canvas.width, canvas.height) context.clearRect(0, 0, canvas.width / scale, canvas.height / scale)
context.fillText(e.target.value, canvas.width / 2, canvas.height / 2 + 11) context.fillText(e.target.value, canvas.width / 2 / scale, canvas.height / 2 / scale + 11)
}, },
toggleTextInput () { toggleTextInput () {
this.remove() this.remove()
@@ -251,45 +282,21 @@ export default {
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => { cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
const file = new File([blob], 'initials.png', { type: 'image/png' }) const file = new File([blob], 'initials.png', { type: 'image/png' })
if (this.isDirectUpload) { const formData = new FormData()
const { DirectUpload } = await import('@rails/activestorage')
new DirectUpload( formData.append('file', file)
file, formData.append('submitter_slug', this.submitterSlug)
'/direct_uploads' formData.append('name', 'attachments')
).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) return fetch(this.baseUrl + '/api/attachments', {
}) method: 'POST',
}) body: formData
} else { }).then((resp) => resp.json()).then((attachment) => {
const formData = new FormData() this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
formData.append('file', file) return resolve(attachment)
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)
})
}
}) })
}) })
} }
@@ -0,0 +1,75 @@
<template>
<span>
<template
v-for="(item, index) in items"
:key="index"
>
<a
v-if="item.startsWith('<a') && item.endsWith('</a>')"
:href="sanitizeHref(extractAttr(item, 'href'))"
rel="noopener noreferrer nofollow"
:class="extractAttr(item, 'class') || 'link'"
target="_blank"
>
{{ extractText(item) }}
</a>
<b
v-else-if="item.startsWith('<b>') || item.startsWith('<strong>')"
>
{{ extractText(item) }}
</b>
<i
v-else-if="item.startsWith('<i>') || item.startsWith('<em>')"
>
{{ extractText(item) }}
</i>
<br
v-else-if="item === '<br>' || item === '\n'"
>
<template
v-else
>
{{ item }}
</template>
</template>
</span>
</template>
<script>
import snarkdown from 'snarkdown'
const htmlSplitRegexp = /(<a.+?<\/a>|<i>.+?<\/i>|<b>.+?<\/b>|<em>.+?<\/em>|<strong>.+?<\/strong>|<br>)/
export default {
name: 'MarkdownContent',
props: {
string: {
type: String,
required: false,
default: ''
}
},
computed: {
items () {
return snarkdown(this.string.replace(/\n/g, '<br>')).split(htmlSplitRegexp)
}
},
methods: {
sanitizeHref (href) {
if (href && href.trim().match(/^((?:https?:\/\/)|\/)/)) {
return href.replace(/javascript:/g, '')
}
},
extractAttr (text, attr) {
if (text.includes(attr)) {
return text.split(attr).pop().split('"')[1]
}
},
extractText (text) {
if (text) {
return text.match(/>(.+?)</)?.[1]
}
}
}
}
</script>
@@ -1,10 +1,23 @@
<template> <template>
<label <label
v-if="showFieldNames && field.name" v-if="showFieldNames && (field.name || field.title)"
:for="field.uuid" :for="field.uuid"
dir="auto" dir="auto"
class="label text-2xl mb-2" class="label text-2xl"
>{{ field.name }}</label> :class="{ 'mb-2': !field.description }"
><MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>{{ field.name }}</template>
</label>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<div class="flex w-full max-h-44 overflow-y-auto"> <div class="flex w-full max-h-44 overflow-y-auto">
<input <input
v-if="modelValue.length === 0" v-if="modelValue.length === 0"
@@ -32,6 +45,7 @@
<label <label
:for="option.uuid" :for="option.uuid"
class="flex items-center space-x-3" class="flex items-center space-x-3"
@click="scrollIntoField(field)"
> >
<input <input
:id="option.uuid" :id="option.uuid"
@@ -53,8 +67,13 @@
</template> </template>
<script> <script>
import MarkdownContent from './markdown_content'
export default { export default {
name: 'MultiSelectStep', name: 'MultiSelectStep',
components: {
MarkdownContent
},
inject: ['t', 'scrollIntoField'], inject: ['t', 'scrollIntoField'],
props: { props: {
field: { field: {
@@ -0,0 +1,86 @@
<template>
<label
v-if="showFieldNames && (field.name || field.title)"
:for="field.uuid"
dir="auto"
class="label text-2xl"
:class="{ 'mb-2': !field.description }"
><MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>{{ field.name }}</template>
<template v-if="!field.required">({{ t('optional') }})</template>
</label>
<div
v-else
class="py-1"
/>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" />
<div class="items-center flex">
<input
type="hidden"
name="cast_number"
value="true"
>
<input
:id="field.uuid"
v-model="number"
type="number"
class="base-input !text-2xl w-full"
step="any"
:required="field.required"
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
:name="`values[${field.uuid}]`"
@focus="$emit('focus')"
>
</div>
</template>
<script>
import AppearsOn from './appears_on'
import MarkdownContent from './markdown_content'
export default {
name: 'TextStep',
components: {
AppearsOn,
MarkdownContent
},
inject: ['t'],
props: {
field: {
type: Object,
required: true
},
showFieldNames: {
type: Boolean,
required: false,
default: true
},
modelValue: {
type: [String, Number],
required: false,
default: ''
}
},
emits: ['update:model-value', 'focus'],
computed: {
number: {
set (value) {
this.$emit('update:model-value', value)
},
get () {
return this.modelValue
}
}
}
}
</script>
+40 -6
View File
@@ -2,10 +2,25 @@
<div> <div>
<label <label
:for="isCodeSent ? 'one_time_code' : field.uuid" :for="isCodeSent ? 'one_time_code' : field.uuid"
class="label text-2xl mb-2" class="label text-2xl"
>{{ showFieldNames && field.name ? field.name : t('verified_phone_number') }} :class="{ 'mb-2': !field.description }"
<template v-if="!field.required">({{ t('optional') }})</template> >
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>
{{ showFieldNames && field.name ? field.name : t('verified_phone_number') }}
<template v-if="!field.required">({{ t('optional') }})</template>
</template>
</label> </label>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<div> <div>
<input <input
type="hidden" type="hidden"
@@ -53,14 +68,13 @@
class="base-input !text-2xl w-full" class="base-input !text-2xl w-full"
autocomplete="tel" autocomplete="tel"
pattern="^\+[0-9\s\-]+$" pattern="^\+[0-9\s\-]+$"
:oninvalid="`this.value ? this.setCustomValidity('${t('use_international_format')}...') : ''`"
oninput="this.setCustomValidity('')"
type="tel" type="tel"
inputmode="tel" inputmode="tel"
:required="field.required" :required="field.required"
placeholder="+1 234 567-8900" placeholder="+1 234 567-8900"
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
@input="$emit('update:model-value', $event.target.value)" @invalid="$event.target.value ? $event.target.setCustomValidity(`${t('use_international_format')}...`) : ''"
@input="[$event.target.setCustomValidity(''), $emit('update:model-value', $event.target.value)]"
@focus="$emit('focus')" @focus="$emit('focus')"
> >
</div> </div>
@@ -68,6 +82,8 @@
</template> </template>
<script> <script>
import MarkdownContent from './markdown_content'
function throttle (func, delay) { function throttle (func, delay) {
let lastCallTime = 0 let lastCallTime = 0
@@ -83,16 +99,29 @@ function throttle (func, delay) {
export default { export default {
name: 'PhoneStep', name: 'PhoneStep',
components: {
MarkdownContent
},
inject: ['t', 'baseUrl'], inject: ['t', 'baseUrl'],
props: { props: {
field: { field: {
type: Object, type: Object,
required: true required: true
}, },
verifiedValue: {
type: String,
required: false,
default: ''
},
submitterSlug: { submitterSlug: {
type: String, type: String,
required: true required: true
}, },
locale: {
type: String,
required: false,
default: 'en'
},
showFieldNames: { showFieldNames: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -139,12 +168,17 @@ export default {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
submitter_slug: this.submitterSlug, submitter_slug: this.submitterSlug,
locale: this.locale,
phone: this.$refs.phone.value phone: this.$refs.phone.value
}), }),
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}) })
}, },
async submit () { async submit () {
if (this.verifiedValue && this.verifiedValue === this.modelValue) {
return Promise.resolve({})
}
if (!this.$refs.phone.value.toString().startsWith('+')) { if (!this.$refs.phone.value.toString().startsWith('+')) {
alert(this.t('use_international_format')) alert(this.t('use_international_format'))
+120 -70
View File
@@ -1,12 +1,26 @@
<template> <template>
<div dir="auto"> <div
<div class="flex justify-between items-center w-full mb-2"> dir="auto"
class="relative"
>
<div
class="flex justify-between items-center w-full"
:class="{ 'mb-2': !field.description }"
>
<label <label
class="label text-2xl" class="label text-2xl"
>{{ showFieldNames && field.name ? field.name : t('signature') }}</label> >
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>
{{ showFieldNames && field.name ? field.name : t('signature') }}
</template>
</label>
<div class="space-x-2 flex"> <div class="space-x-2 flex">
<span <span
v-if="isTextSignature" v-if="isTextSignature && field.preferences?.format !== 'typed'"
class="tooltip" class="tooltip"
:data-tip="t('draw_signature')" :data-tip="t('draw_signature')"
> >
@@ -23,14 +37,14 @@
</a> </a>
</span> </span>
<span <span
v-else-if="withTypedSignature" v-else-if="withTypedSignature && field.preferences?.format !== 'typed' && field.preferences?.format !== 'drawn'"
class="tooltip ml-2" class="tooltip ml-2"
:data-tip="t('type_text')" :data-tip="t('type_text')"
> >
<a <a
id="type_text_button" id="type_text_button"
href="#" href="#"
class="btn btn-outline btn-sm font-medium" class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap"
@click.prevent="toggleTextInput" @click.prevent="toggleTextInput"
> >
<IconTextSize :width="16" /> <IconTextSize :width="16" />
@@ -40,14 +54,16 @@
</a> </a>
</span> </span>
<span <span
v-if="field.preferences?.format !== 'typed'"
class="tooltip" class="tooltip"
:data-tip="t('take_photo')" :data-tip="t('take_photo')"
> >
<label <label
class="btn btn-outline btn-sm font-medium" class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap"
> >
<IconCamera :width="16" /> <IconCamera :width="16" />
<input <input
:key="uploadImageInputKey"
type="file" type="file"
hidden hidden
accept="image/*" accept="image/*"
@@ -78,7 +94,7 @@
</a> </a>
<a <a
href="#" href="#"
title="Minimize" :title="t('minimize')"
class="py-1.5 inline md:hidden" class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')" @click.prevent="$emit('minimize')"
> >
@@ -89,6 +105,13 @@
</a> </a>
</div> </div>
</div> </div>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" /> <AppearsOn :field="field" />
<input <input
:value="modelValue || computedPreviousValue" :value="modelValue || computedPreviousValue"
@@ -104,18 +127,35 @@
v-show="!modelValue && !computedPreviousValue" v-show="!modelValue && !computedPreviousValue"
ref="canvas" ref="canvas"
style="padding: 1px; 0" style="padding: 1px; 0"
class="bg-white border border-base-300 rounded-2xl" class="bg-white border border-base-300 rounded-2xl w-full"
/> />
<input <input
v-if="isTextSignature" v-if="isTextSignature"
id="signature_text_input" id="signature_text_input"
ref="textInput" ref="textInput"
class="base-input !text-2xl w-full mt-6" class="base-input !text-2xl w-full mt-6"
:required="field.required" :required="field.required && !isSignatureStarted"
:placeholder="`${t('type_signature_here')}...`" :placeholder="`${t('type_signature_here')}...`"
type="text" type="text"
@input="updateWrittenSignature" @input="updateWrittenSignature"
> >
<div
v-if="withDisclosure"
dir="auto"
class="text-base-content/60 text-xs text-center w-full mt-1"
>
{{ t('by_clicking_you_agree_to_the').replace('{button}', buttonText.charAt(0).toUpperCase() + buttonText.slice(1)) }} <a
href="https://www.docuseal.co/esign-disclosure"
target="_blank"
>
<span class="inline md:hidden">
{{ t('esignature_disclosure') }}
</span>
<span class="hidden md:inline">
{{ t('electronic_signature_disclosure') }}
</span>
</a>
</div>
</div> </div>
</template> </template>
@@ -124,15 +164,19 @@ import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonal
import { cropCanvasAndExportToPNG } from './crop_canvas' import { cropCanvasAndExportToPNG } from './crop_canvas'
import SignaturePad from 'signature_pad' import SignaturePad from 'signature_pad'
import AppearsOn from './appears_on' import AppearsOn from './appears_on'
import MarkdownContent from './markdown_content'
let isFontLoaded = false let isFontLoaded = false
const scale = 3
export default { export default {
name: 'SignatureStep', name: 'SignatureStep',
components: { components: {
AppearsOn, AppearsOn,
IconReload, IconReload,
IconCamera, IconCamera,
MarkdownContent,
IconTextSize, IconTextSize,
IconSignature, IconSignature,
IconArrowsDiagonalMinimize2 IconArrowsDiagonalMinimize2
@@ -152,11 +196,16 @@ export default {
required: false, required: false,
default: true default: true
}, },
isDirectUpload: { withDisclosure: {
type: Boolean, type: Boolean,
required: true, required: false,
default: false default: false
}, },
buttonText: {
type: String,
required: false,
default: 'Submit'
},
withTypedSignature: { withTypedSignature: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -183,7 +232,8 @@ export default {
return { return {
isSignatureStarted: !!this.previousValue, isSignatureStarted: !!this.previousValue,
isUsePreviousValue: true, isUsePreviousValue: true,
isTextSignature: false isTextSignature: this.field.preferences?.format === 'typed',
uploadImageInputKey: Math.random().toString()
} }
}, },
computed: { computed: {
@@ -198,15 +248,13 @@ export default {
async mounted () { async mounted () {
this.$nextTick(() => { this.$nextTick(() => {
if (this.$refs.canvas) { if (this.$refs.canvas) {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 3 this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3
this.$refs.canvas.getContext('2d').scale(scale, scale)
} }
}) })
if (this.isDirectUpload) {
import('@rails/activestorage')
}
if (this.$refs.canvas) { if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas) this.pad = new SignaturePad(this.$refs.canvas)
@@ -215,8 +263,26 @@ export default {
this.$emit('start') this.$emit('start')
}) })
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3
this.$refs.canvas.getContext('2d').scale(scale, scale)
this.intersectionObserver?.disconnect()
}
})
})
this.intersectionObserver.observe(this.$refs.canvas)
} }
}, },
beforeUnmount () {
this.intersectionObserver?.disconnect()
},
methods: { methods: {
remove () { remove () {
this.$emit('update:model-value', '') this.$emit('update:model-value', '')
@@ -247,7 +313,7 @@ export default {
} }
}, },
updateWrittenSignature (e) { updateWrittenSignature (e) {
this.isSignatureStarted = true this.isSignatureStarted = !!e.target.value
const canvas = this.$refs.canvas const canvas = this.$refs.canvas
const context = canvas.getContext('2d') const context = canvas.getContext('2d')
@@ -260,8 +326,8 @@ export default {
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily
context.textAlign = 'center' context.textAlign = 'center'
context.clearRect(0, 0, canvas.width, canvas.height) context.clearRect(0, 0, canvas.width / scale, canvas.height / scale)
context.fillText(e.target.value, canvas.width / 2, canvas.height / 2 + 11) context.fillText(e.target.value, canvas.width / 2 / scale, canvas.height / 2 / scale + 11)
}, },
toggleTextInput () { toggleTextInput () {
this.remove() this.remove()
@@ -297,14 +363,16 @@ export default {
const context = canvas.getContext('2d') const context = canvas.getContext('2d')
const aspectRatio = img.width / img.height const aspectRatio = img.width / img.height
const canvasWidth = canvas.width / scale
const canvasHeight = canvas.height / scale
let targetWidth = canvas.width let targetWidth = canvasWidth
let targetHeight = canvas.height let targetHeight = canvasHeight
if (canvas.width / canvas.height > aspectRatio) { if (canvasWidth / canvasHeight > aspectRatio) {
targetWidth = canvas.height * aspectRatio targetWidth = canvasHeight * aspectRatio
} else { } else {
targetHeight = canvas.width / aspectRatio targetHeight = canvasWidth / aspectRatio
} }
if (targetHeight > targetWidth) { if (targetHeight > targetWidth) {
@@ -313,17 +381,21 @@ export default {
targetHeight = targetHeight * scale targetHeight = targetHeight * scale
} }
const x = (canvas.width - targetWidth) / 2 const x = (canvasWidth - targetWidth) / 2
const y = (canvas.height - targetHeight) / 2 const y = (canvasHeight - targetHeight) / 2
context.clearRect(0, 0, canvas.width, canvas.height) setTimeout(() => {
context.drawImage(img, x, y, targetWidth, targetHeight) context.clearRect(0, 0, canvasWidth, canvasHeight)
context.drawImage(img, x, y, targetWidth, targetHeight)
this.$emit('start') this.$emit('start')
}, 50)
} }
} }
reader.readAsDataURL(file) reader.readAsDataURL(file)
this.uploadImageInputKey = Math.random().toString()
} }
}, },
async submit () { async submit () {
@@ -335,49 +407,27 @@ export default {
return Promise.resolve({}) return Promise.resolve({})
} }
return new Promise((resolve) => { return new Promise((resolve, reject) => {
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => { cropCanvasAndExportToPNG(this.$refs.canvas, { errorOnTooSmall: true }).then(async (blob) => {
const file = new File([blob], 'signature.png', { type: 'image/png' }) const file = new File([blob], 'signature.png', { type: 'image/png' })
if (this.isDirectUpload) { const formData = new FormData()
const { DirectUpload } = await import('@rails/activestorage')
new DirectUpload( formData.append('file', file)
file, formData.append('submitter_slug', this.submitterSlug)
'/direct_uploads' formData.append('name', 'attachments')
).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) return fetch(this.baseUrl + '/api/attachments', {
}) method: 'POST',
}) body: formData
} else { }).then((resp) => resp.json()).then((attachment) => {
const formData = new FormData() this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
formData.append('file', file) return resolve(attachment)
formData.append('submitter_slug', this.submitterSlug) })
formData.append('name', 'attachments') }).catch((error) => {
return reject(error)
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)
})
}
}) })
}) })
} }
+21 -12
View File
@@ -1,11 +1,14 @@
<template> <template>
<label <label
v-if="showFieldNames && field.name" v-if="showFieldNames && (field.name || field.title)"
:for="field.uuid" :for="field.uuid"
dir="auto" dir="auto"
class="label text-2xl" class="label text-2xl"
:class="{ 'mb-2': !field.description }" :class="{ 'mb-2': !field.description }"
><template v-if="field.title"><span v-html="field.title" /></template> ><MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>{{ field.name }}</template> <template v-else>{{ field.name }}</template>
<template v-if="!field.required">({{ t('optional') }})</template> <template v-if="!field.required">({{ t('optional') }})</template>
</label> </label>
@@ -15,9 +18,11 @@
/> />
<div <div
v-if="field.description" v-if="field.description"
class="mb-3 px-1 text-lg" dir="auto"
v-html="field.description" class="mb-3 px-1"
/> >
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" /> <AppearsOn :field="field" />
<div class="items-center flex"> <div class="items-center flex">
<input <input
@@ -30,11 +35,11 @@
:class="{ '!pr-11 -mr-10': !field.validation?.pattern }" :class="{ '!pr-11 -mr-10': !field.validation?.pattern }"
:required="field.required" :required="field.required"
:pattern="field.validation?.pattern" :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')})`}`" :placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
type="text" type="text"
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
@invalid="field.validation?.message ? $event.target.setCustomValidity(field.validation.message) : ''"
@input="field.validation?.message ? $event.target.setCustomValidity('') : ''"
@focus="$emit('focus')" @focus="$emit('focus')"
> >
<textarea <textarea
@@ -69,11 +74,13 @@
<script> <script>
import { IconAlignBoxLeftTop } from '@tabler/icons-vue' import { IconAlignBoxLeftTop } from '@tabler/icons-vue'
import AppearsOn from './appears_on' import AppearsOn from './appears_on'
import MarkdownContent from './markdown_content'
export default { export default {
name: 'TextStep', name: 'TextStep',
components: { components: {
IconAlignBoxLeftTop, IconAlignBoxLeftTop,
MarkdownContent,
AppearsOn AppearsOn
}, },
inject: ['t'], inject: ['t'],
@@ -123,12 +130,14 @@ export default {
} }
}, },
mounted () { mounted () {
this.isTextArea = this.modelValue?.includes('\n') if (this.modelValue) {
this.isTextArea = this.modelValue.toString().includes('\n')
if (this.isTextArea) { if (this.isTextArea) {
this.$nextTick(() => { this.$nextTick(() => {
this.resizeTextarea() this.resizeTextarea()
}) })
}
} }
}, },
methods: { methods: {
+184 -23
View File
@@ -42,7 +42,7 @@
v-model="field.submitter_uuid" v-model="field.submitter_uuid"
class="border-r" class="border-r"
:compact="true" :compact="true"
:editable="editable && !defaultField" :editable="editable && (!defaultField || defaultField.role !== submitter?.name)"
:allow-add-new="!defaultSubmitters.length" :allow-add-new="!defaultSubmitters.length"
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'" :menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'"
:submitters="template.submitters" :submitters="template.submitters"
@@ -68,9 +68,9 @@
@keydown.enter.prevent="onNameEnter" @keydown.enter.prevent="onNameEnter"
@focus="onNameFocus" @focus="onNameFocus"
@blur="onNameBlur" @blur="onNameBlur"
>{{ optionIndexText }} {{ field.name || defaultName }}</span> >{{ optionIndexText }} {{ (defaultField ? (field.title || field.name) : field.name) || defaultName }}</span>
<div <div
v-if="isNameFocus && !['checkbox', 'phone'].includes(field.type)" v-if="isSettingsFocus || (isNameFocus && !['checkbox', 'phone'].includes(field.type))"
class="flex items-center ml-1.5" class="flex items-center ml-1.5"
> >
<input <input
@@ -86,11 +86,52 @@
@click.prevent="field.required = !field.required" @click.prevent="field.required = !field.required"
@mousedown.prevent @mousedown.prevent
>{{ t('required') }}</label> >{{ t('required') }}</label>
<span
v-if="field.type !== 'payment'"
class="dropdown dropdown-end"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
ref="settingsButton"
tabindex="0"
:title="t('settings')"
class="cursor-pointer flex items-center"
style="height: 25px"
@focus="isSettingsFocus = true"
@blur="maybeBlurSettings"
>
<IconDotsVertical class="w-5 h-5" />
</label>
<ul
v-if="renderDropdown"
ref="settingsDropdown"
tabindex="0"
class="dropdown-content menu menu-xs px-2 pb-2 pt-1 shadow rounded-box w-52 z-10 rounded-t-none"
:style="{ backgroundColor: 'white' }"
@dragstart.prevent.stop
@click="closeDropdown"
@focusout="maybeBlurSettings"
>
<FieldSettings
:field="field"
:default-field="defaultField"
:editable="editable"
:background-color="'white'"
:with-required="false"
:with-areas="false"
@click-formula="isShowFormulaModal = true"
@click-description="isShowDescriptionModal = true"
@click-condition="isShowConditionsModal = true"
@scroll-to="[selectedAreaRef.value = $event, $emit('scroll-to', $event)]"
/>
</ul>
</span>
</div> </div>
<button <button
v-else-if="editable" v-else-if="editable"
class="pr-1" class="pr-1"
title="Remove" :title="t('remove')"
@click.prevent="$emit('remove')" @click.prevent="$emit('remove')"
> >
<IconX width="14" /> <IconX width="14" />
@@ -98,21 +139,36 @@
</div> </div>
<div <div
class="flex items-center h-full w-full" class="flex items-center h-full w-full"
:class="[bgColors[submitterIndex], field?.default_value ? '' : 'justify-center']" dir="auto"
:class="[bgColors[submitterIndex], field?.default_value ? (alignClasses[field.preferences?.align] || '') : 'justify-center']"
> >
<span <span
v-if="field" v-if="field"
class="flex justify-center items-center space-x-1 h-full" class="flex justify-center items-center space-x-1 h-full"
:class="{'w-full h-full': field.type == 'checkbox'}"
> >
<div <div
v-if="field?.default_value" v-if="field?.default_value"
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars }" :class="{ 'w-full h-full': field.type == 'checkbox', 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars }"
> >
<div <div
ref="textContainer" ref="textContainer"
class="flex items-center px-0.5" class="flex items-center px-0.5"
:class="{'w-full h-full': field.type == 'checkbox'}"
> >
<span class="whitespace-pre-wrap">{{ field.default_value }}</span> <IconCheck
v-if="field.type == 'checkbox'"
class="aspect-square mx-auto"
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
/>
<span
v-else-if="field.type === 'number'"
class="whitespace-pre-wrap"
>{{ formatNumber(field.default_value, field.preferences?.format) }}</span>
<span
v-else
class="whitespace-pre-wrap"
>{{ field.default_value }}</span>
</div> </div>
</div> </div>
<component <component
@@ -134,6 +190,38 @@
@mousedown.stop="startResize" @mousedown.stop="startResize"
@touchstart="startTouchResize" @touchstart="startTouchResize"
/> />
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:editable="editable && !defaultField"
:build-default-name="buildDefaultName"
@close="isShowFormulaModal = false"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:field="field"
:build-default-name="buildDefaultName"
@close="isShowConditionsModal = false"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:editable="editable && !defaultField"
:build-default-name="buildDefaultName"
@close="isShowDescriptionModal = false"
/>
</Teleport>
</div> </div>
</template> </template>
@@ -141,13 +229,23 @@
import FieldSubmitter from './field_submitter' import FieldSubmitter from './field_submitter'
import FieldType from './field_type' import FieldType from './field_type'
import Field from './field' import Field from './field'
import { IconX } from '@tabler/icons-vue' import FieldSettings from './field_settings'
import FormulaModal from './formula_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import { IconX, IconCheck, IconDotsVertical } from '@tabler/icons-vue'
import { v4 } from 'uuid' import { v4 } from 'uuid'
export default { export default {
name: 'FieldArea', name: 'FieldArea',
components: { components: {
FieldType, FieldType,
IconCheck,
FieldSettings,
FormulaModal,
IconDotsVertical,
DescriptionModal,
ConditionsModal,
FieldSubmitter, FieldSubmitter,
IconX IconX
}, },
@@ -183,20 +281,37 @@ export default {
default: null default: null
} }
}, },
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove'], emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to'],
data () { data () {
return { return {
isShowFormulaModal: false,
isShowConditionsModal: false,
isSettingsFocus: false,
isShowDescriptionModal: false,
isResize: false, isResize: false,
isDragged: false, isDragged: false,
renderDropdown: false,
isNameFocus: false, isNameFocus: false,
textOverflowChars: 0, textOverflowChars: 0,
dragFrom: { x: 0, y: 0 } dragFrom: { x: 0, y: 0 }
} }
}, },
computed: { computed: {
defaultName: Field.computed.defaultName,
fieldNames: FieldType.computed.fieldNames, fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons, fieldIcons: FieldType.computed.fieldIcons,
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
defaultName () {
return this.buildDefaultName(this.field, this.template.fields)
},
alignClasses () {
return {
center: 'justify-center',
left: 'justify-start',
right: 'justify-end'
}
},
optionIndexText () { optionIndexText () {
if (this.area.option_uuid && this.field.options) { if (this.area.option_uuid && this.field.options) {
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.` return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
@@ -225,6 +340,16 @@ export default {
}, },
borderColors () { borderColors () {
return [ return [
'border-red-500/80',
'border-sky-500/80',
'border-emerald-500/80',
'border-yellow-300/80',
'border-purple-600/80',
'border-pink-500/80',
'border-cyan-500/80',
'border-orange-500/80',
'border-lime-500/80',
'border-indigo-500/80',
'border-red-500/80', 'border-red-500/80',
'border-sky-500/80', 'border-sky-500/80',
'border-emerald-500/80', 'border-emerald-500/80',
@@ -239,6 +364,16 @@ export default {
}, },
bgColors () { bgColors () {
return [ return [
'bg-red-100/80',
'bg-sky-100/80',
'bg-emerald-100/80',
'bg-yellow-100/80',
'bg-purple-100/80',
'bg-pink-100/80',
'bg-cyan-100/80',
'bg-orange-100/80',
'bg-lime-100/80',
'bg-indigo-100/80',
'bg-red-100/80', 'bg-red-100/80',
'bg-sky-100/80', 'bg-sky-100/80',
'bg-emerald-100/80', 'bg-emerald-100/80',
@@ -267,19 +402,41 @@ export default {
}, },
watch: { watch: {
'field.default_value' () { 'field.default_value' () {
if (this.field.type === 'text' && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.field.default_value.length)) {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? this.field.default_value.length : 0
}
}
},
mounted () {
if (this.field.type === 'text' && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.field.default_value)) {
this.$nextTick(() => { this.$nextTick(() => {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? this.field.default_value.length : 0 if (['date', 'text', 'number'].includes(this.field.type) && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.field.default_value}`.length)) {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0
}
}) })
} }
}, },
mounted () {
this.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.field.default_value}`.length)) {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0
}
})
},
methods: { methods: {
buildDefaultName: Field.methods.buildDefaultName,
closeDropdown () {
document.activeElement.blur()
},
formatNumber (number, format) {
if (format === 'comma') {
return new Intl.NumberFormat('en-US').format(number)
} else if (format === 'dot') {
return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number)
} else {
return number
}
},
maybeBlurSettings (e) {
if (!e.relatedTarget || !this.$refs.settingsDropdown.contains(e.relatedTarget)) {
this.isSettingsFocus = false
}
},
onNameFocus (e) { onNameFocus (e) {
this.selectedAreaRef.value = this.area this.selectedAreaRef.value = this.area
@@ -308,7 +465,7 @@ export default {
}, },
onResizeCell (e) { onResizeCell (e) {
if (e.target.id === 'mask') { if (e.target.id === 'mask') {
const positionX = e.layerX / (e.target.clientWidth - 1) const positionX = e.offsetX / (e.target.clientWidth - 1)
if (positionX > this.area.x) { if (positionX > this.area.x) {
this.area.cell_w = positionX - this.area.x this.area.cell_w = positionX - this.area.x
@@ -335,6 +492,10 @@ export default {
}) })
}, },
onNameBlur (e) { onNameBlur (e) {
if (e.relatedTarget === this.$refs.settingsButton) {
this.isSettingsFocus = true
}
const text = this.$refs.name.innerText.trim() const text = this.$refs.name.innerText.trim()
this.isNameFocus = false this.isNameFocus = false
@@ -354,16 +515,16 @@ export default {
}, },
resize (e) { resize (e) {
if (e.target.id === 'mask') { if (e.target.id === 'mask') {
this.area.w = e.layerX / e.target.clientWidth - this.area.x this.area.w = e.offsetX / e.target.clientWidth - this.area.x
this.area.h = e.layerY / e.target.clientHeight - this.area.y this.area.h = e.offsetY / e.target.clientHeight - this.area.y
} }
}, },
drag (e) { drag (e) {
if (e.target.id === 'mask') { if (e.target.id === 'mask') {
this.isDragged = true this.isDragged = true
this.area.x = (e.layerX - this.dragFrom.x) / e.target.clientWidth this.area.x = (e.offsetX - this.dragFrom.x) / e.target.clientWidth
this.area.y = (e.layerY - this.dragFrom.y) / e.target.clientHeight this.area.y = (e.offsetY - this.dragFrom.y) / e.target.clientHeight
} }
}, },
startDrag (e) { startDrag (e) {
+277 -85
View File
@@ -26,7 +26,7 @@
@update:model-value="updateName" @update:model-value="updateName"
/> />
</div> </div>
<div class="space-x-3 flex items-center"> <div class="space-x-3 flex items-center flex-shrink-0">
<slot <slot
v-if="$slots.buttons" v-if="$slots.buttons"
name="buttons" name="buttons"
@@ -37,10 +37,10 @@
class="btn btn-primary btn-ghost text-base hidden md:flex" class="btn btn-primary btn-ghost text-base hidden md:flex"
:target="template.submitters.length > 1 ? '' : '_blank'" :target="template.submitters.length > 1 ? '' : '_blank'"
:data-turbo-frame="template.submitters.length > 1 ? 'modal' : ''" :data-turbo-frame="template.submitters.length > 1 ? 'modal' : ''"
@click="maybeShowEmptyTemplateAlert" @click="maybeShowErrorTemplateAlert"
> >
<IconWritingSign <IconWritingSign
width="20" width="22"
class="inline" class="inline"
/> />
<span class="hidden md:inline"> <span class="hidden md:inline">
@@ -51,7 +51,7 @@
:href="`/templates/${template.id}/submissions/new?with_link=true`" :href="`/templates/${template.id}/submissions/new?with_link=true`"
data-turbo-frame="modal" data-turbo-frame="modal"
class="white-button md:!px-6" class="white-button md:!px-6"
@click="maybeShowEmptyTemplateAlert" @click="maybeShowErrorTemplateAlert"
> >
<IconUsersPlus <IconUsersPlus
width="20" width="20"
@@ -94,11 +94,13 @@
</div> </div>
</div> </div>
<div <div
id="main_container"
class="flex" class="flex"
:class="$slots.buttons || withTitle ? 'md:max-h-[calc(100%_-_60px)]' : 'md:max-h-[100%]'" :class="$slots.buttons || withTitle ? 'md:max-h-[calc(100%_-_60px)]' : 'md:max-h-[100%]'"
> >
<div <div
v-if="withDocumentsList" v-if="withDocumentsList"
id="documents_container"
ref="previews" ref="previews"
:style="{ 'display': isBreakpointLg ? 'none' : 'initial' }" :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" class="overflow-y-auto overflow-x-hidden w-52 flex-none pr-3 mt-0.5 pt-0.5 hidden lg:block"
@@ -113,7 +115,6 @@
:with-replace-button="withUploadButton" :with-replace-button="withUploadButton"
:editable="editable" :editable="editable"
:template="template" :template="template"
:is-direct-upload="isDirectUpload"
@scroll-to="scrollIntoDocument(item)" @scroll-to="scrollIntoDocument(item)"
@remove="onDocumentRemove" @remove="onDocumentRemove"
@replace="onDocumentReplace" @replace="onDocumentReplace"
@@ -129,12 +130,14 @@
v-if="sortedDocuments.length && editable && withUploadButton" v-if="sortedDocuments.length && editable && withUploadButton"
:accept-file-types="acceptFileTypes" :accept-file-types="acceptFileTypes"
:template-id="template.id" :template-id="template.id"
:is-direct-upload="isDirectUpload"
@success="updateFromUpload" @success="updateFromUpload"
/> />
</div> </div>
</div> </div>
<div class="w-full overflow-y-hidden md:overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5"> <div
id="pages_container"
class="w-full overflow-y-hidden md:overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5"
>
<div <div
ref="documents" ref="documents"
class="pr-3.5 pl-0.5" class="pr-3.5 pl-0.5"
@@ -143,7 +146,6 @@
v-if="!sortedDocuments.length && withUploadButton" v-if="!sortedDocuments.length && withUploadButton"
:template-id="template.id" :template-id="template.id"
:accept-file-types="acceptFileTypes" :accept-file-types="acceptFileTypes"
:is-direct-upload="isDirectUpload"
@success="updateFromUpload" @success="updateFromUpload"
/> />
<template v-else> <template v-else>
@@ -157,13 +159,14 @@
:selected-submitter="selectedSubmitter" :selected-submitter="selectedSubmitter"
:document="document" :document="document"
:is-drag="!!dragField" :is-drag="!!dragField"
:default-fields="defaultFields" :default-fields="[...defaultRequiredFields, ...defaultFields]"
:allow-draw="!onlyDefinedFields" :allow-draw="!onlyDefinedFields"
:default-submitters="defaultSubmitters" :default-submitters="defaultSubmitters"
:draw-field="drawField" :draw-field="drawField"
:draw-field-type="drawFieldType"
:editable="editable" :editable="editable"
:base-url="baseUrl" :base-url="baseUrl"
@draw="onDraw" @draw="[onDraw($event), withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]"
@drop-field="onDropfield" @drop-field="onDropfield"
@remove-area="removeArea" @remove-area="removeArea"
/> />
@@ -172,9 +175,9 @@
:with-arrows="template.schema.length > 1" :with-arrows="template.schema.length > 1"
:item="template.schema.find((item) => item.attachment_uuid === document.uuid)" :item="template.schema.find((item) => item.attachment_uuid === document.uuid)"
:with-replace-button="withUploadButton" :with-replace-button="withUploadButton"
:accept-file-types="acceptFileTypes"
:document="document" :document="document"
:template="template" :template="template"
:is-direct-upload="isDirectUpload"
class="pb-2 mb-2 border-b border-base-300 border-dashed" class="pb-2 mb-2 border-b border-base-300 border-dashed"
@remove="onDocumentRemove" @remove="onDocumentRemove"
@replace="onDocumentReplace" @replace="onDocumentReplace"
@@ -190,7 +193,6 @@
<Upload <Upload
v-if="withUploadButton" v-if="withUploadButton"
:template-id="template.id" :template-id="template.id"
:is-direct-upload="isDirectUpload"
:accept-file-types="acceptFileTypes" :accept-file-types="acceptFileTypes"
@success="updateFromUpload" @success="updateFromUpload"
/> />
@@ -200,17 +202,18 @@
</div> </div>
<div <div
v-if="withFieldsList" v-if="withFieldsList"
id="fields_list_container"
class="relative w-80 flex-none mt-1 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-y-auto overflow-x-hidden'" :class="drawField ? 'overflow-hidden' : 'overflow-y-auto overflow-x-hidden'"
> >
<div <div
v-if="drawField" v-if="showDrawField || drawField"
class="sticky inset-0 h-full z-20" class="sticky inset-0 h-full z-20"
:style="{ backgroundColor }" :style="{ backgroundColor }"
> >
<div class="bg-base-300 rounded-lg p-5 text-center space-y-4"> <div class="bg-base-200 rounded-lg p-5 text-center space-y-4">
<p> <p>
{{ t('draw_field_on_the_document').replace('{field}', drawField.name) }} {{ t('draw_field_on_the_document').replace('{field}', drawField?.name || '') }}
</p> </p>
<div> <div>
<button <button
@@ -220,10 +223,10 @@
{{ t('cancel') }} {{ t('cancel') }}
</button> </button>
<a <a
v-if="!drawOption && !drawField.areas.length && !['stamp', 'signature', 'initials'].includes(drawField.type)" v-if="!drawField && !drawOption && !['stamp', 'signature', 'initials'].includes(drawField?.type || drawFieldType)"
href="#" href="#"
class="link block mt-3 text-sm" class="link block mt-3 text-sm"
@click.prevent="[drawField = null, drawOption = null]" @click.prevent="[addField(drawFieldType), drawField = null, drawOption = null, withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]"
> >
{{ t('or_add_field_without_drawing') }} {{ t('or_add_field_without_drawing') }}
</a> </a>
@@ -238,12 +241,16 @@
:selected-submitter="selectedSubmitter" :selected-submitter="selectedSubmitter"
:with-help="withHelp" :with-help="withHelp"
:default-submitters="defaultSubmitters" :default-submitters="defaultSubmitters"
:default-fields="defaultFields" :draw-field-type="drawFieldType"
:default-fields="[...defaultRequiredFields, ...defaultFields]"
:default-required-fields="defaultRequiredFields"
:field-types="fieldTypes" :field-types="fieldTypes"
:with-sticky-submitters="withStickySubmitters" :with-sticky-submitters="withStickySubmitters"
:only-defined-fields="onlyDefinedFields" :only-defined-fields="onlyDefinedFields"
:editable="editable" :editable="editable"
@add-field="addField"
@set-draw="[drawField = $event.field, drawOption = $event.option]" @set-draw="[drawField = $event.field, drawOption = $event.option]"
@set-draw-type="[drawFieldType = $event, showDrawField = true]"
@set-drag="dragField = $event" @set-drag="dragField = $event"
@change-submitter="selectedSubmitter = $event" @change-submitter="selectedSubmitter = $event"
@drag-end="dragField = null" @drag-end="dragField = null"
@@ -267,12 +274,14 @@
<MobileFields <MobileFields
v-if="sortedDocuments.length && !drawField && editable" v-if="sortedDocuments.length && !drawField && editable"
:fields="template.fields" :fields="template.fields"
:default-fields="defaultFields" :default-fields="[...defaultRequiredFields, ...defaultFields]"
:default-required-fields="defaultRequiredFields"
:field-types="fieldTypes" :field-types="fieldTypes"
:selected-submitter="selectedSubmitter" :selected-submitter="selectedSubmitter"
@select="startFieldDraw($event)" @select="startFieldDraw($event)"
/> />
</div> </div>
<div id="docuseal_modal_container" />
</div> </div>
</template> </template>
@@ -321,7 +330,12 @@ export default {
backgroundColor: this.backgroundColor, backgroundColor: this.backgroundColor,
withPhone: this.withPhone, withPhone: this.withPhone,
withPayment: this.withPayment, withPayment: this.withPayment,
selectedAreaRef: computed(() => this.selectedAreaRef) isPaymentConnected: this.isPaymentConnected,
withFormula: this.withFormula,
withConditions: this.withConditions,
defaultDrawFieldType: this.defaultDrawFieldType,
selectedAreaRef: computed(() => this.selectedAreaRef),
fieldsDragFieldRef: computed(() => this.fieldsDragFieldRef)
} }
}, },
props: { props: {
@@ -329,11 +343,6 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
isDirectUpload: {
type: Boolean,
required: false,
default: false
},
i18n: { i18n: {
type: Object, type: Object,
required: false, required: false,
@@ -364,6 +373,21 @@ export default {
required: false, required: false,
default: () => [] default: () => []
}, },
defaultRequiredFields: {
type: Array,
required: false,
default: () => []
},
withSelectedFieldType: {
type: Boolean,
required: false,
default: false
},
defaultDrawFieldType: {
type: String,
required: false,
default: 'text'
},
currencies: { currencies: {
type: Array, type: Array,
required: false, required: false,
@@ -408,6 +432,13 @@ export default {
return () => {} return () => {}
} }
}, },
onChange: {
type: Function,
required: false,
default () {
return () => {}
}
},
withStickySubmitters: { withStickySubmitters: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -428,6 +459,11 @@ export default {
required: false, required: false,
default: true default: true
}, },
authenticityToken: {
type: String,
required: false,
default: ''
},
withDocumentsList: { withDocumentsList: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -443,6 +479,21 @@ export default {
required: false, required: false,
default: false default: false
}, },
isPaymentConnected: {
type: Boolean,
required: false,
default: false
},
withFormula: {
type: Boolean,
required: false,
default: false
},
withConditions: {
type: Boolean,
required: false,
default: false
},
onlyDefinedFields: { onlyDefinedFields: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -460,13 +511,16 @@ export default {
isBreakpointLg: false, isBreakpointLg: false,
isSaving: false, isSaving: false,
selectedSubmitter: null, selectedSubmitter: null,
showDrawField: false,
drawField: null, drawField: null,
drawFieldType: null,
drawOption: null, drawOption: null,
dragField: null dragField: null
} }
}, },
computed: { computed: {
selectedAreaRef: () => ref(), selectedAreaRef: () => ref(),
fieldsDragFieldRef: () => ref(),
fieldAreasIndex () { fieldAreasIndex () {
const areas = {} const areas = {}
@@ -482,6 +536,11 @@ export default {
return areas return areas
}, },
isAllRequiredFieldsAdded () {
return !this.defaultRequiredFields?.some((f) => {
return !this.template.fields?.some((field) => field.name === f.name)
})
},
selectedField () { selectedField () {
return this.template.fields.find((f) => f.areas?.includes(this.selectedAreaRef.value)) return this.template.fields.find((f) => f.areas?.includes(this.selectedAreaRef.value))
}, },
@@ -492,6 +551,12 @@ export default {
} }
}, },
created () { created () {
if (!this.template.fields?.length && this.template.submitters?.length === 1) {
if (this.template.submitters[0]?.name === 'First Party') {
this.template.submitters[0].name = this.t('first_party')
}
}
const existingSubmittersUuids = this.defaultSubmitters.map((name) => { const existingSubmittersUuids = this.defaultSubmitters.map((name) => {
return this.template.submitters.find(e => e.name === name)?.uuid return this.template.submitters.find(e => e.name === name)?.uuid
}) })
@@ -502,7 +567,7 @@ export default {
submitter.name = name submitter.name = name
if (existingSubmittersUuids.filter(Boolean).length) { if (existingSubmittersUuids.filter(Boolean).length) {
submitter.uuid = existingSubmittersUuids[index] || v4() submitter.uuid = existingSubmittersUuids[index] || submitter.uuid || v4()
} else { } else {
submitter.uuid ||= v4() submitter.uuid ||= v4()
} }
@@ -542,6 +607,34 @@ export default {
t (key) { t (key) {
return this.i18n[key] || i18nEn[key] || key return this.i18n[key] || i18nEn[key] || key
}, },
addField (type, area = null) {
const field = {
name: '',
uuid: v4(),
required: type !== 'checkbox',
areas: area ? [area] : [],
submitter_uuid: this.selectedSubmitter.uuid,
type
}
if (['select', 'multiple', 'radio'].includes(type)) {
field.options = [{ value: '', uuid: v4() }]
}
if (type === 'stamp') {
field.readonly = true
}
if (type === 'date') {
field.preferences = {
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
}
}
this.template.fields.push(field)
this.save()
},
startFieldDraw ({ name, type }) { startFieldDraw ({ name, type }) {
const existingField = this.template.fields?.find((f) => f.submitter_uuid === this.selectedSubmitter.uuid && name && name === f.name) const existingField = this.template.fields?.find((f) => f.submitter_uuid === this.selectedSubmitter.uuid && name && name === f.name)
@@ -622,15 +715,13 @@ export default {
ref.$el.scrollIntoView({ behavior: 'smooth', block: 'start' }) ref.$el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, },
clearDrawField () { clearDrawField () {
if (this.drawField && !this.drawOption && this.drawField.areas.length === 0) {
const fieldIndex = this.template.fields.indexOf(this.drawField)
if (fieldIndex !== -1) {
this.template.fields.splice(fieldIndex, 1)
}
}
this.drawField = null this.drawField = null
this.drawOption = null this.drawOption = null
this.showDrawField = false
if (!this.withSelectedFieldType) {
this.drawFieldType = ''
}
}, },
onKeyUp (e) { onKeyUp (e) {
if (e.code === 'Escape') { if (e.code === 'Escape') {
@@ -680,6 +771,27 @@ export default {
} }
} }
}, },
setDefaultAreaSize (area, type) {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
const pageMask = documentRef.pageRefs[area.page].$refs.mask
if (type === 'checkbox') {
area.w = pageMask.clientWidth / 30 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 30 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
} else if (type === 'image') {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
} else if (type === 'signature' || type === 'stamp') {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight) / 2
} else if (type === 'initials') {
area.w = pageMask.clientWidth / 10 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
} else {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
}
},
onDraw (area) { onDraw (area) {
if (this.drawField) { if (this.drawField) {
if (this.drawOption) { if (this.drawOption) {
@@ -702,25 +814,7 @@ export default {
area.w = previousArea.w area.w = previousArea.w
area.h = previousArea.h area.h = previousArea.h
} else { } else {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid) this.setDefaultAreaSize(area, this.drawOption ? 'checkbox' : this.drawField?.type)
const pageMask = documentRef.pageRefs[area.page].$refs.mask
if (this.drawField.type === 'checkbox' || this.drawOption) {
area.w = pageMask.clientWidth / 30 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 30 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
} else if (this.drawField.type === 'image') {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
} else if (this.drawField.type === 'signature' || this.drawField.type === 'stamp') {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight) / 2
} else if (this.drawField.type === 'initials') {
area.w = pageMask.clientWidth / 10 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
} else {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
}
} }
area.x -= area.w / 2 area.x -= area.w / 2
@@ -755,11 +849,15 @@ export default {
let type = (pageMask.clientWidth * area.w) < 35 ? 'checkbox' : 'text' let type = (pageMask.clientWidth * area.w) < 35 ? 'checkbox' : 'text'
if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes(type)) { if (this.drawFieldType) {
type = this.drawFieldType
} else if (this.defaultDrawFieldType && this.defaultDrawFieldType !== 'text') {
type = this.defaultDrawFieldType
} else if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes(type)) {
type = this.fieldTypes[0] type = this.fieldTypes[0]
} }
if (type === 'checkbox') { if (type === 'checkbox' && !this.drawFieldType && (this.template.fields[this.template.fields.length - 1]?.type === 'checkbox' || area.w)) {
const previousField = [...this.template.fields].reverse().find((f) => f.type === type) const previousField = [...this.template.fields].reverse().find((f) => f.type === type)
const previousArea = previousField?.areas?.[previousField.areas.length - 1] const previousArea = previousField?.areas?.[previousField.areas.length - 1]
@@ -777,26 +875,27 @@ export default {
} }
} }
if (area.w) { if (this.drawFieldType && (area.w === 0 || area.h === 0)) {
const field = { if (this.selectedField?.type === this.drawFieldType) {
name: '', area.w = this.selectedAreaRef.value.w
uuid: v4(), area.h = this.selectedAreaRef.value.h
required: type !== 'checkbox', } else {
type, this.setDefaultAreaSize(area, this.drawFieldType)
submitter_uuid: this.selectedSubmitter.uuid,
areas: [area]
} }
this.template.fields.push(field) area.x -= area.w / 2
area.y -= area.h / 2
}
if (area.w) {
this.addField(type, area)
this.selectedAreaRef.value = area this.selectedAreaRef.value = area
this.save()
} }
} }
}, },
onDropfield (area) { onDropfield (area) {
const field = { const field = this.fieldsDragFieldRef.value || {
name: '', name: '',
uuid: v4(), uuid: v4(),
submitter_uuid: this.selectedSubmitter.uuid, submitter_uuid: this.selectedSubmitter.uuid,
@@ -804,17 +903,19 @@ export default {
...this.dragField ...this.dragField
} }
if (['select', 'multiple', 'radio'].includes(field.type)) { if (!this.fieldsDragFieldRef.value) {
field.options = [{ value: '', uuid: v4() }] if (['select', 'multiple', 'radio'].includes(field.type)) {
} field.options = [{ value: '', uuid: v4() }]
}
if (field.type === 'stamp') { if (field.type === 'stamp') {
field.readonly = true field.readonly = true
} }
if (field.type === 'date') { if (field.type === 'date') {
field.preferences = { field.preferences = {
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY' format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
}
} }
} }
@@ -870,22 +971,46 @@ export default {
fieldArea.cell_w = baseArea.cell_w || (baseArea.w / 5) fieldArea.cell_w = baseArea.cell_w || (baseArea.w / 5)
} }
field.areas = [fieldArea] field.areas ||= []
const lastArea = field.areas[field.areas.length - 1]
if (lastArea) {
fieldArea.x -= lastArea.w / 2
fieldArea.w = lastArea.w
fieldArea.h = lastArea.h
}
field.areas.push(fieldArea)
this.selectedAreaRef.value = fieldArea this.selectedAreaRef.value = fieldArea
this.template.fields.push(field) if (this.template.fields.indexOf(field) === -1) {
this.template.fields.push(field)
}
this.save() this.save()
}, },
updateFromUpload ({ schema, documents }) { updateFromUpload (data) {
this.template.schema.push(...schema) this.template.schema.push(...data.schema)
this.template.documents.push(...documents) this.template.documents.push(...data.documents)
if (data.fields) {
this.template.fields = data.fields
}
if (data.submitters) {
this.template.submitters = data.submitters
if (!this.template.submitters.find((s) => s.uuid === this.selectedSubmitter?.uuid)) {
this.selectedSubmitter = this.template.submitters[0]
}
}
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.previews.scrollTop = this.$refs.previews.scrollHeight this.$refs.previews.scrollTop = this.$refs.previews.scrollHeight
this.scrollIntoDocument(schema[0]) this.scrollIntoDocument(data.schema[0])
}) })
if (this.template.name === 'New Document') { if (this.template.name === 'New Document') {
@@ -908,19 +1033,56 @@ export default {
this.template.schema.splice(this.template.schema.indexOf(item), 1) this.template.schema.splice(this.template.schema.indexOf(item), 1)
} }
const removedFieldUuids = []
this.template.fields.forEach((field) => { this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => { [...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === item.attachment_uuid) { if (area.attachment_uuid === item.attachment_uuid) {
field.areas.splice(field.areas.indexOf(area), 1) field.areas.splice(field.areas.indexOf(area), 1)
removedFieldUuids.push(field.uuid)
} }
}) })
}) })
this.template.fields =
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
this.save() this.save()
}, },
onDocumentReplace ({ replaceSchemaItem, schema, documents }) { onDocumentReplace (data) {
const { replaceSchemaItem, schema, documents } = data
this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, schema[0]) this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, schema[0])
this.template.documents.push(...documents) this.template.documents.push(...documents)
if (data.fields) {
this.template.fields = data.fields
const removedFieldUuids = []
this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
field.areas.splice(field.areas.indexOf(area), 1)
removedFieldUuids.push(field.uuid)
}
})
})
this.template.fields =
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
}
if (data.submitters) {
this.template.submitters = data.submitters
if (!this.template.submitters.find((s) => s.uuid === this.selectedSubmitter?.uuid)) {
this.selectedSubmitter = this.template.submitters[0]
}
}
this.template.fields.forEach((field) => { this.template.fields.forEach((field) => {
(field.areas || []).forEach((area) => { (field.areas || []).forEach((area) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) { if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
@@ -950,7 +1112,19 @@ export default {
this.save() this.save()
}, },
maybeShowEmptyTemplateAlert (e) { maybeShowErrorTemplateAlert (e) {
if (!this.isAllRequiredFieldsAdded) {
e.preventDefault()
const fields = this.defaultRequiredFields?.filter((f) => {
return !this.template.fields?.some((field) => field.name === f.name)
})
if (fields?.length) {
return alert(this.t('add_all_required_fields_to_continue') + ': ' + fields.map((f) => f.name).join(', '))
}
}
if (!this.template.fields.length) { if (!this.template.fields.length) {
e.preventDefault() e.preventDefault()
@@ -958,6 +1132,16 @@ export default {
} }
}, },
onSaveClick () { onSaveClick () {
if (!this.isAllRequiredFieldsAdded) {
const fields = this.defaultRequiredFields?.filter((f) => {
return !this.template.fields?.some((field) => field.name === f.name)
})
if (fields?.length) {
return alert(this.t('add_all_required_fields_to_continue') + ': ' + fields.map((f) => f.name).join(', '))
}
}
if (this.template.fields.length) { if (this.template.fields.length) {
this.isSaving = true this.isSaving = true
@@ -980,10 +1164,18 @@ export default {
baseFetch (path, options = {}) { baseFetch (path, options = {}) {
return fetch(this.baseUrl + path, { return fetch(this.baseUrl + path, {
...options, ...options,
headers: { ...this.fetchOptions.headers, ...options.headers } headers: {
'X-CSRF-Token': this.authenticityToken,
...this.fetchOptions.headers,
...options.headers
}
}) })
}, },
save ({ force } = { force: false }) { save ({ force } = { force: false }) {
if (this.onChange) {
this.onChange(this.template)
}
if (!this.autosave && !force) { if (!this.autosave && !force) {
return Promise.resolve({}) return Promise.resolve({})
} }
@@ -996,7 +1188,7 @@ export default {
this.pushUndo() this.pushUndo()
return this.baseFetch(`/api/templates/${this.template.id}`, { return this.baseFetch(`/templates/${this.template.id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
template: { template: {
@@ -0,0 +1,195 @@
<template>
<div
class="modal modal-open items-start !animate-none overflow-y-auto"
>
<div
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span>
{{ t('condition') }} - {{ field.name || buildDefaultName(field, template.fields) }}
</span>
<a
href="#"
class="text-xl"
@click.prevent="$emit('close')"
>&times;</a>
</div>
<div>
<div
v-if="!withConditions"
class="bg-base-300 rounded-xl py-2 px-3 text-center"
>
<a
href="https://www.docuseal.co/pricing"
target="_blank"
class="link"
>Available in Pro</a>
</div>
<form @submit.prevent="validateSaveAndClose">
<div class="my-4">
<div class="space-y-4">
<select
class="select select-bordered select-sm w-full bg-white h-11 pl-4 text-base font-normal"
required
@change="[
newCondition.field_uuid = $event.target.value,
delete newCondition.value,
(conditionActions.includes(newCondition.action) ? '' : newCondition.action = conditionActions[0])
]"
>
<option
value=""
disabled
:selected="!newCondition.field_uuid"
>
{{ t('select_field_') }}
</option>
<option
v-for="f in fields"
:key="f.uuid"
:value="f.uuid"
:selected="newCondition.field_uuid === f.uuid"
>
{{ f.name || buildDefaultName(f, template.fields) }}
</option>
</select>
<select
v-model="newCondition.action"
class="select select-bordered select-sm w-full h-11 pl-4 text-base font-normal"
:class="{ 'bg-white': newCondition.field_uuid, 'bg-base-300': !newCondition.field_uuid }"
:required="newCondition.field_uuid"
>
<option
v-for="action in conditionActions"
:key="action"
:value="action"
>
{{ t(action) }}
</option>
</select>
<select
v-if="conditionField?.options?.length"
class="select select-bordered select-sm w-full bg-white h-11 pl-4 text-base font-normal"
required
@change="newCondition.value = $event.target.value"
>
<option
value=""
disabled
selected
>
{{ t('select_value_') }}
</option>
<option
v-for="(option, index) in conditionField.options"
:key="option.uuid"
:value="option.uuid"
:selected="newCondition.value === option.uuid"
>
{{ option.value || `${t('option')} ${index + 1}` }}
</option>
</select>
</div>
</div>
<button
class="base-button w-full mt-2"
>
{{ t('save') }}
</button>
</form>
<div
v-if="field.conditions?.[0]?.field_uuid"
class="text-center w-full mt-4"
>
<button
class="link"
@click="[newCondition.field_uuid = null, delete field.conditions, validateSaveAndClose()]"
>
{{ t('remove_condition') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ConditionModal',
inject: ['t', 'save', 'template', 'withConditions'],
props: {
field: {
type: Object,
required: true
},
buildDefaultName: {
type: Function,
required: true
}
},
emits: ['close'],
data () {
return {
newCondition: this.field.conditions?.[0] || {}
}
},
computed: {
conditionField () {
return this.fields.find((f) => f.uuid === this.newCondition.field_uuid)
},
conditionActions () {
return this.fieldActions(this.conditionField)
},
fields () {
return this.template.fields.reduce((acc, f) => {
if (f !== this.field && f.submitter_uuid === this.field.submitter_uuid) {
acc.push(f)
}
return acc
}, [])
}
},
created () {
this.field.conditions ||= []
},
methods: {
fieldActions (field) {
const actions = []
if (!field) {
return actions
}
if (field.type === 'checkbox') {
actions.push('checked', 'unchecked')
} else if (['radio', 'select'].includes(field.type)) {
actions.push('equal', 'not_equal')
} else if (['multiple'].includes(field.type)) {
actions.push('contains', 'does_not_contain')
} else {
actions.push('not_empty', 'empty')
}
return actions
},
validateSaveAndClose () {
if (!this.withConditions) {
return alert('Available only in Pro')
}
if (this.newCondition.field_uuid) {
this.field.conditions = [this.newCondition]
} else {
delete this.field.conditions
}
this.save()
this.$emit('close')
}
}
}
</script>
@@ -27,7 +27,7 @@
<IconPencil <IconPencil
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden" 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' }" :style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
title="Edit" :title="t('edit')"
:class="{ invisible: !editable, 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }" :class="{ invisible: !editable, 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
:width="iconWidth" :width="iconWidth"
:stroke-width="iconStrokeWidth" :stroke-width="iconStrokeWidth"
@@ -44,6 +44,7 @@ export default {
components: { components: {
IconPencil IconPencil
}, },
inject: ['t'],
props: { props: {
modelValue: { modelValue: {
type: String, type: String,
+5 -10
View File
@@ -8,8 +8,8 @@
/> />
<ReplaceButton <ReplaceButton
v-if="withReplaceButton" v-if="withReplaceButton"
:is-direct-upload="isDirectUpload"
:template-id="template.id" :template-id="template.id"
:accept-file-types="acceptFileTypes"
@click.stop @click.stop
@success="$emit('replace', { replaceSchemaItem: item, ...$event })" @success="$emit('replace', { replaceSchemaItem: item, ...$event })"
/> />
@@ -63,10 +63,10 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
isDirectUpload: { acceptFileTypes: {
type: Boolean, type: String,
required: true, required: false,
default: false default: 'image/*, application/pdf'
}, },
withReplaceButton: { withReplaceButton: {
type: Boolean, type: Boolean,
@@ -80,11 +80,6 @@ export default {
} }
}, },
emits: ['change', 'remove', 'up', 'down', 'replace'], emits: ['change', 'remove', 'up', 'down', 'replace'],
mounted () {
if (this.isDirectUpload) {
import('@rails/activestorage')
}
},
methods: { methods: {
upload: Upload.methods.upload, upload: Upload.methods.upload,
onUpdateName (value) { onUpdateName (value) {
@@ -0,0 +1,114 @@
<template>
<div
class="modal modal-open items-start !animate-none overflow-y-auto"
>
<div
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span>
{{ field.name || buildDefaultName(field, template.fields) }}
</span>
<a
href="#"
class="text-xl"
@click.prevent="$emit('close')"
>&times;</a>
</div>
<div>
<form @submit.prevent="saveAndClose">
<div class="space-y-1 mb-1">
<div>
<label
dir="auto"
class="label text-sm"
for="description_field"
>
{{ t('description') }}
</label>
<textarea
id="description_field"
ref="textarea"
v-model="description"
dir="auto"
class="base-textarea !text-base w-full"
:readonly="!editable"
@input="resizeTextarea"
/>
</div>
<div>
<label
dir="auto"
class="label text-sm"
for="title_field"
>
{{ t('display_title') }} ({{ t('optional') }})
</label>
<input
id="title_field"
v-model="title"
dir="auto"
:readonly="!editable"
class="base-input !text-base w-full"
>
</div>
</div>
<button
class="base-button w-full mt-4"
>
{{ t('save') }}
</button>
</form>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'DescriptionModal',
inject: ['t', 'save', 'template'],
props: {
field: {
type: Object,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
buildDefaultName: {
type: Function,
required: true
}
},
emits: ['close'],
data () {
return {
description: this.field.description,
title: this.field.title
}
},
mounted () {
this.resizeTextarea()
},
methods: {
saveAndClose () {
this.field.description = this.description
this.field.title = this.title
this.save()
this.$emit('close')
},
resizeTextarea () {
const textarea = this.$refs.textarea
textarea.style.height = 'auto'
textarea.style.height = textarea.scrollHeight + 'px'
}
}
}
</script>
+11 -2
View File
@@ -12,10 +12,12 @@
:default-fields="defaultFields" :default-fields="defaultFields"
:default-submitters="defaultSubmitters" :default-submitters="defaultSubmitters"
:draw-field="drawField" :draw-field="drawField"
:draw-field-type="drawFieldType"
:selected-submitter="selectedSubmitter" :selected-submitter="selectedSubmitter"
:image="image" :image="image"
@drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })" @drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })"
@remove-area="$emit('remove-area', $event)" @remove-area="$emit('remove-area', $event)"
@scroll-to="scrollToArea"
@draw="$emit('draw', {...$event, attachment_uuid: document.uuid })" @draw="$emit('draw', {...$event, attachment_uuid: document.uuid })"
/> />
</div> </div>
@@ -43,6 +45,11 @@ export default {
required: false, required: false,
default: () => [] default: () => []
}, },
drawFieldType: {
type: String,
required: false,
default: ''
},
defaultSubmitters: { defaultSubmitters: {
type: Array, type: Array,
required: false, required: false,
@@ -102,7 +109,7 @@ export default {
return this.previewImagesIndex[i] || { return this.previewImagesIndex[i] || {
metadata: lazyloadMetadata, metadata: lazyloadMetadata,
id: Math.random().toString(), id: Math.random().toString(),
url: this.basePreviewUrl + `/preview/${this.document.uuid}/${i}.jpg` url: this.basePreviewUrl + `/preview/${this.document.signed_uuid || this.document.uuid}/${i}.jpg`
} }
}) })
}, },
@@ -119,7 +126,9 @@ export default {
}, },
methods: { methods: {
scrollToArea (area) { scrollToArea (area) {
this.pageRefs[area.page].areaRefs.find((e) => e.area === area).$el.scrollIntoView({ behavior: 'smooth', block: 'center' }) this.$nextTick(() => {
this.pageRefs[area.page].areaRefs.find((e) => e.area === area).$el.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
}, },
setPageRefs (el) { setPageRefs (el) {
if (el) { if (el) {
+1 -11
View File
@@ -5,7 +5,7 @@
@drop.prevent="onDropFiles" @drop.prevent="onDropFiles"
> >
<label <label
class="w-full relative bg-base-200 hover:bg-base-200/70 rounded-md border border-base-content border-dashed" class="w-full relative hover:bg-base-200/30 rounded-md border border-2 border-base-content/10 border-dashed"
:for="inputId" :for="inputId"
:class="{ 'opacity-50': isLoading || isProcessing }" :class="{ 'opacity-50': isLoading || isProcessing }"
> >
@@ -71,11 +71,6 @@ export default {
type: String, type: String,
required: false, required: false,
default: 'image/*, application/pdf' default: 'image/*, application/pdf'
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
} }
}, },
emits: ['success'], emits: ['success'],
@@ -101,11 +96,6 @@ export default {
} }
} }
}, },
mounted () {
if (this.isDirectUpload) {
import('@rails/activestorage')
}
},
methods: { methods: {
upload: Upload.methods.upload, upload: Upload.methods.upload,
onDropFiles (e) { onDropFiles (e) {
+138 -211
View File
@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="group pb-2" class="list-field group mb-2"
> >
<div <div
class="border border-base-300 rounded rounded-tr-none relative group" class="border border-base-300 rounded rounded-tr-none relative group"
@@ -16,12 +16,14 @@
v-model="field.type" v-model="field.type"
:editable="editable && !defaultField" :editable="editable && !defaultField"
:button-width="20" :button-width="20"
:menu-classes="'mt-1.5'"
:menu-style="{ backgroundColor: dropdownBgColor }"
@update:model-value="[maybeUpdateOptions(), save()]" @update:model-value="[maybeUpdateOptions(), save()]"
@click="scrollToFirstArea" @click="scrollToFirstArea"
/> />
<Contenteditable <Contenteditable
ref="name" ref="name"
:model-value="field.name || defaultName" :model-value="(defaultField ? (field.title || field.name) : field.name) || defaultName"
:editable="editable && !defaultField" :editable="editable && !defaultField"
:icon-inline="true" :icon-inline="true"
:icon-width="18" :icon-width="18"
@@ -56,7 +58,7 @@
> >
<button <button
v-if="field && !field.areas.length" v-if="field && !field.areas.length"
title="Draw" :title="t('draw')"
class="relative cursor-pointer text-transparent group-hover:text-base-content" class="relative cursor-pointer text-transparent group-hover:text-base-content"
@click="$emit('set-draw', { field })" @click="$emit('set-draw', { field })"
> >
@@ -65,6 +67,28 @@
:stroke-width="1.6" :stroke-width="1.6"
/> />
</button> </button>
<button
v-if="field.preferences?.formula"
class="relative cursor-pointer text-transparent group-hover:text-base-content"
:title="t('formula')"
@click="isShowFormulaModal = true"
>
<IconMathFunction
:width="18"
:stroke-width="1.6"
/>
</button>
<button
v-if="field.conditions?.length"
class="relative cursor-pointer text-transparent group-hover:text-base-content"
:title="t('condition')"
@click="isShowConditionsModal = true"
>
<IconRouteAltLeft
:width="18"
:stroke-width="1.6"
/>
</button>
<PaymentSettings <PaymentSettings
v-if="field.type === 'payment'" v-if="field.type === 'payment'"
:field="field" :field="field"
@@ -77,7 +101,7 @@
> >
<label <label
tabindex="0" tabindex="0"
title="Settings" :title="t('settings')"
class="cursor-pointer text-transparent group-hover:text-base-content" class="cursor-pointer text-transparent group-hover:text-base-content"
> >
<IconSettings <IconSettings
@@ -88,136 +112,28 @@
<ul <ul
v-if="renderDropdown" v-if="renderDropdown"
tabindex="0" tabindex="0"
class="mt-1.5 dropdown-content menu menu-xs p-2 shadow bg-base-100 rounded-box w-52 z-10" class="mt-1.5 dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10"
:style="{ backgroundColor: dropdownBgColor }"
draggable="true" draggable="true"
@dragstart.prevent.stop @dragstart.prevent.stop
@click="closeDropdown" @click="closeDropdown"
> >
<div <FieldSettings
v-if="field.type === 'text' && !defaultField" :field="field"
class="py-1.5 px-1 relative" :default-field="defaultField"
@click.stop :editable="editable"
> :background-color="dropdownBgColor"
<input @click-formula="isShowFormulaModal = true"
v-model="field.default_value" @click-description="isShowDescriptionModal = true"
type="text" @click-condition="isShowConditionsModal = true"
:placeholder="t('default_value')" @set-draw="$emit('set-draw', $event)"
dir="auto" @scroll-to="$emit('scroll-to', $event)"
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"
>
{{ t('default_value') }}
</label>
</div>
<div
v-if="field.type === 'date'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
v-model="field.preferences.format"
:placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0"
@change="save"
>
<option
v-for="format in dateFormats"
:key="format"
:value="format"
>
{{ formatDate(new Date(), format) }}
</option>
</select>
<label
:style="{ backgroundColor: backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('format') }}
</label>
</div>
<li
v-if="field.type != 'phone'"
@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">{{ t('required') }}</span>
</label>
</li>
<li
v-if="field.type === 'text' && !defaultField"
@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">{{ t('read_only') }}</span>
</label>
</li>
<hr class="pb-0.5 mt-0.5">
<li
v-for="(area, index) in field.areas || []"
:key="index"
>
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('scroll-to', area)"
>
<IconShape
:width="20"
:stroke-width="1.6"
/>
{{ t('page') }} {{ area.page + 1 }}
</a>
</li>
<li v-if="!field.areas?.length || !['radio', 'multiple'].includes(field.type)">
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('set-draw', { field })"
>
<IconNewSection
:width="20"
:stroke-width="1.6"
/>
{{ t('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"
/>
{{ t('copy_to_all_pages') }}
</a>
</li>
</ul> </ul>
</span> </span>
<button <button
class="relative text-transparent group-hover:text-base-content pr-1" class="relative text-transparent group-hover:text-base-content pr-1"
title="Remove" :title="t('remove')"
@click="$emit('remove', field)" @click="$emit('remove', field)"
> >
<IconTrashX <IconTrashX
@@ -243,7 +159,7 @@
{{ index + 1 }}. {{ index + 1 }}.
</span> </span>
<div <div
v-if="['radio', 'multiple'].includes(field.type) && (index > 0 || field.areas.find((a) => a.option_uuid) || !field.areas.length) && !field.areas.find((a) => a.option_uuid === option.uuid)" v-if="editable && ['radio', 'multiple'].includes(field.type) && (index > 0 || field.areas.find((a) => a.option_uuid) || !field.areas.length) && !field.areas.find((a) => a.option_uuid === option.uuid)"
class="items-center flex w-full" class="items-center flex w-full"
> >
<input <input
@@ -256,7 +172,7 @@
@blur="save" @blur="save"
> >
<button <button
title="Draw" :title="t('draw')"
tabindex="-1" tabindex="-1"
@click.prevent="$emit('set-draw', { field, option })" @click.prevent="$emit('set-draw', { field, option })"
> >
@@ -272,12 +188,14 @@
class="w-full input input-primary input-xs text-sm bg-transparent" class="w-full input input-primary input-xs text-sm bg-transparent"
:placeholder="`${t('option')} ${index + 1}`" :placeholder="`${t('option')} ${index + 1}`"
type="text" type="text"
:readonly="!editable"
required required
dir="auto" dir="auto"
@focus="maybeFocusOnOptionArea(option)" @focus="maybeFocusOnOptionArea(option)"
@blur="save" @blur="save"
> >
<button <button
v-if="editable"
class="text-sm w-3.5" class="text-sm w-3.5"
tabindex="-1" tabindex="-1"
@click="removeOption(option)" @click="removeOption(option)"
@@ -285,8 +203,12 @@
&times; &times;
</button> </button>
</div> </div>
<div
v-if="field.options && !editable"
class="pb-1"
/>
<button <button
v-if="field.options" v-else-if="field.options && editable"
class="text-center text-sm w-full pb-1" class="text-center text-sm w-full pb-1"
@click="addOption" @click="addOption"
> >
@@ -294,6 +216,38 @@
</button> </button>
</div> </div>
</div> </div>
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:editable="editable && !defaultField"
:build-default-name="buildDefaultName"
@close="isShowFormulaModal = false"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:field="field"
:build-default-name="buildDefaultName"
@close="isShowConditionsModal = false"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:editable="editable && !defaultField"
:build-default-name="buildDefaultName"
@close="isShowDescriptionModal = false"
/>
</Teleport>
</div> </div>
</template> </template>
@@ -301,7 +255,11 @@
import Contenteditable from './contenteditable' import Contenteditable from './contenteditable'
import FieldType from './field_type' import FieldType from './field_type'
import PaymentSettings from './payment_settings' import PaymentSettings from './payment_settings'
import { IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue' import FieldSettings from './field_settings'
import FormulaModal from './formula_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import { IconRouteAltLeft, IconMathFunction, IconNewSection, IconTrashX, IconSettings } from '@tabler/icons-vue'
import { v4 } from 'uuid' import { v4 } from 'uuid'
export default { export default {
@@ -309,11 +267,15 @@ export default {
components: { components: {
Contenteditable, Contenteditable,
IconSettings, IconSettings,
IconShape, FieldSettings,
PaymentSettings, PaymentSettings,
IconNewSection, IconNewSection,
FormulaModal,
DescriptionModal,
ConditionsModal,
IconRouteAltLeft,
IconTrashX, IconTrashX,
IconCopy, IconMathFunction,
FieldType FieldType
}, },
inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef', 't'], inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef', 't'],
@@ -338,41 +300,34 @@ export default {
return { return {
isNameFocus: false, isNameFocus: false,
showPaymentModal: false, showPaymentModal: false,
isShowFormulaModal: false,
isShowConditionsModal: false,
isShowDescriptionModal: false,
renderDropdown: false renderDropdown: false
} }
}, },
computed: { computed: {
fieldNames: FieldType.computed.fieldNames, fieldNames: FieldType.computed.fieldNames,
dateFormats () { dropdownBgColor () {
return [ return ['', null, 'transparent'].includes(this.backgroundColor) ? 'white' : this.backgroundColor
'MM/DD/YYYY', },
'DD/MM/YYYY', schemaAttachmentsIndexes () {
'YYYY-MM-DD', return (this.template.schema || []).reduce((acc, item, index) => {
'DD-MM-YYYY', acc[item.attachment_uuid] = index
'DD.MM.YYYY',
'MMM D, YYYY', return acc
'MMMM D, YYYY', }, {})
'D MMM YYYY', },
'D MMMM YYYY' sortedAreas () {
] return (this.field.areas || []).sort((a, b) => {
return this.schemaAttachmentsIndexes[a.attachment_uuid] - this.schemaAttachmentsIndexes[b.attachment_uuid]
})
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
}, },
defaultName () { defaultName () {
if (this.field.type === 'payment' && this.field.preferences?.price) { return this.buildDefaultName(this.field, this.template.fields)
const { price, currency } = this.field.preferences || {}
const formattedPrice = new Intl.NumberFormat([], {
style: 'currency',
currency
}).format(price)
return `${this.fieldNames[this.field.type]} ${formattedPrice}`
} else {
const typeIndex = this.template.fields.filter((f) => f.type === this.field.type).indexOf(this.field)
const suffix = { multiple: this.t('select'), radio: this.t('group') }[this.field.type] || this.t('field')
return `${this.fieldNames[this.field.type]} ${suffix} ${typeIndex + 1}`
}
}, },
areas () { areas () {
return this.field.areas || [] return this.field.areas || []
@@ -387,51 +342,23 @@ export default {
} }
}, },
methods: { methods: {
formatDate (date, format) { buildDefaultName (field, fields) {
const monthFormats = { if (field.type === 'payment' && field.preferences?.price) {
M: 'numeric', const { price, currency } = field.preferences || {}
MM: '2-digit',
MMM: 'short', const formattedPrice = new Intl.NumberFormat([], {
MMMM: 'long' style: 'currency',
currency
}).format(price)
return `${this.fieldNames[field.type]} ${formattedPrice}`
} else {
const typeIndex = fields.filter((f) => f.type === field.type).indexOf(field)
const suffix = { multiple: this.t('select'), radio: this.t('group') }[field.type] || this.t('field')
return `${this.fieldNames[field.type]} ${suffix} ${typeIndex + 1}`
} }
const dayFormats = {
D: 'numeric',
DD: '2-digit'
}
const yearFormats = {
YYYY: 'numeric',
YY: '2-digit'
}
const parts = new Intl.DateTimeFormat([], {
day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)]
}).formatToParts(date)
return format
.replace(/D+/, parts.find((p) => p.type === 'day').value)
.replace(/M+/, parts.find((p) => p.type === 'month').value)
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
},
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), attachment_uuid: attachment.uuid, page: parseInt(page.filename) })
}
})
})
this.$nextTick(() => {
this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1])
})
this.save()
}, },
onNameFocus (e) { onNameFocus (e) {
this.isNameFocus = true this.isNameFocus = true
@@ -450,7 +377,7 @@ export default {
} }
}, },
scrollToFirstArea () { scrollToFirstArea () {
return this.field.areas?.[0] && this.$emit('scroll-to', this.field.areas[0]) return this.sortedAreas[0] && this.$emit('scroll-to', this.sortedAreas[0])
}, },
closeDropdown () { closeDropdown () {
document.activeElement.blur() document.activeElement.blur()
@@ -468,7 +395,12 @@ export default {
}, },
removeOption (option) { removeOption (option) {
this.field.options.splice(this.field.options.indexOf(option), 1) this.field.options.splice(this.field.options.indexOf(option), 1)
this.field.areas.splice(this.field.areas.findIndex((a) => a.option_uuid === option.uuid), 1)
const optionIndex = this.field.areas.findIndex((a) => a.option_uuid === option.uuid)
if (optionIndex !== -1) {
this.field.areas.splice(this.field.areas.findIndex((a) => a.option_uuid === option.uuid), 1)
}
this.save() this.save()
}, },
@@ -503,11 +435,6 @@ export default {
this.isNameFocus = false this.isNameFocus = false
this.save()
},
removeArea (area) {
this.field.areas.splice(this.field.areas.indexOf(area), 1)
this.save() this.save()
} }
} }
@@ -0,0 +1,462 @@
<template>
<div
v-if="field.type === 'number'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
:placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[field.preferences ||= {}, field.preferences.format = $event.target.value, save()]"
>
<option
v-for="format in numberFormats"
:key="format"
:value="format"
:selected="format === field.preferences?.format || (format === 'none' && !field.preferences?.format)"
>
{{ formatNumber(123456789.567, format) }}
</option>
</select>
<label
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('format') }}
</label>
</div>
<div
v-if="['number'].includes(field.type)"
class="py-1.5 px-1 relative"
@click.stop
>
<select
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal bg-transparent"
@change="[field.preferences ||= {}, field.preferences.align = $event.target.value, save()]"
>
<option
v-for="value in ['left', 'right', 'center']"
:key="value"
:selected="field.preferences?.align ? value === field.preferences.align : value === 'left'"
:value="value"
>
{{ t(value) }}
</option>
</select>
<label
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('align') }}
</label>
</div>
<div
v-if="['text', 'number'].includes(field.type) && !defaultField"
class="py-1.5 px-1 relative"
@click.stop
>
<input
v-model="field.default_value"
:placeholder="t('default_value')"
dir="auto"
:type="field.type"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
@blur="save"
>
<label
v-if="field.default_value"
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('default_value') }}
</label>
</div>
<div
v-if="field.type === 'date'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
v-model="field.preferences.format"
:placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="save"
>
<option
v-for="format in dateFormats"
:key="format"
:value="format"
>
{{ formatDate(new Date(), format) }}
</option>
</select>
<label
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('format') }}
</label>
</div>
<div
v-if="field.type === 'signature'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
:placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[field.preferences.format = $event.target.value, save()]"
>
<option
value="any"
:selected="!field.preferences?.format || field.preferences.format === 'any'"
>
{{ t('any') }}
</option>
<option
value="drawn"
:selected="field.preferences?.format === 'drawn'"
>
{{ t('drawn') }}
</option>
<option
value="typed"
:selected="field.preferences?.format === 'typed'"
>
{{ t('typed') }}
</option>
</select>
<label
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('format') }}
</label>
</div>
<li
v-if="withRequired && field.type != 'phone' && field.type != 'stamp'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.required"
type="checkbox"
:disabled="!editable || defaultField"
class="toggle toggle-xs"
@update:model-value="save"
>
<span class="label-text">{{ t('required') }}</span>
</label>
</li>
<li
v-if="field.type == 'stamp'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
:checked="field.preferences?.with_logo != false"
type="checkbox"
class="toggle toggle-xs"
@change="[field.preferences ||= {}, field.preferences.with_logo = field.preferences.with_logo == false, save()]"
>
<span class="label-text">{{ t('with_logo') }}</span>
</label>
</li>
<li
v-if="field.type == 'checkbox'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.default_value"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="[field.default_value = $event, field.readonly = $event, save()]"
>
<span class="label-text">{{ t('checked') }}</span>
</label>
</li>
<li
v-if="field.type == 'date'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.readonly"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="[field.default_value = $event ? '{{date}}' : null, field.readonly = $event, save()]"
>
<span class="label-text">{{ t('set_signing_date') }}</span>
</label>
</li>
<li
v-if="['text', 'number'].includes(field.type) && !defaultField"
@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">{{ t('read_only') }}</span>
</label>
</li>
<hr
v-if="field.type != 'stamp'"
class="pb-0.5 mt-0.5"
>
<li
v-if="field.type != 'stamp'"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-description')"
>
<IconInfoCircle
width="18"
/>
<span class="text-sm">
{{ t('description') }}
</span>
</label>
</li>
<li
v-if="field.type != 'stamp'"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-condition')"
>
<IconRouteAltLeft
width="18"
/>
<span class="text-sm">
{{ t('condition') }}
</span>
</label>
</li>
<li v-if="field.type == 'number'">
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-formula')"
>
<IconMathFunction
width="18"
/>
<span class="text-sm">
{{ t('formula') }}
</span>
</label>
</li>
<hr class="pb-0.5 mt-0.5">
<template v-if="withAreas">
<li
v-for="(area, index) in sortedAreas"
:key="index"
>
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('scroll-to', area)"
>
<IconShape
:width="20"
:stroke-width="1.6"
/>
{{ t('page') }}
<template v-if="template.schema.length > 1">{{ template.schema.findIndex((item) => item.attachment_uuid === area.attachment_uuid) + 1 }}-</template>{{ area.page + 1 }}
</a>
</li>
<li v-if="!field.areas?.length || !['radio', 'multiple'].includes(field.type)">
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('set-draw', { field })"
>
<IconNewSection
:width="20"
:stroke-width="1.6"
/>
{{ t('draw_new_area') }}
</a>
</li>
</template>
<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"
/>
{{ t('copy_to_all_pages') }}
</a>
</li>
</template>
<script>
import { IconRouteAltLeft, IconShape, IconMathFunction, IconNewSection, IconInfoCircle, IconCopy } from '@tabler/icons-vue'
export default {
name: 'FieldSettings',
components: {
IconShape,
IconInfoCircle,
IconMathFunction,
IconRouteAltLeft,
IconCopy,
IconNewSection
},
inject: ['template', 'save', 't'],
props: {
field: {
type: Object,
required: true
},
backgroundColor: {
type: String,
required: false,
default: null
},
defaultField: {
type: Object,
required: false,
default: null
},
withRequired: {
type: Boolean,
required: false,
default: true
},
withAreas: {
type: Boolean,
required: false,
default: true
},
editable: {
type: Boolean,
required: false,
default: true
}
},
emits: ['set-draw', 'scroll-to', 'click-formula', 'click-description', 'click-condition'],
data () {
return {
}
},
computed: {
schemaAttachmentsIndexes () {
return (this.template.schema || []).reduce((acc, item, index) => {
acc[item.attachment_uuid] = index
return acc
}, {})
},
numberFormats () {
return [
'none',
'comma',
'dot',
'space'
]
},
dateFormats () {
const formats = [
'MM/DD/YYYY',
'DD/MM/YYYY',
'YYYY-MM-DD',
'DD-MM-YYYY',
'DD.MM.YYYY',
'MMM D, YYYY',
'MMMM D, YYYY',
'D MMM YYYY',
'D MMMM YYYY'
]
if (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko')) {
formats.push('YYYY년 MM월 DD일')
}
if (this.field.preferences?.format && !formats.includes(this.field.preferences.format)) {
formats.unshift(this.field.preferences.format)
}
return formats
},
sortedAreas () {
return (this.field.areas || []).sort((a, b) => {
return this.schemaAttachmentsIndexes[a.attachment_uuid] - this.schemaAttachmentsIndexes[b.attachment_uuid]
})
}
},
methods: {
copyToAllPages (field) {
const areaString = JSON.stringify(field.areas[0])
this.template.documents.forEach((attachment) => {
const numberOfPages = attachment.metadata?.pdf?.number_of_pages || attachment.preview_images.length
for (let page = 0; page <= numberOfPages - 1; page++) {
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === page)) {
field.areas.push({ ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page })
}
}
})
this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1])
this.save()
},
formatNumber (number, format) {
if (format === 'comma') {
return new Intl.NumberFormat('en-US').format(number)
} else if (format === 'dot') {
return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number)
} else {
return number
}
},
formatDate (date, format) {
const monthFormats = {
M: 'numeric',
MM: '2-digit',
MMM: 'short',
MMMM: 'long'
}
const dayFormats = {
D: 'numeric',
DD: '2-digit'
}
const yearFormats = {
YYYY: 'numeric',
YY: '2-digit'
}
const parts = new Intl.DateTimeFormat([], {
day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)]
}).formatToParts(date)
return format
.replace(/D+/, parts.find((p) => p.type === 'day').value)
.replace(/M+/, parts.find((p) => p.type === 'month').value)
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
}
}
}
</script>
@@ -37,6 +37,7 @@
tabindex="0" tabindex="0"
class="rounded-md min-w-max mb-2" class="rounded-md min-w-max mb-2"
:class="menuClasses" :class="menuClasses"
:style="menuStyle"
@click="closeDropdown" @click="closeDropdown"
> >
<li <li
@@ -67,7 +68,7 @@
</button> </button>
</a> </a>
</li> </li>
<li v-if="submitters.length < 10 && editable"> <li v-if="submitters.length < names.length && editable">
<a <a
href="#" href="#"
class="flex px-2" class="flex px-2"
@@ -95,7 +96,7 @@
<label <label
v-if="compact" v-if="compact"
tabindex="0" tabindex="0"
:title="selectedSubmitter.name" :title="selectedSubmitter?.name"
class="cursor-pointer text-base-100 flex h-full items-center justify-center" class="cursor-pointer text-base-100 flex h-full items-center justify-center"
> >
<button <button
@@ -136,6 +137,7 @@
v-if="(editable || !compact) && renderDropdown" v-if="(editable || !compact) && renderDropdown"
tabindex="0" tabindex="0"
:class="menuClasses" :class="menuClasses"
:style="menuStyle"
@click="closeDropdown" @click="closeDropdown"
> >
<li <li
@@ -145,7 +147,7 @@
<a <a
href="#" href="#"
class="flex px-2 group justify-between items-center" class="flex px-2 group justify-between items-center"
:class="{ 'active': submitter === selectedSubmitter }" :class="{ 'active': submitter === selectedSubmitter, 'py-0.5': submitters.length > 8 }"
@click.prevent="selectSubmitter(submitter)" @click.prevent="selectSubmitter(submitter)"
> >
<span class="py-1 flex items-center"> <span class="py-1 flex items-center">
@@ -163,7 +165,7 @@
> >
<div class="flex-col pr-1 hidden group-hover:flex -mt-1 h-0"> <div class="flex-col pr-1 hidden group-hover:flex -mt-1 h-0">
<button <button
title="Up" :title="t('up')"
class="relative w-2" class="relative w-2"
style="font-size: 10px; margin-bottom: -4px" style="font-size: 10px; margin-bottom: -4px"
@click.prevent.stop="[move(submitter, -1), $refs.label.focus()] " @click.prevent.stop="[move(submitter, -1), $refs.label.focus()] "
@@ -171,7 +173,7 @@
▲ ▲
</button> </button>
<button <button
title="Down" :title="t('down')"
class="relative w-2" class="relative w-2"
style="font-size: 10px; margin-top: -4px" style="font-size: 10px; margin-top: -4px"
@click.prevent.stop="[move(submitter, 1), $refs.label.focus()] " @click.prevent.stop="[move(submitter, 1), $refs.label.focus()] "
@@ -189,7 +191,7 @@
</div> </div>
</a> </a>
</li> </li>
<li v-if="submitters.length < 10 && editable && allowAddNew"> <li v-if="submitters.length < names.length && editable && allowAddNew">
<a <a
href="#" href="#"
class="flex px-2" class="flex px-2"
@@ -253,10 +255,15 @@ export default {
type: String, type: String,
required: true required: true
}, },
menuStyle: {
type: Object,
required: false,
default: () => ({})
},
menuClasses: { menuClasses: {
type: String, type: String,
required: false, required: false,
default: 'dropdown-content menu p-2 shadow bg-base-100 rounded-box w-full z-10' default: 'dropdown-content menu p-2 shadow rounded-box w-full z-10'
} }
}, },
emits: ['update:model-value', 'remove', 'new-submitter', 'name-change'], emits: ['update:model-value', 'remove', 'new-submitter', 'name-change'],
@@ -268,6 +275,16 @@ export default {
computed: { computed: {
colors () { colors () {
return [ return [
'bg-red-500',
'bg-sky-500',
'bg-emerald-500',
'bg-yellow-300',
'bg-purple-600',
'bg-pink-500',
'bg-cyan-500',
'bg-orange-500',
'bg-lime-500',
'bg-indigo-500',
'bg-red-500', 'bg-red-500',
'bg-sky-500', 'bg-sky-500',
'bg-emerald-500', 'bg-emerald-500',
@@ -291,7 +308,17 @@ export default {
this.t('seventh_party'), this.t('seventh_party'),
this.t('eighth_party'), this.t('eighth_party'),
this.t('ninth_party'), this.t('ninth_party'),
this.t('tenth_party') this.t('tenth_party'),
this.t('eleventh_party'),
this.t('twelfth_party'),
this.t('thirteenth_party'),
this.t('fourteenth_party'),
this.t('fifteenth_party'),
this.t('sixteenth_party'),
this.t('seventeenth_party'),
this.t('eighteenth_party'),
this.t('nineteenth_party'),
this.t('twentieth_party')
] ]
}, },
selectedSubmitter () { selectedSubmitter () {
+23 -4
View File
@@ -22,11 +22,12 @@
v-if="editable && renderDropdown" v-if="editable && renderDropdown"
tabindex="0" tabindex="0"
class="dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10 mb-3" class="dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10 mb-3"
:style="menuStyle"
:class="menuClasses" :class="menuClasses"
@click="closeDropdown" @click="closeDropdown"
> >
<template <template
v-for="(icon, type) in fieldIcons" v-for="(icon, type) in fieldIconsSorted"
:key="type" :key="type"
> >
<li v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')"> <li v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')">
@@ -50,7 +51,7 @@
</template> </template>
<script> <script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp } from '@tabler/icons-vue' import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1 } from '@tabler/icons-vue'
export default { export default {
name: 'FiledTypeDropdown', name: 'FiledTypeDropdown',
@@ -65,6 +66,11 @@ export default {
required: false, required: false,
default: 'mt-1.5 bg-base-100' default: 'mt-1.5 bg-base-100'
}, },
menuStyle: {
type: Object,
required: false,
default: () => ({})
},
buttonClasses: { buttonClasses: {
type: String, type: String,
required: false, required: false,
@@ -94,6 +100,7 @@ export default {
signature: this.t('signature'), signature: this.t('signature'),
initials: this.t('initials'), initials: this.t('initials'),
date: this.t('date'), date: this.t('date'),
number: this.t('number'),
image: this.t('image'), image: this.t('image'),
file: this.t('file'), file: this.t('file'),
select: this.t('select'), select: this.t('select'),
@@ -112,17 +119,29 @@ export default {
signature: IconWritingSign, signature: IconWritingSign,
initials: IconLetterCaseUpper, initials: IconLetterCaseUpper,
date: IconCalendarEvent, date: IconCalendarEvent,
number: IconSquareNumber1,
image: IconPhoto, image: IconPhoto,
file: IconPaperclip,
checkbox: IconCheckbox, checkbox: IconCheckbox,
multiple: IconChecks,
file: IconPaperclip,
radio: IconCircleDot, radio: IconCircleDot,
select: IconSelect, select: IconSelect,
multiple: IconChecks,
cells: IconColumns3, cells: IconColumns3,
stamp: IconRubberStamp, stamp: IconRubberStamp,
payment: IconCreditCard, payment: IconCreditCard,
phone: IconPhoneCheck phone: IconPhoneCheck
} }
},
fieldIconsSorted () {
if (this.fieldTypes.length) {
return this.fieldTypes.reduce((acc, type) => {
acc[type] = this.fieldIcons[type]
return acc
}, {})
} else {
return this.fieldIcons
}
} }
}, },
methods: { methods: {
+122 -72
View File
@@ -2,9 +2,10 @@
<div :class="withStickySubmitters ? 'sticky top-0 z-10' : ''"> <div :class="withStickySubmitters ? 'sticky top-0 z-10' : ''">
<FieldSubmitter <FieldSubmitter
:model-value="selectedSubmitter.uuid" :model-value="selectedSubmitter.uuid"
class="w-full rounded-lg" class="roles-dropdown w-full rounded-lg"
:class="{ 'bg-base-100': withStickySubmitters }" :style="withStickySubmitters ? { backgroundColor } : {}"
:submitters="submitters" :submitters="submitters"
:menu-style="{ backgroundColor: ['', null, 'transparent'].includes(backgroundColor) ? 'white' : backgroundColor }"
:editable="editable && !defaultSubmitters.length" :editable="editable && !defaultSubmitters.length"
@new-submitter="save" @new-submitter="save"
@remove="removeSubmitter" @remove="removeSubmitter"
@@ -14,7 +15,7 @@
</div> </div>
<div <div
ref="fields" ref="fields"
class="mb-1 mt-2" class="fields mb-1 mt-2"
@dragover.prevent="onFieldDragover" @dragover.prevent="onFieldDragover"
@drop="reorderFields" @drop="reorderFields"
> >
@@ -24,64 +25,106 @@
:data-uuid="field.uuid" :data-uuid="field.uuid"
:field="field" :field="field"
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)" :type-index="fields.filter((f) => f.type === field.type).indexOf(field)"
:editable="editable && (!dragField || dragField !== field)" :editable="editable && (!fieldsDragFieldRef.value || fieldsDragFieldRef.value !== field)"
:default-field="defaultFields.find((f) => f.name === field.name)" :default-field="defaultFields.find((f) => f.name === field.name)"
:draggable="editable" :draggable="editable"
@dragstart="dragField = field" @dragstart="fieldsDragFieldRef.value = field"
@dragend="dragField = null" @dragend="fieldsDragFieldRef.value = null"
@remove="removeField" @remove="removeField"
@scroll-to="$emit('scroll-to-area', $event)" @scroll-to="$emit('scroll-to-area', $event)"
@set-draw="$emit('set-draw', $event)" @set-draw="$emit('set-draw', $event)"
/> />
</div> </div>
<div v-if="submitterDefaultFields.length"> <div v-if="submitterDefaultFields.length && editable">
<hr class="mb-2"> <hr class="mb-2">
<template <template v-if="isShowFieldSearch">
v-for="field in submitterDefaultFields" <input
:key="field.name" v-model="defaultFieldsSearch"
:placeholder="t('search_field')"
class="input input-ghost input-xs px-0 text-base mb-2 !outline-0 !rounded bg-transparent w-full"
>
<hr class="mb-2">
</template>
<div
class="overflow-auto relative"
:style="{
maxHeight: isShowFieldSearch ? '210px' : '',
minHeight: isShowFieldSearch ? '210px' : ''
}"
> >
<div <div
:style="{ backgroundColor: backgroundColor }" v-if="!filteredSubmitterDefaultFields.length && defaultFieldsSearch"
draggable="true" class="top-0 bottom-0 text-center absolute flex items-center justify-center w-full flex-col"
class="border border-base-300 rounded rounded-tr-none relative group mb-2"
@dragstart="onDragstart({ type: 'text', ...field })"
@dragend="$emit('drag-end')"
> >
<div class="flex items-center justify-between relative cursor-grab"> <div>
<div class="flex items-center p-1 space-x-1"> {{ t('field_not_found') }}
<IconDrag /> </div>
<FieldType <a
:model-value="field.type || 'text'" href="#"
:editable="false" class="link"
:button-width="20" @click.prevent="defaultFieldsSearch = ''"
/> >
<span class="block pl-0.5"> {{ t('clear') }}
{{ field.name }} </a>
</div>
<template
v-for="field in filteredSubmitterDefaultFields"
:key="field.name"
>
<div
:style="{ backgroundColor }"
draggable="true"
class="default-field border border-base-300 rounded rounded-tr-none relative group mb-2"
@dragstart="onDragstart({ type: 'text', ...field })"
@dragend="$emit('drag-end')"
>
<div class="flex items-center justify-between relative cursor-grab">
<div class="flex items-center p-1 space-x-1">
<IconDrag />
<FieldType
:model-value="field.type || 'text'"
:editable="false"
:button-width="20"
/>
<span class="block pl-0.5">
{{ field.title || field.name }}
</span>
</div>
<span
v-if="defaultRequiredFields.includes(field)"
:data-tip="t('required')"
class="text-red-400 text-3xl pr-1.5 tooltip tooltip-left h-8"
>
*
</span> </span>
</div> </div>
</div> </div>
</div> </template>
</template> </div>
</div> </div>
<div <div
v-if="editable && !onlyDefinedFields" v-if="editable && !onlyDefinedFields"
class="grid grid-cols-3 gap-1 pb-2" class="grid grid-cols-3 gap-1 pb-2"
> >
<template <template
v-for="(icon, type) in fieldIcons" v-for="(icon, type) in fieldIconsSorted"
:key="type" :key="type"
> >
<button <button
v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')" v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')"
draggable="true" draggable="true"
class="group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative" class="field-type-button group flex items-center justify-center border border-dashed w-full rounded relative"
:style="{ backgroundColor: backgroundColor }" :style="{ backgroundColor }"
:class="drawFieldType === type ? 'border-base-content/40' : 'border-base-300 hover:border-base-content/20'"
@dragstart="onDragstart({ type: type })" @dragstart="onDragstart({ type: type })"
@dragend="$emit('drag-end')" @dragend="$emit('drag-end')"
@click="addField(type)" @click="['file', 'payment'].includes(type) ? $emit('add-field', type) : $emit('set-draw-type', type)"
> >
<div class="flex items-console group-hover:bg-base-200/50 transition-all cursor-grab h-full absolute left-0"> <div
<IconDrag class=" my-auto" /> class="flex items-console transition-all cursor-grab h-full absolute left-0"
:class="drawFieldType === type ? 'bg-base-200/50' : 'group-hover:bg-base-200/50'"
>
<IconDrag class="my-auto" />
</div> </div>
<div class="flex items-center flex-col px-2 py-2"> <div class="flex items-center flex-col px-2 py-2">
<component :is="icon" /> <component :is="icon" />
@@ -93,14 +136,14 @@
<div <div
v-else-if="type == 'phone' && (fieldTypes.length === 0 || fieldTypes.includes(type))" v-else-if="type == 'phone' && (fieldTypes.length === 0 || fieldTypes.includes(type))"
class="tooltip tooltip-bottom flex" class="tooltip tooltip-bottom flex"
:class="{'tooltip-bottom-start': !withPayment, 'tooltip-bottom': withPayment }" :class="{'tooltip-bottom-end': withPayment, 'tooltip-bottom': !withPayment }"
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification." data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
> >
<a <a
href="https://www.docuseal.co/pricing" href="https://www.docuseal.co/pricing"
target="_blank" target="_blank"
class="opacity-50 flex items-center justify-center border border-dashed border-base-300 w-full rounded relative" class="opacity-50 flex items-center justify-center border border-dashed border-base-300 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }" :style="{ backgroundColor }"
> >
<div class="w-0 absolute left-0"> <div class="w-0 absolute left-0">
<IconLock <IconLock
@@ -139,10 +182,9 @@
<script> <script>
import Field from './field' import Field from './field'
import { v4 } from 'uuid'
import FieldType from './field_type' import FieldType from './field_type'
import FieldSubmitter from './field_submitter' import FieldSubmitter from './field_submitter'
import { IconLock } from '@tabler/icons-vue' import { IconLock, IconCirclePlus } from '@tabler/icons-vue'
import IconDrag from './icon_drag' import IconDrag from './icon_drag'
export default { export default {
@@ -150,11 +192,12 @@ export default {
components: { components: {
Field, Field,
FieldType, FieldType,
IconCirclePlus,
FieldSubmitter, FieldSubmitter,
IconDrag, IconDrag,
IconLock IconLock
}, },
inject: ['save', 'backgroundColor', 'withPhone', 'withPayment', 't'], inject: ['save', 'backgroundColor', 'withPhone', 'withPayment', 't', 'fieldsDragFieldRef'],
props: { props: {
fields: { fields: {
type: Array, type: Array,
@@ -175,11 +218,21 @@ export default {
required: false, required: false,
default: () => [] default: () => []
}, },
defaultRequiredFields: {
type: Array,
required: false,
default: () => []
},
onlyDefinedFields: { onlyDefinedFields: {
type: Boolean, type: Boolean,
required: false, required: false,
default: true default: true
}, },
drawFieldType: {
type: String,
required: false,
default: ''
},
defaultSubmitters: { defaultSubmitters: {
type: Array, type: Array,
required: false, required: false,
@@ -204,15 +257,29 @@ export default {
required: true required: true
} }
}, },
emits: ['set-draw', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter'], emits: ['add-field', 'set-draw', 'set-draw-type', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter'],
data () { data () {
return { return {
dragField: null defaultFieldsSearch: ''
} }
}, },
computed: { computed: {
fieldNames: FieldType.computed.fieldNames, fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons, fieldIcons: FieldType.computed.fieldIcons,
isShowFieldSearch () {
return this.submitterDefaultFields.length > 15
},
fieldIconsSorted () {
if (this.fieldTypes.length) {
return this.fieldTypes.reduce((acc, type) => {
acc[type] = this.fieldIcons[type]
return acc
}, {})
} else {
return this.fieldIcons
}
},
submitterFields () { submitterFields () {
return this.fields.filter((f) => f.submitter_uuid === this.selectedSubmitter.uuid) return this.fields.filter((f) => f.submitter_uuid === this.selectedSubmitter.uuid)
}, },
@@ -220,6 +287,13 @@ export default {
return this.defaultFields.filter((f) => { return this.defaultFields.filter((f) => {
return !this.submitterFields.find((field) => field.name === f.name) && (!f.role || f.role === this.selectedSubmitter.name) return !this.submitterFields.find((field) => field.name === f.name) && (!f.role || f.role === this.selectedSubmitter.name)
}) })
},
filteredSubmitterDefaultFields () {
if (this.defaultFieldsSearch) {
return this.submitterDefaultFields.filter((f) => f.name.toLowerCase().includes(this.defaultFieldsSearch.toLowerCase()))
} else {
return this.submitterDefaultFields
}
} }
}, },
methods: { methods: {
@@ -228,7 +302,7 @@ export default {
}, },
onFieldDragover (e) { onFieldDragover (e) {
const targetField = e.target.closest('[data-uuid]') const targetField = e.target.closest('[data-uuid]')
const dragField = this.$refs.fields.querySelector(`[data-uuid="${this.dragField.uuid}"]`) const dragField = this.$refs.fields.querySelector(`[data-uuid="${this.fieldsDragFieldRef.value.uuid}"]`)
if (dragField && targetField && targetField !== dragField) { if (dragField && targetField && targetField !== dragField) {
const fields = Array.from(this.$refs.fields.children) const fields = Array.from(this.$refs.fields.children)
@@ -272,37 +346,13 @@ export default {
removeField (field) { removeField (field) {
this.fields.splice(this.fields.indexOf(field), 1) this.fields.splice(this.fields.indexOf(field), 1)
this.save() this.fields.forEach((f) => {
}, (f.conditions || []).forEach((c) => {
addField (type, area = null) { if (c.field_uuid === field.uuid) {
const field = { f.conditions.splice(f.conditions.indexOf(c), 1)
name: '', }
uuid: v4(), })
required: type !== 'checkbox', })
areas: [],
submitter_uuid: this.selectedSubmitter.uuid,
type
}
if (['select', 'multiple', 'radio'].includes(type)) {
field.options = [{ value: '', uuid: v4() }]
}
if (type === 'stamp') {
field.readonly = true
}
if (type === 'date') {
field.preferences = {
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
}
}
this.fields.push(field)
if (!['payment', 'file'].includes(type)) {
this.$emit('set-draw', { field })
}
this.save() this.save()
} }
@@ -0,0 +1,236 @@
<template>
<div
class="modal modal-open items-start !animate-none overflow-y-auto"
>
<div
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span>
{{ t('formula') }} - {{ field.name || buildDefaultName(field, template.fields) }}
</span>
<a
href="#"
class="text-xl"
@click.prevent="$emit('close')"
>&times;</a>
</div>
<div>
<div
v-if="!withFormula"
class="bg-base-300 rounded-xl py-2 px-3 text-center"
>
<a
href="https://www.docuseal.co/pricing"
target="_blank"
class="link"
>Available in Pro</a>
</div>
<div class="flex-inline mb-2 gap-2 space-y-1">
<button
v-for="f in fields"
:key="f.uuid"
class="mr-1 btn btn-neutral btn-outline border-base-content/20 btn-sm normal-case font-normal bg-white !rounded-xl"
@click.prevent="insertTextUnderCursor(`{{${f.name || buildDefaultName(f, template.fields)}}}`)"
>
<IconCodePlus
width="20"
height="20"
stroke-width="1.5"
/>
{{ f.name || buildDefaultName(f, template.fields) }}
</button>
</div>
<div>
<div class="flex">
<textarea
ref="textarea"
v-model="formula"
class="base-textarea !rounded-xl !text-base font-mono w-full !outline-0 !ring-0 !px-3"
:readonly="!editable"
required="true"
@input="resizeTextarea"
/>
</div>
<div class="mb-3 mt-1">
<div
target="blank"
class="text-sm mb-2 inline space-x-2"
>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor(' + ')"
>
+
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor(' - ')"
>
-
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor(' * ')"
>
*
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor(' / ')"
>
/
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor(' % ')"
>
%
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor('^')"
>
^
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor('round()')"
>
round(n, d)
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor('abs()')"
>
abs(n)
</button>
</div>
</div>
</div>
<button
class="base-button w-full"
@click.prevent="validateSaveAndClose"
>
{{ t('save') }}
</button>
</div>
</div>
</div>
</template>
<script>
import { IconCodePlus } from '@tabler/icons-vue'
export default {
name: 'FormulaModal',
components: {
IconCodePlus
},
inject: ['t', 'save', 'template', 'withFormula'],
props: {
field: {
type: Object,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
buildDefaultName: {
type: Function,
required: true
}
},
emits: ['close'],
data () {
return {
formula: ''
}
},
computed: {
fields () {
return this.template.fields.reduce((acc, f) => {
if (f !== this.field && f.submitter_uuid === this.field.submitter_uuid && ['number'].includes(f.type) && !f.preferences?.formula) {
acc.push(f)
}
return acc
}, [])
}
},
created () {
this.field.preferences ||= {}
},
mounted () {
this.formula = this.humanizeFormula(this.field.preferences.formula || '')
},
methods: {
humanizeFormula (text) {
return text.replace(/{{(.*?)}}/g, (match, uuid) => {
const foundField = this.fields.find((f) => f.uuid === uuid)
if (foundField) {
return `{{${foundField.name || this.buildDefaultName(foundField, this.template.fields)}}}`
} else {
return '{{FIELD NOT FOUND}}'
}
})
},
normalizeFormula (text) {
return text.replace(/{{(.*?)}}/g, (match, name) => {
const foundField = this.fields.find((f) => {
return (f.name || this.buildDefaultName(f, this.template.fields)).trim() === name.trim()
})
if (foundField) {
return `{{${foundField.uuid}}}`
} else {
return '{{FIELD NOT FOUND}}'
}
})
},
validateSaveAndClose () {
if (!this.withFormula) {
return alert('Available only in Pro')
}
const normalizedFormula = this.normalizeFormula(this.formula)
if (normalizedFormula.includes('FIELD NOT FOUND')) {
alert('Some fields are missing in the formula.')
} else {
this.field.preferences.formula = normalizedFormula
this.field.readonly = !!normalizedFormula
this.save()
this.$emit('close')
}
},
insertTextUnderCursor (textToInsert) {
const textarea = this.$refs.textarea
const selectionEnd = textarea.selectionEnd
const cursorPos = selectionEnd
const newText = textarea.value.substring(0, cursorPos) + textToInsert + textarea.value.substring(cursorPos)
this.formula = newText
textarea.setSelectionRange(cursorPos + textToInsert.length, cursorPos + textToInsert.length)
textarea.focus()
},
resizeTextarea () {
const textarea = this.$refs.textarea
textarea.style.height = 'auto'
textarea.style.height = textarea.scrollHeight + 'px'
}
}
}
</script>
+45
View File
@@ -1,9 +1,41 @@
const en = { const en = {
search_field: 'Search field',
field_not_found: 'Field not found',
clear: 'Clear',
align: 'Align',
add_all_required_fields_to_continue: 'Add all required fields to continue',
left: 'Left',
right: 'Right',
center: 'Center',
description: 'Description',
display_title: 'Display title',
with_logo: 'With logo',
unchecked: 'Unchecked',
equal: 'Equal',
not_equal: 'Not equal',
contains: 'Contains',
does_not_contain: 'Does not contain',
not_empty: 'Not empty',
empty: 'Empty',
select_field_: 'Select field...',
select_value_: 'Select value...',
remove_condition: 'Remove condition',
are_you_sure: 'Are you sure?', are_you_sure: 'Are you sure?',
sign_yourself: 'Sign Yourself', sign_yourself: 'Sign Yourself',
set_signing_date: 'Set signing date',
send: 'Send', send: 'Send',
remove: 'Remove',
edit: 'Edit',
settings: 'Settings',
up: 'Up',
down: 'Down',
checked: 'Checked',
save: 'Save', save: 'Save',
cancel: 'Cancel', cancel: 'Cancel',
any: 'Any',
drawn: 'Drawn',
formula: 'Formula',
typed: 'Typed',
draw_field_on_the_document: 'Draw {field} field on the document', draw_field_on_the_document: 'Draw {field} field on the document',
click_to_upload: 'Click to upload', click_to_upload: 'Click to upload',
or_drag_and_drop_files: 'or drag and drop files', or_drag_and_drop_files: 'or drag and drop files',
@@ -20,6 +52,7 @@ const en = {
copy_to_all_pages: 'Copy to All Pages', copy_to_all_pages: 'Copy to All Pages',
add_option: 'Add option', add_option: 'Add option',
option: 'Option', option: 'Option',
condition: 'Condition',
first_party: 'First Party', first_party: 'First Party',
second_party: 'Second Party', second_party: 'Second Party',
third_party: 'Third Party', third_party: 'Third Party',
@@ -30,9 +63,21 @@ const en = {
eighth_party: 'Eighth Party', eighth_party: 'Eighth Party',
ninth_party: 'Ninth Party', ninth_party: 'Ninth Party',
tenth_party: 'Tenth Party', tenth_party: 'Tenth Party',
eleventh_party: 'Eleventh Party',
twelfth_party: 'Twelfth Party',
thirteenth_party: 'Thirteenth Party',
fourteenth_party: 'Fourteenth Party',
fifteenth_party: 'Fifteenth Party',
sixteenth_party: 'Sixteenth Party',
seventeenth_party: 'Seventeenth Party',
eighteenth_party: 'Eighteenth Party',
nineteenth_party: 'Nineteenth Party',
twentieth_party: 'Twentieth Party',
draw: 'Draw',
add: 'Add', add: 'Add',
or_add_field_without_drawing: 'Or add field without drawing', or_add_field_without_drawing: 'Or add field without drawing',
text: 'Text', text: 'Text',
number: 'Number',
signature: 'Signature', signature: 'Signature',
initials: 'Initials', initials: 'Initials',
date: 'Date', date: 'Date',
@@ -0,0 +1,412 @@
<template>
<div>
<div v-if="selectedSheetIndex === null && spreadsheet">
<form @submit.prevent="[selectedSheetIndex = $refs.selectWorksheet.value, buildDefaultMappings()]">
<label class="label">
Select Worksheet
</label>
<select
ref="selectWorksheet"
class="base-select"
>
<option
v-for="(sheet, index) in spreadsheet"
:key="index"
:value="index"
>
{{ sheet[0] || index }}
</option>
</select>
<button class="base-button mt-4 w-full">
Open
</button>
</form>
</div>
<div v-else-if="selectedSheetIndex !== null">
<div
v-for="submitter in submitters"
:key="submitter.uuid"
class="mb-4"
>
<div
v-if="submitters.length > 1"
class="px-3 border-y py-2 border-base-300 text-center w-full"
>
{{ submitter.name }}
</div>
<div class="flex">
<div class="relative w-full py-2 px-2 text-sm">
Recipient field
</div>
<div class="relative w-full py-2 pl-4 text-sm">
Spreadsheet column
</div>
</div>
<div
v-for="mapping in mappings.filter((m) => m.submitter_uuid === submitter.uuid)"
:key="mapping.uuid"
class="mb-2"
>
<div class="flex">
<select
class="base-select !select-sm !h-10"
required
@change="mapping.field_name = $event.target.value"
>
<option
disabled
value=""
:selected="!mapping.field_name"
>
Select Field
</option>
<option
v-for="(field, index) in selectFieldsForSubmitter(submitter)"
:key="index"
:value="field.name"
:selected="mapping.field_name === field.name"
>
{{ field.name }}
</option>
</select>
<div class="flex items-center px-1">
<IconArrowsHorizontal style="width: 19px; height: 19px" />
</div>
<div class="w-full relative">
<select
class="base-select !select-sm !h-10"
required
@change="mapping.column_index = parseInt($event.target.value)"
>
<option
disabled
value=""
:selected="mapping.column_index == null"
>
Select Column
</option>
<template
v-for="(column, index) in columns"
:key="index"
>
<option
v-if="column"
:value="index"
:selected="index === mapping.column_index"
>
{{ column }}
</option>
</template>
</select>
<div
v-if="mapping.column_index != null"
class="absolute top-0 bottom-0 right-1 flex items-center"
>
<span
class="tooltip tooltip-bottom-end pr-1 tooltip-pre"
style="padding-top: 2px"
:data-tip="[0, 1, 2].map((i) => rows[i]?.[mapping.column_index] ?? '---').join('\n')"
>
<button
class="btn btn-xs btn-circle bg-white border-0 border-gray-300"
@click.prevent
>
<IconInfoCircle class="h-4 w-4" />
</button>
</span>
</div>
</div>
<div class="flex items-center pl-1">
<span
class="tooltip tooltip-top"
data-tip="Remove"
>
<button
:disabled="mappings.filter((m) => m.submitter_uuid === submitter.uuid).length < 2"
class="btn btn-xs btn-circle"
@click.prevent="mappings.splice(mappings.indexOf(mapping), 1)"
>
<IconX class="h-3.5 w-3.5" />
</button>
</span>
</div>
</div>
</div>
<div>
<button
class="btn btn-sm btn-primary w-full !normal-case font-medium"
@click.prevent="addMapping(submitter)"
>
<IconPlus class="w-4 h-4" />
New Field Mapping
</button>
</div>
</div>
<div>
<input
name="submissions_json"
hidden
:value="JSON.stringify(submissionsData.slice(0, 1100))"
>
</div>
<div
class="px-3 border-y py-2 border-base-300 text-center w-full text-sm font-semibold"
>
Total entries: {{ submissionsData.length }}
<template v-if="submissionsData.length >= 1000">
/ 1000
</template>
</div>
</div>
<div
v-else
>
<div
class="flex h-52 w-full"
@dragover.prevent
@drop.prevent="onDropFiles"
>
<label
class="w-full relative bg-base-200/20 hover:bg-base-200/30 rounded-md border border-2 border-base-content/10 border-dashed"
for="import_list_file"
:class="{ 'opacity-50': isLoading }"
>
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
<div class="flex flex-col items-center">
<IconInnerShadowTop
v-if="isLoading"
class="animate-spin"
:width="40"
:height="40"
/>
<IconCloudUpload
v-else
:width="40"
:height="40"
/>
<div
class="font-medium text-lg mb-1"
>
Upload CSV or XLSX Spreadsheet
</div>
<div class="text-sm">
<span class="font-medium">Click to Upload</span> or drag and drop files.
</div>
</div>
</div>
<form
ref="form"
class="hidden"
>
<input
id="import_list_file"
ref="input"
type="file"
name="file"
accept=".xlsx, .xls, .csv"
@change="onSelectFile"
>
</form>
</label>
</div>
<div class="text-center mt-2">
Or <a
:download="`${template.name}.csv`"
:href="`data:text/csv;base64,${csvBase64}`"
class="link font-medium"
>download</a> a spreadsheet to fill and import
</div>
</div>
</div>
</template>
<script>
import { IconCloudUpload, IconX, IconPlus, IconArrowsHorizontal, IconInfoCircle, IconInnerShadowTop } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
name: 'FileDropzone',
components: {
IconCloudUpload,
IconX,
IconArrowsHorizontal,
IconPlus,
IconInfoCircle,
IconInnerShadowTop
},
props: {
template: {
type: Object,
required: true
},
authenticityToken: {
type: String,
required: false,
default: ''
}
},
data () {
return {
isLoading: false,
spreadsheet: null,
selectedSheetIndex: null,
mappings: []
}
},
computed: {
table () {
return this.spreadsheet[this.selectedSheetIndex][1]
},
submissionsData () {
const submissions = []
this.rows.forEach((row) => {
const submittersIndex = {}
this.mappings.forEach((mapping) => {
if (mapping.field_name && mapping.column_index != null) {
submittersIndex[mapping.submitter_uuid] ||= { uuid: mapping.submitter_uuid, fields: [] }
if (['name', 'email', 'phone'].includes(mapping.field_name.toLowerCase())) {
submittersIndex[mapping.submitter_uuid][mapping.field_name.toLowerCase()] = row[mapping.column_index]
} else {
submittersIndex[mapping.submitter_uuid].fields.push({
name: mapping.field_name, default_value: row[mapping.column_index], readonly: true
})
}
}
})
if (Object.keys(submittersIndex).length !== 0) {
submissions.push({ submitters: Object.values(submittersIndex) })
}
})
return submissions
},
csvBase64 () {
const rows = []
this.submitters.forEach((submitter) => {
this.selectFieldsForSubmitter(submitter).forEach((field) => {
rows.push(this.submitters.length > 1 ? `${submitter.name} - ${field.name}` : field.name)
})
})
const csv = rows.map(item => {
if (/[",\n]/.test(item)) {
return `"${item.replace(/"/g, '""')}"`
} else {
return item
}
}).join(',')
return window.btoa(window.unescape(window.encodeURIComponent(csv + '\n' + rows.map(() => '').join(',') + '\n')))
},
submitters () {
return this.template.submitters
},
columns () {
return this.table[0]
},
form () {
return this.$el.closest('form')
},
fieldTypes () {
return ['text', 'cells', 'date', 'number', 'radio', 'select', 'checkbox']
},
defaultFields () {
return [
{ name: 'Name' },
{ name: 'Email' },
{ name: 'Phone' }
]
},
rows () {
return this.table.slice(1)
}
},
watch: {
selectedSheetIndex (value) {
if (value !== null) {
document.getElementById('list_form_buttons')?.classList?.remove('hidden')
}
}
},
methods: {
onDropFiles (e) {
this.uploadFile(e.dataTransfer.files[0])
},
onSelectFile (e) {
this.uploadFile(e.target.files[0])
},
addMapping (submitter) {
this.mappings.push({ uuid: v4(), field_name: '', column_index: null, submitter_uuid: submitter.uuid })
},
selectFieldsForSubmitter (submitter) {
const templateFields = this.template.fields.filter((field) => {
return field.submitter_uuid === submitter.uuid &&
field.name &&
this.fieldTypes.includes(field.type) &&
this.defaultFields.every((f) => field.name?.toLowerCase() !== f.name?.toLowerCase())
})
return [...this.defaultFields, ...templateFields]
},
buildDefaultMappings () {
this.submitters.forEach((submitter) => {
const fields = this.selectFieldsForSubmitter(submitter)
fields.forEach((field) => {
const columnIndex = this.columns.findIndex((column, index) => {
return column &&
column.toString().toLowerCase().includes(field.name?.toLowerCase()) &&
this.mappings.every((m) => m.column_index !== index)
})
if (columnIndex !== -1) {
this.mappings.push({ uuid: v4(), field_name: field.name, column_index: columnIndex, submitter_uuid: submitter.uuid })
}
})
if (!this.mappings.some((m) => m.field_name.toLowerCase() === 'name' && m.submitter_uuid === submitter.uuid)) {
this.mappings.unshift({ uuid: v4(), field_name: 'Name', submitter_uuid: submitter.uuid })
}
if (!this.mappings.some((m) => m.field_name.toLowerCase() === 'email' && m.submitter_uuid === submitter.uuid)) {
this.mappings.unshift({ uuid: v4(), field_name: 'Email', submitter_uuid: submitter.uuid })
}
})
},
uploadFile (file) {
this.isLoading = true
const formData = new FormData()
formData.append('file', file)
return fetch('/upload_spreadsheet', {
method: 'POST',
body: formData,
headers: {
'X-CSRF-Token': this.authenticityToken
}
}).then(resp => resp.json()).then((data) => {
if (data.error) {
return alert(data.error)
}
this.spreadsheet = data
if (data.length === 1) {
this.selectedSheetIndex = 0
this.buildDefaultMappings()
}
}).finally(() => {
this.isLoading = false
})
}
}
}
</script>
Loaded 100 of 276 files, more files were not shown because too many files have changed in this diff. Show more