mirror of
https://github.com/docusealco/docuseal.git
synced 2026-08-07 07:14:43 +00:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4486f8040e | |||
| a98caad968 | |||
| ffb75238b4 | |||
| cba3e74f3a | |||
| 8d3f0b5b98 | |||
| a2598b30f7 | |||
| 8d11f5ddae | |||
| 6ffc88b4ed | |||
| 640b451727 | |||
| db6e2eb7ca | |||
| 407816b576 | |||
| 486cc493e6 | |||
| 3eb0b8a89f | |||
| c23eca0ade | |||
| 67de153016 | |||
| ba2da69a67 | |||
| 111bef14e1 | |||
| 22d4c1bd10 | |||
| e3dfb0ae83 | |||
| 8d7c3f0e26 | |||
| b11775ebda | |||
| 608191183a | |||
| 2ba903e170 | |||
| 342b64d502 | |||
| 1a8f048aaa | |||
| 7309581b75 | |||
| 36d59c40a7 | |||
| ed3c7c324b | |||
| fd868360a3 | |||
| 8dc2ebfbe0 | |||
| 997711b34a | |||
| ac01b7129d | |||
| f4b7528c0d | |||
| d7b9ae5df5 | |||
| 4db29126e9 | |||
| 4cfee2fe45 | |||
| db5dbd6ac2 | |||
| 44db775b89 | |||
| 8e2e780a1a | |||
| 7aba2eb4da | |||
| 3e250bfb28 | |||
| 9c02ed66f5 | |||
| f5ae8c60d3 | |||
| e90ac391b2 | |||
| 0e5f949bcf | |||
| b7c270bef8 | |||
| 5f16b506f5 | |||
| 176148fc71 | |||
| 861daaedf4 | |||
| d8430f4e63 | |||
| dd44864485 | |||
| b3bb29f383 | |||
| b4d137c0de | |||
| 62a969d8fe | |||
| 961f09e092 | |||
| b2dfa83f18 | |||
| 37196ff89f | |||
| b50d982497 | |||
| 4a484aca69 | |||
| 0bf37d571a |
+5
-5
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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]] }]]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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] }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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 = {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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()]"
|
||||
>
|
||||
×
|
||||
</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]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
×
|
||||
↑
|
||||
</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)"
|
||||
>
|
||||
↑
|
||||
</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)"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</template>
|
||||
↓
|
||||
</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
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
@@ -5,6 +5,8 @@ class ApplicationMailer < ActionMailer::Base
|
||||
layout 'mailer'
|
||||
|
||||
register_interceptor ActionMailerConfigsInterceptor
|
||||
register_interceptor HtmlToPlainTextInterceptor
|
||||
register_preview_interceptor HtmlToPlainTextInterceptor
|
||||
|
||||
register_observer ActionMailerEventsObserver
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
#
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: -> { {} }
|
||||
|
||||
@@ -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
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,6 +34,7 @@ class WebhookUrl < ApplicationRecord
|
||||
submission.archived
|
||||
template.created
|
||||
template.updated
|
||||
template.archived
|
||||
].freeze
|
||||
|
||||
belongs_to :account
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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,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 |
@@ -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| %>
|
||||
|
||||
@@ -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: %>
|
||||
|
||||
@@ -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: %>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) %>
|
||||
|
||||
@@ -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 %>
|
||||
@@ -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 %>
|
||||
@@ -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' %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"note": "Safe SQL"
|
||||
},
|
||||
{
|
||||
"fingerprint": "5f52190d03ee922bba9792012d8fcbeb7d4736006bb899b3be9cc10d679e0af1",
|
||||
"fingerprint": "f3a20210cde7b9cb5944d53505fe80fea502308416143f4da9ec2422f6b7035c",
|
||||
"note": "Safe Param"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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] ||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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 l’expé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 d’audit
|
||||
@@ -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 d’un 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 d’identité démarrée</b> par %{submitter_name}"
|
||||
complete_verification_by_html: "<b>Vérification d’identité 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
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user