Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| daaa289a5c | |||
| b93c7cd261 | |||
| 570482e12d | |||
| 39407557f2 | |||
| 3b040d558d | |||
| 56ba3c2a52 | |||
| c7afe33c72 | |||
| 82500c2d7d | |||
| fb1c1f117b | |||
| 7c6a7513fd | |||
| 6d13119ee0 | |||
| 1e2c752937 | |||
| e82faf8320 | |||
| 4adb1001b0 | |||
| 992a1b26c0 | |||
| c07bc6687c | |||
| eea44bda34 | |||
| 7cdf263da1 | |||
| ee65a5693c | |||
| 0af6ccf35f | |||
| 888f1ec6df | |||
| c95a8616ac | |||
| 5ea6289b7a | |||
| d4a79ca5db | |||
| 70015ce1c4 | |||
| 6b85c28944 | |||
| 6c289cf273 | |||
| a64bc3c618 | |||
| 46cf1e3067 | |||
| 565e1eb2bc | |||
| e689687805 | |||
| 3c3b61fb47 | |||
| 1355d350c5 | |||
| fda911e178 | |||
| f995e1864c | |||
| 911e55ccc3 | |||
| 41fedfcc40 | |||
| 2f9fc95af3 | |||
| 11b0b16ca7 | |||
| f8cb7ffdab | |||
| 8b59c0aaa0 | |||
| bb2fb7a0c2 | |||
| 8f8b36617a | |||
| ff53436fd4 | |||
| cf09a4f733 | |||
| 2303c21cea | |||
| 89e797b95f | |||
| 20375c3a42 | |||
| 339ceda18d | |||
| 97ce32fd52 | |||
| 8c4cbd86f7 | |||
| eaedaa96bb | |||
| bd7ad5fce8 | |||
| 759dac1b70 | |||
| 38577c6235 | |||
| b24fff0f55 | |||
| 9e0efeb092 | |||
| 41152f90fc | |||
| 4486f8040e | |||
| a98caad968 | |||
| ffb75238b4 | |||
| cba3e74f3a | |||
| 8d3f0b5b98 | |||
| a2598b30f7 | |||
| 8d11f5ddae | |||
| 6ffc88b4ed | |||
| 640b451727 | |||
| db6e2eb7ca | |||
| 407816b576 | |||
| 486cc493e6 | |||
| 3eb0b8a89f | |||
| c23eca0ade | |||
| 67de153016 | |||
| ba2da69a67 | |||
| 111bef14e1 | |||
| 22d4c1bd10 | |||
| e3dfb0ae83 | |||
| 8d7c3f0e26 | |||
| b11775ebda | |||
| 608191183a | |||
| 2ba903e170 | |||
| 342b64d502 | |||
| 1a8f048aaa | |||
| 7309581b75 | |||
| 36d59c40a7 | |||
| ed3c7c324b | |||
| fd868360a3 | |||
| 8dc2ebfbe0 | |||
| 997711b34a | |||
| ac01b7129d | |||
| f4b7528c0d | |||
| d7b9ae5df5 | |||
| 4db29126e9 | |||
| 4cfee2fe45 | |||
| db5dbd6ac2 | |||
| 44db775b89 | |||
| 8e2e780a1a | |||
| 7aba2eb4da | |||
| 3e250bfb28 | |||
| 9c02ed66f5 | |||
| f5ae8c60d3 | |||
| e90ac391b2 | |||
| 0e5f949bcf | |||
| b7c270bef8 | |||
| 5f16b506f5 | |||
| 176148fc71 |
@@ -28,7 +28,7 @@ Lint/MissingSuper:
|
||||
Enabled: false
|
||||
|
||||
Metrics/ParameterLists:
|
||||
Max: 10
|
||||
Max: 12
|
||||
|
||||
Metrics/MethodLength:
|
||||
Max: 30
|
||||
@@ -51,6 +51,15 @@ Style/NumericPredicate:
|
||||
Style/MinMaxComparison:
|
||||
Enabled: false
|
||||
|
||||
Style/EmptyClassDefinition:
|
||||
Enabled: false
|
||||
|
||||
Style/OneClassPerFile:
|
||||
Enabled: false
|
||||
|
||||
Layout/MultilineMethodCallIndentation:
|
||||
Enabled: false
|
||||
|
||||
Naming/PredicateMethod:
|
||||
Enabled: false
|
||||
|
||||
|
||||
@@ -2,20 +2,17 @@ FROM ruby:4.0.1-alpine AS download
|
||||
|
||||
WORKDIR /fonts
|
||||
|
||||
RUN apk --no-cache add fontforge wget && \
|
||||
RUN apk --no-cache add wget && \
|
||||
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && \
|
||||
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && \
|
||||
wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && \
|
||||
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 https://raw.githubusercontent.com/impallari/DancingScript/master/OFL.txt && \
|
||||
wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \
|
||||
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \
|
||||
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
|
||||
wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-musl-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
|
||||
mkdir -p /pdfium-linux && \
|
||||
tar -xzf pdfium-linux.tgz -C /pdfium-linux
|
||||
|
||||
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:4.0.1-alpine AS webpack
|
||||
|
||||
ENV RAILS_ENV=production
|
||||
@@ -47,13 +44,11 @@ FROM ruby:4.0.1-alpine AS app
|
||||
|
||||
ENV RAILS_ENV=production
|
||||
ENV BUNDLE_WITHOUT="development:test"
|
||||
ENV LD_PRELOAD=/lib/libgcompat.so.0
|
||||
ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
|
||||
ENV VIPS_MAX_COORD=15000
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache sqlite-dev libpq-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont onnxruntime && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
|
||||
RUN apk add --no-cache libpq vips redis vips-heif onnxruntime
|
||||
|
||||
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
|
||||
|
||||
@@ -71,7 +66,7 @@ activate = 1' >> /etc/openssl_legacy.cnf
|
||||
|
||||
COPY --chown=docuseal:docuseal ./Gemfile ./Gemfile.lock ./
|
||||
|
||||
RUN apk add --no-cache build-base git && bundle install && apk del --no-cache build-base git && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
|
||||
RUN apk add --no-cache build-base git libpq-dev yaml-dev && bundle install && apk del --no-cache build-base git libpq-dev yaml-dev && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales,resources/unicode_data/properties}'] + Dir['/usr/local/bundle/gems/*/{test,tests,examples,sample,misc,doc,docs}'] + Dir['/usr/local/bundle/gems/*/ext/**/*.{c,h,o,S}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
|
||||
|
||||
COPY --chown=docuseal:docuseal ./bin ./bin
|
||||
COPY --chown=docuseal:docuseal ./app ./app
|
||||
@@ -84,20 +79,21 @@ COPY --chown=docuseal:docuseal ./tmp ./tmp
|
||||
COPY --chown=docuseal:docuseal LICENSE LICENSE_ADDITIONAL_TERMS README.md Rakefile config.ru .version ./
|
||||
COPY --chown=docuseal:docuseal .version ./public/version
|
||||
|
||||
COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
|
||||
COPY --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont
|
||||
COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts/LICENSE /fonts/
|
||||
COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
|
||||
COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt
|
||||
COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx
|
||||
COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs
|
||||
|
||||
RUN ln -s /fonts /app/public/fonts && \
|
||||
RUN mkdir -p /app/public/fonts && ln -s /fonts/DancingScript-Regular.otf /app/public/fonts/ && \
|
||||
mkdir -p /usr/share/fonts/noto && ln -s /fonts/GoNotoKurrent-Regular.ttf /usr/share/fonts/noto/ && ln -s /fonts/GoNotoKurrent-Bold.ttf /usr/share/fonts/noto/ && fc-cache -f && \
|
||||
bundle exec bootsnap precompile -j 1 --gemfile app/ lib/ && \
|
||||
chown -R docuseal:docuseal /app/tmp/cache
|
||||
|
||||
WORKDIR /data/docuseal
|
||||
ENV HOME=/home/docuseal
|
||||
ENV WORKDIR=/data/docuseal
|
||||
ENV VIPS_MAX_COORD=17000
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
|
||||
|
||||
@@ -4,6 +4,7 @@ source 'https://rubygems.org'
|
||||
|
||||
ruby '4.0.1'
|
||||
|
||||
gem 'addressable'
|
||||
gem 'arabic-letter-connector', require: false
|
||||
gem 'aws-sdk-s3', require: false
|
||||
gem 'aws-sdk-secretsmanager', require: false
|
||||
@@ -28,7 +29,6 @@ gem 'oj'
|
||||
gem 'onnxruntime', require: false
|
||||
gem 'pagy'
|
||||
gem 'pg', require: false
|
||||
gem 'premailer-rails'
|
||||
gem 'pretender'
|
||||
gem 'puma', require: false
|
||||
gem 'rack'
|
||||
@@ -43,7 +43,7 @@ gem 'shakapacker'
|
||||
gem 'sidekiq'
|
||||
gem 'sqlite3', require: false
|
||||
gem 'strip_attributes'
|
||||
gem 'trilogy', github: 'trilogy-libraries/trilogy', glob: 'contrib/ruby/*.gemspec', require: false
|
||||
gem 'trilogy', require: false
|
||||
gem 'turbo-rails'
|
||||
gem 'twitter_cldr', require: false
|
||||
gem 'tzinfo-data'
|
||||
|
||||
@@ -1,39 +1,31 @@
|
||||
GIT
|
||||
remote: https://github.com/trilogy-libraries/trilogy.git
|
||||
revision: 3963d490459df7a2b5bedb42424c3285f25eab22
|
||||
glob: contrib/ruby/*.gemspec
|
||||
specs:
|
||||
trilogy (2.10.0)
|
||||
bigdecimal
|
||||
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
action_text-trix (2.1.16)
|
||||
action_text-trix (2.1.18)
|
||||
railties
|
||||
actioncable (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actioncable (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionmailbox (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionmailer (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionpack (8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
@@ -41,36 +33,36 @@ GEM
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.1.2)
|
||||
actiontext (8.1.3)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionpack (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionview (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activejob (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activerecord (8.1.2)
|
||||
activemodel (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activemodel (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
activerecord (8.1.3)
|
||||
activemodel (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activestorage (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.1.2)
|
||||
activesupport (8.1.3)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
@@ -83,16 +75,16 @@ GEM
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
addressable (2.8.8)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
annotaterb (4.20.0)
|
||||
annotaterb (4.22.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.1209.0)
|
||||
aws-sdk-core (3.241.4)
|
||||
aws-partitions (1.1233.0)
|
||||
aws-sdk-core (3.244.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -100,15 +92,15 @@ GEM
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.121.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sdk-kms (1.123.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.212.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sdk-s3 (1.218.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-secretsmanager (1.128.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sdk-secretsmanager (1.129.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
@@ -116,7 +108,7 @@ GEM
|
||||
cgi
|
||||
rexml
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.21)
|
||||
bcrypt (3.1.22)
|
||||
better_html (2.2.0)
|
||||
actionview (>= 7.0)
|
||||
activesupport (>= 7.0)
|
||||
@@ -124,11 +116,11 @@ GEM
|
||||
erubi (~> 1.4)
|
||||
parser (>= 2.4)
|
||||
smart_properties
|
||||
bigdecimal (4.0.1)
|
||||
bigdecimal (4.1.0)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.21.1)
|
||||
bootsnap (1.23.0)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.1.2)
|
||||
brakeman (8.0.4)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
bullet (8.1.0)
|
||||
@@ -158,8 +150,6 @@ GEM
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
css_parser (1.21.1)
|
||||
addressable
|
||||
csv (3.3.5)
|
||||
csv-safe (3.3.1)
|
||||
csv (~> 3.0)
|
||||
@@ -171,16 +161,16 @@ GEM
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
declarative (0.0.20)
|
||||
devise (4.9.4)
|
||||
devise (5.0.3)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
railties (>= 4.1.0)
|
||||
railties (>= 7.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise-two-factor (6.3.1)
|
||||
activesupport (>= 7.0, < 8.2)
|
||||
devise (>= 4.0, < 5.0)
|
||||
railties (>= 7.0, < 8.2)
|
||||
devise-two-factor (6.4.0)
|
||||
activesupport (>= 7.2, < 8.2)
|
||||
devise (>= 4.0, < 6.0)
|
||||
railties (>= 7.2, < 8.2)
|
||||
rotp (~> 6.0)
|
||||
diff-lcs (1.6.2)
|
||||
digest-crc (0.7.0)
|
||||
@@ -189,7 +179,7 @@ GEM
|
||||
dotenv (3.2.0)
|
||||
drb (2.2.3)
|
||||
email_typo (0.2.3)
|
||||
erb (6.0.1)
|
||||
erb (6.0.4)
|
||||
erb_lint (0.9.0)
|
||||
activesupport
|
||||
better_html (>= 2.0.1)
|
||||
@@ -203,7 +193,7 @@ GEM
|
||||
factory_bot_rails (6.5.1)
|
||||
factory_bot (~> 6.5)
|
||||
railties (>= 6.1.0)
|
||||
faker (3.6.0)
|
||||
faker (3.6.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.14.1)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
@@ -213,17 +203,17 @@ GEM
|
||||
faraday (>= 1, < 3)
|
||||
faraday-net_http (3.4.2)
|
||||
net-http (~> 0.5)
|
||||
ferrum (0.17.1)
|
||||
ferrum (0.17.2)
|
||||
addressable (~> 2.5)
|
||||
base64 (~> 0.2)
|
||||
concurrent-ruby (~> 1.1)
|
||||
webrick (~> 1.7)
|
||||
websocket-driver (~> 0.7)
|
||||
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)
|
||||
ffi (1.17.4-aarch64-linux-gnu)
|
||||
ffi (1.17.4-aarch64-linux-musl)
|
||||
ffi (1.17.4-arm64-darwin)
|
||||
ffi (1.17.4-x86_64-linux-gnu)
|
||||
ffi (1.17.4-x86_64-linux-musl)
|
||||
foreman (0.90.0)
|
||||
thor (~> 1.4)
|
||||
geom2d (0.4.1)
|
||||
@@ -239,7 +229,7 @@ GEM
|
||||
retriable (~> 3.1)
|
||||
google-apis-iamcredentials_v1 (0.26.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.59.0)
|
||||
google-apis-storage_v1 (0.61.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.8.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
@@ -247,8 +237,8 @@ GEM
|
||||
google-cloud-env (2.3.1)
|
||||
base64 (~> 0.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.5.0)
|
||||
google-cloud-storage (1.58.0)
|
||||
google-cloud-errors (1.6.0)
|
||||
google-cloud-storage (1.59.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
@@ -258,7 +248,7 @@ GEM
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
google-logging-utils (0.2.0)
|
||||
googleauth (1.16.1)
|
||||
googleauth (1.16.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.2)
|
||||
google-logging-utils (~> 0.1)
|
||||
@@ -267,24 +257,24 @@ GEM
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
hashdiff (1.2.1)
|
||||
hexapdf (1.5.0)
|
||||
hexapdf (1.6.0)
|
||||
cmdparse (~> 3.0, >= 3.0.3)
|
||||
geom2d (~> 0.4, >= 0.4.1)
|
||||
openssl (>= 2.2.1)
|
||||
strscan (>= 3.1.2)
|
||||
htmlentities (4.4.2)
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.14.0)
|
||||
mini_magick (>= 4.9.5, < 6)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
io-console (0.8.2)
|
||||
irb (1.16.0)
|
||||
irb (1.17.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.18.1)
|
||||
json (2.19.3)
|
||||
jwt (3.1.2)
|
||||
base64
|
||||
language_server-protocol (3.17.0.5)
|
||||
@@ -306,7 +296,7 @@ GEM
|
||||
activesupport (>= 4)
|
||||
railties (>= 4)
|
||||
request_store (~> 1.0)
|
||||
loofah (2.25.0)
|
||||
loofah (2.25.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.9.0)
|
||||
@@ -321,13 +311,14 @@ GEM
|
||||
mini_magick (5.3.1)
|
||||
logger
|
||||
mini_mime (1.1.5)
|
||||
minitest (6.0.1)
|
||||
minitest (6.0.3)
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.19.1)
|
||||
net-http (0.9.1)
|
||||
uri (>= 0.11.1)
|
||||
net-imap (0.6.2)
|
||||
net-imap (0.6.3)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -337,18 +328,18 @@ GEM
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.1-aarch64-linux-gnu)
|
||||
nokogiri (1.19.2-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-aarch64-linux-musl)
|
||||
nokogiri (1.19.2-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-arm64-darwin)
|
||||
nokogiri (1.19.2-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-x86_64-linux-gnu)
|
||||
nokogiri (1.19.2-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-x86_64-linux-musl)
|
||||
nokogiri (1.19.2-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
numo-narray-alt (0.9.13)
|
||||
oj (3.16.13)
|
||||
numo-narray-alt (0.10.3)
|
||||
oj (3.16.16)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
onnxruntime (0.10.1-aarch64-linux)
|
||||
@@ -357,16 +348,17 @@ GEM
|
||||
ffi
|
||||
onnxruntime (0.10.1-x86_64-linux)
|
||||
ffi
|
||||
openssl (4.0.0)
|
||||
openssl (4.0.1)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
package_json (0.2.0)
|
||||
pagy (43.2.8)
|
||||
pagy (43.4.4)
|
||||
json
|
||||
uri
|
||||
yaml
|
||||
parallel (1.27.0)
|
||||
parser (3.3.10.1)
|
||||
parallel (1.28.0)
|
||||
parser (3.3.11.1)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.6.3-aarch64-linux)
|
||||
@@ -376,18 +368,10 @@ GEM
|
||||
pg (1.6.3-x86_64-linux-musl)
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
premailer (1.27.0)
|
||||
addressable
|
||||
css_parser (>= 1.19.0)
|
||||
htmlentities (>= 4.0.0)
|
||||
premailer-rails (1.12.0)
|
||||
actionmailer (>= 3)
|
||||
net-smtp
|
||||
premailer (~> 1.7, >= 1.7.9)
|
||||
pretender (0.6.0)
|
||||
actionpack (>= 7.1)
|
||||
pretender (1.0.0)
|
||||
actionpack (>= 7.2)
|
||||
prettyprint (0.2.0)
|
||||
prism (1.8.0)
|
||||
prism (1.9.0)
|
||||
pry (0.16.0)
|
||||
coderay (~> 1.1)
|
||||
method_source (~> 1.0)
|
||||
@@ -397,47 +381,47 @@ GEM
|
||||
psych (5.3.1)
|
||||
date
|
||||
stringio
|
||||
public_suffix (7.0.2)
|
||||
public_suffix (7.0.5)
|
||||
puma (7.2.0)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.5)
|
||||
rack (3.2.6)
|
||||
rack-proxy (0.7.7)
|
||||
rack
|
||||
rack-session (2.1.1)
|
||||
rack-session (2.1.2)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.2.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
rails (8.1.2)
|
||||
actioncable (= 8.1.2)
|
||||
actionmailbox (= 8.1.2)
|
||||
actionmailer (= 8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
actiontext (= 8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activemodel (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
rails (8.1.3)
|
||||
actioncable (= 8.1.3)
|
||||
actionmailbox (= 8.1.3)
|
||||
actionmailer (= 8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
actiontext (= 8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activemodel (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.1.2)
|
||||
railties (= 8.1.3)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
rails-html-sanitizer (1.7.0)
|
||||
loofah (~> 2.25)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-i18n (8.1.0)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 8.0.0, < 9)
|
||||
railties (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
railties (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -446,11 +430,11 @@ GEM
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.1)
|
||||
rdoc (7.1.0)
|
||||
rdoc (7.2.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
redis-client (0.26.4)
|
||||
redis-client (0.28.0)
|
||||
connection_pool
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.3)
|
||||
@@ -464,7 +448,7 @@ GEM
|
||||
responders (3.2.0)
|
||||
actionpack (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
retriable (3.1.2)
|
||||
retriable (3.4.1)
|
||||
rexml (3.4.4)
|
||||
rotp (6.3.0)
|
||||
rouge (4.7.0)
|
||||
@@ -477,19 +461,19 @@ GEM
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.7)
|
||||
rspec-mocks (3.13.8)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (8.0.2)
|
||||
rspec-rails (8.0.4)
|
||||
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.6)
|
||||
rubocop (1.82.1)
|
||||
rspec-core (>= 3.13.0, < 5.0.0)
|
||||
rspec-expectations (>= 3.13.0, < 5.0.0)
|
||||
rspec-mocks (>= 3.13.0, < 5.0.0)
|
||||
rspec-support (>= 3.13.0, < 5.0.0)
|
||||
rspec-support (3.13.7)
|
||||
rubocop (1.86.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
@@ -497,10 +481,10 @@ GEM
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.48.0, < 2.0)
|
||||
rubocop-ast (>= 1.49.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.49.0)
|
||||
rubocop-ast (1.49.1)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.7)
|
||||
rubocop-performance (1.26.1)
|
||||
@@ -525,14 +509,14 @@ GEM
|
||||
rubyzip (>= 3.2.2)
|
||||
rubyzip (3.2.2)
|
||||
securerandom (0.4.1)
|
||||
semantic_range (3.1.0)
|
||||
shakapacker (9.5.0)
|
||||
semantic_range (3.1.1)
|
||||
shakapacker (9.7.0)
|
||||
activesupport (>= 5.2)
|
||||
package_json
|
||||
rack-proxy (>= 0.6.1)
|
||||
railties (>= 5.2)
|
||||
semantic_range (>= 2.3.0)
|
||||
sidekiq (8.1.0)
|
||||
sidekiq (8.1.2)
|
||||
connection_pool (>= 3.0.0)
|
||||
json (>= 2.16.0)
|
||||
logger (>= 1.7.0)
|
||||
@@ -550,20 +534,22 @@ GEM
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
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)
|
||||
sqlite3 (2.9.2-aarch64-linux-gnu)
|
||||
sqlite3 (2.9.2-aarch64-linux-musl)
|
||||
sqlite3 (2.9.2-arm64-darwin)
|
||||
sqlite3 (2.9.2-x86_64-linux-gnu)
|
||||
sqlite3 (2.9.2-x86_64-linux-musl)
|
||||
stringio (3.2.0)
|
||||
strip_attributes (2.0.1)
|
||||
activemodel (>= 3.0, < 9.0)
|
||||
strscan (3.1.7)
|
||||
strscan (3.1.8)
|
||||
thor (1.5.0)
|
||||
timeout (0.6.0)
|
||||
timeout (0.6.1)
|
||||
trailblazer-option (0.1.2)
|
||||
trilogy (2.12.2)
|
||||
bigdecimal
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.21)
|
||||
turbo-rails (2.0.23)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
twitter_cldr (6.14.0)
|
||||
@@ -573,7 +559,7 @@ GEM
|
||||
tzinfo
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
tzinfo-data (1.2025.3)
|
||||
tzinfo-data (1.2026.1)
|
||||
tzinfo (>= 1.0.0)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (3.2.0)
|
||||
@@ -584,12 +570,11 @@ GEM
|
||||
useragent (0.16.11)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
activemodel (>= 6.0.0)
|
||||
web-console (4.3.0)
|
||||
actionview (>= 8.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
webmock (3.26.1)
|
||||
railties (>= 8.0.0)
|
||||
webmock (3.26.2)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
@@ -601,7 +586,7 @@ GEM
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
yaml (0.4.0)
|
||||
zeitwerk (2.7.4)
|
||||
zeitwerk (2.7.5)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
@@ -611,6 +596,7 @@ PLATFORMS
|
||||
x86_64-linux-musl
|
||||
|
||||
DEPENDENCIES
|
||||
addressable
|
||||
annotaterb
|
||||
arabic-letter-connector
|
||||
aws-sdk-s3
|
||||
@@ -647,7 +633,6 @@ DEPENDENCIES
|
||||
onnxruntime
|
||||
pagy
|
||||
pg
|
||||
premailer-rails
|
||||
pretender
|
||||
pry-rails
|
||||
puma
|
||||
@@ -669,7 +654,7 @@ DEPENDENCIES
|
||||
simplecov
|
||||
sqlite3
|
||||
strip_attributes
|
||||
trilogy!
|
||||
trilogy
|
||||
turbo-rails
|
||||
twitter_cldr
|
||||
tzinfo-data
|
||||
|
||||
@@ -64,7 +64,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|
||||
|
||||
|Heroku|Railway|
|
||||
|:--:|:---:|
|
||||
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
|
||||
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.com/deploy/IGoDnc?referralCode=ruU7JR)|
|
||||
|**DigitalOcean**|**Render**|
|
||||
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ class AccountConfigsController < ApplicationController
|
||||
AccountConfig::FORCE_MFA,
|
||||
AccountConfig::ALLOW_TO_RESUBMIT,
|
||||
AccountConfig::ALLOW_TO_DECLINE_KEY,
|
||||
AccountConfig::ALLOW_TO_DELEGATE_KEY,
|
||||
AccountConfig::FORM_PREFILL_SIGNATURE_KEY,
|
||||
AccountConfig::ESIGNING_PREFERENCE_KEY,
|
||||
AccountConfig::FORM_WITH_CONFETTI_KEY,
|
||||
@@ -36,7 +37,7 @@ class AccountConfigsController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
raise InvalidKey unless ALLOWED_KEYS.include?(@account_config.key)
|
||||
raise InvalidKey unless allowed_keys.include?(@account_config.key)
|
||||
|
||||
@account_config.destroy!
|
||||
|
||||
@@ -45,8 +46,12 @@ class AccountConfigsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def allowed_keys
|
||||
ALLOWED_KEYS
|
||||
end
|
||||
|
||||
def load_account_config
|
||||
raise InvalidKey unless ALLOWED_KEYS.include?(account_config_params[:key])
|
||||
raise InvalidKey unless allowed_keys.include?(account_config_params[:key])
|
||||
|
||||
@account_config =
|
||||
AccountConfig.find_or_initialize_by(account: current_account, key: account_config_params[:key])
|
||||
|
||||
@@ -53,7 +53,9 @@ class AccountsController < ApplicationController
|
||||
# rubocop:disable Layout/LineLength
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
:account_delete_button,
|
||||
html: helpers.tag.p(I18n.t('your_account_removal_request_will_be_processed_within_2_months_please_contact_us_if_you_want_to_keep_your_account'))
|
||||
html: helpers.tag.p(I18n.t('your_account_will_be_permanently_deleted_within_2_months_please_contact_us_if_you_want_to_keep_your_account')) +
|
||||
helpers.tag.br +
|
||||
helpers.tag.p(I18n.t('your_email_address_has_been_released_immediately_you_can_now_be_added_to_your_company_team_without_waiting_for_the_deletion_period_to_end'))
|
||||
)
|
||||
# rubocop:enable Layout/LineLength
|
||||
end
|
||||
|
||||
@@ -10,6 +10,12 @@ module Api
|
||||
def create
|
||||
submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
unless can_upload?(submitter)
|
||||
Rollbar.error("Can't upload: #{submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
if params[:type].in?(%w[initials signature])
|
||||
image = Vips::Image.new_from_file(params[:file].path)
|
||||
|
||||
@@ -40,6 +46,14 @@ module Api
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def can_upload?(submitter)
|
||||
!submitter.declined_at? &&
|
||||
!submitter.completed_at? &&
|
||||
!submitter.submission.archived_at? &&
|
||||
!submitter.submission.expired? &&
|
||||
!submitter.submission.template&.archived_at?
|
||||
end
|
||||
|
||||
def build_new_cookie_signatures_json(submitter, attachment)
|
||||
values =
|
||||
begin
|
||||
|
||||
@@ -156,7 +156,7 @@ module Api
|
||||
params:)
|
||||
else
|
||||
submissions_attrs, attachments =
|
||||
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_params, template)
|
||||
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_params, template, purpose: :api)
|
||||
|
||||
submissions = Submissions.create_from_submitters(
|
||||
template:,
|
||||
|
||||
@@ -40,13 +40,18 @@ module Api
|
||||
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
if @submitter.declined_at?
|
||||
return render json: { error: 'Submitter has already declined the submission.' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
submission = @submitter.submission
|
||||
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
|
||||
|
||||
normalized_params, new_attachments = Submissions::NormalizeParamUtils.normalize_submitter_params!(
|
||||
submitter_params.merge(role:),
|
||||
@submitter.template || Template.new(submitters: submission.template_submitters, account: @submitter.account),
|
||||
for_submitter: @submitter
|
||||
for_submitter: @submitter,
|
||||
purpose: :api
|
||||
)
|
||||
|
||||
Submissions::CreateFromSubmitters.maybe_set_template_fields(submission, [normalized_params],
|
||||
@@ -156,9 +161,7 @@ module Api
|
||||
submitter.values = Submitters::SubmitValues.maybe_remove_condition_values(submitter)
|
||||
end
|
||||
|
||||
submitter.values = submitter.values.transform_values do |v|
|
||||
v == '{{date}}' ? Time.current.in_time_zone(submitter.account.timezone).to_date.to_s : v
|
||||
end
|
||||
submitter.values = Submitters::SubmitValues.replace_current_date_placeholders(submitter)
|
||||
end
|
||||
|
||||
submitter
|
||||
|
||||
@@ -9,7 +9,7 @@ module Api
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@template],
|
||||
associations: [schema_documents: :preview_images_attachments]
|
||||
associations: [{ schema_documents: :preview_images_attachments }]
|
||||
).call
|
||||
|
||||
cloned_template = Templates::Clone.call(
|
||||
|
||||
@@ -69,6 +69,7 @@ module Api
|
||||
SearchEntries.enqueue_reindex(@template)
|
||||
|
||||
WebhookUrls.enqueue_events(@template, 'template.updated')
|
||||
WebhookUrls.enqueue_events(@template, 'template.archived') if archived == true
|
||||
|
||||
render json: @template.as_json(only: %i[id updated_at])
|
||||
end
|
||||
@@ -78,6 +79,8 @@ module Api
|
||||
@template.destroy!
|
||||
else
|
||||
@template.update!(archived_at: Time.current)
|
||||
|
||||
WebhookUrls.enqueue_events(@template, 'template.archived')
|
||||
end
|
||||
|
||||
render json: @template.as_json(only: %i[id archived_at])
|
||||
|
||||
@@ -40,7 +40,7 @@ class ErrorsController < ActionController::Base
|
||||
render json: { status: error_status_code, error: }.compact, status: error_status_code
|
||||
end
|
||||
|
||||
f.html { render error_status_code.to_s, status: error_status_code }
|
||||
f.any { render error_status_code.to_s, status: error_status_code }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -51,7 +51,7 @@ class ErrorsController < ActionController::Base
|
||||
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
|
||||
headers['Access-Control-Allow-Headers'] = '*'
|
||||
headers['Access-Control-Max-Age'] = '1728000'
|
||||
headers['Access-Control-Allow-Credentials'] = true
|
||||
headers['Access-Control-Allow-Credentials'] = 'true'
|
||||
end
|
||||
|
||||
def error_status_code
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
class EsignSettingsController < ApplicationController
|
||||
DEFAULT_CERT_NAME = 'DocuSeal Self-Host Autogenerated'
|
||||
|
||||
CertFormRecord = Struct.new(:name, :file, :password, keyword_init: true) do
|
||||
CertFormRecord = Struct.new(:name, :file, :password) do
|
||||
include ActiveModel::Validations
|
||||
|
||||
def to_key
|
||||
|
||||
@@ -16,6 +16,7 @@ class SubmissionEventsController < ApplicationController
|
||||
'email_verified' => 'email_check',
|
||||
'click_sms' => 'hand_click',
|
||||
'decline_form' => 'x',
|
||||
'delegate_form' => 'user_share',
|
||||
'start_verification' => 'player_play',
|
||||
'complete_verification' => 'check',
|
||||
'invite_party' => 'user_plus'
|
||||
|
||||
@@ -26,7 +26,7 @@ class SubmissionsController < ApplicationController
|
||||
unless @submission.submitters.all?(&:completed_at?)
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@submission],
|
||||
associations: [submitters: :start_form_submission_events]
|
||||
associations: [{ submitters: :start_form_submission_events }]
|
||||
).call
|
||||
end
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ class SubmissionsDebugController < ApplicationController
|
||||
def index
|
||||
@submitter = Submitter.preload({ attachments_attachments: :blob },
|
||||
submission: { template: { documents_attachments: :blob } })
|
||||
.find_by(slug: params[:submitter_slug])
|
||||
.find_by(slug: params[:submit_form_slug])
|
||||
|
||||
respond_to do |f|
|
||||
f.html do
|
||||
|
||||
@@ -1,92 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmissionsDownloadController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
TTL = 40.minutes
|
||||
FILES_TTL = 5.minutes
|
||||
load_and_authorize_resource :submission
|
||||
|
||||
def index
|
||||
@submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
|
||||
|
||||
signature_valid =
|
||||
if @submitter&.slug == params[:submitter_slug]
|
||||
true
|
||||
else
|
||||
@submitter = nil
|
||||
end
|
||||
|
||||
@submitter ||= Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
Submissions::EnsureResultGenerated.call(@submitter)
|
||||
|
||||
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
|
||||
last_submitter = @submission.submitters.where.not(completed_at: nil).order(:completed_at).last
|
||||
|
||||
return head :not_found unless last_submitter
|
||||
|
||||
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
|
||||
if !signature_valid && !current_user_submitter?(last_submitter)
|
||||
return head :not_found unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
|
||||
if last_submitter.completed_at < TTL.ago
|
||||
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
if params[:combined] == 'true'
|
||||
respond_with_combined(last_submitter)
|
||||
url = Submitters.build_combined_url(last_submitter)
|
||||
|
||||
if url
|
||||
render json: [url]
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
else
|
||||
render json: build_urls(last_submitter)
|
||||
render json: Submitters.build_document_urls(last_submitter)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def respond_with_combined(submitter)
|
||||
url = build_combined_url(submitter)
|
||||
|
||||
if url
|
||||
render json: [url]
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
def current_user_submitter?(submitter)
|
||||
current_user && current_ability.can?(:read, submitter)
|
||||
end
|
||||
|
||||
def build_urls(submitter)
|
||||
filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id,
|
||||
key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value
|
||||
|
||||
Submitters.select_attachments_for_download(submitter).map do |attachment|
|
||||
ActiveStorage::Blob.proxy_path(
|
||||
attachment.blob,
|
||||
expires_at: FILES_TTL.from_now.to_i,
|
||||
filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def build_combined_url(submitter)
|
||||
return if submitter.submission.submitters.exists?(completed_at: nil)
|
||||
return if submitter.submission.submitters.order(:completed_at).last != submitter
|
||||
|
||||
attachment = submitter.submission.combined_document_attachment
|
||||
attachment ||= Submissions::EnsureCombinedGenerated.call(submitter)
|
||||
|
||||
filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id,
|
||||
key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value
|
||||
|
||||
ActiveStorage::Blob.proxy_path(
|
||||
attachment.blob,
|
||||
expires_at: FILES_TTL.from_now.to_i,
|
||||
filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,13 +10,15 @@ class SubmissionsPreviewController < ApplicationController
|
||||
TTL = 40.minutes
|
||||
|
||||
def show
|
||||
submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
|
||||
@sig_submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
|
||||
|
||||
signature_valid =
|
||||
if submitter && submitter.submission.slug == params[:slug]
|
||||
@submission = submitter.submission
|
||||
if @sig_submitter && @sig_submitter.submission.slug == params[:slug]
|
||||
@submission = @sig_submitter.submission
|
||||
|
||||
true
|
||||
else
|
||||
@sig_submitter = nil
|
||||
end
|
||||
|
||||
@submission ||= Submission.find_by!(slug: params[:slug])
|
||||
@@ -36,7 +38,7 @@ class SubmissionsPreviewController < ApplicationController
|
||||
|
||||
@submission = Submissions.preload_with_pages(@submission)
|
||||
|
||||
render 'submissions/show', layout: 'plain'
|
||||
render 'submissions/show', layout: 'plain', locals: { is_preview: true }
|
||||
end
|
||||
|
||||
def completed
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmissionsPreviewDownloadController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
TTL = 40.minutes
|
||||
|
||||
def index
|
||||
@submission = Submission.find_by!(slug: params[:submission_slug] || params[:submissions_preview_slug])
|
||||
|
||||
last_submitter = @submission.submitters.where.not(completed_at: nil).order(:completed_at).last
|
||||
|
||||
return head :not_found unless last_submitter
|
||||
|
||||
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
|
||||
unless current_user_submission?(@submission)
|
||||
if use_2fa?(@submission)
|
||||
Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
if last_submitter.completed_at < TTL.ago
|
||||
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
if params[:combined] == 'true'
|
||||
respond_with_combined(last_submitter)
|
||||
else
|
||||
render json: Submitters.build_document_urls(last_submitter)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def respond_with_combined(submitter)
|
||||
url = Submitters.build_combined_url(submitter)
|
||||
|
||||
if url
|
||||
render json: [url]
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
def current_user_submission?(submission)
|
||||
current_user && current_ability.can?(:read, submission)
|
||||
end
|
||||
|
||||
def use_2fa?(submission)
|
||||
return true if submission.submitters.any? do |e|
|
||||
e.preferences['require_phone_2fa'] || e.preferences['require_email_2fa']
|
||||
end
|
||||
return true if submission.template&.preferences&.dig('require_phone_2fa')
|
||||
return true if submission.template&.preferences&.dig('require_email_2fa')
|
||||
|
||||
false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,70 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmitFormCompletedDownloadController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
TTL = 40.minutes
|
||||
FILES_TTL = 5.minutes
|
||||
|
||||
def index
|
||||
@submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
|
||||
|
||||
signature_valid =
|
||||
if @submitter&.slug == submitter_slug
|
||||
true
|
||||
else
|
||||
@submitter = nil
|
||||
end
|
||||
|
||||
@submitter ||= Submitter.find_by!(slug: submitter_slug)
|
||||
|
||||
Submissions::EnsureResultGenerated.call(@submitter)
|
||||
|
||||
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
|
||||
|
||||
return head :not_found unless last_submitter
|
||||
|
||||
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
|
||||
if !signature_valid && !current_user_submitter?(last_submitter)
|
||||
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
if last_submitter.completed_at < TTL.ago
|
||||
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
if params[:combined] == 'true'
|
||||
respond_with_combined(last_submitter)
|
||||
else
|
||||
render json: Submitters.build_document_urls(last_submitter)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def submitter_slug
|
||||
params[:submit_form_slug] || params[:submitter_slug] || params[:submitter_id]
|
||||
end
|
||||
|
||||
def respond_with_combined(submitter)
|
||||
url = Submitters.build_combined_url(submitter)
|
||||
|
||||
if url
|
||||
render json: [url]
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
def current_user_submitter?(submitter)
|
||||
current_user && current_ability.can?(:read, submitter)
|
||||
end
|
||||
end
|
||||
@@ -3,11 +3,12 @@
|
||||
class SubmitFormController < ApplicationController
|
||||
layout 'form'
|
||||
|
||||
around_action :with_browser_locale, only: %i[show completed success]
|
||||
around_action :with_browser_locale, only: %i[show completed success delegated]
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
before_action :load_submitter, only: %i[show update completed]
|
||||
before_action :maybe_redirect_delegated, only: %i[show completed]
|
||||
before_action :maybe_render_locked_page, only: :show
|
||||
before_action :maybe_require_link_2fa, only: %i[show]
|
||||
|
||||
@@ -78,6 +79,8 @@ class SubmitFormController < ApplicationController
|
||||
|
||||
render json: { field_uuid: e.message }, status: :unprocessable_content
|
||||
rescue Submitters::SubmitValues::ValidationError => e
|
||||
Rollbar.warning("Validation error #{@submitter.id}: #{e.message}") if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
@@ -91,6 +94,12 @@ class SubmitFormController < ApplicationController
|
||||
|
||||
def success; end
|
||||
|
||||
def delegated
|
||||
submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
|
||||
|
||||
@submitter = submitter_version.submitter
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def maybe_require_link_2fa
|
||||
@@ -108,8 +117,18 @@ class SubmitFormController < ApplicationController
|
||||
render :declined if @submitter.declined_at?
|
||||
end
|
||||
|
||||
def maybe_redirect_delegated
|
||||
return if @submitter
|
||||
|
||||
submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
|
||||
|
||||
submitter_version.submitter.submission_events.find_by!(event_type: :delegate_form)
|
||||
|
||||
redirect_to submit_form_delegated_path(submitter_version.slug)
|
||||
end
|
||||
|
||||
def load_submitter
|
||||
@submitter = Submitter.find_by!(slug: params[:slug] || params[:submit_form_slug])
|
||||
@submitter = Submitter.find_by(slug: params[:slug] || params[:submit_form_slug])
|
||||
end
|
||||
|
||||
def build_attachments_index(submission)
|
||||
|
||||
@@ -4,31 +4,38 @@ class SubmitFormDeclineController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
def create
|
||||
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
before_action :load_submitter
|
||||
|
||||
return redirect_to submit_form_path(submitter.slug) if submitter.declined_at? ||
|
||||
submitter.completed_at? ||
|
||||
submitter.submission.archived_at? ||
|
||||
submitter.submission.expired? ||
|
||||
submitter.submission.template&.archived_at? ||
|
||||
!Submitters::AuthorizedForForm.call(submitter, current_user,
|
||||
request)
|
||||
def create
|
||||
return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? ||
|
||||
@submitter.completed_at? ||
|
||||
@submitter.submission.archived_at? ||
|
||||
@submitter.submission.expired? ||
|
||||
@submitter.submission.template&.archived_at? ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter,
|
||||
current_user,
|
||||
request)
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
submitter.update!(declined_at: Time.current)
|
||||
@submitter.update!(declined_at: Time.current)
|
||||
|
||||
SubmissionEvents.create_with_tracking_data(submitter, 'decline_form', request, { reason: params[:reason] })
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'decline_form', request, { reason: params[:reason] })
|
||||
end
|
||||
|
||||
user = submitter.submission.created_by_user || submitter.template.author
|
||||
user = @submitter.submission.created_by_user || @submitter.template.author
|
||||
|
||||
if user.user_configs.find_by(key: UserConfig::RECEIVE_DECLINED_EMAIL)&.value != false
|
||||
SubmitterMailer.declined_email(submitter, user).deliver_later!
|
||||
SubmitterMailer.declined_email(@submitter, user).deliver_later!
|
||||
end
|
||||
|
||||
WebhookUrls.enqueue_events(submitter, 'form.declined')
|
||||
WebhookUrls.enqueue_events(@submitter, 'form.declined')
|
||||
|
||||
redirect_to submit_form_path(submitter.slug)
|
||||
redirect_to submit_form_path(@submitter.slug)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_submitter
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmitFormDelegateController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
before_action :load_submitter
|
||||
|
||||
def create
|
||||
return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? ||
|
||||
@submitter.completed_at? ||
|
||||
@submitter.submission.archived_at? ||
|
||||
@submitter.submission.expired? ||
|
||||
@submitter.submission.template&.archived_at? ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter,
|
||||
current_user,
|
||||
request)
|
||||
|
||||
@submitter.account.account_configs.find_by!(key: AccountConfig::ALLOW_TO_DELEGATE_KEY, value: true)
|
||||
|
||||
email = Submissions.normalize_email(params[:email])
|
||||
|
||||
return redirect_to submit_form_path(@submitter.slug) if email.blank?
|
||||
|
||||
old_slug = @submitter.slug
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
@submitter.submitter_versions.create!(slug: old_slug, email: @submitter.email,
|
||||
name: @submitter.name, phone: @submitter.phone)
|
||||
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'delegate_form', request,
|
||||
{ old_email: @submitter.email, email: })
|
||||
|
||||
@submitter.update!(email:, phone: nil, name: nil, slug: SecureRandom.base58(14))
|
||||
end
|
||||
|
||||
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => @submitter.id)
|
||||
|
||||
redirect_to submit_form_delegated_path(old_slug)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_submitter
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
end
|
||||
end
|
||||
@@ -9,7 +9,7 @@ class SubmitFormDownloadController < ApplicationController
|
||||
def index
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
|
||||
return redirect_to submitter_download_index_path(@submitter.slug) if @submitter.completed_at?
|
||||
return redirect_to submit_form_documents_path(@submitter.slug) if @submitter.completed_at?
|
||||
|
||||
return head :unprocessable_content if @submitter.declined_at? ||
|
||||
@submitter.submission.archived_at? ||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmitFormMetadataController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
def index
|
||||
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
|
||||
return head :not_found if submitter.declined_at? ||
|
||||
submitter.completed_at? ||
|
||||
submitter.submission.archived_at? ||
|
||||
submitter.submission.expired? ||
|
||||
submitter.submission.template&.archived_at? ||
|
||||
submitter.account.archived_at? ||
|
||||
!Submitters::AuthorizedForForm.call(submitter, current_user, request)
|
||||
|
||||
submission = submitter.submission
|
||||
values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
|
||||
schema = Submissions.filtered_conditions_schema(submission, values:, include_submitter_uuid: submitter.uuid)
|
||||
|
||||
documents = schema.filter_map do |item|
|
||||
submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] }
|
||||
end
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(records: documents, associations: %i[blob record]).call
|
||||
|
||||
text_runs = documents.to_h do |document|
|
||||
[
|
||||
document.uuid,
|
||||
DocumentMetadatas.find_or_create_for_document(document, account_id: document.record.account_id).text_runs
|
||||
]
|
||||
end
|
||||
|
||||
render json: { text_runs: }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmittersDownloadController < ApplicationController
|
||||
load_and_authorize_resource :submitter
|
||||
|
||||
def index
|
||||
Submissions::EnsureResultGenerated.call(@submitter)
|
||||
|
||||
render json: Submitters.build_document_urls(@submitter)
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmittersSendEmailController < ApplicationController
|
||||
load_and_authorize_resource :submitter, id_param: :submitter_slug, find_by: :slug
|
||||
load_and_authorize_resource :submitter
|
||||
|
||||
def create
|
||||
if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter,
|
||||
|
||||
@@ -8,7 +8,7 @@ class TemplatesCloneAndReplaceController < ApplicationController
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@template],
|
||||
associations: [schema_documents: :preview_images_attachments]
|
||||
associations: [{ schema_documents: :preview_images_attachments }]
|
||||
).call
|
||||
|
||||
cloned_template = Templates::Clone.call(@template, author: current_user)
|
||||
|
||||
@@ -12,7 +12,7 @@ class TemplatesCloneController < ApplicationController
|
||||
def create
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@base_template],
|
||||
associations: [schema_documents: :preview_images_attachments]
|
||||
associations: [{ schema_documents: :preview_images_attachments }]
|
||||
).call
|
||||
|
||||
@template = Templates::Clone.call(@base_template, author: current_user,
|
||||
|
||||
@@ -29,7 +29,7 @@ class TemplatesController < ApplicationController
|
||||
def edit
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@template],
|
||||
associations: [schema_documents: [:blob, { preview_images_attachments: :blob }]]
|
||||
associations: [{ schema_documents: [:blob, { preview_images_attachments: :blob }] }]
|
||||
).call
|
||||
|
||||
@template_data =
|
||||
@@ -84,6 +84,8 @@ class TemplatesController < ApplicationController
|
||||
else
|
||||
@template.update!(archived_at: Time.current)
|
||||
|
||||
WebhookUrls.enqueue_events(@template, 'template.archived')
|
||||
|
||||
I18n.t('template_has_been_archived')
|
||||
end
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class TemplatesDebugController < ApplicationController
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@template],
|
||||
associations: [schema_documents: { preview_images_attachments: :blob }]
|
||||
associations: [{ schema_documents: { preview_images_attachments: :blob } }]
|
||||
).call
|
||||
|
||||
@template_data =
|
||||
|
||||
@@ -58,8 +58,8 @@ class TemplatesPreferencesController < ApplicationController
|
||||
default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled
|
||||
completed_notification_email_subject completed_notification_email_body
|
||||
completed_notification_email_enabled completed_notification_email_attach_audit] +
|
||||
[completed_message: %i[title body],
|
||||
submitters: [%i[uuid request_email_subject request_email_body]], link_form_fields: []]
|
||||
[{ completed_message: %i[title body],
|
||||
submitters: [%i[uuid request_email_subject request_email_body]], link_form_fields: [] }]
|
||||
).tap do |attrs|
|
||||
attrs[:preferences].delete(:submitters) if params[:request_email_per_submitter] != '1'
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ class TemplatesPreviewController < ApplicationController
|
||||
def show
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@template],
|
||||
associations: [schema_documents: { preview_images_attachments: :blob }]
|
||||
associations: [{ schema_documents: { preview_images_attachments: :blob } }]
|
||||
).call
|
||||
|
||||
@template_data =
|
||||
|
||||
@@ -6,6 +6,8 @@ class TemplatesRestoreController < ApplicationController
|
||||
def create
|
||||
@template.update!(archived_at: nil)
|
||||
|
||||
WebhookUrls.enqueue_events(@template, 'template.updated')
|
||||
|
||||
redirect_to template_path(@template), notice: I18n.t('template_has_been_unarchived')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesShareLinkQrController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def show
|
||||
return render :disabled, layout: 'plain' unless @template.shared_link?
|
||||
|
||||
shared_link_url = start_form_url(slug: @template.slug, host: form_link_host)
|
||||
|
||||
@qr_svg_code = RQRCode::QRCode.new(shared_link_url, level: :m).as_svg(viewbox: true)
|
||||
|
||||
@page_size =
|
||||
if TimeUtils.timezone_abbr(current_account.timezone, Time.current.beginning_of_year).in?(TimeUtils::US_TIMEZONES)
|
||||
'Letter'
|
||||
else
|
||||
'A4'
|
||||
end
|
||||
|
||||
render :show, layout: false
|
||||
end
|
||||
end
|
||||
@@ -16,7 +16,19 @@ class UsersController < ApplicationController
|
||||
@users.active.where.not(role: 'integration')
|
||||
end
|
||||
|
||||
@pagy, @users = pagy(@users.preload(account: :account_accesses).where(account: current_account).order(id: :desc))
|
||||
@users = @users.preload(account: :account_accesses).where(account: current_account).order(id: :desc)
|
||||
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
@pagy, @users = pagy(@users)
|
||||
end
|
||||
|
||||
if current_ability.can?(:manage, current_account)
|
||||
format.csv do
|
||||
send_data Users.generate_csv(@users), filename: "users-#{Time.current.iso8601}.csv", type: 'text/csv'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def new; end
|
||||
|
||||
@@ -15,7 +15,7 @@ class WebhookEventsController < ApplicationController
|
||||
Submissions::SerializeForApi.call(@webhook_event.record)
|
||||
when 'template.created', 'template.updated'
|
||||
Templates::SerializeForApi.call(@webhook_event.record)
|
||||
when 'submission.archived'
|
||||
when 'submission.archived', 'template.archived'
|
||||
@webhook_event.record.as_json(only: %i[id archived_at])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -32,9 +32,13 @@ class WebhookSettingsController < ApplicationController
|
||||
def new; end
|
||||
|
||||
def create
|
||||
@webhook_url.save!
|
||||
if @webhook_url.url.present?
|
||||
@webhook_url.save!
|
||||
|
||||
redirect_to settings_webhooks_path, notice: I18n.t('webhook_url_has_been_saved')
|
||||
redirect_to settings_webhooks_path, notice: I18n.t('webhook_url_has_been_saved')
|
||||
else
|
||||
redirect_back fallback_location: settings_webhooks_path
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
|
||||
@@ -169,6 +169,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
withKba: ['true', 'false'].includes(this.dataset.withKba) ? this.dataset.withKba === 'true' : null,
|
||||
withLogo: this.dataset.withLogo !== 'false',
|
||||
withFieldsDetection: this.dataset.withFieldsDetection === 'true',
|
||||
withDetectExistingFields: this.dataset.withDetectExistingFields === 'true',
|
||||
editable: this.dataset.editable !== 'false',
|
||||
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
|
||||
withCustomFields: true,
|
||||
@@ -180,6 +181,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
withConditions: this.dataset.withConditions === 'true',
|
||||
withDynamicDocuments: this.dataset.withDynamicDocuments === 'true',
|
||||
withGoogleDrive: this.dataset.withGoogleDrive === 'true',
|
||||
pagePreviewFormat: this.dataset.pagePreviewFormat || '.jpg',
|
||||
withReplaceAndCloneUpload: true,
|
||||
withDownload: true,
|
||||
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
|
||||
|
||||
@@ -5,6 +5,12 @@ export default targetable(class extends HTMLElement {
|
||||
|
||||
connectedCallback () {
|
||||
this.addEventListener('click', () => this.downloadFiles())
|
||||
this.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
this.downloadFiles()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
toggleState () {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const dialog = document.getElementById(this.dataset.target)
|
||||
|
||||
this.querySelector('button').addEventListener('click', () => {
|
||||
if (dialog) {
|
||||
dialog.inert = false
|
||||
dialog.showModal()
|
||||
}
|
||||
})
|
||||
|
||||
if (dialog) {
|
||||
dialog.addEventListener('close', () => {
|
||||
dialog.inert = true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,11 +47,13 @@ export default class extends HTMLElement {
|
||||
|
||||
this.classList.remove('hidden', '-translate-y-10', 'opacity-0')
|
||||
this.classList.add('translate-y-0', 'opacity-100')
|
||||
this.inert = false
|
||||
}
|
||||
|
||||
hideButtons () {
|
||||
this.classList.remove('translate-y-0', 'opacity-100')
|
||||
this.classList.add('-translate-y-10', 'opacity-0')
|
||||
this.inert = true
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.classList.contains('-translate-y-10')) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import FetchForm from './elements/fetch_form'
|
||||
import ScrollButtons from './elements/scroll_buttons'
|
||||
import PageContainer from './elements/page_container'
|
||||
import SubmitForm from './elements/submit_form'
|
||||
import ModalButton from './elements/modal_button'
|
||||
|
||||
const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options)
|
||||
|
||||
@@ -16,6 +17,7 @@ safeRegisterElement('fetch-form', FetchForm)
|
||||
safeRegisterElement('scroll-buttons', ScrollButtons)
|
||||
safeRegisterElement('page-container', PageContainer)
|
||||
safeRegisterElement('submit-form', SubmitForm)
|
||||
safeRegisterElement('modal-button', ModalButton)
|
||||
safeRegisterElement('submission-form', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.appElem = document.createElement('div')
|
||||
@@ -31,12 +33,13 @@ safeRegisterElement('submission-form', class extends HTMLElement {
|
||||
isDemo: this.dataset.isDemo === 'true',
|
||||
attribution: this.dataset.attribution !== 'false',
|
||||
scrollPadding: this.dataset.scrollPadding || '-80px',
|
||||
signatureText: this.dataset.signatureText,
|
||||
language: this.dataset.language,
|
||||
dryRun: this.dataset.dryRun === 'true',
|
||||
expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null,
|
||||
withSignatureId: this.dataset.withSignatureId === 'true',
|
||||
requireSigningReason: this.dataset.requireSigningReason === 'true',
|
||||
withConfetti: this.dataset.withConfetti !== 'false',
|
||||
withConfetti: this.dataset.withConfetti === 'true',
|
||||
withFieldLabels: this.dataset.withFieldLabels !== 'false',
|
||||
withDisclosure: this.dataset.withDisclosure === 'true',
|
||||
reuseSignature: this.dataset.reuseSignature !== 'false',
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
<template>
|
||||
<template
|
||||
v-for="(pages, docUuid) in textRuns"
|
||||
:key="docUuid"
|
||||
>
|
||||
<template
|
||||
v-for="(_, pageIndex) in pages"
|
||||
:key="pageIndex"
|
||||
>
|
||||
<template
|
||||
v-for="(pageElem, i) in [findPageElement(docUuid, pageIndex)]"
|
||||
:key="i"
|
||||
>
|
||||
<Teleport
|
||||
v-if="pageElem"
|
||||
:to="pageElem"
|
||||
>
|
||||
<template
|
||||
v-for="(item, index) in sortedItemsForPage(docUuid, pageIndex)"
|
||||
:key="index"
|
||||
>
|
||||
<template v-if="item.type === 'text_group'">
|
||||
<span
|
||||
class="absolute overflow-hidden text-transparent select-none pointer-events-none"
|
||||
:style="{ left: item.x * 100 + '%', top: item.y * 100 + '%', width: item.w * 100 + '%', height: item.h * 100 + '%' }"
|
||||
>{{ item.text }}</span>
|
||||
<span
|
||||
v-for="(run, runIndex) in item.items"
|
||||
:key="runIndex"
|
||||
aria-hidden="true"
|
||||
class="absolute overflow-hidden text-transparent"
|
||||
:style="{ left: run.x * 100 + '%', top: run.y * 100 + '%', width: run.w * 100 + '%', height: run.h * 100 + '%', fontSize: run.font_size ? (run.font_size / 10) + 'cqmin' : undefined, textAlign: 'justify', textAlignLast: 'justify', textJustify: 'inter-character' }"
|
||||
>{{ run.text }}</span>
|
||||
</template>
|
||||
<FieldArea
|
||||
v-else-if="item.type === 'field_area'"
|
||||
:ref="setAreaRef"
|
||||
v-model="values[item.field.uuid]"
|
||||
:values="values"
|
||||
:field="item.field"
|
||||
:area="item.area"
|
||||
:submittable="true"
|
||||
:page-width="1400"
|
||||
:page-height="(1400.0 / pageElem.offsetWidth) * pageElem.offsetHeight"
|
||||
:field-index="item.fieldIndex"
|
||||
:is-inline-size="isInlineSize"
|
||||
:scroll-padding="scrollPadding"
|
||||
:submitter="submitter"
|
||||
:with-field-placeholder="withFieldPlaceholder"
|
||||
:with-signature-id="withSignatureId"
|
||||
:is-active="currentStep === item.step"
|
||||
:with-label="withLabel && !withFieldPlaceholder && item.step.length < 2"
|
||||
:is-value-set="item.step.some((f) => f.uuid in values)"
|
||||
:attachments-index="attachmentsIndex"
|
||||
@click="[$emit('focus-step', item.stepIndex), maybeScrollOnClick(item.field, item.area)]"
|
||||
/>
|
||||
<FieldArea
|
||||
v-else-if="item.type === 'readonly_field_area'"
|
||||
:model-value="readonlyConditionalFieldValues[item.field.uuid]"
|
||||
:values="readonlyConditionalFieldValues"
|
||||
:field="item.field"
|
||||
:area="item.area"
|
||||
:submittable="false"
|
||||
:page-width="1400"
|
||||
:page-height="(1400.0 / pageElem.offsetWidth) * pageElem.offsetHeight"
|
||||
:field-index="item.fieldIndex"
|
||||
:is-inline-size="isInlineSize"
|
||||
:submitter="submitter"
|
||||
:attachments-index="attachmentsIndex"
|
||||
/>
|
||||
<FieldArea
|
||||
v-else-if="item.type === 'formula_area' && isMathLoaded"
|
||||
:model-value="calculateFormula(item.field)"
|
||||
:is-inline-size="isInlineSize"
|
||||
:field="item.field"
|
||||
:area="item.area"
|
||||
:submittable="false"
|
||||
:field-index="item.fieldIndex"
|
||||
/>
|
||||
</template>
|
||||
</Teleport>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FieldArea from './area'
|
||||
import FormulaAreas from './formula_areas'
|
||||
import FieldAreas from './areas'
|
||||
|
||||
export default {
|
||||
name: 'AccessibilityAreas',
|
||||
components: {
|
||||
FieldArea
|
||||
},
|
||||
inject: ['baseUrl', 't'],
|
||||
props: {
|
||||
submitterSlug: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
filledFieldsIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
steps: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
readonlyConditionalFields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
readonlyConditionalFieldValues: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
formulaFields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
values: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
readonlyValues: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
submitter: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
currentStep: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
withFieldPlaceholder: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withSignatureId: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withLabel: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
scrollPadding: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '-80px'
|
||||
},
|
||||
scrollEl: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
attachmentsIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
fetchOptions: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
emits: ['focus-step'],
|
||||
data () {
|
||||
return {
|
||||
isMathLoaded: false,
|
||||
math: null,
|
||||
textRuns: {},
|
||||
areaRefs: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldValuesIndex () {
|
||||
return this.filledFieldsIndex || this.extractStaticValues()
|
||||
},
|
||||
isMobileContainer: FieldAreas.computed.isMobileContainer,
|
||||
isInlineSize: FieldAreas.computed.isInlineSize,
|
||||
fieldsUuidIndex () {
|
||||
return this.formulaFields.reduce((acc, field) => {
|
||||
acc[field.uuid] = field
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
fieldAreasIndex () {
|
||||
const index = Object.create(null)
|
||||
|
||||
this.steps.forEach((step, stepIndex) => {
|
||||
step.forEach((field, fieldIndex) => {
|
||||
(field.areas || []).forEach((area) => {
|
||||
index[area.attachment_uuid] ||= Object.create(null)
|
||||
index[area.attachment_uuid][area.page] ||= []
|
||||
index[area.attachment_uuid][area.page].push({
|
||||
type: 'field_area',
|
||||
field,
|
||||
area,
|
||||
step,
|
||||
stepIndex,
|
||||
fieldIndex,
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
w: area.w,
|
||||
h: area.h
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return index
|
||||
},
|
||||
formulaAreasIndex () {
|
||||
const index = Object.create(null)
|
||||
|
||||
this.formulaFields.forEach((field, fieldIndex) => {
|
||||
(field.areas || []).forEach((area) => {
|
||||
index[area.attachment_uuid] ||= Object.create(null)
|
||||
index[area.attachment_uuid][area.page] ||= []
|
||||
index[area.attachment_uuid][area.page].push({
|
||||
type: 'formula_area',
|
||||
field,
|
||||
area,
|
||||
fieldIndex,
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
w: area.w,
|
||||
h: area.h
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return index
|
||||
},
|
||||
readonlyFieldAreasIndex () {
|
||||
const index = Object.create(null)
|
||||
|
||||
this.readonlyConditionalFields.forEach((field, fieldIndex) => {
|
||||
(field.areas || []).forEach((area) => {
|
||||
index[area.attachment_uuid] ||= Object.create(null)
|
||||
index[area.attachment_uuid][area.page] ||= []
|
||||
index[area.attachment_uuid][area.page].push({
|
||||
type: 'readonly_field_area',
|
||||
field,
|
||||
area,
|
||||
fieldIndex,
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
w: area.w,
|
||||
h: area.h
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return index
|
||||
}
|
||||
},
|
||||
beforeUpdate () {
|
||||
this.areaRefs = []
|
||||
},
|
||||
async mounted () {
|
||||
const [metadataResult] = await Promise.all([
|
||||
fetch(this.baseUrl + `/s/${this.submitterSlug}/metadata`, {
|
||||
...this.fetchOptions
|
||||
}).then((r) => r.json()).catch(() => ({})),
|
||||
this.loadMath()
|
||||
])
|
||||
|
||||
this.textRuns = metadataResult.text_runs || {}
|
||||
},
|
||||
methods: {
|
||||
normalizeFormula: FormulaAreas.methods.normalizeFormula,
|
||||
calculateFormula: FormulaAreas.methods.calculateFormula,
|
||||
scrollInContainer: FieldAreas.methods.scrollInContainer,
|
||||
scrollIntoArea: FieldAreas.methods.scrollIntoArea,
|
||||
scrollIntoField: FieldAreas.methods.scrollIntoField,
|
||||
maybeScrollOnClick: FieldAreas.methods.maybeScrollOnClick,
|
||||
setAreaRef (el) {
|
||||
if (el) {
|
||||
this.areaRefs.push(el)
|
||||
}
|
||||
},
|
||||
async loadMath () {
|
||||
if (this.formulaFields.length && !this.isMathLoaded) {
|
||||
const { Calculator } = await import('./calculator')
|
||||
|
||||
this.math = new Calculator()
|
||||
this.isMathLoaded = true
|
||||
}
|
||||
},
|
||||
extractStaticValues () {
|
||||
const result = Object.create(null)
|
||||
const root = this.$root.$el?.parentNode?.getRootNode() || document
|
||||
const pageContainers = root.querySelectorAll('page-container')
|
||||
|
||||
pageContainers.forEach((container) => {
|
||||
const overlay = container.querySelector('[id^="page-"]')
|
||||
|
||||
if (!overlay) return
|
||||
|
||||
const parts = overlay.id.split('-')
|
||||
const pageIndex = parseInt(parts[parts.length - 1])
|
||||
const docUuid = parts.slice(1, -1).join('-')
|
||||
|
||||
const fieldValues = overlay.querySelectorAll('field-value')
|
||||
|
||||
if (!fieldValues.length) return
|
||||
|
||||
result[docUuid] ||= Object.create(null)
|
||||
result[docUuid][pageIndex] = []
|
||||
|
||||
fieldValues.forEach((el) => {
|
||||
const style = el.style
|
||||
const x = parseFloat(style.left) / 100
|
||||
const y = parseFloat(style.top) / 100
|
||||
const w = parseFloat(style.width) / 100
|
||||
const h = parseFloat(style.height) / 100
|
||||
const text = el.textContent.trim()
|
||||
|
||||
if (text) {
|
||||
result[docUuid][pageIndex].push({ type: 'static_value', text, x, y, w, h })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
},
|
||||
findPageElement (docUuid, pageIndex) {
|
||||
return (this.$root.$el?.parentNode?.getRootNode() || document).getElementById(`page-${docUuid}-${pageIndex}`)
|
||||
},
|
||||
sortedItemsForPage (docUuid, pageIndex) {
|
||||
const items = []
|
||||
|
||||
const pageTextRuns = this.textRuns[docUuid]?.[pageIndex] || []
|
||||
|
||||
pageTextRuns.forEach((run) => {
|
||||
items.push({
|
||||
type: 'text_run',
|
||||
text: run.text,
|
||||
x: run.x,
|
||||
y: run.y,
|
||||
w: run.w,
|
||||
h: run.h,
|
||||
font_size: run.font_size
|
||||
})
|
||||
})
|
||||
|
||||
const fieldAreas = this.fieldAreasIndex[docUuid]?.[pageIndex] || []
|
||||
items.push(...fieldAreas)
|
||||
|
||||
const readonlyFieldAreas = this.readonlyFieldAreasIndex[docUuid]?.[pageIndex] || []
|
||||
items.push(...readonlyFieldAreas)
|
||||
|
||||
const formulaAreas = this.formulaAreasIndex[docUuid]?.[pageIndex] || []
|
||||
items.push(...formulaAreas)
|
||||
|
||||
const pageFieldValues = this.fieldValuesIndex[docUuid]?.[pageIndex] || []
|
||||
items.push(...pageFieldValues)
|
||||
|
||||
items.sort((a, b) => {
|
||||
const aCenterY = a.y + a.h / 2
|
||||
const bCenterY = b.y + b.h / 2
|
||||
const lineThreshold = Math.min(a.h, b.h) / 2
|
||||
|
||||
if (Math.abs(aCenterY - bCenterY) < lineThreshold) {
|
||||
return a.x - b.x
|
||||
}
|
||||
|
||||
return aCenterY - bCenterY
|
||||
})
|
||||
|
||||
const grouped = []
|
||||
let currentGroup = null
|
||||
|
||||
const closeGroup = () => {
|
||||
if (!currentGroup) return
|
||||
|
||||
const groupItems = currentGroup.items
|
||||
const minX = Math.min(...groupItems.map((i) => i.x))
|
||||
const minY = Math.min(...groupItems.map((i) => i.y))
|
||||
const maxEndX = Math.max(...groupItems.map((i) => i.x + i.w))
|
||||
const maxEndY = Math.max(...groupItems.map((i) => i.y + i.h))
|
||||
|
||||
currentGroup.x = minX
|
||||
currentGroup.y = minY
|
||||
currentGroup.w = maxEndX - minX
|
||||
currentGroup.h = maxEndY - minY
|
||||
currentGroup.text = groupItems.map((i) => i.text).join(' ').replace(/\s+/g, ' ').trim()
|
||||
|
||||
grouped.push(currentGroup)
|
||||
currentGroup = null
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const isTextLike = item.type === 'text_run' || item.type === 'static_value'
|
||||
|
||||
if (!isTextLike) {
|
||||
closeGroup()
|
||||
grouped.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!currentGroup) {
|
||||
currentGroup = { type: 'text_group', items: [] }
|
||||
}
|
||||
|
||||
currentGroup.items.push(item)
|
||||
}
|
||||
|
||||
closeGroup()
|
||||
|
||||
return grouped
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,9 +1,14 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex absolute lg:text-base -outline-offset-1 field-area"
|
||||
class="flex absolute lg:text-base -outline-offset-1 focus-visible:outline-blue-500 focus-visible:outline-2 focus-visible:outline field-area"
|
||||
dir="auto"
|
||||
:style="[computedStyle, fontStyle]"
|
||||
:class="{ 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'outline-red-500 outline-dashed outline-2 z-10 field-area-active': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
|
||||
:role="submittable && !isNativeInputField ? 'button' : undefined"
|
||||
:tabindex="submittable && !isNativeInputField ? 0 : undefined"
|
||||
:aria-label="submittable && !isNativeInputField ? fieldAreaLabel : undefined"
|
||||
@keydown.enter.prevent="submittable && !isNativeInputField ? $el.click() : undefined"
|
||||
@keydown.space.prevent="submittable && !isNativeInputField ? $el.click() : undefined"
|
||||
>
|
||||
<div
|
||||
v-if="(!withFieldPlaceholder || !field.name || field.type === 'cells') && !isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid"
|
||||
@@ -18,6 +23,7 @@
|
||||
width="100%"
|
||||
height="100%"
|
||||
class="max-h-10 text-base-content"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
@@ -129,6 +135,7 @@
|
||||
v-if="submittable"
|
||||
type="checkbox"
|
||||
:value="false"
|
||||
:aria-label="field.name || fieldNames[field.type]"
|
||||
class="aspect-square base-checkbox"
|
||||
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
|
||||
:checked="!!modelValue"
|
||||
@@ -149,6 +156,8 @@
|
||||
v-if="submittable"
|
||||
type="radio"
|
||||
:value="false"
|
||||
:name="`radio-area-${field.uuid}`"
|
||||
:aria-label="optionValue(option)"
|
||||
class="aspect-square checked:checkbox checked:checkbox-xs"
|
||||
:class="{ 'base-radio': !modelValue || modelValue !== optionValue(option), '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
|
||||
:checked="!!modelValue && modelValue === optionValue(option)"
|
||||
@@ -169,6 +178,7 @@
|
||||
v-if="submittable"
|
||||
type="checkbox"
|
||||
:value="false"
|
||||
:aria-label="optionValue(option)"
|
||||
class="aspect-square base-checkbox"
|
||||
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
|
||||
:checked="!!modelValue && modelValue.includes(optionValue(option))"
|
||||
@@ -400,6 +410,16 @@ export default {
|
||||
kba: this.t('kba')
|
||||
}
|
||||
},
|
||||
isNativeInputField () {
|
||||
return ['checkbox', 'radio', 'multiple'].includes(this.field.type)
|
||||
},
|
||||
fieldAreaLabel () {
|
||||
const name = this.field.name || this.fieldNames[this.field.type] || this.field.type
|
||||
if (this.area.option_uuid && this.option) {
|
||||
return `${name} - ${this.optionValue(this.option)}`
|
||||
}
|
||||
return name
|
||||
},
|
||||
strikethroughWidth () {
|
||||
if (this.isInlineSize) {
|
||||
return '0.6cqmin'
|
||||
@@ -507,7 +527,8 @@ export default {
|
||||
try {
|
||||
return this.formatDate(
|
||||
this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue),
|
||||
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY')
|
||||
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'),
|
||||
{ withTimePlaceholders: this.modelValue === '{{date}}' }
|
||||
)
|
||||
} catch {
|
||||
return this.modelValue
|
||||
@@ -626,36 +647,55 @@ export default {
|
||||
return number
|
||||
}
|
||||
},
|
||||
formatDate (date, format) {
|
||||
const monthFormats = {
|
||||
M: 'numeric',
|
||||
MM: '2-digit',
|
||||
MMM: 'short',
|
||||
MMMM: 'long'
|
||||
}
|
||||
formatDate (date, format, { withTimePlaceholders = false } = {}) {
|
||||
const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
|
||||
const dayFormats = { D: 'numeric', DD: '2-digit' }
|
||||
const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' }
|
||||
const hourFormats = { H: 'numeric', HH: '2-digit', h: 'numeric', hh: '2-digit' }
|
||||
const minuteFormats = { m: 'numeric', mm: '2-digit' }
|
||||
const secondFormats = { s: 'numeric', ss: '2-digit' }
|
||||
|
||||
const dayFormats = {
|
||||
D: 'numeric',
|
||||
DD: '2-digit'
|
||||
}
|
||||
const hasTime = /[HhAasz]/.test(format)
|
||||
|
||||
const yearFormats = {
|
||||
YYYY: 'numeric',
|
||||
YYY: 'numeric',
|
||||
YY: '2-digit'
|
||||
}
|
||||
|
||||
const parts = new Intl.DateTimeFormat([], {
|
||||
const opts = {
|
||||
day: dayFormats[format.match(/D+/)],
|
||||
month: monthFormats[format.match(/M+/)],
|
||||
year: yearFormats[format.match(/Y+/)],
|
||||
timeZone: 'UTC'
|
||||
}).formatToParts(date)
|
||||
year: yearFormats[format.match(/Y+/)]
|
||||
}
|
||||
|
||||
return format
|
||||
.replace(/D+/, parts.find((p) => p.type === 'day').value)
|
||||
.replace(/M+/, parts.find((p) => p.type === 'month').value)
|
||||
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
|
||||
if (format.match(/H+/)) { opts.hour = hourFormats[format.match(/H+/)[0]]; opts.hour12 = false }
|
||||
if (format.match(/h+/)) { opts.hour = hourFormats[format.match(/h+/)[0]]; opts.hour12 = true }
|
||||
if (/[Aa]/.test(format) && opts.hour12 === undefined) opts.hour12 = true
|
||||
if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]]
|
||||
if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]]
|
||||
if (/z/.test(format)) opts.timeZoneName = 'short'
|
||||
if (!hasTime) opts.timeZone = 'UTC'
|
||||
|
||||
const partTypes = {
|
||||
M: 'month',
|
||||
D: 'day',
|
||||
Y: 'year',
|
||||
H: 'hour',
|
||||
h: 'hour',
|
||||
m: 'minute',
|
||||
s: 'second',
|
||||
z: 'timeZoneName',
|
||||
A: 'dayPeriod',
|
||||
a: 'dayPeriod'
|
||||
}
|
||||
|
||||
const parts = new Intl.DateTimeFormat([], opts).formatToParts(date)
|
||||
|
||||
return format.replace(/MMMM|MMM|MM|M|DD|D|YYYY|YYY|YY|HH|hh|H|h|mm|m|ss|s|A|a|z/g, (token) => {
|
||||
if (withTimePlaceholders && /^(HH|hh|H|h|mm|m|ss|s|A|a)$/.test(token)) return '--'
|
||||
|
||||
const value = parts.find((p) => p.type === partTypes[token[0]])?.value
|
||||
|
||||
if (token === 'A') return (value || '').toUpperCase()
|
||||
if (token === 'a') return (value || '').toLowerCase()
|
||||
|
||||
return value
|
||||
})
|
||||
},
|
||||
updateMultipleSelectValue (value) {
|
||||
if (this.modelValue?.includes(value)) {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="value.length">
|
||||
<div
|
||||
<ul
|
||||
v-if="value.length"
|
||||
:aria-label="t('uploaded_files')"
|
||||
class="list-none p-0 m-0"
|
||||
>
|
||||
<li
|
||||
v-for="(val, index) in value"
|
||||
:key="index"
|
||||
class="flex mb-2"
|
||||
@@ -20,23 +24,27 @@
|
||||
<IconPaperclip
|
||||
:width="16"
|
||||
class="flex-none"
|
||||
:heigh="16"
|
||||
:height="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{{ attachmentsIndex[val].filename }}
|
||||
</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="remove-attachment-button"
|
||||
:aria-label="`${t('clear')} ${attachmentsIndex[val].filename}`"
|
||||
@click.prevent="removeAttachment(val)"
|
||||
>
|
||||
<IconTrashX
|
||||
:width="18"
|
||||
:heigh="19"
|
||||
:height="19"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<template v-else>
|
||||
<input
|
||||
value=""
|
||||
|
||||
@@ -0,0 +1,874 @@
|
||||
/*
|
||||
* Portions of this file are derived from the Dentaku gem
|
||||
* https://github.com/rubysolo/dentaku
|
||||
*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2012 Solomon White
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* Modifications and JavaScript port
|
||||
* Copyright (c) 2026 DocuSeal, LLC
|
||||
*/
|
||||
|
||||
class CalculatorError extends Error { constructor (msg) { super(msg); this.name = 'CalculatorError' } }
|
||||
class ParseError extends CalculatorError { constructor (msg) { super(msg); this.name = 'ParseError' } }
|
||||
class TokenizerError extends CalculatorError { constructor (msg) { super(msg); this.name = 'TokenizerError' } }
|
||||
class ArgumentError extends CalculatorError { constructor (msg) { super(msg); this.name = 'ArgumentError' } }
|
||||
class ZeroDivisionError extends CalculatorError { constructor () { super('divided by 0'); this.name = 'ZeroDivisionError' } }
|
||||
class UnboundVariableError extends CalculatorError {
|
||||
constructor (vars) {
|
||||
super('no value provided for variables: ' + vars.join(', '))
|
||||
this.name = 'UnboundVariableError'
|
||||
this.unbound = vars
|
||||
}
|
||||
}
|
||||
|
||||
class Token {
|
||||
constructor (category, value, raw) {
|
||||
this.category = category
|
||||
this.value = value
|
||||
this.raw = raw == null ? String(value) : String(raw)
|
||||
}
|
||||
|
||||
is (cat) { return this.category === cat }
|
||||
}
|
||||
|
||||
const OPERATOR_NAMES = {
|
||||
'^': 'pow',
|
||||
'+': 'add',
|
||||
'-': 'subtract',
|
||||
'*': 'multiply',
|
||||
'/': 'divide',
|
||||
'%': 'mod',
|
||||
'|': 'bitor',
|
||||
'&': 'bitand',
|
||||
'<<': 'bitshiftleft',
|
||||
'>>': 'bitshiftright'
|
||||
}
|
||||
const COMPARATOR_NAMES = {
|
||||
'<=': 'le',
|
||||
'>=': 'ge',
|
||||
'!=': 'ne',
|
||||
'<>': 'ne',
|
||||
'<': 'lt',
|
||||
'>': 'gt',
|
||||
'==': 'eq',
|
||||
'=': 'eq'
|
||||
}
|
||||
const GROUPING_NAMES = { '(': 'open', ')': 'close', ',': 'comma' }
|
||||
|
||||
function buildScanners (caseSensitive) {
|
||||
return [
|
||||
(s) => {
|
||||
const m = /^\s+/.exec(s)
|
||||
return m ? { tokens: [], length: m[0].length } : null
|
||||
},
|
||||
(s) => {
|
||||
const m = /^null\b/i.exec(s)
|
||||
return m ? { tokens: [new Token('null', null, m[0])], length: m[0].length } : null
|
||||
},
|
||||
(s) => {
|
||||
const m = /^((?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?)\b/.exec(s)
|
||||
if (!m) return null
|
||||
return { tokens: [new Token('numeric', Number(m[0]), m[0])], length: m[0].length }
|
||||
},
|
||||
(s) => {
|
||||
const m = /^0x[0-9a-f]+\b/i.exec(s)
|
||||
if (!m) return null
|
||||
return { tokens: [new Token('numeric', parseInt(m[0].slice(2), 16), m[0])], length: m[0].length }
|
||||
},
|
||||
(s) => {
|
||||
const m = /^"([^"]*)"/.exec(s)
|
||||
if (!m) return null
|
||||
return { tokens: [new Token('string', m[1], m[0])], length: m[0].length }
|
||||
},
|
||||
(s) => {
|
||||
const m = /^'([^']*)'/.exec(s)
|
||||
if (!m) return null
|
||||
return { tokens: [new Token('string', m[1], m[0])], length: m[0].length }
|
||||
},
|
||||
(s, last) => {
|
||||
if (s[0] !== '-') return null
|
||||
const ok = last == null ||
|
||||
last.is('operator') ||
|
||||
last.is('comparator') ||
|
||||
last.is('combinator') ||
|
||||
last.value === 'open' ||
|
||||
last.value === 'comma' ||
|
||||
last.value === 'array_start'
|
||||
if (!ok) return null
|
||||
return { tokens: [new Token('operator', 'negate', '-')], length: 1 }
|
||||
},
|
||||
(s) => {
|
||||
const m = /^(and|or|&&|\|\|)\s/i.exec(s)
|
||||
if (!m) return null
|
||||
const raw = m[1].toLowerCase()
|
||||
const name = raw === '&&' ? 'and' : raw === '||' ? 'or' : raw
|
||||
return { tokens: [new Token('combinator', name, m[0])], length: m[0].length }
|
||||
},
|
||||
(s) => {
|
||||
const m = /^(<<|>>|\^|\+|-|\*|\/|%|\||&)/.exec(s)
|
||||
if (!m) return null
|
||||
return { tokens: [new Token('operator', OPERATOR_NAMES[m[0]], m[0])], length: m[0].length }
|
||||
},
|
||||
(s) => {
|
||||
const m = /^(\(|\)|,)/.exec(s)
|
||||
if (!m) return null
|
||||
return { tokens: [new Token('grouping', GROUPING_NAMES[m[0]], m[0])], length: m[0].length }
|
||||
},
|
||||
(s) => {
|
||||
if (s[0] === '{') return { tokens: [new Token('array', 'array_start', '{')], length: 1 }
|
||||
if (s[0] === '}') return { tokens: [new Token('array', 'array_end', '}')], length: 1 }
|
||||
return null
|
||||
},
|
||||
(s) => {
|
||||
const m = /^(<=|>=|!=|<>|==|=|<|>)/.exec(s)
|
||||
if (!m) return null
|
||||
return { tokens: [new Token('comparator', COMPARATOR_NAMES[m[0]], m[0])], length: m[0].length }
|
||||
},
|
||||
(s) => {
|
||||
const m = /^(true|false)\b/i.exec(s)
|
||||
if (!m) return null
|
||||
return { tokens: [new Token('logical', m[0].toLowerCase() === 'true', m[0])], length: m[0].length }
|
||||
},
|
||||
(s) => {
|
||||
const m = /^(\w+!?)\s*\(/.exec(s)
|
||||
if (!m) return null
|
||||
const name = m[1].toLowerCase()
|
||||
return {
|
||||
tokens: [
|
||||
new Token('function', name, m[1]),
|
||||
new Token('grouping', 'open', '(')
|
||||
],
|
||||
length: m[0].length
|
||||
}
|
||||
},
|
||||
(s) => {
|
||||
const m = /^[A-Za-z_][\w.]*\b/.exec(s)
|
||||
if (!m) return null
|
||||
const value = caseSensitive ? m[0] : m[0].toLowerCase()
|
||||
return { tokens: [new Token('identifier', value, m[0])], length: m[0].length }
|
||||
},
|
||||
(s) => {
|
||||
const m = /^`([^`]*)`/.exec(s)
|
||||
if (!m) return null
|
||||
return { tokens: [new Token('identifier', m[1], m[0])], length: m[0].length }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function tokenize (input, options) {
|
||||
options = options || {}
|
||||
let remaining = String(input).replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
const scanners = buildScanners(options.caseSensitive === true)
|
||||
const tokens = []
|
||||
let nesting = 0
|
||||
|
||||
while (remaining.length > 0) {
|
||||
let matched = false
|
||||
for (let i = 0; i < scanners.length; i++) {
|
||||
const result = scanners[i](remaining, tokens.length ? tokens[tokens.length - 1] : null)
|
||||
if (result) {
|
||||
for (const tok of result.tokens) {
|
||||
if (tok.category === 'grouping' && tok.value === 'open') nesting++
|
||||
if (tok.category === 'grouping' && tok.value === 'close') {
|
||||
nesting--
|
||||
if (nesting < 0) throw new TokenizerError('too many closing parentheses')
|
||||
}
|
||||
tokens.push(tok)
|
||||
}
|
||||
remaining = remaining.slice(result.length)
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!matched) throw new TokenizerError("parse error at: '" + remaining + "'")
|
||||
}
|
||||
if (nesting > 0) throw new TokenizerError('too many opening parentheses')
|
||||
return tokens
|
||||
}
|
||||
|
||||
function uniq (arr) {
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const x of arr) { if (!seen.has(x)) { seen.add(x); out.push(x) } }
|
||||
return out
|
||||
}
|
||||
|
||||
function toNumber (val) {
|
||||
if (typeof val === 'number') return val
|
||||
if (typeof val === 'boolean') return val ? 1 : 0
|
||||
if (val == null) throw new ArgumentError("'" + val + "' is not coercible to numeric")
|
||||
if (typeof val === 'string') {
|
||||
if (val.length === 0) throw new ArgumentError("'' is not coercible to numeric")
|
||||
const n = Number(val)
|
||||
if (isNaN(n)) throw new ArgumentError("'" + val + "' is not coercible to numeric")
|
||||
return n
|
||||
}
|
||||
throw new ArgumentError("'" + val + "' is not coercible to numeric")
|
||||
}
|
||||
|
||||
class Node {
|
||||
static precedence = 0
|
||||
static arity = null
|
||||
static rightAssociative = false
|
||||
static resolveClass () { return this }
|
||||
static minParamCount () { return this.arity }
|
||||
static maxParamCount () { return this.arity }
|
||||
value () { return null }
|
||||
dependencies () { return [] }
|
||||
get type () { return null }
|
||||
}
|
||||
|
||||
class NilNode extends Node { value () { return null } }
|
||||
|
||||
class Numeric extends Node {
|
||||
constructor (token) { super(); this.v = token.value }
|
||||
value () { return this.v }
|
||||
get type () { return 'numeric' }
|
||||
}
|
||||
|
||||
class Logical extends Node {
|
||||
constructor (token) { super(); this.v = token.value }
|
||||
value () { return this.v }
|
||||
get type () { return 'logical' }
|
||||
}
|
||||
|
||||
class StringNode extends Node {
|
||||
constructor (token) { super(); this.v = token.value }
|
||||
value () { return this.v }
|
||||
get type () { return 'string' }
|
||||
}
|
||||
|
||||
class Identifier extends Node {
|
||||
constructor (token) { super(); this.ident = token.value }
|
||||
value (ctx) {
|
||||
if (ctx && (this.ident in ctx)) {
|
||||
const v = ctx[this.ident]
|
||||
if (v instanceof Node) return v.value(ctx)
|
||||
if (typeof v === 'function') return v()
|
||||
return v
|
||||
}
|
||||
throw new UnboundVariableError([this.ident])
|
||||
}
|
||||
|
||||
dependencies (ctx) {
|
||||
if (ctx && (this.ident in ctx)) {
|
||||
const v = ctx[this.ident]
|
||||
return v instanceof Node ? v.dependencies(ctx) : []
|
||||
}
|
||||
return [this.ident]
|
||||
}
|
||||
}
|
||||
|
||||
class Grouping extends Node {
|
||||
constructor (node) { super(); this.node = node }
|
||||
value (ctx) { return this.node.value(ctx) }
|
||||
get type () { return this.node.type }
|
||||
dependencies (ctx) { return this.node.dependencies(ctx) }
|
||||
}
|
||||
|
||||
class Operation extends Node {
|
||||
constructor (left, right) { super(); this.left = left; this.right = right }
|
||||
dependencies (ctx) {
|
||||
const l = this.left ? this.left.dependencies(ctx) : []
|
||||
const r = this.right ? this.right.dependencies(ctx) : []
|
||||
return uniq(l.concat(r))
|
||||
}
|
||||
}
|
||||
|
||||
class Arithmetic extends Operation { get type () { return 'numeric' } }
|
||||
|
||||
class Addition extends Arithmetic {
|
||||
static precedence = 10
|
||||
value (ctx) {
|
||||
const l = this.left.value(ctx)
|
||||
const r = this.right.value(ctx)
|
||||
if (typeof l === 'string' || typeof r === 'string') return String(l) + String(r)
|
||||
return toNumber(l) + toNumber(r)
|
||||
}
|
||||
}
|
||||
|
||||
class Subtraction extends Arithmetic {
|
||||
static precedence = 10
|
||||
value (ctx) { return toNumber(this.left.value(ctx)) - toNumber(this.right.value(ctx)) }
|
||||
}
|
||||
|
||||
class Multiplication extends Arithmetic {
|
||||
static precedence = 20
|
||||
value (ctx) { return toNumber(this.left.value(ctx)) * toNumber(this.right.value(ctx)) }
|
||||
}
|
||||
|
||||
class Division extends Arithmetic {
|
||||
static precedence = 20
|
||||
value (ctx) {
|
||||
const r = toNumber(this.right.value(ctx))
|
||||
if (r === 0) throw new ZeroDivisionError()
|
||||
return toNumber(this.left.value(ctx)) / r
|
||||
}
|
||||
}
|
||||
|
||||
class Modulo extends Arithmetic {
|
||||
static arity = 2
|
||||
static precedence = 20
|
||||
static resolveClass (nextTok) {
|
||||
if (!nextTok) return Percentage
|
||||
if (nextTok.category === 'operator') return Percentage
|
||||
if (nextTok.category === 'comparator') return Percentage
|
||||
if (nextTok.category === 'combinator') return Percentage
|
||||
if (nextTok.category === 'grouping' && (nextTok.value === 'close' || nextTok.value === 'comma')) return Percentage
|
||||
if (nextTok.category === 'array' && nextTok.value === 'array_end') return Percentage
|
||||
return Modulo
|
||||
}
|
||||
|
||||
value (ctx) {
|
||||
const r = toNumber(this.right.value(ctx))
|
||||
if (r === 0) throw new ZeroDivisionError()
|
||||
return toNumber(this.left.value(ctx)) % r
|
||||
}
|
||||
}
|
||||
|
||||
class Percentage extends Arithmetic {
|
||||
static arity = 1
|
||||
static precedence = 30
|
||||
constructor (child) { super(child, null) }
|
||||
value (ctx) { return toNumber(this.left.value(ctx)) * 0.01 }
|
||||
dependencies (ctx) { return this.left.dependencies(ctx) }
|
||||
}
|
||||
|
||||
class Exponentiation extends Arithmetic {
|
||||
static precedence = 30
|
||||
static rightAssociative = true
|
||||
value (ctx) { return Math.pow(toNumber(this.left.value(ctx)), toNumber(this.right.value(ctx))) }
|
||||
}
|
||||
|
||||
class Negation extends Arithmetic {
|
||||
static arity = 1
|
||||
static precedence = 40
|
||||
static rightAssociative = true
|
||||
constructor (node) { super(node, null) }
|
||||
value (ctx) { return -toNumber(this.left.value(ctx)) }
|
||||
dependencies (ctx) { return this.left.dependencies(ctx) }
|
||||
}
|
||||
|
||||
class BitwiseOr extends Operation {
|
||||
static precedence = 10
|
||||
value (ctx) { return toNumber(this.left.value(ctx)) | toNumber(this.right.value(ctx)) }
|
||||
get type () { return 'numeric' }
|
||||
}
|
||||
class BitwiseAnd extends Operation {
|
||||
static precedence = 10
|
||||
value (ctx) { return toNumber(this.left.value(ctx)) & toNumber(this.right.value(ctx)) }
|
||||
get type () { return 'numeric' }
|
||||
}
|
||||
class BitwiseShiftLeft extends Operation {
|
||||
static precedence = 10
|
||||
value (ctx) { return toNumber(this.left.value(ctx)) << toNumber(this.right.value(ctx)) }
|
||||
get type () { return 'numeric' }
|
||||
}
|
||||
class BitwiseShiftRight extends Operation {
|
||||
static precedence = 10
|
||||
value (ctx) { return toNumber(this.left.value(ctx)) >> toNumber(this.right.value(ctx)) }
|
||||
get type () { return 'numeric' }
|
||||
}
|
||||
|
||||
function cmpCast (v) {
|
||||
if (typeof v === 'string' && /^-?\d*\.?\d+$/.test(v)) return Number(v)
|
||||
return v
|
||||
}
|
||||
class Comparator extends Operation {
|
||||
static precedence = 5
|
||||
get type () { return 'logical' }
|
||||
}
|
||||
class LessThan extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) < cmpCast(this.right.value(ctx)) } }
|
||||
class LessThanOrEqual extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) <= cmpCast(this.right.value(ctx)) } }
|
||||
class GreaterThan extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) > cmpCast(this.right.value(ctx)) } }
|
||||
class GreaterThanOrEqual extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) >= cmpCast(this.right.value(ctx)) } }
|
||||
class NotEqual extends Comparator {
|
||||
value (ctx) {
|
||||
const l = cmpCast(this.left.value(ctx))
|
||||
const r = cmpCast(this.right.value(ctx))
|
||||
return l !== r
|
||||
}
|
||||
}
|
||||
class Equal extends Comparator {
|
||||
value (ctx) {
|
||||
const l = cmpCast(this.left.value(ctx))
|
||||
const r = cmpCast(this.right.value(ctx))
|
||||
return l === r
|
||||
}
|
||||
}
|
||||
|
||||
class Combinator extends Operation {
|
||||
static precedence = 0
|
||||
get type () { return 'logical' }
|
||||
}
|
||||
class And extends Combinator { value (ctx) { return Boolean(this.left.value(ctx)) && Boolean(this.right.value(ctx)) } }
|
||||
class Or extends Combinator { value (ctx) { return Boolean(this.left.value(ctx)) || Boolean(this.right.value(ctx)) } }
|
||||
|
||||
class ArrayNode extends Node {
|
||||
static arity = null
|
||||
static minParamCount () { return 0 }
|
||||
static maxParamCount () { return Infinity }
|
||||
constructor (...items) {
|
||||
super()
|
||||
this.items = items
|
||||
}
|
||||
|
||||
value (ctx) { return this.items.map((i) => i.value(ctx)) }
|
||||
dependencies (ctx) {
|
||||
return uniq([].concat.apply([], this.items.map((i) => i.dependencies(ctx))))
|
||||
}
|
||||
|
||||
get type () { return 'array' }
|
||||
}
|
||||
|
||||
class FunctionNode extends Node {
|
||||
static arity = null
|
||||
static minParamCount () { return 0 }
|
||||
static maxParamCount () { return Infinity }
|
||||
constructor (...args) {
|
||||
super()
|
||||
this.args = args
|
||||
}
|
||||
|
||||
dependencies (ctx) {
|
||||
return uniq([].concat.apply([], this.args.map((a) => a.dependencies(ctx))))
|
||||
}
|
||||
}
|
||||
|
||||
const FUNCTION_REGISTRY = Object.create(null)
|
||||
function registerFunctionClass (name, cls) { FUNCTION_REGISTRY[name.toLowerCase()] = cls }
|
||||
function getFunctionClass (name) { return FUNCTION_REGISTRY[name.toLowerCase()] }
|
||||
|
||||
function defineFunction (name, spec) {
|
||||
const min = spec.min == null ? 0 : spec.min
|
||||
const max = spec.max == null ? Infinity : spec.max
|
||||
const call = spec.call
|
||||
const type = spec.type || 'numeric'
|
||||
class F extends FunctionNode {
|
||||
static arity = (min === max) ? min : null
|
||||
static minParamCount () { return min }
|
||||
static maxParamCount () { return max }
|
||||
value (ctx) {
|
||||
const vals = this.args.map((a) => a.value(ctx))
|
||||
return call(vals, ctx)
|
||||
}
|
||||
|
||||
get type () { return type }
|
||||
}
|
||||
registerFunctionClass(name, F)
|
||||
return F
|
||||
}
|
||||
|
||||
function flattenNumeric (vals) {
|
||||
const out = [];
|
||||
(function walk (v) {
|
||||
if (Array.isArray(v)) v.forEach(walk)
|
||||
else out.push(toNumber(v))
|
||||
})(vals)
|
||||
return out
|
||||
}
|
||||
|
||||
defineFunction('abs', { min: 1, max: 1, call: (v) => Math.abs(toNumber(v[0])) })
|
||||
defineFunction('min', { min: 1, call: (v) => Math.min.apply(null, flattenNumeric(v)) })
|
||||
defineFunction('max', { min: 1, call: (v) => Math.max.apply(null, flattenNumeric(v)) })
|
||||
defineFunction('sum', { min: 1, call: (v) => flattenNumeric(v).reduce((a, b) => a + b, 0) })
|
||||
defineFunction('avg', {
|
||||
min: 1,
|
||||
call: (v) => {
|
||||
const nums = flattenNumeric(v)
|
||||
if (nums.length === 0) return 0
|
||||
return nums.reduce((a, b) => a + b, 0) / nums.length
|
||||
}
|
||||
})
|
||||
defineFunction('count', {
|
||||
min: 1,
|
||||
max: 1,
|
||||
call: (v) => Array.isArray(v[0]) ? v[0].length : (v[0] == null ? 0 : 1)
|
||||
})
|
||||
defineFunction('round', {
|
||||
min: 1,
|
||||
max: 2,
|
||||
call: (v) => {
|
||||
const n = toNumber(v[0])
|
||||
const p = v.length > 1 ? toNumber(v[1]) : 0
|
||||
const f = Math.pow(10, p)
|
||||
return Math.round(n * f) / f
|
||||
}
|
||||
})
|
||||
defineFunction('roundup', {
|
||||
min: 1,
|
||||
max: 2,
|
||||
call: (v) => {
|
||||
const n = toNumber(v[0])
|
||||
const p = v.length > 1 ? toNumber(v[1]) : 0
|
||||
const f = Math.pow(10, p)
|
||||
return (n < 0 ? -Math.floor(-n * f) : Math.ceil(n * f)) / f
|
||||
}
|
||||
})
|
||||
defineFunction('rounddown', {
|
||||
min: 1,
|
||||
max: 2,
|
||||
call: (v) => {
|
||||
const n = toNumber(v[0])
|
||||
const p = v.length > 1 ? toNumber(v[1]) : 0
|
||||
const f = Math.pow(10, p)
|
||||
return (n < 0 ? -Math.ceil(-n * f) : Math.floor(n * f)) / f
|
||||
}
|
||||
})
|
||||
|
||||
const MATH_FNS = {
|
||||
sqrt: Math.sqrt,
|
||||
cbrt: Math.cbrt,
|
||||
sin: Math.sin,
|
||||
cos: Math.cos,
|
||||
tan: Math.tan,
|
||||
asin: Math.asin,
|
||||
acos: Math.acos,
|
||||
atan: Math.atan,
|
||||
sinh: Math.sinh,
|
||||
cosh: Math.cosh,
|
||||
tanh: Math.tanh,
|
||||
exp: Math.exp,
|
||||
log: Math.log,
|
||||
log2: Math.log2,
|
||||
log10: Math.log10,
|
||||
floor: Math.floor,
|
||||
ceil: Math.ceil
|
||||
}
|
||||
Object.keys(MATH_FNS).forEach((name) => {
|
||||
const fn = MATH_FNS[name]
|
||||
defineFunction(name, { min: 1, max: 1, call: (v) => fn(toNumber(v[0])) })
|
||||
})
|
||||
defineFunction('atan2', { min: 2, max: 2, call: (v) => Math.atan2(toNumber(v[0]), toNumber(v[1])) })
|
||||
defineFunction('hypot', { min: 1, call: (v) => Math.hypot.apply(null, flattenNumeric(v)) })
|
||||
defineFunction('pow', { min: 2, max: 2, call: (v) => Math.pow(toNumber(v[0]), toNumber(v[1])) })
|
||||
defineFunction('pi', { min: 0, max: 0, call: () => Math.PI })
|
||||
defineFunction('e', { min: 0, max: 0, call: () => Math.E })
|
||||
|
||||
defineFunction('not', { min: 1, max: 1, type: 'logical', call: (v) => !v[0] })
|
||||
defineFunction('and', { min: 1, type: 'logical', call: (v) => v.every((x) => Boolean(x)) })
|
||||
defineFunction('or', { min: 1, type: 'logical', call: (v) => v.some((x) => Boolean(x)) })
|
||||
defineFunction('xor', {
|
||||
min: 2,
|
||||
type: 'logical',
|
||||
call: (v) => v.reduce((acc, x) => acc !== Boolean(x), false)
|
||||
})
|
||||
|
||||
class IfFunction extends FunctionNode {
|
||||
static arity = 3
|
||||
static minParamCount () { return 3 }
|
||||
static maxParamCount () { return 3 }
|
||||
value (ctx) {
|
||||
return this.args[0].value(ctx) ? this.args[1].value(ctx) : this.args[2].value(ctx)
|
||||
}
|
||||
|
||||
get type () { return this.args[1].type }
|
||||
dependencies (ctx) {
|
||||
try {
|
||||
return this.args[0].value(ctx) ? this.args[1].dependencies(ctx) : this.args[2].dependencies(ctx)
|
||||
} catch (e) {
|
||||
return uniq([].concat.apply([], this.args.map((a) => a.dependencies(ctx))))
|
||||
}
|
||||
}
|
||||
}
|
||||
registerFunctionClass('if', IfFunction)
|
||||
|
||||
defineFunction('length', { min: 1, max: 1, call: (v) => v[0] == null ? 0 : String(v[0]).length })
|
||||
defineFunction('upcase', { min: 1, max: 1, type: 'string', call: (v) => String(v[0]).toUpperCase() })
|
||||
defineFunction('downcase', { min: 1, max: 1, type: 'string', call: (v) => String(v[0]).toLowerCase() })
|
||||
defineFunction('concat', { min: 1, type: 'string', call: (v) => v.map((x) => String(x)).join('') })
|
||||
defineFunction('left', {
|
||||
min: 2,
|
||||
max: 2,
|
||||
type: 'string',
|
||||
call: (v) => String(v[0]).slice(0, toNumber(v[1]))
|
||||
})
|
||||
defineFunction('right', {
|
||||
min: 2,
|
||||
max: 2,
|
||||
type: 'string',
|
||||
call: (v) => {
|
||||
const n = toNumber(v[1])
|
||||
return n === 0 ? '' : String(v[0]).slice(-n)
|
||||
}
|
||||
})
|
||||
defineFunction('mid', {
|
||||
min: 3,
|
||||
max: 3,
|
||||
type: 'string',
|
||||
call: (v) => {
|
||||
const s = String(v[0])
|
||||
const start = toNumber(v[1]) - 1
|
||||
const len = toNumber(v[2])
|
||||
return s.slice(start, start + len)
|
||||
}
|
||||
})
|
||||
defineFunction('trim', { min: 1, max: 1, type: 'string', call: (v) => String(v[0]).trim() })
|
||||
defineFunction('contains', {
|
||||
min: 2,
|
||||
max: 2,
|
||||
type: 'logical',
|
||||
call: (v) => {
|
||||
if (Array.isArray(v[0])) return v[0].indexOf(v[1]) !== -1
|
||||
return String(v[0]).indexOf(String(v[1])) !== -1
|
||||
}
|
||||
})
|
||||
|
||||
const OP_CLASSES = {
|
||||
add: Addition,
|
||||
subtract: Subtraction,
|
||||
multiply: Multiplication,
|
||||
divide: Division,
|
||||
pow: Exponentiation,
|
||||
negate: Negation,
|
||||
mod: Modulo,
|
||||
bitor: BitwiseOr,
|
||||
bitand: BitwiseAnd,
|
||||
bitshiftleft: BitwiseShiftLeft,
|
||||
bitshiftright: BitwiseShiftRight,
|
||||
lt: LessThan,
|
||||
gt: GreaterThan,
|
||||
le: LessThanOrEqual,
|
||||
ge: GreaterThanOrEqual,
|
||||
ne: NotEqual,
|
||||
eq: Equal,
|
||||
and: And,
|
||||
or: Or
|
||||
}
|
||||
|
||||
function isOperationClass (cls) {
|
||||
return cls && (cls.prototype instanceof Operation)
|
||||
}
|
||||
|
||||
function parse (tokens) {
|
||||
if (!tokens || tokens.length === 0) return new NilNode()
|
||||
|
||||
const output = []
|
||||
const operations = []
|
||||
const arities = []
|
||||
const skip = new Set()
|
||||
|
||||
function consume (count) {
|
||||
if (count == null) count = 2
|
||||
const op = operations.pop()
|
||||
if (!op) throw new ParseError('invalid statement')
|
||||
|
||||
const outputSize = output.length
|
||||
const opMin = (typeof op.minParamCount === 'function') ? op.minParamCount() : null
|
||||
const opMax = (typeof op.maxParamCount === 'function') ? op.maxParamCount() : null
|
||||
|
||||
const actualArgs = (op.arity != null) ? op.arity : count
|
||||
const minSize = (op.arity != null) ? op.arity : (opMin != null ? opMin : count)
|
||||
const maxSize = (op.arity != null) ? op.arity : (opMax != null ? opMax : count)
|
||||
|
||||
if (outputSize < minSize || actualArgs < minSize) {
|
||||
throw new ParseError((op.name || 'operator') + ' has too few operands (given ' + outputSize + ', expected ' + minSize + ')')
|
||||
}
|
||||
if ((outputSize > maxSize && operations.length === 0) || actualArgs > maxSize) {
|
||||
throw new ParseError((op.name || 'operator') + ' has too many operands (given ' + outputSize + ', expected ' + maxSize + ')')
|
||||
}
|
||||
|
||||
if (op === ArrayNode && output.length === 0) {
|
||||
output.push(new ArrayNode())
|
||||
return
|
||||
}
|
||||
|
||||
if (outputSize < actualArgs) throw new ParseError('invalid statement')
|
||||
const args = []
|
||||
for (let i = 0; i < actualArgs; i++) args.unshift(output.pop())
|
||||
|
||||
// eslint-disable-next-line new-cap
|
||||
output.push(new op(...args))
|
||||
}
|
||||
|
||||
function handleOperator (token, lookahead) {
|
||||
let cls = OP_CLASSES[token.value]
|
||||
if (!cls) throw new ParseError('Unknown operator ' + token.value)
|
||||
if (typeof cls.resolveClass === 'function') cls = cls.resolveClass(lookahead)
|
||||
const prec = cls.precedence
|
||||
if (cls.rightAssociative) {
|
||||
while (operations.length > 0) {
|
||||
const top = operations[operations.length - 1]
|
||||
if (isOperationClass(top) && prec < top.precedence) consume()
|
||||
else break
|
||||
}
|
||||
} else {
|
||||
while (operations.length > 0) {
|
||||
const top = operations[operations.length - 1]
|
||||
if (isOperationClass(top) && prec <= top.precedence) consume()
|
||||
else break
|
||||
}
|
||||
}
|
||||
operations.push(cls)
|
||||
}
|
||||
|
||||
function handleFunction (token) {
|
||||
const fn = getFunctionClass(token.value)
|
||||
if (!fn) throw new ParseError('Undefined function ' + token.value)
|
||||
arities.push(0)
|
||||
operations.push(fn)
|
||||
}
|
||||
|
||||
function handleArray (token) {
|
||||
if (token.value === 'array_start') {
|
||||
operations.push(ArrayNode)
|
||||
arities.push(0)
|
||||
} else {
|
||||
while (operations.length > 0 && operations[operations.length - 1] !== ArrayNode) consume()
|
||||
if (operations[operations.length - 1] !== ArrayNode) throw new ParseError('Unbalanced bracket')
|
||||
consume(arities.pop() + 1)
|
||||
}
|
||||
}
|
||||
|
||||
function handleGrouping (token, lookahead, index) {
|
||||
if (token.value === 'open') {
|
||||
if (lookahead && lookahead.value === 'close') {
|
||||
skip.add(index + 1)
|
||||
arities.pop()
|
||||
consume(0)
|
||||
} else {
|
||||
operations.push(Grouping)
|
||||
}
|
||||
} else if (token.value === 'close') {
|
||||
while (operations.length > 0 && operations[operations.length - 1] !== Grouping) consume()
|
||||
const lparen = operations.pop()
|
||||
if (lparen !== Grouping) throw new ParseError('Unbalanced parenthesis')
|
||||
const top = operations[operations.length - 1]
|
||||
if (top && (top.prototype instanceof FunctionNode)) consume(arities.pop() + 1)
|
||||
} else if (token.value === 'comma') {
|
||||
if (arities.length === 0) throw new ParseError('invalid statement')
|
||||
arities[arities.length - 1] += 1
|
||||
while (operations.length > 0 &&
|
||||
operations[operations.length - 1] !== Grouping &&
|
||||
operations[operations.length - 1] !== ArrayNode) consume()
|
||||
} else {
|
||||
throw new ParseError('Unknown grouping token ' + token.value)
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
if (skip.has(i)) continue
|
||||
const token = tokens[i]
|
||||
const lookahead = tokens[i + 1]
|
||||
|
||||
switch (token.category) {
|
||||
case 'numeric': output.push(new Numeric(token)); break
|
||||
case 'logical': output.push(new Logical(token)); break
|
||||
case 'string': output.push(new StringNode(token)); break
|
||||
case 'identifier': output.push(new Identifier(token)); break
|
||||
case 'null': output.push(new NilNode()); break
|
||||
case 'operator':
|
||||
case 'comparator':
|
||||
case 'combinator':
|
||||
handleOperator(token, lookahead); break
|
||||
case 'function': handleFunction(token); break
|
||||
case 'array': handleArray(token); break
|
||||
case 'grouping': handleGrouping(token, lookahead, i); break
|
||||
default:
|
||||
throw new ParseError('Not implemented for tokens of category ' + token.category)
|
||||
}
|
||||
}
|
||||
|
||||
while (operations.length > 0) consume()
|
||||
|
||||
if (output.length !== 1) throw new ParseError('Invalid statement')
|
||||
return output[0]
|
||||
}
|
||||
|
||||
class Calculator {
|
||||
constructor (options) {
|
||||
options = options || {}
|
||||
this.caseSensitive = options.caseSensitive === true
|
||||
this.memory = Object.create(null)
|
||||
this._astCache = Object.create(null)
|
||||
}
|
||||
|
||||
key (k) { return this.caseSensitive ? k : String(k).toLowerCase() }
|
||||
|
||||
normalize (data) {
|
||||
const out = Object.create(null)
|
||||
if (!data) return out
|
||||
for (const k of Object.keys(data)) out[this.key(k)] = data[k]
|
||||
return out
|
||||
}
|
||||
|
||||
store (data) { Object.assign(this.memory, this.normalize(data)); return this }
|
||||
bind (data) { return this.store(data) }
|
||||
clear () { this.memory = Object.create(null); return this }
|
||||
|
||||
ast (expression) {
|
||||
const cached = this._astCache[expression]
|
||||
if (cached) return cached
|
||||
const node = parse(tokenize(expression, { caseSensitive: this.caseSensitive }))
|
||||
this._astCache[expression] = node
|
||||
return node
|
||||
}
|
||||
|
||||
tokenize (expression) {
|
||||
return tokenize(expression, { caseSensitive: this.caseSensitive })
|
||||
}
|
||||
|
||||
dependencies (expression) {
|
||||
return this.ast(expression).dependencies(this.memory)
|
||||
}
|
||||
|
||||
evaluate (expression, data, onError) {
|
||||
try {
|
||||
return this.evaluateStrict(expression, data)
|
||||
} catch (e) {
|
||||
if (typeof data === 'function') return data(expression, e)
|
||||
if (typeof onError === 'function') return onError(expression, e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
evaluateStrict (expression, data) {
|
||||
const ctx = Object.assign(Object.create(null), this.memory, this.normalize(data))
|
||||
const node = this.ast(expression)
|
||||
return node.value(ctx)
|
||||
}
|
||||
|
||||
addFunction (name, spec) {
|
||||
defineFunction(name, {
|
||||
min: spec.minArgs == null ? (spec.min == null ? 0 : spec.min) : spec.minArgs,
|
||||
max: spec.maxArgs == null ? (spec.max == null ? Infinity : spec.max) : spec.maxArgs,
|
||||
type: spec.type || 'numeric',
|
||||
call: spec.call
|
||||
})
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
Calculator,
|
||||
tokenize,
|
||||
parse,
|
||||
CalculatorError,
|
||||
ParseError,
|
||||
TokenizerError,
|
||||
UnboundVariableError,
|
||||
ZeroDivisionError,
|
||||
ArgumentError
|
||||
}
|
||||
|
||||
export function addFunction (name, spec) { defineFunction(name, spec) }
|
||||
@@ -3,10 +3,13 @@
|
||||
id="form_completed"
|
||||
class="mx-auto max-w-md flex flex-col completed-form"
|
||||
dir="auto"
|
||||
role="status"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="font-medium text-2xl flex items-center space-x-1.5 mx-auto">
|
||||
<IconCircleCheck
|
||||
class="inline text-green-600"
|
||||
aria-hidden="true"
|
||||
:width="30"
|
||||
:height="30"
|
||||
/>
|
||||
@@ -42,8 +45,12 @@
|
||||
<IconInnerShadowTop
|
||||
v-if="isSendingCopy"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconMail
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconMail v-else />
|
||||
<span>
|
||||
{{ t('send_copy_via_email') }}
|
||||
</span>
|
||||
@@ -57,8 +64,12 @@
|
||||
<IconInnerShadowTop
|
||||
v-if="isDownloading"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconDownload
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconDownload v-else />
|
||||
<span>
|
||||
{{ t('download') }}
|
||||
</span>
|
||||
@@ -199,7 +210,7 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
document.querySelectorAll('#decline_button').forEach((button) => {
|
||||
document.querySelectorAll('#decline_button, #decline_button_mobile, #delegate_button, #delegate_button_mobile').forEach((button) => {
|
||||
button.setAttribute('disabled', 'true')
|
||||
})
|
||||
},
|
||||
|
||||
@@ -30,12 +30,16 @@
|
||||
class="btn btn-outline btn-sm !normal-case font-normal set-current-date-button"
|
||||
@click.prevent="[setCurrentDate(), $emit('focus')]"
|
||||
>
|
||||
<IconCalendarCheck :width="16" />
|
||||
<IconCalendarCheck
|
||||
:width="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('set_today') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="field.description"
|
||||
:id="field.uuid + '-desc'"
|
||||
class="mb-3 px-1 field-description-text"
|
||||
dir="auto"
|
||||
>
|
||||
@@ -51,12 +55,19 @@
|
||||
:max="validationMax"
|
||||
class="base-input !text-2xl text-center w-full"
|
||||
:required="field.required"
|
||||
type="date"
|
||||
:name="`values[${field.uuid}]`"
|
||||
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
|
||||
:type="inputType"
|
||||
:name="formatType === 'datetime' ? undefined : `values[${field.uuid}]`"
|
||||
@keydown.enter="onEnter"
|
||||
@focus="$emit('focus')"
|
||||
@paste="onPaste"
|
||||
>
|
||||
<input
|
||||
v-if="formatType === 'datetime'"
|
||||
type="hidden"
|
||||
:name="`values[${field.uuid}]`"
|
||||
:value="modelValue"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -92,14 +103,19 @@ export default {
|
||||
},
|
||||
emits: ['update:model-value', 'focus', 'submit'],
|
||||
computed: {
|
||||
formatType () {
|
||||
const format = this.field.preferences?.format || ''
|
||||
|
||||
if (/[HhAasz]/.test(format)) return 'datetime'
|
||||
if (format && !/[Dd]/.test(format)) return 'month'
|
||||
|
||||
return 'date'
|
||||
},
|
||||
inputType () {
|
||||
return { datetime: 'datetime-local', month: 'month', date: 'date' }[this.formatType]
|
||||
},
|
||||
dateNowString () {
|
||||
const today = new Date()
|
||||
|
||||
const yyyy = today.getFullYear()
|
||||
const mm = String(today.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(today.getDate()).padStart(2, '0')
|
||||
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
return this.formatDateValue(new Date())
|
||||
},
|
||||
validationMin () {
|
||||
if (this.field.validation?.min) {
|
||||
@@ -116,6 +132,8 @@ export default {
|
||||
}
|
||||
},
|
||||
withToday () {
|
||||
if (this.formatType === 'datetime') return false
|
||||
|
||||
const todayDate = new Date().setHours(0, 0, 0, 0)
|
||||
|
||||
if (this.validationMin) {
|
||||
@@ -132,9 +150,25 @@ export default {
|
||||
},
|
||||
value: {
|
||||
set (value) {
|
||||
if (this.formatType === 'datetime' && value) {
|
||||
const d = new Date(value)
|
||||
|
||||
if (!isNaN(d)) {
|
||||
this.$emit('update:model-value', d.toISOString())
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.$emit('update:model-value', value)
|
||||
},
|
||||
get () {
|
||||
if (this.formatType === 'datetime') {
|
||||
const d = new Date(this.modelValue)
|
||||
|
||||
return isNaN(d) ? '' : this.formatDateValue(d)
|
||||
}
|
||||
|
||||
return this.modelValue
|
||||
}
|
||||
}
|
||||
@@ -158,20 +192,32 @@ export default {
|
||||
|
||||
const parsedDate = new Date(pasteData)
|
||||
|
||||
if (!isNaN(parsedDate)) {
|
||||
const inputEl = this.$refs.input
|
||||
if (isNaN(parsedDate)) return
|
||||
|
||||
inputEl.valueAsDate = new Date(parsedDate.getTime() - parsedDate.getTimezoneOffset() * 60000)
|
||||
|
||||
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
this.setInputValue(parsedDate)
|
||||
},
|
||||
setCurrentDate () {
|
||||
this.setInputValue(new Date())
|
||||
},
|
||||
setInputValue (date) {
|
||||
const inputEl = this.$refs.input
|
||||
|
||||
inputEl.valueAsDate = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000)
|
||||
if (this.formatType === 'date') {
|
||||
inputEl.valueAsDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
|
||||
} else {
|
||||
inputEl.value = this.formatDateValue(date)
|
||||
}
|
||||
|
||||
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
},
|
||||
formatDateValue (date) {
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
const ymd = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
|
||||
if (this.formatType === 'month') return ymd.slice(0, 7)
|
||||
if (this.formatType === 'datetime') return `${ymd}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
|
||||
return ymd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,9 @@
|
||||
ref="input"
|
||||
:multiple="multiple"
|
||||
:accept="accept"
|
||||
:aria-label="message"
|
||||
type="file"
|
||||
class="hidden"
|
||||
class="sr-only"
|
||||
@change="onSelectFiles"
|
||||
>
|
||||
</label>
|
||||
|
||||
@@ -1,5 +1,52 @@
|
||||
<template>
|
||||
<Teleport
|
||||
v-if="withAccessibilityAreas === null && !isAccessibilityMode"
|
||||
to="#sr_only_content"
|
||||
>
|
||||
<button
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-base-100 focus:text-base-content focus:rounded focus:shadow-lg"
|
||||
@click="isAccessibilityMode = true"
|
||||
>
|
||||
{{ t('enter_screen_reader_mode') }}
|
||||
</button>
|
||||
</Teleport>
|
||||
<Teleport
|
||||
v-for="item in (withAccessibilityAreas === null ? schema : [])"
|
||||
:key="item.attachment_uuid"
|
||||
:to="`#document-${item.attachment_uuid} .sr_only_content`"
|
||||
>
|
||||
<button
|
||||
v-if="!isAccessibilityMode"
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-base-100 focus:text-base-content focus:rounded focus:shadow-lg"
|
||||
@click="isAccessibilityMode = true"
|
||||
>
|
||||
{{ t('enter_screen_reader_mode') }}
|
||||
</button>
|
||||
</Teleport>
|
||||
<AccessibilityAreas
|
||||
v-if="withAccessibilityAreas || isAccessibilityMode"
|
||||
ref="areas"
|
||||
:submitter-slug="submitterSlug"
|
||||
:steps="stepFields"
|
||||
:readonly-conditional-fields="readonlyConditionalFields"
|
||||
:readonly-conditional-field-values="readonlyConditionalFieldValues"
|
||||
:formula-fields="formulaFields"
|
||||
:values="values"
|
||||
:readonly-values="readonlyFieldValues"
|
||||
:submitter="submitter"
|
||||
:scroll-el="scrollEl"
|
||||
:current-step="currentStepFields"
|
||||
:with-field-placeholder="withFieldPlaceholder"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-label="withFieldLabels && !isAnonymousChecboxes && showFieldNames"
|
||||
:scroll-padding="scrollPadding"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:fetch-options="fetchOptions"
|
||||
:filled-fields-index="filledFieldsIndex"
|
||||
@focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]"
|
||||
/>
|
||||
<FieldAreas
|
||||
v-if="!withAccessibilityAreas && !isAccessibilityMode"
|
||||
ref="areas"
|
||||
:steps="stepFields"
|
||||
:values="values"
|
||||
@@ -14,6 +61,7 @@
|
||||
@focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]"
|
||||
/>
|
||||
<FieldAreas
|
||||
v-if="!withAccessibilityAreas && !isAccessibilityMode"
|
||||
:steps="readonlyConditionalFields.map((e) => [e])"
|
||||
:values="readonlyConditionalFieldValues"
|
||||
:submitter="submitter"
|
||||
@@ -21,7 +69,7 @@
|
||||
:submittable="false"
|
||||
/>
|
||||
<FormulaFieldAreas
|
||||
v-if="formulaFields.length"
|
||||
v-if="!withAccessibilityAreas && !isAccessibilityMode && formulaFields.length"
|
||||
:fields="formulaFields"
|
||||
:readonly-values="readonlyFieldValues"
|
||||
:values="values"
|
||||
@@ -57,6 +105,7 @@
|
||||
<IconInnerShadowTop
|
||||
v-if="isSubmittingComplete"
|
||||
class="mr-1 animate-spin w-5 h-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{{ t('complete') }}
|
||||
@@ -88,6 +137,7 @@
|
||||
class="absolute right-0 mr-4"
|
||||
:width="20"
|
||||
:height="20"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
@@ -103,11 +153,13 @@
|
||||
class="absolute right-0 top-0 minimize-form-button"
|
||||
:class="currentField?.description?.length > 100 ? 'mr-1 mt-1 md:mr-2 md:mt-2': 'mr-2 mt-2 hidden md:block'"
|
||||
:title="t('minimize')"
|
||||
:aria-label="t('minimize')"
|
||||
@click.prevent="minimizeForm"
|
||||
>
|
||||
<IconArrowsDiagonalMinimize2
|
||||
:width="20"
|
||||
:height="20"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
@@ -193,6 +245,7 @@
|
||||
/>
|
||||
<div
|
||||
v-if="currentField.description"
|
||||
:id="currentField.uuid + '-desc'"
|
||||
dir="auto"
|
||||
class="mb-3 px-1 field-description-text"
|
||||
>
|
||||
@@ -203,6 +256,7 @@
|
||||
:id="currentField.uuid"
|
||||
dir="auto"
|
||||
:required="currentField.required"
|
||||
:aria-describedby="currentField.description ? currentField.uuid + '-desc' : undefined"
|
||||
class="select base-input !text-2xl w-full text-center font-normal"
|
||||
:class="{ 'text-gray-300': !values[currentField.uuid] }"
|
||||
:name="`values[${currentField.uuid}]`"
|
||||
@@ -250,6 +304,7 @@
|
||||
</label>
|
||||
<div
|
||||
v-if="currentField.description"
|
||||
:id="currentField.uuid + '-desc'"
|
||||
dir="auto"
|
||||
class="mb-3 px-1 field-description-text"
|
||||
>
|
||||
@@ -309,6 +364,7 @@
|
||||
>
|
||||
<div
|
||||
v-if="currentField.description"
|
||||
:id="currentField.uuid + '-desc'"
|
||||
dir="auto"
|
||||
class="mb-3 px-1 field-description-text"
|
||||
>
|
||||
@@ -402,6 +458,8 @@
|
||||
:remember-signature="rememberSignature"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:require-signing-reason="requireSigningReason"
|
||||
:signature-text="signatureText"
|
||||
:signature-src="signatureSrc"
|
||||
:button-text="submitButtonText"
|
||||
:dry-run="dryRun"
|
||||
:with-disclosure="withDisclosure"
|
||||
@@ -509,6 +567,7 @@
|
||||
<IconInnerShadowTop
|
||||
v-if="isSubmitting"
|
||||
class="mr-1 animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{{ submitButtonText }}
|
||||
@@ -520,6 +579,7 @@
|
||||
</button>
|
||||
<div
|
||||
v-if="showFillAllRequiredFields"
|
||||
role="alert"
|
||||
class="text-center mt-1"
|
||||
>
|
||||
{{ t('please_fill_all_required_fields') }}
|
||||
@@ -552,8 +612,10 @@
|
||||
:can-send-email="canSendEmail && !!submitter.email"
|
||||
:submitter-slug="submitterSlug"
|
||||
/>
|
||||
<div
|
||||
<nav
|
||||
v-if="stepFields.length < 80"
|
||||
:aria-label="t('form_progress')"
|
||||
:aria-hidden="isCompleted"
|
||||
class="flex justify-center mt-3 sm:mt-4 mb-0 sm:mb-1 select-none"
|
||||
>
|
||||
<div class="flex items-center flex-wrap steps-progress">
|
||||
@@ -561,16 +623,18 @@
|
||||
v-for="(step, index) in stepFields"
|
||||
:key="step[0].uuid"
|
||||
>
|
||||
<a
|
||||
<button
|
||||
v-if="!onlyRequiredFields || step.some((f) => f.required)"
|
||||
href="#"
|
||||
class="inline border border-base-300 h-3 w-3 rounded-full mx-1 mt-1"
|
||||
type="button"
|
||||
:aria-label="`${t('step')} ${index + 1}`"
|
||||
:aria-current="index === currentStep ? 'step' : undefined"
|
||||
class="inline border border-base-300 h-3 w-3 rounded-full mx-1 mt-1 p-0"
|
||||
:class="{ 'bg-base-300 steps-progress-current': index === currentStep, 'bg-base-content': (index < currentStep && stepFields[index].every((f) => !f.required || ![null, undefined, ''].includes(values[f.uuid]))) || isCompleted, 'bg-white': index > currentStep }"
|
||||
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(index, true)]"
|
||||
@click="isCompleted ? '' : [saveStep(), goToStep(index, true)]"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div
|
||||
v-else
|
||||
class="mt-5"
|
||||
@@ -582,6 +646,7 @@
|
||||
<script>
|
||||
import FieldAreas from './areas'
|
||||
import FormulaFieldAreas from './formula_areas'
|
||||
import AccessibilityAreas from './accessibility_areas'
|
||||
import ImageStep from './image_step'
|
||||
import SignatureStep from './signature_step'
|
||||
import InitialsStep from './initials_step'
|
||||
@@ -640,6 +705,7 @@ export default {
|
||||
name: 'SubmissionForm',
|
||||
components: {
|
||||
FieldAreas,
|
||||
AccessibilityAreas,
|
||||
ImageStep,
|
||||
SignatureStep,
|
||||
AppearsOn,
|
||||
@@ -822,6 +888,16 @@ export default {
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
filledFieldsIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withAccessibilityAreas: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
@@ -832,6 +908,16 @@ export default {
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
signatureText: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
signatureSrc: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
previousSignatureValue: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -922,7 +1008,8 @@ export default {
|
||||
isSubmitting: false,
|
||||
isSubmittingComplete: false,
|
||||
submittedValues: {},
|
||||
recalculateButtonDisabledKey: ''
|
||||
recalculateButtonDisabledKey: '',
|
||||
isAccessibilityMode: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -1446,7 +1533,7 @@ export default {
|
||||
}
|
||||
|
||||
this.enableScrollIntoField = false
|
||||
this.$refs.form.querySelector('input[type="date"], input[type="number"], input[type="text"], select')?.focus()
|
||||
this.$refs.form.querySelector('input[type="date"], input[type="number"], input[type="text"], input[type="tel"], textarea, select')?.focus()
|
||||
this.enableScrollIntoField = true
|
||||
|
||||
if (clickUpload && !this.values[this.currentField.uuid] && ['file', 'image'].includes(this.currentField.type)) {
|
||||
@@ -1617,6 +1704,15 @@ export default {
|
||||
|
||||
if (this.completedRedirectUrl) {
|
||||
window.location.href = sanitizeUrl(this.completedRedirectUrl)
|
||||
} else {
|
||||
this.$nextTick(() => {
|
||||
const root = this.$root.$el.parentNode.getRootNode()
|
||||
const completedEl = root.getElementById('form_completed')
|
||||
|
||||
if (completedEl) {
|
||||
completedEl.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,34 +68,9 @@ export default {
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
const {
|
||||
create,
|
||||
evaluateDependencies,
|
||||
addDependencies,
|
||||
subtractDependencies,
|
||||
divideDependencies,
|
||||
multiplyDependencies,
|
||||
powDependencies,
|
||||
roundDependencies,
|
||||
absDependencies,
|
||||
sinDependencies,
|
||||
tanDependencies,
|
||||
cosDependencies
|
||||
} = await import('mathjs')
|
||||
const { Calculator } = await import('./calculator')
|
||||
|
||||
this.math = create({
|
||||
evaluateDependencies,
|
||||
addDependencies,
|
||||
subtractDependencies,
|
||||
divideDependencies,
|
||||
multiplyDependencies,
|
||||
powDependencies,
|
||||
roundDependencies,
|
||||
absDependencies,
|
||||
sinDependencies,
|
||||
tanDependencies,
|
||||
cosDependencies
|
||||
})
|
||||
this.math = new Calculator()
|
||||
|
||||
this.isMathLoaded = true
|
||||
},
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
const en = {
|
||||
step: 'Step',
|
||||
form_progress: 'Form progress',
|
||||
close: 'Close',
|
||||
uploaded_files: 'Uploaded files',
|
||||
signature_drawing_area: 'Signature drawing area. Use mouse or touch to draw your signature.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Please upload an image file',
|
||||
must_be_characters_length: 'Must be {number} characters long',
|
||||
@@ -98,10 +103,16 @@ const en = {
|
||||
files: 'Files',
|
||||
signature_is_too_small_or_simple_please_redraw: 'Signature is too small or simple. Please redraw.',
|
||||
browser_privacy_settings_block_canvas: 'Your browser privacy settings restrict use of the drawing canvas. Please use a different browser or device, or disable privacy settings that block canvas in order to sign.',
|
||||
wait_countdown_seconds: 'Wait {countdown} seconds'
|
||||
wait_countdown_seconds: 'Wait {countdown} seconds',
|
||||
enter_screen_reader_mode: 'Enter screen reader mode'
|
||||
}
|
||||
|
||||
const es = {
|
||||
step: 'Paso',
|
||||
form_progress: 'Progreso del formulario',
|
||||
close: 'Cerrar',
|
||||
uploaded_files: 'Archivos subidos',
|
||||
signature_drawing_area: 'Área de dibujo de firma. Use el ratón o toque para dibujar su firma.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Por favor, sube un archivo de imagen',
|
||||
must_be_characters_length: 'Debe tener {number} caracteres de longitud',
|
||||
@@ -201,10 +212,16 @@ const es = {
|
||||
files: 'Archivos',
|
||||
signature_is_too_small_or_simple_please_redraw: 'La firma es demasiado pequeña o simple. Por favor, vuelve a dibujarla.',
|
||||
browser_privacy_settings_block_canvas: 'La configuración de privacidad de su navegador restringe el uso del lienzo de dibujo. Utilice un navegador o dispositivo diferente, o desactive la configuración de privacidad que bloquea el lienzo para firmar.',
|
||||
wait_countdown_seconds: 'Espera {countdown} segundos'
|
||||
wait_countdown_seconds: 'Espera {countdown} segundos',
|
||||
enter_screen_reader_mode: 'Activar modo lector de pantalla'
|
||||
}
|
||||
|
||||
const it = {
|
||||
step: 'Passo',
|
||||
form_progress: 'Progresso del modulo',
|
||||
close: 'Chiudi',
|
||||
uploaded_files: 'File caricati',
|
||||
signature_drawing_area: 'Area di disegno della firma. Usa il mouse o il tocco per disegnare la tua firma.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Per favore carica un file immagine',
|
||||
must_be_characters_length: 'Deve essere lungo {number} caratteri',
|
||||
@@ -304,10 +321,16 @@ const it = {
|
||||
files: 'File',
|
||||
signature_is_too_small_or_simple_please_redraw: 'La firma è troppo piccola o semplice. Ridisegnala, per favore.',
|
||||
browser_privacy_settings_block_canvas: 'Le impostazioni sulla privacy del browser limitano l\'uso dell\'area di disegno. Utilizza un browser o dispositivo diverso oppure disattiva le impostazioni sulla privacy che bloccano il canvas per firmare.',
|
||||
wait_countdown_seconds: 'Attendi {countdown} secondi'
|
||||
wait_countdown_seconds: 'Attendi {countdown} secondi',
|
||||
enter_screen_reader_mode: 'Attiva modalità lettore di schermo'
|
||||
}
|
||||
|
||||
const de = {
|
||||
step: 'Schritt',
|
||||
form_progress: 'Formularfortschritt',
|
||||
close: 'Schließen',
|
||||
uploaded_files: 'Hochgeladene Dateien',
|
||||
signature_drawing_area: 'Unterschriftszeichenbereich. Verwenden Sie die Maus oder Berührung, um Ihre Unterschrift zu zeichnen.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch',
|
||||
must_be_characters_length: 'Muss {number} Zeichen lang sein',
|
||||
@@ -407,10 +430,16 @@ const de = {
|
||||
files: 'Dateien',
|
||||
signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte neu zeichnen.',
|
||||
browser_privacy_settings_block_canvas: 'Die Datenschutzeinstellungen Ihres Browsers schränken die Nutzung der Zeichenfläche ein. Bitte verwenden Sie einen anderen Browser oder ein anderes Gerät oder deaktivieren Sie die Datenschutzeinstellungen, die Canvas blockieren, um zu unterschreiben.',
|
||||
wait_countdown_seconds: 'Bitte {countdown} Sekunden warten'
|
||||
wait_countdown_seconds: 'Bitte {countdown} Sekunden warten',
|
||||
enter_screen_reader_mode: 'Screenreader-Modus aktivieren'
|
||||
}
|
||||
|
||||
const fr = {
|
||||
step: 'Étape',
|
||||
form_progress: 'Progression du formulaire',
|
||||
close: 'Fermer',
|
||||
uploaded_files: 'Fichiers téléchargés',
|
||||
signature_drawing_area: 'Zone de dessin de signature. Utilisez la souris ou le toucher pour dessiner votre signature.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Veuillez téléverser un fichier image',
|
||||
must_be_characters_length: 'Doit comporter {number} caractères',
|
||||
@@ -510,10 +539,16 @@ const fr = {
|
||||
files: 'Fichiers',
|
||||
signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.',
|
||||
browser_privacy_settings_block_canvas: 'Les paramètres de confidentialité de votre navigateur empêchent l\'utilisation du canevas de dessin. Veuillez utiliser un autre navigateur ou appareil, ou désactiver les paramètres de confidentialité qui bloquent le canevas pour signer.',
|
||||
wait_countdown_seconds: 'Veuillez patienter {countdown} secondes'
|
||||
wait_countdown_seconds: 'Veuillez patienter {countdown} secondes',
|
||||
enter_screen_reader_mode: 'Activer le mode lecteur d\'écran'
|
||||
}
|
||||
|
||||
const pl = {
|
||||
step: 'Krok',
|
||||
form_progress: 'Postęp formularza',
|
||||
close: 'Zamknij',
|
||||
uploaded_files: 'Przesłane pliki',
|
||||
signature_drawing_area: 'Obszar rysowania podpisu. Użyj myszy lub dotyku, aby narysować swój podpis.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Proszę przesłać plik obrazu',
|
||||
must_be_characters_length: 'Musi mieć długość {number} znaków',
|
||||
@@ -613,10 +648,16 @@ const pl = {
|
||||
files: 'Pliki',
|
||||
signature_is_too_small_or_simple_please_redraw: 'Podpis jest zbyt mały lub zbyt prosty. Proszę narysować go ponownie.',
|
||||
browser_privacy_settings_block_canvas: 'Ustawienia prywatności przeglądarki blokują użycie obszaru rysowania. Użyj innej przeglądarki lub urządzenia albo wyłącz ustawienia prywatności blokujące canvas, aby podpisać.',
|
||||
wait_countdown_seconds: 'Poczekaj {countdown} sekund'
|
||||
wait_countdown_seconds: 'Poczekaj {countdown} sekund',
|
||||
enter_screen_reader_mode: 'Włącz tryb czytnika ekranu'
|
||||
}
|
||||
|
||||
const uk = {
|
||||
step: 'Крок',
|
||||
form_progress: 'Прогрес форми',
|
||||
close: 'Закрити',
|
||||
uploaded_files: 'Завантажені файли',
|
||||
signature_drawing_area: 'Область малювання підпису. Використовуйте мишу або дотик, щоб намалювати свій підпис.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Будь ласка, завантажте файл зображення',
|
||||
must_be_characters_length: 'Має містити {number} символів',
|
||||
@@ -716,10 +757,16 @@ const uk = {
|
||||
files: 'Файли',
|
||||
signature_is_too_small_or_simple_please_redraw: 'Підпис занадто маленький або надто простий. Будь ласка, перемалюйте.',
|
||||
browser_privacy_settings_block_canvas: 'Налаштування конфіденційності вашого браузера блокують використання полотна для малювання. Будь ласка, скористайтеся іншим браузером або пристроєм, або вимкніть налаштування конфіденційності, що блокують canvas, щоб підписати.',
|
||||
wait_countdown_seconds: 'Зачекайте {countdown} секунд'
|
||||
wait_countdown_seconds: 'Зачекайте {countdown} секунд',
|
||||
enter_screen_reader_mode: 'Увімкнути режим читання з екрану'
|
||||
}
|
||||
|
||||
const cs = {
|
||||
step: 'Krok',
|
||||
form_progress: 'Průběh formuláře',
|
||||
close: 'Zavřít',
|
||||
uploaded_files: 'Nahrané soubory',
|
||||
signature_drawing_area: 'Oblast pro kreslení podpisu. Použijte myš nebo dotyk k nakreslení podpisu.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Nahrajte prosím obrázkový soubor',
|
||||
must_be_characters_length: 'Musí mít délku {number} znaků',
|
||||
@@ -819,10 +866,16 @@ const cs = {
|
||||
files: 'Soubory',
|
||||
signature_is_too_small_or_simple_please_redraw: 'Podpis je příliš malý nebo jednoduchý. Nakreslete jej prosím znovu.',
|
||||
browser_privacy_settings_block_canvas: 'Nastavení soukromí vašeho prohlížeče omezuje použití kreslicího plátna. Použijte prosím jiný prohlížeč nebo zařízení, nebo vypněte nastavení soukromí blokující canvas pro podepsání.',
|
||||
wait_countdown_seconds: 'Počkejte {countdown} sekund'
|
||||
wait_countdown_seconds: 'Počkejte {countdown} sekund',
|
||||
enter_screen_reader_mode: 'Zapnout režim čtečky obrazovky'
|
||||
}
|
||||
|
||||
const pt = {
|
||||
step: 'Passo',
|
||||
form_progress: 'Progresso do formulário',
|
||||
close: 'Fechar',
|
||||
uploaded_files: 'Arquivos enviados',
|
||||
signature_drawing_area: 'Área de desenho da assinatura. Use o mouse ou toque para desenhar sua assinatura.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Por favor, envie um arquivo de imagem',
|
||||
must_be_characters_length: 'Deve ter {number} caracteres',
|
||||
@@ -922,10 +975,16 @@ const pt = {
|
||||
files: 'Arquivos',
|
||||
signature_is_too_small_or_simple_please_redraw: 'A assinatura é muito pequena ou simples. Por favor, redesenhe.',
|
||||
browser_privacy_settings_block_canvas: 'As configurações de privacidade do seu navegador restringem o uso da área de desenho. Use um navegador ou dispositivo diferente, ou desative as configurações de privacidade que bloqueiam o canvas para assinar.',
|
||||
wait_countdown_seconds: 'Aguarde {countdown} segundos'
|
||||
wait_countdown_seconds: 'Aguarde {countdown} segundos',
|
||||
enter_screen_reader_mode: 'Ativar modo leitor de tela'
|
||||
}
|
||||
|
||||
const he = {
|
||||
step: 'שלב',
|
||||
form_progress: 'התקדמות הטופס',
|
||||
close: 'סגור',
|
||||
uploaded_files: 'קבצים שהועלו',
|
||||
signature_drawing_area: 'אזור ציור חתימה. השתמש בעכבר או במגע כדי לצייר את החתימה שלך.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'אנא העלה קובץ תמונה',
|
||||
must_be_characters_length: 'חייב להיות באורך של {number} תווים',
|
||||
@@ -1025,10 +1084,16 @@ const he = {
|
||||
files: 'קבצים',
|
||||
signature_is_too_small_or_simple_please_redraw: 'החתימה קטנה או פשוטה מדי. אנא חתום מחדש.',
|
||||
browser_privacy_settings_block_canvas: 'הגדרות הפרטיות של הדפדפן שלך מגבילות את השימוש באזור הציור. אנא השתמש בדפדפן או מכשיר אחר, או בטל את הגדרות הפרטיות החוסמות canvas כדי לחתום.',
|
||||
wait_countdown_seconds: 'המתן {countdown} שניות'
|
||||
wait_countdown_seconds: 'המתן {countdown} שניות',
|
||||
enter_screen_reader_mode: 'הפעל מצב קורא מסך'
|
||||
}
|
||||
|
||||
const nl = {
|
||||
step: 'Stap',
|
||||
form_progress: 'Formuliervoortgang',
|
||||
close: 'Sluiten',
|
||||
uploaded_files: 'Geüploade bestanden',
|
||||
signature_drawing_area: 'Handtekening tekengebied. Gebruik de muis of aanraking om uw handtekening te tekenen.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand',
|
||||
must_be_characters_length: 'Moet {number} tekens lang zijn',
|
||||
@@ -1128,10 +1193,16 @@ const nl = {
|
||||
files: 'Bestanden',
|
||||
signature_is_too_small_or_simple_please_redraw: 'De handtekening is te klein of te eenvoudig. Teken opnieuw.',
|
||||
browser_privacy_settings_block_canvas: 'De privacyinstellingen van uw browser beperken het gebruik van het tekenveld. Gebruik een andere browser of ander apparaat, of schakel de privacyinstellingen uit die canvas blokkeren om te ondertekenen.',
|
||||
wait_countdown_seconds: 'Wacht {countdown} seconden'
|
||||
wait_countdown_seconds: 'Wacht {countdown} seconden',
|
||||
enter_screen_reader_mode: 'Schermlezer-modus inschakelen'
|
||||
}
|
||||
|
||||
const ar = {
|
||||
step: 'خطوة',
|
||||
form_progress: 'تقدم النموذج',
|
||||
close: 'إغلاق',
|
||||
uploaded_files: 'الملفات المرفوعة',
|
||||
signature_drawing_area: 'منطقة رسم التوقيع. استخدم الماوس أو اللمس لرسم توقيعك.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'يرجى تحميل ملف صورة',
|
||||
must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا',
|
||||
@@ -1231,10 +1302,16 @@ const ar = {
|
||||
files: 'الملفات',
|
||||
signature_is_too_small_or_simple_please_redraw: 'التوقيع صغير جدًا أو بسيط جدًا. يرجى إعادة رسمه.',
|
||||
browser_privacy_settings_block_canvas: 'إعدادات الخصوصية في متصفحك تمنع استخدام لوحة الرسم. يرجى استخدام متصفح أو جهاز مختلف، أو تعطيل إعدادات الخصوصية التي تحظر canvas للتوقيع.',
|
||||
wait_countdown_seconds: 'انتظر {countdown} ثانية'
|
||||
wait_countdown_seconds: 'انتظر {countdown} ثانية',
|
||||
enter_screen_reader_mode: 'تفعيل وضع قارئ الشاشة'
|
||||
}
|
||||
|
||||
const ko = {
|
||||
step: '단계',
|
||||
form_progress: '양식 진행',
|
||||
close: '닫기',
|
||||
uploaded_files: '업로드된 파일',
|
||||
signature_drawing_area: '서명 그리기 영역. 마우스 또는 터치를 사용하여 서명을 그리세요.',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: '이미지 파일을 업로드해 주세요',
|
||||
must_be_characters_length: '{number}자여야 합니다',
|
||||
@@ -1334,10 +1411,16 @@ const ko = {
|
||||
files: '파일',
|
||||
signature_is_too_small_or_simple_please_redraw: '서명이 너무 작거나 단순합니다. 다시 그려주세요.',
|
||||
browser_privacy_settings_block_canvas: '브라우저 개인정보 보호 설정으로 인해 그리기 캔버스를 사용할 수 없습니다. 다른 브라우저나 기기를 사용하거나, 서명을 위해 캔버스를 차단하는 개인정보 보호 설정을 비활성화해 주세요.',
|
||||
wait_countdown_seconds: '{countdown}초 기다리세요'
|
||||
wait_countdown_seconds: '{countdown}초 기다리세요',
|
||||
enter_screen_reader_mode: '스크린 리더 모드 활성화'
|
||||
}
|
||||
|
||||
const ja = {
|
||||
step: 'ステップ',
|
||||
form_progress: 'フォームの進捗',
|
||||
close: '閉じる',
|
||||
uploaded_files: 'アップロードされたファイル',
|
||||
signature_drawing_area: '署名描画エリア。マウスまたはタッチを使用して署名を描いてください。',
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: '画像ファイルをアップロードしてください',
|
||||
must_be_characters_length: '{number}文字でなければなりません',
|
||||
@@ -1437,7 +1520,8 @@ const ja = {
|
||||
files: 'ファイル',
|
||||
signature_is_too_small_or_simple_please_redraw: '署名が小さすぎるか単純すぎます。もう一度描いてください。',
|
||||
browser_privacy_settings_block_canvas: 'ブラウザのプライバシー設定により、描画キャンバスの使用が制限されています。別のブラウザまたはデバイスを使用するか、署名するためにキャンバスをブロックするプライバシー設定を無効にしてください。',
|
||||
wait_countdown_seconds: '{countdown} 秒お待ちください'
|
||||
wait_countdown_seconds: '{countdown} 秒お待ちください',
|
||||
enter_screen_reader_mode: 'スクリーンリーダーモードを有効にする'
|
||||
}
|
||||
|
||||
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar, ko, ja }
|
||||
|
||||
@@ -18,13 +18,17 @@
|
||||
class="btn btn-outline btn-sm reupload-button"
|
||||
@click.prevent="remove"
|
||||
>
|
||||
<IconReload :width="16" />
|
||||
<IconReload
|
||||
:width="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('reupload') }}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<img
|
||||
:src="attachmentsIndex[modelValue].url"
|
||||
:alt="field.name || t('image')"
|
||||
class="h-52 border border-base-300 rounded mx-auto uploaded-image-preview"
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -22,43 +22,60 @@
|
||||
class="md:tooltip"
|
||||
:data-tip="t('type_initial')"
|
||||
>
|
||||
<a
|
||||
<button
|
||||
id="type_text_button"
|
||||
href="#"
|
||||
type="button"
|
||||
:aria-label="t('type_initial')"
|
||||
class="btn btn-outline font-medium btn-sm type-text-button"
|
||||
@click.prevent="toggleTextInput"
|
||||
@click="toggleTextInput"
|
||||
>
|
||||
<IconTextSize :width="16" />
|
||||
<IconTextSize
|
||||
:width="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="hidden sm:inline">
|
||||
{{ t('type') }}
|
||||
</span>
|
||||
</a>
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="md:tooltip ml-2"
|
||||
:data-tip="t('draw_initials')"
|
||||
>
|
||||
<a
|
||||
<button
|
||||
id="type_text_button"
|
||||
href="#"
|
||||
type="button"
|
||||
:aria-label="t('draw_initials')"
|
||||
class="btn btn-outline font-medium btn-sm type-text-button"
|
||||
@click.prevent="toggleTextInput"
|
||||
@click="toggleTextInput"
|
||||
>
|
||||
<IconSignature :width="16" />
|
||||
<IconSignature
|
||||
:width="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="hidden sm:inline">
|
||||
{{ t('draw') }}
|
||||
</span>
|
||||
</a>
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
class="md:tooltip"
|
||||
:data-tip="t('click_to_upload')"
|
||||
>
|
||||
<label class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button">
|
||||
<IconUpload :width="16" />
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('click_to_upload')"
|
||||
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button"
|
||||
@click="$refs.uploadInput.click()"
|
||||
>
|
||||
<IconUpload
|
||||
:width="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
:key="uploadImageInputKey"
|
||||
ref="uploadInput"
|
||||
type="file"
|
||||
hidden
|
||||
accept="image/*"
|
||||
@@ -67,37 +84,45 @@
|
||||
<span class="hidden sm:inline">
|
||||
{{ t('upload') }}
|
||||
</span>
|
||||
</label>
|
||||
</button>
|
||||
</span>
|
||||
<a
|
||||
<button
|
||||
v-if="modelValue || computedPreviousValue"
|
||||
href="#"
|
||||
type="button"
|
||||
class="btn font-medium btn-outline btn-sm clear-canvas-button"
|
||||
@click.prevent="remove"
|
||||
@click="remove"
|
||||
>
|
||||
<IconReload :width="16" />
|
||||
<IconReload
|
||||
:width="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('clear') }}
|
||||
</a>
|
||||
<a
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
href="#"
|
||||
type="button"
|
||||
class="btn font-medium btn-outline btn-sm clear-canvas-button"
|
||||
@click.prevent="clear"
|
||||
@click="clear"
|
||||
>
|
||||
<IconReload :width="16" />
|
||||
<IconReload
|
||||
:width="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('clear') }}
|
||||
</a>
|
||||
<a
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:title="t('minimize')"
|
||||
href="#"
|
||||
:aria-label="t('minimize')"
|
||||
class="py-1.5 inline md:hidden"
|
||||
@click.prevent="$emit('minimize')"
|
||||
@click="$emit('minimize')"
|
||||
>
|
||||
<IconArrowsDiagonalMinimize2
|
||||
:width="20"
|
||||
:height="20"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -359,7 +384,9 @@ export default {
|
||||
},
|
||||
initTextInitial () {
|
||||
if (this.submitter.name) {
|
||||
this.$refs.textInput.value = this.submitter.name.trim().split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]?.toUpperCase() || '').join('')
|
||||
const parts = this.submitter.name.trim().split(/\s+/)
|
||||
|
||||
this.$refs.textInput.value = (parts.length > 1 ? [parts[0], parts[parts.length - 1]] : parts).map((part) => part[0]?.toUpperCase() || '').join('')
|
||||
}
|
||||
|
||||
if (this.$refs.textInput.value) {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
</label>
|
||||
<div
|
||||
v-if="field.description"
|
||||
:id="field.uuid + '-desc'"
|
||||
dir="auto"
|
||||
class="mb-3 px-1 field-description-text"
|
||||
>
|
||||
@@ -59,6 +60,7 @@
|
||||
type="checkbox"
|
||||
:name="`values[${field.uuid}][]`"
|
||||
:value="optionValue(option, index)"
|
||||
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
|
||||
class="base-checkbox !h-7 !w-7"
|
||||
:checked="(modelValue || []).includes(optionValue(option, index))"
|
||||
@change="onChange"
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
/>
|
||||
<div
|
||||
v-if="field.description"
|
||||
:id="field.uuid + '-desc'"
|
||||
dir="auto"
|
||||
class="mb-3 px-1 field-description-text"
|
||||
>
|
||||
@@ -44,6 +45,7 @@
|
||||
:max="field.validation?.max"
|
||||
class="base-input !text-2xl w-full"
|
||||
:required="field.required"
|
||||
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
|
||||
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
|
||||
:name="`values[${field.uuid}]`"
|
||||
@focus="$emit('focus')"
|
||||
|
||||
@@ -165,34 +165,9 @@ export default {
|
||||
}
|
||||
|
||||
if (this.field.preferences?.formula) {
|
||||
const {
|
||||
create,
|
||||
evaluateDependencies,
|
||||
addDependencies,
|
||||
subtractDependencies,
|
||||
divideDependencies,
|
||||
multiplyDependencies,
|
||||
powDependencies,
|
||||
roundDependencies,
|
||||
absDependencies,
|
||||
sinDependencies,
|
||||
tanDependencies,
|
||||
cosDependencies
|
||||
} = await import('mathjs')
|
||||
const { Calculator } = await import('./calculator')
|
||||
|
||||
this.math = create({
|
||||
evaluateDependencies,
|
||||
addDependencies,
|
||||
subtractDependencies,
|
||||
divideDependencies,
|
||||
multiplyDependencies,
|
||||
powDependencies,
|
||||
roundDependencies,
|
||||
absDependencies,
|
||||
sinDependencies,
|
||||
tanDependencies,
|
||||
cosDependencies
|
||||
})
|
||||
this.math = new Calculator()
|
||||
|
||||
this.isMathLoaded = true
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
</label>
|
||||
<div
|
||||
v-if="field.description"
|
||||
:id="field.uuid + '-desc'"
|
||||
dir="auto"
|
||||
class="mb-3 px-1 field-description-text"
|
||||
>
|
||||
@@ -42,28 +43,28 @@
|
||||
@input="onInputCode"
|
||||
>
|
||||
<div class="flex justify-between mt-2 -mb-2 md:-mb-4">
|
||||
<a
|
||||
<button
|
||||
v-if="!defaultValue"
|
||||
href="#"
|
||||
type="button"
|
||||
class="link change-phone-number-link"
|
||||
@click.prevent="isCodeSent = false"
|
||||
@click="isCodeSent = false"
|
||||
>
|
||||
{{ t('change_phone_number') }}
|
||||
</a>
|
||||
</button>
|
||||
<span
|
||||
v-if="resendCodeCountdown > 0"
|
||||
class="link"
|
||||
>
|
||||
{{ t('wait_countdown_seconds').replace('{countdown}', resendCodeCountdown) }}
|
||||
</span>
|
||||
<a
|
||||
<button
|
||||
v-else
|
||||
href="#"
|
||||
type="button"
|
||||
class="link resend-code-link"
|
||||
@click.prevent="resendCode"
|
||||
@click="resendCode"
|
||||
>
|
||||
{{ isResendLoading ? t('sending') : t('resend_code') }}
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -107,6 +108,7 @@
|
||||
type="tel"
|
||||
inputmode="tel"
|
||||
:required="field.required"
|
||||
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
|
||||
placeholder="234 567-8900"
|
||||
@input="onPhoneInput"
|
||||
@focus="$emit('focus')"
|
||||
|
||||
@@ -25,17 +25,21 @@
|
||||
class="md:tooltip"
|
||||
:data-tip="t('draw_signature')"
|
||||
>
|
||||
<a
|
||||
<button
|
||||
id="type_text_button"
|
||||
href="#"
|
||||
type="button"
|
||||
:aria-label="t('draw_signature')"
|
||||
class="btn btn-outline btn-sm font-medium type-text-button"
|
||||
@click.prevent="[toggleTextInput(), hideQr()]"
|
||||
@click="[toggleTextInput(), hideQr()]"
|
||||
>
|
||||
<IconSignature :width="16" />
|
||||
<IconSignature
|
||||
:width="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="hidden sm:inline">
|
||||
{{ t('draw') }}
|
||||
</span>
|
||||
</a>
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="withTypedSignature && format !== 'drawn_or_upload' && format !== 'typed_or_upload' && format !== 'typed' && format !== 'drawn' && format !== 'upload'"
|
||||
@@ -43,17 +47,21 @@
|
||||
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
|
||||
:data-tip="t('type_text')"
|
||||
>
|
||||
<a
|
||||
<button
|
||||
id="type_text_button"
|
||||
href="#"
|
||||
type="button"
|
||||
:aria-label="t('type_text')"
|
||||
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap type-text-button"
|
||||
@click.prevent="[toggleTextInput(), hideQr()]"
|
||||
@click="[toggleTextInput(), hideQr()]"
|
||||
>
|
||||
<IconTextSize :width="16" />
|
||||
<IconTextSize
|
||||
:width="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="hidden sm:inline">
|
||||
{{ t('type') }}
|
||||
</span>
|
||||
</a>
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
v-if="format !== 'typed' && format !== 'drawn' && format !== 'upload' && format !== 'drawn_or_typed'"
|
||||
@@ -61,10 +69,19 @@
|
||||
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
|
||||
:data-tip="t('take_photo')"
|
||||
>
|
||||
<label class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button">
|
||||
<IconCamera :width="16" />
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('take_photo')"
|
||||
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button"
|
||||
@click="$refs.takePhotoInput.click()"
|
||||
>
|
||||
<IconCamera
|
||||
:width="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
:key="uploadImageInputKey"
|
||||
ref="takePhotoInput"
|
||||
type="file"
|
||||
hidden
|
||||
accept="image/*"
|
||||
@@ -73,49 +90,57 @@
|
||||
<span class="hidden sm:inline">
|
||||
{{ t('upload') }}
|
||||
</span>
|
||||
</label>
|
||||
</button>
|
||||
</span>
|
||||
<a
|
||||
<button
|
||||
v-if="modelValue || computedPreviousValue"
|
||||
href="#"
|
||||
type="button"
|
||||
class="btn btn-outline btn-sm font-medium reupload-button"
|
||||
@click.prevent="remove"
|
||||
@click="remove"
|
||||
>
|
||||
<IconReload :width="16" />
|
||||
<IconReload
|
||||
:width="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t(format === 'upload' ? 'reupload' : 'redraw') }}
|
||||
</a>
|
||||
</button>
|
||||
<span
|
||||
v-if="withQrButton && !modelValue && !computedPreviousValue && format !== 'typed_or_upload' && format !== 'typed' && format !== 'upload'"
|
||||
class="md:tooltip before:translate-x-[-90%]"
|
||||
:data-tip="t('sign_on_the_touchscreen')"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('sign_on_the_touchscreen')"
|
||||
class="btn btn-sm btn-neutral font-medium hidden md:flex"
|
||||
:class="{ 'btn-outline': !isShowQr, 'text-white': isShowQr }"
|
||||
@click.prevent="isShowQr ? hideQr() : [isTextSignature = false, showQr()]"
|
||||
@click="isShowQr ? hideQr() : [isTextSignature = false, showQr()]"
|
||||
>
|
||||
<IconQrcode
|
||||
:width="19"
|
||||
:height="19"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</button>
|
||||
</span>
|
||||
<a
|
||||
href="#"
|
||||
<button
|
||||
type="button"
|
||||
:title="t('minimize')"
|
||||
:aria-label="t('minimize')"
|
||||
class="py-1.5 inline md:hidden"
|
||||
@click.prevent="$emit('minimize')"
|
||||
@click="$emit('minimize')"
|
||||
>
|
||||
<IconArrowsDiagonalMinimize2
|
||||
:width="20"
|
||||
:height="20"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="field.description"
|
||||
:id="field.uuid + '-desc'"
|
||||
dir="auto"
|
||||
class="mb-3 px-1 field-description-text"
|
||||
>
|
||||
@@ -136,6 +161,7 @@
|
||||
<img
|
||||
v-if="modelValue || computedPreviousValue"
|
||||
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
|
||||
:alt="field.name || t('signature')"
|
||||
class="mx-auto bg-white border border-base-300 rounded max-h-44"
|
||||
>
|
||||
<FileDropzone
|
||||
@@ -154,14 +180,17 @@
|
||||
v-if="!modelValue && !computedPreviousValue && !isShowQr && !isTextSignature && isSignatureStarted"
|
||||
class="absolute top-0.5 right-0.5"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost font-medium btn-xs md:btn-sm"
|
||||
@click.prevent="[clear(), hideQr()]"
|
||||
@click="[clear(), hideQr()]"
|
||||
>
|
||||
<IconReload :width="16" />
|
||||
<IconReload
|
||||
:width="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('clear') }}
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="isTextSignature"
|
||||
@@ -170,6 +199,8 @@
|
||||
<canvas
|
||||
v-show="!modelValue && !computedPreviousValue"
|
||||
ref="canvas"
|
||||
role="application"
|
||||
:aria-label="t('signature_drawing_area')"
|
||||
style="padding: 1px; 0"
|
||||
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
|
||||
/>
|
||||
@@ -182,13 +213,14 @@
|
||||
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">
|
||||
<a
|
||||
href="#"
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-normal btn-outline"
|
||||
@click.prevent="hideQr"
|
||||
:aria-label="t('close')"
|
||||
@click="hideQr"
|
||||
>
|
||||
<IconX />
|
||||
</a>
|
||||
<IconX aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-center w-full h-full p-4">
|
||||
<div
|
||||
@@ -408,6 +440,16 @@ export default {
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
signatureText: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
signatureSrc: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -422,7 +464,7 @@ export default {
|
||||
isOtherReason: false,
|
||||
isUsePreviousValue: true,
|
||||
isTouchAttachment: false,
|
||||
isTextSignature: this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload',
|
||||
isTextSignature: !this.signatureSrc && (!!this.signatureText || this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload'),
|
||||
uploadImageInputKey: Math.random().toString()
|
||||
}
|
||||
},
|
||||
@@ -482,7 +524,9 @@ export default {
|
||||
if (entry.isIntersecting) {
|
||||
this.setCanvasSize()
|
||||
|
||||
if (this.isTextSignature) {
|
||||
if (this.signatureSrc) {
|
||||
this.$nextTick(() => this.drawSignatureSrc())
|
||||
} else if (this.isTextSignature) {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.textInput) {
|
||||
this.initTypedSignature()
|
||||
@@ -686,7 +730,9 @@ export default {
|
||||
}
|
||||
},
|
||||
async initTypedSignature () {
|
||||
if (this.submitter.name) {
|
||||
if (this.signatureText) {
|
||||
this.$refs.textInput.value = this.signatureText
|
||||
} else if (this.submitter.name) {
|
||||
this.$refs.textInput.value = this.submitter.name
|
||||
}
|
||||
|
||||
@@ -696,6 +742,48 @@ export default {
|
||||
this.updateWrittenSignature({ target: this.$refs.textInput })
|
||||
}
|
||||
},
|
||||
drawSignatureSrc () {
|
||||
const canvas = this.$refs.canvas
|
||||
|
||||
if (!canvas) return
|
||||
|
||||
const img = new Image()
|
||||
|
||||
img.crossOrigin = 'anonymous'
|
||||
|
||||
img.onload = () => {
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
const aspectRatio = img.width / img.height
|
||||
const canvasWidth = canvas.width / scale
|
||||
const canvasHeight = canvas.height / scale
|
||||
|
||||
let targetWidth = canvasWidth
|
||||
let targetHeight = canvasHeight
|
||||
|
||||
if (canvasWidth / canvasHeight > aspectRatio) {
|
||||
targetWidth = canvasHeight * aspectRatio
|
||||
} else {
|
||||
targetHeight = canvasWidth / aspectRatio
|
||||
}
|
||||
|
||||
const x = (canvasWidth - targetWidth) / 2
|
||||
const y = (canvasHeight - targetHeight) / 2
|
||||
|
||||
context.clearRect(0, 0, canvasWidth, canvasHeight)
|
||||
context.drawImage(img, x, y, targetWidth, targetHeight)
|
||||
|
||||
this.isSignatureStarted = true
|
||||
|
||||
this.$emit('start')
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
console.error(`Failed to load signature image from ${this.signatureSrc}. The remote server must send an Access-Control-Allow-Origin header to allow CORS access.`)
|
||||
}
|
||||
|
||||
img.src = this.signatureSrc
|
||||
},
|
||||
drawImage (event) {
|
||||
this.remove()
|
||||
this.clear()
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
/>
|
||||
<div
|
||||
v-if="field.description"
|
||||
:id="field.uuid + '-desc'"
|
||||
dir="auto"
|
||||
class="mb-3 px-1 field-description-text"
|
||||
>
|
||||
@@ -40,6 +41,7 @@
|
||||
:class="{ '!pr-11 -mr-10': !field.validation?.pattern }"
|
||||
:required="field.required"
|
||||
:pattern="field.validation?.pattern"
|
||||
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
|
||||
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
|
||||
type="text"
|
||||
:name="`values[${field.uuid}]`"
|
||||
@@ -54,6 +56,7 @@
|
||||
v-model="text"
|
||||
dir="auto"
|
||||
class="base-textarea !text-2xl w-full"
|
||||
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
|
||||
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
|
||||
:required="field.required"
|
||||
:name="`values[${field.uuid}]`"
|
||||
@@ -65,13 +68,14 @@
|
||||
class="tooltip"
|
||||
:data-tip="t('toggle_multiline_text')"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="t('toggle_multiline_text')"
|
||||
class="btn btn-ghost btn-circle btn-sm toggle-multiline-text-button"
|
||||
@click.prevent="toggleTextArea"
|
||||
@click="toggleTextArea"
|
||||
>
|
||||
<IconAlignBoxLeftTop />
|
||||
</a>
|
||||
<IconAlignBoxLeftTop aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -20,13 +20,17 @@
|
||||
{{ t('complete_all_required_fields_to_proceed_with_identity_verification') }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="errorMessage">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
v-else-if="isLoading"
|
||||
class="w-full flex space-x-2 justify-center mb-2"
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
width="40"
|
||||
class="animate-spin h-10"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="redirectUrl">
|
||||
@@ -96,6 +100,7 @@ export default {
|
||||
isMathLoaded: false,
|
||||
redirectUrl: '',
|
||||
isLoading: false,
|
||||
errorMessage: '',
|
||||
eidEasyData: {}
|
||||
}
|
||||
},
|
||||
@@ -162,6 +167,12 @@ export default {
|
||||
}).then(async (resp) => {
|
||||
this.eidEasyData = await resp.json()
|
||||
|
||||
if (this.eidEasyData.error) {
|
||||
this.errorMessage = this.eidEasyData.error
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (this.eidEasyData.check_completed) {
|
||||
this.$emit('submit')
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
<div
|
||||
ref="textContainer"
|
||||
class="flex items-center px-0.5"
|
||||
:style="{ color: field.preferences?.color }"
|
||||
:style="{ color: isConditionMatch ? field.preferences?.color : '#9ca3af' }"
|
||||
:class="{ 'w-full h-full': isWFullType }"
|
||||
>
|
||||
<IconCheck
|
||||
@@ -131,13 +131,13 @@
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
v-else-if="field.type === 'number' && !isValueInput && (field.default_value || field.default_value == 0)"
|
||||
v-else-if="field.type === 'number' && !isContenteditable && (displayValue || displayValue == 0)"
|
||||
class="whitespace-pre-wrap"
|
||||
>{{ formatNumber(field.default_value, field.preferences?.format) }}</span>
|
||||
>{{ formatNumber(displayValue, field.preferences?.format) }}</span>
|
||||
<span
|
||||
v-else-if="field.default_value === '{{date}}'"
|
||||
>
|
||||
{{ t('signing_date') }}
|
||||
{{ /[HhAasz]/.test(field.preferences?.format || '') ? t('signing_date_and_time') : t('signing_date') }}
|
||||
</span>
|
||||
<div
|
||||
v-else-if="field.type === 'cells' && field.default_value"
|
||||
@@ -183,12 +183,12 @@
|
||||
:contenteditable="isValueInput"
|
||||
class="whitespace-pre-wrap outline-none empty:before:content-[attr(placeholder)] before:text-base-content/30"
|
||||
:class="{ 'cursor-text': isValueInput }"
|
||||
:placeholder="withFieldPlaceholder && !isValueInput ? defaultField?.title || field.title || field.name || defaultName : (field.type === 'date' ? field.preferences?.format || t('type_value') : t('type_value'))"
|
||||
:placeholder="withFieldPlaceholder && !isValueInput ? defaultField?.title || field.title || field.name || defaultName : (isConditionMatch ? (field.type === 'date' ? field.preferences?.format || t('type_value') : t('type_value')) : '')"
|
||||
@blur="onDefaultValueBlur"
|
||||
@focus="selectedAreasRef.value = [area]"
|
||||
@paste.prevent="onPaste"
|
||||
@keydown.enter="onDefaultValueEnter"
|
||||
>{{ field.default_value }}</span>
|
||||
>{{ displayValue }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<component
|
||||
@@ -241,6 +241,16 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
conditionalFieldIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
formulaValuesIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
isDraw: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -323,11 +333,27 @@ export default {
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldLabels: FieldType.computed.fieldLabels,
|
||||
fieldIcons: FieldType.computed.fieldIcons,
|
||||
isConditionMatch () {
|
||||
return !this.inputMode || this.conditionalFieldIndex[this.field.uuid] !== false
|
||||
},
|
||||
displayValue () {
|
||||
if (this.field.preferences?.formula && this.field.type !== 'payment') {
|
||||
const computed = this.formulaValuesIndex[this.field.uuid]
|
||||
|
||||
if (computed != null) {
|
||||
return computed
|
||||
}
|
||||
}
|
||||
|
||||
return this.field.default_value
|
||||
},
|
||||
bgClasses () {
|
||||
if (this.field.type === 'heading') {
|
||||
return 'bg-gray-50'
|
||||
} else if (this.field.type === 'strikethrough') {
|
||||
return 'bg-transparent'
|
||||
} else if (!this.isConditionMatch) {
|
||||
return 'bg-gray-100'
|
||||
} else {
|
||||
return this.bgColors[this.submitterIndex % this.bgColors.length]
|
||||
}
|
||||
@@ -337,6 +363,8 @@ export default {
|
||||
return ''
|
||||
} else if (this.field.type === 'strikethrough') {
|
||||
return 'border-dashed border-gray-300'
|
||||
} else if (!this.isConditionMatch) {
|
||||
return 'border-gray-300'
|
||||
} else {
|
||||
return this.borderColors[this.submitterIndex % this.borderColors.length]
|
||||
}
|
||||
@@ -390,7 +418,7 @@ export default {
|
||||
return this.basePageWidth / 612.0
|
||||
},
|
||||
isDefaultValuePresent () {
|
||||
return this.field?.default_value || this.field?.default_value === 0
|
||||
return this.field?.default_value || this.field?.default_value === 0 || this.displayValue || this.displayValue === 0
|
||||
},
|
||||
isSelectInput () {
|
||||
return this.inputMode && (this.field.type === 'select' || (this.field.type === 'radio' && this.field.areas?.length < 2))
|
||||
@@ -399,6 +427,8 @@ export default {
|
||||
return this.inputMode && (this.field.type === 'checkbox' || (['radio', 'multiple'].includes(this.field.type) && this.area.option_uuid))
|
||||
},
|
||||
isValueInput () {
|
||||
if (this.inputMode && this.field.preferences?.formula) return false
|
||||
|
||||
return (this.field.type === 'heading' && this.isHeadingSelected) || this.isContenteditable ||
|
||||
(this.inputMode && (['text', 'number'].includes(this.field.type) || (this.field.type === 'date' && this.field.default_value !== '{{date}}')))
|
||||
},
|
||||
@@ -511,7 +541,7 @@ export default {
|
||||
return option?.value || `${this.t('option')} ${this.field.options.indexOf(option) + 1}`
|
||||
},
|
||||
maybeToggleDefaultValue () {
|
||||
if (!this.editable || this.isCmdKeyRef.value) {
|
||||
if (!this.editable || this.isCmdKeyRef.value || this.field.preferences?.formula) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -559,6 +589,10 @@ export default {
|
||||
}
|
||||
},
|
||||
focusValueInput (e) {
|
||||
if (this.inputMode && this.field.type === 'number' && !this.isContenteditable && !this.field.preferences?.formula) {
|
||||
this.isContenteditable = true
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.defaultValue && this.$refs.defaultValue !== document.activeElement) {
|
||||
this.$refs.defaultValue.focus()
|
||||
@@ -624,6 +658,12 @@ export default {
|
||||
}
|
||||
},
|
||||
onDefaultValueBlur (e) {
|
||||
if (this.field.preferences?.formula) {
|
||||
this.isContenteditable = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const text = this.$refs.defaultValue.innerText.trim()
|
||||
|
||||
this.isContenteditable = false
|
||||
|
||||
@@ -364,7 +364,14 @@
|
||||
:document="dynamicDocuments.find((dynamicDocument) => dynamicDocument.uuid === document.uuid)"
|
||||
:selected-submitter="selectedSubmitter"
|
||||
:drag-field="dragField"
|
||||
:draw-field="drawField"
|
||||
:draw-field-type="drawFieldType"
|
||||
:draw-custom-field="drawCustomField"
|
||||
:draw-option="drawOption"
|
||||
@update="onDynamicDocumentUpdate"
|
||||
@draw="clearDrawField"
|
||||
@add-custom-field="addCustomField"
|
||||
@set-draw="[drawField = $event.field, drawOption = $event.option]"
|
||||
/>
|
||||
<Document
|
||||
v-else
|
||||
@@ -374,6 +381,9 @@
|
||||
:document="document"
|
||||
:is-drag="!!dragField"
|
||||
:input-mode="inputMode"
|
||||
:conditional-field-index="conditionalFieldIndex"
|
||||
:formula-values-index="formulaValuesIndex"
|
||||
:page-preview-format="pagePreviewFormat"
|
||||
:default-fields="[...defaultRequiredFields, ...defaultFields]"
|
||||
:allow-draw="!onlyDefinedFields || drawField || drawCustomField"
|
||||
:with-signature-id="withSignatureId"
|
||||
@@ -497,11 +507,14 @@
|
||||
:with-custom-fields="withCustomFields"
|
||||
:with-fields-search="withFieldsSearch"
|
||||
:default-fields="[...defaultRequiredFields, ...defaultFields]"
|
||||
:with-custom-fields-tab="withCustomFieldsTab"
|
||||
:template="template"
|
||||
:default-required-fields="defaultRequiredFields"
|
||||
:detect-custom-fields-index="detectCustomFieldsIndex"
|
||||
:field-types="fieldTypes"
|
||||
:with-sticky-submitters="withStickySubmitters"
|
||||
:with-fields-detection="withFieldsDetection"
|
||||
:with-detect-existing-fields="withDetectExistingFields"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-prefillable="withPrefillable"
|
||||
:only-defined-fields="onlyDefinedFields"
|
||||
@@ -509,6 +522,8 @@
|
||||
:show-tour-start-form="showTourStartForm"
|
||||
@add-field="addField"
|
||||
@set-draw="[drawField = $event.field, drawOption = $event.option]"
|
||||
@remove-field="onRemoveField"
|
||||
@remove-submitter="onRemoveSubmitter"
|
||||
@select-submitter="selectedSubmitter = $event"
|
||||
@set-draw-type="[drawFieldType = $event, showDrawField = true]"
|
||||
@set-draw-custom-field="[drawCustomField = $event, showDrawField = true]"
|
||||
@@ -608,6 +623,16 @@ import { v4 } from 'uuid'
|
||||
import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
|
||||
import * as i18n from './i18n'
|
||||
|
||||
const isEmpty = (obj) => {
|
||||
if (obj == null) return true
|
||||
if (Array.isArray(obj)) return obj.length === 0
|
||||
if (typeof obj === 'string') return obj.trim().length === 0
|
||||
if (typeof obj === 'object') return Object.keys(obj).length === 0
|
||||
if (obj === false) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'TemplateBuilder',
|
||||
components: {
|
||||
@@ -645,6 +670,7 @@ export default {
|
||||
locale: this.locale,
|
||||
baseFetch: this.baseFetch,
|
||||
fieldTypes: this.fieldTypes,
|
||||
dateFormats: this.dateFormats,
|
||||
backgroundColor: this.backgroundColor,
|
||||
withPhone: this.withPhone,
|
||||
withVerification: this.withVerification,
|
||||
@@ -729,6 +755,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withDetectExistingFields: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withCustomFields: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -769,6 +800,11 @@ export default {
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
withCustomFieldsTab: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withSelectedFieldType: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -789,6 +825,11 @@ export default {
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
dateFormats: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
defaultSubmitters: {
|
||||
type: Array,
|
||||
required: false,
|
||||
@@ -799,6 +840,11 @@ export default {
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
pagePreviewFormat: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '.jpg'
|
||||
},
|
||||
acceptFileTypes: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -944,6 +990,7 @@ export default {
|
||||
isLoadingBlankPage: false,
|
||||
isSaving: false,
|
||||
isDetectingPageFields: false,
|
||||
detectFieldsQueue: [],
|
||||
detectingAnalyzingProgress: null,
|
||||
detectingFieldsAddedCount: null,
|
||||
selectedSubmitter: null,
|
||||
@@ -954,7 +1001,8 @@ export default {
|
||||
drawCustomField: null,
|
||||
drawOption: null,
|
||||
dragField: null,
|
||||
isDragFile: false
|
||||
isDragFile: false,
|
||||
isMathLoaded: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -994,6 +1042,8 @@ export default {
|
||||
return isMobileSafariIos || /android|iphone|ipad/i.test(navigator.userAgent)
|
||||
},
|
||||
defaultDateFormat () {
|
||||
if (this.dateFormats.length) return this.dateFormats[0]
|
||||
|
||||
const isUsBrowser = Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US')
|
||||
const isUsTimezone = new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).format(new Date()).match(/\s(?:CST|CDT|PST|PDT|EST|EDT)$/)
|
||||
|
||||
@@ -1035,6 +1085,43 @@ export default {
|
||||
|
||||
return map
|
||||
},
|
||||
fieldsUuidIndex () {
|
||||
return this.template.fields.reduce((acc, f) => {
|
||||
acc[f.uuid] = f
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
conditionalFieldIndex () {
|
||||
if (!this.inputMode) return {}
|
||||
|
||||
const cache = {}
|
||||
|
||||
return this.template.fields.reduce((acc, f) => {
|
||||
acc[f.uuid] = this.checkFieldConditions(f, cache)
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
formulaValuesIndex () {
|
||||
const formulaFields = this.template.fields.filter((f) => f.preferences?.formula && f.type !== 'payment' && this.hasFormulaDependencyValue(f))
|
||||
|
||||
if (!formulaFields.length) return {}
|
||||
|
||||
if (!this.isMathLoaded) {
|
||||
this.loadCalculator()
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
return formulaFields.reduce((acc, f) => {
|
||||
if (this.conditionalFieldIndex[f.uuid] !== false) {
|
||||
acc[f.uuid] = this.calculateFormula(f)
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
isAllRequiredFieldsAdded () {
|
||||
return !this.defaultRequiredFields?.some((f) => {
|
||||
return !this.template.fields?.some((field) => field.name === f.name)
|
||||
@@ -1043,6 +1130,39 @@ export default {
|
||||
selectedField () {
|
||||
return this.template.fields.find((f) => f.areas?.includes(this.lastSelectedArea))
|
||||
},
|
||||
detectFieldsIndex () {
|
||||
const submittersByUuid = {}
|
||||
|
||||
this.template.submitters.forEach((s) => {
|
||||
submittersByUuid[s.uuid] = s
|
||||
})
|
||||
|
||||
const index = {}
|
||||
|
||||
this.template.fields.forEach((f) => {
|
||||
if (!f.name) return
|
||||
|
||||
const role = submittersByUuid[f.submitter_uuid]?.name
|
||||
const key = [f.name, role].filter(Boolean).join(':').toLowerCase()
|
||||
|
||||
if (!index[key]) index[key] = f
|
||||
})
|
||||
|
||||
return index
|
||||
},
|
||||
detectCustomFieldsIndex () {
|
||||
const index = {}
|
||||
|
||||
;[...this.customFields, ...this.defaultRequiredFields, ...this.defaultFields].forEach((c) => {
|
||||
if (!c.name) return
|
||||
|
||||
const key = [c.name, c.role].filter(Boolean).join(':').toLowerCase()
|
||||
|
||||
if (!index[key]) index[key] = c
|
||||
})
|
||||
|
||||
return index
|
||||
},
|
||||
sortedDocuments () {
|
||||
return this.template.schema.map((item) => {
|
||||
return this.template.documents.find(doc => doc.uuid === item.attachment_uuid)
|
||||
@@ -1088,6 +1208,16 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
const deduplicateUuidsIndex = {}
|
||||
|
||||
this.template.submitters.forEach((submitter) => {
|
||||
if (deduplicateUuidsIndex[submitter.uuid]) {
|
||||
submitter.uuid = v4()
|
||||
}
|
||||
|
||||
deduplicateUuidsIndex[submitter.uuid] = true
|
||||
})
|
||||
|
||||
this.selectedSubmitter = this.template.submitters[0]
|
||||
},
|
||||
mounted () {
|
||||
@@ -1128,6 +1258,132 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
toRaw,
|
||||
applyCustomFieldAttributes: Fields.methods.applyCustomFieldAttributes,
|
||||
buildExistingFields: Fields.methods.buildExistingFields,
|
||||
async loadCalculator () {
|
||||
if (this.math) return
|
||||
|
||||
const { Calculator } = await import('../submission_form/calculator')
|
||||
|
||||
this.math = new Calculator()
|
||||
this.isMathLoaded = true
|
||||
},
|
||||
optionValue (option, index) {
|
||||
if (option.value) {
|
||||
return option.value
|
||||
} else {
|
||||
return `${this.t('option')} ${index + 1}`
|
||||
}
|
||||
},
|
||||
checkFieldConditions (field, cache = {}) {
|
||||
const cacheKey = field.uuid || field.attachment_uuid
|
||||
|
||||
if (cache[cacheKey] !== undefined) {
|
||||
return cache[cacheKey]
|
||||
}
|
||||
|
||||
if (field.conditions?.length) {
|
||||
const result = field.conditions.reduce((acc, cond) => {
|
||||
if (cond.operation === 'or') {
|
||||
acc.push(acc.pop() || this.checkFieldCondition(cond, cache))
|
||||
} else {
|
||||
acc.push(this.checkFieldCondition(cond, cache))
|
||||
}
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
cache[cacheKey] = !result.includes(false)
|
||||
} else {
|
||||
cache[cacheKey] = true
|
||||
}
|
||||
|
||||
return cache[cacheKey]
|
||||
},
|
||||
checkFieldCondition (condition, cache = {}) {
|
||||
const field = this.fieldsUuidIndex[condition.field_uuid]
|
||||
|
||||
if (['not_empty', 'checked', 'equal', 'contains', 'greater_than', 'less_than'].includes(condition.action) && field && !this.checkFieldConditions(field, cache)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const defaultValue = !field || isEmpty(field.default_value) ? null : field.default_value
|
||||
|
||||
if (['empty', 'unchecked'].includes(condition.action)) {
|
||||
return isEmpty(defaultValue)
|
||||
} else if (['not_empty', 'checked'].includes(condition.action)) {
|
||||
return !isEmpty(defaultValue)
|
||||
} else if (field?.type === 'number' && ['equal', 'not_equal', 'greater_than', 'less_than'].includes(condition.action)) {
|
||||
const value = 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)
|
||||
|
||||
if (option) {
|
||||
const values = [defaultValue].flat()
|
||||
|
||||
return values.includes(this.optionValue(option, field.options.indexOf(option)))
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
return [defaultValue].flat().includes(condition.value)
|
||||
}
|
||||
} else if (['not_equal', 'does_not_contain'].includes(condition.action) && field) {
|
||||
if (field.options) {
|
||||
const option = field.options.find((o) => o.uuid === condition.value)
|
||||
|
||||
if (option) {
|
||||
const values = [defaultValue].flat()
|
||||
|
||||
return !values.includes(this.optionValue(option, field.options.indexOf(option)))
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
},
|
||||
normalizeFormula (formula, depth = 0) {
|
||||
if (depth > 10) return formula
|
||||
|
||||
return formula.replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
if (this.fieldsUuidIndex[uuid]?.preferences?.formula) {
|
||||
return `(${this.normalizeFormula(this.fieldsUuidIndex[uuid].preferences.formula, depth + 1)})`
|
||||
} else {
|
||||
return match
|
||||
}
|
||||
})
|
||||
},
|
||||
calculateFormula (field) {
|
||||
const transformedFormula = this.normalizeFormula(field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
return this.fieldsUuidIndex[uuid]?.default_value || 0.0
|
||||
})
|
||||
|
||||
return this.math.evaluate(transformedFormula.toLowerCase())
|
||||
},
|
||||
hasFormulaDependencyValue (field) {
|
||||
const normalized = this.normalizeFormula(field.preferences.formula)
|
||||
|
||||
return [...normalized.matchAll(/{{(.*?)}}/g)].some(([, uuid]) => !isEmpty(this.fieldsUuidIndex[uuid]?.default_value))
|
||||
},
|
||||
addCustomField (field) {
|
||||
return this.$refs.fields.addCustomField(field)
|
||||
},
|
||||
@@ -1155,6 +1411,32 @@ export default {
|
||||
this.drawCustomField = null
|
||||
this.showDrawField = false
|
||||
},
|
||||
onRemoveField (field) {
|
||||
if (this.dynamicDocuments.length) {
|
||||
field.areas?.forEach((area) => {
|
||||
this.documentRefs.forEach((documentRef) => {
|
||||
if (documentRef.isDynamic && documentRef.document.uuid === area.attachment_uuid) {
|
||||
documentRef.removeArea(area)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
onRemoveSubmitter (submitter) {
|
||||
if (this.dynamicDocuments.length) {
|
||||
this.template.fields.forEach((field) => {
|
||||
if (field.submitter_uuid === submitter.uuid) {
|
||||
field.areas?.forEach((area) => {
|
||||
this.documentRefs.forEach((documentRef) => {
|
||||
if (documentRef.isDynamic && documentRef.document.uuid === area.attachment_uuid) {
|
||||
documentRef.removeArea(area)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
toggleSelectMode () {
|
||||
this.isSelectModeRef.value = !this.isSelectModeRef.value
|
||||
|
||||
@@ -1447,6 +1729,30 @@ export default {
|
||||
this.template.fields.push(field)
|
||||
}
|
||||
},
|
||||
insertDetectedField (field) {
|
||||
if (!this.withDetectExistingFields || !field.name) {
|
||||
this.insertField(field)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const role = this.template.submitters.find((s) => s.uuid === field.submitter_uuid)?.name
|
||||
const nameKey = field.name.toLowerCase()
|
||||
const indexKey = [field.name, role].filter(Boolean).join(':').toLowerCase()
|
||||
|
||||
const existingField = this.detectFieldsIndex[indexKey]
|
||||
|
||||
if (existingField) {
|
||||
existingField.areas = existingField.areas || []
|
||||
existingField.areas.push(...(field.areas || []))
|
||||
} else {
|
||||
const customField = this.detectCustomFieldsIndex[indexKey] || this.detectCustomFieldsIndex[nameKey]
|
||||
|
||||
if (customField) this.applyCustomFieldAttributes(field, customField)
|
||||
|
||||
this.insertField(field)
|
||||
}
|
||||
},
|
||||
closeDropdown () {
|
||||
document.activeElement.blur()
|
||||
},
|
||||
@@ -2512,60 +2818,80 @@ export default {
|
||||
|
||||
this.save()
|
||||
},
|
||||
removeAreasByAttachmentUuid (attachmentUuid) {
|
||||
const removedFieldUuids = []
|
||||
|
||||
this.selectedAreasRef.value = this.selectedAreasRef.value.filter((area) => area.attachment_uuid !== attachmentUuid)
|
||||
|
||||
this.template.fields.forEach((field) => {
|
||||
[...(field.areas || [])].forEach((area) => {
|
||||
if (area.attachment_uuid === attachmentUuid) {
|
||||
field.areas.splice(field.areas.indexOf(area), 1)
|
||||
|
||||
removedFieldUuids.push(field.uuid)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.template.fields = this.template.fields.reduce((acc, field) => {
|
||||
if (removedFieldUuids.includes(field.uuid) && !field.areas?.length) {
|
||||
this.removeFieldConditions(field)
|
||||
} else {
|
||||
acc.push(field)
|
||||
}
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
},
|
||||
onDocumentRemove (item) {
|
||||
if (window.confirm(this.t('are_you_sure_'))) {
|
||||
this.template.schema.splice(this.template.schema.indexOf(item), 1)
|
||||
|
||||
const removedFieldUuids = []
|
||||
|
||||
this.template.fields.forEach((field) => {
|
||||
[...(field.areas || [])].forEach((area) => {
|
||||
if (area.attachment_uuid === item.attachment_uuid) {
|
||||
field.areas.splice(field.areas.indexOf(area), 1)
|
||||
|
||||
removedFieldUuids.push(field.uuid)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.template.fields = this.template.fields.reduce((acc, f) => {
|
||||
if (removedFieldUuids.includes(f.uuid) && !f.areas?.length) {
|
||||
this.removeFieldConditions(f)
|
||||
} else {
|
||||
acc.push(f)
|
||||
}
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
this.removeAreasByAttachmentUuid(item.attachment_uuid)
|
||||
|
||||
this.save()
|
||||
}
|
||||
},
|
||||
onDocumentReplace (data) {
|
||||
const { replaceSchemaItem, schema, documents } = data
|
||||
const isReplacingDynamicDocument = !!replaceSchemaItem.dynamic
|
||||
// eslint-disable-next-line camelcase
|
||||
const { google_drive_file_id, dynamic, ...cleanedReplaceSchemaItem } = replaceSchemaItem
|
||||
|
||||
this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, { ...cleanedReplaceSchemaItem, ...schema[0] })
|
||||
this.template.documents.push(...documents)
|
||||
|
||||
if (isReplacingDynamicDocument) {
|
||||
this.removeAreasByAttachmentUuid(replaceSchemaItem.attachment_uuid)
|
||||
|
||||
const dynamicDocumentIndex = this.dynamicDocuments.findIndex((doc) => doc.uuid === replaceSchemaItem.attachment_uuid)
|
||||
|
||||
if (dynamicDocumentIndex !== -1) {
|
||||
this.dynamicDocuments.splice(dynamicDocumentIndex, 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (data.fields) {
|
||||
this.template.fields = data.fields
|
||||
|
||||
const removedFieldUuids = []
|
||||
if (isReplacingDynamicDocument) {
|
||||
this.removeAreasByAttachmentUuid(replaceSchemaItem.attachment_uuid)
|
||||
} else {
|
||||
const removedFieldUuids = []
|
||||
|
||||
this.template.fields.forEach((field) => {
|
||||
[...(field.areas || [])].forEach((area) => {
|
||||
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
|
||||
field.areas.splice(field.areas.indexOf(area), 1)
|
||||
this.template.fields.forEach((field) => {
|
||||
[...(field.areas || [])].forEach((area) => {
|
||||
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
|
||||
field.areas.splice(field.areas.indexOf(area), 1)
|
||||
|
||||
removedFieldUuids.push(field.uuid)
|
||||
}
|
||||
removedFieldUuids.push(field.uuid)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
this.template.fields =
|
||||
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
|
||||
this.template.fields =
|
||||
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
|
||||
}
|
||||
}
|
||||
|
||||
if (data.submitters) {
|
||||
@@ -2576,13 +2902,15 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
this.template.fields.forEach((field) => {
|
||||
(field.areas || []).forEach((area) => {
|
||||
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
|
||||
area.attachment_uuid = schema[0].attachment_uuid
|
||||
}
|
||||
if (!isReplacingDynamicDocument) {
|
||||
this.template.fields.forEach((field) => {
|
||||
(field.areas || []).forEach((area) => {
|
||||
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
|
||||
area.attachment_uuid = schema[0].attachment_uuid
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (this.onUpload) {
|
||||
this.onUpload(this.template)
|
||||
@@ -2678,10 +3006,13 @@ export default {
|
||||
} else {
|
||||
this.isSaving = true
|
||||
|
||||
this.documentRefs.filter((ref) => ref.update).map((ref) => ref.update())
|
||||
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
|
||||
|
||||
dynamicDocumentRefs.map((ref) => ref.update())
|
||||
|
||||
this.rebuildVariablesSchema({ disable: false })
|
||||
|
||||
const dynamicDocumentSaves = this.documentRefs.filter((ref) => ref.saveBody).map((ref) => ref.saveBody())
|
||||
const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody())
|
||||
|
||||
Promise.all([this.save(), ...dynamicDocumentSaves]).then(() => {
|
||||
window.Turbo.visit(`/templates/${this.template.id}`)
|
||||
@@ -2694,9 +3025,13 @@ export default {
|
||||
scrollToArea (area) {
|
||||
const documentRef = this.documentRefs.find((a) => a.document.uuid === area.attachment_uuid)
|
||||
|
||||
documentRef.scrollToArea(area)
|
||||
if (documentRef.isDynamic) {
|
||||
this.selectedAreasRef.value = []
|
||||
} else {
|
||||
this.selectedAreasRef.value = [area]
|
||||
}
|
||||
|
||||
this.selectedAreasRef.value = [area]
|
||||
documentRef.scrollToArea(area)
|
||||
},
|
||||
baseFetch (path, options = {}) {
|
||||
return fetch(this.baseUrl + path, {
|
||||
@@ -2709,6 +3044,12 @@ export default {
|
||||
})
|
||||
},
|
||||
detectFieldsForPage ({ page, attachmentUuid }) {
|
||||
if (this.isDetectingPageFields) {
|
||||
this.detectFieldsQueue.push({ page, attachmentUuid })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.isDetectingPageFields = true
|
||||
this.detectingAnalyzingProgress = null
|
||||
this.detectingFieldsAddedCount = null
|
||||
@@ -2747,7 +3088,11 @@ export default {
|
||||
this.baseFetch(`/templates/${this.template.id}/detect_fields`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ attachment_uuid: attachmentUuid, page })
|
||||
body: JSON.stringify({
|
||||
attachment_uuid: attachmentUuid,
|
||||
page,
|
||||
...(this.withDetectExistingFields ? { fields: this.buildExistingFields() } : {})
|
||||
})
|
||||
}).then(async (response) => {
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
@@ -2776,7 +3121,7 @@ export default {
|
||||
if (!f.submitter_uuid) {
|
||||
f.submitter_uuid = this.template.submitters[0].uuid
|
||||
}
|
||||
this.insertField(f)
|
||||
this.insertDetectedField(f)
|
||||
})
|
||||
|
||||
totalFieldsAdded += errorFields.length
|
||||
@@ -2805,7 +3150,7 @@ export default {
|
||||
|
||||
const nonOverlappingFields = filterNonOverlappingFields(finalFields)
|
||||
|
||||
nonOverlappingFields.forEach((f) => this.insertField(f))
|
||||
nonOverlappingFields.forEach((f) => this.insertDetectedField(f))
|
||||
totalFieldsAdded += nonOverlappingFields.length
|
||||
|
||||
if (nonOverlappingFields.length) {
|
||||
@@ -2843,7 +3188,7 @@ export default {
|
||||
|
||||
const nonOverlappingFields = filterNonOverlappingFields(finalFields)
|
||||
|
||||
nonOverlappingFields.forEach((f) => this.insertField(f))
|
||||
nonOverlappingFields.forEach((f) => this.insertDetectedField(f))
|
||||
totalFieldsAdded += nonOverlappingFields.length
|
||||
|
||||
if (nonOverlappingFields.length) {
|
||||
@@ -2861,7 +3206,7 @@ export default {
|
||||
|
||||
const nonOverlappingFields = filterNonOverlappingFields(finalFields)
|
||||
|
||||
nonOverlappingFields.forEach((f) => this.insertField(f))
|
||||
nonOverlappingFields.forEach((f) => this.insertDetectedField(f))
|
||||
totalFieldsAdded += nonOverlappingFields.length
|
||||
|
||||
if (nonOverlappingFields.length) {
|
||||
@@ -2894,6 +3239,10 @@ export default {
|
||||
setTimeout(() => {
|
||||
this.detectingFieldsAddedCount = null
|
||||
}, 1000)
|
||||
|
||||
if (this.detectFieldsQueue.length) {
|
||||
this.detectFieldsForPage(this.detectFieldsQueue.shift())
|
||||
}
|
||||
})
|
||||
},
|
||||
save ({ force } = { force: false }) {
|
||||
@@ -2942,92 +3291,34 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
this.reconcileDynamicFields()
|
||||
this.save()
|
||||
},
|
||||
rebuildVariablesSchema ({ disable = true } = {}) {
|
||||
const parsed = {}
|
||||
const dynamicDocumentRef = this.documentRefs.find((e) => e.isDynamic)
|
||||
|
||||
const dynamicDocumentRef = this.documentRefs.find((e) => e.mergeSchemaProperties)
|
||||
if (dynamicDocumentRef) {
|
||||
const parsed = {}
|
||||
|
||||
this.documentRefs.forEach((ref) => {
|
||||
if (ref.updateVariablesSchema) {
|
||||
ref.updateVariablesSchema()
|
||||
}
|
||||
})
|
||||
|
||||
this.dynamicDocuments.forEach((doc) => {
|
||||
if (doc.variables_schema) {
|
||||
dynamicDocumentRef.mergeSchemaProperties(parsed, doc.variables_schema)
|
||||
}
|
||||
})
|
||||
|
||||
if (!this.template.variables_schema) {
|
||||
this.template.variables_schema = parsed
|
||||
} else {
|
||||
this.syncVariablesSchema(this.template.variables_schema, parsed, { disable })
|
||||
}
|
||||
},
|
||||
syncVariablesSchema (existing, parsed, { disable = true } = {}) {
|
||||
for (const key of Object.keys(parsed)) {
|
||||
if (!existing[key]) {
|
||||
existing[key] = parsed[key]
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Object.keys(existing)) {
|
||||
if (!parsed[key]) {
|
||||
if (disable) {
|
||||
existing[key].disabled = true
|
||||
} else {
|
||||
delete existing[key]
|
||||
this.documentRefs.forEach((ref) => {
|
||||
if (ref.isDynamic) {
|
||||
ref.updateVariablesSchema()
|
||||
}
|
||||
})
|
||||
|
||||
this.dynamicDocuments.forEach((doc) => {
|
||||
if (doc.variables_schema) {
|
||||
dynamicDocumentRef.mergeSchemaProperties(parsed, doc.variables_schema)
|
||||
}
|
||||
})
|
||||
|
||||
if (!this.template.variables_schema) {
|
||||
this.template.variables_schema = parsed
|
||||
} else {
|
||||
delete existing[key].disabled
|
||||
|
||||
if (!existing[key].form_type) {
|
||||
existing[key].type = parsed[key].type
|
||||
}
|
||||
|
||||
if (parsed[key].items) {
|
||||
if (!existing[key].items) {
|
||||
existing[key].items = parsed[key].items
|
||||
} else if (existing[key].items.properties && parsed[key].items.properties) {
|
||||
this.syncVariablesSchema(existing[key].items.properties, parsed[key].items.properties, { disable })
|
||||
} else if (!existing[key].items.properties && !parsed[key].items.properties) {
|
||||
existing[key].items.type = parsed[key].items.type
|
||||
}
|
||||
}
|
||||
|
||||
if (existing[key].properties && parsed[key].properties) {
|
||||
this.syncVariablesSchema(existing[key].properties, parsed[key].properties, { disable })
|
||||
}
|
||||
dynamicDocumentRef.syncVariablesSchema(this.template.variables_schema, parsed, { disable })
|
||||
}
|
||||
} else {
|
||||
this.template.variables_schema = {}
|
||||
}
|
||||
},
|
||||
reconcileDynamicFields () {
|
||||
const dynamicFieldUuids = new Set()
|
||||
|
||||
this.dynamicDocuments.forEach((doc) => {
|
||||
const body = doc.body || ''
|
||||
const uuidRegex = /uuid="([^"]+)"/g
|
||||
let match
|
||||
|
||||
while ((match = uuidRegex.exec(body)) !== null) {
|
||||
dynamicFieldUuids.add(match[1])
|
||||
}
|
||||
})
|
||||
|
||||
const toRemove = this.template.fields.filter((field) => {
|
||||
if (field.areas && field.areas.length > 0) return false
|
||||
|
||||
return field.uuid && !dynamicFieldUuids.has(field.uuid)
|
||||
})
|
||||
|
||||
toRemove.forEach((field) => {
|
||||
this.template.fields.splice(this.template.fields.indexOf(field), 1)
|
||||
})
|
||||
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
:key="image.id"
|
||||
:ref="setPageRefs"
|
||||
:input-mode="inputMode"
|
||||
:conditional-field-index="conditionalFieldIndex"
|
||||
:formula-values-index="formulaValuesIndex"
|
||||
:number="index"
|
||||
:editable="editable"
|
||||
:data-page="index"
|
||||
@@ -64,6 +66,16 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
conditionalFieldIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
formulaValuesIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
areasIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
@@ -138,6 +150,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
pagePreviewFormat: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '.jpg'
|
||||
},
|
||||
withFieldsDetection: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -168,7 +185,7 @@ export default {
|
||||
return this.previewImagesIndex[i] || reactive({
|
||||
metadata: { ...lazyloadMetadata },
|
||||
id: Math.random().toString(),
|
||||
url: this.basePreviewUrl + `/preview/${this.document.signed_key || this.document.signed_uuid || this.document.uuid}/${i}.jpg`
|
||||
url: this.basePreviewUrl + `/preview/${this.document.signed_key || this.document.signed_uuid || this.document.uuid}/${i}${this.pagePreviewFormat}`
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
:draggable="editable"
|
||||
:style="[nodeStyle]"
|
||||
@mousedown="selectArea"
|
||||
@click.stop
|
||||
@dragstart="onDragStart"
|
||||
@contextmenu.prevent.stop="onContextMenu"
|
||||
>
|
||||
@@ -25,7 +24,14 @@
|
||||
<span
|
||||
v-if="field?.default_value"
|
||||
class="text-xs overflow-hidden text-ellipsis whitespace-nowrap pr-1 font-normal pl-0.5"
|
||||
>{{ field.default_value }}</span>
|
||||
>
|
||||
<template v-if="field.default_value === '{{date}}'">
|
||||
{{ t('signing_date') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ field.default_value }}
|
||||
</template>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="field && !iconOnlyField"
|
||||
class="text-xs overflow-hidden text-ellipsis whitespace-nowrap pr-1 opacity-70 font-normal pl-0.5"
|
||||
@@ -227,6 +233,7 @@ export default {
|
||||
this.onAreaDragStart()
|
||||
},
|
||||
onContextMenu (e) {
|
||||
this.selectArea()
|
||||
this.onAreaContextMenu(this.area, e)
|
||||
},
|
||||
onResizeStart (e) {
|
||||
|
||||
@@ -29,18 +29,28 @@
|
||||
:container="$refs.container"
|
||||
:editable="editable"
|
||||
:section="section"
|
||||
:section-refs="sectionRefs"
|
||||
:container-width="containerWidth"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:selected-submitter="selectedSubmitter"
|
||||
:drag-field="dragField"
|
||||
:draw-field="drawField"
|
||||
:draw-field-type="drawFieldType"
|
||||
:draw-custom-field="drawCustomField"
|
||||
:render-html-for-save-ref="renderHtmlForSaveRef"
|
||||
:draw-option="drawOption"
|
||||
:attachment-uuid="document.uuid"
|
||||
@update="onSectionUpdate(section, $event)"
|
||||
@draw="$emit('draw', $event)"
|
||||
@add-custom-field="$emit('add-custom-field', $event)"
|
||||
@set-draw="$emit('set-draw', $event)"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue'
|
||||
import DynamicSection from './dynamic_section.vue'
|
||||
import { dynamicStylesheet, tiptapStylesheet } from './dynamic_editor.js'
|
||||
import { buildVariablesSchema, mergeSchemaProperties } from './dynamic_variables_schema.js'
|
||||
@@ -70,9 +80,29 @@ export default {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
drawField: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
drawFieldType: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
drawCustomField: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
drawOption: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update'],
|
||||
emits: ['update', 'draw', 'set-draw', 'add-custom-field'],
|
||||
data () {
|
||||
return {
|
||||
containerWidth: 1040,
|
||||
@@ -81,6 +111,10 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
renderHtmlForSaveRef: () => ref(false),
|
||||
isDynamic () {
|
||||
return true
|
||||
},
|
||||
attachmentsIndex () {
|
||||
return (this.document.attachments || []).reduce((acc, att) => {
|
||||
acc[att.uuid] = att.url
|
||||
@@ -135,6 +169,15 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
mergeSchemaProperties,
|
||||
removeArea (area) {
|
||||
this.sectionRefs.forEach((sectionRef) => {
|
||||
const pos = sectionRef.findAreaNodePos(area.uuid)
|
||||
|
||||
if (pos !== -1) {
|
||||
sectionRef.editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run()
|
||||
}
|
||||
})
|
||||
},
|
||||
setSectionRefs (ref) {
|
||||
if (ref) {
|
||||
this.sectionRefs.push(ref)
|
||||
@@ -150,11 +193,13 @@ export default {
|
||||
}
|
||||
},
|
||||
scrollToArea (area) {
|
||||
this.sectionRefs.forEach(({ editor }) => editor.commands.setNodeSelection(0))
|
||||
|
||||
this.sectionRefs.forEach(({ editor }) => {
|
||||
const el = editor.view.dom.querySelector(`[data-area-uuid="${area.uuid}"]`)
|
||||
|
||||
if (el) {
|
||||
editor.chain().focus().setNodeSelection(editor.view.posAtDOM(el, 0)).run()
|
||||
editor.commands.setNodeSelection(editor.view.posAtDOM(el, 0))
|
||||
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
@@ -176,7 +221,7 @@ export default {
|
||||
const target = this.bodyDom.getElementById(section.id)
|
||||
|
||||
if (target) {
|
||||
target.innerHTML = editor.getHTML()
|
||||
target.innerHTML = this.getHtmlForSave(editor)
|
||||
}
|
||||
|
||||
this.document.body = this.bodyDom.body.innerHTML
|
||||
@@ -200,7 +245,7 @@ export default {
|
||||
this.sectionRefs.forEach(({ section, editor }) => {
|
||||
const target = this.bodyDom.getElementById(section.id)
|
||||
|
||||
target.innerHTML = editor.getHTML()
|
||||
target.innerHTML = this.getHtmlForSave(editor)
|
||||
})
|
||||
|
||||
this.document.body = this.bodyDom.body.innerHTML
|
||||
@@ -209,6 +254,15 @@ export default {
|
||||
|
||||
this.$emit('update', this.document)
|
||||
},
|
||||
getHtmlForSave (editor) {
|
||||
this.renderHtmlForSaveRef.value = true
|
||||
|
||||
const result = editor.getHTML()
|
||||
|
||||
this.renderHtmlForSaveRef.value = false
|
||||
|
||||
return result
|
||||
},
|
||||
saveBody () {
|
||||
clearTimeout(this.saveTimer)
|
||||
|
||||
@@ -219,6 +273,43 @@ export default {
|
||||
body: JSON.stringify({ body: this.bodyDom.body.innerHTML }),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
},
|
||||
syncVariablesSchema (existing, parsed, { disable = true } = {}) {
|
||||
for (const key of Object.keys(parsed)) {
|
||||
if (!existing[key]) {
|
||||
existing[key] = parsed[key]
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Object.keys(existing)) {
|
||||
if (!parsed[key]) {
|
||||
if (disable) {
|
||||
existing[key].disabled = true
|
||||
} else {
|
||||
delete existing[key]
|
||||
}
|
||||
} else {
|
||||
delete existing[key].disabled
|
||||
|
||||
if (!existing[key].form_type) {
|
||||
existing[key].type = parsed[key].type
|
||||
}
|
||||
|
||||
if (parsed[key].items) {
|
||||
if (!existing[key].items) {
|
||||
existing[key].items = parsed[key].items
|
||||
} else if (existing[key].items.properties && parsed[key].items.properties) {
|
||||
this.syncVariablesSchema(existing[key].items.properties, parsed[key].items.properties, { disable })
|
||||
} else if (!existing[key].items.properties && !parsed[key].items.properties) {
|
||||
existing[key].items.type = parsed[key].items.type
|
||||
}
|
||||
}
|
||||
|
||||
if (existing[key].properties && parsed[key].properties) {
|
||||
this.syncVariablesSchema(existing[key].properties, parsed[key].properties, { disable })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,16 +19,19 @@ export const tiptapStylesheet = new CSSStyleSheet()
|
||||
|
||||
tiptapStylesheet.replaceSync(
|
||||
`.ProseMirror {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
white-space: break-spaces;
|
||||
-webkit-font-variant-ligatures: none;
|
||||
font-variant-ligatures: none;
|
||||
font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */
|
||||
font-feature-settings: "liga" 0;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
min-height: inherit;
|
||||
}
|
||||
|
||||
.ProseMirror > article {
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
.ProseMirror [contenteditable="false"] {
|
||||
@@ -89,7 +92,7 @@ img.ProseMirror-separator {
|
||||
.ProseMirror-focused .ProseMirror-gapcursor {
|
||||
display: block;
|
||||
}
|
||||
.variable-highlight {
|
||||
dynamic-variable {
|
||||
background-color: #fef3c7;
|
||||
}`)
|
||||
|
||||
@@ -192,6 +195,8 @@ const CustomHeading = Node.create({
|
||||
|
||||
const SectionNode = createBlockNode('section', 'section')
|
||||
const ArticleNode = createBlockNode('article', 'article')
|
||||
const HeaderNode = createBlockNode('header', 'header')
|
||||
const FooterNode = createBlockNode('footer', 'footer')
|
||||
const DivNode = createBlockNode('div', 'div')
|
||||
const BlockquoteNode = createBlockNode('blockquote', 'blockquote')
|
||||
const PreNode = createBlockNode('pre', 'pre')
|
||||
@@ -533,7 +538,7 @@ function buildDecorations (doc) {
|
||||
const from = pos + match.index
|
||||
const to = from + match[0].length
|
||||
|
||||
decorations.push(Decoration.inline(from, to, { class: 'variable-highlight' }))
|
||||
decorations.push(Decoration.inline(from, to, { nodeName: 'dynamic-variable' }))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -584,7 +589,7 @@ const VariableHighlight = Extension.create({
|
||||
}
|
||||
})
|
||||
|
||||
export function buildEditor ({ dynamicAreaProps, attachmentsIndex, onFieldDrop, onFieldDestroy, editorOptions }) {
|
||||
export function buildEditor ({ dynamicAreaProps, attachmentsIndex, renderHtmlForSaveRef, onFieldDrop, onFieldDestroy, editorOptions }) {
|
||||
const FieldNode = Node.create({
|
||||
name: 'fieldNode',
|
||||
inline: true,
|
||||
@@ -617,11 +622,32 @@ export function buildEditor ({ dynamicAreaProps, attachmentsIndex, onFieldDrop,
|
||||
}]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['dynamic-field', {
|
||||
const attrs = {
|
||||
uuid: node.attrs.uuid,
|
||||
'area-uuid': node.attrs.areaUuid,
|
||||
style: `width: ${node.attrs.width}; height: ${node.attrs.height}; display: ${node.attrs.display}; vertical-align: ${node.attrs.verticalAlign};`
|
||||
}]
|
||||
}
|
||||
|
||||
if (!renderHtmlForSaveRef.value) {
|
||||
const fieldArea = dynamicAreaProps.findFieldArea(node.attrs.areaUuid)
|
||||
|
||||
if (fieldArea?.field && fieldArea?.area) {
|
||||
const field = JSON.parse(JSON.stringify(fieldArea.field))
|
||||
const area = JSON.parse(JSON.stringify(fieldArea.area))
|
||||
|
||||
delete field.areas
|
||||
delete field.uuid
|
||||
delete field.submitter_uuid
|
||||
delete area.uuid
|
||||
delete area.attachment_uuid
|
||||
|
||||
attrs['data-field'] = JSON.stringify(field)
|
||||
attrs['data-area'] = JSON.stringify(area)
|
||||
attrs['data-template-id'] = dynamicAreaProps.template.id
|
||||
}
|
||||
}
|
||||
|
||||
return ['dynamic-field', attrs]
|
||||
},
|
||||
addNodeView () {
|
||||
return ({ node, getPos, editor }) => {
|
||||
@@ -721,14 +747,12 @@ export function buildEditor ({ dynamicAreaProps, attachmentsIndex, onFieldDrop,
|
||||
History,
|
||||
Gapcursor,
|
||||
Dropcursor,
|
||||
CustomBold,
|
||||
CustomItalic,
|
||||
CustomUnderline,
|
||||
CustomStrike,
|
||||
CustomParagraph,
|
||||
CustomHeading,
|
||||
SectionNode,
|
||||
ArticleNode,
|
||||
HeaderNode,
|
||||
FooterNode,
|
||||
DivNode,
|
||||
BlockquoteNode,
|
||||
PreNode,
|
||||
@@ -747,6 +771,10 @@ export function buildEditor ({ dynamicAreaProps, attachmentsIndex, onFieldDrop,
|
||||
EmptySpanNode,
|
||||
LinkMark,
|
||||
SpanMark,
|
||||
CustomBold,
|
||||
CustomItalic,
|
||||
CustomUnderline,
|
||||
CustomStrike,
|
||||
SubscriptMark,
|
||||
SuperscriptMark,
|
||||
VariableHighlight,
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative bg-white select-none mb-4 before:border before:rounded before:top-0 before:bottom-0 before:left-0 before:right-0 before:absolute"
|
||||
:class="{ 'cursor-crosshair': isDrawMode && editable }"
|
||||
>
|
||||
<div
|
||||
v-if="isDrawMode && editable && cursorHighlightCoords"
|
||||
class="absolute pointer-events-none z-10 bg-black"
|
||||
:style="{ width: '1px', height: cursorHighlightCoords.height + 'px', left: cursorHighlightCoords.x + 'px', top: cursorHighlightCoords.y + 'px' }"
|
||||
/>
|
||||
<div :style="{ zoom: containerWidth / sectionWidthPx }">
|
||||
<section
|
||||
:id="section.id"
|
||||
ref="editorElement"
|
||||
dir="auto"
|
||||
:class="section.classList.value"
|
||||
:style="section.style.cssText"
|
||||
/>
|
||||
@@ -44,7 +51,10 @@
|
||||
:field="contextMenuField"
|
||||
:with-copy-to-all-pages="false"
|
||||
@close="closeContextMenu"
|
||||
@copy="onContextMenuCopy"
|
||||
@delete="onContextMenuDelete"
|
||||
@add-custom-field="$emit('add-custom-field', $event)"
|
||||
@set-draw="$emit('set-draw', $event)"
|
||||
@save="save"
|
||||
/>
|
||||
</Teleport>
|
||||
@@ -53,6 +63,7 @@
|
||||
|
||||
<script>
|
||||
import { shallowRef } from 'vue'
|
||||
import { DOMSerializer, Fragment } from '@tiptap/pm/model'
|
||||
import { v4 } from 'uuid'
|
||||
import FieldContextMenu from './field_context_menu.vue'
|
||||
import AreaTitle from './area_title.vue'
|
||||
@@ -72,6 +83,11 @@ export default {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
sectionRefs: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -100,19 +116,45 @@ export default {
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
drawField: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
drawFieldType: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
drawCustomField: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
renderHtmlForSaveRef: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
drawOption: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
attachmentUuid: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update'],
|
||||
emits: ['update', 'draw', 'set-draw', 'add-custom-field'],
|
||||
data () {
|
||||
return {
|
||||
isAreaDrag: false,
|
||||
areaToolbarCoords: null,
|
||||
dynamicMenuCoords: null,
|
||||
contextMenu: null
|
||||
contextMenu: null,
|
||||
cursorHighlightCoords: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -140,6 +182,7 @@ export default {
|
||||
verification: { width: '150px', height: '80px' },
|
||||
image: { width: '200px', height: '100px' },
|
||||
date: { width: '100px', height: this.defaultHeight },
|
||||
datenow: { width: '100px', height: this.defaultHeight },
|
||||
text: { width: '120px', height: this.defaultHeight },
|
||||
cells: { width: '120px', height: this.defaultHeight },
|
||||
file: { width: '120px', height: this.defaultHeight },
|
||||
@@ -164,6 +207,9 @@ export default {
|
||||
isDraggingField () {
|
||||
return !!(this.fieldsDragFieldRef?.value || this.customDragFieldRef?.value || this.dragField)
|
||||
},
|
||||
isDrawMode () {
|
||||
return !!(this.drawField || this.drawCustomField || this.drawFieldType)
|
||||
},
|
||||
selectedArea () {
|
||||
return this.selectedAreasRef.value[0]
|
||||
},
|
||||
@@ -192,45 +238,121 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.initEditor()
|
||||
this.editorRef.value = buildEditor({
|
||||
dynamicAreaProps: {
|
||||
template: this.template,
|
||||
t: this.t,
|
||||
selectedAreasRef: this.selectedAreasRef,
|
||||
getFieldTypeIndex: this.getFieldTypeIndex,
|
||||
findFieldArea: (areaUuid) => this.fieldAreaIndex[areaUuid],
|
||||
getZoom: () => this.zoom,
|
||||
onAreaContextMenu: this.onAreaContextMenu,
|
||||
onAreaResize: this.onAreaResize,
|
||||
onAreaDragStart: this.onAreaDragStart
|
||||
},
|
||||
attachmentsIndex: this.attachmentsIndex,
|
||||
onFieldDrop: this.onFieldDrop,
|
||||
onFieldDestroy: this.onFieldDestroy,
|
||||
renderHtmlForSaveRef: this.renderHtmlForSaveRef,
|
||||
editorOptions: {
|
||||
element: this.$refs.editorElement,
|
||||
editable: this.editable,
|
||||
content: this.section.innerHTML,
|
||||
onUpdate: (event) => this.$emit('update', event),
|
||||
onSelectionUpdate: this.onSelectionUpdate,
|
||||
onBlur: () => { this.dynamicMenuCoords = null }
|
||||
}
|
||||
})
|
||||
|
||||
this.editor.view.dom.addEventListener('paste', this.onEditorPaste, true)
|
||||
this.editor.view.dom.addEventListener('pointerdown', this.onEditorPointerDown, true)
|
||||
this.editor.view.dom.addEventListener('mousemove', this.onEditorMouseMove)
|
||||
this.editor.view.dom.addEventListener('mouseleave', this.onEditorMouseLeave)
|
||||
this.editor.view.dom.addEventListener('keydown', this.onEditorKeyDown)
|
||||
},
|
||||
beforeUnmount () {
|
||||
if (this.editor) {
|
||||
this.editor.view.dom.removeEventListener('paste', this.onEditorPaste, true)
|
||||
this.editor.view.dom.removeEventListener('pointerdown', this.onEditorPointerDown, true)
|
||||
this.editor.view.dom.removeEventListener('mousemove', this.onEditorMouseMove)
|
||||
this.editor.view.dom.removeEventListener('mouseleave', this.onEditorMouseLeave)
|
||||
this.editor.view.dom.removeEventListener('keydown', this.onEditorKeyDown)
|
||||
this.editor.destroy()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async initEditor () {
|
||||
this.editorRef.value = buildEditor({
|
||||
dynamicAreaProps: {
|
||||
template: this.template,
|
||||
t: this.t,
|
||||
selectedAreasRef: this.selectedAreasRef,
|
||||
getFieldTypeIndex: this.getFieldTypeIndex,
|
||||
findFieldArea: (areaUuid) => this.fieldAreaIndex[areaUuid],
|
||||
getZoom: () => this.zoom,
|
||||
onAreaContextMenu: this.onAreaContextMenu,
|
||||
onAreaResize: this.onAreaResize,
|
||||
onAreaDragStart: this.onAreaDragStart
|
||||
},
|
||||
attachmentsIndex: this.attachmentsIndex,
|
||||
onFieldDrop: this.onFieldDrop,
|
||||
onFieldDestroy: this.onFieldDestroy,
|
||||
editorOptions: {
|
||||
element: this.$refs.editorElement,
|
||||
editable: this.editable,
|
||||
content: this.section.innerHTML,
|
||||
onUpdate: (event) => this.$emit('update', event),
|
||||
onSelectionUpdate: this.onSelectionUpdate,
|
||||
onBlur: () => { this.dynamicMenuCoords = null }
|
||||
}
|
||||
})
|
||||
},
|
||||
findAreaNodePos (areaUuid) {
|
||||
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
|
||||
|
||||
return this.editor.view.posAtDOM(el, 0)
|
||||
},
|
||||
getFieldInsertIndex (pos) {
|
||||
const view = this.editor.view
|
||||
const fields = this.template.fields || []
|
||||
|
||||
if (!fields.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let previousField = null
|
||||
|
||||
view.state.doc.nodesBetween(0, pos, (node, nodePos) => {
|
||||
if (node.type.name !== 'fieldNode') {
|
||||
return
|
||||
}
|
||||
|
||||
if (nodePos + node.nodeSize <= pos) {
|
||||
previousField = this.fieldAreaIndex[node.attrs.areaUuid]?.field || previousField
|
||||
}
|
||||
})
|
||||
|
||||
if (!previousField) {
|
||||
previousField = this.getPreviousSectionField()
|
||||
}
|
||||
|
||||
if (!previousField) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const previousFieldIndex = fields.indexOf(previousField)
|
||||
|
||||
return previousFieldIndex === -1 ? fields.length : previousFieldIndex + 1
|
||||
},
|
||||
insertFieldInTemplate (field, index) {
|
||||
const currentFieldIndex = this.template.fields.indexOf(field)
|
||||
|
||||
if (currentFieldIndex !== -1) {
|
||||
return currentFieldIndex
|
||||
}
|
||||
|
||||
this.template.fields.splice(index, 0, field)
|
||||
|
||||
return index
|
||||
},
|
||||
getLastFieldInSection (sectionRef) {
|
||||
let lastField = null
|
||||
|
||||
sectionRef.editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === 'fieldNode') {
|
||||
lastField = this.fieldAreaIndex[node.attrs.areaUuid]?.field || lastField
|
||||
}
|
||||
})
|
||||
|
||||
return lastField
|
||||
},
|
||||
getPreviousSectionField () {
|
||||
const sectionIndex = this.sectionRefs.indexOf(this)
|
||||
|
||||
for (let index = sectionIndex - 1; index >= 0; index -= 1) {
|
||||
const previousField = this.getLastFieldInSection(this.sectionRefs[index])
|
||||
|
||||
if (previousField) {
|
||||
return previousField
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
removeArea (area) {
|
||||
const { field } = this.fieldAreaIndex[area.uuid]
|
||||
const areaIndex = field.areas.indexOf(area)
|
||||
@@ -361,6 +483,220 @@ export default {
|
||||
closeContextMenu () {
|
||||
this.contextMenu = null
|
||||
},
|
||||
onEditorMouseLeave () {
|
||||
this.cursorHighlightCoords = null
|
||||
},
|
||||
onEditorKeyDown (event) {
|
||||
if (event.key === 'Escape') {
|
||||
this.editor.chain().setNodeSelection(0).blur().run()
|
||||
this.deselectArea()
|
||||
}
|
||||
},
|
||||
onEditorMouseMove (event) {
|
||||
if (!this.isDrawMode || !this.editable) {
|
||||
this.cursorHighlightCoords = null
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const view = this.editor?.view
|
||||
|
||||
if (!view) return
|
||||
|
||||
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
|
||||
|
||||
if (!pos) {
|
||||
this.cursorHighlightCoords = null
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const coords = view.coordsAtPos(pos.pos)
|
||||
const outerRect = this.$el.getBoundingClientRect()
|
||||
const lineHeight = coords.bottom - coords.top
|
||||
|
||||
this.cursorHighlightCoords = {
|
||||
x: coords.left - outerRect.left,
|
||||
y: coords.top - outerRect.top,
|
||||
height: lineHeight
|
||||
}
|
||||
},
|
||||
getFieldNode (areaUuid) {
|
||||
const pos = this.findAreaNodePos(areaUuid)
|
||||
|
||||
return this.editor.state.doc.nodeAt(pos)
|
||||
},
|
||||
serializeFieldNodeHtml (areaUuid) {
|
||||
const node = this.getFieldNode(areaUuid)
|
||||
|
||||
if (!node) {
|
||||
return null
|
||||
}
|
||||
|
||||
const serializer = DOMSerializer.fromSchema(this.editor.state.schema)
|
||||
const container = document.createElement('div')
|
||||
|
||||
container.appendChild(serializer.serializeFragment(Fragment.from(node)))
|
||||
|
||||
return container.innerHTML
|
||||
},
|
||||
async writeHtmlToClipboard ({ html, text }, clipboardData = null) {
|
||||
if (clipboardData) {
|
||||
clipboardData.setData('text/html', html)
|
||||
clipboardData.setData('text/plain', text || html)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (navigator.clipboard?.write && window.ClipboardItem) {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
'text/html': new Blob([html], { type: 'text/html' }),
|
||||
'text/plain': new Blob([text || html], { type: 'text/plain' })
|
||||
})
|
||||
])
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text || html)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
async copyFieldAreaToClipboard (areaUuid, clipboardData = null) {
|
||||
const html = this.serializeFieldNodeHtml(areaUuid)
|
||||
|
||||
if (!html) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await this.writeHtmlToClipboard({ html, text: html }, clipboardData)
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('Failed to copy dynamic field:', e)
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
async onContextMenuCopy () {
|
||||
await this.copyFieldAreaToClipboard(this.contextMenu.areaUuid)
|
||||
|
||||
this.closeContextMenu()
|
||||
},
|
||||
buildCopiedField (payload) {
|
||||
const field = JSON.parse(JSON.stringify(payload.field))
|
||||
const area = {
|
||||
...JSON.parse(JSON.stringify(payload.area)),
|
||||
uuid: v4(),
|
||||
attachment_uuid: this.attachmentUuid
|
||||
}
|
||||
|
||||
if (payload.templateId !== this.template.id) {
|
||||
delete field.conditions
|
||||
|
||||
if (field.preferences) {
|
||||
delete field.preferences.formula
|
||||
}
|
||||
}
|
||||
|
||||
const newField = {
|
||||
...field,
|
||||
uuid: v4(),
|
||||
submitter_uuid: this.selectedSubmitter.uuid,
|
||||
areas: [area]
|
||||
}
|
||||
|
||||
if (['radio', 'multiple'].includes(field.type) && field.options?.length) {
|
||||
const oldOptionUuid = area.option_uuid
|
||||
const optionsMap = {}
|
||||
|
||||
newField.options = field.options.map((opt) => {
|
||||
const newUuid = v4()
|
||||
optionsMap[opt.uuid] = newUuid
|
||||
|
||||
return { ...opt, uuid: newUuid }
|
||||
})
|
||||
|
||||
area.option_uuid = optionsMap[oldOptionUuid] || newField.options[0]?.uuid
|
||||
}
|
||||
|
||||
return { field: newField, area }
|
||||
},
|
||||
onEditorPaste (event) {
|
||||
const clipboardData = event.clipboardData
|
||||
|
||||
if (!clipboardData) {
|
||||
return
|
||||
}
|
||||
|
||||
const html = clipboardData.getData('text/html')
|
||||
const text = clipboardData.getData('text/plain')
|
||||
const clipboardHtml = html || (text.includes('<dynamic-field') ? text : '')
|
||||
|
||||
if (!clipboardHtml || !clipboardHtml.includes('data-field=')) {
|
||||
return
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
|
||||
container.innerHTML = clipboardHtml
|
||||
|
||||
const fieldNodes = [...container.querySelectorAll('dynamic-field[data-field][data-area]')]
|
||||
|
||||
if (!fieldNodes.length) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const { selection } = this.editor.state
|
||||
const { from, to } = selection
|
||||
let insertIndex = this.getFieldInsertIndex(selection.node?.type.name === 'fieldNode' ? to : from)
|
||||
|
||||
let lastArea = null
|
||||
|
||||
fieldNodes.forEach((fieldNode) => {
|
||||
const fieldValue = fieldNode.dataset.field
|
||||
const areaValue = fieldNode.dataset.area
|
||||
const templateId = fieldNode.dataset.templateId
|
||||
|
||||
if (!fieldValue || !areaValue) {
|
||||
return
|
||||
}
|
||||
|
||||
const { field, area } = this.buildCopiedField({
|
||||
field: JSON.parse(fieldValue),
|
||||
area: JSON.parse(areaValue),
|
||||
templateId: Number(templateId)
|
||||
})
|
||||
|
||||
this.insertFieldInTemplate(field, insertIndex)
|
||||
insertIndex += 1
|
||||
|
||||
fieldNode.setAttribute('uuid', field.uuid)
|
||||
fieldNode.setAttribute('area-uuid', area.uuid)
|
||||
fieldNode.removeAttribute('data-field')
|
||||
fieldNode.removeAttribute('data-area')
|
||||
fieldNode.removeAttribute('data-template-id')
|
||||
|
||||
lastArea = area
|
||||
})
|
||||
|
||||
this.editor.chain().focus().insertContentAt({ from, to }, container.innerHTML).run()
|
||||
|
||||
if (lastArea) {
|
||||
this.editor.commands.setNodeSelection(this.findAreaNodePos(lastArea.uuid))
|
||||
}
|
||||
|
||||
this.closeContextMenu()
|
||||
this.save()
|
||||
},
|
||||
onContextMenuDelete () {
|
||||
const menu = this.contextMenu
|
||||
const fieldArea = this.fieldAreaIndex[menu.areaUuid]
|
||||
@@ -398,89 +734,167 @@ export default {
|
||||
|
||||
if (!pos) return false
|
||||
|
||||
const fieldType = draggedField.type || 'text'
|
||||
const dims = this.defaultSizes[fieldType] || this.defaultSizes.text
|
||||
const areaUuid = v4()
|
||||
|
||||
const existingField = this.fieldsDragFieldRef?.value
|
||||
|
||||
if (existingField) {
|
||||
if (!this.template.fields.includes(existingField)) {
|
||||
this.template.fields.push(existingField)
|
||||
}
|
||||
|
||||
existingField.areas = existingField.areas || []
|
||||
existingField.areas.push({ uuid: areaUuid, attachment_uuid: this.attachmentUuid })
|
||||
|
||||
const nodeType = view.state.schema.nodes.fieldNode
|
||||
const fieldNode = nodeType.create({
|
||||
uuid: existingField.uuid,
|
||||
areaUuid,
|
||||
width: dims.width,
|
||||
height: dims.height
|
||||
})
|
||||
|
||||
const tr = view.state.tr.insert(pos.pos, fieldNode)
|
||||
|
||||
view.dispatch(tr)
|
||||
} else {
|
||||
const newField = {
|
||||
name: draggedField.name || '',
|
||||
uuid: v4(),
|
||||
required: fieldType !== 'checkbox',
|
||||
submitter_uuid: this.selectedSubmitter.uuid,
|
||||
type: fieldType,
|
||||
areas: [{ uuid: areaUuid, attachment_uuid: this.attachmentUuid }]
|
||||
}
|
||||
|
||||
if (['select', 'multiple', 'radio'].includes(fieldType)) {
|
||||
if (draggedField.options?.length) {
|
||||
newField.options = draggedField.options.map((opt) => ({
|
||||
value: typeof opt === 'string' ? opt : opt.value,
|
||||
uuid: v4()
|
||||
}))
|
||||
} else {
|
||||
newField.options = [{ value: '', uuid: v4() }, { value: '', uuid: v4() }]
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldType === 'datenow') {
|
||||
newField.type = 'date'
|
||||
newField.readonly = true
|
||||
newField.default_value = '{{date}}'
|
||||
}
|
||||
|
||||
if (['stamp', 'heading', 'strikethrough'].includes(fieldType)) {
|
||||
newField.readonly = true
|
||||
|
||||
if (fieldType === 'strikethrough') {
|
||||
newField.default_value = true
|
||||
}
|
||||
}
|
||||
|
||||
this.template.fields.push(newField)
|
||||
|
||||
const nodeType = view.state.schema.nodes.fieldNode
|
||||
const fieldNode = nodeType.create({
|
||||
uuid: newField.uuid,
|
||||
areaUuid,
|
||||
width: dims.width,
|
||||
height: dims.height
|
||||
})
|
||||
|
||||
const tr = view.state.tr.insert(pos.pos, fieldNode)
|
||||
|
||||
view.dispatch(tr)
|
||||
}
|
||||
this.insertFieldAtRange({
|
||||
sourceField: draggedField,
|
||||
existingField: this.fieldsDragFieldRef?.value,
|
||||
from: pos.pos
|
||||
})
|
||||
|
||||
this.fieldsDragFieldRef.value = null
|
||||
this.customDragFieldRef.value = null
|
||||
|
||||
this.editor.chain().focus().setNodeSelection(pos.pos).run()
|
||||
return true
|
||||
},
|
||||
onEditorPointerDown (event) {
|
||||
if (!this.isDrawMode || !this.editable || this.isDraggingField) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.button === 2) {
|
||||
return
|
||||
}
|
||||
|
||||
const view = this.editor?.view
|
||||
|
||||
if (!view) {
|
||||
return
|
||||
}
|
||||
|
||||
const selection = view.state.selection
|
||||
const isTextRangeSelection = !selection.empty && !selection.node
|
||||
|
||||
let from = selection.from
|
||||
let to = selection.to
|
||||
|
||||
if (!isTextRangeSelection) {
|
||||
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
|
||||
|
||||
if (!pos) {
|
||||
return
|
||||
}
|
||||
|
||||
from = pos.pos
|
||||
to = pos.pos
|
||||
}
|
||||
|
||||
const sourceField = this.drawField || this.drawCustomField || { type: this.drawFieldType }
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (this.drawOption && this.drawField) {
|
||||
const areaWithoutOption = this.drawField.areas?.find((a) => !a.option_uuid)
|
||||
|
||||
if (areaWithoutOption && !this.drawField.areas.find((a) => a.option_uuid === this.drawField.options[0].uuid)) {
|
||||
areaWithoutOption.option_uuid = this.drawField.options[0].uuid
|
||||
}
|
||||
}
|
||||
|
||||
const inserted = this.insertFieldAtRange({
|
||||
sourceField,
|
||||
existingField: this.drawField,
|
||||
optionUuid: this.drawOption?.uuid,
|
||||
from,
|
||||
to
|
||||
})
|
||||
|
||||
if (inserted) {
|
||||
this.$emit('draw', inserted)
|
||||
}
|
||||
},
|
||||
buildFieldArea ({ optionUuid = null } = {}) {
|
||||
const area = {
|
||||
uuid: v4(),
|
||||
attachment_uuid: this.attachmentUuid
|
||||
}
|
||||
|
||||
if (optionUuid) {
|
||||
area.option_uuid = optionUuid
|
||||
}
|
||||
|
||||
return area
|
||||
},
|
||||
buildNewField (sourceField, area) {
|
||||
const fieldType = sourceField?.type || 'text'
|
||||
const newField = {
|
||||
name: sourceField?.name || '',
|
||||
uuid: v4(),
|
||||
required: fieldType !== 'checkbox',
|
||||
submitter_uuid: this.selectedSubmitter.uuid,
|
||||
type: fieldType,
|
||||
areas: [area]
|
||||
}
|
||||
|
||||
if (['select', 'multiple', 'radio'].includes(fieldType)) {
|
||||
if (sourceField?.options?.length) {
|
||||
newField.options = sourceField.options.map((opt) => ({
|
||||
value: typeof opt === 'string' ? opt : opt.value,
|
||||
uuid: v4()
|
||||
}))
|
||||
} else {
|
||||
newField.options = [{ value: '', uuid: v4() }, { value: '', uuid: v4() }]
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldType === 'datenow') {
|
||||
newField.type = 'date'
|
||||
newField.readonly = true
|
||||
newField.default_value = '{{date}}'
|
||||
}
|
||||
|
||||
if (['stamp', 'heading', 'strikethrough'].includes(fieldType)) {
|
||||
newField.readonly = true
|
||||
|
||||
if (fieldType === 'strikethrough') {
|
||||
newField.default_value = true
|
||||
}
|
||||
}
|
||||
|
||||
return newField
|
||||
},
|
||||
insertFieldAtRange ({ sourceField, existingField = null, optionUuid = null, from, to = from }) {
|
||||
if (!sourceField) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fieldType = sourceField.type || existingField?.type || 'text'
|
||||
const dims = this.defaultSizes[fieldType]
|
||||
const area = this.buildFieldArea({ optionUuid })
|
||||
const insertIndex = this.getFieldInsertIndex(from)
|
||||
|
||||
let field = existingField
|
||||
const view = this.editor.view
|
||||
|
||||
if (field) {
|
||||
if (!this.template.fields.includes(field)) {
|
||||
this.insertFieldInTemplate(field, insertIndex)
|
||||
}
|
||||
|
||||
field.areas = field.areas || []
|
||||
field.areas.push(area)
|
||||
} else {
|
||||
field = this.buildNewField(sourceField, area)
|
||||
this.insertFieldInTemplate(field, insertIndex)
|
||||
}
|
||||
|
||||
const nodeType = view.state.schema.nodes.fieldNode
|
||||
const fieldNode = nodeType.create({
|
||||
uuid: field.uuid,
|
||||
areaUuid: area.uuid,
|
||||
width: dims.width,
|
||||
height: dims.height
|
||||
})
|
||||
|
||||
const tr = from !== to
|
||||
? view.state.tr.replaceWith(from, to, fieldNode)
|
||||
: view.state.tr.insert(from, fieldNode)
|
||||
|
||||
view.dispatch(tr)
|
||||
|
||||
this.editor.chain().focus().setNodeSelection(from).run()
|
||||
this.save()
|
||||
|
||||
return true
|
||||
return { area, field, pos: from }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
@change="[schema.format = $event.target.value, save()]"
|
||||
>
|
||||
<option
|
||||
v-for="format in dateFormats"
|
||||
v-for="format in availableDateFormats"
|
||||
:key="format"
|
||||
:value="format"
|
||||
>{{ formatDate(new Date(), format) }}</option>
|
||||
@@ -248,7 +248,7 @@ export default {
|
||||
FieldType,
|
||||
IconSettings
|
||||
},
|
||||
inject: ['t', 'save', 'backgroundColor'],
|
||||
inject: ['t', 'save', 'backgroundColor', 'dateFormats'],
|
||||
provide () {
|
||||
return {
|
||||
fieldTypes: ['text', 'number', 'date', 'checkbox', 'radio', 'select']
|
||||
@@ -318,20 +318,23 @@ export default {
|
||||
'space'
|
||||
]
|
||||
},
|
||||
dateFormats () {
|
||||
const formats = [
|
||||
'MM/DD/YYYY',
|
||||
'DD/MM/YYYY',
|
||||
'YYYY-MM-DD',
|
||||
'DD-MM-YYYY',
|
||||
'DD.MM.YYYY',
|
||||
'MMM D, YYYY',
|
||||
'MMMM D, YYYY',
|
||||
'D MMM YYYY',
|
||||
'D MMMM YYYY'
|
||||
]
|
||||
availableDateFormats () {
|
||||
const formats = this.dateFormats.length
|
||||
? [...this.dateFormats]
|
||||
: [
|
||||
'MM/DD/YYYY',
|
||||
'DD/MM/YYYY',
|
||||
'YYYY-MM-DD',
|
||||
'DD-MM-YYYY',
|
||||
'DD.MM.YYYY',
|
||||
'MMM D, YYYY',
|
||||
'MMMM D, YYYY',
|
||||
'MMMM YYYY',
|
||||
'D MMM YYYY',
|
||||
'D MMMM YYYY'
|
||||
]
|
||||
|
||||
if (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko')) {
|
||||
if (!this.dateFormats.length && (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko'))) {
|
||||
formats.push('YYYY년 MM월 DD일')
|
||||
}
|
||||
|
||||
@@ -401,18 +404,47 @@ export default {
|
||||
formatDate (date, format) {
|
||||
const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
|
||||
const dayFormats = { D: 'numeric', DD: '2-digit' }
|
||||
const yearFormats = { YYYY: 'numeric', YY: '2-digit' }
|
||||
const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' }
|
||||
const hourFormats = { H: 'numeric', HH: '2-digit', h: 'numeric', hh: '2-digit' }
|
||||
const minuteFormats = { m: 'numeric', mm: '2-digit' }
|
||||
const secondFormats = { s: 'numeric', ss: '2-digit' }
|
||||
|
||||
const parts = new Intl.DateTimeFormat([], {
|
||||
const opts = {
|
||||
day: dayFormats[format.match(/D+/)],
|
||||
month: monthFormats[format.match(/M+/)],
|
||||
year: yearFormats[format.match(/Y+/)]
|
||||
}).formatToParts(date)
|
||||
}
|
||||
|
||||
return format
|
||||
.replace(/D+/, parts.find((p) => p.type === 'day').value)
|
||||
.replace(/M+/, parts.find((p) => p.type === 'month').value)
|
||||
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
|
||||
if (format.match(/H+/)) { opts.hour = hourFormats[format.match(/H+/)[0]]; opts.hour12 = false }
|
||||
if (format.match(/h+/)) { opts.hour = hourFormats[format.match(/h+/)[0]]; opts.hour12 = true }
|
||||
if (/[Aa]/.test(format) && opts.hour12 === undefined) opts.hour12 = true
|
||||
if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]]
|
||||
if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]]
|
||||
if (/z/.test(format)) opts.timeZoneName = 'short'
|
||||
|
||||
const partTypes = {
|
||||
M: 'month',
|
||||
D: 'day',
|
||||
Y: 'year',
|
||||
H: 'hour',
|
||||
h: 'hour',
|
||||
m: 'minute',
|
||||
s: 'second',
|
||||
z: 'timeZoneName',
|
||||
A: 'dayPeriod',
|
||||
a: 'dayPeriod'
|
||||
}
|
||||
|
||||
const parts = new Intl.DateTimeFormat([], opts).formatToParts(date)
|
||||
|
||||
return format.replace(/MMMM|MMM|MM|M|DD|D|YYYY|YYY|YY|HH|hh|H|h|mm|m|ss|s|A|a|z/g, (token) => {
|
||||
const value = parts.find((p) => p.type === partTypes[token[0]])?.value
|
||||
|
||||
if (token === 'A') return (value || '').toUpperCase()
|
||||
if (token === 'a') return (value || '').toLowerCase()
|
||||
|
||||
return value
|
||||
})
|
||||
},
|
||||
closeDropdown () {
|
||||
this.$el.getRootNode().activeElement.blur()
|
||||
|
||||
@@ -22,28 +22,30 @@
|
||||
class="border-base-300"
|
||||
>
|
||||
<label class="peer flex items-center py-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden peer"
|
||||
checked
|
||||
>
|
||||
<IconChevronDown
|
||||
class="hidden peer-checked:block"
|
||||
:width="14"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
<IconChevronRight
|
||||
class="block peer-checked:hidden"
|
||||
:width="14"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
<span class="w-5 flex justify-center items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="hidden peer"
|
||||
checked
|
||||
>
|
||||
<IconChevronDown
|
||||
class="hidden peer-checked:block ml-0.5"
|
||||
:width="14"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
<IconChevronRight
|
||||
class="block peer-checked:hidden ml-0.5"
|
||||
:width="14"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</span>
|
||||
<span class="ml-1">{{ key }}</span>
|
||||
<span
|
||||
v-if="node.type === 'array'"
|
||||
class="text-xs bg-base-200 rounded px-1 ml-1"
|
||||
>{{ t('list') }}</span>
|
||||
</label>
|
||||
<div class="hidden peer-has-[:checked]:block pl-3.5">
|
||||
<div class="hidden peer-has-[:checked]:block pl-5">
|
||||
<template
|
||||
v-for="[varNode, varPath] in nestedVariables(node, key)"
|
||||
:key="varPath"
|
||||
@@ -101,7 +103,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
isGroup (node) {
|
||||
return (node.type === 'object' && node.properties) || (node.type === 'array' && node.items?.properties)
|
||||
return (node.type === 'object' && node.properties) || (node.type === 'array' && node.items?.type === 'object' && node.items?.properties)
|
||||
},
|
||||
nestedVariables (node, groupKey) {
|
||||
const properties = node.type === 'array' ? node.items?.properties : node.properties
|
||||
|
||||
@@ -353,7 +353,26 @@ function extractConditionVariables (node, acc = []) {
|
||||
return acc
|
||||
}
|
||||
|
||||
const IRREGULAR_PLURALS = {
|
||||
people: 'person',
|
||||
men: 'man',
|
||||
women: 'woman',
|
||||
children: 'child',
|
||||
teeth: 'tooth',
|
||||
feet: 'foot',
|
||||
mice: 'mouse',
|
||||
geese: 'goose',
|
||||
oxen: 'ox',
|
||||
criteria: 'criterion',
|
||||
phenomena: 'phenomenon',
|
||||
alumni: 'alumnus',
|
||||
data: 'datum',
|
||||
media: 'medium'
|
||||
}
|
||||
|
||||
function singularize (word) {
|
||||
if (IRREGULAR_PLURALS[word]) return IRREGULAR_PLURALS[word]
|
||||
|
||||
if (word.endsWith('ies')) return word.slice(0, -3) + 'y'
|
||||
if (word.endsWith('ches') || word.endsWith('shes')) return word.slice(0, -2)
|
||||
if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('zes')) return word.slice(0, -2)
|
||||
@@ -513,6 +532,12 @@ function processOperators (operators, propertiesHash = {}, parentProperties = {}
|
||||
|
||||
assignNestedSchema(propertiesHash, parentProperties, op.variableName, { type: 'array', items: itemProperties })
|
||||
processOperators(op.children, propertiesHash, { ...parentProperties, [singularKey]: itemProperties })
|
||||
|
||||
if (itemProperties.type === 'object' && itemProperties.properties && Object.keys(itemProperties.properties).length === 0) {
|
||||
delete itemProperties.properties
|
||||
itemProperties.type = 'string'
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,7 +345,7 @@ export default {
|
||||
IconMathFunction,
|
||||
FieldType
|
||||
},
|
||||
inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale', 'getFieldTypeIndex'],
|
||||
inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale', 'getFieldTypeIndex', 'dateFormats'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
@@ -428,7 +428,8 @@ export default {
|
||||
|
||||
if (this.field.type === 'date') {
|
||||
this.field.preferences.format ||=
|
||||
({ 'de-DE': 'DD.MM.YYYY' }[this.locale] || ((Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') || new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).format(new Date()).match(/\s(?:CST|CDT|PST|PDT|EST|EDT)$/)) ? 'MM/DD/YYYY' : 'DD/MM/YYYY'))
|
||||
this.dateFormats[0] ||
|
||||
({ 'de-DE': 'DD.MM.YYYY' }[this.locale] || ((Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') || new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).format(new Date()).match(/\s(?:CST|CDT|PST|PDT|EST|EDT)$/)) ? 'MM/DD/YYYY' : 'DD/MM/YYYY'))
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -514,7 +514,7 @@ export default {
|
||||
ContextSubmenu,
|
||||
ContextModal
|
||||
},
|
||||
inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies'],
|
||||
inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies', 'dateFormats'],
|
||||
props: {
|
||||
contextMenu: {
|
||||
type: Object,
|
||||
@@ -580,7 +580,7 @@ export default {
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldLabels: FieldType.computed.fieldLabels,
|
||||
validationOptions: FieldSettings.computed.validations,
|
||||
dateFormats: FieldSettings.computed.dateFormats,
|
||||
availableDateFormats: FieldSettings.computed.availableDateFormats,
|
||||
numberFormats: FieldSettings.computed.numberFormats,
|
||||
prefillableFieldTypes: FieldSettings.computed.prefillableFieldTypes,
|
||||
verificationMethods: FieldSettings.computed.verificationMethods,
|
||||
@@ -686,7 +686,7 @@ export default {
|
||||
},
|
||||
formatOptions () {
|
||||
switch (this.field.type) {
|
||||
case 'date': return this.dateFormats.map(f => ({ value: f, label: this.formatDate(new Date(), f) }))
|
||||
case 'date': return this.availableDateFormats.map(f => ({ value: f, label: this.formatDate(new Date(), f) }))
|
||||
case 'number': return this.numberFormats.map(f => ({ value: f, label: this.formatNumber(123456789.567, f) }))
|
||||
case 'signature': return this.signatureFormats.map(f => ({ value: f, label: this.t(f) }))
|
||||
default: return []
|
||||
|
||||
@@ -290,7 +290,7 @@
|
||||
@change="$emit('save')"
|
||||
>
|
||||
<option
|
||||
v-for="format in dateFormats"
|
||||
v-for="format in availableDateFormats"
|
||||
:key="format"
|
||||
:value="format"
|
||||
>
|
||||
@@ -610,7 +610,7 @@ export default {
|
||||
IconTypography,
|
||||
IconX
|
||||
},
|
||||
inject: ['template', 't'],
|
||||
inject: ['template', 't', 'dateFormats'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
@@ -701,20 +701,23 @@ export default {
|
||||
'space'
|
||||
]
|
||||
},
|
||||
dateFormats () {
|
||||
const formats = [
|
||||
'MM/DD/YYYY',
|
||||
'DD/MM/YYYY',
|
||||
'YYYY-MM-DD',
|
||||
'DD-MM-YYYY',
|
||||
'DD.MM.YYYY',
|
||||
'MMM D, YYYY',
|
||||
'MMMM D, YYYY',
|
||||
'D MMM YYYY',
|
||||
'D MMMM YYYY'
|
||||
]
|
||||
availableDateFormats () {
|
||||
const formats = this.dateFormats.length
|
||||
? [...this.dateFormats]
|
||||
: [
|
||||
'MM/DD/YYYY',
|
||||
'DD/MM/YYYY',
|
||||
'YYYY-MM-DD',
|
||||
'DD-MM-YYYY',
|
||||
'DD.MM.YYYY',
|
||||
'MMM D, YYYY',
|
||||
'MMMM D, YYYY',
|
||||
'MMMM YYYY',
|
||||
'D MMM YYYY',
|
||||
'D MMMM YYYY'
|
||||
]
|
||||
|
||||
if (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko')) {
|
||||
if (!this.dateFormats.length && (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko'))) {
|
||||
formats.push('YYYY년 MM월 DD일')
|
||||
}
|
||||
|
||||
@@ -819,33 +822,49 @@ export default {
|
||||
return pattern?.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups || null
|
||||
},
|
||||
formatDate (date, format) {
|
||||
const monthFormats = {
|
||||
M: 'numeric',
|
||||
MM: '2-digit',
|
||||
MMM: 'short',
|
||||
MMMM: 'long'
|
||||
}
|
||||
const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
|
||||
const dayFormats = { D: 'numeric', DD: '2-digit' }
|
||||
const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' }
|
||||
const hourFormats = { H: 'numeric', HH: '2-digit', h: 'numeric', hh: '2-digit' }
|
||||
const minuteFormats = { m: 'numeric', mm: '2-digit' }
|
||||
const secondFormats = { s: 'numeric', ss: '2-digit' }
|
||||
|
||||
const dayFormats = {
|
||||
D: 'numeric',
|
||||
DD: '2-digit'
|
||||
}
|
||||
|
||||
const yearFormats = {
|
||||
YYYY: 'numeric',
|
||||
YY: '2-digit'
|
||||
}
|
||||
|
||||
const parts = new Intl.DateTimeFormat([], {
|
||||
const opts = {
|
||||
day: dayFormats[format.match(/D+/)],
|
||||
month: monthFormats[format.match(/M+/)],
|
||||
year: yearFormats[format.match(/Y+/)]
|
||||
}).formatToParts(date)
|
||||
}
|
||||
|
||||
return format
|
||||
.replace(/D+/, parts.find((p) => p.type === 'day').value)
|
||||
.replace(/M+/, parts.find((p) => p.type === 'month').value)
|
||||
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
|
||||
if (format.match(/H+/)) { opts.hour = hourFormats[format.match(/H+/)[0]]; opts.hour12 = false }
|
||||
if (format.match(/h+/)) { opts.hour = hourFormats[format.match(/h+/)[0]]; opts.hour12 = true }
|
||||
if (/[Aa]/.test(format) && opts.hour12 === undefined) opts.hour12 = true
|
||||
if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]]
|
||||
if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]]
|
||||
if (/z/.test(format)) opts.timeZoneName = 'short'
|
||||
|
||||
const partTypes = {
|
||||
M: 'month',
|
||||
D: 'day',
|
||||
Y: 'year',
|
||||
H: 'hour',
|
||||
h: 'hour',
|
||||
m: 'minute',
|
||||
s: 'second',
|
||||
z: 'timeZoneName',
|
||||
A: 'dayPeriod',
|
||||
a: 'dayPeriod'
|
||||
}
|
||||
|
||||
const parts = new Intl.DateTimeFormat([], opts).formatToParts(date)
|
||||
|
||||
return format.replace(/MMMM|MMM|MM|M|DD|D|YYYY|YYY|YY|HH|hh|H|h|mm|m|ss|s|A|a|z/g, (token) => {
|
||||
const value = parts.find((p) => p.type === partTypes[token[0]])?.value
|
||||
|
||||
if (token === 'A') return (value || '').toUpperCase()
|
||||
if (token === 'a') return (value || '').toLowerCase()
|
||||
|
||||
return value
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,13 +65,33 @@
|
||||
@set-draw="$emit('set-draw', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!isShowVariables && submitterDefaultFields.length && editable">
|
||||
<hr class="mb-2">
|
||||
<div
|
||||
v-if="editable && withCustomFieldsTab"
|
||||
class="tabs w-full mb-1.5 custom-fields-tabs"
|
||||
>
|
||||
<a
|
||||
class="tab tab-bordered w-1/2 border-base-300 custom-fields-tab"
|
||||
: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 custom-fields-tab"
|
||||
:class="{ 'tab-active': showCustomTab }"
|
||||
:style="{ '--tab-border': showCustomTab ? '0.5px' : '0px' }"
|
||||
@click="setFieldsTab('custom')"
|
||||
>{{ t('custom') }}</a>
|
||||
</div>
|
||||
<div v-if="!isShowVariables && submitterDefaultFields.length && editable && (!withCustomFieldsTab || showCustomTab)">
|
||||
<hr
|
||||
v-if="!withCustomFieldsTab"
|
||||
class="mb-1.5"
|
||||
>
|
||||
<template v-if="isShowFieldSearch">
|
||||
<input
|
||||
v-model="defaultFieldsSearch"
|
||||
:placeholder="t('search_field')"
|
||||
class="input input-ghost input-xs px-0 text-base mb-2 !outline-0 !rounded bg-transparent w-full"
|
||||
class="input input-ghost input-xs px-0 text-base mb-1.5 !outline-0 !rounded bg-transparent w-full"
|
||||
>
|
||||
<hr class="mb-2">
|
||||
</template>
|
||||
@@ -218,7 +238,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isShowVariables && editable && !onlyDefinedFields && (!showCustomTab || (!customFields.length && !newCustomField))"
|
||||
v-if="!isShowVariables && editable && !onlyDefinedFields && (!showCustomTab || (!customFields.length && !newCustomField)) && (!withCustomFieldsTab || !showCustomTab)"
|
||||
id="field-types-grid"
|
||||
class="grid grid-cols-3 gap-1 pb-2 fields-grid"
|
||||
>
|
||||
@@ -418,6 +438,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withCustomFieldsTab: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withFieldsSearch: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -428,6 +453,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withDetectExistingFields: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withSignatureId: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -495,13 +525,18 @@ export default {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
detectCustomFieldsIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
showTourStartForm: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter', 'rebuild-variables-schema'],
|
||||
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter', 'rebuild-variables-schema', 'remove-field', 'remove-submitter'],
|
||||
data () {
|
||||
return {
|
||||
fieldPagesLoaded: null,
|
||||
@@ -557,7 +592,8 @@ export default {
|
||||
},
|
||||
submitterDefaultFields () {
|
||||
return this.defaultFields.filter((f) => {
|
||||
return !this.submitterFields.find((field) => field.name === f.name) && (!f.role || f.role === this.selectedSubmitter.name)
|
||||
return (this.withCustomFieldsTab ? true : !this.submitterFields.find((field) => field.name === f.name)) &&
|
||||
(!f.role || f.role === this.selectedSubmitter.name)
|
||||
})
|
||||
},
|
||||
filteredSubmitterDefaultFields () {
|
||||
@@ -656,6 +692,78 @@ export default {
|
||||
this.customFields.splice(0, this.customFields.length, ...fields)
|
||||
})
|
||||
},
|
||||
buildExistingFields () {
|
||||
const existing = []
|
||||
const seen = new Set()
|
||||
|
||||
const submittersByUuid = this.template.submitters.reduce((acc, s) => {
|
||||
acc[s.uuid] = s
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const add = (field, role) => {
|
||||
if (!field?.name) return
|
||||
|
||||
const key = field.name.toLowerCase() + ':' + (role || '').toLowerCase()
|
||||
|
||||
if (seen.has(key)) return
|
||||
|
||||
seen.add(key)
|
||||
|
||||
const item = { name: field.name, type: field.type || 'text' }
|
||||
|
||||
if (role) item.role = role
|
||||
|
||||
const optionValues = Array.isArray(field.options)
|
||||
? field.options.map((o) => (typeof o === 'string' ? o : o?.value)).filter(Boolean)
|
||||
: []
|
||||
|
||||
if (optionValues.length) item.options = optionValues
|
||||
|
||||
existing.push(item)
|
||||
}
|
||||
|
||||
this.template.fields.forEach((f) => add(f, submittersByUuid[f.submitter_uuid]?.name))
|
||||
this.defaultRequiredFields.forEach((f) => add(f, f.role))
|
||||
this.defaultFields.forEach((f) => add(f, f.role))
|
||||
this.customFields.forEach((f) => add(f, f.role))
|
||||
|
||||
return existing
|
||||
},
|
||||
enrichDetectedField (field) {
|
||||
if (!this.withDetectExistingFields || !field.name) return field
|
||||
|
||||
const role = this.template.submitters.find((s) => s.uuid === field.submitter_uuid)?.name
|
||||
const nameKey = field.name.toLowerCase()
|
||||
const indexKey = [field.name, role].filter(Boolean).join(':').toLowerCase()
|
||||
|
||||
const customField = this.detectCustomFieldsIndex[indexKey] || this.detectCustomFieldsIndex[nameKey]
|
||||
|
||||
if (customField) this.applyCustomFieldAttributes(field, customField)
|
||||
|
||||
return field
|
||||
},
|
||||
applyCustomFieldAttributes (field, customField) {
|
||||
const skipKeys = new Set(['uuid', 'areas', 'submitter_uuid', 'conditions', 'prefillable', 'role'])
|
||||
|
||||
Object.entries(customField).forEach(([key, value]) => {
|
||||
if (skipKeys.has(key)) return
|
||||
if (value === null || value === undefined) return
|
||||
|
||||
if (key === 'options') {
|
||||
if (Array.isArray(value) && !Array.isArray(field.options)) {
|
||||
field.options = value.map((o) => (
|
||||
typeof o === 'string' ? { value: o, uuid: v4() } : { ...o, uuid: v4() }
|
||||
))
|
||||
}
|
||||
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
field[key] = JSON.parse(JSON.stringify(value))
|
||||
} else {
|
||||
field[key] = value
|
||||
}
|
||||
})
|
||||
},
|
||||
detectFields () {
|
||||
const fields = []
|
||||
|
||||
@@ -665,7 +773,10 @@ export default {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
},
|
||||
...(this.withDetectExistingFields
|
||||
? { body: JSON.stringify({ fields: this.buildExistingFields() }) }
|
||||
: {})
|
||||
}).then(async (response) => {
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
@@ -687,7 +798,7 @@ export default {
|
||||
|
||||
if (data.error) {
|
||||
if ((data.fields || fields).length) {
|
||||
this.template.fields = data.fields || fields
|
||||
this.template.fields = (data.fields || fields).map((f) => this.enrichDetectedField(f))
|
||||
|
||||
this.save()
|
||||
} else {
|
||||
@@ -705,7 +816,7 @@ export default {
|
||||
this.$emit('select-submitter', this.template.submitters[0])
|
||||
}
|
||||
|
||||
this.template.fields = data.fields || fields
|
||||
this.template.fields = (data.fields || fields).map((f) => this.enrichDetectedField(f))
|
||||
|
||||
this.save()
|
||||
|
||||
@@ -805,6 +916,8 @@ export default {
|
||||
this.$emit('change-submitter', this.submitters[0])
|
||||
}
|
||||
|
||||
this.$emit('remove-submitter', submitter)
|
||||
|
||||
this.save()
|
||||
},
|
||||
removeField (field, save = true) {
|
||||
@@ -830,6 +943,8 @@ export default {
|
||||
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(area), 1)
|
||||
})
|
||||
|
||||
this.$emit('remove-field', field)
|
||||
|
||||
if (save) {
|
||||
this.save()
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ const en = {
|
||||
sign_yourself: 'Sign Yourself',
|
||||
set_signing_date: 'Set signing date',
|
||||
signing_date: 'Signing Date',
|
||||
signing_date_and_time: 'Signing Date and Time',
|
||||
send: 'Send',
|
||||
remove: 'Remove',
|
||||
edit: 'Edit',
|
||||
@@ -271,6 +272,7 @@ const es = {
|
||||
with_logo: 'Con logotipo',
|
||||
description: 'Descripción',
|
||||
signing_date: 'Fecha de Firma',
|
||||
signing_date_and_time: 'Fecha y Hora de Firma',
|
||||
display_title: 'Título de visualización',
|
||||
unchecked: 'No marcado',
|
||||
price: 'Precio',
|
||||
@@ -508,6 +510,7 @@ const it = {
|
||||
sign_yourself: 'Firma te stesso',
|
||||
set_signing_date: 'Imposta data di firma',
|
||||
signing_date: 'Data di firma',
|
||||
signing_date_and_time: 'Data e ora di firma',
|
||||
send: 'Invia',
|
||||
remove: 'Rimuovi',
|
||||
edit: 'Modifica',
|
||||
@@ -710,6 +713,7 @@ const pt = {
|
||||
description: 'Descrição',
|
||||
display_title: 'Título de exibição',
|
||||
signing_date: 'Data da Assinatura',
|
||||
signing_date_and_time: 'Data e Hora da Assinatura',
|
||||
unchecked: 'Não marcado',
|
||||
price: 'Preço',
|
||||
equal: 'Igual',
|
||||
@@ -946,6 +950,7 @@ const fr = {
|
||||
sign_yourself: 'Signer vous-même',
|
||||
set_signing_date: 'Définir la date de signature',
|
||||
signing_date: 'Date de signature',
|
||||
signing_date_and_time: 'Date et heure de signature',
|
||||
send: 'Envoyer',
|
||||
remove: 'Supprimer',
|
||||
edit: 'Modifier',
|
||||
@@ -1165,6 +1170,7 @@ const de = {
|
||||
sign_yourself: 'Selbst unterschreiben',
|
||||
set_signing_date: 'Unterzeichnungsdatum festlegen',
|
||||
signing_date: 'Unterzeichnungsdatum',
|
||||
signing_date_and_time: 'Unterzeichnungsdatum und -uhrzeit',
|
||||
send: 'Senden',
|
||||
remove: 'Entfernen',
|
||||
edit: 'Bearbeiten',
|
||||
@@ -1384,6 +1390,7 @@ const nl = {
|
||||
sign_yourself: 'Zelf ondertekenen',
|
||||
set_signing_date: 'Ondertekeningsdatum instellen',
|
||||
signing_date: 'Ondertekeningsdatum',
|
||||
signing_date_and_time: 'Ondertekeningsdatum en -tijd',
|
||||
send: 'Verzenden',
|
||||
remove: 'Verwijderen',
|
||||
edit: 'Bewerken',
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
:ref="setAreaRefs"
|
||||
:area="item.area"
|
||||
:input-mode="inputMode"
|
||||
:conditional-field-index="conditionalFieldIndex"
|
||||
:formula-values-index="formulaValuesIndex"
|
||||
:page-width="width"
|
||||
:page-height="height"
|
||||
:field="item.field"
|
||||
@@ -180,6 +182,16 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
conditionalFieldIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
formulaValuesIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
defaultFields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
|
||||
@@ -274,13 +274,16 @@ export default {
|
||||
makeDynamic () {
|
||||
this.isMakeDynamicLoading = true
|
||||
|
||||
this.baseFetch(`/templates/${this.template.id}/dynamic_documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ uuid: this.document.uuid }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).then(async (resp) => {
|
||||
Promise.all([
|
||||
this.baseFetch(`/templates/${this.template.id}/dynamic_documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ uuid: this.document.uuid }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}),
|
||||
import(/* webpackChunkName: "dynamic-editor" */ './dynamic_document')
|
||||
]).then(async ([resp, _]) => {
|
||||
const dynamicDocument = await resp.json()
|
||||
|
||||
this.template.schema.find((item) => item.attachment_uuid === dynamicDocument.uuid).dynamic = true
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SendTemplateArchivedWebhookRequestJob
|
||||
include Sidekiq::Job
|
||||
|
||||
sidekiq_options queue: :webhooks
|
||||
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
def perform(params = {})
|
||||
template = Template.find_by(id: params['template_id'])
|
||||
|
||||
return unless template
|
||||
|
||||
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
|
||||
|
||||
return unless webhook_url
|
||||
|
||||
attempt = params['attempt'].to_i
|
||||
|
||||
return if webhook_url.url.blank? || webhook_url.events.exclude?('template.archived')
|
||||
|
||||
resp = SendWebhookRequest.call(webhook_url, event_type: 'template.archived',
|
||||
event_uuid: params['event_uuid'],
|
||||
record: template,
|
||||
attempt:,
|
||||
data: template.as_json(only: %i[id archived_at]))
|
||||
|
||||
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
|
||||
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
|
||||
SendTemplateArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
|
||||
**params,
|
||||
'attempt' => attempt + 1,
|
||||
'last_status' => resp&.status.to_i
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,6 +5,8 @@ class ApplicationMailer < ActionMailer::Base
|
||||
layout 'mailer'
|
||||
|
||||
register_interceptor ActionMailerConfigsInterceptor
|
||||
register_interceptor HtmlToPlainTextInterceptor
|
||||
register_preview_interceptor HtmlToPlainTextInterceptor
|
||||
|
||||
register_observer ActionMailerEventsObserver
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ class Account < ApplicationRecord
|
||||
has_many :submitters, dependent: :destroy
|
||||
has_many :account_linked_accounts, dependent: :destroy
|
||||
has_many :email_events, dependent: :destroy
|
||||
has_many :document_metadata, class_name: 'DocumentMetadata', dependent: :destroy
|
||||
has_many :webhook_urls, dependent: :destroy
|
||||
has_many :webhook_events, dependent: nil
|
||||
has_many :account_accesses, dependent: :destroy
|
||||
|
||||
@@ -30,6 +30,7 @@ class AccountConfig < ApplicationRecord
|
||||
ALLOW_TYPED_SIGNATURE = 'allow_typed_signature'
|
||||
ALLOW_TO_RESUBMIT = 'allow_to_resubmit'
|
||||
ALLOW_TO_DECLINE_KEY = 'allow_to_decline'
|
||||
ALLOW_TO_DELEGATE_KEY = 'allow_to_delegate'
|
||||
ALLOW_TO_PARTIAL_DOWNLOAD_KEY = 'allow_to_partial_download'
|
||||
SUBMITTER_REMINDERS = 'submitter_reminders'
|
||||
ENFORCE_SIGNING_ORDER_KEY = 'enforce_signing_order'
|
||||
@@ -42,6 +43,7 @@ class AccountConfig < ApplicationRecord
|
||||
DOWNLOAD_LINKS_EXPIRE_KEY = 'download_links_expire'
|
||||
FORCE_SSO_AUTH_KEY = 'force_sso_auth'
|
||||
FLATTEN_RESULT_PDF_KEY = 'flatten_result_pdf'
|
||||
ROTATE_INCREMENTAL_PDF_KEY = 'rotate_incremental_pdf'
|
||||
WITH_SIGNATURE_ID = 'with_signature_id'
|
||||
WITH_FILE_LINKS_KEY = 'with_file_links'
|
||||
WITH_SIGNATURE_ID_REASON_KEY = 'with_signature_id_reason'
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: document_metadata
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# blob_checksum :string not null
|
||||
# text_runs :text not null
|
||||
# created_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_document_metadata_on_account_id_and_blob_checksum (account_id,blob_checksum) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
#
|
||||
class DocumentMetadata < ApplicationRecord
|
||||
belongs_to :account
|
||||
|
||||
attribute :text_runs, :string, default: -> { {} }
|
||||
|
||||
serialize :text_runs, coder: JSON
|
||||
end
|
||||
@@ -7,8 +7,8 @@
|
||||
# id :bigint not null, primary key
|
||||
# body :text not null
|
||||
# head :text
|
||||
# sha1 :text not null
|
||||
# uuid :uuid not null
|
||||
# sha1 :string not null
|
||||
# uuid :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# template_id :bigint not null
|
||||
|
||||
@@ -125,7 +125,7 @@ class Submission < ApplicationRecord
|
||||
|
||||
dynamic_count = template_schema&.count { |e| e['dynamic'] }.to_i
|
||||
|
||||
if template.variables_schema.blank?
|
||||
if variables_schema.blank?
|
||||
if dynamic_count > 0
|
||||
if dynamic_count == template_schema.size
|
||||
template_schema_dynamic_document_attachments
|
||||
|
||||
@@ -64,6 +64,7 @@ class SubmissionEvent < ApplicationRecord
|
||||
invite_party: 'invite_party',
|
||||
complete_form: 'complete_form',
|
||||
decline_form: 'decline_form',
|
||||
delegate_form: 'delegate_form',
|
||||
api_complete_form: 'api_complete_form'
|
||||
}, scope: false
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ class Submitter < ApplicationRecord
|
||||
belongs_to :account
|
||||
has_one :template, through: :submission
|
||||
has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy if SearchEntry.table_exists?
|
||||
has_many :submitter_versions, dependent: :destroy
|
||||
|
||||
attribute :values, :string, default: -> { {} }
|
||||
attribute :preferences, :string, default: -> { {} }
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: submitter_versions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# email :string
|
||||
# name :string
|
||||
# phone :string
|
||||
# slug :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# submitter_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_submitter_versions_on_slug (slug)
|
||||
# index_submitter_versions_on_submitter_id (submitter_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (submitter_id => submitters.id)
|
||||
#
|
||||
class SubmitterVersion < ApplicationRecord
|
||||
belongs_to :submitter
|
||||
end
|
||||
@@ -34,6 +34,7 @@ class WebhookUrl < ApplicationRecord
|
||||
submission.archived
|
||||
template.created
|
||||
template.updated
|
||||
template.archived
|
||||
].freeze
|
||||
|
||||
belongs_to :account
|
||||
|
||||
@@ -142,9 +142,38 @@
|
||||
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
</div>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= f.check_box :value, class: 'toggle', checked: account_config.value != false %>
|
||||
</submit-form>
|
||||
<% if !Docuseal.multitenant? || can?(:manage, :disable_decline) %>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= f.check_box :value, class: 'toggle', checked: account_config.value != false %>
|
||||
</submit-form>
|
||||
<% else %>
|
||||
<a href="<%= console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") %>" data-turbo="false" data-tip="<%= I18n.t('unlock_with_docuseal_pro') %>" data-on="change" class="flex tooltip">
|
||||
<%= f.check_box :value, class: 'toggle pointer-events-none', checked: account_config.value != false, disabled: true %>
|
||||
</a>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ALLOW_TO_DELEGATE_KEY) %>
|
||||
<% if can?(:manage, account_config) %>
|
||||
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<div class="flex items-center justify-between gap-4 py-2.5">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-left"><%= t('allow_to_delegate_documents') %></span>
|
||||
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('allow_recipients_to_delegate_documents') %>">
|
||||
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
</div>
|
||||
<% if !Docuseal.multitenant? || can?(:manage, :delegate_form) %>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= f.check_box :value, class: 'toggle', checked: account_config.value == true %>
|
||||
</submit-form>
|
||||
<% else %>
|
||||
<a href="<%= console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") %>" data-turbo="false" data-tip="<%= I18n.t('unlock_with_docuseal_pro') %>" class="flex tooltip">
|
||||
<%= f.check_box :value, class: 'toggle pointer-events-none', checked: account_config.value == true, disabled: true %>
|
||||
</a>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"></path>
|
||||
<path d="M9 12l2 2l4 -4"></path>
|
||||
|
||||
|
Before Width: | Height: | Size: 389 B After Width: | Height: | Size: 408 B |
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2" />
|
||||
<path d="M7 11l5 5l5 -5" />
|
||||
|
||||
|
Before Width: | Height: | Size: 397 B After Width: | Height: | Size: 416 B |
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"></path>
|
||||
<path d="M12 9h.01"></path>
|
||||
|
||||
|
Before Width: | Height: | Size: 412 B After Width: | Height: | Size: 431 B |
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M12 3a9 9 0 1 0 9 9" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 318 B After Width: | Height: | Size: 337 B |
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M12 18h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v7.5" />
|
||||
<path d="M3 6l9 6l9 -6" />
|
||||
|
||||
|
Before Width: | Height: | Size: 448 B After Width: | Height: | Size: 467 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="<%= local_assigns[:class] %>">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M17 17h2a2 2 0 0 0 2 -2v-4a2 2 0 0 0 -2 -2h-14a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h2" />
|
||||
<path d="M17 9v-4a2 2 0 0 0 -2 -2h-6a2 2 0 0 0 -2 2v4" />
|
||||
<path d="M7 15a2 2 0 0 1 2 -2h6a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-6a2 2 0 0 1 -2 -2l0 -4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 529 B |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="<%= local_assigns[:class] %>">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M4 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" />
|
||||
<path d="M7 17l0 .01" />
|
||||
<path d="M14 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" />
|
||||
<path d="M7 7l0 .01" />
|
||||
<path d="M4 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" />
|
||||
<path d="M17 7l0 .01" />
|
||||
<path d="M14 14l3 0" />
|
||||
<path d="M20 14l0 .01" />
|
||||
<path d="M14 14l0 3" />
|
||||
<path d="M14 20l3 0" />
|
||||
<path d="M17 17l3 0" />
|
||||
<path d="M20 17l0 3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 797 B |
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="2" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="2" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M19.933 13.041a8 8 0 1 1 -9.925 -8.788c3.899 -1 7.935 1.007 9.425 4.747"></path>
|
||||
<path d="M20 4v5h-5"></path>
|
||||
|
||||
|
Before Width: | Height: | Size: 409 B After Width: | Height: | Size: 428 B |