Compare commits

..
162 Commits
Author SHA1 Message Date
Pete Matsyburka ac6d5a7aed update readme 2024-04-08 01:18:31 +03:00
Pete Matsyburka 3166a31b67 add icon 2024-04-08 01:02:43 +03:00
Pete Matsyburka fc3283eb3a add Korean i18n 2024-04-08 00:33:54 +03:00
Pete Matsyburka d6a96d4aa3 fix base64 unicode 2024-04-07 18:06:26 +03:00
Pete Matsyburka cda349d71c adjust send email views 2024-04-07 17:58:18 +03:00
Pete Matsyburka 87345a3b3d fix rubocop 2024-04-07 11:14:34 +03:00
Pete Matsyburka b791d0699c fix replace button file types 2024-04-06 21:57:35 +03:00
Pete Matsyburka 2cb15f3282 optimize document upload 2024-04-06 21:57:34 +03:00
Pete Matsyburka 8b823ca0d0 do not fix gob tld 2024-04-06 19:41:17 +03:00
Pete Matsyburka f3f08797f7 optimize document upload 2024-04-06 17:08:45 +03:00
Pete Matsyburka cab9672d25 remove direct upload 2024-04-06 15:00:30 +03:00
Pete Matsyburka c8d3b1bace reorder settings 2024-04-06 12:56:05 +03:00
Pete Matsyburka 5ee51b2b57 fix testing account 2024-04-06 01:20:02 +03:00
Pete Matsyburka 525dc503a6 accept default value array 2024-04-06 01:02:24 +03:00
Pete Matsyburka 12cf28792b add uuid to accounts 2024-04-05 19:56:34 +03:00
Pete Matsyburka 33466c1d07 adjust sso 2024-04-05 19:28:57 +03:00
Pete Matsyburka 226ae2172e word break underscore 2024-04-05 19:14:02 +03:00
Pete Matsyburka 8e2700b8b8 break long titles 2024-04-05 16:44:17 +03:00
Pete Matsyburka 753cbd4d27 allow to add custom form completed message 2024-04-05 16:16:30 +03:00
Pete Matsyburka f16405ca70 assign first party i18n name 2024-04-05 02:21:14 +03:00
Pete Matsyburka 23f052c400 add Korean 2024-04-05 02:02:23 +03:00
Pete Matsyburka 90a0a03e15 validate custom certs in the cloud 2024-04-04 14:08:14 +03:00
Pete Matsyburka 2dba8ae366 add submissions dashboard view 2024-04-04 13:45:11 +03:00
Pete Matsyburka 5bb7ad571c display default date only of the signing party 2024-04-03 10:27:19 +03:00
Pete Matsyburka 0cdb7c2c4f fix date normalization 2024-04-02 11:30:52 +03:00
Pete Matsyburka 11bdc522cd add spreadsheet download 2024-04-01 17:56:08 +03:00
Pete Matsyburka 014835f90d adjust download button state 2024-04-01 15:15:55 +03:00
Pete Matsyburka 05e66b60ee fix result generation with annots 2024-04-01 13:16:54 +03:00
Pete Matsyburka 505b185cf2 fix download indicator 2024-03-31 18:31:09 +03:00
Pete Matsyburka 50c32ac6ff replace date value 2024-03-31 18:20:33 +03:00
Pete Matsyburka 878ab25fd4 access account via submission 2024-03-31 17:40:51 +03:00
Pete Matsyburka 1b31772ff6 show default date on the page 2024-03-31 17:34:23 +03:00
Pete Matsyburka d4940234b7 add bulk placeholder 2024-03-30 23:36:40 +02:00
Pete Matsyburka 9bdc5d4863 adjust batch limit 2024-03-30 14:42:42 +02:00
Pete Matsyburka a73fd7929c add smtp timeout 2024-03-30 12:43:15 +02:00
Pete Matsyburka 8269a61751 adjust payment connected 2024-03-29 23:24:51 +02:00
Pete Matsyburka de072d1437 scroll to multiple checkbox field 2024-03-29 21:43:13 +02:00
Pete Matsyburka 5b24793f2b render links with markdown in emails 2024-03-29 21:21:04 +02:00
Pete Matsyburka 660f7ab8f3 password reset neutral on-prem 2024-03-29 14:50:18 +02:00
Pete Matsyburka 6e3fea5fa0 validate email param 2024-03-28 11:25:32 +02:00
Pete Matsyburka 29ecbecbc2 flatten annots 2024-03-27 18:24:41 +02:00
Pete Matsyburka ad3bbe9892 sctoll to radio group 2024-03-27 18:24:35 +02:00
Pete Matsyburka b570ebee69 remember builder selected field type 2024-03-27 14:21:30 +02:00
Pete Matsyburka 207780fa3f clear fields on remove doc 2024-03-27 00:11:10 +02:00
Pete Matsyburka 8aa219433b render blank option checkbox 2024-03-26 19:28:41 +02:00
Pete Matsyburka cb1e9f2be4 do not send completed emails to integration 2024-03-26 18:01:35 +02:00
Pete Matsyburka 202943f58d fix pidfile issue 2024-03-26 01:17:55 +02:00
Pete Matsyburka 6dd15b2091 use letter page size for us 2024-03-25 20:20:38 +02:00
Pete Matsyburka 1c55e4f18b always display default value 2024-03-25 19:16:12 +02:00
Pete Matsyburka 6cc36b1728 allow to update template roles 2024-03-25 17:37:46 +02:00
Pete Matsyburka 37751cd5c1 update template submitters 2024-03-25 16:34:45 +02:00
Pete Matsyburka 80d882cd20 add reply to param 2024-03-24 14:09:28 +02:00
Pete Matsyburka 8bef75e570 move autocomplete controllers 2024-03-23 23:52:50 +02:00
Pete Matsyburka 077eab7005 refactor templates controller 2024-03-23 23:52:49 +02:00
Pete Matsyburka 715c70d422 rename to users 2024-03-23 21:23:59 +02:00
Pete Matsyburka a3c23f9d53 update readme 2024-03-22 17:35:52 +02:00
Pete Matsyburka c1e67a8af0 add submission id var 2024-03-22 13:11:33 +02:00
Pete Matsyburka 2fdc294293 fix annot 2024-03-21 16:25:49 +02:00
Pete Matsyburka 1f49ba069e disable puma pidfile 2024-03-21 15:03:50 +02:00
Pete Matsyburka 43ef0fc0d0 use FORCE_SSL=false 2024-03-21 14:12:11 +02:00
Pete Matsyburka a1a920e536 allow to remove default value 2024-03-21 00:04:53 +02:00
Pete Matsyburka dec33b4331 add extra small field font size 2024-03-20 16:52:53 +02:00
Pete Matsyburka 432f290633 assign template fields on completed via API 2024-03-20 00:13:16 +02:00
Pete Matsyburka cf98616a27 remove log 2024-03-19 13:57:21 +02:00
Pete Matsyburka 1a720cf1ad allow to clone template from testing 2024-03-19 01:44:16 +02:00
Pete Matsyburka 557c6c34f2 search default fields 2024-03-19 01:21:44 +02:00
Pete Matsyburka 943c76505d remove api error log 2024-03-18 23:18:35 +02:00
Pete Matsyburka 66eb83e6a7 allow to search on value 2024-03-18 20:16:26 +02:00
Pete Matsyburka 0844427357 add rollbar queue 2024-03-18 19:35:19 +02:00
Pete Matsyburka 72e9d9ea2d update readme 2024-03-17 23:16:27 +02:00
Pete Matsyburka 3db7d47746 fix option remove 2024-03-17 19:40:30 +02:00
Pete Matsyburka 95899336b8 align number field 2024-03-17 19:06:00 +02:00
Pete Matsyburka f6691c110e adjust field settings 2024-03-17 17:04:39 +02:00
Pete Matsyburka 749722a9df add rate limit 2024-03-16 23:22:11 +02:00
Pete Matsyburka 265b668cb4 show bulk send message 2024-03-16 22:21:23 +02:00
Pete Matsyburka 0527e6a5c1 fix form font size 2024-03-16 01:59:00 +02:00
Pete Matsyburka d989b61d19 fix font size 2024-03-15 23:06:16 +02:00
Pete Matsyburka c1f3e6ff13 reduce image size 2024-03-15 21:16:30 +02:00
Pete Matsyburka 3c0fff2d82 skip on missing font character error 2024-03-15 20:24:12 +02:00
Pete Matsyburka bd599c264f rescue Encoding::CompatibilityError 2024-03-15 13:44:16 +02:00
Pete Matsyburka 0274093c89 save multiple document attachments 2024-03-15 13:03:10 +02:00
Pete Matsyburka 5845b5bdfd process malformed PDF 2024-03-15 12:17:52 +02:00
Pete Matsyburka 3dd0ba6b0b fix pdf font width 2024-03-14 23:10:21 +02:00
Pete Matsyburka e79306f8a3 optimize build annotations 2024-03-14 21:30:57 +02:00
Pete Matsyburka 0171b0c2dd allow to disable stamp logo 2024-03-14 14:09:43 +02:00
Pete Matsyburka a963ef53d3 adjust submitter role assign 2024-03-14 01:21:42 +02:00
Pete Matsyburka 344f302c76 refactor field validation 2024-03-13 20:25:41 +02:00
Pete Matsyburka 17ded282b2 add symbols font 2024-03-13 14:58:32 +02:00
Pete Matsyburka 2ce57df485 sanitize href 2024-03-13 11:54:56 +02:00
Pete Matsyburka 849289287e add html escape replace variable option 2024-03-12 23:47:58 +02:00
Pete Matsyburka 704fbc2393 try get log params 2024-03-11 21:34:59 +02:00
Pete Matsyburka 168fc8bd03 fix testing acocunt 2024-03-11 14:55:50 +02:00
Pete Matsyburka c5598d9efe fix replace email vars 2024-03-11 10:05:43 +02:00
Pete Matsyburka f02315726d error on too small signature 2024-03-10 19:55:45 +02:00
Pete Matsyburka e56dcb59c0 add cors headers on API error 2024-03-10 15:54:44 +02:00
Pete Matsyburka c74f350ea2 fix typo 2024-03-10 12:03:46 +02:00
Pete Matsyburka af9d015cf2 refactor mailer custom content 2024-03-10 00:41:18 +02:00
Pete Matsyburka 633e5ac5e4 allow to disable confetti 2024-03-10 00:13:35 +02:00
Pete Matsyburka 2d0f323854 adjust sender email var 2024-03-09 22:55:55 +02:00
Pete Matsyburka cfe5610ec9 add field description and title 2024-03-09 22:55:54 +02:00
Pete Matsyburka 55616ac321 sort field areas 2024-03-08 00:34:16 +02:00
Pete Matsyburka 8f2d1deb38 do not show console link on testing 2024-03-07 21:36:41 +02:00
Pete Matsyburka e2763ceb80 add builder selectors 2024-03-07 19:01:34 +02:00
Pete Matsyburka 10462f3140 fix existing submitter uuids 2024-03-07 16:28:14 +02:00
Pete Matsyburka 3aef92486b silence active storage error 2024-03-06 23:14:07 +02:00
Pete Matsyburka c1a9ee0bbb return error on file download 400+ resp 2024-03-06 22:36:15 +02:00
Pete Matsyburka 398fae82cb optimize api pagination 2024-03-06 18:58:27 +02:00
Pete Matsyburka a16443f961 add completed at submitter filter 2024-03-06 18:44:13 +02:00
Pete Matsyburka ad4e0e1d27 adjust email variables 2024-03-06 00:27:16 +02:00
Pete Matsyburka 493a2108e9 fix safari builder scaling 2024-03-05 20:53:54 +02:00
Pete Matsyburka c2c9bb64b1 fix audit log original sha 2024-03-05 17:16:18 +02:00
Pete Matsyburka bb1426c0af log generation error 2024-03-05 13:56:47 +02:00
Pete Matsyburka 2505768e4d SMTP auto option 2024-03-04 22:30:07 +02:00
Pete Matsyburka 23a1b70534 turbo false console links 2024-03-03 21:21:27 +02:00
Pete Matsyburka b34616ecbc fix default date value 2024-03-03 11:50:22 +02:00
Pete Matsyburka 0111252f6f add timeserver spec info 2024-03-02 23:16:43 +02:00
Pete Matsyburka a561cc5f73 adjust invalid date error 2024-03-02 19:50:58 +02:00
Pete Matsyburka 519e6f1f79 cleanup 2024-03-02 12:09:35 +02:00
Pete Matsyburka da871b5d3a adjust builder page numbers 2024-03-02 11:24:04 +02:00
Pete Matsyburka ae3f939d23 add field conditions placeholder 2024-03-02 11:13:39 +02:00
Pete Matsyburka fb6ede564e retry webhooks on faraday error 2024-03-01 18:51:11 +02:00
Pete Matsyburka 1c701d4fe8 adjust FR i18n 2024-03-01 16:34:11 +02:00
Pete Matsyburka c743cc689d validate account config keys 2024-02-29 23:59:49 +02:00
Pete Matsyburka 8432bec3d6 fix result generation with cell 2024-02-29 21:39:52 +02:00
Pete Matsyburka 8e72b0b0e4 show testing shared templates 2024-02-29 21:39:46 +02:00
Pete Matsyburka 19e4bbc01e parse emails user 2024-02-28 21:54:27 +02:00
Pete Matsyburka 56a9e412c8 autocomplete debounce 2024-02-28 14:13:23 +02:00
Pete Matsyburka 4b75623b86 add index on blob checksum 2024-02-28 10:35:51 +02:00
Pete Matsyburka e1ea42a997 add index on submission events 2024-02-28 09:49:59 +02:00
Pete Matsyburka ee37710e57 adjust expand form 2024-02-28 01:54:10 +02:00
Pete Matsyburka ae99218789 adjust field options preview mode 2024-02-28 00:40:42 +02:00
Pete Matsyburka 9fe8214ca0 adjust blobs proxy 2024-02-28 00:40:19 +02:00
Pete Matsyburka 6ca44a5cc3 optimize autocomplete 2024-02-27 15:24:28 +02:00
Pete Matsyburka 801abd197b optimize account submissions assoc 2024-02-27 13:29:18 +02:00
Pete Matsyburka 970977e469 allow to drag existing field on the page 2024-02-27 01:41:57 +02:00
Pete Matsyburka f83674b934 share template only hosted 2024-02-26 18:02:24 +02:00
Pete Matsyburka 57c7dc1b5e adjust template clone response 2024-02-25 22:53:35 +02:00
Pete Matsyburka e9277f4eeb use node 20 in GH actions 2024-02-25 21:17:50 +02:00
Pete Matsyburka 4052b6fe13 puma pid /dev/null 2024-02-25 21:15:00 +02:00
Pete Matsyburka 608347b1e6 adjust pdf processor 2024-02-25 17:19:51 +02:00
Pete Matsyburka 345fe674d4 store original doc uuid in metadata 2024-02-25 16:25:04 +02:00
Pete Matsyburka 92f8901a67 better log 2024-02-25 13:11:13 +02:00
Pete Matsyburka fcd40308ee allow to resent copy email 2024-02-25 12:53:36 +02:00
Pete Matsyburka 2bd023e5d0 fix i18n 2024-02-25 00:33:34 +02:00
Pete Matsyburka a697d6330e allow to disable completed submission emails 2024-02-25 00:08:59 +02:00
Pete Matsyburka ab56e8be20 retry webhooks on timeout 2024-02-24 20:24:43 +02:00
Pete Matsyburka b9a906b569 optimize templates api 2024-02-24 19:59:50 +02:00
Pete Matsyburka 02adbae758 add expand prop 2024-02-24 17:35:38 +02:00
Pete Matsyburka 866aab7fac fix submission preview page 2024-02-24 15:36:57 +02:00
Pete Matsyburka c81b4d855b check signed blob data purpose 2024-02-24 13:04:14 +02:00
Pete Matsyburka 4ad58fc285 not found on wrong blob signature 2024-02-24 13:00:09 +02:00
Pete Matsyburka 8227dd4f6a add formula placeholder 2024-02-24 12:04:08 +02:00
Pete Matsyburka beb677734f do not skip required checkboxes 2024-02-23 19:38:53 +02:00
Pete Matsyburka 03db636d51 add number field type 2024-02-23 15:58:49 +02:00
Pete Matsyburka b83abc9499 fix copy field to multiple pages 2024-02-23 10:50:53 +02:00
Pete Matsyburka 9738cdcf8f fix preview page images 2024-02-23 02:15:33 +02:00
Pete Matsyburka 179ae01551 remove rollbar 2024-02-21 20:16:10 +02:00
Pete Matsyburka 295860a9de update readme 2024-02-21 17:07:56 +02:00
Pete Matsyburka 0a6c15b6b8 add AR form i18n 2024-02-21 16:45:34 +02:00
Pete Matsyburka 18f80020ee update readme 2024-02-21 10:44:19 +02:00
Pete Matsyburka 3768fb94bf prefill stamp field 2024-02-21 02:32:31 +02:00
Pete Matsyburka 7b2cadcb91 add default draw field prop 2024-02-21 02:04:44 +02:00
193 changed files with 4432 additions and 1778 deletions

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@
"rules": {
"vue/no-deprecated-html-element-is": 0,
"vue/no-mutating-props": 0,
"vue/no-v-html": 0
"vue/one-component-per-file": 0
},
"parserOptions": {
"ecmaVersion": 2022,
+2 -2
View File
@@ -59,7 +59,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v1
with:
node-version: 16.13.1
node-version: 20.9.0
- name: Cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
@@ -104,7 +104,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@v1
with:
node-version: 16.13.1
node-version: 20.9.0
- name: Install Chrome
uses: browser-actions/setup-chrome@latest
- name: Cache node_modules
+1 -1
View File
@@ -44,7 +44,7 @@ Layout/LineLength:
AllowedPatterns: ['\A\s*#']
Metrics/AbcSize:
Max: 40
Max: 45
Metrics/ModuleLength:
Max: 500
+7 -4
View File
@@ -2,7 +2,9 @@ FROM ruby:3.2.2-alpine3.18 as fonts
WORKDIR /fonts
RUN apk --no-cache add wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt
RUN apk --no-cache add fontforge wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
FROM ruby:3.2.2-alpine3.18 as webpack
@@ -37,11 +39,11 @@ ENV BUNDLE_WITHOUT="development:test"
WORKDIR /app
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont && mkdir /fonts
RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
COPY ./Gemfile ./Gemfile.lock ./
RUN bundle update --bundler && bundle install && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf
RUN apk add --no-cache build-base && bundle 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
COPY ./bin ./bin
COPY ./app ./app
@@ -54,6 +56,7 @@ COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru .version ./
COPY --from=fonts /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --from=fonts /fonts/FreeSans.ttf /usr/share/fonts/freefont
COPY --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts
@@ -63,4 +66,4 @@ WORKDIR /data/docuseal
ENV WORKDIR=/data/docuseal
EXPOSE 3000
CMD ["/app/bin/rails", "server"]
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
-1
View File
@@ -32,7 +32,6 @@ gem 'rack'
gem 'rails'
gem 'rails_autolink'
gem 'rails-i18n'
gem 'rollbar', require: ENV.key?('ROLLBAR_ACCESS_TOKEN')
gem 'rotp'
gem 'rqrcode'
gem 'ruby-vips'
-2
View File
@@ -433,7 +433,6 @@ GEM
railties (>= 5.2)
retriable (3.1.2)
rexml (3.2.6)
rollbar (3.4.2)
rotp (6.3.0)
rqrcode (2.2.0)
chunky_png (~> 1.0)
@@ -605,7 +604,6 @@ DEPENDENCIES
rails
rails-i18n
rails_autolink
rollbar
rotp
rqrcode
rspec-rails
+12 -6
View File
@@ -42,6 +42,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- PDF signature verification
- Users management
- Mobile-optimized
- Signing available in 13 languages
- API and Webhooks for integrations
- Easy to deploy in minutes
@@ -50,11 +51,13 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- User roles
- Automated reminders
- Invitation and identify verification via SMS
- Conditional fields and formulas
- Bulk send with CSV, XLSX spreadsheet import
- SSO / SAML
- Template creation with HTML API ([Guide](https://www.docuseal.co/guides/create-pdf-document-fillable-form-with-html-api))
- Template creation with PDF or DOCX and text tags API ([Guide](https://www.docuseal.co/guides/use-embedded-text-field-tags-in-the-pdf-to-create-a-fillable-form))
- Embedded signing form ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue) or [JavaScript](https://www.docuseal.co/docs/embedded))
- Embedded document form builder ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue) or [JavaScript](https://www.docuseal.co/docs/embedded))
- Template creation with PDF or DOCX and field tags API ([Guide](https://www.docuseal.co/guides/use-embedded-text-field-tags-in-the-pdf-to-create-a-fillable-form))
- Embedded signing form ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue), [Angular](https://github.com/docusealco/docuseal-angular) or [JavaScript](https://www.docuseal.co/docs/embedded))
- Embedded document form builder ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue), [Angular](https://github.com/docusealco/docuseal-angular) or [JavaScript](https://www.docuseal.co/docs/embedded))
- [Learn more](https://www.docuseal.co/pricing)
## Deploy
@@ -64,9 +67,6 @@ DocuSeal is an open source platform that provides secure and efficient digital d
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
|**DigitalOcean**|**Render**|
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
|**Koyeb**|**Elestio**|
| [<img alt="Deploy on Koyeb" src="https://www.koyeb.com/static/images/deploy/button.svg" height="40">](https://app.koyeb.com/deploy?name=docuseal&type=docker&image=docker.io/docuseal/docuseal&env[PORT]=8000&env[DATABASE_URL]=CHANGE_ME&env[SECRET_KEY_BASE]=CHANGE_ME&ports=8000;http;/) | [<img alt="Deploy on Elestio" src="https://pub-da36157c854648669813f3f76c526c2b.r2.dev/deploy-on-elestio-black.png">](https://dash.elest.io/deploy?soft=DocuSeal&id=339) |
#### Docker
@@ -99,3 +99,9 @@ At DocuSeal we have expertise and technologies to make documents creation, filli
Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information.
Unless otherwise noted, all files © 2023 DocuSeal LLC.
## Tools
- [Signature Maker](https://www.docuseal.co/online-signature)
- [Sign Document Online](https://www.docuseal.co/sign-documents-online)
- [Fill PDF Online](https://www.docuseal.co/fill-pdf)
@@ -8,9 +8,12 @@ class AccountConfigsController < ApplicationController
AccountConfig::ALLOW_TYPED_SIGNATURE,
AccountConfig::FORCE_MFA,
AccountConfig::ALLOW_TO_RESUBMIT,
AccountConfig::ESIGNING_PREFERENCE_KEY
AccountConfig::ESIGNING_PREFERENCE_KEY,
AccountConfig::FORM_WITH_CONFETTI_KEY
].freeze
InvalidKey = Class.new(StandardError)
def create
@account_config.update!(account_config_params)
@@ -20,7 +23,7 @@ class AccountConfigsController < ApplicationController
private
def load_account_config
return head :not_found unless ALLOWED_KEYS.include?(account_config_params[:key])
raise InvalidKey unless ALLOWED_KEYS.include?(account_config_params[:key])
@account_config =
AccountConfig.find_or_initialize_by(account: current_account, key: account_config_params[:key])
@@ -10,9 +10,9 @@ module Api
before_action :set_cors_headers
def show
blob_uuid, = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
if blob_uuid.blank?
if blob_uuid.blank? || (purp.present? && purp != 'blob') || (exp && exp < Time.current.to_i)
Rollbar.error('Blob not found') if defined?(Rollbar)
return head :not_found
@@ -11,7 +11,9 @@ module Api
def show
Rollbar.info('Blob legacy') if defined?(Rollbar)
blob = ActiveStorage::Blob.find_signed!(params[:signed_blob_id] || params[:signed_id])
blob = ActiveStorage::Blob.find_signed(params[:signed_blob_id] || params[:signed_id])
return head :not_found unless blob
is_permitted = blob.attachments.any? do |a|
(current_user && a.record.account.id == current_user.account_id) ||
+8 -4
View File
@@ -19,10 +19,14 @@ module Api
render json: { error: e.message }, status: :unprocessable_entity
end
rescue_from RateLimit::LimitApproached do |e|
Rollbar.error(e) if defined?(Rollbar)
render json: { error: 'Too many requests' }, status: :too_many_requests
end
if Rails.env.production?
rescue_from CanCan::AccessDenied do |e|
Rollbar.warning(e) if defined?(Rollbar)
render json: { error: e.message }, status: :forbidden
end
@@ -39,8 +43,8 @@ module Api
result = relation.order(id: :desc)
.limit([params.fetch(:limit, DEFAULT_LIMIT).to_i, MAX_LIMIT].min)
result = result.where(relation.arel_table[:id].lt(params[:after])) if params[:after].present?
result = result.where(relation.arel_table[:id].gt(params[:before])) 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?
result
end
@@ -161,14 +161,15 @@ module Api
def submissions_params
permitted_attrs = [
:send_email, :send_sms, :bcc_completed, :completed_redirect_url,
:send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to,
{
message: %i[subject body],
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id,
:completed, :phone, :application_key, :external_id, :reply_to,
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
fields: [%i[name default_value title description
readonly validation_pattern invalid_message]] }]]
fields: [:name, :default_value, :title, :description,
:readonly, :validation_pattern, :invalid_message,
{ default_value: [] }] }]]
}
]
@@ -1,35 +0,0 @@
# frozen_string_literal: true
module Api
class SubmittersAutocompleteController < ApiBaseController
load_and_authorize_resource :submitter, parent: false
SELECT_COLUMNS = %w[email phone name].freeze
LIMIT = 100
def index
submitters = search_submitters(@submitters)
values = submitters.limit(LIMIT).group(SELECT_COLUMNS.join(', ')).pluck(SELECT_COLUMNS.join(', '))
attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h }
attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present?
render json: attrs
end
private
def search_submitters(submitters)
if SELECT_COLUMNS.include?(params[:field])
column = Submitter.arel_table[params[:field].to_sym]
term = "%#{params[:q].downcase}%"
submitters.where(column.lower.matches(term))
else
Submitters.search(submitters, params[:q])
end
end
end
end
+15 -1
View File
@@ -10,6 +10,7 @@ module Api
submitters = submitters.where(external_id: params[:application_key]) if params[:application_key].present?
submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present?
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
submitters = maybe_filder_by_completed_at(submitters, params)
submitters = paginate(
submitters.preload(:template, :submission, :submission_events,
@@ -75,12 +76,25 @@ module Api
:send_email, :send_sms, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id,
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
fields: [%i[name default_value readonly validation_pattern invalid_message]] }
fields: [[:name, :default_value, :readonly, :validation_pattern, :invalid_message,
{ default_value: [] }]] }
)
end
private
def maybe_filder_by_completed_at(submitters, params)
if params[:completed_after].present?
submitters = submitters.where(completed_at: Time.zone.parse(params[:completed_after])..)
end
if params[:completed_before].present?
submitters = submitters.where(completed_at: ..Time.zone.parse(params[:completed_before]))
end
submitters
end
def assign_submitter_attrs(submitter, attrs)
submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email)
submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '') if attrs.key?(:phone)
@@ -1,16 +0,0 @@
# frozen_string_literal: true
module Api
class TemplateFoldersAutocompleteController < ApiBaseController
load_and_authorize_resource :template_folder, parent: false
LIMIT = 100
def index
template_folders = @template_folders.joins(:templates).where(templates: { archived_at: nil }).distinct
template_folders = TemplateFolders.search(template_folders, params[:q]).limit(LIMIT)
render json: template_folders.as_json(only: %i[name archived_at])
end
end
end
@@ -20,16 +20,7 @@ module Api
Templates::CloneAttachments.call(template: cloned_template, original_template: @template)
render json: cloned_template.as_json(serialize_params)
end
private
def serialize_params
{
include: { author: { only: %i[id email first_name last_name] },
documents: { only: %i[id uuid], methods: %i[url filename] } }
}
render json: Templates::SerializeForApi.call(cloned_template)
end
end
end
+34 -23
View File
@@ -7,10 +7,31 @@ module Api
def index
templates = filter_templates(@templates, params)
templates = paginate(templates.preload(:author, documents_attachments: :blob))
templates = paginate(templates.preload(:author, :folder))
schema_documents =
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
record_type: 'Template',
name: :documents,
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
.preload(:blob)
preview_image_attachments =
ActiveStorage::Attachment.joins(:blob)
.where(blob: { filename: '0.jpg' })
.where(record_id: schema_documents.map(&:id),
record_type: 'ActiveStorage::Attachment',
name: :preview_images)
.preload(:blob)
render json: {
data: templates.as_json(serialize_params),
data: templates.map do |t|
Templates::SerializeForApi.call(
t,
schema_documents.select { |e| e.record_id == t.id },
preview_image_attachments
)
end,
pagination: {
count: templates.size,
next: templates.last&.id,
@@ -20,7 +41,7 @@ module Api
end
def show
render json: @template.as_json(serialize_params)
render json: Templates::SerializeForApi.call(@template)
end
def update
@@ -28,6 +49,14 @@ module Api
@template.folder = TemplateFolders.find_or_create_by_name(current_user, folder_name)
end
Array.wrap(params[:roles].presence || params.dig(:template, :roles).presence).each_with_index do |role, index|
if (item = @template.submitters[index])
item['name'] = role
else
@template.submitters << { 'name' => role, 'uuid' => SecureRandom.uuid }
end
end
archived = params.key?(:archived) ? params[:archived] : params.dig(:template, :archived)
if archived.in?([true, false])
@@ -61,29 +90,11 @@ module Api
templates
end
def serialize_params
{
methods: %i[application_key],
include: { author: { only: %i[id email first_name last_name] },
documents: { only: %i[id uuid], methods: %i[url preview_image_url filename] } }
}
end
def template_params
permit_params = [
:name,
{ schema: [%i[attachment_uuid name]],
submitters: [%i[name uuid]],
fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value,
{ preferences: {},
options: [%i[value uuid]],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
]
if params.key?(:template)
params.require(:template).permit(*permit_params)
params.require(:template).permit(:name, submitters: [%i[name uuid]])
else
params.permit(*permit_params)
params.permit(:name, submitters: [%i[name uuid]])
end
end
end
@@ -1,29 +0,0 @@
# frozen_string_literal: true
module Api
class TemplatesDocumentsController < ApiBaseController
load_and_authorize_resource :template
def create
return head :unprocessable_entity if params[:blobs].blank? && params[:files].blank?
documents = Templates::CreateAttachments.call(@template, params)
schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base }
end
render json: {
schema:,
documents: documents.as_json(
methods: %i[metadata signed_uuid],
include: {
preview_images: { methods: %i[url metadata filename] }
}
)
}
rescue Templates::CreateAttachments::PdfEncrypted
render json: { error: 'PDF encrypted' }, status: :unprocessable_entity
end
end
end
@@ -22,6 +22,12 @@ class ApplicationController < ActionController::Base
redirect_to request.path
end
rescue_from RateLimit::LimitApproached do |e|
Rollbar.error(e) if defined?(Rollbar)
redirect_to request.referer, alert: 'Too many requests', status: :too_many_requests
end
if Rails.env.production?
rescue_from CanCan::AccessDenied do |e|
Rollbar.warning(e) if defined?(Rollbar)
@@ -5,6 +5,10 @@ class ConsoleRedirectController < ApplicationController
skip_authorization_check
def index
if request.path == '/upgrade'
params[:redir] = Docuseal.multitenant? ? "#{Docuseal::CONSOLE_URL}/plans" : "#{Docuseal::CONSOLE_URL}/on_premise"
end
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if true_user.blank?
auth = JsonWebToken.encode(uuid: true_user.uuid,
+4 -48
View File
@@ -7,62 +7,18 @@ class DashboardController < ApplicationController
before_action :maybe_render_landing
before_action :maybe_redirect_mfa_setup
load_and_authorize_resource :template_folder, parent: false
load_and_authorize_resource :template, parent: false
SHOW_TEMPLATES_FOLDERS_THRESHOLD = 9
TEMPLATES_PER_PAGE = 12
FOLDERS_PER_PAGE = 18
skip_authorization_check
def index
@template_folders = filter_template_folders(@template_folders)
@pagy, @template_folders = pagy(
@template_folders,
items: FOLDERS_PER_PAGE,
page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1
)
if @pagy.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD
@templates = @templates.none
if cookies.permanent[:dashboard_view] == 'submissions'
SubmissionsDashboardController.dispatch(:index, request, response)
else
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME }
@templates = filter_templates(@templates)
items =
if @template_folders.size < 4
TEMPLATES_PER_PAGE
else
(@template_folders.size < 7 ? 9 : 6)
end
@pagy, @templates = pagy(@templates, items:)
TemplatesDashboardController.dispatch(:index, request, response)
end
end
private
def filter_template_folders(template_folders)
rel = template_folders.joins(:active_templates)
.order(id: :desc)
.distinct
TemplateFolders.search(rel, params[:q])
end
def filter_templates(templates)
rel = templates.active.preload(:author).order(id: :desc)
if params[:q].blank?
shared_template_ids =
TemplateSharing.where(account_id: [current_account.id, TemplateSharing::ALL_ID]).select(:template_id)
rel = rel.where(folder_id: current_account.default_template_folder.id).or(rel.where(id: shared_template_ids))
end
Templates.search(rel, params[:q])
end
def maybe_redirect_product_url
return if !Docuseal.multitenant? || signed_in?
+10
View File
@@ -22,6 +22,8 @@ class ErrorsController < ActionController::Base
respond_to do |f|
f.json do
set_cors_headers
render json: { status: error_status_code }, status: error_status_code
end
@@ -31,6 +33,14 @@ class ErrorsController < ActionController::Base
private
def set_cors_headers
headers['Access-Control-Allow-Origin'] = '*'
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
headers['Access-Control-Allow-Headers'] = '*'
headers['Access-Control-Max-Age'] = '1728000'
headers['Access-Control-Allow-Credentials'] = true
end
def error_status_code
@error_status_code ||=
ActionDispatch::ExceptionWrapper.new(request.env,
+6
View File
@@ -5,6 +5,12 @@ class PasswordsController < Devise::PasswordsController
attribute :user
end
def create
super do |resource|
resource.errors.clear unless Docuseal.multitenant?
end
end
def update
super do |resource|
Current.user = resource
@@ -1,26 +1,52 @@
# frozen_string_literal: true
class PersonalizationSettingsController < ApplicationController
ALLOWED_KEYS = [
AccountConfig::FORM_COMPLETED_BUTTON_KEY,
AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY,
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY,
AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY,
AccountConfig::FORM_COMPLETED_MESSAGE_KEY
].freeze
InvalidKey = Class.new(StandardError)
before_action :load_and_authorize_account_config, only: :create
def show
authorize!(:read, AccountConfig)
end
def create
account_config =
current_account.account_configs.find_or_initialize_by(key: account_config_params[:key])
authorize!(:create, account_config)
account_config.update!(account_config_params)
if @account_config.value != false && @account_config.value.blank?
@account_config.destroy!
else
@account_config.save!
end
redirect_back(fallback_location: settings_personalization_path, notice: 'Settings have been saved.')
end
private
def load_and_authorize_account_config
@account_config =
current_account.account_configs.find_or_initialize_by(key: account_config_params[:key])
@account_config.assign_attributes(account_config_params)
authorize!(:create, @account_config)
raise InvalidKey unless ALLOWED_KEYS.include?(@account_config.key)
@account_config
end
def account_config_params
attrs = params.require(:account_config).permit!
return attrs if attrs[:value].is_a?(String)
attrs[:value]&.transform_values! do |value|
if value.in?(%w[true false])
value == 'true'
@@ -8,7 +8,7 @@ class PreviewDocumentPageController < ActionController::API
def show
attachment_uuid = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid], purpose: :attachment)
attachment = ActiveStorage::Attachment.find_by(uuid: attachment_uuid, name: :preview_images) if attachment_uuid
attachment = ActiveStorage::Attachment.find_by(uuid: attachment_uuid) if attachment_uuid
return head :not_found unless attachment
@@ -1,62 +0,0 @@
# frozen_string_literal: true
class RegistrationsController < Devise::RegistrationsController
prepend_before_action :require_no_authentication, only: [:show]
prepend_before_action :maybe_redirect_if_signed_in, only: [:show]
def show; end
def create
super
Accounts.create_default_template(resource.account) if resource.account.persisted?
end
private
def after_sign_up_path_for(...)
if params[:redir].present?
return console_redirect_index_path(redir: params[:redir]) if params[:redir].starts_with?(Docuseal::CONSOLE_URL)
return params[:redir]
end
super
end
def maybe_redirect_if_signed_in
return unless signed_in?
return if params[:redir].blank?
redirect_to after_sign_up_path_for(current_user), allow_other_host: true
end
def set_flash_message(key, kind, options = {})
return if key == :alert && kind == 'already_authenticated'
super
end
def build_resource(_hash = {})
account = Account.new(account_params)
account.timezone = Accounts.normalize_timezone(account.timezone)
self.resource = account.users.new(user_params)
account.name ||= resource.full_name if params[:action] == 'create'
end
def user_params
return {} if params[:user].blank?
params.require(:user).permit(:first_name, :last_name, :email, :password).compact_blank.tap do |attrs|
attrs[:password] ||= SecureRandom.hex if params[:action] == 'create'
end
end
def account_params
return {} if params[:account].blank?
params.require(:account).permit(:name, :timezone).compact_blank
end
end
@@ -12,13 +12,18 @@ class SendSubmissionEmailController < ApplicationController
def create
@submitter =
if params[:template_slug]
Submitter.joins(submission: :template).find_by!(email: params[:email],
Submitter.joins(submission: :template).find_by!(email: params[:email].to_s.downcase,
template: { slug: params[:template_slug] })
elsif params[:submission_slug]
Submitter.joins(:submission).find_by!(email: params[:email].to_s.downcase,
submission: { slug: params[:submission_slug] })
else
Submitter.find_by!(slug: params[:submitter_slug])
end
SubmitterMailer.documents_copy_email(@submitter).deliver_later!
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later!
respond_to do |f|
f.html { redirect_to success_send_submission_email_index_path }
@@ -3,7 +3,6 @@
class SsoSettingsController < ApplicationController
before_action :load_encrypted_config
authorize_resource :encrypted_config, only: :index
authorize_resource :encrypted_config, parent: false, except: :index
def index; end
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class SubmissionsArchivedController < ApplicationController
load_and_authorize_resource :submission, parent: false
def index
@submissions = @submissions.joins(:template)
@submissions = @submissions.where.not(archived_at: nil)
.or(@submissions.where.not(templates: { archived_at: nil }))
.preload(:created_by_user, template: :author)
@submissions = Submissions.search(@submissions, params[:q], search_template: true)
@pagy, @submissions = pagy(@submissions.preload(:submitters).order(id: :desc))
end
end
+1 -16
View File
@@ -6,23 +6,8 @@ class SubmissionsController < ApplicationController
load_and_authorize_resource :submission, only: %i[show destroy]
PRELOAD_ALL_PAGES_AMOUNT = 200
def show
ActiveRecord::Associations::Preloader.new(
records: [@submission],
associations: [:template, { template_schema_documents: :blob }]
).call
total_pages =
@submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
ActiveRecord::Associations::Preloader.new(
records: @submission.template_schema_documents,
associations: [:blob, { preview_images_attachments: :blob }]
).call
end
@submission = Submissions.preload_with_pages(@submission)
render :show, layout: 'plain'
end
@@ -0,0 +1,20 @@
# frozen_string_literal: true
class SubmissionsDashboardController < ApplicationController
load_and_authorize_resource :submission, parent: false
def index
@submissions = @submissions.joins(:template)
@submissions = @submissions.where(archived_at: nil)
.where(templates: { archived_at: nil })
.preload(:created_by_user, template: :author)
@submissions = Submissions.search(@submissions, params[:q], search_template: true)
@submissions = @submissions.pending if params[:status] == 'pending'
@submissions = @submissions.completed if params[:status] == 'completed'
@pagy, @submissions = pagy(@submissions.preload(:submitters).order(id: :desc))
end
end
@@ -4,10 +4,20 @@ class SubmissionsDownloadController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
TTL = 20.minutes
TTL = 40.minutes
FILES_TTL = 5.minutes
def index
submitter = Submitter.find_by!(slug: params[:submitter_slug])
submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
signature_valid =
if submitter&.slug == params[:submitter_slug]
true
else
submitter = nil
end
submitter ||= Submitter.find_by!(slug: params[:submitter_slug])
Submissions::EnsureResultGenerated.call(submitter)
@@ -17,18 +27,24 @@ class SubmissionsDownloadController < ApplicationController
return head :not_found unless last_submitter.completed_at?
if last_submitter.completed_at < TTL.ago &&
(current_user.nil? || !current_user.account.submitters.exists?(id: last_submitter.id))
if last_submitter.completed_at < TTL.ago && !signature_valid && !current_user_submitter?(last_submitter)
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
urls =
Submitters.select_attachments_for_download(last_submitter).map do |attachment|
ActiveStorage::Blob.proxy_url(attachment.blob)
end
render json: build_urls(last_submitter)
end
render json: urls
private
def current_user_submitter?(submitter)
current_user && current_user.account.submitters.exists?(id: submitter.id)
end
def build_urls(submitter)
Submitters.select_attachments_for_download(submitter).map do |attachment|
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: FILES_TTL.minutes.from_now.to_i)
end
end
end
@@ -4,37 +4,31 @@ class SubmissionsPreviewController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
PRELOAD_ALL_PAGES_AMOUNT = 200
TTL = 20.minutes
TTL = 40.minutes
def show
@submission = Submission.find_by!(slug: params[:slug])
submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
signature_valid =
if submitter && submitter.submission.slug == params[:slug]
@submission = submitter.submission
true
end
@submission ||= Submission.find_by!(slug: params[:slug])
if !@submission.submitters.all?(&:completed_at?) && current_user.blank?
raise ActionController::RoutingError, 'Not Found'
end
unless submission_valid_ttl?(@submission)
if !submission_valid_ttl?(@submission) && !signature_valid
Rollbar.info("TTL: #{@submission.id}") if defined?(Rollbar)
return redirect_to submissions_preview_completed_path(@submission.slug)
end
ActiveRecord::Associations::Preloader.new(
records: [@submission],
associations: [:template, { template_schema_documents: :blob }]
).call
total_pages =
@submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
ActiveRecord::Associations::Preloader.new(
records: @submission.template_schema_documents,
associations: [:blob, { preview_images_attachments: :blob }]
).call
end
@submission = Submissions.preload_with_pages(@submission)
render 'submissions/show', layout: 'plain'
end
@@ -42,7 +36,7 @@ class SubmissionsPreviewController < ApplicationController
def completed
@submission = Submission.find_by!(slug: params[:submissions_preview_slug])
render :completed, layout: 'plain'
render :completed, layout: 'form'
end
private
@@ -31,8 +31,6 @@ class SubmitFormController < ApplicationController
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
cookies[:submitter_sid] = @submitter.signed_id
render(@submitter.submission.template.archived_at? || @submitter.submission.archived_at? ? :archived : :show)
end
@@ -0,0 +1,33 @@
# frozen_string_literal: true
class SubmittersAutocompleteController < ApplicationController
load_and_authorize_resource :submitter, parent: false
SELECT_COLUMNS = %w[email phone name].freeze
LIMIT = 100
def index
submitters = search_submitters(@submitters)
values = submitters.limit(LIMIT).group(SELECT_COLUMNS.join(', ')).pluck(SELECT_COLUMNS.join(', '))
attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h }
attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present?
render json: attrs
end
private
def search_submitters(submitters)
if SELECT_COLUMNS.include?(params[:field])
column = Submitter.arel_table[params[:field].to_sym]
term = "#{params[:q].downcase}%"
submitters.where(column.matches(term, false, true))
else
Submitters.search(submitters, params[:q])
end
end
end
@@ -0,0 +1,31 @@
# frozen_string_literal: true
class TemplateDocumentsController < ApplicationController
load_and_authorize_resource :template
def create
return head :unprocessable_entity if params[:blobs].blank? && params[:files].blank?
old_fields_hash = @template.fields.hash
documents = Templates::CreateAttachments.call(@template, params)
schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base }
end
render json: {
schema:,
fields: old_fields_hash == @template.fields.hash ? nil : @template.fields,
submitters: old_fields_hash == @template.fields.hash ? nil : @template.submitters,
documents: documents.as_json(
methods: %i[metadata signed_uuid],
include: {
preview_images: { methods: %i[url metadata filename] }
}
)
}
rescue Templates::CreateAttachments::PdfEncrypted
render json: { error: 'PDF encrypted' }, status: :unprocessable_entity
end
end
@@ -0,0 +1,14 @@
# frozen_string_literal: true
class TemplateFoldersAutocompleteController < ApplicationController
load_and_authorize_resource :template_folder, parent: false
LIMIT = 100
def index
template_folders = @template_folders.joins(:templates).where(templates: { archived_at: nil }).distinct
template_folders = TemplateFolders.search(template_folders, params[:q]).limit(LIMIT)
render json: template_folders.as_json(only: %i[name archived_at])
end
end
@@ -6,7 +6,7 @@ class TemplatesArchivedSubmissionsController < ApplicationController
def index
@submissions = @submissions.where.not(archived_at: nil)
@submissions = Submissions.search(@submissions, params[:q])
@submissions = Submissions.search(@submissions, params[:q], search_values: true)
@pagy, @submissions = pagy(@submissions.preload(:submitters).order(id: :desc))
rescue ActiveRecord::RecordNotFound
+23 -5
View File
@@ -8,7 +8,7 @@ class TemplatesController < ApplicationController
def show
submissions = @template.submissions.accessible_by(current_ability)
submissions = submissions.active if @template.archived_at.blank?
submissions = Submissions.search(submissions, params[:q])
submissions = Submissions.search(submissions, params[:q], search_values: true)
@base_submissions = submissions
@@ -67,6 +67,12 @@ class TemplatesController < ApplicationController
end
end
def update
@template.update!(template_params)
head :ok
end
def destroy
notice =
if params[:permanently].present?
@@ -86,6 +92,22 @@ class TemplatesController < ApplicationController
private
def template_params
params.require(:template).permit(
:name,
{ schema: [%i[attachment_uuid name]],
submitters: [%i[name uuid]],
fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value,
:title, :description,
{ preferences: {},
conditions: [%i[field_uuid value action]],
options: [%i[value uuid]],
validation: %i[message pattern],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
)
end
def authorized_clone_account_id?(account_id)
true_user.account_id.to_s == account_id.to_s || true_user.account.linked_accounts.exists?(id: account_id)
end
@@ -98,10 +120,6 @@ class TemplatesController < ApplicationController
end
end
def template_params
params.require(:template).permit(:name)
end
def load_base_template
return if params[:base_template_id].blank?
@@ -0,0 +1,63 @@
# frozen_string_literal: true
class TemplatesDashboardController < ApplicationController
load_and_authorize_resource :template_folder, parent: false
load_and_authorize_resource :template, parent: false
SHOW_TEMPLATES_FOLDERS_THRESHOLD = 9
TEMPLATES_PER_PAGE = 12
FOLDERS_PER_PAGE = 18
def index
@template_folders = filter_template_folders(@template_folders)
@pagy, @template_folders = pagy(
@template_folders,
items: FOLDERS_PER_PAGE,
page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1
)
if @pagy.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD
@templates = @templates.none
else
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME }
@templates = filter_templates(@templates)
items =
if @template_folders.size < 4
TEMPLATES_PER_PAGE
else
(@template_folders.size < 7 ? 9 : 6)
end
@pagy, @templates = pagy(@templates, items:)
end
end
private
def filter_template_folders(template_folders)
rel = template_folders.joins(:active_templates)
.order(id: :desc)
.distinct
TemplateFolders.search(rel, params[:q])
end
def filter_templates(templates)
rel = templates.active.preload(:author).order(id: :desc)
if params[:q].blank?
if Docuseal.multitenant? && !current_account.testing?
rel = rel.where(folder_id: current_account.default_template_folder.id)
else
shared_template_ids =
TemplateSharing.where(account_id: [current_account.id, TemplateSharing::ALL_ID]).select(:template_id)
rel = rel.where(folder_id: current_account.default_template_folder.id).or(rel.where(id: shared_template_ids))
end
end
Templates.search(rel, params[:q])
end
end
@@ -0,0 +1,33 @@
# frozen_string_literal: true
class UserConfigsController < ApplicationController
before_action :load_user_config
authorize_resource :user_config
ALLOWED_KEYS = [
UserConfig::RECEIVE_COMPLETED_EMAIL
].freeze
InvalidKey = Class.new(StandardError)
def create
@user_config.update!(user_config_params)
head :ok
end
private
def load_user_config
raise InvalidKey unless ALLOWED_KEYS.include?(user_config_params[:key])
@user_config =
UserConfig.find_or_initialize_by(user: current_user, key: user_config_params[:key])
end
def user_config_params
params.required(:user_config).permit!.tap do |attrs|
attrs[:value] = attrs[:value] == '1' if attrs[:value].in?(%w[1 0])
end
end
end
@@ -9,11 +9,14 @@ class VerifyPdfSignatureController < ApplicationController
HexaPDF::Document.new(io: file.open)
end
cert_data = if Docuseal.multitenant?
Docuseal::CERTS
else
EncryptedConfig.find_by(key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
end
cert_data =
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)
+30 -2
View File
@@ -3,8 +3,10 @@ import { encodeMethodIntoRequestBody } from '@hotwired/turbo-rails/app/javascrip
import { createApp, reactive } from 'vue'
import TemplateBuilder from './template_builder/builder'
import ImportList from './template_builder/import_list'
import ToggleVisible from './elements/toggle_visible'
import ToggleCookies from './elements/toggle_cookies'
import DisableHidden from './elements/disable_hidden'
import TurboModal from './elements/turbo_modal'
import FileDropzone from './elements/file_dropzone'
@@ -20,6 +22,7 @@ import FolderAutocomplete from './elements/folder_autocomplete'
import SignatureForm from './elements/signature_form'
import SubmitForm from './elements/submit_form'
import PromptPassword from './elements/prompt_password'
import EmailsTextarea from './elements/emails_textarea'
import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -53,6 +56,8 @@ 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)
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
document.addEventListener('turbo:submit-end', async (event) => {
@@ -89,10 +94,33 @@ window.customElements.define('template-builder', class extends HTMLElement {
withPhone: this.dataset.withPhone === 'true',
withLogo: this.dataset.withLogo !== 'false',
editable: this.dataset.editable !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withPayment: this.dataset.withPayment === 'true',
isPaymentConnected: this.dataset.isPaymentConnected === 'true',
withFormula: this.dataset.withFormula === 'true',
withConditions: this.dataset.withConditions === 'true',
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
acceptFileTypes: this.dataset.acceptFileTypes,
isDirectUpload: this.dataset.isDirectUpload === 'true'
acceptFileTypes: this.dataset.acceptFileTypes
})
this.app.mount(this.appElem)
this.appendChild(this.appElem)
}
disconnectedCallback () {
this.app?.unmount()
this.appElem?.remove()
}
})
window.customElements.define('import-list', class extends HTMLElement {
connectedCallback () {
this.appElem = document.createElement('div')
this.app = createApp(ImportList, {
template: JSON.parse(this.dataset.template),
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content
})
this.app.mount(this.appElem)
+13 -4
View File
@@ -75,16 +75,21 @@ button[disabled] .enabled {
@apply select base-input w-full font-normal;
}
.tooltip-bottom-start:before {
transform: translateX(-30%);
.tooltip-bottom-end:before {
transform: translateX(-95%);
top: var(--tooltip-offset);
left: 100%;
right: auto;
bottom: auto;
}
.tooltip-bottom-start:after {
transform: translateX(-75%);
.tooltip-pre:before {
white-space: pre;
text-align: left;
}
.tooltip-bottom-end:after {
transform: translateX(-25%);
border-color: transparent transparent var(--tooltip-color) transparent;
top: var(--tooltip-tail-offset);
left: 50%;
@@ -125,3 +130,7 @@ button[disabled] .enabled {
outline-offset: 3px;
outline-color: hsl(var(--bc) / 0.2);
}
select:required:invalid {
color: gray !important;
}
+3 -3
View File
@@ -48,9 +48,9 @@ export default targetable(class extends HTMLElement {
fileRequests.reduce(
(prevPromise, request) => prevPromise.then(() => request()),
Promise.resolve()
)
this.toggleState()
).finally(() => {
this.toggleState()
})
}
downloadSafariIos (urls) {
@@ -0,0 +1,56 @@
const emailRegexp = /([^@;,<>\s]+@[^@;,<>\s]+)/g
export default class extends HTMLElement {
connectedCallback () {
if (this.dataset.limit) {
this.textarea.addEventListener('input', () => {
const emails = this.textarea.value.match(emailRegexp) || []
this.updateCounter(emails.length)
})
}
}
updateCounter (count) {
let counter = document.getElementById('emails_counter')
let bulkMessage = document.getElementById('bulk_message')
if (count < 2) {
counter?.remove()
return
}
if ((count + 10) > this.dataset.limit) {
if (!counter) {
counter = document.createElement('span')
counter.id = 'emails_counter'
counter.classList.add('text-xs', 'right-0', 'absolute')
counter.style.bottom = '-15px'
this.textarea.parentNode.append(counter)
}
counter.innerText = `${count} / ${this.dataset.limit}`
}
if (this.dataset.bulkEnabled !== 'true') {
if (!bulkMessage) {
bulkMessage = document.createElement('span')
bulkMessage.id = 'bulk_message'
bulkMessage.classList.add('text-xs', 'left-0', 'absolute')
bulkMessage.style.bottom = '-15px'
this.textarea.parentNode.append(bulkMessage)
}
bulkMessage.innerHTML = '<a class="link" data-turbo="false" href="/upgrade">Upgrade</a> to bulk send multiple recipients'
}
}
get textarea () {
return this.querySelector('textarea')
}
}
+3 -57
View File
@@ -9,10 +9,6 @@ export default actionable(targetable(class extends HTMLElement {
]
connectedCallback () {
if (this.dataset.isDirectUpload === 'true') {
import('@rails/activestorage')
}
this.addEventListener('drop', this.onDrop)
this.addEventListener('dragover', (e) => e.preventDefault())
@@ -48,61 +44,11 @@ export default actionable(targetable(class extends HTMLElement {
this.classList.toggle('opacity-50')
}
async uploadFiles (files) {
uploadFiles () {
this.toggleLoading()
if (this.dataset.isDirectUpload === 'true') {
const { DirectUpload } = await import('@rails/activestorage')
await Promise.all(
Array.from(files).map(async (file) => {
const upload = new DirectUpload(
file,
'/direct_uploads',
this.input
)
return new Promise((resolve, reject) => {
upload.create((error, blob) => {
if (error) {
console.error(error)
return reject(error)
} else {
return resolve(blob)
}
})
}).catch((error) => {
console.error(error)
})
})
).then((blobs) => {
if (this.dataset.submitOnUpload) {
this.querySelectorAll('[name="blob_signed_ids[]"]').forEach((e) => e.remove())
}
blobs.forEach((blob) => {
const input = document.createElement('input')
input.type = 'hidden'
input.name = 'blob_signed_ids[]'
input.value = blob.signed_id
this.append(input)
})
if (this.dataset.submitOnUpload === 'true') {
this.closest('form').querySelector('button[type="submit"]').click()
}
}).finally(() => {
if (this.dataset.submitOnUpload !== 'true') {
this.toggleLoading()
}
})
} else {
if (this.dataset.submitOnUpload) {
this.closest('form').querySelector('button[type="submit"]').click()
}
if (this.dataset.submitOnUpload) {
this.closest('form').querySelector('button[type="submit"]').click()
}
}
}))
@@ -20,7 +20,7 @@ export default class extends HTMLElement {
fetch = (text, resolve) => {
const queryParams = new URLSearchParams({ q: text })
fetch('/api/template_folders_autocomplete?' + queryParams).then(async (resp) => {
fetch('/template_folders_autocomplete?' + queryParams).then(async (resp) => {
const items = await resp.json()
resolve(items)
@@ -7,6 +7,7 @@ export default class extends HTMLElement {
preventSubmit: 1,
minLength: 1,
showOnFocus: true,
debounceWaitMs: 200,
onSelect: this.onSelect,
render: this.render,
fetch: this.fetch
@@ -27,6 +28,8 @@ export default class extends HTMLElement {
if (textarea && item[field]) {
textarea.value = textarea.value.replace(/[^;,\s]+$/, item[field] + ' ')
textarea.dispatchEvent(new Event('input', { bubbles: true }))
}
})
}
@@ -37,7 +40,9 @@ export default class extends HTMLElement {
if (q) {
const queryParams = new URLSearchParams({ q, field: this.dataset.field })
fetch('/api/submitters_autocomplete?' + queryParams).then(async (resp) => {
this.currentFetch ||= fetch('/submitters_autocomplete?' + queryParams)
this.currentFetch.then(async (resp) => {
const items = await resp.json()
if (q.length < 3) {
@@ -47,6 +52,8 @@ export default class extends HTMLElement {
}
}).catch(() => {
resolve([])
}).finally(() => {
this.currentFetch = null
})
} else {
resolve([])
+17
View File
@@ -0,0 +1,17 @@
export default class extends HTMLElement {
connectedCallback () {
this.button.addEventListener('click', () => {
const expirationDate = new Date()
expirationDate.setFullYear(expirationDate.getFullYear() + 10)
const expires = expirationDate.toUTCString()
document.cookie = this.dataset.key + '=' + this.dataset.value + '; expires=' + expires + '; path=/'
})
}
get button () {
return this.querySelector('button')
}
}
+3 -2
View File
@@ -11,14 +11,15 @@ window.customElements.define('submission-form', class extends HTMLElement {
this.app = createApp(Form, {
submitter: JSON.parse(this.dataset.submitter),
canSendEmail: this.dataset.canSendEmail === 'true',
isDirectUpload: this.dataset.isDirectUpload === 'true',
goToLast: this.dataset.goToLast === 'true',
isDemo: this.dataset.isDemo === 'true',
attribution: this.dataset.attribution !== 'false',
withConfetti: this.dataset.withConfetti !== 'false',
withTypedSignature: this.dataset.withTypedSignature !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
values: reactive(JSON.parse(this.dataset.values)),
completedButton: JSON.parse(this.dataset.completedButton),
completedButton: JSON.parse(this.dataset.completedButton || '{}'),
completedMessage: JSON.parse(this.dataset.completedMessage || '{}'),
completedRedirectUrl: this.dataset.completedRedirectUrl,
attachments: reactive(JSON.parse(this.dataset.attachments)),
fields: JSON.parse(this.dataset.fields)
+22 -9
View File
@@ -157,6 +157,7 @@
ref="textContainer"
dir="auto"
class="flex items-center px-0.5 w-full"
:class="alignClasses[field.preferences?.align]"
>
<span v-if="Array.isArray(modelValue)">
{{ modelValue.join(', ') }}
@@ -167,13 +168,14 @@
<span
v-else
class="whitespace-pre-wrap"
:class="{ 'w-full': field.preferences?.align }"
>{{ modelValue }}</span>
</div>
</div>
</template>
<script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp } from '@tabler/icons-vue'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1 } from '@tabler/icons-vue'
export default {
name: 'FieldArea',
@@ -240,6 +242,7 @@ export default {
signature: this.t('signature'),
initials: this.t('initials'),
date: this.t('date'),
number: this.t('number'),
image: this.t('image'),
file: this.t('file'),
select: this.t('select'),
@@ -252,6 +255,13 @@ export default {
phone: this.t('phone')
}
},
alignClasses () {
return {
center: 'text-center',
left: 'text-left',
right: 'text-right'
}
},
option () {
return this.field.options.find((o) => o.uuid === this.area.option_uuid)
},
@@ -260,6 +270,7 @@ export default {
text: IconTextSize,
signature: IconWritingSign,
date: IconCalendarEvent,
number: IconSquareNumber1,
image: IconPhoto,
initials: IconLetterCaseUpper,
file: IconPaperclip,
@@ -336,17 +347,19 @@ export default {
},
watch: {
modelValue () {
if (this.field.type === 'text' && 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.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.modelValue}`.length)) {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue}`.length : 0
}
})
}
},
mounted () {
if (this.field.type === 'text' && this.$refs.textContainer) {
this.$nextTick(() => {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? this.modelValue.length : 0
})
}
this.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer) {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue}`.length : 0
}
})
},
methods: {
optionValue (option) {
@@ -41,10 +41,16 @@
:name="`values[${field.uuid}][]`"
>
</template>
<div
v-if="field.description && !modelValue.length"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<FileDropzone
:message="`${t('upload')} ${field.name || t('files')}${field.required ? '' : ` (${t('optional')})`}`"
:submitter-slug="submitterSlug"
:is-direct-upload="isDirectUpload"
:multiple="true"
@upload="onUpload"
/>
@@ -53,12 +59,14 @@
<script>
import FileDropzone from './dropzone'
import MarkdownContent from './markdown_content'
import { IconPaperclip, IconTrashX } from '@tabler/icons-vue'
export default {
name: 'AttachmentStep',
components: {
FileDropzone,
MarkdownContent,
IconPaperclip,
IconTrashX
},
@@ -77,11 +85,6 @@ export default {
required: false,
default: () => ({})
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
},
modelValue: {
type: Array,
required: false,
+28 -7
View File
@@ -3,20 +3,29 @@
class="mx-auto max-w-md flex flex-col"
dir="auto"
>
<p class="font-medium text-2xl flex items-center space-x-1.5 mx-auto">
<div class="font-medium text-2xl flex items-center space-x-1.5 mx-auto">
<IconCircleCheck
class="inline text-green-600"
:width="30"
:height="30"
/>
<span>
{{ t('form_has_been_completed') }}
{{ completedMessage.title || t('form_has_been_completed') }}
</span>
</p>
</div>
<div
v-if="completedMessage.body"
class="mt-2"
>
<MarkdownContent
:string="completedMessage.body"
/>
</div>
<div class="space-y-3 mt-5">
<a
v-if="completedButton.url"
:href="completedButton.url"
:href="sanitizeHref(completedButton.url)"
rel="noopener noreferrer nofollow"
class="white-button flex items-center w-full"
>
<span>
@@ -91,10 +100,12 @@
<script>
import { IconCircleCheck, IconBrandGithub, IconMail, IconDownload, IconInnerShadowTop, IconLogin } from '@tabler/icons-vue'
import MarkdownContent from './markdown_content'
export default {
name: 'FormCompleted',
components: {
MarkdownContent,
IconCircleCheck,
IconInnerShadowTop,
IconBrandGithub,
@@ -142,6 +153,11 @@ export default {
type: Object,
required: false,
default: () => ({})
},
completedMessage: {
type: Object,
required: false,
default: () => ({})
}
},
data () {
@@ -211,9 +227,14 @@ export default {
fileRequests.reduce(
(prevPromise, request) => prevPromise.then(() => request()),
Promise.resolve()
)
this.isDownloading = false
).finally(() => {
this.isDownloading = false
})
},
sanitizeHref (href) {
if (href && href.trim().match(/^((?:https?:\/\/)|\/)/)) {
return href.replace(/javascript:/g, '')
}
},
downloadSafariIos (urls) {
const fileRequests = urls.map((url) => {
@@ -1,4 +1,4 @@
function cropCanvasAndExportToPNG (canvas) {
function cropCanvasAndExportToPNG (canvas, { errorOnTooSmall } = { errorOnTooSmall: false }) {
const ctx = canvas.getContext('2d')
const width = canvas.width
@@ -33,6 +33,10 @@ function cropCanvasAndExportToPNG (canvas) {
croppedCanvas.height = croppedHeight
const croppedCtx = croppedCanvas.getContext('2d')
if (errorOnTooSmall && (croppedWidth < 20 || croppedHeight < 20)) {
return Promise.reject(new Error('Image too small'))
}
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
return new Promise((resolve, reject) => {
+22 -3
View File
@@ -1,11 +1,21 @@
<template>
<div dir="auto">
<div class="flex justify-between items-center w-full mb-2">
<div
class="flex justify-between items-center w-full"
:class="{ 'mb-2': !field.description }"
>
<label
:for="field.uuid"
class="label text-2xl"
>{{ field.name && showFieldNames ? field.name : t('date') }}
<template v-if="!field.required">({{ t('optional') }})</template>
>
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>
{{ field.name && showFieldNames ? field.name : t('date') }}
<template v-if="!field.required">({{ t('optional') }})</template>
</template>
</label>
<button
class="btn btn-outline btn-sm !normal-case font-normal"
@@ -15,6 +25,13 @@
{{ t('set_today') }}
</button>
</div>
<div
v-if="field.description"
class="mb-3 px-1"
dir="auto"
>
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" />
<div class="text-center">
<input
@@ -33,11 +50,13 @@
<script>
import { IconCalendarCheck } from '@tabler/icons-vue'
import AppearsOn from './appears_on'
import MarkdownContent from './markdown_content'
export default {
name: 'DateStep',
components: {
IconCalendarCheck,
MarkdownContent,
AppearsOn
},
inject: ['t'],
+16 -73
View File
@@ -70,11 +70,6 @@ export default {
required: false,
default: '*/*'
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
},
multiple: {
type: Boolean,
required: false,
@@ -92,11 +87,6 @@ export default {
return 'el' + Math.random().toString(32).split('.')[1]
}
},
mounted () {
if (this.isDirectUpload) {
import('@rails/activestorage')
}
},
methods: {
onDropFiles (e) {
this.uploadFiles(e.dataTransfer.files)
@@ -113,72 +103,25 @@ export default {
async uploadFiles (files) {
this.isLoading = true
if (this.isDirectUpload) {
const { DirectUpload } = await import('@rails/activestorage')
return await Promise.all(
Array.from(files).map((file) => {
const formData = new FormData()
const blobs = await Promise.all(
Array.from(files).map(async (file) => {
const upload = new DirectUpload(
file,
'/direct_uploads',
this.$refs.input
)
formData.append('file', file)
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
return new Promise((resolve, reject) => {
upload.create((error, blob) => {
if (error) {
console.error(error)
return reject(error)
} else {
return resolve(blob)
}
})
}).catch((error) => {
console.error(error)
})
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: formData
}).then(resp => resp.json()).then((data) => {
return data
})
)
return await Promise.all(
blobs.map((blob) => {
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: JSON.stringify({
name: 'attachments',
blob_signed_id: blob.signed_id,
submitter_slug: this.submitterSlug
}),
headers: { 'Content-Type': 'application/json' }
}).then(resp => resp.json()).then((data) => {
return data
})
})).then((result) => {
this.$emit('upload', result)
}).finally(() => {
this.isLoading = false
})
} else {
return await Promise.all(
Array.from(files).map((file) => {
const formData = new FormData()
formData.append('file', file)
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) => {
this.$emit('upload', result)
}).finally(() => {
this.isLoading = false
})
}
})).then((result) => {
this.$emit('upload', result)
}).finally(() => {
this.isLoading = false
})
}
}
}
+246 -89
View File
@@ -8,13 +8,24 @@
:current-step="currentStepFields"
@focus-step="[saveStep(), goToStep($event, false, true), currentField.type !== 'checkbox' ? isFormVisible = true : '']"
/>
<FormulaFieldAreas
v-if="formulaFields.length"
:fields="formulaFields"
:values="values"
/>
<button
v-if="!isFormVisible"
id="expand_form_button"
class="btn btn-neutral flex text-white absolute rounded-none border-x-0 md:border md:rounded-full bottom-0 w-full md:mb-4 text-base"
@click.prevent="isFormVisible = true"
@click.prevent="[isFormVisible = true, scrollIntoField(currentField)]"
>
{{ t('submit_form') }}
<template v-if="['initials', 'signature'].includes(currentField.type)">
<IconWritingSign stroke-width="1.5" />
{{ t('sign_now') }}
</template>
<template v-else>
{{ t('submit_form') }}
</template>
<IconArrowsDiagonal
class="absolute right-0 mr-4"
:width="20"
@@ -72,6 +83,14 @@
@focus="scrollIntoField(currentField)"
/>
</div>
<NumberStep
v-else-if="currentField.type === 'number'"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:show-field-names="showFieldNames"
:field="currentField"
@focus="scrollIntoField(currentField)"
/>
<DateStep
v-else-if="currentField.type === 'date'"
:key="currentField.uuid"
@@ -82,17 +101,32 @@
/>
<div v-else-if="currentField.type === 'select'">
<label
v-if="showFieldNames && currentField.name"
v-if="showFieldNames && (currentField.name || currentField.title)"
:for="currentField.uuid"
dir="auto"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">({{ t('optional') }})</template>
class="label text-2xl"
:class="{ 'mb-2': !currentField.description }"
>
<MarkdownContent
v-if="currentField.title"
:string="currentField.title"
/>
<template v-else>
{{ currentField.name }}
<template v-if="!currentField.required">({{ t('optional') }})</template>
</template>
</label>
<div
v-else
class="py-1"
/>
<div
v-if="currentField.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="currentField.description" />
</div>
<AppearsOn :field="currentField" />
<select
:id="currentField.uuid"
@@ -121,13 +155,28 @@
</div>
<div v-else-if="currentField.type === 'radio'">
<label
v-if="showFieldNames && currentField.name"
v-if="showFieldNames && (currentField.name || currentField.title)"
:for="currentField.uuid"
dir="auto"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">({{ t('optional') }})</template>
class="label text-2xl"
:class="{ 'mb-2': !currentField.description }"
>
<MarkdownContent
v-if="currentField.title"
:string="currentField.title"
/>
<template v-else>
{{ currentField.name }}
<template v-if="!currentField.required">({{ t('optional') }})</template>
</template>
</label>
<div
v-if="currentField.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="currentField.description" />
</div>
<div class="flex w-full max-h-44 overflow-y-auto">
<div
v-if="!showFieldNames || (currentField.options.every((e) => !e.value) && currentField.options.length > 4)"
@@ -159,6 +208,7 @@
:name="`values[${currentField.uuid}]`"
:value="option.value || `${t('option')} ${index + 1}`"
:required="currentField.required"
@click="scrollIntoField(currentField)"
>
<span class="text-xl">
{{ option.value || `${t('option')} ${index + 1}` }}
@@ -178,66 +228,77 @@
/>
<div
v-else-if="currentField.type === 'checkbox'"
class="flex w-full max-h-44 overflow-y-auto"
>
<input
type="hidden"
name="cast_boolean"
value="true"
>
<div
class="space-y-3.5 mx-auto"
v-if="currentField.description"
dir="auto"
class="mb-3 px-1"
>
<template v-if="isAnonymousChecboxes || !showFieldNames">
<span class="text-xl">
{{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>.
</span>
<input
v-for="field in currentStepFields"
:key="field.uuid"
type="hidden"
:name="`values[${field.uuid}]`"
:value="!!values[field.uuid]"
>
</template>
<template v-else>
<div
v-for="(field, index) in currentStepFields"
:key="field.uuid"
>
<label
:for="field.uuid"
class="flex items-center space-x-3"
<MarkdownContent :string="currentField.description" />
</div>
<div
class="flex w-full max-h-44 overflow-y-auto"
>
<input
type="hidden"
name="cast_boolean"
value="true"
>
<div
class="space-y-3.5 mx-auto"
>
<template v-if="isAnonymousChecboxes || !showFieldNames">
<span class="text-xl">
{{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>.
</span>
<input
v-for="field in currentStepFields"
:key="field.uuid"
type="hidden"
:name="`values[${field.uuid}]`"
:value="!!values[field.uuid]"
>
<input
type="hidden"
:name="`values[${field.uuid}]`"
:value="!!values[field.uuid]"
</template>
<template v-else>
<div
v-for="(field, index) in currentStepFields"
:key="field.uuid"
>
<label
:for="field.uuid"
class="flex items-center space-x-3"
>
<input
:id="field.uuid"
type="checkbox"
class="base-checkbox !h-7 !w-7"
:oninvalid="`this.setCustomValidity('${t('please_check_the_box_to_continue')}')`"
:onchange="`this.setCustomValidity(validity.valueMissing ? '${t('please_check_the_box_to_continue')}' : '');`"
:required="field.required"
:checked="!!values[field.uuid]"
@click="[scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
>
<span
v-if="field.title"
class="text-xl"
v-html="field.title"
/>
<span
v-else
class="text-xl"
>
{{ field.name || field.type + ' ' + (index + 1) }}
</span>
</label>
</div>
</template>
<input
type="hidden"
:name="`values[${field.uuid}]`"
:value="!!values[field.uuid]"
>
<input
:id="field.uuid"
type="checkbox"
class="base-checkbox !h-7 !w-7"
:oninvalid="`this.setCustomValidity('${t('please_check_the_box_to_continue')}')`"
:onchange="`this.setCustomValidity(validity.valueMissing ? '${t('please_check_the_box_to_continue')}' : '');`"
:required="field.required"
:checked="!!values[field.uuid]"
@click="[scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
>
<span
v-if="field.title"
class="text-xl"
>
<MarkdownContent :string="field.title" />
</span>
<span
v-else
class="text-xl"
>
{{ field.name || field.type + ' ' + (index + 1) }}
</span>
</label>
</div>
</template>
</div>
</div>
</div>
<ImageStep
@@ -245,7 +306,6 @@
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
:is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
:show-field-names="showFieldNames"
@@ -258,7 +318,6 @@
v-model="values[currentField.uuid]"
:field="currentField"
:previous-value="previousSignatureValueFor(currentField)"
:is-direct-upload="isDirectUpload"
:with-typed-signature="withTypedSignature"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@@ -274,7 +333,6 @@
v-model="values[currentField.uuid]"
:field="currentField"
:previous-value="previousInitialsValue"
:is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex"
:show-field-names="showFieldNames"
:submitter-slug="submitterSlug"
@@ -287,7 +345,6 @@
v-else-if="currentField.type === 'file'"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:is-direct-upload="isDirectUpload"
:field="currentField"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@@ -357,6 +414,7 @@
:is-demo="isDemo"
:attribution="attribution"
:completed-button="completedRedirectUrl ? {} : completedButton"
:completed-message="completedRedirectUrl ? {} : completedMessage"
:with-send-copy-button="withSendCopyButton && !completedRedirectUrl"
:with-download-button="withDownloadButton && !completedRedirectUrl"
:with-confetti="withConfetti"
@@ -388,6 +446,7 @@
<script>
import FieldAreas from './areas'
import FormulaFieldAreas from './formula_areas'
import ImageStep from './image_step'
import SignatureStep from './signature_step'
import InitialsStep from './initials_step'
@@ -396,12 +455,32 @@ import MultiSelectStep from './multi_select_step'
import PhoneStep from './phone_step'
import PaymentStep from './payment_step'
import TextStep from './text_step'
import NumberStep from './number_step'
import DateStep from './date_step'
import MarkdownContent from './markdown_content'
import FormCompleted from './completed'
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import { IconInnerShadowTop, IconArrowsDiagonal, IconWritingSign, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import AppearsOn from './appears_on'
import i18n from './i18n'
const isEmpty = (obj) => {
if (obj == null) return true
if (Array.isArray(obj) || typeof obj === 'string') {
return obj.length === 0
}
if (typeof obj === 'object') {
return Object.keys(obj).length === 0
}
if (obj === false) {
return true
}
return false
}
export default {
name: 'SubmissionForm',
components: {
@@ -409,6 +488,7 @@ export default {
ImageStep,
SignatureStep,
AppearsOn,
IconWritingSign,
AttachmentStep,
InitialsStep,
MultiSelectStep,
@@ -416,7 +496,10 @@ export default {
DateStep,
IconArrowsDiagonal,
TextStep,
NumberStep,
FormulaFieldAreas,
PhoneStep,
MarkdownContent,
PaymentStep,
IconArrowsDiagonalMinimize2,
FormCompleted
@@ -451,6 +534,11 @@ export default {
return () => {}
}
},
expand: {
type: Boolean,
required: false,
default: null
},
withConfetti: {
type: Boolean,
required: false,
@@ -486,11 +574,6 @@ export default {
required: false,
default: ''
},
isDirectUpload: {
type: Boolean,
required: false,
default: false
},
allowToSkip: {
type: Boolean,
required: false,
@@ -521,6 +604,11 @@ export default {
required: false,
default: () => ({})
},
authenticityToken: {
type: String,
required: false,
default: ''
},
i18n: {
type: Object,
required: false,
@@ -545,12 +633,17 @@ export default {
type: Object,
required: false,
default: () => ({})
},
completedMessage: {
type: Object,
required: false,
default: () => ({})
}
},
data () {
return {
isCompleted: false,
isFormVisible: true,
isFormVisible: this.expand !== false,
showFillAllRequiredFields: false,
currentStep: 0,
isSubmitting: false,
@@ -568,19 +661,23 @@ export default {
queryParams () {
return new URLSearchParams(window.location.search)
},
authenticityToken () {
return document.querySelector('meta[name="csrf-token"]')?.content
},
submitterSlug () {
return this.submitter.slug
},
fieldsUuidIndex () {
return this.fields.reduce((acc, f) => {
acc[f.uuid] = f
return acc
}, {})
},
previousInitialsValue () {
const initialsField = [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
return this.values[initialsField?.uuid]
},
isAnonymousChecboxes () {
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name) && this.currentStepFields.length > 4
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name && !e.required) && this.currentStepFields.length > 4
},
isButtonDisabled () {
if (this.recalculateButtonDisabledKey) {
@@ -599,15 +696,20 @@ export default {
return this.fields.filter((f) => !f.readonly).reduce((acc, f) => {
const prevStep = acc[acc.length - 1]
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox') {
prevStep.push(f)
} else {
acc.push([f])
if (this.checkFieldConditions(f)) {
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox' && !f.description) {
prevStep.push(f)
} else {
acc.push([f])
}
}
return acc
}, [])
},
formulaFields () {
return this.fields.filter((f) => f.preferences?.formula)
},
attachmentsIndex () {
return this.attachments.reduce((acc, a) => {
acc[a.uuid] = a
@@ -619,6 +721,11 @@ export default {
return `/s/${this.submitterSlug}`
}
},
watch: {
expand (value) {
this.isFormVisible = value
}
},
mounted () {
this.submittedValues = JSON.parse(JSON.stringify(this.values))
@@ -651,6 +758,10 @@ export default {
this.currentStep = Math.min(...indexesList)
}
if (document.body?.clientWidth >= 768 && this.expand !== true && ['signature', 'initials', 'file', 'image'].includes(this.currentField?.type)) {
this.isFormVisible = false
}
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
this.$nextTick(() => {
const root = this.$root.$el.parentNode.getRootNode()
@@ -680,6 +791,40 @@ export default {
t (key) {
return this.i18n[key] || i18n[this.language?.toLowerCase()]?.[key] || i18n[this.browserLanguage]?.[key] || i18n.en[key] || key
},
checkFieldConditions (field) {
if (field.conditions?.length) {
return field.conditions.reduce((acc, c) => {
if (['empty', 'unchecked'].includes(c.action)) {
return acc && isEmpty(this.values[c.field_uuid])
} else if (['not_empty', 'checked'].includes(c.action)) {
return acc && !isEmpty(this.values[c.field_uuid])
} else if (['equal', 'contains'].includes(c.action)) {
const field = this.fieldsUuidIndex[c.field_uuid]
const option = field.options.find((o) => o.uuid === c.value)
const values = [this.values[c.field_uuid]].flat()
return acc && values.includes(this.optionValue(option, field.options.indexOf(option)))
} else if (['not_equal', 'does_not_contain'].includes(c.action)) {
const field = this.fieldsUuidIndex[c.field_uuid]
const option = field.options.find((o) => o.uuid === c.value)
const values = [this.values[c.field_uuid]].flat()
return acc && !values.includes(this.optionValue(option, field.options.indexOf(option)))
} else {
return acc
}
}, true)
} else {
return true
}
},
optionValue (option, index) {
if (option.value) {
return option.value
} else {
return `${this.t('option')} ${index + 1}`
}
},
maybeTrackEmailClick () {
const { queryParams } = this
@@ -757,7 +902,7 @@ export default {
this.scrollIntoField(step[0])
}
this.$refs.form.querySelector('input[type="date"], input[type="text"], select')?.focus()
this.$refs.form.querySelector('input[type="date"], input[type="number"], input[type="text"], select')?.focus()
if (clickUpload && !this.values[this.currentField.uuid] && ['file', 'image'].includes(this.currentField.type)) {
this.$refs.form.querySelector('input[type="file"]')?.click()
@@ -765,7 +910,7 @@ export default {
})
},
saveStep (formData) {
const currentFieldUuid = this.currentField.uuid
const currentFieldUuids = this.currentStepFields.map((f) => f.uuid)
if (this.isCompleted) {
return Promise.resolve({})
@@ -775,7 +920,9 @@ export default {
body: formData || new FormData(this.$refs.form)
}).then((response) => {
if (response.status === 200) {
this.submittedValues[currentFieldUuid] = this.values[currentFieldUuid]
currentFieldUuids.forEach((fieldUuid) => {
this.submittedValues[fieldUuid] = this.values[fieldUuid]
})
}
return response
@@ -797,7 +944,13 @@ export default {
stepPromise().then(async () => {
const emptyRequiredField = this.stepFields.find((fields, index) => {
return index < this.currentStep && fields[0].required && (fields[0].type === 'phone' || !this.allowToSkip) && (this.submittedValues[fields[0].uuid] === true ? false : !this.submittedValues[fields[0].uuid]?.length)
if (index >= this.currentStep) {
return false
}
return fields.some((f) => {
return f.required && (f.type === 'phone' || !this.allowToSkip) && isEmpty(this.submittedValues[f.uuid])
})
})
const formData = new FormData(this.$refs.form)
@@ -847,7 +1000,11 @@ export default {
this.isSubmitting = false
})
}).catch(error => {
console.log(error)
if (error?.message === 'Image too small') {
alert('Signature is too small - please redraw.')
} else {
console.log(error)
}
}).finally(() => {
this.isSubmitting = false
})
@@ -0,0 +1,97 @@
<template>
<template
v-for="(field, fieldIndex) in fields"
:key="field.uuid"
>
<template
v-for="(area, areaIndex) in field.areas"
:key="areaIndex"
>
<Teleport
v-if="findPageElementForArea(area)"
:to="findPageElementForArea(area)"
>
<FieldArea
v-if="isMathLoaded"
:model-value="calculateFormula(field)"
:field="field"
:area="area"
:submittable="false"
:field-index="fieldIndex"
/>
</Teleport>
</template>
</template>
</template>
<script>
import FieldArea from './area'
export default {
name: 'FormulaFieldAreas',
components: {
FieldArea
},
props: {
fields: {
type: Array,
required: false,
default: () => []
},
values: {
type: Object,
required: false,
default: () => ({})
}
},
data () {
return {
isMathLoaded: false
}
},
async mounted () {
const {
create,
evaluateDependencies,
addDependencies,
subtractDependencies,
divideDependencies,
multiplyDependencies,
powDependencies,
roundDependencies,
absDependencies,
sinDependencies,
tanDependencies,
cosDependencies
} = await import('mathjs')
this.math = create({
evaluateDependencies,
addDependencies,
subtractDependencies,
divideDependencies,
multiplyDependencies,
powDependencies,
roundDependencies,
absDependencies,
sinDependencies,
tanDependencies,
cosDependencies
})
this.isMathLoaded = true
},
methods: {
findPageElementForArea (area) {
return (this.$root.$el?.parentNode?.getRootNode() || document).getElementById(`page-${area.attachment_uuid}-${area.page}`)
},
calculateFormula (field) {
const transformedFormula = field.preferences.formula.replace(/{{(.*?)}}/g, (match, uuid) => {
return this.values[uuid] || 0.0
})
return this.math.evaluate(transformedFormula.toLowerCase())
}
}
}
</script>
+156 -2
View File
@@ -3,6 +3,7 @@ const en = {
signature: 'Signature',
initials: 'Initials',
date: 'Date',
number: 'Number',
image: 'Image',
take_photo: 'Take photo',
file: 'File',
@@ -16,6 +17,7 @@ const en = {
payment: 'Payment',
phone: 'Phone',
submit_form: 'Submit Form',
sign_now: 'Sign Now',
type_here_: 'Type here...',
optional: 'optional',
option: 'Option',
@@ -68,6 +70,7 @@ const es = {
signature: 'Firma',
initials: 'Iniciales',
date: 'Fecha',
number: 'Número',
image: 'Imagen',
file: 'Archivo',
select: 'Seleccionar',
@@ -80,6 +83,7 @@ const es = {
phone: 'Teléfono',
take_photo: 'Tomar foto',
submit_form: 'Enviar Formulario',
sign_now: 'Firmar ahora',
type_here_: 'Escribe aquí...',
optional: 'opcional',
appears_on: 'Aparece en',
@@ -131,6 +135,7 @@ const it = {
signature: 'Firma',
initials: 'Iniziali',
date: 'Data',
number: 'Numero',
image: 'Immagine',
file: 'File',
select: 'Seleziona',
@@ -142,6 +147,7 @@ const it = {
payment: 'Pagamento',
phone: 'Telefono',
submit_form: 'Invia Modulo',
sign_now: 'Firma ora',
type_here_: 'Digita qui...',
optional: 'opzionale',
appears_on: 'Compare su',
@@ -194,6 +200,7 @@ const de = {
signature: 'Unterschrift',
initials: 'Initialen',
date: 'Datum',
number: 'Nummer',
image: 'Bild',
file: 'Datei',
select: 'Auswählen',
@@ -205,6 +212,7 @@ const de = {
payment: 'Zahlung',
phone: 'Telefon',
submit_form: 'Formular absenden',
sign_now: 'Jetzt unterschreiben',
type_here_: 'Hier eingeben...',
optional: 'optional',
appears_on: 'Erscheint auf',
@@ -257,6 +265,7 @@ const fr = {
signature: 'Signature',
initials: 'Initiales',
date: 'Date',
number: 'Numéro',
image: 'Image',
file: 'Fichier',
select: 'Choisir',
@@ -268,6 +277,7 @@ const fr = {
payment: 'Paiement',
phone: 'Téléphone',
submit_form: 'Envoyer le Formulaire',
sign_now: 'Signer maintenant',
type_here_: 'Tapez ici...',
optional: 'facultatif',
appears_on: 'Apparaît sur',
@@ -320,6 +330,7 @@ const pl = {
signature: 'Podpis',
initials: 'Inicjały',
date: 'Data',
number: 'Numer',
image: 'Obraz',
file: 'Plik',
select: 'Wybierz',
@@ -331,6 +342,7 @@ const pl = {
payment: 'Płatność',
phone: 'Telefon',
submit_form: 'Wyślij Formularz',
sign_now: 'Podpisz teraz',
type_here_: 'Wpisz tutaj...',
optional: 'opcjonalny',
appears_on: 'Pojawia się na',
@@ -383,6 +395,7 @@ const uk = {
signature: 'Підпис',
initials: 'Ініціали',
date: 'Дата',
number: 'Число',
image: 'Зображення',
file: 'Файл',
select: 'Вибрати',
@@ -394,6 +407,7 @@ const uk = {
payment: 'Платіж',
phone: 'Телефон',
submit_form: 'Надіслати Форму',
sign_now: 'Підписати зараз',
type_here_: 'Введіть тут',
optional: 'необов’язково',
appears_on: "З'являється на",
@@ -446,6 +460,7 @@ const cs = {
signature: 'Podpis',
initials: 'Iniciály',
date: 'Datum',
number: 'Číslo',
image: 'Obrázek',
file: 'Soubor',
select: 'Vybrat',
@@ -457,6 +472,7 @@ const cs = {
payment: 'Platba',
phone: 'Telefon',
submit_form: 'Odeslat formulář',
sign_now: 'Podepsat nyní',
type_here_: 'Zadejte zde',
optional: 'volitelné',
appears_on: 'Zobrazuje se na',
@@ -509,6 +525,7 @@ const pt = {
signature: 'Assinatura',
initials: 'Iniciais',
date: 'Data',
number: 'Número',
image: 'Imagem',
file: 'Arquivo',
select: 'Selecionar',
@@ -520,6 +537,7 @@ const pt = {
payment: 'Pagamento',
phone: 'Telefone',
submit_form: 'Enviar Formulário',
sign_now: 'Assinar agora',
type_here_: 'Digite aqui',
optional: 'opcional',
appears_on: 'Aparece em',
@@ -570,8 +588,9 @@ const he = {
minimize: 'לקטן',
text: 'טקסט',
signature: 'חתימה',
initials: 'ראשי תיקיות',
initials: 'ראשי תיבות',
date: 'תאריך',
number: 'מספר',
image: 'תמונה',
file: 'קובץ',
select: 'בחר',
@@ -583,6 +602,7 @@ const he = {
payment: 'תשלום',
phone: 'טלפון',
submit_form: 'שלח טופס',
sign_now: 'חתום כעת',
type_here_: 'הקלד כאן',
optional: 'אופציונלי',
option: 'אפשרות',
@@ -636,6 +656,7 @@ const nl = {
signature: 'Handtekening',
initials: 'Initialen',
date: 'Datum',
number: 'Nummer',
image: 'Afbeelding',
take_photo: 'Maak een foto',
file: 'Bestand',
@@ -648,6 +669,7 @@ const nl = {
payment: 'Betaling',
phone: 'Telefoon',
submit_form: 'Formulier verzenden',
sign_now: 'Nu ondertekenen',
type_here_: 'Typ hier...',
optional: 'Optioneel',
option: 'Optie',
@@ -694,6 +716,138 @@ const nl = {
files: 'Bestanden'
}
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl }
const ar = {
text: 'نص',
signature: 'توقيع',
initials: 'الاختصارات',
date: 'تاريخ',
number: 'رقم',
image: 'صورة',
take_photo: 'التقاط صورة',
file: 'ملف',
select: 'اختيار',
checkbox: 'خانة اختيار',
multiple: 'متعدد',
radio: 'راديو',
cells: 'خلايا',
stamp: 'ختم',
minimize: 'تصغير',
payment: 'الدفع',
phone: 'هاتف',
submit_form: 'إرسال النموذج',
sign_now: 'وقع الآن',
type_here_: 'اكتب هنا',
optional: 'اختياري',
option: 'خيار',
appears_on: 'يظهر على',
page: 'صفحة',
select_your_option: 'اختر خيارك',
complete_hightlighted_checkboxes_and_click: 'أكمل الخانات المميزة وانقر',
submit: 'إرسال',
next: 'التالي',
click_to_upload: 'انقر للتحميل',
or_drag_and_drop_files: 'أو اسحب وأسقط الملفات',
send_copy_via_email: 'إرسال نسخة عبر البريد الإلكتروني',
download: 'تحميل',
clear: 'مسح',
redraw: 'إعادة الرسم',
draw_initials: 'ارسم الاختصارات',
type_signature_here: 'اكتب التوقيع هنا',
type_initial_here: 'اكتب الاختصارات هنا',
form_has_been_completed: 'تم إكمال النموذج!',
create_a_free_account: 'إنشاء حساب مجاني',
signed_with: 'تم التوقيع بواسطة',
please_check_the_box_to_continue: 'الرجاء التحقق من الخانة للمتابعة',
open_source_documents_software: 'برنامج وثائق مفتوح المصدر',
verified_phone_number: 'تحقق من رقم الهاتف',
use_international_format: 'استخدم الشكل الدولي: +1xxx',
six_digits_code: 'رمز مكون من 6 أرقام',
change_phone_number: 'تغيير رقم الهاتف',
sending: 'جارٍ الإرسال',
resend_code: 'إعادة إرسال الرمز',
verification_code_has_been_resent: 'تم إعادة إرسال رمز التحقق عبر الرسائل القصيرة',
please_fill_all_required_fields: 'الرجاء ملء جميع الحقول المطلوبة',
set_today: 'تعيين اليوم',
toggle_multiline_text: 'تبديل النصوص متعددة الأسطر',
draw_signature: 'ارسم التوقيع',
type_initial: 'اكتب الاختصارات',
draw: 'ارسم',
type: 'اكتب',
type_text: 'اكتب نصًا',
email_has_been_sent: 'تم إرسال البريد الإلكتروني',
processing: 'جارٍ المعالجة',
pay_with_strip: 'الدفع بواسطة Stripe',
reupload: 'إعادة التحميل',
upload: 'تحميل',
files: 'الملفات'
}
const ko = {
text: '텍스트',
signature: '서명',
initials: '이니셜',
date: '날짜',
number: '숫자',
image: '이미지',
take_photo: '사진 찍기',
file: '파일',
select: '선택',
checkbox: '체크박스',
multiple: '다중',
radio: '라디오',
cells: '셀',
stamp: '스탬프',
minimize: '최소화',
payment: '결제',
phone: '전화',
submit_form: '양식 제출',
sign_now: '지금 서명',
type_here_: '여기에 입력...',
optional: '선택 사항',
option: '옵션',
appears_on: '다음에 나타납니다',
page: '페이지',
select_your_option: '옵션 선택',
complete_hightlighted_checkboxes_and_click: '강조된 체크박스를 완료하고 클릭',
submit: '제출',
next: '다음',
click_to_upload: '업로드하려면 클릭',
or_drag_and_drop_files: '또는 파일을 드래그하여 놓으십시오',
send_copy_via_email: '이메일로 사본 보내기',
download: '다운로드',
clear: '지우기',
redraw: '다시 그리기',
draw_initials: '이니셜 그리기',
type_signature_here: '여기에 서명 입력',
type_initial_here: '여기에 이니셜 입력',
form_has_been_completed: '양식이 완료되었습니다!',
create_a_free_account: '무료 계정 생성',
signed_with: '다음으로 서명:',
please_check_the_box_to_continue: '계속하려면 확인란을 선택하십시오',
open_source_documents_software: '오픈 소스 문서 소프트웨어',
verified_phone_number: '전화번호 확인됨',
use_international_format: '국제 포맷 사용: +1xxx',
six_digits_code: '6자리 코드',
change_phone_number: '전화번호 변경',
sending: '전송 중...',
resend_code: '코드 다시 보내기',
verification_code_has_been_resent: '인증 코드가 SMS로 다시 전송되었습니다',
please_fill_all_required_fields: '필수 필드를 모두 입력하십시오',
set_today: '오늘 날짜로 설정',
toggle_multiline_text: '다중 텍스트 전환',
draw_signature: '서명 그리기',
type_initial: '이니셜 입력',
draw: '그리기',
type: '입력',
type_text: '텍스트 입력',
email_has_been_sent: '이메일이 전송되었습니다',
processing: '처리 중',
pay_with_strip: '스트라이프로 결제',
reupload: '다시 업로드',
upload: '업로드',
files: '파일'
}
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar, ko }
export default i18n
+13 -9
View File
@@ -24,13 +24,20 @@
:name="`values[${field.uuid}]`"
>
</div>
<div>
<div
v-if="!modelValue"
>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<FileDropzone
v-if="!modelValue"
:message="`${t('upload')} ${field.name || t('image')}${field.required ? '' : ` (${t('optional')})`}`"
:submitter-slug="submitterSlug"
:accept="'image/*'"
:is-direct-upload="isDirectUpload"
@upload="onImageUpload"
/>
</div>
@@ -39,12 +46,14 @@
<script>
import FileDropzone from './dropzone'
import { IconReload } from '@tabler/icons-vue'
import MarkdownContent from './markdown_content'
export default {
name: 'ImageStep',
components: {
FileDropzone,
IconReload
IconReload,
MarkdownContent
},
inject: ['t'],
props: {
@@ -52,11 +61,6 @@ export default {
type: Object,
required: true
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
},
showFieldNames: {
type: Boolean,
required: false,
@@ -1,9 +1,20 @@
<template>
<div dir="auto">
<div class="flex justify-between items-center w-full mb-2">
<div
class="flex justify-between items-center w-full"
:class="{ 'mb-2': !field.description }"
>
<label
class="label text-2xl"
>{{ showFieldNames && field.name ? field.name : t('initials') }}</label>
>
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>
{{ showFieldNames && field.name ? field.name : t('initials') }}
</template>
</label>
<div class="space-x-2 flex">
<span
v-if="isDrawInitials"
@@ -70,6 +81,13 @@
</a>
</div>
</div>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" />
<input
:value="modelValue || computedPreviousValue"
@@ -105,6 +123,7 @@ import { cropCanvasAndExportToPNG } from './crop_canvas'
import { IconReload, IconTextSize, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import SignaturePad from 'signature_pad'
import AppearsOn from './appears_on'
import MarkdownContent from './markdown_content'
const scale = 3
@@ -115,6 +134,7 @@ export default {
IconReload,
IconTextSize,
IconSignature,
MarkdownContent,
IconArrowsDiagonalMinimize2
},
inject: ['baseUrl', 't'],
@@ -132,11 +152,6 @@ export default {
required: false,
default: true
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
},
attachmentsIndex: {
type: Object,
required: false,
@@ -182,10 +197,6 @@ export default {
this.$refs.textInput?.focus()
})
if (this.isDirectUpload) {
import('@rails/activestorage')
}
if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas)
@@ -273,45 +284,21 @@ export default {
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
const file = new File([blob], 'initials.png', { type: 'image/png' })
if (this.isDirectUpload) {
const { DirectUpload } = await import('@rails/activestorage')
const formData = new FormData()
new DirectUpload(
file,
'/direct_uploads'
).create((_error, data) => {
fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: JSON.stringify({
submitter_slug: this.submitterSlug,
blob_signed_id: data.signed_id,
name: 'attachments'
}),
headers: { 'Content-Type': 'application/json' }
}).then((resp) => resp.json()).then((attachment) => {
this.$emit('update:model-value', attachment.uuid)
this.$emit('attached', attachment)
formData.append('file', file)
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
return resolve(attachment)
})
})
} else {
const formData = new FormData()
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)
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)
})
}
return resolve(attachment)
})
})
})
}
@@ -0,0 +1,75 @@
<template>
<span>
<template
v-for="(item, index) in items"
:key="index"
>
<a
v-if="item.startsWith('<a') && item.endsWith('</a>')"
:href="sanitizeHref(extractAttr(item, 'href'))"
rel="noopener noreferrer nofollow"
:class="extractAttr(item, 'class') || 'link'"
target="_blank"
>
{{ extractText(item) }}
</a>
<b
v-else-if="item.startsWith('<b>') || item.startsWith('<strong>')"
>
{{ extractText(item) }}
</b>
<i
v-else-if="item.startsWith('<i>') || item.startsWith('<em>')"
>
{{ extractText(item) }}
</i>
<br
v-else-if="item === '<br>' || item === '\n'"
>
<template
v-else
>
{{ item }}
</template>
</template>
</span>
</template>
<script>
import snarkdown from 'snarkdown'
const htmlSplitRegexp = /(<a.+?<\/a>|<i>.+?<\/i>|<b>.+?<\/b>|<em>.+?<\/em>|<strong>.+?<\/strong>|<br>)/
export default {
name: 'MarkdownContent',
props: {
string: {
type: String,
required: false,
default: ''
}
},
computed: {
items () {
return snarkdown(this.string.replace(/\n/g, '<br>')).split(htmlSplitRegexp)
}
},
methods: {
sanitizeHref (href) {
if (href && href.trim().match(/^((?:https?:\/\/)|\/)/)) {
return href.replace(/javascript:/g, '')
}
},
extractAttr (text, attr) {
if (text.includes(attr)) {
return text.split(attr).pop().split('"')[1]
}
},
extractText (text) {
if (text) {
return text.match(/>(.+?)</)?.[1]
}
}
}
}
</script>
@@ -1,10 +1,23 @@
<template>
<label
v-if="showFieldNames && field.name"
v-if="showFieldNames && (field.name || field.title)"
:for="field.uuid"
dir="auto"
class="label text-2xl mb-2"
>{{ field.name }}</label>
class="label text-2xl"
:class="{ 'mb-2': !field.description }"
><MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>{{ field.name }}</template>
</label>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<div class="flex w-full max-h-44 overflow-y-auto">
<input
v-if="modelValue.length === 0"
@@ -32,6 +45,7 @@
<label
:for="option.uuid"
class="flex items-center space-x-3"
@click="scrollIntoField(field)"
>
<input
:id="option.uuid"
@@ -53,8 +67,13 @@
</template>
<script>
import MarkdownContent from './markdown_content'
export default {
name: 'MultiSelectStep',
components: {
MarkdownContent
},
inject: ['t', 'scrollIntoField'],
props: {
field: {
@@ -0,0 +1,86 @@
<template>
<label
v-if="showFieldNames && (field.name || field.title)"
:for="field.uuid"
dir="auto"
class="label text-2xl"
:class="{ 'mb-2': !field.description }"
><MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>{{ field.name }}</template>
<template v-if="!field.required">({{ t('optional') }})</template>
</label>
<div
v-else
class="py-1"
/>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" />
<div class="items-center flex">
<input
type="hidden"
name="cast_number"
value="true"
>
<input
:id="field.uuid"
v-model="number"
type="number"
class="base-input !text-2xl w-full"
step="any"
:required="field.required"
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
:name="`values[${field.uuid}]`"
@focus="$emit('focus')"
>
</div>
</template>
<script>
import AppearsOn from './appears_on'
import MarkdownContent from './markdown_content'
export default {
name: 'TextStep',
components: {
AppearsOn,
MarkdownContent
},
inject: ['t'],
props: {
field: {
type: Object,
required: true
},
showFieldNames: {
type: Boolean,
required: false,
default: true
},
modelValue: {
type: [String, Number],
required: false,
default: ''
}
},
emits: ['update:model-value', 'focus'],
computed: {
number: {
set (value) {
this.$emit('update:model-value', value)
},
get () {
return this.modelValue
}
}
}
}
</script>
+25 -6
View File
@@ -2,10 +2,25 @@
<div>
<label
:for="isCodeSent ? 'one_time_code' : field.uuid"
class="label text-2xl mb-2"
>{{ showFieldNames && field.name ? field.name : t('verified_phone_number') }}
<template v-if="!field.required">({{ t('optional') }})</template>
class="label text-2xl"
:class="{ 'mb-2': !field.description }"
>
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>
{{ showFieldNames && field.name ? field.name : t('verified_phone_number') }}
<template v-if="!field.required">({{ t('optional') }})</template>
</template>
</label>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<div>
<input
type="hidden"
@@ -53,14 +68,13 @@
class="base-input !text-2xl w-full"
autocomplete="tel"
pattern="^\+[0-9\s\-]+$"
:oninvalid="`this.value ? this.setCustomValidity('${t('use_international_format')}...') : ''`"
oninput="this.setCustomValidity('')"
type="tel"
inputmode="tel"
:required="field.required"
placeholder="+1 234 567-8900"
:name="`values[${field.uuid}]`"
@input="$emit('update:model-value', $event.target.value)"
@invalid="$event.target.value ? $event.target.setCustomValidity(`${t('use_international_format')}...`) : ''"
@input="[$event.target.setCustomValidity(''), $emit('update:model-value', $event.target.value)]"
@focus="$emit('focus')"
>
</div>
@@ -68,6 +82,8 @@
</template>
<script>
import MarkdownContent from './markdown_content'
function throttle (func, delay) {
let lastCallTime = 0
@@ -83,6 +99,9 @@ function throttle (func, delay) {
export default {
name: 'PhoneStep',
components: {
MarkdownContent
},
inject: ['t', 'baseUrl'],
props: {
field: {
@@ -1,9 +1,20 @@
<template>
<div dir="auto">
<div class="flex justify-between items-center w-full mb-2">
<div
class="flex justify-between items-center w-full"
:class="{ 'mb-2': !field.description }"
>
<label
class="label text-2xl"
>{{ showFieldNames && field.name ? field.name : t('signature') }}</label>
>
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>
{{ showFieldNames && field.name ? field.name : t('signature') }}
</template>
</label>
<div class="space-x-2 flex">
<span
v-if="isTextSignature && field.preferences?.format !== 'typed'"
@@ -30,7 +41,7 @@
<a
id="type_text_button"
href="#"
class="btn btn-outline btn-sm font-medium"
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap"
@click.prevent="toggleTextInput"
>
<IconTextSize :width="16" />
@@ -45,7 +56,7 @@
:data-tip="t('take_photo')"
>
<label
class="btn btn-outline btn-sm font-medium"
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap"
>
<IconCamera :width="16" />
<input
@@ -91,6 +102,13 @@
</a>
</div>
</div>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" />
<input
:value="modelValue || computedPreviousValue"
@@ -126,6 +144,7 @@ import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonal
import { cropCanvasAndExportToPNG } from './crop_canvas'
import SignaturePad from 'signature_pad'
import AppearsOn from './appears_on'
import MarkdownContent from './markdown_content'
let isFontLoaded = false
@@ -137,6 +156,7 @@ export default {
AppearsOn,
IconReload,
IconCamera,
MarkdownContent,
IconTextSize,
IconSignature,
IconArrowsDiagonalMinimize2
@@ -156,11 +176,6 @@ export default {
required: false,
default: true
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
},
withTypedSignature: {
type: Boolean,
required: false,
@@ -210,10 +225,6 @@ export default {
}
})
if (this.isDirectUpload) {
import('@rails/activestorage')
}
if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas)
@@ -366,49 +377,27 @@ export default {
return Promise.resolve({})
}
return new Promise((resolve) => {
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
return new Promise((resolve, reject) => {
cropCanvasAndExportToPNG(this.$refs.canvas, { errorOnTooSmall: true }).then(async (blob) => {
const file = new File([blob], 'signature.png', { type: 'image/png' })
if (this.isDirectUpload) {
const { DirectUpload } = await import('@rails/activestorage')
const formData = new FormData()
new DirectUpload(
file,
'/direct_uploads'
).create((_error, data) => {
fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: JSON.stringify({
submitter_slug: this.submitterSlug,
blob_signed_id: data.signed_id,
name: 'attachments'
}),
headers: { 'Content-Type': 'application/json' }
}).then((resp) => resp.json()).then((attachment) => {
this.$emit('update:model-value', attachment.uuid)
this.$emit('attached', attachment)
formData.append('file', file)
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
return resolve(attachment)
})
})
} else {
const formData = new FormData()
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)
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)
})
}
return resolve(attachment)
})
}).catch((error) => {
return reject(error)
})
})
}
+14 -7
View File
@@ -1,11 +1,14 @@
<template>
<label
v-if="showFieldNames && field.name"
v-if="showFieldNames && (field.name || field.title)"
:for="field.uuid"
dir="auto"
class="label text-2xl"
:class="{ 'mb-2': !field.description }"
><template v-if="field.title"><span v-html="field.title" /></template>
><MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>{{ field.name }}</template>
<template v-if="!field.required">({{ t('optional') }})</template>
</label>
@@ -15,9 +18,11 @@
/>
<div
v-if="field.description"
class="mb-3 px-1 text-lg"
v-html="field.description"
/>
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" />
<div class="items-center flex">
<input
@@ -30,11 +35,11 @@
:class="{ '!pr-11 -mr-10': !field.validation?.pattern }"
:required="field.required"
:pattern="field.validation?.pattern"
:oninvalid="field.validation?.message ? `this.setCustomValidity(${JSON.stringify(field.validation.message)})` : ''"
:oninput="field.validation?.message ? `this.setCustomValidity('')` : ''"
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
type="text"
:name="`values[${field.uuid}]`"
@invalid="field.validation?.message ? $event.target.setCustomValidity(field.validation.message) : ''"
@input="field.validation?.message ? $event.target.setCustomValidity('') : ''"
@focus="$emit('focus')"
>
<textarea
@@ -69,11 +74,13 @@
<script>
import { IconAlignBoxLeftTop } from '@tabler/icons-vue'
import AppearsOn from './appears_on'
import MarkdownContent from './markdown_content'
export default {
name: 'TextStep',
components: {
IconAlignBoxLeftTop,
MarkdownContent,
AppearsOn
},
inject: ['t'],
+28 -16
View File
@@ -42,7 +42,7 @@
v-model="field.submitter_uuid"
class="border-r"
:compact="true"
:editable="editable && !defaultField"
:editable="editable && (!defaultField || defaultField.role !== submitter?.name)"
:allow-add-new="!defaultSubmitters.length"
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'"
:submitters="template.submitters"
@@ -99,7 +99,7 @@
<div
class="flex items-center h-full w-full"
dir="auto"
:class="[bgColors[submitterIndex], field?.default_value ? '' : 'justify-center']"
:class="[bgColors[submitterIndex], field?.default_value ? (alignClasses[field.preferences?.align] || '') : 'justify-center']"
>
<span
v-if="field"
@@ -206,9 +206,18 @@ export default {
}
},
computed: {
defaultName: Field.computed.defaultName,
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons,
defaultName () {
return this.buildDefaultName(this.field, this.template.fields)
},
alignClasses () {
return {
center: 'justify-center',
left: 'justify-start',
right: 'justify-end'
}
},
optionIndexText () {
if (this.area.option_uuid && this.field.options) {
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
@@ -299,19 +308,22 @@ export default {
},
watch: {
'field.default_value' () {
if (this.field.type === 'text' && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.field.default_value.length)) {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? this.field.default_value.length : 0
}
}
},
mounted () {
if (this.field.type === 'text' && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.field.default_value)) {
this.$nextTick(() => {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? this.field.default_value.length : 0
if (['date', 'text', 'number'].includes(this.field.type) && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.field.default_value}`.length)) {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0
}
})
}
},
mounted () {
this.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.field.default_value}`.length)) {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0
}
})
},
methods: {
buildDefaultName: Field.methods.buildDefaultName,
onNameFocus (e) {
this.selectedAreaRef.value = this.area
@@ -340,7 +352,7 @@ export default {
},
onResizeCell (e) {
if (e.target.id === 'mask') {
const positionX = e.layerX / (e.target.clientWidth - 1)
const positionX = e.offsetX / (e.target.clientWidth - 1)
if (positionX > this.area.x) {
this.area.cell_w = positionX - this.area.x
@@ -386,16 +398,16 @@ export default {
},
resize (e) {
if (e.target.id === 'mask') {
this.area.w = e.layerX / e.target.clientWidth - this.area.x
this.area.h = e.layerY / e.target.clientHeight - this.area.y
this.area.w = e.offsetX / e.target.clientWidth - this.area.x
this.area.h = e.offsetY / e.target.clientHeight - this.area.y
}
},
drag (e) {
if (e.target.id === 'mask') {
this.isDragged = true
this.area.x = (e.layerX - this.dragFrom.x) / e.target.clientWidth
this.area.y = (e.layerY - this.dragFrom.y) / e.target.clientHeight
this.area.x = (e.offsetX - this.dragFrom.x) / e.target.clientWidth
this.area.y = (e.offsetY - this.dragFrom.y) / e.target.clientHeight
}
},
startDrag (e) {
+226 -79
View File
@@ -26,7 +26,7 @@
@update:model-value="updateName"
/>
</div>
<div class="space-x-3 flex items-center">
<div class="space-x-3 flex items-center flex-shrink-0">
<slot
v-if="$slots.buttons"
name="buttons"
@@ -40,7 +40,7 @@
@click="maybeShowEmptyTemplateAlert"
>
<IconWritingSign
width="20"
width="22"
class="inline"
/>
<span class="hidden md:inline">
@@ -94,11 +94,13 @@
</div>
</div>
<div
id="main_container"
class="flex"
:class="$slots.buttons || withTitle ? 'md:max-h-[calc(100%_-_60px)]' : 'md:max-h-[100%]'"
>
<div
v-if="withDocumentsList"
id="documents_container"
ref="previews"
:style="{ 'display': isBreakpointLg ? 'none' : 'initial' }"
class="overflow-y-auto overflow-x-hidden w-52 flex-none pr-3 mt-0.5 pt-0.5 hidden lg:block"
@@ -113,7 +115,6 @@
:with-replace-button="withUploadButton"
:editable="editable"
:template="template"
:is-direct-upload="isDirectUpload"
@scroll-to="scrollIntoDocument(item)"
@remove="onDocumentRemove"
@replace="onDocumentReplace"
@@ -129,12 +130,14 @@
v-if="sortedDocuments.length && editable && withUploadButton"
:accept-file-types="acceptFileTypes"
:template-id="template.id"
:is-direct-upload="isDirectUpload"
@success="updateFromUpload"
/>
</div>
</div>
<div class="w-full overflow-y-hidden md:overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5">
<div
id="pages_container"
class="w-full overflow-y-hidden md:overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5"
>
<div
ref="documents"
class="pr-3.5 pl-0.5"
@@ -143,7 +146,6 @@
v-if="!sortedDocuments.length && withUploadButton"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
:is-direct-upload="isDirectUpload"
@success="updateFromUpload"
/>
<template v-else>
@@ -161,9 +163,10 @@
:allow-draw="!onlyDefinedFields"
:default-submitters="defaultSubmitters"
:draw-field="drawField"
:draw-field-type="drawFieldType"
:editable="editable"
:base-url="baseUrl"
@draw="onDraw"
@draw="[onDraw($event), withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]"
@drop-field="onDropfield"
@remove-area="removeArea"
/>
@@ -172,9 +175,9 @@
:with-arrows="template.schema.length > 1"
:item="template.schema.find((item) => item.attachment_uuid === document.uuid)"
:with-replace-button="withUploadButton"
:accept-file-types="acceptFileTypes"
:document="document"
:template="template"
:is-direct-upload="isDirectUpload"
class="pb-2 mb-2 border-b border-base-300 border-dashed"
@remove="onDocumentRemove"
@replace="onDocumentReplace"
@@ -190,7 +193,6 @@
<Upload
v-if="withUploadButton"
:template-id="template.id"
:is-direct-upload="isDirectUpload"
:accept-file-types="acceptFileTypes"
@success="updateFromUpload"
/>
@@ -200,17 +202,18 @@
</div>
<div
v-if="withFieldsList"
id="fields_list_container"
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block"
:class="drawField ? 'overflow-hidden' : 'overflow-y-auto overflow-x-hidden'"
>
<div
v-if="drawField"
v-if="showDrawField || drawField"
class="sticky inset-0 h-full z-20"
:style="{ backgroundColor }"
>
<div class="bg-base-300 rounded-lg p-5 text-center space-y-4">
<div class="bg-base-200 rounded-lg p-5 text-center space-y-4">
<p>
{{ t('draw_field_on_the_document').replace('{field}', drawField.name) }}
{{ t('draw_field_on_the_document').replace('{field}', drawField?.name || '') }}
</p>
<div>
<button
@@ -220,10 +223,10 @@
{{ t('cancel') }}
</button>
<a
v-if="!drawOption && !drawField.areas.length && !['stamp', 'signature', 'initials'].includes(drawField.type)"
v-if="!drawField && !drawOption && !['stamp', 'signature', 'initials'].includes(drawField?.type || drawFieldType)"
href="#"
class="link block mt-3 text-sm"
@click.prevent="[drawField = null, drawOption = null]"
@click.prevent="[addField(drawFieldType), drawField = null, drawOption = null, withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]"
>
{{ t('or_add_field_without_drawing') }}
</a>
@@ -238,12 +241,15 @@
:selected-submitter="selectedSubmitter"
:with-help="withHelp"
:default-submitters="defaultSubmitters"
:draw-field-type="drawFieldType"
:default-fields="defaultFields"
:field-types="fieldTypes"
:with-sticky-submitters="withStickySubmitters"
:only-defined-fields="onlyDefinedFields"
:editable="editable"
@add-field="addField"
@set-draw="[drawField = $event.field, drawOption = $event.option]"
@set-draw-type="[drawFieldType = $event, showDrawField = true]"
@set-drag="dragField = $event"
@change-submitter="selectedSubmitter = $event"
@drag-end="dragField = null"
@@ -273,6 +279,7 @@
@select="startFieldDraw($event)"
/>
</div>
<div id="docuseal_modal_container" />
</div>
</template>
@@ -321,7 +328,12 @@ export default {
backgroundColor: this.backgroundColor,
withPhone: this.withPhone,
withPayment: this.withPayment,
selectedAreaRef: computed(() => this.selectedAreaRef)
isPaymentConnected: this.isPaymentConnected,
withFormula: this.withFormula,
withConditions: this.withConditions,
defaultDrawFieldType: this.defaultDrawFieldType,
selectedAreaRef: computed(() => this.selectedAreaRef),
fieldsDragFieldRef: computed(() => this.fieldsDragFieldRef)
}
},
props: {
@@ -329,11 +341,6 @@ export default {
type: Object,
required: true
},
isDirectUpload: {
type: Boolean,
required: false,
default: false
},
i18n: {
type: Object,
required: false,
@@ -364,6 +371,16 @@ export default {
required: false,
default: () => []
},
withSelectedFieldType: {
type: Boolean,
required: false,
default: false
},
defaultDrawFieldType: {
type: String,
required: false,
default: 'text'
},
currencies: {
type: Array,
required: false,
@@ -428,6 +445,11 @@ export default {
required: false,
default: true
},
authenticityToken: {
type: String,
required: false,
default: ''
},
withDocumentsList: {
type: Boolean,
required: false,
@@ -443,6 +465,21 @@ export default {
required: false,
default: false
},
isPaymentConnected: {
type: Boolean,
required: false,
default: false
},
withFormula: {
type: Boolean,
required: false,
default: false
},
withConditions: {
type: Boolean,
required: false,
default: false
},
onlyDefinedFields: {
type: Boolean,
required: false,
@@ -460,13 +497,16 @@ export default {
isBreakpointLg: false,
isSaving: false,
selectedSubmitter: null,
showDrawField: false,
drawField: null,
drawFieldType: null,
drawOption: null,
dragField: null
}
},
computed: {
selectedAreaRef: () => ref(),
fieldsDragFieldRef: () => ref(),
fieldAreasIndex () {
const areas = {}
@@ -492,6 +532,12 @@ export default {
}
},
created () {
if (!this.template.fields?.length && this.template.submitters?.length === 1) {
if (this.template.submitters[0]?.name === 'First Party') {
this.template.submitters[0].name = this.t('first_party')
}
}
const existingSubmittersUuids = this.defaultSubmitters.map((name) => {
return this.template.submitters.find(e => e.name === name)?.uuid
})
@@ -502,7 +548,7 @@ export default {
submitter.name = name
if (existingSubmittersUuids.filter(Boolean).length) {
submitter.uuid = existingSubmittersUuids[index] || v4()
submitter.uuid = existingSubmittersUuids[index] || submitter.uuid || v4()
} else {
submitter.uuid ||= v4()
}
@@ -542,6 +588,34 @@ export default {
t (key) {
return this.i18n[key] || i18nEn[key] || key
},
addField (type, area = null) {
const field = {
name: '',
uuid: v4(),
required: type !== 'checkbox',
areas: area ? [area] : [],
submitter_uuid: this.selectedSubmitter.uuid,
type
}
if (['select', 'multiple', 'radio'].includes(type)) {
field.options = [{ value: '', uuid: v4() }]
}
if (type === 'stamp') {
field.readonly = true
}
if (type === 'date') {
field.preferences = {
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
}
}
this.template.fields.push(field)
this.save()
},
startFieldDraw ({ name, type }) {
const existingField = this.template.fields?.find((f) => f.submitter_uuid === this.selectedSubmitter.uuid && name && name === f.name)
@@ -622,15 +696,13 @@ export default {
ref.$el.scrollIntoView({ behavior: 'smooth', block: 'start' })
},
clearDrawField () {
if (this.drawField && !this.drawOption && this.drawField.areas.length === 0) {
const fieldIndex = this.template.fields.indexOf(this.drawField)
if (fieldIndex !== -1) {
this.template.fields.splice(fieldIndex, 1)
}
}
this.drawField = null
this.drawOption = null
this.showDrawField = false
if (!this.withSelectedFieldType) {
this.drawFieldType = ''
}
},
onKeyUp (e) {
if (e.code === 'Escape') {
@@ -680,6 +752,27 @@ export default {
}
}
},
setDefaultAreaSize (area, type) {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
const pageMask = documentRef.pageRefs[area.page].$refs.mask
if (type === 'checkbox') {
area.w = pageMask.clientWidth / 30 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 30 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
} else if (type === 'image') {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
} else if (type === 'signature' || type === 'stamp') {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight) / 2
} else if (type === 'initials') {
area.w = pageMask.clientWidth / 10 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
} else {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
}
},
onDraw (area) {
if (this.drawField) {
if (this.drawOption) {
@@ -702,25 +795,7 @@ export default {
area.w = previousArea.w
area.h = previousArea.h
} else {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
const pageMask = documentRef.pageRefs[area.page].$refs.mask
if (this.drawField.type === 'checkbox' || this.drawOption) {
area.w = pageMask.clientWidth / 30 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 30 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
} else if (this.drawField.type === 'image') {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
} else if (this.drawField.type === 'signature' || this.drawField.type === 'stamp') {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight) / 2
} else if (this.drawField.type === 'initials') {
area.w = pageMask.clientWidth / 10 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
} else {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
}
this.setDefaultAreaSize(area, this.drawOption ? 'checkbox' : this.drawField?.type)
}
area.x -= area.w / 2
@@ -755,11 +830,15 @@ export default {
let type = (pageMask.clientWidth * area.w) < 35 ? 'checkbox' : 'text'
if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes(type)) {
if (this.drawFieldType) {
type = this.drawFieldType
} else if (this.defaultDrawFieldType && this.defaultDrawFieldType !== 'text') {
type = this.defaultDrawFieldType
} else if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes(type)) {
type = this.fieldTypes[0]
}
if (type === 'checkbox') {
if (type === 'checkbox' && !this.drawFieldType) {
const previousField = [...this.template.fields].reverse().find((f) => f.type === type)
const previousArea = previousField?.areas?.[previousField.areas.length - 1]
@@ -777,26 +856,27 @@ export default {
}
}
if (area.w) {
const field = {
name: '',
uuid: v4(),
required: type !== 'checkbox',
type,
submitter_uuid: this.selectedSubmitter.uuid,
areas: [area]
if (this.drawFieldType && (area.w === 0 || area.h === 0)) {
if (this.selectedField?.type === this.drawFieldType) {
area.w = this.selectedAreaRef.value.w
area.h = this.selectedAreaRef.value.h
} else {
this.setDefaultAreaSize(area, this.drawFieldType)
}
this.template.fields.push(field)
area.x -= area.w / 2
area.y -= area.h / 2
}
if (area.w) {
this.addField(type, area)
this.selectedAreaRef.value = area
this.save()
}
}
},
onDropfield (area) {
const field = {
const field = this.fieldsDragFieldRef.value || {
name: '',
uuid: v4(),
submitter_uuid: this.selectedSubmitter.uuid,
@@ -804,17 +884,19 @@ export default {
...this.dragField
}
if (['select', 'multiple', 'radio'].includes(field.type)) {
field.options = [{ value: '', uuid: v4() }]
}
if (!this.fieldsDragFieldRef.value) {
if (['select', 'multiple', 'radio'].includes(field.type)) {
field.options = [{ value: '', uuid: v4() }]
}
if (field.type === 'stamp') {
field.readonly = true
}
if (field.type === 'stamp') {
field.readonly = true
}
if (field.type === 'date') {
field.preferences = {
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
if (field.type === 'date') {
field.preferences = {
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
}
}
}
@@ -870,22 +952,46 @@ export default {
fieldArea.cell_w = baseArea.cell_w || (baseArea.w / 5)
}
field.areas = [fieldArea]
field.areas ||= []
const lastArea = field.areas[field.areas.length - 1]
if (lastArea) {
fieldArea.x -= lastArea.w / 2
fieldArea.w = lastArea.w
fieldArea.h = lastArea.h
}
field.areas.push(fieldArea)
this.selectedAreaRef.value = fieldArea
this.template.fields.push(field)
if (this.template.fields.indexOf(field) === -1) {
this.template.fields.push(field)
}
this.save()
},
updateFromUpload ({ schema, documents }) {
this.template.schema.push(...schema)
this.template.documents.push(...documents)
updateFromUpload (data) {
this.template.schema.push(...data.schema)
this.template.documents.push(...data.documents)
if (data.fields) {
this.template.fields = data.fields
}
if (data.submitters) {
this.template.submitters = data.submitters
if (!this.template.submitters.find((s) => s.uuid === this.selectedSubmitter?.uuid)) {
this.selectedSubmitter = this.template.submitters[0]
}
}
this.$nextTick(() => {
this.$refs.previews.scrollTop = this.$refs.previews.scrollHeight
this.scrollIntoDocument(schema[0])
this.scrollIntoDocument(data.schema[0])
})
if (this.template.name === 'New Document') {
@@ -908,19 +1014,56 @@ export default {
this.template.schema.splice(this.template.schema.indexOf(item), 1)
}
const removedFieldUuids = []
this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === item.attachment_uuid) {
field.areas.splice(field.areas.indexOf(area), 1)
removedFieldUuids.push(field.uuid)
}
})
})
this.template.fields =
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
this.save()
},
onDocumentReplace ({ replaceSchemaItem, schema, documents }) {
onDocumentReplace (data) {
const { replaceSchemaItem, schema, documents } = data
this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, schema[0])
this.template.documents.push(...documents)
if (data.fields) {
this.template.fields = data.fields
const removedFieldUuids = []
this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
field.areas.splice(field.areas.indexOf(area), 1)
removedFieldUuids.push(field.uuid)
}
})
})
this.template.fields =
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
}
if (data.submitters) {
this.template.submitters = data.submitters
if (!this.template.submitters.find((s) => s.uuid === this.selectedSubmitter?.uuid)) {
this.selectedSubmitter = this.template.submitters[0]
}
}
this.template.fields.forEach((field) => {
(field.areas || []).forEach((area) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
@@ -980,7 +1123,11 @@ export default {
baseFetch (path, options = {}) {
return fetch(this.baseUrl + path, {
...options,
headers: { ...this.fetchOptions.headers, ...options.headers }
headers: {
'X-CSRF-Token': this.authenticityToken,
...this.fetchOptions.headers,
...options.headers
}
})
},
save ({ force } = { force: false }) {
@@ -996,7 +1143,7 @@ export default {
this.pushUndo()
return this.baseFetch(`/api/templates/${this.template.id}`, {
return this.baseFetch(`/templates/${this.template.id}`, {
method: 'PUT',
body: JSON.stringify({
template: {
@@ -0,0 +1,195 @@
<template>
<div
class="modal modal-open items-start !animate-none overflow-y-auto"
>
<div
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span>
{{ t('condition') }} - {{ field.name || buildDefaultName(field, template.fields) }}
</span>
<a
href="#"
class="text-xl"
@click.prevent="$emit('close')"
>&times;</a>
</div>
<div>
<div
v-if="!withConditions"
class="bg-base-300 rounded-xl py-2 px-3 text-center"
>
<a
href="https://www.docuseal.co/pricing"
target="_blank"
class="link"
>Available in Pro</a>
</div>
<form @submit.prevent="validateSaveAndClose">
<div class="my-4">
<div class="space-y-4">
<select
class="select select-bordered select-sm w-full bg-white h-11 pl-4 text-base font-normal"
required
@change="[
newCondition.field_uuid = $event.target.value,
delete newCondition.value,
(conditionActions.includes(newCondition.action) ? '' : newCondition.action = conditionActions[0])
]"
>
<option
value=""
disabled
:selected="!newCondition.field_uuid"
>
{{ t('select_field_') }}
</option>
<option
v-for="f in fields"
:key="f.uuid"
:value="f.uuid"
:selected="newCondition.field_uuid === f.uuid"
>
{{ f.name || buildDefaultName(f, template.fields) }}
</option>
</select>
<select
v-model="newCondition.action"
class="select select-bordered select-sm w-full h-11 pl-4 text-base font-normal"
:class="{ 'bg-white': newCondition.field_uuid, 'bg-base-300': !newCondition.field_uuid }"
:required="newCondition.field_uuid"
>
<option
v-for="action in conditionActions"
:key="action"
:value="action"
>
{{ t(action) }}
</option>
</select>
<select
v-if="conditionField?.options?.length"
class="select select-bordered select-sm w-full bg-white h-11 pl-4 text-base font-normal"
required
@change="newCondition.value = $event.target.value"
>
<option
value=""
disabled
selected
>
{{ t('select_value_') }}
</option>
<option
v-for="(option, index) in conditionField.options"
:key="option.uuid"
:value="option.uuid"
:selected="newCondition.value === option.uuid"
>
{{ option.value || `${t('option')} ${index + 1}` }}
</option>
</select>
</div>
</div>
<button
class="base-button w-full mt-2"
>
{{ t('save') }}
</button>
</form>
<div
v-if="field.conditions?.[0]?.field_uuid"
class="text-center w-full mt-4"
>
<button
class="link"
@click="[newCondition.field_uuid = null, delete field.conditions, validateSaveAndClose()]"
>
{{ t('remove_condition') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ConditionModal',
inject: ['t', 'save', 'template', 'withConditions'],
props: {
field: {
type: Object,
required: true
},
buildDefaultName: {
type: Function,
required: true
}
},
emits: ['close'],
data () {
return {
newCondition: this.field.conditions?.[0] || {}
}
},
computed: {
conditionField () {
return this.fields.find((f) => f.uuid === this.newCondition.field_uuid)
},
conditionActions () {
return this.fieldActions(this.conditionField)
},
fields () {
return this.template.fields.reduce((acc, f) => {
if (f !== this.field && f.submitter_uuid === this.field.submitter_uuid) {
acc.push(f)
}
return acc
}, [])
}
},
created () {
this.field.conditions ||= []
},
methods: {
fieldActions (field) {
const actions = []
if (!field) {
return actions
}
if (field.type === 'checkbox') {
actions.push('checked', 'unchecked')
} else if (['radio', 'select'].includes(field.type)) {
actions.push('equal', 'not_equal')
} else if (['multiple'].includes(field.type)) {
actions.push('contains', 'does_not_contain')
} else {
actions.push('not_empty', 'empty')
}
return actions
},
validateSaveAndClose () {
if (!this.withConditions) {
return alert('Available only in Pro')
}
if (this.newCondition.field_uuid) {
this.field.conditions = [this.newCondition]
} else {
delete this.field.conditions
}
this.save()
this.$emit('close')
}
}
}
</script>
@@ -14,7 +14,10 @@
@focus="$emit('focus', $event)"
@blur="onBlur"
>
{{ value }}
<template
v-for="(item, index) in (value || '').split(/(_)/)"
:key="index"
>{{ item }}<wbr></template>
</span>
<span
v-if="withRequired"
+5 -10
View File
@@ -8,8 +8,8 @@
/>
<ReplaceButton
v-if="withReplaceButton"
:is-direct-upload="isDirectUpload"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
@click.stop
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
/>
@@ -63,10 +63,10 @@ export default {
type: Object,
required: true
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
withReplaceButton: {
type: Boolean,
@@ -80,11 +80,6 @@ export default {
}
},
emits: ['change', 'remove', 'up', 'down', 'replace'],
mounted () {
if (this.isDirectUpload) {
import('@rails/activestorage')
}
},
methods: {
upload: Upload.methods.upload,
onUpdateName (value) {
@@ -0,0 +1,107 @@
<template>
<div
class="modal modal-open items-start !animate-none overflow-y-auto"
>
<div
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span>
{{ field.name || buildDefaultName(field, template.fields) }}
</span>
<a
href="#"
class="text-xl"
@click.prevent="$emit('close')"
>&times;</a>
</div>
<div>
<form @submit.prevent="saveAndClose">
<div class="space-y-1 mb-1">
<div>
<label
dir="auto"
class="label text-sm"
for="description_field"
>
{{ t('description') }}
</label>
<textarea
id="description_field"
ref="textarea"
v-model="description"
dir="auto"
class="base-textarea !text-base w-full"
@input="resizeTextarea"
/>
</div>
<div>
<label
dir="auto"
class="label text-sm"
for="title_field"
>
{{ t('display_title') }} ({{ t('optional') }})
</label>
<input
id="title_field"
v-model="title"
dir="auto"
class="base-input !text-base w-full"
>
</div>
</div>
<button
class="base-button w-full mt-4"
>
{{ t('save') }}
</button>
</form>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'DescriptionModal',
inject: ['t', 'save', 'template'],
props: {
field: {
type: Object,
required: true
},
buildDefaultName: {
type: Function,
required: true
}
},
emits: ['close'],
data () {
return {
description: this.field.description,
title: this.field.title
}
},
mounted () {
this.resizeTextarea()
},
methods: {
saveAndClose () {
this.field.description = this.description
this.field.title = this.title
this.save()
this.$emit('close')
},
resizeTextarea () {
const textarea = this.$refs.textarea
textarea.style.height = 'auto'
textarea.style.height = textarea.scrollHeight + 'px'
}
}
}
</script>
@@ -12,6 +12,7 @@
:default-fields="defaultFields"
:default-submitters="defaultSubmitters"
:draw-field="drawField"
:draw-field-type="drawFieldType"
:selected-submitter="selectedSubmitter"
:image="image"
@drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })"
@@ -43,6 +44,11 @@ export default {
required: false,
default: () => []
},
drawFieldType: {
type: String,
required: false,
default: ''
},
defaultSubmitters: {
type: Array,
required: false,
+1 -11
View File
@@ -5,7 +5,7 @@
@drop.prevent="onDropFiles"
>
<label
class="w-full relative bg-base-200 hover:bg-base-200/70 rounded-md border border-base-content border-dashed"
class="w-full relative hover:bg-base-200/30 rounded-md border border-2 border-base-content/10 border-dashed"
:for="inputId"
:class="{ 'opacity-50': isLoading || isProcessing }"
>
@@ -71,11 +71,6 @@ export default {
type: String,
required: false,
default: 'image/*, application/pdf'
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
}
},
emits: ['success'],
@@ -101,11 +96,6 @@ export default {
}
}
},
mounted () {
if (this.isDirectUpload) {
import('@rails/activestorage')
}
},
methods: {
upload: Upload.methods.upload,
onDropFiles (e) {
+226 -33
View File
@@ -1,6 +1,6 @@
<template>
<div
class="group pb-2"
class="list-field group mb-2"
>
<div
class="border border-base-300 rounded rounded-tr-none relative group"
@@ -65,6 +65,28 @@
:stroke-width="1.6"
/>
</button>
<button
v-if="field.preferences?.formula"
class="relative cursor-pointer text-transparent group-hover:text-base-content"
:title="t('formula')"
@click="isShowFormulaModal = true"
>
<IconMathFunction
:width="18"
:stroke-width="1.6"
/>
</button>
<button
v-if="field.conditions?.length"
class="relative cursor-pointer text-transparent group-hover:text-base-content"
:title="t('condition')"
@click="isShowConditionsModal = true"
>
<IconRouteAltLeft
:width="18"
:stroke-width="1.6"
/>
</button>
<PaymentSettings
v-if="field.type === 'payment'"
:field="field"
@@ -94,15 +116,41 @@
@click="closeDropdown"
>
<div
v-if="field.type === 'text' && !defaultField"
v-if="['number'].includes(field.type)"
class="py-1.5 px-1 relative"
@click.stop
>
<select
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal"
@change="[field.preferences ||= {}, field.preferences.align = $event.target.value, save()]"
>
<option
v-for="value in ['left', 'right', 'center']"
:key="value"
:selected="field.preferences?.align ? value === field.preferences.align : value === 'left'"
:value="value"
>
{{ t(value) }}
</option>
</select>
<label
:style="{ backgroundColor: backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('align') }}
</label>
</div>
<div
v-if="['text', 'number'].includes(field.type) && !defaultField"
class="py-1.5 px-1 relative"
@click.stop
>
<input
v-model="field.default_value"
type="text"
:placeholder="t('default_value')"
dir="auto"
:type="field.type"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
@blur="save"
>
@@ -180,7 +228,7 @@
</label>
</div>
<li
v-if="field.type != 'phone'"
v-if="field.type != 'phone' && field.type != 'stamp'"
@click.stop
>
<label class="cursor-pointer py-1.5">
@@ -193,6 +241,20 @@
<span class="label-text">{{ t('required') }}</span>
</label>
</li>
<li
v-if="field.type == 'stamp'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
:checked="field.preferences?.with_logo != false"
type="checkbox"
class="toggle toggle-xs"
@change="[field.preferences ||= {}, field.preferences.with_logo = field.preferences.with_logo == false, save()]"
>
<span class="label-text">{{ t('with_logo') }}</span>
</label>
</li>
<li
v-if="field.type == 'checkbox'"
@click.stop
@@ -222,7 +284,7 @@
</label>
</li>
<li
v-if="field.type === 'text' && !defaultField"
v-if="['text', 'number'].includes(field.type) && !defaultField"
@click.stop
>
<label class="cursor-pointer py-1.5">
@@ -235,9 +297,56 @@
<span class="label-text">{{ t('read_only') }}</span>
</label>
</li>
<hr
v-if="field.type != 'stamp'"
class="pb-0.5 mt-0.5"
>
<li
v-if="field.type != 'stamp'"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="isShowDescriptionModal = !isShowDescriptionModal"
>
<IconInfoCircle
width="18"
/>
<span class="text-sm">
{{ t('description') }}
</span>
</label>
</li>
<li
v-if="field.type != 'stamp'"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="isShowConditionsModal = !isShowConditionsModal"
>
<IconRouteAltLeft
width="18"
/>
<span class="text-sm">
{{ t('condition') }}
</span>
</label>
</li>
<li v-if="field.type == 'number'">
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="isShowFormulaModal = true"
>
<IconMathFunction
width="18"
/>
<span class="text-sm">
{{ t('formula') }}
</span>
</label>
</li>
<hr class="pb-0.5 mt-0.5">
<li
v-for="(area, index) in field.areas || []"
v-for="(area, index) in sortedAreas"
:key="index"
>
<a
@@ -249,7 +358,8 @@
:width="20"
:stroke-width="1.6"
/>
{{ t('page') }} {{ area.page + 1 }}
{{ t('page') }}
<template v-if="template.schema.length > 1">{{ template.schema.findIndex((item) => item.attachment_uuid === area.attachment_uuid) + 1 }}-</template>{{ area.page + 1 }}
</a>
</li>
<li v-if="!field.areas?.length || !['radio', 'multiple'].includes(field.type)">
@@ -308,7 +418,7 @@
{{ index + 1 }}.
</span>
<div
v-if="['radio', 'multiple'].includes(field.type) && (index > 0 || field.areas.find((a) => a.option_uuid) || !field.areas.length) && !field.areas.find((a) => a.option_uuid === option.uuid)"
v-if="editable && ['radio', 'multiple'].includes(field.type) && (index > 0 || field.areas.find((a) => a.option_uuid) || !field.areas.length) && !field.areas.find((a) => a.option_uuid === option.uuid)"
class="items-center flex w-full"
>
<input
@@ -337,12 +447,14 @@
class="w-full input input-primary input-xs text-sm bg-transparent"
:placeholder="`${t('option')} ${index + 1}`"
type="text"
:readonly="!editable"
required
dir="auto"
@focus="maybeFocusOnOptionArea(option)"
@blur="save"
>
<button
v-if="editable"
class="text-sm w-3.5"
tabindex="-1"
@click="removeOption(option)"
@@ -350,8 +462,12 @@
&times;
</button>
</div>
<div
v-if="field.options && !editable"
class="pb-1"
/>
<button
v-if="field.options"
v-else-if="field.options && editable"
class="text-center text-sm w-full pb-1"
@click="addOption"
>
@@ -359,6 +475,36 @@
</button>
</div>
</div>
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
: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"
:build-default-name="buildDefaultName"
@close="isShowDescriptionModal = false"
/>
</Teleport>
</div>
</template>
@@ -366,7 +512,10 @@
import Contenteditable from './contenteditable'
import FieldType from './field_type'
import PaymentSettings from './payment_settings'
import { IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
import FormulaModal from './formula_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import { IconInfoCircle, IconRouteAltLeft, IconMathFunction, IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
@@ -377,7 +526,13 @@ export default {
IconShape,
PaymentSettings,
IconNewSection,
IconInfoCircle,
FormulaModal,
DescriptionModal,
ConditionsModal,
IconRouteAltLeft,
IconTrashX,
IconMathFunction,
IconCopy,
FieldType
},
@@ -403,13 +558,31 @@ export default {
return {
isNameFocus: false,
showPaymentModal: false,
isShowFormulaModal: false,
isShowConditionsModal: false,
isShowDescriptionModal: false,
renderDropdown: false
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
schemaAttachmentsIndexes () {
return (this.template.schema || []).reduce((acc, item, index) => {
acc[item.attachment_uuid] = index
return acc
}, {})
},
sortedAreas () {
return (this.field.areas || []).sort((a, b) => {
return this.schemaAttachmentsIndexes[a.attachment_uuid] - this.schemaAttachmentsIndexes[b.attachment_uuid]
})
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
dateFormats () {
return [
const formats = [
'MM/DD/YYYY',
'DD/MM/YYYY',
'YYYY-MM-DD',
@@ -420,24 +593,19 @@ export default {
'D MMM YYYY',
'D MMMM YYYY'
]
if (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko')) {
formats.push('YYYY년 MM월 DD일')
}
if (this.field.preferences?.format && !formats.includes(this.field.preferences.format)) {
formats.unshift(this.field.preferences.format)
}
return formats
},
defaultName () {
if (this.field.type === 'payment' && this.field.preferences?.price) {
const { price, currency } = this.field.preferences || {}
const formattedPrice = new Intl.NumberFormat([], {
style: 'currency',
currency
}).format(price)
return `${this.fieldNames[this.field.type]} ${formattedPrice}`
} else {
const typeIndex = this.template.fields.filter((f) => f.type === this.field.type).indexOf(this.field)
const suffix = { multiple: this.t('select'), radio: this.t('group') }[this.field.type] || this.t('field')
return `${this.fieldNames[this.field.type]} ${suffix} ${typeIndex + 1}`
}
return this.buildDefaultName(this.field, this.template.fields)
},
areas () {
return this.field.areas || []
@@ -452,6 +620,24 @@ export default {
}
},
methods: {
buildDefaultName (field, fields) {
if (field.type === 'payment' && field.preferences?.price) {
const { price, currency } = field.preferences || {}
const formattedPrice = new Intl.NumberFormat([], {
style: 'currency',
currency
}).format(price)
return `${this.fieldNames[field.type]} ${formattedPrice}`
} else {
const typeIndex = fields.filter((f) => f.type === field.type).indexOf(field)
const suffix = { multiple: this.t('select'), radio: this.t('group') }[field.type] || this.t('field')
return `${this.fieldNames[field.type]} ${suffix} ${typeIndex + 1}`
}
},
formatDate (date, format) {
const monthFormats = {
M: 'numeric',
@@ -485,11 +671,13 @@ export default {
const areaString = JSON.stringify(field.areas[0])
this.template.documents.forEach((attachment) => {
attachment.preview_images.forEach((page) => {
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === parseInt(page.filename))) {
field.areas.push({ ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page: parseInt(page.filename) })
const numberOfPages = attachment.metadata?.pdf?.number_of_pages || attachment.preview_images.length
for (let page = 0; page <= numberOfPages - 1; page++) {
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === page)) {
field.areas.push({ ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page })
}
})
}
})
this.$nextTick(() => {
@@ -515,7 +703,7 @@ export default {
}
},
scrollToFirstArea () {
return this.field.areas?.[0] && this.$emit('scroll-to', this.field.areas[0])
return this.sortedAreas[0] && this.$emit('scroll-to', this.sortedAreas[0])
},
closeDropdown () {
document.activeElement.blur()
@@ -533,7 +721,12 @@ export default {
},
removeOption (option) {
this.field.options.splice(this.field.options.indexOf(option), 1)
this.field.areas.splice(this.field.areas.findIndex((a) => a.option_uuid === option.uuid), 1)
const optionIndex = this.field.areas.findIndex((a) => a.option_uuid === option.uuid)
if (optionIndex !== -1) {
this.field.areas.splice(this.field.areas.findIndex((a) => a.option_uuid === option.uuid), 1)
}
this.save()
},
@@ -95,7 +95,7 @@
<label
v-if="compact"
tabindex="0"
:title="selectedSubmitter.name"
:title="selectedSubmitter?.name"
class="cursor-pointer text-base-100 flex h-full items-center justify-center"
>
<button
@@ -50,7 +50,7 @@
</template>
<script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp } from '@tabler/icons-vue'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1 } from '@tabler/icons-vue'
export default {
name: 'FiledTypeDropdown',
@@ -94,6 +94,7 @@ export default {
signature: this.t('signature'),
initials: this.t('initials'),
date: this.t('date'),
number: this.t('number'),
image: this.t('image'),
file: this.t('file'),
select: this.t('select'),
@@ -112,12 +113,13 @@ export default {
signature: IconWritingSign,
initials: IconLetterCaseUpper,
date: IconCalendarEvent,
number: IconSquareNumber1,
image: IconPhoto,
file: IconPaperclip,
checkbox: IconCheckbox,
multiple: IconChecks,
file: IconPaperclip,
radio: IconCircleDot,
select: IconSelect,
multiple: IconChecks,
cells: IconColumns3,
stamp: IconRubberStamp,
payment: IconCreditCard,
+94 -68
View File
@@ -2,7 +2,7 @@
<div :class="withStickySubmitters ? 'sticky top-0 z-10' : ''">
<FieldSubmitter
:model-value="selectedSubmitter.uuid"
class="w-full rounded-lg"
class="roles-dropdown w-full rounded-lg"
:class="{ 'bg-base-100': withStickySubmitters }"
:submitters="submitters"
:editable="editable && !defaultSubmitters.length"
@@ -14,7 +14,7 @@
</div>
<div
ref="fields"
class="mb-1 mt-2"
class="fields mb-1 mt-2"
@dragover.prevent="onFieldDragover"
@drop="reorderFields"
>
@@ -24,11 +24,11 @@
:data-uuid="field.uuid"
:field="field"
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)"
:editable="editable && (!dragField || dragField !== field)"
:editable="editable && (!fieldsDragFieldRef.value || fieldsDragFieldRef.value !== field)"
:default-field="defaultFields.find((f) => f.name === field.name)"
:draggable="editable"
@dragstart="dragField = field"
@dragend="dragField = null"
@dragstart="fieldsDragFieldRef.value = field"
@dragend="fieldsDragFieldRef.value = null"
@remove="removeField"
@scroll-to="$emit('scroll-to-area', $event)"
@set-draw="$emit('set-draw', $event)"
@@ -36,32 +36,63 @@
</div>
<div v-if="submitterDefaultFields.length">
<hr class="mb-2">
<template
v-for="field in submitterDefaultFields"
:key="field.name"
<template v-if="isShowFieldSearch">
<input
v-model="defaultFieldsSearch"
:placeholder="t('search_field')"
class="input input-ghost input-xs px-0 text-base mb-2 !outline-0 !rounded bg-transparent w-full"
>
<hr class="mb-2">
</template>
<div
class="overflow-auto relative"
:style="{
maxHeight: isShowFieldSearch ? '210px' : '',
minHeight: isShowFieldSearch ? '210px' : ''
}"
>
<div
:style="{ backgroundColor: backgroundColor }"
draggable="true"
class="border border-base-300 rounded rounded-tr-none relative group mb-2"
@dragstart="onDragstart({ type: 'text', ...field })"
@dragend="$emit('drag-end')"
v-if="!filteredSubmitterDefaultFields.length && defaultFieldsSearch"
class="top-0 bottom-0 text-center absolute flex items-center justify-center w-full flex-col"
>
<div class="flex items-center justify-between relative cursor-grab">
<div class="flex items-center p-1 space-x-1">
<IconDrag />
<FieldType
:model-value="field.type || 'text'"
:editable="false"
:button-width="20"
/>
<span class="block pl-0.5">
{{ field.name }}
</span>
<div>
{{ t('field_not_found') }}
</div>
<a
href="#"
class="link"
@click.prevent="defaultFieldsSearch = ''"
>
{{ t('clear') }}
</a>
</div>
<template
v-for="field in filteredSubmitterDefaultFields"
:key="field.name"
>
<div
:style="{ backgroundColor: backgroundColor }"
draggable="true"
class="default-field border border-base-300 rounded rounded-tr-none relative group mb-2"
@dragstart="onDragstart({ type: 'text', ...field })"
@dragend="$emit('drag-end')"
>
<div class="flex items-center justify-between relative cursor-grab">
<div class="flex items-center p-1 space-x-1">
<IconDrag />
<FieldType
:model-value="field.type || 'text'"
:editable="false"
:button-width="20"
/>
<span class="block pl-0.5">
{{ field.name }}
</span>
</div>
</div>
</div>
</div>
</template>
</template>
</div>
</div>
<div
v-if="editable && !onlyDefinedFields"
@@ -74,14 +105,18 @@
<button
v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')"
draggable="true"
class="group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative"
class="field-type-button group flex items-center justify-center border border-dashed w-full rounded relative"
:style="{ backgroundColor: backgroundColor }"
:class="drawFieldType === type ? 'border-base-content/40' : 'border-base-300 hover:border-base-content/20'"
@dragstart="onDragstart({ type: type })"
@dragend="$emit('drag-end')"
@click="addField(type)"
@click="['file', 'payment'].includes(type) ? $emit('add-field', type) : $emit('set-draw-type', type)"
>
<div class="flex items-console group-hover:bg-base-200/50 transition-all cursor-grab h-full absolute left-0">
<IconDrag class=" my-auto" />
<div
class="flex items-console transition-all cursor-grab h-full absolute left-0"
:class="drawFieldType === type ? 'bg-base-200/50' : 'group-hover:bg-base-200/50'"
>
<IconDrag class="my-auto" />
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="icon" />
@@ -93,7 +128,7 @@
<div
v-else-if="type == 'phone' && (fieldTypes.length === 0 || fieldTypes.includes(type))"
class="tooltip tooltip-bottom flex"
:class="{'tooltip-bottom-start': !withPayment, 'tooltip-bottom': withPayment }"
:class="{'tooltip-bottom-end': withPayment, 'tooltip-bottom': !withPayment }"
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
>
<a
@@ -139,10 +174,9 @@
<script>
import Field from './field'
import { v4 } from 'uuid'
import FieldType from './field_type'
import FieldSubmitter from './field_submitter'
import { IconLock } from '@tabler/icons-vue'
import { IconLock, IconCirclePlus } from '@tabler/icons-vue'
import IconDrag from './icon_drag'
export default {
@@ -150,11 +184,12 @@ export default {
components: {
Field,
FieldType,
IconCirclePlus,
FieldSubmitter,
IconDrag,
IconLock
},
inject: ['save', 'backgroundColor', 'withPhone', 'withPayment', 't'],
inject: ['save', 'backgroundColor', 'withPhone', 'withPayment', 't', 'fieldsDragFieldRef'],
props: {
fields: {
type: Array,
@@ -180,6 +215,11 @@ export default {
required: false,
default: true
},
drawFieldType: {
type: String,
required: false,
default: ''
},
defaultSubmitters: {
type: Array,
required: false,
@@ -204,15 +244,18 @@ export default {
required: true
}
},
emits: ['set-draw', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter'],
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter'],
data () {
return {
dragField: null
defaultFieldsSearch: ''
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons,
isShowFieldSearch () {
return this.submitterDefaultFields.length > 15
},
fieldIconsSorted () {
if (this.fieldTypes.length) {
return this.fieldTypes.reduce((acc, type) => {
@@ -231,6 +274,13 @@ export default {
return this.defaultFields.filter((f) => {
return !this.submitterFields.find((field) => field.name === f.name) && (!f.role || f.role === this.selectedSubmitter.name)
})
},
filteredSubmitterDefaultFields () {
if (this.defaultFieldsSearch) {
return this.submitterDefaultFields.filter((f) => f.name.toLowerCase().includes(this.defaultFieldsSearch.toLowerCase()))
} else {
return this.submitterDefaultFields
}
}
},
methods: {
@@ -239,7 +289,7 @@ export default {
},
onFieldDragover (e) {
const targetField = e.target.closest('[data-uuid]')
const dragField = this.$refs.fields.querySelector(`[data-uuid="${this.dragField.uuid}"]`)
const dragField = this.$refs.fields.querySelector(`[data-uuid="${this.fieldsDragFieldRef.value.uuid}"]`)
if (dragField && targetField && targetField !== dragField) {
const fields = Array.from(this.$refs.fields.children)
@@ -283,37 +333,13 @@ export default {
removeField (field) {
this.fields.splice(this.fields.indexOf(field), 1)
this.save()
},
addField (type, area = null) {
const field = {
name: '',
uuid: v4(),
required: type !== 'checkbox',
areas: [],
submitter_uuid: this.selectedSubmitter.uuid,
type
}
if (['select', 'multiple', 'radio'].includes(type)) {
field.options = [{ value: '', uuid: v4() }]
}
if (type === 'stamp') {
field.readonly = true
}
if (type === 'date') {
field.preferences = {
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
}
}
this.fields.push(field)
if (!['payment', 'file'].includes(type)) {
this.$emit('set-draw', { field })
}
this.fields.forEach((f) => {
(f.conditions || []).forEach((c) => {
if (c.field_uuid === field.uuid) {
f.conditions.splice(f.conditions.indexOf(c), 1)
}
})
})
this.save()
}
@@ -0,0 +1,230 @@
<template>
<div
class="modal modal-open items-start !animate-none overflow-y-auto"
>
<div
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span>
{{ t('formula') }} - {{ field.name || buildDefaultName(field, template.fields) }}
</span>
<a
href="#"
class="text-xl"
@click.prevent="$emit('close')"
>&times;</a>
</div>
<div>
<div
v-if="!withFormula"
class="bg-base-300 rounded-xl py-2 px-3 text-center"
>
<a
href="https://www.docuseal.co/pricing"
target="_blank"
class="link"
>Available in Pro</a>
</div>
<div class="flex-inline mb-2 gap-2 space-y-1">
<button
v-for="f in fields"
:key="f.uuid"
class="mr-1 btn btn-neutral btn-outline border-base-content/20 btn-sm normal-case font-normal bg-white !rounded-xl"
@click.prevent="insertTextUnderCursor(`{{${f.name || buildDefaultName(f, template.fields)}}}`)"
>
<IconCodePlus
width="20"
height="20"
stroke-width="1.5"
/>
{{ f.name || buildDefaultName(f, template.fields) }}
</button>
</div>
<div>
<div class="flex">
<textarea
ref="textarea"
v-model="formula"
class="base-textarea !rounded-xl !text-base font-mono w-full !outline-0 !ring-0 !px-3"
required="true"
@input="resizeTextarea"
/>
</div>
<div class="mb-3 mt-1">
<div
target="blank"
class="text-sm mb-2 inline space-x-2"
>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor(' + ')"
>
+
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor(' - ')"
>
-
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor(' * ')"
>
*
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor(' / ')"
>
/
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor(' % ')"
>
%
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor('^')"
>
^
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor('round()')"
>
round(n, d)
</button>
<button
class="bg-base-200 px-2 rounded-xl"
@click="insertTextUnderCursor('abs()')"
>
abs(n)
</button>
</div>
</div>
</div>
<button
class="base-button w-full"
@click.prevent="validateSaveAndClose"
>
{{ t('save') }}
</button>
</div>
</div>
</div>
</template>
<script>
import { IconCodePlus } from '@tabler/icons-vue'
export default {
name: 'FormulaModal',
components: {
IconCodePlus
},
inject: ['t', 'save', 'template', 'withFormula'],
props: {
field: {
type: Object,
required: true
},
buildDefaultName: {
type: Function,
required: true
}
},
emits: ['close'],
data () {
return {
formula: ''
}
},
computed: {
fields () {
return this.template.fields.reduce((acc, f) => {
if (f !== this.field && f.submitter_uuid === this.field.submitter_uuid && ['number'].includes(f.type) && !f.preferences?.formula) {
acc.push(f)
}
return acc
}, [])
}
},
created () {
this.field.preferences ||= {}
},
mounted () {
this.formula = this.humanizeFormula(this.field.preferences.formula || '')
},
methods: {
humanizeFormula (text) {
return text.replace(/{{(.*?)}}/g, (match, uuid) => {
const foundField = this.fields.find((f) => f.uuid === uuid)
if (foundField) {
return `{{${foundField.name || this.buildDefaultName(foundField, this.template.fields)}}}`
} else {
return '{{FIELD NOT FOUND}}'
}
})
},
normalizeFormula (text) {
return text.replace(/{{(.*?)}}/g, (match, name) => {
const foundField = this.fields.find((f) => {
return (f.name || this.buildDefaultName(f, this.template.fields)).trim() === name.trim()
})
if (foundField) {
return `{{${foundField.uuid}}}`
} else {
return '{{FIELD NOT FOUND}}'
}
})
},
validateSaveAndClose () {
if (!this.withFormula) {
return alert('Available only in Pro')
}
const normalizedFormula = this.normalizeFormula(this.formula)
if (normalizedFormula.includes('FIELD NOT FOUND')) {
alert('Some fields are missing in the formula.')
} else {
this.field.preferences.formula = normalizedFormula
this.field.readonly = !!normalizedFormula
this.save()
this.$emit('close')
}
},
insertTextUnderCursor (textToInsert) {
const textarea = this.$refs.textarea
const selectionEnd = textarea.selectionEnd
const cursorPos = selectionEnd
const newText = textarea.value.substring(0, cursorPos) + textToInsert + textarea.value.substring(cursorPos)
this.formula = newText
textarea.setSelectionRange(cursorPos + textToInsert.length, cursorPos + textToInsert.length)
textarea.focus()
},
resizeTextarea () {
const textarea = this.$refs.textarea
textarea.style.height = 'auto'
textarea.style.height = textarea.scrollHeight + 'px'
}
}
}
</script>
+23
View File
@@ -1,4 +1,24 @@
const en = {
search_field: 'Search field',
field_not_found: 'Field not found',
clear: 'Clear',
align: 'Align',
left: 'Left',
right: 'Right',
center: 'Center',
description: 'Description',
display_title: 'Display title',
with_logo: 'With logo',
unchecked: 'Unchecked',
equal: 'Equal',
not_equal: 'Not equal',
contains: 'Contains',
does_not_contain: 'Does not contain',
not_empty: 'Not empty',
empty: 'Empty',
select_field_: 'Select field...',
select_value_: 'Select value...',
remove_condition: 'Remove condition',
are_you_sure: 'Are you sure?',
sign_yourself: 'Sign Yourself',
set_signing_date: 'Set signing date',
@@ -13,6 +33,7 @@ const en = {
cancel: 'Cancel',
any: 'Any',
drawn: 'Drawn',
formula: 'Formula',
typed: 'Typed',
draw_field_on_the_document: 'Draw {field} field on the document',
click_to_upload: 'Click to upload',
@@ -30,6 +51,7 @@ const en = {
copy_to_all_pages: 'Copy to All Pages',
add_option: 'Add option',
option: 'Option',
condition: 'Condition',
first_party: 'First Party',
second_party: 'Second Party',
third_party: 'Third Party',
@@ -54,6 +76,7 @@ const en = {
add: 'Add',
or_add_field_without_drawing: 'Or add field without drawing',
text: 'Text',
number: 'Number',
signature: 'Signature',
initials: 'Initials',
date: 'Date',
@@ -0,0 +1,412 @@
<template>
<div>
<div v-if="selectedSheetIndex === null && spreadsheet">
<form @submit.prevent="[selectedSheetIndex = $refs.selectWorksheet.value, buildDefaultMappings()]">
<label class="label">
Select Worksheet
</label>
<select
ref="selectWorksheet"
class="base-select"
>
<option
v-for="(sheet, index) in spreadsheet"
:key="index"
:value="index"
>
{{ sheet[0] || index }}
</option>
</select>
<button class="base-button mt-4 w-full">
Open
</button>
</form>
</div>
<div v-else-if="selectedSheetIndex !== null">
<div
v-for="submitter in submitters"
:key="submitter.uuid"
class="mb-4"
>
<div
v-if="submitters.length > 1"
class="px-3 border-y py-2 border-base-300 text-center w-full"
>
{{ submitter.name }}
</div>
<div class="flex">
<div class="relative w-full py-2 px-2 text-sm">
Recipient field
</div>
<div class="relative w-full py-2 pl-4 text-sm">
Spreadsheet column
</div>
</div>
<div
v-for="mapping in mappings.filter((m) => m.submitter_uuid === submitter.uuid)"
:key="mapping.uuid"
class="mb-2"
>
<div class="flex">
<select
class="base-select !select-sm !h-10"
required
@change="mapping.field_name = $event.target.value"
>
<option
disabled
value=""
:selected="!mapping.field_name"
>
Select Field
</option>
<option
v-for="(field, index) in selectFieldsForSubmitter(submitter)"
:key="index"
:value="field.name"
:selected="mapping.field_name === field.name"
>
{{ field.name }}
</option>
</select>
<div class="flex items-center px-1">
<IconArrowsHorizontal style="width: 19px; height: 19px" />
</div>
<div class="w-full relative">
<select
class="base-select !select-sm !h-10"
required
@change="mapping.column_index = parseInt($event.target.value)"
>
<option
disabled
value=""
:selected="mapping.column_index == null"
>
Select Column
</option>
<template
v-for="(column, index) in columns"
:key="index"
>
<option
v-if="column"
:value="index"
:selected="index === mapping.column_index"
>
{{ column }}
</option>
</template>
</select>
<div
v-if="mapping.column_index != null"
class="absolute top-0 bottom-0 right-1 flex items-center"
>
<span
class="tooltip tooltip-bottom-end pr-1 tooltip-pre"
style="padding-top: 2px"
:data-tip="[0, 1, 2].map((i) => rows[i]?.[mapping.column_index] ?? '---').join('\n')"
>
<button
class="btn btn-xs btn-circle bg-white border-0 border-gray-300"
@click.prevent
>
<IconInfoCircle class="h-4 w-4" />
</button>
</span>
</div>
</div>
<div class="flex items-center pl-1">
<span
class="tooltip tooltip-top"
data-tip="Remove"
>
<button
:disabled="mappings.filter((m) => m.submitter_uuid === submitter.uuid).length < 2"
class="btn btn-xs btn-circle"
@click.prevent="mappings.splice(mappings.indexOf(mapping), 1)"
>
<IconX class="h-3.5 w-3.5" />
</button>
</span>
</div>
</div>
</div>
<div>
<button
class="btn btn-sm btn-primary w-full !normal-case font-medium"
@click.prevent="addMapping(submitter)"
>
<IconPlus class="w-4 h-4" />
New Field Mapping
</button>
</div>
</div>
<div>
<input
name="submissions_json"
hidden
:value="JSON.stringify(submissionsData.slice(0, 1100))"
>
</div>
<div
class="px-3 border-y py-2 border-base-300 text-center w-full text-sm font-semibold"
>
Total entries: {{ submissionsData.length }}
<template v-if="submissionsData.length >= 1000">
/ 1000
</template>
</div>
</div>
<div
v-else
>
<div
class="flex h-52 w-full"
@dragover.prevent
@drop.prevent="onDropFiles"
>
<label
class="w-full relative bg-base-200/20 hover:bg-base-200/30 rounded-md border border-2 border-base-content/10 border-dashed"
for="import_list_file"
:class="{ 'opacity-50': isLoading }"
>
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
<div class="flex flex-col items-center">
<IconInnerShadowTop
v-if="isLoading"
class="animate-spin"
:width="40"
:height="40"
/>
<IconCloudUpload
v-else
:width="40"
:height="40"
/>
<div
class="font-medium text-lg mb-1"
>
Upload CSV or XLSX Spreadsheet
</div>
<div class="text-sm">
<span class="font-medium">Click to Upload</span> or drag and drop files.
</div>
</div>
</div>
<form
ref="form"
class="hidden"
>
<input
id="import_list_file"
ref="input"
type="file"
name="file"
accept=".xlsx, .xls, .csv"
@change="onSelectFile"
>
</form>
</label>
</div>
<div class="text-center mt-2">
Or <a
:download="`${template.name}.csv`"
:href="`data:text/csv;base64,${csvBase64}`"
class="link font-medium"
>download</a> a spreadsheet to fill and import
</div>
</div>
</div>
</template>
<script>
import { IconCloudUpload, IconX, IconPlus, IconArrowsHorizontal, IconInfoCircle, IconInnerShadowTop } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
name: 'FileDropzone',
components: {
IconCloudUpload,
IconX,
IconArrowsHorizontal,
IconPlus,
IconInfoCircle,
IconInnerShadowTop
},
props: {
template: {
type: Object,
required: true
},
authenticityToken: {
type: String,
required: false,
default: ''
}
},
data () {
return {
isLoading: false,
spreadsheet: null,
selectedSheetIndex: null,
mappings: []
}
},
computed: {
table () {
return this.spreadsheet[this.selectedSheetIndex][1]
},
submissionsData () {
const submissions = []
this.rows.forEach((row) => {
const submittersIndex = {}
this.mappings.forEach((mapping) => {
if (mapping.field_name && mapping.column_index != null) {
submittersIndex[mapping.submitter_uuid] ||= { uuid: mapping.submitter_uuid, fields: [] }
if (['name', 'email', 'phone'].includes(mapping.field_name.toLowerCase())) {
submittersIndex[mapping.submitter_uuid][mapping.field_name.toLowerCase()] = row[mapping.column_index]
} else {
submittersIndex[mapping.submitter_uuid].fields.push({
name: mapping.field_name, default_value: row[mapping.column_index], readonly: true
})
}
}
})
if (Object.keys(submittersIndex).length !== 0) {
submissions.push({ submitters: Object.values(submittersIndex) })
}
})
return submissions
},
csvBase64 () {
const rows = []
this.submitters.forEach((submitter) => {
this.selectFieldsForSubmitter(submitter).forEach((field) => {
rows.push(this.submitters.length > 1 ? `${submitter.name} - ${field.name}` : field.name)
})
})
const csv = rows.map(item => {
if (/[",\n]/.test(item)) {
return `"${item.replace(/"/g, '""')}"`
} else {
return item
}
}).join(',')
return window.btoa(window.unescape(window.encodeURIComponent(csv + '\n' + rows.map(() => '').join(',') + '\n')))
},
submitters () {
return this.template.submitters
},
columns () {
return this.table[0]
},
form () {
return this.$el.closest('form')
},
fieldTypes () {
return ['text', 'cells', 'date', 'number', 'radio', 'select', 'checkbox']
},
defaultFields () {
return [
{ name: 'Name' },
{ name: 'Email' },
{ name: 'Phone' }
]
},
rows () {
return this.table.slice(1)
}
},
watch: {
selectedSheetIndex (value) {
if (value !== null) {
document.getElementById('list_form_buttons')?.classList?.remove('hidden')
}
}
},
methods: {
onDropFiles (e) {
this.uploadFile(e.dataTransfer.files[0])
},
onSelectFile (e) {
this.uploadFile(e.target.files[0])
},
addMapping (submitter) {
this.mappings.push({ uuid: v4(), field_name: '', column_index: null, submitter_uuid: submitter.uuid })
},
selectFieldsForSubmitter (submitter) {
const templateFields = this.template.fields.filter((field) => {
return field.submitter_uuid === submitter.uuid &&
field.name &&
this.fieldTypes.includes(field.type) &&
this.defaultFields.every((f) => field.name?.toLowerCase() !== f.name?.toLowerCase())
})
return [...this.defaultFields, ...templateFields]
},
buildDefaultMappings () {
this.submitters.forEach((submitter) => {
const fields = this.selectFieldsForSubmitter(submitter)
fields.forEach((field) => {
const columnIndex = this.columns.findIndex((column, index) => {
return column &&
column.toString().toLowerCase().includes(field.name?.toLowerCase()) &&
this.mappings.every((m) => m.column_index !== index)
})
if (columnIndex !== -1) {
this.mappings.push({ uuid: v4(), field_name: field.name, column_index: columnIndex, submitter_uuid: submitter.uuid })
}
})
if (!this.mappings.some((m) => m.field_name.toLowerCase() === 'name' && m.submitter_uuid === submitter.uuid)) {
this.mappings.unshift({ uuid: v4(), field_name: 'Name', submitter_uuid: submitter.uuid })
}
if (!this.mappings.some((m) => m.field_name.toLowerCase() === 'email' && m.submitter_uuid === submitter.uuid)) {
this.mappings.unshift({ uuid: v4(), field_name: 'Email', submitter_uuid: submitter.uuid })
}
})
},
uploadFile (file) {
this.isLoading = true
const formData = new FormData()
formData.append('file', file)
return fetch('/upload_spreadsheet', {
method: 'POST',
body: formData,
headers: {
'X-CSRF-Token': this.authenticityToken
}
}).then(resp => resp.json()).then((data) => {
if (data.error) {
return alert(data.error)
}
this.spreadsheet = data
if (data.length === 1) {
this.selectedSheetIndex = 0
this.buildDefaultMappings()
}
}).finally(() => {
this.isLoading = false
})
}
}
}
</script>
+22 -13
View File
@@ -39,7 +39,7 @@
/>
</div>
<div
v-show="resizeDirection || isMove || isDrag || showMask || (drawField && isMobile)"
v-show="resizeDirection || isMove || isDrag || showMask || (drawField && isMobile) || fieldsDragFieldRef.value"
id="mask"
ref="mask"
class="top-0 bottom-0 left-0 right-0 absolute"
@@ -61,7 +61,7 @@ export default {
components: {
FieldArea
},
inject: ['fieldTypes'],
inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef'],
props: {
image: {
type: Object,
@@ -77,6 +77,11 @@ export default {
required: false,
default: () => []
},
drawFieldType: {
type: String,
required: false,
default: ''
},
allowDraw: {
type: Boolean,
required: false,
@@ -123,7 +128,11 @@ export default {
},
computed: {
defaultFieldType () {
if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes('text')) {
if (this.drawFieldType) {
return this.drawFieldType
} else if (this.defaultDrawFieldType && this.defaultDrawFieldType !== 'text') {
return this.defaultDrawFieldType
} else if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes('text')) {
return this.fieldTypes[0]
} else {
return 'text'
@@ -160,8 +169,8 @@ export default {
},
onDrop (e) {
this.$emit('drop-field', {
x: e.layerX,
y: e.layerY,
x: e.offsetX,
y: e.offsetY,
maskW: this.$refs.mask.clientWidth,
maskH: this.$refs.mask.clientHeight,
page: this.number
@@ -184,10 +193,10 @@ export default {
this.$nextTick(() => {
this.newArea = {
initialX: e.layerX / this.$refs.mask.clientWidth,
initialY: e.layerY / this.$refs.mask.clientHeight,
x: e.layerX / this.$refs.mask.clientWidth,
y: e.layerY / this.$refs.mask.clientHeight,
initialX: e.offsetX / this.$refs.mask.clientWidth,
initialY: e.offsetY / this.$refs.mask.clientHeight,
x: e.offsetX / this.$refs.mask.clientWidth,
y: e.offsetY / this.$refs.mask.clientHeight,
w: 0,
h: 0
}
@@ -195,19 +204,19 @@ export default {
},
onPointermove (e) {
if (this.newArea) {
const dx = e.layerX / this.$refs.mask.clientWidth - this.newArea.initialX
const dy = e.layerY / this.$refs.mask.clientHeight - this.newArea.initialY
const dx = e.offsetX / this.$refs.mask.clientWidth - this.newArea.initialX
const dy = e.offsetY / this.$refs.mask.clientHeight - this.newArea.initialY
if (dx > 0) {
this.newArea.x = this.newArea.initialX
} else {
this.newArea.x = e.layerX / this.$refs.mask.clientWidth
this.newArea.x = e.offsetX / this.$refs.mask.clientWidth
}
if (dy > 0) {
this.newArea.y = this.newArea.initialY
} else {
this.newArea.y = e.layerY / this.$refs.mask.clientHeight
this.newArea.y = e.offsetY / this.$refs.mask.clientHeight
}
if (this.drawField?.type === 'cells') {
@@ -167,7 +167,7 @@ export default {
IconInnerShadowTop,
IconBrandStripe
},
inject: ['backgroundColor', 'save', 'currencies', 't'],
inject: ['backgroundColor', 'save', 'currencies', 't', 'isPaymentConnected'],
props: {
field: {
type: Object,
@@ -221,6 +221,8 @@ export default {
mounted () {
this.field.preferences.currency ||= this.defaultCurrency
isConnected.value ||= this.isPaymentConnected
if (!this.isConnected) {
this.checkStatus()
}
@@ -20,7 +20,6 @@
<div class="">
<ReplaceButton
v-if="withReplaceButton"
:is-direct-upload="isDirectUpload"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
class="opacity-0 group-hover:opacity-100"
@@ -115,11 +114,6 @@ export default {
required: true,
default: true
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
},
withArrows: {
type: Boolean,
required: false,
@@ -132,11 +126,6 @@ export default {
return [...this.document.preview_images].sort((a, b) => parseInt(a.filename) - parseInt(b.filename))[0]
}
},
mounted () {
if (this.isDirectUpload) {
import('@rails/activestorage')
}
},
methods: {
upload: Upload.methods.upload,
onUpdateName (value) {
@@ -36,11 +36,6 @@ export default {
type: String,
required: false,
default: 'image/*, application/pdf'
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
}
},
emits: ['success'],
@@ -64,11 +59,6 @@ export default {
}
}
},
mounted () {
if (this.isDirectUpload) {
import('@rails/activestorage')
}
},
methods: {
upload: Upload.methods.upload
}
+27 -101
View File
@@ -60,11 +60,6 @@ export default {
type: String,
required: false,
default: 'image/*, application/pdf'
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
}
},
emits: ['success'],
@@ -79,112 +74,43 @@ export default {
return 'el' + Math.random().toString(32).split('.')[1]
}
},
mounted () {
if (this.isDirectUpload) {
import('@rails/activestorage')
}
},
methods: {
async upload () {
this.isLoading = true
if (this.isDirectUpload) {
const { DirectUpload } = await import('@rails/activestorage')
this.baseFetch(`/templates/${this.templateId}/documents`, {
method: 'POST',
body: new FormData(this.$refs.form)
}).then((resp) => {
if (resp.ok) {
resp.json().then((data) => {
this.$emit('success', data)
this.$refs.input.value = ''
})
} else if (resp.status === 422) {
resp.json().then((data) => {
if (data.error === 'PDF encrypted') {
const formData = new FormData(this.$refs.form)
const blobs = await Promise.all(
Array.from(this.$refs.input.files).map(async (file) => {
const upload = new DirectUpload(
file,
'/direct_uploads',
this.$refs.input
)
formData.append('password', prompt('Enter PDF password'))
return new Promise((resolve, reject) => {
upload.create((error, blob) => {
if (error) {
console.error(error)
return reject(error)
this.baseFetch(`/templates/${this.templateId}/documents`, {
method: 'POST',
body: formData
}).then(async (resp) => {
if (resp.ok) {
this.$emit('success', await resp.json())
this.$refs.input.value = ''
} else {
return resolve(blob)
alert('Wrong password')
}
})
}).catch((error) => {
console.error(error)
})
}
})
).finally(() => {
this.isLoading = false
})
this.isProcessing = true
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
method: 'POST',
body: JSON.stringify({ blobs }),
headers: { 'Content-Type': 'application/json' }
}).then((resp) => {
if (resp.ok) {
resp.json().then((data) => {
this.$emit('success', data)
this.$refs.input.value = ''
})
} else if (resp.status === 422) {
resp.json().then((data) => {
if (data.error === 'PDF encrypted') {
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
method: 'POST',
body: JSON.stringify({ blobs, password: prompt('Enter PDF password') }),
headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => {
if (resp.ok) {
this.$emit('success', await resp.json())
this.$refs.input.value = ''
} else {
alert('Wrong password')
}
})
}
})
}
}).finally(() => {
this.isProcessing = false
})
} else {
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
method: 'POST',
body: new FormData(this.$refs.form)
}).then((resp) => {
if (resp.ok) {
resp.json().then((data) => {
this.$emit('success', data)
this.$refs.input.value = ''
})
} else if (resp.status === 422) {
resp.json().then((data) => {
if (data.error === 'PDF encrypted') {
const formData = new FormData(this.$refs.form)
formData.append('password', prompt('Enter PDF password'))
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
method: 'POST',
body: formData
}).then(async (resp) => {
if (resp.ok) {
this.$emit('success', await resp.json())
this.$refs.input.value = ''
} else {
alert('Wrong password')
}
})
}
})
}
}).finally(() => {
this.isLoading = false
})
}
}
}).finally(() => {
this.isLoading = false
})
}
}
}
+4 -3
View File
@@ -27,14 +27,15 @@ class ProcessSubmitterCompletionJob < ApplicationJob
user = submission.created_by_user || submitter.template.author
if submitter.account.users.exists?(id: user.id) && submission.preferences['send_email'] != false
if submitter.submission.submitters.map(&:email).exclude?(user.email) &&
user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false
if submission.submitters.map(&:email).exclude?(user.email) &&
user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false &&
user.role != 'integration'
SubmitterMailer.completed_email(submitter, user).deliver_later!
end
bcc = submission.preferences['bcc_completed'].presence ||
submission.account.account_configs
.find_by(key: AccountConfig::BCC_EMAILS)&.value.presence
.find_by(key: AccountConfig::BCC_EMAILS)&.value
bcc.to_s.scan(User::EMAIL_REGEXP).each do |to|
SubmitterMailer.completed_email(submitter, user, to:).deliver_later!
@@ -15,21 +15,25 @@ class SendFormCompletedWebhookRequestJob < ApplicationJob
ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = Faraday.post(config.value,
{
event_type: 'form.completed',
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
resp = begin
Faraday.post(config.value,
{
event_type: 'form.completed',
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
rescue Faraday::Error
nil
end
if resp.status.to_i >= 400 && attempt <= MAX_ATTEMPTS &&
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormCompletedWebhookRequestJob.set(wait: (2**attempt).minutes)
.perform_later(submitter, {
attempt: attempt + 1,
last_status: resp.status.to_i
last_status: resp&.status.to_i
})
end
end
@@ -13,21 +13,25 @@ class SendFormStartedWebhookRequestJob < ApplicationJob
ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = Faraday.post(config.value,
{
event_type: 'form.started',
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
resp = begin
Faraday.post(config.value,
{
event_type: 'form.started',
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
rescue Faraday::Error
nil
end
if resp.status.to_i >= 400 && attempt <= MAX_ATTEMPTS &&
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormStartedWebhookRequestJob.set(wait: (2**attempt).minutes)
.perform_later(submitter, {
attempt: attempt + 1,
last_status: resp.status.to_i
last_status: resp&.status.to_i
})
end
end
@@ -13,21 +13,25 @@ class SendFormViewedWebhookRequestJob < ApplicationJob
ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = Faraday.post(config.value,
{
event_type: 'form.viewed',
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
resp = begin
Faraday.post(config.value,
{
event_type: 'form.viewed',
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
rescue Faraday::Error
nil
end
if resp.status.to_i >= 400 && attempt <= MAX_ATTEMPTS &&
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormViewedWebhookRequestJob.set(wait: (2**attempt).minutes)
.perform_later(submitter, {
attempt: attempt + 1,
last_status: resp.status.to_i
last_status: resp&.status.to_i
})
end
end
@@ -4,6 +4,12 @@ class SendSubmitterInvitationEmailJob < ApplicationJob
def perform(params = {})
submitter = Submitter.find(params['submitter_id'])
if submitter.submission.source == 'invite' && !Accounts.can_send_emails?(submitter.account)
Rollbar.error("Skip email: #{submitter.id}") if defined?(Rollbar)
return
end
mail = SubmitterMailer.invitation_email(submitter)
Submitters::ValidateSending.call(submitter, mail)
+11 -5
View File
@@ -2,11 +2,12 @@
class SubmitterMailer < ApplicationMailer
MAX_ATTACHMENTS_SIZE = 10.megabytes
SIGN_TTL = 1.hour + 20.minutes
DEFAULT_INVITATION_SUBJECT = 'You are invited to submit a form'
def invitation_email(submitter)
@current_account = submitter.submission.template.account
@current_account = submitter.submission.account
@submitter = submitter
if submitter.preferences['email_message_uuid']
@@ -29,12 +30,13 @@ class SubmitterMailer < ApplicationMailer
to: @submitter.friendly_name,
from: from_address_for_submitter(submitter),
subject:,
reply_to: (submitter.submission.created_by_user || submitter.template.author)&.friendly_name&.sub(/\+\w+@/, '@')
reply_to: submitter.preferences['reply_to'].presence ||
(submitter.submission.created_by_user || submitter.template.author)&.friendly_name&.sub(/\+\w+@/, '@')
)
end
def completed_email(submitter, user, to: nil)
@current_account = submitter.submission.template.account
@current_account = submitter.submission.account
@submitter = submitter
@submission = submitter.submission
@user = user
@@ -61,9 +63,10 @@ class SubmitterMailer < ApplicationMailer
subject:)
end
def documents_copy_email(submitter, to: nil)
@current_account = submitter.submission.template.account
def documents_copy_email(submitter, to: nil, sig: false)
@current_account = submitter.submission.account
@submitter = submitter
@sig = submitter.signed_id(expires_in: SIGN_TTL, purpose: :download_completed) if sig
Submissions::EnsureResultGenerated.call(@submitter)
@@ -82,6 +85,9 @@ class SubmitterMailer < ApplicationMailer
mail(from: from_address_for_submitter(submitter),
to: to || @submitter.friendly_name,
reply_to: @submitter.preferences['reply_to'].presence ||
(@submitter.submission.created_by_user ||
@submitter.template.author)&.friendly_name&.sub(/\+\w+@/, '@'),
subject:)
end
+8 -1
View File
@@ -8,10 +8,17 @@
# locale :string not null
# name :string not null
# timezone :string not null
# uuid :string not null
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_accounts_on_uuid (uuid) UNIQUE
#
class Account < ApplicationRecord
attribute :uuid, :string, default: -> { SecureRandom.uuid }
has_many :users, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy
has_many :account_configs, dependent: :destroy
@@ -20,7 +27,7 @@ class Account < ApplicationRecord
has_many :template_folders, dependent: :destroy
has_one :default_template_folder, -> { where(name: TemplateFolder::DEFAULT_NAME) },
class_name: 'TemplateFolder', dependent: :destroy, inverse_of: :account
has_many :submissions, through: :templates
has_many :submissions, dependent: :destroy
has_many :submitters, through: :submissions
has_many :account_linked_accounts, dependent: :destroy
has_many :account_testing_accounts, -> { testing }, dependent: :destroy,
+1
View File
@@ -30,6 +30,7 @@ class AccountConfig < ApplicationRecord
ALLOW_TO_RESUBMIT = 'allow_to_resubmit'
SUBMITTER_REMAILERS = 'submitter_reminders'
FORM_COMPLETED_BUTTON_KEY = 'form_completed_button'
FORM_COMPLETED_MESSAGE_KEY = 'form_completed_message'
FORM_WITH_CONFETTI_KEY = 'form_with_confetti'
ESIGNING_PREFERENCE_KEY = 'esigning_preference'
+1
View File
@@ -15,6 +15,7 @@
#
# Indexes
#
# index_submission_events_on_created_at (created_at)
# index_submission_events_on_submission_id (submission_id)
# index_submission_events_on_submitter_id (submitter_id)
#
+12
View File
@@ -0,0 +1,12 @@
<form action="<%= root_path %>" method="get" class="bg-base-200 px-1.5 rounded-xl py-1">
<toggle-cookies data-value="templates" data-key="dashboard_view" class="sm:tooltip tooltip-top" data-tip="Templates">
<button class="<%= local_assigns[:selected] == 'submissions' ? 'btn !border !rounded-lg btn-square !p-0 !btn-sm !h-8 !w-9' : 'btn btn-neutral !rounded-lg btn-square !p-0 hover:text-neutral-300 !btn-sm !h-8 !w-9 disabled:btn-neutral' %>">
<%= svg_icon('layout_grid', class: 'w-6 h-6 stroke-2') %>
</button>
</toggle-cookies>
<toggle-cookies data-value="submissions" data-key="dashboard_view" class="sm:tooltip tooltip-top" data-tip="Submissions">
<button class="<%= local_assigns[:selected] == 'submissions' ? 'btn btn-neutral !rounded-lg btn-square !p-0 hover:text-neutral-300 !btn-sm !h-8 !w-9' : 'btn !border !rounded-lg btn-square !p-0 !btn-sm !h-8 !w-9 disabled:btn-neutral' %>">
<%= svg_icon('layout_list', class: 'w-6 h-6 stroke-2') %>
</button>
</toggle-cookies>
</form>
-80
View File
@@ -1,80 +0,0 @@
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
<div class="flex justify-between mb-4 items-center">
<h1 class="text-4xl font-bold"><span class="hidden md:inline">Document</span> Templates</h1>
<div class="flex space-x-2">
<% if params[:q].present? || @pagy.pages > 1 || @template_folders.present? %>
<%= render 'shared/search_input' %>
<% end %>
<% if can?(:create, ::Template) %>
<%= render 'templates/upload_button' %>
<%= link_to new_template_path, class: 'white-button !border gap-2', data: { turbo_frame: :modal } do %>
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
<span class="hidden md:block">Create</span>
<% end %>
<% end %>
</div>
</div>
<% view_archived_html = capture do %>
<% if current_account.templates.where.not(archived_at: nil).exists? %>
<div>
<a href="<%= templates_archived_index_path %>" class="link text-sm">View Archived</a>
</div>
<% end %>
<% end %>
<% if @template_folders.present? %>
<div class="grid gap-4 md:grid-cols-3 <%= 'mb-6' if @templates.present? %>">
<%= render partial: 'template_folders/folder', collection: @template_folders, as: :folder %>
</div>
<% end %>
<% if @templates.present? %>
<div class="grid gap-4 md:grid-cols-3">
<%= render partial: 'templates/template', collection: @templates %>
</div>
<% end %>
<% if params[:q].blank? && @pagy.pages == 1 && ((@template_folders.size < 10 && @templates.size.zero?) || (@template_folders.size < 7 && @templates.size < 4) || (@template_folders.size < 4 && @templates.size < 7)) %>
<%= form_for '', url: templates_upload_path, id: form_id = SecureRandom.uuid, method: :post, class: 'mt-8 block', html: { enctype: 'multipart/form-data' } do %>
<input type="hidden" name="form_id" value="<%= form_id %>">
<button type="submit" class="hidden"></button>
<file-dropzone data-submit-on-upload="true" class="w-full">
<label for="file_dropzone_input" class="w-full block h-52 relative hover:bg-base-200/30 rounded-xl border border-2 border-base-300 border-dashed">
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
<div class="flex flex-col items-center">
<span data-target="file-dropzone.icon" class="flex flex-col items-center">
<span>
<%= svg_icon('cloud_upload', class: 'w-10 h-10') %>
</span>
<div class="font-medium mb-1">
Upload New Document
</div>
<div class="text-xs">
<span class="font-medium">Click to upload</span> or drag and drop
</div>
</span>
<span data-target="file-dropzone.loading" class="flex flex-col items-center hidden">
<%= svg_icon('loader', class: 'w-10 h-10 animate-spin') %>
<div class="font-medium mb-1">
Uploading...
</div>
</span>
</div>
<input id="file_dropzone_input" name="files[]" class="hidden" data-action="change:file-dropzone#onSelectFiles" data-target="file-dropzone.input" type="file" accept="image/*, application/pdf<%= ', .docx, .doc, .xlsx, .xls' if Docuseal.multitenant? %>" multiple>
</div>
</label>
</file-dropzone>
<% end %>
<% end %>
<% if @templates.present? || params[:q].blank? %>
<% if @pagy.pages > 1 %>
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', left_additional_html: view_archived_html %>
<% else %>
<div class="mt-2">
<%= view_archived_html %>
</div>
<% end %>
<% elsif params[:q].present? %>
<div class="text-center">
<div class="mt-16 text-3xl font-semibold">
Templates not Found
</div>
</div>
<% end %>
@@ -1,47 +0,0 @@
<div class="max-w-lg mx-auto px-2">
<h1 class="text-3xl font-bold text-center mt-8">Profile Details</h1>
<%= form_for('', as: resource_name, html: { class: 'space-y-6' }, data: { turbo: params[:redir].blank? }, url: new_registration_path) do |f| %>
<% if params[:redir].present? %>
<%= hidden_field_tag :redir, params[:redir] %>
<% end %>
<div class="space-y-2">
<%= render 'devise/shared/error_messages', resource: %>
<%= f.fields_for resource do |ff| %>
<div class="grid gap-2 md:grid-cols-2 md:gap-4">
<div class="form-control">
<%= ff.label :first_name, class: 'label' %>
<%= ff.text_field :first_name, required: true, class: 'base-input' %>
</div>
<div class="form-control">
<%= ff.label :last_name, class: 'label' %>
<%= ff.text_field :last_name, required: true, class: 'base-input' %>
</div>
</div>
<% end %>
<%= f.fields_for resource do |ff| %>
<div class="form-control <%= 'hidden' if (params[:oauth_callback] || params[:sign_up]) && resource.email? %>">
<%= ff.label :email, class: 'label' %>
<%= ff.email_field :email, value: EmailTypo.call(resource.email), required: true, class: 'base-input' %>
</div>
<% end %>
<%= f.fields_for resource.account do |ff| %>
<set-timezone data-input-id="_account_timezone"></set-timezone>
<%= ff.hidden_field :timezone %>
<div class="form-control">
<%= ff.label :name, 'Company name (optional)', class: 'label' %>
<%= ff.text_field :name, class: 'base-input' %>
</div>
<% end %>
<%= f.fields_for resource do |ff| %>
<div class="form-control <%= 'hidden' if params[:oauth_callback] %>">
<%= ff.label :password, class: 'label' %>
<%= ff.password_field :password, required: !params[:oauth_callback], class: 'base-input' %>
</div>
<% end %>
</div>
<div class="form-control">
<%= f.button button_title(title: 'Sign up', disabled_with: 'Signing up'), class: 'base-button' %>
</div>
<% end %>
<%= render 'devise/shared/links' %>
</div>
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff. Show more