Compare commits

...
343 Commits
Author SHA1 Message Date
Oleksandr Turchyn 8b035f80b3 fix i18n typos 2024-09-23 11:45:07 +03:00
Pete Matsyburka c717293306 fix form preview 2024-09-23 11:45:07 +03:00
Pete Matsyburka b3f3cf2aa6 fix label 2024-09-23 11:45:07 +03:00
Pete Matsyburka 7406334f77 adjust i18n 2024-09-23 11:45:07 +03:00
Pete Matsyburka 3e1350a221 upgrade puma 2024-09-23 11:45:07 +03:00
Pete Matsyburka 961e0a1c89 fix i18n 2024-09-23 11:45:07 +03:00
Pete Matsyburka 2ce9bbecb9 fix cells field 2024-09-23 11:45:07 +03:00
Pete Matsyburka 704da3a2ec adjust email edit button 2024-09-23 11:45:07 +03:00
Pete Matsyburka 2f530ccfca i18n emails 2024-09-23 11:45:07 +03:00
Pete Matsyburka e6c20f5ff6 adjust submit button 2024-09-23 11:45:07 +03:00
Pete Matsyburka d267978437 set emails copy duration 2024-09-23 11:45:07 +03:00
Oleksandr Turchyn e9d728fa4a add i18n 2024-09-23 11:45:07 +03:00
Pete Matsyburka 3c60fb4ee6 adjust show continue 2024-09-23 11:45:07 +03:00
Oleksandr Turchyn f1e494b669 adjust wording 2024-09-23 11:45:07 +03:00
Pete Matsyburka f7a8e4102b fix scroll 2024-09-23 11:45:07 +03:00
dependabot[bot] 311c2b0e34 Bump devise-two-factor in the bundler group across 1 directory
Bumps the bundler group with 1 update in the / directory: [devise-two-factor](https://github.com/tinfoil/devise-two-factor).


Updates `devise-two-factor` from 5.0.0 to 6.0.0
- [Changelog](https://github.com/devise-two-factor/devise-two-factor/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tinfoil/devise-two-factor/compare/v5.0.0...v6.0.0)

---
updated-dependencies:
- dependency-name: devise-two-factor
  dependency-type: direct:production
  dependency-group: bundler
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-23 11:45:07 +03:00
Pete Matsyburka 5dd59f4f7c optimize image 2024-09-23 11:45:07 +03:00
Pete Matsyburka 571f5d8221 set font style 2024-09-23 11:45:07 +03:00
Pete Matsyburka 136b0eb5ed fix typo 2024-09-23 11:45:07 +03:00
Pete Matsyburka 2d813e184b fix padding 2024-09-16 12:11:20 +03:00
Pete Matsyburka fd23674e87 add decline reason in webhook 2024-09-16 12:11:20 +03:00
Pete Matsyburka 3b9d35acf1 fix tld autocorrection 2024-09-16 12:11:20 +03:00
Pete Matsyburka 17fc6e2f1c reduce textarea size 2024-09-16 12:11:20 +03:00
Oleksandr Turchyn 2e132d8b5a update submission modal 2024-09-16 12:11:20 +03:00
Pete Matsyburka 53bef85a63 adjust cert vars 2024-09-16 12:11:20 +03:00
Pete Matsyburka 99ad6e36ac fix preview preload 2024-09-16 12:11:20 +03:00
Pete Matsyburka bfa41ac35f concurrent preview processing 2024-09-16 12:11:20 +03:00
Pete Matsyburka bc4414a368 use png for previews 2024-09-16 12:11:20 +03:00
Pete Matsyburka 26ae5f1d80 update textarea size 2024-09-16 12:11:20 +03:00
Pete Matsyburka be4962a02a adjust form completion 2024-09-16 12:11:20 +03:00
Pete Matsyburka e72090c2e0 add template form preferences 2024-09-16 12:11:20 +03:00
Pete Matsyburka ad8bca8bf4 fix spec 2024-09-16 12:11:20 +03:00
Pete Matsyburka 22be3b37cf fix complete page 2024-09-16 12:11:20 +03:00
Pete Matsyburka cfc32ee522 set textarea max height 2024-09-16 12:11:20 +03:00
Pete Matsyburka 10fbdf6612 allow to invite party 2024-09-16 12:11:20 +03:00
Pete Matsyburka 3604fb6461 add readonly field attr 2024-09-16 12:11:20 +03:00
Pete Matsyburka 24392d0258 remove redacted 2024-09-16 12:11:20 +03:00
Pete Matsyburka 4ab1065773 adjust default recipients 2024-09-16 12:11:20 +03:00
Pete Matsyburka 50b0f7e5ed adjust default recipients 2024-09-16 12:11:20 +03:00
Pete Matsyburka be4a0505bc assign default submitters 2024-09-09 13:57:46 +03:00
Pete Matsyburka bd853c6265 html escape simple format 2024-09-09 13:57:46 +03:00
Pete Matsyburka 5a1efd0e27 add download utils 2024-09-09 13:57:46 +03:00
Pete Matsyburka 71b8cc7172 add recipient preferences 2024-09-09 13:57:46 +03:00
Pete Matsyburka 126a36dbf3 clear log 2024-09-09 13:57:46 +03:00
Pete Matsyburka 90db64cc03 fix file prefill 2024-09-09 13:57:46 +03:00
Pete Matsyburka 227a067c77 fix readonly condition 2024-09-09 13:57:46 +03:00
Pete Matsyburka dc24df0d4c remove log 2024-09-09 13:57:46 +03:00
Pete Matsyburka 2598962c5b configure image quality 2024-09-09 13:57:46 +03:00
Pete Matsyburka b4884e94f6 add preferences 2024-09-09 13:57:46 +03:00
Pete Matsyburka 4741e64178 fix specs 2024-09-02 20:33:28 +03:00
Pete Matsyburka 25fe35e212 ruby 3.3.4 2024-09-02 20:29:47 +03:00
Pete Matsyburka 92909cea09 clear base64 prefix 2024-09-02 20:11:13 +03:00
Pete Matsyburka b16322dd5b add first name var 2024-09-02 20:11:13 +03:00
dependabot[bot] a04c04141c Bump webpack in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [webpack](https://github.com/webpack/webpack).


Updates `webpack` from 5.82.1 to 5.94.0
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.82.1...v5.94.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-02 20:11:13 +03:00
Pete Matsyburka 90f21d3ed9 add expired condition 2024-09-02 20:11:13 +03:00
Pete Matsyburka f31225e8c2 fix spec 2024-09-02 20:11:13 +03:00
Oleksandr Turchyn eead1b2bc4 add basic API specs 2024-09-02 20:11:13 +03:00
Pete Matsyburka f90a4e5576 adjust document id pdf 2024-09-02 20:11:13 +03:00
Pete Matsyburka 50987ce38b hide app url input 2024-09-02 20:11:13 +03:00
Pete Matsyburka 26113ac3a6 allow signing link with multiple signers 2024-09-02 20:11:13 +03:00
Pete Matsyburka 0b7b72ce16 clear log 2024-09-02 20:11:13 +03:00
Pete Matsyburka 95d48b2808 adjust submissions response 2024-09-02 20:11:13 +03:00
Pete Matsyburka 8fecf94a28 fix typo 2024-09-02 20:11:13 +03:00
Pete Matsyburka 66dd87b290 adjust padding 2024-09-02 20:11:13 +03:00
Pete Matsyburka d20e8cc37d remove checkboxes label 2024-09-02 20:11:13 +03:00
Pete Matsyburka c189e2ed20 clear log 2024-09-02 20:11:13 +03:00
Pete Matsyburka 57f8f45320 archived template preview 2024-09-02 20:11:13 +03:00
Pete Matsyburka 5684baf1e0 add app url var 2024-08-25 16:03:23 +03:00
Pete Matsyburka b6f307690d add combine pdf toggle 2024-08-25 16:03:23 +03:00
Pete Matsyburka 66c44f8ff5 adjust error page 2024-08-25 16:03:23 +03:00
dependabot[bot] 8f3592c481 Bump rexml from 3.3.3 to 3.3.6 in the bundler group across 1 directory
Bumps the bundler group with 1 update in the / directory: [rexml](https://github.com/ruby/rexml).


Updates `rexml` from 3.3.3 to 3.3.6
- [Release notes](https://github.com/ruby/rexml/releases)
- [Changelog](https://github.com/ruby/rexml/blob/master/NEWS.md)
- [Commits](https://github.com/ruby/rexml/compare/v3.3.3...v3.3.6)

---
updated-dependencies:
- dependency-name: rexml
  dependency-type: indirect
  dependency-group: bundler
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-25 16:03:23 +03:00
Pete Matsyburka dc8b1e0741 rescue error 2024-08-25 16:03:23 +03:00
Pete Matsyburka b806a6afc5 disable decline button 2024-08-25 16:03:23 +03:00
Pete Matsyburka d43f8f4dd8 add event data 2024-08-25 16:03:23 +03:00
Pete Matsyburka 37c9bf5741 add reply to 2024-08-25 16:03:23 +03:00
Pete Matsyburka a0d37f516f add audit document id 2024-08-25 16:03:23 +03:00
Pete Matsyburka 2764a18252 decline submission 2024-08-25 16:03:23 +03:00
Pete Matsyburka 96cf228e74 fix upload signature 2024-08-25 16:03:23 +03:00
Pete Matsyburka ff6ff3a51e adjust template resp 2024-08-25 16:03:23 +03:00
Pete Matsyburka 9472a6b5b0 allow to upload initial 2024-08-25 16:03:23 +03:00
Pete Matsyburka 5f3a8bf391 fix condition 2024-08-19 19:51:24 +03:00
Pete Matsyburka a0a0fdd1b5 clean log 2024-08-19 19:51:24 +03:00
Pete Matsyburka 7a6f901b09 add log 2024-08-19 19:51:24 +03:00
Pete Matsyburka c4f05fbd9e remove unique index 2024-08-19 19:51:24 +03:00
Pete Matsyburka 50c342ec5c fix spec 2024-08-19 19:51:24 +03:00
Pete Matsyburka a9cfd2bcfc update submission index 2024-08-19 19:51:24 +03:00
Pete Matsyburka e2bdb2acee use account_id 2024-08-19 19:51:24 +03:00
Pete Matsyburka b8828b503c populate account_id 2024-08-19 19:51:24 +03:00
Pete Matsyburka d63f62109b add submitter account_id 2024-08-19 19:51:24 +03:00
Pete Matsyburka c04483f0ea allow to update external_id 2024-08-19 19:51:24 +03:00
Pete Matsyburka 1efaf30fb7 fix net smtp without creds 2024-08-19 19:51:24 +03:00
Pete Matsyburka 2e462e9633 fix erblint 2024-08-19 19:51:24 +03:00
Pete Matsyburka 838df5f41d fix image documents 2024-08-19 19:51:24 +03:00
Pete Matsyburka 32445f0e19 adjust mobile ui 2024-08-19 19:51:24 +03:00
Pete Matsyburka 5f9067baee fix mobile field types 2024-08-19 19:51:24 +03:00
Pete Matsyburka ad56d5ee14 adjust mobile ui 2024-08-19 19:51:24 +03:00
Pete Matsyburka a091d121d9 confgurable SMTP timeout 2024-08-19 19:51:24 +03:00
Pete Matsyburka cd9c3ee3c1 fix ci 2024-08-19 19:51:24 +03:00
Pete Matsyburka 534c3a2e10 add submission.completed 2024-08-19 19:51:24 +03:00
Pete Matsyburka 7bc8e194df fix font url 2024-08-19 19:51:24 +03:00
Pete Matsyburka de5c4d5bcd adjust retries 2024-08-19 19:51:24 +03:00
dependabot[bot] 1e6b5c684f bump deps 2024-08-10 15:34:13 +03:00
Pete Matsyburka 4651f6b652 simplify variables 2024-08-10 15:34:13 +03:00
Pete Matsyburka e5b0a2355f use aws secret 2024-08-10 15:34:13 +03:00
Pete Matsyburka db22fe7518 fix condition 2024-08-10 15:34:13 +03:00
Pete Matsyburka 0dcd97f49b add verify tool 2024-08-10 15:34:13 +03:00
Pete Matsyburka de14330449 allow to remove page area 2024-08-10 15:34:13 +03:00
Pete Matsyburka 173658138c fix conditions chain 2024-08-10 15:34:13 +03:00
Pete Matsyburka 000f3c72fc fix go to step 2024-08-10 15:34:13 +03:00
Pete Matsyburka 561b16e9fb fix pdf 2024-08-07 17:04:54 +03:00
Pete Matsyburka 507ca49112 adjust ai link 2024-08-07 14:34:32 +03:00
Pete Matsyburka 94d5ab4ace add reason 2024-08-06 00:49:40 +03:00
Pete Matsyburka 91d44a3e31 adjust date format 2024-08-05 19:38:47 +03:00
Pete Matsyburka 478167ea4f add webhook secret 2024-08-05 13:30:04 +03:00
Pete Matsyburka 6b1c3bba19 add testing footer 2024-08-05 10:45:05 +03:00
Pete Matsyburka 73841f751c fix icons 2024-08-04 23:41:32 +03:00
Pete Matsyburka 22dbe3291f adjust compose mount folder 2024-08-04 01:28:22 +03:00
Pete Matsyburka 1dac3fe434 fix cell 2024-08-04 00:13:34 +03:00
Pete Matsyburka d0c49ac04c i18n 2024-08-03 09:26:26 +03:00
Pete Matsyburka 3877dbf83f remove lookbehind regexp 2024-08-02 19:02:58 +03:00
Alex Turchyn d15fd1fb84 allow to view password during registration 2024-08-01 23:31:54 +03:00
Pete Matsyburka 0a105490fe process noreply 2024-08-01 23:22:33 +03:00
Pete Matsyburka 0bd35b526c handle date enter 2024-08-01 18:47:17 +03:00
Pete Matsyburka 3a2910f717 add expire at 2024-08-01 18:34:09 +03:00
Pete Matsyburka c4a91c2b34 extract markdown link 2024-08-01 01:31:25 +03:00
Pete Matsyburka 5643380916 configure page quality 2024-08-01 00:14:33 +03:00
Pete Matsyburka cf6b378ccb adjust payment step 2024-07-31 22:56:18 +03:00
Pete Matsyburka 66bc8cfb5f archive without confirm 2024-07-31 17:57:52 +03:00
Pete Matsyburka 994bd1fe5c dummy script 2024-07-31 10:53:51 +03:00
Pete Matsyburka 12528b6527 fix cell field 2024-07-30 21:18:56 +03:00
Pete Matsyburka d135c93939 fix n+1 2024-07-29 17:23:05 +03:00
Pete Matsyburka 637011d55a remove log 2024-07-29 16:50:25 +03:00
Pete Matsyburka b94e6bf54a adjust font resize 2024-07-26 17:58:00 +03:00
Pete Matsyburka 8f975c2898 fix lang 2024-07-26 17:53:21 +03:00
Pete Matsyburka d25c4fa766 remove log 2024-07-25 15:11:01 +03:00
Pete Matsyburka 43db15b350 add signature id 2024-07-25 09:07:45 +03:00
Pete Matsyburka 09074e2ac0 fix tld 2024-07-24 18:30:21 +03:00
Pete Matsyburka 04295f4540 adjust signature prefill 2024-07-24 09:13:13 +03:00
Pete Matsyburka 11c48a495f update rubocop 2024-07-23 22:42:56 +03:00
Pete Matsyburka 942c961cd1 add it locale 2024-07-22 18:52:40 +03:00
Pete Matsyburka 9193511d5f adjust auto scroll 2024-07-22 17:42:32 +03:00
Pete Matsyburka e0ddf97771 fix rubocop 2024-07-21 17:38:52 +03:00
Pete Matsyburka 2bd249bd5a fix erblint 2024-07-21 15:46:11 +03:00
Pete Matsyburka b72c99dbce add user api 2024-07-21 15:16:55 +03:00
Pete Matsyburka fc0504f91e update rubocop 2024-07-21 15:12:05 +03:00
Alex Turchyn 1da2196b0e add Doorkeeper tables 2024-07-21 15:10:17 +03:00
Pete Matsyburka 4bdb249a2b allow to permanently remove submissions 2024-07-20 22:29:32 +03:00
Pete Matsyburka cb38549480 allow to disable completed email attachments 2024-07-20 21:59:06 +03:00
Pete Matsyburka ee9ee6ee32 adjust attachment create 2024-07-20 21:17:47 +03:00
Pete Matsyburka 748eaaa98d add index on email email event 2024-07-20 09:40:57 +03:00
Pete Matsyburka 9e4ec11e33 adjust signature qr 2024-07-20 09:00:57 +03:00
Pete Matsyburka 93d2b76eae fix form values 2024-07-19 19:30:24 +03:00
Pete Matsyburka 2c8942e888 add form events api 2024-07-19 13:19:56 +03:00
Pete Matsyburka 4424840c81 adjust submissions endpoint 2024-07-19 10:52:16 +03:00
Pete Matsyburka 68392b4e74 add with field placeholder prop 2024-07-19 10:37:01 +03:00
Pete Matsyburka 0b4e345ad6 fix on paste 2024-07-18 10:58:12 +03:00
Pete Matsyburka 1cea5f5a99 fix fields 2024-07-17 22:02:58 +03:00
Pete Matsyburka cdaadbab20 add input mode 2024-07-16 19:41:57 +03:00
Pete Matsyburka d1ec8c6837 use preferences font 2024-07-16 13:18:03 +03:00
Pete Matsyburka cdffa66db1 refactor clone 2024-07-16 08:59:37 +03:00
Pete Matsyburka fca21ab476 fix mm domain 2024-07-15 19:28:57 +03:00
Pete Matsyburka b1d2d92f54 allow to clone template 2024-07-15 15:54:13 +03:00
Pete Matsyburka 10bb9112c7 add with field placeholder option 2024-07-15 14:03:36 +03:00
Pete Matsyburka 58b337a3ee add fields include option 2024-07-15 12:56:02 +03:00
Pete Matsyburka de52f2f5e5 add webhook urls record 2024-07-14 22:53:33 +03:00
Pete Matsyburka f10e941529 adjust field name regexp 2024-07-12 17:44:39 +03:00
Pete Matsyburka 78b1864d7d adjust payment step 2024-07-12 15:25:48 +03:00
Alex Turchyn ab49af9bcb add tool to merge pdf files 2024-07-12 12:25:14 +03:00
Pete Matsyburka 286b8d7067 add heading field 2024-07-12 07:53:09 +03:00
Pete Matsyburka 21c087cba7 fix mark sent 2024-07-11 13:09:35 +03:00
Pete Matsyburka ddd881a52f fix pdf form fields 2024-07-10 21:58:30 +03:00
Pete Matsyburka 12c69578af fix pdf form fields 2024-07-10 17:57:54 +03:00
Pete Matsyburka e96cb5eb8a add blank page button 2024-07-09 23:53:46 +03:00
Pete Matsyburka c01cfe83be add ltv method 2024-07-09 16:41:40 +03:00
Pete Matsyburka b92d1ee38c adjust payment step 2024-07-07 20:33:57 +03:00
Pete Matsyburka b90c67b184 fix cell field font size 2024-07-07 10:22:59 +03:00
Pete Matsyburka edb29977dd skip field description 2024-07-07 09:05:02 +03:00
Alex Turchyn e55c8f251d automatically lower signature font size if text doesn't fit 2024-07-06 09:46:34 +03:00
Pete Matsyburka f1d6bbdc67 fix ci 2024-07-06 09:42:52 +03:00
Pete Matsyburka 8da4c2fa10 adjust link preview 2024-07-05 14:06:37 +03:00
Pete Matsyburka e872fa2497 ruby 3.3.3 2024-07-05 11:45:21 +03:00
Pete Matsyburka 165bfef9b9 remove version from compose file 2024-07-04 22:55:51 +03:00
Pete Matsyburka 8a50c755d2 fix payment field value 2024-07-04 18:25:49 +03:00
Pete Matsyburka 002bc315f6 adjust import 2024-07-04 18:04:36 +03:00
Pete Matsyburka e87522eeab adjust form fields 2024-07-02 22:38:03 +03:00
Pete Matsyburka 377244f948 add form preview 2024-07-02 22:38:02 +03:00
Pete Matsyburka 73423a6f44 fix stamp 2024-07-02 19:11:42 +03:00
Pete Matsyburka d219e3cd7e do not move in preview 2024-07-02 13:17:39 +03:00
Pete Matsyburka 09b0022195 move fields with arrows 2024-07-02 11:41:24 +03:00
Pete Matsyburka 8aac935641 allow to download combined pdf via api 2024-07-01 12:06:05 +03:00
Pete Matsyburka 16e5c13633 fix erblint 2024-07-01 08:05:09 +03:00
Pete Matsyburka fff1831768 add gpt link 2024-06-30 16:21:19 +03:00
Pete Matsyburka f91a318e94 fix drawer 2024-06-29 20:10:28 +03:00
Pete Matsyburka ed06e17b23 adjust field attrs 2024-06-29 11:29:59 +03:00
Pete Matsyburka 63e1be7dfe remove border 2024-06-29 08:52:16 +03:00
Pete Matsyburka 16044612bd fix form fields 2024-06-29 08:44:19 +03:00
Pete Matsyburka 27d8880414 border alert 2024-06-29 00:04:10 +03:00
Pete Matsyburka a0aae6a9de add currencies 2024-06-28 23:34:15 +03:00
Alex Turchyn 749043dfbb process acro form fields 2024-06-28 21:42:39 +03:00
Pete Matsyburka 320fe02e35 remove upload signature button from drawn type 2024-06-28 16:56:51 +03:00
Pete Matsyburka 6f4b9d4a4e double click on builder checkbox 2024-06-28 16:28:37 +03:00
Pete Matsyburka a589cf9471 focus field input 2024-06-27 17:41:08 +03:00
Pete Matsyburka 7fc7283191 copy and paste fields 2024-06-27 15:14:39 +03:00
Pete Matsyburka af03560494 add radio select default value settings 2024-06-27 13:11:48 +03:00
Pete Matsyburka 988d1b93a5 double click to toggle signing date 2024-06-27 12:40:10 +03:00
Pete Matsyburka ab0278198a adjust phone step 2024-06-26 16:18:33 +03:00
Pete Matsyburka fbc649ab59 add extra links 2024-06-26 13:34:47 +03:00
Pete Matsyburka 6b5adc2504 select us date format 2024-06-25 21:49:44 +03:00
Pete Matsyburka 7c4307ef10 fix query 2024-06-25 21:11:48 +03:00
Pete Matsyburka de744e2f6c adjust invite email 2024-06-25 19:05:50 +03:00
Pete Matsyburka c171597c7f adjust max retries 2024-06-25 08:45:38 +03:00
Pete Matsyburka 3034c00bb3 use sidekiq 2024-06-24 21:10:58 +03:00
Pete Matsyburka 9539c476c0 remove legacy tracking param 2024-06-24 14:33:33 +03:00
Pete Matsyburka 4552c72860 add index on external id 2024-06-24 13:27:14 +03:00
Pete Matsyburka 85f8a92721 optimize query 2024-06-23 12:20:01 +03:00
Pete Matsyburka 049b9c78c2 add retries 2024-06-23 09:16:34 +03:00
Pete Matsyburka acf06084bf simplify job params 2024-06-22 10:50:07 +03:00
Pete Matsyburka 61da477733 reorder 2024-06-22 10:50:07 +03:00
Pete Matsyburka fdeec548a3 adjust message 2024-06-22 10:50:07 +03:00
Pete Matsyburka ff0d486831 fix clone template folder 2024-06-22 10:50:07 +03:00
Pete Matsyburka d5bf54ee26 adjust tempalte preferences 2024-06-22 10:50:07 +03:00
Pete Matsyburka 7616774613 use latest alpine 2024-06-21 00:46:11 +03:00
Pete Matsyburka 27c4dc7eca font size in the UI 2024-06-20 22:15:45 +03:00
Pete Matsyburka 4ae834d6f7 skip optional signature 2024-06-20 17:52:40 +03:00
Pete Matsyburka b71a66a082 add font size preference 2024-06-20 16:14:18 +03:00
Pete Matsyburka 80cc38542c fix email attachments 2024-06-18 20:09:58 +03:00
Pete Matsyburka 185aecec42 increase signature size 2024-06-18 17:18:23 +03:00
Pete Matsyburka 8c092eba78 allow to permanently delete submissions 2024-06-18 16:54:15 +03:00
Pete Matsyburka 781ccc701d optimize large files upload 2024-06-16 11:27:24 +03:00
Pete Matsyburka 009e05584a remove submitter signature assoc 2024-06-15 20:14:09 +03:00
Pete Matsyburka f9d52e56a2 prefill signature 2024-06-15 16:23:27 +03:00
Pete Matsyburka 9e66f8412c add extra user fields 2024-06-14 22:59:11 +03:00
Pete Matsyburka 181e42c4b3 refactor 2024-06-14 16:55:20 +03:00
Pete Matsyburka 15e60fc5e7 add emails copy toggle 2024-06-14 12:19:16 +03:00
Pete Matsyburka ee357e58ac fix prefill signature 2024-06-14 10:00:25 +03:00
Pete Matsyburka 0d9a709fa3 disable prefill signature 2024-06-14 09:09:56 +03:00
Pete Matsyburka 7eae09bdb0 assign default values on api complete 2024-06-13 23:45:53 +03:00
Pete Matsyburka e8e32e5d1f add log 2024-06-13 18:05:11 +03:00
Pete Matsyburka a3d54db4c6 adjust list import 2024-06-13 15:20:34 +03:00
Pete Matsyburka 9686ae6250 add field validation 2024-06-13 14:32:49 +03:00
Pete Matsyburka 044195f07b blur on field move 2024-06-13 10:54:10 +03:00
Pete Matsyburka 35f159266a paste raw text 2024-06-13 10:26:36 +03:00
Pete Matsyburka 3b717ae3de double click to edit default value 2024-06-12 22:46:50 +03:00
Pete Matsyburka 2389c3fc40 prefill signature 2024-06-11 19:42:26 +03:00
Pete Matsyburka 92da790e77 fix email tracking params 2024-06-11 16:59:15 +03:00
Pete Matsyburka 14418b93b1 full error log in prod 2024-06-10 17:17:45 +03:00
Pete Matsyburka 1289aff493 parse date using format 2024-06-10 12:08:56 +03:00
Pete Matsyburka 7568f563cb adjust payment step 2024-06-09 23:56:43 +03:00
Pete Matsyburka 5ae56bd50c add field preferences param 2024-06-09 20:25:19 +03:00
Pete Matsyburka c2ded81959 update hmtl lang attr 2024-06-09 16:39:41 +03:00
Pete Matsyburka f2c307ef63 adjust conditions modal 2024-06-09 16:37:28 +03:00
Pete Matsyburka af787887d8 i18n sign in 2024-06-08 23:02:29 +03:00
Pete Matsyburka 54272d83eb add integrations placeholder 2024-06-08 21:30:04 +03:00
Pete Matsyburka 3070fc2074 clear condition values 2024-06-08 12:32:41 +03:00
Pete Matsyburka 6a31d2c722 alert message on missing submitter fields 2024-06-08 10:01:14 +03:00
Pete Matsyburka f26bfe9acc remove button from archived 2024-06-07 21:54:37 +03:00
Pete Matsyburka a729fd6234 fix webhook url on testing 2024-06-06 22:03:15 +03:00
Pete Matsyburka c2051c156e adjust signature step 2024-06-06 17:14:05 +03:00
Pete Matsyburka 3852870c09 add payment field condition 2024-06-05 21:09:08 +03:00
Pete Matsyburka a58253a6a1 add devise emails events 2024-06-05 09:29:00 +03:00
Pete Matsyburka 30c98a970d fix audit trail 2024-06-04 19:34:09 +03:00
Pete Matsyburka 8f2edef09e wrap long email is stamp 2024-06-04 19:31:55 +03:00
Pete Matsyburka 9e465ea47e use field title in audit 2024-06-04 18:33:24 +03:00
Pete Matsyburka cdda55d528 add email events 2024-06-04 17:41:07 +03:00
Pete Matsyburka db302858c4 add template folder name to webhook 2024-06-03 18:13:13 +03:00
Pete Matsyburka b81cc83027 fix signature toggle 2024-06-03 12:31:40 +03:00
Pete Matsyburka 766d653349 fix build 2024-06-03 12:01:09 +03:00
Pete Matsyburka 98c04eac15 normalize client ip 2024-06-03 08:57:14 +03:00
Pete Matsyburka c35dfea154 add log 2024-06-02 21:24:21 +03:00
Pete Matsyburka 37a9a86a1d fix naming 2024-06-02 18:36:59 +03:00
Pete Matsyburka e9a759055b allow to upload user initials 2024-06-02 17:06:09 +03:00
Pete Matsyburka f6c061a57b allow to draw signature on mobile 2024-06-02 16:02:18 +03:00
Pete Matsyburka 91f3d8ed7e adjust meta tags 2024-05-31 22:35:59 +03:00
Pete Matsyburka 3b18502d3d remove onsubmit html event 2024-05-31 14:44:36 +03:00
Pete Matsyburka df8e79683a eager load in test 2024-05-31 13:19:33 +03:00
Alex Turchyn 7080e8ea33 fix js errors 2024-05-31 13:08:02 +03:00
Pete Matsyburka c56f8cb38f fix autocomplete 2024-05-30 23:38:38 +03:00
Pete Matsyburka 1ceac968b0 fix move existing user 2024-05-30 22:18:32 +03:00
Pete Matsyburka 938ee99cfc show full error trace in dev 2024-05-29 16:14:07 +03:00
Pete Matsyburka 2367040e45 add slug to submissions response 2024-05-28 00:25:59 +03:00
Pete Matsyburka df19aabfba adjust users table 2024-05-27 20:36:52 +03:00
Pete Matsyburka f7b5bcd52f add webhooks queue 2024-05-27 14:40:12 +03:00
Pete Matsyburka a249b0a678 fix pdf form flatten fields 2024-05-27 00:47:14 +03:00
Pete Matsyburka 99bc46b40f fix erblint 2024-05-26 22:51:16 +03:00
Pete Matsyburka c1e6f0970d add file types 2024-05-26 22:38:24 +03:00
Pete Matsyburka e8c2316bb2 add users count 2024-05-26 20:51:02 +03:00
Pete Matsyburka 9866e5468c add download combined pdf button 2024-05-26 11:43:47 +03:00
Pete Matsyburka fc60df7974 fix erblint 2024-05-25 16:59:22 +03:00
Pete Matsyburka 08784d28c7 adjust log ip 2024-05-25 16:25:59 +03:00
Pete Matsyburka 0440ea652f increase attachment url ttl 2024-05-25 16:25:56 +03:00
Pete Matsyburka e466562391 use rollbar 2024-05-24 13:35:28 +03:00
Pete Matsyburka b2c8667c53 safe register element 2024-05-23 14:02:48 +03:00
Pete Matsyburka d6893079f7 remove breadcrumbs 2024-05-23 13:39:16 +03:00
Pete Matsyburka ac1d973605 fix loop 2024-05-23 12:45:01 +03:00
Pete Matsyburka 1405fefc85 add sentry 2024-05-23 12:25:25 +03:00
Pete Matsyburka 2a75055fb6 fix pdf parsing 2024-05-22 21:37:23 +03:00
Pete Matsyburka f84fdd51de fix builder areas 2024-05-22 15:39:46 +03:00
Alex Turchyn b38073126e fix dropdown styles 2024-05-22 15:20:04 +03:00
Alex Turchyn c6e681195d upgrade daisyUI to v3.9.4 2024-05-22 13:39:43 +03:00
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
369 changed files with 11440 additions and 2695 deletions

No files matched your search

+5 -3
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.3.1 ruby-version: 3.3.4
- 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.3.1 ruby-version: 3.3.4
- name: Cache gems - name: Cache gems
uses: actions/cache@v1 uses: actions/cache@v1
with: with:
@@ -100,13 +100,15 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 3.3.1 ruby-version: 3.3.4
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 20.9.0 node-version: 20.9.0
- name: Install Chrome - name: Install Chrome
uses: browser-actions/setup-chrome@latest uses: browser-actions/setup-chrome@latest
with:
chrome-version: 125
- name: Cache node_modules - name: Cache node_modules
uses: actions/cache@v1 uses: actions/cache@v1
with: with:
+1
View File
@@ -36,3 +36,4 @@ yarn-debug.log*
/attachments /attachments
/docuseal /docuseal
/ee /ee
dump.rdb
+8 -1
View File
@@ -11,6 +11,7 @@ AllCops:
- bin/* - bin/*
- vendor/**/* - vendor/**/*
TargetRubyVersion: '3.3' TargetRubyVersion: '3.3'
SuggestExtensions: false
Metrics/BlockLength: Metrics/BlockLength:
Exclude: Exclude:
@@ -33,6 +34,7 @@ Metrics/MethodLength:
Max: 30 Max: 30
Exclude: Exclude:
- 'db/migrate/**' - 'db/migrate/**'
- 'spec/**/*'
Metrics/CyclomaticComplexity: Metrics/CyclomaticComplexity:
Max: 15 Max: 15
@@ -45,6 +47,8 @@ Layout/LineLength:
Metrics/AbcSize: Metrics/AbcSize:
Max: 45 Max: 45
Exclude:
- spec/**/*
Metrics/ModuleLength: Metrics/ModuleLength:
Max: 500 Max: 500
@@ -64,6 +68,9 @@ RSpec/ExampleLength:
RSpec/MultipleMemoizedHelpers: RSpec/MultipleMemoizedHelpers:
Max: 6 Max: 6
Metrics/BlockNesting:
Max: 4
Rails/I18nLocaleTexts: Rails/I18nLocaleTexts:
Enabled: false Enabled: false
@@ -76,5 +83,5 @@ Rails/SkipsModelValidations:
Rails/ApplicationController: Rails/ApplicationController:
Enabled: false Enabled: false
Capybara/ClickLinkOrButtonStyle: Rails/Output:
Enabled: false Enabled: false
+19 -5
View File
@@ -1,4 +1,4 @@
FROM ruby:3.3.1-alpine3.18 as fonts FROM ruby:3.3.4-alpine as fonts
WORKDIR /fonts WORKDIR /fonts
@@ -6,7 +6,7 @@ RUN apk --no-cache add fontforge wget && wget https://github.com/satbyy/go-noto-
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.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.3.1-alpine3.18 as webpack FROM ruby:3.3.4-alpine as webpack
ENV RAILS_ENV=production ENV RAILS_ENV=production
ENV NODE_ENV=production ENV NODE_ENV=production
@@ -32,18 +32,32 @@ COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:3.3.1-alpine3.18 as app FROM ruby:3.3.4-alpine 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 sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils redis 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 apk add --no-cache build-base && bundle update --bundler && 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 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
+6 -5
View File
@@ -2,10 +2,11 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '3.3.1' ruby '3.3.4'
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 'aws-sdk-secretsmanager', 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'
@@ -22,13 +23,13 @@ gem 'image_processing'
gem 'jwt' gem 'jwt'
gem 'lograge' gem 'lograge'
gem 'mysql2', require: false gem 'mysql2', require: false
gem 'net-smtp', '0.4.0'
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'
gem 'puma' gem 'puma', require: false
gem 'rack' gem 'rack'
gem 'rails' gem 'rails'
gem 'rails_autolink' gem 'rails_autolink'
@@ -38,8 +39,8 @@ 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'
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
+106 -119
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.4)
actioncable (7.1.3.2) actionpack (= 7.1.3.4)
actionpack (= 7.1.3.2) activesupport (= 7.1.3.4)
activesupport (= 7.1.3.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.3.2) actionmailbox (7.1.3.4)
actionpack (= 7.1.3.2) actionpack (= 7.1.3.4)
activejob (= 7.1.3.2) activejob (= 7.1.3.4)
activerecord (= 7.1.3.2) activerecord (= 7.1.3.4)
activestorage (= 7.1.3.2) activestorage (= 7.1.3.4)
activesupport (= 7.1.3.2) activesupport (= 7.1.3.4)
mail (>= 2.7.1) mail (>= 2.7.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
actionmailer (7.1.3.2) actionmailer (7.1.3.4)
actionpack (= 7.1.3.2) actionpack (= 7.1.3.4)
actionview (= 7.1.3.2) actionview (= 7.1.3.4)
activejob (= 7.1.3.2) activejob (= 7.1.3.4)
activesupport (= 7.1.3.2) activesupport (= 7.1.3.4)
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.3.2) actionpack (7.1.3.4)
actionview (= 7.1.3.2) actionview (= 7.1.3.4)
activesupport (= 7.1.3.2) activesupport (= 7.1.3.4)
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.3.2) actiontext (7.1.3.4)
actionpack (= 7.1.3.2) actionpack (= 7.1.3.4)
activerecord (= 7.1.3.2) activerecord (= 7.1.3.4)
activestorage (= 7.1.3.2) activestorage (= 7.1.3.4)
activesupport (= 7.1.3.2) activesupport (= 7.1.3.4)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.1.3.2) actionview (7.1.3.4)
activesupport (= 7.1.3.2) activesupport (= 7.1.3.4)
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.3.2) activejob (7.1.3.4)
activesupport (= 7.1.3.2) activesupport (= 7.1.3.4)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.1.3.2) activemodel (7.1.3.4)
activesupport (= 7.1.3.2) activesupport (= 7.1.3.4)
activerecord (7.1.3.2) activerecord (7.1.3.4)
activemodel (= 7.1.3.2) activemodel (= 7.1.3.4)
activesupport (= 7.1.3.2) activesupport (= 7.1.3.4)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (7.1.3.2) activestorage (7.1.3.4)
actionpack (= 7.1.3.2) actionpack (= 7.1.3.4)
activejob (= 7.1.3.2) activejob (= 7.1.3.4)
activerecord (= 7.1.3.2) activerecord (= 7.1.3.4)
activesupport (= 7.1.3.2) activesupport (= 7.1.3.4)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (7.1.3.2) activesupport (7.1.3.4)
base64 base64
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
@@ -78,7 +77,6 @@ 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)
@@ -98,6 +96,9 @@ GEM
aws-sdk-core (~> 3, >= 3.191.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-sdk-secretsmanager (1.91.0)
aws-sdk-core (~> 3, >= 3.191.0)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.8.0) aws-sigv4 (1.8.0)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.3) azure-storage-blob (2.0.3)
@@ -117,11 +118,11 @@ GEM
erubi (~> 1.4) erubi (~> 1.4)
parser (>= 2.4) parser (>= 2.4)
smart_properties smart_properties
bigdecimal (3.1.7) bigdecimal (3.1.8)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.18.3) bootsnap (1.18.3)
msgpack (~> 1.2) msgpack (~> 1.2)
builder (3.2.4) builder (3.3.0)
bullet (7.1.6) bullet (7.1.6)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
uniform_notifier (~> 1.11) uniform_notifier (~> 1.11)
@@ -141,7 +142,7 @@ GEM
cldr-plurals-runtime-rb (1.1.0) cldr-plurals-runtime-rb (1.1.0)
cmdparse (3.0.7) cmdparse (3.0.7)
coderay (1.1.3) coderay (1.1.3)
concurrent-ruby (1.2.3) concurrent-ruby (1.3.4)
connection_pool (2.4.1) connection_pool (2.4.1)
crack (1.0.0) crack (1.0.0)
bigdecimal bigdecimal
@@ -164,7 +165,7 @@ GEM
railties (>= 4.1.0) railties (>= 4.1.0)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
devise-two-factor (5.0.0) devise-two-factor (6.0.0)
activesupport (~> 7.0) activesupport (~> 7.0)
devise (~> 4.0) devise (~> 4.0)
railties (~> 7.0) railties (~> 7.0)
@@ -183,7 +184,7 @@ GEM
rainbow rainbow
rubocop rubocop
smart_properties smart_properties
erubi (1.12.0) erubi (1.13.0)
factory_bot (6.4.6) factory_bot (6.4.6)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
factory_bot_rails (6.4.3) factory_bot_rails (6.4.3)
@@ -262,21 +263,20 @@ 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.40.0)
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.4) i18n (1.14.6)
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.2) io-console (0.7.2)
irb (1.12.0) irb (1.14.0)
rdoc rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jmespath (1.6.2) jmespath (1.6.2)
json (2.7.2) json (2.7.2)
@@ -311,8 +311,8 @@ GEM
method_source (1.1.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.6) mini_portile2 (2.8.7)
minitest (5.22.3) minitest (5.25.1)
msgpack (1.7.2) msgpack (1.7.2)
multi_json (1.15.0) multi_json (1.15.0)
multipart-post (2.4.0) multipart-post (2.4.0)
@@ -320,7 +320,7 @@ GEM
mysql2 (0.5.6) 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.10) net-imap (0.4.14)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -329,11 +329,13 @@ GEM
timeout timeout
net-smtp (0.4.0) net-smtp (0.4.0)
net-protocol net-protocol
nio4r (2.7.1) nio4r (2.7.3)
nokogiri (1.16.4) nokogiri (1.16.7)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.4-arm64-darwin) nokogiri (1.16.7-aarch64-linux)
racc (~> 1.4)
nokogiri (1.16.7-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
oj (3.16.3) oj (3.16.3)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
@@ -342,16 +344,10 @@ GEM
os (1.1.4) os (1.1.4)
package_json (0.1.0) package_json (0.1.0)
pagy (8.1.2) pagy (8.1.2)
parallel (1.24.0) parallel (1.25.1)
parser (3.3.1.0) parser (3.3.4.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pdf-reader (2.12.0)
Ascii85 (~> 1.0)
afm (~> 0.2.1)
hashery (~> 2.0)
ruby-rc4
ttfunk
pg (1.5.6) pg (1.5.6)
premailer (1.23.0) premailer (1.23.0)
addressable addressable
@@ -371,33 +367,33 @@ GEM
psych (5.1.2) psych (5.1.2)
stringio stringio
public_suffix (5.0.5) public_suffix (5.0.5)
puma (6.4.2) puma (6.4.3)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.7.3) racc (1.8.1)
rack (2.2.9) rack (3.1.7)
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.3.2) rails (7.1.3.4)
actioncable (= 7.1.3.2) actioncable (= 7.1.3.4)
actionmailbox (= 7.1.3.2) actionmailbox (= 7.1.3.4)
actionmailer (= 7.1.3.2) actionmailer (= 7.1.3.4)
actionpack (= 7.1.3.2) actionpack (= 7.1.3.4)
actiontext (= 7.1.3.2) actiontext (= 7.1.3.4)
actionview (= 7.1.3.2) actionview (= 7.1.3.4)
activejob (= 7.1.3.2) activejob (= 7.1.3.4)
activemodel (= 7.1.3.2) activemodel (= 7.1.3.4)
activerecord (= 7.1.3.2) activerecord (= 7.1.3.4)
activestorage (= 7.1.3.2) activestorage (= 7.1.3.4)
activesupport (= 7.1.3.2) activesupport (= 7.1.3.4)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.1.3.2) railties (= 7.1.3.4)
rails-dom-testing (2.2.0) rails-dom-testing (2.2.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
@@ -412,9 +408,9 @@ GEM
actionview (> 3.1) actionview (> 3.1)
activesupport (> 3.1) activesupport (> 3.1)
railties (> 3.1) railties (> 3.1)
railties (7.1.3.2) railties (7.1.3.4)
actionpack (= 7.1.3.2) actionpack (= 7.1.3.4)
activesupport (= 7.1.3.2) activesupport (= 7.1.3.4)
irb irb
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
@@ -422,12 +418,12 @@ GEM
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.2.1) rake (13.2.1)
rdoc (6.6.3.1) rdoc (6.7.0)
psych (>= 4.0.0) psych (>= 4.0.0)
redis-client (0.22.0) redis-client (0.22.2)
connection_pool connection_pool
regexp_parser (2.9.0) regexp_parser (2.9.2)
reline (0.5.1) reline (0.5.10)
io-console (~> 0.5) io-console (~> 0.5)
representable (3.2.0) representable (3.2.0)
declarative (< 0.1.0) declarative (< 0.1.0)
@@ -439,7 +435,8 @@ GEM
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.3.6)
strscan
rotp (6.3.0) rotp (6.3.0)
rqrcode (2.2.0) rqrcode (2.2.0)
chunky_png (~> 1.0) chunky_png (~> 1.0)
@@ -462,23 +459,19 @@ GEM
rspec-mocks (~> 3.13) rspec-mocks (~> 3.13)
rspec-support (~> 3.13) rspec-support (~> 3.13)
rspec-support (3.13.1) rspec-support (3.13.1)
rubocop (1.63.3) rubocop (1.65.0)
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.3.0.2) 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 (>= 2.4, < 3.0)
rexml (>= 3.2.5, < 4.0) rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 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.31.2) rubocop-ast (1.31.3)
parser (>= 3.3.0.4) parser (>= 3.3.1.0)
rubocop-capybara (2.20.0)
rubocop (~> 1.41)
rubocop-factory_bot (2.25.1)
rubocop (~> 1.41)
rubocop-performance (1.21.0) rubocop-performance (1.21.0)
rubocop (>= 1.48.1, < 2.0) rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0)
@@ -487,15 +480,9 @@ GEM
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0) rubocop (>= 1.33.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rspec (2.29.1) rubocop-rspec (3.0.3)
rubocop (~> 1.40) rubocop (~> 1.61)
rubocop-capybara (~> 2.17)
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.1)
ffi (~> 1.12) ffi (~> 1.12)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
@@ -504,13 +491,13 @@ GEM
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.2.3) shakapacker (8.0.0)
activesupport (>= 5.2) activesupport (>= 5.2)
package_json 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.2) sidekiq (7.2.4)
concurrent-ruby (< 2) concurrent-ruby (< 2)
connection_pool (>= 2.3.0) connection_pool (>= 2.3.0)
rack (>= 2.2.4) rack (>= 2.2.4)
@@ -529,15 +516,13 @@ GEM
smart_properties (1.17.0) smart_properties (1.17.0)
sqlite3 (1.7.3) sqlite3 (1.7.3)
mini_portile2 (~> 2.8.0) mini_portile2 (~> 2.8.0)
sqlite3 (1.7.3-arm64-darwin) stringio (3.1.1)
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.1) strscan (3.1.0)
thor (1.3.2)
timeout (0.4.1) timeout (0.4.1)
trailblazer-option (0.1.2) trailblazer-option (0.1.2)
ttfunk (1.8.0)
bigdecimal (~> 3.1)
turbo-rails (2.0.5) turbo-rails (2.0.5)
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activejob (>= 6.0.0) activejob (>= 6.0.0)
@@ -570,16 +555,18 @@ 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.13) zeitwerk (2.6.18)
PLATFORMS PLATFORMS
arm64-darwin-22 aarch64-linux
ruby ruby
x86_64-linux
DEPENDENCIES DEPENDENCIES
annotate annotate
arabic-letter-connector arabic-letter-connector
aws-sdk-s3 aws-sdk-s3
aws-sdk-secretsmanager
azure-storage-blob azure-storage-blob
better_html better_html
bootsnap bootsnap
@@ -605,9 +592,9 @@ DEPENDENCIES
letter_opener_web letter_opener_web
lograge lograge
mysql2 mysql2
net-smtp (= 0.4.0)
oj oj
pagy pagy
pdf-reader
pg pg
premailer-rails premailer-rails
pretender pretender
@@ -638,7 +625,7 @@ DEPENDENCIES
webmock webmock
RUBY VERSION RUBY VERSION
ruby 3.3.1p55 ruby 3.3.4p94
BUNDLED WITH BUNDLED WITH
2.5.3 2.5.3
+9
View File
@@ -3,3 +3,12 @@
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. 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.** **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,11 +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::FORM_PREFILL_SIGNATURE_KEY,
AccountConfig::ESIGNING_PREFERENCE_KEY, AccountConfig::ESIGNING_PREFERENCE_KEY,
AccountConfig::FORM_WITH_CONFETTI_KEY, AccountConfig::FORM_WITH_CONFETTI_KEY,
AccountConfig::DOWNLOAD_LINKS_AUTH_KEY, AccountConfig::DOWNLOAD_LINKS_AUTH_KEY,
AccountConfig::FORCE_SSO_AUTH_KEY, AccountConfig::FORCE_SSO_AUTH_KEY,
AccountConfig::FLATTEN_RESULT_PDF_KEY AccountConfig::FLATTEN_RESULT_PDF_KEY,
AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::COMBINE_PDF_RESULT_KEY,
AccountConfig::REQUIRE_SIGNING_REASON_KEY
].freeze ].freeze
InvalidKey = Class.new(StandardError) InvalidKey = Class.new(StandardError)
+7 -5
View File
@@ -7,7 +7,8 @@ class AccountsController < ApplicationController
'fr-FR' => 'French (France)', 'fr-FR' => 'French (France)',
'es-ES' => 'Spanish (Spain)', 'es-ES' => 'Spanish (Spain)',
'pt-PT' => 'Portuguese (Portugal)', 'pt-PT' => 'Portuguese (Portugal)',
'de-DE' => 'German (Germany)' 'de-DE' => 'German (Germany)',
'it-IT' => 'Italian (Italy)'
}.freeze }.freeze
before_action :load_account before_action :load_account
@@ -23,7 +24,7 @@ class AccountsController < ApplicationController
@encrypted_config.assign_attributes(app_url_params) @encrypted_config.assign_attributes(app_url_params)
unless URI.parse(@encrypted_config.value.to_s).class.in?([URI::HTTP, URI::HTTPS]) unless URI.parse(@encrypted_config.value.to_s).class.in?([URI::HTTP, URI::HTTPS])
@encrypted_config.errors.add(:value, 'should be a valid URL') @encrypted_config.errors.add(:value, I18n.t('should_be_a_valid_url'))
return render :show, status: :unprocessable_entity return render :show, status: :unprocessable_entity
end end
@@ -32,7 +33,7 @@ class AccountsController < ApplicationController
Docuseal.refresh_default_url_options! Docuseal.refresh_default_url_options!
redirect_to settings_account_path, notice: 'Account information has been updated' redirect_to settings_account_path, notice: I18n.t('account_information_has_been_updated')
rescue ActiveRecord::RecordInvalid rescue ActiveRecord::RecordInvalid
render :show, status: :unprocessable_entity render :show, status: :unprocessable_entity
end end
@@ -42,11 +43,12 @@ class AccountsController < ApplicationController
true_user.update!(locked_at: Time.current) true_user.update!(locked_at: Time.current)
# rubocop:disable Layout/LineLength
render turbo_stream: turbo_stream.replace( render turbo_stream: turbo_stream.replace(
:account_delete_button, :account_delete_button,
html: helpers.tag.p('Your account removal request will be processed within 2 weeks. ' \ html: helpers.tag.p(I18n.t('your_account_removal_request_will_be_processed_within_2_weeks_please_contact_us_if_you_want_to_keep_your_account'))
'Please contact us if you want to keep your account.')
) )
# rubocop:enable Layout/LineLength
end end
private private
+9 -4
View File
@@ -39,12 +39,17 @@ module Api
private private
def paginate(relation) def paginate(relation, field: :id)
result = relation.order(id: :desc) result = relation.order(field => :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(id: ...params[:after].to_i) if params[:after].present? if field == :id
result = result.where(id: (params[:before].to_i + 1)...) if params[:before].present? result = result.where(id: ...params[:after].to_i) if params[:after].present?
result = result.where(id: (params[:before].to_i + 1)...) if params[:before].present?
else
result = result.where(field => ...params[:after]) if params[:after].present?
result = result.where(field => (params[:before] + 1)...) if params[:before].present?
end
result result
end end
+25 -4
View File
@@ -1,16 +1,37 @@
# frozen_string_literal: true # frozen_string_literal: true
module Api module Api
class AttachmentsController < ApiBaseController class AttachmentsController < ActionController::API
skip_before_action :authenticate_user! include ActionController::Cookies
skip_authorization_check include ActiveStorage::SetCurrent
COOKIE_STORE_LIMIT = 10
def create def create
submitter = Submitter.find_by!(slug: params[:submitter_slug]) submitter = Submitter.find_by!(slug: params[:submitter_slug])
attachment = Submitters.create_attachment!(submitter, params) attachment = Submitters.create_attachment!(submitter, params)
render json: attachment.as_json(only: %i[uuid], methods: %i[url filename content_type]) if params[:remember_signature] == 'true' && submitter.email.present?
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(submitter, attachment)
end
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
end
def build_new_cookie_signatures_json(submitter, attachment)
values =
begin
JSON.parse(cookies.encrypted[:signature_uuids].presence || '{}')
rescue JSON::ParserError
{}
end
values[submitter.email] = attachment.uuid
values = values.to_a.last(COOKIE_STORE_LIMIT).to_h if values.size > COOKIE_STORE_LIMIT
values.to_json
end end
end end
end end
@@ -0,0 +1,36 @@
# frozen_string_literal: true
module Api
class FormEventsController < ApiBaseController
load_and_authorize_resource :submitter, parent: false
def index
submitters = @submitters.where.not(completed_at: nil)
params[:after] = Time.zone.at(params[:after].to_i) if params[:after].present?
params[:before] = Time.zone.at(params[:before].to_i) if params[:before].present?
submitters = paginate(
submitters.preload(template: :folder, submission: [:submitters, { audit_trail_attachment: :blob,
combined_document_attachment: :blob }],
documents_attachments: :blob, attachments_attachments: :blob),
field: :completed_at
)
render json: {
data: submitters.map do |s|
{
event_type: 'form.completed',
timestamp: s.completed_at,
data: Submitters::SerializeForWebhook.call(s)
}
end,
pagination: {
count: submitters.size,
next: submitters.last&.completed_at&.to_i,
prev: submitters.first&.completed_at&.to_i
}
}
end
end
end
@@ -0,0 +1,52 @@
# frozen_string_literal: true
module Api
class SubmissionEventsController < ApiBaseController
load_and_authorize_resource :submission, parent: false
def index
submissions = build_completed_query(@submissions)
params[:after] = Time.zone.at(params[:after].to_i) if params[:after].present?
params[:before] = Time.zone.at(params[:before].to_i) if params[:before].present?
submissions = paginate(submissions.preload(
:created_by_user, :submission_events,
template: :folder,
submitters: { documents_attachments: :blob, attachments_attachments: :blob },
audit_trail_attachment: :blob
),
field: :completed_at)
render json: {
data: submissions.map do |s|
{
event_type: 'submission.completed',
timestamp: s.completed_at,
data: Submissions::SerializeForApi.call(s, s.submitters)
}
end,
pagination: {
count: submissions.size,
next: submissions.last&.completed_at&.to_i,
prev: submissions.first&.completed_at&.to_i
}
}
end
private
def build_completed_query(submissions)
submissions = submissions.where(
Submitter.where(completed_at: nil).where(
Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
).select(1).arel.exists.not
)
submissions.joins(:submitters)
.group(:id)
.select(Submission.arel_table[Arel.star],
Submitter.arel_table[:completed_at].maximum.as('completed_at'))
end
end
end
+35 -55
View File
@@ -19,10 +19,14 @@ module Api
submissions = paginate(submissions.preload(:created_by_user, :submitters, submissions = paginate(submissions.preload(:created_by_user, :submitters,
template: :folder, template: :folder,
combined_document_attachment: :blob,
audit_trail_attachment: :blob)) audit_trail_attachment: :blob))
render json: { render json: {
data: submissions.as_json(serialize_params), data: submissions.map do |s|
Submissions::SerializeForApi.call(s, s.submitters, params,
with_events: false, with_documents: false, with_values: false)
end,
pagination: { pagination: {
count: submissions.size, count: submissions.size,
next: submissions.last&.id, next: submissions.last&.id,
@@ -44,29 +48,7 @@ module Api
@submission.audit_trail_attachment = Submissions::GenerateAuditTrail.call(@submission) @submission.audit_trail_attachment = Submissions::GenerateAuditTrail.call(@submission)
end end
serialized_submitters = submitters.map { |submitter| Submitters::SerializeForApi.call(submitter) } render json: Submissions::SerializeForApi.call(@submission, submitters, params)
json = @submission.as_json(
serialize_params.deep_merge(
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
json[:submitters] = serialized_submitters
render json:
end end
def create def create
@@ -85,37 +67,51 @@ module Api
submissions = create_submissions(@template, params) submissions = create_submissions(@template, params)
submissions.each do |submission|
SendSubmissionCreatedWebhookRequestJob.perform_async({ 'submission_id' => submission.id })
end
Submissions.send_signature_requests(submissions) Submissions.send_signature_requests(submissions)
submissions.each do |submission| submissions.each do |submission|
if submission.submitters.all?(&:completed_at?) && submission.submitters.last if submission.submitters.all?(&:completed_at?) && submission.submitters.last
ProcessSubmitterCompletionJob.perform_later(submission.submitters.last) ProcessSubmitterCompletionJob.perform_async({ 'submitter_id' => submission.submitters.last.id })
end end
end end
json = submissions.flat_map do |submission| render json: build_create_json(submissions)
submission.submitters.map do |s| rescue Submitters::NormalizeValues::BaseError, DownloadUtils::UnableToDownload => e
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) 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) if params[:permanently] == 'true'
@submission.destroy!
else
@submission.update!(archived_at: Time.current)
SendSubmissionArchivedWebhookRequestJob.perform_later(@submission) SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id)
end
render json: @submission.as_json(only: %i[id], methods: %i[archived_at]) render json: @submission.as_json(only: %i[id archived_at])
end end
private private
def build_create_json(submissions)
json = submissions.flat_map do |submission|
submission.submitters.map do |s|
Submitters::SerializeForApi.call(s, with_documents: false, with_urls: true, params:)
end
end
json = { submitters: json } if request.path.ends_with?('/init')
json
end
def create_submissions(template, params) def create_submissions(template, params)
is_send_email = !params[:send_email].in?(['false', false]) is_send_email = !params[:send_email].in?(['false', false])
@@ -135,7 +131,6 @@ module Api
template:, template:,
user: current_user, user: current_user,
source: :api, source: :api,
mark_as_sent: is_send_email,
submitters_order: params[:submitters_order] || params[:order] || 'preserved', submitters_order: params[:submitters_order] || params[:order] || 'preserved',
submissions_attrs:, submissions_attrs:,
params: params:
@@ -153,33 +148,18 @@ module Api
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 external_id created_at updated_at],
methods: %i[folder_name] },
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, :reply_to, :go_to_last, :send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to, :go_to_last,
:expire_at,
{ {
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, :reply_to, :go_to_last, :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: [:name, :uuid, :default_value, :value, :title, :description, fields: [:name, :uuid, :default_value, :value, :title, :description,
:readonly, :redacted, :validation_pattern, :invalid_message, :readonly, :validation_pattern, :invalid_message,
{ default_value: [], value: [] }] }]] { default_value: [], value: [], preferences: {} }] }]]
} }
] ]
@@ -13,7 +13,7 @@ module Api
SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request) SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request)
SendFormViewedWebhookRequestJob.perform_later(submitter) SendFormViewedWebhookRequestJob.perform_async({ 'submitter_id' => submitter.id })
render json: {} render json: {}
end end
+58 -13
View File
@@ -23,7 +23,9 @@ module Api
) )
render json: { render json: {
data: submitters.map { |s| Submitters::SerializeForApi.call(s, with_template: true, with_events: true) }, data: submitters.map do |s|
Submitters::SerializeForApi.call(s, with_template: true, with_events: true, params:)
end,
pagination: { pagination: {
count: submitters.size, count: submitters.size,
next: submitters.last&.id, next: submitters.last&.id,
@@ -35,7 +37,7 @@ module Api
def show def show
Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at? Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at?
render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true) render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true, params:)
end end
def update def update
@@ -66,14 +68,19 @@ module Api
end end
if @submitter.completed_at? if @submitter.completed_at?
ProcessSubmitterCompletionJob.perform_later(@submitter) ProcessSubmitterCompletionJob.perform_async({ 'submitter_id' => @submitter.id })
elsif normalized_params[:send_email] || normalized_params[:send_sms] elsif normalized_params[:send_email] || normalized_params[:send_sms]
Submitters.send_signature_requests([@submitter]) Submitters.send_signature_requests([@submitter])
end end
render json: Submitters::SerializeForApi.call(@submitter, with_template: false, render json: Submitters::SerializeForApi.call(@submitter, with_template: false,
with_urls: true, with_urls: true,
with_events: false) with_events: false,
params:)
rescue Submitters::NormalizeValues::BaseError, DownloadUtils::UnableToDownload => e
Rollbar.warning(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_entity
end end
def submitter_params def submitter_params
@@ -84,8 +91,8 @@ module Api
:completed, :phone, :application_key, :external_id, :go_to_last, :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: [[:name, :uuid, :default_value, :value, fields: [[:name, :uuid, :default_value, :value,
:readonly, :redacted, :validation_pattern, :invalid_message, :readonly, :validation_pattern, :invalid_message,
{ default_value: [], value: [] }]] } { default_value: [], value: [], preferences: {} }]] }
) )
end end
@@ -104,20 +111,58 @@ module Api
end 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.metadata = attrs[:metadata] if attrs.key?(:metadata)
maybe_assign_completed_attributes(submitter, attrs)
assign_external_id(submitter, attrs)
assign_preferences(submitter, attrs) assign_preferences(submitter, attrs)
submitter submitter
end end
def maybe_assign_completed_attributes(submitter, attrs)
submitter.completed_at = attrs[:completed] ? Time.current : submitter.completed_at
if attrs[:completed]
submitter.values = Submitters::SubmitValues.merge_default_values(submitter)
submitter.values = Submitters::SubmitValues.merge_formula_values(submitter)
submitter.values = Submitters::SubmitValues.maybe_remove_condition_values(submitter)
submitter.values = submitter.values.transform_values do |v|
v == '{{date}}' ? Time.current.in_time_zone(submitter.account.timezone).to_date.to_s : v
end
end
submitter
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
@@ -7,6 +7,11 @@ module Api
def create def create
authorize!(:manage, @template) authorize!(:manage, @template)
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: :preview_images_attachments]
).call
cloned_template = Templates::Clone.call( cloned_template = Templates::Clone.call(
@template, @template,
author: current_user, author: current_user,
@@ -18,9 +23,11 @@ module Api
cloned_template.source = :api cloned_template.source = :api
cloned_template.save! cloned_template.save!
Templates::CloneAttachments.call(template: cloned_template, original_template: @template) schema_documents = Templates::CloneAttachments.call(template: cloned_template, original_template: @template)
render json: Templates::SerializeForApi.call(cloned_template) SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => cloned_template.id)
render json: Templates::SerializeForApi.call(cloned_template, schema_documents)
end end
end end
end end
+6 -3
View File
@@ -18,7 +18,7 @@ module Api
preview_image_attachments = preview_image_attachments =
ActiveStorage::Attachment.joins(:blob) ActiveStorage::Attachment.joins(:blob)
.where(blob: { filename: '0.jpg' }) .where(blob: { filename: ['0.png', '0.jpg'] })
.where(record_id: schema_documents.map(&:id), .where(record_id: schema_documents.map(&:id),
record_type: 'ActiveStorage::Attachment', record_type: 'ActiveStorage::Attachment',
name: :preview_images) name: :preview_images)
@@ -65,11 +65,13 @@ module Api
@template.update!(template_params) @template.update!(template_params)
SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id)
render json: @template.as_json(only: %i[id updated_at]) render json: @template.as_json(only: %i[id updated_at])
end end
def destroy def destroy
if params[:permanently] == 'true' && !Docuseal.multitenant? if params[:permanently] == 'true'
@template.destroy! @template.destroy!
else else
@template.update!(archived_at: Time.current) @template.update!(archived_at: Time.current)
@@ -93,8 +95,9 @@ module Api
def template_params def template_params
permitted_params = [ permitted_params = [
:name, :name,
:external_id,
{ {
submitters: [%i[name uuid]], submitters: [%i[name uuid is_requester invite_by_uuid linked_to_uuid email]],
fields: [[:uuid, :submitter_uuid, :name, :type, fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value, :required, :readonly, :default_value,
:title, :description, :title, :description,
+42
View File
@@ -0,0 +1,42 @@
# frozen_string_literal: true
module Api
class ToolsController < ApiBaseController
skip_authorization_check
def merge
files = params[:files] || []
return render json: { error: 'Files are required' }, status: :unprocessable_entity if files.blank?
return render json: { error: 'At least 2 files are required' }, status: :unprocessable_entity if files.size < 2
render json: {
data: Base64.encode64(PdfUtils.merge(files.map { |base64| StringIO.new(Base64.decode64(base64)) }).string)
}
end
def verify
file = Base64.decode64(params[:file])
pdf = HexaPDF::Document.new(io: StringIO.new(file))
trusted_certs = Accounts.load_trusted_certs(current_account)
is_checksum_found = ActiveStorage::Attachment.joins(:blob)
.where(name: 'documents', record_type: 'Submitter')
.exists?(blob: { checksum: Digest::MD5.base64digest(file) })
render json: {
checksum_status: is_checksum_found ? 'verified' : 'not_found',
signatures: pdf.signatures.map do |sig|
{
verification_result: sig.verify(trusted_certs:).messages,
signer_name: sig.signer_name,
signing_reason: sig.signing_reason,
signing_time: sig.signing_time,
signature_type: sig.signature_type
}
end
}
end
end
end
+11
View File
@@ -0,0 +1,11 @@
# frozen_string_literal: true
module Api
class UsersController < ApiBaseController
authorize_resource :current_user
def show
render json: current_user.as_json(only: %i[id first_name last_name email])
end
end
end
+1 -1
View File
@@ -12,6 +12,6 @@ class ApiSettingsController < ApplicationController
current_user.access_token.save! current_user.access_token.save!
redirect_back(fallback_location: settings_api_index_path, notice: 'API token as been updated.') redirect_back(fallback_location: settings_api_index_path, notice: I18n.t('api_token_has_been_updated'))
end end
end end
+4 -2
View File
@@ -52,7 +52,8 @@ class ApplicationController < ActionController::Base
private private
def with_browser_locale(&) def with_browser_locale(&)
locale = request.env['HTTP_ACCEPT_LANGUAGE'].to_s[BROWSER_LOCALE_REGEXP].to_s locale = params[:lang].presence
locale ||= request.env['HTTP_ACCEPT_LANGUAGE'].to_s[BROWSER_LOCALE_REGEXP].to_s
locale = locale =
if locale.starts_with?('en-') && locale != 'en-US' if locale.starts_with?('en-') && locale != 'en-US'
@@ -78,7 +79,8 @@ class ApplicationController < ActionController::Base
redirect_to setup_index_path unless User.exists? redirect_to setup_index_path unless User.exists?
end end
def button_title(title: 'Submit', disabled_with: 'Submitting', title_class: '', icon: nil, icon_disabled: nil) def button_title(title: I18n.t('submit'), disabled_with: I18n.t('submitting'), title_class: '', icon: nil,
icon_disabled: nil)
render_to_string(partial: 'shared/button_title', render_to_string(partial: 'shared/button_title',
locals: { title:, disabled_with:, title_class:, icon:, icon_disabled: }) locals: { title:, disabled_with:, title_class:, icon:, icon_disabled: })
end end
@@ -9,6 +9,8 @@ class ConsoleRedirectController < ApplicationController
params[:redir] = Docuseal.multitenant? ? "#{Docuseal::CONSOLE_URL}/plans" : "#{Docuseal::CONSOLE_URL}/on_premise" params[:redir] = Docuseal.multitenant? ? "#{Docuseal::CONSOLE_URL}/plans" : "#{Docuseal::CONSOLE_URL}/on_premise"
end end
params[:redir] = "#{Docuseal::CONSOLE_URL}/manage" if request.path == '/manage'
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,
+1 -1
View File
@@ -33,7 +33,7 @@ class DashboardController < ApplicationController
account_id: current_user.account_id, account_id: current_user.account_id,
key: AccountConfig::FORCE_MFA) key: AccountConfig::FORCE_MFA)
redirect_to mfa_setup_path, notice: 'Setup 2FA to continue' redirect_to mfa_setup_path, notice: I18n.t('setup_2fa_to_continue')
end end
def maybe_render_landing def maybe_render_landing
@@ -11,7 +11,7 @@ class EmailSmtpSettingsController < ApplicationController
if @encrypted_config.update(email_configs) if @encrypted_config.update(email_configs)
SettingsMailer.smtp_successful_setup(@encrypted_config.value['from_email']).deliver_now! SettingsMailer.smtp_successful_setup(@encrypted_config.value['from_email']).deliver_now!
redirect_to settings_email_index_path, notice: 'Changes have been saved' redirect_to settings_email_index_path, notice: I18n.t('changes_have_been_saved')
else else
render :index, status: :unprocessable_entity render :index, status: :unprocessable_entity
end end
@@ -0,0 +1,39 @@
# frozen_string_literal: true
class EmbedScriptsController < ActionController::Metal
DUMMY_SCRIPT = <<~JAVASCRIPT.freeze
const DummyBuilder = class extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<div style="text-align: center; padding: 20px; font-family: Arial, sans-serif;">
<h2>Upgrade to Pro</h2>
<p>Unlock embedded components by upgrading to Pro</p>
<div style="margin-top: 40px;">
<a href="#{Docuseal::CONSOLE_URL}/on_premise" target="_blank" style="padding: 15px 25px; background-color: #222; color: white; text-decoration: none; border-radius: 5px; font-size: 16px; cursor: pointer;">
Learn More
</a>
</div>
</div>
`;
}
};
const DummyForm = class extends DummyBuilder {};
if (!window.customElements.get('docuseal-builder')) {
window.customElements.define('docuseal-builder', DummyBuilder);
}
if (!window.customElements.get('docuseal-form')) {
window.customElements.define('docuseal-form', DummyForm);
}
JAVASCRIPT
def show
headers['Content-Type'] = 'application/javascript'
self.response_body = DUMMY_SCRIPT
self.status = 200
end
end
+8 -6
View File
@@ -48,7 +48,7 @@ class EsignSettingsController < ApplicationController
if (@encrypted_config.value && @encrypted_config.value['custom']&.any? { |e| e['name'] == @cert_record.name }) || if (@encrypted_config.value && @encrypted_config.value['custom']&.any? { |e| e['name'] == @cert_record.name }) ||
@cert_record.name == DEFAULT_CERT_NAME @cert_record.name == DEFAULT_CERT_NAME
@cert_record.errors.add(:name, 'already exists') @cert_record.errors.add(:name, I18n.t('already_exists'))
return render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'), return render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'),
status: :unprocessable_entity status: :unprocessable_entity
@@ -56,9 +56,11 @@ 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: I18n.t('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") Rollbar.error(e) if defined?(Rollbar)
@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
@@ -71,7 +73,7 @@ class EsignSettingsController < ApplicationController
@encrypted_config.save! @encrypted_config.save!
redirect_to settings_esign_path, notice: 'Default certificate has been selected' redirect_to settings_esign_path, notice: I18n.t('default_certificate_has_been_selected')
end end
def destroy def destroy
@@ -79,7 +81,7 @@ class EsignSettingsController < ApplicationController
@encrypted_config.save! @encrypted_config.save!
redirect_to settings_esign_path, notice: 'Certificate has been removed' redirect_to settings_esign_path, notice: I18m.t('certificate_has_been_removed')
end end
private private
+5 -5
View File
@@ -18,11 +18,11 @@ class MfaSetupController < ApplicationController
current_user.otp_required_for_login = true current_user.otp_required_for_login = true
current_user.save! current_user.save!
redirect_to settings_profile_index_path, notice: '2FA has been configured' redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_configured')
else else
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name) @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
@error_message = 'Code is invalid' @error_message = I18n.t('code_is_invalid')
render turbo_stream: turbo_stream.replace(:mfa_form, partial: 'mfa_setup/form'), status: :unprocessable_entity render turbo_stream: turbo_stream.replace(:mfa_form, partial: 'mfa_setup/form'), status: :unprocessable_entity
end end
@@ -32,9 +32,9 @@ class MfaSetupController < ApplicationController
if current_user.validate_and_consume_otp!(params[:otp_attempt]) if current_user.validate_and_consume_otp!(params[:otp_attempt])
current_user.update!(otp_required_for_login: false, otp_secret: nil) current_user.update!(otp_required_for_login: false, otp_secret: nil)
redirect_to settings_profile_index_path, notice: '2FA has been removed' redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_removed')
else else
@error_message = 'Code is invalid' @error_message = I18n.t('code_is_invalid')
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_entity render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_entity
end end
@@ -43,7 +43,7 @@ class MfaSetupController < ApplicationController
private private
def set_provision_url def set_provision_url
return redirect_to root_path, alert: '2FA has been set up already' if current_user.otp_required_for_login return redirect_to root_path, alert: I18n.t('2fa_has_been_set_up_already') if current_user.otp_required_for_login
current_user.otp_secret ||= User.generate_otp_secret current_user.otp_secret ||= User.generate_otp_secret
@@ -13,9 +13,9 @@ class NotificationsSettingsController < ApplicationController
def create def create
if @account_config.value.present? ? @account_config.save : @account_config.delete if @account_config.value.present? ? @account_config.save : @account_config.delete
redirect_back fallback_location: settings_notifications_path, notice: 'Changes have been saved' redirect_back fallback_location: settings_notifications_path, notice: I18n.t('changes_have_been_saved')
else else
redirect_back fallback_location: settings_notifications_path, alert: 'Unable to save' redirect_back fallback_location: settings_notifications_path, alert: I18n.t('unable_to_save')
end end
end end
@@ -30,7 +30,7 @@ class PersonalizationSettingsController < ApplicationController
@account_config.save! @account_config.save!
end end
redirect_back(fallback_location: settings_personalization_path, notice: 'Settings have been saved.') redirect_back(fallback_location: settings_personalization_path, notice: I18n.t('settings_have_been_saved'))
end end
private private
+2 -2
View File
@@ -9,7 +9,7 @@ class ProfileController < ApplicationController
def update_contact def update_contact
if current_user.update(contact_params) if current_user.update(contact_params)
redirect_to settings_profile_index_path, notice: 'Contact information has been updated' redirect_to settings_profile_index_path, notice: I18n.t('contact_information_has_been_update')
else else
render :index, status: :unprocessable_entity render :index, status: :unprocessable_entity
end end
@@ -18,7 +18,7 @@ class ProfileController < ApplicationController
def update_password def update_password
if current_user.update(password_params) if current_user.update(password_params)
bypass_sign_in(current_user) bypass_sign_in(current_user)
redirect_to settings_profile_index_path, notice: 'Password has been changed' redirect_to settings_profile_index_path, notice: I18n.t('password_has_been_changed')
else else
render :index, status: :unprocessable_entity render :index, status: :unprocessable_entity
end end
@@ -7,6 +7,8 @@ class SendSubmissionEmailController < ApplicationController
skip_before_action :verify_authenticity_token skip_before_action :verify_authenticity_token
skip_authorization_check skip_authorization_check
SEND_DURATION = 30.minutes
def success; end def success; end
def create def create
@@ -23,7 +25,10 @@ class SendSubmissionEmailController < ApplicationController
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes) RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! unless EmailEvent.exists?(tag: :submitter_documents_copy, email: @submitter.email, emailable: @submitter,
event_type: :send, created_at: SEND_DURATION.ago..Time.current)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later!
end
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 }
+3 -1
View File
@@ -3,6 +3,8 @@
class SessionsController < Devise::SessionsController class SessionsController < Devise::SessionsController
before_action :configure_permitted_parameters before_action :configure_permitted_parameters
around_action :with_browser_locale
def create def create
email = sign_in_params[:email].to_s.downcase email = sign_in_params[:email].to_s.downcase
@@ -10,7 +12,7 @@ class SessionsController < Devise::SessionsController
Rollbar.warning('Sign in new user') if defined?(Rollbar) 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: I18n.t('create_a_new_account')
end end
if User.exists?(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?
+3 -3
View File
@@ -21,7 +21,7 @@ class SetupController < ApplicationController
@encrypted_config = EncryptedConfig.new(encrypted_config_params) @encrypted_config = EncryptedConfig.new(encrypted_config_params)
unless URI.parse(encrypted_config_params[:value].to_s).class.in?([URI::HTTP, URI::HTTPS]) unless URI.parse(encrypted_config_params[:value].to_s).class.in?([URI::HTTP, URI::HTTPS])
@encrypted_config.errors.add(:value, 'should be a valid URL') @encrypted_config.errors.add(:value, I18n.t('should_be_a_valid_url'))
return render :index, status: :unprocessable_entity return render :index, status: :unprocessable_entity
end end
@@ -66,10 +66,10 @@ class SetupController < ApplicationController
end end
def redirect_to_root_if_signed def redirect_to_root_if_signed
redirect_to root_path, notice: 'You are already signed in' redirect_to root_path, notice: I18n.t('you_are_already_signed_in')
end end
def ensure_first_user_not_created! def ensure_first_user_not_created!
redirect_to new_user_session_path, notice: 'Please sign in.' if User.exists? redirect_to new_user_session_path, notice: I18n.t('please_sign_in') if User.exists?
end end
end end
+32 -12
View File
@@ -11,29 +11,35 @@ class StartFormController < ApplicationController
def show def show
@submitter = @template.submissions.new(account_id: @template.account_id) @submitter = @template.submissions.new(account_id: @template.account_id)
.submitters.new(uuid: @template.submitters.first['uuid']) .submitters.new(uuid: (filter_undefined_submitters(@template).first ||
@template.submitters.first)['uuid'])
end end
def update def update
return redirect_to start_form_path(@template.slug) if @template.archived_at? return redirect_to start_form_path(@template.slug) if @template.archived_at?
@submitter = Submitter.where(submission: @template.submissions.where(archived_at: nil)) @submitter = find_or_initialize_submitter(@template, submitter_params)
.order(id: :desc)
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
.find_or_initialize_by(**submitter_params.compact_blank)
if @submitter.completed_at? if @submitter.completed_at?
redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email]) redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email])
else else
if @template.submitters.to_a.size > 1 && @submitter.new_record? if filter_undefined_submitters(@template).size > 1 && @submitter.new_record?
@error_message = 'Not found' @error_message = I18n.t('not_found')
return render :show return render :show
end end
assign_submission_attributes(@submitter, @template) if @submitter.new_record? if (is_new_record = @submitter.new_record?)
assign_submission_attributes(@submitter, @template)
Submissions::AssignDefinedSubmitters.call(@submitter.submission)
end
if @submitter.save if @submitter.save
if is_new_record
SendSubmissionCreatedWebhookRequestJob.perform_async({ 'submission_id' => @submitter.submission.id })
end
redirect_to submit_form_path(@submitter.slug) redirect_to submit_form_path(@submitter.slug)
else else
render :show render :show
@@ -49,14 +55,21 @@ class StartFormController < ApplicationController
private private
def find_or_initialize_submitter(template, submitter_params)
Submitter.where(submission: template.submissions.where(expire_at: Time.current..)
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
.order(id: :desc)
.where(declined_at: nil)
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
.find_or_initialize_by(**submitter_params.compact_blank)
end
def assign_submission_attributes(submitter, template) def assign_submission_attributes(submitter, template)
resubmit_submitter = resubmit_submitter =
if params[:resubmit].present? (Submitter.where(submission: template.submissions).find_by(slug: params[:resubmit]) if params[:resubmit].present?)
Submitter.where(submission: @template.submissions).find_by(slug: params[:resubmit])
end
submitter.assign_attributes( submitter.assign_attributes(
uuid: template.submitters.first['uuid'], uuid: (filter_undefined_submitters(template).first || @template.submitters.first)['uuid'],
ip: request.remote_ip, ip: request.remote_ip,
ua: request.user_agent, ua: request.user_agent,
values: resubmit_submitter&.preferences&.fetch('default_values', nil) || {}, values: resubmit_submitter&.preferences&.fetch('default_values', nil) || {},
@@ -73,11 +86,18 @@ class StartFormController < ApplicationController
submitter.submission ||= Submission.new(template:, submitter.submission ||= Submission.new(template:,
account_id: template.account_id, account_id: template.account_id,
template_submitters: template.submitters, template_submitters: template.submitters,
submitters: [submitter],
source: :link) source: :link)
submitter.account_id = submitter.submission.account_id
submitter submitter
end end
def filter_undefined_submitters(template)
Templates.filter_undefined_submitters(template)
end
def submitter_params def submitter_params
params.require(:submitter).permit(:email, :phone, :name).tap do |attrs| params.require(:submitter).permit(:email, :phone, :name).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email]) attrs[:email] = Submissions.normalize_email(attrs[:email])
@@ -11,7 +11,7 @@ class StorageSettingsController < ApplicationController
if @encrypted_config.update(storage_configs) if @encrypted_config.update(storage_configs)
LoadActiveStorageConfigs.reload LoadActiveStorageConfigs.reload
redirect_to settings_storage_index_path, notice: 'Changes have been saved' redirect_to settings_storage_index_path, notice: I18n.t('changes_have_been_saved')
else else
render :index, status: :unprocessable_entity render :index, status: :unprocessable_entity
end end
+17 -5
View File
@@ -39,22 +39,34 @@ class SubmissionsController < ApplicationController
user: current_user, user: current_user,
source: :invite, source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random', submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
mark_as_sent: params[:send_email] == '1',
submissions_attrs: submissions_params[:submission].to_h.values, submissions_attrs: submissions_params[:submission].to_h.values,
params: params.merge('send_completed_email' => true)) params: params.merge('send_completed_email' => true))
end end
submissions.each do |submission|
SendSubmissionCreatedWebhookRequestJob.perform_async({ 'submission_id' => submission.id })
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: I18n.t('new_recipients_have_been_added')
end end
def destroy def destroy
@submission.update!(archived_at: Time.current) notice =
if params[:permanently].present?
@submission.destroy!
SendSubmissionArchivedWebhookRequestJob.perform_later(@submission) I18n.t('submission_has_been_removed')
else
@submission.update!(archived_at: Time.current)
redirect_back(fallback_location: template_path(@submission.template), notice: 'Submission has been archived') SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id)
I18n.t('submission_has_been_archived')
end
redirect_back(fallback_location: template_path(@submission.template), notice:)
end end
private private
@@ -16,13 +16,16 @@ class SubmissionsDebugController < ApplicationController
render 'submit_form/show' render 'submit_form/show'
end end
f.pdf do f.pdf do
if params[:audit] result =
Submissions::GenerateAuditTrail.call(@submitter.submission) if params[:audit]
else Submissions::GenerateAuditTrail.call(@submitter.submission)
Submissions::GenerateResultAttachments.call(@submitter) elsif params[:combined]
end Submissions::GenerateCombinedAttachment.call(@submitter)
else
Submissions::GenerateResultAttachments.call(@submitter)
end
send_data ActiveStorage::Attachment.where(name: params[:audit] ? :audit_trail : :documents).last.download, send_data Array.wrap(result).first.download,
filename: 'debug.pdf', filename: 'debug.pdf',
disposition: 'inline', disposition: 'inline',
type: 'application/pdf' type: 'application/pdf'
@@ -33,7 +33,17 @@ class SubmissionsDownloadController < ApplicationController
return head :not_found return head :not_found
end end
render json: build_urls(last_submitter) if params[:combined] == 'true'
url = build_combined_url(submitter)
if url
render json: [url]
else
head :not_found
end
else
render json: build_urls(last_submitter)
end
end end
private private
@@ -47,4 +57,14 @@ class SubmissionsDownloadController < ApplicationController
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: FILES_TTL.from_now.to_i) ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: FILES_TTL.from_now.to_i)
end end
end end
def build_combined_url(submitter)
return if submitter.submission.submitters.exists?(completed_at: nil)
return if submitter.submission.submitters.order(:completed_at).last != submitter
attachment = submitter.submission.combined_document_attachment
attachment ||= Submissions::GenerateCombinedAttachment.call(submitter)
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: FILES_TTL.from_now.to_i)
end
end end
@@ -19,7 +19,7 @@ class SubmissionsPreviewController < ApplicationController
@submission ||= Submission.find_by!(slug: params[:slug]) @submission ||= Submission.find_by!(slug: params[:slug])
if !@submission.submitters.all?(&:completed_at?) && current_user.blank? if !@submission.submitters.all?(&:completed_at?) && current_user.blank?
raise ActionController::RoutingError, 'Not Found' raise ActionController::RoutingError, I18n.t('not_found')
end end
if !submission_valid_ttl?(@submission) && !signature_valid if !submission_valid_ttl?(@submission) && !signature_valid
+34 -19
View File
@@ -7,44 +7,59 @@ class SubmitFormController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_authorization_check skip_authorization_check
PRELOAD_ALL_PAGES_AMOUNT = 200 CONFIG_KEYS = [].freeze
def show def show
@submitter = Submitter.find_by!(slug: params[:slug]) @submitter = Submitter.find_by!(slug: params[:slug])
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at? return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
return render :archived if @submitter.submission.template.archived_at? || @submitter.submission.archived_at?
return render :expired if @submitter.submission.expired?
return render :declined if @submitter.declined_at?
ActiveRecord::Associations::Preloader.new( Submitters.preload_with_pages(@submitter)
records: [@submitter],
associations: [submission: [:template, { template_schema_documents: :blob }]]
).call
total_pages =
@submitter.submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
ActiveRecord::Associations::Preloader.new(
records: @submitter.submission.template_schema_documents,
associations: [:blob, { preview_images_attachments: :blob }]
).call
end
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user) Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
render(@submitter.submission.template.archived_at? || @submitter.submission.archived_at? ? :archived : :show) @attachments_index = ActiveStorage::Attachment.where(record: @submitter.submission.submitters, name: :attachments)
.preload(:blob).index_by(&:uuid)
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
return unless @form_configs[:prefill_signature]
if (user_signature = UserConfigs.load_signature(current_user))
@signature_attachment = ActiveStorage::Attachment.find_or_create_by!(
blob_id: user_signature.blob_id,
name: 'attachments',
record: @submitter
)
end
@signature_attachment ||=
Submitters::MaybeAssignDefaultBrowserSignature.call(@submitter, params, cookies, @attachments_index.values)
@attachments_index[@signature_attachment.uuid] = @signature_attachment if @signature_attachment
end end
def update def update
submitter = Submitter.find_by!(slug: params[:slug]) submitter = Submitter.find_by!(slug: params[:slug])
if submitter.completed_at? if submitter.completed_at?
return render json: { error: 'Form has been completed already.' }, status: :unprocessable_entity return render json: { error: I18n.t('form_has_been_completed_already') }, status: :unprocessable_entity
end end
if submitter.template.archived_at? || submitter.submission.archived_at? if submitter.template.archived_at? || submitter.submission.archived_at?
Rollbar.info("Archived template: #{submitter.template.id}") if defined?(Rollbar) return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_entity
end
return render json: { error: 'Form has been archived.' }, status: :unprocessable_entity if submitter.submission.expired?
return render json: { error: I18n.t('form_has_been_expired') }, status: :unprocessable_entity
end
if submitter.declined_at?
return render json: { error: I18n.t('form_has_been_declined') },
status: :unprocessable_entity
end end
Submitters::SubmitValues.call(submitter, params, request) Submitters::SubmitValues.call(submitter, params, request)
@@ -0,0 +1,29 @@
# frozen_string_literal: true
class SubmitFormDeclineController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def create
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return redirect_to submit_form_path(submitter.slug) if submitter.declined_at? ||
submitter.completed_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired? ||
submitter.submission.template.archived_at?
ApplicationRecord.transaction do
submitter.update!(declined_at: Time.current)
SubmissionEvents.create_with_tracking_data(submitter, 'decline_form', request, { reason: params[:reason] })
end
user = submitter.submission.created_by_user || submitter.template.author
SubmitterMailer.declined_email(submitter, user).deliver_later!
SendFormDeclinedWebhookRequestJob.perform_async('submitter_id' => submitter.id)
redirect_to submit_form_path(submitter.slug)
end
end
@@ -0,0 +1,37 @@
# frozen_string_literal: true
class SubmitFormDownloadController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
FILES_TTL = 5.minutes
def index
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return redirect_to submitter_download_index_path(submitter.slug) if submitter.completed_at?
return head :unprocessable_entity if submitter.declined_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired? ||
submitter.submission.template.archived_at?
last_completed_submitter = submitter.submission.submitters
.where.not(id: submitter.id)
.where.not(completed_at: nil)
.max_by(&:completed_at)
attachments =
if last_completed_submitter
Submitters.select_attachments_for_download(last_completed_submitter)
else
submitter.submission.template.schema_documents.preload(:blob)
end
urls = attachments.map do |attachment|
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: FILES_TTL.from_now.to_i)
end
render json: urls
end
end
@@ -0,0 +1,21 @@
# frozen_string_literal: true
class SubmitFormDrawSignatureController < ApplicationController
layout false
around_action :with_browser_locale, only: %i[show]
skip_before_action :authenticate_user!
skip_authorization_check
def show
@submitter = Submitter.find_by!(slug: params[:slug])
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
if @submitter.submission.template.archived_at? || @submitter.submission.archived_at?
return redirect_to submit_form_path(@submitter.slug)
end
render :show
end
end
@@ -0,0 +1,58 @@
# frozen_string_literal: true
class SubmitFormInviteController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def create
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return head :unprocessable_entity unless can_invite?(submitter)
invite_submitters = filter_invite_submitters(submitter)
ApplicationRecord.transaction do
invite_submitters.each do |item|
attrs = submitters_attributes.find { |e| e[:uuid] == item['uuid'] }
next unless attrs
submitter.submission.submitters.create!(**attrs, account_id: submitter.account_id)
SubmissionEvents.create_with_tracking_data(submitter, 'invite_party', request, { uuid: submitter.uuid })
end
submitter.submission.update!(submitters_order: :preserved)
end
submitter.submission.submitters.reload
if invite_submitters.all? { |s| submitter.submission.submitters.any? { |e| e.uuid == s['uuid'] } }
Submitters::SubmitValues.call(submitter, ActionController::Parameters.new(completed: 'true'), request)
head :ok
else
head :unprocessable_entity
end
end
private
def can_invite?(submitter)
!submitter.declined_at? &&
!submitter.completed_at? &&
!submitter.submission.archived_at? &&
!submitter.submission.expired? &&
!submitter.submission.template.archived_at?
end
def filter_invite_submitters(submitter)
(submitter.submission.template_submitters || submitter.submission.template.submitters).select do |s|
s['invite_by_uuid'] == submitter.uuid && submitter.submission.submitters.none? { |e| e.uuid == s['uuid'] }
end
end
def submitters_attributes
params.require(:submission).permit(submitters: [%i[uuid email]]).fetch(:submitters, [])
end
end
@@ -0,0 +1,23 @@
# frozen_string_literal: true
class SubmitFormValuesController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def index
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return render json: {} if submitter.completed_at? || submitter.declined_at?
return render json: {} if submitter.submission.template.archived_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired?
value = submitter.values[params['field_uuid']]
attachment = submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present?
render json: {
value:,
attachment: attachment&.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
}, head: :ok
end
end
@@ -25,7 +25,7 @@ class SubmittersAutocompleteController < ApplicationController
term = "#{params[:q].downcase}%" term = "#{params[:q].downcase}%"
submitters.where(column.matches(term, false, true)) submitters.where(column.matches(term))
else else
Submitters.search(submitters, params[:q]) Submitters.search(submitters, params[:q])
end end
@@ -10,14 +10,14 @@ class SubmittersSendEmailController < ApplicationController
Rollbar.warning("Already sent: #{@submitter.id}") if defined?(Rollbar) 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: I18n.t('email_has_been_sent_already'))
end end
SendSubmitterInvitationEmailJob.perform_later('submitter_id' => @submitter.id) SendSubmitterInvitationEmailJob.perform_async('submitter_id' => @submitter.id)
@submitter.sent_at ||= Time.current @submitter.sent_at ||= Time.current
@submitter.save! @submitter.save!
redirect_back(fallback_location: submission_path(@submitter.submission), notice: 'Email has been sent') redirect_back(fallback_location: submission_path(@submitter.submission), notice: I18n.t('email_has_been_sent'))
end end
end end
@@ -4,11 +4,13 @@ class TemplateDocumentsController < ApplicationController
load_and_authorize_resource :template load_and_authorize_resource :template
def create def create
return head :unprocessable_entity if params[:blobs].blank? && params[:files].blank? if params[:blobs].blank? && params[:files].blank?
return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_entity
end
old_fields_hash = @template.fields.hash old_fields_hash = @template.fields.hash
documents = Templates::CreateAttachments.call(@template, params) documents = Templates::CreateAttachments.call(@template, params, extract_fields: true)
schema = documents.map do |doc| schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base } { attachment_uuid: doc.uuid, name: doc.filename.base }
@@ -26,6 +28,6 @@ class TemplateDocumentsController < ApplicationController
) )
} }
rescue Templates::CreateAttachments::PdfEncrypted rescue Templates::CreateAttachments::PdfEncrypted
render json: { error: 'PDF encrypted' }, status: :unprocessable_entity render json: { error: 'PDF encrypted', status: 'pdf_encrypted' }, status: :unprocessable_entity
end end
end end
@@ -15,9 +15,9 @@ class TemplateFoldersController < ApplicationController
def update def update
if @template_folder != current_account.default_template_folder && if @template_folder != current_account.default_template_folder &&
@template_folder.update(template_folder_params) @template_folder.update(template_folder_params)
redirect_to folder_path(@template_folder), notice: 'Folder name has been updated' redirect_to folder_path(@template_folder), notice: I18n.t('folder_name_has_been_updated')
else else
redirect_to folder_path(@template_folder), alert: 'Unable to rename folder' redirect_to folder_path(@template_folder), alert: I18n.t('unable_to_rename_folder')
end end
end end
+16 -9
View File
@@ -21,13 +21,13 @@ class TemplatesController < ApplicationController
end end
def new def new
@template.name = "#{@base_template.name} (Clone)" if @base_template @template.name = "#{@base_template.name} (#{I18n.t('clone')})" if @base_template
end end
def edit def edit
ActiveRecord::Associations::Preloader.new( ActiveRecord::Associations::Preloader.new(
records: [@template], records: [@template],
associations: [schema_documents: { preview_images_attachments: :blob }] associations: [schema_documents: [:blob, { preview_images_attachments: :blob }]]
).call ).call
@template_data = @template_data =
@@ -43,6 +43,11 @@ class TemplatesController < ApplicationController
def create def create
if @base_template if @base_template
ActiveRecord::Associations::Preloader.new(
records: [@base_template],
associations: [schema_documents: :preview_images_attachments]
).call
@template = Templates::Clone.call(@base_template, author: current_user, @template = Templates::Clone.call(@base_template, author: current_user,
name: params.dig(:template, :name), name: params.dig(:template, :name),
folder_name: params[:folder_name]) folder_name: params[:folder_name])
@@ -53,7 +58,7 @@ class TemplatesController < ApplicationController
if params[:account_id].present? && authorized_clone_account_id?(params[:account_id]) if params[:account_id].present? && authorized_clone_account_id?(params[:account_id])
@template.account_id = params[:account_id] @template.account_id = params[:account_id]
@template.folder = @template.account.default_template_folder @template.folder = @template.account.default_template_folder if @template.account_id != current_account.id
else else
@template.account = current_account @template.account = current_account
end end
@@ -61,6 +66,8 @@ class TemplatesController < ApplicationController
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
SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id)
maybe_redirect_to_template(@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
@@ -70,6 +77,8 @@ class TemplatesController < ApplicationController
def update def update
@template.update!(template_params) @template.update!(template_params)
SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id)
head :ok head :ok
end end
@@ -78,13 +87,11 @@ class TemplatesController < ApplicationController
if params[:permanently].present? if params[:permanently].present?
@template.destroy! @template.destroy!
Rollbar.info("Remove template: #{@template.id}") if defined?(Rollbar) I18n.t('template_has_been_removed')
'Template has been removed.'
else else
@template.update!(archived_at: Time.current) @template.update!(archived_at: Time.current)
'Template has been archived.' I18n.t('template_has_been_archived')
end end
redirect_back(fallback_location: root_path, notice:) redirect_back(fallback_location: root_path, notice:)
@@ -96,7 +103,7 @@ class TemplatesController < ApplicationController
params.require(:template).permit( params.require(:template).permit(
:name, :name,
{ schema: [%i[attachment_uuid name]], { schema: [%i[attachment_uuid name]],
submitters: [%i[name uuid]], submitters: [%i[name uuid is_requester linked_to_uuid invite_by_uuid email]],
fields: [[:uuid, :submitter_uuid, :name, :type, fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value, :required, :readonly, :default_value,
:title, :description, :title, :description,
@@ -116,7 +123,7 @@ class TemplatesController < ApplicationController
if template.account == current_account if template.account == current_account
redirect_to(edit_template_path(@template)) redirect_to(edit_template_path(@template))
else else
redirect_back(fallback_location: root_path, notice: 'Template has been clonned') redirect_back(fallback_location: root_path, notice: I18n.t('template_has_been_cloned'))
end end
end end
@@ -0,0 +1,33 @@
# frozen_string_literal: true
class TemplatesDebugController < ApplicationController
load_and_authorize_resource :template
def show
attachment = @template.documents.first
pdf = HexaPDF::Document.new(io: StringIO.new(attachment.download))
fields = Templates::FindAcroFields.call(pdf, attachment)
attachment.metadata['pdf'] ||= {}
attachment.metadata['pdf']['fields'] = fields
@template.update!(fields: Templates::ProcessDocument.normalize_attachment_fields(@template, [attachment]))
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: { preview_images_attachments: :blob }]
).call
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: %i[metadata signed_uuid],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
render 'templates/edit', layout: 'plain'
end
end
@@ -9,9 +9,9 @@ class TemplatesFoldersController < ApplicationController
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:name]) @template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:name])
if @template.save if @template.save
redirect_back(fallback_location: template_path(@template), notice: 'Document template has been moved') redirect_back(fallback_location: template_path(@template), notice: I18n.t('document_template_has_been_moved'))
else else
redirect_back(fallback_location: template_path(@template), notice: 'Unable to move template into folder') redirect_back(fallback_location: template_path(@template), notice: I18n.t('unable_to_move_template_into_folder'))
end end
end end
@@ -0,0 +1,23 @@
# frozen_string_literal: true
class TemplatesFormPreviewController < ApplicationController
layout 'form'
load_and_authorize_resource :template
def show
@submitter = Submitter.new(uuid: params[:uuid] || @template.submitters.first['uuid'],
account: current_account,
submission: @template.submissions.new(template_submitters: @template.submitters,
account: current_account))
@submitter.submission.submitters = @template.submitters.map { |item| Submitter.new(uuid: item['uuid']) }
Submitters.preload_with_pages(@submitter)
@attachments_index = ActiveStorage::Attachment.where(record: @submitter.submission.submitters, name: :attachments)
.preload(:blob).index_by(&:uuid)
@form_configs = Submitters::FormConfigs.call(@submitter)
end
end
@@ -9,6 +9,7 @@ class TemplatesPreferencesController < ApplicationController
authorize!(:update, @template) authorize!(:update, @template)
@template.preferences = @template.preferences.merge(template_params[:preferences]) @template.preferences = @template.preferences.merge(template_params[:preferences])
@template.preferences = @template.preferences.reject { |_, v| (v.is_a?(String) || v.is_a?(Hash)) && v.blank? }
@template.save! @template.save!
head :ok head :ok
@@ -17,6 +18,25 @@ class TemplatesPreferencesController < ApplicationController
private private
def template_params def template_params
params.require(:template).permit(preferences: %i[bcc_completed request_email_subject request_email_body]) params.require(:template).permit(
preferences: %i[bcc_completed request_email_subject request_email_body
documents_copy_email_subject documents_copy_email_body
documents_copy_email_enabled documents_copy_email_attach_audit
completed_notification_email_attach_documents
completed_redirect_url
completed_notification_email_subject completed_notification_email_body
completed_notification_email_enabled completed_notification_email_attach_audit] +
[completed_message: %i[title body]]
).tap do |attrs|
attrs[:preferences] = attrs[:preferences].transform_values do |value|
if %w[true false].include?(value)
value == 'true'
elsif value.respond_to?(:compact_blank)
value.compact_blank
else
value
end
end
end
end end
end end
@@ -0,0 +1,54 @@
# frozen_string_literal: true
class TemplatesRecipientsController < ApplicationController
load_and_authorize_resource :template
def create
authorize!(:update, @template)
@template.submitters =
submitters_params.map { |s| s.reject { |_, v| v.is_a?(String) && v.blank? } }
@template.save!
render json: { submitters: @template.submitters }
end
private
def submitters_params
params.require(:template).permit(
submitters: [%i[name uuid is_requester invite_by_uuid linked_to_uuid email option]]
).fetch(:submitters, {}).values.filter_map do |s|
next if s[:uuid].blank?
if s[:is_requester] == '1' && s[:invite_by_uuid].blank?
s[:is_requester] = true
else
s.delete(:is_requester)
end
s.delete(:invite_by_uuid) if s[:invite_by_uuid].blank?
option = s.delete(:option)
if option.present?
case option
when 'is_requester'
s[:is_requester] = true
when 'not_set'
s.delete(:is_requester)
s.delete(:email)
s.delete(:linked_to_uuid)
s.delete(:invite_by_uuid)
when /\Alinked_to_(.*)\z/
s[:linked_to_uuid] = ::Regexp.last_match(-1)
when /\Ainvite_by_(.*)\z/
s[:invite_by_uuid] = ::Regexp.last_match(-1)
end
end
s
end
end
end
@@ -6,6 +6,6 @@ class TemplatesRestoreController < ApplicationController
def create def create
@template.update!(archived_at: nil) @template.update!(archived_at: nil)
redirect_to template_path(@template), notice: 'Template has been unarchived' redirect_to template_path(@template), notice: I18n.t('template_has_been_unarchived')
end end
end end
+14 -11
View File
@@ -12,19 +12,28 @@ class TemplatesUploadsController < ApplicationController
save_template!(@template, url_params) save_template!(@template, url_params)
documents = Templates::CreateAttachments.call(@template, url_params || params) documents = Templates::CreateAttachments.call(@template, url_params || params, extract_fields: true)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } } schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
if @template.fields.blank?
@template.fields = Templates::ProcessDocument.normalize_attachment_fields(@template, documents)
schema.each { |item| item['pending_fields'] = true } if @template.fields.present?
end
@template.update!(schema:) @template.update!(schema:)
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => @template.id)
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)
rescue StandardError => e rescue StandardError => e
Rollbar.error(e) if defined?(Rollbar) Rollbar.error(e) if defined?(Rollbar)
redirect_to root_path, alert: 'Unable to upload file' raise if Rails.env.local?
redirect_to root_path, alert: I18n.t('unable_to_update_file')
end end
private private
@@ -43,23 +52,17 @@ class TemplatesUploadsController < ApplicationController
def create_file_params_from_url def create_file_params_from_url
tempfile = Tempfile.new tempfile = Tempfile.new
tempfile.binmode tempfile.binmode
tempfile.write(conn.get(Addressable::URI.parse(params[:url]).display_uri.to_s).body) tempfile.write(DownloadUtils.call(params[:url]).body)
tempfile.rewind tempfile.rewind
file = ActionDispatch::Http::UploadedFile.new( file = ActionDispatch::Http::UploadedFile.new(
tempfile:, tempfile:,
filename: File.basename( filename: File.basename(
URI.decode_www_form_component(params[:filename].presence || params[:url]) URI.decode_www_form_component(params[:filename].presence || params[:url]), '.*'
), ),
type: Marcel::MimeType.for(tempfile) type: Marcel::MimeType.for(tempfile)
) )
{ files: [file] } { files: [file] }
end end
def conn
Faraday.new do |faraday|
faraday.response :follow_redirects
end
end
end end
+22 -14
View File
@@ -1,38 +1,46 @@
# 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?
test_timeserver_url(@encrypted_config.value) if @encrypted_config.value.present? test_timeserver_url(@encrypted_config.value) if @encrypted_config.value.present?
if @encrypted_config.value.present? ? @encrypted_config.save : @encrypted_config.delete if @encrypted_config.value.present? ? @encrypted_config.save : @encrypted_config.delete
redirect_back fallback_location: settings_notifications_path, notice: 'Changes have been saved' redirect_back fallback_location: settings_notifications_path, notice: I18n.t('changes_have_been_saved')
else else
redirect_back fallback_location: settings_notifications_path, alert: 'Unable to save' redirect_back fallback_location: settings_notifications_path, alert: I18n.t('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: t('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,43 @@
# frozen_string_literal: true
class UserInitialsController < ApplicationController
before_action :load_user_config
authorize_resource :user_config
def edit; end
def update
file = params[:file]
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_initials') if file.blank?
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
attachment = ActiveStorage::Attachment.create!(
blob:,
name: 'initials',
record: current_user
)
if @user_config.update(value: attachment.uuid)
redirect_to settings_profile_index_path, notice: I18n.t('initials_has_been_saved')
else
redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_initials')
end
end
def destroy
@user_config.destroy
redirect_to settings_profile_index_path, notice: I18n.t('initials_has_been_removed')
end
private
def load_user_config
@user_config =
UserConfig.find_or_initialize_by(user: current_user, key: UserConfig::INITIALS_KEY)
end
end
@@ -9,7 +9,7 @@ class UserSignaturesController < ApplicationController
def update def update
file = params[:file] file = params[:file]
return redirect_to settings_profile_index_path, notice: 'Unable to save signature' if file.blank? return redirect_to settings_profile_index_path, notice: I18n.t('Unable to save signature') if file.blank?
blob = ActiveStorage::Blob.create_and_upload!(io: file.open, blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename, filename: file.original_filename,
@@ -22,16 +22,16 @@ class UserSignaturesController < ApplicationController
) )
if @user_config.update(value: attachment.uuid) if @user_config.update(value: attachment.uuid)
redirect_to settings_profile_index_path, notice: 'Signature has been saved' redirect_to settings_profile_index_path, notice: I18n.t('signature_has_been_saved')
else else
redirect_to settings_profile_index_path, notice: 'Unable to save signature' redirect_to settings_profile_index_path, notice: I18n.t('Unable to save signature')
end end
end end
def destroy def destroy
@user_config.destroy @user_config.destroy
redirect_to settings_profile_index_path, notice: 'Signature has been removed' redirect_to settings_profile_index_path, notice: I18n.t('signature_has_been_removed')
end end
private private
+14 -5
View File
@@ -27,6 +27,7 @@ class UsersController < ApplicationController
if existing_user if existing_user
existing_user.archived_at = nil existing_user.archived_at = nil
existing_user.assign_attributes(user_params) existing_user.assign_attributes(user_params)
existing_user.account = current_account
@user = existing_user @user = existing_user
end end
@@ -34,20 +35,28 @@ class UsersController < ApplicationController
if @user.save if @user.save
UserMailer.invitation_email(@user).deliver_later! UserMailer.invitation_email(@user).deliver_later!
redirect_back fallback_location: settings_users_path, notice: 'User has been invited' redirect_back fallback_location: settings_users_path, notice: I18n.t('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
end end
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: I18n.t('unable_to_update_user') if Docuseal.demo?
attrs = user_params.compact_blank.merge(user_params.slice(:archived_at)) 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 params.dig(:user, :account_id).present?
account = Account.accessible_by(current_ability).find(params[:user][:account_id])
authorize!(:manage, account)
@user.account = account
end
if @user.update(attrs) if @user.update(attrs)
redirect_back fallback_location: settings_users_path, notice: 'User has been updated' redirect_back fallback_location: settings_users_path, notice: I18n.t('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
@@ -55,12 +64,12 @@ class UsersController < ApplicationController
def destroy def destroy
if Docuseal.demo? || @user.id == current_user.id if Docuseal.demo? || @user.id == current_user.id
return redirect_to settings_users_path, notice: 'Unable to remove user' return redirect_to settings_users_path, notice: I18n.t('unable_to_remove_user')
end end
@user.update!(archived_at: Time.current) @user.update!(archived_at: Time.current)
redirect_back fallback_location: settings_users_path, notice: 'User has been removed' redirect_back fallback_location: settings_users_path, notice: I18n.t('user_has_been_removed')
end end
private private
@@ -9,29 +9,11 @@ class VerifyPdfSignatureController < ApplicationController
HexaPDF::Document.new(io: file.open) HexaPDF::Document.new(io: file.open)
end end
cert_data = trusted_certs = Accounts.load_trusted_certs(current_account)
if Docuseal.multitenant?
value = EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
Docuseal::CERTS.merge(value)
else
EncryptedConfig.find_by(key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
end
default_pkcs = GenerateCertificate.load_pkcs(cert_data)
custom_certs = cert_data.fetch('custom', []).map do |e|
OpenSSL::PKCS12.new(Base64.urlsafe_decode64(e['data']), e['password'].to_s)
end
trusted_certs = [default_pkcs.certificate,
*default_pkcs.ca_certs,
*custom_certs.map(&:certificate),
*custom_certs.flat_map(&:ca_certs).compact]
render turbo_stream: turbo_stream.replace('result', partial: 'result', render turbo_stream: turbo_stream.replace('result', partial: 'result',
locals: { pdfs:, files: params[:files], trusted_certs: }) locals: { pdfs:, files: params[:files], trusted_certs: })
rescue HexaPDF::MalformedPDFError rescue HexaPDF::MalformedPDFError
render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div('Invalid PDF', id: 'result')) render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div(I18n.t('invalid_pdf'), id: 'result'))
end end
end end
@@ -5,6 +5,10 @@ class WebhookPreferencesController < ApplicationController
form.viewed form.viewed
form.started form.started
form.completed form.completed
form.declined
template.created
template.updated
submission.created
submission.archived submission.archived
].freeze ].freeze
@@ -0,0 +1,29 @@
# frozen_string_literal: true
class WebhookSecretController < ApplicationController
before_action :load_encrypted_config
authorize_resource :encrypted_config, parent: false
def index; end
def create
@encrypted_config.assign_attributes(value: {
encrypted_config_params[:key] => encrypted_config_params[:value]
}.compact_blank)
@encrypted_config.value.present? ? @encrypted_config.save! : @encrypted_config.delete
redirect_back(fallback_location: settings_webhooks_path, notice: I18n.t('webhook_secret_has_been_saved'))
end
private
def load_encrypted_config
@encrypted_config =
current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_SECRET_KEY)
end
def encrypted_config_params
params.require(:encrypted_config).permit(value: %i[key value]).fetch(:value, {})
end
end
@@ -11,15 +11,16 @@ class WebhookSettingsController < ApplicationController
@encrypted_config.value.present? ? @encrypted_config.save! : @encrypted_config.delete @encrypted_config.value.present? ? @encrypted_config.save! : @encrypted_config.delete
redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook URL has been saved.') redirect_back(fallback_location: settings_webhooks_path, notice: I18n.t('webhook_url_has_been_saved'))
end end
def update def update
submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last
SendFormCompletedWebhookRequestJob.perform_later(submitter) SendFormCompletedWebhookRequestJob.perform_async({ 'submitter_id' => submitter.id,
'encrypted_config_id' => @encrypted_config.id })
redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook request has been sent.') redirect_back(fallback_location: settings_webhooks_path, notice: I18n.t('webhook_request_has_been_sent'))
end end
private private
+51 -23
View File
@@ -24,6 +24,11 @@ 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 EmailsTextarea from './elements/emails_textarea'
import ToggleOnSubmit from './elements/toggle_on_submit' import ToggleOnSubmit from './elements/toggle_on_submit'
import PasswordInput from './elements/password_input'
import SearchInput from './elements/search_input'
import ToggleAttribute from './elements/toggle_attribute'
import LinkedInput from './elements/linked_input'
import CheckboxGroup from './elements/checkbox_group'
import * as TurboInstantClick from './lib/turbo_instant_click' import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -41,26 +46,6 @@ document.addEventListener('keyup', (e) => {
} }
}) })
window.customElements.define('toggle-visible', ToggleVisible)
window.customElements.define('disable-hidden', DisableHidden)
window.customElements.define('turbo-modal', TurboModal)
window.customElements.define('file-dropzone', FileDropzone)
window.customElements.define('menu-active', MenuActive)
window.customElements.define('clipboard-copy', ClipboardCopy)
window.customElements.define('dynamic-list', DynamicList)
window.customElements.define('download-button', DownloadButton)
window.customElements.define('set-origin-url', SetOriginUrl)
window.customElements.define('set-timezone', SetTimezone)
window.customElements.define('autoresize-textarea', AutoresizeTextarea)
window.customElements.define('submitters-autocomplete', SubmittersAutocomplete)
window.customElements.define('folder-autocomplete', FolderAutocomplete)
window.customElements.define('signature-form', SignatureForm)
window.customElements.define('submit-form', SubmitForm)
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) => {
const resp = event.detail?.formSubmission?.result?.fetchResponse?.response const resp = event.detail?.formSubmission?.result?.fetchResponse?.response
@@ -84,8 +69,37 @@ document.addEventListener('turbo:submit-end', async (event) => {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
}) })
window.customElements.define('template-builder', class extends HTMLElement { const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options)
safeRegisterElement('toggle-visible', ToggleVisible)
safeRegisterElement('disable-hidden', DisableHidden)
safeRegisterElement('turbo-modal', TurboModal)
safeRegisterElement('file-dropzone', FileDropzone)
safeRegisterElement('menu-active', MenuActive)
safeRegisterElement('clipboard-copy', ClipboardCopy)
safeRegisterElement('dynamic-list', DynamicList)
safeRegisterElement('download-button', DownloadButton)
safeRegisterElement('set-origin-url', SetOriginUrl)
safeRegisterElement('set-timezone', SetTimezone)
safeRegisterElement('autoresize-textarea', AutoresizeTextarea)
safeRegisterElement('submitters-autocomplete', SubmittersAutocomplete)
safeRegisterElement('folder-autocomplete', FolderAutocomplete)
safeRegisterElement('signature-form', SignatureForm)
safeRegisterElement('submit-form', SubmitForm)
safeRegisterElement('prompt-password', PromptPassword)
safeRegisterElement('emails-textarea', EmailsTextarea)
safeRegisterElement('toggle-cookies', ToggleCookies)
safeRegisterElement('toggle-on-submit', ToggleOnSubmit)
safeRegisterElement('password-input', PasswordInput)
safeRegisterElement('search-input', SearchInput)
safeRegisterElement('toggle-attribute', ToggleAttribute)
safeRegisterElement('linked-input', LinkedInput)
safeRegisterElement('checkbox-group', CheckboxGroup)
safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {
document.addEventListener('turbo:submit-end', this.onSubmit)
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
this.appElem.classList.add('md:h-screen') this.appElem.classList.add('md:h-screen')
@@ -93,6 +107,7 @@ window.customElements.define('template-builder', class extends HTMLElement {
this.app = createApp(TemplateBuilder, { this.app = createApp(TemplateBuilder, {
template: reactive(JSON.parse(this.dataset.template)), template: reactive(JSON.parse(this.dataset.template)),
backgroundColor: '#faf7f5', backgroundColor: '#faf7f5',
locale: this.dataset.locale,
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',
@@ -100,28 +115,41 @@ window.customElements.define('template-builder', class extends HTMLElement {
withPayment: this.dataset.withPayment === 'true', withPayment: this.dataset.withPayment === 'true',
isPaymentConnected: this.dataset.isPaymentConnected === 'true', isPaymentConnected: this.dataset.isPaymentConnected === 'true',
withFormula: this.dataset.withFormula === 'true', withFormula: this.dataset.withFormula === 'true',
withSendButton: this.dataset.withSendButton !== 'false',
withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false',
withConditions: this.dataset.withConditions === '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
}) })
this.app.mount(this.appElem) this.component = this.app.mount(this.appElem)
this.appendChild(this.appElem) this.appendChild(this.appElem)
} }
onSubmit = (e) => {
if (e.detail.success && e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
e.detail.fetchResponse.response.json().then((data) => {
this.component.template.submitters = data.submitters
})
}
}
disconnectedCallback () { disconnectedCallback () {
document.removeEventListener('turbo:submit-end', this.onSubmit)
this.app?.unmount() this.app?.unmount()
this.appElem?.remove() this.appElem?.remove()
} }
}) })
window.customElements.define('import-list', class extends HTMLElement { safeRegisterElement('import-list', class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
this.app = createApp(ImportList, { this.app = createApp(ImportList, {
template: JSON.parse(this.dataset.template), template: JSON.parse(this.dataset.template),
multitenant: this.dataset.multitenant === 'true',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content
}) })
+6 -2
View File
@@ -51,6 +51,10 @@ button[disabled] .enabled {
@apply input input-bordered bg-white; @apply input input-bordered bg-white;
} }
.base-input-slim {
@apply input input-bordered bg-white h-10;
}
.base-textarea { .base-textarea {
@apply textarea textarea-bordered bg-white rounded-3xl; @apply textarea textarea-bordered bg-white rounded-3xl;
} }
@@ -100,7 +104,7 @@ button[disabled] .enabled {
.autocomplete { .autocomplete {
background: white; background: white;
z-index: 1000; z-index: 1000;
font: 14px/22px "-apple-system", BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font: 16px/25px "-apple-system", BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
overflow: auto; overflow: auto;
box-sizing: border-box; box-sizing: border-box;
@apply border border-base-300 mt-1 rounded-md; @apply border border-base-300 mt-1 rounded-md;
@@ -111,7 +115,7 @@ button[disabled] .enabled {
} }
.autocomplete > div { .autocomplete > div {
@apply px-2 py-1 font-normal text-sm; @apply px-2 py-1.5 font-normal;
} }
.autocomplete .group { .autocomplete .group {
+114
View File
@@ -0,0 +1,114 @@
import SignaturePad from 'signature_pad'
import { cropCanvasAndExportToPNG } from './submission_form/crop_canvas'
window.customElements.define('draw-signature', class extends HTMLElement {
connectedCallback () {
const scale = 3
this.canvas.width = this.canvas.parentNode.clientWidth * scale
this.canvas.height = this.canvas.parentNode.clientHeight * scale
this.canvas.getContext('2d').scale(scale, scale)
this.pad = new SignaturePad(this.canvas)
this.pad.addEventListener('endStroke', () => {
this.updateSubmitButtonVisibility()
})
this.clearButton.addEventListener('click', (e) => {
e.preventDefault()
this.clearSignaturePad()
})
this.form.addEventListener('submit', (e) => {
e.preventDefault()
this.submitButton.disabled = true
this.submitImage().then((data) => {
this.valueInput.value = data.uuid
return fetch(this.form.action, {
method: 'PUT',
body: new FormData(this.form)
}).then((response) => {
this.form.classList.add('hidden')
this.success.classList.remove('hidden')
return response
})
}).finally(() => {
this.submitButton.disabled = false
})
})
}
clearSignaturePad () {
this.pad.clear()
this.updateSubmitButtonVisibility()
}
updateSubmitButtonVisibility () {
if (this.pad.isEmpty()) {
this.submitButton.style.display = 'none'
this.placeholderButton.style.display = 'block'
} else {
this.submitButton.style.display = 'block'
this.placeholderButton.style.display = 'none'
}
}
async submitImage () {
return new Promise((resolve, reject) => {
cropCanvasAndExportToPNG(this.canvas, { errorOnTooSmall: true }).then(async (blob) => {
const file = new File([blob], 'signature.png', { type: 'image/png' })
const formData = new FormData()
formData.append('file', file)
formData.append('submitter_slug', this.dataset.slug)
formData.append('name', 'attachments')
formData.append('remember_signature', 'true')
return fetch('/api/attachments', {
method: 'POST',
body: formData
}).then((resp) => resp.json()).then((attachment) => {
return resolve(attachment)
})
}).catch((error) => {
return reject(error)
})
})
}
get submitButton () {
return this.querySelector('button[type="submit"]')
}
get clearButton () {
return this.querySelector('button[aria-label="Clear"]')
}
get placeholderButton () {
return this.querySelector('button[disabled]')
}
get canvas () {
return this.querySelector('canvas')
}
get valueInput () {
return this.querySelector('input[name^="values"]')
}
get form () {
return this.querySelector('form')
}
get success () {
return this.querySelector('#success')
}
})
+15
View File
@@ -0,0 +1,15 @@
export default class extends HTMLElement {
connectedCallback () {
this.items.forEach((item) => {
item.addEventListener('change', (e) => {
this.items.forEach((item) => {
item.checked = item === e.target && e.target.checked
})
})
})
}
get items () {
return this.querySelectorAll('input[type="checkbox"]')
}
}
+10 -5
View File
@@ -17,13 +17,18 @@ export default targetable(class extends HTMLElement {
this.toggleState() this.toggleState()
fetch(this.dataset.src).then((response) => response.json()).then((urls) => { fetch(this.dataset.src).then(async (response) => {
const isSafariIos = /iPhone|iPad|iPod/i.test(navigator.userAgent) if (response.ok) {
const urls = await response.json()
const isSafariIos = /iPhone|iPad|iPod/i.test(navigator.userAgent)
if (isSafariIos && urls.length > 1) { if (isSafariIos && urls.length > 1) {
this.downloadSafariIos(urls) this.downloadSafariIos(urls)
} else {
this.downloadUrls(urls)
}
} else { } else {
this.downloadUrls(urls) alert('Failed to download files')
} }
}) })
} }
+31
View File
@@ -0,0 +1,31 @@
export default class extends HTMLElement {
connectedCallback () {
if (this.target) {
this.input.value = this.target.value
this.target.addEventListener('input', (e) => {
this.input.value = e.target.value
})
this.target.addEventListener('linked-input.update', (e) => {
this.input.value = e.target.value
})
}
}
get input () {
return this.querySelector('input')
}
get target () {
if (this.dataset.targetId) {
const listItem = this.closest('[data-targets="dynamic-list.items"]')
if (listItem) {
return listItem.querySelector(`#${this.dataset.targetId}`)
} else {
return document.getElementById(this.dataset.targetId)
}
}
}
}
+35
View File
@@ -0,0 +1,35 @@
import { target, targetable } from '@github/catalyst/lib/targetable'
export default targetable(class extends HTMLElement {
static [target.static] = [
'visiblePasswordIcon',
'hiddenPasswordIcon',
'passwordInput',
'togglePasswordVisibility'
]
connectedCallback () {
this.togglePasswordVisibility.addEventListener('click', this.handleTogglePasswordVisibility)
document.addEventListener('turbo:submit-start', this.setInitialPasswordType)
}
disconnectedCallback () {
this.togglePasswordVisibility.removeEventListener('click', this.handleTogglePasswordVisibility)
document.removeEventListener('turbo:submit-start', this.setInitialPasswordType)
}
handleTogglePasswordVisibility = () => {
this.passwordInput.type = this.passwordInput.type === 'password' ? 'text' : 'password'
this.toggleIcon()
}
setInitialPasswordType = () => {
this.passwordInput.type = 'password'
this.toggleIcon()
}
toggleIcon = () => {
this.visiblePasswordIcon.classList.toggle('hidden', this.passwordInput.type === 'password')
this.hiddenPasswordIcon.classList.toggle('hidden', this.passwordInput.type === 'text')
}
})
+25
View File
@@ -0,0 +1,25 @@
export default class extends HTMLElement {
connectedCallback () {
this.input.addEventListener('focus', () => {
if (this.title) {
this.title.classList.add('hidden', 'md:block')
this.input.classList.add('w-60')
}
})
this.input.addEventListener('blur', (e) => {
if (this.title && !e.target.value) {
this.title.classList.remove('hidden')
this.input.classList.remove('w-60')
}
})
}
get input () {
return this.querySelector('input')
}
get title () {
return document.querySelector(this.dataset.title)
}
}
+6 -2
View File
@@ -5,8 +5,12 @@ export default targetable(class extends HTMLElement {
static [target.static] = ['canvas', 'input', 'clear', 'button'] static [target.static] = ['canvas', 'input', 'clear', 'button']
async connectedCallback () { async connectedCallback () {
this.canvas.width = this.canvas.parentNode.parentNode.clientWidth const scale = 3
this.canvas.height = this.canvas.parentNode.parentNode.clientWidth / 3
this.canvas.width = this.canvas.parentNode.clientWidth * scale
this.canvas.height = this.canvas.parentNode.clientHeight * scale
this.canvas.getContext('2d').scale(scale, scale)
const { default: SignaturePad } = await import('signature_pad') const { default: SignaturePad } = await import('signature_pad')
@@ -24,6 +24,7 @@ export default class extends HTMLElement {
if (input && item[field]) { if (input && item[field]) {
input.value = item[field] input.value = item[field]
input.dispatchEvent(new CustomEvent('linked-input.update', { bubbles: true }))
} }
if (textarea && item[field]) { if (textarea && item[field]) {
@@ -45,11 +46,7 @@ export default class extends HTMLElement {
this.currentFetch.then(async (resp) => { this.currentFetch.then(async (resp) => {
const items = await resp.json() const items = await resp.json()
if (q.length < 3) { resolve(items)
resolve(items.filter((e) => e[this.dataset.field].startsWith(q)))
} else {
resolve(items)
}
}).catch(() => { }).catch(() => {
resolve([]) resolve([])
}).finally(() => { }).finally(() => {
@@ -0,0 +1,28 @@
export default class extends HTMLElement {
connectedCallback () {
this.input.addEventListener('change', (event) => {
if (this.dataset.attribute) {
this.target[this.dataset.attribute] = event.target.checked
}
if (this.dataset.className) {
this.target.classList.toggle(this.dataset.className, event.target.value !== this.dataset.value)
if (this.dataset.className === 'hidden' && this.target.tagName === 'INPUT') {
this.target.disabled = event.target.value !== this.dataset.value
}
}
if (this.dataset.attribute === 'disabled') {
this.target.value = ''
}
})
}
get input () {
return this.querySelector('input[type="checkbox"]') || this.querySelector('select')
}
get target () {
return document.getElementById(this.dataset.targetId)
}
}
+21
View File
@@ -0,0 +1,21 @@
export default class extends HTMLElement {
connectedCallback () {
this.button.disabled = false
this.form.addEventListener('submit', () => {
this.button.disabled = true
})
}
disconnectedCallback () {
this.button.disabled = false
}
get button () {
return this.querySelector('[type="submit"]')
}
get form () {
return this.querySelector('form') || this.closest('form')
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ export default actionable(class extends HTMLElement {
} }
onSubmit = (e) => { onSubmit = (e) => {
if (e.detail.success) { if (e.detail.success && e.detail?.formSubmission?.formElement?.dataset?.closeOnSubmit !== 'false') {
this.close() this.close()
} }
} }
+16 -2
View File
@@ -2,23 +2,37 @@ import { createApp, reactive } from 'vue'
import Form from './submission_form/form' import Form from './submission_form/form'
import DownloadButton from './elements/download_button' import DownloadButton from './elements/download_button'
import ToggleSubmit from './elements/toggle_submit'
window.customElements.define('download-button', DownloadButton) const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options)
window.customElements.define('submission-form', class extends HTMLElement {
safeRegisterElement('download-button', DownloadButton)
safeRegisterElement('toggle-submit', ToggleSubmit)
safeRegisterElement('submission-form', class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
this.app = createApp(Form, { this.app = createApp(Form, {
submitter: JSON.parse(this.dataset.submitter), submitter: JSON.parse(this.dataset.submitter),
inviteSubmitters: JSON.parse(this.dataset.inviteSubmitters),
canSendEmail: this.dataset.canSendEmail === 'true', canSendEmail: this.dataset.canSendEmail === 'true',
previousSignatureValue: this.dataset.previousSignatureValue,
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',
scrollPadding: this.dataset.scrollPadding || '-80px',
dryRun: this.dataset.dryRun === 'true',
expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null,
withSignatureId: this.dataset.withSignatureId === 'true',
requireSigningReason: this.dataset.requireSigningReason === 'true',
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, authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
rememberSignature: this.dataset.rememberSignature === 'true',
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 || '{}'),
withQrButton: true,
completedMessage: JSON.parse(this.dataset.completedMessage || '{}'), 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)),
+40
View File
@@ -0,0 +1,40 @@
import Rollbar from 'rollbar/dist/rollbar.umd'
const token = document.querySelector('meta[name="rollbar-token"]')?.getAttribute('content')
if (token) {
window.Rollbar ||= new Rollbar({
accessToken: token,
captureUncaught: true,
captureUnhandledRejections: true,
captureIp: false,
autoInstrument: false,
ignoredMessages: [
/Failed to fetch/i,
/NetworkError/i,
/Load failed/i,
/Clipboard write is not allowed/i
],
transform (payload) {
payload.body.telemetry = []
if (payload.request.query_string) {
payload.request.query_string = ''
}
if (payload.request.url) {
payload.request.url = payload.request.url.replace(/(\/[sdep]\/)(\w{5})[^/]+/, '$1$2')
}
return payload
},
payload: {
client: {
javascript: {
source_map_enabled: true
}
},
environment: 'production'
}
})
}
+92 -14
View File
@@ -3,10 +3,10 @@
class="field-area 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="{ 'font-serif': field.preferences?.font === 'Times', 'text-[1.6vw] 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="(!withFieldPlaceholder || !field.name || field.type === 'cells') && !isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid"
class="absolute top-0 bottom-0 right-0 left-0 items-center justify-center h-full w-full" class="absolute top-0 bottom-0 right-0 left-0 items-center justify-center h-full w-full"
> >
<span <span
@@ -29,19 +29,20 @@
{{ optionValue(option) }} {{ optionValue(option) }}
</template> </template>
<template v-else> <template v-else>
{{ field.name || fieldNames[field.type] }} {{ field.title || field.name || fieldNames[field.type] }}
<template v-if="field.type === 'checkbox' && !field.name"> <template v-if="field.type === 'checkbox' && !field.name">
{{ fieldIndex + 1 }} {{ fieldIndex + 1 }}
</template> </template>
<template v-else-if="!field.required && field.type !== 'checkbox'"> <template v-else-if="!field.required && field.type !== 'checkbox'">
(optional) ({{ t('optional') }})
</template> </template>
</template> </template>
</div> </div>
<div <div
v-if="isActive" v-if="isActive"
ref="scrollToElem" ref="scrollToElem"
class="absolute -top-20" class="absolute"
:style="{ top: scrollPadding }"
/> />
<img <img
v-if="field.type === 'image' && image" v-if="field.type === 'image' && image"
@@ -53,18 +54,44 @@
class="object-contain mx-auto" class="object-contain mx-auto"
:src="stamp.url" :src="stamp.url"
> >
<img <div
v-else-if="field.type === 'signature' && signature" v-else-if="field.type === 'signature' && signature"
class="object-contain mx-auto" class="flex flex-col justify-between h-full overflow-hidden"
:src="signature.url"
> >
<div
class="flex-grow flex overflow-hidden"
style="min-height: 50%"
>
<img
class="object-contain mx-auto"
:src="signature.url"
>
</div>
<div
v-if="withSignatureId"
class="w-full mt-1 text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem]"
>
<div class="truncate uppercase">
ID: {{ signature.uuid }}
</div>
<div>
{{ t('reason') }}: {{ values[field.preferences?.reason_field_uuid] || t('digitally_signed_by') }} {{ submitter.name }}
<template v-if="submitter.email">
&lt;{{ submitter.email }}&gt;
</template>
</div>
<div>
{{ new Date(signature.created_at).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', timeZoneName: 'short' }) }}
</div>
</div>
</div>
<img <img
v-else-if="field.type === 'initials' && initials" v-else-if="field.type === 'initials' && initials"
class="object-contain mx-auto" class="object-contain mx-auto"
:src="initials.url" :src="initials.url"
> >
<div <div
v-else-if="field.type === 'file' || field.type === 'payment'" v-else-if="(field.type === 'file' || field.type === 'payment') && attachments.length"
class="px-0.5 flex flex-col justify-center" class="px-0.5 flex flex-col justify-center"
> >
<a <a
@@ -74,7 +101,7 @@
:href="attachment.url" :href="attachment.url"
> >
<IconPaperclip <IconPaperclip
class="inline w-[1.5vw] h-[1.5vw] lg:w-4 lg:h-4" class="inline w-[1.6vw] h-[1.6vw] lg:w-4 lg:h-4"
/> />
{{ attachment.filename }} {{ attachment.filename }}
</a> </a>
@@ -159,12 +186,20 @@
class="flex items-center px-0.5 w-full" class="flex items-center px-0.5 w-full"
:class="alignClasses[field.preferences?.align]" :class="alignClasses[field.preferences?.align]"
> >
<span v-if="Array.isArray(modelValue)"> <span
v-if="field && field.name && withFieldPlaceholder && !modelValue && modelValue !== 0"
class="whitespace-pre-wrap text-gray-400"
:class="{ 'w-full': field.preferences?.align }"
>{{ field.name }}</span>
<span v-else-if="Array.isArray(modelValue)">
{{ modelValue.join(', ') }} {{ modelValue.join(', ') }}
</span> </span>
<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"
@@ -189,11 +224,31 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
submitter: {
type: Object,
required: false,
default: () => ({})
},
withSignatureId: {
type: Boolean,
required: false,
default: false
},
isValueSet: { isValueSet: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false default: false
}, },
scrollPadding: {
type: String,
required: false,
default: '-80px'
},
withFieldPlaceholder: {
type: Boolean,
required: false,
default: false
},
submittable: { submittable: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -204,6 +259,11 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
values: {
type: Object,
required: false,
default: () => ({})
},
isActive: { isActive: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -337,19 +397,25 @@ export default {
computedStyle () { computedStyle () {
const { x, y, w, h } = this.area const { x, y, w, h } = this.area
return { const style = {
top: y * 100 + '%', top: y * 100 + '%',
left: x * 100 + '%', left: x * 100 + '%',
width: w * 100 + '%', width: w * 100 + '%',
height: h * 100 + '%' height: h * 100 + '%'
} }
if (this.field.preferences?.font_size) {
style.fontSize = this.field.preferences.font_size + 'pt'
}
return style
} }
}, },
watch: { watch: {
modelValue () { modelValue () {
this.$nextTick(() => { this.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.modelValue}`.length)) { 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 this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue || (this.withFieldPlaceholder ? this.field.name : '')}`.length : 0
} }
}) })
} }
@@ -357,7 +423,7 @@ export default {
mounted () { mounted () {
this.$nextTick(() => { this.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer) { 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 || (this.withFieldPlaceholder ? this.field.name : '')}`.length : 0
} }
}) })
}, },
@@ -373,6 +439,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',
@@ -388,6 +465,7 @@ export default {
const yearFormats = { const yearFormats = {
YYYY: 'numeric', YYYY: 'numeric',
YYY: 'numeric',
YY: '2-digit' YY: '2-digit'
} }
+53 -6
View File
@@ -1,6 +1,6 @@
<template> <template>
<template <template
v-for="step in steps" v-for="(step, stepIndex) in steps"
:key="step[0].uuid" :key="step[0].uuid"
> >
<template <template
@@ -18,15 +18,20 @@
<FieldArea <FieldArea
:ref="setAreaRef" :ref="setAreaRef"
v-model="values[field.uuid]" v-model="values[field.uuid]"
:values="values"
:field="field" :field="field"
:area="area" :area="area"
:submittable="true" :submittable="submittable"
:field-index="fieldIndex" :field-index="fieldIndex"
:scroll-padding="scrollPadding"
:submitter="submitter"
:with-field-placeholder="withFieldPlaceholder"
:with-signature-id="withSignatureId"
:is-active="currentStep === step" :is-active="currentStep === step"
:with-label="withLabel" :with-label="withLabel && !withFieldPlaceholder && step.length < 2"
:is-value-set="step.some((f) => f.uuid in values)" :is-value-set="step.some((f) => f.uuid in values)"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
@click="$emit('focus-step', step)" @click="$emit('focus-step', stepIndex)"
/> />
</Teleport> </Teleport>
</template> </template>
@@ -48,11 +53,35 @@ export default {
required: false, required: false,
default: () => [] default: () => []
}, },
withSignatureId: {
type: Boolean,
required: false,
default: false
},
submittable: {
type: Boolean,
required: false,
default: true
},
submitter: {
type: Object,
required: true
},
values: { values: {
type: Object, type: Object,
required: false, required: false,
default: () => ({}) default: () => ({})
}, },
withFieldPlaceholder: {
type: Boolean,
required: false,
default: false
},
scrollPadding: {
type: String,
required: false,
default: '-80px'
},
attachmentsIndex: { attachmentsIndex: {
type: Object, type: Object,
required: false, required: false,
@@ -63,6 +92,11 @@ export default {
required: false, required: false,
default: true default: true
}, },
scrollEl: {
type: Object,
required: false,
default: null
},
currentStep: { currentStep: {
type: Array, type: Array,
required: false, required: false,
@@ -97,7 +131,20 @@ export default {
if (container.style.overflow === 'hidden') { if (container.style.overflow === 'hidden') {
this.scrollInContainer(areaRef.$el) this.scrollInContainer(areaRef.$el)
} else { } else {
areaRef.$refs.scrollToElem.scrollIntoView({ behavior: 'smooth', block: 'start' }) const targetRect = areaRef.$refs.scrollToElem.getBoundingClientRect()
const scrollEl = this.scrollEl || window
let rootRect = {}
if (this.scrollEl === document.documentElement) {
rootRect = this.scrollEl.getBoundingClientRect()
} else {
const root = this.$root.$el?.parentNode?.classList?.contains('ds') ? this.$root.$el : document.body
rootRect = root.getBoundingClientRect()
}
scrollEl.scrollTo({ top: targetRect.top - rootRect.top, behavior: 'smooth' })
} }
return true return true
@@ -116,7 +163,7 @@ export default {
const targetTopRelativeToBox = targetRect.top - boxRect.top const targetTopRelativeToBox = targetRect.top - boxRect.top
scrollbox.scrollTop = targetTopRelativeToBox - container.offsetHeight + formContainer.offsetHeight + target.offsetHeight + padding scrollbox.scrollTo({ top: targetTopRelativeToBox - container.offsetHeight + formContainer.offsetHeight + target.offsetHeight + padding, behavior: 'smooth' })
}, },
setAreaRef (el) { setAreaRef (el) {
if (el) { if (el) {
@@ -52,6 +52,7 @@
: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"
:multiple="true" :multiple="true"
:dry-run="dryRun"
@upload="onUpload" @upload="onUpload"
/> />
</div> </div>
@@ -80,6 +81,11 @@ export default {
type: String, type: String,
required: true required: true
}, },
dryRun: {
type: Boolean,
required: false,
default: false
},
attachmentsIndex: { attachmentsIndex: {
type: Object, type: Object,
required: false, required: false,
+26 -7
View File
@@ -10,7 +10,7 @@
:height="30" :height="30"
/> />
<span> <span>
{{ completedMessage.title || t('form_has_been_completed') }} {{ completedMessage.title || (hasSignatureFields ? (hasMultipleDocuments ? t('documents_have_been_signed') : t('document_has_been_signed')) : t('form_has_been_completed')) }}
</span> </span>
</div> </div>
<div <div
@@ -88,7 +88,7 @@
v-if="attribution" v-if="attribution"
class="text-center mt-4" class="text-center mt-4"
> >
{{ t('signed_with') }} {{ t('powered_by') }}
<a <a
href="https://www.docuseal.co/start" href="https://www.docuseal.co/start"
target="_blank" target="_blank"
@@ -129,6 +129,16 @@ export default {
required: false, required: false,
default: true default: true
}, },
hasSignatureFields: {
type: Boolean,
required: false,
default: false
},
hasMultipleDocuments: {
type: Boolean,
required: false,
default: false
},
withDownloadButton: { withDownloadButton: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -181,6 +191,10 @@ export default {
spread: 140 spread: 140
}) })
} }
if (window.decline_button) {
window.decline_button.setAttribute('disabled', 'true')
}
}, },
methods: { methods: {
sendCopyToEmail () { sendCopyToEmail () {
@@ -197,13 +211,18 @@ export default {
download () { download () {
this.isDownloading = true this.isDownloading = true
fetch(this.baseUrl + `/submitters/${this.submitterSlug}/download`).then((response) => response.json()).then((urls) => { fetch(this.baseUrl + `/submitters/${this.submitterSlug}/download`).then(async (response) => {
const isSafariIos = /iPhone|iPad|iPod/i.test(navigator.userAgent) if (response.ok) {
const urls = await response.json()
const isSafariIos = /iPhone|iPad|iPod/i.test(navigator.userAgent)
if (isSafariIos && urls.length > 1) { if (isSafariIos && urls.length > 1) {
this.downloadSafariIos(urls) this.downloadSafariIos(urls)
} else {
this.downloadUrls(urls)
}
} else { } else {
this.downloadUrls(urls) alert('Failed to download files')
} }
}) })
}, },
+12 -2
View File
@@ -14,7 +14,9 @@
/> />
<template v-else> <template v-else>
{{ field.name && showFieldNames ? field.name : t('date') }} {{ field.name && showFieldNames ? field.name : t('date') }}
<template v-if="!field.required">({{ t('optional') }})</template> <template v-if="!field.required">
({{ t('optional') }})
</template>
</template> </template>
</label> </label>
<button <button
@@ -41,6 +43,7 @@
:required="field.required" :required="field.required"
type="date" type="date"
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
@keydown.enter="onEnter"
@focus="$emit('focus')" @focus="$emit('focus')"
> >
</div> </div>
@@ -76,7 +79,7 @@ export default {
default: '' default: ''
} }
}, },
emits: ['update:model-value', 'focus'], emits: ['update:model-value', 'focus', 'submit'],
computed: { computed: {
value: { value: {
set (value) { set (value) {
@@ -88,6 +91,13 @@ export default {
} }
}, },
methods: { methods: {
onEnter (e) {
if (this.modelValue) {
e.preventDefault()
this.$emit('submit')
}
},
setCurrentDate () { setCurrentDate () {
const inputEl = this.$refs.input const inputEl = this.$refs.input
+61 -10
View File
@@ -65,6 +65,11 @@ export default {
type: String, type: String,
required: true required: true
}, },
dryRun: {
type: Boolean,
required: false,
default: false
},
accept: { accept: {
type: String, type: String,
required: false, required: false,
@@ -104,24 +109,70 @@ export default {
this.isLoading = true this.isLoading = true
return await Promise.all( return await Promise.all(
Array.from(files).map((file) => { Array.from(files).map(async (file) => {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) if (this.dryRun) {
formData.append('submitter_slug', this.submitterSlug) return new Promise((resolve) => {
formData.append('name', 'attachments') const reader = new FileReader()
return fetch(this.baseUrl + '/api/attachments', { reader.readAsDataURL(file)
method: 'POST',
body: formData reader.onloadend = () => {
}).then(resp => resp.json()).then((data) => { resolve({
return data url: reader.result,
}) uuid: Math.random().toString(),
filename: file.name
})
}
})
} else {
if (file.type === 'image/bmp') {
file = await this.convertBmpToPng(file)
}
formData.append('file', file)
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: formData
}).then(resp => resp.json()).then((data) => {
return data
})
}
})).then((result) => { })).then((result) => {
this.$emit('upload', result) this.$emit('upload', result)
}).finally(() => { }).finally(() => {
this.isLoading = false this.isLoading = false
}) })
},
convertBmpToPng (bmpFile) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const newFile = new File([blob], bmpFile.name.replace(/\.\w+$/, '.png'), { type: 'image/png' })
resolve(newFile)
}, 'image/png')
}
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(bmpFile)
})
} }
} }
} }
+211 -57
View File
@@ -3,10 +3,21 @@
ref="areas" ref="areas"
:steps="stepFields" :steps="stepFields"
:values="values" :values="values"
:with-field-placeholder="withFieldPlaceholder"
:submitter="submitter"
:scroll-el="scrollEl"
:with-signature-id="withSignatureId"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
:with-label="!isAnonymousChecboxes && showFieldNames" :with-label="!isAnonymousChecboxes && showFieldNames"
:current-step="currentStepFields" :current-step="currentStepFields"
@focus-step="[saveStep(), goToStep($event, false, true), currentField.type !== 'checkbox' ? isFormVisible = true : '']" :scroll-padding="scrollPadding"
@focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]"
/>
<FieldAreas
:steps="readonlyConditionalFields.map((e) => [e])"
:values="readonlyConditionalFields.reduce((acc, f) => { acc[f.uuid] = f.default_value; return acc }, {})"
:submitter="submitter"
:submittable="false"
/> />
<FormulaFieldAreas <FormulaFieldAreas
v-if="formulaFields.length" v-if="formulaFields.length"
@@ -27,8 +38,11 @@
<template v-else-if="alwaysMinimize"> <template v-else-if="alwaysMinimize">
{{ t('next') }} {{ t('next') }}
</template> </template>
<template v-else-if="isShowContinue">
{{ t('continue') }}
</template>
<template v-else> <template v-else>
{{ t('submit_form') }} {{ t('start_now') }}
</template> </template>
<IconArrowsDiagonal <IconArrowsDiagonal
class="absolute right-0 mr-4" class="absolute right-0 mr-4"
@@ -48,7 +62,7 @@
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="t('minimize')" :title="t('minimize')"
@click.prevent="isFormVisible = false" @click.prevent="minimizeForm"
> >
<IconArrowsDiagonalMinimize2 <IconArrowsDiagonalMinimize2
:width="20" :width="20"
@@ -59,7 +73,7 @@
:class="{ 'md:px-4': isBreakpointMd }" :class="{ 'md:px-4': isBreakpointMd }"
> >
<form <form
v-if="!isCompleted" v-if="!isCompleted && !isInvite"
ref="form" ref="form"
:action="submitPath" :action="submitPath"
method="post" method="post"
@@ -101,6 +115,7 @@
v-model="values[currentField.uuid]" v-model="values[currentField.uuid]"
:show-field-names="showFieldNames" :show-field-names="showFieldNames"
:field="currentField" :field="currentField"
@submit="submitStep"
@focus="scrollIntoField(currentField)" @focus="scrollIntoField(currentField)"
/> />
<div v-else-if="currentField.type === 'select'"> <div v-else-if="currentField.type === 'select'">
@@ -189,7 +204,7 @@
<span <span
@click="scrollIntoField(currentField)" @click="scrollIntoField(currentField)"
> >
{{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>. {{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ submitButtonText }}</span>.
</span> </span>
</div> </div>
<div <div
@@ -253,7 +268,7 @@
> >
<template v-if="isAnonymousChecboxes || !showFieldNames"> <template v-if="isAnonymousChecboxes || !showFieldNames">
<span class="text-xl"> <span class="text-xl">
{{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>. {{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ submitButtonText }}</span>.
</span> </span>
<input <input
v-for="field in currentStepFields" v-for="field in currentStepFields"
@@ -310,6 +325,7 @@
:key="currentField.uuid" :key="currentField.uuid"
v-model="values[currentField.uuid]" v-model="values[currentField.uuid]"
:field="currentField" :field="currentField"
:dry-run="dryRun"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
:show-field-names="showFieldNames" :show-field-names="showFieldNames"
@@ -320,15 +336,23 @@
ref="currentStep" ref="currentStep"
:key="currentField.uuid" :key="currentField.uuid"
v-model="values[currentField.uuid]" v-model="values[currentField.uuid]"
:reason="values[currentField.preferences?.reason_field_uuid]"
:field="currentField" :field="currentField"
:previous-value="previousSignatureValueFor(currentField)" :previous-value="previousSignatureValueFor(currentField) || previousSignatureValue"
:with-typed-signature="withTypedSignature" :with-typed-signature="withTypedSignature"
:remember-signature="rememberSignature"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug" :require-signing-reason="requireSigningReason"
:button-text="submitButtonText"
:dry-run="dryRun"
:with-disclosure="withDisclosure"
:with-qr-button="withQrButton"
:submitter="submitter"
:show-field-names="showFieldNames" :show-field-names="showFieldNames"
@update:reason="values[currentField.preferences?.reason_field_uuid] = $event"
@attached="attachments.push($event)" @attached="attachments.push($event)"
@start="scrollIntoField(currentField)" @start="scrollIntoField(currentField)"
@minimize="isFormVisible = false" @minimize="minimizeForm"
/> />
<InitialsStep <InitialsStep
v-else-if="currentField.type === 'initials'" v-else-if="currentField.type === 'initials'"
@@ -336,6 +360,7 @@
:key="currentField.uuid" :key="currentField.uuid"
v-model="values[currentField.uuid]" v-model="values[currentField.uuid]"
:field="currentField" :field="currentField"
:dry-run="dryRun"
:previous-value="previousInitialsValue" :previous-value="previousInitialsValue"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
:show-field-names="showFieldNames" :show-field-names="showFieldNames"
@@ -343,12 +368,13 @@
@attached="attachments.push($event)" @attached="attachments.push($event)"
@start="scrollIntoField(currentField)" @start="scrollIntoField(currentField)"
@focus="scrollIntoField(currentField)" @focus="scrollIntoField(currentField)"
@minimize="isFormVisible = false" @minimize="minimizeForm"
/> />
<AttachmentStep <AttachmentStep
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]"
:dry-run="dryRun"
:field="currentField" :field="currentField"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
@@ -375,6 +401,7 @@
v-model="values[currentField.uuid]" v-model="values[currentField.uuid]"
:field="currentField" :field="currentField"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
:values="values"
@attached="attachments.push($event)" @attached="attachments.push($event)"
@focus="scrollIntoField(currentField)" @focus="scrollIntoField(currentField)"
@submit="submitStep" @submit="submitStep"
@@ -382,7 +409,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="currentField.type === 'signature' ? 'mt-2' : 'mt-6 md:mt-8'"
> >
<button <button
id="submit_form_button" id="submit_form_button"
@@ -396,11 +423,8 @@
v-if="isSubmitting" v-if="isSubmitting"
class="mr-1 animate-spin" class="mr-1 animate-spin"
/> />
<span v-if="alwaysMinimize || stepFields.length === currentStep + 1"> <span>
{{ t('submit') }} {{ submitButtonText }}
</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"
@@ -415,14 +439,25 @@
</div> </div>
</div> </div>
</form> </form>
<InviteForm
v-else-if="isInvite"
:submitters="inviteSubmitters"
:submitter-slug="submitterSlug"
:authenticity-token="authenticityToken"
:url="baseUrl + submitPath + '/invite'"
:style="{ maxWidth: isBreakpointMd ? '582px' : '' }"
@success="[isInvite = false, performComplete($event)]"
/>
<FormCompleted <FormCompleted
v-else v-else
:is-demo="isDemo" :is-demo="isDemo"
:attribution="attribution" :attribution="attribution"
:has-signature-fields="stepFields.some((fields) => fields.some((f) => ['signature', 'initials'].includes(f.type)))"
:has-multiple-documents="hasMultipleDocuments"
:completed-button="completedRedirectUrl ? {} : completedButton" :completed-button="completedRedirectUrl ? {} : completedButton"
:completed-message="completedRedirectUrl ? {} : completedMessage" :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 && !dryRun"
:with-confetti="withConfetti" :with-confetti="withConfetti"
:can-send-email="canSendEmail && !!submitter.email" :can-send-email="canSendEmail && !!submitter.email"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
@@ -438,7 +473,7 @@
href="#" href="#"
class="inline border border-base-300 h-3 w-3 rounded-full mx-1 mt-1" class="inline border border-base-300 h-3 w-3 rounded-full mx-1 mt-1"
:class="{ 'bg-base-300': index === currentStep, 'bg-base-content': (index < currentStep && stepFields[index].every((f) => !f.required || ![null, undefined, ''].includes(values[f.uuid]))) || isCompleted, 'bg-white': index > currentStep }" :class="{ 'bg-base-300': index === currentStep, 'bg-base-content': (index < currentStep && stepFields[index].every((f) => !f.required || ![null, undefined, ''].includes(values[f.uuid]))) || isCompleted, 'bg-white': index > currentStep }"
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(step, true)]" @click.prevent="isCompleted ? '' : [saveStep(), goToStep(index, true)]"
/> />
</div> </div>
</div> </div>
@@ -464,6 +499,7 @@ import TextStep from './text_step'
import NumberStep from './number_step' import NumberStep from './number_step'
import DateStep from './date_step' import DateStep from './date_step'
import MarkdownContent from './markdown_content' import MarkdownContent from './markdown_content'
import InviteForm from './invite_form'
import FormCompleted from './completed' import FormCompleted from './completed'
import { IconInnerShadowTop, IconArrowsDiagonal, IconWritingSign, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue' import { IconInnerShadowTop, IconArrowsDiagonal, IconWritingSign, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import AppearsOn from './appears_on' import AppearsOn from './appears_on'
@@ -501,6 +537,7 @@ export default {
IconWritingSign, IconWritingSign,
AttachmentStep, AttachmentStep,
InitialsStep, InitialsStep,
InviteForm,
MultiSelectStep, MultiSelectStep,
IconInnerShadowTop, IconInnerShadowTop,
DateStep, DateStep,
@@ -527,6 +564,26 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
inviteSubmitters: {
type: Array,
required: false,
default: () => []
},
withSignatureId: {
type: Boolean,
required: false,
default: false
},
scrollPadding: {
type: String,
required: false,
default: '-80px'
},
requireSigningReason: {
type: Boolean,
required: false,
default: false
},
canSendEmail: { canSendEmail: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -537,6 +594,16 @@ export default {
required: false, required: false,
default: () => [] default: () => []
}, },
withFieldPlaceholder: {
type: Boolean,
required: false,
default: false
},
scrollEl: {
type: Object,
required: false,
default: null
},
onComplete: { onComplete: {
type: Function, type: Function,
required: false, required: false,
@@ -549,11 +616,21 @@ export default {
required: false, required: false,
default: null default: null
}, },
rememberSignature: {
type: Boolean,
required: false,
default: false
},
minimize: { minimize: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false default: false
}, },
withDisclosure: {
type: Boolean,
required: false,
default: false
},
reuseSignature: { reuseSignature: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -574,6 +651,11 @@ export default {
required: false, required: false,
default: true default: true
}, },
withQrButton: {
type: Boolean,
required: false,
default: false
},
withTypedSignature: { withTypedSignature: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -599,6 +681,11 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
previousSignatureValue: {
type: String,
required: false,
default: ''
},
allowToSkip: { allowToSkip: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -614,6 +701,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,
@@ -668,9 +760,12 @@ export default {
data () { data () {
return { return {
isCompleted: false, isCompleted: false,
isInvite: false,
isFormVisible: this.expand !== false, isFormVisible: this.expand !== false,
showFillAllRequiredFields: false, showFillAllRequiredFields: false,
currentStep: 0, currentStep: 0,
isShowContinue: false,
enableScrollIntoField: true,
phoneVerifiedValues: {}, phoneVerifiedValues: {},
orientation: screen?.orientation?.type, orientation: screen?.orientation?.type,
isSubmitting: false, isSubmitting: false,
@@ -682,11 +777,36 @@ export default {
isMobile () { isMobile () {
return /android|iphone|ipad/i.test(navigator.userAgent) return /android|iphone|ipad/i.test(navigator.userAgent)
}, },
submitButtonText () {
if (this.alwaysMinimize) {
return this.t('submit')
} else if (this.stepFields.length === this.currentStep + 1) {
if (this.currentField.type === 'signature') {
return this.t('sign_and_complete')
} else {
return this.t('complete')
}
} else {
return this.t('next')
}
},
alwaysMinimize () { alwaysMinimize () {
return this.minimize || (this.orientation?.includes('landscape') && this.isMobile && parseInt(window.innerHeight) < 550) return this.minimize || (this.orientation?.includes('landscape') && this.isMobile && parseInt(window.innerHeight) < 550)
}, },
hasMultipleDocuments () {
return Object.keys(
this.stepFields.reduce((acc, fields) => {
fields.forEach((f) => {
f.areas?.forEach((a) => {
acc[a.attachment_uuid] = 1
})
})
return acc
}, {})
).filter(Boolean).length > 1
},
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]
@@ -729,6 +849,9 @@ export default {
currentField () { currentField () {
return this.currentStepFields[0] return this.currentStepFields[0]
}, },
readonlyConditionalFields () {
return this.fields.filter((f) => f.readonly && f.conditions?.length && this.checkFieldConditions(f))
},
stepFields () { stepFields () {
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]
@@ -745,7 +868,7 @@ export default {
}, []) }, [])
}, },
formulaFields () { formulaFields () {
return this.fields.filter((f) => f.preferences?.formula) return this.fields.filter((f) => f.preferences?.formula && f.type !== 'payment')
}, },
attachmentsIndex () { attachmentsIndex () {
return this.attachments.reduce((acc, a) => { return this.attachments.reduce((acc, a) => {
@@ -763,7 +886,7 @@ export default {
this.isFormVisible = value this.isFormVisible = value
}, },
currentStepFields (value) { currentStepFields (value) {
if (!value) { if (isEmpty(value) && this.currentStep > 0) {
this.currentStep -= 1 this.currentStep -= 1
} }
} }
@@ -806,11 +929,11 @@ export default {
} }
if (document.body?.clientWidth >= 768 && this.expand !== true && ['signature', 'initials', 'file', 'image'].includes(this.currentField?.type)) { if (document.body?.clientWidth >= 768 && this.expand !== true && ['signature', 'initials', 'file', 'image'].includes(this.currentField?.type)) {
this.isFormVisible = false this.minimizeForm()
} }
if (this.alwaysMinimize) { if (this.alwaysMinimize) {
this.isFormVisible = false this.minimizeForm()
} }
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) { if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
@@ -830,12 +953,14 @@ export default {
this.$nextTick(() => { this.$nextTick(() => {
this.recalculateButtonDisabledKey = Math.random() this.recalculateButtonDisabledKey = Math.random()
Promise.all([ if (!this.dryRun) {
this.maybeTrackEmailClick(), Promise.all([
this.maybeTrackSmsClick() this.maybeTrackEmailClick(),
]).finally(() => { this.maybeTrackSmsClick()
this.trackViewForm() ]).finally(() => {
}) this.trackViewForm()
})
}
}) })
}, },
methods: { methods: {
@@ -848,18 +973,22 @@ export default {
checkFieldConditions (field) { checkFieldConditions (field) {
if (field.conditions?.length) { if (field.conditions?.length) {
return field.conditions.reduce((acc, c) => { return field.conditions.reduce((acc, c) => {
const field = this.fieldsUuidIndex[c.field_uuid]
if (['not_empty', 'checked', 'equal', 'contains'].includes(c.action) && field && !this.checkFieldConditions(field)) {
return false
}
if (['empty', 'unchecked'].includes(c.action)) { if (['empty', 'unchecked'].includes(c.action)) {
return acc && isEmpty(this.values[c.field_uuid]) return acc && isEmpty(this.values[c.field_uuid])
} else if (['not_empty', 'checked'].includes(c.action)) { } else if (['not_empty', 'checked'].includes(c.action)) {
return acc && !isEmpty(this.values[c.field_uuid]) return acc && !isEmpty(this.values[c.field_uuid])
} else if (['equal', 'contains'].includes(c.action)) { } else if (['equal', 'contains'].includes(c.action) && field) {
const field = this.fieldsUuidIndex[c.field_uuid]
const option = field.options.find((o) => o.uuid === c.value) const option = field.options.find((o) => o.uuid === c.value)
const values = [this.values[c.field_uuid]].flat() const values = [this.values[c.field_uuid]].flat()
return acc && values.includes(this.optionValue(option, field.options.indexOf(option))) return acc && values.includes(this.optionValue(option, field.options.indexOf(option)))
} else if (['not_equal', 'does_not_contain'].includes(c.action)) { } else if (['not_equal', 'does_not_contain'].includes(c.action) && field) {
const field = this.fieldsUuidIndex[c.field_uuid]
const option = field.options.find((o) => o.uuid === c.value) const option = field.options.find((o) => o.uuid === c.value)
const values = [this.values[c.field_uuid]].flat() const values = [this.values[c.field_uuid]].flat()
@@ -949,21 +1078,25 @@ export default {
return null return null
} }
}, },
goToStep (step, scrollToArea = false, clickUpload = false) { goToStep (stepIndex, scrollToArea = false, clickUpload = false) {
this.currentStep = this.stepFields.indexOf(step) this.currentStep = stepIndex
this.showFillAllRequiredFields = false this.showFillAllRequiredFields = false
this.$nextTick(() => { this.$nextTick(() => {
this.recalculateButtonDisabledKey = Math.random() this.recalculateButtonDisabledKey = Math.random()
if (scrollToArea) { if (!this.isCompleted) {
this.scrollIntoField(step[0]) if (scrollToArea) {
this.scrollIntoField(this.currentField)
}
this.enableScrollIntoField = false
this.$refs.form.querySelector('input[type="date"], input[type="number"], input[type="text"], select')?.focus() this.$refs.form.querySelector('input[type="date"], input[type="number"], input[type="text"], select')?.focus()
} this.enableScrollIntoField = true
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()
}
} }
}) })
}, },
@@ -971,7 +1104,13 @@ export default {
const currentFieldUuids = this.currentStepFields.map((f) => f.uuid) const currentFieldUuids = this.currentStepFields.map((f) => f.uuid)
const currentFieldType = this.currentField.type const currentFieldType = this.currentField.type
if (this.isCompleted) { if (this.dryRun) {
currentFieldUuids.forEach((fieldUuid) => {
this.submittedValues[fieldUuid] = this.values[fieldUuid]
})
return Promise.resolve({})
} else if (this.isCompleted) {
return Promise.resolve({}) return Promise.resolve({})
} else { } else {
return fetch(this.baseUrl + this.submitPath, { return fetch(this.baseUrl + this.submitPath, {
@@ -993,7 +1132,9 @@ export default {
} }
}, },
scrollIntoField (field) { scrollIntoField (field) {
return this.$refs.areas.scrollIntoField(field) if (this.enableScrollIntoField) {
return this.$refs.areas.scrollIntoField(field)
}
}, },
scrollIntoArea (area) { scrollIntoArea (area) {
return this.$refs.areas.scrollIntoArea(area) return this.$refs.areas.scrollIntoArea(area)
@@ -1019,7 +1160,7 @@ export default {
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.inviteSubmitters.length) {
formData.append('completed', 'true') formData.append('completed', 'true')
} }
@@ -1035,7 +1176,9 @@ export default {
if (response.status === 422 || response.status === 500) { if (response.status === 422 || response.status === 500) {
const data = await response.json() const data = await response.json()
alert(data.error || 'Value is invalid') const i18nError = data.error ? this.t(data.error.replace(/\s+/g, '_').toLowerCase()) : ''
alert(i18nError !== data.error ? i18nError : (data.error || this.t('value_is_invalid')))
return Promise.reject(new Error(data.error)) return Promise.reject(new Error(data.error))
} }
@@ -1048,26 +1191,18 @@ export default {
if (nextStep) { if (nextStep) {
if (this.alwaysMinimize) { if (this.alwaysMinimize) {
this.isFormVisible = false this.minimizeForm()
} }
this.goToStep(nextStep, this.autoscrollFields) this.goToStep(this.stepFields.indexOf(nextStep), this.autoscrollFields)
if (emptyRequiredField === nextStep) { if (emptyRequiredField === nextStep) {
this.showFillAllRequiredFields = true this.showFillAllRequiredFields = true
} }
} else if (this.inviteSubmitters.length) {
this.isInvite = true
} else { } else {
this.isCompleted = true this.performComplete(response)
const respData = await response.text()
if (respData) {
this.onComplete(JSON.parse(respData))
}
if (this.completedRedirectUrl) {
window.location.href = this.completedRedirectUrl
}
} }
}).catch(error => { }).catch(error => {
console.error(error) console.error(error)
@@ -1083,6 +1218,25 @@ export default {
}).finally(() => { }).finally(() => {
this.isSubmitting = false this.isSubmitting = false
}) })
},
minimizeForm () {
this.isFormVisible = false
this.isShowContinue = true
},
async performComplete (resp) {
this.isCompleted = true
if (resp?.text) {
const respData = await resp.text()
if (respData) {
this.onComplete(JSON.parse(respData))
}
}
if (this.completedRedirectUrl) {
window.location.href = this.completedRedirectUrl
}
} }
} }
} }
+374 -26
View File
@@ -1,12 +1,36 @@
const en = { const en = {
complete: 'Complete',
sign_and_complete: 'Sign and Complete',
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',
drawn_signature_on_a_touchscreen_device: 'Drawn signature on a touchscreen device',
approved: 'Approved',
reviewed: 'Reviewed',
other: 'Other',
authored_by_me: 'Authored by me',
invite: 'Invite',
email: 'Email',
approved_by: 'Approved by',
reviewed_by: 'Reviewed by',
authored_by: 'Authored by',
select_a_reason: 'Select a reason',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scan the QR code with the camera app to open the form on mobile and draw your signature',
date: 'Date', date: 'Date',
number: 'Number', number: 'Number',
value_is_invalid: 'Value is invalid',
verification_code_is_invalid: 'Verification code is invalid',
already_paid: 'Already paid',
image: 'Image', image: 'Image',
pay: 'Pay',
take_photo: 'Take photo', take_photo: 'Take photo',
number_phone_is_invalid: '{number} phone is invalid',
file: 'File', file: 'File',
digitally_signed_by: 'Digitally signed by',
reason: 'Reason',
select: 'Select', select: 'Select',
checkbox: 'Checkbox', checkbox: 'Checkbox',
multiple: 'Multiple', multiple: 'Multiple',
@@ -16,7 +40,8 @@ const en = {
minimize: 'Minimize', minimize: 'Minimize',
payment: 'Payment', payment: 'Payment',
phone: 'Phone', phone: 'Phone',
submit_form: 'Submit Form', start_now: 'Start Now',
continue: 'Continue',
sign_now: 'Sign Now', sign_now: 'Sign Now',
type_here_: 'Type here...', type_here_: 'Type here...',
optional: 'optional', optional: 'optional',
@@ -37,8 +62,10 @@ const en = {
type_signature_here: 'Type signature here', type_signature_here: 'Type signature here',
type_initial_here: 'Type initials here', type_initial_here: 'Type initials here',
form_has_been_completed: 'Form has been completed!', form_has_been_completed: 'Form has been completed!',
document_has_been_signed: 'Document has been signed!',
documents_have_been_signed: 'Documents have been signed!',
create_a_free_account: 'Create a Free Account', create_a_free_account: 'Create a Free Account',
signed_with: 'Signed with', powered_by: 'Powered by',
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',
@@ -65,9 +92,32 @@ const en = {
} }
const es = { const es = {
complete: 'Completar',
sign_and_complete: 'Firmar y Completar',
invite: 'Invitar',
email: 'Correo electrónico',
approved: 'Aprobado',
reviewed: 'Revisado',
other: 'Otro',
authored_by_me: 'Escrito por mí',
approved_by: 'Aprobado por',
reviewed_by: 'Revisado por',
authored_by: 'Escrito por',
select_a_reason: 'Selecciona una razón',
value_is_invalid: 'El valor no es válido',
verification_code_is_invalid: 'El código de verificación no es válido',
drawn_signature_on_a_touchscreen_device: 'Firma dibujada en un dispositivo con pantalla táctil',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escanea el código QR con la aplicación de la cámara para abrir el formulario en el móvil y dibujar tu firma',
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',
already_paid: 'Ya pagado',
minimize: 'Minimizar', minimize: 'Minimizar',
text: 'Texto', text: 'Texto',
signature: 'Firma', signature: 'Firma',
pay: 'Pagar',
digitally_signed_by: 'Firmado digitalmente por',
reason: 'Razón',
initials: 'Iniciales', initials: 'Iniciales',
date: 'Fecha', date: 'Fecha',
number: 'Número', number: 'Número',
@@ -75,6 +125,7 @@ const es = {
file: 'Archivo', file: 'Archivo',
select: 'Seleccionar', select: 'Seleccionar',
checkbox: 'Casilla', checkbox: 'Casilla',
number_phone_is_invalid: '{number} teléfono es inválido',
multiple: 'Múltiple', multiple: 'Múltiple',
radio: 'Radio', radio: 'Radio',
cells: 'Celdas', cells: 'Celdas',
@@ -82,7 +133,8 @@ const es = {
payment: 'Pago', payment: 'Pago',
phone: 'Teléfono', phone: 'Teléfono',
take_photo: 'Tomar foto', take_photo: 'Tomar foto',
submit_form: 'Enviar Formulario', start_now: 'Comenzar ahora',
continue: 'Continuar',
sign_now: 'Firmar ahora', sign_now: 'Firmar ahora',
type_here_: 'Escribe aquí...', type_here_: 'Escribe aquí...',
optional: 'opcional', optional: 'opcional',
@@ -102,8 +154,10 @@ const es = {
type_signature_here: 'Escribe la firma aquí', type_signature_here: 'Escribe la firma aquí',
type_initial_here: 'Escribe las iniciales aquí', type_initial_here: 'Escribe las iniciales aquí',
form_has_been_completed: '¡El formulario ha sido completado!', form_has_been_completed: '¡El formulario ha sido completado!',
document_has_been_signed: '¡El documento ha sido firmado!',
documents_have_been_signed: '¡Los documentos han sido firmados!',
create_a_free_account: 'Crear una Cuenta Gratuita', create_a_free_account: 'Crear una Cuenta Gratuita',
signed_with: 'Firmado con', powered_by: 'Desarrollado por',
please_check_the_box_to_continue: 'Por favor marque la casilla para continuar', please_check_the_box_to_continue: 'Por favor marque la casilla para continuar',
open_source_documents_software: 'software de documentos de código abierto', open_source_documents_software: 'software de documentos de código abierto',
verified_phone_number: 'Verificar número de teléfono', verified_phone_number: 'Verificar número de teléfono',
@@ -130,13 +184,36 @@ const es = {
} }
const it = { const it = {
complete: 'Completa',
sign_and_complete: 'Firma e Completa',
invite: 'Invita',
email: 'Email',
approved: 'Approvato',
reviewed: 'Revisionato',
other: 'Altro',
authored_by_me: 'Creato da me',
approved_by: 'Approvato da',
reviewed_by: 'Revisionato da',
authored_by: 'Creato da',
select_a_reason: 'Seleziona una ragione',
value_is_invalid: 'Il valore non è valido',
verification_code_is_invalid: 'Il codice di verifica non è valido',
drawn_signature_on_a_touchscreen_device: 'Firma disegnata su un dispositivo con schermo tattile',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scansiona il codice QR con l\'app della fotocamera per aprire il modulo sul cellulare e disegnare la tua firma',
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', minimize: 'Minimizza',
already_paid: 'Pagato',
text: 'Testo', text: 'Testo',
signature: 'Firma', signature: 'Firma',
digitally_signed_by: 'Firmato digitalmente da',
reason: 'Motivo',
initials: 'Iniziali', initials: 'Iniziali',
date: 'Data', date: 'Data',
number: 'Numero', number: 'Numero',
image: 'Immagine', image: 'Immagine',
pay: 'Pagamento',
file: 'File', file: 'File',
select: 'Seleziona', select: 'Seleziona',
checkbox: 'Casella di controllo', checkbox: 'Casella di controllo',
@@ -145,8 +222,10 @@ const it = {
cells: 'Celle', cells: 'Celle',
stamp: 'Timbro', stamp: 'Timbro',
payment: 'Pagamento', payment: 'Pagamento',
number_phone_is_invalid: '{number} telefono non è valido',
phone: 'Telefono', phone: 'Telefono',
submit_form: 'Invia Modulo', start_now: 'Inizia ora',
continue: 'Continua',
sign_now: 'Firma ora', sign_now: 'Firma ora',
type_here_: 'Digita qui...', type_here_: 'Digita qui...',
optional: 'opzionale', optional: 'opzionale',
@@ -167,8 +246,10 @@ const it = {
type_signature_here: 'Scrivi la firma qui', type_signature_here: 'Scrivi la firma qui',
type_initial_here: 'Scrivi le iniziali qui', type_initial_here: 'Scrivi le iniziali qui',
form_has_been_completed: 'Il modulo è stato completato!', form_has_been_completed: 'Il modulo è stato completato!',
document_has_been_signed: 'Il documento è stato firmato!',
documents_have_been_signed: 'I documenti sono stati firmati!',
create_a_free_account: 'Crea un Account Gratuito', create_a_free_account: 'Crea un Account Gratuito',
signed_with: 'Firmato con', powered_by: 'Desarrollado por',
please_check_the_box_to_continue: 'Si prega di spuntare la casella per continuare', please_check_the_box_to_continue: 'Si prega di spuntare la casella per continuare',
open_source_documents_software: 'software di documenti open source', open_source_documents_software: 'software di documenti open source',
verified_phone_number: 'Verifica numero di telefono', verified_phone_number: 'Verifica numero di telefono',
@@ -195,13 +276,36 @@ const it = {
} }
const de = { const de = {
complete: 'Abschließen',
sign_and_complete: 'Signieren und Abschließen',
invite: 'Einladen',
email: 'E-Mail',
approved: 'Genehmigt',
reviewed: 'Überprüft',
other: 'Andere',
authored_by_me: 'Von mir verfasst',
approved_by: 'Genehmigt von',
reviewed_by: 'Überprüft von',
authored_by: 'Verfasst von',
select_a_reason: 'Grund auswählen',
value_is_invalid: 'Wert ist ungültig',
verification_code_is_invalid: 'Bestätigungscode ist ungültig',
drawn_signature_on_a_touchscreen_device: 'Gezeichnete Unterschrift auf einem Touchscreen-Gerät',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scannen Sie den QR-Code mit der Kamera-App, um das Formular auf dem Handy zu öffnen und Ihre Unterschrift zu zeichnen',
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',
already_paid: 'Bereits bezahlt',
minimize: 'Minimieren', minimize: 'Minimieren',
text: 'Text', text: 'Text',
signature: 'Unterschrift', signature: 'Unterschrift',
digitally_signed_by: 'Digital unterschrieben von',
reason: 'Grund',
initials: 'Initialen', initials: 'Initialen',
date: 'Datum', date: 'Datum',
number: 'Nummer', number: 'Nummer',
image: 'Bild', image: 'Bild',
pay: 'Bezahlen',
file: 'Datei', file: 'Datei',
select: 'Auswählen', select: 'Auswählen',
checkbox: 'Checkbox', checkbox: 'Checkbox',
@@ -210,8 +314,10 @@ const de = {
cells: 'Zellen', cells: 'Zellen',
stamp: 'Stempel', stamp: 'Stempel',
payment: 'Zahlung', payment: 'Zahlung',
number_phone_is_invalid: '{number} Telefonnummer ist ungültig',
phone: 'Telefon', phone: 'Telefon',
submit_form: 'Formular absenden', start_now: 'Jetzt starten',
continue: 'Fortsetzen',
sign_now: 'Jetzt unterschreiben', sign_now: 'Jetzt unterschreiben',
type_here_: 'Hier eingeben...', type_here_: 'Hier eingeben...',
optional: 'optional', optional: 'optional',
@@ -232,8 +338,10 @@ const de = {
type_signature_here: 'Unterschrift hier eingeben', type_signature_here: 'Unterschrift hier eingeben',
type_initial_here: 'Initialen hier eingeben', type_initial_here: 'Initialen hier eingeben',
form_has_been_completed: 'Formular wurde ausgefüllt!', form_has_been_completed: 'Formular wurde ausgefüllt!',
document_has_been_signed: 'Dokument wurde unterschrieben!',
documents_have_been_signed: 'Dokumente wurden unterschrieben!',
create_a_free_account: 'Kostenloses Konto erstellen', create_a_free_account: 'Kostenloses Konto erstellen',
signed_with: 'Unterschrieben mit', powered_by: 'Bereitgestellt von',
please_check_the_box_to_continue: 'Bitte setzen Sie das Häkchen, um fortzufahren', please_check_the_box_to_continue: 'Bitte setzen Sie das Häkchen, um fortzufahren',
open_source_documents_software: 'Open-Source-Dokumentensoftware', open_source_documents_software: 'Open-Source-Dokumentensoftware',
verified_phone_number: 'Telefonnummer überprüfen', verified_phone_number: 'Telefonnummer überprüfen',
@@ -260,10 +368,33 @@ const de = {
} }
const fr = { const fr = {
complete: 'Terminer',
sign_and_complete: 'Signer et Terminer',
invite: 'Inviter',
email: 'Courriel',
approved: 'Approuvé',
reviewed: 'Révisé',
other: 'Autre',
authored_by_me: 'Rédigé par moi',
approved_by: 'Approuvé par',
reviewed_by: 'Révisé par',
authored_by: 'Rédigé par',
select_a_reason: 'Sélectionnez une raison',
value_is_invalid: 'La valeur est invalide',
verification_code_is_invalid: 'Le code de vérification est invalide',
drawn_signature_on_a_touchscreen_device: 'Signature dessinée sur un appareil à écran tactile',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scannez le code QR avec l\'application de l\'appareil photo pour ouvrir le formulaire sur mobile et dessiner votre signature',
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', minimize: 'Réduire',
text: 'Texte', text: 'Texte',
already_paid: 'Déjà payé',
signature: 'Signature', signature: 'Signature',
initials: 'Initiales', initials: 'Initiales',
digitally_signed_by: 'Signé numériquement par',
reason: 'Raison',
pay: 'Payer',
date: 'Date', date: 'Date',
number: 'Numéro', number: 'Numéro',
image: 'Image', image: 'Image',
@@ -275,8 +406,10 @@ const fr = {
cells: 'Cellules', cells: 'Cellules',
stamp: 'Tampon', stamp: 'Tampon',
payment: 'Paiement', payment: 'Paiement',
number_phone_is_invalid: '{number} téléphone est invalide',
phone: 'Téléphone', phone: 'Téléphone',
submit_form: 'Envoyer le Formulaire', start_now: 'Commencer maintenant',
continue: 'Continuer',
sign_now: 'Signer maintenant', sign_now: 'Signer maintenant',
type_here_: 'Tapez ici...', type_here_: 'Tapez ici...',
optional: 'facultatif', optional: 'facultatif',
@@ -297,8 +430,10 @@ const fr = {
type_signature_here: 'Tapez la signature ici', type_signature_here: 'Tapez la signature ici',
type_initial_here: 'Tapez les initiales ici', type_initial_here: 'Tapez les initiales ici',
form_has_been_completed: 'Le formulaire a été complété !', form_has_been_completed: 'Le formulaire a été complété !',
document_has_been_signed: 'Le document a été signé!',
documents_have_been_signed: 'Les documents ont été signés!',
create_a_free_account: 'Créer un Compte Gratuit', create_a_free_account: 'Créer un Compte Gratuit',
signed_with: 'Signé avec', powered_by: 'Propulsé par',
please_check_the_box_to_continue: 'Veuillez cocher la case pour continuer', please_check_the_box_to_continue: 'Veuillez cocher la case pour continuer',
open_source_documents_software: 'logiciel de documents open source', open_source_documents_software: 'logiciel de documents open source',
verified_phone_number: 'Vérifier le numéro de téléphone', verified_phone_number: 'Vérifier le numéro de téléphone',
@@ -325,14 +460,37 @@ const fr = {
} }
const pl = { const pl = {
complete: 'Zakończ',
sign_and_complete: 'Podpisz i zakończ',
invite: 'Zaproś',
email: 'E-mail',
approved: 'Zaakceptowany',
reviewed: 'Przejrzany',
other: 'Inny',
authored_by_me: 'Napisane przeze mnie',
approved_by: 'Zaakceptowany przez',
reviewed_by: 'Przejrzany przez',
authored_by: 'Napisane przez',
select_a_reason: 'Wybierz powód',
value_is_invalid: 'Wartość jest nieprawidłowa',
verification_code_is_invalid: 'Kod weryfikacyjny jest nieprawidłowy',
drawn_signature_on_a_touchscreen_device: 'Podpis odręczny na urządzeniu z ekranem dotykowym',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Zeskanuj kod QR za pomocą aplikacji aparatu, aby otworzyć formularz na telefonie i narysować swój podpis',
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', minimize: 'Zminimalizuj',
text: 'Tekst', text: 'Tekst',
already_paid: 'Już zapłacono',
digitally_signed_by: 'Podpisano cyfrowo przez',
reason: 'Powód',
signature: 'Podpis', signature: 'Podpis',
initials: 'Inicjały', initials: 'Inicjały',
date: 'Data', date: 'Data',
number: 'Numer', number: 'Numer',
image: 'Obraz', image: 'Obraz',
file: 'Plik', file: 'Plik',
pay: 'Zapłać',
select: 'Wybierz', select: 'Wybierz',
checkbox: 'Pole wyboru', checkbox: 'Pole wyboru',
multiple: 'Wielokrotne', multiple: 'Wielokrotne',
@@ -340,8 +498,10 @@ const pl = {
cells: 'Komórki', cells: 'Komórki',
stamp: 'Pieczątka', stamp: 'Pieczątka',
payment: 'Płatność', payment: 'Płatność',
number_phone_is_invalid: '{number} telefon jest nieprawidłowy',
phone: 'Telefon', phone: 'Telefon',
submit_form: 'Wyślij Formularz', start_now: 'Zacznij teraz',
continue: 'Kontynuuj',
sign_now: 'Podpisz teraz', sign_now: 'Podpisz teraz',
type_here_: 'Wpisz tutaj...', type_here_: 'Wpisz tutaj...',
optional: 'opcjonalny', optional: 'opcjonalny',
@@ -362,8 +522,10 @@ const pl = {
type_signature_here: 'Wpisz podpis tutaj', type_signature_here: 'Wpisz podpis tutaj',
type_initial_here: 'Wpisz inicjały tutaj', type_initial_here: 'Wpisz inicjały tutaj',
form_has_been_completed: 'Formularz został wypełniony!', form_has_been_completed: 'Formularz został wypełniony!',
document_has_been_signed: 'Dokument został podpisany!',
documents_have_been_signed: 'Dokumenty zostały podpisane!',
create_a_free_account: 'Utwórz darmowe konto', create_a_free_account: 'Utwórz darmowe konto',
signed_with: 'Podpisane za pomocą', powered_by: 'Napędzany przez',
please_check_the_box_to_continue: 'Proszę zaznaczyć pole, aby kontynuować', please_check_the_box_to_continue: 'Proszę zaznaczyć pole, aby kontynuować',
open_source_documents_software: 'oprogramowanie do dokumentów open source', open_source_documents_software: 'oprogramowanie do dokumentów open source',
verified_phone_number: 'Zweryfikuj numer telefonu', verified_phone_number: 'Zweryfikuj numer telefonu',
@@ -390,13 +552,36 @@ const pl = {
} }
const uk = { const uk = {
complete: 'Завершити',
sign_and_complete: 'Підписати і завершити',
invite: 'Запросити',
email: 'Електронна пошта',
approved: 'Затверджено',
reviewed: 'Переглянуто',
other: 'Інше',
authored_by_me: 'Авторство моє',
approved_by: 'Затверджено',
reviewed_by: 'Переглянуто',
authored_by: 'Автор',
select_a_reason: 'Виберіть причину',
value_is_invalid: 'Значення є неправильним',
verification_code_is_invalid: 'Код підтвердження є неправильним',
drawn_signature_on_a_touchscreen_device: 'Підпис на сенсорному пристрої',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Скануйте QR-код за допомогою програми камери, щоб відкрити форму на мобільному пристрої та намалювати свій підпис',
by_clicking_you_agree_to_the: 'Натиснувши на "{button}", ви погоджуєтеся з',
electronic_signature_disclosure: 'Розголошення Електронного Підпису',
esignature_disclosure: 'Розголошення еПідпису',
minimize: 'Зменшити', minimize: 'Зменшити',
text: 'Текст', text: 'Текст',
already_paid: 'Вже оплачено',
signature: 'Підпис', signature: 'Підпис',
digitally_signed_by: 'Підписано в електронному вигляді',
reason: 'Причина',
initials: 'Ініціали', initials: 'Ініціали',
date: 'Дата', date: 'Дата',
number: 'Число', number: 'Число',
image: 'Зображення', image: 'Зображення',
pay: 'Оплатити',
file: 'Файл', file: 'Файл',
select: 'Вибрати', select: 'Вибрати',
checkbox: 'Прапорець', checkbox: 'Прапорець',
@@ -405,8 +590,10 @@ const uk = {
cells: 'Клітинки', cells: 'Клітинки',
stamp: 'Штамп', stamp: 'Штамп',
payment: 'Платіж', payment: 'Платіж',
number_phone_is_invalid: '{number} телефон недійсний',
phone: 'Телефон', phone: 'Телефон',
submit_form: 'Надіслати Форму', start_now: 'Почати зараз',
continue: 'Продовжити',
sign_now: 'Підписати зараз', sign_now: 'Підписати зараз',
type_here_: 'Введіть тут', type_here_: 'Введіть тут',
optional: 'необов’язково', optional: 'необов’язково',
@@ -427,8 +614,10 @@ const uk = {
type_signature_here: 'Введіть підпис тут', type_signature_here: 'Введіть підпис тут',
type_initial_here: 'Введіть ініціали тут', type_initial_here: 'Введіть ініціали тут',
form_has_been_completed: 'Форму заповнено!', form_has_been_completed: 'Форму заповнено!',
document_has_been_signed: 'Документ був підписаний!',
documents_have_been_signed: 'Документи були підписані!',
create_a_free_account: 'Створити безкоштовний обліковий запис', create_a_free_account: 'Створити безкоштовний обліковий запис',
signed_with: 'Підписано за допомогою', powered_by: 'Працює на базі',
please_check_the_box_to_continue: 'Будь ласка, позначте прапорець, щоб продовжити', please_check_the_box_to_continue: 'Будь ласка, позначте прапорець, щоб продовжити',
open_source_documents_software: 'відкритий програмний засіб для документів', open_source_documents_software: 'відкритий програмний засіб для документів',
verified_phone_number: 'Підтвердіть номер телефону', verified_phone_number: 'Підтвердіть номер телефону',
@@ -455,13 +644,36 @@ const uk = {
} }
const cs = { const cs = {
complete: 'Dokončit',
sign_and_complete: 'Podepsat a dokončit',
invite: 'Pozvat',
email: 'E-mail',
approved: 'Schváleno',
reviewed: 'Zkontrolováno',
other: 'Jiné',
authored_by_me: 'Autorem jsem já',
approved_by: 'Schváleno kým',
reviewed_by: 'Zkontrolováno kým',
authored_by: 'Autorem',
select_a_reason: 'Vyberte důvod',
value_is_invalid: 'Hodnota je neplatná',
verification_code_is_invalid: 'Ověřovací kód je neplatný',
drawn_signature_on_a_touchscreen_device: 'Namalovaný podpis na dotykovém zařízení',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Naskenujte QR kód pomocí aplikace fotoaparátu, abyste otevřeli formulář na mobilním zařízení a nakreslili svůj podpis',
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',
already_paid: 'Už zaplaceno',
minimize: 'Minimalizovat', minimize: 'Minimalizovat',
text: 'Text', text: 'Text',
signature: 'Podpis', signature: 'Podpis',
initials: 'Iniciály', initials: 'Iniciály',
date: 'Datum', date: 'Datum',
number: 'Číslo', number: 'Číslo',
digitally_signed_by: 'Digitálně podepsáno',
reason: 'Důvod',
image: 'Obrázek', image: 'Obrázek',
pay: 'Zaplatit',
file: 'Soubor', file: 'Soubor',
select: 'Vybrat', select: 'Vybrat',
checkbox: 'Zaškrtávací políčko', checkbox: 'Zaškrtávací políčko',
@@ -471,7 +683,9 @@ const cs = {
stamp: 'Razítko', stamp: 'Razítko',
payment: 'Platba', payment: 'Platba',
phone: 'Telefon', phone: 'Telefon',
submit_form: 'Odeslat formulář', number_phone_is_invalid: '{number} telefon je neplatný',
start_now: 'Začít nyní',
continue: 'Pokračovat',
sign_now: 'Podepsat nyní', sign_now: 'Podepsat nyní',
type_here_: 'Zadejte zde', type_here_: 'Zadejte zde',
optional: 'volitelné', optional: 'volitelné',
@@ -492,8 +706,10 @@ const cs = {
type_signature_here: 'Sem zadejte podpis', type_signature_here: 'Sem zadejte podpis',
type_initial_here: 'Sem zadejte iniciály', type_initial_here: 'Sem zadejte iniciály',
form_has_been_completed: 'Formulář byl dokončen!', form_has_been_completed: 'Formulář byl dokončen!',
document_has_been_signed: 'Dokument byl podepsán!',
documents_have_been_signed: 'Dokumenty byly podepsány!',
create_a_free_account: 'Vytvořit bezplatný účet', create_a_free_account: 'Vytvořit bezplatný účet',
signed_with: 'Podepsáno pomocí', powered_by: 'Poháněno',
please_check_the_box_to_continue: 'Prosím, zaškrtněte políčko pro pokračování', please_check_the_box_to_continue: 'Prosím, zaškrtněte políčko pro pokračování',
open_source_documents_software: 'open source software pro dokumenty', open_source_documents_software: 'open source software pro dokumenty',
verified_phone_number: 'Ověřte telefonní číslo', verified_phone_number: 'Ověřte telefonní číslo',
@@ -520,13 +736,36 @@ const cs = {
} }
const pt = { const pt = {
complete: 'Completar',
sign_and_complete: 'Assinar e Completar',
invite: 'Convidar',
email: 'E-mail',
approved: 'Aprovado',
reviewed: 'Revisado',
other: 'Outro',
authored_by_me: 'Autorizado por mim',
approved_by: 'Aprovado por',
reviewed_by: 'Revisado por',
authored_by: 'Autorizado por',
select_a_reason: 'Selecione um motivo',
value_is_invalid: 'Valor é inválido',
verification_code_is_invalid: 'Código de verificação é inválido',
drawn_signature_on_a_touchscreen_device: 'Assinatura desenhada em um dispositivo com tela sensível ao toque',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escaneie o código QR com o aplicativo da câmera para abrir o formulário no celular e desenhar sua assinatura',
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',
already_paid: 'Já pago',
minimize: 'Minimizar', minimize: 'Minimizar',
text: 'Texto', text: 'Texto',
signature: 'Assinatura', signature: 'Assinatura',
initials: 'Iniciais', initials: 'Iniciais',
digitally_signed_by: 'Assinado digitalmente por',
reason: 'Razão',
date: 'Data', date: 'Data',
number: 'Número', number: 'Número',
image: 'Imagem', image: 'Imagem',
pay: 'Pagar',
file: 'Arquivo', file: 'Arquivo',
select: 'Selecionar', select: 'Selecionar',
checkbox: 'Caixa', checkbox: 'Caixa',
@@ -535,8 +774,10 @@ const pt = {
cells: 'Células', cells: 'Células',
stamp: 'Carimbo', stamp: 'Carimbo',
payment: 'Pagamento', payment: 'Pagamento',
number_phone_is_invalid: '{number} telefone é inválido',
phone: 'Telefone', phone: 'Telefone',
submit_form: 'Enviar Formulário', start_now: 'Começar agora',
continue: 'Continuar',
sign_now: 'Assinar agora', sign_now: 'Assinar agora',
type_here_: 'Digite aqui', type_here_: 'Digite aqui',
optional: 'opcional', optional: 'opcional',
@@ -557,8 +798,10 @@ const pt = {
type_signature_here: 'Digite a assinatura aqui', type_signature_here: 'Digite a assinatura aqui',
type_initial_here: 'Digite as iniciais aqui', type_initial_here: 'Digite as iniciais aqui',
form_has_been_completed: 'O formulário foi concluído!', form_has_been_completed: 'O formulário foi concluído!',
document_has_been_signed: 'O documento foi assinado!',
documents_have_been_signed: 'Os documentos foram assinados!',
create_a_free_account: 'Criar uma Conta Gratuita', create_a_free_account: 'Criar uma Conta Gratuita',
signed_with: 'Assinado com', powered_by: 'Desenvolvido por',
please_check_the_box_to_continue: 'Por favor, marque a caixa para continuar', please_check_the_box_to_continue: 'Por favor, marque a caixa para continuar',
open_source_documents_software: 'software de documentos de código aberto', open_source_documents_software: 'software de documentos de código aberto',
verified_phone_number: 'Verificar Número de Telefone', verified_phone_number: 'Verificar Número de Telefone',
@@ -585,14 +828,37 @@ const pt = {
} }
const he = { const he = {
complete: 'השלם',
sign_and_complete: 'חתום והשלם',
invite: 'הזמן',
email: 'דוא"ל',
approved: 'מאושר',
reviewed: 'נסקר',
other: 'אחר',
authored_by_me: 'נכתב על ידי',
approved_by: 'אושר על ידי',
reviewed_by: 'נסקר על ידי',
authored_by: 'נכתב על ידי',
select_a_reason: 'בחר סיבה',
value_is_invalid: 'ערך לא תקין',
verification_code_is_invalid: 'קוד האימות אינו תקין',
drawn_signature_on_a_touchscreen_device: 'חתימה שנוצרה במכשיר עם מסך מגע',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'סרוק את קוד ה-QR באמצעות אפליקציית המצלמה כדי לפתוח את הטופס במובייל ולצייר את החתימה שלך',
by_clicking_you_agree_to_the: 'על ידי לחיצה על "{button}", אתה מסכים ל',
electronic_signature_disclosure: 'חשיפת חתימה אלקטרונית',
esignature_disclosure: 'חשיפת ה-eחתימה',
minimize: 'לקטן', minimize: 'לקטן',
text: 'טקסט', text: 'טקסט',
already_paid: 'כבר שולם',
signature: 'חתימה', signature: 'חתימה',
initials: 'ראשי תיבות', initials: 'ראשי תיבות',
date: 'תאריך', date: 'תאריך',
digitally_signed_by: 'חתום דיגיטלית על ידי',
reason: 'סיבה',
number: 'מספר', number: 'מספר',
image: 'תמונה', image: 'תמונה',
file: 'קובץ', file: 'קובץ',
pay: 'תשלום',
select: 'בחר', select: 'בחר',
checkbox: 'תיק בחירה', checkbox: 'תיק בחירה',
multiple: 'רב ערכים', multiple: 'רב ערכים',
@@ -601,7 +867,9 @@ const he = {
stamp: 'חותמת', stamp: 'חותמת',
payment: 'תשלום', payment: 'תשלום',
phone: 'טלפון', phone: 'טלפון',
submit_form: 'שלח טופס', number_phone_is_invalid: '{number} טלפון אינו חוקי',
start_now: 'התחל עכשיו',
continue: 'המשך',
sign_now: 'חתום כעת', sign_now: 'חתום כעת',
type_here_: 'הקלד כאן', type_here_: 'הקלד כאן',
optional: 'אופציונלי', optional: 'אופציונלי',
@@ -623,8 +891,10 @@ const he = {
type_signature_here: 'הקלד חתימה כאן', type_signature_here: 'הקלד חתימה כאן',
type_initial_here: 'הקלד ציוני ראשי תיבות כאן', type_initial_here: 'הקלד ציוני ראשי תיבות כאן',
form_has_been_completed: 'הטופס הושלם', form_has_been_completed: 'הטופס הושלם',
document_has_been_signed: 'המסמך נחתם!',
documents_have_been_signed: 'המסמכים נחתמו!',
create_a_free_account: 'צור חשבון חינם', create_a_free_account: 'צור חשבון חינם',
signed_with: 'חתום באמצעות', powered_by: 'מופעל על ידי',
please_check_the_box_to_continue: 'אנא סמן את התיבה כדי להמשיך', please_check_the_box_to_continue: 'אנא סמן את התיבה כדי להמשיך',
open_source_documents_software: 'תוכנה פתוחה למסמכים', open_source_documents_software: 'תוכנה פתוחה למסמכים',
verified_phone_number: 'אימות מספר טלפון', verified_phone_number: 'אימות מספר טלפון',
@@ -651,9 +921,31 @@ const he = {
} }
const nl = { const nl = {
complete: 'Voltooien',
sign_and_complete: 'Ondertekenen en voltooien',
invite: 'Uitnodigen',
email: 'E-mail',
approved: 'Goedgekeurd',
reviewed: 'Beoordeeld',
other: 'Anders',
authored_by_me: 'Door mij geschreven',
approved_by: 'Goedgekeurd door',
reviewed_by: 'Beoordeeld door',
authored_by: 'Geschreven door',
select_a_reason: 'Selecteer een reden',
value_is_invalid: 'Waarde is ongeldig',
verification_code_is_invalid: 'Verificatiecode is ongeldig',
drawn_signature_on_a_touchscreen_device: 'Getekende handtekening op een apparaat met een touchscreen',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scan de QR-code met de camera-app om het formulier op mobiel te openen en uw handtekening te tekenen',
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', minimize: 'Minimaliseren',
text: 'Tekst', text: 'Tekst',
already_paid: 'Al betaald',
signature: 'Handtekening', signature: 'Handtekening',
digitally_signed_by: 'Digitaal ondertekend door',
reason: 'Reden',
initials: 'Initialen', initials: 'Initialen',
date: 'Datum', date: 'Datum',
number: 'Nummer', number: 'Nummer',
@@ -663,12 +955,15 @@ const nl = {
select: 'Selecteer', select: 'Selecteer',
checkbox: 'Aankruisvakje', checkbox: 'Aankruisvakje',
multiple: 'Meerdere', multiple: 'Meerdere',
pay: 'Betaal',
radio: 'Radio', radio: 'Radio',
cells: 'Cellen', cells: 'Cellen',
stamp: 'Stempel', stamp: 'Stempel',
payment: 'Betaling', payment: 'Betaling',
phone: 'Telefoon', phone: 'Telefoon',
submit_form: 'Formulier verzenden', number_phone_is_invalid: '{number} telefoon is ongeldig',
start_now: 'Nu beginnen',
continue: 'Doorgaan',
sign_now: 'Nu ondertekenen', sign_now: 'Nu ondertekenen',
type_here_: 'Typ hier...', type_here_: 'Typ hier...',
optional: 'Optioneel', optional: 'Optioneel',
@@ -689,8 +984,10 @@ const nl = {
type_signature_here: 'Typ hier uw handtekening', type_signature_here: 'Typ hier uw handtekening',
type_initial_here: 'Typ hier uw initialen', type_initial_here: 'Typ hier uw initialen',
form_has_been_completed: 'Formulier is voltooid!', form_has_been_completed: 'Formulier is voltooid!',
document_has_been_signed: 'Document is ondertekend!',
documents_have_been_signed: 'De documenten zijn ondertekend!',
create_a_free_account: 'Maak een gratis account aan', create_a_free_account: 'Maak een gratis account aan',
signed_with: 'Ondertekend met', powered_by: 'Aangedreven door',
please_check_the_box_to_continue: 'Vink het vakje aan om door te gaan', please_check_the_box_to_continue: 'Vink het vakje aan om door te gaan',
open_source_documents_software: 'Open source documenten software', open_source_documents_software: 'Open source documenten software',
verified_phone_number: 'Geverifieerd telefoonnummer', verified_phone_number: 'Geverifieerd telefoonnummer',
@@ -717,13 +1014,35 @@ const nl = {
} }
const ar = { const ar = {
complete: 'اكتمال',
sign_and_complete: 'التوقيع والاكتمال',
invite: 'دعوة',
email: 'البريد الإلكتروني',
approved: 'موافق عليه',
reviewed: 'تمت مراجعته',
other: 'آخر',
authored_by_me: 'مؤلف بواسطتي',
approved_by: 'موافق عليه من قبل',
reviewed_by: 'مراجع من قبل',
authored_by: 'مؤلف من قبل',
select_a_reason: 'اختر سببًا',
value_is_invalid: 'القيمة غير صالحة',
verification_code_is_invalid: 'رمز التحقق غير صالح',
drawn_signature_on_a_touchscreen_device: 'توقيع مرسوم على جهاز بشاشة تعمل باللمس',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'امسح رمز الاستجابة السريعة باستخدام تطبيق الكاميرا لفتح النموذج على الهاتف المحمول ورسم توقيعك',
by_clicking_you_agree_to_the: 'بالنقر فوق "{button}"، أنت توافق على',
electronic_signature_disclosure: 'كشف التوقيع الإلكتروني',
esignature_disclosure: 'كشف التوقيع الإلكتروني',
text: 'نص', text: 'نص',
signature: 'توقيع', signature: 'توقيع',
initials: 'الاختصارات', initials: 'الاختصارات',
date: 'تاريخ', date: 'تاريخ',
number: 'رقم', number: 'رقم',
digitally_signed_by: 'تم التوقيع رقميًا بواسطة',
reason: 'السبب',
image: 'صورة', image: 'صورة',
take_photo: 'التقاط صورة', take_photo: 'التقاط صورة',
pay: 'دفع',
file: 'ملف', file: 'ملف',
select: 'اختيار', select: 'اختيار',
checkbox: 'خانة اختيار', checkbox: 'خانة اختيار',
@@ -734,7 +1053,9 @@ const ar = {
minimize: 'تصغير', minimize: 'تصغير',
payment: 'الدفع', payment: 'الدفع',
phone: 'هاتف', phone: 'هاتف',
submit_form: 'إرسال النموذج', number_phone_is_invalid: '{number} هاتف غير صالح',
start_now: 'ابدأ الآن',
continue: 'متابعة',
sign_now: 'وقع الآن', sign_now: 'وقع الآن',
type_here_: 'اكتب هنا', type_here_: 'اكتب هنا',
optional: 'اختياري', optional: 'اختياري',
@@ -755,8 +1076,10 @@ const ar = {
type_signature_here: 'اكتب التوقيع هنا', type_signature_here: 'اكتب التوقيع هنا',
type_initial_here: 'اكتب الاختصارات هنا', type_initial_here: 'اكتب الاختصارات هنا',
form_has_been_completed: 'تم إكمال النموذج!', form_has_been_completed: 'تم إكمال النموذج!',
document_has_been_signed: 'تم توقيع الوثيقة!',
documents_have_been_signed: 'تم توقيع الوثائق!',
create_a_free_account: 'إنشاء حساب مجاني', create_a_free_account: 'إنشاء حساب مجاني',
signed_with: 'تم التوقيع بواسطة', powered_by: 'مدعوم من',
please_check_the_box_to_continue: 'الرجاء التحقق من الخانة للمتابعة', please_check_the_box_to_continue: 'الرجاء التحقق من الخانة للمتابعة',
open_source_documents_software: 'برنامج وثائق مفتوح المصدر', open_source_documents_software: 'برنامج وثائق مفتوح المصدر',
verified_phone_number: 'تحقق من رقم الهاتف', verified_phone_number: 'تحقق من رقم الهاتف',
@@ -783,11 +1106,32 @@ const ar = {
} }
const ko = { const ko = {
complete: '완료',
sign_and_complete: '서명하고 완료하기',
invite: '초대하기',
email: '이메일',
approved: '승인됨',
reviewed: '검토됨',
other: '기타',
authored_by_me: '내가 작성한',
approved_by: '승인자',
reviewed_by: '검토자',
authored_by: '작성자',
select_a_reason: '이유 선택',
drawn_signature_on_a_touchscreen_device: '터치스크린 장치에서 그린 서명',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: '카메라 앱으로 QR 코드를 스캔하여 모바일에서 양식을 열고 서명을 그리세요',
by_clicking_you_agree_to_the: '"{button}"를 클릭함으로써, 다음에 동의하게 됩니다',
electronic_signature_disclosure: '전자 서명 공개',
esignature_disclosure: '전자 서명 공개',
already_paid: '이미 지불됨',
text: '텍스트', text: '텍스트',
signature: '서명', signature: '서명',
initials: '이니셜', initials: '이니셜',
date: '날짜', date: '날짜',
digitally_signed_by: '디지털 서명이 완료되었습니다',
reason: '이유',
number: '숫자', number: '숫자',
pay: '급여',
image: '이미지', image: '이미지',
take_photo: '사진 찍기', take_photo: '사진 찍기',
file: '파일', file: '파일',
@@ -800,7 +1144,9 @@ const ko = {
minimize: '최소화', minimize: '최소화',
payment: '결제', payment: '결제',
phone: '전화', phone: '전화',
submit_form: '양식 제출', number_phone_is_invalid: '{number} 전화번호가 유효하지 않습니다',
start_now: '지금 시작하기',
continue: '계속하다',
sign_now: '지금 서명', sign_now: '지금 서명',
type_here_: '여기에 입력...', type_here_: '여기에 입력...',
optional: '선택 사항', optional: '선택 사항',
@@ -821,8 +1167,10 @@ const ko = {
type_signature_here: '여기에 서명 입력', type_signature_here: '여기에 서명 입력',
type_initial_here: '여기에 이니셜 입력', type_initial_here: '여기에 이니셜 입력',
form_has_been_completed: '양식이 완료되었습니다!', form_has_been_completed: '양식이 완료되었습니다!',
document_has_been_signed: '문서가 서명되었습니다!',
documents_have_been_signed: '문서가 서명되었습니다!',
create_a_free_account: '무료 계정 생성', create_a_free_account: '무료 계정 생성',
signed_with: '다음으로 서명:', powered_by: '구동',
please_check_the_box_to_continue: '계속하려면 확인란을 선택하십시오', please_check_the_box_to_continue: '계속하려면 확인란을 선택하십시오',
open_source_documents_software: '오픈 소스 문서 소프트웨어', open_source_documents_software: '오픈 소스 문서 소프트웨어',
verified_phone_number: '전화번호 확인됨', verified_phone_number: '전화번호 확인됨',
@@ -37,6 +37,7 @@
<FileDropzone <FileDropzone
: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"
:dry-run="dryRun"
:accept="'image/*'" :accept="'image/*'"
@upload="onImageUpload" @upload="onImageUpload"
/> />
@@ -66,6 +67,11 @@ export default {
required: false, required: false,
default: true default: true
}, },
dryRun: {
type: Boolean,
required: false,
default: false
},
submitterSlug: { submitterSlug: {
type: String, type: String,
required: true required: true
@@ -50,6 +50,26 @@
</span> </span>
</a> </a>
</span> </span>
<span
class="tooltip"
:data-tip="t('click_to_upload')"
>
<label
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap"
>
<IconUpload :width="16" />
<input
:key="uploadImageInputKey"
type="file"
hidden
accept="image/*"
@change="drawImage"
>
<span class="hidden sm:inline">
{{ t('upload') }}
</span>
</label>
</span>
<a <a
v-if="modelValue || computedPreviousValue" v-if="modelValue || computedPreviousValue"
href="#" href="#"
@@ -97,7 +117,7 @@
<img <img
v-if="modelValue || computedPreviousValue" v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url" :src="attachmentsIndex[modelValue || computedPreviousValue].url"
class="mx-auto bg-white border border-base-300 rounded max-h-72" class="mx-auto bg-white border border-base-300 rounded max-h-44"
> >
<canvas <canvas
v-show="!modelValue && !computedPreviousValue" v-show="!modelValue && !computedPreviousValue"
@@ -120,16 +140,18 @@
<script> <script>
import { cropCanvasAndExportToPNG } from './crop_canvas' import { cropCanvasAndExportToPNG } from './crop_canvas'
import { IconReload, IconTextSize, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue' import { IconReload, IconTextSize, IconUpload, 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' import MarkdownContent from './markdown_content'
import SignatureStep from './signature_step'
const scale = 3 const scale = 3
export default { export default {
name: 'InitialsStep', name: 'InitialsStep',
components: { components: {
IconUpload,
AppearsOn, AppearsOn,
IconReload, IconReload,
IconTextSize, IconTextSize,
@@ -143,6 +165,11 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
dryRun: {
type: Boolean,
required: false,
default: false
},
submitterSlug: { submitterSlug: {
type: String, type: String,
required: true required: true
@@ -173,7 +200,8 @@ export default {
return { return {
isInitialsStarted: !!this.previousValue, isInitialsStarted: !!this.previousValue,
isUsePreviousValue: true, isUsePreviousValue: true,
isDrawInitials: false isDrawInitials: false,
uploadImageInputKey: Math.random().toString()
} }
}, },
computed: { computed: {
@@ -189,7 +217,7 @@ export default {
this.$nextTick(() => { this.$nextTick(() => {
if (this.$refs.canvas) { if (this.$refs.canvas) {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
this.$refs.canvas.height = (this.$refs.canvas.parentNode.clientWidth / 3) * scale this.$refs.canvas.height = (this.$refs.canvas.parentNode.clientWidth / 4.5) * scale
this.$refs.canvas.getContext('2d').scale(scale, scale) this.$refs.canvas.getContext('2d').scale(scale, scale)
} }
@@ -208,7 +236,7 @@ export default {
entries.forEach(entry => { entries.forEach(entry => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
this.$refs.canvas.height = (this.$refs.canvas.parentNode.clientWidth / 3) * scale this.$refs.canvas.height = (this.$refs.canvas.parentNode.clientWidth / 4.5) * scale
this.$refs.canvas.getContext('2d').scale(scale, scale) this.$refs.canvas.getContext('2d').scale(scale, scale)
@@ -224,6 +252,15 @@ export default {
this.intersectionObserver?.disconnect() this.intersectionObserver?.disconnect()
}, },
methods: { methods: {
drawOnCanvas: SignatureStep.methods.drawOnCanvas,
drawImage (event) {
this.remove()
this.isInitialsStarted = true
this.drawOnCanvas(event.target.files[0], this.$refs.canvas)
this.uploadImageInputKey = Math.random().toString()
},
remove () { remove () {
this.$emit('update:model-value', '') this.$emit('update:model-value', '')
@@ -246,13 +283,28 @@ export default {
const context = canvas.getContext('2d') const context = canvas.getContext('2d')
const fontFamily = 'Arial' const fontFamily = 'Arial'
const fontSize = '44px' const initialFontSize = 50
const fontStyle = 'italic' const fontStyle = 'italic'
const fontWeight = '' const fontWeight = ''
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily const setFontSize = (size) => {
context.textAlign = 'center' context.font = `${fontStyle} ${fontWeight} ${size}px ${fontFamily}`
}
const adjustFontSizeToFit = (text, maxWidth, initialSize) => {
let size = initialSize
setFontSize(size)
while (context.measureText(text).width > maxWidth && size > 1) {
size -= 1
setFontSize(size)
}
}
adjustFontSizeToFit(e.target.value, canvas.width / scale, initialFontSize)
context.textAlign = 'center'
context.clearRect(0, 0, canvas.width / scale, canvas.height / scale) context.clearRect(0, 0, canvas.width / scale, canvas.height / scale)
context.fillText(e.target.value, canvas.width / 2 / scale, canvas.height / 2 / scale + 11) context.fillText(e.target.value, canvas.width / 2 / scale, canvas.height / 2 / scale + 11)
}, },
@@ -282,21 +334,36 @@ 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' })
const formData = new FormData() if (this.dryRun) {
const reader = new FileReader()
formData.append('file', file) reader.readAsDataURL(file)
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
return fetch(this.baseUrl + '/api/attachments', { reader.onloadend = () => {
method: 'POST', const attachment = { url: reader.result, uuid: Math.random().toString() }
body: formData
}).then((resp) => resp.json()).then((attachment) => {
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
return resolve(attachment) this.$emit('attached', attachment)
}) this.$emit('update:model-value', attachment.uuid)
resolve(attachment)
}
} else {
const formData = new FormData()
formData.append('file', file)
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: formData
}).then((resp) => resp.json()).then((attachment) => {
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
return resolve(attachment)
})
}
}) })
}) })
} }
@@ -0,0 +1,117 @@
<template>
<form
ref="form"
action="post"
method="post"
class="mx-auto"
@submit.prevent="submit"
>
<input
type="hidden"
name="authenticity_token"
:value="authenticityToken"
>
<div
v-for="(submitter, index) in submitters"
:key="submitter.uuid"
:class="{ 'mt-4': index !== 0 }"
>
<input
:value="submitter.uuid"
hidden
name="submission[submitters][][uuid]"
>
<label
:for="submitter.uuid"
dir="auto"
class="label text-2xl"
>
{{ t('invite') }} {{ submitter.name }}
</label>
<input
:id="submitter.uuid"
dir="auto"
class="base-input !text-2xl w-full"
:placeholder="t('email')"
type="email"
required
autofocus="true"
name="submission[submitters][][email]"
>
</div>
<div
class="mt-6 md:mt-8"
>
<button
type="submit"
class="base-button w-full flex justify-center"
:disabled="isSubmitting"
>
<span class="flex">
<IconInnerShadowTop
v-if="isSubmitting"
class="mr-1 animate-spin"
/>
<span>
{{ t('submit') }}
</span><span
v-if="isSubmitting"
class="w-6 flex justify-start mr-1"
><span>...</span></span>
</span>
</button>
</div>
</form>
</template>
<script>
import { IconInnerShadowTop } from '@tabler/icons-vue'
export default {
name: 'InviteForm',
components: {
IconInnerShadowTop
},
inject: ['t'],
props: {
submitters: {
type: Array,
required: true
},
url: {
type: String,
required: true
},
authenticityToken: {
type: String,
required: true
},
submitterSlug: {
type: String,
required: true
}
},
emits: ['success'],
data () {
return {
isSubmitting: false
}
},
methods: {
submit () {
this.isSubmitting = true
return fetch(this.url, {
method: 'POST',
body: new FormData(this.$refs.form)
}).then((response) => {
if (response.status === 200) {
this.$emit('success')
}
}).finally(() => {
this.isSubmitting = false
})
}
}
}
</script>
@@ -51,7 +51,17 @@ export default {
}, },
computed: { computed: {
items () { items () {
return snarkdown(this.string.replace(/\n/g, '<br>')).split(htmlSplitRegexp) const linkParts = this.string.split(/(https?:\/\/[^\s)]+)/g)
const text = linkParts.map((part, index) => {
if (part.match(/^https?:\/\//) && !linkParts[index - 1]?.match(/\(\s*$/) && !linkParts[index + 1]?.match(/^\s*\)/)) {
return `[${part}](${part})`
} else {
return part
}
}).join('')
return snarkdown(text.replace(/\n/g, '<br>')).split(htmlSplitRegexp)
} }
}, },
methods: { methods: {
@@ -10,7 +10,9 @@
:string="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>
<div <div
v-else v-else
+85 -12
View File
@@ -3,8 +3,20 @@
v-if="!modelValue && !sessionId" v-if="!modelValue && !sessionId"
:for="field.uuid" :for="field.uuid"
class="label text-2xl mb-2" class="label text-2xl mb-2"
>{{ field.name || defaultName }} >
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>{{ field.name || defaultName }}</template>
</label> </label>
<div
v-if="field.description"
dir="auto"
class="mb-4 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<div> <div>
<input <input
type="text" type="text"
@@ -17,7 +29,7 @@
v-if="modelValue && !sessionId" v-if="modelValue && !sessionId"
class=" text-2xl mb-2" class=" text-2xl mb-2"
> >
Already paid {{ t('already_paid') }}
</div> </div>
<div v-else> <div v-else>
<button <button
@@ -39,7 +51,7 @@
class="btn bg-[#7B73FF] text-white hover:bg-[#0A2540] text-lg w-full" class="btn bg-[#7B73FF] text-white hover:bg-[#0A2540] text-lg w-full"
:class="{ disabled: isCreatingCheckout }" :class="{ disabled: isCreatingCheckout }"
:disabled="isCreatingCheckout" :disabled="isCreatingCheckout"
@click.prevent="startCheckout" @click.prevent="postCheckout"
> >
<IconInnerShadowTop <IconInnerShadowTop
v-if="isCreatingCheckout" v-if="isCreatingCheckout"
@@ -60,11 +72,13 @@
<script> <script>
import { IconBrandStripe, IconInnerShadowTop, IconLoader } from '@tabler/icons-vue' import { IconBrandStripe, IconInnerShadowTop, IconLoader } from '@tabler/icons-vue'
import MarkdownContent from './markdown_content'
export default { export default {
name: 'PaymentStep', name: 'PaymentStep',
components: { components: {
IconBrandStripe, IconBrandStripe,
MarkdownContent,
IconInnerShadowTop, IconInnerShadowTop,
IconLoader IconLoader
}, },
@@ -79,6 +93,10 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
values: {
type: Object,
required: true
},
submitterSlug: { submitterSlug: {
type: String, type: String,
required: true required: true
@@ -87,7 +105,8 @@ export default {
emits: ['focus', 'submit', 'update:model-value', 'attached'], emits: ['focus', 'submit', 'update:model-value', 'attached'],
data () { data () {
return { return {
isCreatingCheckout: false isCreatingCheckout: false,
isMathLoaded: false
} }
}, },
computed: { computed: {
@@ -100,23 +119,75 @@ export default {
defaultName () { defaultName () {
const { price, currency } = this.field.preferences || {} const { price, currency } = this.field.preferences || {}
const formattedPrice = new Intl.NumberFormat([], { const formatter = new Intl.NumberFormat([], {
style: 'currency', style: 'currency',
currency currency
}).format(price) })
return `Pay ${formattedPrice}` if (this.field.preferences?.formula) {
if (this.isMathLoaded) {
return this.t('pay') + ' ' + formatter.format(this.calculateFormula())
} else {
return ''
}
} else {
return this.t('pay') + ' ' + formatter.format(price)
}
} }
}, },
mounted () { async mounted () {
if (this.sessionId) { if (this.sessionId) {
this.$emit('submit') this.$emit('submit')
} }
if (!this.sessionId) {
this.postCheckout({ checkStatus: true })
}
if (this.field.preferences?.formula) {
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: { methods: {
calculateFormula () {
const transformedFormula = this.field.preferences.formula.replace(/{{(.*?)}}/g, (match, uuid) => {
return this.values[uuid] || 0.0
})
return this.math.evaluate(transformedFormula.toLowerCase())
},
async submit () { async submit () {
if (this.sessionId) { if (this.sessionId) {
return fetch((this.baseUrl || '/embed').replace('/embed', '/api/stripe_payments/' + this.sessionId), { return fetch(this.baseUrl + '/api/stripe_payments/' + this.sessionId, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
submitter_slug: this.submitterSlug submitter_slug: this.submitterSlug
@@ -144,14 +215,16 @@ export default {
return Promise.resolve({}) return Promise.resolve({})
} }
}, },
startCheckout () { postCheckout ({ checkStatus } = {}) {
this.isCreatingCheckout = true this.isCreatingCheckout = true
fetch((this.baseUrl || '/embed').replace('/embed', '/api/stripe_payments'), { fetch(this.baseUrl + '/api/stripe_payments', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
submitter_slug: this.submitterSlug, submitter_slug: this.submitterSlug,
field_uuid: this.field.uuid field_uuid: this.field.uuid,
check_status: checkStatus,
referer: document.location.href
}), }),
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => { }).then(async (resp) => {
+16 -6
View File
@@ -11,7 +11,9 @@
/> />
<template v-else> <template v-else>
{{ showFieldNames && field.name ? field.name : t('verified_phone_number') }} {{ showFieldNames && field.name ? field.name : t('verified_phone_number') }}
<template v-if="!field.required">({{ t('optional') }})</template> <template v-if="!field.required">
({{ t('optional') }})
</template>
</template> </template>
</label> </label>
<div <div
@@ -172,6 +174,14 @@ export default {
phone: this.$refs.phone.value phone: this.$refs.phone.value
}), }),
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => {
if (resp.status === 422) {
const data = await resp.json()
alert(this.t('number_phone_is_invalid').replace('{number}', this.$refs.phone.value))
return Promise.reject(new Error(data.error))
}
}) })
}, },
async submit () { async submit () {
@@ -184,13 +194,13 @@ export default {
return Promise.reject(new Error('phone invalid')) return Promise.reject(new Error('phone invalid'))
} else if (!this.isCodeSent) { } else if (!this.isCodeSent) {
this.sendVerificationCode() return this.sendVerificationCode().then(() => {
this.$emit('update:model-value', this.$refs.phone.value)
this.$emit('update:model-value', this.$refs.phone.value) this.isCodeSent = true
this.isCodeSent = true return Promise.reject(new Error('verify with code'))
})
return Promise.reject(new Error('verify with code'))
} else { } else {
return Promise.resolve({}) return Promise.resolve({})
} }
+337 -43
View File
@@ -1,5 +1,8 @@
<template> <template>
<div dir="auto"> <div
dir="auto"
class="relative"
>
<div <div
class="flex justify-between items-center w-full" class="flex justify-between items-center w-full"
:class="{ 'mb-2': !field.description }" :class="{ 'mb-2': !field.description }"
@@ -25,7 +28,7 @@
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"
@click.prevent="toggleTextInput" @click.prevent="[toggleTextInput(), hideQr()]"
> >
<IconSignature :width="16" /> <IconSignature :width="16" />
<span class="hidden sm:inline"> <span class="hidden sm:inline">
@@ -42,7 +45,7 @@
id="type_text_button" id="type_text_button"
href="#" href="#"
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap" class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap"
@click.prevent="toggleTextInput" @click.prevent="[toggleTextInput(), hideQr()]"
> >
<IconTextSize :width="16" /> <IconTextSize :width="16" />
<span class="hidden sm:inline"> <span class="hidden sm:inline">
@@ -51,7 +54,7 @@
</a> </a>
</span> </span>
<span <span
v-if="field.preferences?.format !== 'typed'" v-if="field.preferences?.format !== 'typed' && field.preferences?.format !== 'drawn'"
class="tooltip" class="tooltip"
:data-tip="t('take_photo')" :data-tip="t('take_photo')"
> >
@@ -80,15 +83,23 @@
<IconReload :width="16" /> <IconReload :width="16" />
{{ t('redraw') }} {{ t('redraw') }}
</a> </a>
<a <span
v-else v-if="withQrButton && !modelValue && !computedPreviousValue && field.preferences?.format !== 'typed'"
href="#" class=" tooltip"
class="btn btn-outline btn-sm font-medium" :data-tip="t('drawn_signature_on_a_touchscreen_device')"
@click.prevent="clear"
> >
<IconReload :width="16" /> <a
{{ t('clear') }} href="#"
</a> class="btn btn-sm btn-neutral font-medium hidden md:flex"
:class="{ 'btn-outline': !isShowQr, 'text-white': isShowQr }"
@click.prevent="isShowQr ? hideQr() : [isTextSignature = false, showQr()]"
>
<IconQrcode
:width="19"
:height="19"
/>
</a>
</span>
<a <a
href="#" href="#"
:title="t('minimize')" :title="t('minimize')"
@@ -118,14 +129,61 @@
<img <img
v-if="modelValue || computedPreviousValue" v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url" :src="attachmentsIndex[modelValue || computedPreviousValue].url"
class="mx-auto bg-white border border-base-300 rounded max-h-72" class="mx-auto bg-white border border-base-300 rounded max-h-44"
> >
<canvas <div class="relative">
v-show="!modelValue && !computedPreviousValue" <div
ref="canvas" v-if="!modelValue && !computedPreviousValue && !isShowQr && !isTextSignature && isSignatureStarted"
style="padding: 1px; 0" class="absolute top-0.5 right-0.5"
class="bg-white border border-base-300 rounded-2xl w-full" >
/> <a
href="#"
class="btn btn-ghost font-medium btn-xs md:btn-sm"
@click.prevent="[clear(), hideQr()]"
>
<IconReload :width="16" />
{{ t('clear') }}
</a>
</div>
<canvas
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
style="padding: 1px; 0"
class="bg-white border border-base-300 rounded-2xl w-full"
/>
<div
v-if="isShowQr"
class="top-0 bottom-0 right-0 left-0 absolute bg-white rounded-2xl m-0.5"
/>
<div
v-if="isShowQr"
class="top-0 bottom-0 right-0 left-0 absolute bg-base-content/10 rounded-2xl"
>
<div
class="absolute top-1.5 right-1.5 tooltip"
>
<a
href="#"
class="btn btn-sm btn-circle btn-normal btn-outline"
@click.prevent="hideQr"
>
<IconX />
</a>
</div>
<div class="flex items-center justify-center w-full h-full p-4">
<div
class="bg-white p-4 rounded-xl h-full"
>
<canvas
ref="qrCanvas"
class="h-full"
width="132"
height="132"
/>
</div>
</div>
</div>
</div>
<input <input
v-if="isTextSignature" v-if="isTextSignature"
id="signature_text_input" id="signature_text_input"
@@ -136,15 +194,86 @@
type="text" type="text"
@input="updateWrittenSignature" @input="updateWrittenSignature"
> >
<select
v-if="requireSigningReason && !isOtherReason"
class="select base-input !text-2xl w-full mt-6 text-center"
required
:name="`values[${field.preferences.reason_field_uuid}]`"
@change="$event.target.value === 'other' ? [reason = '', isOtherReason = true] : $emit('update:reason', $event.target.value)"
>
<option
value=""
disabled
:selected="!reason"
>
{{ t('select_a_reason') }}
</option>
<option
v-for="(label, option) in defaultReasons"
:key="option"
:value="option"
:selected="reason === option"
>
{{ label }}
</option>
<option value="other">
{{ t('other') }}
</option>
</select>
<input
v-if="requireSigningReason && isOtherReason"
class="base-input !text-2xl w-full mt-6"
required
:name="`values[${field.preferences.reason_field_uuid}]`"
:placeholder="t('type_here_')"
:value="reason"
type="text"
@input="$emit('update:reason', $event.target.value)"
>
<input
v-if="requireSigningReason"
hidden
name="with_reason"
:value="field.preferences.reason_field_uuid"
>
<div
v-if="isShowQr"
dir="auto"
class="text-base-content/60 text-xs text-center w-full mt-1"
>
{{ t('scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature') }}
</div>
<div
v-else-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
v-else
class="mt-5 md:mt-7"
/>
</div> </div>
</template> </template>
<script> <script>
import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue' import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2, IconQrcode, IconX } from '@tabler/icons-vue'
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' import MarkdownContent from './markdown_content'
import { v4 } from 'uuid'
let isFontLoaded = false let isFontLoaded = false
@@ -156,7 +285,9 @@ export default {
AppearsOn, AppearsOn,
IconReload, IconReload,
IconCamera, IconCamera,
IconQrcode,
MarkdownContent, MarkdownContent,
IconX,
IconTextSize, IconTextSize,
IconSignature, IconSignature,
IconArrowsDiagonalMinimize2 IconArrowsDiagonalMinimize2
@@ -167,8 +298,13 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
submitterSlug: { requireSigningReason: {
type: String, type: Boolean,
required: false,
default: false
},
submitter: {
type: Object,
required: true required: true
}, },
showFieldNames: { showFieldNames: {
@@ -176,11 +312,36 @@ export default {
required: false, required: false,
default: true default: true
}, },
dryRun: {
type: Boolean,
required: false,
default: false
},
withDisclosure: {
type: Boolean,
required: false,
default: false
},
withQrButton: {
type: Boolean,
required: false,
default: false
},
buttonText: {
type: String,
required: false,
default: 'Submit'
},
withTypedSignature: { withTypedSignature: {
type: Boolean, type: Boolean,
required: false, required: false,
default: true default: true
}, },
rememberSignature: {
type: Boolean,
required: false,
default: false
},
attachmentsIndex: { attachmentsIndex: {
type: Object, type: Object,
required: false, required: false,
@@ -191,22 +352,39 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
reason: {
type: String,
required: false,
default: ''
},
modelValue: { modelValue: {
type: String, type: String,
required: false, required: false,
default: '' default: ''
} }
}, },
emits: ['attached', 'update:model-value', 'start', 'minimize'], emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason'],
data () { data () {
return { return {
isSignatureStarted: !!this.previousValue, isSignatureStarted: !!this.previousValue,
isShowQr: false,
isOtherReason: false,
isUsePreviousValue: true, isUsePreviousValue: true,
isTextSignature: this.field.preferences?.format === 'typed', isTextSignature: this.field.preferences?.format === 'typed',
uploadImageInputKey: Math.random().toString() uploadImageInputKey: Math.random().toString()
} }
}, },
computed: { computed: {
submitterSlug () {
return this.submitter.slug
},
defaultReasons () {
return {
[this.t('approved_by')]: this.t('approved'),
[this.t('reviewed_by')]: this.t('reviewed'),
[this.t('authored_by')]: this.t('authored_by_me')
}
},
computedPreviousValue () { computedPreviousValue () {
if (this.isUsePreviousValue) { if (this.isUsePreviousValue) {
return this.previousValue return this.previousValue
@@ -215,6 +393,13 @@ export default {
} }
} }
}, },
created () {
if (this.requireSigningReason) {
this.field.preferences ||= {}
this.field.preferences.reason_field_uuid ||= v4()
this.isOtherReason = this.reason && !this.defaultReasons[this.reason]
}
},
async mounted () { async mounted () {
this.$nextTick(() => { this.$nextTick(() => {
if (this.$refs.canvas) { if (this.$refs.canvas) {
@@ -228,7 +413,7 @@ export default {
if (this.$refs.canvas) { if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas) this.pad = new SignaturePad(this.$refs.canvas)
this.pad.addEventListener('beginStroke', () => { this.pad.addEventListener('endStroke', () => {
this.isSignatureStarted = true this.isSignatureStarted = true
this.$emit('start') this.$emit('start')
@@ -252,6 +437,7 @@ export default {
}, },
beforeUnmount () { beforeUnmount () {
this.intersectionObserver?.disconnect() this.intersectionObserver?.disconnect()
this.stopCheckSignature()
}, },
methods: { methods: {
remove () { remove () {
@@ -273,6 +459,55 @@ export default {
}) })
} }
}, },
showQr () {
this.isShowQr = true
this.$nextTick(() => {
import('qr-creator').then(({ default: Qr }) => {
if (this.$refs.qrCanvas) {
Qr.render({
text: `${document.location.origin}/p/${this.submitterSlug}?f=${this.field.uuid.split('-')[0]}`,
radius: 0.0,
ecLevel: 'H',
background: null,
size: 132
}, this.$refs.qrCanvas)
}
})
})
this.startCheckSignature()
},
hideQr () {
this.isShowQr = false
this.stopCheckSignature()
},
startCheckSignature () {
const after = JSON.stringify(new Date())
this.checkSignatureInterval = setInterval(() => {
this.checkSignature({ after })
}, 2000)
},
stopCheckSignature () {
if (this.checkSignatureInterval) {
clearInterval(this.checkSignatureInterval)
}
},
checkSignature (params = {}) {
return fetch(this.baseUrl + '/s/' + this.submitterSlug + '/values?field_uuid=' + this.field.uuid + '&after=' + params.after, {
method: 'GET'
}).then(async (resp) => {
const { attachment } = await resp.json()
if (attachment?.uuid) {
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
this.hideQr()
}
})
},
clear () { clear () {
this.pad.clear() this.pad.clear()
@@ -280,6 +515,7 @@ export default {
if (this.$refs.textInput) { if (this.$refs.textInput) {
this.$refs.textInput.value = '' this.$refs.textInput.value = ''
this.$refs.textInput.focus()
} }
}, },
updateWrittenSignature (e) { updateWrittenSignature (e) {
@@ -289,13 +525,28 @@ export default {
const context = canvas.getContext('2d') const context = canvas.getContext('2d')
const fontFamily = 'Dancing Script' const fontFamily = 'Dancing Script'
const fontSize = '38px' const initialFontSize = 44
const fontStyle = 'italic' const fontStyle = 'italic'
const fontWeight = '' const fontWeight = ''
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily const setFontSize = (size) => {
context.textAlign = 'center' context.font = `${fontStyle} ${fontWeight} ${size}px ${fontFamily}`
}
const adjustFontSizeToFit = (text, maxWidth, initialSize) => {
let size = initialSize
setFontSize(size)
while (context.measureText(text).width > maxWidth && size > 1) {
size -= 1
setFontSize(size)
}
}
adjustFontSizeToFit(e.target.value, canvas.width / scale, initialFontSize)
context.textAlign = 'center'
context.clearRect(0, 0, canvas.width / scale, canvas.height / scale) context.clearRect(0, 0, canvas.width / scale, canvas.height / scale)
context.fillText(e.target.value, canvas.width / 2 / scale, canvas.height / 2 / scale + 11) context.fillText(e.target.value, canvas.width / 2 / scale, canvas.height / 2 / scale + 11)
}, },
@@ -318,8 +569,11 @@ export default {
this.remove() this.remove()
this.isSignatureStarted = true this.isSignatureStarted = true
const file = event.target.files[0] this.drawOnCanvas(event.target.files[0], this.$refs.canvas)
this.uploadImageInputKey = Math.random().toString()
},
drawOnCanvas (file, canvas) {
if (file && file.type.match('image.*')) { if (file && file.type.match('image.*')) {
const reader = new FileReader() const reader = new FileReader()
@@ -329,7 +583,6 @@ export default {
img.src = event.target.result img.src = event.target.result
img.onload = () => { img.onload = () => {
const canvas = this.$refs.canvas
const context = canvas.getContext('2d') const context = canvas.getContext('2d')
const aspectRatio = img.width / img.height const aspectRatio = img.width / img.height
@@ -364,8 +617,27 @@ export default {
} }
reader.readAsDataURL(file) reader.readAsDataURL(file)
}
},
maybeSetSignedUuid (signedUuid) {
try {
if (window.localStorage && signedUuid && this.rememberSignature) {
const values = window.localStorage.getItem('signed_signature_uuids')
this.uploadImageInputKey = Math.random().toString() let data
if (values) {
data = JSON.parse(values)
} else {
data = {}
}
data[this.submitter.email] = signedUuid
window.localStorage.setItem('signed_signature_uuids', JSON.stringify(data))
}
} catch (e) {
console.error(e)
} }
}, },
async submit () { async submit () {
@@ -381,23 +653,45 @@ export default {
cropCanvasAndExportToPNG(this.$refs.canvas, { errorOnTooSmall: true }).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' })
const formData = new FormData() if (this.dryRun) {
const reader = new FileReader()
formData.append('file', file) reader.readAsDataURL(file)
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
return fetch(this.baseUrl + '/api/attachments', { reader.onloadend = () => {
method: 'POST', const attachment = { url: reader.result, uuid: Math.random().toString() }
body: formData
}).then((resp) => resp.json()).then((attachment) => {
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
return resolve(attachment) this.$emit('attached', attachment)
}) this.$emit('update:model-value', attachment.uuid)
resolve(attachment)
}
} else {
const formData = new FormData()
formData.append('file', file)
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
formData.append('remember_signature', this.rememberSignature)
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)
this.maybeSetSignedUuid(attachment.signed_uuid)
return resolve(attachment)
})
}
}).catch((error) => { }).catch((error) => {
return reject(error) if (error.message === 'Image too small' && this.field.required === false) {
return resolve({})
} else {
return reject(error)
}
}) })
}) })
} }
+4 -2
View File
@@ -10,7 +10,9 @@
:string="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>
<div <div
v-else v-else
@@ -145,7 +147,7 @@ export default {
const textarea = this.$refs.textarea const textarea = this.$refs.textarea
textarea.style.height = 'auto' textarea.style.height = 'auto'
textarea.style.height = textarea.scrollHeight + 'px' textarea.style.height = Math.min(250, textarea.scrollHeight) + 'px'
}, },
toggleTextArea () { toggleTextArea () {
this.isTextArea = true this.isTextArea = true
+377 -58
View File
@@ -3,13 +3,13 @@
class="absolute overflow-visible group" class="absolute overflow-visible group"
:style="positionStyle" :style="positionStyle"
@pointerdown.stop @pointerdown.stop
@mousedown.stop="startDrag" @mousedown="startMouseMove"
@touchstart="startTouchDrag" @touchstart="startTouchDrag"
> >
<div <div
v-if="isSelected || isDraw" v-if="isSelected || isDraw"
class="top-0 bottom-0 right-0 left-0 absolute border border-1.5 pointer-events-none" class="top-0 bottom-0 right-0 left-0 absolute border border-1.5 pointer-events-none"
:class="borderColors[submitterIndex]" :class="field.type === 'heading' ? '' : borderColors[submitterIndex]"
/> />
<div <div
v-if="field.type === 'cells' && (isSelected || isDraw)" v-if="field.type === 'cells' && (isSelected || isDraw)"
@@ -19,7 +19,7 @@
v-for="(cellW, index) in cells" v-for="(cellW, index) in cells"
:key="index" :key="index"
class="absolute top-0 bottom-0 border-r" class="absolute top-0 bottom-0 border-r"
:class="borderColors[submitterIndex]" :class="field.type === 'heading' ? '' : borderColors[submitterIndex]"
:style="{ left: (cellW / area.w * 100) + '%' }" :style="{ left: (cellW / area.w * 100) + '%' }"
> >
<span <span
@@ -31,14 +31,14 @@
</div> </div>
</div> </div>
<div <div
v-if="field?.type" v-if="field?.type && (isSelected || isNameFocus)"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap group-hover:flex group-hover:z-10" class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10"
:class="{ 'flex z-10': isNameFocus || isSelected, invisible: !isNameFocus && !isSelected }"
style="top: -25px; height: 25px" style="top: -25px; height: 25px"
@mousedown.stop @mousedown.stop
@pointerdown.stop @pointerdown.stop
> >
<FieldSubmitter <FieldSubmitter
v-if="field.type != 'heading'"
v-model="field.submitter_uuid" v-model="field.submitter_uuid"
class="border-r" class="border-r"
:compact="true" :compact="true"
@@ -61,19 +61,21 @@
<span <span
v-if="field.type !== 'checkbox' || field.name" v-if="field.type !== 'checkbox' || field.name"
ref="name" ref="name"
:contenteditable="editable && !defaultField" :contenteditable="editable && !defaultField && field.type !== 'heading'"
dir="auto" dir="auto"
class="pr-1 cursor-text outline-none block" class="pr-1 cursor-text outline-none block"
style="min-width: 2px" style="min-width: 2px"
@paste.prevent="onPaste"
@keydown.enter.prevent="onNameEnter" @keydown.enter.prevent="onNameEnter"
@focus="onNameFocus" @focus="onNameFocus"
@blur="onNameBlur" @blur="onNameBlur"
>{{ optionIndexText }} {{ (defaultField ? (field.title || field.name) : 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 || (isValueInput && field.type !== 'heading') || (isNameFocus && !['checkbox', 'phone'].includes(field.type))"
class="flex items-center ml-1.5" class="flex items-center ml-1.5"
> >
<input <input
v-if="!isValueInput"
:id="`required-checkbox-${field.uuid}`" :id="`required-checkbox-${field.uuid}`"
v-model="field.required" v-model="field.required"
type="checkbox" type="checkbox"
@@ -81,11 +83,69 @@
@mousedown.prevent @mousedown.prevent
> >
<label <label
v-if="!isValueInput"
:for="`required-checkbox-${field.uuid}`" :for="`required-checkbox-${field.uuid}`"
class="label text-xs" class="label text-xs"
@click.prevent="field.required = !field.required" @click.prevent="field.required = !field.required"
@mousedown.prevent @mousedown.prevent
>{{ t('required') }}</label> >{{ t('required') }}</label>
<input
v-if="isValueInput"
:id="`readonly-checkbox-${field.uuid}`"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
:checked="!(field.readonly ?? true)"
@change="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>
<label
v-if="isValueInput"
:for="`readonly-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>{{ t('editable') }}</label>
<span
v-if="field.type !== 'payment' && !isValueInput"
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"
@@ -97,23 +157,25 @@
</button> </button>
</div> </div>
<div <div
ref="touchValueTarget"
class="flex items-center h-full w-full" class="flex items-center h-full w-full"
dir="auto" dir="auto"
:class="[bgColors[submitterIndex], field?.default_value ? (alignClasses[field.preferences?.align] || '') : 'justify-center']" :class="[isValueInput ? 'bg-opacity-50' : 'bg-opacity-80', field.type === 'heading' ? 'bg-gray-50' : bgColors[submitterIndex], isDefaultValuePresent || isValueInput || (withFieldPlaceholder && field.areas) ? (alignClasses[field.preferences?.align] || '') : 'justify-center']"
@click="focusValueInput"
> >
<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"
:class="{'w-full h-full': field.type == 'checkbox'}" :class="{ 'w-full': ['cells', 'checkbox'].includes(field.type), 'h-full': !isValueInput }"
> >
<div <div
v-if="field?.default_value" v-if="isDefaultValuePresent || isValueInput || (withFieldPlaceholder && field.areas && field.type !== 'checkbox')"
:class="{ 'w-full h-full': field.type == 'checkbox', 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars }" :class="{ 'w-full h-full': ['cells', 'checkbox'].includes(field.type), 'text-[1.6vw] 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'}" :class="{ 'w-full h-full': ['cells', 'checkbox'].includes(field.type) }"
> >
<IconCheck <IconCheck
v-if="field.type == 'checkbox'" v-if="field.type == 'checkbox'"
@@ -121,8 +183,37 @@
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }" :class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
/> />
<span <span
v-else v-else-if="field.type === 'number' && !isValueInput && (field.default_value || field.default_value == 0)"
class="whitespace-pre-wrap" class="whitespace-pre-wrap"
>{{ formatNumber(field.default_value, field.preferences?.format) }}</span>
<span
v-else-if="field.default_value === '{{date}}'"
>
{{ t('signing_date') }}
</span>
<div
v-else-if="field.type === 'cells' && field.default_value"
class="w-full flex items-center"
>
<div
v-for="(char, index) in field.default_value"
:key="index"
class="text-center flex-none"
:style="{ width: (area.cell_w / area.w * 100) + '%' }"
>
{{ char }}
</div>
</div>
<span
v-else
ref="defaultValue"
:contenteditable="isValueInput"
class="whitespace-pre-wrap outline-none empty:before:content-[attr(placeholder)] before:text-gray-400"
:class="{ 'cursor-text': isValueInput }"
:placeholder="withFieldPlaceholder && !isValueInput ? field.name || defaultName : t('type_value')"
@blur="onDefaultValueBlur"
@paste.prevent="onPaste"
@keydown.enter="onDefaultValueEnter"
>{{ field.default_value }}</span> >{{ field.default_value }}</span>
</div> </div>
</div> </div>
@@ -136,8 +227,12 @@
</span> </span>
</div> </div>
<div <div
v-if="!isValueInput"
ref="touchTarget" ref="touchTarget"
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer" class="absolute top-0 bottom-0 right-0 left-0"
:class="isDragged ? 'cursor-grab' : 'cursor-pointer'"
@dblclick="maybeToggleDefaultValue"
@click="maybeToggleCheckboxValue"
/> />
<span <span
v-if="field?.type && editable" v-if="field?.type && editable"
@@ -145,6 +240,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>
@@ -152,7 +279,11 @@
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, IconCheck } 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 {
@@ -160,6 +291,11 @@ export default {
components: { components: {
FieldType, FieldType,
IconCheck, IconCheck,
FieldSettings,
FormulaModal,
IconDotsVertical,
DescriptionModal,
ConditionsModal,
FieldSubmitter, FieldSubmitter,
IconX IconX
}, },
@@ -169,6 +305,11 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
inputMode: {
type: Boolean,
required: false,
default: false
},
isDraw: { isDraw: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -179,6 +320,11 @@ export default {
required: false, required: false,
default: null default: null
}, },
withFieldPlaceholder: {
type: Boolean,
required: false,
default: false
},
defaultSubmitters: { defaultSubmitters: {
type: Array, type: Array,
required: false, required: false,
@@ -195,11 +341,18 @@ 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,
isContenteditable: false,
isSettingsFocus: false,
isShowDescriptionModal: false,
isResize: false, isResize: false,
isDragged: false, isDragged: false,
isMoved: false,
renderDropdown: false,
isNameFocus: false, isNameFocus: false,
textOverflowChars: 0, textOverflowChars: 0,
dragFrom: { x: 0, y: 0 } dragFrom: { x: 0, y: 0 }
@@ -208,6 +361,19 @@ export default {
computed: { computed: {
fieldNames: FieldType.computed.fieldNames, fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons, fieldIcons: FieldType.computed.fieldIcons,
isDefaultValuePresent () {
if (this.field?.type === 'radio' && this.field?.areas?.length > 1) {
return false
} else {
return this.field?.default_value || this.field?.default_value === 0
}
},
isValueInput () {
return (this.field.type === 'heading' && this.isSelected) || this.isContenteditable || (this.inputMode && ['text', 'number', 'date'].includes(this.field.type))
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
defaultName () { defaultName () {
return this.buildDefaultName(this.field, this.template.fields) return this.buildDefaultName(this.field, this.template.fields)
}, },
@@ -270,26 +436,26 @@ export default {
}, },
bgColors () { bgColors () {
return [ return [
'bg-red-100/80', 'bg-red-100',
'bg-sky-100/80', 'bg-sky-100',
'bg-emerald-100/80', 'bg-emerald-100',
'bg-yellow-100/80', 'bg-yellow-100',
'bg-purple-100/80', 'bg-purple-100',
'bg-pink-100/80', 'bg-pink-100',
'bg-cyan-100/80', 'bg-cyan-100',
'bg-orange-100/80', 'bg-orange-100',
'bg-lime-100/80', 'bg-lime-100',
'bg-indigo-100/80', 'bg-indigo-100',
'bg-red-100/80', 'bg-red-100',
'bg-sky-100/80', 'bg-sky-100',
'bg-emerald-100/80', 'bg-emerald-100',
'bg-yellow-100/80', 'bg-yellow-100',
'bg-purple-100/80', 'bg-purple-100',
'bg-pink-100/80', 'bg-pink-100',
'bg-cyan-100/80', 'bg-cyan-100',
'bg-orange-100/80', 'bg-orange-100',
'bg-lime-100/80', 'bg-lime-100',
'bg-indigo-100/80' 'bg-indigo-100'
] ]
}, },
isSelected () { isSelected () {
@@ -324,6 +490,62 @@ export default {
}, },
methods: { methods: {
buildDefaultName: Field.methods.buildDefaultName, buildDefaultName: Field.methods.buildDefaultName,
closeDropdown () {
document.activeElement.blur()
},
maybeToggleDefaultValue () {
if (['text', 'number'].includes(this.field.type)) {
this.isContenteditable = true
this.$nextTick(() => this.focusValueInput())
} else if (this.field.type === 'checkbox') {
this.field.readonly = !this.field.readonly
this.field.default_value === true ? delete this.field.default_value : this.field.default_value = true
this.save()
} else if (this.field.type === 'date') {
this.field.readonly = !this.field.readonly
this.field.default_value === '{{date}}' ? delete this.field.default_value : this.field.default_value = '{{date}}'
this.save()
}
},
maybeToggleCheckboxValue () {
if (this.inputMode && this.field.type === 'checkbox') {
this.field.readonly = !this.field.readonly
this.field.default_value === true ? delete this.field.default_value : this.field.default_value = true
this.save()
}
},
focusValueInput (e) {
if (this.$refs.defaultValue !== document.activeElement) {
this.$refs.defaultValue.focus()
if (this.$refs.defaultValue.innerText.length && this.$refs.defaultValue !== e?.target) {
window.getSelection().collapse(
this.$refs.defaultValue.firstChild,
this.$refs.defaultValue.innerText.length
)
}
}
},
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
@@ -350,6 +572,17 @@ export default {
this.save() this.save()
}, },
onPaste (e) {
const text = (e.clipboardData || window.clipboardData).getData('text/plain')
const selection = this.$el.getRootNode().getSelection()
if (selection.rangeCount) {
selection.deleteFromDocument()
selection.getRangeAt(0).insertNode(document.createTextNode(text))
selection.collapseToEnd()
}
},
onResizeCell (e) { onResizeCell (e) {
if (e.target.id === 'mask') { if (e.target.id === 'mask') {
const positionX = e.offsetX / (e.target.clientWidth - 1) const positionX = e.offsetX / (e.target.clientWidth - 1)
@@ -366,6 +599,10 @@ export default {
delete this.field.options delete this.field.options
} }
if (['heading'].includes(this.field.type)) {
this.field.readonly = true
}
if (['select', 'multiple', 'radio'].includes(this.field.type)) { if (['select', 'multiple', 'radio'].includes(this.field.type)) {
this.field.options ||= [{ value: '', uuid: v4() }] this.field.options ||= [{ value: '', uuid: v4() }]
} }
@@ -379,6 +616,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
@@ -393,6 +634,42 @@ export default {
this.save() this.save()
}, },
onDefaultValueBlur (e) {
const text = this.$refs.defaultValue.innerText.trim()
this.isContenteditable = false
if (text) {
if (this.field.type === 'number') {
const number = parseFloat(text)
if (number || number === 0) {
this.field.default_value = parseFloat(text)
}
} else {
this.field.default_value = text
}
if (![true, false].includes(this.field.readonly)) {
this.field.readonly = true
}
this.$refs.defaultValue.innerText = text
} else {
delete this.field.readonly
delete this.field.default_value
this.$refs.defaultValue.innerText = ''
}
this.save()
},
onDefaultValueEnter (e) {
if (this.field.type !== 'heading') {
e.preventDefault()
this.$refs.defaultValue.blur()
}
},
onNameEnter (e) { onNameEnter (e) {
this.$refs.name.blur() this.$refs.name.blur()
}, },
@@ -410,28 +687,12 @@ export default {
this.area.y = (e.offsetY - this.dragFrom.y) / e.target.clientHeight this.area.y = (e.offsetY - this.dragFrom.y) / e.target.clientHeight
} }
}, },
startDrag (e) {
this.selectedAreaRef.value = this.area
if (!this.editable) {
return
}
const rect = e.target.getBoundingClientRect()
this.dragFrom = { x: e.clientX - rect.left, y: e.clientY - rect.top }
this.$el.getRootNode().addEventListener('mousemove', this.drag)
this.$el.getRootNode().addEventListener('mouseup', this.stopDrag)
this.$emit('start-drag')
},
startTouchDrag (e) { startTouchDrag (e) {
if (e.target !== this.$refs.touchTarget) { if (e.target !== this.$refs.touchTarget && e.target !== this.$refs.touchValueTarget) {
return return
} }
this.$refs?.name?.blur() document.activeElement?.blur()
e.preventDefault() e.preventDefault()
@@ -449,11 +710,15 @@ export default {
this.$emit('start-drag') this.$emit('start-drag')
}, },
touchDrag (e) { touchDrag (e) {
if (!this.editable) {
return
}
const page = this.$parent.$refs.mask.previousSibling const page = this.$parent.$refs.mask.previousSibling
const rect = page.getBoundingClientRect() const rect = page.getBoundingClientRect()
this.area.x = (this.dragFrom.x + e.touches[0].clientX - rect.left) / rect.width this.area.x = Math.min(Math.max((this.dragFrom.x + e.touches[0].clientX - rect.left) / rect.width, 0), 1 - this.area.w)
this.area.y = (this.dragFrom.y + e.touches[0].clientY - rect.top) / rect.height this.area.y = Math.min(Math.max((this.dragFrom.y + e.touches[0].clientY - rect.top) / rect.height, 0), 1 - this.area.h)
}, },
stopTouchDrag () { stopTouchDrag () {
this.$el.getRootNode().removeEventListener('touchmove', this.touchDrag) this.$el.getRootNode().removeEventListener('touchmove', this.touchDrag)
@@ -467,6 +732,60 @@ export default {
this.$emit('stop-drag') this.$emit('stop-drag')
}, },
startMouseMove (e) {
if (e.target !== this.$refs.touchTarget && e.target !== this.$refs.touchValueTarget) {
return
}
if (document.activeElement !== this.$refs.defaultValue) {
document.activeElement?.blur()
}
e.preventDefault()
this.isDragged = true
const rect = e.target.getBoundingClientRect()
this.selectedAreaRef.value = this.area
if (this.field.type === 'heading') {
this.$nextTick(() => this.focusValueInput())
}
this.dragFrom = { x: rect.left - e.clientX, y: rect.top - e.clientY }
this.$el.getRootNode().addEventListener('mousemove', this.mouseMove)
this.$el.getRootNode().addEventListener('mouseup', this.stopMouseMove)
this.$emit('start-drag')
},
mouseMove (e) {
if (!this.editable) {
return
}
this.isMoved = true
const page = this.$parent.$refs.mask.previousSibling
const rect = page.getBoundingClientRect()
this.area.x = Math.min(Math.max((this.dragFrom.x + e.clientX - rect.left) / rect.width, 0), 1 - this.area.w)
this.area.y = Math.min(Math.max((this.dragFrom.y + e.clientY - rect.top) / rect.height, 0), 1 - this.area.h)
},
stopMouseMove (e) {
this.$el.getRootNode().removeEventListener('mousemove', this.mouseMove)
this.$el.getRootNode().removeEventListener('mouseup', this.stopMouseMove)
if (this.isMoved) {
this.save()
}
this.isDragged = false
this.isMoved = false
this.$emit('stop-drag')
},
stopDrag () { stopDrag () {
this.$el.getRootNode().removeEventListener('mousemove', this.drag) this.$el.getRootNode().removeEventListener('mousemove', this.drag)
this.$el.getRootNode().removeEventListener('mouseup', this.stopDrag) this.$el.getRootNode().removeEventListener('mouseup', this.stopDrag)
+397 -44
View File
@@ -3,6 +3,31 @@
style="max-width: 1600px" style="max-width: 1600px"
class="mx-auto pl-3 md:pl-4 h-full" class="mx-auto pl-3 md:pl-4 h-full"
> >
<div
v-if="pendingFieldAttachmentUuids.length"
class="top-1.5 sticky h-0 z-20 max-w-2xl mx-auto"
>
<div class="alert border-base-content/30 py-2 px-2.5">
<IconInfoCircle
class="stroke-info shrink-0 w-6 h-6"
/>
<span>{{ t('uploaded_pdf_contains_form_fields_keep_or_remove_them') }}</span>
<div>
<button
class="btn btn-sm"
@click.prevent="removePendingFields"
>
{{ t('remove') }}
</button>
<button
class="btn btn-sm btn-neutral text-white"
@click.prevent="save"
>
{{ t('keep') }}
</button>
</div>
</div>
</div>
<div <div
v-if="$slots.buttons || withTitle" v-if="$slots.buttons || withTitle"
id="title_container" id="title_container"
@@ -33,6 +58,7 @@
/> />
<template v-else> <template v-else>
<a <a
v-if="withSignYourselfButton"
:href="template.submitters.length > 1 ? `/templates/${template.id}/submissions/new?selfsign=true` : `/d/${template.slug}`" :href="template.submitters.length > 1 ? `/templates/${template.id}/submissions/new?selfsign=true` : `/d/${template.slug}`"
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'"
@@ -48,6 +74,7 @@
</span> </span>
</a> </a>
<a <a
v-if="withSendButton"
: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"
@@ -61,26 +88,66 @@
{{ t('send') }} {{ t('send') }}
</span> </span>
</a> </a>
<button <span
v-if="editable" v-if="editable"
class="base-button" class="flex"
:class="{ disabled: isSaving }"
v-bind="isSaving ? { disabled: true } : {}"
@click.prevent="onSaveClick"
> >
<IconInnerShadowTop <button
v-if="isSaving" class="base-button !rounded-r-none !pr-2"
width="22" :class="{ disabled: isSaving }"
class="animate-spin" v-bind="isSaving ? { disabled: true } : {}"
/> @click.prevent="onSaveClick"
<IconDeviceFloppy >
v-else <IconInnerShadowTop
width="22" v-if="isSaving"
/> width="22"
<span class="hidden md:inline"> class="animate-spin"
{{ t('save') }} />
</span> <IconDeviceFloppy
</button> v-else
width="22"
/>
<span class="hidden md:inline">
{{ t('save') }}
</span>
</button>
<div class="dropdown dropdown-end">
<label
tabindex="0"
class="base-button !rounded-l-none !pl-1 !pr-2 !border-l-neutral-500"
>
<span class="text-sm align-text-top">
<IconChevronDown class="w-5 h-5 flex-shrink-0" />
</span>
</label>
<ul
tabindex="0"
class="dropdown-content p-2 mt-2 shadow menu text-base bg-base-100 rounded-box text-right"
>
<li>
<a
:href="`/templates/${template.id}/form`"
data-turbo="false"
class="flex items-center justify-center space-x-2"
>
<IconEye class="w-6 h-6 flex-shrink-0" />
<span class="whitespace-nowrap">{{ t('save_and_preview') }}</span>
</a>
</li>
<li>
<a
:href="`/templates/${template.id}/preferences`"
data-turbo-frame="modal"
class="flex space-x-2"
@click="closeDropdown"
>
<IconAdjustments class="w-6 h-6 flex-shrink-0" />
<span class="whitespace-nowrap">{{ t('preferences') }}</span>
</a>
</li>
</ul>
</div>
</span>
<a <a
v-else v-else
:href="`/templates/${template.id}`" :href="`/templates/${template.id}`"
@@ -123,7 +190,7 @@
@change="save" @change="save"
/> />
<div <div
class="sticky bottom-0 py-2" class="sticky bottom-0 py-2 space-y-2"
:style="{ backgroundColor }" :style="{ backgroundColor }"
> >
<Upload <Upload
@@ -132,6 +199,22 @@
:template-id="template.id" :template-id="template.id"
@success="updateFromUpload" @success="updateFromUpload"
/> />
<button
v-if="sortedDocuments.length && editable && withAddPageButton"
id="add_blank_page_button"
class="btn btn-outline w-full"
@click.prevent="addBlankPage"
>
<IconInnerShadowTop
v-if="isLoadingBlankPage"
class="animate-spin w-5 h-5"
/>
<IconPlus
v-else
class="w-5 h-5"
/>
{{ t('add_blank_page') }}
</button>
</div> </div>
</div> </div>
<div <div
@@ -142,12 +225,30 @@
ref="documents" ref="documents"
class="pr-3.5 pl-0.5" class="pr-3.5 pl-0.5"
> >
<Dropzone <template v-if="!sortedDocuments.length && (withUploadButton || withAddPageButton)">
v-if="!sortedDocuments.length && withUploadButton" <Dropzone
:template-id="template.id" v-if="withUploadButton"
:accept-file-types="acceptFileTypes" :template-id="template.id"
@success="updateFromUpload" :accept-file-types="acceptFileTypes"
/> @success="updateFromUpload"
/>
<button
v-if="withAddPageButton"
id="add_blank_page_button"
class="btn btn-outline w-full mt-4"
@click.prevent="addBlankPage"
>
<IconInnerShadowTop
v-if="isLoadingBlankPage"
class="animate-spin w-5 h-5"
/>
<IconPlus
v-else
class="w-5 h-5"
/>
{{ t('add_blank_page') }}
</button>
</template>
<template v-else> <template v-else>
<template <template
v-for="document in sortedDocuments" v-for="document in sortedDocuments"
@@ -159,9 +260,11 @@
:selected-submitter="selectedSubmitter" :selected-submitter="selectedSubmitter"
:document="document" :document="document"
:is-drag="!!dragField" :is-drag="!!dragField"
:input-mode="inputMode"
:default-fields="[...defaultRequiredFields, ...defaultFields]" :default-fields="[...defaultRequiredFields, ...defaultFields]"
:allow-draw="!onlyDefinedFields" :allow-draw="!onlyDefinedFields"
:default-submitters="defaultSubmitters" :default-submitters="defaultSubmitters"
:with-field-placeholder="withFieldPlaceholder"
:draw-field="drawField" :draw-field="drawField"
:draw-field-type="drawFieldType" :draw-field-type="drawFieldType"
:editable="editable" :editable="editable"
@@ -188,7 +291,7 @@
</template> </template>
<div <div
v-if="sortedDocuments.length && isBreakpointLg && editable" v-if="sortedDocuments.length && isBreakpointLg && editable"
class="pb-4" class="pb-4 space-y-2"
> >
<Upload <Upload
v-if="withUploadButton" v-if="withUploadButton"
@@ -196,6 +299,22 @@
:accept-file-types="acceptFileTypes" :accept-file-types="acceptFileTypes"
@success="updateFromUpload" @success="updateFromUpload"
/> />
<button
v-if="withAddPageButton"
id="add_blank_page_button"
class="btn btn-outline w-full mt-4"
@click.prevent="addBlankPage"
>
<IconInnerShadowTop
v-if="isLoadingBlankPage"
class="animate-spin w-5 h-5"
/>
<IconPlus
v-else
class="w-5 h-5"
/>
{{ t('add_blank_page') }}
</button>
</div> </div>
</template> </template>
</div> </div>
@@ -223,7 +342,7 @@
{{ t('cancel') }} {{ t('cancel') }}
</button> </button>
<a <a
v-if="!drawField && !drawOption && !['stamp', 'signature', 'initials'].includes(drawField?.type || drawFieldType)" v-if="!drawField && !drawOption && !['stamp', 'signature', 'initials', 'heading'].includes(drawField?.type || drawFieldType)"
href="#" href="#"
class="link block mt-3 text-sm" class="link block mt-3 text-sm"
@click.prevent="[addField(drawFieldType), drawField = null, drawOption = null, withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]" @click.prevent="[addField(drawFieldType), drawField = null, drawOption = null, withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]"
@@ -296,7 +415,7 @@ import Contenteditable from './contenteditable'
import DocumentPreview from './preview' import DocumentPreview from './preview'
import DocumentControls from './controls' import DocumentControls from './controls'
import MobileFields from './mobile_fields' import MobileFields from './mobile_fields'
import { IconUsersPlus, IconDeviceFloppy, IconWritingSign, IconInnerShadowTop } from '@tabler/icons-vue' import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments } from '@tabler/icons-vue'
import { v4 } from 'uuid' import { v4 } from 'uuid'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { en as i18nEn } from './i18n' import { en as i18nEn } from './i18n'
@@ -307,7 +426,9 @@ export default {
Upload, Upload,
Document, Document,
Fields, Fields,
IconInfoCircle,
MobileDrawField, MobileDrawField,
IconPlus,
IconWritingSign, IconWritingSign,
MobileFields, MobileFields,
Logo, Logo,
@@ -317,6 +438,9 @@ export default {
IconInnerShadowTop, IconInnerShadowTop,
Contenteditable, Contenteditable,
IconUsersPlus, IconUsersPlus,
IconChevronDown,
IconAdjustments,
IconEye,
IconDeviceFloppy IconDeviceFloppy
}, },
provide () { provide () {
@@ -325,6 +449,7 @@ export default {
save: this.save, save: this.save,
t: this.t, t: this.t,
currencies: this.currencies, currencies: this.currencies,
locale: this.locale,
baseFetch: this.baseFetch, baseFetch: this.baseFetch,
fieldTypes: this.fieldTypes, fieldTypes: this.fieldTypes,
backgroundColor: this.backgroundColor, backgroundColor: this.backgroundColor,
@@ -348,21 +473,51 @@ export default {
required: false, required: false,
default: () => ({}) default: () => ({})
}, },
withFieldPlaceholder: {
type: Boolean,
required: false,
default: false
},
backgroundColor: { backgroundColor: {
type: String, type: String,
required: false, required: false,
default: '' default: ''
}, },
locale: {
type: String,
required: false,
default: ''
},
editable: { editable: {
type: Boolean, type: Boolean,
required: false, required: false,
default: true default: true
}, },
withSendButton: {
type: Boolean,
required: false,
default: true
},
withSignYourselfButton: {
type: Boolean,
required: false,
default: true
},
inputMode: {
type: Boolean,
required: false,
default: false
},
withHelp: { withHelp: {
type: Boolean, type: Boolean,
required: false, required: false,
default: true default: true
}, },
withAddPageButton: {
type: Boolean,
required: false,
default: false
},
autosave: { autosave: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -509,10 +664,13 @@ export default {
return { return {
documentRefs: [], documentRefs: [],
isBreakpointLg: false, isBreakpointLg: false,
isLoadingBlankPage: false,
isSaving: false, isSaving: false,
selectedSubmitter: null, selectedSubmitter: null,
showDrawField: false, showDrawField: false,
pendingFieldAttachmentUuids: [],
drawField: null, drawField: null,
copiedArea: null,
drawFieldType: null, drawFieldType: null,
drawOption: null, drawOption: null,
dragField: null dragField: null
@@ -521,6 +679,20 @@ export default {
computed: { computed: {
selectedAreaRef: () => ref(), selectedAreaRef: () => ref(),
fieldsDragFieldRef: () => ref(), fieldsDragFieldRef: () => ref(),
defaultDateFormat () {
const isUsBrowser = Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US')
const isUsTimezone = new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).format(new Date()).match(/\s(?:CST|CDT|PST|PDT|EST|EDT)$/)
return this.localeDateFormats[this.locale] || ((isUsBrowser || isUsTimezone) ? 'MM/DD/YYYY' : 'DD/MM/YYYY')
},
localeDateFormats () {
return {
'de-DE': 'DD.MM.YYYY',
'fr-FR': 'DD/MM/YYYY',
'it-IT': 'DD/MM/YYYY',
'es-ES': 'DD/MM/YYYY'
}
},
fieldAreasIndex () { fieldAreasIndex () {
const areas = {} const areas = {}
@@ -593,6 +765,12 @@ export default {
document.querySelector('form[action="/auth/stripe_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus() document.querySelector('form[action="/auth/stripe_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus()
} }
}) })
this.template.schema.forEach((item) => {
if (item.pending_fields) {
this.pendingFieldAttachmentUuids.push(item.attachment_uuid)
}
})
}, },
unmounted () { unmounted () {
document.removeEventListener('keyup', this.onKeyUp) document.removeEventListener('keyup', this.onKeyUp)
@@ -604,9 +782,19 @@ export default {
this.documentRefs = [] this.documentRefs = []
}, },
methods: { methods: {
closeDropdown () {
document.activeElement.blur()
},
t (key) { t (key) {
return this.i18n[key] || i18nEn[key] || key return this.i18n[key] || i18nEn[key] || key
}, },
removePendingFields () {
this.template.fields = this.template.fields.filter((f) => {
return this.template.schema.find((item) => item.attachment_uuid === f.attachment_uuid && item.pending_fields)
})
this.save()
},
addField (type, area = null) { addField (type, area = null) {
const field = { const field = {
name: '', name: '',
@@ -627,7 +815,7 @@ export default {
if (type === 'date') { if (type === 'date') {
field.preferences = { field.preferences = {
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY' format: this.defaultDateFormat
} }
} }
@@ -660,7 +848,7 @@ export default {
if (type === 'date') { if (type === 'date') {
field.preferences = { field.preferences = {
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY' format: this.defaultDateFormat
} }
} }
@@ -747,8 +935,58 @@ export default {
event.preventDefault() event.preventDefault()
this.undo() this.undo()
} else if ((event.ctrlKey || event.metaKey) && event.key === 'c' && document.activeElement === document.body) {
event.preventDefault()
this.copiedArea = this.selectedAreaRef?.value
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v' && this.copiedArea && document.activeElement === document.body) {
event.preventDefault()
this.pasteField()
} else if (this.selectedAreaRef.value && ['ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown'].includes(event.key) && document.activeElement === document.body) {
event.preventDefault()
this.handleAreaArrows(event)
} }
}, },
handleAreaArrows (event) {
if (!this.editable) {
return
}
const area = this.selectedAreaRef.value
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
const page = documentRef.pageRefs[area.page].$refs.image
const rect = page.getBoundingClientRect()
const diff = (event.shiftKey ? 5.0 : 1.0)
if (event.key === 'ArrowRight' && event.altKey) {
area.w = Math.min(Math.max(area.w + diff / rect.width, 0), 1 - area.x)
} else if (event.key === 'ArrowLeft' && event.altKey) {
area.w = Math.min(Math.max(area.w - diff / rect.width, 0), 1 - area.x)
} else if (event.key === 'ArrowUp' && event.altKey) {
area.h = Math.min(Math.max(area.h - diff / rect.height, 0), 1 - area.y)
} else if (event.key === 'ArrowDown' && event.altKey) {
area.h = Math.min(Math.max(area.h + diff / rect.height, 0), 1 - area.y)
} else if (event.key === 'ArrowRight') {
area.x = Math.min(Math.max(area.x + diff / rect.width, 0), 1 - area.w)
} else if (event.key === 'ArrowLeft') {
area.x = Math.min(Math.max(area.x - diff / rect.width, 0), 1 - area.w)
} else if (event.key === 'ArrowUp') {
area.y = Math.min(Math.max(area.y - diff / rect.height, 0), 1 - area.h)
} else if (event.key === 'ArrowDown') {
area.y = Math.min(Math.max(area.y + diff / rect.height, 0), 1 - area.h)
}
this.debouncedSave()
},
debouncedSave () {
clearTimeout(this._saveTimeout)
this._saveTimeout = setTimeout(() => {
this.save()
}, 700)
},
removeArea (area) { removeArea (area) {
const field = this.template.fields.find((f) => f.areas?.includes(area)) const field = this.template.fields.find((f) => f.areas?.includes(area))
@@ -760,6 +998,39 @@ export default {
this.save() this.save()
}, },
pasteField () {
const field = this.template.fields.find((f) => f.areas?.includes(this.copiedArea))
const currentArea = this.selectedAreaRef?.value || this.copiedArea
if (field && currentArea) {
const area = {
...JSON.parse(JSON.stringify(this.copiedArea)),
attachment_uuid: currentArea.attachment_uuid,
page: currentArea.page,
x: currentArea.x,
y: currentArea.y + currentArea.h * 1.3
}
if (['radio', 'multiple'].includes(field.type)) {
this.copiedArea.option_uuid ||= field.options[0].uuid
area.option_uuid = v4()
field.options.push({ uuid: area.option_uuid })
field.areas.push(area)
} else {
this.template.fields.push({
...JSON.parse(JSON.stringify(field)),
uuid: v4(),
areas: [area]
})
}
this.selectedAreaRef.value = area
this.save()
}
},
pushUndo () { pushUndo () {
const stringData = JSON.stringify(this.template) const stringData = JSON.stringify(this.template)
@@ -857,7 +1128,7 @@ export default {
type = this.fieldTypes[0] type = this.fieldTypes[0]
} }
if (type === 'checkbox' && !this.drawFieldType) { 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]
@@ -908,13 +1179,13 @@ export default {
field.options = [{ value: '', uuid: v4() }] field.options = [{ value: '', uuid: v4() }]
} }
if (field.type === 'stamp') { if (['stamp', 'heading'].includes(field.type)) {
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: this.defaultDateFormat
} }
} }
} }
@@ -990,6 +1261,46 @@ export default {
} }
this.save() this.save()
document.activeElement?.blur()
if (field.type === 'heading') {
this.$nextTick(() => {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
const areaRef = documentRef.pageRefs[area.page].areaRefs.find((ref) => ref.area === this.selectedAreaRef.value)
areaRef.focusValueInput()
})
}
},
addBlankPage () {
this.isLoadingBlankPage = true
const canvas = document.createElement('canvas')
canvas.width = 816
canvas.height = 1056
const ctx = canvas.getContext('2d')
ctx.fillStyle = 'white'
ctx.fillRect(0, 0, canvas.width, canvas.height)
canvas.toBlob((blob) => {
const file = new File([blob], `Page ${this.template.schema.length + 1}.png`, { type: blob.type, lastModified: Date.now() })
const formData = new FormData()
formData.append('files[]', file)
this.baseFetch(`/templates/${this.template.id}/documents`, {
method: 'POST',
body: formData
}).then(async (resp) => {
this.updateFromUpload(await resp.json())
}).finally(() => {
this.isLoadingBlankPage = false
})
}, 'image/png')
}, },
updateFromUpload (data) { updateFromUpload (data) {
this.template.schema.push(...data.schema) this.template.schema.push(...data.schema)
@@ -1022,6 +1333,18 @@ export default {
} }
this.save() this.save()
data.documents.forEach((attachment) => {
if (attachment.metadata?.pdf?.fields?.length) {
this.pendingFieldAttachmentUuids.push(attachment.uuid)
attachment.metadata.pdf.fields.forEach((field) => {
field.submitter_uuid = this.selectedSubmitter.uuid
this.template.fields.push(field)
})
}
})
}, },
updateName (value) { updateName (value) {
this.template.name = value this.template.name = value
@@ -1116,30 +1439,58 @@ export default {
if (!this.isAllRequiredFieldsAdded) { if (!this.isAllRequiredFieldsAdded) {
e.preventDefault() e.preventDefault()
return alert(this.t('add_all_required_fields_to_continue')) 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()
alert('Please draw fields to prepare the document.') alert('Please draw fields to prepare the document.')
} else {
const submitterWithoutFields =
this.template.submitters.find((submitter) => !this.template.fields.some((f) => f.submitter_uuid === submitter.uuid))
if (submitterWithoutFields) {
e.preventDefault()
alert(`Please add fields for the ${submitterWithoutFields.name}. Or, remove the ${submitterWithoutFields.name} if not needed.`)
}
} }
}, },
onSaveClick () { onSaveClick () {
if (!this.isAllRequiredFieldsAdded) { if (!this.isAllRequiredFieldsAdded) {
return alert(this.t('add_all_required_fields_to_continue')) 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.save().then(() => {
window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => {
this.isSaving = false
})
} else {
alert('Please draw fields to prepare the document.') alert('Please draw fields to prepare the document.')
} else {
const submitterWithoutFields =
this.template.submitters.find((submitter) => !this.template.fields.some((f) => f.submitter_uuid === submitter.uuid))
if (submitterWithoutFields) {
alert(`Please add fields for the ${submitterWithoutFields.name}. Or, remove the ${submitterWithoutFields.name} if not needed.`)
} else {
this.isSaving = true
this.save().then(() => {
window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => {
this.isSaving = false
})
}
} }
}, },
scrollToArea (area) { scrollToArea (area) {
@@ -1160,6 +1511,8 @@ export default {
}) })
}, },
save ({ force } = { force: false }) { save ({ force } = { force: false }) {
this.pendingFieldAttachmentUuids = []
if (this.onChange) { if (this.onChange) {
this.onChange(this.template) this.onChange(this.template)
} }
Loaded 100 of 369 files, more files were not shown because too many files have changed in this diff. Show more