Compare commits

..
1 Commits
Author SHA1 Message Date
Alex Turchyn 6fbc81fae2 increase tokens length 2023-07-08 21:05:29 +03:00
324 changed files with 1561 additions and 9855 deletions

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
spec:
name: docuseal
services:
- name: docuseal
dockerfile_path: Dockerfile
git:
repo_clone_url: https://github.com/docusealco/docuseal.git
branch: master
envs:
- key: SECRET_KEY_BASE
value: "- 64 characters string 64 characters string 64 characters string -"
type: SECRET
scope: RUN_TIME
- key: DATABASE_URL
scope: RUN_TIME
value: ${docuseal-db.DATABASE_URL}
databases:
- name: docuseal-db
-2
View File
@@ -1,6 +1,5 @@
/db/*.sqlite3 /db/*.sqlite3
**/node_modules
/node_modules /node_modules
/coverage /coverage
/doc /doc
@@ -28,4 +27,3 @@
/attachments /attachments
/docuseal /docuseal
.DS_Store .DS_Store
.env
-2
View File
@@ -30,5 +30,3 @@ linters:
Enabled: false Enabled: false
Style/ZeroLengthPredicate: Style/ZeroLengthPredicate:
Enabled: false Enabled: false
Style/RedundantConstantBase:
Enabled: false
-2
View File
@@ -127,7 +127,6 @@ jobs:
bundle config path vendor/bundle bundle config path vendor/bundle
bundle install --jobs 4 --retry 4 bundle install --jobs 4 --retry 4
yarn install yarn install
sudo apt-get update
sudo apt-get install libvips sudo apt-get install libvips
- name: Run - name: Run
env: env:
@@ -138,5 +137,4 @@ 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
-1
View File
@@ -35,4 +35,3 @@ yarn-debug.log*
/coverage /coverage
/attachments /attachments
/docuseal /docuseal
/ee
+3 -12
View File
@@ -26,11 +26,8 @@ Style/Documentation:
Lint/MissingSuper: Lint/MissingSuper:
Enabled: false Enabled: false
Metrics/ParameterLists:
Max: 10
Metrics/MethodLength: Metrics/MethodLength:
Max: 25 Max: 20
Exclude: Exclude:
- 'db/migrate/**' - 'db/migrate/**'
@@ -56,19 +53,13 @@ RSpec/NestedGroups:
Max: 6 Max: 6
RSpec/MultipleExpectations: RSpec/MultipleExpectations:
Max: 20 Max: 7
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 40 Max: 15
RSpec/MultipleMemoizedHelpers:
Max: 6
Rails/I18nLocaleTexts: Rails/I18nLocaleTexts:
Enabled: false Enabled: false
Rails/SkipsModelValidations:
Enabled: false
Rails/ApplicationController: Rails/ApplicationController:
Enabled: false Enabled: false
+2 -10
View File
@@ -31,21 +31,13 @@ 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 /usr/share/fonts/liberation/LiberationSans-Bold.ttf / && apk del ttf-liberation RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler vips-heif libc6-compat ttf-freefont
COPY ./Gemfile ./Gemfile.lock ./ COPY ./Gemfile ./Gemfile.lock ./
RUN bundle update --bundler && bundle install && rm -rf ~/.bundle RUN bundle update --bundler && bundle install && rm -rf ~/.bundle
COPY ./bin ./bin COPY . ./
COPY ./app ./app
COPY ./config ./config
COPY ./db ./db
COPY ./log ./log
COPY ./lib ./lib
COPY ./public ./public
COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru ./
COPY --from=webpack /app/public/packs ./public/packs COPY --from=webpack /app/public/packs ./public/packs
-13
View File
@@ -7,37 +7,24 @@ 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'
gem 'jwt'
gem 'lograge' gem 'lograge'
gem 'mysql2', require: false gem 'mysql2', require: false
gem 'oj' gem 'oj'
gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection'
gem 'pagy' gem 'pagy'
gem 'pdf-reader'
gem 'pg', require: false gem 'pg', require: false
gem 'premailer-rails' gem 'premailer-rails'
gem 'puma' gem 'puma'
gem 'rails' gem 'rails'
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 'shakapacker' gem 'shakapacker'
gem 'sidekiq', require: ENV.key?('REDIS_URL')
gem 'sqlite3', require: false gem 'sqlite3', require: false
gem 'strip_attributes' gem 'strip_attributes'
gem 'turbo-rails' gem 'turbo-rails'
+99 -191
View File
@@ -1,94 +1,92 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
Ascii85 (1.1.0) actioncable (7.0.5)
actioncable (7.0.7) actionpack (= 7.0.5)
actionpack (= 7.0.7) activesupport (= 7.0.5)
activesupport (= 7.0.7)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailbox (7.0.7) actionmailbox (7.0.5)
actionpack (= 7.0.7) actionpack (= 7.0.5)
activejob (= 7.0.7) activejob (= 7.0.5)
activerecord (= 7.0.7) activerecord (= 7.0.5)
activestorage (= 7.0.7) activestorage (= 7.0.5)
activesupport (= 7.0.7) activesupport (= 7.0.5)
mail (>= 2.7.1) mail (>= 2.7.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
actionmailer (7.0.7) actionmailer (7.0.5)
actionpack (= 7.0.7) actionpack (= 7.0.5)
actionview (= 7.0.7) actionview (= 7.0.5)
activejob (= 7.0.7) activejob (= 7.0.5)
activesupport (= 7.0.7) activesupport (= 7.0.5)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (7.0.7) actionpack (7.0.5)
actionview (= 7.0.7) actionview (= 7.0.5)
activesupport (= 7.0.7) activesupport (= 7.0.5)
rack (~> 2.0, >= 2.2.4) rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.7) actiontext (7.0.5)
actionpack (= 7.0.7) actionpack (= 7.0.5)
activerecord (= 7.0.7) activerecord (= 7.0.5)
activestorage (= 7.0.7) activestorage (= 7.0.5)
activesupport (= 7.0.7) activesupport (= 7.0.5)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.0.7) actionview (7.0.5)
activesupport (= 7.0.7) activesupport (= 7.0.5)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0)
activejob (7.0.7) activejob (7.0.5)
activesupport (= 7.0.7) activesupport (= 7.0.5)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.0.7) activemodel (7.0.5)
activesupport (= 7.0.7) activesupport (= 7.0.5)
activerecord (7.0.7) activerecord (7.0.5)
activemodel (= 7.0.7) activemodel (= 7.0.5)
activesupport (= 7.0.7) activesupport (= 7.0.5)
activestorage (7.0.7) activestorage (7.0.5)
actionpack (= 7.0.7) actionpack (= 7.0.5)
activejob (= 7.0.7) activejob (= 7.0.5)
activerecord (= 7.0.7) activerecord (= 7.0.5)
activesupport (= 7.0.7) activesupport (= 7.0.5)
marcel (~> 1.0) marcel (~> 1.0)
mini_mime (>= 1.1.0) mini_mime (>= 1.1.0)
activesupport (7.0.7) activesupport (7.0.5)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
minitest (>= 5.1) minitest (>= 5.1)
tzinfo (~> 2.0) tzinfo (~> 2.0)
addressable (2.8.5) addressable (2.8.4)
public_suffix (>= 2.0.2, < 6.0) public_suffix (>= 2.0.2, < 6.0)
afm (0.2.2)
annotate (3.2.0) annotate (3.2.0)
activerecord (>= 3.2, < 8.0) activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0) rake (>= 10.4, < 14.0)
ast (2.4.2) ast (2.4.2)
aws-eventstream (1.2.0) aws-eventstream (1.2.0)
aws-partitions (1.807.0) aws-partitions (1.781.0)
aws-sdk-core (3.180.3) aws-sdk-core (3.175.0)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0) aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.71.0) aws-sdk-kms (1.67.0)
aws-sdk-core (~> 3, >= 3.177.0) aws-sdk-core (~> 3, >= 3.174.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.132.1) aws-sdk-s3 (1.126.0)
aws-sdk-core (~> 3, >= 3.179.0) aws-sdk-core (~> 3, >= 3.174.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.6) aws-sigv4 (~> 1.4)
aws-sigv4 (1.6.0) aws-sigv4 (1.5.2)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.3) azure-storage-blob (2.0.3)
azure-storage-common (~> 2.0) azure-storage-common (~> 2.0)
@@ -98,9 +96,8 @@ 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.1)
actionview (>= 6.0) actionview (>= 6.0)
activesupport (>= 6.0) activesupport (>= 6.0)
ast (~> 2.0) ast (~> 2.0)
@@ -108,13 +105,12 @@ GEM
parser (>= 2.4) parser (>= 2.4)
smart_properties smart_properties
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.16.0) bootsnap (1.15.0)
msgpack (~> 1.2) msgpack (~> 1.2)
builder (3.2.4) builder (3.2.4)
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
@@ -124,7 +120,6 @@ 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)
@@ -148,17 +143,11 @@ 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.4)
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)
@@ -172,7 +161,7 @@ GEM
factory_bot_rails (6.2.0) factory_bot_rails (6.2.0)
factory_bot (~> 6.2.0) factory_bot (~> 6.2.0)
railties (>= 5.0.0) railties (>= 5.0.0)
faker (3.2.1) faker (3.2.0)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (1.10.3) faraday (1.10.3)
faraday-em_http (~> 1.0) faraday-em_http (~> 1.0)
@@ -189,8 +178,6 @@ 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)
@@ -207,10 +194,10 @@ GEM
webrick (~> 1.7) webrick (~> 1.7)
websocket-driver (>= 0.6, < 0.8) websocket-driver (>= 0.6, < 0.8)
ffi (1.15.5) ffi (1.15.5)
geom2d (0.4.1) geom2d (0.3.1)
globalid (1.1.0) globalid (1.1.0)
activesupport (>= 5.0) activesupport (>= 5.0)
google-apis-core (0.11.1) google-apis-core (0.11.0)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a) googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a) httpclient (>= 2.8.1, < 3.a)
@@ -237,7 +224,7 @@ GEM
google-cloud-core (~> 1.6) google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a) googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0) mini_mime (~> 1.0)
googleauth (1.7.0) googleauth (1.6.0)
faraday (>= 0.17.3, < 3.a) faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0) jwt (>= 1.4, < 3.0)
memoist (~> 0.16) memoist (~> 0.16)
@@ -245,11 +232,9 @@ GEM
os (>= 0.9, < 2.0) os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a) signet (>= 0.16, < 2.a)
hashdiff (1.0.1) hashdiff (1.0.1)
hashery (2.1.2) hexapdf (0.32.2)
hashie (5.0.0)
hexapdf (0.33.0)
cmdparse (~> 3.0, >= 3.0.3) cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1) geom2d (~> 0.3)
openssl (>= 2.2.1) openssl (>= 2.2.1)
htmlentities (4.3.4) htmlentities (4.3.4)
httpclient (2.8.3) httpclient (2.8.3)
@@ -259,8 +244,8 @@ GEM
mini_magick (>= 4.9.5, < 5) mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3) ruby-vips (>= 2.0.17, < 3)
io-console (0.6.0) io-console (0.6.0)
irb (1.7.4) irb (1.7.0)
reline (>= 0.3.6) reline (>= 0.3.0)
jmespath (1.6.2) jmespath (1.6.2)
json (2.6.3) json (2.6.3)
jwt (2.7.1) jwt (2.7.1)
@@ -274,7 +259,7 @@ GEM
letter_opener (~> 1.7) letter_opener (~> 1.7)
railties (>= 5.2) railties (>= 5.2)
rexml rexml
lograge (0.13.0) lograge (0.12.0)
actionpack (>= 4) actionpack (>= 4)
activesupport (>= 4) activesupport (>= 4)
railties (>= 4) railties (>= 4)
@@ -292,17 +277,16 @@ GEM
memoist (0.16.2) memoist (0.16.2)
method_source (1.0.0) method_source (1.0.0)
mini_magick (4.12.0) mini_magick (4.12.0)
mini_mime (1.1.5) mini_mime (1.1.2)
mini_portile2 (2.8.4) mini_portile2 (2.8.2)
minitest (5.19.0) minitest (5.18.1)
msgpack (1.7.2) msgpack (1.7.1)
multi_json (1.15.0) multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.3.0) multipart-post (2.3.0)
mysql2 (0.5.5) mysql2 (0.5.5)
net-http-persistent (4.0.2) net-http-persistent (4.0.2)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-imap (0.3.7) net-imap (0.3.6)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -312,34 +296,9 @@ GEM
net-smtp (0.3.3) net-smtp (0.3.3)
net-protocol net-protocol
nio4r (2.5.9) nio4r (2.5.9)
nokogiri (1.15.4) nokogiri (1.15.2-arm64-darwin)
mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.15.4-arm64-darwin) oj (3.15.0)
racc (~> 1.4)
oauth2 (2.0.9)
faraday (>= 0.17.3, < 3.0)
jwt (>= 1.0, < 3.0)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
oj (3.16.0)
omniauth (2.1.1)
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-google-oauth2 (1.1.1)
jwt (>= 2.0)
oauth2 (~> 2.0.6)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8.0)
omniauth-oauth2 (1.8.0)
oauth2 (>= 1.4, < 3)
omniauth (~> 2.0)
omniauth-rails_csrf_protection (1.0.1)
actionpack (>= 4.2)
omniauth (~> 2.0)
openssl (3.1.0) openssl (3.1.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
@@ -348,12 +307,6 @@ GEM
parser (3.2.2.3) parser (3.2.2.3)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pdf-reader (2.11.0)
Ascii85 (~> 1.0)
afm (~> 0.2.1)
hashery (~> 2.0)
ruby-rc4
ttfunk
pg (1.5.3) pg (1.5.3)
premailer (1.21.0) premailer (1.21.0)
addressable addressable
@@ -368,58 +321,49 @@ GEM
method_source (~> 1.0) method_source (~> 1.0)
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (5.0.3) public_suffix (5.0.1)
puma (6.3.1) puma (6.3.0)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.7.1) racc (1.7.1)
rack (2.2.8) rack (2.2.7)
rack-protection (3.1.0)
rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.6) rack-proxy (0.7.6)
rack rack
rack-test (2.1.0) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rails (7.0.7) rails (7.0.5)
actioncable (= 7.0.7) actioncable (= 7.0.5)
actionmailbox (= 7.0.7) actionmailbox (= 7.0.5)
actionmailer (= 7.0.7) actionmailer (= 7.0.5)
actionpack (= 7.0.7) actionpack (= 7.0.5)
actiontext (= 7.0.7) actiontext (= 7.0.5)
actionview (= 7.0.7) actionview (= 7.0.5)
activejob (= 7.0.7) activejob (= 7.0.5)
activemodel (= 7.0.7) activemodel (= 7.0.5)
activerecord (= 7.0.7) activerecord (= 7.0.5)
activestorage (= 7.0.7) activestorage (= 7.0.5)
activesupport (= 7.0.7) activesupport (= 7.0.5)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.0.7) railties (= 7.0.5)
rails-dom-testing (2.2.0) rails-dom-testing (2.0.3)
activesupport (>= 5.0.0) activesupport (>= 4.2.0)
minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0) rails-html-sanitizer (1.6.0)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (~> 1.14) nokogiri (~> 1.14)
rails-i18n (7.0.8) rails-i18n (7.0.7)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8) railties (>= 6.0.0, < 8)
rails_autolink (1.1.8) railties (7.0.5)
actionview (> 3.1) actionpack (= 7.0.5)
activesupport (> 3.1) activesupport (= 7.0.5)
railties (> 3.1)
railties (7.0.7)
actionpack (= 7.0.7)
activesupport (= 7.0.7)
method_source method_source
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0) thor (~> 1.0)
zeitwerk (~> 2.5) zeitwerk (~> 2.5)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.0.6) rake (13.0.6)
redis-client (0.16.0)
connection_pool
regexp_parser (2.8.1) regexp_parser (2.8.1)
reline (0.3.8) reline (0.3.5)
io-console (~> 0.5) io-console (~> 0.5)
representable (3.2.0) representable (3.2.0)
declarative (< 0.1.0) declarative (< 0.1.0)
@@ -431,19 +375,14 @@ GEM
actionpack (>= 5.2) actionpack (>= 5.2)
railties (>= 5.2) railties (>= 5.2)
retriable (3.1.2) retriable (3.1.2)
rexml (3.2.6) rexml (3.2.5)
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)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.12.0)
rspec-mocks (3.12.6) rspec-mocks (3.12.5)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.12.0)
rspec-rails (6.0.3) rspec-rails (6.0.3)
@@ -454,9 +393,8 @@ GEM
rspec-expectations (~> 3.12) rspec-expectations (~> 3.12)
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.0)
rubocop (1.56.1) rubocop (1.53.0)
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)
@@ -464,7 +402,7 @@ GEM
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.0, < 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.29.0)
@@ -473,37 +411,27 @@ GEM
rubocop (~> 1.41) rubocop (~> 1.41)
rubocop-factory_bot (2.23.1) rubocop-factory_bot (2.23.1)
rubocop (~> 1.33) rubocop (~> 1.33)
rubocop-performance (1.19.0) rubocop-performance (1.18.0)
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.20.2)
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.22.0)
rubocop (~> 1.33) rubocop (~> 1.33)
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)
ruby-rc4 (0.1.5)
ruby-vips (2.1.4) ruby-vips (2.1.4)
ffi (~> 1.12) ffi (~> 1.12)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
rubyXL (3.4.25)
nokogiri (>= 1.10.8)
rubyzip (>= 1.3.0)
rubyzip (2.3.2)
semantic_range (3.0.0) semantic_range (3.0.0)
shakapacker (7.0.3) shakapacker (7.0.0)
activesupport (>= 5.2) activesupport (>= 5.2)
rack-proxy (>= 0.6.1) rack-proxy (>= 0.6.1)
railties (>= 5.2) railties (>= 5.2)
semantic_range (>= 2.3.0) semantic_range (>= 2.3.0)
sidekiq (7.1.2)
concurrent-ruby (< 2)
connection_pool (>= 2.3.0)
rack (>= 2.2.4)
redis-client (>= 0.14.0)
signet (0.17.0) signet (0.17.0)
addressable (~> 2.8) addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a) faraday (>= 0.17.5, < 3.a)
@@ -516,18 +444,13 @@ GEM
simplecov-html (0.12.3) simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
smart_properties (1.17.0) smart_properties (1.17.0)
snaky_hash (2.0.1) sqlite3 (1.5.4)
hashie
version_gem (~> 1.1, >= 1.1.1)
sqlite3 (1.6.3)
mini_portile2 (~> 2.8.0) mini_portile2 (~> 2.8.0)
sqlite3 (1.6.3-arm64-darwin)
strip_attributes (1.13.0) strip_attributes (1.13.0)
activemodel (>= 3.0, < 8.0) activemodel (>= 3.0, < 8.0)
thor (1.2.2) thor (1.2.2)
timeout (0.4.0) timeout (0.4.0)
trailblazer-option (0.1.2) trailblazer-option (0.1.2)
ttfunk (1.7.0)
turbo-rails (1.4.0) turbo-rails (1.4.0)
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activejob (>= 6.0.0) activejob (>= 6.0.0)
@@ -539,7 +462,6 @@ GEM
uber (0.1.0) uber (0.1.0)
unicode-display_width (2.4.2) unicode-display_width (2.4.2)
uniform_notifier (1.16.0) uniform_notifier (1.16.0)
version_gem (1.1.3)
warden (1.2.9) warden (1.2.9)
rack (>= 2.0.9) rack (>= 2.0.9)
web-console (4.2.0) web-console (4.2.0)
@@ -552,16 +474,15 @@ GEM
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.8.1) webrick (1.8.1)
websocket-driver (0.7.6) websocket-driver (0.7.5)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.6.11) zeitwerk (2.6.8)
PLATFORMS PLATFORMS
arm64-darwin-22 arm64-darwin-22
ruby
DEPENDENCIES DEPENDENCIES
annotate annotate
@@ -570,50 +491,37 @@ 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
jwt
letter_opener_web letter_opener_web
lograge lograge
mysql2 mysql2
oj oj
omniauth-google-oauth2
omniauth-rails_csrf_protection
pagy pagy
pdf-reader
pg pg
premailer-rails premailer-rails
pry-rails pry-rails
puma puma
rails rails
rails-i18n rails-i18n
rails_autolink
rollbar rollbar
rotp
rqrcode
rspec-rails rspec-rails
rubocop rubocop
rubocop-performance rubocop-performance
rubocop-rails rubocop-rails
rubocop-rspec rubocop-rspec
ruby-vips ruby-vips
rubyXL
shakapacker shakapacker
sidekiq
simplecov simplecov
sqlite3 sqlite3
strip_attributes strip_attributes
+6 -21
View File
@@ -14,7 +14,7 @@
<a href="https://hub.docker.com/r/docuseal/docuseal"> <a href="https://hub.docker.com/r/docuseal/docuseal">
<img alt="Docker releases" src="https://img.shields.io/docker/v/docuseal/docuseal"> <img alt="Docker releases" src="https://img.shields.io/docker/v/docuseal/docuseal">
</a> </a>
<a href="https://discord.gg/qygYCDGck9"> <a href="https://discord.com/invite/B5wg5wKk">
<img src="https://img.shields.io/discord/1125112641170448454?logo=discord"/> <img src="https://img.shields.io/discord/1125112641170448454?logo=discord"/>
</a> </a>
<a href="https://twitter.com/intent/follow?screen_name=docusealco"> <a href="https://twitter.com/intent/follow?screen_name=docusealco">
@@ -26,8 +26,6 @@ DocuSeal is an open source platform that provides secure and efficient digital d
</p> </p>
<h2 align="center"> <h2 align="center">
<a href="https://demo.docuseal.co">✨ Live Demo</a> <a href="https://demo.docuseal.co">✨ Live Demo</a>
<span>|</span>
<a href="https://docuseal.co/sign_up">☁️ Try in Cloud</a>
</h2> </h2>
[![Demo](https://github.com/docusealco/docuseal/assets/5418788/d8703ea3-361a-423f-8bfe-eff1bd9dbe14)](https://demo.docuseal.co) [![Demo](https://github.com/docusealco/docuseal/assets/5418788/d8703ea3-361a-423f-8bfe-eff1bd9dbe14)](https://demo.docuseal.co)
@@ -42,18 +40,13 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- [x] PDF signature verification - [x] PDF signature verification
- [x] Users management - [x] Users management
- [x] Mobile-optimized - [x] Mobile-optimized
- [x] API and Webhooks for integrations
- [x] Easy to deploy in minutes - [x] Easy to deploy in minutes
## Deploy ## Deploy
|Heroku|Railway| | Heroku | Railway | DigitalOcean |
|:--:|:---:| | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| [<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) | [<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/tree/master&refcode=421d50f53990) |
|**DigitalOcean**|**Render**|
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
|**Koyeb**|**Elestio**|
| [<img alt="Deploy on Koyeb" src="https://www.koyeb.com/static/images/deploy/button.svg" height="40">](https://app.koyeb.com/deploy?name=docuseal&type=docker&image=docker.io/docuseal/docuseal&env[PORT]=8000&env[DATABASE_URL]=CHANGE_ME&env[SECRET_KEY_BASE]=CHANGE_ME&ports=8000;http;/) | [<img alt="Deploy on Elestio" src="https://pub-da36157c854648669813f3f76c526c2b.r2.dev/deploy-on-elestio-black.png">](https://dash.elest.io/deploy?soft=DocuSeal&id=339) |
#### Docker #### Docker
@@ -71,19 +64,11 @@ Download docker-compose.yml into your private server:
curl https://raw.githubusercontent.com/docusealco/docuseal/master/docker-compose.yml > docker-compose.yml curl https://raw.githubusercontent.com/docusealco/docuseal/master/docker-compose.yml > docker-compose.yml
``` ```
Run the app under a custom domain over https using docker compose (make sure your DNS points to the server to automatically issue ssl certs with Caddy): Run docker compose under a custom domain over https (make sure your DNS points to the server to automatically issue ssl certs with Caddy):
```sh ```sh
HOST=your-domain-name.com docker-compose up HOST=your-domain-name.com docker-compose up
``` ```
## For Companies
### Integrate seamless document signing into your web or mobile apps with DocuSeal!
At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, Real Estate, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws.
[Book 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. DocuSeal is released under the GNU Affero General Public License v3.0.
Unless otherwise noted, all files © 2023 DocuSeal LLC.
-9
View File
@@ -9,9 +9,6 @@ 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
@@ -38,17 +35,11 @@ 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
def app_url_params def app_url_params
return {} if params[:encrypted_config].blank?
params.require(:encrypted_config).permit(:value) params.require(:encrypted_config).permit(:value)
end end
end end
@@ -3,36 +3,11 @@
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
+14 -2
View File
@@ -3,12 +3,24 @@
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])
attachment = Submitters.create_attachment!(submitter, params) blob =
if (file = params[:file])
ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
else
ActiveStorage::Blob.find_signed(params[:blob_signed_id])
end
attachment = ActiveStorage::Attachment.create!(
blob:,
name: params[:name],
record: submitter
)
render json: attachment.as_json(only: %i[uuid], methods: %i[url filename content_type]) render json: attachment.as_json(only: %i[uuid], methods: %i[url filename content_type])
end end
@@ -1,152 +0,0 @@
# frozen_string_literal: true
module Api
class SubmissionsController < ApiBaseController
load_and_authorize_resource :template, only: :create
load_and_authorize_resource :submission, only: %i[show index]
before_action only: :create do
authorize!(:create, Submission)
end
def index
submissions = Submissions.search(@submissions, params[:q])
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
submissions = paginate(submissions.preload(:created_by_user, :template, :submitters))
render json: {
data: submissions.as_json(serialize_params),
pagination: {
count: submissions.size,
next: submissions.last&.id,
prev: submissions.first&.id
}
}
end
def show
serialized_subbmitters =
@submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob).map do |submitter|
Submissions::EnsureResultGenerated.call(submitter) if submitter.completed_at?
Submitters::SerializeForApi.call(submitter)
end
json = @submission.as_json(
serialize_params.deep_merge(
include: {
submission_events: {
only: %i[id submitter_id event_type event_timestamp]
}
}
)
)
json[:submitters] = serialized_subbmitters
render json:
end
def create
is_send_email = !params[:send_email].in?(['false', false])
submissions =
if (emails = (params[:emails] || params[:email]).presence) && params[:submission].blank?
Submissions.create_from_emails(template: @template,
user: current_user,
source: :api,
mark_as_sent: is_send_email,
emails:)
else
submissions_attrs, attachments = normalize_submissions_params!(submissions_params[:submission], @template)
Submissions.create_from_submitters(
template: @template,
user: current_user,
source: :api,
mark_as_sent: is_send_email,
submitters_order: params[:submitters_order] || 'preserved',
submissions_attrs:
)
end
Submissions.send_signature_requests(submissions, send_email: is_send_email)
submitters = submissions.flat_map(&:submitters)
save_default_value_attachments!(attachments, submitters)
render json: submitters
rescue Submitters::NormalizeValues::UnknownFieldName, Submitters::NormalizeValues::UnknownSubmitterName => e
render json: { error: e.message }, status: :unprocessable_entity
end
def destroy
@submission.update!(deleted_at: Time.current)
render json: @submission.as_json(only: %i[id deleted_at])
end
private
def serialize_params
{
only: %i[id source submitters_order created_at updated_at],
include: {
submitters: { only: %i[id slug uuid name email phone
completed_at opened_at sent_at
created_at updated_at],
methods: %i[status] },
template: { only: %i[id name created_at updated_at] },
created_by_user: { only: %i[id email first_name last_name] }
}
}
end
def submissions_params
params.permit(submission: [{
submitters: [[:uuid, :name, :email, :role, :completed, :phone, { values: {} }]]
}])
end
def normalize_submissions_params!(submissions_params, template)
attachments = []
Array.wrap(submissions_params).each do |submission|
submission[:submitters].each_with_index do |submitter, index|
next if submitter[:values].blank?
values, new_attachments =
Submitters::NormalizeValues.call(template,
submitter[:values],
submitter[:role] || template.submitters[index]['name'])
attachments.push(*new_attachments)
submitter[:values] = values
end
end
[submissions_params, attachments]
end
def save_default_value_attachments!(attachments, submitters)
return if attachments.blank?
attachments_index = attachments.index_by(&:uuid)
submitters.each do |submitter|
submitter.values.to_a.each do |_, value|
attachment = attachments_index[value]
next unless attachment
attachment.record = submitter
attachment.save!
end
end
end
end
end
@@ -1,18 +0,0 @@
# 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
@@ -1,18 +0,0 @@
# 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
@@ -1,35 +0,0 @@
# frozen_string_literal: true
module Api
class SubmittersAutocompleteController < ApiBaseController
load_and_authorize_resource :submitter, parent: false
SELECT_COLUMNS = %w[email phone name].freeze
LIMIT = 100
def index
submitters = search_submitters(@submitters)
values = submitters.limit(LIMIT).group(SELECT_COLUMNS.join(', ')).pluck(SELECT_COLUMNS.join(', '))
attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h }
attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present?
render json: attrs
end
private
def search_submitters(submitters)
if SELECT_COLUMNS.include?(params[:field])
column = Submitter.arel_table[params[:field].to_sym]
term = "%#{params[:q].downcase}%"
submitters.where(column.lower.matches(term))
else
Submitters.search(submitters, params[:q])
end
end
end
end
@@ -1,13 +0,0 @@
# frozen_string_literal: true
module Api
class SubmittersController < ApiBaseController
load_and_authorize_resource :submitter
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
@@ -1,16 +0,0 @@
# frozen_string_literal: true
module Api
class TemplateFoldersAutocompleteController < ApiBaseController
load_and_authorize_resource :template_folder, parent: false
LIMIT = 100
def index
template_folders = @template_folders.joins(:templates).where(templates: { 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 -40
View File
@@ -2,54 +2,16 @@
module Api module Api
class TemplatesController < ApiBaseController class TemplatesController < ApiBaseController
load_and_authorize_resource :template
def index
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
def show
render json: @template.as_json(serialize_params)
end
def update def update
if (folder_name = params.dig(:template, :folder_name)) @template = current_account.templates.find(params[:id])
@template.folder = TemplateFolders.find_or_create_by_name(current_user, folder_name)
end
@template.update!(template_params) @template.update!(template_params)
render json: @template.as_json(only: %i[id updated_at]) render :ok
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]],
@@ -2,12 +2,15 @@
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? @template = current_account.templates.find(params[:template_id])
documents = Templates::CreateAttachments.call(@template, params) documents =
find_or_create_blobs.map do |blob|
document = @template.documents.create!(blob:)
Templates::ProcessDocument.call(document)
end
schema = documents.map do |doc| schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base } { attachment_uuid: doc.uuid, name: doc.filename.base }
@@ -22,5 +25,19 @@ module Api
) )
} }
end end
private
def find_or_create_blobs
blobs = params[:blobs]&.map do |attrs|
ActiveStorage::Blob.find_signed(attrs[:signed_id])
end
blobs || params[:files].map do |file|
ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
end
end
end end
end end
@@ -1,7 +0,0 @@
# frozen_string_literal: true
class ApiSettingsController < ApplicationController
def index
authorize!(:read, current_user.access_token)
end
end
+3 -13
View File
@@ -4,8 +4,6 @@ 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?
@@ -18,14 +16,6 @@ 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
@@ -33,7 +23,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.first) unless signed_in?
end end
def current_account def current_account
@@ -44,8 +34,8 @@ class ApplicationController < ActionController::Base
redirect_to setup_index_path unless User.exists? redirect_to setup_index_path unless User.exists?
end end
def button_title(title: 'Submit', disabled_with: 'Submitting', icon: nil, icon_disabled: nil) def button_title(title: 'Submit', disabled_with: 'Submitting', icon: nil)
render_to_string(partial: 'shared/button_title', locals: { title:, disabled_with:, icon:, icon_disabled: }) render_to_string(partial: 'shared/button_title', locals: { title:, disabled_with:, icon: })
end end
def svg_icon(icon_name, class: '') def svg_icon(icon_name, class: '')
@@ -1,18 +0,0 @@
# frozen_string_literal: true
class ConsoleRedirectController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def index
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if current_user.blank?
auth = JsonWebToken.encode(uuid: current_user.uuid,
scope: :console,
exp: 1.minute.from_now.to_i)
path = Addressable::URI.parse(params[:redir]).path if params[:redir].to_s.starts_with?(Docuseal::CONSOLE_URL)
redirect_to("#{Docuseal::CONSOLE_URL}#{path}?#{{ auth: }.to_query}", allow_other_host: true)
end
end
+3 -60
View File
@@ -3,68 +3,11 @@
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
@template_folders = filter_template_folders(@template_folders) return render 'pages/landing' unless signed_in?
@pagy, @template_folders = pagy( templates = current_account.templates.active.preload(:author).order(id: :desc)
@template_folders,
items: FOLDERS_PER_PAGE,
page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1
)
if @pagy.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD @pagy, @templates = pagy(templates, items: 12)
@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
@@ -1,24 +1,16 @@
# frozen_string_literal: true # frozen_string_literal: true
class EmailSmtpSettingsController < ApplicationController class EmailSettingsController < 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
def create def create
if @encrypted_config.update(email_configs) if @encrypted_config.update(storage_configs)
SettingsMailer.smtp_successful_setup(@encrypted_config.value['from_email']).deliver_now!
redirect_to settings_email_index_path, notice: 'Changes have been saved' redirect_to settings_email_index_path, notice: 'Changes have been saved'
else else
render :index, status: :unprocessable_entity render :index, status: :unprocessable_entity
end end
rescue StandardError => e
flash[:alert] = e.message
render :index, status: :unprocessable_entity
end end
private private
@@ -28,7 +20,7 @@ class EmailSmtpSettingsController < ApplicationController
EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::EMAIL_SMTP_KEY) EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::EMAIL_SMTP_KEY)
end end
def email_configs def storage_configs
params.require(:encrypted_config).permit(value: {}).tap do |e| params.require(:encrypted_config).permit(value: {}).tap do |e|
e[:value].compact_blank! e[:value].compact_blank!
end end
+10 -104
View File
@@ -1,113 +1,19 @@
# frozen_string_literal: true # frozen_string_literal: true
class EsignSettingsController < ApplicationController class EsignSettingsController < ApplicationController
DEFAULT_CERT_NAME = 'DocuSeal Self-Host Autogenerated'
CertFormRecord = Struct.new(:name, :file, :password, keyword_init: true) do
include ActiveModel::Validations
def to_key
[]
end
end
before_action :load_encrypted_config
authorize_resource :encrypted_config, parent: false, only: %i[new create]
authorize_resource :encrypted_config, only: %i[update destroy show]
def show
cert_data = @encrypted_config.value || {}
default_pkcs = GenerateCertificate.load_pkcs(cert_data) if cert_data['cert'].present?
custom_pkcs_list = (cert_data['custom'] || []).map do |e|
{ 'pkcs' => OpenSSL::PKCS12.new(Base64.urlsafe_decode64(e['data']), e['password'].to_s),
'name' => e['name'],
'status' => e['status'] }
end
@pkcs_list = [
if default_pkcs
{
'pkcs' => default_pkcs,
'name' => DEFAULT_CERT_NAME,
'status' => custom_pkcs_list.any? { |e| e['status'] == 'default' } ? 'validate' : 'default'
}
end,
*custom_pkcs_list
].compact.reverse
end
def new
@cert_record = CertFormRecord.new
end
def create def create
@cert_record = CertFormRecord.new(**cert_params) pdfs =
params[:files].map do |file|
HexaPDF::Document.new(io: file.open)
end
if (@encrypted_config.value && @encrypted_config.value['custom']&.any? { |e| e['name'] == @cert_record.name }) || certs = Accounts.load_signing_certs(current_account)
@cert_record.name == DEFAULT_CERT_NAME
@cert_record.errors.add(:name, 'already exists') trusted_certs = [certs[:cert], certs[:sub_ca], certs[:root_ca]]
return render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'), render turbo_stream: turbo_stream.replace('result', partial: 'result',
status: :unprocessable_entity locals: { pdfs:, files: params[:files], trusted_certs: })
end rescue HexaPDF::MalformedPDFError
render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div('Invalid PDF', id: 'result'))
save_new_cert!(@encrypted_config, @cert_record)
redirect_to settings_esign_path, notice: 'Certificate has been successfully added!'
rescue OpenSSL::PKCS12::PKCS12Error
@cert_record.errors.add(:password, "is invalid. Make sure you're uploading a valid .p12 file")
render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'), status: :unprocessable_entity
end
def update
@encrypted_config.value['custom'].each { |e| e['status'] = 'validate' }
custom_cert_data = @encrypted_config.value['custom'].find { |e| e['name'] == params[:name] }
custom_cert_data['status'] = 'default' if custom_cert_data
@encrypted_config.save!
redirect_to settings_esign_path, notice: 'Default certificate has been selected'
end
def destroy
@encrypted_config.value['custom'].reject! { |e| e['name'] == params[:name] }
@encrypted_config.save!
redirect_to settings_esign_path, notice: 'Certificate has been removed'
end
private
def load_encrypted_config
@encrypted_config = EncryptedConfig.find_or_initialize_by(account: current_account,
key: EncryptedConfig::ESIGN_CERTS_KEY)
end
def save_new_cert!(cert_configs, cert_record)
pkcs = OpenSSL::PKCS12.new(cert_record.file.read, cert_record.password)
cert_configs.value ||= {}
cert_configs.value['custom'] ||= []
cert_configs.value['custom'].each { |e| e['status'] = 'validate' }
cert_configs.value['custom'] << {
data: Base64.urlsafe_encode64(pkcs.to_der),
password: cert_record.password,
name: cert_record.name,
status: 'default'
}
cert_configs.save!
end
def cert_params
return {} if params[:esign_settings_controller_cert_form_record].blank?
params.require(:esign_settings_controller_cert_form_record).permit(:name, :file, :password)
end end
end end
-44
View File
@@ -1,44 +0,0 @@
# 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,8 +1,6 @@
# 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
@@ -1,46 +0,0 @@
# 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
@@ -1,16 +0,0 @@
# frozen_string_literal: true
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
def google_oauth2
@user = Users.from_omniauth(request.env['omniauth.auth'])
if @user.persisted?
flash[:notice] = I18n.t('devise.omniauth_callbacks.success', kind: 'Google')
sign_in_and_redirect @user, event: :authentication
else
redirect_to new_registration_path(oauth_callback: true, user: @user.slice(:email, :first_name, :last_name)),
notice: 'Please complete registration with Google auth'
end
end
end
@@ -1,24 +0,0 @@
# frozen_string_literal: true
class PersonalizationSettingsController < ApplicationController
def show
authorize!(:read, AccountConfig)
end
def create
account_config =
current_account.account_configs.find_or_initialize_by(key: encrypted_config_params[:key])
authorize!(:create, account_config)
account_config.update!(encrypted_config_params)
redirect_back(fallback_location: settings_personalization_path, notice: 'Settings have been saved.')
end
private
def encrypted_config_params
params.require(:account_config).permit!
end
end
-4
View File
@@ -1,10 +1,6 @@
# 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
+2 -40
View File
@@ -1,62 +1,24 @@
# frozen_string_literal: true # frozen_string_literal: true
class RegistrationsController < Devise::RegistrationsController class RegistrationsController < Devise::RegistrationsController
prepend_before_action :require_no_authentication, only: [:show]
prepend_before_action :maybe_redirect_if_signed_in, only: [:show]
def show; end
def create
super
Accounts.create_default_template(resource.account) if resource.account.persisted?
end
private private
def after_sign_up_path_for(...)
if params[:redir].present?
return console_redirect_index_path(redir: params[:redir]) if params[:redir].starts_with?(Docuseal::CONSOLE_URL)
return params[:redir]
end
super
end
def maybe_redirect_if_signed_in
return unless signed_in?
return if params[:redir].blank?
redirect_to after_sign_up_path_for(current_user), allow_other_host: true
end
def require_no_authentication
super
flash.clear
end
def build_resource(_hash = {}) def build_resource(_hash = {})
account = Account.new(account_params) account = Account.new(account_params)
account.timezone = Accounts.normalize_timezone(account.timezone) account.timezone = Accounts.normalize_timezone(account.timezone)
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'
end end
def user_params def user_params
return {} if params[:user].blank? return {} if params[:user].blank?
params.require(:user).permit(:first_name, :last_name, :email, :password).compact_blank.tap do |attrs| params.require(:user).permit(:first_name, :last_name, :email, :password)
attrs[:password] ||= SecureRandom.hex if params[:action] == 'create'
end
end end
def account_params def account_params
return {} if params[:account].blank? return {} if params[:account].blank?
params.require(:account).permit(:name, :timezone).compact_blank params.require(:account).permit(:name, :timezone)
end end
end end
@@ -5,7 +5,6 @@ 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
-40
View File
@@ -1,40 +0,0 @@
# frozen_string_literal: true
class SessionsController < Devise::SessionsController
before_action :configure_permitted_parameters
def create
if Docuseal.multitenant? && !User.exists?(email: sign_in_params[:email])
return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
notice: 'Create a new account'
end
if User.exists?(email: sign_in_params[:email], otp_required_for_login: true) && sign_in_params[:otp_attempt].blank?
return render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_entity
end
super
end
private
def after_sign_in_path_for(...)
if params[:redir].present?
return console_redirect_index_path(redir: params[:redir]) if params[:redir].starts_with?(Docuseal::CONSOLE_URL)
return params[:redir]
end
super
end
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
end
def require_no_authentication
super
flash.clear
end
end
+2 -4
View File
@@ -3,7 +3,6 @@
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!
@@ -17,17 +16,16 @@ class SetupController < ApplicationController
def create def create
@account = Account.new(account_params) @account = Account.new(account_params)
@account.timezone = Accounts.normalize_timezone(@account.timezone) @account.timezone = Accounts.normalize_timezone(@account.timezone)
@user = @account.users.new(user_params) @user = @account.users.new(user_params)
@encrypted_config = EncryptedConfig.new(encrypted_config_params)
unless URI.parse(encrypted_config_params[:value].to_s).class.in?([URI::HTTP, URI::HTTPS]) unless URI.parse(encrypted_config_params[:value].to_s).class.in?([URI::HTTP, URI::HTTPS])
@encrypted_config = EncryptedConfig.new(encrypted_config_params)
@encrypted_config.errors.add(:value, 'should be a valid URL') @encrypted_config.errors.add(:value, 'should be a valid URL')
return render :index, status: :unprocessable_entity return render :index, status: :unprocessable_entity
end end
return render :index, status: :unprocessable_entity unless @account.valid?
if @user.save if @user.save
encrypted_configs = [ encrypted_configs = [
{ key: EncryptedConfig::APP_URL_KEY, value: encrypted_config_params[:value] }, { key: EncryptedConfig::APP_URL_KEY, value: encrypted_config_params[:value] },
@@ -1,16 +0,0 @@
# frozen_string_literal: true
class SmsSettingsController < ApplicationController
before_action :load_encrypted_config
authorize_resource :encrypted_config, only: :index
authorize_resource :encrypted_config, parent: false, except: :index
def index; end
private
def load_encrypted_config
@encrypted_config =
EncryptedConfig.find_or_initialize_by(account: current_account, key: 'sms_configs')
end
end
+4 -10
View File
@@ -4,7 +4,6 @@ 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
@@ -21,13 +20,12 @@ class StartFormController < ApplicationController
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, @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)
@@ -38,17 +36,13 @@ class StartFormController < ApplicationController
end end
def completed def completed
@submitter = Submitter.where(submission: @template.submissions) @submitter = Submitter.where(submission: @template.submissions).find_by!(email: params[:email])
.where.not(completed_at: nil)
.find_by!(email: params[:email])
end end
private private
def submitter_params def submitter_params
params.require(:submitter).permit(:email).tap do |attrs| params.require(:submitter).permit(:email)
attrs[:email] = Submissions.normalize_email(attrs[:email])
end
end end
def load_template def load_template
@@ -2,8 +2,6 @@
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
+44 -28
View File
@@ -2,57 +2,73 @@
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]
def show def show
ActiveRecord::Associations::Preloader.new( @submission =
records: [@submission], Submission.joins(:template).where(template: { account_id: current_account.id })
associations: [:template, { template_schema_documents: [:blob, { preview_images_attachments: :blob }] }] .preload(template: { documents_attachments: { preview_images_attachments: :blob } })
).call .find(params[:id])
render :show, layout: 'plain' render :show, layout: 'plain'
end end
def new def new; end
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, create_submissions_from_emails
user: current_user,
source: :invite,
mark_as_sent: params[:send_email] == '1',
emails: params[:emails])
else else
Submissions.create_from_submitters(template: @template, create_submissions_from_submitters
user: current_user,
source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
mark_as_sent: params[:send_email] == '1',
submissions_attrs: submissions_params[:submission].to_h.values)
end end
Submissions.send_signature_requests(submissions, params) if params[:send_email] == '1'
submissions.flat_map(&:submitters).each do |submitter|
SubmitterMailer.invitation_email(submitter, message: params[:message]).deliver_later!
end
end
redirect_to template_path(@template), notice: 'New recipients have been added' redirect_to template_path(@template),
notice: "#{submissions.size} #{'recipient'.pluralize(submissions.size)} added"
end end
def destroy def destroy
@submission.update!(deleted_at: Time.current) submission = Submission.joins(:template).where(template: { account_id: current_account.id })
.find(params[:id])
redirect_back(fallback_location: template_path(@submission.template), notice: 'Submission has been archived') submission.update!(deleted_at: Time.current)
redirect_back(fallback_location: template_path(submission.template), notice: 'Submission has been archived')
end end
private private
def create_submissions_from_emails
emails = params[:emails].to_s.scan(User::EMAIL_REGEXP)
emails.map do |email|
submission = @template.submissions.new(created_by_user: current_user)
submission.submitters.new(email:, uuid: @template.submitters.first['uuid'],
sent_at: params[:send_email] == '1' ? Time.current : nil)
submission.tap(&:save!)
end
end
def create_submissions_from_submitters
submissions_params[:submission].to_h.map do |_, attrs|
submission = @template.submissions.new(created_by_user: current_user)
attrs[:submitters].each do |submitter_attrs|
submission.submitters.new(**submitter_attrs, sent_at: params[:send_email] == '1' ? Time.current : nil)
end
submission.tap(&:save!)
end
end
def submissions_params def submissions_params
params.permit(submission: { submitters: [%i[uuid email phone name]] }) params.permit(submission: { submitters: [%i[uuid email]] })
end end
def load_template def load_template
@@ -4,7 +4,6 @@ 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 },
@@ -16,13 +15,9 @@ class SubmissionsDebugController < ApplicationController
render 'submit_form/show' render 'submit_form/show'
end end
f.pdf do f.pdf do
if params[:audit] Submissions::GenerateResultAttachments.call(@submitter)
Submissions::GenerateAuditTrail.call(@submitter.submission)
else
Submissions::GenerateResultAttachments.call(@submitter)
end
send_data ActiveStorage::Attachment.where(name: params[:audit] ? :audit_trail : :documents).last.download, send_data ActiveStorage::Attachment.where(name: :documents).last.download,
filename: 'debug.pdf', filename: 'debug.pdf',
disposition: 'inline', disposition: 'inline',
type: 'application/pdf' type: 'application/pdf'
@@ -2,19 +2,14 @@
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(last_submitter).map do |attachment| Submitters.select_attachments_for_download(submitter).map do |attachment|
helpers.rails_blob_url(attachment) helpers.rails_blob_url(attachment)
end end
@@ -1,23 +0,0 @@
# frozen_string_literal: true
class SubmissionsExportController < ApplicationController
load_and_authorize_resource :template
load_and_authorize_resource :submission, through: :template, parent: false, only: :index
def index
submissions = @submissions.active
.preload(submitters: { documents_attachments: :blob,
attachments_attachments: :blob })
.order(id: :asc)
if params[:format] == 'csv'
send_data Submissions::GenerateExportFiles.call(submissions, format: params[:format]),
filename: "#{@template.name}.csv"
elsif params[:format] == 'xlsx'
send_data Submissions::GenerateExportFiles.call(submissions, format: params[:format]),
filename: "#{@template.name}.xlsx"
end
end
def new; end
end
+25 -11
View File
@@ -4,35 +4,49 @@ class SubmitFormController < ApplicationController
layout 'form' layout 'form'
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_authorization_check
def show def show
@submitter = @submitter =
Submitter.preload(submission: [ Submitter.preload(submission: { template: { documents_attachments: { preview_images_attachments: :blob } } })
:template, { template_schema_documents: [:blob, { preview_images_attachments: :blob }] }
])
.find_by!(slug: params[:slug]) .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?
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
submitter = Submitter.find_by!(slug: params[:slug]) submitter = Submitter.find_by!(slug: params[:slug])
submitter.values.merge!(normalized_values)
submitter.completed_at = Time.current if params[:completed] == 'true'
submitter.opened_at ||= Time.current
Submitters::SubmitValues.call(submitter, params, request) submitter.save!
if submitter.completed_at?
GenerateSubmitterResultAttachmentsJob.perform_later(submitter)
submitter.submission.template.account.users.active.each do |user|
SubmitterMailer.completed_email(submitter, user).deliver_later!
end
end
head :ok head :ok
rescue Submitters::SubmitValues::ValidationError => e
render json: { error: e.message }, status: :unprocessable_entity
end end
def completed def completed
@submitter = Submitter.find_by!(slug: params[:submit_form_slug]) @submitter = Submitter.find_by!(slug: params[:submit_form_slug])
end end
private
def normalized_values
params.fetch(:values, {}).to_unsafe_h.transform_values do |v|
if params[:cast_boolean] == 'true'
v == 'true'
else
v.is_a?(Array) ? v.compact_blank : v
end
end
end
end end
@@ -1,16 +0,0 @@
# frozen_string_literal: true
class SubmittersSendEmailController < ApplicationController
load_and_authorize_resource :submitter, id_param: :submitter_slug, find_by: :slug
def create
SubmitterMailer.invitation_email(@submitter).deliver_later!
SubmissionEvent.create!(submitter: @submitter, event_type: 'send_email')
@submitter.sent_at ||= Time.current
@submitter.save!
redirect_back(fallback_location: submission_path(@submitter.submission), notice: 'Email has been sent')
end
end
@@ -1,29 +0,0 @@
# 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
@@ -1,12 +0,0 @@
# 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
@@ -1,15 +0,0 @@
# 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
+10 -13
View File
@@ -1,37 +1,31 @@
# 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
submissions = @template.submissions @template = current_account.templates.active.find(params[:id])
submissions = submissions.active if @template.deleted_at.blank?
submissions = Submissions.search(submissions, params[:q])
@pagy, @submissions = pagy(submissions.preload(:submitters).order(id: :desc)) @pagy, @submissions = pagy(@template.submissions.active.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
ActiveRecord::Associations::Preloader.new( @template = current_account.templates.preload(documents_attachments: { preview_images_attachments: :blob })
records: [@template], .find(params[:id])
associations: [schema_documents: { preview_images_attachments: :blob }]
).call
render :edit, layout: 'plain' render :edit, layout: 'plain'
end end
def create def create
@template.account = current_account @template = current_account.templates.new(template_params)
@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
@@ -44,6 +38,7 @@ class TemplatesController < ApplicationController
end end
def destroy def destroy
@template = current_account.templates.find(params[:id])
@template.update!(deleted_at: Time.current) @template.update!(deleted_at: Time.current)
redirect_back(fallback_location: root_path, notice: 'Template has been archived.') redirect_back(fallback_location: root_path, notice: 'Template has been archived.')
@@ -58,6 +53,8 @@ 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.find_by(id: params[:base_template_id]) @base_template = current_account.templates
.preload(documents_attachments: :preview_images_attachments)
.find_by(id: params[:base_template_id])
end end
end end
@@ -1,23 +0,0 @@
# 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
@@ -1,11 +0,0 @@
# 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
@@ -1,22 +0,0 @@
# frozen_string_literal: true
class TemplatesUploadsController < ApplicationController
load_and_authorize_resource :template, parent: false
def create
@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(params[:files].first.original_filename, '.*')
@template.save!
documents = Templates::CreateAttachments.call(@template, params)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
@template.update!(schema:)
redirect_to edit_template_path(@template)
end
end
@@ -1,37 +0,0 @@
# 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
+33 -43
View File
@@ -1,55 +1,19 @@
# frozen_string_literal: true # frozen_string_literal: true
class UsersController < ApplicationController class UsersController < ApplicationController
load_and_authorize_resource :user, only: %i[index edit new update destroy] before_action :load_user, only: %i[edit update destroy]
before_action :build_user, only: :create
authorize_resource :user, only: :create
def index def index
@pagy, @users = pagy(@users.active.order(id: :desc)) @pagy, @users = pagy(current_account.users.active.order(id: :desc))
end end
def new; end def new
@user = current_account.users.new
end
def edit; end def edit; end
def create def create
if @user.save
UserMailer.invitation_email(@user).deliver_later!
redirect_to settings_users_path, notice: 'User has been invited'
else
render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_entity
end
end
def update
return redirect_to settings_users_path, notice: 'Unable to update user.' if Docuseal.demo?
attrs = user_params.compact_blank
attrs.delete(:role) if User::ROLES.exclude?(attrs[:role])
if @user.update(attrs.except(current_user == @user ? :role : nil))
redirect_to settings_users_path, notice: 'User has been updated'
else
render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_entity
end
end
def destroy
if Docuseal.demo? || @user.id == current_user.id
return redirect_to settings_users_path, notice: 'Unable to remove user'
end
@user.update!(deleted_at: Time.current)
redirect_to settings_users_path, notice: 'User has been removed'
end
private
def build_user
@user = current_account.users.find_by(email: user_params[:email])&.tap do |user| @user = current_account.users.find_by(email: user_params[:email])&.tap do |user|
user.assign_attributes(user_params) user.assign_attributes(user_params)
user.deleted_at = nil user.deleted_at = nil
@@ -57,10 +21,36 @@ class UsersController < ApplicationController
@user ||= current_account.users.new(user_params) @user ||= current_account.users.new(user_params)
@user if @user.save
UserMailer.invitation_email(@user).deliver_later!
redirect_to settings_users_path, notice: 'User has been invited.'
else
render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_entity
end
end
def update
if @user.update(user_params.compact_blank)
redirect_to settings_users_path, notice: 'User has been updated.'
else
render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_entity
end
end
def destroy
@user.update!(deleted_at: Time.current)
redirect_to settings_users_path, notice: 'User has been removed.'
end
private
def load_user
@user = current_account.users.find(params[:id])
end end
def user_params def user_params
params.require(:user).permit(:email, :first_name, :last_name, :password, :role) params.require(:user).permit(:email, :first_name, :last_name, :password)
end end
end end
@@ -1,34 +0,0 @@
# frozen_string_literal: true
class VerifyPdfSignatureController < ApplicationController
skip_authorization_check
def create
pdfs =
params[:files].map do |file|
HexaPDF::Document.new(io: file.open)
end
cert_data = if Docuseal.multitenant?
Docuseal::CERTS
else
EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
end
default_pkcs = GenerateCertificate.load_pkcs(cert_data)
custom_certs = cert_data.fetch('custom', []).map do |e|
OpenSSL::PKCS12.new(Base64.urlsafe_decode64(e['data']), e['password'].to_s)
end
trusted_certs = [default_pkcs.certificate,
*default_pkcs.ca_certs,
*custom_certs.map(&:certificate),
*custom_certs.flat_map(&:ca_certs).compact]
render turbo_stream: turbo_stream.replace('result', partial: 'result',
locals: { pdfs:, files: params[:files], trusted_certs: })
rescue HexaPDF::MalformedPDFError
render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div('Invalid PDF', id: 'result'))
end
end
@@ -1,33 +0,0 @@
# frozen_string_literal: true
class WebhookSettingsController < ApplicationController
before_action :load_encrypted_config
authorize_resource :encrypted_config, parent: false
def show; end
def create
@encrypted_config.update!(encrypted_config_params)
redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook URL has been saved.')
end
def update
submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last
SendFormCompletedWebhookRequestJob.perform_later(submitter)
redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook request has been sent.')
end
private
def load_encrypted_config
@encrypted_config =
current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
end
def encrypted_config_params
params.require(:encrypted_config).permit(:value)
end
end
-31
View File
@@ -15,9 +15,6 @@ 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 * as TurboInstantClick from './lib/turbo_instant_click' import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -46,32 +43,8 @@ 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)
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody) document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
document.addEventListener('turbo:submit-end', async (event) => {
const resp = event.detail?.formSubmission?.result?.fetchResponse?.response
if (!resp?.headers?.get('content-disposition')?.includes('attachment')) {
return
}
const url = URL.createObjectURL(await resp.blob())
const link = document.createElement('a')
link.href = url
link.setAttribute('download', decodeURIComponent(resp.headers.get('content-disposition').split('"')[1]))
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
})
window.customElements.define('template-builder', class extends HTMLElement { window.customElements.define('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {
@@ -79,10 +52,6 @@ window.customElements.define('template-builder', class extends HTMLElement {
this.app = createApp(TemplateBuilder, { this.app = createApp(TemplateBuilder, {
template: reactive(JSON.parse(this.dataset.template)), template: reactive(JSON.parse(this.dataset.template)),
backgroundColor: '#faf7f5',
withPhone: this.dataset.withPhone === 'true',
withLogo: this.dataset.withLogo !== 'false',
acceptFileTypes: this.dataset.acceptFileTypes,
isDirectUpload: this.dataset.isDirectUpload === 'true' isDirectUpload: this.dataset.isDirectUpload === 'true'
}) })
-55
View File
@@ -70,58 +70,3 @@ button[disabled] .enabled {
.base-radio { .base-radio {
@apply radio bg-white radio-sm no-animation; @apply radio bg-white radio-sm no-animation;
} }
.base-select {
@apply select base-input w-full font-normal;
}
.tooltip-bottom-end:before {
transform: translateX(-95%);
top: var(--tooltip-offset);
left: 100%;
right: auto;
bottom: auto;
}
.tooltip-bottom-end:after {
transform: translateX(-25%);
border-color: transparent transparent var(--tooltip-color) transparent;
top: var(--tooltip-tail-offset);
left: 50%;
right: auto;
bottom: auto;
}
.autocomplete {
background: white;
z-index: 1000;
font: 14px/22px "-apple-system", BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
overflow: auto;
box-sizing: border-box;
@apply border border-base-300 mt-1 rounded-md;
}
.autocomplete * {
font: inherit;
}
.autocomplete > div {
@apply px-2 py-1 font-normal text-sm;
}
.autocomplete .group {
background: #eee;
}
.autocomplete > div:hover:not(.group),
.autocomplete > div.selected {
@apply bg-base-300;
cursor: pointer;
}
.input-outlined {
outline-style: solid;
outline-width: 1px;
outline-offset: 3px;
outline-color: hsl(var(--bc) / 0.2);
}
+3 -9
View File
@@ -38,11 +38,7 @@ export default actionable(targetable(class extends HTMLElement {
this.uploadFiles(this.input.files) this.uploadFiles(this.input.files)
} }
toggleLoading = (e) => { toggleLoading = () => {
if (e && e.target && !e.target.contains(this)) {
return
}
this.loading.classList.toggle('hidden') this.loading.classList.toggle('hidden')
this.icon.classList.toggle('hidden') this.icon.classList.toggle('hidden')
this.classList.toggle('opacity-50') this.classList.toggle('opacity-50')
@@ -91,13 +87,11 @@ export default actionable(targetable(class extends HTMLElement {
this.append(input) this.append(input)
}) })
if (this.dataset.submitOnUpload === 'true') { if (this.dataset.submitOnUpload) {
this.closest('form').querySelector('button[type="submit"]').click() this.closest('form').querySelector('button[type="submit"]').click()
} }
}).finally(() => { }).finally(() => {
if (this.dataset.submitOnUpload !== 'true') { this.toggleLoading()
this.toggleLoading()
}
}) })
} else { } else {
if (this.dataset.submitOnUpload) { if (this.dataset.submitOnUpload) {
@@ -1,43 +0,0 @@
import autocomplete from 'autocompleter'
export default class extends HTMLElement {
connectedCallback () {
autocomplete({
input: this.input,
preventSubmit: this.dataset.submitOnSelect === 'true' ? 0 : 1,
minLength: 0,
showOnFocus: true,
onSelect: this.onSelect,
render: this.render,
fetch: this.fetch
})
}
onSelect = (item) => {
this.input.value = item.name
}
fetch = (text, resolve) => {
const queryParams = new URLSearchParams({ q: text })
fetch('/api/template_folders_autocomplete?' + queryParams).then(async (resp) => {
const items = await resp.json()
resolve(items)
}).catch(() => {
resolve([])
})
}
render = (item) => {
const div = document.createElement('div')
div.textContent = item.name
return div
}
get input () {
return this.querySelector('input')
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.querySelectorAll('a').forEach((link) => { this.querySelectorAll('a').forEach((link) => {
if (document.location.pathname.startsWith(link.pathname) && !link.getAttribute('href').startsWith('http')) { if (document.location.pathname.startsWith(link.pathname)) {
link.classList.add('bg-base-300') link.classList.add('bg-base-300')
} }
}) })
-46
View File
@@ -1,46 +0,0 @@
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()
}
})
@@ -1,67 +0,0 @@
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')
}
}
+2 -6
View File
@@ -5,19 +5,15 @@ export default actionable(class extends HTMLElement {
document.body.classList.add('overflow-hidden') document.body.classList.add('overflow-hidden')
document.addEventListener('keyup', this.onEscKey) document.addEventListener('keyup', this.onEscKey)
document.addEventListener('turbo:submit-end', this.onSubmit)
document.addEventListener('turbo:before-cache', this.close) document.addEventListener('turbo:before-cache', this.close)
if (this.dataset.closeAfterSubmit !== 'false') {
document.addEventListener('turbo:submit-end', this.onSubmit)
}
} }
disconnectedCallback () { disconnectedCallback () {
document.body.classList.remove('overflow-hidden') document.body.classList.remove('overflow-hidden')
document.removeEventListener('keyup', this.onEscKey) document.removeEventListener('keyup', this.onEscKey)
document.removeEventListener('turbo:submit-end', this.onSubmit) document.removeEventListener('turbo:submit-end', this.handleSubmit)
document.removeEventListener('turbo:before-cache', this.close) document.removeEventListener('turbo:before-cache', this.close)
} }
+2 -6
View File
@@ -9,16 +9,12 @@ window.customElements.define('submission-form', class extends HTMLElement {
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
this.app = createApp(Form, { this.app = createApp(Form, {
submitter: JSON.parse(this.dataset.submitter), submitterSlug: this.dataset.submitterSlug,
submitterUuid: this.dataset.submitterUuid,
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',
attribution: this.dataset.attribution !== 'false',
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)
}) })
+7 -28
View File
@@ -21,14 +21,14 @@
</span> </span>
</div> </div>
<div <div
v-if="isActive && withLabel" v-if="isActive"
class="absolute -top-7 rounded bg-base-content text-base-100 px-2 text-sm whitespace-nowrap" class="absolute -top-7 rounded bg-base-content text-base-100 px-2 text-sm whitespace-nowrap"
> >
{{ field.name || fieldNames[field.type] }} {{ field.name || fieldNames[field.type] }}
<template v-if="field.type === 'checkbox' && !field.name"> <template v-if="field.type === 'checkbox'">
{{ fieldIndex + 1 }} {{ fieldIndex + 1 }}
</template> </template>
<template v-else-if="!field.required && field.type !== 'checkbox'"> <template v-else-if="!field.required">
(optional) (optional)
</template> </template>
</div> </div>
@@ -47,11 +47,6 @@
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"
@@ -118,7 +113,7 @@
</template> </template>
<script> <script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper } from '@tabler/icons-vue' import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3 } from '@tabler/icons-vue'
export default { export default {
name: 'FieldArea', name: 'FieldArea',
@@ -151,11 +146,6 @@ export default {
required: false, required: false,
default: false default: false
}, },
withLabel: {
type: Boolean,
required: false,
default: true
},
fieldIndex: { fieldIndex: {
type: Number, type: Number,
required: false, required: false,
@@ -180,13 +170,11 @@ 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 () {
@@ -195,14 +183,12 @@ 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 () {
@@ -219,16 +205,9 @@ 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' }).format(new Date(this.modelValue))
} else { } else {
return '' return ''
} }
+4 -24
View File
@@ -11,9 +11,7 @@
v-for="(area, areaIndex) in field.areas" v-for="(area, areaIndex) in field.areas"
:key="areaIndex" :key="areaIndex"
> >
<Teleport <Teleport :to="`#page-${area.attachment_uuid}-${area.page}`">
:to="findPageElementForArea(area)"
>
<FieldArea <FieldArea
:ref="setAreaRef" :ref="setAreaRef"
v-model="values[field.uuid]" v-model="values[field.uuid]"
@@ -22,7 +20,6 @@
:submittable="true" :submittable="true"
:field-index="fieldIndex" :field-index="fieldIndex"
:is-active="currentStep === step" :is-active="currentStep === step"
:with-label="withLabel"
:is-value-set="step.some((f) => f.uuid in values)" :is-value-set="step.some((f) => f.uuid in values)"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
@click="$emit('focus-step', step)" @click="$emit('focus-step', step)"
@@ -57,11 +54,6 @@ export default {
required: false, required: false,
default: () => ({}) default: () => ({})
}, },
withLabel: {
type: Boolean,
required: false,
default: true
},
currentStep: { currentStep: {
type: Array, type: Array,
required: false, required: false,
@@ -78,16 +70,10 @@ export default {
this.areaRefs = [] this.areaRefs = []
}, },
methods: { methods: {
findPageElementForArea (area) {
return (this.$root.$el?.parentNode?.getRootNode() || document).getElementById(`page-${area.attachment_uuid}-${area.page}`)
},
scrollIntoField (field) { scrollIntoField (field) {
this.areaRefs.find((area) => { this.areaRefs.find((area) => {
if (area.field === field) { if (area.field === field) {
const root = this.$root.$el.parentNode.getRootNode() if (document.body.style.overflow === 'hidden') {
const container = root.body || root.querySelector('div')
if (container.style.overflow === 'hidden') {
this.scrollInContainer(area.$el) this.scrollInContainer(area.$el)
} else { } else {
area.$refs.scrollToElem.scrollIntoView({ behavior: 'smooth', block: 'start' }) area.$refs.scrollToElem.scrollIntoView({ behavior: 'smooth', block: 'start' })
@@ -100,19 +86,13 @@ export default {
}) })
}, },
scrollInContainer (target) { scrollInContainer (target) {
const root = this.$root.$el.parentNode.getRootNode()
const scrollbox = root.getElementById('scrollbox')
const formContainer = root.getElementById('form_container')
const container = root.body || root.querySelector('div')
const padding = 64 const padding = 64
const boxRect = scrollbox.children[0].getBoundingClientRect() const boxRect = window.scrollbox.children[0].getBoundingClientRect()
const targetRect = target.getBoundingClientRect() const targetRect = target.getBoundingClientRect()
const targetTopRelativeToBox = targetRect.top - boxRect.top const targetTopRelativeToBox = targetRect.top - boxRect.top
scrollbox.scrollTop = targetTopRelativeToBox - container.offsetHeight + formContainer.offsetHeight + target.offsetHeight + padding window.scrollbox.scrollTop = targetTopRelativeToBox - document.body.offsetHeight + window.form_container.offsetHeight + target.offsetHeight + padding
}, },
setAreaRef (el) { setAreaRef (el) {
if (el) { if (el) {
+18 -82
View File
@@ -7,22 +7,13 @@
:height="30" :height="30"
/> />
<span> <span>
{{ t('form_has_been_completed') }} Form has been completed!
</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"
class="white-button !h-auto flex items-center space-x-1 w-full" class="white-button flex items-center space-x-1 w-full"
:disabled="isSendingCopy" :disabled="isSendingCopy"
@click.prevent="sendCopyToEmail" @click.prevent="sendCopyToEmail"
> >
@@ -32,11 +23,10 @@
/> />
<IconMail v-else /> <IconMail v-else />
<span> <span>
{{ t('send_copy_via_email') }} Send copy via email
</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"
@@ -47,88 +37,41 @@
/> />
<IconDownload v-else /> <IconDownload v-else />
<span> <span>
{{ t('download') }} Download
</span> </span>
</button> </button>
<a
v-if="isDemo"
target="_blank"
href="https://github.com/docusealco/docuseal"
class="white-button flex items-center space-x-1 w-full"
>
<IconBrandGithub />
<span>
Star on Github
</span>
</a>
<a
v-if="isDemo"
href="https://docuseal.co/sign_up"
class="white-button flex items-center space-x-1 w-full"
>
<IconLogin />
<span>
{{ t('create_a_free_account') }}
</span>
</a>
</div> </div>
<div <div class="text-center mt-4">
v-if="attribution" Signed with
class="text-center mt-4"
>
{{ t('signed_with') }}
<a <a
href="https://www.docuseal.co" href="https://www.docuseal.co"
target="_blank" target="_blank"
class="underline" class="underline"
>DocuSeal</a> - {{ t('open_source_documents_software') }} >DocuSeal</a> - open source documents software
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import { IconCircleCheck, IconBrandGithub, IconMail, IconDownload, IconInnerShadowTop, IconLogin } from '@tabler/icons-vue' import { IconCircleCheck, IconMail, IconDownload, IconInnerShadowTop } from '@tabler/icons-vue'
export default { export default {
name: 'FormCompleted', name: 'FormCompleted',
components: { components: {
IconCircleCheck, IconCircleCheck,
IconInnerShadowTop, IconInnerShadowTop,
IconBrandGithub,
IconMail, IconMail,
IconLogin,
IconDownload IconDownload
}, },
inject: ['baseUrl', 't'],
props: { props: {
submitterSlug: { submitterSlug: {
type: String, type: String,
required: true required: true
}, },
isDemo: {
type: Boolean,
required: false,
default: false
},
attribution: {
type: Boolean,
required: false,
default: true
},
withConfetti: {
type: Boolean,
required: false,
default: false
},
canSendEmail: { canSendEmail: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false default: false
},
completedButton: {
type: Object,
required: false,
default: () => ({})
} }
}, },
data () { data () {
@@ -137,30 +80,23 @@ 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) { const { default: confetti } = await import('canvas-confetti')
const { default: confetti } = await import('canvas-confetti')
confetti({ confetti({
particleCount: 50, particleCount: 50,
startVelocity: 30, startVelocity: 30,
spread: 140 spread: 140
}) })
}
}, },
methods: { methods: {
sendCopyToEmail () { sendCopyToEmail () {
this.isSendingCopy = true this.isSendingCopy = true
fetch(this.baseUrl + `/send_submission_email.json?submitter_slug=${this.submitterSlug}`, { fetch(`/send_submission_email.json?submitter_slug=${this.submitterSlug}`, {
method: 'POST' method: 'POST'
}).then(() => { }).then(() => {
alert(this.t('email_has_been_sent')) alert('Email has been sent')
}).finally(() => { }).finally(() => {
this.isSendingCopy = false this.isSendingCopy = false
}) })
@@ -168,7 +104,7 @@ export default {
download () { download () {
this.isDownloading = true this.isDownloading = true
fetch(this.baseUrl + `/submitters/${this.submitterSlug}/download`).then((response) => response.json()).then((urls) => { fetch(`/submitters/${this.submitterSlug}/download`).then((response) => response.json()).then((urls) => {
const fileRequests = urls.map((url) => { const fileRequests = urls.map((url) => {
return () => { return () => {
return fetch(url).then(async (resp) => { return fetch(url).then(async (resp) => {
@@ -1,49 +0,0 @@
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 }
+3 -4
View File
@@ -29,7 +29,7 @@
{{ message }} {{ message }}
</div> </div>
<div class="text-xs"> <div class="text-xs">
<span class="font-medium">{{ t('click_to_upload') }}</span> {{ t('or_drag_and_drop_files') }} <span class="font-medium">Click to upload</span> or drag and drop files
</div> </div>
</div> </div>
</div> </div>
@@ -55,7 +55,6 @@ export default {
IconCloudUpload, IconCloudUpload,
IconInnerShadowTop IconInnerShadowTop
}, },
inject: ['baseUrl', 't'],
props: { props: {
message: { message: {
type: String, type: String,
@@ -142,7 +141,7 @@ export default {
return await Promise.all( return await Promise.all(
blobs.map((blob) => { blobs.map((blob) => {
return fetch(this.baseUrl + '/api/attachments', { return fetch('/api/attachments', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
name: 'attachments', name: 'attachments',
@@ -167,7 +166,7 @@ export default {
formData.append('submitter_slug', this.submitterSlug) formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments') formData.append('name', 'attachments')
return fetch(this.baseUrl + '/api/attachments', { return fetch('/api/attachments', {
method: 'POST', method: 'POST',
body: formData body: formData
}).then(resp => resp.json()).then((data) => { }).then(resp => resp.json()).then((data) => {
+259 -502
View File
@@ -4,338 +4,261 @@
:steps="stepFields" :steps="stepFields"
:values="values" :values="values"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
:with-label="!isAnonymousChecboxes"
:current-step="currentStepFields" :current-step="currentStepFields"
@focus-step="[saveStep(), goToStep($event, false, true), currentField.type !== 'checkbox' ? isFormVisible = true : '']" @focus-step="[saveStep(), goToStep($event, false, true)]"
/> />
<button <div>
v-if="!isFormVisible" <form
class="btn btn-neutral flex text-white absolute rounded-none border-x-0 md:border md:rounded-full bottom-0 w-full md:mb-4 text-base"
@click.prevent="isFormVisible = true"
>
{{ t('submit_form') }}
<IconArrowsDiagonal
class="absolute right-0 mr-4"
:width="20"
:height="20"
/>
</button>
<div
v-show="isFormVisible"
id="form_container"
class="shadow-md bg-base-100 absolute bottom-0 md:bottom-4 w-full border-base-200 border p-4 rounded"
:style="{ backgroundColor: backgroundColor }"
>
<button
v-if="!isCompleted" v-if="!isCompleted"
class="absolute right-0 mr-2 mt-2 top-0 hidden md:block" ref="form"
title="Minimize" :action="submitPath"
@click.prevent="isFormVisible = false" method="post"
class="md:mx-16"
@submit.prevent="submitStep"
> >
<IconArrowsDiagonalMinimize2 <input
:width="20" type="hidden"
:height="20" name="authenticity_token"
/> :value="authenticityToken"
</button>
<div>
<form
v-if="!isCompleted"
ref="form"
:action="submitPath"
method="post"
class="md:mx-16"
@submit.prevent="submitStep"
> >
<input <input
type="hidden" value="put"
name="authenticity_token" name="_method"
:value="authenticityToken" type="hidden"
> >
<input <div class="md:mt-4">
value="put" <div v-if="['cells', 'text'].includes(currentField.type)">
name="_method" <label
type="hidden" v-if="currentField.name"
> :for="currentField.uuid"
<div class="md:mt-4"> class="label text-2xl mb-2"
<div v-if="['cells', 'text'].includes(currentField.type)"> >{{ currentField.name }}
<label <template v-if="!currentField.required">(optional)</template>
v-if="currentField.name" </label>
:for="currentField.uuid" <div
class="label text-2xl mb-2" v-else
>{{ currentField.name }} class="py-1"
<template v-if="!currentField.required">({{ t('optional') }})</template> />
</label> <div>
<div <input
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 v-else-if="currentField.type === 'select'">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">({{ t('optional') }})</template>
</label>
<div
v-else
class="py-1"
/>
<select
:id="currentField.uuid" :id="currentField.uuid"
v-model="values[currentField.uuid]"
class="base-input !text-2xl w-full"
:required="currentField.required" :required="currentField.required"
class="select base-input !text-2xl w-full text-center font-normal" :placeholder="`Type here...${currentField.required ? '' : ' (optional)'}`"
type="text"
:name="`values[${currentField.uuid}]`" :name="`values[${currentField.uuid}]`"
@change="values[currentField.uuid] = $event.target.value"
@focus="$refs.areas.scrollIntoField(currentField)" @focus="$refs.areas.scrollIntoField(currentField)"
> >
<option </div>
value="" </div>
:selected="!values[currentField.uuid]" <div v-else-if="currentField.type === 'date'">
> <label
{{ t('select_your_option') }} v-if="currentField.name"
</option> :for="currentField.uuid"
<option class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
</label>
<div
v-else
class="py-1"
/>
<div class="text-center">
<input
:id="currentField.uuid"
v-model="values[currentField.uuid]"
class="base-input !text-2xl text-center w-full"
:required="currentField.required"
type="date"
:name="`values[${currentField.uuid}]`"
@focus="$refs.areas.scrollIntoField(currentField)"
>
</div>
</div>
<div v-else-if="currentField.type === 'select'">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
</label>
<div
v-else
class="py-1"
/>
<select
:id="currentField.uuid"
:required="currentField.required"
class="select base-input !text-2xl w-full text-center font-normal"
:name="`values[${currentField.uuid}]`"
@change="values[currentField.uuid] = $event.target.value"
@focus="$refs.areas.scrollIntoField(currentField)"
>
<option
value=""
:selected="!values[currentField.uuid]"
>
Select your option
</option>
<option
v-for="(option, index) in currentField.options"
:key="index"
:selected="values[currentField.uuid] == option"
:value="option"
>
{{ option }}
</option>
</select>
</div>
<div v-else-if="currentField.type === 'radio'">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
</label>
<div class="flex w-full">
<div class="space-y-3.5 mx-auto">
<div
v-for="(option, index) in currentField.options" v-for="(option, index) in currentField.options"
:key="index" :key="index"
:selected="values[currentField.uuid] == option"
:value="option"
> >
{{ option }} <label
</option> :for="currentField.uuid + option"
</select> class="flex items-center space-x-3"
</div>
<div v-else-if="currentField.type === 'radio'">
<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 class="flex w-full">
<div class="space-y-3.5 mx-auto">
<div
v-for="(option, index) in currentField.options"
:key="index"
> >
<label <input
:for="currentField.uuid + option" :id="currentField.uuid + option"
class="flex items-center space-x-3" v-model="values[currentField.uuid]"
type="radio"
class="base-radio !h-7 !w-7"
:name="`values[${currentField.uuid}]`"
:value="option"
:required="currentField.required"
> >
<input <span class="text-xl">
:id="currentField.uuid + option" {{ option }}
v-model="values[currentField.uuid]" </span>
type="radio" </label>
class="base-radio !h-7 !w-7"
:name="`values[${currentField.uuid}]`"
:value="option"
:required="currentField.required"
>
<span class="text-xl">
{{ option }}
</span>
</label>
</div>
</div> </div>
</div> </div>
</div> </div>
<MultiSelectStep </div>
v-else-if="currentField.type === 'multiple'" <MultiSelectStep
v-model="values[currentField.uuid]" v-else-if="currentField.type === 'multiple'"
:field="currentField" v-model="values[currentField.uuid]"
/> :field="currentField"
/>
<div
v-else-if="currentField.type === 'checkbox'"
class="flex w-full"
>
<input
type="hidden"
name="cast_boolean"
value="true"
>
<div <div
v-else-if="currentField.type === 'checkbox'" class="space-y-3.5 mx-auto"
class="flex w-full max-h-44 overflow-y-auto"
> >
<input
type="hidden"
name="cast_boolean"
value="true"
>
<div <div
class="space-y-3.5 mx-auto" v-for="(field, index) in currentStepFields"
:key="field.uuid"
> >
<template v-if="isAnonymousChecboxes"> <label
<span class="text-xl"> :for="field.uuid"
{{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>. class="flex items-center space-x-3"
</span> >
<input <input
v-for="field in currentStepFields"
:key="field.uuid"
type="hidden" type="hidden"
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
:value="!!values[field.uuid]" :value="!!values[field.uuid]"
> >
</template> <input
<template v-else> :id="field.uuid"
<div type="checkbox"
v-for="(field, index) in currentStepFields" class="base-checkbox !h-7 !w-7"
:key="field.uuid" :checked="!!values[field.uuid]"
@click="[$refs.areas.scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
> >
<label <span class="text-xl">
:for="field.uuid" {{ currentField.name || currentField.type + ' ' + (index + 1) }}
class="flex items-center space-x-3" </span>
> </label>
<input
type="hidden"
:name="`values[${field.uuid}]`"
:value="!!values[field.uuid]"
>
<input
:id="field.uuid"
type="checkbox"
class="base-checkbox !h-7 !w-7"
:oninvalid="`this.setCustomValidity('${t('please_check_the_box_to_continue')}')`"
:onchange="`this.setCustomValidity(validity.valueMissing ? '${t('please_check_the_box_to_continue')}' : '');`"
:required="field.required"
:checked="!!values[field.uuid]"
@click="[$refs.areas.scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
>
<span class="text-xl">
{{ field.name || field.type + ' ' + (index + 1) }}
</span>
</label>
</div>
</template>
</div> </div>
</div> </div>
<ImageStep
v-else-if="currentField.type === 'image'"
v-model="values[currentField.uuid]"
:field="currentField"
:is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]"
/>
<SignatureStep
v-else-if="currentField.type === 'signature'"
ref="currentStep"
v-model="values[currentField.uuid]"
:field="currentField"
:is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@attached="attachments.push($event)"
@start="$refs.areas.scrollIntoField(currentField)"
@minimize="isFormVisible = false"
/>
<InitialsStep
v-else-if="currentField.type === 'initials'"
ref="currentStep"
v-model="values[currentField.uuid]"
:field="currentField"
:is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@attached="attachments.push($event)"
@start="$refs.areas.scrollIntoField(currentField)"
@focus="$refs.areas.scrollIntoField(currentField)"
@minimize="isFormVisible = false"
/>
<AttachmentStep
v-else-if="currentField.type === 'file'"
v-model="values[currentField.uuid]"
:is-direct-upload="isDirectUpload"
:field="currentField"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]"
/>
<PhoneStep
v-else-if="currentField.type === 'phone'"
ref="currentStep"
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"> <ImageStep
<button v-else-if="currentField.type === 'image'"
type="submit" v-model="values[currentField.uuid]"
class="base-button w-full flex justify-center" :field="currentField"
:disabled="isButtonDisabled" :is-direct-upload="isDirectUpload"
> :attachments-index="attachmentsIndex"
<span class="flex"> :submitter-slug="submitterSlug"
<IconInnerShadowTop @attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]"
v-if="isSubmitting" />
class="mr-1 animate-spin" <SignatureStep
/> v-else-if="currentField.type === 'signature'"
<span v-if="stepFields.length === currentStep + 1"> ref="currentStep"
{{ t('submit') }} v-model="values[currentField.uuid]"
</span> :field="currentField"
<span v-else> :is-direct-upload="isDirectUpload"
{{ t('next') }} :attachments-index="attachmentsIndex"
</span><span :submitter-slug="submitterSlug"
v-if="isSubmitting" @attached="attachments.push($event)"
class="w-6 flex justify-start mr-1" />
><span>...</span></span> <AttachmentStep
v-else-if="currentField.type === 'file'"
v-model="values[currentField.uuid]"
:is-direct-upload="isDirectUpload"
:field="currentField"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]"
/>
</div>
<div class="mt-6 md:mt-8">
<button
type="submit"
class="base-button w-full flex justify-center"
:disabled="isButtonDisabled"
>
<span class="flex">
<IconInnerShadowTop
v-if="isSubmitting"
class="mr-1 animate-spin"
/>
<span v-if="stepFields.length === currentStep + 1">
Submit
</span> </span>
</button> <span v-else>
</div> Next
</form> </span><span
<FormCompleted v-if="isSubmitting"
v-else class="w-6 flex justify-start mr-1"
:is-demo="isDemo" ><span>...</span></span>
:attribution="attribution" </span>
:completed-button="completedButton" </button>
:with-confetti="withConfetti" </div>
:can-send-email="canSendEmail && !!submitter.email" </form>
:submitter-slug="submitterSlug" <FormCompleted
/> v-else
<div class="flex justify-center"> :can-send-email="canSendEmail"
<div class="flex items-center mt-5 mb-1"> :submitter-slug="submitterSlug"
<a />
v-for="(step, index) in stepFields" <div class="flex justify-center">
:key="step[0].uuid" <div class="flex items-center mt-5 mb-1">
href="#" <a
class="inline border border-base-300 h-3 w-3 rounded-full mx-1" v-for="(step, index) in stepFields"
:class="{ 'bg-base-300': index === currentStep, 'bg-base-content': index < currentStep || isCompleted, 'bg-white': index > currentStep }" :key="step[0].uuid"
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(step, true)]" href="#"
/> class="inline border border-base-300 h-3 w-3 rounded-full mx-1"
</div> :class="{ 'bg-base-300': index === currentStep, 'bg-base-content': index < currentStep || isCompleted, 'bg-white': index > currentStep }"
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(step, true)]"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -345,13 +268,10 @@
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 FormCompleted from './completed' import FormCompleted from './completed'
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue' import { IconInnerShadowTop } from '@tabler/icons-vue'
import { t } from './i18n'
export default { export default {
name: 'SubmissionForm', name: 'SubmissionForm',
@@ -360,23 +280,13 @@ export default {
ImageStep, ImageStep,
SignatureStep, SignatureStep,
AttachmentStep, AttachmentStep,
InitialsStep,
MultiSelectStep, MultiSelectStep,
IconInnerShadowTop, IconInnerShadowTop,
IconArrowsDiagonal,
PhoneStep,
IconArrowsDiagonalMinimize2,
FormCompleted FormCompleted
}, },
provide () {
return {
baseUrl: this.baseUrl,
t: this.t
}
},
props: { props: {
submitter: { submitterSlug: {
type: Object, type: String,
required: true required: true
}, },
canSendEmail: { canSendEmail: {
@@ -384,21 +294,15 @@ export default {
required: false, required: false,
default: false default: false
}, },
submitterUuid: {
type: String,
required: true
},
attachments: { attachments: {
type: Array, type: Array,
required: false, required: false,
default: () => [] default: () => []
}, },
withConfetti: {
type: Boolean,
required: false,
default: false
},
baseUrl: {
type: String,
required: false,
default: ''
},
fields: { fields: {
type: Array, type: Array,
required: false, required: false,
@@ -406,79 +310,34 @@ export default {
}, },
authenticityToken: { authenticityToken: {
type: String, type: String,
required: false, required: true
default: ''
},
backgroundColor: {
type: String,
required: false,
default: ''
}, },
isDirectUpload: { isDirectUpload: {
type: Boolean, type: Boolean,
required: false, required: true,
default: false default: false
}, },
allowToSkip: {
type: Boolean,
required: false,
default: true
},
goToLast: {
type: Boolean,
required: false,
default: true
},
isDemo: {
type: Boolean,
required: false,
default: false
},
attribution: {
type: Boolean,
required: false,
default: true
},
values: { values: {
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,
currentStep: 0, currentStep: 0,
isSubmitting: false, isSubmitting: false
submittedValues: {},
recalculateButtonDisabledKey: ''
} }
}, },
computed: { computed: {
currentStepFields () { currentStepFields () {
return this.stepFields[this.currentStep] return this.stepFields[this.currentStep]
}, },
submitterSlug () {
return this.submitter.slug
},
isAnonymousChecboxes () {
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name) && this.currentStepFields.length > 4
},
isButtonDisabled () { isButtonDisabled () {
if (this.recalculateButtonDisabledKey) { return this.isSubmitting ||
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]
@@ -508,114 +367,30 @@ export default {
} }
}, },
mounted () { mounted () {
this.submittedValues = JSON.parse(JSON.stringify(this.values)) this.currentStep = Math.min(
this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.values[f.uuid]))) + 1,
if (this.goToLast) { this.stepFields.length - 1
this.currentStep = Math.min( )
this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.values[f.uuid]))) + 1,
this.stepFields.length - 1
)
}
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) { if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
this.$nextTick(() => { document.body.style.overflow = 'hidden'
const root = this.$root.$el.parentNode.getRootNode()
const scrollbox = root.getElementById('scrollbox')
const parent = root.body || root.querySelector('div')
parent.style.overflow = 'hidden' window.scrollbox.classList.add('h-full', 'overflow-y-auto')
window.scrollbox.parentNode.classList.add('h-screen', 'overflow-y-auto')
scrollbox.classList.add('h-full', 'overflow-y-auto')
scrollbox.parentNode.classList.add('h-screen', 'overflow-y-auto')
scrollbox.parentNode.style.maxHeight = '-webkit-fill-available'
})
} }
this.$nextTick(() => {
this.recalculateButtonDisabledKey = Math.random()
Promise.all([
this.maybeTrackEmailClick(),
this.maybeTrackSmsClick()
]).finally(() => {
this.trackViewForm()
})
})
}, },
methods: { methods: {
t,
maybeTrackEmailClick () {
const queryParams = new URLSearchParams(window.location.search)
if (queryParams.has('t')) {
const t = queryParams.get('t')
queryParams.delete('t')
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
window.history.replaceState({}, document.title, newUrl)
return fetch(this.baseUrl + '/api/submitter_email_clicks', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
t,
submitter_slug: this.submitterSlug
})
})
} else {
return Promise.resolve({})
}
},
maybeTrackSmsClick () {
const queryParams = new URLSearchParams(window.location.search)
if (queryParams.has('c')) {
const c = queryParams.get('c')
queryParams.delete('c')
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
window.history.replaceState({}, document.title, newUrl)
return fetch(this.baseUrl + '/api/submitter_sms_clicks', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
c,
submitter_slug: this.submitterSlug
})
})
} else {
return Promise.resolve({})
}
},
trackViewForm () {
fetch(this.baseUrl + '/api/submitter_form_views', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
submitter_slug: this.submitterSlug
})
})
},
goToStep (step, scrollToArea = false, clickUpload = false) { goToStep (step, scrollToArea = false, clickUpload = false) {
this.currentStep = this.stepFields.indexOf(step) this.currentStep = this.stepFields.indexOf(step)
this.$nextTick(() => { this.$nextTick(() => {
this.recalculateButtonDisabledKey = Math.random()
if (scrollToArea) { if (scrollToArea) {
this.$refs.areas.scrollIntoField(step[0]) this.$refs.areas.scrollIntoField(step[0])
} }
this.$refs.form.querySelector('input[type="date"], input[type="text"], select')?.focus() this.$refs.form.querySelector('input[type="date"], input[type="text"], select')?.focus()
if (clickUpload && !this.values[this.currentField.uuid] && ['file', 'image'].includes(this.currentField.type)) { if (clickUpload && !this.values[this.currentField.uuid]) {
this.$refs.form.querySelector('input[type="file"]')?.click() this.$refs.form.querySelector('input[type="file"]')?.click()
} }
}) })
@@ -624,7 +399,7 @@ export default {
if (this.isCompleted) { if (this.isCompleted) {
return Promise.resolve({}) return Promise.resolve({})
} else { } else {
return fetch(this.baseUrl + this.submitPath, { return fetch(this.submitPath, {
method: 'POST', method: 'POST',
body: formData || new FormData(this.$refs.form) body: formData || new FormData(this.$refs.form)
}) })
@@ -633,46 +408,28 @@ export default {
async submitStep () { async submitStep () {
this.isSubmitting = true this.isSubmitting = true
const stepPromise = ['signature', 'phone', 'initials'].includes(this.currentField.type) const stepPromise = this.currentField.type === 'signature'
? this.$refs.currentStep.submit ? this.$refs.currentStep.submit
: () => Promise.resolve({}) : () => Promise.resolve({})
stepPromise().then(async () => { await stepPromise()
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)
if (this.currentStep === this.stepFields.length - 1 && !emptyRequiredField) { if (this.currentStep === this.stepFields.length - 1) {
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]
const nextStep = emptyRequiredField || this.stepFields[this.currentStep + 1]
if (nextStep) {
this.goToStep(nextStep, true)
} else {
this.isCompleted = true
}
}).catch(error => {
console.error('Error submitting form:', error)
}).finally(() => {
this.isSubmitting = false
})
}).catch(error => { }).catch(error => {
console.log(error) console.error('Error submitting form:', error)
}).finally(() => { }).finally(() => {
this.isSubmitting = false this.isSubmitting = false
}) })
-314
View File
@@ -1,314 +0,0 @@
const en = {
submit_form: 'Submit Form',
type_here: 'Type here',
optional: 'optional',
select_your_option: 'Select your option',
complete_hightlighted_checkboxes_and_click: 'Complete hightlighted checkboxes and click',
submit: 'submit',
next: 'next',
click_to_upload: 'Click to upload',
or_drag_and_drop_files: 'or drag and drop files',
send_copy_via_email: 'Send copy via email',
download: 'Download',
signature: 'Signature',
initials: 'Initials',
clear: 'Clear',
redraw: 'Redraw',
draw_initials: 'Draw initials',
type_signature_here: 'Type signature here',
type_initial_here: 'Type initials here',
form_has_been_completed: 'Form has been completed!',
create_a_free_account: 'Create a Free Account',
signed_with: 'Signed with',
please_check_the_box_to_continue: 'Please check the box to continue',
open_source_documents_software: 'open source documents software',
verified_phone_number: 'Verify Phone Number',
use_international_format: 'Use internatioanl format: +1xxx',
six_digits_code: '6-digit code',
change_phone_number: 'Change phone number',
sending: 'Sending...',
resend_code: 'Re-send code',
verification_code_has_been_resent: 'Verification code has been re-sent via SMS',
email_has_been_sent: 'Email has been sent'
}
const es = {
submit_form: 'Enviar Formulario',
type_here: 'Escribe aquí',
optional: 'opcional',
select_your_option: 'Selecciona tu opción',
complete_hightlighted_checkboxes_and_click: 'Completa las casillas resaltadas y haz clic',
submit: 'enviar',
next: 'siguiente',
click_to_upload: 'Haz clic para cargar',
or_drag_and_drop_files: 'o arrastra y suelta archivos',
send_copy_via_email: 'Enviar copia por correo electrónico',
download: 'Descargar',
signature: 'Firma',
initials: 'Iniciales',
clear: 'Borrar',
redraw: 'Redibujar',
draw_initials: 'Dibujar iniciales',
type_signature_here: 'Escribe la firma aquí',
type_initial_here: 'Escribe las iniciales aquí',
form_has_been_completed: '¡El formulario ha sido completado!',
create_a_free_account: 'Crear una Cuenta Gratuita',
signed_with: 'Firmado con',
please_check_the_box_to_continue: 'Por favor marque la casilla para continuar',
open_source_documents_software: 'software de documentos de código abierto',
verified_phone_number: 'Verificar número de teléfono',
use_international_format: 'Usar formato internacional: +1xxx',
six_digits_code: 'Código de 6 dígitos',
change_phone_number: 'Cambiar número de teléfono',
sending: 'Enviando...',
resend_code: 'Reenviar código',
verification_code_has_been_resent: 'El código de verificación ha sido reenviado por SMS',
email_has_been_sent: 'El correo electrónico ha sido enviado'
}
const it = {
submit_form: 'Invia Modulo',
type_here: 'Digita qui',
optional: 'opzionale',
select_your_option: 'Seleziona la tua opzione',
complete_hightlighted_checkboxes_and_click: 'Completa le caselle evidenziate e fai clic',
submit: 'invia',
next: 'avanti',
click_to_upload: 'Clicca per caricare',
or_drag_and_drop_files: 'oppure trascina e rilascia i file',
send_copy_via_email: 'Invia copia via email',
download: 'Scarica',
signature: 'Firma',
initials: 'Iniziali',
clear: 'Cancella',
redraw: 'Ridisegna',
draw_initials: 'Disegna iniziali',
type_signature_here: 'Scrivi la firma qui',
type_initial_here: 'Scrivi le iniziali qui',
form_has_been_completed: 'Il modulo è stato completato!',
create_a_free_account: 'Crea un Account Gratuito',
signed_with: 'Firmato con',
please_check_the_box_to_continue: 'Si prega di spuntare la casella per continuare',
open_source_documents_software: 'software di documenti open source',
verified_phone_number: 'Verifica numero di telefono',
use_international_format: 'Usa formato internazionale: +1xxx',
six_digits_code: 'Codice a 6 cifre',
change_phone_number: 'Cambia numero di telefono',
sending: 'Invio in corso...',
resend_code: 'Rinvia codice',
verification_code_has_been_resent: 'Il codice di verifica è stato rinviato tramite SMS',
email_has_been_sent: "L'email è stata inviata"
}
const de = {
submit_form: 'Formular absenden',
type_here: 'Hier eingeben',
optional: 'optional',
select_your_option: 'Wähle deine Option',
complete_hightlighted_checkboxes_and_click: 'Markierte Kontrollkästchen ausfüllen und klicken',
submit: 'absenden',
next: 'weiter',
click_to_upload: 'Klicken zum Hochladen',
or_drag_and_drop_files: 'oder Dateien hierher ziehen und ablegen',
send_copy_via_email: 'Kopie per E-Mail senden',
download: 'Herunterladen',
signature: 'Unterschrift',
initials: 'Initialen',
clear: 'Löschen',
redraw: 'Neu zeichnen',
draw_initials: 'Initialen zeichnen',
type_signature_here: 'Unterschrift hier eingeben',
type_initial_here: 'Initialen hier eingeben',
form_has_been_completed: 'Formular wurde ausgefüllt!',
create_a_free_account: 'Kostenloses Konto erstellen',
signed_with: 'Unterschrieben mit',
please_check_the_box_to_continue: 'Bitte setzen Sie das Häkchen, um fortzufahren',
open_source_documents_software: 'Open-Source-Dokumentensoftware',
verified_phone_number: 'Telefonnummer überprüfen',
use_international_format: 'Internationales Format verwenden: +1xxx',
six_digits_code: '6-stelliger Code',
change_phone_number: 'Telefonnummer ändern',
sending: 'Senden...',
resend_code: 'Code erneut senden',
verification_code_has_been_resent: 'Die Verifizierungscode wurde erneut per SMS gesendet',
email_has_been_sent: 'Die E-Mail wurde gesendet'
}
const fr = {
submit_form: 'Envoyer le Formulaire',
type_here: 'Tapez ici',
optional: 'facultatif',
select_your_option: 'Sélectionnez votre option',
complete_hightlighted_checkboxes_and_click: 'Complétez les cases à cocher en surbrillance et cliquez',
submit: 'envoyer',
next: 'suivant',
click_to_upload: 'Cliquez pour télécharger',
or_drag_and_drop_files: 'ou faites glisser-déposer les fichiers',
send_copy_via_email: 'Envoyer une copie par e-mail',
download: 'Télécharger',
signature: 'Signature',
initials: 'Initiales',
clear: 'Effacer',
redraw: 'Redessiner',
draw_initials: 'Dessiner les initiales',
type_signature_here: 'Tapez la signature ici',
type_initial_here: 'Tapez les initiales ici',
form_has_been_completed: 'Le formulaire a été complété !',
create_a_free_account: 'Créer un Compte Gratuit',
signed_with: 'Signé avec',
please_check_the_box_to_continue: 'Veuillez cocher la case pour continuer',
open_source_documents_software: 'logiciel de documents open source',
verified_phone_number: 'Vérifier le numéro de téléphone',
use_international_format: 'Utiliser le format international : +1xxx',
six_digits_code: 'Code à 6 chiffres',
change_phone_number: 'Changer le numéro de téléphone',
sending: 'Envoi en cours...',
resend_code: 'Renvoyer le code',
verification_code_has_been_resent: 'Le code de vérification a été renvoyé par SMS',
email_has_been_sent: "L'email a été envoyé"
}
const pl = {
submit_form: 'Wyślij Formularz',
type_here: 'Wpisz tutaj',
optional: 'opcjonalny',
select_your_option: 'Wybierz swoją opcję',
complete_hightlighted_checkboxes_and_click: 'Wypełnij zaznaczone pola wyboru i kliknij',
submit: 'wyślij',
next: 'dalej',
click_to_upload: 'Kliknij, aby przesłać',
or_drag_and_drop_files: 'lub przeciągnij i upuść pliki',
send_copy_via_email: 'Wyślij kopię drogą mailową',
download: 'Pobierz',
signature: 'Podpis',
initials: 'Inicjały',
clear: 'Wyczyść',
redraw: 'Przerysuj',
draw_initials: 'Narysuj inicjały',
type_signature_here: 'Wpisz podpis tutaj',
type_initial_here: 'Wpisz inicjały tutaj',
form_has_been_completed: 'Formularz został wypełniony!',
create_a_free_account: 'Utwórz darmowe konto',
signed_with: 'Podpisane za pomocą',
please_check_the_box_to_continue: 'Proszę zaznaczyć pole, aby kontynuować',
open_source_documents_software: 'oprogramowanie do dokumentów open source',
verified_phone_number: 'Zweryfikuj numer telefonu',
use_international_format: 'Użyj międzynarodowego formatu: +1xxx',
six_digits_code: '6-cyfrowy kod',
change_phone_number: 'Zmień numer telefonu',
sending: 'Wysyłanie...',
resend_code: 'Ponownie wyślij kod',
verification_code_has_been_resent: 'Kod weryfikacyjny został ponownie wysłany',
email_has_been_sent: 'E-mail został wysłany'
}
const uk = {
submit_form: 'Надіслати Форму',
type_here: 'Введіть тут',
optional: 'необов’язково',
select_your_option: 'Виберіть варіант',
complete_hightlighted_checkboxes_and_click: 'Заповніть позначені прапорці та натисніть',
submit: 'надіслати',
next: 'далі',
click_to_upload: 'Клацніть, щоб завантажити',
or_drag_and_drop_files: 'або перетягніть файли сюди',
send_copy_via_email: 'Надіслати копію електронною поштою',
download: 'Завантажити',
signature: 'Підпис',
initials: 'Ініціали',
clear: 'Очистити',
redraw: 'Перемалювати',
draw_initials: 'Намалювати ініціали',
type_signature_here: 'Введіть підпис тут',
type_initial_here: 'Введіть ініціали тут',
form_has_been_completed: 'Форму заповнено!',
create_a_free_account: 'Створити безкоштовний обліковий запис',
signed_with: 'Підписано за допомогою',
please_check_the_box_to_continue: 'Будь ласка, позначте прапорець, щоб продовжити',
open_source_documents_software: 'відкритий програмний засіб для документів',
verified_phone_number: 'Підтвердіть номер телефону',
use_international_format: 'Використовуйте міжнародний формат: +1xxx',
six_digits_code: '6-значний код',
change_phone_number: 'Змінити номер телефону',
sending: 'Надсилаю...',
resend_code: 'Повторно відправити код',
verification_code_has_been_resent: 'Код підтвердження був повторно надісланий',
email_has_been_sent: 'Електронний лист був відправлений'
}
const cs = {
submit_form: 'Odeslat formulář',
type_here: 'Zadejte zde',
optional: 'volitelné',
select_your_option: 'Vyberte svou volbu',
complete_hightlighted_checkboxes_and_click: 'Označte zvýrazněné zaškrtávací políčka a klikněte na',
submit: 'odeslat',
next: 'další',
click_to_upload: 'Klikněte pro nahrání',
or_drag_and_drop_files: 'nebo přetáhněte soubory sem',
send_copy_via_email: 'Odeslat kopii e-mailem',
download: 'Stáhnout',
signature: 'Podpis',
initials: 'Iniciály',
clear: 'Smazat',
redraw: 'Překreslit',
draw_initials: 'Nakreslit iniciály',
type_signature_here: 'Sem zadejte podpis',
type_initial_here: 'Sem zadejte iniciály',
form_has_been_completed: 'Formulář byl dokončen!',
create_a_free_account: 'Vytvořit bezplatný účet',
signed_with: 'Podepsáno pomocí',
please_check_the_box_to_continue: 'Prosím, zaškrtněte políčko pro pokračování',
open_source_documents_software: 'open source software pro dokumenty',
verified_phone_number: 'Ověřte telefonní číslo',
use_international_format: 'Použijte mezinárodní formát: +1xxx',
six_digits_code: '6-místný kód',
change_phone_number: 'Změnit telefonní číslo',
sending: 'Odesílání...',
resend_code: 'Znovu odeslat kód',
verification_code_has_been_resent: 'Ověřovací kód byl znovu odeslán',
email_has_been_sent: 'E-mail byl odeslán'
}
const pt = {
submit_form: 'Enviar Formulário',
type_here: 'Digite aqui',
optional: 'opcional',
select_your_option: 'Selecione sua opção',
complete_hightlighted_checkboxes_and_click: 'Complete as caixas de seleção destacadas e clique',
submit: 'enviar',
next: 'próximo',
click_to_upload: 'Clique para fazer o upload',
or_drag_and_drop_files: 'ou arraste e solte arquivos',
send_copy_via_email: 'Enviar cópia por e-mail',
download: 'Baixar',
signature: 'Assinatura',
initials: 'Iniciais',
clear: 'Limpar',
redraw: 'Redesenhar',
draw_initials: 'Desenhar iniciais',
type_signature_here: 'Digite a assinatura aqui',
type_initial_here: 'Digite as iniciais aqui',
form_has_been_completed: 'O formulário foi concluído!',
create_a_free_account: 'Criar uma Conta Gratuita',
signed_with: 'Assinado com',
please_check_the_box_to_continue: 'Por favor, marque a caixa para continuar',
open_source_documents_software: 'software de documentos de código aberto',
verified_phone_number: 'Verificar Número de Telefone',
use_international_format: 'Use formato internacional: +1xxx',
six_digits_code: 'Código de 6 dígitos',
change_phone_number: 'Alterar número de telefone',
sending: 'Enviando...',
resend_code: 'Reenviar código',
verification_code_has_been_resent: 'O código de verificação foi reenviado via SMS',
email_has_been_sent: 'Email enviado'
}
const i18n = { en, es, it, de, fr, pl, uk, cs, pt }
const browserLanguage = (navigator.language || navigator.userLanguage || 'en').split('-')[0]
const t = (key) => i18n[browserLanguage]?.[key] || i18n.en[key] || key
export default i18n
export { t }
@@ -1,244 +0,0 @@
<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')"
>
<button
id="type_text_button"
class="btn btn-sm btn-circle"
:class="{ 'btn-neutral': isDrawInitials, 'btn-outline': !isDrawInitials }"
@click.prevent="toggleTextInput"
>
<IconSignature :width="16" />
</button>
</span>
<button
v-if="modelValue"
class="btn btn-outline btn-sm"
@click.prevent="remove"
>
<IconReload :width="16" />
{{ t('clear') }}
</button>
<button
v-else
class="btn btn-outline btn-sm"
@click.prevent="clear"
>
<IconReload :width="16" />
{{ t('clear') }}
</button>
<button
title="Minimize"
class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')"
>
<IconArrowsDiagonalMinimize2
:width="20"
:height="20"
/>
</button>
</div>
</div>
<input
:value="modelValue"
type="hidden"
:name="`values[${field.uuid}]`"
>
<img
v-if="modelValue"
:src="attachmentsIndex[modelValue].url"
class="mx-auto bg-white border border-base-300 rounded max-h-72"
>
<canvas
v-show="!modelValue"
ref="canvas"
class="bg-white border border-base-300 rounded"
/>
<input
v-if="!isDrawInitials"
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'
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: () => ({})
},
modelValue: {
type: String,
required: false,
default: ''
}
},
emits: ['attached', 'update:model-value', 'start', 'minimize', 'focus'],
data () {
return {
isInitialsStarted: false,
isDrawInitials: false
}
},
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')
}
const { default: SignaturePad } = await import('signature_pad')
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', '')
},
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) {
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">
@@ -1,155 +0,0 @@
<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
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"
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>
+33 -177
View File
@@ -3,64 +3,23 @@
<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 || t('signature') }}</label> >{{ field.name || 'Signature' }}</label>
<div class="space-x-2 flex"> <button
<span v-if="modelValue"
class="tooltip" class="btn btn-outline btn-sm"
data-tip="Type text" @click.prevent="remove"
> >
<button <IconReload :width="16" />
id="type_text_button" Redraw
class="btn btn-sm btn-circle" </button>
:class="{ 'btn-neutral': isTextSignature, 'btn-outline': !isTextSignature }" <button
@click.prevent="toggleTextInput" v-else
> class="btn btn-outline btn-sm"
<IconTextSize :width="16" /> @click.prevent="clear"
</button> >
</span> <IconReload :width="16" />
<span Clear
class="tooltip" </button>
data-tip="Take photo"
>
<label
class="btn btn-outline btn-sm btn-circle"
>
<IconCamera :width="16" />
<input
type="file"
hidden
accept="image/*"
@change="drawImage"
>
</label>
</span>
<button
v-if="modelValue"
class="btn btn-outline btn-sm"
@click.prevent="remove"
>
<IconReload :width="16" />
{{ t('redraw') }}
</button>
<button
v-else
class="btn btn-outline btn-sm"
@click.prevent="clear"
>
<IconReload :width="16" />
{{ t('clear') }}
</button>
<button
title="Minimize"
class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')"
>
<IconArrowsDiagonalMinimize2
:width="20"
:height="20"
/>
</button>
</div>
</div> </div>
<input <input
:value="modelValue" :value="modelValue"
@@ -70,39 +29,24 @@
<img <img
v-if="modelValue" v-if="modelValue"
:src="attachmentsIndex[modelValue].url" :src="attachmentsIndex[modelValue].url"
class="mx-auto bg-white border border-base-300 rounded max-h-72" class="w-full bg-white border border-base-300 rounded"
> >
<canvas <canvas
v-show="!modelValue" v-show="!modelValue"
ref="canvas" ref="canvas"
class="bg-white border border-base-300 rounded" class="bg-white border border-base-300 rounded"
/> />
<input
v-if="isTextSignature"
id="signature_text_input"
ref="textInput"
class="base-input !text-2xl w-full mt-6"
:required="field.required"
:placeholder="`${t('type_signature_here')}...`"
type="text"
@input="updateWrittenSignature"
>
</div> </div>
</template> </template>
<script> <script>
import { IconReload, IconCamera, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue' import { IconReload } from '@tabler/icons-vue'
import { cropCanvasAndExportToPNG } from './crop_canvas'
export default { export default {
name: 'SignatureStep', name: 'SignatureStep',
components: { components: {
IconReload, IconReload
IconCamera,
IconTextSize,
IconArrowsDiagonalMinimize2
}, },
inject: ['baseUrl', 't'],
props: { props: {
field: { field: {
type: Object, type: Object,
@@ -128,19 +72,16 @@ export default {
default: '' default: ''
} }
}, },
emits: ['attached', 'update:model-value', 'start', 'minimize'], emits: ['attached', 'update:model-value'],
data () { data () {
return { return {
isSignatureStarted: false, isSignatureStarted: false
isTextSignature: false
} }
}, },
async mounted () { async mounted () {
this.$nextTick(() => { this.$nextTick(() => {
if (this.$refs.canvas) { this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 3
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 3
}
}) })
if (this.isDirectUpload) { if (this.isDirectUpload) {
@@ -149,15 +90,11 @@ export default {
const { default: SignaturePad } = await import('signature_pad') 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.pad.addEventListener('beginStroke', () => {
this.isSignatureStarted = true this.isSignatureStarted = true
})
this.$emit('start')
})
}
}, },
methods: { methods: {
remove () { remove () {
@@ -167,87 +104,6 @@ export default {
this.pad.clear() this.pad.clear()
this.isSignatureStarted = false this.isSignatureStarted = false
if (this.$refs.textInput) {
this.$refs.textInput.value = ''
}
},
updateWrittenSignature (e) {
this.isSignatureStarted = 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.isTextSignature = !this.isTextSignature
if (this.isTextSignature) {
this.$nextTick(() => {
this.$refs.textInput.focus()
this.$emit('start')
})
}
},
drawImage (event) {
this.remove()
this.isSignatureStarted = true
const file = event.target.files[0]
if (file && file.type.match('image.*')) {
const reader = new FileReader()
reader.onload = (event) => {
const img = new Image()
img.src = event.target.result
img.onload = () => {
const canvas = this.$refs.canvas
const context = canvas.getContext('2d')
const aspectRatio = img.width / img.height
let targetWidth = canvas.width
let targetHeight = canvas.height
if (canvas.width / canvas.height > aspectRatio) {
targetWidth = canvas.height * aspectRatio
} else {
targetHeight = canvas.width / aspectRatio
}
if (targetHeight > targetWidth) {
const scale = targetHeight / targetWidth
targetWidth = targetWidth * scale
targetHeight = targetHeight * scale
}
const x = (canvas.width - targetWidth) / 2
const y = (canvas.height - targetHeight) / 2
context.clearRect(0, 0, canvas.width, canvas.height)
context.drawImage(img, x, y, targetWidth, targetHeight)
this.$emit('start')
}
}
reader.readAsDataURL(file)
}
}, },
async submit () { async submit () {
if (this.modelValue) { if (this.modelValue) {
@@ -255,7 +111,7 @@ export default {
} }
return new Promise((resolve) => { return new Promise((resolve) => {
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => { this.$refs.canvas.toBlob(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) {
@@ -265,7 +121,7 @@ export default {
file, file,
'/direct_uploads' '/direct_uploads'
).create((_error, data) => { ).create((_error, data) => {
fetch(this.baseUrl + '/api/attachments', { fetch('/api/attachments', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
submitter_slug: this.submitterSlug, submitter_slug: this.submitterSlug,
@@ -287,17 +143,17 @@ export default {
formData.append('submitter_slug', this.submitterSlug) formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments') formData.append('name', 'attachments')
return fetch(this.baseUrl + '/api/attachments', { return fetch('/api/attachments', {
method: 'POST', method: 'POST',
body: formData body: formData
}).then((resp) => resp.json()).then((attachment) => { }).then((resp) => resp.json()).then((attachment) => {
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid) this.$emit('update:model-value', attachment.uuid)
this.$emit('attached', attachment)
return resolve(attachment) return resolve(attachment)
}) })
} }
}) }, 'image/png')
}) })
} }
} }
+19 -102
View File
@@ -4,7 +4,6 @@
: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"
@@ -33,7 +32,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, invisible: !isNameFocus && !isSelected }" :class="{ 'flex z-10': isNameFocus || isSelected, hidden: !isNameFocus && !isSelected }"
style="top: -25px; height: 25px" style="top: -25px; height: 25px"
@mousedown.stop @mousedown.stop
@pointerdown.stop @pointerdown.stop
@@ -66,7 +65,7 @@
@blur="onNameBlur" @blur="onNameBlur"
>{{ field.name || defaultName }}</span> >{{ field.name || defaultName }}</span>
<div <div
v-if="isNameFocus && field.type !== 'checkbox'" v-if="isNameFocus"
class="flex items-center ml-1.5" class="flex items-center ml-1.5"
> >
<input <input
@@ -109,14 +108,12 @@
</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-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" 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"
@mousedown.stop="startResize" @mousedown.stop="startResize"
@touchstart="startTouchResize"
/> />
</div> </div>
</template> </template>
@@ -189,12 +186,7 @@ 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 () {
@@ -203,12 +195,7 @@ 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 () {
@@ -239,14 +226,14 @@ export default {
} }
}, },
startResizeCell (e) { startResizeCell (e) {
this.$el.getRootNode().addEventListener('mousemove', this.onResizeCell) document.addEventListener('mousemove', this.onResizeCell)
this.$el.getRootNode().addEventListener('mouseup', this.stopResizeCell) document.addEventListener('mouseup', this.stopResizeCell)
this.$emit('start-resize', 'ew') this.$emit('start-resize', 'ew')
}, },
stopResizeCell (e) { stopResizeCell (e) {
this.$el.getRootNode().removeEventListener('mousemove', this.onResizeCell) document.removeEventListener('mousemove', this.onResizeCell)
this.$el.getRootNode().removeEventListener('mouseup', this.stopResizeCell) document.removeEventListener('mouseup', this.stopResizeCell)
this.$emit('stop-resize') this.$emit('stop-resize')
@@ -279,13 +266,11 @@ export default {
}) })
}, },
onNameBlur (e) { onNameBlur (e) {
const text = this.$refs.name.innerText.trim()
this.isNameFocus = false this.isNameFocus = false
this.$refs.name.style.minWidth = '' this.$refs.name.style.minWidth = ''
if (text) { if (e.target.innerText.trim()) {
this.field.name = text this.field.name = e.target.innerText.trim()
} else { } else {
this.field.name = '' this.field.name = ''
this.$refs.name.innerText = this.defaultName this.$refs.name.innerText = this.defaultName
@@ -317,55 +302,14 @@ export default {
this.dragFrom = { x: e.clientX - rect.left, y: e.clientY - rect.top } this.dragFrom = { x: e.clientX - rect.left, y: e.clientY - rect.top }
this.$el.getRootNode().addEventListener('mousemove', this.drag) document.addEventListener('mousemove', this.drag)
this.$el.getRootNode().addEventListener('mouseup', this.stopDrag) document.addEventListener('mouseup', this.stopDrag)
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) document.removeEventListener('mousemove', this.drag)
this.$el.getRootNode().removeEventListener('mouseup', this.stopDrag) document.removeEventListener('mouseup', this.stopDrag)
if (this.isDragged) { if (this.isDragged) {
this.save() this.save()
@@ -378,41 +322,14 @@ export default {
startResize () { startResize () {
this.selectedAreaRef.value = this.area this.selectedAreaRef.value = this.area
this.$el.getRootNode().addEventListener('mousemove', this.resize) document.addEventListener('mousemove', this.resize)
this.$el.getRootNode().addEventListener('mouseup', this.stopResize) document.addEventListener('mouseup', this.stopResize)
this.$emit('start-resize', 'nwse') this.$emit('start-resize', 'nwse')
}, },
stopResize () { stopResize () {
this.$el.getRootNode().removeEventListener('mousemove', this.resize) document.removeEventListener('mousemove', this.resize)
this.$el.getRootNode().removeEventListener('mouseup', this.stopResize) document.removeEventListener('mouseup', this.stopResize)
this.$emit('stop-resize')
this.save()
},
startTouchResize (e) {
this.selectedAreaRef.value = this.area
this.$refs?.name?.blur()
e.preventDefault()
this.$el.getRootNode().addEventListener('touchmove', this.touchResize)
this.$el.getRootNode().addEventListener('touchend', this.stopTouchResize)
this.$emit('start-resize', 'nwse')
},
touchResize (e) {
const page = this.$parent.$refs.mask.previousSibling
const rect = page.getBoundingClientRect()
this.area.w = (e.touches[0].clientX - rect.left) / rect.width - this.area.x
this.area.h = (e.touches[0].clientY - rect.top) / rect.height - this.area.y
},
stopTouchResize () {
this.$el.getRootNode().removeEventListener('touchmove', this.touchResize)
this.$el.getRootNode().removeEventListener('touchend', this.stopTouchResize)
this.$emit('stop-resize') this.$emit('stop-resize')
+91 -358
View File
@@ -1,17 +1,11 @@
<template> <template>
<div <div
style="max-width: 1600px" style="max-width: 1600px"
class="mx-auto pl-3 md:pl-4 h-full" class="mx-auto pl-4"
> >
<div <div class="flex justify-between py-1.5 items-center pr-4">
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 href="/">
v-if="withLogo"
href="/"
>
<Logo /> <Logo />
</a> </a>
<Contenteditable <Contenteditable
@@ -22,50 +16,46 @@
/> />
</div> </div>
<div class="space-x-3 flex items-center"> <div class="space-x-3 flex items-center">
<slot <a
v-if="$slots.buttons" :href="`/templates/${template.id}/submissions/new`"
name="buttons" data-turbo-frame="modal"
/> class="btn btn-primary"
<template v-else> >
<a <IconUsersPlus
:href="`/templates/${template.id}/submissions/new`" width="20"
data-turbo-frame="modal" class="inline"
class="btn btn-primary text-base" />
> <span class="hidden md:inline">
<IconUsersPlus Recipients
width="20" </span>
class="inline" </a>
/> <button
<span class="hidden md:inline"> class="base-button"
Recipients :class="{ disabled: isSaving }"
</span> v-bind="isSaving ? { disabled: true } : {}"
</a> @click.prevent="onSaveClick"
<button >
class="base-button" <IconInnerShadowTop
:class="{ disabled: isSaving }" v-if="isSaving"
v-bind="isSaving ? { disabled: true } : {}" width="20"
@click.prevent="onSaveClick" class="animate-spin"
> />
<IconInnerShadowTop <IconDeviceFloppy
v-if="isSaving" v-else
width="22" width="20"
class="animate-spin" />
/> <span class="hidden md:inline">
<IconDeviceFloppy Save
v-else </span>
width="22" </button>
/>
<span class="hidden md:inline">
Save
</span>
</button>
</template>
</div> </div>
</div> </div>
<div class="flex md:max-h-[calc(100vh-60px)]"> <div
class="flex"
style="max-height: calc(100vh - 60px)"
>
<div <div
ref="previews" ref="previews"
:style="{ 'display': isBreakpointLg ? 'none' : 'initial' }"
class="overflow-y-auto overflow-x-hidden w-52 flex-none pr-3 mt-0.5 pt-0.5 hidden lg:block" class="overflow-y-auto overflow-x-hidden w-52 flex-none pr-3 mt-0.5 pt-0.5 hidden lg:block"
> >
<DocumentPreview <DocumentPreview
@@ -74,30 +64,22 @@
: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"
:is-direct-upload="isDirectUpload"
@scroll-to="scrollIntoDocument(item)" @scroll-to="scrollIntoDocument(item)"
@remove="onDocumentRemove" @remove="onDocumentRemove"
@replace="onDocumentReplace"
@up="moveDocument(item, -1)" @up="moveDocument(item, -1)"
@down="moveDocument(item, 1)" @down="moveDocument(item, 1)"
@change="save" @change="save"
/> />
<div <div class="sticky bottom-0 bg-base-100 py-2">
class="sticky bottom-0 py-2"
:class="{ 'bg-base-100': withStickySubmitters }"
>
<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-hidden md:overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5"> <div class="w-full 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"
@@ -105,104 +87,39 @@
<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"
/> />
<template v-else> <template v-else>
<template <Document
v-for="document in sortedDocuments" v-for="document in sortedDocuments"
:key="document.uuid" :key="document.uuid"
> :ref="setDocumentRefs"
<Document :areas-index="fieldAreasIndex[document.uuid]"
:ref="setDocumentRefs" :selected-submitter="selectedSubmitter"
:areas-index="fieldAreasIndex[document.uuid]" :document="document"
:selected-submitter="selectedSubmitter" :is-drag="!!dragFieldType"
:document="document" :draw-field="drawField"
:is-drag="!!dragFieldType" @draw="onDraw"
:draw-field="drawField" @drop-field="onDropfield"
@draw="onDraw" @remove-area="removeArea"
@drop-field="onDropfield" />
@remove-area="removeArea"
/>
<DocumentControls
v-if="isBreakpointLg"
:with-arrows="template.schema.length > 1"
:item="template.schema.find((item) => item.attachment_uuid === document.uuid)"
:document="document"
:template="template"
:is-direct-upload="isDirectUpload"
class="pb-2 mb-2 border-b border-base-300 border-dashed"
@remove="onDocumentRemove"
@replace="onDocumentReplace"
@up="moveDocument(template.schema.find((item) => item.attachment_uuid === document.uuid), -1)"
@down="moveDocument(template.schema.find((item) => item.attachment_uuid === document.uuid), 1)"
@change="save"
/>
</template>
<div
v-if="sortedDocuments.length && isBreakpointLg"
class="pb-4"
>
<Upload
:template-id="template.id"
:is-direct-upload="isDirectUpload"
@success="updateFromUpload"
/>
</div>
</template> </template>
</div> </div>
<span <div
v-if="drawField" v-if="sortedDocuments.length"
class="fixed text-center w-full left-1/2 bottom-0 transform -translate-x-1/2" class="sticky md:hidden"
style="bottom: 100px"
> >
<span <div class="px-4 py-3 rounded-2xl bg-base-200 flex items-center justify-between ml-4 mr-6">
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" <span class="w-full text-center text-lg">
> 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>
<a </div>
href="#" </div>
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 pt-0.5 pr-4 pl-0.5 hidden md:block"
:class="drawField ? 'overflow-hidden' : 'overflow-auto'" :class="drawField ? 'overflow-hidden' : 'overflow-auto'"
> >
<div <div
@@ -216,7 +133,7 @@
<p> <p>
<button <button
class="base-button" class="base-button"
@click="drawField = null" @click="drawField = false"
> >
Cancel Cancel
</button> </button>
@@ -229,7 +146,6 @@
:fields="template.fields" :fields="template.fields"
:submitters="template.submitters" :submitters="template.submitters"
:selected-submitter="selectedSubmitter" :selected-submitter="selectedSubmitter"
:with-sticky-submitters="withStickySubmitters"
@set-draw="drawField = $event" @set-draw="drawField = $event"
@set-drag="dragFieldType = $event" @set-drag="dragFieldType = $event"
@change-submitter="selectedSubmitter = $event" @change-submitter="selectedSubmitter = $event"
@@ -250,9 +166,7 @@ import Document from './document'
import Logo from './logo' 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 { 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'
@@ -262,13 +176,9 @@ export default {
Upload, Upload,
Document, Document,
Fields, Fields,
IconPlus,
FieldType,
IconX,
Logo, Logo,
Dropzone, Dropzone,
DocumentPreview, DocumentPreview,
DocumentControls,
IconInnerShadowTop, IconInnerShadowTop,
Contenteditable, Contenteditable,
IconUsersPlus, IconUsersPlus,
@@ -278,9 +188,6 @@ export default {
return { return {
template: this.template, template: this.template,
save: this.save, save: this.save,
baseFetch: this.baseFetch,
backgroundColor: this.backgroundColor,
withPhone: this.withPhone,
selectedAreaRef: computed(() => this.selectedAreaRef) selectedAreaRef: computed(() => this.selectedAreaRef)
} }
}, },
@@ -291,49 +198,13 @@ export default {
}, },
isDirectUpload: { isDirectUpload: {
type: Boolean, type: Boolean,
required: false, required: true,
default: false default: false
},
backgroundColor: {
type: String,
required: false,
default: ''
},
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
baseUrl: {
type: String,
required: false,
default: ''
},
withLogo: {
type: Boolean,
required: false,
default: true
},
withStickySubmitters: {
type: Boolean,
required: false,
default: true
},
withPhone: {
type: Boolean,
required: false,
default: false
},
fetchOptions: {
type: Object,
required: false,
default: () => ({ headers: {} })
} }
}, },
data () { data () {
return { return {
documentRefs: [], documentRefs: [],
isBreakpointLg: false,
isSaving: false, isSaving: false,
selectedSubmitter: null, selectedSubmitter: null,
drawField: null, drawField: null,
@@ -341,8 +212,6 @@ export default {
} }
}, },
computed: { computed: {
fieldIcons: FieldType.computed.fieldIcons,
fieldNames: FieldType.computed.fieldNames,
selectedAreaRef: () => ref(), selectedAreaRef: () => ref(),
fieldAreasIndex () { fieldAreasIndex () {
const areas = {} const areas = {}
@@ -372,79 +241,15 @@ 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.onWindowResize()
})
document.addEventListener('keyup', this.onKeyUp) document.addEventListener('keyup', this.onKeyUp)
window.addEventListener('keydown', this.onKeyDown)
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)
}, },
beforeUpdate () { beforeUpdate () {
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) {
const breakpointLg = 1024
this.isBreakpointLg = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth < breakpointLg
},
setDocumentRefs (el) { setDocumentRefs (el) {
if (el) { if (el) {
this.documentRefs.push(el) this.documentRefs.push(el)
@@ -467,19 +272,6 @@ 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))
@@ -491,31 +283,12 @@ 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
@@ -526,37 +299,33 @@ 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]
if (previousArea || area.w) { const areaW = previousArea?.w || (30 / pageMask.clientWidth)
const areaW = previousArea?.w || (30 / pageMask.clientWidth) const areaH = previousArea?.h || (30 / pageMask.clientHeight)
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]
} }
this.template.fields.push(field) area.w = areaW
area.h = areaH
this.selectedAreaRef.value = area
this.save()
} }
const field = {
name: '',
uuid: v4(),
required: true,
type,
submitter_uuid: this.selectedSubmitter.uuid,
areas: [area]
}
this.template.fields.push(field)
} }
this.selectedAreaRef.value = area
this.save()
}, },
onDropfield (area) { onDropfield (area) {
const field = { const field = {
@@ -564,7 +333,7 @@ export default {
type: this.dragFieldType, type: this.dragFieldType,
uuid: v4(), uuid: v4(),
submitter_uuid: this.selectedSubmitter.uuid, submitter_uuid: this.selectedSubmitter.uuid,
required: this.dragFieldType !== 'checkbox' required: true
} }
if (['select', 'multiple', 'radio'].includes(this.dragFieldType)) { if (['select', 'multiple', 'radio'].includes(this.dragFieldType)) {
@@ -584,7 +353,7 @@ export default {
if (this.selectedField?.type === this.dragFieldType) { if (this.selectedField?.type === this.dragFieldType) {
baseArea = this.selectedAreaRef.value baseArea = this.selectedAreaRef.value
} else if (previousField?.areas?.length) { } else if (previousField?.areas) {
baseArea = previousField.areas[previousField.areas.length - 1] baseArea = previousField.areas[previousField.areas.length - 1]
} else { } else {
if (['checkbox'].includes(this.dragFieldType)) { if (['checkbox'].includes(this.dragFieldType)) {
@@ -602,11 +371,6 @@ 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,
@@ -653,27 +417,6 @@ export default {
this.template.schema.splice(this.template.schema.indexOf(item), 1) this.template.schema.splice(this.template.schema.indexOf(item), 1)
} }
this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === item.attachment_uuid) {
field.areas.splice(field.areas.indexOf(area), 1)
}
})
})
this.save()
},
onDocumentReplace ({ replaceSchemaItem, schema, documents }) {
this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, schema[0])
this.template.documents.push(...documents)
this.template.fields.forEach((field) => {
(field.areas || []).forEach((area) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
area.attachment_uuid = schema[0].attachment_uuid
}
})
})
this.save() this.save()
}, },
moveDocument (item, direction) { moveDocument (item, direction) {
@@ -707,20 +450,10 @@ export default {
this.selectedAreaRef.value = area this.selectedAreaRef.value = area
}, },
baseFetch (path, options = {}) {
return fetch(this.baseUrl + path, {
...options,
headers: { ...this.fetchOptions.headers, ...options.headers }
})
},
save () { save () {
if (this.$el.closest('template-builder')) { 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 fetch(`/api/templates/${this.template.id}`, {
return this.baseFetch(`/api/templates/${this.template.id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
template: { template: {
@@ -30,7 +30,7 @@
: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(), selectOnEditClick && selectContent()]" @click="focusContenteditable"
/> />
</div> </div>
</template> </template>
@@ -64,11 +64,6 @@ 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,
@@ -90,19 +85,6 @@ 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
@@ -1,91 +0,0 @@
<template>
<div class="flex space-x-2">
<Contenteditable
class="w-full block mr-6"
:model-value="item.name"
:icon-width="16"
@update:model-value="onUpdateName"
/>
<ReplaceButton
:is-direct-upload="isDirectUpload"
:template-id="template.id"
@click.stop
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
/>
<button
v-if="withArrows"
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors"
style="width: 24px; height: 24px"
@click.stop="$emit('up', item)"
>
&uarr;
</button>
<button
v-if="withArrows"
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors"
style="width: 24px; height: 24px"
@click.stop="$emit('down', item)"
>
&darr;
</button>
<button
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors"
style="width: 24px; height: 24px"
@click.stop="$emit('remove', item)"
>
&times;
</button>
</div>
</template>
<script>
import Contenteditable from './contenteditable'
import Upload from './upload'
import ReplaceButton from './replace'
export default {
name: 'DocumentControls',
components: {
Contenteditable,
ReplaceButton
},
props: {
item: {
type: Object,
required: true
},
template: {
type: Object,
required: true
},
document: {
type: Object,
required: true
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
},
withArrows: {
type: Boolean,
required: false,
default: true
}
},
emits: ['change', 'remove', 'up', 'down', 'replace'],
mounted () {
if (this.isDirectUpload) {
import('@rails/activestorage')
}
},
methods: {
upload: Upload.methods.upload,
onUpdateName (value) {
this.item.name = value
this.$emit('change')
}
}
}
</script>
+4 -21
View File
@@ -42,7 +42,7 @@
ref="input" ref="input"
type="file" type="file"
name="files[]" name="files[]"
:accept="acceptFileTypes" accept="image/*, application/pdf"
multiple multiple
@change="upload" @change="upload"
> >
@@ -61,17 +61,11 @@ export default {
IconCloudUpload, IconCloudUpload,
IconInnerShadowTop IconInnerShadowTop
}, },
inject: ['baseFetch'],
props: { props: {
templateId: { templateId: {
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,
@@ -94,28 +88,17 @@ export default {
return 'Uploading...' return 'Uploading...'
} else if (this.isProcessing) { } else if (this.isProcessing) {
return 'Processing...' return 'Processing...'
} else if (this.acceptFileTypes === 'image/*, application/pdf') {
return 'Add PDF documents or images'
} else { } else {
return 'Add documents or images' return 'Add PDF documents or images'
} }
} }
}, },
mounted () {
if (this.isDirectUpload) {
import('@rails/activestorage')
}
},
methods: { methods: {
upload: Upload.methods.upload, upload: Upload.methods.upload,
onDropFiles (e) { onDropFiles (e) {
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()
} else {
alert('Only PDF and images are supported.')
}
} }
} }
} }
+8 -41
View File
@@ -56,7 +56,7 @@
<label <label
tabindex="0" tabindex="0"
title="Areas" title="Areas"
class="cursor-pointer text-transparent group-hover:text-base-content" class="cursor-pointer text-base-100 group-hover:text-base-content"
> >
<IconShape <IconShape
:width="18" :width="18"
@@ -97,25 +97,12 @@
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
v-else v-else
title="Areas" title="Areas"
class="relative cursor-pointer text-transparent group-hover:text-base-content" class="relative cursor-pointer text-base-100 group-hover:text-base-content"
@click="$emit('set-draw', field)" @click="$emit('set-draw', field)"
> >
<IconShape <IconShape
@@ -124,7 +111,7 @@
/> />
</button> </button>
<button <button
class="relative text-transparent group-hover:text-base-content" class="relative text-base-100 group-hover:text-base-content"
title="Remove" title="Remove"
@click="$emit('remove', field)" @click="$emit('remove', field)"
> >
@@ -133,7 +120,7 @@
:stroke-width="1.6" :stroke-width="1.6"
/> />
</button> </button>
<div class="flex flex-col pr-1 text-transparent group-hover:text-base-content"> <div class="flex flex-col pr-1 text-base-100 group-hover:text-base-content">
<button <button
title="Up" title="Up"
class="relative" class="relative"
@@ -167,7 +154,7 @@
</span> </span>
<input <input
v-model="field.options[index]" v-model="field.options[index]"
class="w-full input input-primary input-xs text-sm bg-transparent" class="w-full input input-primary input-xs text-sm"
type="text" type="text"
required required
@blur="save" @blur="save"
@@ -194,7 +181,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, IconCopy } from '@tabler/icons-vue' import { IconShape, IconNewSection, IconTrashX } from '@tabler/icons-vue'
export default { export default {
name: 'TemplateField', name: 'TemplateField',
@@ -203,7 +190,6 @@ export default {
IconShape, IconShape,
IconNewSection, IconNewSection,
IconTrashX, IconTrashX,
IconCopy,
FieldType FieldType
}, },
inject: ['template', 'save'], inject: ['template', 'save'],
@@ -233,23 +219,6 @@ 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
@@ -283,10 +252,8 @@ export default {
}) })
}, },
onNameBlur (e) { onNameBlur (e) {
const text = this.$refs.name.$refs.contenteditable.innerText.trim() if (e.target.innerText.trim()) {
this.field.name = e.target.innerText.trim()
if (text) {
this.field.name = text
} else { } else {
this.field.name = '' this.field.name = ''
this.$refs.name.$refs.contenteditable.innerText = this.defaultName this.$refs.name.$refs.contenteditable.innerText = this.defaultName
@@ -25,16 +25,12 @@
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 class="flex items-center"> <span>
<IconPlus ▼
width="18"
height="18"
/>
</span> </span>
</label> </label>
<ul <ul
@@ -70,7 +66,7 @@
</button> </button>
</a> </a>
</li> </li>
<li v-if="submitters.length < 10"> <li v-if="submitters.length < 5">
<a <a
href="#" href="#"
class="flex px-2" class="flex px-2"
@@ -90,7 +86,7 @@
</template> </template>
<script> <script>
import { IconUserPlus, IconTrashX, IconPlus } from '@tabler/icons-vue' import { IconUserPlus, IconTrashX } from '@tabler/icons-vue'
import Contenteditable from './contenteditable' import Contenteditable from './contenteditable'
import { v4 } from 'uuid' import { v4 } from 'uuid'
@@ -99,7 +95,6 @@ export default {
components: { components: {
IconUserPlus, IconUserPlus,
Contenteditable, Contenteditable,
IconPlus,
IconTrashX IconTrashX
}, },
props: { props: {
@@ -130,12 +125,7 @@ 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 () {
@@ -144,12 +134,7 @@ 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 () {
+32 -41
View File
@@ -1,55 +1,50 @@
<template> <template>
<span class="dropdown"> <span class="dropdown">
<slot> <label
<label tabindex="0"
tabindex="0" :title="fieldNames[modelValue]"
:title="fieldNames[modelValue]" class="cursor-pointer"
class="cursor-pointer" >
> <component
<component :is="fieldIcons[modelValue]"
:is="fieldIcons[modelValue]" :width="buttonWidth"
:width="buttonWidth" :class="buttonClasses"
:class="buttonClasses" :stroke-width="1.6"
: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 mb-3" class="dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10"
:class="menuClasses" :class="menuClasses"
@click="closeDropdown" @click="closeDropdown"
> >
<template <li
v-for="(icon, type) in fieldIcons" v-for="(icon, type) in fieldIcons"
:key="type" :key="type"
> >
<li v-if="withPhone || type !== 'phone'"> <a
<a href="#"
href="#" class="text-sm py-1 px-2"
class="text-sm py-1 px-2" :class="{ 'active': type === modelValue }"
:class="{ 'active': type === modelValue }" @click.prevent="$emit('update:model-value', type)"
@click.prevent="$emit('update:model-value', type)" >
> <component
<component :is="icon"
:is="icon" :stroke-width="1.6"
:stroke-width="1.6" :width="20"
:width="20" />
/> {{ fieldNames[type] }}
{{ fieldNames[type] }} </a>
</a> </li>
</li>
</template>
</ul> </ul>
</span> </span>
</template> </template>
<script> <script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper } from '@tabler/icons-vue' import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3 } from '@tabler/icons-vue'
export default { export default {
name: 'FiledTypeDropdown', name: 'FiledTypeDropdown',
inject: ['withPhone'],
props: { props: {
modelValue: { modelValue: {
type: String, type: String,
@@ -77,7 +72,6 @@ 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',
@@ -85,24 +79,21 @@ 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,
radio: IconCircleDot,
multiple: IconChecks,
cells: IconColumns3, cells: IconColumns3,
phone: IconPhoneCheck multiple: IconChecks,
radio: IconCircleDot
} }
} }
}, },
+47 -84
View File
@@ -1,9 +1,8 @@
<template> <template>
<div :class="withStickySubmitters ? 'sticky top-0 z-10' : ''"> <div class="sticky -top-1 bg-base-100 pt-1 -mt-1 z-10">
<FieldSubmitter <FieldSubmitter
:model-value="selectedSubmitter.uuid" :model-value="selectedSubmitter.uuid"
class="w-full rounded-lg" class="w-full bg-base-100"
:class="{ 'bg-base-100': withStickySubmitters }"
:submitters="submitters" :submitters="submitters"
@new-submitter="save" @new-submitter="save"
@remove="removeSubmitter" @remove="removeSubmitter"
@@ -25,95 +24,67 @@
/> />
</div> </div>
<div class="grid grid-cols-3 gap-1 pb-2"> <div class="grid grid-cols-3 gap-1 pb-2">
<template <button
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 bg-base-100 w-full rounded relative"
@dragstart="onDragstart(type)"
@dragend="$emit('drag-end')"
@click="addField(type)"
> >
<button <div class="w-0 absolute left-0">
v-if="withPhone || type != 'phone'" <svg
draggable="true" xmlns="http://www.w3.org/2000/svg"
class="flex items-center justify-center border border-dashed border-base-300 w-full rounded relative" class="cursor-grab"
:style="{ backgroundColor: backgroundColor }" width="18"
@dragstart="onDragstart(type)" height="18"
@dragend="$emit('drag-end')" viewBox="0 0 24 24"
@click="addField(type)" stroke-width="1.5"
> stroke="currentColor"
<div class="w-0 absolute left-0"> fill="none"
<svg stroke-linecap="round"
xmlns="http://www.w3.org/2000/svg" stroke-linejoin="round"
class="cursor-grab"
width="18"
height="18"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
stroke="none"
d="M0 0h24v24H0z"
fill="none"
/>
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="icon" />
<span class="text-xs mt-1">
{{ fieldNames[type] }}
</span>
</div>
</button>
<div
v-else
class="tooltip tooltip-bottom-end flex"
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
>
<a
href="https://www.docuseal.co/pricing"
target="_blank"
class="opacity-50 flex items-center justify-center border border-dashed border-base-300 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }"
> >
<div class="w-0 absolute left-0"> <path
<IconLock stroke="none"
width="18" d="M0 0h24v24H0z"
height="18" fill="none"
stroke-width="1.5" />
/> <path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</div> <path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<div class="flex items-center flex-col px-2 py-2"> <path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<component :is="icon" /> <path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<span class="text-xs mt-1"> <path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
{{ fieldNames[type] }} <path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</span> </svg>
</div>
</a>
</div> </div>
</template> <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> </div>
<div <div
v-if="fields.length < 4" v-if="fields.length < 4"
class="text-xs p-2 border border-base-200 rounded" class="text-xs p-2 border border-base-200 rounded"
> >
<ul class="list-disc list-outside ml-3"> <ui>
<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 of the page to add a checkbox
</li>
<li> <li>
Drag &amp; drop any other field type on the page Drag &amp; drop any other field type on the page
</li> </li>
<li> <li>
Click on the field type above to add it to the form without drawing it on the document Click on the field type above to add it to the form without drawing it on the document
</li> </li>
</ul> </ui>
</div> </div>
</template> </template>
@@ -122,26 +93,19 @@ 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', 'withPhone'], inject: ['save'],
props: { props: {
fields: { fields: {
type: Array, type: Array,
required: true required: true
}, },
withStickySubmitters: {
type: Boolean,
required: false,
default: true
},
submitters: { submitters: {
type: Array, type: Array,
required: true required: true
@@ -207,8 +171,7 @@ export default {
const field = { const field = {
name: '', name: '',
uuid: v4(), uuid: v4(),
required: type !== 'checkbox', required: true,
areas: [],
submitter_uuid: this.selectedSubmitter.uuid, submitter_uuid: this.selectedSubmitter.uuid,
type type
} }
+3 -14
View File
@@ -1,13 +1,10 @@
<template> <template>
<div <div class="relative cursor-crosshair select-none">
class="relative cursor-crosshair select-none"
:style="drawField ? 'touch-action: none' : ''"
>
<img <img
ref="image" ref="image"
:src="image.url" :src="image.url"
:width="width" :width="width"
class="border rounded mb-4" class="shadow-md mb-4"
:height="height" :height="height"
loading="lazy" loading="lazy"
> >
@@ -35,13 +32,12 @@
/> />
</div> </div>
<div <div
v-show="resizeDirection || isMove || isDrag || showMask || (drawField && isMobile)" v-show="resizeDirection || isMove || isDrag || showMask"
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"
@@ -97,9 +93,6 @@ 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',
@@ -132,10 +125,6 @@ export default {
}) })
}, },
onStartDraw (e) { onStartDraw (e) {
if (this.isMobile && !this.drawField) {
return
}
this.showMask = true this.showMask = true
this.$nextTick(() => { this.$nextTick(() => {
+29 -65
View File
@@ -9,52 +9,39 @@
loading="lazy" loading="lazy"
> >
<div <div
class="group flex justify-end cursor-pointer top-0 bottom-0 left-0 right-0 absolute p-1" class="group flex justify-end cursor-pointer top-0 bottom-0 left-0 right-0 absolute"
@click="$emit('scroll-to', item)" @click="$emit('scroll-to', item)"
> >
<div class="flex justify-between w-full"> <div
<div style="width: 26px" /> class="flex flex-col justify-between opacity-0 group-hover:opacity-100"
<div class=""> >
<ReplaceButton <div class="rounded-bl rounded-tr bg-white border">
:is-direct-upload="isDirectUpload" <button
:template-id="template.id" class="rounded-bl rounded-tr hover:text-base-100 hover:bg-base-content w-full transition-colors"
:accept-file-types="acceptFileTypes" style="width: 24px"
class="opacity-0 group-hover:opacity-100" @click.stop="$emit('remove', item)"
@click.stop >
@success="$emit('replace', { replaceSchemaItem: item, ...$event })" &times;
/> </button>
</div> </div>
<div <div
class="flex flex-col justify-between opacity-0 group-hover:opacity-100" v-if="withArrows"
class="flex flex-col border rounded-br rounded-tl bg-white divide-y"
> >
<div> <button
<button class="rounded-tl hover:text-base-100 hover:bg-base-content w-full transition-colors"
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors" style="width: 24px"
style="width: 24px; height: 24px" @click.stop="$emit('up', item)"
@click.stop="$emit('remove', item)"
>
&times;
</button>
</div>
<div
v-if="withArrows"
class="flex flex-col space-y-1"
> >
<button &uarr;
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors" </button>
style="width: 24px; height: 24px" <button
@click.stop="$emit('up', item)" class="rounded-br hover:text-base-100 hover:bg-base-content w-full transition-colors"
> style="width: 24px"
&uarr; @click.stop="$emit('down', item)"
</button> >
<button &darr;
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors" </button>
style="width: 24px; height: 24px"
@click.stop="$emit('down', item)"
>
&darr;
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -73,57 +60,34 @@
<script> <script>
import Contenteditable from './contenteditable' import Contenteditable from './contenteditable'
import Upload from './upload'
import ReplaceButton from './replace'
export default { export default {
name: 'DocumentPreview', name: 'DocumentPreview',
components: { components: {
Contenteditable, Contenteditable
ReplaceButton
}, },
props: { props: {
item: { item: {
type: Object, type: Object,
required: true required: true
}, },
template: {
type: Object,
required: true
},
document: { document: {
type: Object, type: Object,
required: true required: true
}, },
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
},
withArrows: { withArrows: {
type: Boolean, type: Boolean,
required: false, required: false,
default: true default: true
} }
}, },
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace'], emits: ['scroll-to', 'change', 'remove', 'up', 'down'],
computed: { computed: {
previewImage () { previewImage () {
return this.document.preview_images[0] return this.document.preview_images[0]
} }
}, },
mounted () {
if (this.isDirectUpload) {
import('@rails/activestorage')
}
},
methods: { methods: {
upload: Upload.methods.upload,
onUpdateName (value) { onUpdateName (value) {
this.item.name = value this.item.name = value
@@ -1,76 +0,0 @@
<template>
<label
:for="inputId"
class="btn btn-neutral btn-xs text-white transition-none"
:class="{ 'opacity-100': isLoading || isProcessing }"
>
{{ message }}
<form
ref="form"
class="hidden"
>
<input
:id="inputId"
ref="input"
name="files[]"
type="file"
:accept="acceptFileTypes"
@change="upload"
>
</form>
</label>
</template>
<script>
import Upload from './upload'
export default {
name: 'ReplaceDocument',
inject: ['baseFetch'],
props: {
templateId: {
type: [Number, String],
required: true
},
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
}
},
emits: ['success'],
data () {
return {
isLoading: false,
isProcessing: false
}
},
computed: {
inputId () {
return 'el' + Math.random().toString(32).split('.')[1]
},
message () {
if (this.isLoading) {
return 'Uploading...'
} else if (this.isProcessing) {
return 'Processing...'
} else {
return 'Replace'
}
}
},
mounted () {
if (this.isDirectUpload) {
import('@rails/activestorage')
}
},
methods: {
upload: Upload.methods.upload
}
}
</script>
+3 -9
View File
@@ -33,7 +33,7 @@
ref="input" ref="input"
name="files[]" name="files[]"
type="file" type="file"
:accept="acceptFileTypes" accept="image/*, application/pdf"
multiple multiple
@change="upload" @change="upload"
> >
@@ -50,17 +50,11 @@ export default {
IconUpload, IconUpload,
IconInnerShadowTop IconInnerShadowTop
}, },
inject: ['baseFetch'],
props: { props: {
templateId: { templateId: {
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,
@@ -119,7 +113,7 @@ export default {
this.isProcessing = true this.isProcessing = true
this.baseFetch(`/api/templates/${this.templateId}/documents`, { fetch(`/api/templates/${this.templateId}/documents`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ blobs }), body: JSON.stringify({ blobs }),
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
@@ -130,7 +124,7 @@ export default {
this.isProcessing = false this.isProcessing = false
}) })
} else { } else {
this.baseFetch(`/api/templates/${this.templateId}/documents`, { fetch(`/api/templates/${this.templateId}/documents`, {
method: 'POST', method: 'POST',
body: new FormData(this.$refs.form) body: new FormData(this.$refs.form)
}).then(resp => resp.json()).then((data) => { }).then(resp => resp.json()).then((data) => {
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class GenerateSubmitterResultAttachmentsJob < ApplicationJob
def perform(submitter)
Submissions::EnsureResultGenerated.call(submitter)
end
end
@@ -1,54 +0,0 @@
# 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
@@ -1,24 +0,0 @@
# frozen_string_literal: true
class SendFormCompletedWebhookRequestJob < ApplicationJob
USER_AGENT = 'DocuSeal.co Webhook'
def perform(submitter)
config = Accounts.load_webhook_configs(submitter.submission.account)
return if config.blank? || config.value.blank?
Submissions::EnsureResultGenerated.call(submitter)
ActiveStorage::Current.url_options = Docuseal.default_url_options
Faraday.post(config.value,
{
event_type: 'form.completed',
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
end
end
@@ -1,22 +0,0 @@
# 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
@@ -1,22 +0,0 @@
# 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
@@ -1,14 +0,0 @@
# 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
-4
View File
@@ -6,10 +6,6 @@ class ApplicationMailer < ActionMailer::Base
register_interceptor ActionMailerConfigsInterceptor register_interceptor ActionMailerConfigsInterceptor
before_action do
ActiveStorage::Current.url_options = Docuseal.default_url_options
end
def default_url_options def default_url_options
Docuseal.default_url_options Docuseal.default_url_options
end end
-7
View File
@@ -1,7 +0,0 @@
# frozen_string_literal: true
class SettingsMailer < ApplicationMailer
def smtp_successful_setup(email)
mail(to: email, subject: 'SMTP has been configured')
end
end
+13 -70
View File
@@ -1,92 +1,35 @@
# frozen_string_literal: true # frozen_string_literal: true
class SubmitterMailer < ApplicationMailer class SubmitterMailer < ApplicationMailer
def invitation_email(submitter, body: nil, subject: nil) DEFAULT_MESSAGE = %(You have been invited to submit the "%<name>s" form:)
@current_account = submitter.submission.template.account
def invitation_email(submitter, message: format(DEFAULT_MESSAGE, name: submitter.submission.template.name))
@submitter = submitter @submitter = submitter
@body = body.presence @message = message
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY) mail(to: @submitter.email,
subject: 'You have been invited to submit a form')
subject =
if @email_config || subject.present?
ReplaceEmailVariables.call(subject.presence || @email_config.value['subject'], submitter:)
else
'You have been invited to submit a form'
end
mail(to: @submitter.friendly_name,
from: from_address_for_submitter(submitter),
subject:,
reply_to: (submitter.submission.created_by_user || submitter.template.author)&.friendly_name)
end end
def completed_email(submitter, user, bcc: nil) def completed_email(submitter, user)
@current_account = submitter.submission.template.account
@submitter = submitter @submitter = submitter
@submission = submitter.submission
@user = user @user = user
Submissions::EnsureResultGenerated.call(submitter) mail(to: user.email,
subject: %(#{submitter.email} has completed the "#{submitter.submission.template.name}" form))
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
add_completed_email_attachments!(submitter)
subject =
if @email_config
ReplaceEmailVariables.call(@email_config.value['subject'], submitter:)
else
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
mail(from: from_address_for_submitter(submitter),
to: user.friendly_name,
bcc:,
subject:)
end end
def documents_copy_email(submitter, to: nil) def documents_copy_email(submitter)
@current_account = submitter.submission.template.account
@submitter = submitter @submitter = submitter
Submissions::EnsureResultGenerated.call(@submitter) Submissions::EnsureResultGenerated.call(@submitter)
@documents = add_completed_email_attachments!(submitter) @documents = Submitters.select_attachments_for_download(submitter)
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY) @documents.each do |attachment|
subject =
if @email_config
ReplaceEmailVariables.call(@email_config.value['subject'], submitter:)
else
'Your copy of documents'
end
mail(from: from_address_for_submitter(submitter),
to: to || @submitter.friendly_name,
subject:)
end
private
def add_completed_email_attachments!(submitter)
documents = Submitters.select_attachments_for_download(submitter)
documents.each do |attachment|
attachments[attachment.filename.to_s] = attachment.download attachments[attachment.filename.to_s] = attachment.download
end end
if submitter.submission.audit_trail.present? mail(to: submitter.email, subject: 'Your copy of documents')
attachments[submitter.submission.audit_trail.filename.to_s] = submitter.submission.audit_trail.download
end
documents
end
def from_address_for_submitter(submitter)
submitter.submission.created_by_user&.friendly_name || submitter.submission.template.author.friendly_name
end end
end end
-1
View File
@@ -2,7 +2,6 @@
class UserMailer < ApplicationMailer class UserMailer < ApplicationMailer
def invitation_email(user) def invitation_email(user)
@current_account = user.account
@user = user @user = user
@token = @user.send(:set_reset_password_token) @token = @user.send(:set_reset_password_token)
Loaded 100 of 324 files, more files were not shown because too many files have changed in this diff. Show more