Compare commits

...

60 Commits

Author SHA1 Message Date
Alex Turchyn 4486f8040e Merge from docusealco/wip 2026-03-30 14:35:18 +03:00
Pete Matsyburka a98caad968 use musl build 2026-03-29 11:18:41 +03:00
Pete Matsyburka ffb75238b4 disable confetti by default 2026-03-28 16:53:20 +02:00
Pete Matsyburka cba3e74f3a optimize build 2026-03-28 16:44:08 +02:00
Pete Matsyburka 8d3f0b5b98 silence not found trace 2026-03-28 14:50:38 +02:00
Pete Matsyburka a2598b30f7 optimize build 2026-03-28 13:48:39 +02:00
Pete Matsyburka 8d11f5ddae adjust email 2fa 2026-03-28 12:51:00 +02:00
Pete Matsyburka 6ffc88b4ed fix n+1 2026-03-28 09:27:50 +02:00
Pete Matsyburka 640b451727 adjust shared link 2026-03-28 09:07:52 +02:00
Pete Matsyburka db6e2eb7ca preserve order with verification 2026-03-27 17:16:06 +02:00
Pete Matsyburka 407816b576 redirect delegate completed 2026-03-27 14:51:14 +02:00
Pete Matsyburka 486cc493e6 add i18n 2026-03-27 14:21:47 +02:00
Pete Matsyburka 3eb0b8a89f allow to delegate 2026-03-27 14:21:47 +02:00
Pete Matsyburka c23eca0ade fix field style 2026-03-26 16:09:57 +02:00
Pete Matsyburka 67de153016 increase image limit 2026-03-26 11:40:42 +02:00
Pete Matsyburka ba2da69a67 fix load ico 2026-03-26 08:29:17 +02:00
Pete Matsyburka 111bef14e1 update rails 2026-03-24 23:56:40 +02:00
Alex Turchyn 22d4c1bd10 Merge from docusealco/wip 2026-03-23 14:59:53 +02:00
Pete Matsyburka e3dfb0ae83 add SMTP_ENABLE_TLS 2026-03-23 10:02:54 +02:00
Pete Matsyburka 8d7c3f0e26 fix generation 2026-03-23 09:48:39 +02:00
Pete Matsyburka b11775ebda remove premailer 2026-03-22 18:13:41 +02:00
Pete Matsyburka 608191183a update deps 2026-03-21 13:37:31 +02:00
Pete Matsyburka 2ba903e170 fix specs 2026-03-18 10:18:28 +02:00
Alex Turchyn 342b64d502 add translations 2026-03-18 09:52:58 +02:00
Pete Matsyburka 1a8f048aaa update devise 2026-03-18 09:52:21 +02:00
Pete Matsyburka 7309581b75 add template archived event 2026-03-18 09:52:21 +02:00
Pete Matsyburka 36d59c40a7 add template update event 2026-03-18 08:36:36 +02:00
Pete Matsyburka ed3c7c324b adjust save dynamic 2026-03-16 17:24:07 +02:00
Alex Turchyn fd868360a3 Merge from docusealco/wip 2026-03-16 17:07:07 +02:00
Pete Matsyburka 8dc2ebfbe0 fix builder save 2026-03-16 16:59:45 +02:00
Alex Turchyn 997711b34a Merge from docusealco/wip 2026-03-16 13:36:06 +02:00
Pete Matsyburka ac01b7129d add i18n 2026-03-16 12:42:58 +02:00
Pete Matsyburka f4b7528c0d fix send form 2026-03-16 08:52:42 +02:00
Pete Matsyburka d7b9ae5df5 add datenow size 2026-03-16 08:21:36 +02:00
Pete Matsyburka 4db29126e9 fix variables list 2026-03-16 08:04:02 +02:00
Pete Matsyburka 4cfee2fe45 fix schema documents 2026-03-15 15:02:46 +02:00
Pete Matsyburka db5dbd6ac2 adjust dynamic editor 2026-03-15 14:05:20 +02:00
Pete Matsyburka 44db775b89 fix variable style 2026-03-14 17:47:53 +02:00
Alex Turchyn 8e2e780a1a fix webhook events filtering 2026-03-13 20:04:09 +02:00
Pete Matsyburka 7aba2eb4da disable decline 2026-03-12 08:58:56 +02:00
Pete Matsyburka 3e250bfb28 error update if declined 2026-03-11 23:44:06 +02:00
Pete Matsyburka 9c02ed66f5 fix field scroll 2026-03-11 14:43:16 +02:00
Pete Matsyburka f5ae8c60d3 preload dynamic editor 2026-03-11 10:44:12 +02:00
Pete Matsyburka e90ac391b2 update readme 2026-03-10 19:09:00 +02:00
Pete Matsyburka 0e5f949bcf remove email from signing reason 2026-03-10 11:39:00 +02:00
Pete Matsyburka b7c270bef8 dir auto section 2026-03-10 10:31:45 +02:00
Pete Matsyburka 5f16b506f5 fix n+1 2026-03-10 08:12:32 +02:00
Pete Matsyburka 176148fc71 adjust default initial 2026-03-09 17:29:46 +02:00
Alex Turchyn 861daaedf4 Merge from docusealco/wip 2026-03-09 10:40:29 +02:00
Pete Matsyburka d8430f4e63 fix migration 2026-03-09 09:02:56 +02:00
Pete Matsyburka dd44864485 fix field position 2026-03-06 11:37:53 +02:00
Pete Matsyburka b3bb29f383 fix brakeman 2026-03-05 21:42:55 +02:00
Pete Matsyburka b4d137c0de remove flash 2026-03-05 12:30:26 +02:00
Alex Turchyn 62a969d8fe add MCP support 2026-03-05 12:17:14 +02:00
Pete Matsyburka 961f09e092 set dynamic fields 2026-03-05 10:08:29 +02:00
Pete Matsyburka b2dfa83f18 upload json 2026-03-04 12:27:15 +02:00
Pete Matsyburka 37196ff89f add dynamic documents 2026-03-04 10:13:30 +02:00
Pete Matsyburka b50d982497 smtp timeout env vars 2026-03-03 22:49:55 +02:00
Pete Matsyburka 4a484aca69 hide storage nav if configured with env 2026-03-03 22:01:44 +02:00
Pete Matsyburka 0bf37d571a update color 2026-03-03 18:46:06 +02:00
146 changed files with 6903 additions and 772 deletions
+5 -5
View File
@@ -10,7 +10,7 @@ RUN apk --no-cache add fontforge wget && \
wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-musl-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux
@@ -36,6 +36,7 @@ COPY ./config/shakapacker.yml ./config/shakapacker.yml
COPY ./postcss.config.js ./postcss.config.js
COPY ./tailwind.config.js ./tailwind.config.js
COPY ./tailwind.form.config.js ./tailwind.form.config.js
COPY ./tailwind.dynamic.config.js ./tailwind.dynamic.config.js
COPY ./tailwind.application.config.js ./tailwind.application.config.js
COPY ./app/javascript ./app/javascript
COPY ./app/views ./app/views
@@ -46,13 +47,11 @@ FROM ruby:4.0.1-alpine AS app
ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test"
ENV LD_PRELOAD=/lib/libgcompat.so.0
ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
ENV VIPS_MAX_COORD=15000
WORKDIR /app
RUN apk add --no-cache sqlite-dev libpq-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont onnxruntime && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN apk add --no-cache libpq vips redis vips-heif ttf-freefont onnxruntime && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
@@ -70,7 +69,7 @@ activate = 1' >> /etc/openssl_legacy.cnf
COPY --chown=docuseal:docuseal ./Gemfile ./Gemfile.lock ./
RUN apk add --no-cache build-base git && bundle install && apk del --no-cache build-base git && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
RUN apk add --no-cache build-base git libpq-dev yaml-dev && bundle install && apk del --no-cache build-base git libpq-dev yaml-dev && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales,resources/unicode_data/properties}'] + Dir['/usr/local/bundle/gems/*/{test,tests,examples,sample,misc,doc,docs}'] + Dir['/usr/local/bundle/gems/*/ext/**/*.{c,h,o,S}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
COPY --chown=docuseal:docuseal ./bin ./bin
COPY --chown=docuseal:docuseal ./app ./app
@@ -97,6 +96,7 @@ RUN ln -s /fonts /app/public/fonts && \
WORKDIR /data/docuseal
ENV HOME=/home/docuseal
ENV WORKDIR=/data/docuseal
ENV VIPS_MAX_COORD=17000
EXPOSE 3000
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
+2 -2
View File
@@ -4,6 +4,7 @@ source 'https://rubygems.org'
ruby '4.0.1'
gem 'addressable'
gem 'arabic-letter-connector', require: false
gem 'aws-sdk-s3', require: false
gem 'aws-sdk-secretsmanager', require: false
@@ -28,7 +29,6 @@ gem 'oj'
gem 'onnxruntime', require: false
gem 'pagy'
gem 'pg', require: false
gem 'premailer-rails'
gem 'pretender'
gem 'puma', require: false
gem 'rack'
@@ -43,7 +43,7 @@ gem 'shakapacker'
gem 'sidekiq'
gem 'sqlite3', require: false
gem 'strip_attributes'
gem 'trilogy', github: 'trilogy-libraries/trilogy', glob: 'contrib/ruby/*.gemspec', require: false
gem 'trilogy', require: false
gem 'turbo-rails'
gem 'twitter_cldr', require: false
gem 'tzinfo-data'
+85 -100
View File
@@ -1,39 +1,31 @@
GIT
remote: https://github.com/trilogy-libraries/trilogy.git
revision: 3963d490459df7a2b5bedb42424c3285f25eab22
glob: contrib/ruby/*.gemspec
specs:
trilogy (2.10.0)
bigdecimal
GEM
remote: https://rubygems.org/
specs:
action_text-trix (2.1.16)
action_text-trix (2.1.17)
railties
actioncable (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
actioncable (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
actionmailbox (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
mail (>= 2.8.0)
actionmailer (8.1.2)
actionpack (= 8.1.2)
actionview (= 8.1.2)
activejob (= 8.1.2)
activesupport (= 8.1.2)
actionmailer (8.1.3)
actionpack (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activesupport (= 8.1.3)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.1.2)
actionview (= 8.1.2)
activesupport (= 8.1.2)
actionpack (8.1.3)
actionview (= 8.1.3)
activesupport (= 8.1.3)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -41,36 +33,36 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.1.2)
actiontext (8.1.3)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
actionpack (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.1.2)
activesupport (= 8.1.2)
actionview (8.1.3)
activesupport (= 8.1.3)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.1.2)
activesupport (= 8.1.2)
activejob (8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.3.6)
activemodel (8.1.2)
activesupport (= 8.1.2)
activerecord (8.1.2)
activemodel (= 8.1.2)
activesupport (= 8.1.2)
activemodel (8.1.3)
activesupport (= 8.1.3)
activerecord (8.1.3)
activemodel (= 8.1.3)
activesupport (= 8.1.3)
timeout (>= 0.4.0)
activestorage (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activesupport (= 8.1.2)
activestorage (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activesupport (= 8.1.3)
marcel (~> 1.0)
activesupport (8.1.2)
activesupport (8.1.3)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -116,7 +108,7 @@ GEM
cgi
rexml
base64 (0.3.0)
bcrypt (3.1.21)
bcrypt (3.1.22)
better_html (2.2.0)
actionview (>= 7.0)
activesupport (>= 7.0)
@@ -158,8 +150,6 @@ GEM
bigdecimal
rexml
crass (1.0.6)
css_parser (1.21.1)
addressable
csv (3.3.5)
csv-safe (3.3.1)
csv (~> 3.0)
@@ -171,16 +161,16 @@ GEM
irb (~> 1.10)
reline (>= 0.3.8)
declarative (0.0.20)
devise (4.9.4)
devise (5.0.3)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
railties (>= 7.0)
responders
warden (~> 1.2.3)
devise-two-factor (6.3.1)
activesupport (>= 7.0, < 8.2)
devise (>= 4.0, < 5.0)
railties (>= 7.0, < 8.2)
devise-two-factor (6.4.0)
activesupport (>= 7.2, < 8.2)
devise (>= 4.0, < 6.0)
railties (>= 7.2, < 8.2)
rotp (~> 6.0)
diff-lcs (1.6.2)
digest-crc (0.7.0)
@@ -189,7 +179,7 @@ GEM
dotenv (3.2.0)
drb (2.2.3)
email_typo (0.2.3)
erb (6.0.1)
erb (6.0.2)
erb_lint (0.9.0)
activesupport
better_html (>= 2.0.1)
@@ -272,19 +262,19 @@ GEM
geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1)
strscan (>= 3.1.2)
htmlentities (4.4.2)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
io-console (0.8.2)
irb (1.16.0)
irb (1.17.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.18.1)
json (2.19.2)
jwt (3.1.2)
base64
language_server-protocol (3.17.0.5)
@@ -306,7 +296,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.25.0)
loofah (2.25.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
@@ -321,13 +311,14 @@ GEM
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (6.0.1)
minitest (6.0.2)
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.0)
multi_json (1.19.1)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.6.2)
net-imap (0.6.3)
date
net-protocol
net-pop (0.1.2)
@@ -337,17 +328,17 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.1-aarch64-linux-gnu)
nokogiri (1.19.2-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.1-aarch64-linux-musl)
nokogiri (1.19.2-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.1-arm64-darwin)
nokogiri (1.19.2-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-gnu)
nokogiri (1.19.2-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-musl)
nokogiri (1.19.2-x86_64-linux-musl)
racc (~> 1.4)
numo-narray-alt (0.9.13)
numo-narray-alt (0.10.3)
oj (3.16.13)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
@@ -376,18 +367,10 @@ GEM
pg (1.6.3-x86_64-linux-musl)
pp (0.6.3)
prettyprint
premailer (1.27.0)
addressable
css_parser (>= 1.19.0)
htmlentities (>= 4.0.0)
premailer-rails (1.12.0)
actionmailer (>= 3)
net-smtp
premailer (~> 1.7, >= 1.7.9)
pretender (0.6.0)
actionpack (>= 7.1)
prettyprint (0.2.0)
prism (1.8.0)
prism (1.9.0)
pry (0.16.0)
coderay (~> 1.1)
method_source (~> 1.0)
@@ -411,33 +394,33 @@ GEM
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails (8.1.2)
actioncable (= 8.1.2)
actionmailbox (= 8.1.2)
actionmailer (= 8.1.2)
actionpack (= 8.1.2)
actiontext (= 8.1.2)
actionview (= 8.1.2)
activejob (= 8.1.2)
activemodel (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
rails (8.1.3)
actioncable (= 8.1.3)
actionmailbox (= 8.1.3)
actionmailer (= 8.1.3)
actionpack (= 8.1.3)
actiontext (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activemodel (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
bundler (>= 1.15.0)
railties (= 8.1.2)
railties (= 8.1.3)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.1.0)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
railties (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
railties (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -446,7 +429,7 @@ GEM
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.1)
rdoc (7.1.0)
rdoc (7.2.0)
erb
psych (>= 4.0.0)
tsort
@@ -560,8 +543,10 @@ GEM
activemodel (>= 3.0, < 9.0)
strscan (3.1.7)
thor (1.5.0)
timeout (0.6.0)
timeout (0.6.1)
trailblazer-option (0.1.2)
trilogy (2.10.0)
bigdecimal
tsort (0.2.0)
turbo-rails (2.0.21)
actionpack (>= 7.1.0)
@@ -601,7 +586,7 @@ GEM
xpath (3.2.0)
nokogiri (~> 1.8)
yaml (0.4.0)
zeitwerk (2.7.4)
zeitwerk (2.7.5)
PLATFORMS
aarch64-linux
@@ -611,6 +596,7 @@ PLATFORMS
x86_64-linux-musl
DEPENDENCIES
addressable
annotaterb
arabic-letter-connector
aws-sdk-s3
@@ -647,7 +633,6 @@ DEPENDENCIES
onnxruntime
pagy
pg
premailer-rails
pretender
pry-rails
puma
@@ -669,7 +654,7 @@ DEPENDENCIES
simplecov
sqlite3
strip_attributes
trilogy!
trilogy
turbo-rails
twitter_cldr
tzinfo-data
+1 -1
View File
@@ -64,7 +64,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|Heroku|Railway|
|:--:|:---:|
| [<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.com/deploy/IGoDnc?referralCode=ruU7JR)|
|**DigitalOcean**|**Render**|
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
@@ -11,6 +11,7 @@ class AccountConfigsController < ApplicationController
AccountConfig::FORCE_MFA,
AccountConfig::ALLOW_TO_RESUBMIT,
AccountConfig::ALLOW_TO_DECLINE_KEY,
AccountConfig::ALLOW_TO_DELEGATE_KEY,
AccountConfig::FORM_PREFILL_SIGNATURE_KEY,
AccountConfig::ESIGNING_PREFERENCE_KEY,
AccountConfig::FORM_WITH_CONFETTI_KEY,
@@ -23,7 +24,8 @@ class AccountConfigsController < ApplicationController
AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::COMBINE_PDF_RESULT_KEY,
AccountConfig::REQUIRE_SIGNING_REASON_KEY,
AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY
AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY,
AccountConfig::ENABLE_MCP_KEY
].freeze
InvalidKey = Class.new(StandardError)
@@ -35,7 +37,7 @@ class AccountConfigsController < ApplicationController
end
def destroy
raise InvalidKey unless ALLOWED_KEYS.include?(@account_config.key)
raise InvalidKey unless allowed_keys.include?(@account_config.key)
@account_config.destroy!
@@ -44,8 +46,12 @@ class AccountConfigsController < ApplicationController
private
def allowed_keys
ALLOWED_KEYS
end
def load_account_config
raise InvalidKey unless ALLOWED_KEYS.include?(account_config_params[:key])
raise InvalidKey unless allowed_keys.include?(account_config_params[:key])
@account_config =
AccountConfig.find_or_initialize_by(account: current_account, key: account_config_params[:key])
@@ -40,6 +40,10 @@ module Api
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content
end
if @submitter.declined_at?
return render json: { error: 'Submitter has already declined the submission.' }, status: :unprocessable_content
end
submission = @submitter.submission
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
+4 -1
View File
@@ -69,6 +69,7 @@ module Api
SearchEntries.enqueue_reindex(@template)
WebhookUrls.enqueue_events(@template, 'template.updated')
WebhookUrls.enqueue_events(@template, 'template.archived') if archived == true
render json: @template.as_json(only: %i[id updated_at])
end
@@ -78,6 +79,8 @@ module Api
@template.destroy!
else
@template.update!(archived_at: Time.current)
WebhookUrls.enqueue_events(@template, 'template.archived')
end
render json: @template.as_json(only: %i[id archived_at])
@@ -117,7 +120,7 @@ module Api
conditions: [%i[field_uuid value action operation]],
options: [%i[value uuid]],
validation: %i[message pattern min max step],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
areas: [%i[uuid x y w h cell_w attachment_uuid option_uuid page]] }]]
}
]
+2 -2
View File
@@ -40,7 +40,7 @@ class ErrorsController < ActionController::Base
render json: { status: error_status_code, error: }.compact, status: error_status_code
end
f.html { render error_status_code.to_s, status: error_status_code }
f.any { render error_status_code.to_s, status: error_status_code }
end
end
@@ -51,7 +51,7 @@ class ErrorsController < ActionController::Base
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
headers['Access-Control-Allow-Headers'] = '*'
headers['Access-Control-Max-Age'] = '1728000'
headers['Access-Control-Allow-Credentials'] = true
headers['Access-Control-Allow-Credentials'] = 'true'
end
def error_status_code
+58
View File
@@ -0,0 +1,58 @@
# frozen_string_literal: true
class McpController < ActionController::API
before_action :authenticate_user!
before_action :verify_mcp_enabled!
before_action do
authorize!(:manage, :mcp)
end
def call
return head :ok if request.raw_post.blank?
body = JSON.parse(request.raw_post)
result = Mcp::HandleRequest.call(body, current_user, current_ability)
if result
render json: result
else
head :accepted
end
rescue CanCan::AccessDenied
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_603, message: 'Forbidden' } }, status: :forbidden
rescue JSON::ParserError
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_700, message: 'Parse error' } }, status: :bad_request
end
private
def authenticate_user!
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
end
def verify_mcp_enabled!
return if Docuseal.multitenant?
return if AccountConfig.exists?(account_id: current_user.account_id,
key: AccountConfig::ENABLE_MCP_KEY,
value: true)
render json: { error: 'MCP is disabled' }, status: :forbidden
end
def current_user
@current_user ||= user_from_api_key
end
def user_from_api_key
token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
return if token.blank?
sha256 = Digest::SHA256.hexdigest(token)
User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil })
end
end
@@ -0,0 +1,37 @@
# frozen_string_literal: true
class McpSettingsController < ApplicationController
load_and_authorize_resource :mcp_token, parent: false
before_action do
authorize!(:manage, :mcp)
end
def index
@mcp_tokens = @mcp_tokens.active.order(id: :desc)
end
def create
@mcp_token = current_user.mcp_tokens.new(mcp_token_params)
if @mcp_token.save
@mcp_tokens = [@mcp_token]
render :index, status: :created
else
render turbo_stream: turbo_stream.replace(:modal, template: 'mcp_settings/new'), status: :unprocessable_content
end
end
def destroy
@mcp_token.update!(archived_at: Time.current)
redirect_back fallback_location: settings_mcp_index_path, notice: I18n.t('mcp_token_has_been_removed')
end
private
def mcp_token_params
params.require(:mcp_token).permit(:name)
end
end
@@ -6,9 +6,17 @@ class PreviewDocumentPageController < ActionController::API
FORMAT = Templates::ProcessDocument::FORMAT
def show
attachment_uuid = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid], purpose: :attachment)
result_data =
ApplicationRecord.signed_id_verifier.verified(params[:signed_key], purpose: :attachment)
attachment = ActiveStorage::Attachment.find_by(uuid: attachment_uuid) if attachment_uuid
attachment =
if result_data.is_a?(Array) && result_data.compact_blank.size == 2
attachment_id, attachment_uuid = result_data
ActiveStorage::Attachment.find_by(id: attachment_id, uuid: attachment_uuid)
elsif result_data
ActiveStorage::Attachment.find_by(uuid: result_data)
end
return head :not_found unless attachment
+2
View File
@@ -172,6 +172,8 @@ class StartFormController < ApplicationController
submitters: [submitter],
source: :link)
Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(submitter.submission)
submitter.account_id = submitter.submission.account_id
submitter
@@ -16,6 +16,7 @@ class SubmissionEventsController < ApplicationController
'email_verified' => 'email_check',
'click_sms' => 'hand_click',
'decline_form' => 'x',
'delegate_form' => 'user_share',
'start_verification' => 'player_play',
'complete_verification' => 'check',
'invite_party' => 'user_plus'
+16 -12
View File
@@ -51,18 +51,7 @@ class SubmissionsController < ApplicationController
emails: params[:emails],
params: params.merge('send_completed_email' => true))
else
submissions_attrs = submissions_params[:submission].to_h.values
submissions_attrs, _, new_fields =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, @template, add_fields: true)
Submissions.create_from_submitters(template: @template,
user: current_user,
source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
submissions_attrs:,
new_fields:,
params: params.merge('send_completed_email' => true))
create_submissions(@template, submissions_params, params)
end
WebhookUrls.enqueue_events(submissions, 'submission.created')
@@ -97,6 +86,21 @@ class SubmissionsController < ApplicationController
private
def create_submissions(template, submissions_params, params)
submissions_attrs = submissions_params[:submission].to_h.values
submissions_attrs, _, new_fields =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, template, add_fields: true)
Submissions.create_from_submitters(template: template,
user: current_user,
source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
submissions_attrs:,
new_fields:,
params: params.merge('send_completed_email' => true))
end
def save_template_message(template, params)
template.preferences['request_email_subject'] = params[:subject] if params[:subject].present?
template.preferences['request_email_body'] = params[:body] if params[:body].present?
+19 -2
View File
@@ -3,11 +3,12 @@
class SubmitFormController < ApplicationController
layout 'form'
around_action :with_browser_locale, only: %i[show completed success]
around_action :with_browser_locale, only: %i[show completed success delegated]
skip_before_action :authenticate_user!
skip_authorization_check
before_action :load_submitter, only: %i[show update completed]
before_action :maybe_redirect_delegated, only: %i[show completed]
before_action :maybe_render_locked_page, only: :show
before_action :maybe_require_link_2fa, only: %i[show]
@@ -91,6 +92,12 @@ class SubmitFormController < ApplicationController
def success; end
def delegated
submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
@submitter = submitter_version.submitter
end
private
def maybe_require_link_2fa
@@ -108,8 +115,18 @@ class SubmitFormController < ApplicationController
render :declined if @submitter.declined_at?
end
def maybe_redirect_delegated
return if @submitter
submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
submitter_version.submitter.submission_events.find_by!(event_type: :delegate_form)
redirect_to submit_form_delegated_path(submitter_version.slug)
end
def load_submitter
@submitter = Submitter.find_by!(slug: params[:slug] || params[:submit_form_slug])
@submitter = Submitter.find_by(slug: params[:slug] || params[:submit_form_slug])
end
def build_attachments_index(submission)
@@ -4,31 +4,38 @@ class SubmitFormDeclineController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def create
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
before_action :load_submitter
return redirect_to submit_form_path(submitter.slug) if submitter.declined_at? ||
submitter.completed_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired? ||
submitter.submission.template&.archived_at? ||
!Submitters::AuthorizedForForm.call(submitter, current_user,
request)
def create
return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? ||
@submitter.completed_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter,
current_user,
request)
ApplicationRecord.transaction do
submitter.update!(declined_at: Time.current)
@submitter.update!(declined_at: Time.current)
SubmissionEvents.create_with_tracking_data(submitter, 'decline_form', request, { reason: params[:reason] })
SubmissionEvents.create_with_tracking_data(@submitter, 'decline_form', request, { reason: params[:reason] })
end
user = submitter.submission.created_by_user || submitter.template.author
user = @submitter.submission.created_by_user || @submitter.template.author
if user.user_configs.find_by(key: UserConfig::RECEIVE_DECLINED_EMAIL)&.value != false
SubmitterMailer.declined_email(submitter, user).deliver_later!
SubmitterMailer.declined_email(@submitter, user).deliver_later!
end
WebhookUrls.enqueue_events(submitter, 'form.declined')
WebhookUrls.enqueue_events(@submitter, 'form.declined')
redirect_to submit_form_path(submitter.slug)
redirect_to submit_form_path(@submitter.slug)
end
private
def load_submitter
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
end
end
@@ -0,0 +1,47 @@
# frozen_string_literal: true
class SubmitFormDelegateController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
before_action :load_submitter
def create
return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? ||
@submitter.completed_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter,
current_user,
request)
@submitter.account.account_configs.find_by!(key: AccountConfig::ALLOW_TO_DELEGATE_KEY, value: true)
email = Submissions.normalize_email(params[:email])
return redirect_to submit_form_path(@submitter.slug) if email.blank?
old_slug = @submitter.slug
ApplicationRecord.transaction do
@submitter.submitter_versions.create!(slug: old_slug, email: @submitter.email,
name: @submitter.name, phone: @submitter.phone)
SubmissionEvents.create_with_tracking_data(@submitter, 'delegate_form', request,
{ old_email: @submitter.email, email: })
@submitter.update!(email:, phone: nil, name: nil, slug: SecureRandom.base58(14))
end
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => @submitter.id)
redirect_to submit_form_delegated_path(old_slug)
end
private
def load_submitter
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
end
end
@@ -16,7 +16,7 @@ class TemplateDocumentsController < ApplicationController
old_fields_hash = @template.fields.hash
documents = Templates::CreateAttachments.call(@template, params, extract_fields: true)
documents, = Templates::CreateAttachments.call(@template, params, extract_fields: true)
schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base }
@@ -27,7 +27,7 @@ class TemplateDocumentsController < ApplicationController
fields: old_fields_hash == @template.fields.hash ? nil : @template.fields,
submitters: old_fields_hash == @template.fields.hash ? nil : @template.submitters,
documents: documents.as_json(
methods: %i[metadata signed_uuid],
methods: %i[metadata signed_key],
include: {
preview_images: { methods: %i[url metadata filename] }
}
+6 -3
View File
@@ -35,7 +35,7 @@ class TemplatesController < ApplicationController
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: %i[metadata signed_uuid],
methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
@@ -84,6 +84,8 @@ class TemplatesController < ApplicationController
else
@template.update!(archived_at: Time.current)
WebhookUrls.enqueue_events(@template, 'template.archived')
I18n.t('template_has_been_archived')
end
@@ -95,10 +97,11 @@ class TemplatesController < ApplicationController
def template_params
params.require(:template).permit(
:name,
{ schema: [[:attachment_uuid, :google_drive_file_id, :name,
{ schema: [[:attachment_uuid, :google_drive_file_id, :name, :dynamic,
{ conditions: [%i[field_uuid value action operation]] }]],
submitters: [%i[name uuid is_requester linked_to_uuid invite_via_field_uuid
invite_by_uuid optional_invite_by_uuid email order]],
variables_schema: {},
fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value,
:title, :description, :prefillable,
@@ -107,7 +110,7 @@ class TemplatesController < ApplicationController
conditions: [%i[field_uuid value action operation]],
options: [%i[value uuid]],
validation: %i[message pattern min max step],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
areas: [%i[uuid x y w h cell_w attachment_uuid option_uuid page]] }]] }
)
end
end
+23 -14
View File
@@ -9,21 +9,23 @@ class TemplatesDebugController < ApplicationController
schema_uuids = @template.schema.index_by { |e| e['attachment_uuid'] }
attachment = @template.documents.find { |a| schema_uuids[a.uuid] }
data = attachment.download
if attachment
data = attachment.download
unless attachment.image?
pdf = HexaPDF::Document.new(io: StringIO.new(data))
unless attachment.image?
pdf = HexaPDF::Document.new(io: StringIO.new(data))
fields = Templates::FindAcroFields.call(pdf, attachment, data)
fields = Templates::FindAcroFields.call(pdf, attachment, data)
end
# fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
attachment.metadata['pdf'] ||= {}
attachment.metadata['pdf']['fields'] = fields
@template.update!(fields: Templates::ProcessDocument.normalize_attachment_fields(@template, [attachment]))
end
fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
attachment.metadata['pdf'] ||= {}
attachment.metadata['pdf']['fields'] = fields
@template.update!(fields: Templates::ProcessDocument.normalize_attachment_fields(@template, [attachment]))
debug_file if DEBUG_FILE.present?
ActiveRecord::Associations::Preloader.new(
@@ -34,7 +36,7 @@ class TemplatesDebugController < ApplicationController
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: %i[metadata signed_uuid],
methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
@@ -58,9 +60,16 @@ class TemplatesDebugController < ApplicationController
params = { files: [file] }
documents = Templates::CreateAttachments.call(@template, params)
documents, dynamic_documents = Templates::CreateAttachments.call(@template, params,
dynamic: DEBUG_FILE.ends_with?('.docx'))
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
schema = documents.map do |doc|
{
attachment_uuid: doc.uuid,
name: doc.filename.base,
dynamic: dynamic_documents.find { |e| e.uuid == doc.uuid }.present?
}
end
@template.update!(schema:)
end
@@ -12,7 +12,7 @@ class TemplatesPreviewController < ApplicationController
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: %i[metadata signed_uuid],
methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
@@ -6,6 +6,8 @@ class TemplatesRestoreController < ApplicationController
def create
@template.update!(archived_at: nil)
WebhookUrls.enqueue_events(@template, 'template.updated')
redirect_to template_path(@template), notice: I18n.t('template_has_been_unarchived')
end
end
@@ -12,7 +12,7 @@ class TemplatesUploadsController < ApplicationController
save_template!(@template, url_params)
documents = Templates::CreateAttachments.call(@template, url_params || params, extract_fields: true)
documents, = Templates::CreateAttachments.call(@template, url_params || params, extract_fields: true)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
if @template.fields.blank?
+1 -1
View File
@@ -15,7 +15,7 @@ class WebhookEventsController < ApplicationController
Submissions::SerializeForApi.call(@webhook_event.record)
when 'template.created', 'template.updated'
Templates::SerializeForApi.call(@webhook_event.record)
when 'submission.archived'
when 'submission.archived', 'template.archived'
@webhook_event.record.as_json(only: %i[id archived_at])
end
end
+2
View File
@@ -160,6 +160,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
this.app = createApp(TemplateBuilder, {
template,
customFields: reactive(JSON.parse(this.dataset.customFields || '[]')),
dynamicDocuments: reactive(JSON.parse(this.dataset.dynamicDocuments || '[]')),
backgroundColor: '#faf7f5',
locale: this.dataset.locale,
withPhone: this.dataset.withPhone === 'true',
@@ -177,6 +178,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withSendButton: this.dataset.withSendButton !== 'false',
withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false',
withConditions: this.dataset.withConditions === 'true',
withDynamicDocuments: this.dataset.withDynamicDocuments === 'true',
withGoogleDrive: this.dataset.withGoogleDrive === 'true',
withReplaceAndCloneUpload: true,
withDownload: true,
+1 -1
View File
@@ -36,7 +36,7 @@ safeRegisterElement('submission-form', class extends HTMLElement {
expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null,
withSignatureId: this.dataset.withSignatureId === 'true',
requireSigningReason: this.dataset.requireSigningReason === 'true',
withConfetti: this.dataset.withConfetti !== 'false',
withConfetti: this.dataset.withConfetti === 'true',
withFieldLabels: this.dataset.withFieldLabels !== 'false',
withDisclosure: this.dataset.withDisclosure === 'true',
reuseSignature: this.dataset.reuseSignature !== 'false',
@@ -359,7 +359,9 @@ export default {
},
initTextInitial () {
if (this.submitter.name) {
this.$refs.textInput.value = this.submitter.name.trim().split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]?.toUpperCase() || '').join('')
const parts = this.submitter.name.trim().split(/\s+/)
this.$refs.textInput.value = (parts.length > 1 ? [parts[0], parts[parts.length - 1]] : parts).map((part) => part[0]?.toUpperCase() || '').join('')
}
if (this.$refs.textInput.value) {
+24 -294
View File
@@ -31,144 +31,26 @@
/>
</div>
</div>
<div
v-if="field?.type && (isSelected || isNameFocus) && !isInMultiSelection"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls"
style="top: -25px; height: 25px"
@mousedown.stop
@pointerdown.stop
>
<FieldSubmitter
v-if="field.type != 'heading' && field.type != 'strikethrough'"
v-model="field.submitter_uuid"
class="border-r roles-dropdown"
:compact="true"
:editable="editable && (!defaultField || defaultField.role !== submitter?.name)"
:allow-add-new="!defaultSubmitters.length"
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'"
:submitters="template.submitters"
@update:model-value="save"
@click="selectedAreasRef.value = [area]"
/>
<FieldType
v-model="field.type"
:button-width="27"
:editable="editable && !defaultField"
:button-classes="'px-1'"
:menu-classes="'bg-white rounded-t-none'"
@update:model-value="[maybeUpdateOptions(), save()]"
@click="selectedAreasRef.value = [area]"
/>
<span
v-if="field.type !== 'checkbox' || field.name"
ref="name"
:contenteditable="editable && !defaultField && field.type !== 'heading'"
dir="auto"
class="pr-1 cursor-text outline-none block"
style="min-width: 2px"
@paste.prevent="onPaste"
@keydown.enter.prevent="onNameEnter"
@focus="onNameFocus"
@blur="onNameBlur"
>{{ optionIndexText }} {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || defaultName }}</span>
<div
v-if="isSettingsFocus || isSelectInput || (isValueInput && field.type !== 'heading') || (isNameFocus && !['checkbox', 'phone'].includes(field.type))"
class="flex items-center ml-1.5"
>
<input
v-if="!isValueInput && !isSelectInput"
:id="`required-checkbox-${field.uuid}`"
v-model="field.required"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
@mousedown.prevent
>
<label
v-if="!isValueInput && !isSelectInput"
:for="`required-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.required = !field.required"
@mousedown.prevent
>{{ t('required') }}</label>
<input
v-if="isValueInput || isSelectInput"
:id="`readonly-checkbox-${field.uuid}`"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
:checked="!(field.readonly ?? true)"
@change="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>
<label
v-if="isValueInput || isSelectInput"
:for="`readonly-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>{{ t('editable') }}</label>
<span
v-if="field.type !== 'payment' && !isValueInput"
class="dropdown dropdown-end field-area-settings-dropdown"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
ref="settingsButton"
tabindex="0"
:title="t('settings')"
class="cursor-pointer flex items-center"
style="height: 25px"
@focus="isSettingsFocus = true"
@blur="maybeBlurSettings"
>
<IconDotsVertical class="w-5 h-5" />
</label>
<ul
v-if="renderDropdown"
ref="settingsDropdown"
tabindex="0"
class="dropdown-content menu menu-xs px-2 pb-2 pt-1 shadow rounded-box w-52 z-10 rounded-t-none"
:style="{ backgroundColor: 'white' }"
@dragstart.prevent.stop
@click="closeDropdown"
@focusout="maybeBlurSettings"
>
<FieldSettings
v-if="isMobile"
:field="field"
:default-field="defaultField"
:editable="editable"
:background-color="'white'"
:with-required="false"
:with-areas="false"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@add-custom-field="$emit('add-custom-field')"
@click-condition="isShowConditionsModal = true"
@save="save"
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
/>
<div
v-else
class="whitespace-normal"
>
The dots menu is retired in favor of the field context menu. Right-click the field to access field settings. Double-click the field to set a default value.
</div>
</ul>
</span>
</div>
<button
v-else-if="editable"
class="pr-1"
:title="t('remove')"
@click.prevent="$emit('remove')"
>
<IconX width="14" />
</button>
</div>
<AreaTitle
ref="title"
:area="area"
:field="field"
:template="template"
:selected-areas-ref="selectedAreasRef"
:get-field-type-index="getFieldTypeIndex"
:default-field="defaultField"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:default-submitters="defaultSubmitters"
:editable="editable"
:is-mobile="isMobile"
:is-value-input="isValueInput"
:is-select-input="isSelectInput"
@change="save"
@remove="$emit('remove')"
@scroll-to="$emit('scroll-to', $event)"
@add-custom-field="$emit('add-custom-field')"
/>
<div
ref="touchValueTarget"
class="flex h-full w-full field-area"
@@ -333,85 +215,20 @@
@mousedown.stop="startResize"
@touchstart="startTouchResize"
/>
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="save"
@close="isShowFormulaModal = false"
/>
</Teleport>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="save"
@close="isShowFontModal = false"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:item="field"
:build-default-name="buildDefaultName"
:default-field="defaultField"
@save="save"
@close="isShowConditionsModal = false"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="save"
@close="isShowDescriptionModal = false"
/>
</Teleport>
</div>
</template>
<script>
import FieldSubmitter from './field_submitter'
import FieldType from './field_type'
import Field from './field'
import FieldSettings from './field_settings'
import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import { IconX, IconCheck, IconDotsVertical } from '@tabler/icons-vue'
import { v4 } from 'uuid'
import AreaTitle from './area_title'
import { IconCheck } from '@tabler/icons-vue'
export default {
name: 'FieldArea',
components: {
FieldType,
IconCheck,
FieldSettings,
FormulaModal,
FontModal,
IconDotsVertical,
DescriptionModal,
ConditionsModal,
FieldSubmitter,
IconX
AreaTitle
},
inject: ['template', 'save', 't', 'isInlineSize', 'selectedAreasRef', 'isCmdKeyRef', 'getFieldTypeIndex'],
props: {
@@ -493,17 +310,10 @@ export default {
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field'],
data () {
return {
isShowFormulaModal: false,
isShowFontModal: false,
isShowConditionsModal: false,
isContenteditable: false,
isSettingsFocus: false,
isShowDescriptionModal: false,
isResize: false,
isDragged: false,
isMoved: false,
renderDropdown: false,
isNameFocus: false,
isHeadingSelected: false,
textOverflowChars: 0,
dragFrom: { x: 0, y: 0 }
@@ -592,9 +402,6 @@ export default {
return (this.field.type === 'heading' && this.isHeadingSelected) || this.isContenteditable ||
(this.inputMode && (['text', 'number'].includes(this.field.type) || (this.field.type === 'date' && this.field.default_value !== '{{date}}')))
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
defaultName () {
return this.buildDefaultName(this.field)
},
@@ -616,13 +423,6 @@ export default {
italic: ['bold_italic', 'italic'].includes(this.field.preferences.font_type)
}
},
optionIndexText () {
if (this.area.option_uuid && this.field.options) {
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
} else {
return ''
}
},
cells () {
const cells = []
@@ -705,9 +505,6 @@ export default {
},
methods: {
buildDefaultName: Field.methods.buildDefaultName,
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
},
buildAreaOptionValue (area) {
const option = this.optionsUuidIndex[area.option_uuid]
@@ -792,23 +589,6 @@ export default {
return number
}
},
maybeBlurSettings (e) {
if (!e.relatedTarget || !this.$refs.settingsDropdown.contains(e.relatedTarget)) {
this.isSettingsFocus = false
}
},
onNameFocus (e) {
this.selectedAreasRef.value = [this.area]
this.isNameFocus = true
this.$refs.name.style.minWidth = this.$refs.name.clientWidth + 'px'
if (!this.field.name) {
setTimeout(() => {
this.$refs.name.innerText = ' '
}, 1)
}
},
startResizeCell (e) {
this.$el.getRootNode().addEventListener('mousemove', this.onResizeCell)
this.$el.getRootNode().addEventListener('mouseup', this.stopResizeCell)
@@ -843,53 +623,6 @@ export default {
}
}
},
maybeUpdateOptions () {
delete this.field.default_value
if (!['radio', 'multiple', 'select'].includes(this.field.type)) {
delete this.field.options
}
if (this.field.type === 'heading') {
this.field.readonly = true
}
if (this.field.type === 'strikethrough') {
this.field.readonly = true
this.field.default_value = true
}
if (['select', 'multiple', 'radio'].includes(this.field.type)) {
this.field.options ||= [{ value: '', uuid: v4() }]
}
(this.field.areas || []).forEach((area) => {
if (this.field.type === 'cells') {
area.cell_w = area.w * 2 / Math.floor(area.w / area.h)
} else {
delete area.cell_w
}
})
},
onNameBlur (e) {
if (e.relatedTarget === this.$refs.settingsButton) {
this.isSettingsFocus = true
}
const text = this.$refs.name.innerText.trim()
this.isNameFocus = false
this.$refs.name.style.minWidth = ''
if (text) {
this.field.name = text
} else {
this.field.name = ''
this.$refs.name.innerText = this.defaultName
}
this.save()
},
onDefaultValueBlur (e) {
const text = this.$refs.defaultValue.innerText.trim()
@@ -927,9 +660,6 @@ export default {
this.$refs.defaultValue.blur()
}
},
onNameEnter (e) {
this.$refs.name.blur()
},
resize (e) {
if (e.target.id === 'mask') {
this.area.w = e.offsetX / e.target.clientWidth - this.area.x
@@ -1124,7 +854,7 @@ export default {
this.selectedAreasRef.value = [this.area]
}
this.$refs?.name?.blur()
this.$refs?.title?.$refs?.name?.blur()
e.preventDefault()
@@ -0,0 +1,404 @@
<template>
<div
v-if="field?.type && (isSelected || isNameFocus) && !isInMultiSelection"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls"
style="top: -25px; height: 25px"
@mousedown.stop
@pointerdown.stop
>
<FieldSubmitter
v-if="field.type != 'heading' && field.type != 'strikethrough'"
v-model="field.submitter_uuid"
class="border-r roles-dropdown"
:compact="true"
:editable="editable && (!defaultField || defaultField.role !== submitter?.name)"
:allow-add-new="!defaultSubmitters.length"
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'"
:submitters="template.submitters"
@update:model-value="$emit('change')"
@click="selectedAreasRef.value = [area]"
/>
<FieldType
v-model="field.type"
:button-width="27"
:editable="editable && !defaultField"
:button-classes="'px-1'"
:menu-classes="'bg-white rounded-t-none'"
@update:model-value="[maybeUpdateOptions(), $emit('change')]"
@click="selectedAreasRef.value = [area]"
/>
<span
v-if="field.type !== 'checkbox' || field.name"
ref="name"
:contenteditable="editable && !defaultField && field.type !== 'heading'"
dir="auto"
class="pr-1 cursor-text outline-none block"
style="min-width: 2px"
@paste.prevent="onPaste"
@keydown.enter.prevent="onNameEnter"
@focus="onNameFocus"
@blur="onNameBlur"
>{{ optionIndexText }} {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || defaultName }}</span>
<div
v-if="isSettingsFocus || isSelectInput || (isValueInput && field.type !== 'heading') || (isNameFocus && !['checkbox', 'phone'].includes(field.type))"
class="flex items-center ml-1.5"
>
<input
v-if="!isValueInput && !isSelectInput"
:id="`required-checkbox-${field.uuid}`"
v-model="field.required"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
@mousedown.prevent
>
<label
v-if="!isValueInput && !isSelectInput"
:for="`required-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.required = !field.required"
@mousedown.prevent
>{{ t('required') }}</label>
<input
v-if="isValueInput || isSelectInput"
:id="`readonly-checkbox-${field.uuid}`"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
:checked="!(field.readonly ?? true)"
@change="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>
<label
v-if="isValueInput || isSelectInput"
:for="`readonly-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>{{ t('editable') }}</label>
<span
v-if="field.type !== 'payment' && !isValueInput"
class="dropdown dropdown-end field-area-settings-dropdown"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
ref="settingsButton"
tabindex="0"
:title="t('settings')"
class="cursor-pointer flex items-center"
style="height: 25px"
@focus="isSettingsFocus = true"
@blur="maybeBlurSettings"
>
<IconDotsVertical class="w-5 h-5" />
</label>
<ul
v-if="renderDropdown"
ref="settingsDropdown"
tabindex="0"
class="dropdown-content menu menu-xs px-2 pb-2 pt-1 shadow rounded-box w-52 z-10 rounded-t-none"
:style="{ backgroundColor: 'white' }"
@dragstart.prevent.stop
@click="closeDropdown"
@focusout="maybeBlurSettings"
>
<FieldSettings
v-if="isMobile"
:field="field"
:default-field="defaultField"
:editable="editable"
:background-color="'white'"
:with-required="false"
:with-areas="false"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@add-custom-field="$emit('add-custom-field')"
@click-condition="isShowConditionsModal = true"
@save="$emit('change')"
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
/>
<div
v-else
class="whitespace-normal"
>
The dots menu is retired in favor of the field context menu. Right-click the field to access field settings. Double-click the field to set a default value.
</div>
</ul>
</span>
</div>
<button
v-else-if="editable"
class="pr-1"
:title="t('remove')"
@click.prevent="$emit('remove')"
>
<IconX width="14" />
</button>
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('change')"
@close="isShowFormulaModal = false"
/>
</Teleport>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('change')"
@close="isShowFontModal = false"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:item="field"
:build-default-name="buildDefaultName"
:default-field="defaultField"
@save="$emit('change')"
@close="isShowConditionsModal = false"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('change')"
@close="isShowDescriptionModal = false"
/>
</Teleport>
</div>
</template>
<script>
import FieldSubmitter from './field_submitter'
import FieldType from './field_type'
import Field from './field'
import FieldSettings from './field_settings'
import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import { IconX, IconDotsVertical } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
name: 'AreaTitle',
components: {
FieldType,
FieldSettings,
FormulaModal,
FontModal,
IconDotsVertical,
DescriptionModal,
ConditionsModal,
FieldSubmitter,
IconX
},
inject: ['t'],
props: {
template: {
type: Object,
required: true
},
selectedAreasRef: {
type: Object,
required: true
},
getFieldTypeIndex: {
type: Function,
required: true
},
area: {
type: Object,
required: true
},
field: {
type: Object,
required: false,
default: null
},
defaultField: {
type: Object,
required: false,
default: null
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
withPrefillable: {
type: Boolean,
required: false,
default: false
},
defaultSubmitters: {
type: Array,
required: false,
default: () => []
},
editable: {
type: Boolean,
required: false,
default: true
},
isMobile: {
type: Boolean,
required: false,
default: false
},
isValueInput: {
type: Boolean,
required: false,
default: false
},
isSelectInput: {
type: Boolean,
required: false,
default: false
}
},
emits: ['remove', 'scroll-to', 'add-custom-field', 'change'],
data () {
return {
isShowFormulaModal: false,
isShowFontModal: false,
isShowConditionsModal: false,
isShowDescriptionModal: false,
isSettingsFocus: false,
renderDropdown: false,
isNameFocus: false
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels,
submitter () {
return this.template.submitters.find((s) => s.uuid === this.field.submitter_uuid)
},
isSelected () {
return this.selectedAreasRef.value.includes(this.area)
},
isInMultiSelection () {
return this.selectedAreasRef.value.length >= 2 && this.isSelected
},
optionIndexText () {
if (this.area.option_uuid && this.field.options) {
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
} else {
return ''
}
},
defaultName () {
return this.buildDefaultName(this.field)
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
}
},
methods: {
buildDefaultName: Field.methods.buildDefaultName,
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
},
maybeBlurSettings (e) {
if (!e.relatedTarget || !this.$refs.settingsDropdown.contains(e.relatedTarget)) {
this.isSettingsFocus = false
}
},
onNameFocus (e) {
this.selectedAreasRef.value = [this.area]
this.isNameFocus = true
this.$refs.name.style.minWidth = this.$refs.name.clientWidth + 'px'
if (!this.field.name) {
setTimeout(() => {
this.$refs.name.innerText = ' '
}, 1)
}
},
onNameBlur (e) {
if (e.relatedTarget === this.$refs.settingsButton) {
this.isSettingsFocus = true
}
const text = this.$refs.name.innerText.trim()
this.isNameFocus = false
this.$refs.name.style.minWidth = ''
if (text) {
this.field.name = text
} else {
this.field.name = ''
this.$refs.name.innerText = this.defaultName
}
this.$emit('change')
},
onNameEnter (e) {
this.$refs.name.blur()
},
onPaste (e) {
const text = (e.clipboardData || window.clipboardData).getData('text/plain')
const selection = this.$el.getRootNode().getSelection()
if (selection.rangeCount) {
selection.deleteFromDocument()
selection.getRangeAt(0).insertNode(document.createTextNode(text))
selection.collapseToEnd()
}
},
maybeUpdateOptions () {
delete this.field.default_value
if (!['radio', 'multiple', 'select'].includes(this.field.type)) {
delete this.field.options
}
if (this.field.type === 'heading') {
this.field.readonly = true
}
if (this.field.type === 'strikethrough') {
this.field.readonly = true
this.field.default_value = true
}
if (['select', 'multiple', 'radio'].includes(this.field.type)) {
this.field.options ||= [{ value: '', uuid: v4() }]
}
(this.field.areas || []).forEach((area) => {
if (this.field.type === 'cells') {
area.cell_w = area.w * 2 / Math.floor(area.w / area.h)
} else {
delete area.cell_w
}
})
}
}
}
</script>
+178 -46
View File
@@ -81,7 +81,7 @@
/>
<template v-else>
<form
v-if="withSignYourselfButton && undefinedSubmitters.length < 2"
v-if="withSignYourselfButton && undefinedSubmitters.length < 2 && (!template.variables_schema || Object.keys(template.variables_schema).length === 0)"
target="_blank"
data-turbo="false"
class="inline"
@@ -274,6 +274,8 @@
:accept-file-types="acceptFileTypes"
:with-replace-button="withUploadButton"
:editable="editable"
:dynamic-documents="dynamicDocuments"
:with-dynamic-documents="withDynamicDocuments"
:template="template"
@scroll-to="scrollIntoDocument(item)"
@remove="onDocumentRemove"
@@ -352,10 +354,27 @@
</template>
<template v-else>
<template
v-for="document in sortedDocuments"
v-for="(document, index) in sortedDocuments"
:key="document.uuid"
>
<DynamicDocument
v-if="template.schema[index].dynamic"
:ref="setDocumentRefs"
:editable="editable"
:document="dynamicDocuments.find((dynamicDocument) => dynamicDocument.uuid === document.uuid)"
:selected-submitter="selectedSubmitter"
:drag-field="dragField"
:draw-field="drawField"
:draw-field-type="drawFieldType"
:draw-custom-field="drawCustomField"
:draw-option="drawOption"
@update="onDynamicDocumentUpdate"
@draw="clearDrawField"
@add-custom-field="addCustomField"
@set-draw="[drawField = $event.field, drawOption = $event.option]"
/>
<Document
v-else
:ref="setDocumentRefs"
:areas-index="fieldAreasIndex[document.uuid]"
:selected-submitter="selectedSubmitter"
@@ -497,6 +516,8 @@
:show-tour-start-form="showTourStartForm"
@add-field="addField"
@set-draw="[drawField = $event.field, drawOption = $event.option]"
@remove-field="onRemoveField"
@remove-submitter="onRemoveSubmitter"
@select-submitter="selectedSubmitter = $event"
@set-draw-type="[drawFieldType = $event, showDrawField = true]"
@set-draw-custom-field="[drawCustomField = $event, showDrawField = true]"
@@ -505,6 +526,7 @@
@change-submitter="selectedSubmitter = $event"
@drag-end="[dragField = null, $refs.dragPlaceholder.dragPlaceholder = null]"
@scroll-to-area="scrollToArea"
@rebuild-variables-schema="rebuildVariablesSchema"
/>
</div>
</div>
@@ -592,12 +614,13 @@ import MobileFields from './mobile_fields'
import FieldSubmitter from './field_submitter'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload } from '@tabler/icons-vue'
import { v4 } from 'uuid'
import { ref, computed, toRaw } from 'vue'
import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
import * as i18n from './i18n'
export default {
name: 'TemplateBuilder',
components: {
DynamicDocument: defineAsyncComponent(() => import(/* webpackChunkName: "dynamic-editor" */ './dynamic_document')),
Upload,
DragPlaceholder,
Document,
@@ -725,6 +748,11 @@ export default {
required: false,
default: false
},
dynamicDocuments: {
type: Array,
required: false,
default: () => []
},
customFields: {
type: Array,
required: false,
@@ -783,7 +811,7 @@ export default {
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf, application/zip'
default: 'image/*, application/pdf, application/zip, application/json'
},
baseUrl: {
type: String,
@@ -846,6 +874,11 @@ export default {
required: false,
default: ''
},
withDynamicDocuments: {
type: Boolean,
required: false,
default: false
},
withDocumentsList: {
type: Boolean,
required: false,
@@ -1131,6 +1164,32 @@ export default {
this.drawCustomField = null
this.showDrawField = false
},
onRemoveField (field) {
if (this.dynamicDocuments.length) {
field.areas?.forEach((area) => {
this.documentRefs.forEach((documentRef) => {
if (documentRef.isDynamic && documentRef.document.uuid === area.attachment_uuid) {
documentRef.removeArea(area)
}
})
})
}
},
onRemoveSubmitter (submitter) {
if (this.dynamicDocuments.length) {
this.template.fields.forEach((field) => {
if (field.submitter_uuid === submitter.uuid) {
field.areas?.forEach((area) => {
this.documentRefs.forEach((documentRef) => {
if (documentRef.isDynamic && documentRef.document.uuid === area.attachment_uuid) {
documentRef.removeArea(area)
}
})
})
}
})
}
},
toggleSelectMode () {
this.isSelectModeRef.value = !this.isSelectModeRef.value
@@ -2488,60 +2547,80 @@ export default {
this.save()
},
removeAreasByAttachmentUuid (attachmentUuid) {
const removedFieldUuids = []
this.selectedAreasRef.value = this.selectedAreasRef.value.filter((area) => area.attachment_uuid !== attachmentUuid)
this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === attachmentUuid) {
field.areas.splice(field.areas.indexOf(area), 1)
removedFieldUuids.push(field.uuid)
}
})
})
this.template.fields = this.template.fields.reduce((acc, field) => {
if (removedFieldUuids.includes(field.uuid) && !field.areas?.length) {
this.removeFieldConditions(field)
} else {
acc.push(field)
}
return acc
}, [])
},
onDocumentRemove (item) {
if (window.confirm(this.t('are_you_sure_'))) {
this.template.schema.splice(this.template.schema.indexOf(item), 1)
const removedFieldUuids = []
this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === item.attachment_uuid) {
field.areas.splice(field.areas.indexOf(area), 1)
removedFieldUuids.push(field.uuid)
}
})
})
this.template.fields = this.template.fields.reduce((acc, f) => {
if (removedFieldUuids.includes(f.uuid) && !f.areas?.length) {
this.removeFieldConditions(f)
} else {
acc.push(f)
}
return acc
}, [])
this.removeAreasByAttachmentUuid(item.attachment_uuid)
this.save()
}
},
onDocumentReplace (data) {
const { replaceSchemaItem, schema, documents } = data
const isReplacingDynamicDocument = !!replaceSchemaItem.dynamic
// eslint-disable-next-line camelcase
const { google_drive_file_id, ...cleanedReplaceSchemaItem } = replaceSchemaItem
const { google_drive_file_id, dynamic, ...cleanedReplaceSchemaItem } = replaceSchemaItem
this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, { ...cleanedReplaceSchemaItem, ...schema[0] })
this.template.documents.push(...documents)
if (isReplacingDynamicDocument) {
this.removeAreasByAttachmentUuid(replaceSchemaItem.attachment_uuid)
const dynamicDocumentIndex = this.dynamicDocuments.findIndex((doc) => doc.uuid === replaceSchemaItem.attachment_uuid)
if (dynamicDocumentIndex !== -1) {
this.dynamicDocuments.splice(dynamicDocumentIndex, 1)
}
}
if (data.fields) {
this.template.fields = data.fields
const removedFieldUuids = []
if (isReplacingDynamicDocument) {
this.removeAreasByAttachmentUuid(replaceSchemaItem.attachment_uuid)
} else {
const removedFieldUuids = []
this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
field.areas.splice(field.areas.indexOf(area), 1)
this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
field.areas.splice(field.areas.indexOf(area), 1)
removedFieldUuids.push(field.uuid)
}
removedFieldUuids.push(field.uuid)
}
})
})
})
this.template.fields =
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
this.template.fields =
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
}
}
if (data.submitters) {
@@ -2552,13 +2631,15 @@ export default {
}
}
this.template.fields.forEach((field) => {
(field.areas || []).forEach((area) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
area.attachment_uuid = schema[0].attachment_uuid
}
if (!isReplacingDynamicDocument) {
this.template.fields.forEach((field) => {
(field.areas || []).forEach((area) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
area.attachment_uuid = schema[0].attachment_uuid
}
})
})
})
}
if (this.onUpload) {
this.onUpload(this.template)
@@ -2654,7 +2735,15 @@ export default {
} else {
this.isSaving = true
this.save().then(() => {
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
dynamicDocumentRefs.map((ref) => ref.update())
this.rebuildVariablesSchema({ disable: false })
const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody())
Promise.all([this.save(), ...dynamicDocumentSaves]).then(() => {
window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => {
this.isSaving = false
@@ -2665,9 +2754,13 @@ export default {
scrollToArea (area) {
const documentRef = this.documentRefs.find((a) => a.document.uuid === area.attachment_uuid)
documentRef.scrollToArea(area)
if (documentRef.isDynamic) {
this.selectedAreasRef.value = []
} else {
this.selectedAreasRef.value = [area]
}
this.selectedAreasRef.value = [area]
documentRef.scrollToArea(area)
},
baseFetch (path, options = {}) {
return fetch(this.baseUrl + path, {
@@ -2893,7 +2986,8 @@ export default {
name: this.template.name,
schema: this.template.schema,
submitters: this.template.submitters,
fields: this.template.fields
fields: this.template.fields,
variables_schema: this.template.variables_schema
}
}),
headers: { 'Content-Type': 'application/json' }
@@ -2902,6 +2996,44 @@ export default {
this.onSave(this.template)
}
})
},
onDynamicDocumentUpdate () {
this.rebuildVariablesSchema()
this.$nextTick(() => {
if (this.$el.closest('template-builder')) {
this.$el.closest('template-builder').dataset.dynamicDocuments = JSON.stringify(this.dynamicDocuments)
}
})
this.save()
},
rebuildVariablesSchema ({ disable = true } = {}) {
const dynamicDocumentRef = this.documentRefs.find((e) => e.isDynamic)
if (dynamicDocumentRef) {
const parsed = {}
this.documentRefs.forEach((ref) => {
if (ref.isDynamic) {
ref.updateVariablesSchema()
}
})
this.dynamicDocuments.forEach((doc) => {
if (doc.variables_schema) {
dynamicDocumentRef.mergeSchemaProperties(parsed, doc.variables_schema)
}
})
if (!this.template.variables_schema) {
this.template.variables_schema = parsed
} else {
dynamicDocumentRef.syncVariablesSchema(this.template.variables_schema, parsed, { disable })
}
} else {
this.template.variables_schema = {}
}
}
}
}
+1 -1
View File
@@ -66,7 +66,7 @@ export default {
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf, application/zip'
default: 'image/*, application/pdf, application/zip, application/json'
},
withReplaceButton: {
type: Boolean,
+1 -1
View File
@@ -168,7 +168,7 @@ export default {
return this.previewImagesIndex[i] || reactive({
metadata: { ...lazyloadMetadata },
id: Math.random().toString(),
url: this.basePreviewUrl + `/preview/${this.document.signed_uuid || this.document.uuid}/${i}.jpg`
url: this.basePreviewUrl + `/preview/${this.document.signed_key || this.document.signed_uuid || this.document.uuid}/${i}.jpg`
})
})
},
+3 -3
View File
@@ -122,7 +122,7 @@ export default {
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf, application/zip'
default: 'image/*, application/pdf, application/zip, application/json'
}
},
emits: ['success', 'error', 'loading', 'click-google-drive'],
@@ -146,7 +146,7 @@ export default {
message () {
if (this.isLoading) {
return this.t('uploading')
} else if (this.acceptFileTypes === 'image/*, application/pdf, application/zip') {
} else if (this.acceptFileTypes === 'image/*, application/pdf, application/zip, application/json') {
return this.title || this.t('add_pdf_documents_or_images')
} else {
return this.title || this.t('add_documents_or_images')
@@ -161,7 +161,7 @@ export default {
methods: {
upload: Upload.methods.upload,
onDropFiles (e) {
if (this.acceptFileTypes !== 'image/*, application/pdf, application/zip' || [...e.dataTransfer.files].every((f) => f.type.match(/(?:image\/)|(?:application\/pdf)|(?:application\/zip)/))) {
if (this.acceptFileTypes !== 'image/*, application/pdf, application/zip, application/json' || [...e.dataTransfer.files].every((f) => f.type.match(/(?:image\/)|(?:application\/pdf)|(?:application\/zip)|(?:application\/json)/))) {
this.$refs.input.files = e.dataTransfer.files
this.upload()
@@ -0,0 +1,289 @@
<template>
<span
class="items-center select-none cursor-pointer relative overflow-visible text-base-content/80 font-sans"
:class="[bgColorClass, iconOnlyField ? 'justify-center' : '']"
:draggable="editable"
:style="[nodeStyle]"
@mousedown="selectArea"
@dragstart="onDragStart"
@contextmenu.prevent.stop="onContextMenu"
>
<span
class="absolute inset-0 pointer-events-none border-solid"
:class="borderColorClass"
:style="{ borderWidth: (isSelected ? 1 : 0) + 'px' }"
/>
<component
:is="fieldIcons[field?.type || 'text']"
v-if="field && !field.default_value"
width="100%"
height="100%"
:stroke-width="1.5"
:class="iconOnlyField ? 'shrink min-h-0 max-h-full max-w-6 opacity-70 m-auto p-0.5' : 'shrink min-h-0 max-h-full max-w-4 opacity-70 mx-0.5 pl-0.5'"
/>
<span
v-if="field?.default_value"
class="text-xs overflow-hidden text-ellipsis whitespace-nowrap pr-1 font-normal pl-0.5"
>
<template v-if="field.default_value === '{{date}}'">
{{ t('signing_date') }}
</template>
<template v-else>
{{ field.default_value }}
</template>
</span>
<span
v-else-if="field && !iconOnlyField"
class="text-xs overflow-hidden text-ellipsis whitespace-nowrap pr-1 opacity-70 font-normal pl-0.5"
>{{ displayLabel }}</span>
<span
class="absolute rounded-full bg-white border border-gray-400 shadow-md cursor-nwse-resize z-10"
:style="{ width: resizeHandleSize + 'px', height: resizeHandleSize + 'px', right: (-4 / zoom) + 'px', bottom: (-4 / zoom) + 'px' }"
@pointerdown.prevent.stop="onResizeStart"
/>
</span>
</template>
<script>
import FieldArea from './area'
import FieldType from './field_type'
export default {
name: 'DynamicArea',
props: {
fieldUuid: {
type: String,
required: true
},
areaUuid: {
type: String,
required: true
},
template: {
type: Object,
required: true
},
nodeStyle: {
type: Object,
required: true
},
selectedAreasRef: {
type: Object,
required: true
},
getPos: {
type: Function,
required: true
},
editor: {
type: Object,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
getZoom: {
type: Function,
required: true
},
onAreaContextMenu: {
type: Function,
required: true
},
onAreaResize: {
type: Function,
required: true
},
onAreaDragStart: {
type: Function,
required: true
},
t: {
type: Function,
required: true
},
findFieldArea: {
type: Function,
required: true
},
getFieldTypeIndex: {
type: Function,
required: true
}
},
data () {
return {
isResizing: false
}
},
computed: {
fieldArea () {
return this.findFieldArea(this.areaUuid)
},
area () {
return this.fieldArea?.area
},
field () {
return this.fieldArea?.field
},
fieldIcons: FieldArea.computed.fieldIcons,
fieldNames: FieldArea.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels,
borderColors () {
return [
'border-red-500/80',
'border-sky-500/80',
'border-emerald-500/80',
'border-yellow-300/80',
'border-purple-600/80',
'border-pink-500/80',
'border-cyan-500/80',
'border-orange-500/80',
'border-lime-500/80',
'border-indigo-500/80'
]
},
bgColors () {
return [
'bg-red-100',
'bg-sky-100',
'bg-emerald-100',
'bg-yellow-100',
'bg-purple-100',
'bg-pink-100',
'bg-cyan-100',
'bg-orange-100',
'bg-lime-100',
'bg-indigo-100'
]
},
isSelected () {
return this.selectedAreasRef.value.some((a) => a === this.area)
},
zoom () {
return this.getZoom()
},
submitterIndex () {
if (!this.field) return 0
const submitter = this.template.submitters.find((s) => s.uuid === this.field.submitter_uuid)
return submitter ? this.template.submitters.indexOf(submitter) : 0
},
borderColorClass () {
return this.borderColors[this.submitterIndex % this.borderColors.length]
},
bgColorClass () {
return this.bgColors[this.submitterIndex % this.bgColors.length]
},
resizeHandleSize () {
return this.zoom > 0 ? Math.round(10 / this.zoom) : 10
},
iconOnlyField () {
return ['radio', 'multiple', 'checkbox', 'initials'].includes(this.field?.type)
},
defaultName () {
if (!this.field) return 'text'
const typeIndex = this.getFieldTypeIndex(this.field)
return `${this.fieldLabels[this.field.type] || this.fieldNames[this.field.type] || this.field.type} ${typeIndex + 1}`
},
displayLabel () {
return this.field?.name || this.defaultName
}
},
methods: {
selectArea () {
this.editor.commands.setNodeSelection(this.getPos())
},
onDragStart (e) {
if (this.isResizing) {
e.preventDefault()
return
}
const pos = this.getPos()
if (pos == null) {
e.preventDefault()
return
}
const root = this.$el
const rect = root.getBoundingClientRect()
const zoom = this.zoom || 1
const clone = root.cloneNode(true)
clone.querySelector('[class*="cursor-nwse-resize"]')?.remove()
clone.style.cssText = `position:fixed;top:-1000px;width:${rect.width / zoom}px;height:${rect.height / zoom}px;display:${root.style.display};vertical-align:${root.style.verticalAlign};zoom:${zoom}`
document.body.appendChild(clone)
e.dataTransfer.setDragImage(clone, e.offsetX, e.offsetY)
requestAnimationFrame(() => clone.remove())
e.dataTransfer.effectAllowed = 'move'
this.onAreaDragStart()
},
onContextMenu (e) {
this.selectArea()
this.onAreaContextMenu(this.area, e)
},
onResizeStart (e) {
if (!this.editable) return
this.isResizing = true
this.selectArea()
const handle = e.target
handle.setPointerCapture(e.pointerId)
const startX = e.clientX
const startY = e.clientY
const startWidth = this.$el.offsetWidth
const startHeight = this.$el.offsetHeight
const onResizeMove = (e) => {
e.preventDefault()
this.nodeStyle.width = startWidth + (e.clientX - startX) / this.zoom + 'px'
this.nodeStyle.height = startHeight + (e.clientY - startY) / this.zoom + 'px'
this.onAreaResize(this.$el.getBoundingClientRect())
}
const onResizeEnd = () => {
if (!this.isResizing) return
this.isResizing = false
handle.removeEventListener('pointermove', onResizeMove)
handle.removeEventListener('pointerup', onResizeEnd)
const pos = this.getPos()
const tr = this.editor.view.state.tr.setNodeMarkup(pos, undefined, {
...this.editor.view.state.doc.nodeAt(pos)?.attrs,
width: this.nodeStyle.width,
height: this.nodeStyle.height
})
this.editor.view.dispatch(tr)
this.editor.commands.setNodeSelection(pos)
}
handle.addEventListener('pointermove', onResizeMove)
handle.addEventListener('pointerup', onResizeEnd)
}
}
}
</script>
@@ -0,0 +1,316 @@
<template>
<div
ref="container"
class="relative"
style="container-type: inline-size;"
>
<div ref="shadow" />
<template
v-for="style in styles"
:key="style.innerText"
>
<Teleport
v-if="shadow"
:to="style.innerText.includes('@font-face {') ? 'head' : shadow"
>
<component :is="'style'">
{{ style.innerText }}
</component>
</Teleport>
</template>
<Teleport
v-if="shadow"
:to="shadow"
>
<DynamicSection
v-for="section in sections"
:ref="setSectionRefs"
:key="section.id"
:container="$refs.container"
:editable="editable"
:section="section"
:section-refs="sectionRefs"
:container-width="containerWidth"
:attachments-index="attachmentsIndex"
:selected-submitter="selectedSubmitter"
:drag-field="dragField"
:draw-field="drawField"
:draw-field-type="drawFieldType"
:draw-custom-field="drawCustomField"
:render-html-for-save-ref="renderHtmlForSaveRef"
:draw-option="drawOption"
:attachment-uuid="document.uuid"
@update="onSectionUpdate(section, $event)"
@draw="$emit('draw', $event)"
@add-custom-field="$emit('add-custom-field', $event)"
@set-draw="$emit('set-draw', $event)"
/>
</Teleport>
</div>
</template>
<script>
import { ref } from 'vue'
import DynamicSection from './dynamic_section.vue'
import { dynamicStylesheet, tiptapStylesheet } from './dynamic_editor.js'
import { buildVariablesSchema, mergeSchemaProperties } from './dynamic_variables_schema.js'
export default {
name: 'TemplateDynamicDocument',
components: {
DynamicSection
},
inject: ['baseFetch', 'template'],
props: {
document: {
type: Object,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
selectedSubmitter: {
type: Object,
required: false,
default: null
},
dragField: {
type: Object,
required: false,
default: null
},
drawField: {
type: Object,
required: false,
default: null
},
drawFieldType: {
type: String,
required: false,
default: ''
},
drawCustomField: {
type: Object,
required: false,
default: null
},
drawOption: {
type: Object,
required: false,
default: null
}
},
emits: ['update', 'draw', 'set-draw', 'add-custom-field'],
data () {
return {
containerWidth: 1040,
isMounted: false,
sectionRefs: []
}
},
computed: {
renderHtmlForSaveRef: () => ref(false),
isDynamic () {
return true
},
attachmentsIndex () {
return (this.document.attachments || []).reduce((acc, att) => {
acc[att.uuid] = att.url
return acc
}, {})
},
bodyDom () {
return new DOMParser().parseFromString(this.document.body, 'text/html')
},
headDom () {
return new DOMParser().parseFromString(this.document.head, 'text/html')
},
sections () {
return this.bodyDom.querySelectorAll('section')
},
styles () {
return this.headDom.querySelectorAll('style')
},
shadow () {
if (this.isMounted) {
return this.$refs.shadow.attachShadow({ mode: 'open' })
} else {
return null
}
}
},
mounted () {
this.isMounted = true
this.shadow.adoptedStyleSheets.push(dynamicStylesheet, tiptapStylesheet)
this.containerWidth = this.$refs.container.clientWidth
this.resizeObserver = new ResizeObserver(() => {
if (this.$refs.container) {
this.containerWidth = this.$refs.container.clientWidth
}
})
this.resizeObserver.observe(this.$refs.container)
window.addEventListener('beforeunload', this.onBeforeUnload)
},
beforeUnmount () {
window.removeEventListener('beforeunload', this.onBeforeUnload)
this.resizeObserver.unobserve(this.$refs.container)
},
beforeUpdate () {
this.sectionRefs = []
},
methods: {
mergeSchemaProperties,
removeArea (area) {
this.sectionRefs.forEach((sectionRef) => {
const pos = sectionRef.findAreaNodePos(area.uuid)
if (pos !== -1) {
sectionRef.editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run()
}
})
},
setSectionRefs (ref) {
if (ref) {
this.sectionRefs.push(ref)
}
},
onBeforeUnload (event) {
if (this.saveTimer) {
event.preventDefault()
event.returnValue = ''
return ''
}
},
scrollToArea (area) {
this.sectionRefs.forEach(({ editor }) => editor.commands.setNodeSelection(0))
this.sectionRefs.forEach(({ editor }) => {
const el = editor.view.dom.querySelector(`[data-area-uuid="${area.uuid}"]`)
if (el) {
editor.commands.setNodeSelection(editor.view.posAtDOM(el, 0))
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
})
},
onSectionUpdate (section, { editor }) {
clearTimeout(this.saveTimer)
this.saveTimer = setTimeout(async () => {
await this.updateSectionAndSave(section, editor)
delete this.saveTimer
}, 1000)
},
updateVariablesSchema () {
this.document.variables_schema = buildVariablesSchema(this.bodyDom.body)
},
updateSectionAndSave (section, editor) {
const target = this.bodyDom.getElementById(section.id)
if (target) {
target.innerHTML = this.getHtmlForSave(editor)
}
this.document.body = this.bodyDom.body.innerHTML
this.updateVariablesSchema()
this.$emit('update', this.document)
return this.saveBody()
},
updateAndSave () {
this.update()
return this.saveBody()
},
update () {
clearTimeout(this.saveTimer)
delete this.saveTimer
this.sectionRefs.forEach(({ section, editor }) => {
const target = this.bodyDom.getElementById(section.id)
target.innerHTML = this.getHtmlForSave(editor)
})
this.document.body = this.bodyDom.body.innerHTML
this.updateVariablesSchema()
this.$emit('update', this.document)
},
getHtmlForSave (editor) {
this.renderHtmlForSaveRef.value = true
const result = editor.getHTML()
this.renderHtmlForSaveRef.value = false
return result
},
saveBody () {
clearTimeout(this.saveTimer)
delete this.saveTimer
return this.baseFetch(`/templates/${this.template.id}/dynamic_documents/${this.document.uuid}`, {
method: 'PUT',
body: JSON.stringify({ body: this.bodyDom.body.innerHTML }),
headers: { 'Content-Type': 'application/json' }
})
},
syncVariablesSchema (existing, parsed, { disable = true } = {}) {
for (const key of Object.keys(parsed)) {
if (!existing[key]) {
existing[key] = parsed[key]
}
}
for (const key of Object.keys(existing)) {
if (!parsed[key]) {
if (disable) {
existing[key].disabled = true
} else {
delete existing[key]
}
} else {
delete existing[key].disabled
if (!existing[key].form_type) {
existing[key].type = parsed[key].type
}
if (parsed[key].items) {
if (!existing[key].items) {
existing[key].items = parsed[key].items
} else if (existing[key].items.properties && parsed[key].items.properties) {
this.syncVariablesSchema(existing[key].items.properties, parsed[key].items.properties, { disable })
} else if (!existing[key].items.properties && !parsed[key].items.properties) {
existing[key].items.type = parsed[key].items.type
}
}
if (existing[key].properties && parsed[key].properties) {
this.syncVariablesSchema(existing[key].properties, parsed[key].properties, { disable })
}
}
}
}
}
}
</script>
@@ -0,0 +1,789 @@
import { Editor, Extension, Node, Mark } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import Document from '@tiptap/extension-document'
import Text from '@tiptap/extension-text'
import HardBreak from '@tiptap/extension-hard-break'
import History from '@tiptap/extension-history'
import Gapcursor from '@tiptap/extension-gapcursor'
import Dropcursor from '@tiptap/extension-dropcursor'
import { createApp, reactive } from 'vue'
import DynamicArea from './dynamic_area.vue'
import styles from './dynamic_styles.scss'
export const dynamicStylesheet = new CSSStyleSheet()
dynamicStylesheet.replaceSync(styles[0][1])
export const tiptapStylesheet = new CSSStyleSheet()
tiptapStylesheet.replaceSync(
`.ProseMirror {
position: relative;
}
.ProseMirror {
word-wrap: break-word;
white-space: pre-wrap;
white-space: break-spaces;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
font-feature-settings: "liga" 0;
}
.ProseMirror [contenteditable="false"] {
white-space: normal;
}
.ProseMirror [contenteditable="false"] [contenteditable="true"] {
white-space: pre-wrap;
}
.ProseMirror pre {
white-space: pre-wrap;
}
img.ProseMirror-separator {
display: inline !important;
border: none !important;
margin: 0 !important;
width: 0 !important;
height: 0 !important;
}
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
margin: 0;
}
.ProseMirror-gapcursor:after {
content: "";
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid black;
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
}
@keyframes ProseMirror-cursor-blink {
to {
visibility: hidden;
}
}
.ProseMirror-hideselection *::selection {
background: transparent;
}
.ProseMirror-hideselection *::-moz-selection {
background: transparent;
}
.ProseMirror-hideselection * {
caret-color: transparent;
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
dynamic-variable {
background-color: #fef3c7;
}`)
function collectDomAttrs (dom) {
const attrs = {}
for (let i = 0; i < dom.attributes.length; i++) {
attrs[dom.attributes[i].name] = dom.attributes[i].value
}
return { htmlAttrs: attrs }
}
function collectSpanDomAttrs (dom) {
const result = collectDomAttrs(dom)
if (result.htmlAttrs.style) {
const temp = document.createElement('span')
temp.style.cssText = result.htmlAttrs.style
if (['bold', '700'].includes(temp.style.fontWeight)) {
temp.style.removeProperty('font-weight')
}
if (temp.style.fontStyle === 'italic') {
temp.style.removeProperty('font-style')
}
if (temp.style.textDecoration === 'underline') {
temp.style.removeProperty('text-decoration')
}
if (temp.style.cssText) {
result.htmlAttrs.style = temp.style.cssText
} else {
delete result.htmlAttrs.style
}
}
return result
}
function createBlockNode (name, tag, content) {
return Node.create({
name,
group: 'block',
content: content || 'block+',
addAttributes () {
return {
htmlAttrs: { default: {} }
}
},
parseHTML () {
return [{ tag, getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return [tag, node.attrs.htmlAttrs, 0]
}
})
}
const CustomParagraph = Node.create({
name: 'paragraph',
group: 'block',
content: 'inline*',
addAttributes () {
return {
htmlAttrs: { default: {} }
}
},
parseHTML () {
return [{ tag: 'p', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['p', node.attrs.htmlAttrs, 0]
}
})
const CustomHeading = Node.create({
name: 'heading',
group: 'block',
content: 'inline*',
addAttributes () {
return {
htmlAttrs: { default: {} },
level: { default: 1 }
}
},
parseHTML () {
return [1, 2, 3, 4, 5, 6].map((level) => ({
tag: `h${level}`,
getAttrs: (dom) => ({ ...collectDomAttrs(dom), level })
}))
},
renderHTML ({ node }) {
return [`h${node.attrs.level}`, node.attrs.htmlAttrs, 0]
}
})
const SectionNode = createBlockNode('section', 'section')
const ArticleNode = createBlockNode('article', 'article')
const DivNode = createBlockNode('div', 'div')
const BlockquoteNode = createBlockNode('blockquote', 'blockquote')
const PreNode = createBlockNode('pre', 'pre')
const OrderedListNode = createBlockNode('orderedList', 'ol', '(listItem | block)+')
const BulletListNode = createBlockNode('bulletList', 'ul', '(listItem | block)+')
const ListItemNode = Node.create({
name: 'listItem',
content: 'block+',
addAttributes () {
return {
htmlAttrs: { default: {} }
}
},
parseHTML () {
return [{ tag: 'li', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['li', node.attrs.htmlAttrs, 0]
}
})
const TableNode = Node.create({
name: 'table',
group: 'block',
content: '(colgroup | tableHead | tableBody | tableRow)+',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'table', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['table', node.attrs.htmlAttrs, 0]
}
})
const TableHead = Node.create({
name: 'tableHead',
content: 'tableRow+',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'thead', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['thead', node.attrs.htmlAttrs, 0]
}
})
const TableBody = Node.create({
name: 'tableBody',
content: 'tableRow+',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'tbody', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['tbody', node.attrs.htmlAttrs, 0]
}
})
const TableRow = Node.create({
name: 'tableRow',
content: '(tableCell | tableHeader)+',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'tr', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['tr', node.attrs.htmlAttrs, 0]
}
})
const TableCell = Node.create({
name: 'tableCell',
content: 'block*',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'td', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['td', node.attrs.htmlAttrs, 0]
}
})
const TableHeader = Node.create({
name: 'tableHeader',
content: 'block*',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'th', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['th', node.attrs.htmlAttrs, 0]
}
})
const ImageNode = Node.create({
name: 'image',
inline: true,
group: 'inline',
draggable: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'img', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['img', node.attrs.htmlAttrs]
}
})
const ColGroupNode = Node.create({
name: 'colgroup',
group: 'block',
content: 'col*',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'colgroup', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['colgroup', node.attrs.htmlAttrs, 0]
}
})
const ColNode = Node.create({
name: 'col',
group: 'block',
atom: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'col', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['col', node.attrs.htmlAttrs]
}
})
const CustomBold = Mark.create({
name: 'bold',
parseHTML () {
return [{ tag: 'strong' }, { tag: 'b' }, { style: 'font-weight=bold' }, { style: 'font-weight=700' }]
},
renderHTML () {
return ['strong', 0]
},
addCommands () {
return {
toggleBold: () => ({ commands }) => commands.toggleMark(this.name)
}
},
addKeyboardShortcuts () {
return {
'Mod-b': () => this.editor.commands.toggleBold()
}
}
})
const CustomItalic = Mark.create({
name: 'italic',
parseHTML () {
return [{ tag: 'em' }, { tag: 'i' }, { style: 'font-style=italic' }]
},
renderHTML () {
return ['em', 0]
},
addCommands () {
return {
toggleItalic: () => ({ commands }) => commands.toggleMark(this.name)
}
},
addKeyboardShortcuts () {
return {
'Mod-i': () => this.editor.commands.toggleItalic()
}
}
})
const CustomUnderline = Mark.create({
name: 'underline',
parseHTML () {
return [{ tag: 'u' }, { style: 'text-decoration=underline' }]
},
renderHTML () {
return ['u', 0]
},
addCommands () {
return {
toggleUnderline: () => ({ commands }) => commands.toggleMark(this.name)
}
},
addKeyboardShortcuts () {
return {
'Mod-u': () => this.editor.commands.toggleUnderline()
}
}
})
const CustomStrike = Mark.create({
name: 'strike',
parseHTML () {
return [{ tag: 's' }, { tag: 'del' }, { tag: 'strike' }, { style: 'text-decoration=line-through' }]
},
renderHTML () {
return ['s', 0]
},
addCommands () {
return {
toggleStrike: () => ({ commands }) => commands.toggleMark(this.name)
}
},
addKeyboardShortcuts () {
return {
'Mod-Shift-s': () => this.editor.commands.toggleStrike()
}
}
})
const EmptySpanNode = Node.create({
name: 'emptySpan',
inline: true,
group: 'inline',
atom: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{
tag: 'span',
priority: 60,
getAttrs (dom) {
if (dom.childNodes.length === 0 && dom.attributes.length > 0) {
return collectDomAttrs(dom)
}
return false
}
}]
},
renderHTML ({ node }) {
return ['span', node.attrs.htmlAttrs]
}
})
const SpanMark = Mark.create({
name: 'span',
excludes: '',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'span', getAttrs: collectSpanDomAttrs }]
},
renderHTML ({ mark }) {
return ['span', mark.attrs.htmlAttrs, 0]
}
})
const LinkMark = Mark.create({
name: 'link',
excludes: '',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'a', getAttrs: collectDomAttrs }]
},
renderHTML ({ mark }) {
return ['a', mark.attrs.htmlAttrs, 0]
}
})
const SubscriptMark = Mark.create({
name: 'subscript',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'sub', getAttrs: collectDomAttrs }]
},
renderHTML ({ mark }) {
return ['sub', mark.attrs.htmlAttrs, 0]
}
})
const SuperscriptMark = Mark.create({
name: 'superscript',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'sup', getAttrs: collectDomAttrs }]
},
renderHTML ({ mark }) {
return ['sup', mark.attrs.htmlAttrs, 0]
}
})
const TabHandler = Extension.create({
name: 'tabHandler',
addKeyboardShortcuts () {
return {
Tab: () => {
this.editor.commands.insertContent('\t')
return true
}
}
}
})
const variableHighlightKey = new PluginKey('variableHighlight')
function buildDecorations (doc) {
const decorations = []
const regex = /\[\[[^\]]*\]\]/g
doc.descendants((node, pos) => {
if (!node.isText) return
let match
while ((match = regex.exec(node.text)) !== null) {
const from = pos + match.index
const to = from + match[0].length
decorations.push(Decoration.inline(from, to, { nodeName: 'dynamic-variable' }))
}
})
return DecorationSet.create(doc, decorations)
}
const VariableHighlight = Extension.create({
name: 'variableHighlight',
addProseMirrorPlugins () {
return [
new Plugin({
key: variableHighlightKey,
state: {
init (_, { doc }) {
return buildDecorations(doc)
},
apply (tr, oldSet) {
if (tr.docChanged) {
return buildDecorations(tr.doc)
}
return oldSet
}
},
props: {
decorations (state) {
return this.getState(state)
},
handleTextInput (view, from, to, text) {
if (text !== '[') return false
const { state } = view
const charBefore = state.doc.textBetween(Math.max(from - 1, 0), from)
if (charBefore !== '[') return false
const tr = state.tr.insertText('[]]', from, to)
tr.setSelection(state.selection.constructor.create(tr.doc, from + 1))
view.dispatch(tr)
return true
}
}
})
]
}
})
export function buildEditor ({ dynamicAreaProps, attachmentsIndex, renderHtmlForSaveRef, onFieldDrop, onFieldDestroy, editorOptions }) {
const FieldNode = Node.create({
name: 'fieldNode',
inline: true,
group: 'inline',
atom: true,
draggable: true,
addAttributes () {
return {
uuid: { default: null },
areaUuid: { default: null },
width: { default: '124px' },
height: { default: null },
verticalAlign: { default: 'text-bottom' },
display: { default: 'inline-flex' }
}
},
parseHTML () {
return [{
tag: 'dynamic-field',
getAttrs (dom) {
return {
uuid: dom.getAttribute('uuid'),
areaUuid: dom.getAttribute('area-uuid'),
width: dom.style.width,
height: dom.style.height,
display: dom.style.display,
verticalAlign: dom.style.verticalAlign
}
}
}]
},
renderHTML ({ node }) {
const attrs = {
uuid: node.attrs.uuid,
'area-uuid': node.attrs.areaUuid,
style: `width: ${node.attrs.width}; height: ${node.attrs.height}; display: ${node.attrs.display}; vertical-align: ${node.attrs.verticalAlign};`
}
if (!renderHtmlForSaveRef.value) {
const fieldArea = dynamicAreaProps.findFieldArea(node.attrs.areaUuid)
if (fieldArea?.field && fieldArea?.area) {
const field = JSON.parse(JSON.stringify(fieldArea.field))
const area = JSON.parse(JSON.stringify(fieldArea.area))
delete field.areas
delete field.uuid
delete field.submitter_uuid
delete area.uuid
delete area.attachment_uuid
attrs['data-field'] = JSON.stringify(field)
attrs['data-area'] = JSON.stringify(area)
attrs['data-template-id'] = dynamicAreaProps.template.id
}
}
return ['dynamic-field', attrs]
},
addNodeView () {
return ({ node, getPos, editor }) => {
const dom = document.createElement('span')
const nodeStyle = reactive({
width: node.attrs.width,
height: node.attrs.height,
verticalAlign: node.attrs.verticalAlign,
display: node.attrs.display
})
dom.dataset.areaUuid = node.attrs.areaUuid
const shadow = dom.attachShadow({ mode: 'open' })
shadow.adoptedStyleSheets = [dynamicStylesheet]
const app = createApp(DynamicArea, {
fieldUuid: node.attrs.uuid,
areaUuid: node.attrs.areaUuid,
nodeStyle,
getPos,
editor,
editable: editorOptions.editable,
...dynamicAreaProps
})
app.mount(shadow)
return {
dom,
update (updatedNode) {
if (updatedNode.attrs.areaUuid === node.attrs.areaUuid) {
nodeStyle.width = updatedNode.attrs.width
nodeStyle.height = updatedNode.attrs.height
nodeStyle.verticalAlign = updatedNode.attrs.verticalAlign
nodeStyle.display = updatedNode.attrs.display
}
},
destroy () {
onFieldDestroy(node)
app.unmount()
}
}
}
}
})
const FieldDropPlugin = Extension.create({
name: 'fieldDrop',
addProseMirrorPlugins () {
return [
new Plugin({
key: new PluginKey('fieldDrop'),
props: {
handleDrop: onFieldDrop
}
})
]
}
})
const DynamicImageNode = ImageNode.extend({
renderHTML ({ node }) {
const { loading, ...attrs } = node.attrs.htmlAttrs
return ['img', attrs]
},
addNodeView () {
return ({ node }) => {
const dom = document.createElement('img')
const attrs = { ...node.attrs.htmlAttrs }
const blobUuid = attrs.src?.startsWith('blob:') && attrs.src.slice(5)
if (blobUuid && attachmentsIndex[blobUuid]) {
attrs.src = attachmentsIndex[blobUuid]
}
dom.setAttribute('loading', 'lazy')
Object.entries(attrs).forEach(([k, v]) => dom.setAttribute(k, v))
return { dom }
}
}
})
return new Editor({
extensions: [
Document,
Text,
HardBreak,
History,
Gapcursor,
Dropcursor,
CustomBold,
CustomItalic,
CustomUnderline,
CustomStrike,
CustomParagraph,
CustomHeading,
SectionNode,
ArticleNode,
DivNode,
BlockquoteNode,
PreNode,
OrderedListNode,
BulletListNode,
ListItemNode,
TableNode,
TableHead,
TableBody,
TableRow,
TableCell,
TableHeader,
ColGroupNode,
ColNode,
DynamicImageNode,
EmptySpanNode,
LinkMark,
SpanMark,
SubscriptMark,
SuperscriptMark,
VariableHighlight,
TabHandler,
FieldNode,
FieldDropPlugin
],
editorProps: {
attributes: {
style: 'outline: none'
}
},
parseOptions: {
preserveWhitespace: true
},
injectCSS: false,
...editorOptions
})
}
@@ -0,0 +1,211 @@
<template>
<div
v-if="visible"
class="absolute z-10 flex items-center gap-0.5 px-1.5 py-1 bg-white border border-base-300 rounded-lg shadow select-none"
:style="{ top: (coords.top - 42) + 'px', left: coords.left + 'px', transform: 'translateX(-50%)' }"
@mousedown.prevent
>
<button
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
:class="isBold ? 'bg-base-200' : 'bg-transparent'"
title="Bold"
@click="toggleBold"
>
<IconBold
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
:class="isItalic ? 'bg-base-200' : 'bg-transparent'"
title="Italic"
@click="toggleItalic"
>
<IconItalic
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
:class="isUnderline ? 'bg-base-200' : 'bg-transparent'"
title="Underline"
@click="toggleUnderline"
>
<IconUnderline
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
:class="isStrike ? 'bg-base-200' : 'bg-transparent'"
title="Strikethrough"
@click="toggleStrike"
>
<IconStrikethrough
:width="16"
:height="16"
/>
</button>
<div class="w-px h-5 bg-base-300 mx-1" />
<button
class="inline-flex items-center justify-center text-xs h-7 border-none rounded cursor-pointer text-gray-700 bg-transparent"
title="Wrap in variable"
@click="wrapVariable"
>
<IconBracketsContain
:width="16"
:height="16"
:stroke-width="1.6"
/>
<span class="px-0.5">
Variable
</span>
</button>
<div class="w-px h-5 bg-base-300 mx-1" />
<button
class="inline-flex items-center justify-center text-xs h-7 border-none rounded cursor-pointer text-gray-700 bg-transparent"
title="Wrap in condition"
@click="wrapCondition"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
class="tabler-icon tabler-icon-brackets-contain"
><path d="M7 4h-4v16h4" /><path d="M17 4h4v16h-4" />
<text
x="12"
y="16.5"
text-anchor="middle"
fill="currentColor"
stroke="none"
font-size="14"
font-weight="600"
font-family="ui-sans-serif, system-ui, sans-serif"
>if</text>
</svg>
<span class="px-0.5">
Condition
</span>
</button>
</div>
</template>
<script>
import { IconBold, IconItalic, IconUnderline, IconStrikethrough, IconBracketsContain } from '@tabler/icons-vue'
export default {
name: 'DynamicMenu',
components: {
IconBold,
IconItalic,
IconUnderline,
IconStrikethrough,
IconBracketsContain
},
props: {
editor: {
type: Object,
required: true
},
coords: {
type: Object,
required: false,
default: null
}
},
emits: ['add-variable', 'add-condition'],
data () {
return {
isMouseDown: false,
isBold: this.editor.isActive('bold'),
isItalic: this.editor.isActive('italic'),
isUnderline: this.editor.isActive('underline'),
isStrike: this.editor.isActive('strike')
}
},
computed: {
visible () {
return !!this.coords && !this.isMouseDown
}
},
mounted () {
this.editor.view.dom.addEventListener('mousedown', this.onMouseDown)
document.addEventListener('mouseup', this.onMouseUp)
this.editor.on('transaction', this.onTransaction)
},
beforeUnmount () {
if (!this.editor.isDestroyed) {
this.editor.view.dom.removeEventListener('mousedown', this.onMouseDown)
this.editor.off('transaction', this.onTransaction)
}
document.removeEventListener('mouseup', this.onMouseUp)
},
methods: {
toggleBold () {
this.editor.chain().focus().toggleBold().run()
},
toggleItalic () {
this.editor.chain().focus().toggleItalic().run()
},
toggleUnderline () {
this.editor.chain().focus().toggleUnderline().run()
},
toggleStrike () {
this.editor.chain().focus().toggleStrike().run()
},
wrapVariable () {
const { from, to } = this.editor.state.selection
const replacement = '[[variable]]'
const varFrom = from + 2
const varTo = varFrom + 8
this.editor.chain().focus()
.insertContentAt({ from, to }, replacement)
.setTextSelection({ from: varFrom, to: varTo })
.run()
this.$emit('add-variable')
},
wrapCondition () {
const { from, to } = this.editor.state.selection
const endText = '[[end]]'
const ifText = '[[if:variable]]'
this.editor.chain().focus()
.insertContentAt(to, endText)
.insertContentAt(from, ifText)
.setTextSelection({ from: from + 5, to: from + 13 })
.run()
this.$emit('add-condition')
},
onMouseDown () {
this.isMouseDown = true
},
onMouseUp () {
setTimeout(() => {
this.isMouseDown = false
}, 1)
},
onTransaction () {
this.isBold = this.editor.isActive('bold')
this.isItalic = this.editor.isActive('italic')
this.isUnderline = this.editor.isActive('underline')
this.isStrike = this.editor.isActive('strike')
}
}
}
</script>
@@ -0,0 +1,901 @@
<template>
<div
class="relative bg-white select-none mb-4 before:border before:rounded before:top-0 before:bottom-0 before:left-0 before:right-0 before:absolute"
:class="{ 'cursor-crosshair': isDrawMode && editable }"
>
<div
v-if="isDrawMode && editable && cursorHighlightCoords"
class="absolute pointer-events-none z-10 bg-black"
:style="{ width: '1px', height: cursorHighlightCoords.height + 'px', left: cursorHighlightCoords.x + 'px', top: cursorHighlightCoords.y + 'px' }"
/>
<div :style="{ zoom: containerWidth / sectionWidthPx }">
<section
:id="section.id"
ref="editorElement"
dir="auto"
:class="section.classList.value"
:style="section.style.cssText"
/>
</div>
<Teleport
v-if="editor"
:to="container"
>
<div
v-if="areaToolbarCoords && selectedField && selectedArea && !isAreaDrag"
class="absolute z-10"
:style="{ left: areaToolbarCoords.left + 'px', top: areaToolbarCoords.top + 'px' }"
>
<AreaTitle
:area="selectedArea"
:field="selectedField"
:editable="editable"
:template="template"
:selected-areas-ref="selectedAreasRef"
:get-field-type-index="getFieldTypeIndex"
@remove="onRemoveSelectedArea"
@change="onSelectedAreaChange"
/>
</div>
<DynamicMenu
v-if="editable"
v-show="!selectedAreasRef.value.length"
:editor="editor"
:coords="dynamicMenuCoords"
@add-variable="dynamicMenuCoords = null"
@add-condition="dynamicMenuCoords = null"
/>
<FieldContextMenu
v-if="contextMenu && contextMenuField"
:context-menu="contextMenu"
:field="contextMenuField"
:with-copy-to-all-pages="false"
@close="closeContextMenu"
@copy="onContextMenuCopy"
@delete="onContextMenuDelete"
@add-custom-field="$emit('add-custom-field', $event)"
@set-draw="$emit('set-draw', $event)"
@save="save"
/>
</Teleport>
</div>
</template>
<script>
import { shallowRef } from 'vue'
import { DOMSerializer, Fragment } from '@tiptap/pm/model'
import { v4 } from 'uuid'
import FieldContextMenu from './field_context_menu.vue'
import AreaTitle from './area_title.vue'
import DynamicMenu from './dynamic_menu.vue'
import { buildEditor } from './dynamic_editor.js'
export default {
name: 'DynamicSection',
components: {
DynamicMenu,
FieldContextMenu,
AreaTitle
},
inject: ['template', 'save', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'selectedAreasRef', 'getFieldTypeIndex', 'fieldTypes', 'withPhone', 'withPayment', 'withVerification', 'withKba', 'backgroundColor'],
props: {
section: {
type: Object,
required: true
},
sectionRefs: {
type: Array,
required: false,
default: () => []
},
editable: {
type: Boolean,
required: false,
default: true
},
container: {
type: Object,
required: true
},
containerWidth: {
type: Number,
required: true
},
attachmentsIndex: {
type: Object,
required: false,
default: () => ({})
},
selectedSubmitter: {
type: Object,
required: false,
default: null
},
dragField: {
type: Object,
required: false,
default: null
},
drawField: {
type: Object,
required: false,
default: null
},
drawFieldType: {
type: String,
required: false,
default: ''
},
drawCustomField: {
type: Object,
required: false,
default: null
},
renderHtmlForSaveRef: {
type: Object,
required: false,
default: null
},
drawOption: {
type: Object,
required: false,
default: null
},
attachmentUuid: {
type: String,
required: false,
default: null
}
},
emits: ['update', 'draw', 'set-draw', 'add-custom-field'],
data () {
return {
isAreaDrag: false,
areaToolbarCoords: null,
dynamicMenuCoords: null,
contextMenu: null,
cursorHighlightCoords: null
}
},
computed: {
defaultHeight () {
return CSS.supports('height', '1lh') ? '1lh' : '1em'
},
fieldAreaIndex () {
return (this.template.fields || []).reduce((acc, field) => {
field.areas?.forEach((area) => {
acc[area.uuid] = { area, field }
})
return acc
}, {})
},
defaultSizes () {
return {
checkbox: { width: '18px', height: '18px' },
radio: { width: '18px', height: '18px' },
multiple: { width: '18px', height: '18px' },
signature: { width: '140px', height: '50px' },
initials: { width: '40px', height: '32px' },
stamp: { width: '150px', height: '80px' },
kba: { width: '150px', height: '80px' },
verification: { width: '150px', height: '80px' },
image: { width: '200px', height: '100px' },
date: { width: '100px', height: this.defaultHeight },
datenow: { width: '100px', height: this.defaultHeight },
text: { width: '120px', height: this.defaultHeight },
cells: { width: '120px', height: this.defaultHeight },
file: { width: '120px', height: this.defaultHeight },
payment: { width: '120px', height: this.defaultHeight },
number: { width: '80px', height: this.defaultHeight },
select: { width: '120px', height: this.defaultHeight },
phone: { width: '120px', height: this.defaultHeight }
}
},
editorRef: () => shallowRef(),
editor () {
return this.editorRef.value
},
sectionWidthPx () {
const pt = parseFloat(this.section.style.width)
return pt * (96 / 72)
},
zoom () {
return this.containerWidth / this.sectionWidthPx
},
isDraggingField () {
return !!(this.fieldsDragFieldRef?.value || this.customDragFieldRef?.value || this.dragField)
},
isDrawMode () {
return !!(this.drawField || this.drawCustomField || this.drawFieldType)
},
selectedArea () {
return this.selectedAreasRef.value[0]
},
selectedField () {
if (this.selectedArea) {
return this.fieldAreaIndex[this.selectedArea.uuid]?.field
} else {
return null
}
},
contextMenuField () {
if (this.contextMenu?.areaUuid) {
return this.fieldAreaIndex[this.contextMenu.areaUuid].field
} else {
return null
}
}
},
watch: {
containerWidth () {
this.closeContextMenu()
if (this.dynamicMenuCoords && this.editor && !this.editor.state.selection.empty) {
this.$nextTick(() => this.setDynamicMenuCoords(this.editor))
}
}
},
mounted () {
this.editorRef.value = buildEditor({
dynamicAreaProps: {
template: this.template,
t: this.t,
selectedAreasRef: this.selectedAreasRef,
getFieldTypeIndex: this.getFieldTypeIndex,
findFieldArea: (areaUuid) => this.fieldAreaIndex[areaUuid],
getZoom: () => this.zoom,
onAreaContextMenu: this.onAreaContextMenu,
onAreaResize: this.onAreaResize,
onAreaDragStart: this.onAreaDragStart
},
attachmentsIndex: this.attachmentsIndex,
onFieldDrop: this.onFieldDrop,
onFieldDestroy: this.onFieldDestroy,
renderHtmlForSaveRef: this.renderHtmlForSaveRef,
editorOptions: {
element: this.$refs.editorElement,
editable: this.editable,
content: this.section.innerHTML,
onUpdate: (event) => this.$emit('update', event),
onSelectionUpdate: this.onSelectionUpdate,
onBlur: () => { this.dynamicMenuCoords = null }
}
})
this.editor.view.dom.addEventListener('paste', this.onEditorPaste, true)
this.editor.view.dom.addEventListener('pointerdown', this.onEditorPointerDown, true)
this.editor.view.dom.addEventListener('mousemove', this.onEditorMouseMove)
this.editor.view.dom.addEventListener('mouseleave', this.onEditorMouseLeave)
this.editor.view.dom.addEventListener('keydown', this.onEditorKeyDown)
},
beforeUnmount () {
if (this.editor) {
this.editor.view.dom.removeEventListener('paste', this.onEditorPaste, true)
this.editor.view.dom.removeEventListener('pointerdown', this.onEditorPointerDown, true)
this.editor.view.dom.removeEventListener('mousemove', this.onEditorMouseMove)
this.editor.view.dom.removeEventListener('mouseleave', this.onEditorMouseLeave)
this.editor.view.dom.removeEventListener('keydown', this.onEditorKeyDown)
this.editor.destroy()
}
},
methods: {
findAreaNodePos (areaUuid) {
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
return this.editor.view.posAtDOM(el, 0)
},
getFieldInsertIndex (pos) {
const view = this.editor.view
const fields = this.template.fields || []
if (!fields.length) {
return 0
}
let previousField = null
view.state.doc.nodesBetween(0, pos, (node, nodePos) => {
if (node.type.name !== 'fieldNode') {
return
}
if (nodePos + node.nodeSize <= pos) {
previousField = this.fieldAreaIndex[node.attrs.areaUuid]?.field || previousField
}
})
if (!previousField) {
previousField = this.getPreviousSectionField()
}
if (!previousField) {
return 0
}
const previousFieldIndex = fields.indexOf(previousField)
return previousFieldIndex === -1 ? fields.length : previousFieldIndex + 1
},
insertFieldInTemplate (field, index) {
const currentFieldIndex = this.template.fields.indexOf(field)
if (currentFieldIndex !== -1) {
return currentFieldIndex
}
this.template.fields.splice(index, 0, field)
return index
},
getLastFieldInSection (sectionRef) {
let lastField = null
sectionRef.editor.state.doc.descendants((node) => {
if (node.type.name === 'fieldNode') {
lastField = this.fieldAreaIndex[node.attrs.areaUuid]?.field || lastField
}
})
return lastField
},
getPreviousSectionField () {
const sectionIndex = this.sectionRefs.indexOf(this)
for (let index = sectionIndex - 1; index >= 0; index -= 1) {
const previousField = this.getLastFieldInSection(this.sectionRefs[index])
if (previousField) {
return previousField
}
}
return null
},
removeArea (area) {
const { field } = this.fieldAreaIndex[area.uuid]
const areaIndex = field.areas.indexOf(area)
if (areaIndex !== -1) {
field.areas.splice(areaIndex, 1)
}
if (field.areas.length === 0) {
this.template.fields.splice(this.template.fields.indexOf(field), 1)
}
const pos = this.findAreaNodePos(area.uuid)
this.editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run()
this.save()
},
onSelectionUpdate ({ editor }) {
const { selection } = editor.state
if (selection.node?.type.name === 'fieldNode') {
const { areaUuid } = selection.node.attrs
const field = this.fieldAreaIndex[areaUuid]?.field
if (field) {
const area = field.areas.find((a) => a.uuid === areaUuid)
if (area) {
const dom = editor.view.nodeDOM(selection.from)
const areaEl = dom.shadowRoot.firstElementChild
if (areaEl) {
const rect = areaEl.getBoundingClientRect()
const containerRect = this.container.getBoundingClientRect()
this.areaToolbarCoords = {
left: rect.left - containerRect.left,
top: rect.top - containerRect.top
}
}
this.selectedAreasRef.value = [area]
}
}
} else {
this.areaToolbarCoords = null
this.selectedAreasRef.value = []
if (editor.state.selection.empty) {
this.dynamicMenuCoords = null
} else {
this.setDynamicMenuCoords(editor)
}
}
},
setDynamicMenuCoords (editor) {
const { from, to } = editor.state.selection
const view = editor.view
const start = view.coordsAtPos(from)
const end = view.coordsAtPos(to)
const containerRect = this.container.getBoundingClientRect()
const left = (start.left + end.right) / 2 - containerRect.left
this.dynamicMenuCoords = {
top: Math.min(start.top, end.top) - containerRect.top,
left: Math.max(80, Math.min(left, containerRect.width - 80))
}
},
onFieldDestroy (node) {
this.selectedAreasRef.value = []
const { areaUuid } = node.attrs
let nodeExistsInDoc = false
this.editor.state.doc.descendants((docNode) => {
if (docNode.attrs.areaUuid === areaUuid) {
nodeExistsInDoc = true
return false
}
})
if (nodeExistsInDoc) return
const fieldArea = this.fieldAreaIndex[areaUuid]
if (!fieldArea) return
const field = fieldArea.field
const areaIndex = field.areas.findIndex((a) => a.uuid === areaUuid)
if (areaIndex !== -1) {
field.areas.splice(areaIndex, 1)
}
if (!field.areas?.length) {
this.template.fields.splice(this.template.fields.indexOf(field), 1)
}
this.save()
},
onAreaResize (rect) {
const containerRect = this.container.getBoundingClientRect()
this.areaToolbarCoords = {
left: rect.left - containerRect.left,
top: rect.top - containerRect.top
}
},
onAreaDragStart () {
this.isAreaDrag = true
},
onAreaContextMenu (area, e) {
this.contextMenu = {
x: e.clientX,
y: e.clientY,
areaUuid: area.uuid
}
},
deselectArea () {
this.areaToolbarCoords = null
this.selectedAreasRef.value = []
},
closeContextMenu () {
this.contextMenu = null
},
onEditorMouseLeave () {
this.cursorHighlightCoords = null
},
onEditorKeyDown (event) {
if (event.key === 'Escape') {
this.editor.chain().setNodeSelection(0).blur().run()
this.deselectArea()
}
},
onEditorMouseMove (event) {
if (!this.isDrawMode || !this.editable) {
this.cursorHighlightCoords = null
return
}
const view = this.editor?.view
if (!view) return
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
if (!pos) {
this.cursorHighlightCoords = null
return
}
const coords = view.coordsAtPos(pos.pos)
const outerRect = this.$el.getBoundingClientRect()
const lineHeight = coords.bottom - coords.top
this.cursorHighlightCoords = {
x: coords.left - outerRect.left,
y: coords.top - outerRect.top,
height: lineHeight
}
},
getFieldNode (areaUuid) {
const pos = this.findAreaNodePos(areaUuid)
return this.editor.state.doc.nodeAt(pos)
},
serializeFieldNodeHtml (areaUuid) {
const node = this.getFieldNode(areaUuid)
if (!node) {
return null
}
const serializer = DOMSerializer.fromSchema(this.editor.state.schema)
const container = document.createElement('div')
container.appendChild(serializer.serializeFragment(Fragment.from(node)))
return container.innerHTML
},
async writeHtmlToClipboard ({ html, text }, clipboardData = null) {
if (clipboardData) {
clipboardData.setData('text/html', html)
clipboardData.setData('text/plain', text || html)
return true
}
if (navigator.clipboard?.write && window.ClipboardItem) {
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([text || html], { type: 'text/plain' })
})
])
return true
}
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text || html)
return true
}
return false
},
async copyFieldAreaToClipboard (areaUuid, clipboardData = null) {
const html = this.serializeFieldNodeHtml(areaUuid)
if (!html) {
return false
}
try {
await this.writeHtmlToClipboard({ html, text: html }, clipboardData)
return true
} catch (e) {
console.error('Failed to copy dynamic field:', e)
return false
}
},
async onContextMenuCopy () {
await this.copyFieldAreaToClipboard(this.contextMenu.areaUuid)
this.closeContextMenu()
},
buildCopiedField (payload) {
const field = JSON.parse(JSON.stringify(payload.field))
const area = {
...JSON.parse(JSON.stringify(payload.area)),
uuid: v4(),
attachment_uuid: this.attachmentUuid
}
if (payload.templateId !== this.template.id) {
delete field.conditions
if (field.preferences) {
delete field.preferences.formula
}
}
const newField = {
...field,
uuid: v4(),
submitter_uuid: this.selectedSubmitter.uuid,
areas: [area]
}
if (['radio', 'multiple'].includes(field.type) && field.options?.length) {
const oldOptionUuid = area.option_uuid
const optionsMap = {}
newField.options = field.options.map((opt) => {
const newUuid = v4()
optionsMap[opt.uuid] = newUuid
return { ...opt, uuid: newUuid }
})
area.option_uuid = optionsMap[oldOptionUuid] || newField.options[0]?.uuid
}
return { field: newField, area }
},
onEditorPaste (event) {
const clipboardData = event.clipboardData
if (!clipboardData) {
return
}
const html = clipboardData.getData('text/html')
const text = clipboardData.getData('text/plain')
const clipboardHtml = html || (text.includes('<dynamic-field') ? text : '')
if (!clipboardHtml || !clipboardHtml.includes('data-field=')) {
return
}
const container = document.createElement('div')
container.innerHTML = clipboardHtml
const fieldNodes = [...container.querySelectorAll('dynamic-field[data-field][data-area]')]
if (!fieldNodes.length) {
return
}
event.preventDefault()
const { selection } = this.editor.state
const { from, to } = selection
let insertIndex = this.getFieldInsertIndex(selection.node?.type.name === 'fieldNode' ? to : from)
let lastArea = null
fieldNodes.forEach((fieldNode) => {
const fieldValue = fieldNode.dataset.field
const areaValue = fieldNode.dataset.area
const templateId = fieldNode.dataset.templateId
if (!fieldValue || !areaValue) {
return
}
const { field, area } = this.buildCopiedField({
field: JSON.parse(fieldValue),
area: JSON.parse(areaValue),
templateId: Number(templateId)
})
this.insertFieldInTemplate(field, insertIndex)
insertIndex += 1
fieldNode.setAttribute('uuid', field.uuid)
fieldNode.setAttribute('area-uuid', area.uuid)
fieldNode.removeAttribute('data-field')
fieldNode.removeAttribute('data-area')
fieldNode.removeAttribute('data-template-id')
lastArea = area
})
this.editor.chain().focus().insertContentAt({ from, to }, container.innerHTML).run()
if (lastArea) {
this.editor.commands.setNodeSelection(this.findAreaNodePos(lastArea.uuid))
}
this.closeContextMenu()
this.save()
},
onContextMenuDelete () {
const menu = this.contextMenu
const fieldArea = this.fieldAreaIndex[menu.areaUuid]
if (fieldArea) {
this.removeArea(fieldArea.area)
}
this.closeContextMenu()
this.deselectArea()
},
onRemoveSelectedArea () {
this.removeArea(this.selectedArea)
this.deselectArea()
this.save()
},
onSelectedAreaChange () {
this.save()
},
onFieldDrop (view, event, _slice, moved) {
this.isAreaDrag = false
if (moved) {
return
}
const draggedField = this.fieldsDragFieldRef?.value || this.customDragFieldRef?.value || this.dragField
if (!draggedField) return false
event.preventDefault()
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
if (!pos) return false
this.insertFieldAtRange({
sourceField: draggedField,
existingField: this.fieldsDragFieldRef?.value,
from: pos.pos
})
this.fieldsDragFieldRef.value = null
this.customDragFieldRef.value = null
return true
},
onEditorPointerDown (event) {
if (!this.isDrawMode || !this.editable || this.isDraggingField) {
return
}
if (event.button === 2) {
return
}
const view = this.editor?.view
if (!view) {
return
}
const selection = view.state.selection
const isTextRangeSelection = !selection.empty && !selection.node
let from = selection.from
let to = selection.to
if (!isTextRangeSelection) {
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
if (!pos) {
return
}
from = pos.pos
to = pos.pos
}
const sourceField = this.drawField || this.drawCustomField || { type: this.drawFieldType }
event.preventDefault()
event.stopPropagation()
if (this.drawOption && this.drawField) {
const areaWithoutOption = this.drawField.areas?.find((a) => !a.option_uuid)
if (areaWithoutOption && !this.drawField.areas.find((a) => a.option_uuid === this.drawField.options[0].uuid)) {
areaWithoutOption.option_uuid = this.drawField.options[0].uuid
}
}
const inserted = this.insertFieldAtRange({
sourceField,
existingField: this.drawField,
optionUuid: this.drawOption?.uuid,
from,
to
})
if (inserted) {
this.$emit('draw', inserted)
}
},
buildFieldArea ({ optionUuid = null } = {}) {
const area = {
uuid: v4(),
attachment_uuid: this.attachmentUuid
}
if (optionUuid) {
area.option_uuid = optionUuid
}
return area
},
buildNewField (sourceField, area) {
const fieldType = sourceField?.type || 'text'
const newField = {
name: sourceField?.name || '',
uuid: v4(),
required: fieldType !== 'checkbox',
submitter_uuid: this.selectedSubmitter.uuid,
type: fieldType,
areas: [area]
}
if (['select', 'multiple', 'radio'].includes(fieldType)) {
if (sourceField?.options?.length) {
newField.options = sourceField.options.map((opt) => ({
value: typeof opt === 'string' ? opt : opt.value,
uuid: v4()
}))
} else {
newField.options = [{ value: '', uuid: v4() }, { value: '', uuid: v4() }]
}
}
if (fieldType === 'datenow') {
newField.type = 'date'
newField.readonly = true
newField.default_value = '{{date}}'
}
if (['stamp', 'heading', 'strikethrough'].includes(fieldType)) {
newField.readonly = true
if (fieldType === 'strikethrough') {
newField.default_value = true
}
}
return newField
},
insertFieldAtRange ({ sourceField, existingField = null, optionUuid = null, from, to = from }) {
if (!sourceField) {
return null
}
const fieldType = sourceField.type || existingField?.type || 'text'
const dims = this.defaultSizes[fieldType]
const area = this.buildFieldArea({ optionUuid })
const insertIndex = this.getFieldInsertIndex(from)
let field = existingField
const view = this.editor.view
if (field) {
if (!this.template.fields.includes(field)) {
this.insertFieldInTemplate(field, insertIndex)
}
field.areas = field.areas || []
field.areas.push(area)
} else {
field = this.buildNewField(sourceField, area)
this.insertFieldInTemplate(field, insertIndex)
}
const nodeType = view.state.schema.nodes.fieldNode
const fieldNode = nodeType.create({
uuid: field.uuid,
areaUuid: area.uuid,
width: dims.width,
height: dims.height
})
const tr = from !== to
? view.state.tr.replaceWith(from, to, fieldNode)
: view.state.tr.insert(from, fieldNode)
view.dispatch(tr)
this.editor.chain().focus().setNodeSelection(from).run()
this.save()
return { area, field, pos: from }
}
}
}
</script>
@@ -0,0 +1,22 @@
@config "../../../tailwind.dynamic.config.js";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
*,
::before,
::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: #e5e7eb;
}
::before,
::after {
--tw-content: '';
}
:host {
all: initial;
}
@@ -0,0 +1,422 @@
<template>
<div class="group">
<div class="flex items-center justify-between py-1.5 px-0.5">
<div class="flex items-center space-x-1 min-w-0">
<FieldType
:model-value="formType"
:editable="editable"
:button-width="18"
:menu-classes="'mt-1.5'"
:menu-style="{ backgroundColor: dropdownBgColor }"
@update:model-value="onTypeChange"
/>
<span
class="truncate"
:title="path"
>{{ displayName }}</span>
<span
v-if="isArray"
class="text-xs bg-base-200 rounded px-1 flex-shrink-0"
>{{ t('list') }}</span>
</div>
<div
v-if="editable"
class="flex items-center flex-shrink-0"
>
<span
class="dropdown dropdown-end"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
tabindex="0"
:title="t('settings')"
class="cursor-pointer text-transparent group-hover:text-base-content"
>
<IconSettings
:width="18"
:stroke-width="1.6"
/>
</label>
<ul
v-if="renderDropdown"
tabindex="0"
class="mt-1.5 dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10"
:style="{ backgroundColor: dropdownBgColor }"
@click="closeDropdown"
>
<div
class="py-1.5 px-1 relative"
@click.stop
>
<select
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="onTypeChange($event.target.value)"
>
<option
v-for="varType in variableTypes"
:key="varType"
:value="varType"
:selected="varType === formType"
>{{ t(varType) }}</option>
</select>
<label
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>{{ t('type') }}</label>
</div>
<div
v-if="formType === 'number'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[schema.format = $event.target.value, save()]"
>
<option
v-for="format in numberFormats"
:key="format"
:value="format"
:selected="format === schema.format || (format === 'none' && !schema.format)"
>{{ formatNumber(123456789.567, format) }}</option>
</select>
<label
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>{{ t('format') }}</label>
</div>
<div
v-if="['text', 'number'].includes(formType)"
class="py-1.5 px-1 relative"
@click.stop
>
<input
v-model="schema.default_value"
:type="formType === 'number' ? 'number' : 'text'"
:placeholder="t('default_value')"
dir="auto"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
@blur="save"
>
<label
v-if="schema.default_value"
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>{{ t('default_value') }}</label>
</div>
<div
v-if="formType === 'date'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
:value="schema.format || 'MM/DD/YYYY'"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[schema.format = $event.target.value, save()]"
>
<option
v-for="format in dateFormats"
:key="format"
:value="format"
>{{ formatDate(new Date(), format) }}</option>
</select>
<label
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>{{ t('format') }}</label>
</div>
<li
v-if="formType === 'date'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
:checked="schema.default_value === '{{date}}'"
type="checkbox"
class="toggle toggle-xs"
@change="[schema.default_value = $event.target.checked ? '{{date}}' : undefined, save()]"
>
<span class="label-text">{{ t('current_date') }}</span>
</label>
</li>
<div
v-if="['radio', 'select'].includes(formType)"
class="py-1.5 px-1 relative"
@click.stop
>
<select
dir="auto"
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal bg-transparent"
@change="[schema.default_value = $event.target.value || undefined, save()]"
>
<option
value=""
:selected="!schema.default_value"
>{{ t('none') }}</option>
<option
v-for="opt in (schema.options || [])"
:key="opt"
:value="opt"
:selected="schema.default_value === opt"
>{{ opt }}</option>
</select>
<label
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>{{ t('default_value') }}</label>
</div>
<li
v-if="formType === 'checkbox'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
:checked="schema.default_value === true"
type="checkbox"
class="toggle toggle-xs"
@change="[schema.default_value = $event.target.checked || undefined, save()]"
>
<span class="label-text">{{ t('checked') }}</span>
</label>
</li>
<li @click.stop>
<label class="cursor-pointer py-1.5">
<input
:checked="schema.required !== false"
type="checkbox"
class="toggle toggle-xs"
@change="[schema.required = $event.target.checked, save()]"
>
<span class="label-text">{{ t('required') }}</span>
</label>
</li>
</ul>
</span>
</div>
</div>
<div
v-if="['radio', 'select'].includes(formType) && schema.options"
ref="options"
class="pl-2 pr-1 pb-1.5 space-y-1.5"
>
<div
v-for="(option, index) in schema.options"
:key="index"
class="flex space-x-1.5 items-center"
>
<span class="text-sm w-3.5 select-none">{{ index + 1 }}.</span>
<input
:value="option"
class="w-full input input-primary input-xs text-sm bg-transparent"
type="text"
dir="auto"
:placeholder="`${t('option')} ${index + 1}`"
@blur="[schema.options.splice(index, 1, $event.target.value), save()]"
@keydown.enter="$event.target.value ? onOptionEnter(index, $event.target.value) : null"
>
<button
class="text-sm w-3.5"
tabindex="-1"
@click="[schema.options.splice(index, 1), save()]"
>
&times;
</button>
</div>
<button
class="text-center text-sm w-full pb-1"
@click="addOptionAndFocus((schema.options || []).length)"
>
+ {{ t('add_option') }}
</button>
</div>
</div>
</template>
<script>
import FieldType from './field_type'
import { IconSettings } from '@tabler/icons-vue'
export default {
name: 'DynamicVariable',
components: {
FieldType,
IconSettings
},
inject: ['t', 'save', 'backgroundColor'],
provide () {
return {
fieldTypes: ['text', 'number', 'date', 'checkbox', 'radio', 'select']
}
},
props: {
path: {
type: String,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
groupKey: {
type: String,
default: ''
},
schema: {
type: Object,
required: true
},
isArray: {
type: Boolean,
default: false
}
},
data () {
return {
renderDropdown: false
}
},
computed: {
displayName () {
if (this.groupKey) {
const prefix = this.groupKey + (this.path.startsWith(this.groupKey + '[].') ? '[].' : '.')
return this.path.slice(prefix.length)
} else {
return this.path
}
},
dropdownBgColor () {
return ['', null, 'transparent'].includes(this.backgroundColor) ? 'white' : this.backgroundColor
},
schemaTypeToFormType () {
return { string: 'text', number: 'number', boolean: 'checkbox', date: 'date' }
},
formType () {
return this.schema.form_type || this.schemaTypeToFormType[this.schema.type] || 'text'
},
variableTypes () {
return ['text', 'number', 'date', 'checkbox', 'radio', 'select']
},
formTypeToSchemaType () {
return { text: 'string', number: 'number', date: 'date', checkbox: 'boolean', radio: 'string', select: 'string' }
},
numberFormats () {
return [
'none',
'usd',
'eur',
'gbp',
'comma',
'dot',
'space'
]
},
dateFormats () {
const formats = [
'MM/DD/YYYY',
'DD/MM/YYYY',
'YYYY-MM-DD',
'DD-MM-YYYY',
'DD.MM.YYYY',
'MMM D, YYYY',
'MMMM D, YYYY',
'D MMM YYYY',
'D MMMM YYYY'
]
if (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko')) {
formats.push('YYYY년 MM월 DD일')
}
if (this.schema.format && !formats.includes(this.schema.format)) {
formats.unshift(this.schema.format)
}
return formats
}
},
methods: {
onTypeChange (newType) {
this.schema.type = this.formTypeToSchemaType[newType] || 'string'
this.schema.form_type = newType
if (['radio', 'select'].includes(newType)) {
if (!this.schema.options || !this.schema.options.length) {
this.schema.options = ['', '']
}
} else {
delete this.schema.options
delete this.schema.default_value
delete this.schema.format
}
this.save()
},
onOptionEnter (index, value) {
this.schema.options.splice(index, 1, value)
this.schema.options.splice(index + 1, 0, '')
this.save()
this.$nextTick(() => {
this.$refs.options.querySelectorAll('input')[index + 1]?.focus()
})
},
addOptionAndFocus (index) {
if (!this.schema.options) {
this.schema.options = []
}
this.schema.options.splice(index, 0, '')
this.save()
this.$nextTick(() => {
this.$refs.options.querySelectorAll('input')[index]?.focus()
})
},
formatNumber (number, format) {
if (format === 'comma') {
return new Intl.NumberFormat('en-US').format(number)
} else if (format === 'usd') {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'gbp') {
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'eur') {
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'dot') {
return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number)
} else {
return number
}
},
formatDate (date, format) {
const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
const dayFormats = { D: 'numeric', DD: '2-digit' }
const yearFormats = { YYYY: 'numeric', YY: '2-digit' }
const parts = new Intl.DateTimeFormat([], {
day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)]
}).formatToParts(date)
return format
.replace(/D+/, parts.find((p) => p.type === 'day').value)
.replace(/M+/, parts.find((p) => p.type === 'month').value)
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
},
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
}
}
}
</script>
@@ -0,0 +1,136 @@
<template>
<div>
<div
v-if="!schemaEntries.length"
class="text-center py-4 px-2"
>
<p class="font-medium">
{{ t('no_variables') }}
</p>
<p class="text-sm mt-1">
{{ t('no_variables_description') }}
</p>
</div>
<template v-else>
<template
v-for="([key, node], index) in schemaEntries"
:key="key"
>
<div v-if="isGroup(node)">
<hr
v-if="index > 0"
class="border-base-300"
>
<label class="peer flex items-center py-1.5 cursor-pointer select-none">
<span class="w-5 flex justify-center items-center">
<input
type="checkbox"
class="hidden peer"
checked
>
<IconChevronDown
class="hidden peer-checked:block ml-0.5"
:width="14"
:stroke-width="1.6"
/>
<IconChevronRight
class="block peer-checked:hidden ml-0.5"
:width="14"
:stroke-width="1.6"
/>
</span>
<span class="ml-1">{{ key }}</span>
<span
v-if="node.type === 'array'"
class="text-xs bg-base-200 rounded px-1 ml-1"
>{{ t('list') }}</span>
</label>
<div class="hidden peer-has-[:checked]:block pl-5">
<template
v-for="[varNode, varPath] in nestedVariables(node, key)"
:key="varPath"
>
<hr class="border-base-300">
<DynamicVariable
:path="varPath"
:group-key="key"
:editable="editable"
:schema="varNode"
/>
</template>
</div>
</div>
<template v-else>
<hr
v-if="index > 0"
class="border-base-300"
>
<DynamicVariable
:path="key"
:editable="editable"
:schema="node.type === 'array' && node.items ? node.items : node"
:is-array="node.type === 'array'"
/>
</template>
</template>
</template>
</div>
</template>
<script>
import DynamicVariable from './dynamic_variable'
import { IconChevronDown, IconChevronRight } from '@tabler/icons-vue'
export default {
name: 'DynamicVariables',
components: {
DynamicVariable,
IconChevronDown,
IconChevronRight
},
inject: ['t', 'template', 'save', 'backgroundColor'],
props: {
editable: {
type: Boolean,
required: false,
default: true
}
},
computed: {
schemaEntries () {
return Object.entries(this.template.variables_schema || {}).filter(([, node]) => !node.disabled)
}
},
methods: {
isGroup (node) {
return (node.type === 'object' && node.properties) || (node.type === 'array' && node.items?.type === 'object' && node.items?.properties)
},
nestedVariables (node, groupKey) {
const properties = node.type === 'array' ? node.items?.properties : node.properties
if (!properties) return []
const prefix = node.type === 'array' ? `${groupKey}[]` : groupKey
return this.collectLeafVariables(properties, prefix)
},
collectLeafVariables (properties, prefix) {
return Object.entries(properties).reduce((result, [key, node]) => {
if (node.disabled) return result
const path = `${prefix}.${key}`
if (node.type === 'object' && node.properties) {
result.push(...this.collectLeafVariables(node.properties, path))
} else if (node.type === 'array' && node.items?.properties) {
result.push(...this.collectLeafVariables(node.items.properties, `${path}[]`))
} else {
result.push([node, path])
}
return result
}, [])
}
}
}
</script>
@@ -0,0 +1,584 @@
const KEYWORDS = ['if', 'else', 'for', 'end']
const TYPE_PRIORITY = { string: 3, number: 2, boolean: 1 }
const AND_OR_REGEXP = /\s+(AND|OR)\s+/i
const COMPARISON_OPERATORS_REGEXP = />=|<=|!=|==|>|<|=/
function buildTokens (elem, acc = []) {
if (elem.nodeType === Node.TEXT_NODE) {
if (elem.textContent) {
const text = elem.textContent
const re = /[[\]]/g
let match
let found = false
while ((match = re.exec(text)) !== null) {
found = true
acc.push({
elem,
value: match[0],
textLength: text.length,
index: match.index
})
}
if (!found) {
acc.push({ elem, value: '', textLength: 0, index: 0 })
}
}
} else {
for (const child of elem.childNodes) {
buildTokens(child, acc)
}
}
return acc
}
function tokensPair (cur, nxt) {
if (cur.elem === nxt.elem) {
return cur.elem.textContent.slice(cur.index + 1, nxt.index).trim() === ''
} else {
return cur.elem.textContent.slice(cur.index + 1).trim() === '' &&
nxt.elem.textContent.slice(0, nxt.index).trim() === ''
}
}
function buildTags (tokens) {
const normalized = []
for (let i = 0; i < tokens.length - 1; i++) {
const cur = tokens[i]
const nxt = tokens[i + 1]
if (cur.value === '[' && nxt.value === '[' && tokensPair(cur, nxt)) {
normalized.push(['open', cur])
} else if (cur.value === ']' && nxt.value === ']' && tokensPair(cur, nxt)) {
normalized.push(['close', nxt])
}
}
const tags = []
for (let i = 0; i < normalized.length - 1; i++) {
const [curOp, openToken] = normalized[i]
const [nxtOp, closeToken] = normalized[i + 1]
if (curOp === 'open' && nxtOp === 'close') {
tags.push({ openToken, closeToken, value: '' })
}
}
return tags
}
function findTextNodesInBranch (elements, toElem, acc) {
if (!elements || elements.length === 0) return acc
for (const elem of elements) {
if (elem.nodeType === Node.TEXT_NODE) {
acc.push(elem)
} else {
findTextNodesInBranch(Array.from(elem.childNodes), toElem, acc)
}
if (acc.length > 0 && acc[acc.length - 1] === toElem) return acc
}
return acc
}
function findTextNodesBetween (fromElem, toElem, acc = []) {
if (fromElem === toElem) return [fromElem]
let currentElement = fromElem
while (true) {
const parent = currentElement.parentNode
if (!parent) return acc
const children = Array.from(parent.childNodes)
const startIndex = children.indexOf(currentElement)
if (startIndex === -1) return acc
const elementsInBranch = children.slice(startIndex)
findTextNodesInBranch(elementsInBranch, toElem, acc)
if (acc.length > 0 && acc[acc.length - 1] === toElem) return acc
let p = elementsInBranch[0].parentNode
while (p && !p.nextSibling) {
p = p.parentNode
}
if (!p || !p.nextSibling) return acc
currentElement = p.nextSibling
}
}
function mapTagValues (tags) {
for (const tag of tags) {
const textNodes = findTextNodesBetween(tag.openToken.elem, tag.closeToken.elem)
for (const elem of textNodes) {
let part
if (tag.openToken.elem === elem && tag.closeToken.elem === elem) {
part = elem.textContent.slice(tag.openToken.index, tag.closeToken.index + 1)
} else if (tag.openToken.elem === elem) {
part = elem.textContent.slice(tag.openToken.index)
} else if (tag.closeToken.elem === elem) {
part = elem.textContent.slice(0, tag.closeToken.index + 1)
} else {
part = elem.textContent
}
tag.value += part
}
}
return tags
}
function parseTagTypeName (tagString) {
const val = tagString.replace(/[[\]]/g, '').trim()
const parts = val.split(':').map((s) => s.trim())
if (parts.length === 2 && KEYWORDS.includes(parts[0])) {
return [parts[0], parts[1]]
} else if (KEYWORDS.includes(val)) {
return [val, null]
} else {
return ['var', val]
}
}
function isSimpleVariable (str) {
const s = str.trim()
return !AND_OR_REGEXP.test(s) &&
!COMPARISON_OPERATORS_REGEXP.test(s) &&
!s.includes('(') &&
!s.includes('!') &&
!s.includes('&&') &&
!s.includes('||') &&
!s.startsWith('"') &&
!s.startsWith("'") &&
!/^-?\d/.test(s) &&
!/^(true|false)$/i.test(s)
}
function tokenizeCondition (str) {
const tokens = []
let pos = 0
str = str.trim()
while (pos < str.length) {
const rest = str.slice(pos)
let m
if ((m = rest.match(/^\s+/))) {
pos += m[0].length
} else if ((m = rest.match(/^(>=|<=|!=|==|>|<|=)/))) {
tokens.push({ type: 'operator', value: m[1] })
pos += m[1].length
} else if (rest[0] === '!') {
tokens.push({ type: 'not', value: '!' })
pos += 1
} else if (rest[0] === '(') {
tokens.push({ type: 'lparen', value: '(' })
pos += 1
} else if (rest[0] === ')') {
tokens.push({ type: 'rparen', value: ')' })
pos += 1
} else if (rest.startsWith('&&')) {
tokens.push({ type: 'and', value: 'AND' })
pos += 2
} else if ((m = rest.match(/^AND\b/i))) {
tokens.push({ type: 'and', value: 'AND' })
pos += 3
} else if (rest.startsWith('||')) {
tokens.push({ type: 'or', value: 'OR' })
pos += 2
} else if ((m = rest.match(/^OR\b/i))) {
tokens.push({ type: 'or', value: 'OR' })
pos += 2
} else if ((m = rest.match(/^"([^"]*)"/) || rest.match(/^'([^']*)'/))) {
tokens.push({ type: 'string', value: m[1] })
pos += m[0].length
} else if ((m = rest.match(/^(-?\d+\.?\d*)/))) {
tokens.push({ type: 'number', value: m[1].includes('.') ? parseFloat(m[1]) : parseInt(m[1], 10) })
pos += m[1].length
} else if ((m = rest.match(/^(true|false)\b/i))) {
tokens.push({ type: 'boolean', value: m[1].toLowerCase() === 'true' })
pos += m[1].length
} else if ((m = rest.match(/^([\p{L}_][\p{L}\p{N}_.]*)/u))) {
tokens.push({ type: 'variable', value: m[1] })
pos += m[1].length
} else {
pos += 1
}
}
return tokens
}
function parseOrExpr (tokens, pos) {
let left, right
;[left, pos] = parseAndExpr(tokens, pos)
while (pos < tokens.length && tokens[pos].type === 'or') {
pos += 1
;[right, pos] = parseAndExpr(tokens, pos)
left = { type: 'or', left, right }
}
return [left, pos]
}
function parseAndExpr (tokens, pos) {
let left, right
;[left, pos] = parsePrimary(tokens, pos)
while (pos < tokens.length && tokens[pos].type === 'and') {
pos += 1
;[right, pos] = parsePrimary(tokens, pos)
left = { type: 'and', left, right }
}
return [left, pos]
}
function parsePrimary (tokens, pos) {
if (pos >= tokens.length) return [null, pos]
if (tokens[pos].type === 'not') {
const [child, p] = parsePrimary(tokens, pos + 1)
return [{ type: 'not', child }, p]
}
if (tokens[pos].type === 'lparen') {
const [node, p] = parseOrExpr(tokens, pos + 1)
return [node, p < tokens.length && tokens[p].type === 'rparen' ? p + 1 : p]
}
return parseComparisonOrPresence(tokens, pos)
}
function parseComparisonOrPresence (tokens, pos) {
if (pos >= tokens.length || tokens[pos].type !== 'variable') return [null, pos]
const variableName = tokens[pos].value
pos += 1
if (pos < tokens.length && tokens[pos].type === 'operator') {
let operator = tokens[pos].value
if (operator === '=') operator = '=='
pos += 1
if (pos < tokens.length && ['string', 'number', 'variable', 'boolean'].includes(tokens[pos].type)) {
const valueToken = tokens[pos]
return [{
type: 'comparison',
variableName,
operator,
value: valueToken.value,
valueIsVariable: valueToken.type === 'variable'
}, pos + 1]
}
}
return [{ type: 'presence', variableName }, pos]
}
function parseCondition (conditionString) {
const stripped = conditionString.trim()
if (stripped.startsWith('!') && isSimpleVariable(stripped.slice(1))) {
return { type: 'not', child: { type: 'presence', variableName: stripped.slice(1) } }
}
if (isSimpleVariable(stripped)) {
return { type: 'presence', variableName: stripped }
}
const tokens = tokenizeCondition(stripped)
const [ast] = parseOrExpr(tokens, 0)
return ast
}
function extractConditionVariables (node, acc = []) {
if (!node) return acc
switch (node.type) {
case 'or':
case 'and':
extractConditionVariables(node.left, acc)
extractConditionVariables(node.right, acc)
break
case 'not':
extractConditionVariables(node.child, acc)
break
case 'comparison':
acc.push({
name: node.variableName,
type: node.valueIsVariable ? null : (typeof node.value === 'boolean' ? 'boolean' : (typeof node.value === 'number' ? 'number' : 'string'))
})
if (node.valueIsVariable) {
acc.push({ name: node.value, type: null })
}
break
case 'presence':
acc.push({ name: node.variableName, type: 'boolean' })
break
}
return acc
}
const IRREGULAR_PLURALS = {
people: 'person',
men: 'man',
women: 'woman',
children: 'child',
teeth: 'tooth',
feet: 'foot',
mice: 'mouse',
geese: 'goose',
oxen: 'ox',
criteria: 'criterion',
phenomena: 'phenomenon',
alumni: 'alumnus',
data: 'datum',
media: 'medium'
}
function singularize (word) {
if (IRREGULAR_PLURALS[word]) return IRREGULAR_PLURALS[word]
if (word.endsWith('ies')) return word.slice(0, -3) + 'y'
if (word.endsWith('ches') || word.endsWith('shes')) return word.slice(0, -2)
if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('zes')) return word.slice(0, -2)
if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1)
return word
}
function buildOperators (tags) {
const operators = []
const stack = [{ children: operators, operator: null }]
for (const tag of tags) {
const [type, variableName] = parseTagTypeName(tag.value)
switch (type) {
case 'for':
case 'if': {
const operator = { type, variableName, tag, children: [] }
if (type === 'if') {
try {
operator.condition = parseCondition(variableName)
} catch (e) {
// ignore parse errors
}
}
stack[stack.length - 1].children.push(operator)
stack.push({ children: operator.children, operator })
break
}
case 'else': {
const current = stack[stack.length - 1]
if (current.operator && current.operator.type === 'if') {
current.operator.elseTag = tag
current.operator.elseChildren = []
current.children = current.operator.elseChildren
}
break
}
case 'end': {
const popped = stack.pop()
if (popped.operator) {
popped.operator.endTag = tag
}
break
}
case 'var':
stack[stack.length - 1].children.push({ type, variableName, tag })
break
}
}
return operators
}
function assignNestedSchema (propertiesHash, parentProperties, keyString, value) {
const keys = keyString.split('.')
const lastKey = keys.pop()
let currentLevel = null
if (keys.length > 0 && parentProperties[keys[0]]) {
currentLevel = keys.reduce((current, key) => {
if (!current[key]) current[key] = { type: 'object', properties: {} }
return current[key].properties
}, parentProperties)
}
if (!currentLevel) {
currentLevel = keys.reduce((current, key) => {
if (!current[key]) current[key] = { type: 'object', properties: {} }
return current[key].properties
}, propertiesHash)
}
currentLevel[lastKey] = value
}
function assignNestedSchemaWithPriority (propertiesHash, parentProperties, keyString, newType) {
const keys = keyString.split('.')
const lastKey = keys.pop()
let currentLevel = null
if (keys.length > 0 && parentProperties[keys[0]]) {
currentLevel = keys.reduce((current, key) => {
if (!current[key]) current[key] = { type: 'object', properties: {} }
return current[key].properties
}, parentProperties)
}
if (!currentLevel) {
currentLevel = keys.reduce((current, key) => {
if (!current[key]) current[key] = { type: 'object', properties: {} }
return current[key].properties
}, propertiesHash)
}
const existing = currentLevel[lastKey]
if (existing && (TYPE_PRIORITY[newType] || 0) <= (TYPE_PRIORITY[existing.type] || 0)) return
currentLevel[lastKey] = { type: newType }
}
function processConditionVariables (condition, propertiesHash, parentProperties) {
const variables = extractConditionVariables(condition)
for (const varInfo of variables) {
assignNestedSchemaWithPriority(propertiesHash, parentProperties, varInfo.name, varInfo.type || 'boolean')
}
}
function processOperators (operators, propertiesHash = {}, parentProperties = {}) {
if (!operators || operators.length === 0) return propertiesHash
for (const op of operators) {
switch (op.type) {
case 'var': {
if (!op.variableName.includes('.') && parentProperties[op.variableName]) {
const item = parentProperties[op.variableName]
if (item && item.type === 'object' && item.properties && Object.keys(item.properties).length === 0) {
delete item.properties
item.type = 'string'
}
} else {
assignNestedSchema(propertiesHash, parentProperties, op.variableName, { type: 'string' })
}
break
}
case 'if':
if (op.condition) {
processConditionVariables(op.condition, propertiesHash, parentProperties)
}
processOperators(op.children, propertiesHash, parentProperties)
processOperators(op.elseChildren, propertiesHash, parentProperties)
break
case 'for': {
const parts = op.variableName.split('.')
const singularKey = singularize(parts[parts.length - 1])
let itemProperties = parentProperties[singularKey]?.items
itemProperties = itemProperties || propertiesHash[parts[0]]?.items
itemProperties = itemProperties || { type: 'object', properties: {} }
assignNestedSchema(propertiesHash, parentProperties, op.variableName, { type: 'array', items: itemProperties })
processOperators(op.children, propertiesHash, { ...parentProperties, [singularKey]: itemProperties })
if (itemProperties.type === 'object' && itemProperties.properties && Object.keys(itemProperties.properties).length === 0) {
delete itemProperties.properties
itemProperties.type = 'string'
}
break
}
}
}
return propertiesHash
}
function mergeSchemaProperties (target, source) {
for (const key of Object.keys(source)) {
if (!target[key]) {
target[key] = source[key]
} else if (target[key].type === 'object' && source[key].type === 'object') {
if (!target[key].properties) target[key].properties = {}
if (source[key].properties) {
mergeSchemaProperties(target[key].properties, source[key].properties)
}
} else if (target[key].type === 'array' && source[key].type === 'array') {
if (source[key].items && source[key].items.properties) {
if (!target[key].items) {
target[key].items = source[key].items
} else if (target[key].items.properties) {
mergeSchemaProperties(target[key].items.properties, source[key].items.properties)
}
} else if (source[key].items && !target[key].items) {
target[key].items = source[key].items
}
} else if ((TYPE_PRIORITY[source[key].type] || 0) > (TYPE_PRIORITY[target[key].type] || 0)) {
target[key] = source[key]
}
}
return target
}
function buildVariablesSchema (dom) {
const tokens = buildTokens(dom)
const tags = mapTagValues(buildTags(tokens))
const operators = buildOperators(tags)
return processOperators(operators)
}
export { buildVariablesSchema, mergeSchemaProperties, buildOperators, buildTokens, buildTags, mapTagValues }
@@ -516,7 +516,7 @@
</label>
</li>
<hr
v-if="withCopyToAllPages || withAreas || withCustomFields"
v-if="(withCopyToAllPages && canCopyToAllPages) || withAreas || withCustomFields"
class="pb-0.5 mt-0.5"
>
<template v-if="withAreas">
@@ -561,7 +561,7 @@
</li>
</template>
<li
v-if="withCopyToAllPages && field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells', 'stamp'].includes(field.type)"
v-if="withCopyToAllPages && canCopyToAllPages && field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells', 'stamp'].includes(field.type)"
class="field-settings-copy-to-all-pages"
>
<a
@@ -681,6 +681,15 @@ export default {
return acc
}, {})
},
canCopyToAllPages () {
const firstArea = this.field.areas[0]
if (firstArea) {
return firstArea.page !== null && firstArea.page !== undefined
} else {
return false
}
},
numberFormats () {
return [
'none',
@@ -744,7 +753,7 @@ export default {
return ['text', 'number', 'cells', 'date', 'checkbox', 'select', 'radio', 'phone']
},
sortedAreas () {
return (this.field.areas || []).sort((a, b) => {
return (this.field.areas || []).filter((e) => e.page !== null && e.page !== undefined).sort((a, b) => {
return this.schemaAttachmentsIndexes[a.attachment_uuid] - this.schemaAttachmentsIndexes[b.attachment_uuid]
})
}
+56 -14
View File
@@ -1,19 +1,42 @@
<template>
<div :class="withStickySubmitters ? 'sticky top-0 z-[1]' : ''">
<div
:class="withStickySubmitters ? 'sticky top-0 z-[1]' : ''"
:style="withStickySubmitters ? { backgroundColor } : {}"
class="flex items-center gap-1"
>
<FieldSubmitter
:model-value="selectedSubmitter.uuid"
class="roles-dropdown w-full rounded-lg roles-dropdown"
:style="withStickySubmitters ? { backgroundColor } : {}"
:submitters="submitters"
:menu-style="{ overflow: 'auto', display: 'flex', flexDirection: 'row', maxHeight: 'calc(100vh - 120px)', backgroundColor: ['', null, 'transparent'].includes(backgroundColor) ? 'white' : backgroundColor }"
:editable="editable && !defaultSubmitters.length"
@new-submitter="save"
@remove="removeSubmitter"
@name-change="save"
@update:model-value="$emit('change-submitter', submitters.find((s) => s.uuid === $event))"
@update:model-value="[$emit('change-submitter', submitters.find((s) => s.uuid === $event)), isShowVariables = false]"
/>
<button
v-if="hasDynamicDocuments"
class="flex-shrink-0 rounded-md border hover:border-content flex items-center justify-center self-stretch"
:class="isShowVariables ? 'border-base-content bg-base-content text-base-100' : 'border-base-300'"
style="width: 44px"
:title="t('variables')"
@click.prevent="toggleVariables"
>
<IconBracketsContain
:width="22"
:height="22"
:stroke-width="1.6"
/>
</button>
</div>
<DynamicVariables
v-if="isShowVariables"
:editable="editable"
class="mt-1"
/>
<div
v-if="!isShowVariables"
ref="fields"
class="fields mt-2"
:class="{ 'mb-1': !withCustomFields || !customFields.length }"
@@ -42,7 +65,7 @@
@set-draw="$emit('set-draw', $event)"
/>
</div>
<div v-if="submitterDefaultFields.length && editable">
<div v-if="!isShowVariables && submitterDefaultFields.length && editable">
<hr class="mb-2">
<template v-if="isShowFieldSearch">
<input
@@ -110,7 +133,7 @@
</div>
</div>
<div
v-if="editable && withCustomFields && (customFields.length || newCustomField)"
v-if="!isShowVariables && editable && withCustomFields && (customFields.length || newCustomField)"
class="tabs w-full mb-1.5"
>
<a
@@ -127,7 +150,7 @@
>{{ t('custom') }}</a>
</div>
<div
v-if="showCustomTab && editable && (customFields.length || newCustomField)"
v-if="!isShowVariables && showCustomTab && editable && (customFields.length || newCustomField)"
ref="customFields"
class="custom-fields"
@dragover.prevent="onCustomFieldDragover"
@@ -195,7 +218,7 @@
</div>
</div>
<div
v-if="editable && !onlyDefinedFields && (!showCustomTab || (!customFields.length && !newCustomField))"
v-if="!isShowVariables && editable && !onlyDefinedFields && (!showCustomTab || (!customFields.length && !newCustomField))"
id="field-types-grid"
class="grid grid-cols-3 gap-1 pb-2 fields-grid"
>
@@ -283,7 +306,7 @@
</template>
</div>
<div
v-if="fields.length < 4 && editable && withHelp && !showTourStartForm"
v-if="!isShowVariables && fields.length < 4 && editable && withHelp && !showTourStartForm"
class="text-xs p-2 border border-base-200 rounded"
>
<ul class="list-disc list-outside ml-3">
@@ -299,7 +322,7 @@
</ul>
</div>
<div
v-if="withFieldsDetection && editable && fields.length < 2"
v-if="!isShowVariables && withFieldsDetection && editable && fields.length < 2 && !template.schema.some((item) => item.dynamic)"
class="my-2"
>
<button
@@ -336,7 +359,7 @@
</button>
</div>
<div
v-show="fields.length < 4 && editable && withHelp && showTourStartForm"
v-show="!isShowVariables && fields.length < 4 && editable && withHelp && showTourStartForm"
class="rounded py-2 px-4 w-full border border-dashed border-base-300"
>
<div class="text-center text-sm">
@@ -359,7 +382,8 @@ import Field from './field'
import CustomField from './custom_field'
import FieldType from './field_type'
import FieldSubmitter from './field_submitter'
import { IconLock, IconCirclePlus, IconInnerShadowTop, IconSparkles } from '@tabler/icons-vue'
import { defineAsyncComponent } from 'vue'
import { IconLock, IconCirclePlus, IconInnerShadowTop, IconSparkles, IconBracketsContain } from '@tabler/icons-vue'
import IconDrag from './icon_drag'
import { v4 } from 'uuid'
@@ -374,7 +398,9 @@ export default {
IconInnerShadowTop,
FieldSubmitter,
IconDrag,
IconLock
IconLock,
IconBracketsContain,
DynamicVariables: defineAsyncComponent(() => import(/* webpackChunkName: "dynamic-editor" */ './dynamic_variables'))
},
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'baseFetch', 'selectedAreasRef', 'getFieldTypeIndex'],
props: {
@@ -475,7 +501,7 @@ export default {
default: false
}
},
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter'],
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter', 'rebuild-variables-schema', 'remove-field', 'remove-submitter'],
data () {
return {
fieldPagesLoaded: null,
@@ -483,12 +509,16 @@ export default {
newCustomField: null,
showCustomTab: false,
defaultFieldsSearch: '',
customFieldsSearch: ''
customFieldsSearch: '',
isShowVariables: false
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons,
hasDynamicDocuments () {
return this.template.schema.some((item) => item.dynamic)
},
numberOfPages () {
return this.template.documents.reduce((acc, doc) => {
return acc + doc.metadata?.pdf?.number_of_pages || doc.preview_images.length
@@ -556,6 +586,10 @@ export default {
}
},
methods: {
toggleVariables () {
this.$emit('rebuild-variables-schema')
this.isShowVariables = !this.isShowVariables
},
onDragstart (event, field) {
this.removeDragOverlay(event)
@@ -582,6 +616,10 @@ export default {
delete customField.prefillable
delete customField.conditions
if (Array.isArray(customField.areas)) {
customField.areas = customField.areas.filter((area) => area.page !== null && area.page !== undefined)
}
customField.areas?.forEach((area) => {
delete area.attachment_uuid
delete area.page
@@ -767,6 +805,8 @@ export default {
this.$emit('change-submitter', this.submitters[0])
}
this.$emit('remove-submitter', submitter)
this.save()
},
removeField (field, save = true) {
@@ -792,6 +832,8 @@ export default {
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(area), 1)
})
this.$emit('remove-field', field)
if (save) {
this.save()
}
@@ -78,7 +78,7 @@ export default {
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf, application/zip'
default: 'image/*, application/pdf, application/zip, application/json'
}
},
emits: ['add', 'replace', 'replace-and-clone', 'error'],
+42
View File
@@ -49,6 +49,10 @@ const en = {
with_logo: 'With logo',
unchecked: 'Unchecked',
price: 'Price',
type: 'Type',
list: 'list',
no_variables: 'No variables yet',
no_variables_description: 'Add [[variable]] marks to your document to create dynamic content variables.',
type_value: 'Type value',
equal: 'Equal',
not_equal: 'Not equal',
@@ -73,6 +77,7 @@ const en = {
up: 'Up',
down: 'Down',
checked: 'Checked',
current_date: 'Current date',
save: 'Save',
cancel: 'Cancel',
any: 'Any',
@@ -104,6 +109,7 @@ const en = {
option: 'Option',
options: 'Options',
condition: 'Condition',
make_dynamic: 'Make dynamic',
first_party: 'First Party',
second_party: 'Second Party',
third_party: 'Third Party',
@@ -244,6 +250,10 @@ const es = {
search_field: 'Campo de búsqueda',
field_not_found: 'Campo no encontrado',
clear: 'Borrar',
type: 'Tipo',
list: 'lista',
no_variables: 'Aún sin variables',
no_variables_description: 'Agregue marcas [[variable]] a su documento para crear variables de contenido dinámico.',
type_value: 'Escriba valor',
align: 'Alinear',
resize: 'Redimensionar',
@@ -277,6 +287,7 @@ const es = {
remove_condition: 'Eliminar condición',
add_condition: 'Agregar condición',
condition: 'Condición',
make_dynamic: 'Hacer dinámico',
formula: 'Fórmula',
edit: 'Editar',
settings: 'Configuración',
@@ -286,6 +297,7 @@ const es = {
are_you_sure_: '¿Estás seguro?',
sign_yourself: 'Firma tú mismo',
checked: 'Seleccionado',
current_date: 'Fecha actual',
send: 'Enviar',
remove: 'Eliminar',
save: 'Guardar',
@@ -475,6 +487,10 @@ const it = {
with_logo: 'Con logo',
unchecked: 'Non selezionato',
price: 'Prezzo',
type: 'Tipo',
list: 'lista',
no_variables: 'Ancora nessuna variabile',
no_variables_description: 'Aggiungi marcatori [[variable]] al documento per creare variabili di contenuto dinamico.',
type_value: 'Inserisci valore',
equal: 'Uguale',
not_equal: 'Non uguale',
@@ -499,6 +515,7 @@ const it = {
up: 'Su',
down: 'Giù',
checked: 'Selezionato',
current_date: 'Data corrente',
save: 'Salva',
cancel: 'Annulla',
any: 'Qualsiasi',
@@ -530,6 +547,7 @@ const it = {
option: 'Opzione',
options: 'Opzioni',
condition: 'Condizione',
make_dynamic: 'Rendi dinamico',
first_party: 'Prima parte',
second_party: 'Seconda parte',
third_party: 'Terza parte',
@@ -670,6 +688,10 @@ const pt = {
search_field: 'Campo de busca',
field_not_found: 'Campo não encontrado',
clear: 'Limpar',
type: 'Tipo',
list: 'lista',
no_variables: 'Ainda sem variáveis',
no_variables_description: 'Adicione marcações [[variable]] ao documento para criar variáveis de conteúdo dinâmico.',
type_value: 'Digite valor',
add_all_required_fields_to_continue: 'Adicione todos os campos obrigatórios para continuar',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'O PDF carregado contém campos. Manter ou removê-los?',
@@ -703,6 +725,7 @@ const pt = {
select_value_: 'Selecionar valor...',
remove_condition: 'Remover condição',
condition: 'Condição',
make_dynamic: 'Tornar dinâmico',
formula: 'Fórmula',
edit: 'Editar',
settings: 'Configurações',
@@ -712,6 +735,7 @@ const pt = {
are_you_sure_: 'Tem certeza?',
sign_yourself: 'Assine você mesmo',
checked: 'Marcado',
current_date: 'Data atual',
send: 'Enviar',
remove: 'Remover',
save: 'Salvar',
@@ -901,6 +925,10 @@ const fr = {
with_logo: 'Avec logo',
unchecked: 'Décoché',
price: 'Prix',
type: 'Type',
list: 'liste',
no_variables: 'Pas encore de variables',
no_variables_description: 'Ajoutez des balises [[variable]] à votre document pour créer des variables de contenu dynamique.',
type_value: 'Saisir une valeur',
equal: 'Égal',
not_equal: 'Différent',
@@ -925,6 +953,7 @@ const fr = {
up: 'Haut',
down: 'Bas',
checked: 'Coché',
current_date: 'Date du jour',
save: 'Enregistrer',
cancel: 'Annuler',
any: "N'importe lequel",
@@ -956,6 +985,7 @@ const fr = {
option: 'Option',
options: 'Options',
condition: 'Condition',
make_dynamic: 'Rendre dynamique',
first_party: 'Première partie',
second_party: 'Deuxième partie',
third_party: 'Troisième partie',
@@ -1114,6 +1144,10 @@ const de = {
with_logo: 'Mit Logo',
unchecked: 'Nicht markiert',
price: 'Preis',
type: 'Typ',
list: 'Liste',
no_variables: 'Noch keine Variablen',
no_variables_description: 'Fügen Sie [[variable]]-Markierungen zu Ihrem Dokument hinzu, um dynamische Inhaltsvariablen zu erstellen.',
type_value: 'Wert eingeben',
equal: 'Gleich',
not_equal: 'Ungleich',
@@ -1138,6 +1172,7 @@ const de = {
up: 'Nach oben',
down: 'Nach unten',
checked: 'Markiert',
current_date: 'Aktuelles Datum',
save: 'Speichern',
cancel: 'Abbrechen',
any: 'Beliebig',
@@ -1169,6 +1204,7 @@ const de = {
option: 'Option',
options: 'Optionen',
condition: 'Bedingung',
make_dynamic: 'Dynamisch machen',
first_party: 'Erste Partei',
second_party: 'Zweite Partei',
third_party: 'Dritte Partei',
@@ -1327,6 +1363,10 @@ const nl = {
with_logo: 'Met logo',
unchecked: 'Niet aangevinkt',
price: 'Prijs',
type: 'Type',
list: 'lijst',
no_variables: 'Nog geen variabelen',
no_variables_description: 'Voeg [[variable]]-markeringen toe aan uw document om dynamische inhoudsvariabelen te maken.',
type_value: 'Typ waarde',
equal: 'Gelijk aan',
not_equal: 'Niet gelijk aan',
@@ -1351,6 +1391,7 @@ const nl = {
up: 'Omhoog',
down: 'Omlaag',
checked: 'Aangevinkt',
current_date: 'Huidige datum',
save: 'Opslaan',
cancel: 'Annuleren',
any: 'Elke',
@@ -1382,6 +1423,7 @@ const nl = {
option: 'Optie',
options: 'Opties',
condition: 'Voorwaarde',
make_dynamic: 'Dynamisch maken',
first_party: 'Eerste partij',
second_party: 'Tweede partij',
third_party: 'Derde partij',
+1 -1
View File
@@ -2,7 +2,7 @@
<svg
height="40"
width="40"
style="color: #e0753f"
style="color: #e97a42"
viewBox="0 0 180 180"
xmlns="http://www.w3.org/2000/svg"
>
+214 -56
View File
@@ -18,20 +18,18 @@
>
<div
style="width: 26px"
class="flex flex-col justify-between group-hover:opacity-100"
:class="{'opacity-0': !item.conditions?.length }"
class="flex flex-col"
>
<div>
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button"
@click.stop="isShowConditionsModal = true"
>
<IconRouteAltLeft
:width="14"
:stroke-width="1.6"
/>
</button>
</div>
<button
v-if="item.conditions?.length"
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button"
@click.stop="isShowConditionsModal = true"
>
<IconRouteAltLeft
:width="14"
:stroke-width="1.6"
/>
</button>
</div>
<div class="">
<ReplaceButton
@@ -44,51 +42,105 @@
/>
</div>
<div
class="flex flex-col justify-between opacity-0 group-hover:opacity-100"
class="flex flex-col justify-between"
>
<div>
<span
class="dropdown dropdown-end group-hover:opacity-100 has-[label:focus]:opacity-100"
:class="{ 'dropdown-open': isMakeDynamicLoading, 'opacity-0': !isMakeDynamicLoading }"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
tabindex="0"
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button px-0"
style="width: 24px; height: 24px"
@click.stop
>
<IconDotsVertical
:width="16"
:height="16"
:stroke-width="1.6"
/>
</label>
<ul
v-if="renderDropdown"
tabindex="0"
class="mt-1.5 dropdown-content p-1 shadow-lg rounded-lg border border-neutral-200 z-50 bg-white"
style="min-width: 170px"
@click="closeDropdown"
>
<li>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@click.stop="isShowConditionsModal = true; closeDropdown()"
>
<span class="flex items-center space-x-2">
<IconRouteAltLeft class="w-4 h-4" />
<span>{{ t('condition') }}</span>
</span>
<span
v-if="item.conditions?.length"
class="bg-neutral-200 rounded px-1 leading-3"
style="font-size: 9px;"
>{{ item.conditions.length }}</span>
</button>
</li>
<li v-if="!item.dynamic">
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm whitespace-nowrap"
@click.stop="$emit('reorder', item); closeDropdown()"
>
<IconSortDescending2 class="w-4 h-4" />
<span>{{ t('reorder_fields') }}</span>
</button>
</li>
<li v-if="withDynamicDocuments && !item.dynamic && document.metadata?.original_uuid">
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm whitespace-nowrap"
:disabled="isMakeDynamicLoading"
@click.stop="makeDynamic"
>
<IconInnerShadowTop
v-if="isMakeDynamicLoading"
class="w-4 h-4 animate-spin"
/>
<IconBolt
v-else
class="w-4 h-4"
/>
<span>{{ t('make_dynamic') }}</span>
</button>
</li>
<hr class="my-1 border-neutral-200">
<li>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm text-red-600"
@click.stop="$emit('remove', item); closeDropdown()"
>
<IconTrashX class="w-4 h-4" />
<span>{{ t('remove') }}</span>
</button>
</li>
</ul>
</span>
<div
v-if="withArrows"
class="flex flex-col space-y-1 opacity-0 group-hover:opacity-100"
>
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
style="width: 24px; height: 24px"
@click.stop="$emit('remove', item)"
@click.stop="$emit('up', item)"
>
&times;
&uarr;
</button>
</div>
<div
class="flex flex-col space-y-1"
>
<span
:data-tip="t('reorder_fields')"
class="tooltip tooltip-left before:text-xs"
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
style="width: 24px; height: 24px"
@click.stop="$emit('down', item)"
>
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0 document-control-button"
@click.stop="$emit('reorder', item)"
>
<IconSortDescending2
:width="18"
:height="18"
:stroke-width="1.6"
/>
</button>
</span>
<template v-if="withArrows">
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
style="width: 24px; height: 24px"
@click.stop="$emit('up', item)"
>
&uarr;
</button>
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
style="width: 24px; height: 24px"
@click.stop="$emit('down', item)"
>
&darr;
</button>
</template>
&darr;
</button>
</div>
</div>
</div>
@@ -129,24 +181,29 @@
<script>
import Contenteditable from './contenteditable'
import Upload from './upload'
import { IconRouteAltLeft, IconSortDescending2 } from '@tabler/icons-vue'
import { IconRouteAltLeft, IconSortDescending2, IconDotsVertical, IconTrashX, IconBolt, IconInnerShadowTop } from '@tabler/icons-vue'
import ConditionsModal from './conditions_modal'
import ReplaceButton from './replace'
import GoogleDriveDocumentSettings from './google_drive_document_settings'
import Field from './field'
import FieldType from './field_type'
import { v4 } from 'uuid'
export default {
name: 'DocumentPreview',
components: {
Contenteditable,
IconInnerShadowTop,
IconRouteAltLeft,
ConditionsModal,
ReplaceButton,
GoogleDriveDocumentSettings,
IconSortDescending2
IconSortDescending2,
IconDotsVertical,
IconTrashX,
IconBolt
},
inject: ['t', 'getFieldTypeIndex'],
inject: ['t', 'getFieldTypeIndex', 'baseFetch'],
props: {
item: {
type: Object,
@@ -168,13 +225,22 @@ export default {
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf, application/zip'
default: 'image/*, application/pdf, application/zip, application/json'
},
withReplaceButton: {
type: Boolean,
required: true,
default: true
},
dynamicDocuments: {
type: Array,
required: true
},
withDynamicDocuments: {
type: Boolean,
required: false,
default: false
},
withArrows: {
type: Boolean,
required: false,
@@ -184,7 +250,9 @@ export default {
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace', 'reorder'],
data () {
return {
isShowConditionsModal: false
isShowConditionsModal: false,
isMakeDynamicLoading: false,
renderDropdown: false
}
},
computed: {
@@ -200,6 +268,96 @@ export default {
methods: {
upload: Upload.methods.upload,
buildDefaultName: Field.methods.buildDefaultName,
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
},
makeDynamic () {
this.isMakeDynamicLoading = true
Promise.all([
this.baseFetch(`/templates/${this.template.id}/dynamic_documents`, {
method: 'POST',
body: JSON.stringify({ uuid: this.document.uuid }),
headers: {
'Content-Type': 'application/json'
}
}),
import(/* webpackChunkName: "dynamic-editor" */ './dynamic_document')
]).then(async ([resp, _]) => {
const dynamicDocument = await resp.json()
this.template.schema.find((item) => item.attachment_uuid === dynamicDocument.uuid).dynamic = true
this.removeFieldAreas()
if (dynamicDocument.fields?.length) {
this.addDynamicFields(dynamicDocument.fields)
}
if (dynamicDocument.uuid) {
delete dynamicDocument.fields
this.dynamicDocuments.push(dynamicDocument)
}
this.$emit('change')
}).finally(() => {
this.isMakeDynamicLoading = false
})
},
removeFieldAreas () {
this.template.fields.forEach((field) => {
if (field.areas?.length) {
field.areas = field.areas.filter((a) => a.attachment_uuid !== this.document.uuid)
}
})
this.template.fields = this.template.fields.filter((field) => field.areas?.length)
},
addDynamicFields (fields) {
const submittersNameIndex = this.template.submitters.reduce((acc, submitter) => {
acc[submitter.name] = submitter
return acc
}, {})
fields.forEach((field) => {
const roleName = field.role || this.template.submitters[0]?.name || this.t('first_party')
let submitter = submittersNameIndex[roleName]
if (!submitter) {
submitter = { name: roleName, uuid: v4() }
this.template.submitters.push(submitter)
submittersNameIndex[roleName] = submitter
}
const existingField = this.template.fields.find((f) => {
return f.name && f.name === field.name && f.type === (field.type || 'text') && f.submitter_uuid === submitter.uuid
})
if (existingField) {
field.areas.forEach((area) => {
area.attachment_uuid = this.document.uuid
existingField.areas = existingField.areas || []
existingField.areas.push(area)
})
} else {
field.submitter_uuid = submitter.uuid
delete field.role
field.areas.forEach((area) => {
area.attachment_uuid = this.document.uuid
})
this.template.fields.push(field)
}
})
},
onUpdateName (value) {
this.item.name = value
+1 -1
View File
@@ -35,7 +35,7 @@ export default {
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf, application/zip'
default: 'image/*, application/pdf, application/zip, application/json'
}
},
emits: ['success'],
+1 -1
View File
@@ -195,7 +195,7 @@ export default {
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf, application/zip'
default: 'image/*, application/pdf, application/zip, application/json'
}
},
emits: ['success', 'error'],
@@ -1,21 +0,0 @@
# frozen_string_literal: true
class GenerateAttachmentPreviewJob
include Sidekiq::Job
InvalidFormat = Class.new(StandardError)
sidekiq_options queue: :images
def perform(params = {})
attachment = ActiveStorage::Attachment.find(params['attachment_id'])
if attachment.content_type == Templates::ProcessDocument::PDF_CONTENT_TYPE
Templates::ProcessDocument.generate_pdf_preview_images(attachment, attachment.download)
elsif attachment.image?
Templates::ProcessDocument.generate_preview_image(attachment, attachment.download)
else
raise InvalidFormat, attachment.id
end
end
end
@@ -0,0 +1,38 @@
# frozen_string_literal: true
class SendTemplateArchivedWebhookRequestJob
include Sidekiq::Job
sidekiq_options queue: :webhooks
MAX_ATTEMPTS = 10
def perform(params = {})
template = Template.find_by(id: params['template_id'])
return unless template
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
return unless webhook_url
attempt = params['attempt'].to_i
return if webhook_url.url.blank? || webhook_url.events.exclude?('template.archived')
resp = SendWebhookRequest.call(webhook_url, event_type: 'template.archived',
event_uuid: params['event_uuid'],
record: template,
attempt:,
data: template.as_json(only: %i[id archived_at]))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
end
+2
View File
@@ -5,6 +5,8 @@ class ApplicationMailer < ActionMailer::Base
layout 'mailer'
register_interceptor ActionMailerConfigsInterceptor
register_interceptor HtmlToPlainTextInterceptor
register_preview_interceptor HtmlToPlainTextInterceptor
register_observer ActionMailerEventsObserver
+2
View File
@@ -30,6 +30,7 @@ class AccountConfig < ApplicationRecord
ALLOW_TYPED_SIGNATURE = 'allow_typed_signature'
ALLOW_TO_RESUBMIT = 'allow_to_resubmit'
ALLOW_TO_DECLINE_KEY = 'allow_to_decline'
ALLOW_TO_DELEGATE_KEY = 'allow_to_delegate'
ALLOW_TO_PARTIAL_DOWNLOAD_KEY = 'allow_to_partial_download'
SUBMITTER_REMINDERS = 'submitter_reminders'
ENFORCE_SIGNING_ORDER_KEY = 'enforce_signing_order'
@@ -57,6 +58,7 @@ class AccountConfig < ApplicationRecord
DOCUMENT_FILENAME_FORMAT_KEY = 'document_filename_format'
TEMPLATE_CUSTOM_FIELDS_KEY = 'template_custom_fields'
POLICY_LINKS_KEY = 'policy_links'
ENABLE_MCP_KEY = 'enable_mcp'
EMAIL_VARIABLES = {
SUBMITTER_INVITATION_EMAIL_KEY => %w[template.name submitter.link account.name].freeze,
+1 -1
View File
@@ -13,7 +13,7 @@
# Indexes
#
# index_document_generation_events_on_submitter_id (submitter_id)
# index_document_generation_events_on_submitter_id_and_event_name (submitter_id,event_name) UNIQUE WHERE ((event_name)::text = ANY ((ARRAY['start'::character varying, 'complete'::character varying])::text[]))
# index_document_generation_events_on_submitter_id_and_event_name (submitter_id,event_name) UNIQUE WHERE ((event_name)::text = ANY (ARRAY[('start'::character varying)::text, ('complete'::character varying)::text]))
#
# Foreign Keys
#
+38
View File
@@ -0,0 +1,38 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: dynamic_documents
#
# id :bigint not null, primary key
# body :text not null
# head :text
# sha1 :text not null
# uuid :uuid not null
# created_at :datetime not null
# updated_at :datetime not null
# template_id :bigint not null
#
# Indexes
#
# index_dynamic_documents_on_template_id (template_id)
#
# Foreign Keys
#
# fk_rails_... (template_id => templates.id)
#
class DynamicDocument < ApplicationRecord
belongs_to :template
has_many_attached :attachments
has_many :versions, class_name: 'DynamicDocumentVersion', dependent: :destroy
attribute :fields, :json
before_validation :set_sha1
def set_sha1
self.sha1 = Digest::SHA1.hexdigest(body)
end
end
+30
View File
@@ -0,0 +1,30 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: dynamic_document_versions
#
# id :bigint not null, primary key
# areas :text not null
# sha1 :string not null
# created_at :datetime not null
# updated_at :datetime not null
# dynamic_document_id :bigint not null
#
# Indexes
#
# idx_on_dynamic_document_id_sha1_3503adf557 (dynamic_document_id,sha1) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (dynamic_document_id => dynamic_documents.id)
#
class DynamicDocumentVersion < ApplicationRecord
belongs_to :dynamic_document
has_one_attached :document
attribute :areas, :string, default: -> { [] }
serialize :areas, coder: JSON
end
+1 -1
View File
@@ -20,7 +20,7 @@
#
# index_email_events_on_account_id_and_event_datetime (account_id,event_datetime)
# index_email_events_on_email (email)
# index_email_events_on_email_event_types (email) WHERE ((event_type)::text = ANY ((ARRAY['bounce'::character varying, 'soft_bounce'::character varying, 'permanent_bounce'::character varying, 'complaint'::character varying, 'soft_complaint'::character varying])::text[]))
# index_email_events_on_email_event_types (email) WHERE ((event_type)::text = ANY (ARRAY[('bounce'::character varying)::text, ('soft_bounce'::character varying)::text, ('permanent_bounce'::character varying)::text, ('complaint'::character varying)::text, ('soft_complaint'::character varying)::text]))
# index_email_events_on_emailable (emailable_type,emailable_id)
# index_email_events_on_message_id (message_id)
#
+1 -1
View File
@@ -12,7 +12,7 @@
#
# Indexes
#
# index_lock_events_on_event_name_and_key (event_name,key) UNIQUE WHERE ((event_name)::text = ANY ((ARRAY['start'::character varying, 'complete'::character varying])::text[]))
# index_lock_events_on_event_name_and_key (event_name,key) UNIQUE WHERE ((event_name)::text = ANY (ARRAY[('start'::character varying)::text, ('complete'::character varying)::text]))
# index_lock_events_on_key (key)
#
class LockEvent < ApplicationRecord
+42
View File
@@ -0,0 +1,42 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: mcp_tokens
#
# id :bigint not null, primary key
# archived_at :datetime
# name :string not null
# sha256 :string not null
# token_prefix :string not null
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint not null
#
# Indexes
#
# index_mcp_tokens_on_sha256 (sha256) UNIQUE
# index_mcp_tokens_on_user_id (user_id)
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
class McpToken < ApplicationRecord
TOKEN_LENGTH = 43
belongs_to :user
before_validation :set_sha256_and_token_prefix, on: :create
attribute :token, :string, default: -> { SecureRandom.base58(TOKEN_LENGTH) }
scope :active, -> { where(archived_at: nil) }
private
def set_sha256_and_token_prefix
self.sha256 = Digest::SHA256.hexdigest(token)
self.token_prefix = token[0, 5]
end
end
+52 -3
View File
@@ -10,7 +10,7 @@
# name :text
# preferences :text not null
# slug :string not null
# source :text not null
# source :string not null
# submitters_order :string not null
# template_fields :text
# template_schema :text
@@ -75,6 +75,17 @@ class Submission < ApplicationRecord
->(e) { where(uuid: (e.template_schema.presence || e.template.schema).pluck('attachment_uuid')) },
through: :template, source: :documents_attachments
has_many :template_schema_static_documents,
->(e) { where(uuid: e.template_schema.reject { |s| s['dynamic'] }.pluck('attachment_uuid')) },
through: :template, source: :documents_attachments
has_many :template_schema_dynamic_document_versions,
->(e) { where(sha1: e.template_schema.select { |s| s['dynamic'] }.pluck('dynamic_document_sha1')) },
through: :template, source: :dynamic_document_versions
has_many :template_schema_dynamic_document_attachments,
through: :template_schema_dynamic_document_versions, source: :document_attachment
scope :active, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) }
scope :pending, lambda {
@@ -110,13 +121,51 @@ class Submission < ApplicationRecord
end
def schema_documents
if template_id?
template_schema_documents
return documents_attachments unless template_id?
dynamic_count = template_schema&.count { |e| e['dynamic'] }.to_i
if variables_schema.blank?
if dynamic_count > 0
if dynamic_count == template_schema.size
template_schema_dynamic_document_attachments
else
template_schema_dynamic_and_static_document_attachments
end
else
template_schema_documents
end
elsif dynamic_count > 0 && dynamic_count != template_schema.size
template_schema_submission_dynamic_and_static_document_attachments
else
documents_attachments
end
end
def template_schema_submission_dynamic_and_static_document_attachments
@template_schema_submission_dynamic_and_static_document_attachments ||=
ActiveStorage::Attachment.where(
ActiveStorage::Attachment.arel_table[:id].in(
template_schema_static_documents.select(:id).arel.union(
:all,
documents_attachments.select(:id).arel
)
)
)
end
def template_schema_dynamic_and_static_document_attachments
@template_schema_dynamic_and_static_document_attachments ||=
ActiveStorage::Attachment.where(
ActiveStorage::Attachment.arel_table[:id].in(
template_schema_static_documents.select(:id).arel.union(
:all,
template_schema_dynamic_document_attachments.select(:id).arel
)
)
)
end
def fields_uuid_index
@fields_uuid_index ||= (template_fields || template.fields).index_by { |f| f['uuid'] }
end
+2 -1
View File
@@ -20,7 +20,7 @@
# index_submission_events_on_created_at (created_at)
# index_submission_events_on_submission_id (submission_id)
# index_submission_events_on_submitter_id (submitter_id)
# index_submissions_events_on_sms_event_types (account_id,created_at) WHERE ((event_type)::text = ANY ((ARRAY['send_sms'::character varying, 'send_2fa_sms'::character varying])::text[]))
# index_submissions_events_on_sms_event_types (account_id,created_at) WHERE ((event_type)::text = ANY (ARRAY[('send_sms'::character varying)::text, ('send_2fa_sms'::character varying)::text]))
#
# Foreign Keys
#
@@ -64,6 +64,7 @@ class SubmissionEvent < ApplicationRecord
invite_party: 'invite_party',
complete_form: 'complete_form',
decline_form: 'decline_form',
delegate_form: 'delegate_form',
api_complete_form: 'api_complete_form'
}, scope: false
+1
View File
@@ -44,6 +44,7 @@ class Submitter < ApplicationRecord
belongs_to :account
has_one :template, through: :submission
has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy if SearchEntry.table_exists?
has_many :submitter_versions, dependent: :destroy
attribute :values, :string, default: -> { {} }
attribute :preferences, :string, default: -> { {} }
+27
View File
@@ -0,0 +1,27 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: submitter_versions
#
# id :bigint not null, primary key
# email :string
# name :string
# phone :string
# slug :string not null
# created_at :datetime not null
# updated_at :datetime not null
# submitter_id :bigint not null
#
# Indexes
#
# index_submitter_versions_on_slug (slug)
# index_submitter_versions_on_submitter_id (submitter_id)
#
# Foreign Keys
#
# fk_rails_... (submitter_id => submitters.id)
#
class SubmitterVersion < ApplicationRecord
belongs_to :submitter
end
+6
View File
@@ -70,6 +70,12 @@ class Template < ApplicationRecord
has_many :submissions, dependent: :destroy
has_many :template_sharings, dependent: :destroy
has_many :template_accesses, dependent: :destroy
has_many :dynamic_documents, dependent: :destroy
has_many :dynamic_document_versions, through: :dynamic_documents, source: :versions
has_many :schema_dynamic_documents, lambda { |e|
where(uuid: e.schema.select { |e| e['dynamic'] }.pluck('attachment_uuid'))
}, class_name: 'DynamicDocument', dependent: :destroy, inverse_of: :template
scope :active, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) }
+1
View File
@@ -62,6 +62,7 @@ class User < ApplicationRecord
belongs_to :account
has_one :access_token, dependent: :destroy
has_many :access_tokens, dependent: :destroy
has_many :mcp_tokens, dependent: :destroy
has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
has_many :template_folders, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
has_many :user_configs, dependent: :destroy
+1
View File
@@ -34,6 +34,7 @@ class WebhookUrl < ApplicationRecord
submission.archived
template.created
template.updated
template.archived
].freeze
belongs_to :account
+32 -3
View File
@@ -142,9 +142,38 @@
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
</span>
</div>
<submit-form data-on="change" class="flex">
<%= f.check_box :value, class: 'toggle', checked: account_config.value != false %>
</submit-form>
<% if !Docuseal.multitenant? || can?(:manage, :disable_decline) %>
<submit-form data-on="change" class="flex">
<%= f.check_box :value, class: 'toggle', checked: account_config.value != false %>
</submit-form>
<% else %>
<a href="<%= console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") %>" data-turbo="false" data-tip="<%= I18n.t('unlock_with_docuseal_pro') %>" data-on="change" class="flex tooltip">
<%= f.check_box :value, class: 'toggle pointer-events-none', checked: account_config.value != false, disabled: true %>
</a>
<% end %>
</div>
<% end %>
<% end %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ALLOW_TO_DELEGATE_KEY) %>
<% if can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
<%= f.hidden_field :key %>
<div class="flex items-center justify-between gap-4 py-2.5">
<div class="flex items-center space-x-1">
<span class="text-left"><%= t('allow_to_delegate_documents') %></span>
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('allow_recipients_to_delegate_documents') %>">
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
</span>
</div>
<% if !Docuseal.multitenant? || can?(:manage, :delegate_form) %>
<submit-form data-on="change" class="flex">
<%= f.check_box :value, class: 'toggle', checked: account_config.value == true %>
</submit-form>
<% else %>
<a href="<%= console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") %>" data-turbo="false" data-tip="<%= I18n.t('unlock_with_docuseal_pro') %>" class="flex tooltip">
<%= f.check_box :value, class: 'toggle pointer-events-none', checked: account_config.value == true, disabled: true %>
</a>
<% end %>
</div>
<% end %>
<% end %>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="<%= local_assigns[:class] %>"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h3" /><path d="M16 22l5 -5" /><path d="M21 21.5v-4.5h-4.5" /></svg>

After

Width:  |  Height:  |  Size: 416 B

+112
View File
@@ -0,0 +1,112 @@
<div class="flex-wrap space-y-4 md:flex md:flex-nowrap md:space-y-0 md:space-x-10">
<%= render 'shared/settings_nav' %>
<div class="md:flex-grow">
<div class="flex flex-col md:flex-row md:flex-wrap gap-2 md:justify-between md:items-end mb-4 min-h-12">
<h1 class="text-4xl font-bold">
<%= t('mcp_server') %>
</h1>
<div class="flex flex-col md:flex-row gap-y-2 gap-x-4 md:items-center">
<div class="tooltip">
<%= link_to new_settings_mcp_path, class: 'btn btn-primary btn-md gap-2 w-full md:w-fit', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('plus', class: 'w-6 h-6') %>
<span><%= t('new_token') %></span>
<% end %>
</div>
</div>
</div>
<% if @mcp_token.present? %>
<div class="space-y-4 mb-4">
<div class="card bg-base-200">
<div class="card-body p-6">
<label for="mcp_token" class="text-sm font-semibold">
<%= t('please_copy_the_token_below_now_as_it_wont_be_shown_again') %>:
</label>
<div class="flex w-full space-x-4">
<input id="mcp_token" type="text" value="<%= @mcp_token.token %>" class="input font-mono input-bordered w-full" autocomplete="off" readonly>
<%= render 'shared/clipboard_copy', icon: 'copy', text: @mcp_token.token, class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
</div>
</div>
</div>
<div class="space-y-4">
<p class="text-2xl font-bold">
<%= t('instructions') %>
</p>
<div class="card bg-base-200">
<div class="card-body p-6">
<p class="text-2xl font-semibold"><%= t('connect_to_docuseal_mcp') %></p>
<p class="text-lg"><%= t('add_the_following_to_your_mcp_client_configuration') %>:</p>
<div class="mockup-code overflow-hidden">
<% text = JSON.pretty_generate({ mcpServers: { docuseal: { type: 'http', url: "#{root_url(Docuseal.default_url_options)}mcp", headers: { Authorization: "Bearer #{@mcp_token.token}" } } } }).strip %>
<span class="top-0 right-0 absolute">
<%= render 'shared/clipboard_copy', icon: 'copy', text:, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
</span>
<pre class="before:!m-0 pl-4 pb-4"><code class="overflow-hidden w-full"><%== HighlightCode.call(text, 'JSON', theme: 'base16.dark') %></code></pre>
</div>
<p class="text-lg"><%= t('works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client') %></p>
</div>
</div>
</div>
</div>
<% end %>
<div class="overflow-x-auto">
<table class="table w-full table-lg rounded-b-none overflow-hidden">
<thead class="bg-base-200">
<tr class="text-neutral uppercase">
<th>
<%= t('name') %>
</th>
<th>
<%= t('token') %>
</th>
<th>
<%= t('created_at') %>
</th>
<th class="text-right" width="1px">
</th>
</tr>
</thead>
<tbody>
<% @mcp_tokens.each do |mcp_token| %>
<tr scope="row">
<td>
<%= mcp_token.name %>
</td>
<td>
<% if @mcp_token.present? && mcp_token.id == @mcp_token.id %>
<%= @mcp_token.token %>
<% else %>
<%= "#{mcp_token.token_prefix}#{'*' * 38}" %>
<% end %>
</td>
<td>
<%= l(mcp_token.created_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>
</td>
<td class="flex items-center space-x-2 justify-end">
<%= button_to settings_mcp_path(mcp_token), method: :delete, class: 'btn btn-outline btn-error btn-xs', title: t('remove'), data: { turbo_confirm: t('are_you_sure_') } do %>
<%= t('remove') %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ENABLE_MCP_KEY) %>
<% if can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
<%= f.hidden_field :key %>
<div class="flex items-center gap-4 py-2.5">
<div class="flex items-center space-x-1">
<span class="text-left"><%= t('enable_mcp_server') %></span>
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('all_existing_mcp_connections_will_be_stopped_immediately_when_this_setting_is_disabled') %>">
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
</span>
</div>
<submit-form data-on="change" class="flex">
<%= f.check_box :value, class: 'toggle', checked: account_config.value == true %>
</submit-form>
</div>
<% end %>
<% end %>
</div>
</div>
+13
View File
@@ -0,0 +1,13 @@
<%= render 'shared/turbo_modal', title: t('new_token') do %>
<%= form_for @mcp_token, url: settings_mcp_index_path, method: :post, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<div class="space-y-4">
<div class="w-full">
<%= f.label :name, t('name'), class: 'label' %>
<%= f.text_field :name, required: true, class: 'base-input w-full', dir: 'auto' %>
</div>
<div class="form-control pt-2">
<%= f.button button_title, class: 'base-button' %>
</div>
</div>
<% end %>
<% end %>
@@ -1,4 +1,4 @@
<% account_config = AccountConfig.where(account: current_account, key: AccountConfig::FORM_WITH_CONFETTI_KEY).first_or_initialize(value: true) %>
<% account_config = AccountConfig.where(account: current_account, key: AccountConfig::FORM_WITH_CONFETTI_KEY).first_or_initialize(value: false) %>
<% if can?(:manage, account_config) %>
<div class="px-1 mt-2">
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
@@ -8,7 +8,7 @@
<%= t('show_confetti_on_successful_completion') %>
</span>
<submit-form data-on="change" class="flex">
<%= f.check_box :value, { class: 'toggle', checked: account_config.value != false }, '1', '0' %>
<%= f.check_box :value, { class: 'toggle', checked: account_config.value == true }, '1', '0' %>
</submit-form>
</div>
<% end %>
+1 -1
View File
@@ -1,4 +1,4 @@
<svg class="<%= local_assigns[:class] %>" height="<%= local_assigns.fetch(:height, '37') %>" width="<%= local_assigns.fetch(:width, '37') %>" style="color: #e0753f" viewBox="0 0 180 180" xmlns="http://www.w3.org/2000/svg">
<svg class="<%= local_assigns[:class] %>" height="<%= local_assigns.fetch(:height, '37') %>" width="<%= local_assigns.fetch(:width, '37') %>" style="color: #e97a42" viewBox="0 0 180 180" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M 178.224 72.09 c -0.296 -1.463 -0.627 -2.919 -0.996 -4.364 -0.293 -1.151 -0.616 -2.293 -0.956 -3.433 -0.301 -1.008 -0.612 -2.014 -0.95 -3.012 -0.531 -1.578 -1.113 -3.142 -1.735 -4.694 -0.216 -0.54 -0.433 -1.082 -0.661 -1.618 -0.195 -0.462 -0.399 -0.917 -0.601 -1.375 -0.262 -0.591 -0.53 -1.177 -0.804 -1.762 -0.074 -0.159 -0.151 -0.315 -0.226 -0.474 -0.209 -0.441 -0.422 -0.881 -0.638 -1.318 -0.076 -0.154 -0.153 -0.306 -0.229 -0.459 -0.236 -0.471 -0.477 -0.939 -0.721 -1.406 -0.053 -0.101 -0.105 -0.201 -0.158 -0.302 -1.143 -2.16 -2.367 -4.269 -3.68 -6.322 -0.116 -0.181 -0.237 -0.359 -0.355 -0.539 -0.094 -0.144 -0.189 -0.288 -0.284 -0.432 -0.284 -0.431 -0.57 -0.861 -0.862 -1.287 -0.112 -0.164 -0.225 -0.326 -0.338 -0.489 -0.193 -0.279 -0.382 -0.56 -0.579 -0.836 -0.089 -0.125 -0.182 -0.249 -0.273 -0.374 -0.13 -0.182 -0.264 -0.362 -0.395 -0.542 -0.277 -0.38 -0.556 -0.76 -0.838 -1.135 -0.15 -0.199 -0.303 -0.395 -0.454 -0.593 -0.21 -0.274 -0.417 -0.552 -0.63 -0.823 -0.055 -0.069 -0.111 -0.136 -0.166 -0.205 -0.482 -0.61 -0.971 -1.216 -1.47 -1.814 -0.129 -0.155 -0.262 -0.306 -0.392 -0.461 -0.402 -0.476 -0.808 -0.95 -1.22 -1.417 -0.186 -0.212 -0.375 -0.422 -0.563 -0.631 -0.384 -0.428 -0.773 -0.854 -1.167 -1.276 -0.176 -0.189 -0.351 -0.379 -0.529 -0.567 -0.564 -0.595 -1.134 -1.186 -1.716 -1.768 -1.091 -1.091 -2.207 -2.15 -3.346 -3.178 -1.016 -0.919 -2.05 -1.815 -3.103 -2.684 -0.772 -0.636 -1.557 -1.255 -2.348 -1.864 -3.465 -2.67 -7.112 -5.075 -10.927 -7.209 -2.869 -1.604 -5.83 -3.06 -8.883 -4.351 -2.443 -1.033 -4.922 -1.948 -7.428 -2.756 -8.879 -2.863 -18.13 -4.318 -27.605 -4.318 -3.19 0 -6.354 0.169 -9.488 0.496 -4.036 0.421 -8.019 1.114 -11.94 2.073 -1.732 0.423 -3.452 0.892 -5.157 1.42 -2.856 0.883 -5.673 1.912 -8.447 3.085 -2.645 1.118 -5.222 2.357 -7.729 3.711 -2.574 1.39 -5.073 2.901 -7.494 4.533 -1.195 0.805 -2.37 1.64 -3.527 2.503 -1.156 0.864 -2.292 1.756 -3.408 2.676 -0.553 0.456 -1.1 0.919 -1.643 1.389 -1.649 1.427 -3.252 2.92 -4.806 4.473 -2.582 2.582 -4.991 5.299 -7.222 8.138 -0.892 1.135 -1.756 2.292 -2.59 3.467 -0.417 0.588 -0.827 1.18 -1.23 1.778 -0.403 0.597 -0.798 1.199 -1.186 1.806 -0.388 0.607 -0.769 1.218 -1.143 1.835 -2.241 3.697 -4.216 7.562 -5.916 11.582 -1.095 2.589 -2.059 5.217 -2.901 7.877 -0.153 0.482 -0.3 0.965 -0.444 1.449 -0.339 1.14 -0.663 2.282 -0.956 3.433 -0.369 1.446 -0.7 2.901 -0.996 4.364 -1.034 5.121 -1.618 10.343 -1.749 15.637 -0.018 0.757 -0.028 1.514 -0.028 2.274 0 1.123 0.02 2.244 0.062 3.361 0.285 7.82 1.568 15.475 3.825 22.879 0.044 0.147 0.088 0.295 0.133 0.441 0.877 2.823 1.894 5.608 3.054 8.35 0.85 2.009 1.769 3.98 2.755 5.912 0.539 1.057 1.105 2.099 1.685 3.132 4.013 7.142 8.98 13.698 14.846 19.564 7.713 7.713 16.611 13.878 26.477 18.352 0.705 0.32 1.415 0.632 2.131 0.935 2.081 0.88 4.185 1.679 6.313 2.396 9.217 3.106 18.85 4.677 28.719 4.677 8.031 0 15.902 -1.047 23.522 -3.107 0.633 -0.172 1.266 -0.35 1.895 -0.535 0.757 -0.222 1.509 -0.456 2.26 -0.698 0.717 -0.232 1.431 -0.474 2.145 -0.723 1.752 -0.616 3.49 -1.281 5.211 -2.009 0.755 -0.319 1.503 -0.651 2.247 -0.989 1.237 -0.563 2.459 -1.15 3.664 -1.766 0.644 -0.328 1.283 -0.665 1.917 -1.009 1.654 -0.896 3.274 -1.848 4.865 -2.844 5.736 -3.591 11.06 -7.827 15.912 -12.679 0.775 -0.775 1.534 -1.562 2.278 -2.36 5.204 -5.59 9.636 -11.754 13.246 -18.417 0.343 -0.634 0.68 -1.274 1.009 -1.917 0.482 -0.944 0.943 -1.9 1.392 -2.863 0.471 -1.007 0.928 -2.021 1.364 -3.049 1.22 -2.886 2.281 -5.82 3.187 -8.793 0.559 -1.833 1.056 -3.68 1.494 -5.542 0.108 -0.458 0.211 -0.916 0.312 -1.376 0.194 -0.883 0.373 -1.77 0.539 -2.659 1.02 -5.455 1.542 -11.02 1.542 -16.663 0 -6.074 -0.595 -12.058 -1.776 -17.911 z m -161.733 19.614 c -1.118 -56.662 44.604 -74.877 60.998 -67.647 2.187 0.965 4.732 2.431 7.042 2.96 5.295 1.213 13.432 -3.113 13.521 6.273 0.078 8.156 -3.389 13.108 -10.797 16.177 -7.539 3.124 -14.777 9.181 -19.95 15.493 -21.487 26.216 -31.231 68.556 -7.565 94.296 -13.679 -5.545 -42.418 -25.467 -43.248 -67.552 z m 91.109 72.619 c -0.053 0.008 -4.171 0.775 -4.171 0.775 0 0 -15.862 -22.957 -23.509 -21.719 11.291 16.04 12.649 22.625 12.649 22.625 -0.053 0.001 -0.107 0.001 -0.161 0.003 -51.831 2.131 -42.785 -64.026 -28.246 -86.502 -1.555 13.073 8.878 39.992 39.034 44.1 9.495 1.293 32.302 -3.275 41.015 -11.38 0.098 1.825 0.163 3.85 0.159 6.013 -0.046 23.538 -13.47 42.743 -36.77 46.085 z m 30.575 -15.708 c 9.647 -9.263 12.869 -27.779 9.103 -44.137 -4.608 -20.011 -28.861 -32.383 -40.744 -35.564 5.766 -8.089 27.908 -14.274 39.567 5.363 -5.172 -10.519 -13.556 -23.023 -1.732 -33.128 12.411 13.329 19.411 29.94 20.161 48.7 0.75 18.753 -6.64 41.768 -26.355 58.765 z" />
<circle fill="currentColor" cx="71.927" cy="32.004" r="2.829" />
</svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

+7 -2
View File
@@ -13,12 +13,12 @@
<%= link_to t('account'), settings_account_path, class: 'text-base hover:bg-base-300' %>
</li>
<% unless Docuseal.multitenant? %>
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::EMAIL_SMTP_KEY, account: current_account)) && ENV['SMTP_ADDRESS'].blank? && true_user == current_user %>
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::EMAIL_SMTP_KEY, account: current_account)) && ENV['SMTP_ADDRESS'].blank? && true_user == current_user %>
<li>
<%= link_to t('email'), settings_email_index_path, class: 'text-base hover:bg-base-300' %>
</li>
<% end %>
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::FILES_STORAGE_KEY, account: current_account)) && true_user == current_user %>
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::FILES_STORAGE_KEY, account: current_account)) && true_user == current_user && ENV['S3_ATTACHMENTS_BUCKET'].blank? && ENV['GCS_BUCKET'].blank? && ENV['AZURE_CONTAINER'].blank? %>
<li>
<%= link_to t('storage'), settings_storage_index_path, class: 'text-base hover:bg-base-300' %>
</li>
@@ -90,6 +90,11 @@
<%= link_to 'SSO', settings_sso_index_path, class: 'text-base hover:bg-base-300' %>
</li>
<% end %>
<% if !Docuseal.multitenant? && can?(:read, McpToken) && can?(:manage, :mcp) %>
<li>
<%= link_to 'MCP', settings_mcp_index_path, class: 'text-base hover:bg-base-300' %>
</li>
<% end %>
<%= render 'shared/settings_nav_extra2' %>
<% if (can?(:manage, EncryptedConfig) && current_user == true_user) || (current_user != true_user && current_account.testing?) %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :get, html: { class: 'w-full' } do |f| %>
+7 -1
View File
@@ -20,10 +20,13 @@
<% end %>
</p>
</li>
<% submitter_versions_index = @submission.submitters.preload(:submitter_versions).each_with_object({}) { |s, h| h[s.id] = s.submitter_versions.to_a.sort_by(&:created_at) } %>
<% @submission.submission_events.order(:event_timestamp).each do |event| %>
<% submitter = @submission.submitters.find { |e| e.id == event.submitter_id } %>
<% bg_class = event_colors[submitters_uuids.index(submitter.uuid) % event_colors.length] %>
<% submitter_name = event.event_type.include?('sms') || event.event_type.include?('phone') ? (event.data['phone'] || submitter.phone) : (submitter.name || submitter.email || submitter.phone) %>
<% versions = submitter_versions_index[submitter.id] || [] %>
<% active_version = versions.find { |v| v.created_at > event.event_timestamp } %>
<% submitter_name = event.event_type.include?('sms') || event.event_type.include?('phone') ? (event.data['phone'] || active_version&.phone || submitter.phone) : (active_version&.name || active_version&.email || active_version&.phone || submitter.name || submitter.email || submitter.phone) %>
<li class="ml-7">
<span class="absolute flex items-center justify-center w-7 h-7 rounded-full -start-3.5 ring-8 ring-base-100 text-base-content <%= bg_class %>">
<%= svg_icon(SubmissionEventsController::SUBMISSION_EVENT_ICONS.fetch(event.event_type, 'circle_dot'), class: 'w-4 h-4') %>
@@ -43,6 +46,9 @@
<%= t('submission_event_names.complete_verification_by_html', provider: event.data['method'], submitter_name:) %>
<% elsif event.event_type == 'invite_party' && (invited_submitter = @submission.submitters.find { |e| e.uuid == event.data['uuid'] }) && (name = @submission.template_submitters.find { |e| e['uuid'] == event.data['uuid'] }&.dig('name')) %>
<%= t('submission_event_names.invite_party_by_html', invited_submitter_name: [invited_submitter.name || invited_submitter.email || invited_submitter.phone, name].join(' '), submitter_name:) %>
<% elsif event.event_type == 'delegate_form' %>
<% delegate_from = event.data['old_email'].presence || (versions.reverse.find { |v| v.created_at <= event.event_timestamp }&.then { |v| v.name || v.phone }) %>
<%= t('submission_event_names.delegate_form_by_html', from: delegate_from, to: event.data['email']) %>
<% elsif event.event_type.include?('send_') %>
<%= t("submission_event_names.#{event.event_type}_to_html", submitter_name:) %>
<% elsif event.event_type.start_with?('bounce_') || event.event_type.start_with?('complaint_') %>
@@ -3,8 +3,8 @@
<% submitters = template.submitters.reject { |e| e['invite_by_uuid'].present? || e['optional_invite_by_uuid'].present? || e['invite_via_field_uuid'].present? } %>
<dynamic-list class="space-y-4">
<div class="space-y-4">
<div class="card card-compact bg-base-300/40" data-targets="dynamic-list.items">
<div class="card-body">
<div class="relative <%= 'p-4 rounded-xl bg-base-300/40' unless local_assigns[:variables_form] %>" data-targets="dynamic-list.items">
<div>
<div class="absolute right-4 top-5">
<a href="#" data-action="click:dynamic-list#removeItem" class="<%= submitters.size == 1 ? 'right-2 top-1' : '-top-3' %> relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white">
<%= svg_icon('trash', class: 'w-4 h-4') %>
@@ -86,13 +86,14 @@
</div>
</div>
</div>
<% if params[:selfsign].blank? && local_assigns[:prefillable_fields].blank? && local_assigns[:recipient_form_fields].blank? %>
<% if params[:selfsign].blank? && local_assigns[:prefillable_fields].blank? && local_assigns[:recipient_form_fields].blank? && local_assigns[:variables_form].blank? %>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span><%= t('add_new') %></span>
</a>
<% end %>
</dynamic-list>
<%= local_assigns[:variables_form] %>
<div>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
<%= render 'send_email', f:, template: %>
+26 -4
View File
@@ -1,6 +1,27 @@
<%= form_for '', url: template_submissions_path(template), html: { class: 'space-y-4', autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<% submitters = template.submitters.reject { |e| e['invite_by_uuid'].present? || e['optional_invite_by_uuid'].present? || e['invite_via_field_uuid'].present? } %>
<% if submitters.size == 1 %>
<% if submitters.size == 1 && local_assigns[:variables_form] %>
<% item = submitters.first %>
<div class="grid gap-1">
<submitter-item class="grid md:grid-cols-2 gap-1">
<div class="form-control">
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<submitters-autocomplete data-field="email">
<linked-input data-target-id="<%= "email_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<%= tag.input type: 'email', multiple: true, name: 'submission[1][submitters][][email]', autocomplete: 'off', class: 'base-input !h-10 w-full', placeholder: t('email'), required: true, value: item['email'].presence || (params[:selfsign] || item['is_requester'] ? current_user.email : ''), id: "email_#{item['uuid']}" %>
</linked-input>
</submitters-autocomplete>
</div>
<div class="form-control flex">
<submitters-autocomplete data-field="name">
<linked-input data-target-id="<%= "email_name_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="base-input !h-10 w-full" placeholder="<%= "#{t('name')} (#{t('optional')})" %>" value="<%= params[:selfsign] || item['is_requester'] ? current_user.full_name : '' %>" dir="auto" id="email_name_<%= item['uuid'] %>">
</linked-input>
</submitters-autocomplete>
</div>
</submitter-item>
</div>
<% elsif submitters.size == 1 %>
<submitter-item class="form-control">
<emails-textarea data-bulk-enabled="<%= Docuseal.demo? || !Docuseal.multitenant? || can?(:manage, :bulk_send) %>" data-limit="<%= Docuseal.multitenant? ? (can?(:manage, :bulk_send) ? 40 : 1) : nil %>">
<submitters-autocomplete data-field="email" class="block relative">
@@ -13,8 +34,8 @@
<% else %>
<dynamic-list class="space-y-4">
<div class="space-y-4">
<div class="card card-compact bg-base-300/40" data-targets="dynamic-list.items">
<div class="card-body">
<div class="relative <%= 'p-4 rounded-xl bg-base-300/40' unless local_assigns[:variables_form] %>" data-targets="dynamic-list.items">
<div>
<div class="absolute right-4 top-5">
<a href="#" data-action="click:dynamic-list#removeItem" class="-top-3 relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white">
<%= svg_icon('trash', class: 'w-4 h-4') %>
@@ -38,7 +59,7 @@
</div>
</div>
</div>
<% if params[:selfsign].blank? %>
<% if params[:selfsign].blank? && local_assigns[:variables_form].blank? %>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span><%= t('add_new') %></span>
@@ -46,6 +67,7 @@
<% end %>
</dynamic-list>
<% end %>
<%= local_assigns[:variables_form] %>
<div>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
<%= render 'send_email', f:, template: %>
+4 -3
View File
@@ -2,8 +2,8 @@
<% submitters = template.submitters.reject { |e| e['invite_by_uuid'].present? || e['optional_invite_by_uuid'].present? || e['invite_via_field_uuid'].present? } %>
<dynamic-list class="space-y-4">
<div class="space-y-4">
<div class="card card-compact bg-base-300/40" data-targets="dynamic-list.items">
<div class="card-body">
<div class="relative <%= 'p-4 rounded-xl bg-base-300/40' unless local_assigns[:variables_form] %>" data-targets="dynamic-list.items">
<div>
<div class="absolute right-4 top-5">
<a href="#" data-action="click:dynamic-list#removeItem" class="<%= submitters.size == 1 ? 'right-2 top-1' : '-top-3' %> relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white">
<%= svg_icon('trash', class: 'w-4 h-4') %>
@@ -49,13 +49,14 @@
</div>
</div>
</div>
<% if params[:selfsign].blank? %>
<% if params[:selfsign].blank? && local_assigns[:variables_form].blank? %>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span><%= t('add_new') %></span>
</a>
<% end %>
</dynamic-list>
<%= local_assigns[:variables_form] %>
<div>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
<%= render 'send_sms', f:, checked: true %>
@@ -1,4 +1,4 @@
<% if template.preferences['submitters_order'] == 'preserved' || template.submitters.any? { |s| s['order'] } %>
<% if template.preferences['submitters_order'] == 'preserved' || template.submitters.any? { |s| s['order'] } || (template.submitters.size > 1 && template.fields.any? { |f| f['type'] == 'verification' }) %>
<%= f.hidden_field :preserve_order, value: '1' %>
<% elsif template.submitters.size > 1 %>
<div class="form-control">
+24 -18
View File
@@ -4,35 +4,41 @@
<% default_tab = cookies.permanent[:add_recipients_tab].presence || 'email' %>
<% recipient_form_fields = Accounts.load_recipient_form_fields(current_account) if prefillable_fields.blank? %>
<% only_detailed = require_phone_2fa || require_email_2fa || prefillable_fields.present? || recipient_form_fields.present? %>
<% with_list = @template.variables_schema.blank? %>
<% variables_form = render 'variables_form', schema: @template.variables_schema if @template.variables_schema.present? && @template.variables_schema.any? { |_, v| !v['disabled'] } %>
<%= render 'shared/turbo_modal_large', title: params[:selfsign] ? t('add_recipients') : t('add_new_recipients') do %>
<% options = [only_detailed ? nil : [t('via_email'), 'email'], only_detailed ? nil : [t('via_phone'), 'phone'], [t('detailed'), 'detailed'], [t('upload_list'), 'list']].compact %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center px-2 mt-4 block">
<div class="flex justify-center">
<% options.each_with_index do |(label, value), index| %>
<%= content_tag(value == 'list' ? 'span' : 'toggle-cookies', data: { value:, key: 'add_recipients_tab' }) do %>
<%= radio_button_tag 'option', value, value == (only_detailed ? 'detailed' : default_tab), class: 'peer hidden', data: { action: 'change:toggle-visible#trigger' } %>
<label for="option_<%= value %>" class="block bg-base-200 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 peer-checked:bg-base-300 <%= 'hidden sm:inline-block' if value == 'list' %> <%= 'rounded-l-3xl' if index.zero? %> <%= 'rounded-r-3xl sm:rounded-r-none' if value == 'detailed' %> <%= 'rounded-r-3xl' if index == options.size - 1 %>">
<%= label %>
</label>
<% options = [only_detailed ? nil : [t('via_email'), 'email'], only_detailed ? nil : [t('via_phone'), 'phone'], [t('detailed'), 'detailed'], with_list ? [t('upload_list'), 'list'] : nil].compact %>
<% if options.size > 1 %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center px-2 mt-4 block">
<div class="flex justify-center">
<% options.each_with_index do |(label, value), index| %>
<%= content_tag(value == 'list' ? 'span' : 'toggle-cookies', data: { value:, key: 'add_recipients_tab' }) do %>
<%= radio_button_tag 'option', value, value == (only_detailed ? 'detailed' : default_tab), class: 'peer hidden', data: { action: 'change:toggle-visible#trigger' } %>
<label for="option_<%= value %>" class="block bg-base-200 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 peer-checked:bg-base-300 <%= 'hidden sm:inline-block' if value == 'list' %> <%= 'rounded-l-3xl' if index.zero? %> <%= 'rounded-r-3xl sm:rounded-r-none' if value == 'detailed' && index != options.size - 1 %> <%= 'rounded-r-3xl' if index == options.size - 1 %>">
<%= label %>
</label>
<% end %>
<% end %>
<% end %>
</div>
</toggle-visible>
</div>
</toggle-visible>
<% end %>
<div class="px-5 mb-5 mt-4">
<% unless only_detailed %>
<div id="email" class="<%= 'hidden' if default_tab != 'email' %>">
<%= render 'email_form', template: @template %>
<%= render 'email_form', template: @template, variables_form: %>
</div>
<div id="phone" class="<%= 'hidden' if default_tab != 'phone' %>">
<%= render 'phone_form', template: @template %>
<%= render 'phone_form', template: @template, variables_form: %>
</div>
<% end %>
<div id="detailed" class="<%= 'hidden' if !only_detailed && default_tab != 'detailed' %>">
<%= render 'detailed_form', template: @template, require_phone_2fa:, require_email_2fa:, prefillable_fields:, recipient_form_fields: %>
</div>
<div id="list" class="hidden">
<%= render 'list_form', template: @template %>
<%= render 'detailed_form', template: @template, require_phone_2fa:, require_email_2fa:, prefillable_fields:, recipient_form_fields:, variables_form: %>
</div>
<% if with_list %>
<div id="list" class="hidden">
<%= render 'list_form', template: @template %>
</div>
<% end %>
<%= render 'submissions/error' %>
</div>
<%= content_for(:modal_extra) %>
+1 -1
View File
@@ -104,7 +104,7 @@
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.first&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_uuid, "#{index}.jpg")) %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_key, "#{index}.jpg")) %>
<page-container id="<%= "page-#{document.uuid}-#{index}" %>" class="block before:border before:absolute before:top-0 before:bottom-0 before:left-0 before:right-0 before:rounded relative mb-4" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>">
<img loading="lazy" src="<%= page.url %>" width="<%= width %>" class="rounded" height="<%= height %>">
<div class="top-0 bottom-0 left-0 right-0 absolute">
@@ -0,0 +1,9 @@
<%= form_for '', url: submit_form_delegate_index_path(submitter.slug), method: :post do |f| %>
<div class="form-control mt-2">
<%= f.label :email, t(:enter_the_email_address_of_the_person_you_want_to_delegate_to), class: 'label' %>
<%= f.email_field :email, required: true, class: 'base-input w-full', dir: 'auto', placeholder: t('email') %>
</div>
<toggle-submit dir="auto" class="form-control mt-4">
<%= f.button button_title(title: t(:delegate)), class: 'base-button' %>
</toggle-submit>
<% end %>
+21
View File
@@ -0,0 +1,21 @@
<div class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto">
<div class="space-y-6">
<div class="flex items-center justify-center">
<%= render 'start_form/banner' %>
</div>
<div class="flex items-center bg-base-200 rounded-xl p-4 mb-4">
<div class="flex items-center">
<div class="mr-3">
<%= svg_icon('user_share', class: 'w-10 h-10') %>
</div>
<div dir="auto">
<p class="text-lg font-bold mb-1"><%= @submitter.submission.name || @submitter.submission.template.name %></p>
<p class="text-sm"><%= t('document_has_been_delegated_on_html', time: l(@submitter.submission_events.order(event_timestamp: :desc).find_by!(event_type: :delegate_form).event_timestamp, format: :long)) %></p>
</div>
</div>
</div>
</div>
</div>
</div>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>
+76 -27
View File
@@ -9,6 +9,7 @@
<% schema = Submissions.filtered_conditions_schema(@submitter.submission, values:, include_submitter_uuid: @submitter.uuid) %>
<% font_scale = 1000.0 / PdfUtils::US_LETTER_W %>
<% decline_modal_checkbox_uuid = nil %>
<% delegate_modal_checkbox_uuid = nil %>
<div style="max-height: -webkit-fill-available;">
<div id="scrollbox">
<div class="mx-auto block pb-72" style="max-width: 1000px">
@@ -20,42 +21,85 @@
<%= @submitter.submission.name || @submitter.submission.template.name %>
</div>
<div class="flex items-center space-x-2" style="margin-left: 20px; flex-shrink: 0">
<% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid = SecureRandom.uuid %>" class="btn btn-sm !px-5"><%= t(:decline) %></label>
<% end %>
<% if @form_configs[:with_partial_download] %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm !px-4">
<span class="flex items-center justify-center" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6 inline md:hidden') %>
<span class="hidden md:inline"><%= t('download') %></span>
</span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
<span class="hidden md:inline"><%= t('downloading') %></span>
</span>
</download-button>
<% if @form_configs[:with_delegate] %>
<label id="delegate_button" for="<%= delegate_modal_checkbox_uuid = SecureRandom.uuid %>" class="btn btn-sm !px-5"><%= t(:delegate) %></label>
<% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid = SecureRandom.uuid %>" class="btn btn-sm px-2" title="<%= t(:decline) %>">
<%= svg_icon('x', class: 'w-5 h-5') %>
</label>
<% end %>
<% if @form_configs[:with_partial_download] %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2" title="<%= t('download') %>">
<span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %>
</span>
<span class="hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
</span>
</download-button>
<% end %>
<% else %>
<% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid = SecureRandom.uuid %>" class="btn btn-sm !px-5"><%= t(:decline) %></label>
<% end %>
<% if @form_configs[:with_partial_download] %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm !px-4">
<span class="flex items-center justify-center" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6 inline md:hidden') %>
<span class="hidden md:inline"><%= t('download') %></span>
</span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
<span class="hidden md:inline"><%= t('downloading') %></span>
</span>
</download-button>
<% end %>
<% end %>
</div>
</div>
<scroll-buttons class="fixed right-5 top-2 hidden md:flex gap-1 z-50 ease-in-out opacity-0 -translate-y-10">
<% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid %>" class="btn btn-sm px-0">
<% if @form_configs[:with_delegate] %>
<label id="delegate_button" for="<%= delegate_modal_checkbox_uuid %>" class="btn btn-sm px-0">
<span class="min-[1366px]:inline hidden px-3">
<%= t(:decline) %>
<%= t(:delegate) %>
</span>
<span class="inline min-[1366px]:hidden px-2">
<%= svg_icon('x', class: 'w-5 h-5') %>
<%= svg_icon('user_share', class: 'w-5 h-5') %>
</span>
</label>
<% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid %>" class="btn btn-sm px-2">
<%= svg_icon('x', class: 'w-5 h-5') %>
</label>
<% end %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2">
<span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %>
</span>
<span class="hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
</span>
</download-button>
<% else %>
<% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid %>" class="btn btn-sm px-0">
<span class="min-[1366px]:inline hidden px-3">
<%= t(:decline) %>
</span>
<span class="inline min-[1366px]:hidden px-2">
<%= svg_icon('x', class: 'w-5 h-5') %>
</span>
</label>
<% end %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2">
<span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %>
</span>
<span class="hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
</span>
</download-button>
<% end %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2">
<span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %>
</span>
<span class="hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
</span>
</download-button>
</scroll-buttons>
<% end %>
<% schema.each do |item| %>
@@ -65,7 +109,7 @@
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.last&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_uuid, "#{index}.jpg")) %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_key, "#{index}.jpg")) %>
<page-container class="block relative my-4 shadow-md" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>">
<img loading="lazy" src="<%= page.url %>" width="<%= width %>" height="<%= height %>">
<div id="page-<%= [document.uuid, index].join('-') %>" class="top-0 bottom-0 left-0 right-0 absolute">
@@ -110,4 +154,9 @@
<%= render 'submit_form/decline_form', submitter: @submitter %>
<% end %>
<% end %>
<% if @form_configs[:with_delegate] %>
<%= render 'shared/html_modal', title: t(:delegate), uuid: delegate_modal_checkbox_uuid do %>
<%= render 'submit_form/delegate_form', submitter: @submitter %>
<% end %>
<% end %>
<%= render 'scripts/autosize_field' %>
+1 -1
View File
@@ -30,7 +30,7 @@
</div>
</span>
</div>
<input id="file_dropzone_input" name="files[]" class="hidden" data-action="change:file-dropzone#onSelectFiles" data-target="file-dropzone.input" type="file" accept="image/*, application/pdf, application/zip<%= ", #{Templates::CreateAttachments::DOCUMENT_EXTENSIONS.join(', ')}" if Docuseal.advanced_formats? %>" multiple>
<input id="file_dropzone_input" name="files[]" class="hidden" data-action="change:file-dropzone#onSelectFiles" data-target="file-dropzone.input" type="file" accept="image/*, application/pdf, application/zip, application/json<%= ", #{Templates::CreateAttachments::DOCUMENT_EXTENSIONS.join(', ')}" if Docuseal.advanced_formats? %>" multiple>
</div>
</label>
</file-dropzone>
+2 -2
View File
@@ -49,9 +49,9 @@
<% end %>
</div>
<% end %>
<%= link_to template_share_link_path(template), class: 'absolute md:relative bottom-0 right-0 btn btn-xs md:btn-sm whitespace-nowrap btn-neutral text-white mt-1 px-2', data: { turbo_frame: :modal } do %>
<%= link_to template_share_link_path(template), class: "absolute md:relative bottom-0 right-0 btn btn-xs md:btn-sm whitespace-nowrap btn-neutral text-white mt-1 px-2", data: { turbo_frame: :modal } do %>
<span class="flex items-center justify-center space-x-2">
<%= svg_icon('link', class: 'w-4 h-4 md:w-6 md:h-6 text-white') %>
<%= svg_icon('link', class: 'w-4 h-4 md:w-6 md:h-6') %>
<span><%= t('link') %></span>
</span>
<% end %>
+1 -1
View File
@@ -16,7 +16,7 @@
</label>
<input type="hidden" name="form_id" value="<%= form_id %>">
<submit-form data-on="change" data-disable="true">
<input id="upload_template" name="files[]" class="hidden" type="file" accept="image/*, application/pdf, application/zip<%= ", #{Templates::CreateAttachments::DOCUMENT_EXTENSIONS.join(', ')}" if Docuseal.advanced_formats? %>" multiple>
<input id="upload_template" name="files[]" class="hidden" type="file" accept="image/*, application/pdf, application/zip, application/json<%= ", #{Templates::CreateAttachments::DOCUMENT_EXTENSIONS.join(', ')}" if Docuseal.advanced_formats? %>" multiple>
</submit-form>
<input hidden name="folder_name" value="<%= local_assigns[:folder_name] %>">
<% end %>
+1 -1
View File
@@ -94,7 +94,7 @@
<span class="mr-1"><%= t('send_to_recipients') %></span>
<% end %>
<% end %>
<% if Templates.filter_undefined_submitters(@template.submitters).size == 1 %>
<% if Templates.filter_undefined_submitters(@template.submitters).size == 1 && @template.variables_schema.blank? %>
<%= button_to start_form_path(@template.slug), params: { selfsign: true }, method: :put, class: 'white-button w-full', form: { style: 'display: inline', target: '_blank', data: { turbo: false } } do %>
<%= svg_icon('writing', class: 'w-6 h-6') %>
<span class="mr-1"><%= t('sign_it_yourself') %></span>
+8 -2
View File
@@ -2,6 +2,12 @@
<% enough_defined_submitters = Templates.filter_undefined_submitters(@template.submitters).size < 2 %>
<%= render 'shared/turbo_modal_large', title: t('share_link') do %>
<div class="mt-2 mb-4 px-5">
<% if @template.variables_schema.present? %>
<div class="alert items-start bg-base-100 border-base-300 mt-4">
<%= svg_icon('info_circle', class: 'stroke-current shrink-0 h-6 w-6') %>
<div><%= t('dynamic_template_with_variables_cannot_be_used_via_shared_link') %></div>
</div>
<% end %>
<%= form_for @template, url: template_share_link_path(@template), method: :post, html: { id: 'shared_link_form', autocomplete: 'off', class: 'mt-3' }, data: { close_on_submit: false } do |f| %>
<% if @template.preferences&.dig('require_email_2fa') || @template.preferences&.dig('require_phone_2fa') %>
<label for="template_shared_link" class="tooltip tooltip-bottom flex items-center my-4 justify-between gap-1 alert bg-base-100 border-base-300" data-tip="<%= t(:templates_that_require_email_or_phone_2fa_cannot_be_used_via_a_shared_link) %>">
@@ -12,7 +18,7 @@
<label for="template_shared_link" class="flex items-center my-4 justify-between gap-1 alert bg-base-100 border-base-300">
<span><%= t('enable_shared_link') %></span>
<submit-form data-on="change" class="flex">
<%= f.check_box :shared_link, { disabled: !can?(:update, @template), class: 'toggle' }, 'true', 'false' %>
<%= f.check_box :shared_link, { disabled: !can?(:update, @template) || @template.variables_schema.present?, class: 'toggle' }, 'true', 'false' %>
</submit-form>
</label>
<% end %>
@@ -23,7 +29,7 @@
</check-on-click>
</div>
<% end %>
<% if multiple_submitters && !enough_defined_submitters %>
<% if multiple_submitters && !enough_defined_submitters && @template.variables_schema.blank? %>
<div class="alert items-start bg-base-100 border-base-300 mt-4">
<%= svg_icon('info_circle', class: 'stroke-current shrink-0 h-6 w-6 mt-1') %>
<div><%= t('this_template_has_multiple_parties_which_prevents_the_use_of_a_sharing_link') %></div>
+3 -3
View File
@@ -85,9 +85,9 @@
<div class="mt-6">
<h2 id="log" class="text-3xl font-bold"><%= t('events_log') %></h2>
<div class="tabs border-b mt-4">
<%= link_to t('all'), url_for(params: request.query_parameters.except('status')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status].blank? ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
<%= link_to t('succeeded'), url_for(params: request.query_parameters.merge('status' => 'success')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'success' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
<%= link_to t('failed'), url_for(params: request.query_parameters.merge('status' => 'error')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'error' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
<%= link_to t('all'), url_for(params: request.query_parameters.except('status', 'page')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status].blank? ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
<%= link_to t('succeeded'), url_for(params: request.query_parameters.except('page').merge('status' => 'success')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'success' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
<%= link_to t('failed'), url_for(params: request.query_parameters.except('page').merge('status' => 'error')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'error' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
</div>
<% if @webhook_events.present? %>
<div class="divide-y divide-base-300 rounded-lg">
+1 -1
View File
@@ -17,7 +17,7 @@
"note": "Safe SQL"
},
{
"fingerprint": "5f52190d03ee922bba9792012d8fcbeb7d4736006bb899b3be9cc10d679e0af1",
"fingerprint": "f3a20210cde7b9cb5944d53505fe80fea502308416143f4da9ec2422f6b7035c",
"note": "Safe Param"
},
{
+6 -2
View File
@@ -87,7 +87,11 @@ Rails.application.configure do
password: ENV.fetch('SMTP_PASSWORD', nil),
openssl_verify_mode: ENV['SMTP_SSL_VERIFY'] == 'false' ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER,
authentication: ENV.fetch('SMTP_PASSWORD', nil).present? ? ENV.fetch('SMTP_AUTHENTICATION', 'plain') : nil,
enable_starttls: ENV['SMTP_ENABLE_STARTTLS'] != 'false'
enable_starttls: ENV['SMTP_ENABLE_STARTTLS'] != 'false',
ssl: ENV['SMTP_ENABLE_SSL'] == 'true',
tls: ENV['SMTP_ENABLE_TLS'] == 'true',
open_timeout: ENV.fetch('SMTP_OPEN_TIMEOUT', '15').to_i,
read_timeout: ENV.fetch('SMTP_READ_TIMEOUT', '25').to_i
}.compact
end
@@ -150,7 +154,7 @@ Rails.application.configure do
template_id: params[:template_id],
submission_id: params[:submission_id],
submitter_id: params[:submitter_id],
sig: (params[:signed_uuid] || params[:signed_id]).to_s.split('--').first,
sig: (params[:signed_key] || params[:signed_uuid] || params[:signed_id]).to_s.split('--').first,
slug: (params[:slug] ||
params[:submitter_slug] ||
params[:submission_slug] ||
+4
View File
@@ -8,6 +8,10 @@ ActiveSupport.on_load(:active_storage_attachment) do
def signed_uuid
@signed_uuid ||= ApplicationRecord.signed_id_verifier.generate(uuid, expires_in: 6.hours, purpose: :attachment)
end
def signed_key
@signed_key ||= ApplicationRecord.signed_id_verifier.generate([id, uuid], expires_in: 6.hours, purpose: :attachment)
end
end
# rubocop:disable Metrics/BlockLength
+11
View File
@@ -0,0 +1,11 @@
# frozen_string_literal: true
module SilenceErrors
def log_error(request, wrapper)
return if wrapper.status_code == 404
super
end
end
ActionDispatch::DebugExceptions.prepend(SilenceErrors)
+252
View File
@@ -29,6 +29,7 @@ en: &en
pro: Pro
thanks: Thanks
private: Private
_variables: Variables
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Make all newly created templates private to their creator and admins by default.
create_templates_with_admin_access_by_default: Create templates with admin access by default
require_email_2fa: Require email 2FA
@@ -162,10 +163,13 @@ en: &en
download: Download
decline: Decline
declined: Declined
delegate: Delegate
enter_the_email_address_of_the_person_you_want_to_delegate_to: Enter the email address of the person you want to delegate to
decline_reason: Decline reason
provide_a_reason: Provide a reason
notify_the_sender_with_the_reason_you_declined: Notify the sender with the reason you declined
form_has_been_declined_on_html: 'Form has been declined on <span class="font-semibold">%{time}</span>'
document_has_been_delegated_on_html: 'Document has been delegated on <span class="font-semibold">%{time}</span>'
name_declined_by_submitter: '"%{name}" declined by %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" has been declined by %{submitter} with the following reason:'
completed_successfully: Completed Successfully
@@ -200,6 +204,7 @@ en: &en
allow_typed_text_signatures: Allow typed text signatures
allow_to_resubmit_completed_forms: Allow to resubmit completed forms
allow_to_decline_documents: Allow to decline documents
allow_to_delegate_documents: Allow to delegate documents
remember_and_pre_fill_signatures: Remember and pre-fill signatures
require_authentication_for_file_download_links: Require authentication for file download links
combine_completed_documents_and_audit_log: Combine completed documents and Audit Log
@@ -786,6 +791,7 @@ en: &en
share_link: Share link
enable_shared_link: Enable shared link
share_link_is_currently_disabled: Share link is currently disabled
dynamic_template_with_variables_cannot_be_used_via_shared_link: Dynamic template with variables can't be used via a shared link
select_data_residency: Select data residency
account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: '%{account_name} has invited you to fill and sign documents online effortlessly with a secure, fast, and user-friendly digital document signing solution.'
review_or_download_completed_documents_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: Review or download completed documents. Fill and sign documents online effortlessly with a secure, fast, and user-friendly digital document signing solution.
@@ -840,6 +846,7 @@ en: &en
allow_signers_to_create_signatures_by_typing_their_name_instead_of_drawing_or_uploading_one: Allow signers to create signatures by typing their name instead of drawing or uploading one.
allow_signers_to_resubmit_forms_after_completion_useful_when_corrections_or_multiple_submissions_are_needed: Allow signers to resubmit forms after completion, useful when corrections or multiple submissions are needed.
allow_recipients_to_decline_signing_a_document_the_decline_reason_notification_will_be_sent_to_the_signature_requester: Allow recipients to decline signing a document. The decline reason notification will be sent to the signature requester.
allow_recipients_to_delegate_documents: Allow recipients to delegate documents to a different person via email. This can be useful when the original recipient needs to pass the document to the authorized signer.
save_a_users_signature_and_automatically_pre_fill_it_in_future_signing_sessions: "Save a user's signature and automatically pre-fill it in future signing sessions."
make_document_download_links_expire_after_40_minutes_to_prevent_long_term_access_and_enhance_security: Make document download links expire after 40 minutes to prevent long-term access and enhance security.
require_authentication_with_user_login_or_api_key_to_access_the_document_download_links: Require authentication with user login or API key to access the document download links.
@@ -882,6 +889,13 @@ en: &en
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} email address is awaiting confirmation. Follow the link in the email to confirm."
please_confirm_your_email_address_using_the_link_below_: 'Please confirm your email address using the link below:'
confirm_email: Confirm email
please_verify_your_email_address_to_continue: Please verify your email address to continue.
verification_code_sent_click_link_or_enter_code: A verification code has been sent to your email. Click the email link or enter the one time code to confirm your email.
use_otp_code_to_verify_email_or_click_link_below_html: 'Use <b>%{code}</b> code to verify your email or click the link below:'
verify_your_email: Verify your email
your_email_has_been_confirmed: Your email has been confirmed.
invalid_or_expired_verification_code: Invalid or expired verification code.
didnt_receive_an_email: Did not receive an email?
unconfirmed: Unconfirmed
you_requested_to_reset_your_password_use_the_link_below_to_continue: You requested to reset your password. Use the link below to continue
if_you_didnt_request_this_you_can_ignore_this_email: "If you didn't request this, please ignore this email."
@@ -894,6 +908,21 @@ en: &en
redo: Redo
add_variable: Add variable
enter_a_url_or_variable_name: Enter a URL or variable name
new_token: New token
token: Token
mcp_server: MCP Server
instructions: Instructions
please_copy_the_token_below_now_as_it_wont_be_shown_again: Please copy the token below now, as it won't be shown again
mcp_token_has_been_created: MCP token has been created.
mcp_token_has_been_removed: MCP token has been removed.
enable_mcp_server: Enable MCP server
all_existing_mcp_connections_will_be_stopped_immediately_when_this_setting_is_disabled: All existing MCP connections will be stopped immediately when this setting is disabled.
connect_to_docuseal_mcp: Connect to DocuSeal MCP
add_the_following_to_your_mcp_client_configuration: Add the following to your MCP client configuration
works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Works with Claude Desktop, Cursor, Windsurf, VS Code, and any MCP-compatible client.
your_email_address_has_been_changed: Your email address has been changed
the_email_address_for_your_account_has_been_changed_to_new_email: The email address for your account has been changed to %{new_email}.
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: If you did not make this change, please contact us by replying to this email.
devise:
confirmations:
confirmed: Your email address has been successfully confirmed.
@@ -931,6 +960,8 @@ en: &en
complete_kba_by_html: '<b>KBA completed</b> by %{submitter_name}'
fail_kba_by_html: '<b>KBA failed</b> by %{submitter_name}'
api_complete_form_by_html: '<b>Submission completed via API</b> by %{submitter_name}'
decline_form_by_html: '<b>Form declined</b> by %{submitter_name}'
delegate_form_by_html: '<b>Delegated</b> from %{from} to %{to}'
import_list:
select_worksheet: Select Worksheet
open: Open
@@ -991,6 +1022,8 @@ en: &en
scopes:
write: Update your data
read: Read your data
mcp: Use MCP
claudeai: Use Claude AI
pagination:
submissions:
range_with_total: "%{from}-%{to} of %{count} submissions"
@@ -1049,6 +1082,7 @@ es: &es
stripe_account_has_been_connected: La cuenta de Stripe ha sido conectada.
re_connect_stripe: Volver a conectar Stripe
private: Privado
_variables: Variables
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Hacer que todas las plantillas recién creadas sean privadas para su creador y los administradores por defecto.
create_templates_with_admin_access_by_default: Crear plantillas con acceso de administrador por defecto
require_email_2fa: Requerir 2FA por correo electrónico
@@ -1166,11 +1200,14 @@ es: &es
downloading: Descargando
download: Descargar
decline: Rechazar
delegate: Delegar
enter_the_email_address_of_the_person_you_want_to_delegate_to: Ingrese la dirección de correo electrónico de la persona a quien desea delegar
declined: Rechazado
decline_reason: Motivo de rechazo
provide_a_reason: Proporcione un motivo
notify_the_sender_with_the_reason_you_declined: Notifique al remitente con el motivo por el que rechazó
form_has_been_declined_on_html: 'El formulario ha sido rechazado el <span class="font-semibold">%{time}</span>'
document_has_been_delegated_on_html: 'El documento ha sido delegado el <span class="font-semibold">%{time}</span>'
name_declined_by_submitter: '"%{name}" rechazado por %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" ha sido rechazado por %{submitter} con el siguiente motivo:'
completed_successfully: Completado exitosamente
@@ -1205,6 +1242,7 @@ es: &es
allow_typed_text_signatures: Permitir firmas de texto mecanografiadas
allow_to_resubmit_completed_forms: Permitir reenviar formularios completados
allow_to_decline_documents: Permitir rechazar documentos
allow_to_delegate_documents: Permitir delegar documentos
remember_and_pre_fill_signatures: Recordar y prellenar firmas
require_authentication_for_file_download_links: Requerir autenticación para enlaces de descarga de archivos
combine_completed_documents_and_audit_log: Combinar documentos completados y Registro de Auditoría
@@ -1788,6 +1826,7 @@ es: &es
share_link: Enlace para compartir
enable_shared_link: Habilitar enlace compartido
share_link_is_currently_disabled: El enlace compartido está deshabilitado actualmente
dynamic_template_with_variables_cannot_be_used_via_shared_link: La plantilla dinámica con variables no se puede usar mediante un enlace compartido
select_data_residency: Seleccionar ubicación de datos
account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: '%{account_name} te ha invitado a completar y firmar documentos en línea fácilmente con una solución de firma digital segura, rápida y fácil de usar.'
review_or_download_completed_documents_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: Revisa o descarga los documentos completados. Completa y firma documentos en línea fácilmente con una solución de firma digital segura, rápida y fácil de usar.
@@ -1842,6 +1881,7 @@ es: &es
allow_signers_to_create_signatures_by_typing_their_name_instead_of_drawing_or_uploading_one: Permitir que los firmantes creen firmas escribiendo su nombre en lugar de dibujar o subir una.
allow_signers_to_resubmit_forms_after_completion_useful_when_corrections_or_multiple_submissions_are_needed: Permitir que los firmantes vuelvan a enviar formularios después de completarlos, útil cuando se necesitan correcciones o múltiples envíos.
allow_recipients_to_decline_signing_a_document_the_decline_reason_notification_will_be_sent_to_the_signature_requester: Permitir que los destinatarios rechacen firmar un documento. La notificación del motivo del rechazo se enviará al solicitante de la firma.
allow_recipients_to_delegate_documents: Permitir que los destinatarios deleguen documentos a otra persona por correo electrónico. Esto puede ser útil cuando el destinatario original necesita pasar el documento al firmante autorizado.
save_a_users_signature_and_automatically_pre_fill_it_in_future_signing_sessions: Guardar la firma de un usuario y rellenarla automáticamente en futuras sesiones de firma.
make_document_download_links_expire_after_40_minutes_to_prevent_long_term_access_and_enhance_security: Hacer que los enlaces de descarga de documentos caduquen después de 40 minutos para evitar el acceso a largo plazo y mejorar la seguridad.
require_authentication_with_user_login_or_api_key_to_access_the_document_download_links: Requerir autenticación con inicio de sesión de usuario o clave API para acceder a los enlaces de descarga de documentos.
@@ -1884,6 +1924,13 @@ es: &es
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} está pendiente de confirmación. Sigue el enlace en el correo para confirmarla."
please_confirm_your_email_address_using_the_link_below_: 'Por favor, confirma tu dirección de correo electrónico utilizando el enlace a continuación:'
confirm_email: Confirmar correo
please_verify_your_email_address_to_continue: Por favor, verifica tu dirección de correo electrónico para continuar.
verification_code_sent_click_link_or_enter_code: Se ha enviado un código de verificación a tu correo. Puedes hacer clic en el enlace del correo o ingresar el código a continuación.
use_otp_code_to_verify_email_or_click_link_below_html: 'Usa el código <b>%{code}</b> para verificar tu correo electrónico o haz clic en el enlace a continuación:'
verify_your_email: Verificar tu correo electrónico
your_email_has_been_confirmed: Tu correo electrónico ha sido confirmado.
invalid_or_expired_verification_code: Código de verificación inválido o expirado.
didnt_receive_an_email: "¿No recibiste un correo electrónico?"
unconfirmed: No confirmado
you_requested_to_reset_your_password_use_the_link_below_to_continue: Solicitaste restablecer tu contraseña. Usa el enlace a continuación para continuar.
if_you_didnt_request_this_you_can_ignore_this_email: "Si no solicitaste esto, puedes ignorar este correo electrónico."
@@ -1896,6 +1943,21 @@ es: &es
redo: Rehacer
add_variable: Agregar variable
enter_a_url_or_variable_name: Ingrese una URL o nombre de variable
new_token: Nuevo token
token: Token
mcp_server: Servidor MCP
instructions: Instrucciones
please_copy_the_token_below_now_as_it_wont_be_shown_again: Copie el token a continuación ahora, ya que no se mostrará de nuevo
mcp_token_has_been_created: El token MCP ha sido creado.
mcp_token_has_been_removed: El token MCP ha sido eliminado.
enable_mcp_server: Habilitar servidor MCP
all_existing_mcp_connections_will_be_stopped_immediately_when_this_setting_is_disabled: Todas las conexiones MCP existentes se detendrán inmediatamente cuando se desactive esta configuración.
connect_to_docuseal_mcp: Conectar a DocuSeal MCP
add_the_following_to_your_mcp_client_configuration: Agregue lo siguiente a la configuración de su cliente MCP
works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Funciona con Claude Desktop, Cursor, Windsurf, VS Code y cualquier cliente compatible con MCP.
your_email_address_has_been_changed: Tu dirección de correo electrónico ha sido cambiada
the_email_address_for_your_account_has_been_changed_to_new_email: La dirección de correo electrónico de tu cuenta ha sido cambiada a %{new_email}.
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Si no realizaste este cambio, contáctanos respondiendo a este correo electrónico.
devise:
confirmations:
confirmed: Tu dirección de correo electrónico ha sido confirmada correctamente.
@@ -1928,6 +1990,8 @@ es: &es
invite_party_by_html: '<b>Invitado</b> %{invited_submitter_name} por %{submitter_name}'
complete_form_by_html: '<b>Envío completado</b> por %{submitter_name}'
api_complete_form_by_html: '<b>Envío completado vía API</b> por %{submitter_name}'
decline_form_by_html: '<b>Formulario rechazado</b> por %{submitter_name}'
delegate_form_by_html: '<b>Delegado</b> de %{from} a %{to}'
start_verification_by_html: '<b>Verificación de identidad iniciada</b> por %{submitter_name}'
complete_verification_by_html: '<b>Verificación de identidad completada</b> por %{submitter_name} con %{provider}'
start_kba_by_html: '<b>KBA iniciado</b> por %{submitter_name}'
@@ -1993,6 +2057,8 @@ es: &es
scopes:
write: Actualizar tus datos
read: Leer tus datos
mcp: Usar MCP
claudeai: Usar Claude AI
pagination:
submissions:
range_with_total: "%{from}-%{to} de %{count} envíos"
@@ -2051,6 +2117,7 @@ it: &it
stripe_account_has_been_connected: L'account Stripe è stato collegato.
re_connect_stripe: Ricollega Stripe
private: Privato
_variables: Variabili
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Rendere tutte le nuove template private per il creatore e gli amministratori per impostazione predefinita.
create_templates_with_admin_access_by_default: Crea modelli con accesso amministratore per impostazione predefinita
require_email_2fa: Richiedi 2FA email
@@ -2168,11 +2235,14 @@ it: &it
downloading: Scaricamento
download: Scarica
decline: Rifiuta
delegate: Delega
enter_the_email_address_of_the_person_you_want_to_delegate_to: Inserisci l'indirizzo email della persona a cui vuoi delegare
declined: Rifiutato
decline_reason: Motivo del rifiuto
provide_a_reason: Fornisci un motivo
notify_the_sender_with_the_reason_you_declined: Informare il mittente del motivo per cui hai rifiutato
form_has_been_declined_on_html: 'Il modulo è stato rifiutato il <span class="font-semibold">%{time}</span>'
document_has_been_delegated_on_html: 'Il documento è stato delegato il <span class="font-semibold">%{time}</span>'
name_declined_by_submitter: '"%{name}" rifiutato da %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" è stato rifiutato da %{submitter} per il seguente motivo:'
completed_successfully: Completato con successo
@@ -2207,6 +2277,7 @@ it: &it
allow_typed_text_signatures: Permettere firme con testo digitato
allow_to_resubmit_completed_forms: Permettere di reinviare i moduli completati
allow_to_decline_documents: Permettere di rifiutare i documenti
allow_to_delegate_documents: Permettere di delegare i documenti
remember_and_pre_fill_signatures: Ricordare e precompilare le firme
require_authentication_for_file_download_links: "Richiedere l'autenticazione per i link di download dei file"
combine_completed_documents_and_audit_log: Combinare i documenti completati e il Registro di Audit
@@ -2790,6 +2861,7 @@ it: &it
share_link: Link di condivisione
enable_shared_link: Abilita link condiviso
share_link_is_currently_disabled: Il link condiviso è attualmente disabilitato
dynamic_template_with_variables_cannot_be_used_via_shared_link: Il template dinamico con variabili non può essere utilizzato tramite link condiviso
select_data_residency: Seleziona la residenza dei dati
account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: '%{account_name} ti ha invitato a compilare e firmare documenti online con facilità utilizzando una soluzione di firma digitale sicura, veloce e facile da usare.'
review_or_download_completed_documents_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: Rivedi o scarica i documenti completati. Compila e firma documenti online facilmente con una soluzione di firma digitale sicura, veloce e facile da usare.
@@ -2845,6 +2917,7 @@ it: &it
allow_signers_to_create_signatures_by_typing_their_name_instead_of_drawing_or_uploading_one: Consentire ai firmatari di creare firme digitando il proprio nome invece di disegnarle o caricarle.
allow_signers_to_resubmit_forms_after_completion_useful_when_corrections_or_multiple_submissions_are_needed: Consentire ai firmatari di reinviare i moduli dopo il completamento, utile quando sono necessarie correzioni o più invii.
allow_recipients_to_decline_signing_a_document_the_decline_reason_notification_will_be_sent_to_the_signature_requester: Consentire ai destinatari di rifiutare di firmare un documento. La notifica del motivo del rifiuto verrà inviata al richiedente della firma.
allow_recipients_to_delegate_documents: Consentire ai destinatari di delegare i documenti a un'altra persona tramite e-mail. Ciò può essere utile quando il destinatario originale deve passare il documento al firmatario autorizzato.
save_a_users_signature_and_automatically_pre_fill_it_in_future_signing_sessions: Salvare la firma di un utente e compilarla automaticamente nelle future sessioni di firma.
make_document_download_links_expire_after_40_minutes_to_prevent_long_term_access_and_enhance_security: "Fare in modo che i link di download dei documenti scadano dopo 40 minuti per prevenire l'accesso a lungo termine e migliorare la sicurezza."
require_authentication_with_user_login_or_api_key_to_access_the_document_download_links: "Richiedere l'autenticazione con login utente o chiave API per accedere ai link di download dei documenti."
@@ -2887,6 +2960,13 @@ it: &it
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} è in attesa di conferma. Segui il link nell'email per confermare."
please_confirm_your_email_address_using_the_link_below_: 'Conferma il tuo indirizzo email utilizzando il link qui sotto:'
confirm_email: Conferma email
please_verify_your_email_address_to_continue: Verifica il tuo indirizzo email per continuare.
verification_code_sent_click_link_or_enter_code: "È stato inviato un codice di verifica alla tua email. Puoi cliccare il link nell'email o inserire il codice qui sotto."
use_otp_code_to_verify_email_or_click_link_below_html: 'Usa il codice <b>%{code}</b> per verificare la tua email o clicca il link qui sotto:'
verify_your_email: Verifica la tua email
your_email_has_been_confirmed: La tua email è stata confermata.
invalid_or_expired_verification_code: Codice di verifica non valido o scaduto.
didnt_receive_an_email: Non hai ricevuto un'email?
unconfirmed: Non confermato
you_requested_to_reset_your_password_use_the_link_below_to_continue: Hai richiesto di reimpostare la tua password. Usa il link qui sotto per continuare.
if_you_didnt_request_this_you_can_ignore_this_email: "Se non hai richiesto questo, puoi ignorare questa email."
@@ -2899,6 +2979,21 @@ it: &it
redo: Ripeti
add_variable: Aggiungi variabile
enter_a_url_or_variable_name: Inserisci un URL o nome variabile
new_token: Nuovo token
token: Token
mcp_server: Server MCP
instructions: Istruzioni
please_copy_the_token_below_now_as_it_wont_be_shown_again: Copia il token qui sotto ora, poiché non verrà mostrato di nuovo
mcp_token_has_been_created: Il token MCP è stato creato.
mcp_token_has_been_removed: Il token MCP è stato rimosso.
enable_mcp_server: Abilita server MCP
all_existing_mcp_connections_will_be_stopped_immediately_when_this_setting_is_disabled: Tutte le connessioni MCP esistenti verranno interrotte immediatamente quando questa impostazione viene disattivata.
connect_to_docuseal_mcp: Connetti a DocuSeal MCP
add_the_following_to_your_mcp_client_configuration: Aggiungi quanto segue alla configurazione del tuo client MCP
works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Funziona con Claude Desktop, Cursor, Windsurf, VS Code e qualsiasi client compatibile con MCP.
your_email_address_has_been_changed: Il tuo indirizzo email è stato modificato
the_email_address_for_your_account_has_been_changed_to_new_email: L'indirizzo email del tuo account è stato modificato in %{new_email}.
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Se non hai effettuato questa modifica, contattaci rispondendo a questa email.
devise:
confirmations:
confirmed: Il tuo indirizzo email è stato confermato con successo.
@@ -2931,6 +3026,8 @@ it: &it
invite_party_by_html: '<b>Invitato</b> %{invited_submitter_name} da %{submitter_name}'
complete_form_by_html: '<b>Invio completato</b> da %{submitter_name}'
api_complete_form_by_html: '<b>Invio completato tramite API</b> da %{submitter_name}'
decline_form_by_html: '<b>Modulo rifiutato</b> da %{submitter_name}'
delegate_form_by_html: '<b>Delegato</b> da %{from} a %{to}'
start_verification_by_html: "<b>Verifica dell'identità iniziata</b> da %{submitter_name}"
complete_verification_by_html: "<b>Verifica dell'identità completata</b> da %{submitter_name} con %{provider}"
start_kba_by_html: "<b>KBA avviata</b> da %{submitter_name}"
@@ -2996,6 +3093,8 @@ it: &it
scopes:
write: Aggiorna i tuoi dati
read: Leggi i tuoi dati
mcp: Usa MCP
claudeai: Usa Claude AI
pagination:
submissions:
range_with_total: "%{from}-%{to} di %{count} invii"
@@ -3054,6 +3153,7 @@ fr: &fr
stripe_account_has_been_connected: Le compte Stripe a été connecté.
re_connect_stripe: Reconnecter Stripe
private: Privé
_variables: Variables
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Rendre tous les nouveaux modèles privés pour leur créateur et les administrateurs par défaut.
create_templates_with_admin_access_by_default: Créer des modèles avec un accès administrateur par défaut
require_email_2fa: Exiger la 2FA par email
@@ -3171,11 +3271,14 @@ fr: &fr
downloading: Téléchargement
download: Télécharger
decline: Refuser
delegate: Déléguer
enter_the_email_address_of_the_person_you_want_to_delegate_to: Saisissez l'adresse e-mail de la personne à qui vous souhaitez déléguer
declined: Refusé
decline_reason: Motif du refus
provide_a_reason: Fournissez un motif
notify_the_sender_with_the_reason_you_declined: Notifier lexpéditeur avec le motif du refus
form_has_been_declined_on_html: Le formulaire a été refusé le <span class="font-semibold">%{time}</span>
document_has_been_delegated_on_html: Le document a été délégué le <span class="font-semibold">%{time}</span>
name_declined_by_submitter: '"%{name}" refusé par %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" a été refusé par %{submitter} pour le motif suivant :'
completed_successfully: Terminé avec succès
@@ -3210,6 +3313,7 @@ fr: &fr
allow_typed_text_signatures: Autoriser les signatures tapées
allow_to_resubmit_completed_forms: Autoriser la nouvelle soumission des formulaires complétés
allow_to_decline_documents: Autoriser le refus de documents
allow_to_delegate_documents: Autoriser la délégation de documents
remember_and_pre_fill_signatures: Mémoriser et pré-remplir les signatures
require_authentication_for_file_download_links: Exiger une authentification pour les liens de téléchargement
combine_completed_documents_and_audit_log: Combiner les documents complétés et le journal daudit
@@ -3790,6 +3894,7 @@ fr: &fr
share_link: Lien de partage
enable_shared_link: Activer le lien partagé
share_link_is_currently_disabled: Le lien de partage est actuellement désactivé
dynamic_template_with_variables_cannot_be_used_via_shared_link: Le modèle dynamique avec des variables ne peut pas être utilisé via un lien partagé
select_data_residency: Sélectionner la résidence des données
account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: "%{account_name} vous a invité à remplir et signer des documents en ligne facilement, grâce à une solution de signature numérique sécurisée, rapide et conviviale."
review_or_download_completed_documents_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: Consultez ou téléchargez les documents complétés. Remplissez et signez des documents en ligne facilement, grâce à une solution de signature numérique sécurisée, rapide et conviviale.
@@ -3843,6 +3948,7 @@ fr: &fr
allow_signers_to_create_signatures_by_typing_their_name_instead_of_drawing_or_uploading_one: Autoriser les signataires à créer des signatures en tapant leur nom au lieu de dessiner ou téléverser une signature.
allow_signers_to_resubmit_forms_after_completion_useful_when_corrections_or_multiple_submissions_are_needed: Autoriser les signataires à soumettre à nouveau les formulaires après finalisation, utile pour des corrections ou soumissions multiples.
allow_recipients_to_decline_signing_a_document_the_decline_reason_notification_will_be_sent_to_the_signature_requester: Autoriser les destinataires à refuser de signer un document. Le motif du refus sera notifié au demandeur de signature.
allow_recipients_to_delegate_documents: Autoriser les destinataires à déléguer des documents à une autre personne par e-mail. Cela peut être utile lorsque le destinataire d'origine doit transmettre le document au signataire autorisé.
save_a_users_signature_and_automatically_pre_fill_it_in_future_signing_sessions: Mémoriser la signature dun utilisateur et la pré‑remplir automatiquement lors de futures sessions de signature.
make_document_download_links_expire_after_40_minutes_to_prevent_long_term_access_and_enhance_security: Faire expirer les liens de téléchargement des documents après 40 minutes pour éviter un accès à long terme et renforcer la sécurité.
require_authentication_with_user_login_or_api_key_to_access_the_document_download_links: Exiger une authentification par connexion utilisateur ou clé API pour accéder aux liens de téléchargement des documents.
@@ -3886,6 +3992,13 @@ fr: &fr
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} est en attente de confirmation. Suivez le lien dans l'e-mail pour la confirmer."
please_confirm_your_email_address_using_the_link_below_: 'Veuillez confirmer votre adresse e-mail en utilisant le lien ci-dessous :'
confirm_email: "Confirmer l'e-mail"
please_verify_your_email_address_to_continue: "Veuillez vérifier votre adresse e-mail pour continuer."
verification_code_sent_click_link_or_enter_code: "Un code de vérification a été envoyé à votre adresse e-mail. Vous pouvez cliquer sur le lien dans l'e-mail ou saisir le code ci-dessous."
use_otp_code_to_verify_email_or_click_link_below_html: "Utilisez le code <b>%{code}</b> pour vérifier votre e-mail ou cliquez sur le lien ci-dessous :"
verify_your_email: "Vérifier votre e-mail"
your_email_has_been_confirmed: "Votre adresse e-mail a été confirmée."
invalid_or_expired_verification_code: "Code de vérification invalide ou expiré."
didnt_receive_an_email: "Vous n'avez pas reçu d'e-mail ?"
unconfirmed: Non confirmé
you_requested_to_reset_your_password_use_the_link_below_to_continue: Vous avez demandé à réinitialiser votre mot de passe. Utilisez le lien ci-dessous pour continuer.
if_you_didnt_request_this_you_can_ignore_this_email: "Si vous n'avez pas fait cette demande, veuillez ignorer cet e-mail."
@@ -3898,6 +4011,21 @@ fr: &fr
redo: Rétablir
add_variable: Ajouter une variable
enter_a_url_or_variable_name: Entrez une URL ou un nom de variable
new_token: Nouveau jeton
token: Jeton
mcp_server: Serveur MCP
instructions: Instructions
please_copy_the_token_below_now_as_it_wont_be_shown_again: Copiez le jeton ci-dessous maintenant, car il ne sera plus affiché
mcp_token_has_been_created: Le jeton MCP a été créé.
mcp_token_has_been_removed: Le jeton MCP a été supprimé.
enable_mcp_server: Activer le serveur MCP
all_existing_mcp_connections_will_be_stopped_immediately_when_this_setting_is_disabled: Toutes les connexions MCP existantes seront arrêtées immédiatement lorsque ce paramètre est désactivé.
connect_to_docuseal_mcp: Se connecter à DocuSeal MCP
add_the_following_to_your_mcp_client_configuration: Ajoutez ce qui suit à la configuration de votre client MCP
works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Fonctionne avec Claude Desktop, Cursor, Windsurf, VS Code et tout client compatible MCP.
your_email_address_has_been_changed: Votre adresse e-mail a été modifiée
the_email_address_for_your_account_has_been_changed_to_new_email: "L'adresse e-mail de votre compte a été modifiée en %{new_email}."
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Si vous n'avez pas effectué ce changement, veuillez nous contacter en répondant à cet e-mail.
devise:
confirmations:
confirmed: Votre adresse e-mail a été confirmée avec succès.
@@ -3932,6 +4060,8 @@ fr: &fr
start_verification_by_html: "<b>Vérification didentité démarrée</b> par %{submitter_name}"
complete_verification_by_html: "<b>Vérification didentité terminée</b> par %{submitter_name} avec %{provider}"
api_complete_form_by_html: "<b>Soumission terminée via API</b> par %{submitter_name}"
decline_form_by_html: "<b>Formulaire refusé</b> par %{submitter_name}"
delegate_form_by_html: "<b>Délégué</b> de %{from} à %{to}"
start_kba_by_html: "<b>KBA démarré</b> par %{submitter_name}"
complete_kba_by_html: "<b>KBA terminé</b> par %{submitter_name}"
fail_kba_by_html: "<b>KBA échoué</b> par %{submitter_name}"
@@ -3995,6 +4125,8 @@ fr: &fr
scopes:
write: Mettre à jour vos données
read: Lire vos données
mcp: Utiliser MCP
claudeai: Utiliser Claude AI
pagination:
submissions:
range_with_total: "%{from}-%{to} sur %{count} soumissions"
@@ -4053,6 +4185,7 @@ pt: &pt
stripe_account_has_been_connected: Conta Stripe foi conectada.
re_connect_stripe: Reconectar Stripe
private: Privado
_variables: Variáveis
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Tornar todos os modelos recém-criados privados para seu criador e administradores por padrão.
create_templates_with_admin_access_by_default: Criar modelos com acesso de administrador por padrão
require_email_2fa: Exigir 2FA por email
@@ -4170,11 +4303,14 @@ pt: &pt
downloading: Baixando
download: Baixar
decline: Recusar
delegate: Delegar
enter_the_email_address_of_the_person_you_want_to_delegate_to: Insira o endereço de e-mail da pessoa para quem deseja delegar
declined: Recusado
decline_reason: Motivo da recusa
provide_a_reason: Forneça um motivo
notify_the_sender_with_the_reason_you_declined: Notifique o remetente com o motivo da sua recusa
form_has_been_declined_on_html: "O formulário foi recusado em <span class='font-semibold'>%{time}</span>"
document_has_been_delegated_on_html: "O documento foi delegado em <span class='font-semibold'>%{time}</span>"
name_declined_by_submitter: '"%{name}" foi recusado por %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" foi recusado por %{submitter} com o seguinte motivo:'
completed_successfully: Concluído com sucesso
@@ -4209,6 +4345,7 @@ pt: &pt
allow_typed_text_signatures: Permitir assinaturas digitadas
allow_to_resubmit_completed_forms: Permitir reenviar formulários concluídos
allow_to_decline_documents: Permitir recusar documentos
allow_to_delegate_documents: Permitir delegar documentos
remember_and_pre_fill_signatures: Lembrar e preencher assinaturas automaticamente
require_authentication_for_file_download_links: Requerer autenticação para links de download de arquivos
combine_completed_documents_and_audit_log: Combinar documentos concluídos e log de auditoria
@@ -4792,6 +4929,7 @@ pt: &pt
share_link: Link de compartilhamento
enable_shared_link: Ativar link compartilhado
share_link_is_currently_disabled: O link compartilhado está desativado no momento
dynamic_template_with_variables_cannot_be_used_via_shared_link: O template dinâmico com variáveis não pode ser usado por meio de link compartilhado
select_data_residency: Selecionar local dos dados
account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: '%{account_name} convidou você para preencher e assinar documentos online com facilidade usando uma solução de assinatura digital segura, rápida e fácil de usar.'
review_or_download_completed_documents_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: Reveja ou baixe os documentos concluídos. Preencha e assine documentos online com facilidade usando uma solução de assinatura digital segura, rápida e fácil de usar.
@@ -4846,6 +4984,7 @@ pt: &pt
allow_signers_to_create_signatures_by_typing_their_name_instead_of_drawing_or_uploading_one: Permitir que os signatários criem assinaturas digitando seu nome em vez de desenhá-las ou carregá-las.
allow_signers_to_resubmit_forms_after_completion_useful_when_corrections_or_multiple_submissions_are_needed: Permitir que os signatários reenviem formulários após o término, útil quando são necessárias correções ou múltiplos envios.
allow_recipients_to_decline_signing_a_document_the_decline_reason_notification_will_be_sent_to_the_signature_requester: Permitir que os destinatários recusem assinar um documento. A notificação do motivo da recusa será enviada ao solicitante da assinatura.
allow_recipients_to_delegate_documents: Permitir que os destinatários deleguem documentos a outra pessoa por e-mail. Isso pode ser útil quando o destinatário original precisa passar o documento ao signatário autorizado.
save_a_users_signature_and_automatically_pre_fill_it_in_future_signing_sessions: Salvar a assinatura de um usuário e preenchê-la automaticamente em futuras sessões de assinatura.
make_document_download_links_expire_after_40_minutes_to_prevent_long_term_access_and_enhance_security: Fazer os links de download de documentos expirarem após 40 minutos para evitar o acesso prolongado e aumentar a segurança.
require_authentication_with_user_login_or_api_key_to_access_the_document_download_links: Exigir autenticação com login do usuário ou chave API para acessar os links de download de documentos.
@@ -4888,6 +5027,13 @@ pt: &pt
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} está aguardando confirmação. Siga o link enviado para esse endereço de e-mail para confirmar."
please_confirm_your_email_address_using_the_link_below_: 'Por favor, confirme seu endereço de e-mail usando o link abaixo:'
confirm_email: Confirmar e-mail
please_verify_your_email_address_to_continue: Verifique seu endereço de e-mail para continuar.
verification_code_sent_click_link_or_enter_code: Um código de verificação foi enviado para seu e-mail. Você pode clicar no link do e-mail ou inserir o código abaixo.
use_otp_code_to_verify_email_or_click_link_below_html: 'Use o código <b>%{code}</b> para verificar seu e-mail ou clique no link abaixo:'
verify_your_email: Verificar seu e-mail
your_email_has_been_confirmed: Seu e-mail foi confirmado.
invalid_or_expired_verification_code: Código de verificação inválido ou expirado.
didnt_receive_an_email: Não recebeu um e-mail?
unconfirmed: Não confirmado
you_requested_to_reset_your_password_use_the_link_below_to_continue: Você solicitou a redefinição da sua senha. Use o link abaixo para continuar.
if_you_didnt_request_this_you_can_ignore_this_email: "Se você não solicitou isso, pode ignorar este e-mail."
@@ -4900,6 +5046,21 @@ pt: &pt
redo: Refazer
add_variable: Adicionar variável
enter_a_url_or_variable_name: Digite uma URL ou nome de variável
new_token: Novo token
token: Token
mcp_server: Servidor MCP
instructions: Instruções
please_copy_the_token_below_now_as_it_wont_be_shown_again: Copie o token abaixo agora, pois ele não será exibido novamente
mcp_token_has_been_created: O token MCP foi criado.
mcp_token_has_been_removed: O token MCP foi removido.
enable_mcp_server: Ativar servidor MCP
all_existing_mcp_connections_will_be_stopped_immediately_when_this_setting_is_disabled: Todas as conexões MCP existentes serão interrompidas imediatamente quando esta configuração for desativada.
connect_to_docuseal_mcp: Conectar ao DocuSeal MCP
add_the_following_to_your_mcp_client_configuration: Adicione o seguinte à configuração do seu cliente MCP
works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Funciona com Claude Desktop, Cursor, Windsurf, VS Code e qualquer cliente compatível com MCP.
your_email_address_has_been_changed: Seu endereço de e-mail foi alterado
the_email_address_for_your_account_has_been_changed_to_new_email: O endereço de e-mail da sua conta foi alterado para %{new_email}.
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Se você não fez essa alteração, entre em contato conosco respondendo a este e-mail.
devise:
confirmations:
confirmed: Seu endereço de e-mail foi confirmado com sucesso.
@@ -4934,6 +5095,8 @@ pt: &pt
start_verification_by_html: '<b>Verificação de identidade iniciada</b> por %{submitter_name}'
complete_verification_by_html: '<b>Verificação de identidade concluída</b> por %{submitter_name} com %{provider}'
api_complete_form_by_html: '<b>Submissão concluída via API</b> por %{submitter_name}'
decline_form_by_html: '<b>Formulário recusado</b> por %{submitter_name}'
delegate_form_by_html: '<b>Delegado</b> de %{from} para %{to}'
start_kba_by_html: '<b>KBA iniciada</b> por %{submitter_name}'
complete_kba_by_html: '<b>KBA concluída</b> por %{submitter_name}'
fail_kba_by_html: '<b>KBA reprovada</b> por %{submitter_name}'
@@ -4997,6 +5160,8 @@ pt: &pt
scopes:
write: Atualizar seus dados
read: Ler seus dados
mcp: Usar MCP
claudeai: Usar Claude AI
pagination:
submissions:
range_with_total: "%{from}-%{to} de %{count} submissões"
@@ -5041,6 +5206,7 @@ de: &de
pro: Pro
thanks: Danke
private: Privat
_variables: Variablen
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Alle neu erstellten Vorlagen standardmäßig nur für ihren Ersteller und Administratoren sichtbar machen.
create_templates_with_admin_access_by_default: Vorlagen standardmäßig mit Administratorzugriff erstellen
require_email_2fa: E-Mail 2FA erforderlich
@@ -5172,11 +5338,14 @@ de: &de
downloading: Wird heruntergeladen
download: Download
decline: Ablehnen
delegate: Delegieren
enter_the_email_address_of_the_person_you_want_to_delegate_to: Geben Sie die E-Mail-Adresse der Person ein, an die Sie delegieren möchten
declined: Abgelehnt
decline_reason: Ablehnungsgrund
provide_a_reason: Geben Sie einen Grund an
notify_the_sender_with_the_reason_you_declined: Den Absender über den Grund Ihrer Ablehnung benachrichtigen
form_has_been_declined_on_html: "Das Formular wurde am <span class='font-semibold'>%{time}</span> abgelehnt"
document_has_been_delegated_on_html: "Das Dokument wurde am <span class='font-semibold'>%{time}</span> delegiert"
name_declined_by_submitter: '"%{name}" wurde von %{submitter} abgelehnt'
name_declined_by_submitter_with_the_following_reason: '"%{name}" wurde von %{submitter} mit folgendem Grund abgelehnt:'
completed_successfully: Erfolgreich abgeschlossen
@@ -5211,6 +5380,7 @@ de: &de
allow_typed_text_signatures: Getippte Unterschriften zulassen
allow_to_resubmit_completed_forms: Erneutes Einreichen abgeschlossener Formulare zulassen
allow_to_decline_documents: Ablehnen von Dokumenten erlauben
allow_to_delegate_documents: Delegieren von Dokumenten erlauben
remember_and_pre_fill_signatures: Signaturen merken und vorab ausfüllen
require_authentication_for_file_download_links: Authentifizierung für Dateidownload-Links erforderlich
combine_completed_documents_and_audit_log: Abgeschlossene Dokumente und Prüfprotokoll kombinieren
@@ -5794,6 +5964,7 @@ de: &de
share_link: Freigabelink
enable_shared_link: Freigabelink aktivieren
share_link_is_currently_disabled: Freigabelink ist derzeit deaktiviert
dynamic_template_with_variables_cannot_be_used_via_shared_link: Dynamische Vorlage mit Variablen kann nicht über einen Freigabelink verwendet werden
select_data_residency: Datenstandort auswählen
account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: '%{account_name} hat Sie eingeladen, Dokumente mühelos online mit einer sicheren, schnellen und benutzerfreundlichen digitalen Signaturlösung auszufüllen und zu unterschreiben.'
review_or_download_completed_documents_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: Überprüfen oder laden Sie abgeschlossene Dokumente herunter. Füllen und unterschreiben Sie Dokumente mühelos online mit einer sicheren, schnellen und benutzerfreundlichen digitalen Signaturlösung.
@@ -5848,6 +6019,7 @@ de: &de
allow_signers_to_create_signatures_by_typing_their_name_instead_of_drawing_or_uploading_one: Unterzeichner dürfen Signaturen erstellen, indem sie ihren Namen eingeben, statt eine Signatur zu zeichnen oder hochzuladen.
allow_signers_to_resubmit_forms_after_completion_useful_when_corrections_or_multiple_submissions_are_needed: Unterzeichner dürfen Formulare nach Abschluss erneut einreichen nützlich für Korrekturen oder mehrere Einreichungen.
allow_recipients_to_decline_signing_a_document_the_decline_reason_notification_will_be_sent_to_the_signature_requester: Empfänger dürfen die Unterzeichnung eines Dokuments ablehnen. Die Benachrichtigung mit dem Ablehnungsgrund wird an den Anforderer der Signatur gesendet.
allow_recipients_to_delegate_documents: Empfängern erlauben, Dokumente per E-Mail an eine andere Person weiterzuleiten. Dies kann nützlich sein, wenn der ursprüngliche Empfänger das Dokument an den autorisierten Unterzeichner weiterleiten muss.
save_a_users_signature_and_automatically_pre_fill_it_in_future_signing_sessions: Die Signatur eines Benutzers speichern und sie in zukünftigen Signatursitzungen automatisch vorausfüllen.
make_document_download_links_expire_after_40_minutes_to_prevent_long_term_access_and_enhance_security: Download-Links für Dokumente nach 40 Minuten ablaufen lassen, um langfristigen Zugriff zu verhindern und die Sicherheit zu erhöhen.
require_authentication_with_user_login_or_api_key_to_access_the_document_download_links: Authentifizierung mit Benutzeranmeldung oder API-Schlüssel erforderlich, um auf die Download-Links für Dokumente zuzugreifen.
@@ -5890,6 +6062,13 @@ de: &de
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} wartet auf Bestätigung. Folgen Sie dem Link in der E-Mail, um sie zu bestätigen."
please_confirm_your_email_address_using_the_link_below_: 'Bitte bestätigen Sie Ihre E-Mail-Adresse über den folgenden Link:'
confirm_email: E-Mail bestätigen
please_verify_your_email_address_to_continue: Bitte bestätigen Sie Ihre E-Mail-Adresse, um fortzufahren.
verification_code_sent_click_link_or_enter_code: Ein Verifizierungscode wurde an Ihre E-Mail gesendet. Sie können auf den Link in der E-Mail klicken oder den Code unten eingeben.
use_otp_code_to_verify_email_or_click_link_below_html: 'Verwenden Sie den Code <b>%{code}</b>, um Ihre E-Mail zu bestätigen, oder klicken Sie auf den Link unten:'
verify_your_email: E-Mail bestätigen
your_email_has_been_confirmed: Ihre E-Mail-Adresse wurde bestätigt.
invalid_or_expired_verification_code: Ungültiger oder abgelaufener Verifizierungscode.
didnt_receive_an_email: Keine E-Mail erhalten?
unconfirmed: Unbestätigt
you_requested_to_reset_your_password_use_the_link_below_to_continue: Sie haben angefordert, Ihr Passwort zurückzusetzen. Verwenden Sie den untenstehenden Link, um fortzufahren.
if_you_didnt_request_this_you_can_ignore_this_email: "Wenn Sie dies nicht angefordert haben, können Sie diese E-Mail ignorieren."
@@ -5902,6 +6081,21 @@ de: &de
redo: Wiederholen
add_variable: Variable hinzufügen
enter_a_url_or_variable_name: Geben Sie eine URL oder einen Variablennamen ein
new_token: Neues Token
token: Token
mcp_server: MCP-Server
instructions: Anweisungen
please_copy_the_token_below_now_as_it_wont_be_shown_again: Kopieren Sie das Token jetzt, da es nicht erneut angezeigt wird
mcp_token_has_been_created: Das MCP-Token wurde erstellt.
mcp_token_has_been_removed: Das MCP-Token wurde entfernt.
enable_mcp_server: MCP-Server aktivieren
all_existing_mcp_connections_will_be_stopped_immediately_when_this_setting_is_disabled: Alle bestehenden MCP-Verbindungen werden sofort gestoppt, wenn diese Einstellung deaktiviert wird.
connect_to_docuseal_mcp: Mit DocuSeal MCP verbinden
add_the_following_to_your_mcp_client_configuration: Fügen Sie Folgendes zu Ihrer MCP-Client-Konfiguration hinzu
works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Funktioniert mit Claude Desktop, Cursor, Windsurf, VS Code und jedem MCP-kompatiblen Client.
your_email_address_has_been_changed: Ihre E-Mail-Adresse wurde geändert
the_email_address_for_your_account_has_been_changed_to_new_email: Die E-Mail-Adresse Ihres Kontos wurde in %{new_email} geändert.
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Wenn Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns bitte, indem Sie auf diese E-Mail antworten.
devise:
confirmations:
confirmed: Ihre E-Mail-Adresse wurde erfolgreich bestätigt.
@@ -5936,6 +6130,8 @@ de: &de
start_verification_by_html: '<b>Identitätsüberprüfung gestartet</b> von %{submitter_name}'
complete_verification_by_html: '<b>Identitätsüberprüfung abgeschlossen</b> von %{submitter_name} mit %{provider}'
api_complete_form_by_html: '<b>Einreichung über API abgeschlossen</b> von %{submitter_name}'
decline_form_by_html: '<b>Formular abgelehnt</b> von %{submitter_name}'
delegate_form_by_html: '<b>Delegiert</b> von %{from} an %{to}'
start_kba_by_html: '<b>KBA gestartet</b> von %{submitter_name}'
complete_kba_by_html: '<b>KBA abgeschlossen</b> von %{submitter_name}'
fail_kba_by_html: '<b>KBA fehlgeschlagen</b> von %{submitter_name}'
@@ -5999,6 +6195,8 @@ de: &de
scopes:
write: Aktualisieren Sie Ihre Daten
read: Lesen Sie Ihre Daten
mcp: MCP verwenden
claudeai: Claude AI verwenden
pagination:
submissions:
range_with_total: "%{from}-%{to} von %{count} Einreichungen"
@@ -6040,11 +6238,14 @@ pl:
hi_there: Cześć,
download: Pobierz
decline: Odrzuć
delegate: Deleguj
enter_the_email_address_of_the_person_you_want_to_delegate_to: Wprowadź adres e-mail osoby, do której chcesz delegować
declined: Odrzucono
decline_reason: Powód odrzucenia
provide_a_reason: Podaj powód
notify_the_sender_with_the_reason_you_declined: Powiadom nadawcę o powodzie, dla którego odrzuciłeś
form_has_been_declined_on_html: 'Formularz został odrzucony o <span class="font-semibold">%{time}</span>'
document_has_been_delegated_on_html: 'Dokument został oddelegowany o <span class="font-semibold">%{time}</span>'
name_declined_by_submitter: '"%{name}" odrzucono przez %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" został odrzucony przez %{submitter} z następującym powodem:'
email: E-mail
@@ -6137,11 +6338,14 @@ uk:
hi_there: Привіт,
download: Завантажити
decline: Відхилити
delegate: Делегувати
enter_the_email_address_of_the_person_you_want_to_delegate_to: Введіть адресу електронної пошти особи, якій ви хочете делегувати
declined: Відхилено
decline_reason: Причина відхилення
provide_a_reason: Надайте причину
notify_the_sender_with_the_reason_you_declined: Повідомте відправника про причину, з якої ви відхилили
form_has_been_declined_on_html: 'Форму було відхилено о <span class="font-semibold">%{time}</span>'
document_has_been_delegated_on_html: 'Документ було делеговано о <span class="font-semibold">%{time}</span>'
name_declined_by_submitter: '"%{name}" відхилено %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" було відхилено %{submitter} з такою причиною:'
email: E-mail
@@ -6234,11 +6438,14 @@ cs:
hi_there: Ahoj,
download: Stáhnout
decline: Odmítnout
delegate: Delegovat
enter_the_email_address_of_the_person_you_want_to_delegate_to: Zadejte e-mailovou adresu osoby, na kterou chcete delegovat
declined: Odmítnuto
decline_reason: Důvod odmítnutí
provide_a_reason: Uveďte důvod
notify_the_sender_with_the_reason_you_declined: Informujte odesílatele o důvodu odmítnutí
form_has_been_declined_on_html: 'Formulář byl odmítnut dne <span class="font-semibold">%{time}</span>'
document_has_been_delegated_on_html: 'Dokument byl delegován dne <span class="font-semibold">%{time}</span>'
name_declined_by_submitter: '"%{name}" odmítnuto %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" bylo odmítnuto %{submitter} s následujícím důvodem:'
email: E-mail
@@ -6331,11 +6538,14 @@ he:
hi_there: שלום,
download: הורד
decline: דחייה
delegate: הואלה
enter_the_email_address_of_the_person_you_want_to_delegate_to: הזן את כתובת הדוא"ל של האדם שברצונך להאציל אליו
declined: דחוי
decline_reason: סיבת דחייה
provide_a_reason: אנא ספק סיבה
notify_the_sender_with_the_reason_you_declined: הודע לשולח על הסיבה לדחייה
form_has_been_declined_on_html: 'הטופס נדחה בתאריך <span class="font-semibold">%{time}</span>'
document_has_been_delegated_on_html: 'המסמך הועבר בתאריך <span class="font-semibold">%{time}</span>'
name_declined_by_submitter: '"%{name}" נדחה על ידי %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" נדחה על ידי %{submitter} עם הסיבה הבאה:'
email: דוא"ל
@@ -6431,6 +6641,7 @@ nl: &nl
pro: Pro
thanks: Bedankt
private: Privé
_variables: Variabelen
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Maak alle nieuw aangemaakte sjablonen standaard privé voor hun maker en admins.
create_templates_with_admin_access_by_default: Sjablonen standaard met admin-toegang maken
require_email_2fa: E-mail 2FA vereist
@@ -6563,11 +6774,14 @@ nl: &nl
downloading: Downloaden
download: Downloaden
decline: Weigeren
delegate: Delegeren
enter_the_email_address_of_the_person_you_want_to_delegate_to: Voer het e-mailadres in van de persoon aan wie u wilt delegeren
declined: Geweigerd
decline_reason: Reden van weigering
provide_a_reason: Geef een reden op
notify_the_sender_with_the_reason_you_declined: Informeer de verzender over de reden van uw weigering
form_has_been_declined_on_html: Formulier is geweigerd op <span class="font-semibold">%{time}</span>
document_has_been_delegated_on_html: Document is gedelegeerd op <span class="font-semibold">%{time}</span>
name_declined_by_submitter: '"%{name}" geweigerd door %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" is geweigerd door %{submitter} met de volgende reden:'
completed_successfully: Succesvol voltooid
@@ -6602,6 +6816,7 @@ nl: &nl
allow_typed_text_signatures: Handtekeningen met getypte tekst toestaan
allow_to_resubmit_completed_forms: Opnieuw indienen van voltooide formulieren toestaan
allow_to_decline_documents: Weigeren van documenten toestaan
allow_to_delegate_documents: Delegeren van documenten toestaan
remember_and_pre_fill_signatures: Handtekeningen onthouden en vooraf invullen
require_authentication_for_file_download_links: Authenticatie vereisen voor links naar bestandsdownloads
combine_completed_documents_and_audit_log: Voltooide documenten en auditlogboek combineren
@@ -7182,6 +7397,7 @@ nl: &nl
share_link: Deellink
enable_shared_link: Deellink inschakelen
share_link_is_currently_disabled: Deellink is momenteel uitgeschakeld
dynamic_template_with_variables_cannot_be_used_via_shared_link: Dynamisch sjabloon met variabelen kan niet worden gebruikt via een deellink
select_data_residency: Gegevensresidentie selecteren
account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: "%{account_name} heeft u uitgenodigd om documenten online moeiteloos in te vullen en te ondertekenen met een veilige, snelle en gebruiksvriendelijke digitale ondertekenoplossing."
review_or_download_completed_documents_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: Bekijk of download voltooide documenten. Vul en onderteken documenten online moeiteloos met een veilige, snelle en gebruiksvriendelijke digitale ondertekenoplossing.
@@ -7235,6 +7451,7 @@ nl: &nl
allow_signers_to_create_signatures_by_typing_their_name_instead_of_drawing_or_uploading_one: Sta ondertekenaars toe handtekeningen te maken door hun naam te typen in plaats van te tekenen of te uploaden.
allow_signers_to_resubmit_forms_after_completion_useful_when_corrections_or_multiple_submissions_are_needed: Sta ondertekenaars toe formulieren na voltooiing opnieuw in te dienen; nuttig wanneer correcties of meerdere inzendingen nodig zijn.
allow_recipients_to_decline_signing_a_document_the_decline_reason_notification_will_be_sent_to_the_signature_requester: Sta ontvangers toe het ondertekenen van een document te weigeren. De melding met de reden van weigering wordt naar de aanvrager van de handtekening gestuurd.
allow_recipients_to_delegate_documents: Sta ontvangers toe documenten per e-mail te delegeren aan een andere persoon. Dit kan handig zijn wanneer de oorspronkelijke ontvanger het document moet doorgeven aan de gemachtigde ondertekenaar.
save_a_users_signature_and_automatically_pre_fill_it_in_future_signing_sessions: Sla de handtekening van een gebruiker op en vul deze automatisch vooraf in bij toekomstige ondertekeningssessies.
make_document_download_links_expire_after_40_minutes_to_prevent_long_term_access_and_enhance_security: Laat links voor het downloaden van documenten na 40 minuten verlopen om langdurige toegang te voorkomen en de beveiliging te verbeteren.
require_authentication_with_user_login_or_api_key_to_access_the_document_download_links: Authenticatie met gebruikerslogin of API-sleutel vereisen om toegang te krijgen tot de documentdownloadlinks.
@@ -7277,6 +7494,13 @@ nl: &nl
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} wacht op bevestiging. Volg de link in de e-mail om te bevestigen."
please_confirm_your_email_address_using_the_link_below_: 'Bevestig je e-mailadres via de onderstaande link:'
confirm_email: E-mailadres bevestigen
please_verify_your_email_address_to_continue: Bevestig je e-mailadres om door te gaan.
verification_code_sent_click_link_or_enter_code: Er is een verificatiecode naar je e-mail gestuurd. Je kunt op de link in de e-mail klikken of de code hieronder invoeren.
use_otp_code_to_verify_email_or_click_link_below_html: 'Gebruik code <b>%{code}</b> om je e-mail te verifiëren of klik op de link hieronder:'
verify_your_email: Je e-mail verifiëren
your_email_has_been_confirmed: Je e-mailadres is bevestigd.
invalid_or_expired_verification_code: Ongeldige of verlopen verificatiecode.
didnt_receive_an_email: Geen e-mail ontvangen?
unconfirmed: Onbevestigd
you_requested_to_reset_your_password_use_the_link_below_to_continue: Je hebt gevraagd je wachtwoord te resetten. Gebruik de onderstaande link om verder te gaan.
if_you_didnt_request_this_you_can_ignore_this_email: "Als je dit niet hebt aangevraagd, kun je deze e-mail negeren."
@@ -7289,6 +7513,21 @@ nl: &nl
redo: Opnieuw
add_variable: Variabele toevoegen
enter_a_url_or_variable_name: Voer een URL of variabelenaam in
new_token: Nieuw token
token: Token
mcp_server: MCP-server
instructions: Instructies
please_copy_the_token_below_now_as_it_wont_be_shown_again: Kopieer het token hieronder nu, want het wordt niet opnieuw getoond
mcp_token_has_been_created: Het MCP-token is aangemaakt.
mcp_token_has_been_removed: Het MCP-token is verwijderd.
enable_mcp_server: MCP-server inschakelen
all_existing_mcp_connections_will_be_stopped_immediately_when_this_setting_is_disabled: Alle bestaande MCP-verbindingen worden onmiddellijk gestopt wanneer deze instelling wordt uitgeschakeld.
connect_to_docuseal_mcp: Verbinden met DocuSeal MCP
add_the_following_to_your_mcp_client_configuration: Voeg het volgende toe aan uw MCP-clientconfiguratie
works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Werkt met Claude Desktop, Cursor, Windsurf, VS Code en elke MCP-compatibele client.
your_email_address_has_been_changed: Je e-mailadres is gewijzigd
the_email_address_for_your_account_has_been_changed_to_new_email: Het e-mailadres van je account is gewijzigd naar %{new_email}.
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Als je deze wijziging niet hebt aangebracht, neem dan contact met ons op door op deze e-mail te antwoorden.
devise:
confirmations:
confirmed: Je e-mailadres is succesvol bevestigd.
@@ -7323,6 +7562,8 @@ nl: &nl
start_verification_by_html: "<b>Identiteitsverificatie gestart</b> door %{submitter_name}"
complete_verification_by_html: "<b>Identiteitsverificatie voltooid</b> door %{submitter_name} met %{provider}"
api_complete_form_by_html: "<b>Inzending via API voltooid</b> door %{submitter_name}"
decline_form_by_html: "<b>Formulier geweigerd</b> door %{submitter_name}"
delegate_form_by_html: "<b>Gedelegeerd</b> van %{from} naar %{to}"
start_kba_by_html: "<b>KBA gestart</b> door %{submitter_name}"
complete_kba_by_html: "<b>KBA voltooid</b> door %{submitter_name}"
fail_kba_by_html: "<b>KBA mislukt</b> door %{submitter_name}"
@@ -7386,6 +7627,8 @@ nl: &nl
scopes:
write: Uw gegevens bijwerken
read: Uw gegevens lezen
mcp: MCP gebruiken
claudeai: Claude AI gebruiken
pagination:
submissions:
range_with_total: "%{from}-%{to} van %{count} inzendingen"
@@ -7427,11 +7670,14 @@ ar:
hi_there: مرحبا,
download: تحميل
decline: رفض
delegate: تفويض
enter_the_email_address_of_the_person_you_want_to_delegate_to: أدخل عنوان البريد الإلكتروني للشخص الذي تريد التفويض إليه
declined: مرفوض
decline_reason: سبب الرفض
provide_a_reason: قدم سببًا
notify_the_sender_with_the_reason_you_declined: أخطر المرسل بسبب الرفض
form_has_been_declined_on_html: 'تم رفض النموذج في <span class="font-semibold">%{time}</span>'
document_has_been_delegated_on_html: 'تم تفويض المستند في <span class="font-semibold">%{time}</span>'
name_declined_by_submitter: '"%{name}" مرفوض بواسطة %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" تم رفضه بواسطة %{submitter} مع السبب التالي:'
email: البريد الإلكتروني
@@ -7524,11 +7770,14 @@ ko:
hi_there: 안녕하세요,
download: 다운로드
decline: 거절
delegate: 위임
enter_the_email_address_of_the_person_you_want_to_delegate_to: 위임할 사람의 이메일 주소를 입력하세요
declined: 거절됨
decline_reason: 거절 사유
provide_a_reason: 사유를 제공하세요
notify_the_sender_with_the_reason_you_declined: 거절한 이유로 발신자에게 알리기
form_has_been_declined_on_html: '양식이 <span class="font-semibold">%{time}</span>에 거절되었습니다.'
document_has_been_delegated_on_html: '문서가 <span class="font-semibold">%{time}</span>에 위임되었습니다.'
name_declined_by_submitter: '"%{name}"이(가) %{submitter}에 의해 거절되었습니다.'
name_declined_by_submitter_with_the_following_reason: '"%{name}"이(가) %{submitter}에 의해 다음과 같은 사유로 거절되었습니다:'
email: 이메일
@@ -7621,11 +7870,14 @@ ja:
hi_there: こんにちは
download: ダウンロード
decline: 辞退
delegate: 委任
enter_the_email_address_of_the_person_you_want_to_delegate_to: 委任したい人のメールアドレスを入力してください
declined: 辞退済み
decline_reason: 辞退の理由
provide_a_reason: 理由を入力してください
notify_the_sender_with_the_reason_you_declined: 辞退理由を送信者に通知してください
form_has_been_declined_on_html: '<span class="font-semibold">%{time}</span> にフォームが辞退されました'
document_has_been_delegated_on_html: '<span class="font-semibold">%{time}</span> にドキュメントが委任されました'
name_declined_by_submitter: '"%{name}" は %{submitter} により辞退されました'
name_declined_by_submitter_with_the_following_reason: '"%{name}" は %{submitter} により次の理由で辞退されました:'
email: メール
+6 -1
View File
@@ -111,7 +111,7 @@ Rails.application.routes.draw do
resources :prefillable_fields, only: %i[create], controller: 'templates_prefillable_fields'
resources :submissions_export, only: %i[index new]
end
resources :preview_document_page, only: %i[show], path: '/preview/:signed_uuid'
resources :preview_document_page, only: %i[show], path: '/preview/:signed_key'
resource :blobs_proxy, only: %i[show], path: '/file/:signed_uuid/*filename',
controller: 'api/active_storage_blobs_proxy'
resource :blobs_proxy, only: %i[show], path: '/blobs_proxy/:signed_uuid/*filename',
@@ -145,8 +145,10 @@ Rails.application.routes.draw do
resources :values, only: %i[index], controller: 'submit_form_values'
resources :download, only: %i[index], controller: 'submit_form_download'
resources :decline, only: %i[create], controller: 'submit_form_decline'
resources :delegate, only: %i[create], controller: 'submit_form_delegate'
resources :invite, only: %i[create], controller: 'submit_form_invite'
get :completed
get :delegated
end
resources :submit_form_draw_signature, only: %i[show], path: 'p', param: 'slug'
@@ -168,6 +170,7 @@ Rails.application.routes.draw do
resources :storage, only: %i[index create], controller: 'storage_settings'
resources :search_entries_reindex, only: %i[create]
resources :sms, only: %i[index], controller: 'sms_settings'
resources :mcp, only: %i[index new create destroy], controller: 'mcp_settings'
end
if Docuseal.demo? || !Docuseal.multitenant?
resources :api, only: %i[index create], controller: 'api_settings'
@@ -201,6 +204,8 @@ Rails.application.routes.draw do
end
end
match '/mcp', to: 'mcp#call', via: %i[get post]
get '/js/:filename', to: 'embed_scripts#show', as: :embed_script
ActiveSupport.run_load_hooks(:routes, self)
+7 -1
View File
@@ -14,7 +14,7 @@ const configs = generateWebpackConfig({
concatenateModules: !process.env.BUNDLE_ANALYZE,
splitChunks: {
chunks (chunk) {
return chunk.name !== 'rollbar'
return chunk.name !== 'rollbar' && chunk.name !== 'dynamic-editor'
},
cacheGroups: {
default: false,
@@ -39,8 +39,14 @@ const configs = generateWebpackConfig({
].filter(Boolean)
})
configs.module.rules[3].exclude = /dynamic_styles\.scss$/
configs.module = merge({
rules: [
{
test: /dynamic_styles\.scss$/,
use: ['css-loader', 'postcss-loader', 'sass-loader']
},
{
test: /\.vue$/,
use: [{
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class CreateDynamicDocuments < ActiveRecord::Migration[8.1]
def change
create_table :dynamic_documents do |t|
t.string :uuid, null: false
t.references :template, null: false, foreign_key: true, index: true
t.text :body, null: false
t.text :head
t.string :sha1, null: false
t.timestamps
end
end
end
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class CreateDynamicDocumentVersions < ActiveRecord::Migration[8.1]
def change
create_table :dynamic_document_versions do |t|
t.references :dynamic_document, null: false, foreign_key: true, index: false
t.string :sha1, null: false
t.text :areas, null: false
t.timestamps
end
add_index :dynamic_document_versions, %i[dynamic_document_id sha1], unique: true
end
end

Some files were not shown because too many files have changed in this diff Show More