Compare commits

..

54 Commits

Author SHA1 Message Date
Alex Turchyn 6ab68cc36b Merge from docusealco/wip 2026-02-02 14:13:54 +02:00
Pete Matsyburka ee2400a5c5 touch signature attachment 2026-02-02 12:46:34 +02:00
Pete Matsyburka 80be5d2cf5 sandbox 2026-02-01 08:10:02 +02:00
Pete Matsyburka 76cf44a31b fix 2026-01-31 22:17:11 +02:00
Pete Matsyburka eb63113227 use trilogy mysql 2026-01-31 22:15:17 +02:00
Pete Matsyburka b7045fb3d9 fix azure proxy 2026-01-31 13:07:42 +02:00
Pete Matsyburka 11a5b5842d starttls label 2026-01-31 11:41:52 +02:00
Pete Matsyburka 362994afd5 adjust i18n 2026-01-31 10:26:09 +02:00
Pete Matsyburka d7682f4ebd add number conditions 2026-01-31 09:00:24 +02:00
Pete Matsyburka 56d4fe7fa9 add white color 2026-01-31 07:58:12 +02:00
Pete Matsyburka 93a852ac14 fix save modal 2026-01-30 21:45:55 +02:00
Pete Matsyburka 964db0ab08 fix menu 2026-01-30 21:03:56 +02:00
Pete Matsyburka 8547295da7 border color 2026-01-30 20:53:47 +02:00
Pete Matsyburka c75050fa46 adjust kba 2026-01-30 18:17:47 +02:00
Pete Matsyburka 65372c52aa fix order 2026-01-30 17:42:50 +02:00
Alex Turchyn 12c767b7d3 add more options to context menu 2026-01-30 16:36:50 +02:00
Pete Matsyburka 2fc5e9cfb3 format cells 2026-01-30 11:21:47 +02:00
Pete Matsyburka bccc78ee65 optimize builder 2026-01-30 10:23:51 +02:00
Pete Matsyburka c0793f3ce7 cell font settings 2026-01-29 21:43:01 +02:00
Pete Matsyburka a8dd8699a3 adjust kba 2026-01-29 15:55:15 +02:00
Pete Matsyburka 91bb25e8b6 fix reload 2026-01-29 15:33:24 +02:00
Pete Matsyburka c3f4480cdc add submitter index 2026-01-29 13:01:44 +02:00
Pete Matsyburka 33e226861e add SMTP_SSL_VERIFY env 2026-01-29 11:34:37 +02:00
Pete Matsyburka 4f5acbec9d readonly radio 2026-01-28 21:36:48 +02:00
Pete Matsyburka d2c9bbf6ab optimize conditions 2026-01-28 13:24:05 +02:00
Pete Matsyburka b5e57e1f2f add include fields 2026-01-28 11:49:35 +02:00
Pete Matsyburka c82c8b2272 fix race condition 2026-01-28 08:50:44 +02:00
Pete Matsyburka 43f1b598a7 adjust smtp noverify 2026-01-28 08:27:25 +02:00
Pete Matsyburka 5081e387d0 fix undefined 2026-01-27 20:13:26 +02:00
Pete Matsyburka 1e5f55c1d6 fix Point is invalid 2026-01-27 20:07:57 +02:00
Alex Turchyn aa38773be3 Merge from docusealco/wip 2026-01-27 18:39:04 +02:00
Pete Matsyburka 96530bd809 fix race condition 2026-01-27 17:06:30 +02:00
Pete Matsyburka 127f95a9d0 fix typo 2026-01-27 12:31:06 +02:00
Pete Matsyburka cf31e12348 adjust smtp configs 2026-01-27 10:57:04 +02:00
Pete Matsyburka 4adaa531b7 fix pagination 2026-01-27 10:34:18 +02:00
Pete Matsyburka c7659563b8 fix puma warnings 2026-01-26 20:23:11 +02:00
Alex Turchyn 7b5494468f update rails to 8.1.2 2026-01-26 18:22:47 +02:00
Pete Matsyburka d12c67fd54 with force open 2026-01-26 18:00:46 +02:00
Pete Matsyburka e20eacb5ee center on click 2026-01-26 12:35:53 +02:00
Pete Matsyburka 98055f0762 add can parse polyfill 2026-01-26 12:17:57 +02:00
Pete Matsyburka a46c42c3a0 fix resize warning 2026-01-26 12:06:41 +02:00
Pete Matsyburka c3d1503361 cleanup 2026-01-26 11:44:12 +02:00
Pete Matsyburka 65b47d27cd adjust form 2026-01-26 11:38:33 +02:00
Pete Matsyburka 968c07f8be include idcode 2026-01-26 11:13:01 +02:00
Pete Matsyburka 91814ca105 custom fields 2026-01-26 11:12:55 +02:00
Pete Matsyburka 9128f24270 fix synology acl 2026-01-24 18:01:49 +02:00
Pete Matsyburka 264897bbd5 audit with sender 2026-01-23 12:12:37 +02:00
Pete Matsyburka 67d59ff1ba fix invite by order 2026-01-22 21:20:34 +02:00
Pete Matsyburka 81eee56e1e i18n 2026-01-21 20:14:53 +02:00
Pete Matsyburka 92ea8af786 fix options drag 2026-01-21 16:02:20 +02:00
Pete Matsyburka df0616f77e paste options 2026-01-21 15:42:38 +02:00
Pete Matsyburka a337dff85c change model 2026-01-21 10:23:26 +02:00
Pete Matsyburka 48e349c1d2 fix signature pad when resize 2026-01-20 17:24:05 +02:00
Pete Matsyburka df9fa0fec7 fix scroll into field 2026-01-20 15:27:35 +02:00
82 changed files with 5194 additions and 1949 deletions
+6 -6
View File
@@ -12,7 +12,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.2
ruby-version: 4.0.1
- name: Cache gems
uses: actions/cache@v4
with:
@@ -37,7 +37,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.2
ruby-version: 4.0.1
- name: Cache gems
uses: actions/cache@v4
with:
@@ -62,7 +62,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v1
with:
node-version: 20.9.0
node-version: 20.19.0
- name: Cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
@@ -89,7 +89,7 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.2
ruby-version: 4.0.1
- name: Cache gems
uses: actions/cache@v4
with:
@@ -132,11 +132,11 @@ jobs:
- name: Install Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.4.2
ruby-version: 4.0.1
- name: Set up Node
uses: actions/setup-node@v1
with:
node-version: 20.9.0
node-version: 20.19.0
- name: Install Chrome
uses: browser-actions/setup-chrome@latest
with:
+8 -2
View File
@@ -1,4 +1,4 @@
require:
plugins:
- rubocop-performance
- rubocop-rails
- rubocop-rspec
@@ -10,7 +10,7 @@ AllCops:
- node_modules/**/*
- bin/*
- vendor/**/*
TargetRubyVersion: '3.3'
TargetRubyVersion: '4.0'
SuggestExtensions: false
Metrics/BlockLength:
@@ -100,3 +100,9 @@ Rails/ApplicationController:
Rails/Output:
Enabled: false
Rails/StrongParametersExpect:
Enabled: false
Rails/RedirectBackOrTo:
Enabled: false
+6 -8
View File
@@ -1,4 +1,4 @@
FROM ruby:3.4.2-alpine AS download
FROM ruby:4.0.1-alpine AS download
WORKDIR /fonts
@@ -9,14 +9,14 @@ RUN apk --no-cache add fontforge wget && \
wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && \
wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.1.0/model_704_int8.onnx" && \
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" && \
mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
FROM ruby:3.4.2-alpine AS webpack
FROM ruby:4.0.1-alpine AS webpack
ENV RAILS_ENV=production
ENV NODE_ENV=production
@@ -42,7 +42,7 @@ COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:3.4.2-alpine AS app
FROM ruby:4.0.1-alpine AS app
ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test"
@@ -51,7 +51,7 @@ ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
WORKDIR /app
RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
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 addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
@@ -69,9 +69,7 @@ activate = 1' >> /etc/openssl_legacy.cnf
COPY --chown=docuseal:docuseal ./Gemfile ./Gemfile.lock ./
RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
RUN echo 'https://dl-cdn.alpinelinux.org/alpine/edge/main' >> /etc/apk/repositories && echo 'https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache onnxruntime
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")
COPY --chown=docuseal:docuseal ./bin ./bin
COPY --chown=docuseal:docuseal ./app ./app
+11 -11
View File
@@ -2,16 +2,16 @@
source 'https://rubygems.org'
ruby '3.4.2'
ruby '4.0.1'
gem 'arabic-letter-connector', require: 'arabic-letter-connector/logic'
gem 'arabic-letter-connector', require: false
gem 'aws-sdk-s3', require: false
gem 'aws-sdk-secretsmanager', require: false
gem 'azure-storage-blob', require: false
gem 'azure-blob', require: false
gem 'bootsnap', require: false
gem 'cancancan'
gem 'csv'
gem 'csv-safe'
gem 'csv', require: false
gem 'csv-safe', require: false
gem 'devise'
gem 'devise-two-factor'
gem 'dotenv', require: false
@@ -21,12 +21,11 @@ gem 'faraday-follow_redirects'
gem 'google-cloud-storage', require: false
gem 'hexapdf'
gem 'image_processing'
gem 'jwt'
gem 'jwt', require: false
gem 'lograge'
gem 'mysql2', require: false
gem 'numo-narray'
gem 'numo-narray-alt', require: false
gem 'oj'
gem 'onnxruntime'
gem 'onnxruntime', require: false
gem 'pagy'
gem 'pg', require: false
gem 'premailer-rails'
@@ -38,13 +37,14 @@ gem 'rails_autolink'
gem 'rails-i18n'
gem 'rotp'
gem 'rouge', require: false
gem 'rqrcode'
gem 'rqrcode', require: false
gem 'ruby-vips'
gem 'rubyXL'
gem 'rubyXL', require: false
gem 'shakapacker'
gem 'sidekiq'
gem 'sqlite3', require: false
gem 'strip_attributes'
gem 'trilogy', github: 'trilogy-libraries/trilogy', glob: 'contrib/ruby/*.gemspec', require: false
gem 'turbo-rails'
gem 'twitter_cldr', require: false
gem 'tzinfo-data'
+286 -285
View File
@@ -1,29 +1,39 @@
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:
actioncable (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
action_text-trix (2.1.16)
railties
actioncable (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.0.2.1)
actionpack (= 8.0.2.1)
activejob (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
actionmailbox (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
mail (>= 2.8.0)
actionmailer (8.0.2.1)
actionpack (= 8.0.2.1)
actionview (= 8.0.2.1)
activejob (= 8.0.2.1)
activesupport (= 8.0.2.1)
actionmailer (8.1.2)
actionpack (= 8.1.2)
actionview (= 8.1.2)
activejob (= 8.1.2)
activesupport (= 8.1.2)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.0.2.1)
actionview (= 8.0.2.1)
activesupport (= 8.0.2.1)
actionpack (8.1.2)
actionview (= 8.1.2)
activesupport (= 8.1.2)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -31,55 +41,58 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.0.2.1)
actionpack (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
actiontext (8.1.2)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.0.2.1)
activesupport (= 8.0.2.1)
actionview (8.1.2)
activesupport (= 8.1.2)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.0.2.1)
activesupport (= 8.0.2.1)
activejob (8.1.2)
activesupport (= 8.1.2)
globalid (>= 0.3.6)
activemodel (8.0.2.1)
activesupport (= 8.0.2.1)
activerecord (8.0.2.1)
activemodel (= 8.0.2.1)
activesupport (= 8.0.2.1)
activemodel (8.1.2)
activesupport (= 8.1.2)
activerecord (8.1.2)
activemodel (= 8.1.2)
activesupport (= 8.1.2)
timeout (>= 0.4.0)
activestorage (8.0.2.1)
actionpack (= 8.0.2.1)
activejob (= 8.0.2.1)
activerecord (= 8.0.2.1)
activesupport (= 8.0.2.1)
activestorage (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activesupport (= 8.1.2)
marcel (~> 1.0)
activesupport (8.0.2.1)
activesupport (8.1.2)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
annotaterb (4.14.0)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
arabic-letter-connector (0.1.1)
ast (2.4.3)
aws-eventstream (1.4.0)
aws-partitions (1.1197.0)
aws-sdk-core (3.240.0)
aws-partitions (1.1209.0)
aws-sdk-core (3.241.4)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -87,44 +100,38 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sdk-kms (1.121.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.208.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-s3 (1.212.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sdk-secretsmanager (1.110.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-secretsmanager (1.128.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.3)
azure-storage-common (~> 2.0)
nokogiri (~> 1, >= 1.10.8)
azure-storage-common (2.0.4)
faraday (~> 1.0)
faraday_middleware (~> 1.0, >= 1.0.0.rc1)
net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8)
azure-blob (0.8.0)
cgi
rexml
base64 (0.3.0)
bcrypt (3.1.20)
benchmark (0.4.1)
better_html (2.1.1)
actionview (>= 6.0)
activesupport (>= 6.0)
bcrypt (3.1.21)
better_html (2.2.0)
actionview (>= 7.0)
activesupport (>= 7.0)
ast (~> 2.0)
erubi (~> 1.4)
parser (>= 2.4)
smart_properties
bigdecimal (3.2.2)
bigdecimal (4.0.1)
bindex (0.8.1)
bootsnap (1.18.4)
bootsnap (1.21.1)
msgpack (~> 1.2)
brakeman (7.0.0)
brakeman (7.1.2)
racc
builder (3.3.0)
bullet (8.0.0)
bullet (8.1.0)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
camertron-eprun (1.1.1)
@@ -138,28 +145,29 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
cgi (0.5.1)
childprocess (5.1.0)
logger (~> 1.5)
chunky_png (1.4.0)
cldr-plurals-runtime-rb (1.1.0)
cmdparse (3.0.7)
coderay (1.1.3)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
crack (1.0.0)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crack (1.0.1)
bigdecimal
rexml
crass (1.0.6)
css_parser (1.21.0)
css_parser (1.21.1)
addressable
csv (3.3.2)
csv (3.3.5)
csv-safe (3.3.1)
csv (~> 3.0)
cuprite (0.15.1)
cuprite (0.17)
capybara (~> 3.0)
ferrum (~> 0.15.0)
date (3.4.1)
debug (1.10.0)
ferrum (~> 0.17.0)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
declarative (0.0.20)
@@ -169,20 +177,20 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise-two-factor (6.1.0)
activesupport (>= 7.0, < 8.1)
devise (~> 4.0)
railties (>= 7.0, < 8.1)
devise-two-factor (6.3.1)
activesupport (>= 7.0, < 8.2)
devise (>= 4.0, < 5.0)
railties (>= 7.0, < 8.2)
rotp (~> 6.0)
diff-lcs (1.5.1)
digest-crc (0.6.5)
diff-lcs (1.6.2)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
docile (1.4.1)
dotenv (3.1.7)
dotenv (3.2.0)
drb (2.2.3)
email_typo (0.2.3)
erb (5.0.2)
erb_lint (0.7.0)
erb (6.0.1)
erb_lint (0.9.0)
activesupport
better_html (>= 2.0.1)
parser (>= 2.7.1.4)
@@ -190,117 +198,101 @@ GEM
rubocop (>= 1)
smart_properties
erubi (1.13.1)
factory_bot (6.5.0)
activesupport (>= 5.0.0)
factory_bot_rails (6.4.4)
factory_bot (6.5.6)
activesupport (>= 6.1.0)
factory_bot_rails (6.5.1)
factory_bot (~> 6.5)
railties (>= 5.0.0)
faker (3.5.1)
railties (>= 6.1.0)
faker (3.6.0)
i18n (>= 1.8.11, < 2)
faraday (1.10.4)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-follow_redirects (0.3.0)
faraday (2.14.0)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3)
faraday-httpclient (1.0.1)
faraday-multipart (1.1.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
ferrum (0.15)
faraday-net_http (3.4.2)
net-http (~> 0.5)
ferrum (0.17.1)
addressable (~> 2.5)
base64 (~> 0.2)
concurrent-ruby (~> 1.1)
webrick (~> 1.7)
websocket-driver (~> 0.7)
ffi (1.17.1)
ffi (1.17.1-aarch64-linux-gnu)
ffi (1.17.1-aarch64-linux-musl)
ffi (1.17.1-arm64-darwin)
ffi (1.17.1-x86_64-linux-gnu)
ffi (1.17.1-x86_64-linux-musl)
foreman (0.88.1)
ffi (1.17.3)
ffi (1.17.3-aarch64-linux-gnu)
ffi (1.17.3-aarch64-linux-musl)
ffi (1.17.3-arm64-darwin)
ffi (1.17.3-x86_64-linux-gnu)
ffi (1.17.3-x86_64-linux-musl)
foreman (0.90.0)
thor (~> 1.4)
geom2d (0.4.1)
globalid (1.2.1)
globalid (1.3.0)
activesupport (>= 6.1)
google-apis-core (0.15.1)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
google-apis-core (1.0.2)
addressable (~> 2.8, >= 2.8.7)
faraday (~> 2.13)
faraday-follow_redirects (~> 0.3)
googleauth (~> 1.14)
mini_mime (~> 1.1)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.22.0)
retriable (~> 3.1)
google-apis-iamcredentials_v1 (0.26.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.49.0)
google-apis-storage_v1 (0.59.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.7.1)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.2.1)
google-cloud-env (2.3.1)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.4.0)
google-cloud-storage (1.54.0)
google-cloud-errors (1.5.0)
google-cloud-storage (1.58.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (~> 0.13)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (~> 0.38)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
mini_mime (~> 1.0)
google-logging-utils (0.1.0)
googleauth (1.12.2)
google-logging-utils (0.2.0)
googleauth (1.16.1)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 3.0)
jwt (>= 1.4, < 4.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
hashdiff (1.1.2)
hexapdf (1.4.0)
hashdiff (1.2.1)
hexapdf (1.5.0)
cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1)
strscan (>= 3.1.2)
htmlentities (4.3.4)
httpclient (2.8.3)
i18n (1.14.7)
htmlentities (4.4.2)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.13.0)
mini_magick (>= 4.9.5, < 5)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
io-console (0.8.1)
irb (1.15.2)
io-console (0.8.2)
irb (1.16.0)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.15.0)
jwt (2.9.3)
json (2.18.0)
jwt (3.1.2)
base64
language_server-protocol (3.17.0.5)
launchy (3.0.1)
launchy (3.1.1)
addressable (~> 2.8)
childprocess (~> 5.0)
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
letter_opener_web (3.0.0)
@@ -315,29 +307,28 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.24.1)
loofah (2.25.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.0.4)
matrix (0.4.2)
marcel (1.1.0)
matrix (0.4.3)
method_source (1.1.0)
mini_magick (4.13.2)
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.25.5)
msgpack (1.7.5)
multi_json (1.15.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
mysql2 (0.5.6)
net-http-persistent (4.0.5)
connection_pool (~> 2.2)
net-imap (0.5.9)
minitest (6.0.1)
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)
date
net-protocol
net-pop (0.1.2)
@@ -346,22 +337,19 @@ GEM
timeout
net-smtp (0.5.1)
net-protocol
nio4r (2.7.4)
nokogiri (1.18.9)
mini_portile2 (~> 2.8.2)
nio4r (2.7.5)
nokogiri (1.19.0-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.9-aarch64-linux-gnu)
nokogiri (1.19.0-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.9-aarch64-linux-musl)
nokogiri (1.19.0-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.9-arm64-darwin)
nokogiri (1.19.0-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-gnu)
nokogiri (1.19.0-x86_64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-musl)
racc (~> 1.4)
numo-narray (0.9.2.1)
oj (3.16.11)
numo-narray-alt (0.9.13)
oj (3.16.13)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
onnxruntime (0.10.1)
@@ -372,18 +360,25 @@ GEM
ffi
onnxruntime (0.10.1-x86_64-linux)
ffi
openssl (3.3.0)
openssl (4.0.0)
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.3)
package_json (0.1.0)
pagy (9.3.3)
package_json (0.2.0)
pagy (43.2.8)
json
yaml
parallel (1.27.0)
parser (3.3.9.0)
parser (3.3.10.1)
ast (~> 2.4.1)
racc
pg (1.5.9)
pp (0.6.2)
pg (1.6.3)
pg (1.6.3-aarch64-linux)
pg (1.6.3-aarch64-linux-musl)
pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl)
pp (0.6.3)
prettyprint
premailer (1.27.0)
addressable
@@ -393,23 +388,24 @@ GEM
actionmailer (>= 3)
net-smtp
premailer (~> 1.7, >= 1.7.9)
pretender (0.5.0)
actionpack (>= 6.1)
pretender (0.6.0)
actionpack (>= 7.1)
prettyprint (0.2.0)
prism (1.5.1)
pry (0.15.0)
prism (1.8.0)
pry (0.16.0)
coderay (~> 1.1)
method_source (~> 1.0)
reline (>= 0.6.0)
pry-rails (0.3.11)
pry (>= 0.13.0)
psych (5.2.6)
psych (5.3.1)
date
stringio
public_suffix (6.0.1)
puma (6.5.0)
public_suffix (7.0.2)
puma (7.2.0)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.3)
rack (3.2.4)
rack-proxy (0.7.7)
rack
rack-session (2.1.1)
@@ -417,22 +413,22 @@ GEM
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.2.1)
rackup (2.3.1)
rack (>= 3)
rails (8.0.2.1)
actioncable (= 8.0.2.1)
actionmailbox (= 8.0.2.1)
actionmailer (= 8.0.2.1)
actionpack (= 8.0.2.1)
actiontext (= 8.0.2.1)
actionview (= 8.0.2.1)
activejob (= 8.0.2.1)
activemodel (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
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)
bundler (>= 1.15.0)
railties (= 8.0.2.1)
railties (= 8.1.2)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@@ -440,30 +436,32 @@ GEM
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
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.0.1)
rails-i18n (8.1.0)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
rails_autolink (1.1.8)
actionview (> 3.1)
activesupport (> 3.1)
railties (> 3.1)
railties (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
railties (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.0)
rdoc (6.14.2)
rake (13.3.1)
rdoc (7.1.0)
erb
psych (>= 4.0.0)
redis-client (0.23.0)
tsort
redis-client (0.26.4)
connection_pool
regexp_parser (2.11.3)
reline (0.6.2)
reline (0.6.3)
io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)
@@ -471,35 +469,35 @@ GEM
uber (< 0.2.0)
request_store (1.7.0)
rack (>= 1.4)
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
responders (3.2.0)
actionpack (>= 7.0)
railties (>= 7.0)
retriable (3.1.2)
rexml (3.4.4)
rotp (6.3.0)
rouge (4.5.2)
rqrcode (2.2.0)
rouge (4.7.0)
rqrcode (3.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
rqrcode_core (1.2.0)
rspec-core (3.13.2)
rqrcode_core (~> 2.0)
rqrcode_core (2.1.0)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.3)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.2)
rspec-mocks (3.13.7)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (7.1.0)
actionpack (>= 7.0)
activesupport (>= 7.0)
railties (>= 7.0)
rspec-rails (8.0.2)
actionpack (>= 7.2)
activesupport (>= 7.2)
railties (>= 7.2)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.2)
rubocop (1.81.1)
rspec-support (3.13.6)
rubocop (1.82.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -507,86 +505,89 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-ast (>= 1.48.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.47.1)
rubocop-ast (1.49.0)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-performance (1.23.0)
rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails (2.27.0)
prism (~> 1.7)
rubocop-performance (1.26.1)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.34.3)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.52.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rspec (3.3.0)
rubocop (~> 1.61)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rspec (3.9.0)
lint_roller (~> 1.1)
rubocop (~> 1.81)
ruby-progressbar (1.13.0)
ruby-vips (2.2.2)
ruby-vips (2.3.0)
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rubyXL (3.4.33)
rubyXL (3.4.35)
nokogiri (>= 1.10.8)
rubyzip (>= 1.3.0)
rubyzip (2.3.2)
rubyzip (>= 3.2.2)
rubyzip (3.2.2)
securerandom (0.4.1)
semantic_range (3.1.0)
shakapacker (8.0.2)
shakapacker (9.5.0)
activesupport (>= 5.2)
package_json
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
sidekiq (7.3.7)
connection_pool (>= 2.3.0)
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
signet (0.19.0)
sidekiq (8.1.0)
connection_pool (>= 3.0.0)
json (>= 2.16.0)
logger (>= 1.7.0)
rack (>= 3.2.0)
redis-client (>= 0.26.0)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sqlite3 (2.5.0)
mini_portile2 (~> 2.8.0)
sqlite3 (2.5.0-aarch64-linux-gnu)
sqlite3 (2.5.0-aarch64-linux-musl)
sqlite3 (2.5.0-arm64-darwin)
sqlite3 (2.5.0-x86_64-linux-gnu)
sqlite3 (2.5.0-x86_64-linux-musl)
stringio (3.1.7)
strip_attributes (1.14.1)
sqlite3 (2.9.0-aarch64-linux-gnu)
sqlite3 (2.9.0-aarch64-linux-musl)
sqlite3 (2.9.0-arm64-darwin)
sqlite3 (2.9.0-x86_64-linux-gnu)
sqlite3 (2.9.0-x86_64-linux-musl)
stringio (3.2.0)
strip_attributes (2.0.1)
activemodel (>= 3.0, < 9.0)
strscan (3.1.5)
thor (1.4.0)
timeout (0.4.3)
strscan (3.1.7)
thor (1.5.0)
timeout (0.6.0)
trailblazer-option (0.1.2)
turbo-rails (2.0.11)
actionpack (>= 6.0.0)
railties (>= 6.0.0)
twitter_cldr (6.12.1)
tsort (0.2.0)
turbo-rails (2.0.21)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
twitter_cldr (6.14.0)
base64
camertron-eprun
cldr-plurals-runtime-rb (~> 1.1)
tzinfo
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2024.2)
tzinfo-data (1.2025.3)
tzinfo (>= 1.0.0)
uber (0.1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uniform_notifier (1.16.0)
unicode-emoji (4.2.0)
uniform_notifier (1.18.0)
uri (1.1.1)
useragent (0.16.11)
warden (1.2.9)
@@ -596,24 +597,24 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.24.0)
webmock (3.26.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.1)
webrick (1.9.2)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.3)
yaml (0.4.0)
zeitwerk (2.7.4)
PLATFORMS
aarch64-linux
aarch64-linux-musl
arm64-darwin
ruby
x86_64-linux
x86_64-linux-musl
@@ -622,7 +623,7 @@ DEPENDENCIES
arabic-letter-connector
aws-sdk-s3
aws-sdk-secretsmanager
azure-storage-blob
azure-blob
better_html
bootsnap
brakeman
@@ -649,8 +650,7 @@ DEPENDENCIES
jwt
letter_opener_web
lograge
mysql2
numo-narray
numo-narray-alt
oj
onnxruntime
pagy
@@ -678,6 +678,7 @@ DEPENDENCIES
simplecov
sqlite3
strip_attributes
trilogy!
turbo-rails
twitter_cldr
tzinfo-data
@@ -685,7 +686,7 @@ DEPENDENCIES
webmock
RUBY VERSION
ruby 3.4.2p28
ruby 4.0.1
BUNDLED WITH
2.5.3
4.0.3
@@ -0,0 +1,33 @@
# frozen_string_literal: true
class AccountCustomFieldsController < ApplicationController
before_action :load_account_config, only: :create
def create
authorize!(:create, Template)
@account_config.update!(account_config_params)
render json: @account_config.value
end
private
def load_account_config
@account_config =
AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY)
end
def account_config_params
params.permit(
value: [[:uuid, :name, :type,
:required, :readonly, :default_value,
:title, :description,
{ preferences: {},
default_value: [],
options: [%i[value uuid]],
validation: %i[message pattern min max step],
areas: [%i[x y w h cell_w option_uuid]] }]]
)
end
end
+1 -1
View File
@@ -3,7 +3,7 @@
module Api
class ApiBaseController < ActionController::API
include ActiveStorage::SetCurrent
include Pagy::Backend
include Pagy::Method
DEFAULT_LIMIT = 10
MAX_LIMIT = 100
@@ -188,7 +188,7 @@ module Api
message: %i[subject body],
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id, :reply_to, :go_to_last,
:require_phone_2fa, :require_email_2fa, :order, :invite_by,
:require_phone_2fa, :require_email_2fa, :order, :index, :invite_by,
{ metadata: {}, values: {}, roles: [], readonly_fields: [], message: %i[subject body],
fields: [:name, :uuid, :default_value, :value, :title, :description,
:readonly, :required, :validation_pattern, :invalid_message,
+3 -3
View File
@@ -4,7 +4,7 @@ class ApplicationController < ActionController::Base
BROWSER_LOCALE_REGEXP = /\A\w{2}(?:-\w{2})?/
include ActiveStorage::SetCurrent
include Pagy::Backend
include Pagy::Method
check_authorization unless: :devise_controller?
@@ -23,7 +23,7 @@ class ApplicationController < ActionController::Base
impersonates :user, with: ->(uuid) { User.find_by(uuid:) }
rescue_from Pagy::OverflowError do
rescue_from Pagy::RangeError do
redirect_to request.path
end
@@ -60,7 +60,7 @@ class ApplicationController < ActionController::Base
def pagy_auto(collection, **keyword_args)
if current_ability.can?(:manage, :countless)
pagy_countless(collection, **keyword_args)
pagy(:countless, collection, **keyword_args)
else
pagy(collection, **keyword_args)
end
@@ -13,7 +13,7 @@ class TemplatesDetectFieldsController < ApplicationController
documents = @template.schema_documents.preload(:blob)
documents = documents.where(uuid: params[:attachment_uuid]) if params[:attachment_uuid].present?
page_number = params[:page].present? ? params[:page].to_i : nil
page_number = params[:page].presence&.to_i
documents.each do |document|
io = StringIO.new(document.download)
@@ -32,7 +32,7 @@ class TimestampServerController < ApplicationController
uri = Addressable::URI.parse(url)
conn = Faraday.new(uri.origin) do |c|
c.basic_auth(uri.user, uri.password) if uri.password.present?
c.request :authorization, :basic, uri.user, uri.password if uri.password.present?
end
response = conn.post(uri.path, req.to_der,
@@ -15,7 +15,7 @@ class WebhookSettingsController < ApplicationController
@webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status])
@pagy, @webhook_events = pagy_countless(@webhook_events.order(id: :desc))
@pagy, @webhook_events = pagy(:countless, @webhook_events.order(id: :desc))
render :show
end
@@ -26,7 +26,7 @@ class WebhookSettingsController < ApplicationController
@webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status])
@pagy, @webhook_events = pagy_countless(@webhook_events.order(id: :desc))
@pagy, @webhook_events = pagy(:countless, @webhook_events.order(id: :desc))
end
def new; end
+2
View File
@@ -155,6 +155,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
this.app = createApp(TemplateBuilder, {
template: reactive(JSON.parse(this.dataset.template)),
customFields: reactive(JSON.parse(this.dataset.customFields || '[]')),
backgroundColor: '#faf7f5',
locale: this.dataset.locale,
withPhone: this.dataset.withPhone === 'true',
@@ -164,6 +165,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withFieldsDetection: this.dataset.withFieldsDetection === 'true',
editable: this.dataset.editable !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withCustomFields: true,
withPayment: this.dataset.withPayment === 'true',
isPaymentConnected: this.dataset.isPaymentConnected === 'true',
withFormula: this.dataset.withFormula === 'true',
+51 -6
View File
@@ -4,15 +4,26 @@ import { isValidSignatureCanvas } from './submission_form/validate_signature'
window.customElements.define('draw-signature', class extends HTMLElement {
connectedCallback () {
const scale = 3
this.canvas.width = this.canvas.parentNode.clientWidth * scale
this.canvas.height = this.canvas.parentNode.clientHeight * scale
this.canvas.getContext('2d').scale(scale, scale)
this.setCanvasSize()
this.pad = new SignaturePad(this.canvas)
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.canvas) return
const { width, height } = this.canvas
this.setCanvasSize()
if (this.canvas.width !== width || this.canvas.height !== height) {
this.redrawCanvas(width, height)
}
})
})
this.resizeObserver.observe(this.canvas.parentNode)
if (this.dataset.color) {
this.pad.penColor = this.dataset.color
}
@@ -57,6 +68,40 @@ window.customElements.define('draw-signature', class extends HTMLElement {
this.updateSubmitButtonVisibility()
}
disconnectedCallback () {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
}
setCanvasSize () {
const scale = 3
const width = this.canvas.parentNode.clientWidth
const height = this.canvas.parentNode.clientHeight
if (this.canvas.width !== width * scale || this.canvas.height !== height * scale) {
this.canvas.width = width * scale
this.canvas.height = height * scale
this.canvas.getContext('2d').scale(scale, scale)
}
}
redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight
const scaledData = this.pad.toData().map((stroke) => ({
...stroke,
points: stroke.points.map((p) => ({ ...p, x: p.x * sx, y: p.y * sy }))
}))
this.pad.fromData(scaledData)
}
}
updateSubmitButtonVisibility () {
if (this.pad.isEmpty()) {
this.submitButton.style.display = 'none'
@@ -64,6 +64,22 @@ export default class extends HTMLElement {
if (action === 'empty' || action === 'unchecked') return this.isEmpty(actual)
if (action === 'not_empty' || action === 'checked') return !this.isEmpty(actual)
if (['equal', 'not_equal', 'greater_than', 'less_than'].includes(action) && this.sourceEl?.getAttribute('type') === 'number') {
if (this.isEmpty(actual) || this.isEmpty(expected)) return false
const actualNumber = parseFloat(actual)
const expectedNumber = parseFloat(expected)
if (Number.isNaN(actualNumber) || Number.isNaN(expectedNumber)) return false
if (action === 'equal') return Math.abs(actualNumber - expectedNumber) < Number.EPSILON
if (action === 'not_equal') return Math.abs(actualNumber - expectedNumber) > Number.EPSILON
if (action === 'greater_than') return actualNumber > expectedNumber
if (action === 'less_than') return actualNumber < expectedNumber
return false
}
if (action === 'equal') {
const list = Array.isArray(actual) ? actual : [actual]
return list.filter((v) => v !== null && v !== undefined).map(String).includes(String(expected))
+52 -7
View File
@@ -5,17 +5,28 @@ export default targetable(class extends HTMLElement {
static [target.static] = ['canvas', 'input', 'clear', 'button']
async connectedCallback () {
const scale = 3
this.canvas.width = this.canvas.parentNode.clientWidth * scale
this.canvas.height = this.canvas.parentNode.clientHeight * scale
this.canvas.getContext('2d').scale(scale, scale)
const { default: SignaturePad } = await import('signature_pad')
this.setCanvasSize()
this.pad = new SignaturePad(this.canvas)
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.canvas) return
const { width, height } = this.canvas
this.setCanvasSize()
if (this.canvas.width !== width || this.canvas.height !== height) {
this.redrawCanvas(width, height)
}
})
})
this.resizeObserver.observe(this.canvas.parentNode)
this.clear.addEventListener('click', (e) => {
e.preventDefault()
@@ -47,4 +58,38 @@ export default targetable(class extends HTMLElement {
this.closest('form').requestSubmit()
}
disconnectedCallback () {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
}
setCanvasSize () {
const scale = 3
const width = this.canvas.parentNode.clientWidth
const height = this.canvas.parentNode.clientWidth / 2.5
if (this.canvas.width !== width * scale || this.canvas.height !== height * scale) {
this.canvas.width = width * scale
this.canvas.height = height * scale
this.canvas.getContext('2d').scale(scale, scale)
}
}
redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight
const scaledData = this.pad.toData().map((stroke) => ({
...stroke,
points: stroke.points.map((p) => ({ ...p, x: p.x * sx, y: p.y * sy }))
}))
this.pad.fromData(scaledData)
}
}
})
+2 -2
View File
@@ -182,8 +182,8 @@
</div>
<div
v-else-if="field.type === 'cells'"
class="w-full flex items-center"
:class="{ 'justify-end': field.preferences?.align === 'right', ...fontClasses }"
class="w-full flex"
:class="{ 'justify-end': field.preferences?.align === 'right', ...alignClasses, ...fontClasses }"
>
<div
v-for="(char, index) in modelValue"
+67 -18
View File
@@ -69,7 +69,7 @@
id="expand_form_button"
class="btn btn-neutral flex text-white absolute bottom-0 w-full mb-3 expand-form-button text-base"
style="width: 96%; margin-left: 2%"
@click.prevent="[isFormVisible = true, scrollIntoField(currentField)]"
@click.prevent="[isFormVisible = true, $nextTick(() => scrollIntoField(currentField))]"
>
<template v-if="['initials', 'signature'].includes(currentField.type)">
<IconWritingSign stroke-width="1.5" />
@@ -395,7 +395,9 @@
v-model="values[currentField.uuid]"
:reason="values[currentField.preferences?.reason_field_uuid]"
:field="currentField"
:values="values"
:previous-value="previousSignatureValueFor(currentField) || previousSignatureValue"
:touch-attachment-uuid="previousSignatureValue"
:with-typed-signature="withTypedSignature"
:remember-signature="rememberSignature"
:attachments-index="attachmentsIndex"
@@ -407,6 +409,7 @@
:submitter="submitter"
:show-field-names="showFieldNames"
@update:reason="values[currentField.preferences?.reason_field_uuid] = $event"
@touch-attachment="attachmentsIndex[previousSignatureValue] ? attachmentsIndex[previousSignatureValue].created_at = new Date() : null"
@attached="attachments.push($event)"
@start="scrollIntoField(currentField)"
@minimize="minimizeForm"
@@ -592,6 +595,18 @@ import AppearsOn from './appears_on'
import i18n from './i18n'
import { sanitizeUrl } from '@braintree/sanitize-url'
if (typeof URL.canParse !== 'function') {
URL.canParse = function (url, base) {
try {
const parsed = new URL(url, base)
return !!parsed
} catch {
return false
}
}
}
const isEmpty = (obj) => {
if (obj == null) return true
@@ -914,10 +929,12 @@ export default {
}, {})
},
attachmentConditionsIndex () {
const cache = {}
return this.schema.reduce((acc, item) => {
if (item.conditions?.length) {
if (item.conditions.every((c) => this.fieldsUuidIndex[c.field_uuid])) {
acc[item.attachment_uuid] = this.checkFieldConditions(item)
acc[item.attachment_uuid] = this.checkFieldConditions(item, cache)
} else {
acc[item.attachment_uuid] = true
}
@@ -984,7 +1001,9 @@ export default {
},
previousInitialsValue () {
if (this.reuseSignature !== false) {
const initialsField = [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
const initialsField = this.fields.findLast
? this.fields.findLast((field) => field.type === 'initials' && !!this.values[field.uuid])
: [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
return this.values[initialsField?.uuid]
} else {
@@ -1011,7 +1030,9 @@ export default {
return this.readonlyFields.filter((f) => f.conditions?.length)
},
readonlyFields () {
return this.fields.filter((f) => f.readonly && this.checkFieldConditions(f) && this.checkFieldDocumentsConditions(f))
const cache = {}
return this.fields.filter((f) => f.readonly && this.checkFieldConditions(f, cache) && this.checkFieldDocumentsConditions(f))
},
stepFields () {
const verificationFields = []
@@ -1066,10 +1087,12 @@ export default {
sortedFields.push(verificationFields.pop())
}
const cache = {}
return sortedFields.reduce((acc, f) => {
const prevStep = acc[acc.length - 1]
if (this.checkFieldConditions(f) && this.checkFieldDocumentsConditions(f)) {
if (this.checkFieldConditions(f, cache) && this.checkFieldDocumentsConditions(f)) {
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox' && !f.description) {
prevStep.push(f)
} else {
@@ -1081,7 +1104,9 @@ export default {
}, [])
},
formulaFields () {
return this.fields.filter((f) => f.preferences?.formula && f.type !== 'payment' && this.checkFieldConditions(f) && this.checkFieldDocumentsConditions(f))
const cache = {}
return this.fields.filter((f) => f.preferences?.formula && f.type !== 'payment' && this.checkFieldConditions(f, cache) && this.checkFieldDocumentsConditions(f))
},
attachmentsIndex () {
return this.attachments.reduce((acc, a) => {
@@ -1143,7 +1168,9 @@ export default {
this.currentStep = Math.max(stepIndex, 0)
} else if (this.goToLast) {
const requiredEmptyStepIndex = this.stepFields.indexOf(this.stepFields.find((fields) => fields.some((f) => f.required && !this.submittedValues[f.uuid])))
const lastFilledStepIndex = this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))) + 1
const lastFilledStepIndex = this.stepFields.indexOf(this.stepFields.findLast
? this.stepFields.findLast((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))
: [...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))) + 1
const indexesList = [this.stepFields.length - 1]
@@ -1211,27 +1238,33 @@ export default {
return true
}
},
checkFieldConditions (field) {
checkFieldConditions (field, cache = {}) {
if (cache[field.uuid] !== undefined) {
return cache[field.uuid]
}
cache[field.uuid] = true
if (field.conditions?.length) {
const result = field.conditions.reduce((acc, cond) => {
if (cond.operation === 'or') {
acc.push(acc.pop() || this.checkFieldCondition(cond))
acc.push(acc.pop() || this.checkFieldCondition(cond, cache))
} else {
acc.push(this.checkFieldCondition(cond))
acc.push(this.checkFieldCondition(cond, cache))
}
return acc
}, [])
return !result.includes(false)
} else {
return true
cache[field.uuid] = !result.includes(false)
}
return cache[field.uuid]
},
checkFieldCondition (condition) {
checkFieldCondition (condition, cache = {}) {
const field = this.fieldsUuidIndex[condition.field_uuid]
if (['not_empty', 'checked', 'equal', 'contains'].includes(condition.action) && field && !this.checkFieldConditions(field)) {
if (['not_empty', 'checked', 'equal', 'contains', 'greater_than', 'less_than'].includes(condition.action) && field && !this.checkFieldConditions(field, cache)) {
return false
}
@@ -1241,6 +1274,22 @@ export default {
return isEmpty(this.values[condition.field_uuid] ?? defaultValue)
} else if (['not_empty', 'checked'].includes(condition.action)) {
return !isEmpty(this.values[condition.field_uuid] ?? defaultValue)
} else if (field?.type === 'number' && ['equal', 'not_equal', 'greater_than', 'less_than'].includes(condition.action)) {
const value = this.values[condition.field_uuid] ?? defaultValue
if (isEmpty(value) || isEmpty(condition.value)) return false
const actual = parseFloat(value)
const expected = parseFloat(condition.value)
if (Number.isNaN(actual) || Number.isNaN(expected)) return false
if (condition.action === 'equal') return Math.abs(actual - expected) < Number.EPSILON
if (condition.action === 'not_equal') return Math.abs(actual - expected) > Number.EPSILON
if (condition.action === 'greater_than') return actual > expected
if (condition.action === 'less_than') return actual < expected
return false
} else if (['equal', 'contains'].includes(condition.action) && field) {
if (field.options) {
const option = field.options.find((o) => o.uuid === condition.value)
@@ -1341,9 +1390,9 @@ export default {
},
previousSignatureValueFor (field) {
if (this.reuseSignature !== false) {
const signatureField = [...this.fields].reverse().find((f) =>
f.type === 'signature' && field.preferences?.format === f.preferences?.format && !!this.values[f.uuid]
)
const signatureField = this.fields.findLast
? this.fields.findLast((f) => f.type === 'signature' && field.preferences?.format === f.preferences?.format && !!this.values[f.uuid])
: [...this.fields].reverse().find((f) => f.type === 'signature' && field.preferences?.format === f.preferences?.format && !!this.values[f.uuid])
return this.values[signatureField?.uuid]
} else {
+7 -3
View File
@@ -497,10 +497,10 @@ export default {
body: JSON.stringify(payload)
})
if (!resp.ok) throw new Error('Failed to start KBA')
const data = await resp.json()
if (!resp.ok) throw new Error(data.error || 'Failed to start KBA')
if (data.result && data.result.action === 'FAIL') {
if (data.result.detail === 'NO MATCH') {
throw new Error('Unfortunately, we were unable to start Knowledge Based Authentication with the details provided. Please review and confirm that all your personal details are correct.')
@@ -555,7 +555,11 @@ export default {
const data = await resp.json()
if (data.result?.action !== 'PASS') {
this.error = 'Knowledge Based Authentication Failed - make sure you provide correct answers for the Knowledge Based authentication.'
if (data.result?.issues?.length) {
this.error = `Knowledge Based Authentication Failed - make sure you provide correct details for the Knowledge Based authentication: ${data.result.issues.join(', ')}`
} else {
this.error = 'Knowledge Based Authentication Failed - make sure you provide correct answers for the Knowledge Based authentication.'
}
throw new Error('Knowledge Based Authentication Failed')
}
@@ -127,6 +127,12 @@
type="hidden"
:name="`values[${field.uuid}]`"
>
<input
v-if="isTouchAttachment"
:value="touchAttachmentUuid"
type="hidden"
name="touch_attachment_uuid"
>
<img
v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
@@ -175,9 +181,7 @@
v-if="isShowQr"
class="top-0 bottom-0 right-0 left-0 absolute bg-base-content/10 rounded-2xl"
>
<div
class="absolute top-1.5 right-1.5 md:tooltip"
>
<div class="absolute top-1.5 right-1.5">
<a
href="#"
class="btn btn-sm btn-circle btn-normal btn-outline"
@@ -336,6 +340,10 @@ export default {
type: Object,
required: true
},
values: {
type: Object,
required: true
},
requireSigningReason: {
type: Boolean,
required: false,
@@ -390,6 +398,11 @@ export default {
required: false,
default: ''
},
touchAttachmentUuid: {
type: String,
required: false,
default: ''
},
reason: {
type: String,
required: false,
@@ -401,13 +414,14 @@ export default {
default: ''
}
},
emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason'],
emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason', 'touch-attachment'],
data () {
return {
isSignatureStarted: false,
isShowQr: false,
isOtherReason: false,
isUsePreviousValue: true,
isTouchAttachment: false,
isTextSignature: this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload',
uploadImageInputKey: Math.random().toString()
}
@@ -448,14 +462,7 @@ export default {
}
},
async mounted () {
this.$nextTick(() => {
if (this.$refs.canvas) {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3
this.$refs.canvas.getContext('2d').scale(scale, scale)
}
})
this.$nextTick(() => this.setCanvasSize())
if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas)
@@ -470,13 +477,10 @@ export default {
this.$emit('start')
})
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
this.intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3
this.$refs.canvas.getContext('2d').scale(scale, scale)
this.setCanvasSize()
this.intersectionObserver?.disconnect()
}
@@ -484,13 +488,62 @@ export default {
})
this.intersectionObserver.observe(this.$refs.canvas)
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.$refs.canvas) return
const { width, height } = this.$refs.canvas
this.setCanvasSize()
if (this.$refs.canvas.width !== width || this.$refs.canvas.height !== height) {
this.redrawCanvas(width, height)
}
})
})
this.resizeObserver.observe(this.$refs.canvas.parentNode)
}
},
beforeUnmount () {
this.intersectionObserver?.disconnect()
this.resizeObserver?.disconnect()
this.stopCheckSignature()
},
methods: {
setCanvasSize () {
const canvas = this.$refs.canvas
if (canvas) {
const width = canvas.parentNode.clientWidth
const height = width / 3
if (canvas.width !== width * scale || canvas.height !== height * scale) {
canvas.width = width * scale
canvas.height = height * scale
canvas.getContext('2d').scale(scale, scale)
}
}
},
redrawCanvas (oldWidth, oldHeight) {
const canvas = this.$refs.canvas
if (this.pad && !this.isTextSignature && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
const sx = canvas.width / oldWidth
const sy = canvas.height / oldHeight
const scaledData = this.pad.toData().map((stroke) => ({
...stroke,
points: stroke.points.map((p) => ({ ...p, x: p.x * sx, y: p.y * sy }))
}))
this.pad.fromData(scaledData)
} else if (this.isTextSignature && this.$refs.textInput) {
this.updateWrittenSignature({ target: { value: this.$refs.textInput.value } })
}
},
remove () {
this.$emit('update:model-value', '')
@@ -694,6 +747,13 @@ export default {
},
async submit () {
if (this.modelValue || this.computedPreviousValue) {
if (this.touchAttachmentUuid && this.computedPreviousValue === this.touchAttachmentUuid && !Object.values(this.values).includes(this.touchAttachmentUuid)) {
this.isTouchAttachment = true
this.$emit('touch-attachment', this.touchAttachmentUuid)
} else {
this.isTouchAttachment = false
}
if (this.computedPreviousValue) {
this.$emit('update:model-value', this.computedPreviousValue)
}
@@ -119,6 +119,7 @@ export default {
docId: this.eidEasyData.doc_id,
language: this.locale,
countryCode: this.countryCode,
sandbox: ['demo.docuseal.tech'].includes(location.host),
enabledMethods: {
signature: this.eidEasyData.available_methods
},
+23 -4
View File
@@ -134,6 +134,7 @@
@focusout="maybeBlurSettings"
>
<FieldSettings
v-if="isMobile"
:field="field"
:default-field="defaultField"
:editable="editable"
@@ -145,9 +146,17 @@
@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>
@@ -250,7 +259,8 @@
</span>
<div
v-else-if="field.type === 'cells' && field.default_value"
class="w-full flex items-center"
class="w-full flex"
:class="fontClasses"
>
<div
v-for="(char, index) in field.default_value"
@@ -332,6 +342,7 @@
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="save"
@close="isShowFormulaModal = false"
/>
</Teleport>
@@ -344,6 +355,7 @@
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="save"
@close="isShowFontModal = false"
/>
</Teleport>
@@ -355,6 +367,7 @@
:item="field"
:build-default-name="buildDefaultName"
:default-field="defaultField"
@save="save"
@close="isShowConditionsModal = false"
/>
</Teleport>
@@ -367,6 +380,7 @@
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="save"
@close="isShowDescriptionModal = false"
/>
</Teleport>
@@ -399,7 +413,7 @@ export default {
FieldSubmitter,
IconX
},
inject: ['template', 'save', 't', 'isInlineSize', 'selectedAreasRef', 'isCmdKeyRef'],
inject: ['template', 'save', 't', 'isInlineSize', 'selectedAreasRef', 'isCmdKeyRef', 'getFieldTypeIndex'],
props: {
area: {
type: Object,
@@ -465,13 +479,18 @@ export default {
required: false,
default: null
},
isMobile: {
type: Boolean,
required: false,
default: false
},
isSelectMode: {
type: Boolean,
required: false,
default: false
}
},
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to'],
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field'],
data () {
return {
isShowFormulaModal: false,
@@ -577,7 +596,7 @@ export default {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
defaultName () {
return this.buildDefaultName(this.field, this.template.fields)
return this.buildDefaultName(this.field)
},
fontClasses () {
if (!this.field.preferences) {
+206 -33
View File
@@ -20,8 +20,9 @@
/>
<DragPlaceholder
ref="dragPlaceholder"
:field="fieldsDragFieldRef.value || toRaw(dragField)"
:field="customDragFieldRef.value || fieldsDragFieldRef.value || toRaw(dragField)"
:is-field="template.fields.includes(fieldsDragFieldRef.value)"
:is-custom="!!customDragFieldRef.value"
:is-default="defaultFields.includes(toRaw(dragField))"
:is-required="defaultRequiredFields.includes(toRaw(dragField))"
/>
@@ -362,7 +363,7 @@
:is-drag="!!dragField"
:input-mode="inputMode"
:default-fields="[...defaultRequiredFields, ...defaultFields]"
:allow-draw="!onlyDefinedFields || drawField"
:allow-draw="!onlyDefinedFields || drawField || drawCustomField"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:data-document-uuid="document.uuid"
@@ -371,17 +372,20 @@
:with-field-placeholder="withFieldPlaceholder"
:draw-field="drawField"
:draw-field-type="drawFieldType"
:draw-custom-field="drawCustomField"
:editable="editable"
:is-mobile="isMobile"
:base-url="baseUrl"
:with-fields-detection="withFieldsDetection"
@draw="[onDraw($event), withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]"
@draw="[onDraw($event), withSelectedFieldType ? '' : drawFieldType = '', drawCustomField = null, showDrawField = false]"
@drop-field="onDropfield"
@remove-area="removeArea"
@paste-field="pasteField"
@copy-field="copyField"
@add-custom-field="addCustomField"
@set-draw="[drawField = $event.field, drawOption = $event.option]"
@copy-selected-areas="copySelectedAreas"
@delete-selected-areas="deleteSelectedAreas"
@align-selected-areas="alignSelectedAreas"
@autodetect-fields="detectFieldsForPage"
/>
<DocumentControls
@@ -436,15 +440,15 @@
v-if="withFieldsList && !isMobile"
id="fields_list_container"
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block fields-list-container"
:class="drawField ? 'overflow-hidden' : 'overflow-y-auto overflow-x-hidden'"
:class="drawField || drawCustomField ? 'overflow-hidden' : 'overflow-y-auto overflow-x-hidden'"
>
<div
v-if="showDrawField || drawField"
v-if="showDrawField || drawField || drawCustomField"
class="sticky inset-0 h-full z-20"
:style="{ backgroundColor }"
>
<div class="bg-base-200 rounded-lg p-5 text-center space-y-4 draw-field-container">
<p v-if="(drawField?.type || drawFieldType) === 'strikethrough'">
<p v-if="(drawField?.type || drawFieldType || drawCustomField?.type) === 'strikethrough'">
{{ t('draw_strikethrough_the_document') }}
</p>
<p v-else>
@@ -458,10 +462,10 @@
{{ t('cancel') }}
</button>
<a
v-if="!drawField && !drawOption && !['stamp', 'signature', 'initials', 'heading', 'strikethrough'].includes(drawField?.type || drawFieldType)"
v-if="!drawField && !drawOption && !['stamp', 'signature', 'initials', 'heading', 'strikethrough'].includes(drawField?.type || drawFieldType || drawCustomField?.type)"
href="#"
class="link block mt-3 text-sm"
@click.prevent="[addField(drawFieldType), drawField = null, drawOption = null, withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]"
@click.prevent="drawCustomField ? addCustomFieldWithoutDraw() : [addField(drawFieldType), drawField = null, drawOption = null, withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]"
>
{{ t('or_add_field_without_drawing') }}
</a>
@@ -477,6 +481,8 @@
:with-help="withHelp"
:default-submitters="defaultSubmitters"
:draw-field-type="drawFieldType"
:custom-fields="customFields"
:with-custom-fields="withCustomFields"
:with-fields-search="withFieldsSearch"
:default-fields="[...defaultRequiredFields, ...defaultFields]"
:template="template"
@@ -493,6 +499,7 @@
@set-draw="[drawField = $event.field, drawOption = $event.option]"
@select-submitter="selectedSubmitter = $event"
@set-draw-type="[drawFieldType = $event, showDrawField = true]"
@set-draw-custom-field="[drawCustomField = $event, showDrawField = true]"
@set-drag="dragField = $event"
@set-drag-placeholder="$refs.dragPlaceholder.dragPlaceholder = $event"
@change-submitter="selectedSubmitter = $event"
@@ -632,12 +639,15 @@ export default {
isPaymentConnected: this.isPaymentConnected,
withFormula: this.withFormula,
withConditions: this.withConditions,
withCustomFields: this.withCustomFields,
isInlineSize: this.isInlineSize,
defaultDrawFieldType: this.defaultDrawFieldType,
selectedAreasRef: computed(() => this.selectedAreasRef),
fieldsDragFieldRef: computed(() => this.fieldsDragFieldRef),
customDragFieldRef: computed(() => this.customDragFieldRef),
isSelectModeRef: computed(() => this.isSelectModeRef),
isCmdKeyRef: computed(() => this.isCmdKeyRef)
isCmdKeyRef: computed(() => this.isCmdKeyRef),
getFieldTypeIndex: this.getFieldTypeIndex
}
},
props: {
@@ -705,6 +715,16 @@ export default {
required: false,
default: false
},
withCustomFields: {
type: Boolean,
required: false,
default: false
},
customFields: {
type: Array,
required: false,
default: () => []
},
withAddPageButton: {
type: Boolean,
required: false,
@@ -902,6 +922,7 @@ export default {
pendingFieldAttachmentUuids: [],
drawField: null,
drawFieldType: null,
drawCustomField: null,
drawOption: null,
dragField: null,
isDragFile: false
@@ -912,6 +933,7 @@ export default {
isSelectModeRef: () => ref(false),
isCmdKeyRef: () => ref(false),
fieldsDragFieldRef: () => ref(),
customDragFieldRef: () => ref(),
selectedAreasRef: () => ref([]),
language () {
return this.locale.split('-')[0].toLowerCase()
@@ -969,6 +991,18 @@ export default {
return areas
},
fieldTypeIndexMap () {
const map = {}
const typeCounters = {}
this.template.fields.forEach((f) => {
typeCounters[f.type] ||= 0
map[f.uuid] = typeCounters[f.type]
typeCounters[f.type]++
})
return map
},
isAllRequiredFieldsAdded () {
return !this.defaultRequiredFields?.some((f) => {
return !this.template.fields?.some((field) => field.name === f.name)
@@ -1062,6 +1096,33 @@ export default {
},
methods: {
toRaw,
addCustomField (field) {
return this.$refs.fields.addCustomField(field)
},
getFieldTypeIndex (field) {
return this.fieldTypeIndexMap[field.uuid]
},
addCustomFieldWithoutDraw () {
const customField = this.drawCustomField
const field = JSON.parse(JSON.stringify(customField))
field.uuid = v4()
field.submitter_uuid = this.selectedSubmitter.uuid
field.areas = []
if (field.options?.length) {
field.options = field.options.map(opt => ({ ...opt, uuid: v4() }))
}
delete field.conditions
this.insertField(field)
this.save()
this.drawCustomField = null
this.showDrawField = false
},
toggleSelectMode () {
this.isSelectModeRef.value = !this.isSelectModeRef.value
@@ -1116,27 +1177,6 @@ export default {
this.debouncedSave()
},
alignSelectedAreas (direction) {
const areas = this.selectedAreasRef.value
let targetValue
if (direction === 'left') {
targetValue = Math.min(...areas.map(a => a.x))
areas.forEach((area) => { area.x = targetValue })
} else if (direction === 'right') {
targetValue = Math.max(...areas.map(a => a.x + a.w))
areas.forEach((area) => { area.x = targetValue - area.w })
} else if (direction === 'top') {
targetValue = Math.min(...areas.map(a => a.y))
areas.forEach((area) => { area.y = targetValue })
} else if (direction === 'bottom') {
targetValue = Math.max(...areas.map(a => a.y + a.h))
areas.forEach((area) => { area.y = targetValue - area.h })
}
this.save()
},
download () {
this.isDownloading = true
@@ -1514,6 +1554,7 @@ export default {
clearDrawField () {
this.drawField = null
this.drawOption = null
this.drawCustomField = null
this.showDrawField = false
if (!this.withSelectedFieldType) {
@@ -1951,6 +1992,10 @@ export default {
}
},
onDraw ({ area, isTooSmall }) {
if (this.drawCustomField) {
return this.onDrawCustomField(area)
}
if (this.drawField) {
if (this.drawOption) {
const areaWithoutOption = this.drawField.areas?.find((a) => !a.option_uuid)
@@ -2016,7 +2061,9 @@ export default {
}
if (type === 'checkbox' && !this.drawFieldType && (this.template.fields[this.template.fields.length - 1]?.type === 'checkbox' || area.w)) {
const previousField = [...this.template.fields].reverse().find((f) => f.type === type)
const previousField = this.template.fields.findLast
? this.template.fields.findLast((f) => f.type === type)
: [...this.template.fields].reverse().find((f) => f.type === type)
const previousArea = previousField?.areas?.[previousField.areas.length - 1]
if (previousArea || area.w) {
@@ -2061,6 +2108,10 @@ export default {
return
}
if (this.customDragFieldRef.value) {
return this.dropCustomField(area)
}
const field = this.fieldsDragFieldRef.value || {
name: '',
uuid: v4(),
@@ -2153,10 +2204,132 @@ export default {
})
}
},
dropCustomField (area) {
const customField = this.customDragFieldRef.value
const customAreas = customField.areas || []
const field = JSON.parse(JSON.stringify(customField))
field.uuid = v4()
field.submitter_uuid = this.selectedSubmitter.uuid
field.areas = []
if (field.options?.length) {
field.options = field.options.map(opt => ({ ...opt, uuid: v4() }))
}
delete field.conditions
const dropX = (area.x - 6) / area.maskW
const dropY = area.y / area.maskH
if (customAreas.length > 0) {
const refArea = customAreas[0]
customAreas.forEach((customArea) => {
const fieldArea = {
x: dropX + (customArea.x - refArea.x),
y: dropY + (customArea.y - refArea.y) - (customArea.h / 2),
w: customArea.w,
h: customArea.h,
page: area.page,
attachment_uuid: area.attachment_uuid
}
if (customArea.cell_w) {
fieldArea.cell_w = customArea.cell_w
}
if (customArea.option_uuid && field.options?.length) {
const optionIndex = customField.options.findIndex(o => o.uuid === customArea.option_uuid)
if (optionIndex !== -1) {
fieldArea.option_uuid = field.options[optionIndex].uuid
}
}
field.areas.push(fieldArea)
})
} else {
const fieldArea = {
x: dropX,
y: dropY,
page: area.page,
attachment_uuid: area.attachment_uuid
}
this.assignDropAreaSize(fieldArea, field, area)
field.areas.push(fieldArea)
}
this.selectedAreasRef.value = [field.areas[0]]
this.insertField(field)
this.save()
document.activeElement?.blur()
},
onDrawCustomField (area) {
const customField = this.drawCustomField
const customAreas = customField.areas || []
const field = JSON.parse(JSON.stringify(customField))
field.uuid = v4()
field.submitter_uuid = this.selectedSubmitter.uuid
field.areas = []
if (field.options?.length) {
field.options = field.options.map(opt => ({ ...opt, uuid: v4() }))
}
delete field.conditions
const isClick = area.w === 0 || area.h === 0
const firstArea = {
x: area.x,
y: area.y,
w: area.w || customAreas[0]?.w,
h: area.h || customAreas[0]?.h,
page: area.page,
attachment_uuid: area.attachment_uuid
}
if (!firstArea.w || !firstArea.h) {
if (customAreas[0]) {
firstArea.w = customAreas[0].w
firstArea.h = customAreas[0].h
} else {
this.setDefaultAreaSize(firstArea, field.type)
}
}
if (isClick) {
firstArea.x -= firstArea.w / 2
firstArea.y -= firstArea.h / 2
}
if (field.options?.length) {
firstArea.option_uuid = field.options[0].uuid
}
field.areas.push(firstArea)
this.selectedAreasRef.value = [field.areas[0]]
this.insertField(field)
this.save()
this.drawCustomField = null
this.showDrawField = false
},
assignDropAreaSize (fieldArea, field, area) {
const fieldType = field.type || 'text'
const previousField = [...this.template.fields].reverse().find((f) => f.type === fieldType)
const previousField = this.template.fields.findLast
? this.template.fields.findLast((f) => f.type === fieldType)
: [...this.template.fields].reverse().find((f) => f.type === fieldType)
let baseArea
@@ -9,7 +9,7 @@
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title">
{{ t('condition') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item, template.fields) }}
{{ t('condition') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item) }}
</span>
<a
href="#"
@@ -83,7 +83,7 @@
class="text-base-content"
:selected="condition.field_uuid === f.uuid"
>
{{ f.name || buildDefaultName(f, template.fields) }}
{{ f.name || buildDefaultName(f) }}
</option>
</select>
<select
@@ -124,6 +124,16 @@
{{ option.value || `${t('option')} ${index + 1}` }}
</option>
</select>
<input
v-else-if="conditionField(condition)?.type === 'number' && ['equal', 'not_equal', 'greater_than', 'less_than'].includes(condition.action)"
v-model="condition.value"
type="number"
step="any"
class="input input-bordered input-sm w-full bg-white h-11 pl-4 text-base font-normal"
:class="{ 'text-gray-300': !condition.value }"
:placeholder="t('type_value')"
required
>
</div>
</div>
<a
@@ -154,7 +164,7 @@
<script>
export default {
name: 'ConditionModal',
inject: ['t', 'save', 'template', 'withConditions'],
inject: ['t', 'template', 'withConditions'],
props: {
item: {
type: Object,
@@ -169,18 +179,13 @@ export default {
type: Function,
required: true
},
withClickSaveEvent: {
type: Boolean,
required: false,
default: false
},
excludeFieldUuids: {
type: Array,
required: false,
default: () => []
}
},
emits: ['close', 'click-save'],
emits: ['close', 'save'],
data () {
return {
conditions: this.item.conditions?.[0] ? JSON.parse(JSON.stringify(this.item.conditions)) : [{}]
@@ -227,6 +232,8 @@ export default {
actions.push('equal', 'not_equal')
} else if (['multiple'].includes(field.type)) {
actions.push('contains', 'does_not_contain')
} else if (field.type === 'number') {
actions.push('not_empty', 'empty', 'equal', 'not_equal', 'greater_than', 'less_than')
} else {
actions.push('not_empty', 'empty')
}
@@ -244,12 +251,7 @@ export default {
delete this.item.conditions
}
if (this.withClickSaveEvent) {
this.$emit('click-save')
} else {
this.save()
}
this.$emit('save')
this.$emit('close')
}
}
@@ -6,18 +6,24 @@
<span
ref="contenteditable"
dir="auto"
:contenteditable="editable"
style="min-width: 2px"
:class="[iconInline ? 'inline' : 'block', hideIcon ? 'focus:block' : '']"
class="peer outline-none"
:contenteditable="editable && (!editableOnButton || isEditable)"
:data-placeholder="placeholder"
:data-empty="isEmpty"
:style="{ minWidth }"
:class="[iconInline ? (isEmpty ? 'inline-block' : 'inline') : 'block', hideIcon ? 'focus:block' : '']"
class="peer relative inline-block outline-none before:pointer-events-none before:absolute before:left-0 before:top-0 before:select-none before:whitespace-pre before:text-neutral-400 before:content-[attr(data-placeholder)] before:opacity-0 data-[empty=true]:before:opacity-100"
@paste.prevent="onPaste"
@keydown.enter.prevent="blurContenteditable"
@input="updateInputValue"
@cut="updateInputValue"
@focus="$emit('focus', $event)"
@blur="onBlur"
@click="editable && (!editableOnButton || isEditable) ? '' : $emit('click-contenteditable')"
>
{{ value }}
</span>
<span
v-if="withButton"
class="relative inline"
:class="{ 'peer-focus:hidden': hideIcon, 'peer-focus:invisible': !hideIcon }"
>
@@ -28,7 +34,7 @@
:class="{ invisible: !editable, 'absolute top-1/2 -translate-y-1/2': !iconInline || floatIcon, 'inline align-bottom': iconInline, 'left-0': floatIcon }"
:width="iconWidth + 4"
:stroke-width="iconStrokeWidth"
@click="[focusContenteditable(), selectOnEditClick && selectContent()]"
@click="clickEdit"
/>
</span>
</div>
@@ -49,6 +55,16 @@ export default {
required: false,
default: ''
},
placeholder: {
type: String,
required: false,
default: ''
},
withButton: {
type: Boolean,
required: false,
default: true
},
iconInline: {
type: Boolean,
required: false,
@@ -74,6 +90,16 @@ export default {
required: false,
default: false
},
editableOnButton: {
type: Boolean,
required: false,
default: false
},
minWidth: {
type: String,
required: false,
default: '2px'
},
editable: {
type: Boolean,
required: false,
@@ -85,21 +111,34 @@ export default {
default: 2
}
},
emits: ['update:model-value', 'focus', 'blur'],
emits: ['update:model-value', 'focus', 'blur', 'click-contenteditable'],
data () {
return {
isEditable: false,
inputValue: '',
value: ''
}
},
computed: {
isEmpty () {
return !this.inputValue.replace(/\u200B/g, '').replace(/\u00A0/g, ' ').trim()
}
},
watch: {
modelValue: {
handler (value) {
this.value = value
this.value = value || ''
},
immediate: true
}
},
mounted () {
this.updateInputValue()
},
methods: {
updateInputValue () {
this.inputValue = this.$refs.contenteditable?.textContent || ''
},
onPaste (e) {
const text = (e.clipboardData || window.clipboardData).getData('text/plain')
@@ -110,6 +149,20 @@ export default {
selection.getRangeAt(0).insertNode(document.createTextNode(text))
selection.collapseToEnd()
}
this.updateInputValue()
},
clickEdit (e) {
this.focusContenteditable()
if (this.selectOnEditClick) {
this.selectContent()
}
},
setText (text) {
this.$refs.contenteditable.innerText = text
this.updateInputValue()
},
selectContent () {
const el = this.$refs.contenteditable
@@ -126,13 +179,24 @@ export default {
},
onBlur (e) {
setTimeout(() => {
this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue
this.$emit('update:model-value', this.value)
if (this.$refs.contenteditable) {
this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue
this.$emit('update:model-value', this.value)
}
this.$emit('blur', e)
this.isEditable = false
}, 1)
},
focusContenteditable () {
this.$refs.contenteditable.focus()
this.isEditable = true
this.$nextTick(() => {
this.$refs.contenteditable.focus()
this.updateInputValue()
})
},
blurContenteditable () {
this.$refs.contenteditable.blur()
@@ -1,546 +0,0 @@
<template>
<div>
<div
v-if="!isShowFormulaModal && !isShowFontModal && !isShowConditionsModal && !isShowDescriptionModal"
ref="menu"
class="fixed z-50 p-1 bg-white shadow-lg rounded-lg border border-base-300 min-w-[170px] cursor-default"
:style="menuStyle"
@mousedown.stop
@pointerdown.stop
>
<label
v-if="showRequired"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
:checked="isRequired"
type="checkbox"
class="toggle toggle-xs"
@change="handleToggleRequired($event.target.checked)"
@click.stop
>
<span>{{ t('required') }}</span>
</label>
<label
v-if="showReadOnly"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
:checked="isReadOnly"
type="checkbox"
class="toggle toggle-xs"
@change="handleToggleReadOnly($event.target.checked)"
@click.stop
>
<span>{{ t('read_only') }}</span>
</label>
<hr
v-if="(showRequired || showReadOnly) && (showFont || showDescription || showCondition || showFormula)"
class="my-1 border-base-300"
>
<button
v-if="showFont && !isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openFontModal"
>
<IconTypography class="w-4 h-4" />
<span>{{ t('font') }}</span>
</button>
<button
v-if="showDescription"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openDescriptionModal"
>
<IconInfoCircle class="w-4 h-4" />
<span>{{ t('description') }}</span>
</button>
<button
v-if="showCondition && !isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openConditionModal"
>
<IconRouteAltLeft class="w-4 h-4" />
<span>{{ t('condition') }}</span>
</button>
<button
v-if="showFormula"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openFormulaModal"
>
<IconMathFunction class="w-4 h-4" />
<span>{{ t('formula') }}</span>
</button>
<hr
v-if="((showFont && !isMultiSelection) || showDescription || (showCondition && !isMultiSelection) || showFormula) && (showCopy || showDelete || showPaste)"
class="my-1 border-base-300"
>
<button
v-if="isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('align', 'left')"
>
<IconLayoutAlignLeft class="w-4 h-4" />
<span>{{ t('align_left') }}</span>
</button>
<button
v-if="isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('align', 'right')"
>
<IconLayoutAlignRight class="w-4 h-4" />
<span>{{ t('align_right') }}</span>
</button>
<button
v-if="isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('align', 'top')"
>
<IconLayoutAlignTop class="w-4 h-4" />
<span>{{ t('align_top') }}</span>
</button>
<button
v-if="isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('align', 'bottom')"
>
<IconLayoutAlignBottom class="w-4 h-4" />
<span>{{ t('align_bottom') }}</span>
</button>
<hr
v-if="isMultiSelection && (showFont || showCondition)"
class="my-1 border-base-300"
>
<button
v-if="showFont && isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openFontModal"
>
<IconTypography class="w-4 h-4" />
<span>{{ t('font') }}</span>
</button>
<button
v-if="showCondition && isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openConditionModal"
>
<IconRouteAltLeft class="w-4 h-4" />
<span>{{ t('condition') }}</span>
</button>
<hr
v-if="isMultiSelection"
class="my-1 border-base-300"
>
<button
v-if="showCopy"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm"
@click.stop="$emit('copy')"
>
<span class="flex items-center space-x-2">
<IconCopy class="w-4 h-4" />
<span>{{ t('copy') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘C' : 'Ctrl+C' }}</span>
</button>
<button
v-if="showDelete"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm text-red-600"
@click.stop="$emit('delete')"
>
<span class="flex items-center space-x-2">
<IconTrashX class="w-4 h-4" />
<span>{{ t('remove') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">Del</span>
</button>
<button
v-if="showPaste"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm"
:class="!hasClipboardData ? 'opacity-50 cursor-not-allowed' : 'hover:bg-base-100'"
:disabled="!hasClipboardData"
@click.stop="!hasClipboardData ? null : $emit('paste')"
>
<span class="flex items-center space-x-2">
<IconClipboard class="w-4 h-4" />
<span>{{ t('paste') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘V' : 'Ctrl+V' }}</span>
</button>
<button
v-if="showSelectFields"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm"
@click.stop="handleToggleSelectMode"
>
<span class="flex items-center space-x-2">
<IconClick
v-if="!isSelectModeRef.value"
class="w-4 h-4"
/>
<IconNewSection
v-else
class="w-4 h-4"
/>
<span>{{ isSelectModeRef.value ? t('draw_fields') : t('select_fields') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">Tab</span>
</button>
<hr
v-if="showAutodetectFields"
class="my-1 border-base-300"
>
<button
v-if="showAutodetectFields"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('autodetect-fields')"
>
<IconSparkles class="w-4 h-4" />
<span>{{ t('autodetect_fields') }}</span>
</button>
</div>
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:editable="editable"
:build-default-name="buildDefaultName"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="multiSelectField || field"
:area="contextMenu.area"
:editable="editable"
:build-default-name="buildDefaultName"
:with-click-save-event="isMultiSelection"
@click-save="handleSaveMultiSelectFontModal"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:item="multiSelectField || field"
:build-default-name="buildDefaultName"
:exclude-field-uuids="isMultiSelection ? selectedFields.map(f => f.uuid) : []"
:with-click-save-event="isMultiSelection"
@click-save="handleSaveMultiSelectConditionsModal"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:editable="editable"
:build-default-name="buildDefaultName"
@close="closeModal"
/>
</Teleport>
</div>
</template>
<script>
import { IconCopy, IconClipboard, IconTrashX, IconTypography, IconInfoCircle, IconRouteAltLeft, IconMathFunction, IconClick, IconNewSection, IconLayoutAlignLeft, IconLayoutAlignRight, IconLayoutAlignTop, IconLayoutAlignBottom, IconSparkles } from '@tabler/icons-vue'
import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import Field from './field'
import FieldType from './field_type.vue'
export default {
name: 'ContextMenu',
components: {
IconCopy,
IconClipboard,
IconTrashX,
IconTypography,
IconInfoCircle,
IconRouteAltLeft,
IconMathFunction,
IconClick,
IconNewSection,
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconLayoutAlignTop,
IconLayoutAlignBottom,
IconSparkles,
FormulaModal,
FontModal,
ConditionsModal,
DescriptionModal
},
inject: ['t', 'save', 'selectedAreasRef', 'isSelectModeRef'],
props: {
contextMenu: {
type: Object,
default: null,
required: true
},
field: {
type: Object,
default: null
},
editable: {
type: Boolean,
default: true
},
isMultiSelection: {
type: Boolean,
default: false
},
selectedAreas: {
type: Array,
default: () => []
},
template: {
type: Object,
default: null
},
withFieldsDetection: {
type: Boolean,
default: false
}
},
emits: ['copy', 'paste', 'delete', 'close', 'align', 'autodetect-fields'],
data () {
return {
isShowFormulaModal: false,
isShowFontModal: false,
isShowConditionsModal: false,
isShowDescriptionModal: false,
multiSelectField: null
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels,
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
selectedFields () {
if (!this.isMultiSelection) return []
return this.selectedAreasRef.value.map((area) => {
return this.template.fields.find((f) => f.areas?.includes(area))
}).filter(Boolean)
},
isMac () {
return (navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')
},
hasClipboardData () {
try {
const clipboard = localStorage.getItem('docuseal_clipboard')
if (clipboard) {
const data = JSON.parse(clipboard)
return Date.now() - data.timestamp < 3600000
}
return false
} catch {
return false
}
},
menuStyle () {
return {
left: this.contextMenu.x + 'px',
top: this.contextMenu.y + 'px'
}
},
showCopy () {
return !!this.contextMenu.area || this.isMultiSelection
},
showPaste () {
return !this.contextMenu.area && !this.isMultiSelection
},
showDelete () {
return !!this.contextMenu.area || this.isMultiSelection
},
showFont () {
if (this.isMultiSelection) return true
if (!this.field) return false
return ['text', 'number', 'date', 'select', 'heading'].includes(this.field.type)
},
showDescription () {
if (!this.field) return false
return !['stamp', 'heading', 'strikethrough'].includes(this.field.type)
},
showCondition () {
if (this.isMultiSelection) return true
if (!this.field) return false
return !['stamp', 'heading'].includes(this.field.type)
},
showFormula () {
if (!this.field) return false
return this.field.type === 'number'
},
showRequired () {
if (!this.field) return false
return !['phone', 'stamp', 'verification', 'strikethrough', 'heading'].includes(this.field.type)
},
showReadOnly () {
if (!this.field) return false
return ['text', 'number'].includes(this.field.type)
},
isRequired () {
return this.field?.required || false
},
isReadOnly () {
return this.field?.readonly || false
},
showSelectFields () {
return !this.contextMenu.area && !this.isMultiSelection
},
showAutodetectFields () {
return this.withFieldsDetection && this.editable && !this.contextMenu.area && !this.isMultiSelection
}
},
mounted () {
document.addEventListener('keydown', this.onKeyDown)
document.addEventListener('mousedown', this.handleClickOutside)
this.$nextTick(() => {
this.checkMenuPosition()
})
},
beforeUnmount () {
document.removeEventListener('keydown', this.onKeyDown)
document.removeEventListener('mousedown', this.handleClickOutside)
},
methods: {
buildDefaultName: Field.methods.buildDefaultName,
checkMenuPosition () {
if (this.$refs.menu) {
const rect = this.$refs.menu.getBoundingClientRect()
if (rect.bottom > window.innerHeight) {
this.contextMenu.y = this.contextMenu.y - rect.height
}
if (rect.right > window.innerWidth) {
this.contextMenu.x = this.contextMenu.x - rect.width
}
}
},
handleToggleRequired (value) {
if (this.field) {
this.field.required = value
this.save()
}
},
handleToggleReadOnly (value) {
if (this.field) {
this.field.readonly = value
this.save()
}
},
onKeyDown (event) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
this.$emit('close')
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v' && this.hasClipboardData) {
event.preventDefault()
event.stopPropagation()
this.$emit('paste')
}
},
handleClickOutside (event) {
if (this.$refs.menu && !this.$refs.menu.contains(event.target)) {
this.$emit('close')
}
},
openFontModal () {
if (this.isMultiSelection) {
this.multiSelectField = {
name: this.t('fields_selected').replace('{count}', this.selectedFields.length),
preferences: {}
}
const preferencesStrings = this.selectedFields.map((f) => JSON.stringify(f.preferences || {}))
if (preferencesStrings.every((s) => s === preferencesStrings[0])) {
this.multiSelectField.preferences = JSON.parse(preferencesStrings[0])
}
}
this.isShowFontModal = true
},
openDescriptionModal () {
this.isShowDescriptionModal = true
},
openConditionModal () {
if (this.isMultiSelection) {
this.multiSelectField = {
name: this.t('fields_selected').replace('{count}', this.selectedFields.length),
conditions: []
}
const conditionStrings = this.selectedFields.map((f) => JSON.stringify(f.conditions || []))
if (conditionStrings.every((s) => s === conditionStrings[0])) {
this.multiSelectField.conditions = JSON.parse(conditionStrings[0])
}
}
this.isShowConditionsModal = true
},
openFormulaModal () {
this.isShowFormulaModal = true
},
closeModal () {
this.isShowFormulaModal = false
this.isShowFontModal = false
this.isShowConditionsModal = false
this.isShowDescriptionModal = false
this.multiSelectField = null
this.$emit('close')
},
handleSaveMultiSelectFontModal () {
this.selectedFields.forEach((field) => {
field.preferences = { ...field.preferences, ...this.multiSelectField.preferences }
})
this.save()
this.closeModal()
},
handleSaveMultiSelectConditionsModal () {
this.selectedFields.forEach((field) => {
field.conditions = JSON.parse(JSON.stringify(this.multiSelectField.conditions))
})
this.save()
this.closeModal()
},
handleToggleSelectMode () {
this.isSelectModeRef.value = !this.isSelectModeRef.value
this.$emit('close')
}
}
}
</script>
@@ -0,0 +1,271 @@
<template>
<div
class="list-field group"
>
<div
class="border border-dashed border-base-300 hover:border-base-content/20 rounded relative group fields-list-item transition-colors"
:style="{ backgroundColor: backgroundColor }"
>
<div class="flex items-center justify-between relative group/contenteditable-container">
<div
v-if="!isNew"
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer"
@click="$emit('click', field)"
/>
<div
class="absolute top-0 bottom-0 left-0 flex items-center transition-all cursor-grab group-hover:bg-base-200/50"
@click="$emit('click', field)"
>
<IconDrag style="margin-left: 1px" />
</div>
<div class="flex items-center p-1 pl-6 space-x-1">
<FieldType
v-model="field.type"
:editable="false"
:button-width="20"
@click="$emit('click', field)"
/>
<Contenteditable
ref="name"
:model-value="field.name"
:placeholder="'Field Name'"
:icon-inline="true"
:icon-width="18"
:min-width="isNew ? '100px' : '2px'"
:icon-stroke-width="1.6"
:editable-on-button="!isNew"
:with-button="!isNew"
:class="{ 'cursor-pointer': !isNew }"
@click-contenteditable="$emit('click', field)"
@focus="onNameFocus"
@blur="onNameBlur"
/>
</div>
<div
class="flex items-center space-x-1"
>
<PaymentSettings
v-if="field.type === 'payment' && !isNew"
:field="field"
:with-condition="false"
:with-force-open="false"
@click-description="isShowDescriptionModal = true"
@click-formula="isShowFormulaModal = true"
/>
<span
v-else-if="!isNew"
class="dropdown dropdown-end field-settings-dropdown"
@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 }"
draggable="true"
@dragstart.prevent.stop
@click="closeDropdown"
>
<FieldSettings
:field="field"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:background-color="dropdownBgColor"
:with-areas="false"
:with-copy-to-all-pages="false"
:with-condition="false"
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@save="$emit('save')"
/>
</ul>
</span>
<button
v-if="isNew && !$refs.name"
class="relative text-base-content pr-1 field-save-button"
:title="t('save')"
@click="field.name ? $emit('save', field) : focusName()"
>
<IconCheck
:width="18"
:stroke-width="2"
/>
</button>
<button
class="relative group-hover:text-base-content pr-1 field-remove-button"
:class="isNew ? 'text-base-content' : 'text-transparent group-hover:text-base-content'"
:title="t('remove')"
@click="onRemoveClick"
>
<IconTrashX
:width="18"
:stroke-width="1.6"
/>
</button>
</div>
</div>
</div>
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowFormulaModal = false"
/>
</Teleport>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="field"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowFontModal = false"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowDescriptionModal = false"
/>
</Teleport>
</div>
</template>
<script>
import Contenteditable from './contenteditable'
import FieldType from './field_type'
import PaymentSettings from './payment_settings'
import FieldSettings from './field_settings'
import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import DescriptionModal from './description_modal'
import { IconTrashX, IconSettings, IconCheck } from '@tabler/icons-vue'
import IconDrag from './icon_drag'
export default {
name: 'CustomField',
components: {
Contenteditable,
IconSettings,
IconCheck,
FieldSettings,
PaymentSettings,
IconDrag,
FormulaModal,
FontModal,
DescriptionModal,
IconTrashX,
FieldType
},
inject: ['backgroundColor', 't'],
props: {
field: {
type: Object,
required: true
},
isNew: {
type: Boolean,
required: false,
default: false
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
withPrefillable: {
type: Boolean,
required: false,
default: false
}
},
emits: ['remove', 'save', 'click'],
data () {
return {
isShowFormulaModal: false,
isShowFontModal: false,
isShowDescriptionModal: false,
renderDropdown: false
}
},
computed: {
dropdownBgColor () {
return ['', null, 'transparent'].includes(this.backgroundColor) ? 'white' : this.backgroundColor
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
}
},
created () {
this.field.preferences ||= {}
},
mounted () {
if (this.isNew) {
this.focusName()
}
},
methods: {
buildDefaultName () {
return this.t('custom')
},
focusName () {
setTimeout(() => {
this.$refs.name.clickEdit()
}, 1)
},
onNameFocus (e) {
if (!this.field.name) {
setTimeout(() => {
this.$refs.name.$refs.contenteditable.innerText = ' '
}, 1)
}
},
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
},
onRemoveClick () {
if (this.isNew || window.confirm(this.t('are_you_sure_'))) {
this.$emit('remove', this.field)
}
},
onNameBlur (e) {
const text = this.$refs.name.$refs.contenteditable.innerText.trim()
if (text) {
this.field.name = text
} else {
this.$refs.name.setText(this.field.name)
}
if (this.field.name) {
this.$emit('save')
}
}
}
}
</script>
@@ -9,7 +9,7 @@
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title">
{{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field, template.fields) }}
{{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }}
</span>
<a
href="#"
@@ -67,7 +67,7 @@
<script>
export default {
name: 'DescriptionModal',
inject: ['t', 'save', 'template'],
inject: ['t', 'template'],
props: {
field: {
type: Object,
@@ -88,7 +88,7 @@ export default {
required: true
}
},
emits: ['close'],
emits: ['close', 'save'],
data () {
return {
description: this.field.description,
@@ -103,7 +103,7 @@ export default {
this.field.description = this.description
this.field.title = this.title
this.save()
this.$emit('save')
this.$emit('close')
},
resizeTextarea () {
+24 -3
View File
@@ -13,12 +13,14 @@
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:is-drag="isDrag"
:is-mobile="isMobile"
:with-field-placeholder="withFieldPlaceholder"
:default-fields="defaultFields"
:drag-field-placeholder="dragFieldPlaceholder"
:default-submitters="defaultSubmitters"
:draw-field="drawField"
:draw-field-type="drawFieldType"
:draw-custom-field="drawCustomField"
:selected-submitter="selectedSubmitter"
:total-pages="sortedPreviewImages.length"
:image="image"
@@ -28,9 +30,10 @@
@remove-area="$emit('remove-area', $event)"
@copy-field="$emit('copy-field', $event)"
@paste-field="$emit('paste-field', { ...$event, attachment_uuid: document.uuid })"
@add-custom-field="$emit('add-custom-field', $event)"
@set-draw="$emit('set-draw', $event)"
@copy-selected-areas="$emit('copy-selected-areas')"
@delete-selected-areas="$emit('delete-selected-areas')"
@align-selected-areas="$emit('align-selected-areas', $event)"
@autodetect-fields="$emit('autodetect-fields', $event)"
@scroll-to="scrollToArea"
@draw="$emit('draw', { area: {...$event.area, attachment_uuid: document.uuid }, isTooSmall: $event.isTooSmall })"
@@ -96,6 +99,11 @@ export default {
required: false,
default: () => []
},
isMobile: {
type: Boolean,
required: false,
default: false
},
allowDraw: {
type: Boolean,
required: false,
@@ -115,6 +123,11 @@ export default {
required: false,
default: null
},
drawCustomField: {
type: Object,
required: false,
default: null
},
baseUrl: {
type: String,
required: false,
@@ -131,7 +144,7 @@ export default {
default: false
}
},
emits: ['draw', 'drop-field', 'remove-area', 'paste-field', 'copy-field', 'copy-selected-areas', 'delete-selected-areas', 'align-selected-areas', 'autodetect-fields'],
emits: ['draw', 'drop-field', 'remove-area', 'paste-field', 'copy-field', 'copy-selected-areas', 'delete-selected-areas', 'autodetect-fields', 'add-custom-field', 'set-draw'],
data () {
return {
pageRefs: []
@@ -173,7 +186,15 @@ export default {
methods: {
scrollToArea (area) {
this.$nextTick(() => {
this.pageRefs[area.page].areaRefs.find((e) => e.area === area).$el.scrollIntoView({ behavior: 'smooth', block: 'center' })
const pageRef = this.pageRefs[area.page]
if (pageRef && pageRef.areaRefs) {
const areaRef = pageRef.areaRefs.find((e) => e.area === area)
if (areaRef && areaRef.$el) {
areaRef.$el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
})
},
setPageRefs (el) {
@@ -8,6 +8,13 @@
class="fixed z-20 pointer-events-none"
:editable="false"
/>
<CustomField
v-else-if="dragPlaceholder && isCustom && !isMask && field"
ref="dragPlaceholder"
:style="dragPlaceholderStyle"
:field="field"
class="fixed z-20 pointer-events-none opacity-90"
/>
<div
v-else-if="dragPlaceholder && (isDefault || isRequired) && !isMask && field"
ref="dragPlaceholder"
@@ -57,6 +64,7 @@
<script>
import Field from './field'
import CustomField from './custom_field'
import IconDrag from './icon_drag'
import FieldType from './field_type'
@@ -64,6 +72,7 @@ export default {
name: 'DragPlaceholder',
components: {
Field,
CustomField,
IconDrag
},
inject: ['t', 'backgroundColor'],
@@ -87,6 +96,11 @@ export default {
type: Boolean,
required: false,
default: false
},
isCustom: {
type: Boolean,
required: false,
default: false
}
},
data () {
+62 -15
View File
@@ -1,6 +1,6 @@
<template>
<div
class="list-field group mb-2"
class="list-field group"
>
<div
class="border border-base-300 rounded relative group fields-list-item"
@@ -18,7 +18,7 @@
:button-width="20"
:menu-classes="'mt-1.5'"
:menu-style="{ backgroundColor: dropdownBgColor }"
@update:model-value="[maybeUpdateOptions(), save()]"
@update:model-value="[maybeUpdateOptions(), $emit('save')]"
@click="scrollToFirstArea"
/>
<Contenteditable
@@ -92,9 +92,12 @@
<PaymentSettings
v-if="field.type === 'payment'"
:field="field"
:with-custom-fields="withCustomFields"
@click-condition="isShowConditionsModal = true"
@click-description="isShowDescriptionModal = true"
@add-custom-field="$emit('add-custom-field', $event)"
@click-formula="isShowFormulaModal = true"
@save="$emit('save')"
/>
<span
v-else
@@ -128,12 +131,15 @@
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:background-color="dropdownBgColor"
:with-custom-fields="withCustomFields"
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@click-condition="isShowConditionsModal = true"
@set-draw="$emit('set-draw', $event)"
@add-custom-field="$emit('add-custom-field', $event)"
@remove-area="removeArea"
@save="$emit('save')"
@scroll-to="$emit('scroll-to', $event)"
/>
</ul>
@@ -154,8 +160,10 @@
v-if="field.options && withOptions && (isExpandOptions || field.options.length < 5)"
ref="options"
class="border-t border-base-300 mx-2 pt-2 space-y-1.5"
draggable="true"
@dragover="onOptionDragover"
@drop="reorderOptions"
@dragstart.prevent.stop
>
<div
v-for="(option, index) in field.options"
@@ -184,7 +192,8 @@
required
:placeholder="`${t('option')} ${index + 1}`"
@keydown.enter="option.value ? addOptionAt(index + 1) : null"
@blur="save"
@blur="$emit('save')"
@paste="onOptionPaste($event, index)"
>
<button
:title="t('draw')"
@@ -208,7 +217,8 @@
dir="auto"
@keydown.enter="option.value ? addOptionAt(index + 1) : null"
@focus="maybeFocusOnOptionArea(option)"
@blur="save"
@blur="$emit('save')"
@paste="onOptionPaste($event, index)"
>
<button
v-if="editable && !defaultField"
@@ -259,6 +269,7 @@
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowFormulaModal = false"
/>
</Teleport>
@@ -271,6 +282,7 @@
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowFontModal = false"
/>
</Teleport>
@@ -282,6 +294,7 @@
:item="field"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowConditionsModal = false"
/>
</Teleport>
@@ -294,6 +307,7 @@
:editable="editable && !defaultField"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowDescriptionModal = false"
/>
</Teleport>
@@ -330,7 +344,7 @@ export default {
IconMathFunction,
FieldType
},
inject: ['template', 'save', 'backgroundColor', 'selectedAreasRef', 't', 'locale'],
inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale', 'getFieldTypeIndex'],
props: {
field: {
type: Object,
@@ -341,6 +355,11 @@ export default {
required: false,
default: null
},
withCustomFields: {
type: Boolean,
required: false,
default: false
},
withPrefillable: {
type: Boolean,
required: false,
@@ -362,12 +381,11 @@ export default {
default: true
}
},
emits: ['set-draw', 'remove', 'scroll-to'],
emits: ['set-draw', 'remove', 'scroll-to', 'save', 'add-custom-field'],
data () {
return {
isExpandOptions: false,
isNameFocus: false,
showPaymentModal: false,
isShowFormulaModal: false,
isShowFontModal: false,
isShowConditionsModal: false,
@@ -398,7 +416,7 @@ export default {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
defaultName () {
return this.buildDefaultName(this.field, this.template.fields)
return this.buildDefaultName(this.field)
},
areas () {
return this.field.areas || []
@@ -416,9 +434,9 @@ export default {
removeArea (area) {
this.field.areas.splice(this.field.areas.indexOf(area), 1)
this.save()
this.$emit('save')
},
buildDefaultName (field, fields) {
buildDefaultName (field) {
if (field.type === 'payment' && field.preferences?.price && !field.preferences?.formula) {
const { price, currency } = field.preferences || {}
@@ -429,7 +447,7 @@ export default {
return `${this.fieldNames[field.type]} ${formattedPrice}`
} else {
const typeIndex = fields.filter((f) => f.type === field.type).indexOf(field)
const typeIndex = this.getFieldTypeIndex(field)
if (field.type === 'heading' || field.type === 'strikethrough') {
return `${this.fieldNames[field.type]} ${typeIndex + 1}`
@@ -460,6 +478,35 @@ export default {
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
},
onOptionPaste (e, index) {
const text = e.clipboardData.getData('text')
if (text.includes('\n')) {
e.preventDefault()
this.isExpandOptions = true
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l)
if (lines.length > 0) {
const currentOption = this.field.options[index]
currentOption.value = (currentOption.value + lines[0]).trim()
const newOptions = lines.slice(1).map((line) => ({ value: line, uuid: v4() }))
this.field.options.splice(index + 1, 0, ...newOptions)
this.$nextTick(() => {
const inputs = this.$refs.options.querySelectorAll('input')
inputs[index + newOptions.length]?.focus()
})
this.$emit('save')
}
}
},
addOptionAt (index) {
this.isExpandOptions = true
@@ -473,7 +520,7 @@ export default {
inputs[insertAt]?.focus()
})
this.save()
this.$emit('save')
},
removeOption (option) {
this.field.options.splice(this.field.options.indexOf(option), 1)
@@ -484,7 +531,7 @@ export default {
this.field.areas.splice(this.field.areas.findIndex((a) => a.option_uuid === option.uuid), 1)
}
this.save()
this.$emit('save')
},
maybeUpdateOptions () {
delete this.field.default_value
@@ -526,7 +573,7 @@ export default {
this.isNameFocus = false
this.save()
this.$emit('save')
},
onOptionDragstart (event, option) {
this.optionDragRef = option
@@ -587,7 +634,7 @@ export default {
if (newOrder.length === this.field.options.length) {
this.field.options.splice(0, this.field.options.length, ...newOrder)
this.save()
this.$emit('save')
}
this.optionDragRef = null
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
<template>
<Teleport :to="modalContainerEl">
<div class="modal modal-open items-start !animate-none overflow-y-auto">
<div
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title">
{{ title }}
</span>
<a
href="#"
class="text-xl modal-close-button"
@click.prevent="$emit('close')"
>&times;</a>
</div>
<form @submit.prevent="$emit('save')">
<slot />
<button
class="base-button w-full mt-4 modal-save-button"
type="submit"
>
{{ t('save') }}
</button>
</form>
</div>
</div>
</Teleport>
</template>
<script>
export default {
name: 'ContextModal',
inject: ['t'],
props: {
title: {
type: String,
required: true
},
modalContainerEl: {
type: Element,
required: true
}
},
emits: ['close', 'save']
}
</script>
@@ -0,0 +1,147 @@
<template>
<div
class="relative"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@click.stop="isOpen ? close() : open()"
>
<span class="flex items-center space-x-2">
<component
:is="icon || 'span'"
class="w-4 h-4"
/>
<span>{{ label }}</span>
</span>
<IconChevronRight class="w-4 h-4" />
</button>
<div
v-if="isOpen"
ref="submenu"
class="absolute p-1 z-50 left-full bg-white shadow-lg rounded-lg border border-neutral-200 cursor-default"
style="min-width: 170px"
:style="submenuStyle"
:class="menuClass"
@click.stop
>
<slot>
<button
v-for="option in options"
:key="option.value"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between space-x-2 text-sm cursor-pointer"
@click="handleSelect(option.value)"
>
<span class="whitespace-nowrap">{{ option.label }}</span>
<IconCheck
v-if="modelValue === option.value"
class="w-4 h-4"
/>
</button>
</slot>
</div>
</div>
</template>
<script>
import { IconChevronRight, IconCheck } from '@tabler/icons-vue'
export default {
name: 'ContextSubmenu',
components: {
IconChevronRight,
IconCheck
},
props: {
icon: {
type: [Function],
required: false,
default: null
},
label: {
type: String,
required: true
},
options: {
type: Array,
default: () => []
},
modelValue: {
type: [String, Number],
default: null
},
menuClass: {
type: String,
default: ''
}
},
emits: ['select', 'update:modelValue'],
data () {
return {
isOpen: false,
topOffset: 0
}
},
computed: {
submenuStyle () {
return {
top: this.topOffset + 'px'
}
}
},
beforeUnmount () {
this.clearTimeout()
},
methods: {
handleMouseEnter () {
clearTimeout(this.closeTimeout)
this.openTimeout = setTimeout(() => this.open(), 200)
},
handleMouseLeave () {
clearTimeout(this.openTimeout)
this.closeTimeout = setTimeout(() => this.close(), 200)
},
open () {
this.clearTimeout()
this.isOpen = true
this.topOffset = 0
this.$nextTick(() => setTimeout(() => this.adjustPosition(), 0))
},
clearTimeout () {
if (this.openTimeout) {
clearTimeout(this.openTimeout)
}
if (this.closeTimeout) {
clearTimeout(this.closeTimeout)
}
},
close () {
this.clearTimeout()
this.isOpen = false
},
handleSelect (value) {
this.$emit('select', value)
this.$emit('update:modelValue', value)
},
adjustPosition () {
if (!this.$refs.submenu) return
const rect = this.$refs.submenu.getBoundingClientRect()
const overflow = rect.bottom - window.innerHeight
if (overflow > 0) {
this.topOffset = -overflow - 4
} else {
this.topOffset = 0
}
}
}
}
</script>
@@ -7,10 +7,10 @@
<select
:placeholder="t('method')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[field.preferences ||= {}, field.preferences.method = $event.target.value, save()]"
@change="[field.preferences ||= {}, field.preferences.method = $event.target.value, $emit('save')]"
>
<option
v-for="method in ['QeS', 'AeS']"
v-for="method in verificationMethods"
:key="method"
:value="method.toLowerCase()"
:selected="method.toLowerCase() === field.preferences?.method || (method === 'QeS' && !field.preferences?.method)"
@@ -26,32 +26,6 @@
{{ t('method') }}
</label>
</div>
<div
v-if="['cells'].includes(field.type)"
class="py-1.5 px-1 relative"
@click.stop
>
<select
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal bg-transparent"
@change="[field.preferences ||= {}, field.preferences.align = $event.target.value, save()]"
>
<option
v-for="value in ['left', 'right', field.type === 'cells' ? null : 'center'].filter(Boolean)"
:key="value"
:selected="field.preferences?.align ? value === field.preferences.align : value === 'left'"
:value="value"
>
{{ t(value) }}
</option>
</select>
<label
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('align') }}
</label>
</div>
<div
v-if="['select', 'radio'].includes(field.type) && !defaultField"
class="py-1.5 px-1 relative"
@@ -61,7 +35,7 @@
:placeholder="t('default_value')"
dir="auto"
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal bg-transparent"
@change="[field.default_value = $event.target.value, !field.default_value && delete field.default_value, save()]"
@change="[field.default_value = $event.target.value, !field.default_value && delete field.default_value, $emit('save')]"
>
<option
value=""
@@ -97,7 +71,7 @@
dir="auto"
:type="field.type"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
@blur="save"
@blur="$emit('save')"
>
<label
v-if="field.default_value"
@@ -159,7 +133,7 @@
:value="lengthValidation.min"
class="input input-bordered w-full input-xs h-7 !outline-0 bg-transparent"
@input="field.validation.pattern = `.{${$event.target.value || 0},${lengthValidation.max || ''}}`"
@blur="save"
@blur="$emit('save')"
>
<label
v-if="lengthValidation.min"
@@ -178,7 +152,7 @@
class="input input-bordered w-full input-xs h-7 !outline-0 bg-transparent"
:value="lengthValidation.max"
@input="field.validation.pattern = `.{${lengthValidation.min},${$event.target.value || ''}}`"
@blur="save"
@blur="$emit('save')"
>
<label
v-if="lengthValidation.max"
@@ -203,7 +177,7 @@
:value="field.validation?.min"
class="input input-bordered w-full input-xs h-7 !outline-0 bg-transparent"
@input="[field.validation ||= {}, $event.target.value ? field.validation.min = $event.target.value : delete field.validation.min]"
@blur="save"
@blur="$emit('save')"
>
<label
v-if="field.validation?.min"
@@ -222,7 +196,7 @@
class="input input-bordered w-full input-xs h-7 !outline-0 bg-transparent"
:value="field.validation?.max"
@input="[field.validation ||= {}, $event.target.value ? field.validation.max = $event.target.value : delete field.validation.max]"
@blur="save"
@blur="$emit('save')"
>
<label
v-if="field.validation?.max"
@@ -242,7 +216,7 @@
<select
:placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[field.preferences ||= {}, field.preferences.format = $event.target.value, save()]"
@change="[field.preferences ||= {}, field.preferences.format = $event.target.value, $emit('save')]"
>
<option
v-for="format in numberFormats"
@@ -272,7 +246,7 @@
:placeholder="t('regexp_validation')"
dir="auto"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
@blur="save"
@blur="$emit('save')"
>
<label
v-if="field.validation.pattern"
@@ -293,7 +267,7 @@
:placeholder="t('error_message')"
dir="auto"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
@blur="save"
@blur="$emit('save')"
>
<label
v-if="field.validation.message"
@@ -313,7 +287,7 @@
v-model="field.preferences.format"
:placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="save"
@change="$emit('save')"
>
<option
v-for="format in dateFormats"
@@ -339,7 +313,7 @@
<select
:placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[field.preferences.format = $event.target.value, save()]"
@change="[field.preferences.format = $event.target.value, $emit('save')]"
>
<option
value="any"
@@ -348,7 +322,7 @@
{{ t('any') }}
</option>
<option
v-for="type in ['drawn', 'typed', 'drawn_or_typed', 'drawn_or_upload', 'upload']"
v-for="type in signatureFormats"
:key="type"
:value="type"
:selected="field.preferences?.format === type"
@@ -374,7 +348,7 @@
type="checkbox"
:disabled="!editable || (defaultField && [true, false].includes(defaultField.required))"
class="toggle toggle-xs"
@change="[field.preferences ||= {}, field.preferences.with_signature_id = $event.target.checked, save()]"
@change="[field.preferences ||= {}, field.preferences.with_signature_id = $event.target.checked, $emit('save')]"
>
<span class="label-text">{{ t('signature_id') }}</span>
</label>
@@ -389,7 +363,7 @@
type="checkbox"
:disabled="!editable || (defaultField && [true, false].includes(defaultField.required))"
class="toggle toggle-xs"
@update:model-value="save"
@update:model-value="$emit('save')"
>
<span class="label-text">{{ t('required') }}</span>
</label>
@@ -403,7 +377,7 @@
:checked="field.preferences?.with_logo != false"
type="checkbox"
class="toggle toggle-xs"
@change="[field.preferences ||= {}, field.preferences.with_logo = field.preferences.with_logo == false, save()]"
@change="[field.preferences ||= {}, field.preferences.with_logo = field.preferences.with_logo == false, $emit('save')]"
>
<span class="label-text">{{ t('with_logo') }}</span>
</label>
@@ -417,7 +391,7 @@
v-model="field.default_value"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="[field.default_value = $event, field.readonly = $event, save()]"
@update:model-value="[field.default_value = $event, field.readonly = $event, $emit('save')]"
>
<span class="label-text">{{ t('checked') }}</span>
</label>
@@ -431,13 +405,13 @@
v-model="field.readonly"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="[field.default_value = $event ? '{{date}}' : null, field.readonly = $event, save()]"
@update:model-value="[field.default_value = $event ? '{{date}}' : null, field.readonly = $event, $emit('save')]"
>
<span class="label-text">{{ t('set_signing_date') }}</span>
</label>
</li>
<li
v-if="['text', 'number'].includes(field.type)"
v-if="['text', 'number', 'radio', 'multiple', 'select'].includes(field.type)"
@click.stop
>
<label class="cursor-pointer py-1.5">
@@ -446,13 +420,13 @@
type="checkbox"
class="toggle toggle-xs"
:disabled="!editable || (defaultField && [true, false].includes(defaultField.readonly))"
@update:model-value="save"
@update:model-value="$emit('save')"
>
<span class="label-text">{{ t('read_only') }}</span>
</label>
</li>
<li
v-if="withPrefillable && ['text', 'number', 'cells', 'date', 'checkbox', 'select', 'radio', 'phone'].includes(field['type'])"
v-if="withPrefillable && prefillableFieldTypes.includes(field['type'])"
@click.stop
>
<label class="cursor-pointer py-1.5">
@@ -461,7 +435,7 @@
type="checkbox"
:disabled="!editable || (defaultField && [true, false].includes(defaultField.prefillable))"
class="toggle toggle-xs"
@update:model-value="save"
@update:model-value="$emit('save')"
>
<span class="label-text">{{ t('prefillable') }}</span>
</label>
@@ -470,7 +444,7 @@
v-if="field.type != 'stamp'"
class="pb-0.5 mt-0.5"
>
<li v-if="['text', 'number', 'date', 'select', 'heading'].includes(field.type)">
<li v-if="['text', 'number', 'date', 'select', 'heading', 'cells'].includes(field.type)">
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-font')"
@@ -499,7 +473,7 @@
</label>
</li>
<li
v-if="field.type != 'stamp' && field.type != 'heading'"
v-if="withCondition && field.type != 'stamp' && field.type != 'heading'"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@@ -526,7 +500,10 @@
</span>
</label>
</li>
<hr class="pb-0.5 mt-0.5">
<hr
v-if="withCopyToAllPages || withAreas || withCustomFields"
class="pb-0.5 mt-0.5"
>
<template v-if="withAreas">
<li
v-for="(area, index) in sortedAreas"
@@ -564,7 +541,7 @@
</a>
</li>
</template>
<li v-if="field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells', 'stamp'].includes(field.type)">
<li v-if="withCopyToAllPages && field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells', 'stamp'].includes(field.type)">
<a
href="#"
class="text-sm py-1 px-2"
@@ -577,10 +554,23 @@
{{ t('copy_to_all_pages') }}
</a>
</li>
<li v-if="withCustomFields">
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('add-custom-field', field)"
>
<IconForms
:width="20"
:stroke-width="1.6"
/>
{{ t('save_as_custom_field') }}
</a>
</li>
</template>
<script>
import { IconRouteAltLeft, IconTypography, IconShape, IconX, IconMathFunction, IconNewSection, IconInfoCircle, IconCopy } from '@tabler/icons-vue'
import { IconRouteAltLeft, IconTypography, IconShape, IconX, IconMathFunction, IconNewSection, IconInfoCircle, IconCopy, IconForms } from '@tabler/icons-vue'
export default {
name: 'FieldSettings',
@@ -589,17 +579,33 @@ export default {
IconInfoCircle,
IconMathFunction,
IconRouteAltLeft,
IconForms,
IconCopy,
IconNewSection,
IconTypography,
IconX
},
inject: ['template', 'save', 't'],
inject: ['template', 't'],
props: {
field: {
type: Object,
required: true
},
withCondition: {
type: Boolean,
required: false,
default: true
},
withCustomFields: {
type: Boolean,
required: false,
default: false
},
withCopyToAllPages: {
type: Boolean,
required: false,
default: true
},
withSignatureId: {
type: Boolean,
required: false,
@@ -636,7 +642,7 @@ export default {
default: true
}
},
emits: ['set-draw', 'scroll-to', 'click-formula', 'click-description', 'click-condition', 'click-font', 'remove-area'],
emits: ['set-draw', 'scroll-to', 'click-formula', 'click-description', 'click-condition', 'click-font', 'remove-area', 'save', 'add-custom-field'],
data () {
return {
selectedValidation: ''
@@ -686,7 +692,7 @@ export default {
},
lengthValidation () {
if (this.field.validation?.pattern && this.selectedValidation !== 'custom') {
return this.field.validation.pattern.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups
return this.parseLengthPattern(this.field.validation.pattern)
} else {
return null
}
@@ -703,6 +709,15 @@ export default {
'^[a-zA-Z]+$': 'letters_only'
}
},
signatureFormats () {
return ['drawn', 'typed', 'drawn_or_typed', 'drawn_or_upload', 'upload']
},
verificationMethods () {
return ['QeS', 'AeS']
},
prefillableFieldTypes () {
return ['text', 'number', 'cells', 'date', 'checkbox', 'select', 'radio', 'phone']
},
sortedAreas () {
return (this.field.areas || []).sort((a, b) => {
return this.schemaAttachmentsIndexes[a.attachment_uuid] - this.schemaAttachmentsIndexes[b.attachment_uuid]
@@ -730,7 +745,7 @@ export default {
delete this.field.validation
}
this.save()
this.$emit('save')
},
copyToAllPages (field) {
const areaString = JSON.stringify(field.areas[0])
@@ -747,7 +762,7 @@ export default {
this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1])
this.save()
this.$emit('save')
},
formatNumber (number, format) {
if (format === 'comma') {
@@ -766,6 +781,9 @@ export default {
return number
}
},
parseLengthPattern (pattern) {
return pattern?.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups || null
},
formatDate (date, format) {
const monthFormats = {
M: 'numeric',
+225 -6
View File
@@ -15,7 +15,8 @@
</div>
<div
ref="fields"
class="fields mb-1 mt-2"
class="fields mt-2"
:class="{ 'mb-1': !withCustomFields || !customFields.length }"
@dragover.prevent="onFieldDragover"
@drop="fieldsDragFieldRef.value ? reorderFields() : null"
>
@@ -24,13 +25,17 @@
:key="field.uuid"
:data-uuid="field.uuid"
:field="field"
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)"
:type-index="getFieldTypeIndex(field)"
:editable="editable"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:default-field="defaultFieldsIndex[field.name]"
:draggable="editable"
:with-custom-fields="withCustomFields"
class="mb-1.5"
@add-custom-field="addCustomField"
@dragstart="[fieldsDragFieldRef.value = field, removeDragOverlay($event), setDragPlaceholder($event)]"
@save="save"
@dragend="[fieldsDragFieldRef.value = null, $emit('set-drag-placeholder', null)]"
@remove="removeField"
@scroll-to="$emit('scroll-to-area', $event)"
@@ -105,7 +110,92 @@
</div>
</div>
<div
v-if="editable && !onlyDefinedFields"
v-if="editable && withCustomFields && (customFields.length || newCustomField)"
class="tabs w-full mb-1.5"
>
<a
class="tab tab-bordered w-1/2 border-base-300"
:class="{ 'tab-active': !showCustomTab }"
:style="{ '--tab-border': showCustomTab ? '0px' : '0.5px' }"
@click="setFieldsTab('default')"
>{{ t('default') }}</a>
<a
class="tab tab-bordered w-1/2 border-base-300"
:class="{ 'tab-active': showCustomTab }"
:style="{ '--tab-border': showCustomTab ? '0.5px' : '0px' }"
@click="setFieldsTab('custom')"
>{{ t('custom') }}</a>
</div>
<div
v-if="showCustomTab && editable && (customFields.length || newCustomField)"
ref="customFields"
class="custom-fields"
@dragover.prevent="onCustomFieldDragover"
@drop="customDragFieldRef.value ? reorderCustomFields() : null"
>
<template v-if="isShowCustomFieldSearch">
<input
v-model="customFieldsSearch"
:placeholder="t('search_field')"
class="input input-ghost input-xs px-0 text-base mb-1 !outline-0 !rounded bg-transparent w-full"
>
<hr class="mb-2">
</template>
<div
ref="customFieldsList"
class="overflow-auto relative"
:style="{
maxHeight: isShowCustomFieldSearch ? '320px' : '',
minHeight: '320px'
}"
>
<div
v-if="!filteredCustomFields.length && customFieldsSearch"
class="top-0 bottom-0 text-center absolute flex items-center justify-center w-full flex-col"
>
<div>
{{ t('field_not_found') }}
</div>
<a
href="#"
class="link"
@click.prevent="customFieldsSearch = ''"
>
{{ t('clear') }}
</a>
</div>
<CustomField
v-if="newCustomField"
:key="newCustomField.uuid"
ref="newCustomField"
:data-uuid="newCustomField.uuid"
:is-new="true"
:field="newCustomField"
:draggable="false"
class="mb-1.5"
@save="onNewCustomFieldSave"
@remove="newCustomField = null"
/>
<CustomField
v-for="field in filteredCustomFields"
:key="field.uuid"
:data-uuid="field.uuid"
:field="field"
:draggable="true"
class="mb-1.5"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
@save="saveCustomFields"
@click="$emit('set-draw-custom-field', field)"
@dragstart="[customDragFieldRef.value = field, removeDragOverlay($event), setDragPlaceholder($event)]"
@dragend="[customDragFieldRef.value = null, $emit('set-drag-placeholder', null)]"
@remove="removeCustomField"
@set-draw="$emit('set-draw', $event)"
/>
</div>
</div>
<div
v-if="editable && !onlyDefinedFields && (!showCustomTab || (!customFields.length && !newCustomField))"
id="field-types-grid"
class="grid grid-cols-3 gap-1 pb-2 fields-grid"
>
@@ -266,15 +356,18 @@
<script>
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 IconDrag from './icon_drag'
import { v4 } from 'uuid'
export default {
name: 'TemplateFields',
components: {
Field,
CustomField,
FieldType,
IconCirclePlus,
IconSparkles,
@@ -283,12 +376,22 @@ export default {
IconDrag,
IconLock
},
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 't', 'fieldsDragFieldRef', 'baseFetch', 'selectedAreasRef'],
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'baseFetch', 'selectedAreasRef', 'getFieldTypeIndex'],
props: {
fields: {
type: Array,
required: true
},
customFields: {
type: Array,
required: false,
default: () => []
},
withCustomFields: {
type: Boolean,
required: false,
default: false
},
withFieldsSearch: {
type: Boolean,
required: false,
@@ -372,12 +475,15 @@ export default {
default: false
}
},
emits: ['add-field', 'set-draw', 'set-draw-type', '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'],
data () {
return {
fieldPagesLoaded: null,
analyzingProgress: 0,
defaultFieldsSearch: ''
newCustomField: null,
showCustomTab: false,
defaultFieldsSearch: '',
customFieldsSearch: ''
}
},
computed: {
@@ -430,6 +536,23 @@ export default {
} else {
return this.submitterDefaultFields
}
},
isShowCustomFieldSearch () {
return this.customFields.length > 8
},
filteredCustomFields () {
if (this.customFieldsSearch) {
return this.customFields.filter((f) => f.name.toLowerCase().includes(this.customFieldsSearch.toLowerCase()))
} else {
return this.customFields
}
}
},
mounted () {
try {
this.showCustomTab = localStorage.getItem('docuseal_builder_tab') === 'custom'
} catch (e) {
console.error(e)
}
},
methods: {
@@ -440,6 +563,61 @@ export default {
this.$emit('set-drag', field)
},
onNewCustomFieldSave () {
if (this.newCustomField.name) {
this.customFields.unshift(this.newCustomField)
this.newCustomField = null
this.saveCustomFields()
} else {
this.newCustomField = null
}
},
addCustomField (field) {
const customField = JSON.parse(JSON.stringify(field))
customField.uuid = v4()
delete customField.submitter_uuid
delete customField.prefillable
delete customField.conditions
customField.areas?.forEach((area) => {
delete area.attachment_uuid
delete area.page
})
if (customField.name) {
this.customFields.unshift(customField)
this.saveCustomFields()
} else {
this.newCustomField = customField
}
this.setFieldsTab('custom')
},
setFieldsTab (type) {
try {
localStorage.setItem('docuseal_builder_tab', type)
} catch (e) {
console.error(e)
}
this.showCustomTab = type === 'custom'
},
saveCustomFields () {
return this.baseFetch('/account_custom_fields', {
method: 'POST',
body: JSON.stringify({
value: this.customFields
}),
headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => {
const fields = await resp.json()
this.customFields.splice(0, this.customFields.length, ...fields)
})
},
detectFields () {
const fields = []
@@ -617,6 +795,47 @@ export default {
if (save) {
this.save()
}
},
removeCustomField (field) {
this.customFields.splice(this.customFields.indexOf(field), 1)
if (!this.customFields.length) {
this.setFieldsTab('default')
}
this.saveCustomFields()
},
onCustomFieldDragover (e) {
if (this.customDragFieldRef.value && this.customFields.includes(this.customDragFieldRef.value)) {
const container = this.$refs.customFieldsList
const targetField = e.target.closest('[data-uuid]')
const dragField = container.querySelector(`[data-uuid="${this.customDragFieldRef.value.uuid}"]`)
if (dragField && targetField && targetField !== dragField) {
const fields = Array.from(container.children)
const currentIndex = fields.indexOf(dragField)
const targetIndex = fields.indexOf(targetField)
if (currentIndex < targetIndex) {
targetField.after(dragField)
} else {
targetField.before(dragField)
}
}
}
},
reorderCustomFields () {
if (!this.customFields.includes(this.customDragFieldRef.value)) {
return
}
const reorderedFields = Array.from(this.$refs.customFieldsList.children).map((el) => {
return this.customFields.find((f) => f.uuid === el.dataset.uuid)
}).filter(Boolean)
this.customFields.splice(0, this.customFields.length, ...reorderedFields)
this.saveCustomFields()
}
}
}
+7 -15
View File
@@ -9,7 +9,7 @@
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title">
{{ t('font') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field, template.fields) }}
{{ t('font') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }}
</span>
<a
href="#"
@@ -171,7 +171,7 @@
contenteditable="true"
class="outline-none whitespace-nowrap truncate"
>
{{ field.default_value || field.name || buildDefaultName(field, template.fields) }}
{{ field.default_value || field.name || buildDefaultName(field) }}
</span>
</div>
</div>
@@ -196,7 +196,7 @@ export default {
components: {
IconChevronDown
},
inject: ['t', 'save', 'template'],
inject: ['t', 'template'],
props: {
field: {
type: Object,
@@ -212,17 +212,12 @@ export default {
required: false,
default: true
},
withClickSaveEvent: {
type: Boolean,
required: false,
default: false
},
buildDefaultName: {
type: Function,
required: true
}
},
emits: ['close', 'click-save'],
emits: ['close', 'save'],
data () {
return {
preferences: {}
@@ -262,6 +257,7 @@ export default {
colors () {
return [
{ label: '⬛', value: 'black' },
{ label: '⬜', value: 'white' },
{ label: '🟦', value: 'blue' },
{ label: '🟥', value: 'red' }
]
@@ -276,6 +272,7 @@ export default {
'items-center': !this.preferences.valign || this.preferences.valign === 'center',
'items-start': this.preferences.valign === 'top',
'items-end': this.preferences.valign === 'bottom',
'bg-black': this.preferences.color === 'white',
'font-bold': ['bold_italic', 'bold'].includes(this.preferences.font_type),
italic: ['bold_italic', 'italic'].includes(this.preferences.font_type)
}
@@ -327,12 +324,7 @@ export default {
Object.assign(this.field.preferences, this.preferences)
if (this.withClickSaveEvent) {
this.$emit('click-save')
} else {
this.save()
}
this.$emit('save')
this.$emit('close')
}
}
@@ -9,7 +9,7 @@
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title">
{{ t('formula') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field, template.fields) }}
{{ t('formula') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }}
</span>
<a
href="#"
@@ -33,7 +33,7 @@
v-for="f in fields"
:key="f.uuid"
class="mr-1 flex btn btn-neutral btn-outline border-base-content/20 btn-sm normal-case font-normal bg-white !rounded-xl"
@click.prevent="insertTextUnderCursor(`{{${f.name || buildDefaultName(f, template.fields)}}}`)"
@click.prevent="insertTextUnderCursor(`{{${f.name || buildDefaultName(f)}}}`)"
>
<IconMathFunction
v-if="f.preferences?.formula"
@@ -47,7 +47,7 @@
height="20"
stroke-width="1.5"
/>
{{ f.name || buildDefaultName(f, template.fields) }}
{{ f.name || buildDefaultName(f) }}
</button>
</div>
<div>
@@ -131,7 +131,7 @@ export default {
IconCodePlus,
IconMathFunction
},
inject: ['t', 'save', 'template', 'withFormula'],
inject: ['t', 'template', 'withFormula'],
props: {
field: {
type: Object,
@@ -152,7 +152,7 @@ export default {
required: true
}
},
emits: ['close'],
emits: ['close', 'save'],
data () {
return {
formula: ''
@@ -181,7 +181,7 @@ export default {
const foundField = this.fields.find((f) => f.uuid === uuid)
if (foundField) {
return `{{${foundField.name || this.buildDefaultName(foundField, this.template.fields)}}}`
return `{{${foundField.name || this.buildDefaultName(foundField)}}}`
} else {
return '{{FIELD NOT FOUND}}'
}
@@ -190,7 +190,7 @@ export default {
normalizeFormula (text) {
return text.replace(/{{(.*?)}}/g, (match, name) => {
const foundField = this.fields.find((f) => {
return (f.name || this.buildDefaultName(f, this.template.fields)).trim() === name.trim()
return (f.name || this.buildDefaultName(f)).trim() === name.trim()
})
if (foundField) {
@@ -212,11 +212,14 @@ export default {
} else {
this.field.preferences.formula = normalizedFormula
if (this.field.type !== 'payment') {
if (this.field.type === 'payment') {
delete this.field.preferences.price
delete this.field.preferences.payment_link_id
} else {
this.field.readonly = !!normalizedFormula
}
this.save()
this.$emit('save')
this.$emit('close')
}
+87 -3
View File
@@ -1,10 +1,13 @@
const en = {
fixed: 'Fixed',
default: 'Default',
save_as_custom_field: 'Save as custom field',
kba: 'KBA',
analyzing_: 'Analyzing...',
download: 'Download',
downloading_: 'Downloading...',
view: 'View',
autodetect_fields: 'Autodetect fields',
autodetect_fields: 'Autodetect Fields',
payment_link: 'Payment link',
strikeout: 'Strikeout',
draw_strikethrough_the_document: 'Draw strikethrough the document',
@@ -29,6 +32,9 @@ const en = {
field_not_found: 'Field not found',
clear: 'Clear',
align: 'Align',
resize: 'Resize',
width: 'Width',
height: 'Height',
add_all_required_fields_to_continue: 'Add all required fields to continue',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Uploaded PDF contains form fields. Keep or remove them?',
keep: 'Keep',
@@ -46,6 +52,8 @@ const en = {
type_value: 'Type value',
equal: 'Equal',
not_equal: 'Not equal',
greater_than: 'Greater than',
less_than: 'Less than',
contains: 'Contains',
does_not_contain: 'Does not contain',
not_empty: 'Not empty',
@@ -89,8 +97,9 @@ const en = {
format: 'Format',
read_only: 'Read-only',
page: 'Page',
draw_new_area: 'Draw New Area',
copy_to_all_pages: 'Copy to All Pages',
draw_new_area: 'Draw new area',
copy_to_all_pages: 'Copy to all pages',
more: 'More',
add_option: 'Add option',
option: 'Option',
options: 'Options',
@@ -171,6 +180,9 @@ const en = {
numbers_only: 'Numbers only',
letters_only: 'Letters only',
regexp_validation: 'Regexp validation',
custom_validation: 'Custom Validation',
length_validation: 'Length Validation',
number_range: 'Number Range',
enter_pdf_password: 'Enter PDF password',
wrong_password: 'Wrong password',
currency: 'Currency',
@@ -200,6 +212,9 @@ const en = {
}
const es = {
fixed: 'Fijo',
default: 'Predeterminado',
save_as_custom_field: 'Guardar como personalizado',
kba: 'KBA',
autodetect_fields: 'Autodetectar campos',
analyzing_: 'Analizando...',
@@ -231,6 +246,9 @@ const es = {
clear: 'Borrar',
type_value: 'Escriba valor',
align: 'Alinear',
resize: 'Redimensionar',
width: 'Ancho',
height: 'Alto',
add_all_required_fields_to_continue: 'Agregar todos los campos requeridos para continuar',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'El PDF cargado tiene campos. ¿Mantenerlos o eliminarlos?',
keep: 'Mantener',
@@ -248,6 +266,8 @@ const es = {
price: 'Precio',
equal: 'Igual',
not_equal: 'No es igual',
greater_than: 'Mayor que',
less_than: 'Menor que',
contains: 'Contiene',
does_not_contain: 'No contiene',
not_empty: 'No vacío',
@@ -288,6 +308,7 @@ const es = {
page: 'Página',
draw_new_area: 'Dibujar nueva área',
copy_to_all_pages: 'Copiar a todas las páginas',
more: 'Más',
add_option: 'Agregar opción',
option: 'Opción',
options: 'Opciones',
@@ -372,6 +393,9 @@ const es = {
numbers_only: 'Solo números',
letters_only: 'Solo letras',
regexp_validation: 'Validación de expresión regular',
custom_validation: 'Validación Personalizada',
length_validation: 'Validación de Longitud',
number_range: 'Rango de Números',
enter_pdf_password: 'Ingrese la contraseña del PDF',
wrong_password: 'Contraseña incorrecta',
currency: 'Moneda',
@@ -401,6 +425,9 @@ const es = {
}
const it = {
fixed: 'Fisso',
default: 'Predefinito',
save_as_custom_field: 'Salva come personalizzato',
kba: 'KBA',
autodetect_fields: 'Rileva campi',
analyzing_: 'Analisi...',
@@ -431,6 +458,9 @@ const it = {
field_not_found: 'Campo non trovato',
clear: 'Cancella',
align: 'Allinea',
resize: 'Ridimensiona',
width: 'Larghezza',
height: 'Altezza',
add_all_required_fields_to_continue: 'Aggiungi tutti i campi obbligatori per continuare',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Il PDF caricato contiene campi del modulo. Mantenerli o rimuoverli?',
keep: 'Mantieni',
@@ -448,6 +478,8 @@ const it = {
type_value: 'Inserisci valore',
equal: 'Uguale',
not_equal: 'Non uguale',
greater_than: 'Maggiore di',
less_than: 'Minore di',
contains: 'Contiene',
does_not_contain: 'Non contiene',
not_empty: 'Non vuoto',
@@ -493,6 +525,7 @@ const it = {
page: 'Pagina',
draw_new_area: 'Disegna nuova area',
copy_to_all_pages: 'Copia in tutte le pagine',
more: 'Altro',
add_option: 'Aggiungi opzione',
option: 'Opzione',
options: 'Opzioni',
@@ -573,6 +606,9 @@ const it = {
numbers_only: 'Solo numeri',
letters_only: 'Solo lettere',
regexp_validation: 'Validazione regexp',
custom_validation: 'Validazione Personalizzata',
length_validation: 'Validazione Lunghezza',
number_range: 'Intervallo Numerico',
enter_pdf_password: 'Inserisci password PDF',
wrong_password: 'Password errata',
currency: 'Valuta',
@@ -602,6 +638,9 @@ const it = {
}
const pt = {
fixed: 'Fixo',
default: 'Padrão',
save_as_custom_field: 'Salvar como personalizado',
kba: 'KBA',
autodetect_fields: 'Detectar campos',
analyzing_: 'Analisando...',
@@ -636,6 +675,9 @@ const pt = {
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'O PDF carregado contém campos. Manter ou removê-los?',
keep: 'Manter',
align: 'Alinhar',
resize: 'Redimensionar',
width: 'Largura',
height: 'Altura',
left: 'Esquerda',
heading: 'Cabeçalho',
validation: 'Validação',
@@ -650,6 +692,8 @@ const pt = {
price: 'Preço',
equal: 'Igual',
not_equal: 'Não é igual',
greater_than: 'Maior que',
less_than: 'Menor que',
contains: 'Contém',
does_not_contain: 'Não contém',
not_empty: 'Não vazio',
@@ -690,6 +734,7 @@ const pt = {
page: 'Página',
draw_new_area: 'Desenhar nova área',
copy_to_all_pages: 'Copiar para todas as páginas',
more: 'Mais',
add_option: 'Adicionar opção',
option: 'Opção',
options: 'Opções',
@@ -774,6 +819,9 @@ const pt = {
numbers_only: 'Somente números',
letters_only: 'Somente letras',
regexp_validation: 'Validação de expressão regular',
custom_validation: 'Validação Personalizada',
length_validation: 'Validação de Comprimento',
number_range: 'Intervalo de Números',
enter_pdf_password: 'Digite a senha do PDF',
wrong_password: 'Senha incorreta',
currency: 'Moeda',
@@ -803,6 +851,9 @@ const pt = {
}
const fr = {
fixed: 'Fixe',
default: 'Par défaut',
save_as_custom_field: 'Enregistrer comme personnalisé',
kba: 'KBA',
autodetect_fields: 'Détecter les champs',
analyzing_: 'Analyse...',
@@ -833,6 +884,9 @@ const fr = {
field_not_found: 'Champ introuvable',
clear: 'Effacer',
align: 'Aligner',
resize: 'Redimensionner',
width: 'Largeur',
height: 'Hauteur',
add_all_required_fields_to_continue: 'Ajoutez tous les champs obligatoires pour continuer',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Le PDF téléversé contient des champs de formulaire. Les conserver ou les supprimer ?',
keep: 'Conserver',
@@ -850,6 +904,8 @@ const fr = {
type_value: 'Saisir une valeur',
equal: 'Égal',
not_equal: 'Différent',
greater_than: 'Supérieur à',
less_than: 'Inférieur à',
contains: 'Contient',
does_not_contain: 'Ne contient pas',
not_empty: 'Non vide',
@@ -895,6 +951,7 @@ const fr = {
page: 'Page',
draw_new_area: 'Dessiner une zone',
copy_to_all_pages: 'Copier sur toutes les pages',
more: 'Plus',
add_option: 'Ajouter une option',
option: 'Option',
options: 'Options',
@@ -975,6 +1032,9 @@ const fr = {
numbers_only: 'Chiffres uniquement',
letters_only: 'Lettres uniquement',
regexp_validation: 'Validation par expression régulière',
custom_validation: 'Validation Personnalisée',
length_validation: 'Validation de Longueur',
number_range: 'Plage de Nombres',
enter_pdf_password: 'Saisir le mot de passe du PDF',
wrong_password: 'Mot de passe incorrect',
currency: 'Devise',
@@ -1004,6 +1064,9 @@ const fr = {
}
const de = {
fixed: 'Fest',
default: 'Standard',
save_as_custom_field: 'Als benutzerdefiniert speichern',
kba: 'KBA',
autodetect_fields: 'Felder erkennen',
analyzing_: 'Analysiere...',
@@ -1034,6 +1097,9 @@ const de = {
field_not_found: 'Feld nicht gefunden',
clear: 'Leeren',
align: 'Ausrichten',
resize: 'Größe ändern',
width: 'Breite',
height: 'Höhe',
add_all_required_fields_to_continue: 'Fügen Sie alle erforderlichen Felder hinzu, um fortzufahren',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Das hochgeladene PDF enthält Formularfelder. Beibehalten oder entfernen?',
keep: 'Beibehalten',
@@ -1051,6 +1117,8 @@ const de = {
type_value: 'Wert eingeben',
equal: 'Gleich',
not_equal: 'Ungleich',
greater_than: 'Größer als',
less_than: 'Kleiner als',
contains: 'Enthält',
does_not_contain: 'Enthält nicht',
not_empty: 'Nicht leer',
@@ -1096,6 +1164,7 @@ const de = {
page: 'Seite',
draw_new_area: 'Bereich zeichnen',
copy_to_all_pages: 'Auf alle Seiten kopieren',
more: 'Mehr',
add_option: 'Option hinzufügen',
option: 'Option',
options: 'Optionen',
@@ -1176,6 +1245,9 @@ const de = {
numbers_only: 'Nur Zahlen',
letters_only: 'Nur Buchstaben',
regexp_validation: 'RegExp-Validierung',
custom_validation: 'Benutzerdefinierte Validierung',
length_validation: 'Längenvalidierung',
number_range: 'Zahlenbereich',
enter_pdf_password: 'PDF-Passwort eingeben',
wrong_password: 'Falsches Passwort',
currency: 'Währung',
@@ -1205,6 +1277,9 @@ const de = {
}
const nl = {
fixed: 'Vast',
default: 'Standaard',
save_as_custom_field: 'Opslaan als aangepast',
kba: 'KBA',
autodetect_fields: 'Velden detecteren',
analyzing_: 'Analyseren...',
@@ -1235,6 +1310,9 @@ const nl = {
field_not_found: 'Veld niet gevonden',
clear: 'Wissen',
align: 'Uitlijnen',
resize: 'Formaat wijzigen',
width: 'Breedte',
height: 'Hoogte',
add_all_required_fields_to_continue: 'Voeg alle vereiste velden toe om door te gaan',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Geüploade PDF bevat formuliervelden. Behouden of verwijderen?',
keep: 'Behouden',
@@ -1252,6 +1330,8 @@ const nl = {
type_value: 'Typ waarde',
equal: 'Gelijk aan',
not_equal: 'Niet gelijk aan',
greater_than: 'Groter dan',
less_than: 'Kleiner dan',
contains: 'Bevat',
does_not_contain: 'Bevat niet',
not_empty: 'Niet leeg',
@@ -1297,6 +1377,7 @@ const nl = {
page: 'Pagina',
draw_new_area: 'Nieuw gebied tekenen',
copy_to_all_pages: 'Kopieer naar alle pag.',
more: 'Meer',
add_option: 'Optie toevoegen',
option: 'Optie',
options: 'Opties',
@@ -1377,6 +1458,9 @@ const nl = {
numbers_only: 'Alleen cijfers',
letters_only: 'Alleen letters',
regexp_validation: 'Regex validatie',
custom_validation: 'Aangepaste Validatie',
length_validation: 'Lengte Validatie',
number_range: 'Getalbereik',
enter_pdf_password: 'Voer PDF-wachtwoord in',
wrong_password: 'Onjuist wachtwoord',
currency: 'Valuta',
+193 -91
View File
@@ -29,7 +29,7 @@
:is-drag="isDrag"
@move="onSelectionBoxMove"
@contextmenu="openSelectionContextMenu"
@close-context-menu="closeSelectionContextMenu"
@close-context-menu="closeContextMenu"
/>
<FieldArea
v-for="(item, i) in areas"
@@ -48,52 +48,70 @@
:default-submitters="defaultSubmitters"
:max-page="totalPages - 1"
:is-select-mode="isSelectMode"
:is-mobile="isMobile"
@start-resize="resizeDirection = $event"
@stop-resize="resizeDirection = null"
@remove="$emit('remove-area', item.area)"
@scroll-to="$emit('scroll-to', $event)"
@add-custom-field="$emit('add-custom-field', $event)"
@contextmenu="openAreaContextMenu($event, item.area, item.field)"
/>
<FieldArea
v-if="newArea"
v-for="(area, index) in newAreas"
:key="index"
:is-draw="true"
:page-width="width"
:page-height="height"
:field="{ submitter_uuid: selectedSubmitter.uuid, type: drawField?.type || dragFieldPlaceholder?.type || defaultFieldType }"
:area="newArea"
:field="{ submitter_uuid: selectedSubmitter.uuid, type: newAreaFieldType }"
:area="area"
/>
<div
v-if="newAreas.length > 1"
class="absolute outline-dashed outline-gray-400 pointer-events-none z-20"
:style="newAreasBoxStyle"
/>
<div
v-if="selectionRect"
class="absolute outline-dashed outline-gray-400 pointer-events-none z-20"
:style="selectionRectStyle"
/>
<ContextMenu
v-if="contextMenu"
<FieldContextMenu
v-if="contextMenu && contextMenu.field"
:context-menu="contextMenu"
:field="contextMenu.field"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:editable="editable"
:with-fields-detection="withFieldsDetection"
:default-field="defaultFieldsIndex[contextMenu.field.name]"
@copy="handleCopy"
@delete="handleDelete"
@close="closeContextMenu"
@set-draw="$emit('set-draw', $event)"
@scroll-to="$emit('scroll-to', $event)"
@save="save"
@add-custom-field="$emit('add-custom-field', $event)"
/>
<SelectionContextMenu
v-else-if="contextMenu && contextMenu.areas"
:context-menu="contextMenu"
:editable="editable"
:template="template"
@copy="handleSelectionCopy"
@delete="handleSelectionDelete"
@close="closeContextMenu"
/>
<PageContextMenu
v-else-if="contextMenu && !contextMenu.field && !contextMenu.areas"
:context-menu="contextMenu"
:editable="editable"
:with-fields-detection="withFieldsDetection"
@paste="handlePaste"
@autodetect-fields="handleAutodetectFields"
@close="closeContextMenu"
/>
<ContextMenu
v-if="selectionContextMenu"
:context-menu="selectionContextMenu"
:editable="editable"
:is-multi-selection="true"
:selected-areas="selectedAreasRef.value"
:template="template"
@copy="handleSelectionCopy"
@delete="handleSelectionDelete"
@align="handleSelectionAlign"
@close="closeSelectionContextMenu"
/>
</div>
<div
v-show="resizeDirection || isDrag || showMask || (drawField && isMobile) || fieldsDragFieldRef.value || selectionRect"
v-show="resizeDirection || isDrag || showMask || (drawField && isMobile) || fieldsDragFieldRef.value || customDragFieldRef?.value || selectionRect"
id="mask"
ref="mask"
class="top-0 bottom-0 left-0 right-0 absolute"
@@ -103,7 +121,7 @@
@contextmenu="openContextMenu"
@dragover.prevent="onDragover"
@dragenter="onDragenter"
@dragleave="newArea = null"
@dragleave="newAreas = []"
@drop="onDrop"
@pointerup="onPointerup"
/>
@@ -112,17 +130,21 @@
<script>
import FieldArea from './area'
import ContextMenu from './context_menu'
import FieldContextMenu from './field_context_menu'
import SelectionContextMenu from './selection_context_menu'
import PageContextMenu from './page_context_menu'
import SelectionBox from './selection_box'
export default {
name: 'TemplatePage',
components: {
FieldArea,
ContextMenu,
FieldContextMenu,
SelectionContextMenu,
PageContextMenu,
SelectionBox
},
inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'assignDropAreaSize', 'selectedAreasRef', 'template', 'isSelectModeRef'],
inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'customDragFieldRef', 'assignDropAreaSize', 'selectedAreasRef', 'template', 'isSelectModeRef', 'save'],
props: {
image: {
type: Object,
@@ -133,6 +155,11 @@ export default {
required: false,
default: null
},
isMobile: {
type: Boolean,
required: false,
default: false
},
withSignatureId: {
type: Boolean,
required: false,
@@ -191,6 +218,11 @@ export default {
required: false,
default: null
},
drawCustomField: {
type: Object,
required: false,
default: null
},
editable: {
type: Boolean,
required: false,
@@ -216,16 +248,15 @@ export default {
default: false
}
},
emits: ['draw', 'drop-field', 'remove-area', 'copy-field', 'paste-field', 'scroll-to', 'copy-selected-areas', 'delete-selected-areas', 'align-selected-areas', 'autodetect-fields'],
emits: ['draw', 'drop-field', 'remove-area', 'copy-field', 'paste-field', 'scroll-to', 'copy-selected-areas', 'delete-selected-areas', 'autodetect-fields', 'add-custom-field', 'set-draw'],
data () {
return {
areaRefs: [],
showMask: false,
resizeDirection: null,
newArea: null,
newAreas: [],
contextMenu: null,
selectionRect: null,
selectionContextMenu: null
selectionRect: null
}
},
computed: {
@@ -274,6 +305,14 @@ export default {
return acc
}, {})
},
newAreaFieldType () {
if (this.drawField?.type) return this.drawField.type
if (this.drawCustomField?.type) return this.drawCustomField.type
if (this.dragFieldPlaceholder?.type) return this.dragFieldPlaceholder.type
if (this.customDragFieldRef?.value?.type) return this.customDragFieldRef.value.type
return this.defaultFieldType
},
defaultFieldType () {
if (this.drawFieldType) {
return this.drawFieldType
@@ -285,11 +324,6 @@ export default {
return 'text'
}
},
isMobile () {
const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent)
return isMobileSafariIos || /android|iphone|ipad/i.test(navigator.userAgent)
},
resizeDirectionClasses () {
return {
nwse: 'cursor-nwse-resize',
@@ -302,6 +336,21 @@ export default {
height () {
return this.image.metadata.height
},
newAreasBoxStyle () {
if (this.newAreas.length < 2) return {}
const minX = Math.min(...this.newAreas.map(a => a.x))
const minY = Math.min(...this.newAreas.map(a => a.y))
const maxX = Math.max(...this.newAreas.map(a => a.x + a.w))
const maxY = Math.max(...this.newAreas.map(a => a.y + a.h))
return {
left: minX * 100 + '%',
top: minY * 100 + '%',
width: (maxX - minX) * 100 + '%',
height: (maxY - minY) * 100 + '%'
}
},
selectionRectStyle () {
if (!this.selectionRect) return {}
@@ -331,7 +380,7 @@ export default {
const rect = this.$refs.image.getBoundingClientRect()
this.newArea = null
this.newAreas = []
this.showMask = false
this.contextMenu = {
@@ -351,7 +400,7 @@ export default {
const rect = this.$refs.image.getBoundingClientRect()
this.newArea = null
this.newAreas = []
this.showMask = false
this.contextMenu = {
@@ -364,33 +413,36 @@ export default {
}
},
openSelectionContextMenu (event) {
if (!this.editable) {
return
}
event.preventDefault()
event.stopPropagation()
const rect = this.$el.getBoundingClientRect()
this.selectionContextMenu = {
this.contextMenu = {
x: event.clientX,
y: event.clientY,
relativeX: (event.clientX - rect.left) / rect.width,
relativeY: (event.clientY - rect.top) / rect.height
relativeY: (event.clientY - rect.top) / rect.height,
areas: this.selectedAreasRef.value
}
},
closeSelectionContextMenu () {
this.selectionContextMenu = null
},
handleSelectionCopy () {
this.$emit('copy-selected-areas')
this.closeSelectionContextMenu()
this.closeContextMenu()
},
handleSelectionDelete () {
this.$emit('delete-selected-areas')
this.closeSelectionContextMenu()
},
handleSelectionAlign (direction) {
this.$emit('align-selected-areas', direction)
this.closeSelectionContextMenu()
this.closeContextMenu()
},
closeContextMenu () {
this.contextMenu = null
this.newArea = null
this.newAreas = []
this.showMask = false
},
handleCopy () {
@@ -410,7 +462,7 @@ export default {
this.closeContextMenu()
},
handlePaste () {
this.newArea = null
this.newAreas = []
this.showMask = false
this.$emit('paste-field', {
@@ -435,22 +487,66 @@ export default {
}
},
onDragenter (e) {
this.newArea = {}
const customField = this.customDragFieldRef?.value
const customAreas = customField?.areas || []
const dropX = (e.offsetX - 6) / this.$refs.mask.clientWidth
const dropY = e.offsetY / this.$refs.mask.clientHeight
this.assignDropAreaSize(this.newArea, this.dragFieldPlaceholder, {
maskW: this.$refs.mask.clientWidth,
maskH: this.$refs.mask.clientHeight
})
if (customAreas.length > 1) {
const refArea = customAreas[0]
this.newArea.x = (e.offsetX - 6) / this.$refs.mask.clientWidth
this.newArea.y = e.offsetY / this.$refs.mask.clientHeight - this.newArea.h / 2
this.newAreas = customAreas.map((customArea) => ({
x: dropX + (customArea.x - refArea.x),
y: dropY + (customArea.y - refArea.y) - (customArea.h / 2),
w: customArea.w,
h: customArea.h
}))
} else {
const newArea = {}
if (customAreas.length === 1) {
newArea.w = customAreas[0].w
newArea.h = customAreas[0].h
} else if (customField) {
this.assignDropAreaSize(newArea, customField, {
maskW: this.$refs.mask.clientWidth,
maskH: this.$refs.mask.clientHeight
})
} else {
this.assignDropAreaSize(newArea, this.dragFieldPlaceholder, {
maskW: this.$refs.mask.clientWidth,
maskH: this.$refs.mask.clientHeight
})
}
newArea.x = dropX
newArea.y = dropY - newArea.h / 2
this.newAreas = [newArea]
}
},
onDragover (e) {
this.newArea.x = (e.offsetX - 6) / this.$refs.mask.clientWidth
this.newArea.y = e.offsetY / this.$refs.mask.clientHeight - this.newArea.h / 2
const customField = this.customDragFieldRef?.value
const customAreas = customField?.areas || []
const dropX = (e.offsetX - 6) / this.$refs.mask.clientWidth
const dropY = e.offsetY / this.$refs.mask.clientHeight
if (customAreas.length > 1) {
const refArea = customAreas[0]
this.newAreas.forEach((newArea, index) => {
const customArea = customAreas[index]
newArea.x = dropX + (customArea.x - refArea.x)
newArea.y = dropY + (customArea.y - refArea.y) - (customArea.h / 2)
})
} else if (this.newAreas.length) {
this.newAreas[0].x = dropX
this.newAreas[0].y = dropY - this.newAreas[0].h / 2
}
},
onDrop (e) {
this.newArea = null
this.newAreas = []
this.$emit('drop-field', {
x: e.offsetX,
@@ -479,7 +575,7 @@ export default {
return
}
if (this.isMobile && !this.drawField) {
if (this.isMobile && !this.drawField && !this.drawCustomField) {
return
}
@@ -490,14 +586,14 @@ export default {
this.showMask = true
this.$nextTick(() => {
this.newArea = {
this.newAreas = [{
initialX: e.offsetX / this.$refs.mask.clientWidth,
initialY: e.offsetY / this.$refs.mask.clientHeight,
x: e.offsetX / this.$refs.mask.clientWidth,
y: e.offsetY / this.$refs.mask.clientHeight,
w: 0,
h: 0
}
}]
})
},
startSelectionRect (e) {
@@ -563,28 +659,30 @@ export default {
return
}
if (this.newArea) {
const dx = e.offsetX / this.$refs.mask.clientWidth - this.newArea.initialX
const dy = e.offsetY / this.$refs.mask.clientHeight - this.newArea.initialY
const drawArea = this.newAreas[0]
if (drawArea?.initialX !== undefined) {
const dx = e.offsetX / this.$refs.mask.clientWidth - drawArea.initialX
const dy = e.offsetY / this.$refs.mask.clientHeight - drawArea.initialY
if (dx > 0) {
this.newArea.x = this.newArea.initialX
drawArea.x = drawArea.initialX
} else {
this.newArea.x = e.offsetX / this.$refs.mask.clientWidth
drawArea.x = e.offsetX / this.$refs.mask.clientWidth
}
if (dy > 0) {
this.newArea.y = this.newArea.initialY
drawArea.y = drawArea.initialY
} else {
this.newArea.y = e.offsetY / this.$refs.mask.clientHeight
drawArea.y = e.offsetY / this.$refs.mask.clientHeight
}
if ((this.drawField?.type || this.drawFieldType) === 'cells') {
this.newArea.cell_w = this.newArea.h * (this.$refs.mask.clientHeight / this.$refs.mask.clientWidth)
if ((this.drawField?.type || this.drawCustomField?.type || this.drawFieldType) === 'cells') {
drawArea.cell_w = drawArea.h * (this.$refs.mask.clientHeight / this.$refs.mask.clientWidth)
}
this.newArea.w = Math.abs(dx)
this.newArea.h = Math.abs(dy)
drawArea.w = Math.abs(dx)
drawArea.h = Math.abs(dy)
}
},
onPointerup (e) {
@@ -601,29 +699,33 @@ export default {
})
this.selectionRect = null
} else if (this.newArea) {
const area = {
x: this.newArea.x,
y: this.newArea.y,
w: this.newArea.w,
h: this.newArea.h,
page: this.number
} else {
const drawArea = this.newAreas[0]
if (drawArea?.initialX !== undefined) {
const area = {
x: drawArea.x,
y: drawArea.y,
w: drawArea.w,
h: drawArea.h,
page: this.number
}
if ('cell_w' in drawArea) {
area.cell_w = drawArea.cell_w
}
const dx = Math.abs(e.offsetX - this.$refs.mask.clientWidth * drawArea.initialX)
const dy = Math.abs(e.offsetY - this.$refs.mask.clientHeight * drawArea.initialY)
const isTooSmall = dx < 8 && dy < 8
this.$emit('draw', { area, isTooSmall })
}
if ('cell_w' in this.newArea) {
area.cell_w = this.newArea.cell_w
}
const dx = Math.abs(e.offsetX - this.$refs.mask.clientWidth * this.newArea.initialX)
const dy = Math.abs(e.offsetY - this.$refs.mask.clientHeight * this.newArea.initialY)
const isTooSmall = dx < 8 && dy < 8
this.$emit('draw', { area, isTooSmall })
}
this.showMask = false
this.newArea = null
this.newAreas = []
},
rectsOverlap (r1, r2) {
return !(
@@ -0,0 +1,159 @@
<template>
<div
ref="menu"
class="fixed z-50 p-1 bg-white shadow-lg rounded-lg border border-neutral-200 cursor-default"
style="min-width: 170px"
:style="menuStyle"
@mousedown.stop
@pointerdown.stop
>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
:class="!hasClipboardData ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-100'"
:disabled="!hasClipboardData"
@click.stop="!hasClipboardData ? null : $emit('paste')"
>
<span class="flex items-center space-x-2">
<IconClipboard class="w-4 h-4" />
<span>{{ t('paste') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘V' : 'Ctrl+V' }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@click.stop="handleToggleSelectMode"
>
<span class="flex items-center space-x-2">
<IconClick
v-if="!isSelectModeRef.value"
class="w-4 h-4"
/>
<IconNewSection
v-else
class="w-4 h-4"
/>
<span>{{ isSelectModeRef.value ? t('draw_fields') : t('select_fields') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">Tab</span>
</button>
<hr
v-if="showAutodetectFields"
class="my-1 border-neutral-200"
>
<button
v-if="showAutodetectFields"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('autodetect-fields')"
>
<IconSparkles class="w-4 h-4" />
<span>{{ t('autodetect_fields') }}</span>
</button>
</div>
</template>
<script>
import { IconClipboard, IconClick, IconNewSection, IconSparkles } from '@tabler/icons-vue'
export default {
name: 'PageContextMenu',
components: {
IconClipboard,
IconClick,
IconNewSection,
IconSparkles
},
inject: ['t', 'isSelectModeRef'],
props: {
contextMenu: {
type: Object,
default: null,
required: true
},
editable: {
type: Boolean,
default: true
},
withFieldsDetection: {
type: Boolean,
default: false
}
},
emits: ['paste', 'close', 'autodetect-fields'],
computed: {
isMac () {
return (navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')
},
hasClipboardData () {
try {
const clipboard = localStorage.getItem('docuseal_clipboard')
if (clipboard) {
const data = JSON.parse(clipboard)
return Date.now() - data.timestamp < 3600000
}
return false
} catch {
return false
}
},
menuStyle () {
return {
left: this.contextMenu.x + 'px',
top: this.contextMenu.y + 'px'
}
},
showAutodetectFields () {
return this.withFieldsDetection && this.editable
}
},
mounted () {
document.addEventListener('keydown', this.onKeyDown)
document.addEventListener('mousedown', this.handleClickOutside)
this.$nextTick(() => {
this.checkMenuPosition()
})
},
beforeUnmount () {
document.removeEventListener('keydown', this.onKeyDown)
document.removeEventListener('mousedown', this.handleClickOutside)
},
methods: {
checkMenuPosition () {
if (this.$refs.menu) {
const rect = this.$refs.menu.getBoundingClientRect()
const overflow = rect.bottom - window.innerHeight
if (overflow > 0) {
this.contextMenu.y = this.contextMenu.y - overflow - 4
}
}
},
onKeyDown (event) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
this.$emit('close')
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v' && this.hasClipboardData) {
event.preventDefault()
event.stopPropagation()
this.$emit('paste')
}
},
handleClickOutside (event) {
if (this.$refs.menu && !this.$refs.menu.contains(event.target)) {
this.$emit('close')
}
},
handleToggleSelectMode () {
this.isSelectModeRef.value = !this.isSelectModeRef.value
this.$emit('close')
}
}
}
</script>
@@ -1,7 +1,7 @@
<template>
<span
class="dropdown dropdown-end field-settings-dropdown"
:class="{ 'dropdown-open': ((!field.preferences?.price && !field.preferences?.formula && !field.preferences?.price_id && !field.preferences?.payment_link_id) || !isConnected) && !isLoading }"
:class="{ 'dropdown-open': withForceOpen && ((!field.preferences?.price && !field.preferences?.formula && !field.preferences?.price_id && !field.preferences?.payment_link_id) || !isConnected) && !isLoading }"
>
<label
tabindex="0"
@@ -231,7 +231,10 @@
</span>
</label>
</li>
<li class="mt-1">
<li
v-if="withCondition"
class="mt-1"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-condition')"
@@ -244,12 +247,29 @@
</span>
</label>
</li>
<hr
v-if="withCustomFields"
class="pb-0.5 mt-0.5"
>
<li v-if="withCustomFields">
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('add-custom-field', field)"
>
<IconForms
:width="20"
:stroke-width="1.6"
/>
{{ t('save_as_custom_field') }}
</a>
</li>
</ul>
</span>
</template>
<script>
import { IconMathFunction, IconSettings, IconCircleCheck, IconInfoCircle, IconBrandStripe, IconInnerShadowTop, IconRouteAltLeft } from '@tabler/icons-vue'
import { IconMathFunction, IconSettings, IconCircleCheck, IconInfoCircle, IconBrandStripe, IconInnerShadowTop, IconRouteAltLeft, IconForms } from '@tabler/icons-vue'
import { ref } from 'vue'
const isConnected = ref(false)
@@ -261,6 +281,7 @@ export default {
IconCircleCheck,
IconRouteAltLeft,
IconInfoCircle,
IconForms,
IconMathFunction,
IconInnerShadowTop,
IconBrandStripe
@@ -270,9 +291,24 @@ export default {
field: {
type: Object,
required: true
},
withForceOpen: {
type: Boolean,
required: false,
default: true
},
withCustomFields: {
type: Boolean,
required: false,
default: false
},
withCondition: {
type: Boolean,
required: false,
default: true
}
},
emits: ['click-condition', 'click-description', 'click-formula'],
emits: ['click-condition', 'click-description', 'click-formula', 'add-custom-field'],
data () {
return {
isLoading: false
+2 -1
View File
@@ -119,6 +119,7 @@
<ConditionsModal
:item="item"
:build-default-name="buildDefaultName"
@save="$emit('change')"
@close="isShowConditionsModal = false"
/>
</Teleport>
@@ -145,7 +146,7 @@ export default {
GoogleDriveDocumentSettings,
IconSortDescending2
},
inject: ['t'],
inject: ['t', 'getFieldTypeIndex'],
props: {
item: {
type: Object,
@@ -0,0 +1,348 @@
<template>
<div>
<div
v-if="!isShowFontModal && !isShowConditionsModal"
ref="menu"
class="fixed z-50 p-1 bg-white shadow-lg rounded-lg border border-neutral-200 cursor-default"
style="min-width: 170px"
:style="menuStyle"
@mousedown.stop
@pointerdown.stop
>
<ContextSubmenu
:icon="IconLayoutAlignMiddle"
:label="t('align')"
>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="alignSelectedAreas('left')"
>
<IconLayoutAlignLeft class="w-4 h-4" />
<span>{{ t('align_left') }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="alignSelectedAreas('right')"
>
<IconLayoutAlignRight class="w-4 h-4" />
<span>{{ t('align_right') }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="alignSelectedAreas('top')"
>
<IconLayoutAlignTop class="w-4 h-4" />
<span>{{ t('align_top') }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="alignSelectedAreas('bottom')"
>
<IconLayoutAlignBottom class="w-4 h-4" />
<span>{{ t('align_bottom') }}</span>
</button>
</ContextSubmenu>
<ContextSubmenu
:icon="IconAspectRatio"
:label="t('resize')"
>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="resizeSelectedAreas('width')"
>
<IconArrowsHorizontal class="w-4 h-4" />
<span>{{ t('width') }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="resizeSelectedAreas('height')"
>
<IconArrowsVertical class="w-4 h-4" />
<span>{{ t('height') }}</span>
</button>
</ContextSubmenu>
<hr
v-if="showFont || showCondition"
class="my-1 border-neutral-200"
>
<button
v-if="showFont"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="openFontModal"
>
<IconTypography class="w-4 h-4" />
<span>{{ t('font') }}</span>
</button>
<button
v-if="showCondition"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="openConditionModal"
>
<IconRouteAltLeft class="w-4 h-4" />
<span>{{ t('condition') }}</span>
</button>
<hr class="my-1 border-neutral-200">
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@click.stop="$emit('copy')"
>
<span class="flex items-center space-x-2">
<IconCopy class="w-4 h-4" />
<span>{{ t('copy') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘C' : 'Ctrl+C' }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm text-red-600"
@click.stop="$emit('delete')"
>
<span class="flex items-center space-x-2">
<IconTrashX class="w-4 h-4" />
<span>{{ t('remove') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">Del</span>
</button>
</div>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="multiSelectField"
:area="contextMenu.area"
:editable="editable"
:build-default-name="buildDefaultName"
@save="handleSaveMultiSelectFontModal"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:item="multiSelectField"
:build-default-name="buildDefaultName"
:exclude-field-uuids="selectedFields.map(f => f.uuid)"
@save="handleSaveMultiSelectConditionsModal"
@close="closeModal"
/>
</Teleport>
</div>
</template>
<script>
import { IconCopy, IconTrashX, IconTypography, IconRouteAltLeft, IconLayoutAlignLeft, IconLayoutAlignRight, IconLayoutAlignTop, IconLayoutAlignBottom, IconLayoutAlignMiddle, IconAspectRatio, IconArrowsHorizontal, IconArrowsVertical } from '@tabler/icons-vue'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import ContextSubmenu from './field_context_submenu'
import Field from './field'
import FieldType from './field_type'
export default {
name: 'SelectionContextMenu',
components: {
IconCopy,
IconTrashX,
IconTypography,
IconRouteAltLeft,
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconLayoutAlignTop,
IconLayoutAlignBottom,
FontModal,
IconArrowsHorizontal,
IconArrowsVertical,
ConditionsModal,
ContextSubmenu
},
inject: ['t', 'save', 'selectedAreasRef', 'getFieldTypeIndex'],
props: {
contextMenu: {
type: Object,
required: true
},
editable: {
type: Boolean,
default: true
},
template: {
type: Object,
required: true
},
withCondition: {
type: Boolean,
default: true
}
},
emits: ['copy', 'delete', 'close'],
data () {
return {
isShowFontModal: false,
isShowConditionsModal: false,
multiSelectField: null
}
},
computed: {
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
selectedFields () {
return this.selectedAreasRef.value.map((area) => {
return this.template.fields.find((f) => f.areas?.includes(area))
}).filter(Boolean)
},
isMac () {
return (navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')
},
menuStyle () {
return {
left: this.contextMenu.x + 'px',
top: this.contextMenu.y + 'px'
}
},
showFont () {
return true
},
showCondition () {
return this.withCondition
},
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels
},
mounted () {
document.addEventListener('keydown', this.onKeyDown)
document.addEventListener('mousedown', this.handleClickOutside)
this.$nextTick(() => this.checkMenuPosition())
},
beforeUnmount () {
document.removeEventListener('keydown', this.onKeyDown)
document.removeEventListener('mousedown', this.handleClickOutside)
},
methods: {
IconLayoutAlignMiddle,
IconAspectRatio,
buildDefaultName: Field.methods.buildDefaultName,
checkMenuPosition () {
if (this.$refs.menu) {
const rect = this.$refs.menu.getBoundingClientRect()
const overflow = rect.bottom - window.innerHeight
if (overflow > 0) {
this.contextMenu.y = this.contextMenu.y - overflow - 4
}
}
},
onKeyDown (event) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
this.$emit('close')
}
},
handleClickOutside (event) {
if (this.$refs.menu && !this.$refs.menu.contains(event.target)) {
this.$emit('close')
}
},
openFontModal () {
this.multiSelectField = {
name: this.t('fields_selected').replace('{count}', this.selectedFields.length),
preferences: {}
}
const preferencesStrings = this.selectedFields.map((f) => JSON.stringify(f.preferences || {}))
if (preferencesStrings.every((s) => s === preferencesStrings[0])) {
this.multiSelectField.preferences = JSON.parse(preferencesStrings[0])
}
this.isShowFontModal = true
},
openConditionModal () {
this.multiSelectField = {
name: this.t('fields_selected').replace('{count}', this.selectedFields.length),
conditions: []
}
const conditionStrings = this.selectedFields.map((f) => JSON.stringify(f.conditions || []))
if (conditionStrings.every((s) => s === conditionStrings[0])) {
this.multiSelectField.conditions = JSON.parse(conditionStrings[0])
}
this.isShowConditionsModal = true
},
closeModal () {
this.isShowFontModal = false
this.isShowConditionsModal = false
this.multiSelectField = null
this.$emit('close')
},
alignSelectedAreas (direction) {
const areas = this.selectedAreasRef.value
let targetValue
if (direction === 'left') {
targetValue = Math.min(...areas.map(a => a.x))
areas.forEach((area) => { area.x = targetValue })
} else if (direction === 'right') {
targetValue = Math.max(...areas.map(a => a.x + a.w))
areas.forEach((area) => { area.x = targetValue - area.w })
} else if (direction === 'top') {
targetValue = Math.min(...areas.map(a => a.y))
areas.forEach((area) => { area.y = targetValue })
} else if (direction === 'bottom') {
targetValue = Math.max(...areas.map(a => a.y + a.h))
areas.forEach((area) => { area.y = targetValue - area.h })
}
this.save()
this.$emit('close')
},
resizeSelectedAreas (dimension) {
const areas = this.selectedAreasRef.value
const values = areas.map(a => dimension === 'width' ? a.w : a.h).sort((a, b) => a - b)
const medianValue = values[Math.floor(values.length / 2)]
if (dimension === 'width') {
areas.forEach((area) => { area.w = medianValue })
} else if (dimension === 'height') {
areas.forEach((area) => {
const diff = medianValue - area.h
area.y = area.y - diff
area.h = medianValue
})
}
this.save()
this.$emit('close')
},
handleSaveMultiSelectFontModal () {
this.selectedFields.forEach((field) => {
field.preferences = { ...field.preferences, ...this.multiSelectField.preferences }
})
this.save()
this.closeModal()
},
handleSaveMultiSelectConditionsModal () {
this.selectedFields.forEach((field) => {
field.conditions = JSON.parse(JSON.stringify(this.multiSelectField.conditions))
})
this.save()
this.closeModal()
}
}
}
</script>
+5 -1
View File
@@ -290,7 +290,11 @@ export default {
if (resp.ok) {
resp.json().then((data) => {
this.$emit('success', data)
this.$refs.input.value = ''
if (this.$refs.input) {
this.$refs.input.value = ''
}
this.isLoading = false
})
} else if (resp.status === 422) {
+5 -3
View File
@@ -164,11 +164,13 @@ class ProcessSubmitterCompletionJob
next_submitter_items =
if submission.template_submitters.any? { |s| s['order'] }
submitter_groups =
submission.template_submitters.group_by.with_index { |s, index| s['order'] || index }
submission.template_submitters
.group_by.with_index { |s, index| s['order'] || index }
.sort_by(&:first).pluck(1)
current_group_index = submitter_groups.find { |_, group| group.any? { |s| s['uuid'] == submitter.uuid } }&.first
current_group_index = submitter_groups.index { |group| group.any? { |s| s['uuid'] == submitter.uuid } }
if submitter_groups[current_group_index + 1] &&
if current_group_index && submitter_groups[current_group_index + 1] &&
submitters_index.values_at(*submitter_groups[current_group_index].pluck('uuid'))
.compact.all?(&:completed_at?)
submitter_groups[current_group_index + 1]
+2
View File
@@ -47,12 +47,14 @@ class AccountConfig < ApplicationRecord
WITH_SIGNATURE_ID_REASON_KEY = 'with_signature_id_reason'
RECIPIENT_FORM_FIELDS_KEY = 'recipient_form_fields'
WITH_AUDIT_VALUES_KEY = 'with_audit_values'
WITH_AUDIT_SENDER_KEY = 'with_audit_sender'
WITH_SUBMITTER_TIMEZONE_KEY = 'with_submitter_timezone'
REQUIRE_SIGNING_REASON_KEY = 'require_signing_reason'
REUSE_SIGNATURE_KEY = 'reuse_signature'
WITH_FIELD_LABELS_KEY = 'with_field_labels'
COMBINE_PDF_RESULT_KEY = 'combine_pdf_result_key'
DOCUMENT_FILENAME_FORMAT_KEY = 'document_filename_format'
TEMPLATE_CUSTOM_FIELDS_KEY = 'template_custom_fields'
POLICY_LINKS_KEY = 'policy_links'
DEFAULT_VALUES = {
+1 -1
View File
@@ -39,7 +39,7 @@
<div class="form-control">
<%= ff.label :security_label, 'SMTP Security', class: 'label' %>
<div class="flex items-center space-x-6">
<% [%w[Auto none], %w[SSL ssl], %w[TLS tls], %w[Noverify noverify]].each do |(label, val)| %>
<% [%w[STARTTLS none], %w[TLS tls], %w[SSL ssl], %w[Noverify noverify]].each do |(label, val)| %>
<%= ff.label :security, value: val, for: "#{val}_radio", class: 'label' do %>
<%= ff.radio_button :security, val, checked: (value['security'].blank? && val == 'none') || value['security'] == val, id: "#{val}_radio", class: 'base-radio mr-2' %>
<%= label %>
-1
View File
@@ -1,5 +1,4 @@
<% uuid = local_assigns[:uuid] || SecureRandom.uuid %>
<label id="<%= local_assigns[:button_id] %>" for="<%= uuid %>" class="<%= local_assigns[:btn_class] %>"><%= local_assigns[:btn_text] %></label>
<input type="checkbox" id="<%= uuid %>" class="modal-toggle">
<div id="<%= local_assigns[:id] %>" class="modal items-start !animate-none overflow-y-auto">
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none">
+3 -4
View File
@@ -1,4 +1,3 @@
<% link = pagy_anchor(pagy) %>
<% if pagy.pages > 1 %>
<div class="flex my-6 justify-center md:justify-between">
<div class="hidden md:block text-sm">
@@ -12,8 +11,8 @@
<div class="flex items-center space-x-1.5">
<%= local_assigns[:right_additional_html] %>
<div class="join">
<% if pagy.prev %>
<%== link.call(pagy.prev, '«', classes: 'join-item btn min-h-full h-10') %>
<% if pagy.previous %>
<%= link_to '«', url_for(params: request.query_parameters.merge('page' => pagy.previous)), class: 'join-item btn min-h-full h-10' %>
<% else %>
<span class="join-item btn btn-disabled !bg-base-200 min-h-full h-10">«</span>
<% end %>
@@ -23,7 +22,7 @@
<% if local_assigns[:next_page_path].present? %>
<%= link_to '»', local_assigns[:next_page_path], class: 'join-item btn min-h-full h-10' %>
<% elsif pagy.next %>
<%== link.call(pagy.next, '»', classes: 'join-item btn min-h-full h-10') %>
<%= link_to '»', url_for(params: request.query_parameters.merge('page' => pagy.next)), class: 'join-item btn min-h-full h-10' %>
<% else %>
<span class="join-item btn btn-disabled !bg-base-200 min-h-full h-10">»</span>
<% end %>
-3
View File
@@ -1,7 +1,4 @@
<form action="<%= url_for %>" method="get" class="items-center flex">
<% if params[:status].present? %>
<input name="status" value="<%= params[:status] %>" class="hidden">
<% end %>
<% Submissions::Filter::ALLOWED_PARAMS.each do |key| %>
<% if params[key].present? %>
<input name="<%= key %>" value="<%= params[key] %>" class="hidden">
+1 -1
View File
@@ -60,7 +60,7 @@
<% end %>
<% elsif field['type'] == 'cells' && area['cell_w'].to_f > 0.0 %>
<% cell_width = area['cell_w'] / area['w'] * 100 %>
<div class="w-full flex items-center <%= 'justify-end' if align == 'right' %>">
<div class="w-full flex <%= valign == 'top' ? 'items-start' : (valign == 'bottom' ? 'items-end' : 'items-center') %> <%= 'justify-end' if align == 'right' %>">
<% (0..(area['w'] / area['cell_w']).ceil).each do |index| %>
<% if value[index] %>
<div class="text-center flex-none" style="width: <%= cell_width %>%;"><%= value[index] %></div>
+8 -7
View File
@@ -8,6 +8,7 @@
<% page_blob_struct = Struct.new(:url, :metadata, keyword_init: true) %>
<% 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 %>
<div style="max-height: -webkit-fill-available;">
<div id="scrollbox">
<div class="mx-auto block pb-72" style="max-width: 1000px">
@@ -20,12 +21,7 @@
</div>
<div class="flex items-center space-x-2" style="margin-left: 20px; flex-shrink: 0">
<% if @form_configs[:with_decline] %>
<% decline_modal_checkbox_uuid = SecureRandom.uuid %>
<div>
<%= render 'shared/html_modal', title: t(:decline), btn_text: t(:decline), btn_class: 'btn btn-sm !px-5', button_id: 'decline_button', uuid: decline_modal_checkbox_uuid do %>
<%= render 'submit_form/decline_form', submitter: @submitter %>
<% end %>
</div>
<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">
@@ -102,11 +98,16 @@
</div>
</div>
</div>
<div class="fixed bottom-0 w-full h-0 z-20">
<div class="fixed bottom-0 w-full h-0 z-50">
<div class="mx-auto" style="max-width: 1000px">
<div class="relative md:mx-32">
<%= render 'submit_form/submission_form', attachments_index: @attachments_index, submitter: @submitter, signature_attachment: @signature_attachment, configs: @form_configs, dry_run: local_assigns[:dry_run], expand: local_assigns[:expand], scroll_padding: local_assigns.fetch(:scroll_padding, '-110px'), schema: %>
</div>
</div>
</div>
<% if @form_configs[:with_decline] %>
<%= render 'shared/html_modal', title: t(:decline), uuid: decline_modal_checkbox_uuid do %>
<%= render 'submit_form/decline_form', submitter: @submitter %>
<% end %>
<% end %>
<%= render 'scripts/autosize_field' %>
+1 -1
View File
@@ -6,4 +6,4 @@
<%= button_to nil, user_configs_path, method: :post, params: { user_config: { key: UserConfig::SHOW_APP_TOUR, value: true } }, class: 'hidden', id: 'start_tour_button' %>
<% end %>
<% end %>
<template-builder class="grid" data-template="<%= @template_data %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
<template-builder class="grid" data-template="<%= @template_data %>" data-custom-fields="<%= (current_account.account_configs.find_or_initialize_by(key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY).value || []).to_json %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
+3 -3
View File
@@ -105,8 +105,8 @@
</div>
<div class="flex items-center space-x-1.5">
<div class="join">
<% if @pagy.prev %>
<%= link_to '«', url_for(page: @pagy.prev, anchor: 'log'), class: 'join-item btn min-h-full h-10' %>
<% if @pagy.previous %>
<%= link_to '«', url_for(params: request.query_parameters.merge('page' => @pagy.previous), anchor: 'log'), class: 'join-item btn min-h-full h-10' %>
<% else %>
<span class="join-item btn btn-disabled !bg-base-200 min-h-full h-10">«</span>
<% end %>
@@ -114,7 +114,7 @@
<%= "Page #{@pagy.page}" %>
</span>
<% if @pagy.next %>
<%= link_to '»', url_for(page: @pagy.next, anchor: 'log'), class: 'join-item btn min-h-full h-10' %>
<%= link_to '»', url_for(params: request.query_parameters.merge('page' => @pagy.next), anchor: 'log'), class: 'join-item btn min-h-full h-10' %>
<% else %>
<span class="join-item btn btn-disabled !bg-base-200 min-h-full h-10">»</span>
<% end %>
+1 -1
View File
@@ -17,7 +17,7 @@ Bundler.require(*Rails.groups)
module DocuSeal
class Application < Rails::Application
config.load_defaults 8.0
config.load_defaults 8.1
config.autoload_lib(ignore: %w[assets tasks puma])
+2 -3
View File
@@ -30,9 +30,8 @@ production:
<% elsif ENV['DATABASE_URL'].match?(/\Apostgres/) %>
<<: *default
url: <%= ENV['DATABASE_URL'] %>
<% elsif ENV['DATABASE_URL'].match?(/\Amysql/) %>
adapter: mysql2
<% elsif ENV['DATABASE_URL'].match?(/\Amysql|\Atrilogy/) %>
encoding: utf8mb4
pool: <%= ENV.fetch('RAILS_MAX_THREADS', 15).to_i + ENV.fetch('SIDEKIQ_THREADS', 5).to_i %>
url: <%= ENV['DATABASE_URL'] %>
url: <%= ENV['DATABASE_URL'].sub(/\Amysql2?/, 'trilogy') %>
<% end %>
+20 -4
View File
@@ -60,12 +60,28 @@ if ENV['RAILS_ENV'] == 'production'
ENV['DATABASE_URL'] = ENV['DATABASE_URL'].to_s.empty? ? database_url : ENV.fetch('DATABASE_URL', nil)
end
unless Process.uid == 2000
unless Process.euid == 2000
begin
Process::Sys.setgid(2000)
Process::Sys.setuid(2000)
test_file = "#{ENV.fetch('WORKDIR', '.')}/test"
orig_euid = Process.euid
orig_egid = Process.egid
Process::Sys.setegid(2000)
Process::Sys.seteuid(2000)
File.open(test_file, 'w') { true }
rescue StandardError
puts 'Unable to run as 2000:2000'
Process::Sys.seteuid(orig_euid)
Process::Sys.setegid(orig_egid)
puts "Unable to run as 2000:2000, running as #{orig_euid}:#{orig_egid}"
ensure
begin
File.unlink(test_file)
rescue StandardError
nil
end
end
end
end
+2 -1
View File
@@ -85,8 +85,9 @@ Rails.application.configure do
domain: ENV.fetch('SMTP_DOMAIN', nil),
user_name: ENV.fetch('SMTP_USERNAME', nil),
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_auto: ENV['SMTP_ENABLE_STARTTLS_AUTO'] != 'false'
enable_starttls: ENV['SMTP_ENABLE_STARTTLS'] != 'false'
}.compact
end
+11
View File
@@ -0,0 +1,11 @@
# frozen_string_literal: true
autoload :CSV, 'csv'
autoload :CSVSafe, 'csv-safe'
autoload :RubyXL, 'rubyXL'
autoload :Zip, 'zip'
autoload :Numo, 'numo/narray'
autoload :OnnxRuntime, 'onnxruntime'
autoload :RQRCode, 'rqrcode'
autoload :ArabicLetterConnector, 'arabic-letter-connector/logic'
autoload :JWT, 'jwt'
-3
View File
@@ -1,3 +0,0 @@
# frozen_string_literal: true
require 'csv'
+1 -8
View File
@@ -1,10 +1,3 @@
# frozen_string_literal: true
require 'pagy/extras/countless'
Pagy::DEFAULT[:limit] = 10
Pagy::DEFAULT.freeze
ActiveSupport.on_load(:action_view) do
include Pagy::Frontend
end
Pagy.options[:limit] = 10
+14
View File
@@ -29,6 +29,8 @@ en: &en
pro: Pro
thanks: Thanks
private: Private
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
when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: When checked, each signer must verify their email with a one-time code before accessing the document.
require_phone_2fa: Require phone 2FA
@@ -1029,6 +1031,8 @@ es: &es
stripe_account_has_been_connected: La cuenta de Stripe ha sido conectada.
re_connect_stripe: Volver a conectar Stripe
private: Privado
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
when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Cuando está marcado, cada firmante debe verificar su correo electrónico con un código de un solo uso antes de acceder al documento.
require_phone_2fa: Requerir 2FA por teléfono
@@ -2011,6 +2015,8 @@ it: &it
stripe_account_has_been_connected: L'account Stripe è stato collegato.
re_connect_stripe: Ricollega Stripe
private: Privato
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
when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Quando selezionato, ogni firmatario deve verificare la propria email con un codice monouso prima di accedere al documento.
require_phone_2fa: Richiedi 2FA telefono
@@ -2994,6 +3000,8 @@ fr: &fr
stripe_account_has_been_connected: Le compte Stripe a été connecté.
re_connect_stripe: Reconnecter Stripe
private: Privé
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
when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Lorsque coché, chaque signataire doit vérifier son email avec un code à usage unique avant d'accéder au document.
require_phone_2fa: Exiger la 2FA par téléphone
@@ -3973,6 +3981,8 @@ pt: &pt
stripe_account_has_been_connected: Conta Stripe foi conectada.
re_connect_stripe: Reconectar Stripe
private: Privado
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
when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Quando marcado, cada signatário deve verificar seu email com um código de uso único antes de acessar o documento.
require_phone_2fa: Exigir 2FA por telefone
@@ -4941,6 +4951,8 @@ de: &de
pro: Pro
thanks: Danke
private: Privat
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
when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Wenn aktiviert, muss jeder Unterzeichner seine E-Mail mit einem einmaligen Code verifizieren, bevor er auf das Dokument zugreifen kann.
require_phone_2fa: Telefon 2FA erforderlich
@@ -6311,6 +6323,8 @@ nl: &nl
pro: Pro
thanks: Bedankt
private: Privé
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
when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Wanneer aangevinkt, moet elke ondertekenaar zijn e-mail verifiëren met een eenmalige code voordat hij toegang krijgt tot het document.
require_phone_2fa: Telefoon 2FA vereist
+1
View File
@@ -53,6 +53,7 @@ Rails.application.routes.draw do
resources :verify_pdf_signature, only: %i[create]
resource :mfa_setup, only: %i[show new edit create destroy], controller: 'mfa_setup'
resources :account_configs, only: %i[create destroy]
resources :account_custom_fields, only: %i[create]
resources :user_configs, only: %i[create]
resources :encrypted_user_configs, only: %i[destroy]
resources :timestamp_server, only: %i[create]
+1 -1
View File
@@ -8,7 +8,7 @@ default: &default
shakapacker_precompile: true
webpack_compile_output: true
additional_paths: []
webpack_loader: 'babel'
javascript_transpiler: 'babel'
compiler_strategy: digest
cache_manifest: false
ensure_consistent_versioning: false
+1 -1
View File
@@ -26,7 +26,7 @@ google:
cache_control: "public, max-age=31536000"
azure:
service: AzureStorage
service: AzureBlob
storage_account_name: <%= ENV['AZURE_STORAGE_ACCOUNT_NAME'] %>
storage_access_key: <%= ENV['AZURE_STORAGE_ACCESS_KEY'] %>
container: <%= ENV['AZURE_CONTAINER'] %>
+10 -4
View File
@@ -45,19 +45,25 @@ module ActionMailerConfigsInterceptor
def build_smtp_configs_hash(email_configs)
value = email_configs.value
is_tls = value['security'] == 'tls' || (value['security'].blank? && value['port'].to_s == '465')
is_ssl = value['security'] == 'ssl'
is_noverify = value['security'] == 'noverify'
enable_starttls = is_noverify ? :enable_starttls_auto : :enable_starttls
{
user_name: value['username'],
password: value['password'],
address: value['host'],
port: value['port'],
domain: value['domain'],
openssl_verify_mode: value['security'] == 'noverify' ? OpenSSL::SSL::VERIFY_NONE : nil,
openssl_verify_mode: is_noverify ? OpenSSL::SSL::VERIFY_NONE : nil,
authentication: value['password'].present? ? value.fetch('authentication', 'plain') : nil,
enable_starttls_auto: value['security'] != 'tls',
enable_starttls => !is_tls && !is_ssl,
open_timeout: OPEN_TIMEOUT,
read_timeout: READ_TIMEOUT,
ssl: value['security'] == 'ssl',
tls: value['security'] == 'tls' || (value['security'].blank? && value['port'].to_s == '465')
ssl: is_ssl,
tls: is_tls
}.compact_blank
end
end
+10 -3
View File
@@ -6,9 +6,16 @@ class NormalizeClientIpMiddleware
end
def call(env)
if env['HTTP_CLIENT_IP'].present? && env['HTTP_X_CLIENT_IP'].present? &&
env['HTTP_CLIENT_IP'].starts_with?("#{env['HTTP_X_CLIENT_IP']}:")
env['HTTP_CLIENT_IP'] = env['HTTP_X_CLIENT_IP']
if env['HTTP_CLIENT_IP'].present?
if env['HTTP_X_CLIENT_IP'].present? &&
env['HTTP_CLIENT_IP'].starts_with?("#{env['HTTP_X_CLIENT_IP']}:")
env['HTTP_CLIENT_IP'] = env['HTTP_X_CLIENT_IP']
end
if env['HTTP_X_FORWARDED_FOR'].present? &&
env['HTTP_X_FORWARDED_FOR'].sub(/:\d+\z/, '') == env['HTTP_CLIENT_IP']
env['HTTP_X_FORWARDED_FOR'] = env['HTTP_CLIENT_IP']
end
end
@app.call(env)
+1 -3
View File
@@ -364,9 +364,7 @@ class Pdfium
@closed
end
def form_handle
@document.form_handle
end
delegate :form_handle, to: :@document
def ensure_not_closed!
raise PdfiumError, 'Page is closed.' if closed?
+3 -3
View File
@@ -9,7 +9,7 @@ Puma::Plugin.create do
@puma_pid = $PROCESS_ID
launcher.events.on_booted do
launcher.events.after_booted do
@redis_server_pid = fork_redis
end
@@ -19,8 +19,8 @@ Puma::Plugin.create do
stop_redis_server if Process.pid == @puma_pid
end
launcher.events.on_stopped { stop_redis_server }
launcher.events.on_restart { stop_redis_server }
launcher.events.after_stopped { stop_redis_server }
launcher.events.before_restart { stop_redis_server }
end
private
+3 -3
View File
@@ -14,14 +14,14 @@ Puma::Plugin.create do
end
def start(launcher)
launcher.events.on_booted do
launcher.events.after_booted do
next if Puma.stats_hash[:workers].to_i != 0
start_sidekiq!
end
launcher.events.on_stopped { Thread.new { @sidekiq&.stop }.join }
launcher.events.on_restart { Thread.new { @sidekiq&.stop }.join }
launcher.events.after_stopped { Thread.new { @sidekiq&.stop }.join }
launcher.events.before_restart { Thread.new { @sidekiq&.stop }.join }
end
def fire_event(config, event)
+5 -2
View File
@@ -61,7 +61,8 @@ module Submissions
submission.template_submitters << template_submitter
is_order_sent = submitters_order == 'random' || (template_submitter['order'] || index).zero?
is_order_sent = submitters_order == 'random' ||
(template_submitter['order'] || submitter_attrs[:index] || index).zero?
build_submitter(submission:, attrs: submitter_attrs,
uuid:, is_order_sent:, user:, params:,
@@ -115,6 +116,8 @@ module Submissions
next if item['invite_by_uuid'].blank? && item['optional_invite_by_uuid'].blank?
next if submission.template_submitters.any? { |e| e['uuid'] == item['uuid'] }
item = item.merge('order' => submitter_attr['order']) if submitter_attr && submitter_attr['order'].present?
if index.zero?
submission.template_submitters.insert(1, item)
elsif submission.template_submitters.size > index
@@ -306,7 +309,7 @@ module Submissions
uuid = attrs[:uuid].presence
uuid ||= submitters.find { |e| e['name'].to_s.casecmp(attrs[:role].to_s).zero? }&.dig('uuid')
uuid || submitters[index]&.dig('uuid')
uuid || submitters[attrs[:index] || index]&.dig('uuid')
end
def build_submitter(submission:, attrs:, uuid:, is_order_sent:, user:, preferences:, params:)
+3 -1
View File
@@ -19,7 +19,7 @@ module Submissions
total_wait_time ||= 0
key = ['result_attachments', submitter.id].join(':')
return submitter.documents if ApplicationRecord.uncached { LockEvent.exists?(key:, event_name: :complete) }
return submitter.documents.reload if ApplicationRecord.uncached { LockEvent.exists?(key:, event_name: :complete) }
events = ApplicationRecord.uncached { LockEvent.where(key:).order(:id).to_a }
@@ -32,6 +32,8 @@ module Submissions
LockEvent.create!(key:, event_name: :complete)
submitter.documents.reset
documents
end
rescue ActiveRecord::RecordNotUnique
+18 -2
View File
@@ -116,6 +116,7 @@ module Submissions
configs = submission.account.account_configs.where(key: [AccountConfig::WITH_AUDIT_VALUES_KEY,
AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::WITH_FILE_LINKS_KEY,
AccountConfig::WITH_AUDIT_SENDER_KEY,
AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY])
last_submitter = submission.submitters.select(&:completed_at).max_by(&:completed_at)
@@ -123,6 +124,7 @@ module Submissions
with_signature_id = configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true
with_file_links = configs.find { |c| c.key == AccountConfig::WITH_FILE_LINKS_KEY }&.value == true
with_audit_values = configs.find { |c| c.key == AccountConfig::WITH_AUDIT_VALUES_KEY }&.value != false
with_audit_sender = configs.find { |c| c.key == AccountConfig::WITH_AUDIT_SENDER_KEY }&.value == true
with_submitter_timezone = configs.find { |c| c.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true
timezone = account.timezone
@@ -464,6 +466,11 @@ module Submissions
name].join(' ')
I18n.t('submission_event_names.invite_party_by_html', invited_submitter_name:,
submitter_name:)
elsif with_audit_sender && (event.event_type == 'send_email' || event.event_type == 'send_sms')
[
I18n.t("submission_event_names.#{event.event_type}_to_html", submitter_name:),
"<b>#{I18n.t(:from)}</b> #{submission.created_by_user.full_name} #{submission.created_by_user.email}"
].join("\n")
elsif event.event_type.include?('send_')
I18n.t("submission_event_names.#{event.event_type}_to_html", submitter_name:)
else
@@ -472,11 +479,20 @@ module Submissions
bold_text, normal_text = text.match(%r{<b>(.*?)</b>(.*)}).captures
text_box = [{ text: bold_text, font: [FONT_NAME, { variant: :bold }] }, normal_text]
if text.include?("\n")
text_box = text.split("\n")[1..].reduce(text_box) do |acc, row|
bold_text, normal_text = row.match(%r{<b>(.*?)</b>(.*)}).captures
[*acc, "\n", { text: bold_text, font: [FONT_NAME, { variant: :bold }] }, normal_text]
end
end
[
"#{I18n.l(event.event_timestamp.in_time_zone(timezone), format: :long, locale: account.locale)} " \
"#{TimeUtils.timezone_abbr(timezone, event.event_timestamp)}",
composer.document.layout.formatted_text_box([{ text: bold_text, font: [FONT_NAME, { variant: :bold }] },
normal_text])
composer.document.layout.formatted_text_box(text_box)
]
end
@@ -200,7 +200,9 @@ module Submissions
def fill_submitter_fields(submitter, account, pdfs_index, with_signature_id:, is_flatten:, with_headings: nil,
with_submitter_timezone: false, with_signature_id_reason: true, with_file_links: nil)
cell_layouter = HexaPDF::Layout::TextLayouter.new(text_valign: :center, text_align: :center)
cell_layouters = Hash.new do |hash, valign|
hash[valign] = HexaPDF::Layout::TextLayouter.new(text_valign: valign.to_sym, text_align: :center)
end
attachments_data_cache = {}
@@ -238,7 +240,7 @@ module Submissions
width = page.box.width
height = page.box.height
preferences_font_size = field.dig('preferences', 'font_size').then { |num| num.present? ? num.to_i : nil }
preferences_font_size = field.dig('preferences', 'font_size').then { |num| num.presence&.to_i }
font_size = preferences_font_size
font_size ||= (([page.box.width, page.box.height].min / A4_SIZE[0].to_f) * FONT_SIZE).to_i
@@ -550,6 +552,8 @@ module Submissions
)
when ->(type) { type == 'cells' && !area['cell_w'].to_f.zero? }
cell_width = area['cell_w'] * width
cell_valign = field.dig('preferences', 'valign').to_s.presence || 'center'
cell_layouter = cell_layouters[cell_valign]
if (mask = field.dig('preferences', 'mask').presence)
value = TextUtils.mask_value(value, mask)
@@ -790,10 +794,10 @@ module Submissions
def build_pdfs_index(submission, submitter: nil, flatten: true)
latest_submitter = find_last_submitter(submission, submitter:)
Submissions::EnsureResultGenerated.call(latest_submitter) if latest_submitter
documents = Submissions::EnsureResultGenerated.call(latest_submitter) if latest_submitter
documents ||= submission.schema_documents
documents = latest_submitter&.documents&.preload(:blob).to_a.presence
documents ||= submission.schema_documents.preload(:blob)
ActiveRecord::Associations::Preloader.new(records: documents, associations: [:blob]).call
attachment_uuids = Submissions.filtered_conditions_schema(submission).pluck('attachment_uuid')
attachments_index = documents.index_by { |a| a.metadata['original_uuid'] || a.uuid }
+2 -1
View File
@@ -10,7 +10,8 @@ module Submissions
Array.wrap(submissions_params).each do |submission|
submission[:submitters].each_with_index do |submitter, index|
_, new_attachments, new_fields = normalize_submitter_params!(submitter, template, index, add_fields:)
_, new_attachments, new_fields =
normalize_submitter_params!(submitter, template, submitter[:index] || index, add_fields:)
attachments.push(*new_attachments)
fields.push(*new_fields)
+6
View File
@@ -14,6 +14,7 @@ module Submissions
module_function
# rubocop:disable Metrics
def call(submission, submitters = nil, params = {}, with_events: true, with_documents: true, with_values: true,
expires_at: Accounts.link_expires_at(Account.new(id: submission.account_id)))
submitters ||= submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob)
@@ -32,6 +33,10 @@ module Submissions
json['submission_events'] = Submitters::SerializeForApi.serialize_events(submission.submission_events)
end
if params[:include].to_s.include?('fields')
json['fields'] = submission.template_fields || submission.template&.fields
end
if submitters.all?(&:completed_at?)
last_submitter = submitters.max_by(&:completed_at)
@@ -57,6 +62,7 @@ module Submissions
json
end
# rubocop:enable Metrics
def build_status(submission, submitters)
if submitters.any?(&:declined_at?)
+1 -1
View File
@@ -35,7 +35,7 @@ module Submissions
conn = Faraday.new(uri.origin) do |c|
c.options.read_timeout = TIMEOUT
c.options.open_timeout = TIMEOUT
c.basic_auth(uri.user, uri.password) if uri.password.present?
c.request :authorization, :basic, uri.user, uri.password if uri.password.present?
end
response = conn.post(uri.request_uri, build_payload(digest.digest),
+1 -1
View File
@@ -55,7 +55,7 @@ module Submitters
def serialize_events(events)
events.map do |event|
event.as_json(only: %i[id submitter_id event_type event_timestamp])
.merge('data' => event.data.slice('reason', 'firstname', 'lastname', 'method', 'country'))
.merge('data' => event.data.slice('reason', 'firstname', 'lastname', 'method', 'country', 'idcode'))
end
end
end
+23 -4
View File
@@ -48,6 +48,10 @@ module Submitters
maybe_set_signature_reason!(values, submitter, params)
validate_values!(values, submitter, params, request)
if (touch_attachment_uuid = params[:touch_attachment_uuid].presence)
ActiveStorage::Attachment.where(uuid: touch_attachment_uuid, record: submitter).touch_all(:created_at)
end
SubmissionEvents.create_with_tracking_data(submitter, 'complete_form', request) if params[:completed] == 'true'
submitter.save!
@@ -317,17 +321,33 @@ module Submitters
end.exclude?(false)
end
# rubocop:disable Metrics
def check_field_condition(condition, submitter_values, fields_uuid_index)
value = submitter_values[condition['field_uuid']]
field = fields_uuid_index[condition['field_uuid']]
case condition['action']
when 'empty', 'unchecked'
value.blank?
when 'not_empty', 'checked'
value.present?
when 'equal', 'contains'
field = fields_uuid_index[condition['field_uuid']]
when ->(action) { action == 'equal' && field&.dig('type') == 'number' }
return false if value.blank? || condition['value'].blank?
(value.to_f - condition['value'].to_f).abs < Float::EPSILON
when ->(action) { action == 'not_equal' && field&.dig('type') == 'number' }
return false if value.blank? || condition['value'].blank?
(value.to_f - condition['value'].to_f).abs > Float::EPSILON
when 'greater_than'
return false if field.nil? || value.blank? || condition['value'].blank?
value.to_f > condition['value'].to_f
when 'less_than'
return false if field.nil? || value.blank? || condition['value'].blank?
value.to_f < condition['value'].to_f
when 'equal', 'contains'
return true unless field
values = Array.wrap(value)
@@ -340,8 +360,6 @@ module Submitters
values.include?(option['value'].presence || "#{I18n.t('option')} #{field['options'].index(option) + 1}")
when 'not_equal', 'does_not_contain'
field = fields_uuid_index[condition['field_uuid']]
return true unless field
return false unless field['options']
@@ -356,6 +374,7 @@ module Submitters
true
end
end
# rubocop:enable Metrics
def replace_default_variables(value, attrs, submission, with_time: false)
return value if value.in?([true, false]) || value.is_a?(Numeric) || value.is_a?(Array)
+13 -11
View File
@@ -22,34 +22,36 @@
"canvas-confetti": "^1.6.0",
"chart.js": "^4.5.1",
"codemirror": "^6.0.2",
"compression-webpack-plugin": "10.0.0",
"css-loader": "^6.7.3",
"compression-webpack-plugin": "11.1.0",
"css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^5.0.0",
"daisyui": "^3.9.4",
"driver.js": "^1.3.5",
"mathjs": "^12.4.0",
"mini-css-extract-plugin": "^2.7.5",
"mini-css-extract-plugin": "^2.10.0",
"postcss": "^8.4.31",
"postcss-import": "^15.1.0",
"postcss-loader": "^7.3.0",
"qr-creator": "^1.0.0",
"rollbar": "^2.26.4",
"sass": "^1.62.1",
"sass-loader": "^13.2.2",
"shakapacker": "8.0.0",
"sass-loader": "^16.0.6",
"shakapacker": "9.5.0",
"signature_pad": "^4.1.5",
"snarkdown": "^2.0.0",
"style-loader": "^4.0.0",
"tailwindcss": "^3.4.17",
"terser-webpack-plugin": "5.3.8",
"terser-webpack-plugin": "5.3.16",
"uuid": "^9.0.0",
"vue": "^3.3.2",
"vue-loader": "^17.1.1",
"webpack": "5.94.0",
"webpack-assets-manifest": "5",
"webpack": "5.104.1",
"webpack-assets-manifest": "6.5.0",
"webpack-bundle-analyzer": "^4.7.0",
"webpack-cli": "5.1.1",
"webpack-dev-server": "^4.15.0",
"webpack-merge": "5"
"webpack-cli": "6.0.1",
"webpack-dev-server": "^5.2.3",
"webpack-merge": "6.0.1",
"webpack-subresource-integrity": "^5.1.0"
},
"packageManager": "yarn@1.0",
"version": "0.1.0",
+1325 -666
View File
File diff suppressed because it is too large Load Diff