mirror of
https://github.com/docusealco/docuseal
synced 2026-11-04 14:42:32 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df561a6211 | ||
|
|
50d3404f3b | ||
|
|
607fc8d338 | ||
|
|
8c366dfc38 | ||
|
|
5ed08fa9ab | ||
|
|
ddb557ebef | ||
|
|
b48615df3f | ||
|
|
86c7b92675 | ||
|
|
041f362cc4 | ||
|
|
65f491325e | ||
|
|
57aef5f5e5 | ||
|
|
e704e6fa04 | ||
|
|
4c99bbb58a | ||
|
|
c0647eef2c | ||
|
|
c619b9be93 | ||
|
|
abab3f9688 | ||
|
|
7b15111e8f | ||
|
|
2c7f38c503 | ||
|
|
ed80df1372 | ||
|
|
3614d1796c | ||
|
|
ce39494f84 | ||
|
|
c639a3f733 | ||
|
|
74bef133b3 | ||
|
|
33142966dc | ||
|
|
42ec8d5fc9 | ||
|
|
01d752e811 | ||
|
|
99b33d4165 | ||
|
|
11a90d797a | ||
|
|
9da7463657 | ||
|
|
b6d350ebbd | ||
|
|
9e87a0373f | ||
|
|
aee55165c5 | ||
|
|
9ddbcae153 | ||
|
|
10cd3aa72a | ||
|
|
6684a27e2f | ||
|
|
b6f7a7dd15 | ||
|
|
309e43e18e | ||
|
|
d5b8640a0e | ||
|
|
49b9064d9f | ||
|
|
698031cfc0 | ||
|
|
6dc21e66e1 | ||
|
|
60b32fec94 | ||
|
|
c621166b45 | ||
|
|
8e2da83f9e | ||
|
|
a39ba5b275 | ||
|
|
edf466a6de | ||
|
|
3b8bb8c5bb | ||
|
|
79f1061353 | ||
|
|
745f2160c5 | ||
|
|
2f0c79444b | ||
|
|
69a3782903 | ||
|
|
3dd7c47558 | ||
|
|
761bb07ee6 | ||
|
|
92d7bc65f5 | ||
|
|
79e158be43 | ||
|
|
41ee2dba6a | ||
|
|
8e1354175f | ||
|
|
2474adffc2 | ||
|
|
b8fd5a77d4 | ||
|
|
18bb57aa99 | ||
|
|
17ff193e34 | ||
|
|
20ac63e524 | ||
|
|
518bbe3d1c | ||
|
|
fa5e3e7163 | ||
|
|
2b8280168e | ||
|
|
82854b8ce8 | ||
|
|
276b4f4a5f | ||
|
|
b73a730390 | ||
|
|
2e98885f41 | ||
|
|
bfece574b9 | ||
|
|
0dd5bbe25a | ||
|
|
3975c963c5 | ||
|
|
1f41807412 | ||
|
|
872fbbc875 | ||
|
|
b98a874e20 | ||
|
|
50e123c221 | ||
|
|
7f97bfb3bd | ||
|
|
784665b549 | ||
|
|
c651709e45 | ||
|
|
881a2acbfc | ||
|
|
c49cb4b0c8 | ||
|
|
17b8354c40 | ||
|
|
dc7593c70c | ||
|
|
d2edb2fb69 | ||
|
|
154a30b60b | ||
|
|
392c5c1c54 | ||
|
|
cad0d0f371 | ||
|
|
cb879738c0 | ||
|
|
abfa64140f | ||
|
|
db1e2b9aa2 | ||
|
|
c1ab4fafb2 | ||
|
|
7ba3a604bd | ||
|
|
cf85bea0df | ||
|
|
5643094a7a | ||
|
|
380f553a17 | ||
|
|
f8712a9da9 | ||
|
|
ef8c3cfe7f | ||
|
|
9bcf5aacc0 | ||
|
|
54592e7264 | ||
|
|
f1f4de53c6 | ||
|
|
ee350324e5 | ||
|
|
dc02964d9b | ||
|
|
0424b93fe7 | ||
|
|
905e0b22ed | ||
|
|
d757f06ff4 | ||
|
|
132999176c | ||
|
|
2f74caf16a | ||
|
|
41d3eaf582 | ||
|
|
907aaa9c30 | ||
|
|
7bb241815f | ||
|
|
84e879bad0 | ||
|
|
a1a0205d33 | ||
|
|
6f8e88cdbd | ||
|
|
2f1843151d | ||
|
|
f89c50d096 | ||
|
|
ef618f8f49 | ||
|
|
b3662ddea8 | ||
|
|
53fa35fd01 | ||
|
|
7f6c0af0e8 | ||
|
|
3faec9d174 | ||
|
|
9d9e134bb4 | ||
|
|
ec4f399fcc | ||
|
|
effa7b0fc7 | ||
|
|
016d9de87a | ||
|
|
71ff064356 | ||
|
|
9100d48cc3 | ||
|
|
21b207cb1a | ||
|
|
3aa703032f | ||
|
|
fcfd2e13bd | ||
|
|
cf6cacdc92 | ||
|
|
8cabe31827 | ||
|
|
62cea05084 | ||
|
|
07e49f9f07 | ||
|
|
baad784818 | ||
|
|
6eecd3afa4 | ||
|
|
8e90100871 | ||
|
|
12c5138364 | ||
|
|
80ad166fd9 | ||
|
|
2fab6984ea | ||
|
|
422639114b | ||
|
|
fca441878a | ||
|
|
76578077e8 | ||
|
|
bbd3618f0e | ||
|
|
47ed8db955 | ||
|
|
fec6e2e35d | ||
|
|
7c5c8779ec | ||
|
|
37ed110ad4 | ||
|
|
2ad5f8a316 | ||
|
|
e7594d81b6 | ||
|
|
e7c5f14860 | ||
|
|
c38830dd1c | ||
|
|
2cbf429293 | ||
|
|
517c292c17 |
No files matched your search
@@ -138,4 +138,5 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
bundle exec rake db:create
|
bundle exec rake db:create
|
||||||
bundle exec rake db:migrate
|
bundle exec rake db:migrate
|
||||||
|
bundle exec rake assets:precompile
|
||||||
bundle exec rspec
|
bundle exec rspec
|
||||||
@@ -26,6 +26,9 @@ Style/Documentation:
|
|||||||
Lint/MissingSuper:
|
Lint/MissingSuper:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
|
Metrics/ParameterLists:
|
||||||
|
Max: 10
|
||||||
|
|
||||||
Metrics/MethodLength:
|
Metrics/MethodLength:
|
||||||
Max: 25
|
Max: 25
|
||||||
Exclude:
|
Exclude:
|
||||||
@@ -64,8 +67,14 @@ RSpec/MultipleMemoizedHelpers:
|
|||||||
Rails/I18nLocaleTexts:
|
Rails/I18nLocaleTexts:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
|
Rails/FindEach:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
Rails/SkipsModelValidations:
|
Rails/SkipsModelValidations:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
Rails/ApplicationController:
|
Rails/ApplicationController:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
|
Capybara/ClickLinkOrButtonStyle:
|
||||||
|
Enabled: false
|
||||||
+2
-1
@@ -31,7 +31,7 @@ ENV BUNDLE_WITHOUT="development:test"
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler vips-heif libc6-compat ttf-freefont ttf-liberation && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /LiberationSans-Regular.ttf && apk del ttf-liberation
|
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont ttf-liberation && mkdir /fonts && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf /fonts && apk del ttf-liberation && wget -O /fonts/DancingScript.otf "https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf" && wget -O /fonts/DancingScript-License.txt https://github.com/impallari/DancingScript/blob/master/OFL.txt
|
||||||
|
|
||||||
COPY ./Gemfile ./Gemfile.lock ./
|
COPY ./Gemfile ./Gemfile.lock ./
|
||||||
|
|
||||||
@@ -49,6 +49,7 @@ COPY LICENSE README.md Rakefile config.ru ./
|
|||||||
|
|
||||||
COPY --from=webpack /app/public/packs ./public/packs
|
COPY --from=webpack /app/public/packs ./public/packs
|
||||||
|
|
||||||
|
RUN ln -s /fonts /app/public/fonts
|
||||||
RUN bundle exec bootsnap precompile --gemfile app/ lib/
|
RUN bundle exec bootsnap precompile --gemfile app/ lib/
|
||||||
|
|
||||||
WORKDIR /data/docuseal
|
WORKDIR /data/docuseal
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ ruby '3.2.2'
|
|||||||
gem 'aws-sdk-s3', require: false
|
gem 'aws-sdk-s3', require: false
|
||||||
gem 'azure-storage-blob', require: false
|
gem 'azure-storage-blob', require: false
|
||||||
gem 'bootsnap', require: false
|
gem 'bootsnap', require: false
|
||||||
|
gem 'cancancan'
|
||||||
gem 'devise'
|
gem 'devise'
|
||||||
|
gem 'devise-two-factor'
|
||||||
gem 'dotenv', require: false
|
gem 'dotenv', require: false
|
||||||
|
gem 'email_typo'
|
||||||
gem 'faraday'
|
gem 'faraday'
|
||||||
|
gem 'faraday-follow_redirects'
|
||||||
gem 'google-cloud-storage', require: false
|
gem 'google-cloud-storage', require: false
|
||||||
gem 'hexapdf'
|
gem 'hexapdf'
|
||||||
gem 'image_processing'
|
gem 'image_processing'
|
||||||
@@ -28,6 +32,8 @@ gem 'rails'
|
|||||||
gem 'rails_autolink'
|
gem 'rails_autolink'
|
||||||
gem 'rails-i18n'
|
gem 'rails-i18n'
|
||||||
gem 'rollbar', require: ENV.key?('ROLLBAR_ACCESS_TOKEN')
|
gem 'rollbar', require: ENV.key?('ROLLBAR_ACCESS_TOKEN')
|
||||||
|
gem 'rotp'
|
||||||
|
gem 'rqrcode'
|
||||||
gem 'ruby-vips'
|
gem 'ruby-vips'
|
||||||
gem 'rubyXL'
|
gem 'rubyXL'
|
||||||
gem 'shakapacker'
|
gem 'shakapacker'
|
||||||
|
|||||||
+34
-15
@@ -98,7 +98,6 @@ GEM
|
|||||||
faraday_middleware (~> 1.0, >= 1.0.0.rc1)
|
faraday_middleware (~> 1.0, >= 1.0.0.rc1)
|
||||||
net-http-persistent (~> 4.0)
|
net-http-persistent (~> 4.0)
|
||||||
nokogiri (~> 1, >= 1.10.8)
|
nokogiri (~> 1, >= 1.10.8)
|
||||||
base64 (0.1.1)
|
|
||||||
bcrypt (3.1.19)
|
bcrypt (3.1.19)
|
||||||
better_html (2.0.2)
|
better_html (2.0.2)
|
||||||
actionview (>= 6.0)
|
actionview (>= 6.0)
|
||||||
@@ -114,6 +113,7 @@ GEM
|
|||||||
bullet (7.0.7)
|
bullet (7.0.7)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
uniform_notifier (~> 1.11)
|
uniform_notifier (~> 1.11)
|
||||||
|
cancancan (3.5.0)
|
||||||
capybara (3.39.2)
|
capybara (3.39.2)
|
||||||
addressable
|
addressable
|
||||||
matrix
|
matrix
|
||||||
@@ -123,6 +123,7 @@ GEM
|
|||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
regexp_parser (>= 1.5, < 3.0)
|
regexp_parser (>= 1.5, < 3.0)
|
||||||
xpath (~> 3.2)
|
xpath (~> 3.2)
|
||||||
|
chunky_png (1.4.0)
|
||||||
cmdparse (3.0.7)
|
cmdparse (3.0.7)
|
||||||
coderay (1.1.3)
|
coderay (1.1.3)
|
||||||
concurrent-ruby (1.2.2)
|
concurrent-ruby (1.2.2)
|
||||||
@@ -146,11 +147,17 @@ GEM
|
|||||||
railties (>= 4.1.0)
|
railties (>= 4.1.0)
|
||||||
responders
|
responders
|
||||||
warden (~> 1.2.3)
|
warden (~> 1.2.3)
|
||||||
|
devise-two-factor (5.0.0)
|
||||||
|
activesupport (~> 7.0)
|
||||||
|
devise (~> 4.0)
|
||||||
|
railties (~> 7.0)
|
||||||
|
rotp (~> 6.0)
|
||||||
diff-lcs (1.5.0)
|
diff-lcs (1.5.0)
|
||||||
digest-crc (0.6.5)
|
digest-crc (0.6.5)
|
||||||
rake (>= 12.0.0, < 14.0.0)
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
docile (1.4.0)
|
docile (1.4.0)
|
||||||
dotenv (2.8.1)
|
dotenv (2.8.1)
|
||||||
|
email_typo (0.2.3)
|
||||||
erb_lint (0.4.0)
|
erb_lint (0.4.0)
|
||||||
activesupport
|
activesupport
|
||||||
better_html (>= 2.0.1)
|
better_html (>= 2.0.1)
|
||||||
@@ -181,6 +188,8 @@ GEM
|
|||||||
faraday-em_http (1.0.0)
|
faraday-em_http (1.0.0)
|
||||||
faraday-em_synchrony (1.0.0)
|
faraday-em_synchrony (1.0.0)
|
||||||
faraday-excon (1.1.0)
|
faraday-excon (1.1.0)
|
||||||
|
faraday-follow_redirects (0.3.0)
|
||||||
|
faraday (>= 1, < 3)
|
||||||
faraday-httpclient (1.0.1)
|
faraday-httpclient (1.0.1)
|
||||||
faraday-multipart (1.0.4)
|
faraday-multipart (1.0.4)
|
||||||
multipart-post (~> 2)
|
multipart-post (~> 2)
|
||||||
@@ -284,7 +293,7 @@ GEM
|
|||||||
mini_magick (4.12.0)
|
mini_magick (4.12.0)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
mini_portile2 (2.8.4)
|
mini_portile2 (2.8.4)
|
||||||
minitest (5.19.0)
|
minitest (5.20.0)
|
||||||
msgpack (1.7.2)
|
msgpack (1.7.2)
|
||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
multi_xml (0.6.0)
|
multi_xml (0.6.0)
|
||||||
@@ -335,7 +344,7 @@ GEM
|
|||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
pagy (6.0.4)
|
pagy (6.0.4)
|
||||||
parallel (1.23.0)
|
parallel (1.23.0)
|
||||||
parser (3.2.2.3)
|
parser (3.2.2.4)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
racc
|
racc
|
||||||
pdf-reader (2.11.0)
|
pdf-reader (2.11.0)
|
||||||
@@ -408,7 +417,7 @@ GEM
|
|||||||
rake (13.0.6)
|
rake (13.0.6)
|
||||||
redis-client (0.16.0)
|
redis-client (0.16.0)
|
||||||
connection_pool
|
connection_pool
|
||||||
regexp_parser (2.8.1)
|
regexp_parser (2.8.2)
|
||||||
reline (0.3.8)
|
reline (0.3.8)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
representable (3.2.0)
|
representable (3.2.0)
|
||||||
@@ -423,6 +432,11 @@ GEM
|
|||||||
retriable (3.1.2)
|
retriable (3.1.2)
|
||||||
rexml (3.2.6)
|
rexml (3.2.6)
|
||||||
rollbar (3.4.0)
|
rollbar (3.4.0)
|
||||||
|
rotp (6.3.0)
|
||||||
|
rqrcode (2.2.0)
|
||||||
|
chunky_png (~> 1.0)
|
||||||
|
rqrcode_core (~> 1.0)
|
||||||
|
rqrcode_core (1.2.0)
|
||||||
rspec-core (3.12.2)
|
rspec-core (3.12.2)
|
||||||
rspec-support (~> 3.12.0)
|
rspec-support (~> 3.12.0)
|
||||||
rspec-expectations (3.12.3)
|
rspec-expectations (3.12.3)
|
||||||
@@ -440,33 +454,32 @@ GEM
|
|||||||
rspec-mocks (~> 3.12)
|
rspec-mocks (~> 3.12)
|
||||||
rspec-support (~> 3.12)
|
rspec-support (~> 3.12)
|
||||||
rspec-support (3.12.1)
|
rspec-support (3.12.1)
|
||||||
rubocop (1.56.1)
|
rubocop (1.57.2)
|
||||||
base64 (~> 0.1.1)
|
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (>= 3.17.0)
|
language_server-protocol (>= 3.17.0)
|
||||||
parallel (~> 1.10)
|
parallel (~> 1.10)
|
||||||
parser (>= 3.2.2.3)
|
parser (>= 3.2.2.4)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 1.8, < 3.0)
|
regexp_parser (>= 1.8, < 3.0)
|
||||||
rexml (>= 3.2.5, < 4.0)
|
rexml (>= 3.2.5, < 4.0)
|
||||||
rubocop-ast (>= 1.28.1, < 2.0)
|
rubocop-ast (>= 1.28.1, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 3.0)
|
unicode-display_width (>= 2.4.0, < 3.0)
|
||||||
rubocop-ast (1.29.0)
|
rubocop-ast (1.30.0)
|
||||||
parser (>= 3.2.1.0)
|
parser (>= 3.2.1.0)
|
||||||
rubocop-capybara (2.18.0)
|
rubocop-capybara (2.19.0)
|
||||||
rubocop (~> 1.41)
|
rubocop (~> 1.41)
|
||||||
rubocop-factory_bot (2.23.1)
|
rubocop-factory_bot (2.24.0)
|
||||||
rubocop (~> 1.33)
|
rubocop (~> 1.33)
|
||||||
rubocop-performance (1.19.0)
|
rubocop-performance (1.19.1)
|
||||||
rubocop (>= 1.7.0, < 2.0)
|
rubocop (>= 1.7.0, < 2.0)
|
||||||
rubocop-ast (>= 0.4.0)
|
rubocop-ast (>= 0.4.0)
|
||||||
rubocop-rails (2.20.2)
|
rubocop-rails (2.22.1)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
rubocop (>= 1.33.0, < 2.0)
|
rubocop (>= 1.33.0, < 2.0)
|
||||||
rubocop-rspec (2.23.2)
|
rubocop-rspec (2.25.0)
|
||||||
rubocop (~> 1.33)
|
rubocop (~> 1.40)
|
||||||
rubocop-capybara (~> 2.17)
|
rubocop-capybara (~> 2.17)
|
||||||
rubocop-factory_bot (~> 2.22)
|
rubocop-factory_bot (~> 2.22)
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
@@ -522,7 +535,7 @@ GEM
|
|||||||
tzinfo-data (1.2023.3)
|
tzinfo-data (1.2023.3)
|
||||||
tzinfo (>= 1.0.0)
|
tzinfo (>= 1.0.0)
|
||||||
uber (0.1.0)
|
uber (0.1.0)
|
||||||
unicode-display_width (2.4.2)
|
unicode-display_width (2.5.0)
|
||||||
uniform_notifier (1.16.0)
|
uniform_notifier (1.16.0)
|
||||||
version_gem (1.1.3)
|
version_gem (1.1.3)
|
||||||
warden (1.2.9)
|
warden (1.2.9)
|
||||||
@@ -555,15 +568,19 @@ DEPENDENCIES
|
|||||||
better_html
|
better_html
|
||||||
bootsnap
|
bootsnap
|
||||||
bullet
|
bullet
|
||||||
|
cancancan
|
||||||
capybara
|
capybara
|
||||||
cuprite
|
cuprite
|
||||||
debug
|
debug
|
||||||
devise
|
devise
|
||||||
|
devise-two-factor
|
||||||
dotenv
|
dotenv
|
||||||
|
email_typo
|
||||||
erb_lint
|
erb_lint
|
||||||
factory_bot_rails
|
factory_bot_rails
|
||||||
faker
|
faker
|
||||||
faraday
|
faraday
|
||||||
|
faraday-follow_redirects
|
||||||
google-cloud-storage
|
google-cloud-storage
|
||||||
hexapdf
|
hexapdf
|
||||||
image_processing
|
image_processing
|
||||||
@@ -584,6 +601,8 @@ DEPENDENCIES
|
|||||||
rails-i18n
|
rails-i18n
|
||||||
rails_autolink
|
rails_autolink
|
||||||
rollbar
|
rollbar
|
||||||
|
rotp
|
||||||
|
rqrcode
|
||||||
rspec-rails
|
rspec-rails
|
||||||
rubocop
|
rubocop
|
||||||
rubocop-performance
|
rubocop-performance
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|
|||||||
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
|
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
|
||||||
|**DigitalOcean**|**Render**|
|
|**DigitalOcean**|**Render**|
|
||||||
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
|
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
|
||||||
|
|**Koyeb**|**Elestio**|
|
||||||
|
| [<img alt="Deploy on Koyeb" src="https://www.koyeb.com/static/images/deploy/button.svg" height="40">](https://app.koyeb.com/deploy?name=docuseal&type=docker&image=docker.io/docuseal/docuseal&env[PORT]=8000&env[DATABASE_URL]=CHANGE_ME&env[SECRET_KEY_BASE]=CHANGE_ME&ports=8000;http;/) | [<img alt="Deploy on Elestio" src="https://pub-da36157c854648669813f3f76c526c2b.r2.dev/deploy-on-elestio-black.png">](https://dash.elest.io/deploy?soft=DocuSeal&id=339) |
|
||||||
|
|
||||||
|
|
||||||
#### Docker
|
#### Docker
|
||||||
@@ -79,9 +81,9 @@ HOST=your-domain-name.com docker-compose up
|
|||||||
|
|
||||||
At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, Real Estate, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws.
|
At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, Real Estate, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws.
|
||||||
|
|
||||||
[](https://cal.com/docuseal)
|
[Book a Meeting](https://calendly.com/kriti-docuseal/30min)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information.
|
Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information.
|
||||||
Unless otherwise noted, all files © 2023 Oleksandr Turchyn.
|
Unless otherwise noted, all files © 2023 DocuSeal LLC.
|
||||||
@@ -9,6 +9,9 @@ class AccountsController < ApplicationController
|
|||||||
'de-DE' => 'German (Germany)'
|
'de-DE' => 'German (Germany)'
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
|
before_action :load_account
|
||||||
|
authorize_resource :account
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -35,6 +38,10 @@ class AccountsController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def load_account
|
||||||
|
@account = current_account
|
||||||
|
end
|
||||||
|
|
||||||
def account_params
|
def account_params
|
||||||
params.require(:account).permit(:name, :timezone, :locale)
|
params.require(:account).permit(:name, :timezone, :locale)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,11 +3,36 @@
|
|||||||
module Api
|
module Api
|
||||||
class ApiBaseController < ActionController::API
|
class ApiBaseController < ActionController::API
|
||||||
include ActiveStorage::SetCurrent
|
include ActiveStorage::SetCurrent
|
||||||
|
include Pagy::Backend
|
||||||
|
|
||||||
|
DEFAULT_LIMIT = 10
|
||||||
|
MAX_LIMIT = 100
|
||||||
|
|
||||||
|
wrap_parameters false
|
||||||
|
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
|
check_authorization
|
||||||
|
|
||||||
|
if Rails.env.production?
|
||||||
|
rescue_from CanCan::AccessDenied do |e|
|
||||||
|
Rollbar.error(e) if defined?(Rollbar)
|
||||||
|
|
||||||
|
render json: { error: e.message }, status: :forbidden
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def paginate(relation)
|
||||||
|
result = relation.order(id: :desc)
|
||||||
|
.limit([params[:limit] || DEFAULT_LIMIT, MAX_LIMIT].min)
|
||||||
|
|
||||||
|
result = result.where('id < ?', params[:after]) if params[:after].present?
|
||||||
|
result = result.where('id > ?', params[:before]) if params[:before].present?
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
def current_account
|
def current_account
|
||||||
current_user&.account
|
current_user&.account
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
module Api
|
module Api
|
||||||
class AttachmentsController < ApiBaseController
|
class AttachmentsController < ApiBaseController
|
||||||
skip_before_action :authenticate_user!
|
skip_before_action :authenticate_user!
|
||||||
|
skip_authorization_check
|
||||||
|
|
||||||
def create
|
def create
|
||||||
submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||||
|
|||||||
@@ -2,73 +2,156 @@
|
|||||||
|
|
||||||
module Api
|
module Api
|
||||||
class SubmissionsController < ApiBaseController
|
class SubmissionsController < ApiBaseController
|
||||||
UnknownFieldName = Class.new(StandardError)
|
load_and_authorize_resource :template, only: :create
|
||||||
UnknownSubmitterName = Class.new(StandardError)
|
load_and_authorize_resource :submission, only: %i[show index]
|
||||||
|
|
||||||
|
before_action only: :create do
|
||||||
|
authorize!(:create, Submission)
|
||||||
|
end
|
||||||
|
|
||||||
|
def index
|
||||||
|
submissions = Submissions.search(@submissions, params[:q])
|
||||||
|
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
|
||||||
|
|
||||||
|
submissions = paginate(submissions.preload(:created_by_user, :template, :submitters))
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
data: submissions.as_json(serialize_params),
|
||||||
|
pagination: {
|
||||||
|
count: submissions.size,
|
||||||
|
next: submissions.last&.id,
|
||||||
|
prev: submissions.first&.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
serialized_subbmitters =
|
||||||
|
@submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob).map do |submitter|
|
||||||
|
Submissions::EnsureResultGenerated.call(submitter) if submitter.completed_at?
|
||||||
|
|
||||||
|
Submitters::SerializeForApi.call(submitter)
|
||||||
|
end
|
||||||
|
|
||||||
|
json = @submission.as_json(
|
||||||
|
serialize_params.deep_merge(
|
||||||
|
include: {
|
||||||
|
submission_events: {
|
||||||
|
only: %i[id submitter_id event_type event_timestamp]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
json[:submitters] = serialized_subbmitters
|
||||||
|
|
||||||
|
render json:
|
||||||
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
template = current_account.templates.find(params[:template_id])
|
is_send_email = !params[:send_email].in?(['false', false])
|
||||||
|
|
||||||
submissions =
|
submissions =
|
||||||
if (params[:emails] || params[:email]).present?
|
if (emails = (params[:emails] || params[:email]).presence) && params[:submission].blank?
|
||||||
Submissions.create_from_emails(template:,
|
Submissions.create_from_emails(template: @template,
|
||||||
user: current_user,
|
user: current_user,
|
||||||
source: :api,
|
source: :api,
|
||||||
mark_as_sent: params[:send_email] != 'false',
|
mark_as_sent: is_send_email,
|
||||||
emails: params[:emails] || params[:email])
|
emails:)
|
||||||
else
|
else
|
||||||
submissions_attrs = normalize_submissions_params!(submissions_params[:submission], template)
|
submissions_attrs, attachments = normalize_submissions_params!(submissions_params[:submission], @template)
|
||||||
|
|
||||||
Submissions.create_from_submitters(template:,
|
Submissions.create_from_submitters(
|
||||||
user: current_user,
|
template: @template,
|
||||||
source: :api,
|
user: current_user,
|
||||||
mark_as_sent: params[:send_email] != 'false',
|
source: :api,
|
||||||
submissions_attrs:)
|
mark_as_sent: is_send_email,
|
||||||
|
submitters_order: params[:submitters_order] || 'preserved',
|
||||||
|
submissions_attrs:
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Submissions.send_signature_requests(submissions, send_email: is_send_email)
|
||||||
|
|
||||||
submitters = submissions.flat_map(&:submitters)
|
submitters = submissions.flat_map(&:submitters)
|
||||||
|
|
||||||
Submitters.send_signature_requests(submitters, send_email: params[:send_email] != 'false')
|
save_default_value_attachments!(attachments, submitters)
|
||||||
|
|
||||||
render json: submitters
|
render json: submitters
|
||||||
rescue UnknownFieldName, UnknownSubmitterName => e
|
rescue Submitters::NormalizeValues::UnknownFieldName, Submitters::NormalizeValues::UnknownSubmitterName => e
|
||||||
render json: { error: e.message }, status: :unprocessable_entity
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@submission.update!(deleted_at: Time.current)
|
||||||
|
|
||||||
|
render json: @submission.as_json(only: %i[id deleted_at])
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def serialize_params
|
||||||
|
{
|
||||||
|
only: %i[id source submitters_order created_at updated_at],
|
||||||
|
include: {
|
||||||
|
submitters: { only: %i[id slug uuid name email phone
|
||||||
|
completed_at opened_at sent_at
|
||||||
|
created_at updated_at],
|
||||||
|
methods: %i[status] },
|
||||||
|
template: { only: %i[id name created_at updated_at] },
|
||||||
|
created_by_user: { only: %i[id email first_name last_name] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def submissions_params
|
def submissions_params
|
||||||
params.permit(submission: [{ submitters: [[:uuid, :name, :email, :role, :phone, { values: {} }]] }])
|
params.permit(submission: [{
|
||||||
|
submitters: [[:uuid, :name, :email, :role, :completed, :phone, :application_key,
|
||||||
|
{ values: {}, readonly_fields: [],
|
||||||
|
fields: [%i[name default_value readonly validation_pattern invalid_message]] }]]
|
||||||
|
}])
|
||||||
end
|
end
|
||||||
|
|
||||||
def normalize_submissions_params!(submissions_params, template)
|
def normalize_submissions_params!(submissions_params, template)
|
||||||
submissions_params.each do |submission|
|
attachments = []
|
||||||
submission[:submitters].each_with_index do |submitter, index|
|
|
||||||
next if submitter[:values].blank?
|
|
||||||
|
|
||||||
submitter[:values] =
|
Array.wrap(submissions_params).each do |submission|
|
||||||
normalize_submitter_values(template,
|
submission[:submitters].each_with_index do |submitter, index|
|
||||||
submitter[:values],
|
default_values = submitter[:values] || {}
|
||||||
submitter[:role] || template.submitters[index]['name'])
|
|
||||||
|
submitter[:fields]&.each { |f| default_values[f[:name]] = f[:default_value] if f[:default_value].present? }
|
||||||
|
|
||||||
|
next if default_values.blank?
|
||||||
|
|
||||||
|
values, new_attachments =
|
||||||
|
Submitters::NormalizeValues.call(template,
|
||||||
|
default_values,
|
||||||
|
submitter[:role] || template.submitters[index]['name'])
|
||||||
|
|
||||||
|
attachments.push(*new_attachments)
|
||||||
|
|
||||||
|
submitter[:values] = values
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
submissions_params
|
[submissions_params, attachments]
|
||||||
end
|
end
|
||||||
|
|
||||||
def normalize_submitter_values(template, values, submitter_name)
|
def save_default_value_attachments!(attachments, submitters)
|
||||||
submitter =
|
return if attachments.blank?
|
||||||
template.submitters.find { |e| e['name'] == submitter_name } ||
|
|
||||||
raise(UnknownSubmitterName, "Unknown submitter: #{submitter_name}")
|
|
||||||
|
|
||||||
fields = template.fields.select { |e| e['submitter_uuid'] == submitter['uuid'] }
|
attachments_index = attachments.index_by(&:uuid)
|
||||||
|
|
||||||
fields_uuid_index = fields.index_by { |e| e['uuid'] }
|
submitters.each do |submitter|
|
||||||
fields_name_index = fields.index_by { |e| e['name'] }
|
submitter.values.to_a.each do |_, value|
|
||||||
|
attachment = attachments_index[value]
|
||||||
|
|
||||||
values.transform_keys do |key|
|
next unless attachment
|
||||||
next key if fields_uuid_index[key].present?
|
|
||||||
|
|
||||||
fields_name_index[key]&.dig('uuid') || raise(UnknownFieldName, "Unknown field: #{key}")
|
attachment.record = submitter
|
||||||
|
|
||||||
|
attachment.save!
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
class SubmitterEmailClicksController < ApiBaseController
|
||||||
|
skip_before_action :authenticate_user!
|
||||||
|
skip_authorization_check
|
||||||
|
|
||||||
|
def create
|
||||||
|
submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||||
|
|
||||||
|
if params[:t] == SubmissionEvents.build_tracking_param(submitter, 'click_email')
|
||||||
|
SubmissionEvents.create_with_tracking_data(submitter, 'click_email', request)
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: {}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
class SubmitterFormViewsController < ApiBaseController
|
||||||
|
skip_before_action :authenticate_user!
|
||||||
|
skip_authorization_check
|
||||||
|
|
||||||
|
def create
|
||||||
|
submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||||
|
|
||||||
|
SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request)
|
||||||
|
|
||||||
|
SendFormViewedWebhookRequestJob.perform_later(submitter)
|
||||||
|
|
||||||
|
render json: {}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
class SubmittersAutocompleteController < ApiBaseController
|
||||||
|
load_and_authorize_resource :submitter, parent: false
|
||||||
|
|
||||||
|
SELECT_COLUMNS = %w[email phone name].freeze
|
||||||
|
LIMIT = 100
|
||||||
|
|
||||||
|
def index
|
||||||
|
submitters = search_submitters(@submitters)
|
||||||
|
|
||||||
|
values = submitters.limit(LIMIT).group(SELECT_COLUMNS.join(', ')).pluck(SELECT_COLUMNS.join(', '))
|
||||||
|
|
||||||
|
attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h }
|
||||||
|
attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present?
|
||||||
|
|
||||||
|
render json: attrs
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def search_submitters(submitters)
|
||||||
|
if SELECT_COLUMNS.include?(params[:field])
|
||||||
|
column = Submitter.arel_table[params[:field].to_sym]
|
||||||
|
|
||||||
|
term = "%#{params[:q].downcase}%"
|
||||||
|
|
||||||
|
submitters.where(column.lower.matches(term))
|
||||||
|
else
|
||||||
|
Submitters.search(submitters, params[:q])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
class SubmittersController < ApiBaseController
|
||||||
|
load_and_authorize_resource :submitter
|
||||||
|
|
||||||
|
def index
|
||||||
|
submitters = Submitters.search(@submitters, params[:q])
|
||||||
|
|
||||||
|
submitters = submitters.where(application_key: params[:application_key]) if params[:application_key].present?
|
||||||
|
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
|
||||||
|
|
||||||
|
submitters = paginate(
|
||||||
|
submitters.preload(:template, :submission, :submission_events,
|
||||||
|
documents_attachments: :blob, attachments_attachments: :blob)
|
||||||
|
)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
data: submitters.map { |s| Submitters::SerializeForApi.call(s, with_template: true, with_events: true) },
|
||||||
|
pagination: {
|
||||||
|
count: submitters.size,
|
||||||
|
next: submitters.last&.id,
|
||||||
|
prev: submitters.first&.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at?
|
||||||
|
|
||||||
|
render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
class TemplateFoldersAutocompleteController < ApiBaseController
|
||||||
|
load_and_authorize_resource :template_folder, parent: false
|
||||||
|
|
||||||
|
LIMIT = 100
|
||||||
|
|
||||||
|
def index
|
||||||
|
template_folders = @template_folders.joins(:templates).where(templates: { deleted_at: nil }).distinct
|
||||||
|
template_folders = TemplateFolders.search(template_folders, params[:q]).limit(LIMIT)
|
||||||
|
|
||||||
|
render json: template_folders.as_json(only: %i[name deleted_at])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -2,25 +2,54 @@
|
|||||||
|
|
||||||
module Api
|
module Api
|
||||||
class TemplatesController < ApiBaseController
|
class TemplatesController < ApiBaseController
|
||||||
before_action :load_template, only: %i[show update]
|
load_and_authorize_resource :template
|
||||||
|
|
||||||
def index
|
def index
|
||||||
render json: current_account.templates
|
templates = Templates.search(@templates, params[:q])
|
||||||
|
|
||||||
|
templates = params[:archived] ? templates.archived : templates.active
|
||||||
|
|
||||||
|
templates = paginate(templates.preload(:author, documents_attachments: :blob))
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
data: templates.as_json(serialize_params),
|
||||||
|
pagination: {
|
||||||
|
count: templates.size,
|
||||||
|
next: templates.last&.id,
|
||||||
|
prev: templates.first&.id
|
||||||
|
}
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
render json: @template.as_json(include: { author: { only: %i[id email first_name last_name] },
|
render json: @template.as_json(serialize_params)
|
||||||
documents: { only: %i[id uuid], methods: %i[url filename] } })
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
if (folder_name = params.dig(:template, :folder_name))
|
||||||
|
@template.folder = TemplateFolders.find_or_create_by_name(current_user, folder_name)
|
||||||
|
end
|
||||||
|
|
||||||
@template.update!(template_params)
|
@template.update!(template_params)
|
||||||
|
|
||||||
render :ok
|
render json: @template.as_json(only: %i[id updated_at])
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@template.update!(deleted_at: Time.current)
|
||||||
|
|
||||||
|
render json: @template.as_json(only: %i[id deleted_at])
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def serialize_params
|
||||||
|
{
|
||||||
|
include: { author: { only: %i[id email first_name last_name] },
|
||||||
|
documents: { only: %i[id uuid], methods: %i[url filename] } }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def template_params
|
def template_params
|
||||||
params.require(:template).permit(:name,
|
params.require(:template).permit(:name,
|
||||||
schema: [%i[attachment_uuid name]],
|
schema: [%i[attachment_uuid name]],
|
||||||
@@ -28,9 +57,5 @@ module Api
|
|||||||
fields: [[:uuid, :submitter_uuid, :name, :type, :required,
|
fields: [[:uuid, :submitter_uuid, :name, :type, :required,
|
||||||
{ options: [], areas: [%i[x y w h cell_w attachment_uuid page]] }]])
|
{ options: [], areas: [%i[x y w h cell_w attachment_uuid page]] }]])
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_template
|
|
||||||
@template = current_account.templates.find(params[:id])
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
module Api
|
module Api
|
||||||
class TemplatesDocumentsController < ApiBaseController
|
class TemplatesDocumentsController < ApiBaseController
|
||||||
|
load_and_authorize_resource :template
|
||||||
|
|
||||||
def create
|
def create
|
||||||
return head :unprocessable_entity if params[:blobs].blank? && params[:files].blank?
|
return head :unprocessable_entity if params[:blobs].blank? && params[:files].blank?
|
||||||
|
|
||||||
@template = current_account.templates.find(params[:template_id])
|
|
||||||
|
|
||||||
documents = Templates::CreateAttachments.call(@template, params)
|
documents = Templates::CreateAttachments.call(@template, params)
|
||||||
|
|
||||||
schema = documents.map do |doc|
|
schema = documents.map do |doc|
|
||||||
@@ -16,6 +16,7 @@ module Api
|
|||||||
render json: {
|
render json: {
|
||||||
schema:,
|
schema:,
|
||||||
documents: documents.as_json(
|
documents: documents.as_json(
|
||||||
|
methods: [:metadata],
|
||||||
include: {
|
include: {
|
||||||
preview_images: { methods: %i[url metadata filename] }
|
preview_images: { methods: %i[url metadata filename] }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ApiSettingsController < ApplicationController
|
class ApiSettingsController < ApplicationController
|
||||||
def index; end
|
def index
|
||||||
|
authorize!(:read, current_user.access_token)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -4,6 +4,8 @@ class ApplicationController < ActionController::Base
|
|||||||
include ActiveStorage::SetCurrent
|
include ActiveStorage::SetCurrent
|
||||||
include Pagy::Backend
|
include Pagy::Backend
|
||||||
|
|
||||||
|
check_authorization unless: :devise_controller?
|
||||||
|
|
||||||
before_action :sign_in_for_demo, if: -> { Docuseal.demo? }
|
before_action :sign_in_for_demo, if: -> { Docuseal.demo? }
|
||||||
before_action :maybe_redirect_to_setup, unless: :signed_in?
|
before_action :maybe_redirect_to_setup, unless: :signed_in?
|
||||||
before_action :authenticate_user!, unless: :devise_controller?
|
before_action :authenticate_user!, unless: :devise_controller?
|
||||||
@@ -16,6 +18,14 @@ class ApplicationController < ActionController::Base
|
|||||||
redirect_to request.path
|
redirect_to request.path
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if Rails.env.production?
|
||||||
|
rescue_from CanCan::AccessDenied do |e|
|
||||||
|
Rollbar.error(e) if defined?(Rollbar)
|
||||||
|
|
||||||
|
redirect_back(fallback_location: root_path, alert: e.message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def default_url_options
|
def default_url_options
|
||||||
Docuseal.default_url_options
|
Docuseal.default_url_options
|
||||||
end
|
end
|
||||||
@@ -23,7 +33,7 @@ class ApplicationController < ActionController::Base
|
|||||||
private
|
private
|
||||||
|
|
||||||
def sign_in_for_demo
|
def sign_in_for_demo
|
||||||
sign_in(User.order('random()').take) unless signed_in?
|
sign_in(User.active.order('random()').take) unless signed_in?
|
||||||
end
|
end
|
||||||
|
|
||||||
def current_account
|
def current_account
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
class ConsoleRedirectController < ApplicationController
|
class ConsoleRedirectController < ApplicationController
|
||||||
skip_before_action :authenticate_user!
|
skip_before_action :authenticate_user!
|
||||||
|
skip_authorization_check
|
||||||
|
|
||||||
def index
|
def index
|
||||||
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if current_user.blank?
|
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if current_user.blank?
|
||||||
|
|||||||
@@ -3,12 +3,68 @@
|
|||||||
class DashboardController < ApplicationController
|
class DashboardController < ApplicationController
|
||||||
skip_before_action :authenticate_user!, only: %i[index]
|
skip_before_action :authenticate_user!, only: %i[index]
|
||||||
|
|
||||||
|
before_action :maybe_redirect_product_url
|
||||||
|
before_action :maybe_render_landing
|
||||||
|
|
||||||
|
load_and_authorize_resource :template_folder, parent: false
|
||||||
|
load_and_authorize_resource :template, parent: false
|
||||||
|
|
||||||
|
SHOW_TEMPLATES_FOLDERS_THRESHOLD = 9
|
||||||
|
TEMPLATES_PER_PAGE = 12
|
||||||
|
FOLDERS_PER_PAGE = 18
|
||||||
|
|
||||||
def index
|
def index
|
||||||
return redirect_to Docuseal::PRODUCT_URL, allow_other_host: true if Docuseal.multitenant? && !signed_in?
|
@template_folders = filter_template_folders(@template_folders)
|
||||||
return render 'pages/landing' unless signed_in?
|
|
||||||
|
|
||||||
templates = current_account.templates.active.preload(:author).order(id: :desc)
|
@pagy, @template_folders = pagy(
|
||||||
|
@template_folders,
|
||||||
|
items: FOLDERS_PER_PAGE,
|
||||||
|
page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1
|
||||||
|
)
|
||||||
|
|
||||||
@pagy, @templates = pagy(templates, items: 12)
|
if @pagy.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD
|
||||||
|
@templates = @templates.none
|
||||||
|
else
|
||||||
|
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME }
|
||||||
|
@templates = filter_templates(@templates)
|
||||||
|
|
||||||
|
items =
|
||||||
|
if @template_folders.size < 4
|
||||||
|
TEMPLATES_PER_PAGE
|
||||||
|
else
|
||||||
|
(@template_folders.size < 7 ? 9 : 6)
|
||||||
|
end
|
||||||
|
|
||||||
|
@pagy, @templates = pagy(@templates, items:)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def filter_template_folders(template_folders)
|
||||||
|
rel = template_folders.joins(:active_templates)
|
||||||
|
.order(id: :desc)
|
||||||
|
.distinct
|
||||||
|
|
||||||
|
TemplateFolders.search(rel, params[:q])
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_templates(templates)
|
||||||
|
rel = templates.active.preload(:author).order(id: :desc)
|
||||||
|
rel = rel.where(folder_id: current_account.default_template_folder.id) if params[:q].blank?
|
||||||
|
|
||||||
|
Templates.search(rel, params[:q])
|
||||||
|
end
|
||||||
|
|
||||||
|
def maybe_redirect_product_url
|
||||||
|
return if !Docuseal.multitenant? || signed_in?
|
||||||
|
|
||||||
|
redirect_to Docuseal::PRODUCT_URL, allow_other_host: true
|
||||||
|
end
|
||||||
|
|
||||||
|
def maybe_render_landing
|
||||||
|
return if signed_in?
|
||||||
|
|
||||||
|
render 'pages/landing'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
+3
-1
@@ -1,7 +1,9 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class EmailSettingsController < ApplicationController
|
class EmailSmtpSettingsController < ApplicationController
|
||||||
before_action :load_encrypted_config
|
before_action :load_encrypted_config
|
||||||
|
authorize_resource :encrypted_config, only: :index
|
||||||
|
authorize_resource :encrypted_config, parent: false, only: :create
|
||||||
|
|
||||||
def index; end
|
def index; end
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class EnquiriesController < ApplicationController
|
||||||
|
skip_before_action :authenticate_user!
|
||||||
|
skip_authorization_check
|
||||||
|
|
||||||
|
def create
|
||||||
|
if params[:talk_to_sales] == 'on'
|
||||||
|
Faraday.post(Docuseal::ENQUIRIES_URL,
|
||||||
|
enquiry_params.merge(type: :talk_to_sales).to_json,
|
||||||
|
'Content-Type' => 'application/json')
|
||||||
|
end
|
||||||
|
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def enquiry_params
|
||||||
|
params.require(:user).permit(:email)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -11,9 +11,12 @@ class EsignSettingsController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
before_action :load_encrypted_config
|
||||||
|
authorize_resource :encrypted_config, parent: false, only: %i[new create]
|
||||||
|
authorize_resource :encrypted_config, only: %i[update destroy show]
|
||||||
|
|
||||||
def show
|
def show
|
||||||
cert_data = EncryptedConfig.find_by(account: current_account,
|
cert_data = @encrypted_config.value || {}
|
||||||
key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
|
|
||||||
|
|
||||||
default_pkcs = GenerateCertificate.load_pkcs(cert_data) if cert_data['cert'].present?
|
default_pkcs = GenerateCertificate.load_pkcs(cert_data) if cert_data['cert'].present?
|
||||||
|
|
||||||
@@ -42,10 +45,7 @@ class EsignSettingsController < ApplicationController
|
|||||||
def create
|
def create
|
||||||
@cert_record = CertFormRecord.new(**cert_params)
|
@cert_record = CertFormRecord.new(**cert_params)
|
||||||
|
|
||||||
cert_configs = EncryptedConfig.find_or_initialize_by(account: current_account,
|
if (@encrypted_config.value && @encrypted_config.value['custom']&.any? { |e| e['name'] == @cert_record.name }) ||
|
||||||
key: EncryptedConfig::ESIGN_CERTS_KEY)
|
|
||||||
|
|
||||||
if (cert_configs.value && cert_configs.value['custom']&.any? { |e| e['name'] == @cert_record.name }) ||
|
|
||||||
@cert_record.name == DEFAULT_CERT_NAME
|
@cert_record.name == DEFAULT_CERT_NAME
|
||||||
|
|
||||||
@cert_record.errors.add(:name, 'already exists')
|
@cert_record.errors.add(:name, 'already exists')
|
||||||
@@ -54,7 +54,7 @@ class EsignSettingsController < ApplicationController
|
|||||||
status: :unprocessable_entity
|
status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
save_new_cert!(cert_configs, @cert_record)
|
save_new_cert!(@encrypted_config, @cert_record)
|
||||||
|
|
||||||
redirect_to settings_esign_path, notice: 'Certificate has been successfully added!'
|
redirect_to settings_esign_path, notice: 'Certificate has been successfully added!'
|
||||||
rescue OpenSSL::PKCS12::PKCS12Error
|
rescue OpenSSL::PKCS12::PKCS12Error
|
||||||
@@ -64,29 +64,31 @@ class EsignSettingsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
cert_configs = EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY)
|
@encrypted_config.value['custom'].each { |e| e['status'] = 'validate' }
|
||||||
|
|
||||||
cert_configs.value['custom'].each { |e| e['status'] = 'validate' }
|
custom_cert_data = @encrypted_config.value['custom'].find { |e| e['name'] == params[:name] }
|
||||||
custom_cert_data = cert_configs.value['custom'].find { |e| e['name'] == params[:name] }
|
|
||||||
custom_cert_data['status'] = 'default' if custom_cert_data
|
custom_cert_data['status'] = 'default' if custom_cert_data
|
||||||
|
|
||||||
cert_configs.save!
|
@encrypted_config.save!
|
||||||
|
|
||||||
redirect_to settings_esign_path, notice: 'Default certificate has been selected'
|
redirect_to settings_esign_path, notice: 'Default certificate has been selected'
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
cert_configs = EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY)
|
@encrypted_config.value['custom'].reject! { |e| e['name'] == params[:name] }
|
||||||
|
|
||||||
cert_configs.value['custom'].reject! { |e| e['name'] == params[:name] }
|
@encrypted_config.save!
|
||||||
|
|
||||||
cert_configs.save!
|
|
||||||
|
|
||||||
redirect_to settings_esign_path, notice: 'Certificate has been removed'
|
redirect_to settings_esign_path, notice: 'Certificate has been removed'
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def load_encrypted_config
|
||||||
|
@encrypted_config = EncryptedConfig.find_or_initialize_by(account: current_account,
|
||||||
|
key: EncryptedConfig::ESIGN_CERTS_KEY)
|
||||||
|
end
|
||||||
|
|
||||||
def save_new_cert!(cert_configs, cert_record)
|
def save_new_cert!(cert_configs, cert_record)
|
||||||
pkcs = OpenSSL::PKCS12.new(cert_record.file.read, cert_record.password)
|
pkcs = OpenSSL::PKCS12.new(cert_record.file.read, cert_record.password)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class MfaSetupController < ApplicationController
|
||||||
|
before_action do
|
||||||
|
authorize!(:update, current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
current_user.otp_secret ||= User.generate_otp_secret
|
||||||
|
|
||||||
|
current_user.save!
|
||||||
|
|
||||||
|
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit; end
|
||||||
|
|
||||||
|
def create
|
||||||
|
if current_user.validate_and_consume_otp!(params[:otp_attempt])
|
||||||
|
current_user.otp_required_for_login = true
|
||||||
|
current_user.save!
|
||||||
|
|
||||||
|
redirect_to settings_profile_index_path, notice: '2FA has been configured'
|
||||||
|
else
|
||||||
|
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
|
||||||
|
|
||||||
|
@error_message = 'Code is invalid'
|
||||||
|
|
||||||
|
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/new'), status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
if current_user.validate_and_consume_otp!(params[:otp_attempt])
|
||||||
|
current_user.update!(otp_required_for_login: false, otp_secret: nil)
|
||||||
|
|
||||||
|
redirect_to settings_profile_index_path, notice: '2FA has been removed'
|
||||||
|
else
|
||||||
|
@error_message = 'Code is invalid'
|
||||||
|
|
||||||
|
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class NewslettersController < ApplicationController
|
class NewslettersController < ApplicationController
|
||||||
|
skip_authorization_check
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class NotificationsSettingsController < ApplicationController
|
||||||
|
before_action :load_bcc_config, only: :index
|
||||||
|
before_action :load_reminder_config, only: :index
|
||||||
|
authorize_resource :bcc_config, only: :index
|
||||||
|
authorize_resource :reminder_config, only: :index
|
||||||
|
|
||||||
|
before_action :build_account_config, only: :create
|
||||||
|
authorize_resource :account_config, only: :create
|
||||||
|
|
||||||
|
def index; end
|
||||||
|
|
||||||
|
def create
|
||||||
|
if @account_config.save
|
||||||
|
redirect_back fallback_location: settings_notifications_path, notice: 'Changes have been saved'
|
||||||
|
else
|
||||||
|
redirect_back fallback_location: settings_notifications_path, alert: 'Unable to save'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def build_account_config
|
||||||
|
@account_config =
|
||||||
|
AccountConfig.find_or_initialize_by(account: current_account, key: email_config_params[:key])
|
||||||
|
|
||||||
|
@account_config.assign_attributes(email_config_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_bcc_config
|
||||||
|
@bcc_config =
|
||||||
|
AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::BCC_EMAILS)
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_reminder_config
|
||||||
|
@reminder_config =
|
||||||
|
AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::SUBMITTER_REMAILERS)
|
||||||
|
end
|
||||||
|
|
||||||
|
def email_config_params
|
||||||
|
params.require(:account_config).permit!.tap do |attrs|
|
||||||
|
attrs[:key] = nil unless attrs[:key].in?([AccountConfig::BCC_EMAILS, AccountConfig::SUBMITTER_REMAILERS])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class PasswordsController < Devise::PasswordsController
|
||||||
|
class Current < ActiveSupport::CurrentAttributes
|
||||||
|
attribute :user
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
super do |resource|
|
||||||
|
Current.user = resource
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class PersonalizationSettingsController < ApplicationController
|
class PersonalizationSettingsController < ApplicationController
|
||||||
def show; end
|
def show
|
||||||
|
authorize!(:read, AccountConfig)
|
||||||
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
account_config =
|
account_config =
|
||||||
current_account.account_configs.find_or_initialize_by(key: encrypted_config_params[:key])
|
current_account.account_configs.find_or_initialize_by(key: encrypted_config_params[:key])
|
||||||
|
|
||||||
|
authorize!(:create, account_config)
|
||||||
|
|
||||||
account_config.update!(encrypted_config_params)
|
account_config.update!(encrypted_config_params)
|
||||||
|
|
||||||
redirect_back(fallback_location: settings_personalization_path, notice: 'Settings have been saved.')
|
redirect_back(fallback_location: settings_personalization_path, notice: 'Settings have been saved.')
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class PreviewDocumentPageController < ActionController::API
|
||||||
|
include ActiveStorage::SetCurrent
|
||||||
|
|
||||||
|
FORMAT = Templates::ProcessDocument::FORMAT
|
||||||
|
|
||||||
|
def show
|
||||||
|
attachment = ActiveStorage::Attachment.find_by(uuid: params[:attachment_uuid])
|
||||||
|
|
||||||
|
return head :not_found unless attachment
|
||||||
|
|
||||||
|
preview_image = attachment.preview_images.joins(:blob).find_by(blob: { filename: "#{params[:id]}#{FORMAT}" })
|
||||||
|
|
||||||
|
return redirect_to preview_image.url, allow_other_host: true if preview_image
|
||||||
|
|
||||||
|
file_path =
|
||||||
|
if attachment.service.name == :disk
|
||||||
|
ActiveStorage::Blob.service.path_for(attachment.key)
|
||||||
|
else
|
||||||
|
find_or_create_document_tempfile_path(attachment)
|
||||||
|
end
|
||||||
|
|
||||||
|
io = Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
|
||||||
|
|
||||||
|
render plain: io.tap(&:rewind).read
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_or_create_document_tempfile_path(attachment)
|
||||||
|
file_path = "#{Dir.tmpdir}/#{attachment.uuid}"
|
||||||
|
|
||||||
|
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f|
|
||||||
|
f.flock(File::LOCK_EX)
|
||||||
|
|
||||||
|
# rubocop:disable Style/ZeroLengthPredicate
|
||||||
|
if f.size.zero?
|
||||||
|
f.binmode
|
||||||
|
|
||||||
|
f.write(attachment.download)
|
||||||
|
end
|
||||||
|
# rubocop:enable Style/ZeroLengthPredicate
|
||||||
|
end
|
||||||
|
|
||||||
|
file_path
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ProfileController < ApplicationController
|
class ProfileController < ApplicationController
|
||||||
|
before_action do
|
||||||
|
authorize!(:manage, current_user)
|
||||||
|
end
|
||||||
|
|
||||||
def index; end
|
def index; end
|
||||||
|
|
||||||
def update_contact
|
def update_contact
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ class RegistrationsController < Devise::RegistrationsController
|
|||||||
redirect_to after_sign_up_path_for(current_user), allow_other_host: true
|
redirect_to after_sign_up_path_for(current_user), allow_other_host: true
|
||||||
end
|
end
|
||||||
|
|
||||||
def require_no_authentication
|
def set_flash_message(key, kind, options = {})
|
||||||
super
|
return if key == :alert && kind == 'already_authenticated'
|
||||||
|
|
||||||
flash.clear
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_resource(_hash = {})
|
def build_resource(_hash = {})
|
||||||
@@ -43,7 +43,7 @@ class RegistrationsController < Devise::RegistrationsController
|
|||||||
|
|
||||||
self.resource = account.users.new(user_params)
|
self.resource = account.users.new(user_params)
|
||||||
|
|
||||||
account.name ||= "#{resource.full_name}'s Company" if params[:action] == 'create'
|
account.name ||= resource.full_name if params[:action] == 'create'
|
||||||
end
|
end
|
||||||
|
|
||||||
def user_params
|
def user_params
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ class SendSubmissionEmailController < ApplicationController
|
|||||||
|
|
||||||
skip_before_action :authenticate_user!
|
skip_before_action :authenticate_user!
|
||||||
skip_before_action :verify_authenticity_token
|
skip_before_action :verify_authenticity_token
|
||||||
|
skip_authorization_check
|
||||||
|
|
||||||
def success; end
|
def success; end
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class SessionsController < Devise::SessionsController
|
class SessionsController < Devise::SessionsController
|
||||||
|
before_action :configure_permitted_parameters
|
||||||
|
|
||||||
def create
|
def create
|
||||||
if Docuseal.multitenant? && !User.exists?(email: sign_in_params[:email])
|
if Docuseal.multitenant? && !User.exists?(email: sign_in_params[:email])
|
||||||
return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
|
return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
|
||||||
notice: 'Create a new account'
|
notice: 'Create a new account'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if User.exists?(email: sign_in_params[:email], otp_required_for_login: true) && sign_in_params[:otp_attempt].blank?
|
||||||
|
return render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -22,9 +28,13 @@ class SessionsController < Devise::SessionsController
|
|||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
def require_no_authentication
|
def configure_permitted_parameters
|
||||||
super
|
devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
|
||||||
|
end
|
||||||
|
|
||||||
flash.clear
|
def set_flash_message(key, kind, options = {})
|
||||||
|
return if key == :alert && kind == 'already_authenticated'
|
||||||
|
|
||||||
|
super
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
class SetupController < ApplicationController
|
class SetupController < ApplicationController
|
||||||
skip_before_action :maybe_redirect_to_setup
|
skip_before_action :maybe_redirect_to_setup
|
||||||
skip_before_action :authenticate_user!
|
skip_before_action :authenticate_user!
|
||||||
|
skip_authorization_check
|
||||||
|
|
||||||
before_action :redirect_to_root_if_signed, if: :signed_in?
|
before_action :redirect_to_root_if_signed, if: :signed_in?
|
||||||
before_action :ensure_first_user_not_created!
|
before_action :ensure_first_user_not_created!
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class SmsSettingsController < ApplicationController
|
class SmsSettingsController < ApplicationController
|
||||||
|
before_action :load_encrypted_config
|
||||||
|
authorize_resource :encrypted_config, only: :index
|
||||||
|
authorize_resource :encrypted_config, parent: false, except: :index
|
||||||
|
|
||||||
def index; end
|
def index; end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def load_encrypted_config
|
||||||
|
@encrypted_config =
|
||||||
|
EncryptedConfig.find_or_initialize_by(account: current_account, key: 'sms_configs')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class SsoSettingsController < ApplicationController
|
||||||
|
before_action :load_encrypted_config
|
||||||
|
authorize_resource :encrypted_config, only: :index
|
||||||
|
authorize_resource :encrypted_config, parent: false, except: :index
|
||||||
|
|
||||||
|
def index; end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def load_encrypted_config
|
||||||
|
@encrypted_config =
|
||||||
|
EncryptedConfig.find_or_initialize_by(account: current_account, key: 'saml_configs')
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,6 +4,7 @@ class StartFormController < ApplicationController
|
|||||||
layout 'form'
|
layout 'form'
|
||||||
|
|
||||||
skip_before_action :authenticate_user!
|
skip_before_action :authenticate_user!
|
||||||
|
skip_authorization_check
|
||||||
|
|
||||||
before_action :load_template
|
before_action :load_template
|
||||||
|
|
||||||
@@ -13,19 +14,21 @@ class StartFormController < ApplicationController
|
|||||||
|
|
||||||
def update
|
def update
|
||||||
@submitter = Submitter.where(submission: @template.submissions.where(deleted_at: nil))
|
@submitter = Submitter.where(submission: @template.submissions.where(deleted_at: nil))
|
||||||
.find_or_initialize_by(email: submitter_params[:email])
|
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
|
||||||
|
.find_or_initialize_by(**submitter_params.compact_blank)
|
||||||
|
|
||||||
if @submitter.completed_at?
|
if @submitter.completed_at?
|
||||||
redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email])
|
redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email])
|
||||||
else
|
else
|
||||||
@submitter.assign_attributes(
|
@submitter.assign_attributes(
|
||||||
uuid: @template.submitters.first['uuid'],
|
uuid: @template.submitters.first['uuid'],
|
||||||
opened_at: Time.current,
|
|
||||||
ip: request.remote_ip,
|
ip: request.remote_ip,
|
||||||
ua: request.user_agent
|
ua: request.user_agent
|
||||||
)
|
)
|
||||||
|
|
||||||
@submitter.submission ||= Submission.new(template: @template, source: :link)
|
@submitter.submission ||= Submission.new(template: @template,
|
||||||
|
template_submitters: @template.submitters,
|
||||||
|
source: :link)
|
||||||
|
|
||||||
if @submitter.save
|
if @submitter.save
|
||||||
redirect_to submit_form_path(@submitter.slug)
|
redirect_to submit_form_path(@submitter.slug)
|
||||||
@@ -44,7 +47,9 @@ class StartFormController < ApplicationController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def submitter_params
|
def submitter_params
|
||||||
params.require(:submitter).permit(:email)
|
params.require(:submitter).permit(:email, :phone).tap do |attrs|
|
||||||
|
attrs[:email] = Submissions.normalize_email(attrs[:email])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_template
|
def load_template
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
class StorageSettingsController < ApplicationController
|
class StorageSettingsController < ApplicationController
|
||||||
before_action :load_encrypted_config
|
before_action :load_encrypted_config
|
||||||
|
authorize_resource :encrypted_config, only: :index
|
||||||
|
authorize_resource :encrypted_config, parent: false, only: :create
|
||||||
|
|
||||||
def index; end
|
def index; end
|
||||||
|
|
||||||
|
|||||||
@@ -2,19 +2,38 @@
|
|||||||
|
|
||||||
class SubmissionsController < ApplicationController
|
class SubmissionsController < ApplicationController
|
||||||
before_action :load_template, only: %i[new create]
|
before_action :load_template, only: %i[new create]
|
||||||
|
authorize_resource :template, only: %i[new create]
|
||||||
|
|
||||||
|
load_and_authorize_resource :submission, only: %i[show destroy]
|
||||||
|
|
||||||
|
PRELOAD_ALL_PAGES_AMOUNT = 200
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@submission =
|
ActiveRecord::Associations::Preloader.new(
|
||||||
Submission.joins(:template).where(template: { account_id: current_account.id })
|
records: [@submission],
|
||||||
.preload(:template, template_schema_documents: [:blob, { preview_images_attachments: :blob }])
|
associations: [:template, { template_schema_documents: :blob }]
|
||||||
.find(params[:id])
|
).call
|
||||||
|
|
||||||
|
total_pages =
|
||||||
|
@submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
|
||||||
|
|
||||||
|
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
|
||||||
|
ActiveRecord::Associations::Preloader.new(
|
||||||
|
records: @submission.template_schema_documents,
|
||||||
|
associations: [:blob, { preview_images_attachments: :blob }]
|
||||||
|
).call
|
||||||
|
end
|
||||||
|
|
||||||
render :show, layout: 'plain'
|
render :show, layout: 'plain'
|
||||||
end
|
end
|
||||||
|
|
||||||
def new; end
|
def new
|
||||||
|
authorize!(:new, Submission)
|
||||||
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
authorize!(:create, Submission)
|
||||||
|
|
||||||
submissions =
|
submissions =
|
||||||
if params[:emails].present?
|
if params[:emails].present?
|
||||||
Submissions.create_from_emails(template: @template,
|
Submissions.create_from_emails(template: @template,
|
||||||
@@ -26,25 +45,20 @@ class SubmissionsController < ApplicationController
|
|||||||
Submissions.create_from_submitters(template: @template,
|
Submissions.create_from_submitters(template: @template,
|
||||||
user: current_user,
|
user: current_user,
|
||||||
source: :invite,
|
source: :invite,
|
||||||
|
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
|
||||||
mark_as_sent: params[:send_email] == '1',
|
mark_as_sent: params[:send_email] == '1',
|
||||||
submissions_attrs: submissions_params[:submission].to_h.values)
|
submissions_attrs: submissions_params[:submission].to_h.values)
|
||||||
end
|
end
|
||||||
|
|
||||||
submitters = submissions.flat_map(&:submitters)
|
Submissions.send_signature_requests(submissions, params)
|
||||||
|
|
||||||
Submitters.send_signature_requests(submitters, params)
|
redirect_to template_path(@template), notice: 'New recipients have been added'
|
||||||
|
|
||||||
redirect_to template_path(@template),
|
|
||||||
notice: "#{submitters.size} #{'recipient'.pluralize(submitters.size)} added"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
submission = Submission.joins(:template).where(template: { account_id: current_account.id })
|
@submission.update!(deleted_at: Time.current)
|
||||||
.find(params[:id])
|
|
||||||
|
|
||||||
submission.update!(deleted_at: Time.current)
|
redirect_back(fallback_location: template_path(@submission.template), notice: 'Submission has been archived')
|
||||||
|
|
||||||
redirect_back(fallback_location: template_path(submission.template), notice: 'Submission has been archived')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ class SubmissionsDebugController < ApplicationController
|
|||||||
layout 'plain'
|
layout 'plain'
|
||||||
|
|
||||||
skip_before_action :authenticate_user!
|
skip_before_action :authenticate_user!
|
||||||
|
skip_authorization_check
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@submitter = Submitter.preload({ attachments_attachments: :blob },
|
@submitter = Submitter.preload({ attachments_attachments: :blob },
|
||||||
@@ -15,9 +16,13 @@ class SubmissionsDebugController < ApplicationController
|
|||||||
render 'submit_form/show'
|
render 'submit_form/show'
|
||||||
end
|
end
|
||||||
f.pdf do
|
f.pdf do
|
||||||
Submissions::GenerateResultAttachments.call(@submitter)
|
if params[:audit]
|
||||||
|
Submissions::GenerateAuditTrail.call(@submitter.submission)
|
||||||
|
else
|
||||||
|
Submissions::GenerateResultAttachments.call(@submitter)
|
||||||
|
end
|
||||||
|
|
||||||
send_data ActiveStorage::Attachment.where(name: :documents).last.download,
|
send_data ActiveStorage::Attachment.where(name: params[:audit] ? :audit_trail : :documents).last.download,
|
||||||
filename: 'debug.pdf',
|
filename: 'debug.pdf',
|
||||||
disposition: 'inline',
|
disposition: 'inline',
|
||||||
type: 'application/pdf'
|
type: 'application/pdf'
|
||||||
|
|||||||
@@ -2,14 +2,19 @@
|
|||||||
|
|
||||||
class SubmissionsDownloadController < ApplicationController
|
class SubmissionsDownloadController < ApplicationController
|
||||||
skip_before_action :authenticate_user!
|
skip_before_action :authenticate_user!
|
||||||
|
skip_authorization_check
|
||||||
|
|
||||||
def index
|
def index
|
||||||
submitter = Submitter.find_by(slug: params[:submitter_slug])
|
submitter = Submitter.find_by(slug: params[:submitter_slug])
|
||||||
|
|
||||||
Submissions::EnsureResultGenerated.call(submitter)
|
Submissions::EnsureResultGenerated.call(submitter)
|
||||||
|
|
||||||
|
last_submitter = submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
|
||||||
|
|
||||||
|
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||||
|
|
||||||
urls =
|
urls =
|
||||||
Submitters.select_attachments_for_download(submitter).map do |attachment|
|
Submitters.select_attachments_for_download(last_submitter).map do |attachment|
|
||||||
helpers.rails_blob_url(attachment)
|
helpers.rails_blob_url(attachment)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class SubmissionsExportController < ApplicationController
|
class SubmissionsExportController < ApplicationController
|
||||||
before_action :load_template
|
load_and_authorize_resource :template
|
||||||
|
load_and_authorize_resource :submission, through: :template, parent: false, only: :index
|
||||||
|
|
||||||
def index
|
def index
|
||||||
submissions = @template.submissions.active
|
submissions = @submissions.active
|
||||||
.preload(submitters: { documents_attachments: :blob,
|
.preload(submitters: { documents_attachments: :blob,
|
||||||
attachments_attachments: :blob })
|
attachments_attachments: :blob })
|
||||||
.order(id: :asc)
|
.order(id: :asc)
|
||||||
|
|
||||||
if params[:format] == 'csv'
|
if params[:format] == 'csv'
|
||||||
send_data Submissions::GenerateExportFiles.call(submissions, format: params[:format]),
|
send_data Submissions::GenerateExportFiles.call(submissions, format: params[:format]),
|
||||||
@@ -19,10 +20,4 @@ class SubmissionsExportController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def new; end
|
def new; end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def load_template
|
|
||||||
@template = current_account.templates.find(params[:template_id])
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@@ -4,17 +4,35 @@ class SubmitFormController < ApplicationController
|
|||||||
layout 'form'
|
layout 'form'
|
||||||
|
|
||||||
skip_before_action :authenticate_user!
|
skip_before_action :authenticate_user!
|
||||||
|
skip_authorization_check
|
||||||
|
|
||||||
|
PRELOAD_ALL_PAGES_AMOUNT = 200
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@submitter =
|
@submitter = Submitter.find_by!(slug: params[:slug])
|
||||||
Submitter.preload(submission: [
|
|
||||||
:template, { template_schema_documents: [:blob, { preview_images_attachments: :blob }] }
|
|
||||||
])
|
|
||||||
.find_by!(slug: params[:slug])
|
|
||||||
|
|
||||||
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
|
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
|
||||||
|
|
||||||
|
ActiveRecord::Associations::Preloader.new(
|
||||||
|
records: [@submitter],
|
||||||
|
associations: [submission: [:template, { template_schema_documents: :blob }]]
|
||||||
|
).call
|
||||||
|
|
||||||
|
total_pages =
|
||||||
|
@submitter.submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
|
||||||
|
|
||||||
|
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
|
||||||
|
ActiveRecord::Associations::Preloader.new(
|
||||||
|
records: @submitter.submission.template_schema_documents,
|
||||||
|
associations: [:blob, { preview_images_attachments: :blob }]
|
||||||
|
).call
|
||||||
|
end
|
||||||
|
|
||||||
|
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
|
||||||
|
|
||||||
cookies[:submitter_sid] = @submitter.signed_id
|
cookies[:submitter_sid] = @submitter.signed_id
|
||||||
|
|
||||||
|
render @submitter.submission.template.deleted_at? ? :archived : :show
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -23,6 +41,8 @@ class SubmitFormController < ApplicationController
|
|||||||
Submitters::SubmitValues.call(submitter, params, request)
|
Submitters::SubmitValues.call(submitter, params, request)
|
||||||
|
|
||||||
head :ok
|
head :ok
|
||||||
|
rescue Submitters::SubmitValues::ValidationError => e
|
||||||
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
def completed
|
def completed
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class SubmittersSendEmailController < ApplicationController
|
class SubmittersSendEmailController < ApplicationController
|
||||||
|
load_and_authorize_resource :submitter, id_param: :submitter_slug, find_by: :slug
|
||||||
|
|
||||||
def create
|
def create
|
||||||
submitter = Submitter.joins(:template)
|
SubmitterMailer.invitation_email(@submitter).deliver_later!
|
||||||
.where(template: { account_id: current_account.id })
|
|
||||||
.find_by!(slug: params[:submitter_slug])
|
|
||||||
|
|
||||||
SubmitterMailer.invitation_email(submitter).deliver_later!
|
SubmissionEvent.create!(submitter: @submitter, event_type: 'send_email')
|
||||||
|
|
||||||
submitter.sent_at ||= Time.current
|
@submitter.sent_at ||= Time.current
|
||||||
submitter.save!
|
@submitter.save!
|
||||||
|
|
||||||
redirect_back(fallback_location: submission_path(submitter.submission), notice: 'Email has been sent')
|
redirect_back(fallback_location: submission_path(@submitter.submission), notice: 'Email has been sent')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TemplateFoldersController < ApplicationController
|
||||||
|
load_and_authorize_resource :template_folder
|
||||||
|
|
||||||
|
def show
|
||||||
|
@templates = @template_folder.templates.active.preload(:author).order(id: :desc)
|
||||||
|
@templates = Templates.search(@templates, params[:q])
|
||||||
|
|
||||||
|
@pagy, @templates = pagy(@templates, items: 12)
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit; end
|
||||||
|
|
||||||
|
def update
|
||||||
|
if @template_folder != current_account.default_template_folder &&
|
||||||
|
@template_folder.update(template_folder_params)
|
||||||
|
redirect_to folder_path(@template_folder), notice: 'Folder name has been updated'
|
||||||
|
else
|
||||||
|
redirect_to folder_path(@template_folder), alert: 'Unable to rename folder'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def template_folder_params
|
||||||
|
params.require(:template_folder).permit(:name)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TemplatesArchivedController < ApplicationController
|
||||||
|
load_and_authorize_resource :template, parent: false
|
||||||
|
|
||||||
|
def index
|
||||||
|
@templates = @templates.where.not(deleted_at: nil).preload(:author, :folder).order(id: :desc)
|
||||||
|
@templates = Templates.search(@templates, params[:q])
|
||||||
|
|
||||||
|
@pagy, @templates = pagy(@templates, items: 12)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TemplatesArchivedSubmissionsController < ApplicationController
|
||||||
|
load_and_authorize_resource :template
|
||||||
|
load_and_authorize_resource :submission, through: :template, parent: false
|
||||||
|
|
||||||
|
def index
|
||||||
|
@submissions = @submissions.where.not(deleted_at: nil)
|
||||||
|
@submissions = Submissions.search(@submissions, params[:q])
|
||||||
|
|
||||||
|
@pagy, @submissions = pagy(@submissions.preload(:submitters).order(id: :desc))
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
redirect_to root_path
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,31 +1,50 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class TemplatesController < ApplicationController
|
class TemplatesController < ApplicationController
|
||||||
|
load_and_authorize_resource :template
|
||||||
|
|
||||||
before_action :load_base_template, only: %i[new create]
|
before_action :load_base_template, only: %i[new create]
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@template = current_account.templates.active.find(params[:id])
|
submissions = @template.submissions
|
||||||
|
submissions = submissions.active if @template.deleted_at.blank?
|
||||||
|
submissions = Submissions.search(submissions, params[:q])
|
||||||
|
|
||||||
@pagy, @submissions = pagy(@template.submissions.active.preload(:submitters).order(id: :desc))
|
@base_submissions = submissions
|
||||||
|
|
||||||
|
submissions = submissions.pending if params[:status] == 'pending'
|
||||||
|
submissions = submissions.completed if params[:status] == 'completed'
|
||||||
|
|
||||||
|
@pagy, @submissions = pagy(submissions.preload(:submitters).order(id: :desc))
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
redirect_to root_path
|
redirect_to root_path
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@template = current_account.templates.new
|
|
||||||
@template.name = "#{@base_template.name} (Clone)" if @base_template
|
@template.name = "#{@base_template.name} (Clone)" if @base_template
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
@template = current_account.templates.preload(schema_documents: { preview_images_attachments: :blob })
|
ActiveRecord::Associations::Preloader.new(
|
||||||
.find(params[:id])
|
records: [@template],
|
||||||
|
associations: [schema_documents: { preview_images_attachments: :blob }]
|
||||||
|
).call
|
||||||
|
|
||||||
|
@template_data =
|
||||||
|
@template.as_json.merge(
|
||||||
|
documents: @template.schema_documents.as_json(
|
||||||
|
methods: [:metadata],
|
||||||
|
include: { preview_images: { methods: %i[url metadata filename] } }
|
||||||
|
)
|
||||||
|
).to_json
|
||||||
|
|
||||||
render :edit, layout: 'plain'
|
render :edit, layout: 'plain'
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@template = current_account.templates.new(template_params)
|
@template.account = current_account
|
||||||
@template.author = current_user
|
@template.author = current_user
|
||||||
|
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
|
||||||
@template.assign_attributes(@base_template.slice(:fields, :schema, :submitters)) if @base_template
|
@template.assign_attributes(@base_template.slice(:fields, :schema, :submitters)) if @base_template
|
||||||
|
|
||||||
if @template.save
|
if @template.save
|
||||||
@@ -38,10 +57,18 @@ class TemplatesController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@template = current_account.templates.find(params[:id])
|
notice =
|
||||||
@template.update!(deleted_at: Time.current)
|
if !Docuseal.multitenant? && params[:permanently].present?
|
||||||
|
@template.destroy!
|
||||||
|
|
||||||
redirect_back(fallback_location: root_path, notice: 'Template has been archived.')
|
'Template has been removed.'
|
||||||
|
else
|
||||||
|
@template.update!(deleted_at: Time.current)
|
||||||
|
|
||||||
|
'Template has been archived.'
|
||||||
|
end
|
||||||
|
|
||||||
|
redirect_back(fallback_location: root_path, notice:)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -53,8 +80,6 @@ class TemplatesController < ApplicationController
|
|||||||
def load_base_template
|
def load_base_template
|
||||||
return if params[:base_template_id].blank?
|
return if params[:base_template_id].blank?
|
||||||
|
|
||||||
@base_template = current_account.templates
|
@base_template = current_account.templates.find_by(id: params[:base_template_id])
|
||||||
.preload(documents_attachments: :preview_images_attachments)
|
|
||||||
.find_by(id: params[:base_template_id])
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TemplatesFoldersController < ApplicationController
|
||||||
|
load_and_authorize_resource :template
|
||||||
|
|
||||||
|
def edit; end
|
||||||
|
|
||||||
|
def update
|
||||||
|
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:name])
|
||||||
|
|
||||||
|
if @template.save
|
||||||
|
redirect_back(fallback_location: template_path(@template), notice: 'Document template has been moved')
|
||||||
|
else
|
||||||
|
redirect_back(fallback_location: template_path(@template), notice: 'Unable to move template into folder')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def template_folder_params
|
||||||
|
params.require(:template_folder).permit(:name)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TemplatesRestoreController < ApplicationController
|
||||||
|
load_and_authorize_resource :template
|
||||||
|
|
||||||
|
def create
|
||||||
|
@template.update!(deleted_at: nil)
|
||||||
|
|
||||||
|
redirect_to template_path(@template), notice: 'Template has been unarchived'
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TemplatesUploadsController < ApplicationController
|
||||||
|
load_and_authorize_resource :template, parent: false
|
||||||
|
|
||||||
|
layout 'plain'
|
||||||
|
|
||||||
|
def show; end
|
||||||
|
|
||||||
|
def create
|
||||||
|
url_params = create_file_params_from_url if params[:url].present?
|
||||||
|
|
||||||
|
@template.account = current_account
|
||||||
|
@template.author = current_user
|
||||||
|
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
|
||||||
|
@template.name = File.basename((url_params || params)[:files].first.original_filename, '.*')
|
||||||
|
|
||||||
|
@template.save!
|
||||||
|
|
||||||
|
documents = Templates::CreateAttachments.call(@template, url_params || params)
|
||||||
|
|
||||||
|
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
|
||||||
|
|
||||||
|
@template.update!(schema:)
|
||||||
|
|
||||||
|
redirect_to edit_template_path(@template)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rollbar.error(e) if defined?(Rollbar)
|
||||||
|
|
||||||
|
redirect_to root_path, alert: 'Unable to upload file'
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def create_file_params_from_url
|
||||||
|
tempfile = Tempfile.new
|
||||||
|
tempfile.binmode
|
||||||
|
tempfile.write(conn.get(params[:url]).body)
|
||||||
|
tempfile.rewind
|
||||||
|
|
||||||
|
file = ActionDispatch::Http::UploadedFile.new(
|
||||||
|
tempfile:,
|
||||||
|
filename: File.basename(
|
||||||
|
URI.decode_www_form_component(params[:filename].presence || params[:url])
|
||||||
|
),
|
||||||
|
type: Marcel::MimeType.for(tempfile)
|
||||||
|
)
|
||||||
|
|
||||||
|
{ files: [file] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def conn
|
||||||
|
Faraday.new do |faraday|
|
||||||
|
faraday.response :follow_redirects
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class UserSignaturesController < ApplicationController
|
||||||
|
before_action :load_user_config
|
||||||
|
authorize_resource :user_config
|
||||||
|
|
||||||
|
def edit; end
|
||||||
|
|
||||||
|
def update
|
||||||
|
file = params[:file]
|
||||||
|
|
||||||
|
return redirect_to settings_profile_index_path, notice: 'Unable to save signature' if file.blank?
|
||||||
|
|
||||||
|
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
|
||||||
|
filename: file.original_filename,
|
||||||
|
content_type: file.content_type)
|
||||||
|
|
||||||
|
attachment = ActiveStorage::Attachment.create!(
|
||||||
|
blob:,
|
||||||
|
name: 'signature',
|
||||||
|
record: current_user
|
||||||
|
)
|
||||||
|
|
||||||
|
if @user_config.update(value: attachment.uuid)
|
||||||
|
redirect_to settings_profile_index_path, notice: 'Signature has been saved'
|
||||||
|
else
|
||||||
|
redirect_to settings_profile_index_path, notice: 'Unable to save signature'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def load_user_config
|
||||||
|
@user_config =
|
||||||
|
UserConfig.find_or_initialize_by(user: current_user, key: UserConfig::SIGNATURE_KEY)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,26 +1,20 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class UsersController < ApplicationController
|
class UsersController < ApplicationController
|
||||||
before_action :load_user, only: %i[edit update destroy]
|
load_and_authorize_resource :user, only: %i[index edit new update destroy]
|
||||||
|
|
||||||
|
before_action :build_user, only: :create
|
||||||
|
authorize_resource :user, only: :create
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@pagy, @users = pagy(current_account.users.active.order(id: :desc))
|
@pagy, @users = pagy(@users.active.order(id: :desc))
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new; end
|
||||||
@user = current_account.users.new
|
|
||||||
end
|
|
||||||
|
|
||||||
def edit; end
|
def edit; end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@user = current_account.users.find_by(email: user_params[:email])&.tap do |user|
|
|
||||||
user.assign_attributes(user_params)
|
|
||||||
user.deleted_at = nil
|
|
||||||
end
|
|
||||||
|
|
||||||
@user ||= current_account.users.new(user_params)
|
|
||||||
|
|
||||||
if @user.save
|
if @user.save
|
||||||
UserMailer.invitation_email(@user).deliver_later!
|
UserMailer.invitation_email(@user).deliver_later!
|
||||||
|
|
||||||
@@ -33,7 +27,10 @@ class UsersController < ApplicationController
|
|||||||
def update
|
def update
|
||||||
return redirect_to settings_users_path, notice: 'Unable to update user.' if Docuseal.demo?
|
return redirect_to settings_users_path, notice: 'Unable to update user.' if Docuseal.demo?
|
||||||
|
|
||||||
if @user.update(user_params.compact_blank)
|
attrs = user_params.compact_blank
|
||||||
|
attrs.delete(:role) if User::ROLES.exclude?(attrs[:role])
|
||||||
|
|
||||||
|
if @user.update(attrs.except(current_user == @user ? :role : nil))
|
||||||
redirect_to settings_users_path, notice: 'User has been updated'
|
redirect_to settings_users_path, notice: 'User has been updated'
|
||||||
else
|
else
|
||||||
render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_entity
|
render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_entity
|
||||||
@@ -52,11 +49,18 @@ class UsersController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def load_user
|
def build_user
|
||||||
@user = current_account.users.find(params[:id])
|
@user = current_account.users.find_by(email: user_params[:email])&.tap do |user|
|
||||||
|
user.assign_attributes(user_params)
|
||||||
|
user.deleted_at = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
@user ||= current_account.users.new(user_params)
|
||||||
|
|
||||||
|
@user
|
||||||
end
|
end
|
||||||
|
|
||||||
def user_params
|
def user_params
|
||||||
params.require(:user).permit(:email, :first_name, :last_name, :password)
|
params.require(:user).permit(:email, :first_name, :last_name, :password, :role)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class VerifyPdfSignatureController < ApplicationController
|
class VerifyPdfSignatureController < ApplicationController
|
||||||
|
skip_authorization_check
|
||||||
|
|
||||||
def create
|
def create
|
||||||
pdfs =
|
pdfs =
|
||||||
params[:files].map do |file|
|
params[:files].map do |file|
|
||||||
@@ -10,7 +12,7 @@ class VerifyPdfSignatureController < ApplicationController
|
|||||||
cert_data = if Docuseal.multitenant?
|
cert_data = if Docuseal.multitenant?
|
||||||
Docuseal::CERTS
|
Docuseal::CERTS
|
||||||
else
|
else
|
||||||
EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
|
EncryptedConfig.find_by(key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
|
||||||
end
|
end
|
||||||
|
|
||||||
default_pkcs = GenerateCertificate.load_pkcs(cert_data)
|
default_pkcs = GenerateCertificate.load_pkcs(cert_data)
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class WebhookSettingsController < ApplicationController
|
class WebhookSettingsController < ApplicationController
|
||||||
def show
|
before_action :load_encrypted_config
|
||||||
@encrypted_config =
|
authorize_resource :encrypted_config, parent: false
|
||||||
current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
|
|
||||||
end
|
def show; end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@encrypted_config =
|
|
||||||
current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
|
|
||||||
|
|
||||||
@encrypted_config.update!(encrypted_config_params)
|
@encrypted_config.update!(encrypted_config_params)
|
||||||
|
|
||||||
redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook URL has been saved.')
|
redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook URL has been saved.')
|
||||||
@@ -18,13 +15,18 @@ class WebhookSettingsController < ApplicationController
|
|||||||
def update
|
def update
|
||||||
submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last
|
submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last
|
||||||
|
|
||||||
SendWebhookRequestJob.perform_later(submitter)
|
SendFormCompletedWebhookRequestJob.perform_later(submitter)
|
||||||
|
|
||||||
redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook request has been sent.')
|
redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook request has been sent.')
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def load_encrypted_config
|
||||||
|
@encrypted_config =
|
||||||
|
current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
|
||||||
|
end
|
||||||
|
|
||||||
def encrypted_config_params
|
def encrypted_config_params
|
||||||
params.require(:encrypted_config).permit(:value)
|
params.require(:encrypted_config).permit(:value)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import DownloadButton from './elements/download_button'
|
|||||||
import SetOriginUrl from './elements/set_origin_url'
|
import SetOriginUrl from './elements/set_origin_url'
|
||||||
import SetTimezone from './elements/set_timezone'
|
import SetTimezone from './elements/set_timezone'
|
||||||
import AutoresizeTextarea from './elements/autoresize_textarea'
|
import AutoresizeTextarea from './elements/autoresize_textarea'
|
||||||
|
import SubmittersAutocomplete from './elements/submitter_autocomplete'
|
||||||
|
import FolderAutocomplete from './elements/folder_autocomplete'
|
||||||
|
import SignatureForm from './elements/signature_form'
|
||||||
|
import SubmitForm from './elements/submit_form'
|
||||||
|
|
||||||
import * as TurboInstantClick from './lib/turbo_instant_click'
|
import * as TurboInstantClick from './lib/turbo_instant_click'
|
||||||
|
|
||||||
@@ -43,6 +47,10 @@ window.customElements.define('download-button', DownloadButton)
|
|||||||
window.customElements.define('set-origin-url', SetOriginUrl)
|
window.customElements.define('set-origin-url', SetOriginUrl)
|
||||||
window.customElements.define('set-timezone', SetTimezone)
|
window.customElements.define('set-timezone', SetTimezone)
|
||||||
window.customElements.define('autoresize-textarea', AutoresizeTextarea)
|
window.customElements.define('autoresize-textarea', AutoresizeTextarea)
|
||||||
|
window.customElements.define('submitters-autocomplete', SubmittersAutocomplete)
|
||||||
|
window.customElements.define('folder-autocomplete', FolderAutocomplete)
|
||||||
|
window.customElements.define('signature-form', SignatureForm)
|
||||||
|
window.customElements.define('submit-form', SubmitForm)
|
||||||
|
|
||||||
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
|
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
|
||||||
document.addEventListener('turbo:submit-end', async (event) => {
|
document.addEventListener('turbo:submit-end', async (event) => {
|
||||||
@@ -70,11 +78,13 @@ document.addEventListener('turbo:submit-end', async (event) => {
|
|||||||
window.customElements.define('template-builder', class extends HTMLElement {
|
window.customElements.define('template-builder', class extends HTMLElement {
|
||||||
connectedCallback () {
|
connectedCallback () {
|
||||||
this.appElem = document.createElement('div')
|
this.appElem = document.createElement('div')
|
||||||
this.appElem.classList.add('max-h-screen')
|
|
||||||
|
|
||||||
this.app = createApp(TemplateBuilder, {
|
this.app = createApp(TemplateBuilder, {
|
||||||
template: reactive(JSON.parse(this.dataset.template)),
|
template: reactive(JSON.parse(this.dataset.template)),
|
||||||
backgroundColor: '#faf7f5',
|
backgroundColor: '#faf7f5',
|
||||||
|
withPhone: this.dataset.withPhone === 'true',
|
||||||
|
withLogo: this.dataset.withLogo !== 'false',
|
||||||
|
acceptFileTypes: this.dataset.acceptFileTypes,
|
||||||
isDirectUpload: this.dataset.isDirectUpload === 'true'
|
isDirectUpload: this.dataset.isDirectUpload === 'true'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -74,3 +74,54 @@ button[disabled] .enabled {
|
|||||||
.base-select {
|
.base-select {
|
||||||
@apply select base-input w-full font-normal;
|
@apply select base-input w-full font-normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tooltip-bottom-end:before {
|
||||||
|
transform: translateX(-95%);
|
||||||
|
top: var(--tooltip-offset);
|
||||||
|
left: 100%;
|
||||||
|
right: auto;
|
||||||
|
bottom: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-bottom-end:after {
|
||||||
|
transform: translateX(-25%);
|
||||||
|
border-color: transparent transparent var(--tooltip-color) transparent;
|
||||||
|
top: var(--tooltip-tail-offset);
|
||||||
|
left: 50%;
|
||||||
|
right: auto;
|
||||||
|
bottom: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete {
|
||||||
|
background: white;
|
||||||
|
z-index: 1000;
|
||||||
|
font: 14px/22px "-apple-system", BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
overflow: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
@apply border border-base-300 mt-1 rounded-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete * {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete > div {
|
||||||
|
@apply px-2 py-1 font-normal text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete .group {
|
||||||
|
background: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autocomplete > div:hover:not(.group),
|
||||||
|
.autocomplete > div.selected {
|
||||||
|
@apply bg-base-300;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-outlined {
|
||||||
|
outline-style: solid;
|
||||||
|
outline-width: 1px;
|
||||||
|
outline-offset: 3px;
|
||||||
|
outline-color: hsl(var(--bc) / 0.2);
|
||||||
|
}
|
||||||
@@ -91,11 +91,13 @@ export default actionable(targetable(class extends HTMLElement {
|
|||||||
this.append(input)
|
this.append(input)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.dataset.submitOnUpload) {
|
if (this.dataset.submitOnUpload === 'true') {
|
||||||
this.closest('form').querySelector('button[type="submit"]').click()
|
this.closest('form').querySelector('button[type="submit"]').click()
|
||||||
}
|
}
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
this.toggleLoading()
|
if (this.dataset.submitOnUpload !== 'true') {
|
||||||
|
this.toggleLoading()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
if (this.dataset.submitOnUpload) {
|
if (this.dataset.submitOnUpload) {
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import autocomplete from 'autocompleter'
|
||||||
|
|
||||||
|
export default class extends HTMLElement {
|
||||||
|
connectedCallback () {
|
||||||
|
autocomplete({
|
||||||
|
input: this.input,
|
||||||
|
preventSubmit: this.dataset.submitOnSelect === 'true' ? 0 : 1,
|
||||||
|
minLength: 0,
|
||||||
|
showOnFocus: true,
|
||||||
|
onSelect: this.onSelect,
|
||||||
|
render: this.render,
|
||||||
|
fetch: this.fetch
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect = (item) => {
|
||||||
|
this.input.value = item.name
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch = (text, resolve) => {
|
||||||
|
const queryParams = new URLSearchParams({ q: text })
|
||||||
|
|
||||||
|
fetch('/api/template_folders_autocomplete?' + queryParams).then(async (resp) => {
|
||||||
|
const items = await resp.json()
|
||||||
|
|
||||||
|
resolve(items)
|
||||||
|
}).catch(() => {
|
||||||
|
resolve([])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
render = (item) => {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
|
||||||
|
div.textContent = item.name
|
||||||
|
|
||||||
|
return div
|
||||||
|
}
|
||||||
|
|
||||||
|
get input () {
|
||||||
|
return this.querySelector('input')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { target, targetable } from '@github/catalyst/lib/targetable'
|
||||||
|
import { cropCanvasAndExportToPNG } from '../submission_form/crop_canvas'
|
||||||
|
|
||||||
|
export default targetable(class extends HTMLElement {
|
||||||
|
static [target.static] = ['canvas', 'input', 'clear', 'button']
|
||||||
|
|
||||||
|
async connectedCallback () {
|
||||||
|
this.canvas.width = this.canvas.parentNode.parentNode.clientWidth
|
||||||
|
this.canvas.height = this.canvas.parentNode.parentNode.clientWidth / 3
|
||||||
|
|
||||||
|
const { default: SignaturePad } = await import('signature_pad')
|
||||||
|
|
||||||
|
this.pad = new SignaturePad(this.canvas)
|
||||||
|
|
||||||
|
this.clear.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.pad.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
this.button.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.button.disabled = true
|
||||||
|
|
||||||
|
this.submit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit () {
|
||||||
|
const blob = await cropCanvasAndExportToPNG(this.canvas)
|
||||||
|
const file = new File([blob], 'signature.png', { type: 'image/png' })
|
||||||
|
|
||||||
|
const dataTransfer = new DataTransfer()
|
||||||
|
|
||||||
|
dataTransfer.items.add(file)
|
||||||
|
|
||||||
|
this.input.files = dataTransfer.files
|
||||||
|
|
||||||
|
if (this.input.webkitEntries.length) {
|
||||||
|
this.input.dataset.file = `${dataTransfer.files[0].name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
this.closest('form').requestSubmit()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default class extends HTMLElement {
|
||||||
|
connectedCallback () {
|
||||||
|
this.querySelector('form').requestSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import autocomplete from 'autocompleter'
|
||||||
|
|
||||||
|
export default class extends HTMLElement {
|
||||||
|
connectedCallback () {
|
||||||
|
autocomplete({
|
||||||
|
input: this.input,
|
||||||
|
preventSubmit: 1,
|
||||||
|
minLength: 1,
|
||||||
|
showOnFocus: true,
|
||||||
|
onSelect: this.onSelect,
|
||||||
|
render: this.render,
|
||||||
|
fetch: this.fetch
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect = (item) => {
|
||||||
|
const fields = ['email', 'name', 'phone']
|
||||||
|
const submitterItemEl = this.closest('submitter-item')
|
||||||
|
|
||||||
|
fields.forEach((field) => {
|
||||||
|
const input = submitterItemEl.querySelector(`submitters-autocomplete[data-field="${field}"] input`)
|
||||||
|
const textarea = submitterItemEl.querySelector(`submitters-autocomplete[data-field="${field}"] textarea`)
|
||||||
|
|
||||||
|
if (input && item[field]) {
|
||||||
|
input.value = item[field]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textarea && item[field]) {
|
||||||
|
textarea.value = textarea.value.replace(/[^;,\s]+$/, item[field] + ' ')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch = (text, resolve) => {
|
||||||
|
const q = text.split(/[;,\s]+/).pop().trim()
|
||||||
|
|
||||||
|
if (q) {
|
||||||
|
const queryParams = new URLSearchParams({ q, field: this.dataset.field })
|
||||||
|
|
||||||
|
fetch('/api/submitters_autocomplete?' + queryParams).then(async (resp) => {
|
||||||
|
const items = await resp.json()
|
||||||
|
|
||||||
|
if (q.length < 3) {
|
||||||
|
resolve(items.filter((e) => e[this.dataset.field].startsWith(q)))
|
||||||
|
} else {
|
||||||
|
resolve(items)
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
resolve([])
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resolve([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render = (item) => {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
|
||||||
|
div.textContent = item[this.dataset.field]
|
||||||
|
|
||||||
|
return div
|
||||||
|
}
|
||||||
|
|
||||||
|
get input () {
|
||||||
|
return this.querySelector('input') || this.querySelector('textarea')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,10 +13,12 @@ window.customElements.define('submission-form', class extends HTMLElement {
|
|||||||
authenticityToken: this.dataset.authenticityToken,
|
authenticityToken: this.dataset.authenticityToken,
|
||||||
canSendEmail: this.dataset.canSendEmail === 'true',
|
canSendEmail: this.dataset.canSendEmail === 'true',
|
||||||
isDirectUpload: this.dataset.isDirectUpload === 'true',
|
isDirectUpload: this.dataset.isDirectUpload === 'true',
|
||||||
|
goToLast: this.dataset.goToLast === 'true',
|
||||||
isDemo: this.dataset.isDemo === 'true',
|
isDemo: this.dataset.isDemo === 'true',
|
||||||
attribution: this.dataset.attribution !== 'false',
|
attribution: this.dataset.attribution !== 'false',
|
||||||
withConfetti: true,
|
withConfetti: true,
|
||||||
values: reactive(JSON.parse(this.dataset.values)),
|
values: reactive(JSON.parse(this.dataset.values)),
|
||||||
|
completedButton: JSON.parse(this.dataset.completedButton),
|
||||||
attachments: reactive(JSON.parse(this.dataset.attachments)),
|
attachments: reactive(JSON.parse(this.dataset.attachments)),
|
||||||
fields: JSON.parse(this.dataset.fields)
|
fields: JSON.parse(this.dataset.fields)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ select:required:invalid {
|
|||||||
@apply border-base-content/20;
|
@apply border-base-content/20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.base-textarea {
|
||||||
|
@apply textarea textarea-bordered bg-white rounded-3xl;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
@apply no-animation;
|
@apply no-animation;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,11 @@
|
|||||||
class="object-contain mx-auto"
|
class="object-contain mx-auto"
|
||||||
:src="signature.url"
|
:src="signature.url"
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
v-else-if="field.type === 'initials' && initials"
|
||||||
|
class="object-contain mx-auto"
|
||||||
|
:src="initials.url"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
v-else-if="field.type === 'file'"
|
v-else-if="field.type === 'file'"
|
||||||
class="px-0.5 flex flex-col justify-center"
|
class="px-0.5 flex flex-col justify-center"
|
||||||
@@ -105,15 +110,16 @@
|
|||||||
<span v-else-if="field.type === 'date'">
|
<span v-else-if="field.type === 'date'">
|
||||||
{{ formattedDate }}
|
{{ formattedDate }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span
|
||||||
{{ modelValue }}
|
v-else
|
||||||
</span>
|
class="whitespace-pre-wrap"
|
||||||
|
>{{ modelValue }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3 } from '@tabler/icons-vue'
|
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper } from '@tabler/icons-vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'FieldArea',
|
name: 'FieldArea',
|
||||||
@@ -175,11 +181,13 @@ export default {
|
|||||||
signature: 'Signature',
|
signature: 'Signature',
|
||||||
date: 'Date',
|
date: 'Date',
|
||||||
image: 'Image',
|
image: 'Image',
|
||||||
|
initials: 'Initials',
|
||||||
file: 'File',
|
file: 'File',
|
||||||
select: 'Select',
|
select: 'Select',
|
||||||
checkbox: 'Checkbox',
|
checkbox: 'Checkbox',
|
||||||
radio: 'Radio',
|
radio: 'Radio',
|
||||||
multiple: 'Multiple Select'
|
multiple: 'Multiple Select',
|
||||||
|
phone: 'Phone'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fieldIcons () {
|
fieldIcons () {
|
||||||
@@ -188,12 +196,14 @@ export default {
|
|||||||
signature: IconWritingSign,
|
signature: IconWritingSign,
|
||||||
date: IconCalendarEvent,
|
date: IconCalendarEvent,
|
||||||
image: IconPhoto,
|
image: IconPhoto,
|
||||||
|
initials: IconLetterCaseUpper,
|
||||||
file: IconPaperclip,
|
file: IconPaperclip,
|
||||||
select: IconSelect,
|
select: IconSelect,
|
||||||
checkbox: IconCheckbox,
|
checkbox: IconCheckbox,
|
||||||
radio: IconCircleDot,
|
radio: IconCircleDot,
|
||||||
cells: IconColumns3,
|
cells: IconColumns3,
|
||||||
multiple: IconChecks
|
multiple: IconChecks,
|
||||||
|
phone: IconPhoneCheck
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
image () {
|
image () {
|
||||||
@@ -210,6 +220,13 @@ export default {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
initials () {
|
||||||
|
if (this.field.type === 'initials') {
|
||||||
|
return this.attachmentsIndex[this.modelValue]
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
formattedDate () {
|
formattedDate () {
|
||||||
if (this.field.type === 'date' && this.modelValue) {
|
if (this.field.type === 'date' && this.modelValue) {
|
||||||
return new Intl.DateTimeFormat([], { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }).format(new Date(this.modelValue))
|
return new Intl.DateTimeFormat([], { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }).format(new Date(this.modelValue))
|
||||||
|
|||||||
@@ -11,6 +11,15 @@
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<div class="space-y-3 mt-5">
|
<div class="space-y-3 mt-5">
|
||||||
|
<a
|
||||||
|
v-if="completedButton.url"
|
||||||
|
:href="completedButton.url"
|
||||||
|
class="white-button flex items-center w-full"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ completedButton.title || 'Back to Website' }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
v-if="canSendEmail && !isDemo"
|
v-if="canSendEmail && !isDemo"
|
||||||
class="white-button !h-auto flex items-center space-x-1 w-full"
|
class="white-button !h-auto flex items-center space-x-1 w-full"
|
||||||
@@ -27,6 +36,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="!isWebView"
|
||||||
class="base-button flex items-center space-x-1 w-full"
|
class="base-button flex items-center space-x-1 w-full"
|
||||||
:disabled="isDownloading"
|
:disabled="isDownloading"
|
||||||
@click.prevent="download"
|
@click.prevent="download"
|
||||||
@@ -68,7 +78,7 @@
|
|||||||
>
|
>
|
||||||
{{ t('signed_with') }}
|
{{ t('signed_with') }}
|
||||||
<a
|
<a
|
||||||
href="https://www.docuseal.co"
|
href="https://www.docuseal.co/start"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="underline"
|
class="underline"
|
||||||
>DocuSeal</a> - {{ t('open_source_documents_software') }}
|
>DocuSeal</a> - {{ t('open_source_documents_software') }}
|
||||||
@@ -114,6 +124,11 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
completedButton: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: () => ({})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
@@ -122,6 +137,11 @@ export default {
|
|||||||
isDownloading: false
|
isDownloading: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
isWebView () {
|
||||||
|
return /webview|wv|ip((?!.*Safari)|(?=.*like Safari))/i.test(window.navigator.userAgent)
|
||||||
|
}
|
||||||
|
},
|
||||||
async mounted () {
|
async mounted () {
|
||||||
if (this.withConfetti) {
|
if (this.withConfetti) {
|
||||||
const { default: confetti } = await import('canvas-confetti')
|
const { default: confetti } = await import('canvas-confetti')
|
||||||
@@ -140,7 +160,7 @@ export default {
|
|||||||
fetch(this.baseUrl + `/send_submission_email.json?submitter_slug=${this.submitterSlug}`, {
|
fetch(this.baseUrl + `/send_submission_email.json?submitter_slug=${this.submitterSlug}`, {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
alert('Email has been sent')
|
alert(this.t('email_has_been_sent'))
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
this.isSendingCopy = false
|
this.isSendingCopy = false
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
function cropCanvasAndExportToPNG (canvas) {
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
|
||||||
|
const width = canvas.width
|
||||||
|
const height = canvas.height
|
||||||
|
|
||||||
|
let topmost = height
|
||||||
|
let bottommost = 0
|
||||||
|
let leftmost = width
|
||||||
|
let rightmost = 0
|
||||||
|
|
||||||
|
const imageData = ctx.getImageData(0, 0, width, height)
|
||||||
|
const pixels = imageData.data
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const pixelIndex = (y * width + x) * 4
|
||||||
|
const alpha = pixels[pixelIndex + 3]
|
||||||
|
if (alpha !== 0) {
|
||||||
|
topmost = Math.min(topmost, y)
|
||||||
|
bottommost = Math.max(bottommost, y)
|
||||||
|
leftmost = Math.min(leftmost, x)
|
||||||
|
rightmost = Math.max(rightmost, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const croppedWidth = rightmost - leftmost + 1
|
||||||
|
const croppedHeight = bottommost - topmost + 1
|
||||||
|
|
||||||
|
const croppedCanvas = document.createElement('canvas')
|
||||||
|
croppedCanvas.width = croppedWidth
|
||||||
|
croppedCanvas.height = croppedHeight
|
||||||
|
const croppedCtx = croppedCanvas.getContext('2d')
|
||||||
|
|
||||||
|
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
croppedCanvas.toBlob((blob) => {
|
||||||
|
if (blob) {
|
||||||
|
resolve(blob)
|
||||||
|
} else {
|
||||||
|
reject(new Error('Failed to create a PNG blob.'))
|
||||||
|
}
|
||||||
|
}, 'image/png')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { cropCanvasAndExportToPNG }
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center w-full mb-2">
|
||||||
|
<label
|
||||||
|
:for="field.uuid"
|
||||||
|
class="label text-2xl"
|
||||||
|
>{{ field.name || t('date') }}
|
||||||
|
<template v-if="!field.required">({{ t('optional') }})</template>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
class="btn btn-outline btn-sm !normal-case font-normal"
|
||||||
|
@click.prevent="setCurrentDate"
|
||||||
|
>
|
||||||
|
<IconCalendarCheck :width="16" />
|
||||||
|
{{ t('set_today') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<input
|
||||||
|
ref="input"
|
||||||
|
v-model="value"
|
||||||
|
class="base-input !text-2xl text-center w-full"
|
||||||
|
:required="field.required"
|
||||||
|
type="date"
|
||||||
|
:name="`values[${field.uuid}]`"
|
||||||
|
@focus="$emit('focus')"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { IconCalendarCheck } from '@tabler/icons-vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'DateStep',
|
||||||
|
components: {
|
||||||
|
IconCalendarCheck
|
||||||
|
},
|
||||||
|
inject: ['t'],
|
||||||
|
props: {
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['update:model-value', 'focus'],
|
||||||
|
computed: {
|
||||||
|
value: {
|
||||||
|
set (value) {
|
||||||
|
this.$emit('update:model-value', value)
|
||||||
|
},
|
||||||
|
get () {
|
||||||
|
return this.modelValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setCurrentDate () {
|
||||||
|
const inputEl = this.$refs.input
|
||||||
|
|
||||||
|
inputEl.valueAsDate = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000)
|
||||||
|
|
||||||
|
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -58,54 +58,20 @@
|
|||||||
>
|
>
|
||||||
<div class="md:mt-4">
|
<div class="md:mt-4">
|
||||||
<div v-if="['cells', 'text'].includes(currentField.type)">
|
<div v-if="['cells', 'text'].includes(currentField.type)">
|
||||||
<label
|
<TextStep
|
||||||
v-if="currentField.name"
|
:key="currentField.uuid"
|
||||||
:for="currentField.uuid"
|
v-model="values[currentField.uuid]"
|
||||||
class="label text-2xl mb-2"
|
:field="currentField"
|
||||||
>{{ currentField.name }}
|
@focus="$refs.areas.scrollIntoField(currentField)"
|
||||||
<template v-if="!currentField.required">({{ t('optional') }})</template>
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="py-1"
|
|
||||||
/>
|
/>
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
:id="currentField.uuid"
|
|
||||||
v-model="values[currentField.uuid]"
|
|
||||||
class="base-input !text-2xl w-full"
|
|
||||||
:required="currentField.required"
|
|
||||||
:placeholder="`${t('type_here')}...${currentField.required ? '' : ` (${t('optional')})`}`"
|
|
||||||
type="text"
|
|
||||||
:name="`values[${currentField.uuid}]`"
|
|
||||||
@focus="$refs.areas.scrollIntoField(currentField)"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="currentField.type === 'date'">
|
|
||||||
<label
|
|
||||||
v-if="currentField.name"
|
|
||||||
:for="currentField.uuid"
|
|
||||||
class="label text-2xl mb-2"
|
|
||||||
>{{ currentField.name }}
|
|
||||||
<template v-if="!currentField.required">({{ t('optional') }})</template>
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="py-1"
|
|
||||||
/>
|
|
||||||
<div class="text-center">
|
|
||||||
<input
|
|
||||||
:id="currentField.uuid"
|
|
||||||
v-model="values[currentField.uuid]"
|
|
||||||
class="base-input !text-2xl text-center w-full"
|
|
||||||
:required="currentField.required"
|
|
||||||
type="date"
|
|
||||||
:name="`values[${currentField.uuid}]`"
|
|
||||||
@focus="$refs.areas.scrollIntoField(currentField)"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<DateStep
|
||||||
|
v-else-if="currentField.type === 'date'"
|
||||||
|
:key="currentField.uuid"
|
||||||
|
v-model="values[currentField.uuid]"
|
||||||
|
:field="currentField"
|
||||||
|
@focus="$refs.areas.scrollIntoField(currentField)"
|
||||||
|
/>
|
||||||
<div v-else-if="currentField.type === 'select'">
|
<div v-else-if="currentField.type === 'select'">
|
||||||
<label
|
<label
|
||||||
v-if="currentField.name"
|
v-if="currentField.name"
|
||||||
@@ -179,6 +145,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<MultiSelectStep
|
<MultiSelectStep
|
||||||
v-else-if="currentField.type === 'multiple'"
|
v-else-if="currentField.type === 'multiple'"
|
||||||
|
:key="currentField.uuid"
|
||||||
v-model="values[currentField.uuid]"
|
v-model="values[currentField.uuid]"
|
||||||
:field="currentField"
|
:field="currentField"
|
||||||
/>
|
/>
|
||||||
@@ -224,6 +191,9 @@
|
|||||||
:id="field.uuid"
|
:id="field.uuid"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="base-checkbox !h-7 !w-7"
|
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]"
|
:checked="!!values[field.uuid]"
|
||||||
@click="[$refs.areas.scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
|
@click="[$refs.areas.scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
|
||||||
>
|
>
|
||||||
@@ -237,6 +207,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<ImageStep
|
<ImageStep
|
||||||
v-else-if="currentField.type === 'image'"
|
v-else-if="currentField.type === 'image'"
|
||||||
|
:key="currentField.uuid"
|
||||||
v-model="values[currentField.uuid]"
|
v-model="values[currentField.uuid]"
|
||||||
:field="currentField"
|
:field="currentField"
|
||||||
:is-direct-upload="isDirectUpload"
|
:is-direct-upload="isDirectUpload"
|
||||||
@@ -247,8 +218,10 @@
|
|||||||
<SignatureStep
|
<SignatureStep
|
||||||
v-else-if="currentField.type === 'signature'"
|
v-else-if="currentField.type === 'signature'"
|
||||||
ref="currentStep"
|
ref="currentStep"
|
||||||
|
:key="currentField.uuid"
|
||||||
v-model="values[currentField.uuid]"
|
v-model="values[currentField.uuid]"
|
||||||
:field="currentField"
|
:field="currentField"
|
||||||
|
:previous-value="previousSignatureValue"
|
||||||
:is-direct-upload="isDirectUpload"
|
:is-direct-upload="isDirectUpload"
|
||||||
:attachments-index="attachmentsIndex"
|
:attachments-index="attachmentsIndex"
|
||||||
:submitter-slug="submitterSlug"
|
:submitter-slug="submitterSlug"
|
||||||
@@ -256,8 +229,24 @@
|
|||||||
@start="$refs.areas.scrollIntoField(currentField)"
|
@start="$refs.areas.scrollIntoField(currentField)"
|
||||||
@minimize="isFormVisible = false"
|
@minimize="isFormVisible = false"
|
||||||
/>
|
/>
|
||||||
|
<InitialsStep
|
||||||
|
v-else-if="currentField.type === 'initials'"
|
||||||
|
ref="currentStep"
|
||||||
|
:key="currentField.uuid"
|
||||||
|
v-model="values[currentField.uuid]"
|
||||||
|
:field="currentField"
|
||||||
|
:previous-value="previousInitialsValue"
|
||||||
|
:is-direct-upload="isDirectUpload"
|
||||||
|
:attachments-index="attachmentsIndex"
|
||||||
|
:submitter-slug="submitterSlug"
|
||||||
|
@attached="attachments.push($event)"
|
||||||
|
@start="$refs.areas.scrollIntoField(currentField)"
|
||||||
|
@focus="$refs.areas.scrollIntoField(currentField)"
|
||||||
|
@minimize="isFormVisible = false"
|
||||||
|
/>
|
||||||
<AttachmentStep
|
<AttachmentStep
|
||||||
v-else-if="currentField.type === 'file'"
|
v-else-if="currentField.type === 'file'"
|
||||||
|
:key="currentField.uuid"
|
||||||
v-model="values[currentField.uuid]"
|
v-model="values[currentField.uuid]"
|
||||||
:is-direct-upload="isDirectUpload"
|
:is-direct-upload="isDirectUpload"
|
||||||
:field="currentField"
|
:field="currentField"
|
||||||
@@ -265,9 +254,21 @@
|
|||||||
:submitter-slug="submitterSlug"
|
:submitter-slug="submitterSlug"
|
||||||
@attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]"
|
@attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]"
|
||||||
/>
|
/>
|
||||||
|
<PhoneStep
|
||||||
|
v-else-if="currentField.type === 'phone'"
|
||||||
|
ref="currentStep"
|
||||||
|
:key="currentField.uuid"
|
||||||
|
v-model="values[currentField.uuid]"
|
||||||
|
:field="currentField"
|
||||||
|
:default-value="submitter.phone"
|
||||||
|
:submitter-slug="submitterSlug"
|
||||||
|
@focus="$refs.areas.scrollIntoField(currentField)"
|
||||||
|
@submit="submitStep"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-6 md:mt-8">
|
<div class="mt-6 md:mt-8">
|
||||||
<button
|
<button
|
||||||
|
ref="submitButton"
|
||||||
type="submit"
|
type="submit"
|
||||||
class="base-button w-full flex justify-center"
|
class="base-button w-full flex justify-center"
|
||||||
:disabled="isButtonDisabled"
|
:disabled="isButtonDisabled"
|
||||||
@@ -288,14 +289,21 @@
|
|||||||
><span>...</span></span>
|
><span>...</span></span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="showFillAllRequiredFields"
|
||||||
|
class="text-center mt-1"
|
||||||
|
>
|
||||||
|
{{ t('please_fill_all_required_fields') }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<FormCompleted
|
<FormCompleted
|
||||||
v-else
|
v-else
|
||||||
:is-demo="isDemo"
|
:is-demo="isDemo"
|
||||||
:attribution="attribution"
|
:attribution="attribution"
|
||||||
|
:completed-button="completedButton"
|
||||||
:with-confetti="withConfetti"
|
:with-confetti="withConfetti"
|
||||||
:can-send-email="canSendEmail && submitter.email"
|
:can-send-email="canSendEmail && !!submitter.email"
|
||||||
:submitter-slug="submitterSlug"
|
:submitter-slug="submitterSlug"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
@@ -305,7 +313,7 @@
|
|||||||
:key="step[0].uuid"
|
:key="step[0].uuid"
|
||||||
href="#"
|
href="#"
|
||||||
class="inline border border-base-300 h-3 w-3 rounded-full mx-1"
|
class="inline border border-base-300 h-3 w-3 rounded-full mx-1"
|
||||||
:class="{ 'bg-base-300': index === currentStep, 'bg-base-content': index < currentStep || isCompleted, 'bg-white': index > currentStep }"
|
:class="{ 'bg-base-300': index === currentStep, 'bg-base-content': (index < currentStep && stepFields[index].every((f) => !f.required || ![null, undefined, ''].includes(values[f.uuid]))) || isCompleted, 'bg-white': index > currentStep }"
|
||||||
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(step, true)]"
|
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(step, true)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -318,8 +326,12 @@
|
|||||||
import FieldAreas from './areas'
|
import FieldAreas from './areas'
|
||||||
import ImageStep from './image_step'
|
import ImageStep from './image_step'
|
||||||
import SignatureStep from './signature_step'
|
import SignatureStep from './signature_step'
|
||||||
|
import InitialsStep from './initials_step'
|
||||||
import AttachmentStep from './attachment_step'
|
import AttachmentStep from './attachment_step'
|
||||||
import MultiSelectStep from './multi_select_step'
|
import MultiSelectStep from './multi_select_step'
|
||||||
|
import PhoneStep from './phone_step'
|
||||||
|
import TextStep from './text_step'
|
||||||
|
import DateStep from './date_step'
|
||||||
import FormCompleted from './completed'
|
import FormCompleted from './completed'
|
||||||
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
||||||
import { t } from './i18n'
|
import { t } from './i18n'
|
||||||
@@ -331,9 +343,13 @@ export default {
|
|||||||
ImageStep,
|
ImageStep,
|
||||||
SignatureStep,
|
SignatureStep,
|
||||||
AttachmentStep,
|
AttachmentStep,
|
||||||
|
InitialsStep,
|
||||||
MultiSelectStep,
|
MultiSelectStep,
|
||||||
IconInnerShadowTop,
|
IconInnerShadowTop,
|
||||||
|
DateStep,
|
||||||
IconArrowsDiagonal,
|
IconArrowsDiagonal,
|
||||||
|
TextStep,
|
||||||
|
PhoneStep,
|
||||||
IconArrowsDiagonalMinimize2,
|
IconArrowsDiagonalMinimize2,
|
||||||
FormCompleted
|
FormCompleted
|
||||||
},
|
},
|
||||||
@@ -358,6 +374,13 @@ export default {
|
|||||||
required: false,
|
required: false,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
|
onComplete: {
|
||||||
|
type: Function,
|
||||||
|
required: false,
|
||||||
|
default () {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
withConfetti: {
|
withConfetti: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -388,6 +411,16 @@ export default {
|
|||||||
required: false,
|
required: false,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
|
allowToSkip: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
goToLast: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
isDemo: {
|
isDemo: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -402,14 +435,22 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
required: false,
|
required: false,
|
||||||
default: () => ({})
|
default: () => ({})
|
||||||
|
},
|
||||||
|
completedButton: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: () => ({})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
isFormVisible: true,
|
isFormVisible: true,
|
||||||
|
showFillAllRequiredFields: false,
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
isSubmitting: false
|
isSubmitting: false,
|
||||||
|
submittedValues: {},
|
||||||
|
recalculateButtonDisabledKey: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -419,19 +460,34 @@ export default {
|
|||||||
submitterSlug () {
|
submitterSlug () {
|
||||||
return this.submitter.slug
|
return this.submitter.slug
|
||||||
},
|
},
|
||||||
|
previousSignatureValue () {
|
||||||
|
const signatureField = [...this.fields].reverse().find((field) => field.type === 'signature' && !!this.values[field.uuid])
|
||||||
|
|
||||||
|
return this.values[signatureField?.uuid]
|
||||||
|
},
|
||||||
|
previousInitialsValue () {
|
||||||
|
const initialsField = [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
|
||||||
|
|
||||||
|
return this.values[initialsField?.uuid]
|
||||||
|
},
|
||||||
isAnonymousChecboxes () {
|
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) && this.currentStepFields.length > 4
|
||||||
},
|
},
|
||||||
isButtonDisabled () {
|
isButtonDisabled () {
|
||||||
return this.isSubmitting ||
|
if (this.recalculateButtonDisabledKey) {
|
||||||
|
return this.isSubmitting ||
|
||||||
(this.currentField.required && ['image', 'file'].includes(this.currentField.type) && !this.values[this.currentField.uuid]?.length) ||
|
(this.currentField.required && ['image', 'file'].includes(this.currentField.type) && !this.values[this.currentField.uuid]?.length) ||
|
||||||
(this.currentField.required && this.currentField.type === 'signature' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isSignatureStarted)
|
(this.currentField.required && this.currentField.type === 'signature' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isSignatureStarted) ||
|
||||||
|
(this.currentField.required && this.currentField.type === 'initials' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isInitialsStarted)
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
currentField () {
|
currentField () {
|
||||||
return this.currentStepFields[0]
|
return this.currentStepFields[0]
|
||||||
},
|
},
|
||||||
stepFields () {
|
stepFields () {
|
||||||
return this.fields.reduce((acc, f) => {
|
return this.fields.filter((f) => !f.readonly).reduce((acc, f) => {
|
||||||
const prevStep = acc[acc.length - 1]
|
const prevStep = acc[acc.length - 1]
|
||||||
|
|
||||||
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox') {
|
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox') {
|
||||||
@@ -455,10 +511,24 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
this.currentStep = Math.min(
|
this.submittedValues = JSON.parse(JSON.stringify(this.values))
|
||||||
this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.values[f.uuid]))) + 1,
|
|
||||||
this.stepFields.length - 1
|
if (this.goToLast) {
|
||||||
)
|
const requiredEmptyStepIndex = this.stepFields.indexOf(this.stepFields.find((fields) => fields.some((f) => f.required && !this.values[f.uuid])))
|
||||||
|
const lastFilledStepIndex = this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.values[f.uuid]))) + 1
|
||||||
|
|
||||||
|
const indexesList = [this.stepFields.length - 1]
|
||||||
|
|
||||||
|
if (requiredEmptyStepIndex !== -1) {
|
||||||
|
indexesList.push(requiredEmptyStepIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastFilledStepIndex !== -1) {
|
||||||
|
indexesList.push(lastFilledStepIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentStep = Math.min(...indexesList)
|
||||||
|
}
|
||||||
|
|
||||||
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
|
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
@@ -473,13 +543,86 @@ export default {
|
|||||||
scrollbox.parentNode.style.maxHeight = '-webkit-fill-available'
|
scrollbox.parentNode.style.maxHeight = '-webkit-fill-available'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.recalculateButtonDisabledKey = Math.random()
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
this.maybeTrackEmailClick(),
|
||||||
|
this.maybeTrackSmsClick()
|
||||||
|
]).finally(() => {
|
||||||
|
this.trackViewForm()
|
||||||
|
})
|
||||||
|
})
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
t,
|
t,
|
||||||
|
maybeTrackEmailClick () {
|
||||||
|
const queryParams = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
|
if (queryParams.has('t')) {
|
||||||
|
const t = queryParams.get('t')
|
||||||
|
|
||||||
|
queryParams.delete('t')
|
||||||
|
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
|
||||||
|
window.history.replaceState({}, document.title, newUrl)
|
||||||
|
|
||||||
|
return fetch(this.baseUrl + '/api/submitter_email_clicks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
t,
|
||||||
|
submitter_slug: this.submitterSlug
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return Promise.resolve({})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
maybeTrackSmsClick () {
|
||||||
|
const queryParams = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
|
if (queryParams.has('c')) {
|
||||||
|
const c = queryParams.get('c')
|
||||||
|
|
||||||
|
queryParams.delete('c')
|
||||||
|
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
|
||||||
|
window.history.replaceState({}, document.title, newUrl)
|
||||||
|
|
||||||
|
return fetch(this.baseUrl + '/api/submitter_sms_clicks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
c,
|
||||||
|
submitter_slug: this.submitterSlug
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return Promise.resolve({})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trackViewForm () {
|
||||||
|
fetch(this.baseUrl + '/api/submitter_form_views', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
submitter_slug: this.submitterSlug
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
goToStep (step, scrollToArea = false, clickUpload = false) {
|
goToStep (step, scrollToArea = false, clickUpload = false) {
|
||||||
this.currentStep = this.stepFields.indexOf(step)
|
this.currentStep = this.stepFields.indexOf(step)
|
||||||
|
this.showFillAllRequiredFields = false
|
||||||
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
|
this.recalculateButtonDisabledKey = Math.random()
|
||||||
|
|
||||||
if (scrollToArea) {
|
if (scrollToArea) {
|
||||||
this.$refs.areas.scrollIntoField(step[0])
|
this.$refs.areas.scrollIntoField(step[0])
|
||||||
}
|
}
|
||||||
@@ -504,28 +647,61 @@ export default {
|
|||||||
async submitStep () {
|
async submitStep () {
|
||||||
this.isSubmitting = true
|
this.isSubmitting = true
|
||||||
|
|
||||||
const stepPromise = this.currentField.type === 'signature'
|
const stepPromise = ['signature', 'phone', 'initials'].includes(this.currentField.type)
|
||||||
? this.$refs.currentStep.submit
|
? this.$refs.currentStep.submit
|
||||||
: () => Promise.resolve({})
|
: () => Promise.resolve({})
|
||||||
|
|
||||||
await stepPromise()
|
stepPromise().then(async () => {
|
||||||
|
const emptyRequiredField = this.stepFields.find((fields, index) => {
|
||||||
|
return index < this.currentStep && fields[0].required && (fields[0].type === 'phone' || !this.allowToSkip) && !this.submittedValues[fields[0].uuid]
|
||||||
|
})
|
||||||
|
|
||||||
const formData = new FormData(this.$refs.form)
|
const formData = new FormData(this.$refs.form)
|
||||||
|
const isLastStep = this.currentStep === this.stepFields.length - 1
|
||||||
|
|
||||||
if (this.currentStep === this.stepFields.length - 1) {
|
if (isLastStep && !emptyRequiredField) {
|
||||||
formData.append('completed', 'true')
|
formData.append('completed', 'true')
|
||||||
}
|
|
||||||
|
|
||||||
this.saveStep(formData).then(response => {
|
|
||||||
const nextStep = this.stepFields[this.currentStep + 1]
|
|
||||||
|
|
||||||
if (nextStep) {
|
|
||||||
this.goToStep(this.stepFields[this.currentStep + 1], true)
|
|
||||||
} else {
|
|
||||||
this.isCompleted = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.saveStep(formData).then(async (response) => {
|
||||||
|
if (response.status === 422) {
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
alert(data.error || 'Value is invalid')
|
||||||
|
|
||||||
|
return Promise.reject(new Error(data.error))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.submittedValues[this.currentField.uuid] = this.values[this.currentField.uuid]
|
||||||
|
|
||||||
|
if (isLastStep) {
|
||||||
|
this.isSecondWalkthrough = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStep = (isLastStep && emptyRequiredField) || this.stepFields[this.currentStep + 1]
|
||||||
|
|
||||||
|
if (nextStep) {
|
||||||
|
this.goToStep(nextStep, true)
|
||||||
|
|
||||||
|
if (emptyRequiredField === nextStep) {
|
||||||
|
this.showFillAllRequiredFields = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.isCompleted = true
|
||||||
|
|
||||||
|
const respData = await response.text()
|
||||||
|
|
||||||
|
if (respData) {
|
||||||
|
this.onComplete(JSON.parse(respData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Error submitting form:', error)
|
||||||
|
}).finally(() => {
|
||||||
|
this.isSubmitting = false
|
||||||
|
})
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
console.error('Error submitting form:', error)
|
console.log(error)
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
this.isSubmitting = false
|
this.isSubmitting = false
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,10 +10,30 @@ const en = {
|
|||||||
or_drag_and_drop_files: 'or drag and drop files',
|
or_drag_and_drop_files: 'or drag and drop files',
|
||||||
send_copy_via_email: 'Send copy via email',
|
send_copy_via_email: 'Send copy via email',
|
||||||
download: 'Download',
|
download: 'Download',
|
||||||
|
signature: 'Signature',
|
||||||
|
initials: 'Initials',
|
||||||
|
clear: 'Clear',
|
||||||
|
redraw: 'Redraw',
|
||||||
|
draw_initials: 'Draw initials',
|
||||||
|
type_signature_here: 'Type signature here',
|
||||||
|
type_initial_here: 'Type initials here',
|
||||||
form_has_been_completed: 'Form has been completed!',
|
form_has_been_completed: 'Form has been completed!',
|
||||||
create_a_free_account: 'Create a Free Account',
|
create_a_free_account: 'Create a Free Account',
|
||||||
signed_with: 'Signed with',
|
signed_with: 'Signed with',
|
||||||
open_source_documents_software: 'open source documents software'
|
please_check_the_box_to_continue: 'Please check the box to continue',
|
||||||
|
open_source_documents_software: 'open source documents software',
|
||||||
|
verified_phone_number: 'Verify Phone Number',
|
||||||
|
use_international_format: 'Use internatioanl format: +1xxx',
|
||||||
|
six_digits_code: '6-digit code',
|
||||||
|
change_phone_number: 'Change phone number',
|
||||||
|
sending: 'Sending...',
|
||||||
|
resend_code: 'Re-send code',
|
||||||
|
verification_code_has_been_resent: 'Verification code has been re-sent via SMS',
|
||||||
|
please_fill_all_required_fields: 'Please fill all required fields',
|
||||||
|
set_today: 'Set Today',
|
||||||
|
toggle_multiline_text: 'Toggle Multiline Text',
|
||||||
|
date: 'Date',
|
||||||
|
email_has_been_sent: 'Email has been sent'
|
||||||
}
|
}
|
||||||
|
|
||||||
const es = {
|
const es = {
|
||||||
@@ -28,10 +48,30 @@ const es = {
|
|||||||
or_drag_and_drop_files: 'o arrastra y suelta archivos',
|
or_drag_and_drop_files: 'o arrastra y suelta archivos',
|
||||||
send_copy_via_email: 'Enviar copia por correo electrónico',
|
send_copy_via_email: 'Enviar copia por correo electrónico',
|
||||||
download: 'Descargar',
|
download: 'Descargar',
|
||||||
|
signature: 'Firma',
|
||||||
|
initials: 'Iniciales',
|
||||||
|
clear: 'Borrar',
|
||||||
|
redraw: 'Redibujar',
|
||||||
|
draw_initials: 'Dibujar iniciales',
|
||||||
|
type_signature_here: 'Escribe la firma aquí',
|
||||||
|
type_initial_here: 'Escribe las iniciales aquí',
|
||||||
form_has_been_completed: '¡El formulario ha sido completado!',
|
form_has_been_completed: '¡El formulario ha sido completado!',
|
||||||
create_a_free_account: 'Crear una Cuenta Gratuita',
|
create_a_free_account: 'Crear una Cuenta Gratuita',
|
||||||
signed_with: 'Firmado con',
|
signed_with: 'Firmado con',
|
||||||
open_source_documents_software: 'software de documentos de código abierto'
|
please_check_the_box_to_continue: 'Por favor marque la casilla para continuar',
|
||||||
|
open_source_documents_software: 'software de documentos de código abierto',
|
||||||
|
verified_phone_number: 'Verificar número de teléfono',
|
||||||
|
use_international_format: 'Usar formato internacional: +1xxx',
|
||||||
|
six_digits_code: 'Código de 6 dígitos',
|
||||||
|
change_phone_number: 'Cambiar número de teléfono',
|
||||||
|
sending: 'Enviando...',
|
||||||
|
resend_code: 'Reenviar código',
|
||||||
|
verification_code_has_been_resent: 'El código de verificación ha sido reenviado por SMS',
|
||||||
|
please_fill_all_required_fields: 'Por favor, complete todos los campos obligatorios',
|
||||||
|
set_today: 'Establecer Hoy',
|
||||||
|
date: 'Fecha',
|
||||||
|
toggle_multiline_text: 'Alternar Texto Multilínea',
|
||||||
|
email_has_been_sent: 'El correo electrónico ha sido enviado'
|
||||||
}
|
}
|
||||||
|
|
||||||
const it = {
|
const it = {
|
||||||
@@ -46,10 +86,30 @@ const it = {
|
|||||||
or_drag_and_drop_files: 'oppure trascina e rilascia i file',
|
or_drag_and_drop_files: 'oppure trascina e rilascia i file',
|
||||||
send_copy_via_email: 'Invia copia via email',
|
send_copy_via_email: 'Invia copia via email',
|
||||||
download: 'Scarica',
|
download: 'Scarica',
|
||||||
|
signature: 'Firma',
|
||||||
|
initials: 'Iniziali',
|
||||||
|
clear: 'Cancella',
|
||||||
|
redraw: 'Ridisegna',
|
||||||
|
draw_initials: 'Disegna iniziali',
|
||||||
|
type_signature_here: 'Scrivi la firma qui',
|
||||||
|
type_initial_here: 'Scrivi le iniziali qui',
|
||||||
form_has_been_completed: 'Il modulo è stato completato!',
|
form_has_been_completed: 'Il modulo è stato completato!',
|
||||||
create_a_free_account: 'Crea un Account Gratuito',
|
create_a_free_account: 'Crea un Account Gratuito',
|
||||||
signed_with: 'Firmato con',
|
signed_with: 'Firmato con',
|
||||||
open_source_documents_software: 'software di documenti open source'
|
please_check_the_box_to_continue: 'Si prega di spuntare la casella per continuare',
|
||||||
|
open_source_documents_software: 'software di documenti open source',
|
||||||
|
verified_phone_number: 'Verifica numero di telefono',
|
||||||
|
use_international_format: 'Usa formato internazionale: +1xxx',
|
||||||
|
six_digits_code: 'Codice a 6 cifre',
|
||||||
|
change_phone_number: 'Cambia numero di telefono',
|
||||||
|
sending: 'Invio in corso...',
|
||||||
|
resend_code: 'Rinvia codice',
|
||||||
|
verification_code_has_been_resent: 'Il codice di verifica è stato rinviato tramite SMS',
|
||||||
|
please_fill_all_required_fields: 'Si prega di compilare tutti i campi obbligatori',
|
||||||
|
set_today: 'Imposta Oggi',
|
||||||
|
date: 'Data',
|
||||||
|
toggle_multiline_text: 'Attiva Testo Multilinea',
|
||||||
|
email_has_been_sent: "L'email è stata inviata"
|
||||||
}
|
}
|
||||||
|
|
||||||
const de = {
|
const de = {
|
||||||
@@ -64,10 +124,30 @@ const de = {
|
|||||||
or_drag_and_drop_files: 'oder Dateien hierher ziehen und ablegen',
|
or_drag_and_drop_files: 'oder Dateien hierher ziehen und ablegen',
|
||||||
send_copy_via_email: 'Kopie per E-Mail senden',
|
send_copy_via_email: 'Kopie per E-Mail senden',
|
||||||
download: 'Herunterladen',
|
download: 'Herunterladen',
|
||||||
|
signature: 'Unterschrift',
|
||||||
|
initials: 'Initialen',
|
||||||
|
clear: 'Löschen',
|
||||||
|
redraw: 'Neu zeichnen',
|
||||||
|
draw_initials: 'Initialen zeichnen',
|
||||||
|
type_signature_here: 'Unterschrift hier eingeben',
|
||||||
|
type_initial_here: 'Initialen hier eingeben',
|
||||||
form_has_been_completed: 'Formular wurde ausgefüllt!',
|
form_has_been_completed: 'Formular wurde ausgefüllt!',
|
||||||
create_a_free_account: 'Kostenloses Konto erstellen',
|
create_a_free_account: 'Kostenloses Konto erstellen',
|
||||||
signed_with: 'Unterschrieben mit',
|
signed_with: 'Unterschrieben mit',
|
||||||
open_source_documents_software: 'Open-Source-Dokumentensoftware'
|
please_check_the_box_to_continue: 'Bitte setzen Sie das Häkchen, um fortzufahren',
|
||||||
|
open_source_documents_software: 'Open-Source-Dokumentensoftware',
|
||||||
|
verified_phone_number: 'Telefonnummer überprüfen',
|
||||||
|
use_international_format: 'Internationales Format verwenden: +1xxx',
|
||||||
|
six_digits_code: '6-stelliger Code',
|
||||||
|
change_phone_number: 'Telefonnummer ändern',
|
||||||
|
sending: 'Senden...',
|
||||||
|
resend_code: 'Code erneut senden',
|
||||||
|
verification_code_has_been_resent: 'Die Verifizierungscode wurde erneut per SMS gesendet',
|
||||||
|
please_fill_all_required_fields: 'Bitte füllen Sie alle erforderlichen Felder aus',
|
||||||
|
set_today: 'Heute einstellen',
|
||||||
|
date: 'Datum',
|
||||||
|
toggle_multiline_text: 'Mehrzeiligen Text umschalten',
|
||||||
|
email_has_been_sent: 'Die E-Mail wurde gesendet'
|
||||||
}
|
}
|
||||||
|
|
||||||
const fr = {
|
const fr = {
|
||||||
@@ -82,10 +162,30 @@ const fr = {
|
|||||||
or_drag_and_drop_files: 'ou faites glisser-déposer les fichiers',
|
or_drag_and_drop_files: 'ou faites glisser-déposer les fichiers',
|
||||||
send_copy_via_email: 'Envoyer une copie par e-mail',
|
send_copy_via_email: 'Envoyer une copie par e-mail',
|
||||||
download: 'Télécharger',
|
download: 'Télécharger',
|
||||||
|
signature: 'Signature',
|
||||||
|
initials: 'Initiales',
|
||||||
|
clear: 'Effacer',
|
||||||
|
redraw: 'Redessiner',
|
||||||
|
draw_initials: 'Dessiner les initiales',
|
||||||
|
type_signature_here: 'Tapez la signature ici',
|
||||||
|
type_initial_here: 'Tapez les initiales ici',
|
||||||
form_has_been_completed: 'Le formulaire a été complété !',
|
form_has_been_completed: 'Le formulaire a été complété !',
|
||||||
create_a_free_account: 'Créer un Compte Gratuit',
|
create_a_free_account: 'Créer un Compte Gratuit',
|
||||||
signed_with: 'Signé avec',
|
signed_with: 'Signé avec',
|
||||||
open_source_documents_software: 'logiciel de documents open source'
|
please_check_the_box_to_continue: 'Veuillez cocher la case pour continuer',
|
||||||
|
open_source_documents_software: 'logiciel de documents open source',
|
||||||
|
verified_phone_number: 'Vérifier le numéro de téléphone',
|
||||||
|
use_international_format: 'Utiliser le format international : +1xxx',
|
||||||
|
six_digits_code: 'Code à 6 chiffres',
|
||||||
|
change_phone_number: 'Changer le numéro de téléphone',
|
||||||
|
sending: 'Envoi en cours...',
|
||||||
|
resend_code: 'Renvoyer le code',
|
||||||
|
verification_code_has_been_resent: 'Le code de vérification a été renvoyé par SMS',
|
||||||
|
please_fill_all_required_fields: 'Veuillez remplir tous les champs obligatoires',
|
||||||
|
set_today: "Définir Aujourd'hui",
|
||||||
|
date: 'Date',
|
||||||
|
toggle_multiline_text: 'Basculer le Texte Multiligne',
|
||||||
|
email_has_been_sent: "L'email a été envoyé"
|
||||||
}
|
}
|
||||||
|
|
||||||
const pl = {
|
const pl = {
|
||||||
@@ -100,10 +200,30 @@ const pl = {
|
|||||||
or_drag_and_drop_files: 'lub przeciągnij i upuść pliki',
|
or_drag_and_drop_files: 'lub przeciągnij i upuść pliki',
|
||||||
send_copy_via_email: 'Wyślij kopię drogą mailową',
|
send_copy_via_email: 'Wyślij kopię drogą mailową',
|
||||||
download: 'Pobierz',
|
download: 'Pobierz',
|
||||||
|
signature: 'Podpis',
|
||||||
|
initials: 'Inicjały',
|
||||||
|
clear: 'Wyczyść',
|
||||||
|
redraw: 'Przerysuj',
|
||||||
|
draw_initials: 'Narysuj inicjały',
|
||||||
|
type_signature_here: 'Wpisz podpis tutaj',
|
||||||
|
type_initial_here: 'Wpisz inicjały tutaj',
|
||||||
form_has_been_completed: 'Formularz został wypełniony!',
|
form_has_been_completed: 'Formularz został wypełniony!',
|
||||||
create_a_free_account: 'Utwórz darmowe konto',
|
create_a_free_account: 'Utwórz darmowe konto',
|
||||||
signed_with: 'Podpisane za pomocą',
|
signed_with: 'Podpisane za pomocą',
|
||||||
open_source_documents_software: 'oprogramowanie do dokumentów open source'
|
please_check_the_box_to_continue: 'Proszę zaznaczyć pole, aby kontynuować',
|
||||||
|
open_source_documents_software: 'oprogramowanie do dokumentów open source',
|
||||||
|
verified_phone_number: 'Zweryfikuj numer telefonu',
|
||||||
|
use_international_format: 'Użyj międzynarodowego formatu: +1xxx',
|
||||||
|
six_digits_code: '6-cyfrowy kod',
|
||||||
|
change_phone_number: 'Zmień numer telefonu',
|
||||||
|
sending: 'Wysyłanie...',
|
||||||
|
resend_code: 'Ponownie wyślij kod',
|
||||||
|
verification_code_has_been_resent: 'Kod weryfikacyjny został ponownie wysłany',
|
||||||
|
please_fill_all_required_fields: 'Proszę wypełnić wszystkie wymagane pola',
|
||||||
|
set_today: 'Ustaw Dziś',
|
||||||
|
date: 'Data',
|
||||||
|
toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy',
|
||||||
|
email_has_been_sent: 'E-mail został wysłany'
|
||||||
}
|
}
|
||||||
|
|
||||||
const uk = {
|
const uk = {
|
||||||
@@ -118,10 +238,30 @@ const uk = {
|
|||||||
or_drag_and_drop_files: 'або перетягніть файли сюди',
|
or_drag_and_drop_files: 'або перетягніть файли сюди',
|
||||||
send_copy_via_email: 'Надіслати копію електронною поштою',
|
send_copy_via_email: 'Надіслати копію електронною поштою',
|
||||||
download: 'Завантажити',
|
download: 'Завантажити',
|
||||||
|
signature: 'Підпис',
|
||||||
|
initials: 'Ініціали',
|
||||||
|
clear: 'Очистити',
|
||||||
|
redraw: 'Перемалювати',
|
||||||
|
draw_initials: 'Намалювати ініціали',
|
||||||
|
type_signature_here: 'Введіть підпис тут',
|
||||||
|
type_initial_here: 'Введіть ініціали тут',
|
||||||
form_has_been_completed: 'Форму заповнено!',
|
form_has_been_completed: 'Форму заповнено!',
|
||||||
create_a_free_account: 'Створити безкоштовний обліковий запис',
|
create_a_free_account: 'Створити безкоштовний обліковий запис',
|
||||||
signed_with: 'Підписано за допомогою',
|
signed_with: 'Підписано за допомогою',
|
||||||
open_source_documents_software: 'відкритий програмний засіб для документів'
|
please_check_the_box_to_continue: 'Будь ласка, позначте прапорець, щоб продовжити',
|
||||||
|
open_source_documents_software: 'відкритий програмний засіб для документів',
|
||||||
|
verified_phone_number: 'Підтвердіть номер телефону',
|
||||||
|
use_international_format: 'Використовуйте міжнародний формат: +1xxx',
|
||||||
|
six_digits_code: '6-значний код',
|
||||||
|
change_phone_number: 'Змінити номер телефону',
|
||||||
|
sending: 'Надсилаю...',
|
||||||
|
resend_code: 'Повторно відправити код',
|
||||||
|
verification_code_has_been_resent: 'Код підтвердження був повторно надісланий',
|
||||||
|
please_fill_all_required_fields: "Будь ласка, заповніть всі обов'язкові поля",
|
||||||
|
set_today: 'Задати Сьогодні',
|
||||||
|
date: 'Дата',
|
||||||
|
toggle_multiline_text: 'Перемкнути Багаторядковий Текст',
|
||||||
|
email_has_been_sent: 'Електронний лист був відправлений'
|
||||||
}
|
}
|
||||||
|
|
||||||
const cs = {
|
const cs = {
|
||||||
@@ -136,17 +276,75 @@ const cs = {
|
|||||||
or_drag_and_drop_files: 'nebo přetáhněte soubory sem',
|
or_drag_and_drop_files: 'nebo přetáhněte soubory sem',
|
||||||
send_copy_via_email: 'Odeslat kopii e-mailem',
|
send_copy_via_email: 'Odeslat kopii e-mailem',
|
||||||
download: 'Stáhnout',
|
download: 'Stáhnout',
|
||||||
|
signature: 'Podpis',
|
||||||
|
initials: 'Iniciály',
|
||||||
|
clear: 'Smazat',
|
||||||
|
redraw: 'Překreslit',
|
||||||
|
draw_initials: 'Nakreslit iniciály',
|
||||||
|
type_signature_here: 'Sem zadejte podpis',
|
||||||
|
type_initial_here: 'Sem zadejte iniciály',
|
||||||
form_has_been_completed: 'Formulář byl dokončen!',
|
form_has_been_completed: 'Formulář byl dokončen!',
|
||||||
create_a_free_account: 'Vytvořit bezplatný účet',
|
create_a_free_account: 'Vytvořit bezplatný účet',
|
||||||
signed_with: 'Podepsáno pomocí',
|
signed_with: 'Podepsáno pomocí',
|
||||||
open_source_documents_software: 'open source software pro dokumenty'
|
please_check_the_box_to_continue: 'Prosím, zaškrtněte políčko pro pokračování',
|
||||||
|
open_source_documents_software: 'open source software pro dokumenty',
|
||||||
|
verified_phone_number: 'Ověřte telefonní číslo',
|
||||||
|
use_international_format: 'Použijte mezinárodní formát: +1xxx',
|
||||||
|
six_digits_code: '6-místný kód',
|
||||||
|
change_phone_number: 'Změnit telefonní číslo',
|
||||||
|
sending: 'Odesílání...',
|
||||||
|
resend_code: 'Znovu odeslat kód',
|
||||||
|
verification_code_has_been_resent: 'Ověřovací kód byl znovu odeslán',
|
||||||
|
please_fill_all_required_fields: 'Prosím vyplňte všechny povinné položky',
|
||||||
|
set_today: 'Nastavit Dnes',
|
||||||
|
date: 'Datum',
|
||||||
|
toggle_multiline_text: 'Přepnout Víceřádkový Text',
|
||||||
|
email_has_been_sent: 'E-mail byl odeslán'
|
||||||
}
|
}
|
||||||
|
|
||||||
const i18n = { en, es, it, de, fr, pl, uk, cs }
|
const pt = {
|
||||||
|
submit_form: 'Enviar Formulário',
|
||||||
|
type_here: 'Digite aqui',
|
||||||
|
optional: 'opcional',
|
||||||
|
select_your_option: 'Selecione sua opção',
|
||||||
|
complete_hightlighted_checkboxes_and_click: 'Complete as caixas de seleção destacadas e clique',
|
||||||
|
submit: 'enviar',
|
||||||
|
next: 'próximo',
|
||||||
|
click_to_upload: 'Clique para fazer o upload',
|
||||||
|
or_drag_and_drop_files: 'ou arraste e solte arquivos',
|
||||||
|
send_copy_via_email: 'Enviar cópia por e-mail',
|
||||||
|
download: 'Baixar',
|
||||||
|
signature: 'Assinatura',
|
||||||
|
initials: 'Iniciais',
|
||||||
|
clear: 'Limpar',
|
||||||
|
redraw: 'Redesenhar',
|
||||||
|
draw_initials: 'Desenhar iniciais',
|
||||||
|
type_signature_here: 'Digite a assinatura aqui',
|
||||||
|
type_initial_here: 'Digite as iniciais aqui',
|
||||||
|
form_has_been_completed: 'O formulário foi concluído!',
|
||||||
|
create_a_free_account: 'Criar uma Conta Gratuita',
|
||||||
|
signed_with: 'Assinado com',
|
||||||
|
please_check_the_box_to_continue: 'Por favor, marque a caixa para continuar',
|
||||||
|
open_source_documents_software: 'software de documentos de código aberto',
|
||||||
|
verified_phone_number: 'Verificar Número de Telefone',
|
||||||
|
use_international_format: 'Use formato internacional: +1xxx',
|
||||||
|
six_digits_code: 'Código de 6 dígitos',
|
||||||
|
change_phone_number: 'Alterar número de telefone',
|
||||||
|
sending: 'Enviando...',
|
||||||
|
resend_code: 'Reenviar código',
|
||||||
|
verification_code_has_been_resent: 'O código de verificação foi reenviado via SMS',
|
||||||
|
please_fill_all_required_fields: 'Por favor, preencha todos os campos obrigatórios',
|
||||||
|
set_today: 'Definir Hoje',
|
||||||
|
date: 'Data',
|
||||||
|
toggle_multiline_text: 'Alternar Texto Multilinha',
|
||||||
|
email_has_been_sent: 'Email enviado'
|
||||||
|
}
|
||||||
|
|
||||||
|
const i18n = { en, es, it, de, fr, pl, uk, cs, pt }
|
||||||
|
|
||||||
const browserLanguage = (navigator.language || navigator.userLanguage || 'en').split('-')[0]
|
const browserLanguage = (navigator.language || navigator.userLanguage || 'en').split('-')[0]
|
||||||
|
|
||||||
const t = (key) => i18n[browserLanguage][key] || i18n.en[key] || key
|
const t = (key) => i18n[browserLanguage]?.[key] || i18n.en[key] || key
|
||||||
|
|
||||||
export default i18n
|
export default i18n
|
||||||
export { t }
|
export { t }
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between items-center w-full mb-2">
|
||||||
|
<label
|
||||||
|
class="label text-2xl"
|
||||||
|
>{{ field.name || t('initials') }}</label>
|
||||||
|
<div class="space-x-2 flex">
|
||||||
|
<span
|
||||||
|
class="tooltip"
|
||||||
|
:data-tip="t('draw_initials')"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
id="type_text_button"
|
||||||
|
href="#"
|
||||||
|
class="btn btn-sm btn-circle"
|
||||||
|
:class="{ 'btn-neutral': isDrawInitials, 'btn-outline': !isDrawInitials }"
|
||||||
|
@click.prevent="toggleTextInput"
|
||||||
|
>
|
||||||
|
<IconSignature :width="16" />
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
v-if="modelValue || computedPreviousValue"
|
||||||
|
href="#"
|
||||||
|
class="btn btn-outline btn-sm"
|
||||||
|
@click.prevent="remove"
|
||||||
|
>
|
||||||
|
<IconReload :width="16" />
|
||||||
|
{{ t('clear') }}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
v-else
|
||||||
|
href="#"
|
||||||
|
class="btn btn-outline btn-sm"
|
||||||
|
@click.prevent="clear"
|
||||||
|
>
|
||||||
|
<IconReload :width="16" />
|
||||||
|
{{ t('clear') }}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
title="Minimize"
|
||||||
|
href="#"
|
||||||
|
class="py-1.5 inline md:hidden"
|
||||||
|
@click.prevent="$emit('minimize')"
|
||||||
|
>
|
||||||
|
<IconArrowsDiagonalMinimize2
|
||||||
|
:width="20"
|
||||||
|
:height="20"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
:value="modelValue || computedPreviousValue"
|
||||||
|
type="hidden"
|
||||||
|
:name="`values[${field.uuid}]`"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="modelValue || computedPreviousValue"
|
||||||
|
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
|
||||||
|
class="mx-auto bg-white border border-base-300 rounded max-h-72"
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
v-show="!modelValue && !computedPreviousValue"
|
||||||
|
ref="canvas"
|
||||||
|
class="bg-white border border-base-300 rounded"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
|
||||||
|
id="initials_text_input"
|
||||||
|
ref="textInput"
|
||||||
|
class="base-input !text-2xl w-full mt-6 text-center"
|
||||||
|
:required="field.required && !isInitialsStarted"
|
||||||
|
:placeholder="`${t('type_initial_here')}...`"
|
||||||
|
type="text"
|
||||||
|
@focus="$emit('focus')"
|
||||||
|
@input="updateWrittenInitials"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { cropCanvasAndExportToPNG } from './crop_canvas'
|
||||||
|
import { IconReload, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
||||||
|
import SignaturePad from 'signature_pad'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'InitialsStep',
|
||||||
|
components: {
|
||||||
|
IconReload,
|
||||||
|
IconSignature,
|
||||||
|
IconArrowsDiagonalMinimize2
|
||||||
|
},
|
||||||
|
inject: ['baseUrl', 't'],
|
||||||
|
props: {
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
submitterSlug: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
isDirectUpload: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
attachmentsIndex: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
|
previousValue: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['attached', 'update:model-value', 'start', 'minimize', 'focus'],
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
isInitialsStarted: !!this.previousValue,
|
||||||
|
isUsePreviousValue: true,
|
||||||
|
isDrawInitials: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
computedPreviousValue () {
|
||||||
|
if (this.isUsePreviousValue) {
|
||||||
|
return this.previousValue
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted () {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.$refs.canvas) {
|
||||||
|
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
|
||||||
|
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 5
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$refs.textInput?.focus()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.isDirectUpload) {
|
||||||
|
import('@rails/activestorage')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.$refs.canvas) {
|
||||||
|
this.pad = new SignaturePad(this.$refs.canvas)
|
||||||
|
|
||||||
|
this.pad.addEventListener('beginStroke', () => {
|
||||||
|
this.isInitialsStarted = true
|
||||||
|
|
||||||
|
this.$emit('start')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
remove () {
|
||||||
|
this.$emit('update:model-value', '')
|
||||||
|
|
||||||
|
this.isUsePreviousValue = false
|
||||||
|
this.isInitialsStarted = false
|
||||||
|
},
|
||||||
|
clear () {
|
||||||
|
this.pad.clear()
|
||||||
|
|
||||||
|
this.isInitialsStarted = false
|
||||||
|
|
||||||
|
if (this.$refs.textInput) {
|
||||||
|
this.$refs.textInput.value = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateWrittenInitials (e) {
|
||||||
|
this.isInitialsStarted = true
|
||||||
|
|
||||||
|
const canvas = this.$refs.canvas
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
|
||||||
|
const fontFamily = 'Arial'
|
||||||
|
const fontSize = '44px'
|
||||||
|
const fontStyle = 'italic'
|
||||||
|
const fontWeight = ''
|
||||||
|
|
||||||
|
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily
|
||||||
|
context.textAlign = 'center'
|
||||||
|
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
context.fillText(e.target.value, canvas.width / 2, canvas.height / 2 + 11)
|
||||||
|
},
|
||||||
|
toggleTextInput () {
|
||||||
|
this.remove()
|
||||||
|
this.clear()
|
||||||
|
this.isDrawInitials = !this.isDrawInitials
|
||||||
|
|
||||||
|
if (!this.isDrawInitials) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$refs.textInput.focus()
|
||||||
|
|
||||||
|
this.$emit('start')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async submit () {
|
||||||
|
if (this.modelValue || this.computedPreviousValue) {
|
||||||
|
if (this.computedPreviousValue) {
|
||||||
|
this.$emit('update:model-value', this.computedPreviousValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({})
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
|
||||||
|
const file = new File([blob], 'initials.png', { type: 'image/png' })
|
||||||
|
|
||||||
|
if (this.isDirectUpload) {
|
||||||
|
const { DirectUpload } = await import('@rails/activestorage')
|
||||||
|
|
||||||
|
new DirectUpload(
|
||||||
|
file,
|
||||||
|
'/direct_uploads'
|
||||||
|
).create((_error, data) => {
|
||||||
|
fetch(this.baseUrl + '/api/attachments', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
submitter_slug: this.submitterSlug,
|
||||||
|
blob_signed_id: data.signed_id,
|
||||||
|
name: 'attachments'
|
||||||
|
}),
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}).then((resp) => resp.json()).then((attachment) => {
|
||||||
|
this.$emit('update:model-value', attachment.uuid)
|
||||||
|
this.$emit('attached', attachment)
|
||||||
|
|
||||||
|
return resolve(attachment)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const formData = new FormData()
|
||||||
|
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('submitter_slug', this.submitterSlug)
|
||||||
|
formData.append('name', 'attachments')
|
||||||
|
|
||||||
|
return fetch(this.baseUrl + '/api/attachments', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
}).then((resp) => resp.json()).then((attachment) => {
|
||||||
|
this.$emit('attached', attachment)
|
||||||
|
this.$emit('update:model-value', attachment.uuid)
|
||||||
|
|
||||||
|
return resolve(attachment)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
:name="`values[${field.uuid}][]`"
|
:name="`values[${field.uuid}][]`"
|
||||||
:value="option"
|
:value="option"
|
||||||
class="base-checkbox !h-7 !w-7"
|
class="base-checkbox !h-7 !w-7"
|
||||||
:checked="modelValue.includes(option)"
|
:checked="(modelValue || []).includes(option)"
|
||||||
@change="onChange"
|
@change="onChange"
|
||||||
>
|
>
|
||||||
<span class="text-xl">
|
<span class="text-xl">
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
:for="isCodeSent ? 'one_time_code' : field.uuid"
|
||||||
|
class="label text-2xl mb-2"
|
||||||
|
>{{ field.name || t('verified_phone_number') }}
|
||||||
|
<template v-if="!field.required">({{ t('optional') }})</template>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="normalize_phone"
|
||||||
|
value="true"
|
||||||
|
>
|
||||||
|
<div v-if="isCodeSent">
|
||||||
|
<input
|
||||||
|
id="one_time_code"
|
||||||
|
class="base-input !text-2xl w-full text-center"
|
||||||
|
name="one_time_code"
|
||||||
|
type="text"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
:placeholder="t('six_digits_code')"
|
||||||
|
required
|
||||||
|
maxlength="6"
|
||||||
|
autofocus
|
||||||
|
inputmode="decimal"
|
||||||
|
@input="onInputCode"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between mt-2 -mb-2 md:-mb-4">
|
||||||
|
<a
|
||||||
|
v-if="!defaultValue"
|
||||||
|
href="#"
|
||||||
|
class="link"
|
||||||
|
@click.prevent="isCodeSent = false"
|
||||||
|
>
|
||||||
|
{{ t('change_phone_number') }}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="link"
|
||||||
|
@click.prevent="resendCode"
|
||||||
|
>
|
||||||
|
{{ isResendLoading ? t('sending') : t('resend_code') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-show="!isCodeSent"
|
||||||
|
:id="field.uuid"
|
||||||
|
ref="phone"
|
||||||
|
:value="modelValue || defaultValue"
|
||||||
|
:readonly="!!defaultValue"
|
||||||
|
class="base-input !text-2xl w-full"
|
||||||
|
autocomplete="tel"
|
||||||
|
pattern="^\+[0-9\s\-]+$"
|
||||||
|
:oninvalid="`this.value ? this.setCustomValidity('${t('use_international_format')}...') : ''`"
|
||||||
|
oninput="this.setCustomValidity('')"
|
||||||
|
type="tel"
|
||||||
|
inputmode="tel"
|
||||||
|
:required="field.required"
|
||||||
|
placeholder="+1 234 567-8900"
|
||||||
|
:name="`values[${field.uuid}]`"
|
||||||
|
@input="$emit('update:model-value', $event.target.value)"
|
||||||
|
@focus="$emit('focus')"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function throttle (func, delay) {
|
||||||
|
let lastCallTime = 0
|
||||||
|
|
||||||
|
return function (...args) {
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
if (now - lastCallTime >= delay) {
|
||||||
|
func.apply(this, args)
|
||||||
|
lastCallTime = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'PhoneStep',
|
||||||
|
inject: ['t', 'baseUrl'],
|
||||||
|
props: {
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
submitterSlug: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
defaultValue: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['update:model-value', 'focus', 'submit'],
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
isCodeSent: false,
|
||||||
|
isResendLoading: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
emitSubmit: throttle(function (e) {
|
||||||
|
this.$emit('submit')
|
||||||
|
}, 1000),
|
||||||
|
onInputCode (e) {
|
||||||
|
if (e.target.value.length === 6) {
|
||||||
|
this.emitSubmit()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resendCode () {
|
||||||
|
this.isResendLoading = true
|
||||||
|
|
||||||
|
this.sendVerificationCode().finally(() => {
|
||||||
|
alert(this.t('verification_code_has_been_resent'))
|
||||||
|
|
||||||
|
this.isResendLoading = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
sendVerificationCode () {
|
||||||
|
return fetch(this.baseUrl + '/api/send_phone_verification_code', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
submitter_slug: this.submitterSlug,
|
||||||
|
phone: this.$refs.phone.value
|
||||||
|
}),
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async submit () {
|
||||||
|
if (!this.isCodeSent) {
|
||||||
|
this.sendVerificationCode()
|
||||||
|
|
||||||
|
this.$emit('update:model-value', this.$refs.phone.value)
|
||||||
|
|
||||||
|
this.isCodeSent = true
|
||||||
|
|
||||||
|
return Promise.reject(new Error('verify with code'))
|
||||||
|
} else {
|
||||||
|
return Promise.resolve({})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -3,20 +3,21 @@
|
|||||||
<div class="flex justify-between items-center w-full mb-2">
|
<div class="flex justify-between items-center w-full mb-2">
|
||||||
<label
|
<label
|
||||||
class="label text-2xl"
|
class="label text-2xl"
|
||||||
>{{ field.name || 'Signature' }}</label>
|
>{{ field.name || t('signature') }}</label>
|
||||||
<div class="space-x-2 flex">
|
<div class="space-x-2 flex">
|
||||||
<span
|
<span
|
||||||
class="tooltip"
|
class="tooltip"
|
||||||
data-tip="Type text"
|
data-tip="Type text"
|
||||||
>
|
>
|
||||||
<button
|
<a
|
||||||
id="type_text_button"
|
id="type_text_button"
|
||||||
|
href="#"
|
||||||
class="btn btn-sm btn-circle"
|
class="btn btn-sm btn-circle"
|
||||||
:class="{ 'btn-neutral': isTextSignature, 'btn-outline': !isTextSignature }"
|
:class="{ 'btn-neutral': isTextSignature, 'btn-outline': !isTextSignature }"
|
||||||
@click.prevent="toggleTextInput"
|
@click.prevent="toggleTextInput"
|
||||||
>
|
>
|
||||||
<IconTextSize :width="16" />
|
<IconTextSize :width="16" />
|
||||||
</button>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class="tooltip"
|
class="tooltip"
|
||||||
@@ -34,23 +35,26 @@
|
|||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<a
|
||||||
v-if="modelValue"
|
v-if="modelValue || computedPreviousValue"
|
||||||
|
href="#"
|
||||||
class="btn btn-outline btn-sm"
|
class="btn btn-outline btn-sm"
|
||||||
@click.prevent="remove"
|
@click.prevent="remove"
|
||||||
>
|
>
|
||||||
<IconReload :width="16" />
|
<IconReload :width="16" />
|
||||||
Redraw
|
{{ t('redraw') }}
|
||||||
</button>
|
</a>
|
||||||
<button
|
<a
|
||||||
v-else
|
v-else
|
||||||
|
href="#"
|
||||||
class="btn btn-outline btn-sm"
|
class="btn btn-outline btn-sm"
|
||||||
@click.prevent="clear"
|
@click.prevent="clear"
|
||||||
>
|
>
|
||||||
<IconReload :width="16" />
|
<IconReload :width="16" />
|
||||||
Clear
|
{{ t('clear') }}
|
||||||
</button>
|
</a>
|
||||||
<button
|
<a
|
||||||
|
href="#"
|
||||||
title="Minimize"
|
title="Minimize"
|
||||||
class="py-1.5 inline md:hidden"
|
class="py-1.5 inline md:hidden"
|
||||||
@click.prevent="$emit('minimize')"
|
@click.prevent="$emit('minimize')"
|
||||||
@@ -59,21 +63,21 @@
|
|||||||
:width="20"
|
:width="20"
|
||||||
:height="20"
|
:height="20"
|
||||||
/>
|
/>
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
:value="modelValue"
|
:value="modelValue || computedPreviousValue"
|
||||||
type="hidden"
|
type="hidden"
|
||||||
:name="`values[${field.uuid}]`"
|
:name="`values[${field.uuid}]`"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="modelValue"
|
v-if="modelValue || computedPreviousValue"
|
||||||
:src="attachmentsIndex[modelValue].url"
|
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
|
||||||
class="mx-auto bg-white border border-base-300 rounded max-h-72"
|
class="mx-auto bg-white border border-base-300 rounded max-h-72"
|
||||||
>
|
>
|
||||||
<canvas
|
<canvas
|
||||||
v-show="!modelValue"
|
v-show="!modelValue && !computedPreviousValue"
|
||||||
ref="canvas"
|
ref="canvas"
|
||||||
class="bg-white border border-base-300 rounded"
|
class="bg-white border border-base-300 rounded"
|
||||||
/>
|
/>
|
||||||
@@ -83,7 +87,7 @@
|
|||||||
ref="textInput"
|
ref="textInput"
|
||||||
class="base-input !text-2xl w-full mt-6"
|
class="base-input !text-2xl w-full mt-6"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:placeholder="`Type signature here...`"
|
:placeholder="`${t('type_signature_here')}...`"
|
||||||
type="text"
|
type="text"
|
||||||
@input="updateWrittenSignature"
|
@input="updateWrittenSignature"
|
||||||
>
|
>
|
||||||
@@ -92,6 +96,10 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { IconReload, IconCamera, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
import { IconReload, IconCamera, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
||||||
|
import { cropCanvasAndExportToPNG } from './crop_canvas'
|
||||||
|
import SignaturePad from 'signature_pad'
|
||||||
|
|
||||||
|
let isFontLoaded = false
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'SignatureStep',
|
name: 'SignatureStep',
|
||||||
@@ -101,7 +109,7 @@ export default {
|
|||||||
IconTextSize,
|
IconTextSize,
|
||||||
IconArrowsDiagonalMinimize2
|
IconArrowsDiagonalMinimize2
|
||||||
},
|
},
|
||||||
inject: ['baseUrl'],
|
inject: ['baseUrl', 't'],
|
||||||
props: {
|
props: {
|
||||||
field: {
|
field: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -121,6 +129,11 @@ export default {
|
|||||||
required: false,
|
required: false,
|
||||||
default: () => ({})
|
default: () => ({})
|
||||||
},
|
},
|
||||||
|
previousValue: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: String,
|
type: String,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -130,33 +143,61 @@ export default {
|
|||||||
emits: ['attached', 'update:model-value', 'start', 'minimize'],
|
emits: ['attached', 'update:model-value', 'start', 'minimize'],
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
isSignatureStarted: false,
|
isSignatureStarted: !!this.previousValue,
|
||||||
|
isUsePreviousValue: true,
|
||||||
isTextSignature: false
|
isTextSignature: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
computedPreviousValue () {
|
||||||
|
if (this.isUsePreviousValue) {
|
||||||
|
return this.previousValue
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
async mounted () {
|
async mounted () {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
|
if (this.$refs.canvas) {
|
||||||
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 3
|
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
|
||||||
|
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 3
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.isDirectUpload) {
|
if (this.isDirectUpload) {
|
||||||
import('@rails/activestorage')
|
import('@rails/activestorage')
|
||||||
}
|
}
|
||||||
|
|
||||||
const { default: SignaturePad } = await import('signature_pad')
|
if (this.$refs.canvas) {
|
||||||
|
this.pad = new SignaturePad(this.$refs.canvas)
|
||||||
|
|
||||||
this.pad = new SignaturePad(this.$refs.canvas)
|
this.pad.addEventListener('beginStroke', () => {
|
||||||
|
this.isSignatureStarted = true
|
||||||
|
|
||||||
this.pad.addEventListener('beginStroke', () => {
|
this.$emit('start')
|
||||||
this.isSignatureStarted = true
|
})
|
||||||
|
}
|
||||||
this.$emit('start')
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
remove () {
|
remove () {
|
||||||
this.$emit('update:model-value', '')
|
this.$emit('update:model-value', '')
|
||||||
|
|
||||||
|
this.isUsePreviousValue = false
|
||||||
|
this.isSignatureStarted = false
|
||||||
|
},
|
||||||
|
loadFont () {
|
||||||
|
if (!isFontLoaded) {
|
||||||
|
const font = new FontFace('Dancing Script', `url(${this.baseUrl}/fonts/DancingScript.otf) format("opentype")`)
|
||||||
|
|
||||||
|
font.load().then((loadedFont) => {
|
||||||
|
document.fonts.add(loadedFont)
|
||||||
|
|
||||||
|
isFontLoaded = true
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Font loading failed:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
clear () {
|
clear () {
|
||||||
this.pad.clear()
|
this.pad.clear()
|
||||||
@@ -173,8 +214,8 @@ export default {
|
|||||||
const canvas = this.$refs.canvas
|
const canvas = this.$refs.canvas
|
||||||
const context = canvas.getContext('2d')
|
const context = canvas.getContext('2d')
|
||||||
|
|
||||||
const fontFamily = 'Arial'
|
const fontFamily = 'Dancing Script'
|
||||||
const fontSize = '44px'
|
const fontSize = '38px'
|
||||||
const fontStyle = 'italic'
|
const fontStyle = 'italic'
|
||||||
const fontWeight = ''
|
const fontWeight = ''
|
||||||
|
|
||||||
@@ -192,6 +233,8 @@ export default {
|
|||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.$refs.textInput.focus()
|
this.$refs.textInput.focus()
|
||||||
|
|
||||||
|
this.loadFont()
|
||||||
|
|
||||||
this.$emit('start')
|
this.$emit('start')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -244,60 +287,17 @@ export default {
|
|||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
cropCanvasAndExportToPNG (canvas) {
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
|
|
||||||
const width = canvas.width
|
|
||||||
const height = canvas.height
|
|
||||||
|
|
||||||
let topmost = height
|
|
||||||
let bottommost = 0
|
|
||||||
let leftmost = width
|
|
||||||
let rightmost = 0
|
|
||||||
|
|
||||||
const imageData = ctx.getImageData(0, 0, width, height)
|
|
||||||
const pixels = imageData.data
|
|
||||||
|
|
||||||
for (let y = 0; y < height; y++) {
|
|
||||||
for (let x = 0; x < width; x++) {
|
|
||||||
const pixelIndex = (y * width + x) * 4
|
|
||||||
const alpha = pixels[pixelIndex + 3]
|
|
||||||
if (alpha !== 0) {
|
|
||||||
topmost = Math.min(topmost, y)
|
|
||||||
bottommost = Math.max(bottommost, y)
|
|
||||||
leftmost = Math.min(leftmost, x)
|
|
||||||
rightmost = Math.max(rightmost, x)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const croppedWidth = rightmost - leftmost + 1
|
|
||||||
const croppedHeight = bottommost - topmost + 1
|
|
||||||
|
|
||||||
const croppedCanvas = document.createElement('canvas')
|
|
||||||
croppedCanvas.width = croppedWidth
|
|
||||||
croppedCanvas.height = croppedHeight
|
|
||||||
const croppedCtx = croppedCanvas.getContext('2d')
|
|
||||||
|
|
||||||
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
croppedCanvas.toBlob((blob) => {
|
|
||||||
if (blob) {
|
|
||||||
resolve(blob)
|
|
||||||
} else {
|
|
||||||
reject(new Error('Failed to create a PNG blob.'))
|
|
||||||
}
|
|
||||||
}, 'image/png')
|
|
||||||
})
|
|
||||||
},
|
|
||||||
async submit () {
|
async submit () {
|
||||||
if (this.modelValue) {
|
if (this.modelValue || this.computedPreviousValue) {
|
||||||
|
if (this.computedPreviousValue) {
|
||||||
|
this.$emit('update:model-value', this.computedPreviousValue)
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.resolve({})
|
return Promise.resolve({})
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
this.cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
|
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
|
||||||
const file = new File([blob], 'signature.png', { type: 'image/png' })
|
const file = new File([blob], 'signature.png', { type: 'image/png' })
|
||||||
|
|
||||||
if (this.isDirectUpload) {
|
if (this.isDirectUpload) {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<label
|
||||||
|
v-if="field.name"
|
||||||
|
:for="field.uuid"
|
||||||
|
class="label text-2xl mb-2"
|
||||||
|
>{{ field.name }}
|
||||||
|
<template v-if="!field.required">({{ t('optional') }})</template>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="py-1"
|
||||||
|
/>
|
||||||
|
<div class="items-center flex">
|
||||||
|
<input
|
||||||
|
v-if="!isTextArea"
|
||||||
|
:id="field.uuid"
|
||||||
|
v-model="text"
|
||||||
|
class="base-input !text-2xl w-full !pr-11 -mr-10"
|
||||||
|
:required="field.required"
|
||||||
|
:pattern="field.validation?.pattern"
|
||||||
|
:oninvalid="field.validation?.message ? `this.setCustomValidity(${JSON.stringify(field.validation.message)})` : ''"
|
||||||
|
:oninput="field.validation?.message ? `this.setCustomValidity('')` : ''"
|
||||||
|
:placeholder="`${t('type_here')}...${field.required ? '' : ` (${t('optional')})`}`"
|
||||||
|
type="text"
|
||||||
|
:name="`values[${field.uuid}]`"
|
||||||
|
@focus="$emit('focus')"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
v-if="isTextArea"
|
||||||
|
:id="field.uuid"
|
||||||
|
ref="textarea"
|
||||||
|
v-model="text"
|
||||||
|
class="base-textarea !text-2xl w-full"
|
||||||
|
:placeholder="`${t('type_here')}...${field.required ? '' : ` (${t('optional')})`}`"
|
||||||
|
:required="field.required"
|
||||||
|
:name="`values[${field.uuid}]`"
|
||||||
|
@input="resizeTextarea"
|
||||||
|
@focus="$emit('focus')"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="!isTextArea"
|
||||||
|
class="tooltip"
|
||||||
|
:data-tip="t('toggle_multiline_text')"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="btn btn-ghost btn-circle btn-sm"
|
||||||
|
@click.prevent="toggleTextArea"
|
||||||
|
>
|
||||||
|
<IconAlignBoxLeftTop />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { IconAlignBoxLeftTop } from '@tabler/icons-vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'TextStep',
|
||||||
|
components: {
|
||||||
|
IconAlignBoxLeftTop
|
||||||
|
},
|
||||||
|
inject: ['t'],
|
||||||
|
props: {
|
||||||
|
field: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['update:model-value', 'focus'],
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
isTextArea: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
text: {
|
||||||
|
set (value) {
|
||||||
|
this.$emit('update:model-value', value)
|
||||||
|
},
|
||||||
|
get () {
|
||||||
|
return this.modelValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted () {
|
||||||
|
this.isTextArea = this.modelValue?.includes('\n')
|
||||||
|
|
||||||
|
if (this.isTextArea) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.resizeTextarea()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
resizeTextarea () {
|
||||||
|
const textarea = this.$refs.textarea
|
||||||
|
|
||||||
|
textarea.style.height = 'auto'
|
||||||
|
textarea.style.height = textarea.scrollHeight + 'px'
|
||||||
|
},
|
||||||
|
toggleTextArea () {
|
||||||
|
this.isTextArea = true
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$refs.textarea.focus()
|
||||||
|
this.$refs.textarea.setSelectionRange(this.$refs.textarea.value.length, this.$refs.textarea.value.length)
|
||||||
|
|
||||||
|
this.resizeTextarea()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
:style="positionStyle"
|
:style="positionStyle"
|
||||||
@pointerdown.stop
|
@pointerdown.stop
|
||||||
@mousedown.stop="startDrag"
|
@mousedown.stop="startDrag"
|
||||||
|
@touchstart="startTouchDrag"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="isSelected || isDraw"
|
v-if="isSelected || isDraw"
|
||||||
@@ -32,7 +33,7 @@
|
|||||||
<div
|
<div
|
||||||
v-if="field?.type"
|
v-if="field?.type"
|
||||||
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap group-hover:flex group-hover:z-10"
|
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap group-hover:flex group-hover:z-10"
|
||||||
:class="{ 'flex z-10': isNameFocus || isSelected, hidden: !isNameFocus && !isSelected }"
|
:class="{ 'flex z-10': isNameFocus || isSelected, invisible: !isNameFocus && !isSelected }"
|
||||||
style="top: -25px; height: 25px"
|
style="top: -25px; height: 25px"
|
||||||
@mousedown.stop
|
@mousedown.stop
|
||||||
@pointerdown.stop
|
@pointerdown.stop
|
||||||
@@ -108,12 +109,14 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
ref="touchTarget"
|
||||||
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer"
|
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
v-if="field?.type"
|
v-if="field?.type"
|
||||||
class="h-2.5 w-2.5 -right-1 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-nwse-resize"
|
class="h-4 w-4 md:h-2.5 md:w-2.5 -right-1 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-nwse-resize"
|
||||||
@mousedown.stop="startResize"
|
@mousedown.stop="startResize"
|
||||||
|
@touchstart="startTouchResize"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -186,7 +189,12 @@ export default {
|
|||||||
'border-sky-500',
|
'border-sky-500',
|
||||||
'border-emerald-500',
|
'border-emerald-500',
|
||||||
'border-yellow-300',
|
'border-yellow-300',
|
||||||
'border-purple-600'
|
'border-purple-600',
|
||||||
|
'border-pink-500',
|
||||||
|
'border-cyan-500',
|
||||||
|
'border-orange-500',
|
||||||
|
'border-lime-500',
|
||||||
|
'border-indigo-500'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
bgColors () {
|
bgColors () {
|
||||||
@@ -195,7 +203,12 @@ export default {
|
|||||||
'bg-sky-100',
|
'bg-sky-100',
|
||||||
'bg-emerald-100',
|
'bg-emerald-100',
|
||||||
'bg-yellow-100',
|
'bg-yellow-100',
|
||||||
'bg-purple-100'
|
'bg-purple-100',
|
||||||
|
'bg-pink-100',
|
||||||
|
'bg-cyan-100',
|
||||||
|
'bg-orange-100',
|
||||||
|
'bg-lime-100',
|
||||||
|
'bg-indigo-100'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
isSelected () {
|
isSelected () {
|
||||||
@@ -309,6 +322,47 @@ export default {
|
|||||||
|
|
||||||
this.$emit('start-drag')
|
this.$emit('start-drag')
|
||||||
},
|
},
|
||||||
|
startTouchDrag (e) {
|
||||||
|
if (e.target !== this.$refs.touchTarget) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$refs?.name?.blur()
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.isDragged = true
|
||||||
|
|
||||||
|
const rect = e.target.getBoundingClientRect()
|
||||||
|
|
||||||
|
this.selectedAreaRef.value = this.area
|
||||||
|
|
||||||
|
this.dragFrom = { x: rect.left - e.touches[0].clientX, y: rect.top - e.touches[0].clientY }
|
||||||
|
|
||||||
|
this.$el.getRootNode().addEventListener('touchmove', this.touchDrag)
|
||||||
|
this.$el.getRootNode().addEventListener('touchend', this.stopTouchDrag)
|
||||||
|
|
||||||
|
this.$emit('start-drag')
|
||||||
|
},
|
||||||
|
touchDrag (e) {
|
||||||
|
const page = this.$parent.$refs.mask.previousSibling
|
||||||
|
const rect = page.getBoundingClientRect()
|
||||||
|
|
||||||
|
this.area.x = (this.dragFrom.x + e.touches[0].clientX - rect.left) / rect.width
|
||||||
|
this.area.y = (this.dragFrom.y + e.touches[0].clientY - rect.top) / rect.height
|
||||||
|
},
|
||||||
|
stopTouchDrag () {
|
||||||
|
this.$el.getRootNode().removeEventListener('touchmove', this.touchDrag)
|
||||||
|
this.$el.getRootNode().removeEventListener('touchend', this.stopTouchDrag)
|
||||||
|
|
||||||
|
if (this.isDragged) {
|
||||||
|
this.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isDragged = false
|
||||||
|
|
||||||
|
this.$emit('stop-drag')
|
||||||
|
},
|
||||||
stopDrag () {
|
stopDrag () {
|
||||||
this.$el.getRootNode().removeEventListener('mousemove', this.drag)
|
this.$el.getRootNode().removeEventListener('mousemove', this.drag)
|
||||||
this.$el.getRootNode().removeEventListener('mouseup', this.stopDrag)
|
this.$el.getRootNode().removeEventListener('mouseup', this.stopDrag)
|
||||||
@@ -335,6 +389,33 @@ export default {
|
|||||||
|
|
||||||
this.$emit('stop-resize')
|
this.$emit('stop-resize')
|
||||||
|
|
||||||
|
this.save()
|
||||||
|
},
|
||||||
|
startTouchResize (e) {
|
||||||
|
this.selectedAreaRef.value = this.area
|
||||||
|
|
||||||
|
this.$refs?.name?.blur()
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.$el.getRootNode().addEventListener('touchmove', this.touchResize)
|
||||||
|
this.$el.getRootNode().addEventListener('touchend', this.stopTouchResize)
|
||||||
|
|
||||||
|
this.$emit('start-resize', 'nwse')
|
||||||
|
},
|
||||||
|
touchResize (e) {
|
||||||
|
const page = this.$parent.$refs.mask.previousSibling
|
||||||
|
const rect = page.getBoundingClientRect()
|
||||||
|
|
||||||
|
this.area.w = (e.touches[0].clientX - rect.left) / rect.width - this.area.x
|
||||||
|
this.area.h = (e.touches[0].clientY - rect.top) / rect.height - this.area.y
|
||||||
|
},
|
||||||
|
stopTouchResize () {
|
||||||
|
this.$el.getRootNode().removeEventListener('touchmove', this.touchResize)
|
||||||
|
this.$el.getRootNode().removeEventListener('touchend', this.stopTouchResize)
|
||||||
|
|
||||||
|
this.$emit('stop-resize')
|
||||||
|
|
||||||
this.save()
|
this.save()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
style="max-width: 1600px"
|
style="max-width: 1600px"
|
||||||
class="mx-auto pl-4 h-full"
|
class="mx-auto pl-3 md:pl-4 h-full"
|
||||||
>
|
>
|
||||||
<div class="flex justify-between py-1.5 items-center pr-4">
|
<div
|
||||||
|
class="flex justify-between py-1.5 items-center pr-4 sticky top-0 z-10"
|
||||||
|
:style="{ backgroundColor }"
|
||||||
|
>
|
||||||
<div class="flex space-x-3">
|
<div class="flex space-x-3">
|
||||||
<a
|
<a
|
||||||
v-if="withLogoLink"
|
v-if="withLogo"
|
||||||
href="/"
|
href="/"
|
||||||
>
|
>
|
||||||
<Logo />
|
<Logo />
|
||||||
</a>
|
</a>
|
||||||
<Logo v-else />
|
|
||||||
<Contenteditable
|
<Contenteditable
|
||||||
:model-value="template.name"
|
:model-value="template.name"
|
||||||
class="text-3xl font-semibold focus:text-clip"
|
class="text-3xl font-semibold focus:text-clip"
|
||||||
@@ -28,7 +30,8 @@
|
|||||||
<a
|
<a
|
||||||
:href="`/templates/${template.id}/submissions/new`"
|
:href="`/templates/${template.id}/submissions/new`"
|
||||||
data-turbo-frame="modal"
|
data-turbo-frame="modal"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary text-base"
|
||||||
|
@click="maybeShowEmptyTemplateAlert"
|
||||||
>
|
>
|
||||||
<IconUsersPlus
|
<IconUsersPlus
|
||||||
width="20"
|
width="20"
|
||||||
@@ -46,12 +49,12 @@
|
|||||||
>
|
>
|
||||||
<IconInnerShadowTop
|
<IconInnerShadowTop
|
||||||
v-if="isSaving"
|
v-if="isSaving"
|
||||||
width="20"
|
width="22"
|
||||||
class="animate-spin"
|
class="animate-spin"
|
||||||
/>
|
/>
|
||||||
<IconDeviceFloppy
|
<IconDeviceFloppy
|
||||||
v-else
|
v-else
|
||||||
width="20"
|
width="22"
|
||||||
/>
|
/>
|
||||||
<span class="hidden md:inline">
|
<span class="hidden md:inline">
|
||||||
Save
|
Save
|
||||||
@@ -60,10 +63,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="flex md:max-h-[calc(100vh-60px)]">
|
||||||
class="flex"
|
|
||||||
style="max-height: calc(100% - 60px)"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
ref="previews"
|
ref="previews"
|
||||||
:style="{ 'display': isBreakpointLg ? 'none' : 'initial' }"
|
:style="{ 'display': isBreakpointLg ? 'none' : 'initial' }"
|
||||||
@@ -75,6 +75,7 @@
|
|||||||
:with-arrows="template.schema.length > 1"
|
:with-arrows="template.schema.length > 1"
|
||||||
:item="item"
|
:item="item"
|
||||||
:document="sortedDocuments[index]"
|
:document="sortedDocuments[index]"
|
||||||
|
:accept-file-types="acceptFileTypes"
|
||||||
:template="template"
|
:template="template"
|
||||||
:is-direct-upload="isDirectUpload"
|
:is-direct-upload="isDirectUpload"
|
||||||
@scroll-to="scrollIntoDocument(item)"
|
@scroll-to="scrollIntoDocument(item)"
|
||||||
@@ -90,13 +91,14 @@
|
|||||||
>
|
>
|
||||||
<Upload
|
<Upload
|
||||||
v-if="sortedDocuments.length"
|
v-if="sortedDocuments.length"
|
||||||
|
:accept-file-types="acceptFileTypes"
|
||||||
:template-id="template.id"
|
:template-id="template.id"
|
||||||
:is-direct-upload="isDirectUpload"
|
:is-direct-upload="isDirectUpload"
|
||||||
@success="updateFromUpload"
|
@success="updateFromUpload"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5">
|
<div class="w-full overflow-y-hidden md:overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5">
|
||||||
<div
|
<div
|
||||||
ref="documents"
|
ref="documents"
|
||||||
class="pr-3.5 pl-0.5"
|
class="pr-3.5 pl-0.5"
|
||||||
@@ -104,6 +106,7 @@
|
|||||||
<Dropzone
|
<Dropzone
|
||||||
v-if="!sortedDocuments.length"
|
v-if="!sortedDocuments.length"
|
||||||
:template-id="template.id"
|
:template-id="template.id"
|
||||||
|
:accept-file-types="acceptFileTypes"
|
||||||
:is-direct-upload="isDirectUpload"
|
:is-direct-upload="isDirectUpload"
|
||||||
@success="updateFromUpload"
|
@success="updateFromUpload"
|
||||||
/>
|
/>
|
||||||
@@ -150,17 +153,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<span
|
||||||
v-if="sortedDocuments.length"
|
v-if="drawField"
|
||||||
class="sticky md:hidden"
|
class="fixed text-center w-full left-1/2 bottom-0 transform -translate-x-1/2"
|
||||||
style="bottom: 100px"
|
|
||||||
>
|
>
|
||||||
<div class="px-4 py-3 rounded-2xl bg-base-200 flex items-center justify-between ml-4 mr-6">
|
<span
|
||||||
<span class="w-full text-center text-lg">
|
class="rounded bg-base-200 px-4 py-2 rounded-full inline-flex space-x-2 mx-auto items-center mb-4 z-20 md:hidden"
|
||||||
You need a larger screen to use builder tools.
|
>
|
||||||
|
<component
|
||||||
|
:is="fieldIcons[drawField.type]"
|
||||||
|
:width="20"
|
||||||
|
:height="20"
|
||||||
|
class="inline"
|
||||||
|
:stroke-width="1.6"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Draw {{ fieldNames[drawField.type] }} Field
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<a
|
||||||
</div>
|
href="#"
|
||||||
|
class="link block text-center"
|
||||||
|
@click.prevent="drawField = null"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<FieldType
|
||||||
|
v-if="sortedDocuments.length && !drawField"
|
||||||
|
class="dropdown-top dropdown-end fixed bottom-4 right-4 z-10 md:hidden"
|
||||||
|
:model-value="''"
|
||||||
|
@update:model-value="startFieldDraw($event)"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="btn btn-neutral text-white btn-circle btn-lg group"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<IconPlus
|
||||||
|
class="group-focus:hidden"
|
||||||
|
width="28"
|
||||||
|
height="28"
|
||||||
|
/>
|
||||||
|
<IconX
|
||||||
|
class="hidden group-focus:inline"
|
||||||
|
width="28"
|
||||||
|
height="28"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</FieldType>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block"
|
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block"
|
||||||
@@ -212,7 +252,8 @@ import Logo from './logo'
|
|||||||
import Contenteditable from './contenteditable'
|
import Contenteditable from './contenteditable'
|
||||||
import DocumentPreview from './preview'
|
import DocumentPreview from './preview'
|
||||||
import DocumentControls from './controls'
|
import DocumentControls from './controls'
|
||||||
import { IconUsersPlus, IconDeviceFloppy, IconInnerShadowTop } from '@tabler/icons-vue'
|
import FieldType from './field_type'
|
||||||
|
import { IconUsersPlus, IconDeviceFloppy, IconInnerShadowTop, IconPlus, IconX } from '@tabler/icons-vue'
|
||||||
import { v4 } from 'uuid'
|
import { v4 } from 'uuid'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
@@ -222,6 +263,9 @@ export default {
|
|||||||
Upload,
|
Upload,
|
||||||
Document,
|
Document,
|
||||||
Fields,
|
Fields,
|
||||||
|
IconPlus,
|
||||||
|
FieldType,
|
||||||
|
IconX,
|
||||||
Logo,
|
Logo,
|
||||||
Dropzone,
|
Dropzone,
|
||||||
DocumentPreview,
|
DocumentPreview,
|
||||||
@@ -237,6 +281,7 @@ export default {
|
|||||||
save: this.save,
|
save: this.save,
|
||||||
baseFetch: this.baseFetch,
|
baseFetch: this.baseFetch,
|
||||||
backgroundColor: this.backgroundColor,
|
backgroundColor: this.backgroundColor,
|
||||||
|
withPhone: this.withPhone,
|
||||||
selectedAreaRef: computed(() => this.selectedAreaRef)
|
selectedAreaRef: computed(() => this.selectedAreaRef)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -255,12 +300,17 @@ export default {
|
|||||||
required: false,
|
required: false,
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
|
acceptFileTypes: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: 'image/*, application/pdf'
|
||||||
|
},
|
||||||
baseUrl: {
|
baseUrl: {
|
||||||
type: String,
|
type: String,
|
||||||
required: false,
|
required: false,
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
withLogoLink: {
|
withLogo: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
default: true
|
default: true
|
||||||
@@ -270,6 +320,11 @@ export default {
|
|||||||
required: false,
|
required: false,
|
||||||
default: true
|
default: true
|
||||||
},
|
},
|
||||||
|
withPhone: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -287,6 +342,8 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
fieldIcons: FieldType.computed.fieldIcons,
|
||||||
|
fieldNames: FieldType.computed.fieldNames,
|
||||||
selectedAreaRef: () => ref(),
|
selectedAreaRef: () => ref(),
|
||||||
fieldAreasIndex () {
|
fieldAreasIndex () {
|
||||||
const areas = {}
|
const areas = {}
|
||||||
@@ -316,16 +373,21 @@ export default {
|
|||||||
this.selectedSubmitter = this.template.submitters[0]
|
this.selectedSubmitter = this.template.submitters[0]
|
||||||
},
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
|
this.undoStack = [JSON.stringify(this.template)]
|
||||||
|
this.redoStack = []
|
||||||
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.onWindowResize()
|
this.onWindowResize()
|
||||||
})
|
})
|
||||||
|
|
||||||
document.addEventListener('keyup', this.onKeyUp)
|
document.addEventListener('keyup', this.onKeyUp)
|
||||||
|
window.addEventListener('keydown', this.onKeyDown)
|
||||||
|
|
||||||
window.addEventListener('resize', this.onWindowResize)
|
window.addEventListener('resize', this.onWindowResize)
|
||||||
},
|
},
|
||||||
unmounted () {
|
unmounted () {
|
||||||
document.removeEventListener('keyup', this.onKeyUp)
|
document.removeEventListener('keyup', this.onKeyUp)
|
||||||
|
window.removeEventListener('keydown', this.onKeyDown)
|
||||||
|
|
||||||
window.removeEventListener('resize', this.onWindowResize)
|
window.removeEventListener('resize', this.onWindowResize)
|
||||||
},
|
},
|
||||||
@@ -333,10 +395,56 @@ export default {
|
|||||||
this.documentRefs = []
|
this.documentRefs = []
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
startFieldDraw (type) {
|
||||||
|
const field = {
|
||||||
|
name: '',
|
||||||
|
uuid: v4(),
|
||||||
|
required: type !== 'checkbox',
|
||||||
|
areas: [],
|
||||||
|
submitter_uuid: this.selectedSubmitter.uuid,
|
||||||
|
type
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['select', 'multiple', 'radio'].includes(type)) {
|
||||||
|
field.options = ['']
|
||||||
|
}
|
||||||
|
|
||||||
|
this.drawField = field
|
||||||
|
},
|
||||||
|
undo () {
|
||||||
|
if (this.undoStack.length > 1) {
|
||||||
|
this.undoStack.pop()
|
||||||
|
const stringData = this.undoStack[this.undoStack.length - 1]
|
||||||
|
const currentStringData = JSON.stringify(this.template)
|
||||||
|
|
||||||
|
if (stringData && stringData !== currentStringData) {
|
||||||
|
this.redoStack.push(currentStringData)
|
||||||
|
|
||||||
|
Object.assign(this.template, JSON.parse(stringData))
|
||||||
|
|
||||||
|
this.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
redo () {
|
||||||
|
const stringData = this.redoStack.pop()
|
||||||
|
this.lastRedoData = stringData
|
||||||
|
const currentStringData = JSON.stringify(this.template)
|
||||||
|
|
||||||
|
if (stringData && stringData !== currentStringData) {
|
||||||
|
if (this.undoStack[this.undoStack.length - 1] !== currentStringData) {
|
||||||
|
this.undoStack.push(currentStringData)
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(this.template, JSON.parse(stringData))
|
||||||
|
|
||||||
|
this.save()
|
||||||
|
}
|
||||||
|
},
|
||||||
onWindowResize (e) {
|
onWindowResize (e) {
|
||||||
const breakpointLg = 1024
|
const breakpointLg = 1024
|
||||||
|
|
||||||
this.isBreakpointLg = this.$el.getRootNode().querySelector('div').offsetWidth < breakpointLg
|
this.isBreakpointLg = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth < breakpointLg
|
||||||
},
|
},
|
||||||
setDocumentRefs (el) {
|
setDocumentRefs (el) {
|
||||||
if (el) {
|
if (el) {
|
||||||
@@ -360,6 +468,19 @@ export default {
|
|||||||
this.selectedAreaRef.value = null
|
this.selectedAreaRef.value = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onKeyDown (event) {
|
||||||
|
if ((event.metaKey && event.shiftKey && event.key === 'z') || (event.ctrlKey && event.key === 'Z')) {
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
this.redo()
|
||||||
|
} else if ((event.ctrlKey || event.metaKey) && event.key === 'z') {
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
this.undo()
|
||||||
|
}
|
||||||
|
},
|
||||||
removeArea (area) {
|
removeArea (area) {
|
||||||
const field = this.template.fields.find((f) => f.areas?.includes(area))
|
const field = this.template.fields.find((f) => f.areas?.includes(area))
|
||||||
|
|
||||||
@@ -371,12 +492,31 @@ export default {
|
|||||||
|
|
||||||
this.save()
|
this.save()
|
||||||
},
|
},
|
||||||
|
pushUndo () {
|
||||||
|
const stringData = JSON.stringify(this.template)
|
||||||
|
|
||||||
|
if (this.undoStack[this.undoStack.length - 1] !== stringData) {
|
||||||
|
this.undoStack.push(stringData)
|
||||||
|
|
||||||
|
if (this.lastRedoData !== stringData) {
|
||||||
|
this.redoStack = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onDraw (area) {
|
onDraw (area) {
|
||||||
if (this.drawField) {
|
if (this.drawField) {
|
||||||
this.drawField.areas ||= []
|
this.drawField.areas ||= []
|
||||||
this.drawField.areas.push(area)
|
this.drawField.areas.push(area)
|
||||||
|
|
||||||
|
if (this.template.fields.indexOf(this.drawField) === -1) {
|
||||||
|
this.template.fields.push(this.drawField)
|
||||||
|
}
|
||||||
|
|
||||||
this.drawField = null
|
this.drawField = null
|
||||||
|
|
||||||
|
this.selectedAreaRef.value = area
|
||||||
|
|
||||||
|
this.save()
|
||||||
} else {
|
} else {
|
||||||
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
|
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
|
||||||
const pageMask = documentRef.pageRefs[area.page].$refs.mask
|
const pageMask = documentRef.pageRefs[area.page].$refs.mask
|
||||||
@@ -387,33 +527,37 @@ export default {
|
|||||||
const previousField = [...this.template.fields].reverse().find((f) => f.type === type)
|
const previousField = [...this.template.fields].reverse().find((f) => f.type === type)
|
||||||
const previousArea = previousField?.areas?.[previousField.areas.length - 1]
|
const previousArea = previousField?.areas?.[previousField.areas.length - 1]
|
||||||
|
|
||||||
const areaW = previousArea?.w || (30 / pageMask.clientWidth)
|
if (previousArea || area.w) {
|
||||||
const areaH = previousArea?.h || (30 / pageMask.clientHeight)
|
const areaW = previousArea?.w || (30 / pageMask.clientWidth)
|
||||||
|
const areaH = previousArea?.h || (30 / pageMask.clientHeight)
|
||||||
|
|
||||||
if ((pageMask.clientWidth * area.w) < 5) {
|
if ((pageMask.clientWidth * area.w) < 5) {
|
||||||
area.x = area.x - (areaW / 2)
|
area.x = area.x - (areaW / 2)
|
||||||
area.y = area.y - (areaH / 2)
|
area.y = area.y - (areaH / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
area.w = areaW
|
||||||
|
area.h = areaH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (area.w) {
|
||||||
|
const field = {
|
||||||
|
name: '',
|
||||||
|
uuid: v4(),
|
||||||
|
required: type !== 'checkbox',
|
||||||
|
type,
|
||||||
|
submitter_uuid: this.selectedSubmitter.uuid,
|
||||||
|
areas: [area]
|
||||||
}
|
}
|
||||||
|
|
||||||
area.w = areaW
|
this.template.fields.push(field)
|
||||||
area.h = areaH
|
|
||||||
}
|
|
||||||
|
|
||||||
const field = {
|
this.selectedAreaRef.value = area
|
||||||
name: '',
|
|
||||||
uuid: v4(),
|
|
||||||
required: type !== 'checkbox',
|
|
||||||
type,
|
|
||||||
submitter_uuid: this.selectedSubmitter.uuid,
|
|
||||||
areas: [area]
|
|
||||||
}
|
|
||||||
|
|
||||||
this.template.fields.push(field)
|
this.save()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.selectedAreaRef.value = area
|
|
||||||
|
|
||||||
this.save()
|
|
||||||
},
|
},
|
||||||
onDropfield (area) {
|
onDropfield (area) {
|
||||||
const field = {
|
const field = {
|
||||||
@@ -459,6 +603,11 @@ export default {
|
|||||||
w: area.maskW / 5 / area.maskW,
|
w: area.maskW / 5 / area.maskW,
|
||||||
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) / 2
|
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) / 2
|
||||||
}
|
}
|
||||||
|
} else if (this.dragFieldType === 'initials') {
|
||||||
|
baseArea = {
|
||||||
|
w: area.maskW / 10 / area.maskW,
|
||||||
|
h: area.maskW / 35 / area.maskW
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
baseArea = {
|
baseArea = {
|
||||||
w: area.maskW / 5 / area.maskW,
|
w: area.maskW / 5 / area.maskW,
|
||||||
@@ -543,14 +692,25 @@ export default {
|
|||||||
|
|
||||||
this.save()
|
this.save()
|
||||||
},
|
},
|
||||||
onSaveClick () {
|
maybeShowEmptyTemplateAlert (e) {
|
||||||
this.isSaving = true
|
if (!this.template.fields.length) {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
this.save().then(() => {
|
alert('Please draw fields to prepare the document.')
|
||||||
window.Turbo.visit(`/templates/${this.template.id}`)
|
}
|
||||||
}).finally(() => {
|
},
|
||||||
this.isSaving = false
|
onSaveClick () {
|
||||||
})
|
if (this.template.fields.length) {
|
||||||
|
this.isSaving = true
|
||||||
|
|
||||||
|
this.save().then(() => {
|
||||||
|
window.Turbo.visit(`/templates/${this.template.id}`)
|
||||||
|
}).finally(() => {
|
||||||
|
this.isSaving = false
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
alert('Please draw fields to prepare the document.')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
scrollToArea (area) {
|
scrollToArea (area) {
|
||||||
const documentRef = this.documentRefs.find((a) => a.document.uuid === area.attachment_uuid)
|
const documentRef = this.documentRefs.find((a) => a.document.uuid === area.attachment_uuid)
|
||||||
@@ -570,6 +730,8 @@ export default {
|
|||||||
this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template)
|
this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.pushUndo()
|
||||||
|
|
||||||
return this.baseFetch(`/api/templates/${this.template.id}`, {
|
return this.baseFetch(`/api/templates/${this.template.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -24,13 +24,13 @@
|
|||||||
*
|
*
|
||||||
</span>
|
</span>
|
||||||
<IconPencil
|
<IconPencil
|
||||||
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden"
|
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden"
|
||||||
:style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
|
:style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
|
||||||
title="Edit"
|
title="Edit"
|
||||||
:class="{ 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
|
:class="{ 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
|
||||||
:width="iconWidth"
|
:width="iconWidth"
|
||||||
:stroke-width="iconStrokeWidth"
|
:stroke-width="iconStrokeWidth"
|
||||||
@click="focusContenteditable"
|
@click="[focusContenteditable(), selectOnEditClick && selectContent()]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -64,6 +64,11 @@ export default {
|
|||||||
required: false,
|
required: false,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
|
selectOnEditClick: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
iconStrokeWidth: {
|
iconStrokeWidth: {
|
||||||
type: Number,
|
type: Number,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -85,6 +90,19 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
selectContent () {
|
||||||
|
const el = this.$refs.contenteditable
|
||||||
|
|
||||||
|
const range = document.createRange()
|
||||||
|
|
||||||
|
range.selectNodeContents(el)
|
||||||
|
|
||||||
|
const sel = window.getSelection()
|
||||||
|
|
||||||
|
sel.removeAllRanges()
|
||||||
|
|
||||||
|
sel.addRange(range)
|
||||||
|
},
|
||||||
onBlur (e) {
|
onBlur (e) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue
|
this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue
|
||||||
|
|||||||
@@ -56,8 +56,26 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
numberOfPages () {
|
||||||
|
return this.document.metadata?.pdf?.number_of_pages || this.document.preview_images.length
|
||||||
|
},
|
||||||
sortedPreviewImages () {
|
sortedPreviewImages () {
|
||||||
return [...this.document.preview_images].sort((a, b) => parseInt(a.filename) - parseInt(b.filename))
|
const lazyloadMetadata = this.document.preview_images[this.document.preview_images.length - 1].metadata
|
||||||
|
|
||||||
|
return [...Array(this.numberOfPages).keys()].map((i) => {
|
||||||
|
return this.previewImagesIndex[i] || {
|
||||||
|
metadata: lazyloadMetadata,
|
||||||
|
id: Math.random().toString(),
|
||||||
|
url: `/preview/${this.document.uuid}/${i}.jpg`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
previewImagesIndex () {
|
||||||
|
return this.document.preview_images.reduce((acc, e) => {
|
||||||
|
acc[parseInt(e.filename)] = e
|
||||||
|
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
beforeUpdate () {
|
beforeUpdate () {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
ref="input"
|
ref="input"
|
||||||
type="file"
|
type="file"
|
||||||
name="files[]"
|
name="files[]"
|
||||||
accept="image/*, application/pdf"
|
:accept="acceptFileTypes"
|
||||||
multiple
|
multiple
|
||||||
@change="upload"
|
@change="upload"
|
||||||
>
|
>
|
||||||
@@ -67,6 +67,11 @@ export default {
|
|||||||
type: [Number, String],
|
type: [Number, String],
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
|
acceptFileTypes: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: 'image/*, application/pdf'
|
||||||
|
},
|
||||||
isDirectUpload: {
|
isDirectUpload: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -89,8 +94,10 @@ export default {
|
|||||||
return 'Uploading...'
|
return 'Uploading...'
|
||||||
} else if (this.isProcessing) {
|
} else if (this.isProcessing) {
|
||||||
return 'Processing...'
|
return 'Processing...'
|
||||||
} else {
|
} else if (this.acceptFileTypes === 'image/*, application/pdf') {
|
||||||
return 'Add PDF documents or images'
|
return 'Add PDF documents or images'
|
||||||
|
} else {
|
||||||
|
return 'Add documents or images'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -102,7 +109,7 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
upload: Upload.methods.upload,
|
upload: Upload.methods.upload,
|
||||||
onDropFiles (e) {
|
onDropFiles (e) {
|
||||||
if ([...e.dataTransfer.files].every((f) => f.type.match(/(?:image\/)|(?:application\/pdf)/))) {
|
if (this.acceptFileTypes !== 'image/*, application/pdf' || [...e.dataTransfer.files].every((f) => f.type.match(/(?:image\/)|(?:application\/pdf)/))) {
|
||||||
this.$refs.input.files = e.dataTransfer.files
|
this.$refs.input.files = e.dataTransfer.files
|
||||||
|
|
||||||
this.upload()
|
this.upload()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div
|
<div
|
||||||
class="border border-base-300 rounded rounded-tr-none relative group"
|
class="border border-base-300 rounded rounded-tr-none relative group"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between relative">
|
<div class="flex items-center justify-between relative group/contenteditable-container">
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer"
|
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer"
|
||||||
@click="scrollToFirstArea"
|
@click="scrollToFirstArea"
|
||||||
@@ -31,21 +31,19 @@
|
|||||||
v-if="isNameFocus"
|
v-if="isNameFocus"
|
||||||
class="flex items-center relative"
|
class="flex items-center relative"
|
||||||
>
|
>
|
||||||
<template v-if="field.type !== 'checkbox'">
|
<input
|
||||||
<input
|
:id="`required-checkbox-${field.uuid}`"
|
||||||
:id="`required-checkbox-${field.uuid}`"
|
v-model="field.required"
|
||||||
v-model="field.required"
|
type="checkbox"
|
||||||
type="checkbox"
|
class="checkbox checkbox-xs no-animation rounded"
|
||||||
class="checkbox checkbox-xs no-animation rounded"
|
@mousedown.prevent
|
||||||
@mousedown.prevent
|
>
|
||||||
>
|
<label
|
||||||
<label
|
:for="`required-checkbox-${field.uuid}`"
|
||||||
:for="`required-checkbox-${field.uuid}`"
|
class="label text-xs"
|
||||||
class="label text-xs"
|
@click.prevent="field.required = !field.required"
|
||||||
@click.prevent="field.required = !field.required"
|
@mousedown.prevent
|
||||||
@mousedown.prevent
|
>Required</label>
|
||||||
>Required</label>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
@@ -99,6 +97,19 @@
|
|||||||
Draw New Area
|
Draw New Area
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li v-if="field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells'].includes(field.type)">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="text-sm py-1 px-2"
|
||||||
|
@click.prevent="copyToAllPages(field)"
|
||||||
|
>
|
||||||
|
<IconCopy
|
||||||
|
:width="20"
|
||||||
|
:stroke-width="1.6"
|
||||||
|
/>
|
||||||
|
Copy to All Pages
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -183,7 +194,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import Contenteditable from './contenteditable'
|
import Contenteditable from './contenteditable'
|
||||||
import FieldType from './field_type'
|
import FieldType from './field_type'
|
||||||
import { IconShape, IconNewSection, IconTrashX } from '@tabler/icons-vue'
|
import { IconShape, IconNewSection, IconTrashX, IconCopy } from '@tabler/icons-vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'TemplateField',
|
name: 'TemplateField',
|
||||||
@@ -192,6 +203,7 @@ export default {
|
|||||||
IconShape,
|
IconShape,
|
||||||
IconNewSection,
|
IconNewSection,
|
||||||
IconTrashX,
|
IconTrashX,
|
||||||
|
IconCopy,
|
||||||
FieldType
|
FieldType
|
||||||
},
|
},
|
||||||
inject: ['template', 'save'],
|
inject: ['template', 'save'],
|
||||||
@@ -221,6 +233,23 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
copyToAllPages (field) {
|
||||||
|
const areaString = JSON.stringify(field.areas[0])
|
||||||
|
|
||||||
|
this.template.documents.forEach((attachment) => {
|
||||||
|
attachment.preview_images.forEach((page) => {
|
||||||
|
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === parseInt(page.filename))) {
|
||||||
|
field.areas.push({ ...JSON.parse(areaString), page: parseInt(page.filename) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
this.save()
|
||||||
|
},
|
||||||
onNameFocus (e) {
|
onNameFocus (e) {
|
||||||
this.isNameFocus = true
|
this.isNameFocus = true
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<label
|
<label
|
||||||
v-else
|
v-else
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="cursor-pointer rounded-md p-2 border border-base-300 w-full flex justify-between"
|
class="cursor-pointer group/contenteditable-container rounded-md p-2 border border-base-300 w-full flex justify-between"
|
||||||
>
|
>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<span
|
<span
|
||||||
@@ -25,12 +25,16 @@
|
|||||||
v-model="selectedSubmitter.name"
|
v-model="selectedSubmitter.name"
|
||||||
class="cursor-text"
|
class="cursor-text"
|
||||||
:icon-inline="true"
|
:icon-inline="true"
|
||||||
|
:select-on-edit-click="true"
|
||||||
:icon-width="18"
|
:icon-width="18"
|
||||||
@update:model-value="$emit('name-change', selectedSubmitter)"
|
@update:model-value="$emit('name-change', selectedSubmitter)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span>
|
<span class="flex items-center">
|
||||||
▼
|
<IconPlus
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<ul
|
<ul
|
||||||
@@ -66,7 +70,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="submitters.length < 5">
|
<li v-if="submitters.length < 10">
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
class="flex px-2"
|
class="flex px-2"
|
||||||
@@ -86,7 +90,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { IconUserPlus, IconTrashX } from '@tabler/icons-vue'
|
import { IconUserPlus, IconTrashX, IconPlus } from '@tabler/icons-vue'
|
||||||
import Contenteditable from './contenteditable'
|
import Contenteditable from './contenteditable'
|
||||||
import { v4 } from 'uuid'
|
import { v4 } from 'uuid'
|
||||||
|
|
||||||
@@ -95,6 +99,7 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
IconUserPlus,
|
IconUserPlus,
|
||||||
Contenteditable,
|
Contenteditable,
|
||||||
|
IconPlus,
|
||||||
IconTrashX
|
IconTrashX
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
@@ -125,7 +130,12 @@ export default {
|
|||||||
'bg-sky-500',
|
'bg-sky-500',
|
||||||
'bg-emerald-500',
|
'bg-emerald-500',
|
||||||
'bg-yellow-300',
|
'bg-yellow-300',
|
||||||
'bg-purple-600'
|
'bg-purple-600',
|
||||||
|
'bg-pink-500',
|
||||||
|
'bg-cyan-500',
|
||||||
|
'bg-orange-500',
|
||||||
|
'bg-lime-500',
|
||||||
|
'bg-indigo-500'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
names () {
|
names () {
|
||||||
@@ -134,7 +144,12 @@ export default {
|
|||||||
'Second Submitter',
|
'Second Submitter',
|
||||||
'Third Submitter',
|
'Third Submitter',
|
||||||
'Fourth Submitter',
|
'Fourth Submitter',
|
||||||
'Fifth Submitter'
|
'Fifth Submitter',
|
||||||
|
'Sixth Submitter',
|
||||||
|
'Seventh Submitter',
|
||||||
|
'Eighth Submitter',
|
||||||
|
'Ninth Submitter',
|
||||||
|
'Tenth Submitter'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
selectedSubmitter () {
|
selectedSubmitter () {
|
||||||
|
|||||||
@@ -1,50 +1,55 @@
|
|||||||
<template>
|
<template>
|
||||||
<span class="dropdown">
|
<span class="dropdown">
|
||||||
<label
|
<slot>
|
||||||
tabindex="0"
|
<label
|
||||||
:title="fieldNames[modelValue]"
|
tabindex="0"
|
||||||
class="cursor-pointer"
|
:title="fieldNames[modelValue]"
|
||||||
>
|
class="cursor-pointer"
|
||||||
<component
|
>
|
||||||
:is="fieldIcons[modelValue]"
|
<component
|
||||||
:width="buttonWidth"
|
:is="fieldIcons[modelValue]"
|
||||||
:class="buttonClasses"
|
:width="buttonWidth"
|
||||||
:stroke-width="1.6"
|
:class="buttonClasses"
|
||||||
/>
|
:stroke-width="1.6"
|
||||||
</label>
|
/>
|
||||||
|
</label>
|
||||||
|
</slot>
|
||||||
<ul
|
<ul
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10"
|
class="dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10 mb-3"
|
||||||
:class="menuClasses"
|
:class="menuClasses"
|
||||||
@click="closeDropdown"
|
@click="closeDropdown"
|
||||||
>
|
>
|
||||||
<li
|
<template
|
||||||
v-for="(icon, type) in fieldIcons"
|
v-for="(icon, type) in fieldIcons"
|
||||||
:key="type"
|
:key="type"
|
||||||
>
|
>
|
||||||
<a
|
<li v-if="withPhone || type !== 'phone'">
|
||||||
href="#"
|
<a
|
||||||
class="text-sm py-1 px-2"
|
href="#"
|
||||||
:class="{ 'active': type === modelValue }"
|
class="text-sm py-1 px-2"
|
||||||
@click.prevent="$emit('update:model-value', type)"
|
:class="{ 'active': type === modelValue }"
|
||||||
>
|
@click.prevent="$emit('update:model-value', type)"
|
||||||
<component
|
>
|
||||||
:is="icon"
|
<component
|
||||||
:stroke-width="1.6"
|
:is="icon"
|
||||||
:width="20"
|
:stroke-width="1.6"
|
||||||
/>
|
:width="20"
|
||||||
{{ fieldNames[type] }}
|
/>
|
||||||
</a>
|
{{ fieldNames[type] }}
|
||||||
</li>
|
</a>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3 } from '@tabler/icons-vue'
|
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper } from '@tabler/icons-vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'FiledTypeDropdown',
|
name: 'FiledTypeDropdown',
|
||||||
|
inject: ['withPhone'],
|
||||||
props: {
|
props: {
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -72,6 +77,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
text: 'Text',
|
text: 'Text',
|
||||||
signature: 'Signature',
|
signature: 'Signature',
|
||||||
|
initials: 'Initials',
|
||||||
date: 'Date',
|
date: 'Date',
|
||||||
image: 'Image',
|
image: 'Image',
|
||||||
file: 'File',
|
file: 'File',
|
||||||
@@ -79,21 +85,24 @@ export default {
|
|||||||
checkbox: 'Checkbox',
|
checkbox: 'Checkbox',
|
||||||
multiple: 'Multiple',
|
multiple: 'Multiple',
|
||||||
radio: 'Radio',
|
radio: 'Radio',
|
||||||
cells: 'Cells'
|
cells: 'Cells',
|
||||||
|
phone: 'Phone'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fieldIcons () {
|
fieldIcons () {
|
||||||
return {
|
return {
|
||||||
text: IconTextSize,
|
text: IconTextSize,
|
||||||
signature: IconWritingSign,
|
signature: IconWritingSign,
|
||||||
|
initials: IconLetterCaseUpper,
|
||||||
date: IconCalendarEvent,
|
date: IconCalendarEvent,
|
||||||
image: IconPhoto,
|
image: IconPhoto,
|
||||||
file: IconPaperclip,
|
file: IconPaperclip,
|
||||||
select: IconSelect,
|
select: IconSelect,
|
||||||
checkbox: IconCheckbox,
|
checkbox: IconCheckbox,
|
||||||
cells: IconColumns3,
|
radio: IconCircleDot,
|
||||||
multiple: IconChecks,
|
multiple: IconChecks,
|
||||||
radio: IconCircleDot
|
cells: IconColumns3,
|
||||||
|
phone: IconPhoneCheck
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,49 +25,79 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-3 gap-1 pb-2">
|
<div class="grid grid-cols-3 gap-1 pb-2">
|
||||||
<button
|
<template
|
||||||
v-for="(icon, type) in fieldIcons"
|
v-for="(icon, type) in fieldIcons"
|
||||||
:key="type"
|
:key="type"
|
||||||
draggable="true"
|
|
||||||
class="flex items-center justify-center border border-dashed border-base-300 w-full rounded relative"
|
|
||||||
:style="{ backgroundColor: backgroundColor }"
|
|
||||||
@dragstart="onDragstart(type)"
|
|
||||||
@dragend="$emit('drag-end')"
|
|
||||||
@click="addField(type)"
|
|
||||||
>
|
>
|
||||||
<div class="w-0 absolute left-0">
|
<button
|
||||||
<svg
|
v-if="withPhone || type != 'phone'"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
draggable="true"
|
||||||
class="cursor-grab"
|
class="flex items-center justify-center border border-dashed border-base-300 w-full rounded relative"
|
||||||
width="18"
|
:style="{ backgroundColor: backgroundColor }"
|
||||||
height="18"
|
@dragstart="onDragstart(type)"
|
||||||
viewBox="0 0 24 24"
|
@dragend="$emit('drag-end')"
|
||||||
stroke-width="1.5"
|
@click="addField(type)"
|
||||||
stroke="currentColor"
|
>
|
||||||
fill="none"
|
<div class="w-0 absolute left-0">
|
||||||
stroke-linecap="round"
|
<svg
|
||||||
stroke-linejoin="round"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
class="cursor-grab"
|
||||||
<path
|
width="18"
|
||||||
stroke="none"
|
height="18"
|
||||||
d="M0 0h24v24H0z"
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
fill="none"
|
fill="none"
|
||||||
/>
|
stroke-linecap="round"
|
||||||
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
stroke-linejoin="round"
|
||||||
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
>
|
||||||
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
<path
|
||||||
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
stroke="none"
|
||||||
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
d="M0 0h24v24H0z"
|
||||||
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
fill="none"
|
||||||
</svg>
|
/>
|
||||||
|
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||||
|
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||||
|
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||||
|
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||||
|
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||||
|
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center flex-col px-2 py-2">
|
||||||
|
<component :is="icon" />
|
||||||
|
<span class="text-xs mt-1">
|
||||||
|
{{ fieldNames[type] }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="tooltip tooltip-bottom-end flex"
|
||||||
|
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="https://www.docuseal.co/pricing"
|
||||||
|
target="_blank"
|
||||||
|
class="opacity-50 flex items-center justify-center border border-dashed border-base-300 w-full rounded relative"
|
||||||
|
:style="{ backgroundColor: backgroundColor }"
|
||||||
|
>
|
||||||
|
<div class="w-0 absolute left-0">
|
||||||
|
<IconLock
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center flex-col px-2 py-2">
|
||||||
|
<component :is="icon" />
|
||||||
|
<span class="text-xs mt-1">
|
||||||
|
{{ fieldNames[type] }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center flex-col px-2 py-2">
|
</template>
|
||||||
<component :is="icon" />
|
|
||||||
<span class="text-xs mt-1">
|
|
||||||
{{ fieldNames[type] }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="fields.length < 4"
|
v-if="fields.length < 4"
|
||||||
@@ -77,9 +107,6 @@
|
|||||||
<li>
|
<li>
|
||||||
Draw a text field on the page with a mouse
|
Draw a text field on the page with a mouse
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
Single click on the page to add a checkbox
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
Drag & drop any other field type on the page
|
Drag & drop any other field type on the page
|
||||||
</li>
|
</li>
|
||||||
@@ -95,14 +122,16 @@ import Field from './field'
|
|||||||
import { v4 } from 'uuid'
|
import { v4 } from 'uuid'
|
||||||
import FieldType from './field_type'
|
import FieldType from './field_type'
|
||||||
import FieldSubmitter from './field_submitter'
|
import FieldSubmitter from './field_submitter'
|
||||||
|
import { IconLock } from '@tabler/icons-vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'TemplateFields',
|
name: 'TemplateFields',
|
||||||
components: {
|
components: {
|
||||||
Field,
|
Field,
|
||||||
FieldSubmitter
|
FieldSubmitter,
|
||||||
|
IconLock
|
||||||
},
|
},
|
||||||
inject: ['save', 'backgroundColor'],
|
inject: ['save', 'backgroundColor', 'withPhone'],
|
||||||
props: {
|
props: {
|
||||||
fields: {
|
fields: {
|
||||||
type: Array,
|
type: Array,
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative cursor-crosshair select-none">
|
<div
|
||||||
|
class="relative cursor-crosshair select-none"
|
||||||
|
:style="drawField ? 'touch-action: none' : ''"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
ref="image"
|
ref="image"
|
||||||
|
loading="lazy"
|
||||||
:src="image.url"
|
:src="image.url"
|
||||||
:width="width"
|
:width="width"
|
||||||
class="border rounded mb-4"
|
|
||||||
:height="height"
|
:height="height"
|
||||||
loading="lazy"
|
class="border rounded mb-4"
|
||||||
|
@load="onImageLoad"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="top-0 bottom-0 left-0 right-0 absolute"
|
class="top-0 bottom-0 left-0 right-0 absolute"
|
||||||
@@ -32,12 +36,13 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-show="resizeDirection || isMove || isDrag || showMask"
|
v-show="resizeDirection || isMove || isDrag || showMask || (drawField && isMobile)"
|
||||||
id="mask"
|
id="mask"
|
||||||
ref="mask"
|
ref="mask"
|
||||||
class="top-0 bottom-0 left-0 right-0 absolute z-10"
|
class="top-0 bottom-0 left-0 right-0 absolute z-10"
|
||||||
:class="{ 'cursor-grab': isDrag || isMove, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
|
:class="{ 'cursor-grab': isDrag || isMove, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
|
||||||
@pointermove="onPointermove"
|
@pointermove="onPointermove"
|
||||||
|
@pointerdown="onStartDraw"
|
||||||
@dragover.prevent
|
@dragover.prevent
|
||||||
@drop="onDrop"
|
@drop="onDrop"
|
||||||
@pointerup="onPointerup"
|
@pointerup="onPointerup"
|
||||||
@@ -93,6 +98,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
isMobile () {
|
||||||
|
return /android|iphone|ipad/i.test(navigator.userAgent)
|
||||||
|
},
|
||||||
resizeDirectionClasses () {
|
resizeDirectionClasses () {
|
||||||
return {
|
return {
|
||||||
nwse: 'cursor-nwse-resize',
|
nwse: 'cursor-nwse-resize',
|
||||||
@@ -110,6 +118,10 @@ export default {
|
|||||||
this.areaRefs = []
|
this.areaRefs = []
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
onImageLoad (e) {
|
||||||
|
e.target.setAttribute('width', e.target.naturalWidth)
|
||||||
|
e.target.setAttribute('height', e.target.naturalHeight)
|
||||||
|
},
|
||||||
setAreaRefs (el) {
|
setAreaRefs (el) {
|
||||||
if (el) {
|
if (el) {
|
||||||
this.areaRefs.push(el)
|
this.areaRefs.push(el)
|
||||||
@@ -125,6 +137,10 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
onStartDraw (e) {
|
onStartDraw (e) {
|
||||||
|
if (this.isMobile && !this.drawField) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.showMask = true
|
this.showMask = true
|
||||||
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<ReplaceButton
|
<ReplaceButton
|
||||||
:is-direct-upload="isDirectUpload"
|
:is-direct-upload="isDirectUpload"
|
||||||
:template-id="template.id"
|
:template-id="template.id"
|
||||||
|
:accept-file-types="acceptFileTypes"
|
||||||
class="opacity-0 group-hover:opacity-100"
|
class="opacity-0 group-hover:opacity-100"
|
||||||
@click.stop
|
@click.stop
|
||||||
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
|
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
|
||||||
@@ -94,6 +95,11 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
|
acceptFileTypes: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: 'image/*, application/pdf'
|
||||||
|
},
|
||||||
isDirectUpload: {
|
isDirectUpload: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -108,7 +114,7 @@ export default {
|
|||||||
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace'],
|
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace'],
|
||||||
computed: {
|
computed: {
|
||||||
previewImage () {
|
previewImage () {
|
||||||
return this.document.preview_images[0]
|
return [...this.document.preview_images].sort((a, b) => parseInt(a.filename) - parseInt(b.filename))[0]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
ref="input"
|
ref="input"
|
||||||
name="files[]"
|
name="files[]"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*, application/pdf"
|
:accept="acceptFileTypes"
|
||||||
@change="upload"
|
@change="upload"
|
||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
@@ -32,6 +32,11 @@ export default {
|
|||||||
type: [Number, String],
|
type: [Number, String],
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
|
acceptFileTypes: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: 'image/*, application/pdf'
|
||||||
|
},
|
||||||
isDirectUpload: {
|
isDirectUpload: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: true,
|
required: true,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
ref="input"
|
ref="input"
|
||||||
name="files[]"
|
name="files[]"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*, application/pdf"
|
:accept="acceptFileTypes"
|
||||||
multiple
|
multiple
|
||||||
@change="upload"
|
@change="upload"
|
||||||
>
|
>
|
||||||
@@ -56,6 +56,11 @@ export default {
|
|||||||
type: [Number, String],
|
type: [Number, String],
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
|
acceptFileTypes: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: 'image/*, application/pdf'
|
||||||
|
},
|
||||||
isDirectUpload: {
|
isDirectUpload: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: true,
|
required: true,
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class GenerateSubmitterResultAttachmentsJob < ApplicationJob
|
|
||||||
def perform(submitter)
|
|
||||||
Submissions::EnsureResultGenerated.call(submitter)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ProcessSubmitterCompletionJob < ApplicationJob
|
||||||
|
def perform(submitter)
|
||||||
|
is_all_completed = !submitter.submission.submitters.exists?(completed_at: nil)
|
||||||
|
|
||||||
|
if !is_all_completed && submitter.submission.submitters_order_preserved?
|
||||||
|
enqueue_next_submitter_request_notification(submitter)
|
||||||
|
end
|
||||||
|
|
||||||
|
Submissions::EnsureResultGenerated.call(submitter)
|
||||||
|
|
||||||
|
if submitter.account.encrypted_configs.exists?(key: EncryptedConfig::WEBHOOK_URL_KEY)
|
||||||
|
SendFormCompletedWebhookRequestJob.perform_later(submitter)
|
||||||
|
end
|
||||||
|
|
||||||
|
return unless is_all_completed
|
||||||
|
return if submitter.completed_at != submitter.submission.submitters.maximum(:completed_at)
|
||||||
|
|
||||||
|
Submissions::GenerateAuditTrail.call(submitter.submission)
|
||||||
|
|
||||||
|
enqueue_emails(submitter)
|
||||||
|
end
|
||||||
|
|
||||||
|
def enqueue_emails(submitter)
|
||||||
|
user = submitter.submission.created_by_user || submitter.template.author
|
||||||
|
|
||||||
|
if submitter.template.account.users.exists?(id: user.id)
|
||||||
|
bcc = submitter.submission.template.account.account_configs
|
||||||
|
.find_by(key: AccountConfig::BCC_EMAILS)&.value
|
||||||
|
|
||||||
|
SubmitterMailer.completed_email(submitter, user, bcc:).deliver_later!
|
||||||
|
end
|
||||||
|
|
||||||
|
to = submitter.submission.submitters.sort_by(&:completed_at).select(&:email?).map(&:friendly_name).join(', ')
|
||||||
|
|
||||||
|
SubmitterMailer.documents_copy_email(submitter, to:).deliver_later! if to.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def enqueue_next_submitter_request_notification(submitter)
|
||||||
|
next_submitter_item =
|
||||||
|
submitter.submission.template_submitters.find do |e|
|
||||||
|
sub = submitter.submission.submitters.find { |s| s.uuid == e['uuid'] }
|
||||||
|
|
||||||
|
sub.completed_at.blank? && sub.sent_at.blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
return unless next_submitter_item
|
||||||
|
|
||||||
|
next_submitter = submitter.submission.submitters.find { |s| s.uuid == next_submitter_item['uuid'] }
|
||||||
|
|
||||||
|
Submitters.send_signature_requests([next_submitter], send_email: true)
|
||||||
|
end
|
||||||
|
end
|
||||||
+4
-4
@@ -1,10 +1,10 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class SendWebhookRequestJob < ApplicationJob
|
class SendFormCompletedWebhookRequestJob < ApplicationJob
|
||||||
USER_AGENT = 'DocuSeal.co Webhook'
|
USER_AGENT = 'DocuSeal.co Webhook'
|
||||||
|
|
||||||
def perform(submitter)
|
def perform(submitter)
|
||||||
config = submitter.submission.account.encrypted_configs.find_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
|
config = Accounts.load_webhook_configs(submitter.submission.account)
|
||||||
|
|
||||||
return if config.blank? || config.value.blank?
|
return if config.blank? || config.value.blank?
|
||||||
|
|
||||||
@@ -14,8 +14,8 @@ class SendWebhookRequestJob < ApplicationJob
|
|||||||
|
|
||||||
Faraday.post(config.value,
|
Faraday.post(config.value,
|
||||||
{
|
{
|
||||||
event_type: 'form.submitted',
|
event_type: 'form.completed',
|
||||||
timestamp: Time.current.iso8601,
|
timestamp: Time.current,
|
||||||
data: Submitters::SerializeForWebhook.call(submitter)
|
data: Submitters::SerializeForWebhook.call(submitter)
|
||||||
}.to_json,
|
}.to_json,
|
||||||
'Content-Type' => 'application/json',
|
'Content-Type' => 'application/json',
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class SendFormStartedWebhookRequestJob < ApplicationJob
|
||||||
|
USER_AGENT = 'DocuSeal.co Webhook'
|
||||||
|
|
||||||
|
def perform(submitter)
|
||||||
|
config = Accounts.load_webhook_configs(submitter.submission.account)
|
||||||
|
|
||||||
|
return if config.blank? || config.value.blank?
|
||||||
|
|
||||||
|
ActiveStorage::Current.url_options = Docuseal.default_url_options
|
||||||
|
|
||||||
|
Faraday.post(config.value,
|
||||||
|
{
|
||||||
|
event_type: 'form.started',
|
||||||
|
timestamp: Time.current,
|
||||||
|
data: Submitters::SerializeForWebhook.call(submitter)
|
||||||
|
}.to_json,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'User-Agent' => USER_AGENT)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class SendFormViewedWebhookRequestJob < ApplicationJob
|
||||||
|
USER_AGENT = 'DocuSeal.co Webhook'
|
||||||
|
|
||||||
|
def perform(submitter)
|
||||||
|
config = Accounts.load_webhook_configs(submitter.submission.account)
|
||||||
|
|
||||||
|
return if config.blank? || config.value.blank?
|
||||||
|
|
||||||
|
ActiveStorage::Current.url_options = Docuseal.default_url_options
|
||||||
|
|
||||||
|
Faraday.post(config.value,
|
||||||
|
{
|
||||||
|
event_type: 'form.viewed',
|
||||||
|
timestamp: Time.current,
|
||||||
|
data: Submitters::SerializeForWebhook.call(submitter)
|
||||||
|
}.to_json,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'User-Agent' => USER_AGENT)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class SendSubmitterInvitationEmailJob < ApplicationJob
|
||||||
|
def perform(params = {})
|
||||||
|
submitter = Submitter.find(params['submitter_id'])
|
||||||
|
|
||||||
|
SubmitterMailer.invitation_email(submitter, subject: params['subject'], body: params['body']).deliver_now!
|
||||||
|
|
||||||
|
SubmissionEvent.create!(submitter:, event_type: 'send_email')
|
||||||
|
|
||||||
|
submitter.sent_at ||= Time.current
|
||||||
|
submitter.save
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,58 +1,62 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class SubmitterMailer < ApplicationMailer
|
class SubmitterMailer < ApplicationMailer
|
||||||
DEFAULT_MESSAGE = %(You have been invited to submit the "%<name>s" form:)
|
MAX_ATTACHMENTS_SIZE = 10.megabytes
|
||||||
|
|
||||||
def invitation_email(submitter, message: '')
|
def invitation_email(submitter, body: nil, subject: nil)
|
||||||
@current_account = submitter.submission.template.account
|
@current_account = submitter.submission.template.account
|
||||||
@submitter = submitter
|
@submitter = submitter
|
||||||
@message = message.presence || format(DEFAULT_MESSAGE, name: submitter.submission.template.name)
|
@body = body.presence
|
||||||
|
|
||||||
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY)
|
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY)
|
||||||
|
|
||||||
subject =
|
subject =
|
||||||
if @email_config
|
if @email_config || subject.present?
|
||||||
ReplaceEmailVariables.call(@email_config.value['subject'], submitter:)
|
ReplaceEmailVariables.call(subject.presence || @email_config.value['subject'], submitter:)
|
||||||
else
|
else
|
||||||
'You have been invited to submit a form'
|
'You are invited to submit a form'
|
||||||
end
|
end
|
||||||
|
|
||||||
mail(to: @submitter.email,
|
mail(to: @submitter.friendly_name,
|
||||||
from: from_address_for_submitter(submitter),
|
from: from_address_for_submitter(submitter),
|
||||||
subject:,
|
subject:,
|
||||||
reply_to: submitter.submission.created_by_user&.friendly_name)
|
reply_to: (submitter.submission.created_by_user || submitter.template.author)&.friendly_name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def completed_email(submitter, user)
|
def completed_email(submitter, user, bcc: nil)
|
||||||
@current_account = submitter.submission.template.account
|
@current_account = submitter.submission.template.account
|
||||||
@submitter = submitter
|
@submitter = submitter
|
||||||
|
@submission = submitter.submission
|
||||||
@user = user
|
@user = user
|
||||||
|
|
||||||
|
Submissions::EnsureResultGenerated.call(submitter)
|
||||||
|
|
||||||
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
|
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
|
||||||
|
|
||||||
|
add_completed_email_attachments!(submitter)
|
||||||
|
|
||||||
subject =
|
subject =
|
||||||
if @email_config
|
if @email_config
|
||||||
ReplaceEmailVariables.call(@email_config.value['subject'], submitter:)
|
ReplaceEmailVariables.call(@email_config.value['subject'], submitter:)
|
||||||
else
|
else
|
||||||
%(#{submitter.email} has completed the "#{submitter.submission.template.name}" form)
|
submitters = submitter.submission.submitters.order(:completed_at)
|
||||||
|
.map { |e| e.name || e.email || e.phone }.join(', ')
|
||||||
|
%(#{submitter.submission.template.name} has been completed by #{submitters})
|
||||||
end
|
end
|
||||||
|
|
||||||
mail(from: from_address_for_submitter(submitter),
|
mail(from: from_address_for_submitter(submitter),
|
||||||
to: user.email,
|
to: user.friendly_name,
|
||||||
|
bcc:,
|
||||||
subject:)
|
subject:)
|
||||||
end
|
end
|
||||||
|
|
||||||
def documents_copy_email(submitter)
|
def documents_copy_email(submitter, to: nil)
|
||||||
@current_account = submitter.submission.template.account
|
@current_account = submitter.submission.template.account
|
||||||
@submitter = submitter
|
@submitter = submitter
|
||||||
|
|
||||||
Submissions::EnsureResultGenerated.call(@submitter)
|
Submissions::EnsureResultGenerated.call(@submitter)
|
||||||
|
|
||||||
@documents = Submitters.select_attachments_for_download(submitter)
|
@documents = add_completed_email_attachments!(submitter)
|
||||||
|
|
||||||
@documents.each do |attachment|
|
|
||||||
attachments[attachment.filename.to_s] = attachment.download
|
|
||||||
end
|
|
||||||
|
|
||||||
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
|
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
|
||||||
|
|
||||||
@@ -60,16 +64,41 @@ class SubmitterMailer < ApplicationMailer
|
|||||||
if @email_config
|
if @email_config
|
||||||
ReplaceEmailVariables.call(@email_config.value['subject'], submitter:)
|
ReplaceEmailVariables.call(@email_config.value['subject'], submitter:)
|
||||||
else
|
else
|
||||||
'Your copy of documents'
|
'Your document copy'
|
||||||
end
|
end
|
||||||
|
|
||||||
mail(from: from_address_for_submitter(submitter),
|
mail(from: from_address_for_submitter(submitter),
|
||||||
to: submitter.email,
|
to: to || @submitter.friendly_name,
|
||||||
subject:)
|
subject:)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def add_completed_email_attachments!(submitter)
|
||||||
|
documents = Submitters.select_attachments_for_download(submitter)
|
||||||
|
|
||||||
|
total_size = 0
|
||||||
|
audit_trail_data = nil
|
||||||
|
|
||||||
|
if submitter.submission.audit_trail.present?
|
||||||
|
audit_trail_data = submitter.submission.audit_trail.download
|
||||||
|
|
||||||
|
total_size = audit_trail_data.size
|
||||||
|
end
|
||||||
|
|
||||||
|
documents.each do |attachment|
|
||||||
|
total_size += attachment.byte_size
|
||||||
|
|
||||||
|
break if total_size >= MAX_ATTACHMENTS_SIZE
|
||||||
|
|
||||||
|
attachments[attachment.filename.to_s] = attachment.download
|
||||||
|
end
|
||||||
|
|
||||||
|
attachments[submitter.submission.audit_trail.filename.to_s] = audit_trail_data if audit_trail_data
|
||||||
|
|
||||||
|
documents
|
||||||
|
end
|
||||||
|
|
||||||
def from_address_for_submitter(submitter)
|
def from_address_for_submitter(submitter)
|
||||||
submitter.submission.created_by_user&.friendly_name || submitter.submission.template.author.friendly_name
|
submitter.submission.created_by_user&.friendly_name || submitter.submission.template.author.friendly_name
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class UserMailer < ApplicationMailer
|
class UserMailer < ApplicationMailer
|
||||||
def invitation_email(user)
|
def invitation_email(user, invited_by: nil)
|
||||||
@current_account = user.account
|
@current_account = invited_by&.account || user.account
|
||||||
@user = user
|
@user = user
|
||||||
@token = @user.send(:set_reset_password_token)
|
@token = @user.send(:set_reset_password_token)
|
||||||
|
|
||||||
mail(to: @user.friendly_name,
|
mail(to: @user.friendly_name,
|
||||||
subject: 'You have been invited to Docuseal')
|
subject: 'You are invited to DocuSeal')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -16,6 +16,9 @@ class Account < ApplicationRecord
|
|||||||
has_many :encrypted_configs, dependent: :destroy
|
has_many :encrypted_configs, dependent: :destroy
|
||||||
has_many :account_configs, dependent: :destroy
|
has_many :account_configs, dependent: :destroy
|
||||||
has_many :templates, dependent: :destroy
|
has_many :templates, dependent: :destroy
|
||||||
|
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, through: :templates
|
||||||
has_many :submitters, through: :submissions
|
has_many :submitters, through: :submissions
|
||||||
has_many :active_users, -> { active }, dependent: :destroy,
|
has_many :active_users, -> { active }, dependent: :destroy,
|
||||||
@@ -23,4 +26,9 @@ class Account < ApplicationRecord
|
|||||||
|
|
||||||
attribute :timezone, :string, default: 'UTC'
|
attribute :timezone, :string, default: 'UTC'
|
||||||
attribute :locale, :string, default: 'en-US'
|
attribute :locale, :string, default: 'en-US'
|
||||||
|
|
||||||
|
def default_template_folder
|
||||||
|
super || build_default_template_folder(name: TemplateFolder::DEFAULT_NAME,
|
||||||
|
author_id: users.minimum(:id)).tap(&:save!)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -24,30 +24,31 @@ class AccountConfig < ApplicationRecord
|
|||||||
SUBMITTER_INVITATION_EMAIL_KEY = 'submitter_invitation_email'
|
SUBMITTER_INVITATION_EMAIL_KEY = 'submitter_invitation_email'
|
||||||
SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email'
|
SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email'
|
||||||
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
|
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
|
||||||
|
BCC_EMAILS = 'bcc_emails'
|
||||||
|
SUBMITTER_REMAILERS = 'submitter_reminders'
|
||||||
|
FORM_COMPLETED_BUTTON_KEY = 'form_completed_button'
|
||||||
|
|
||||||
DEFAULT_VALUES = {
|
DEFAULT_VALUES = {
|
||||||
SUBMITTER_INVITATION_EMAIL_KEY => {
|
SUBMITTER_INVITATION_EMAIL_KEY => {
|
||||||
'subject' => 'You have been invited to submit a form',
|
'subject' => 'You are invited to submit a form',
|
||||||
'body' => "Hi there,\n\n" \
|
'body' => "Hi there,\n\n" \
|
||||||
"You have been invited to submit the \"{{template.name}}\" form:\n\n" \
|
"You have been invited to submit the \"{{template.name}}\" form.\n\n" \
|
||||||
"{{submitter.link}}\n\n" \
|
"{{submitter.link}}\n\n" \
|
||||||
"Please contact us by replying to this email if you didn't request this.\n\n" \
|
"Please contact us by replying to this email if you didn't request this.\n\n" \
|
||||||
"Thanks,\n" \
|
"Thanks,\n" \
|
||||||
'{{account.name}}'
|
'{{account.name}}'
|
||||||
},
|
},
|
||||||
SUBMITTER_COMPLETED_EMAIL_KEY => {
|
SUBMITTER_COMPLETED_EMAIL_KEY => {
|
||||||
'subject' => '{{submitter.email}} has completed the "{{template.name}}" form',
|
'subject' => '{{template.name}} has been completed by {{submission.submitters}}',
|
||||||
'body' => "Hi,\n\n" \
|
'body' => "Hi,\n\n" \
|
||||||
"{{submitter.email}} has completed the \"{{template.name}}\" form:\n\n" \
|
"\"{{template.name}}\" form has been completed by {{submission.submitters}}\n\n" \
|
||||||
"{{submission.link}}\n\n" \
|
'{{submission.link}}'
|
||||||
"Thanks,\n" \
|
|
||||||
'{{account.name}}'
|
|
||||||
},
|
},
|
||||||
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => {
|
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => {
|
||||||
'subject' => 'Your copy of documents',
|
'subject' => 'Your document copy',
|
||||||
'body' => "Hi there,\n\n" \
|
'body' => "Hi there,\n\n" \
|
||||||
"Please check the copy of your \"{{template.name}}\" submission in the email attachments.\n" \
|
"Please check the copy of your \"{{template.name}}\" submission in the email attachments.\n" \
|
||||||
"Alternatively, you can download the copy using:\n\n" \
|
"Alternatively, you can download your copy using:\n\n" \
|
||||||
"{{documents.links}}\n\n" \
|
"{{documents.links}}\n\n" \
|
||||||
"Thanks,\n" \
|
"Thanks,\n" \
|
||||||
'{{account.name}}'
|
'{{account.name}}'
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: encrypted_user_configs
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# key :string not null
|
||||||
|
# value :text not null
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
# user_id :bigint not null
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_encrypted_user_configs_on_user_id (user_id)
|
||||||
|
# index_encrypted_user_configs_on_user_id_and_key (user_id,key) UNIQUE
|
||||||
|
#
|
||||||
|
# Foreign Keys
|
||||||
|
#
|
||||||
|
# fk_rails_... (user_id => users.id)
|
||||||
|
#
|
||||||
|
class EncryptedUserConfig < ApplicationRecord
|
||||||
|
belongs_to :user
|
||||||
|
|
||||||
|
encrypts :value
|
||||||
|
|
||||||
|
serialize :value, JSON
|
||||||
|
end
|
||||||
Loaded 100 of 265 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user