Compare commits

..
59 Commits
Author SHA1 Message Date
Alex Turchyn aa38773be3 Merge from docusealco/wip 2026-01-27 18:39:04 +02:00
Pete Matsyburka 96530bd809 fix race condition 2026-01-27 17:06:30 +02:00
Pete Matsyburka 127f95a9d0 fix typo 2026-01-27 12:31:06 +02:00
Pete Matsyburka cf31e12348 adjust smtp configs 2026-01-27 10:57:04 +02:00
Pete Matsyburka 4adaa531b7 fix pagination 2026-01-27 10:34:18 +02:00
Pete Matsyburka c7659563b8 fix puma warnings 2026-01-26 20:23:11 +02:00
Alex Turchyn 7b5494468f update rails to 8.1.2 2026-01-26 18:22:47 +02:00
Pete Matsyburka d12c67fd54 with force open 2026-01-26 18:00:46 +02:00
Pete Matsyburka e20eacb5ee center on click 2026-01-26 12:35:53 +02:00
Pete Matsyburka 98055f0762 add can parse polyfill 2026-01-26 12:17:57 +02:00
Pete Matsyburka a46c42c3a0 fix resize warning 2026-01-26 12:06:41 +02:00
Pete Matsyburka c3d1503361 cleanup 2026-01-26 11:44:12 +02:00
Pete Matsyburka 65b47d27cd adjust form 2026-01-26 11:38:33 +02:00
Pete Matsyburka 968c07f8be include idcode 2026-01-26 11:13:01 +02:00
Pete Matsyburka 91814ca105 custom fields 2026-01-26 11:12:55 +02:00
Pete Matsyburka 9128f24270 fix synology acl 2026-01-24 18:01:49 +02:00
Pete Matsyburka 264897bbd5 audit with sender 2026-01-23 12:12:37 +02:00
Pete Matsyburka 67d59ff1ba fix invite by order 2026-01-22 21:20:34 +02:00
Pete Matsyburka 81eee56e1e i18n 2026-01-21 20:14:53 +02:00
Pete Matsyburka 92ea8af786 fix options drag 2026-01-21 16:02:20 +02:00
Pete Matsyburka df0616f77e paste options 2026-01-21 15:42:38 +02:00
Pete Matsyburka a337dff85c change model 2026-01-21 10:23:26 +02:00
Pete Matsyburka 48e349c1d2 fix signature pad when resize 2026-01-20 17:24:05 +02:00
Pete Matsyburka df9fa0fec7 fix scroll into field 2026-01-20 15:27:35 +02:00
Alex Turchyn b3e72f0726 Merge from docusealco/wip 2026-01-19 15:40:56 +02:00
Pete Matsyburka 700ebc4727 render reason 2026-01-19 13:56:17 +02:00
Pete Matsyburka 745a18efca fix select mode draw 2026-01-19 11:57:09 +02:00
Pete Matsyburka b4a8147ff0 fix radio group paste 2026-01-19 11:51:00 +02:00
Pete Matsyburka 0543c25054 detect fields in context menu 2026-01-19 09:23:04 +02:00
Pete Matsyburka b1a0afea66 fix menu 2026-01-18 10:29:15 +02:00
Pete Matsyburka 43c41e2557 add select fields mode 2026-01-18 09:52:43 +02:00
Pete Matsyburka bc8eb33f05 Revert "hide area name menu on desktop"
This reverts commit 5212773d0a.
2026-01-17 19:42:27 +02:00
Pete Matsyburka d1cebc92f7 fix eslint 2026-01-16 12:38:29 +02:00
Pete Matsyburka bf02cf4058 fix paste 2026-01-16 12:25:50 +02:00
Pete Matsyburka 5212773d0a hide area name menu on desktop 2026-01-16 12:18:17 +02:00
Alex Turchyn bf53204a41 add context menu to template builder 2026-01-16 12:07:59 +02:00
Pete Matsyburka 092a19966c adjust condition 2026-01-15 17:34:05 +02:00
Pete Matsyburka 6060a2b798 fix icon 2026-01-14 21:58:30 +02:00
Pete Matsyburka ce12af68b2 fix required 2026-01-14 14:32:00 +02:00
Pete Matsyburka c05f8d47aa require 2fa 2026-01-14 12:44:27 +02:00
Pete Matsyburka f8be19c20e pg 18 2026-01-13 20:44:52 +02:00
Pete Matsyburka 2fe32a2145 show version 2026-01-13 12:25:08 +02:00
Pete Matsyburka 794ea4d0e8 remove expired from pending filter 2026-01-13 12:07:39 +02:00
Pete Matsyburka 307b0f5afd fix build 2026-01-13 09:44:58 +02:00
Alex Turchyn 9011e3f4b4 Merge from docusealco/wip 2026-01-12 13:25:54 +02:00
Pete Matsyburka 0b45ce5c0c update model 2026-01-12 12:05:54 +02:00
Pete Matsyburka a8ab93a4cc adjust sort 2026-01-12 11:27:19 +02:00
Pete Matsyburka d1768be636 secure env 2026-01-12 11:27:17 +02:00
Pete Matsyburka b2cd7c82a7 insert new field at page position 2026-01-11 14:45:42 +02:00
Pete Matsyburka b88d99afe0 merge preview docs 2026-01-11 11:09:00 +02:00
Pete Matsyburka 4523d38551 direct links option 2026-01-09 22:05:37 +02:00
Pete Matsyburka c4ba892559 refactor clone 2026-01-08 09:34:45 +02:00
Pete Matsyburka 40f8093e00 adjust onnx 2026-01-07 14:50:27 +02:00
Pete Matsyburka 4375459856 add nmm 2026-01-07 12:19:05 +02:00
Pete Matsyburka b3eb8a70bf fix submitter uuid 2026-01-07 00:01:47 +02:00
Pete Matsyburka 6245a487b6 add kba countdown 2026-01-06 11:24:30 +02:00
Pete Matsyburka a89f4c1b10 adjust trim 2026-01-06 10:22:34 +02:00
Pete Matsyburka 7df729b8b5 containment threshold 2026-01-06 09:04:25 +02:00
Pete Matsyburka f1db7c7e82 adjust trim 2026-01-06 08:54:29 +02:00
89 changed files with 5462 additions and 1510 deletions

No files matched your search

+6 -6
View File
@@ -12,7 +12,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 3.4.2 ruby-version: 4.0.1
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -37,7 +37,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 3.4.2 ruby-version: 4.0.1
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -62,7 +62,7 @@ jobs:
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 20.9.0 node-version: 20.19.0
- name: Cache directory path - name: Cache directory path
id: yarn-cache-dir-path id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)" run: echo "::set-output name=dir::$(yarn cache dir)"
@@ -89,7 +89,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 3.4.2 ruby-version: 4.0.1
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -132,11 +132,11 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 3.4.2 ruby-version: 4.0.1
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 20.9.0 node-version: 20.19.0
- name: Install Chrome - name: Install Chrome
uses: browser-actions/setup-chrome@latest uses: browser-actions/setup-chrome@latest
with: with:
+11 -2
View File
@@ -1,4 +1,4 @@
require: plugins:
- rubocop-performance - rubocop-performance
- rubocop-rails - rubocop-rails
- rubocop-rspec - rubocop-rspec
@@ -10,7 +10,7 @@ AllCops:
- node_modules/**/* - node_modules/**/*
- bin/* - bin/*
- vendor/**/* - vendor/**/*
TargetRubyVersion: '3.3' TargetRubyVersion: '4.0'
SuggestExtensions: false SuggestExtensions: false
Metrics/BlockLength: Metrics/BlockLength:
@@ -48,6 +48,9 @@ Style/MultipleComparison:
Style/NumericPredicate: Style/NumericPredicate:
Enabled: false Enabled: false
Style/MinMaxComparison:
Enabled: false
Naming/PredicateMethod: Naming/PredicateMethod:
Enabled: false Enabled: false
@@ -97,3 +100,9 @@ Rails/ApplicationController:
Rails/Output: Rails/Output:
Enabled: false Enabled: false
Rails/StrongParametersExpect:
Enabled: false
Rails/RedirectBackOrTo:
Enabled: false
+26 -24
View File
@@ -1,4 +1,4 @@
FROM ruby:3.4.2-alpine AS download FROM ruby:4.0.1-alpine AS download
WORKDIR /fonts WORKDIR /fonts
@@ -16,7 +16,7 @@ RUN apk --no-cache add fontforge wget && \
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")' RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
FROM ruby:3.4.2-alpine AS webpack FROM ruby:4.0.1-alpine AS webpack
ENV RAILS_ENV=production ENV RAILS_ENV=production
ENV NODE_ENV=production ENV NODE_ENV=production
@@ -42,16 +42,18 @@ COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:3.4.2-alpine AS app FROM ruby:4.0.1-alpine AS app
ENV RAILS_ENV=production ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test" ENV BUNDLE_WITHOUT="development:test"
ENV LD_PRELOAD=/lib/libgcompat.so.0 ENV LD_PRELOAD=/lib/libgcompat.so.0
ENV OPENSSL_CONF=/app/openssl_legacy.cnf ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
WORKDIR /app WORKDIR /app
RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont onnxruntime && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
RUN echo $'.include = /etc/ssl/openssl.cnf\n\ RUN echo $'.include = /etc/ssl/openssl.cnf\n\
\n\ \n\
@@ -63,36 +65,36 @@ legacy = legacy_sect\n\
activate = 1\n\ activate = 1\n\
\n\ \n\
[legacy_sect]\n\ [legacy_sect]\n\
activate = 1' >> /app/openssl_legacy.cnf activate = 1' >> /etc/openssl_legacy.cnf
COPY ./Gemfile ./Gemfile.lock ./ COPY --chown=docuseal:docuseal ./Gemfile ./Gemfile.lock ./
RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first") RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
RUN echo 'https://dl-cdn.alpinelinux.org/alpine/edge/main' >> /etc/apk/repositories && echo 'https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache onnxruntime COPY --chown=docuseal:docuseal ./bin ./bin
COPY --chown=docuseal:docuseal ./app ./app
COPY --chown=docuseal:docuseal ./config ./config
COPY --chown=docuseal:docuseal ./db/migrate ./db/migrate
COPY --chown=docuseal:docuseal ./log ./log
COPY --chown=docuseal:docuseal ./lib ./lib
COPY --chown=docuseal:docuseal ./public ./public
COPY --chown=docuseal:docuseal ./tmp ./tmp
COPY --chown=docuseal:docuseal LICENSE README.md Rakefile config.ru .version ./
COPY --chown=docuseal:docuseal .version ./public/version
COPY ./bin ./bin COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY ./app ./app
COPY ./config ./config
COPY ./db/migrate ./db/migrate
COPY ./log ./log
COPY ./lib ./lib
COPY ./public ./public
COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru .version ./
COPY .version ./public/version
COPY --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 --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont
COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so 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 --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt
COPY --from=download /model.onnx /app/tmp/model.onnx COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx
COPY --from=webpack /app/public/packs ./public/packs COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts RUN ln -s /fonts /app/public/fonts && \
RUN bundle exec bootsnap precompile -j 1 --gemfile app/ lib/ bundle exec bootsnap precompile -j 1 --gemfile app/ lib/ && \
chown -R docuseal:docuseal /app/tmp/cache
WORKDIR /data/docuseal WORKDIR /data/docuseal
ENV HOME=/home/docuseal
ENV WORKDIR=/data/docuseal ENV WORKDIR=/data/docuseal
EXPOSE 3000 EXPOSE 3000
+10 -10
View File
@@ -2,16 +2,16 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '3.4.2' ruby '4.0.1'
gem 'arabic-letter-connector', require: 'arabic-letter-connector/logic' gem 'arabic-letter-connector', require: false
gem 'aws-sdk-s3', require: false gem 'aws-sdk-s3', require: false
gem 'aws-sdk-secretsmanager', require: false gem 'aws-sdk-secretsmanager', require: false
gem 'azure-storage-blob', require: false gem 'azure-blob', require: false
gem 'bootsnap', require: false gem 'bootsnap', require: false
gem 'cancancan' gem 'cancancan'
gem 'csv' gem 'csv', require: false
gem 'csv-safe' gem 'csv-safe', require: false
gem 'devise' gem 'devise'
gem 'devise-two-factor' gem 'devise-two-factor'
gem 'dotenv', require: false gem 'dotenv', require: false
@@ -21,12 +21,12 @@ gem 'faraday-follow_redirects'
gem 'google-cloud-storage', require: false gem 'google-cloud-storage', require: false
gem 'hexapdf' gem 'hexapdf'
gem 'image_processing' gem 'image_processing'
gem 'jwt' gem 'jwt', require: false
gem 'lograge' gem 'lograge'
gem 'mysql2', require: false gem 'mysql2', require: false
gem 'numo-narray' gem 'numo-narray-alt', require: false
gem 'oj' gem 'oj'
gem 'onnxruntime' gem 'onnxruntime', require: false
gem 'pagy' gem 'pagy'
gem 'pg', require: false gem 'pg', require: false
gem 'premailer-rails' gem 'premailer-rails'
@@ -38,9 +38,9 @@ gem 'rails_autolink'
gem 'rails-i18n' gem 'rails-i18n'
gem 'rotp' gem 'rotp'
gem 'rouge', require: false gem 'rouge', require: false
gem 'rqrcode' gem 'rqrcode', require: false
gem 'ruby-vips' gem 'ruby-vips'
gem 'rubyXL' gem 'rubyXL', require: false
gem 'shakapacker' gem 'shakapacker'
gem 'sidekiq' gem 'sidekiq'
gem 'sqlite3', require: false gem 'sqlite3', require: false
+279 -284
View File
@@ -1,29 +1,31 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (8.0.2.1) action_text-trix (2.1.16)
actionpack (= 8.0.2.1) railties
activesupport (= 8.0.2.1) actioncable (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (8.0.2.1) actionmailbox (8.1.2)
actionpack (= 8.0.2.1) actionpack (= 8.1.2)
activejob (= 8.0.2.1) activejob (= 8.1.2)
activerecord (= 8.0.2.1) activerecord (= 8.1.2)
activestorage (= 8.0.2.1) activestorage (= 8.1.2)
activesupport (= 8.0.2.1) activesupport (= 8.1.2)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (8.0.2.1) actionmailer (8.1.2)
actionpack (= 8.0.2.1) actionpack (= 8.1.2)
actionview (= 8.0.2.1) actionview (= 8.1.2)
activejob (= 8.0.2.1) activejob (= 8.1.2)
activesupport (= 8.0.2.1) activesupport (= 8.1.2)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (8.0.2.1) actionpack (8.1.2)
actionview (= 8.0.2.1) actionview (= 8.1.2)
activesupport (= 8.0.2.1) activesupport (= 8.1.2)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
rack (>= 2.2.4) rack (>= 2.2.4)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
@@ -31,55 +33,58 @@ GEM
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (8.0.2.1) actiontext (8.1.2)
actionpack (= 8.0.2.1) action_text-trix (~> 2.1.15)
activerecord (= 8.0.2.1) actionpack (= 8.1.2)
activestorage (= 8.0.2.1) activerecord (= 8.1.2)
activesupport (= 8.0.2.1) activestorage (= 8.1.2)
activesupport (= 8.1.2)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (8.0.2.1) actionview (8.1.2)
activesupport (= 8.0.2.1) activesupport (= 8.1.2)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activejob (8.0.2.1) activejob (8.1.2)
activesupport (= 8.0.2.1) activesupport (= 8.1.2)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (8.0.2.1) activemodel (8.1.2)
activesupport (= 8.0.2.1) activesupport (= 8.1.2)
activerecord (8.0.2.1) activerecord (8.1.2)
activemodel (= 8.0.2.1) activemodel (= 8.1.2)
activesupport (= 8.0.2.1) activesupport (= 8.1.2)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (8.0.2.1) activestorage (8.1.2)
actionpack (= 8.0.2.1) actionpack (= 8.1.2)
activejob (= 8.0.2.1) activejob (= 8.1.2)
activerecord (= 8.0.2.1) activerecord (= 8.1.2)
activesupport (= 8.0.2.1) activesupport (= 8.1.2)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (8.0.2.1) activesupport (8.1.2)
base64 base64
benchmark (>= 0.3)
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1) concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5) connection_pool (>= 2.2.5)
drb drb
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
json
logger (>= 1.4.2) logger (>= 1.4.2)
minitest (>= 5.1) minitest (>= 5.1)
securerandom (>= 0.3) securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5) tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1) uri (>= 0.13.1)
addressable (2.8.7) addressable (2.8.8)
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 8.0)
annotaterb (4.14.0) annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
arabic-letter-connector (0.1.1) arabic-letter-connector (0.1.1)
ast (2.4.3) ast (2.4.3)
aws-eventstream (1.4.0) aws-eventstream (1.4.0)
aws-partitions (1.1197.0) aws-partitions (1.1209.0)
aws-sdk-core (3.240.0) aws-sdk-core (3.241.4)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0) aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9) aws-sigv4 (~> 1.9)
@@ -87,44 +92,38 @@ GEM
bigdecimal bigdecimal
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
logger logger
aws-sdk-kms (1.118.0) aws-sdk-kms (1.121.0)
aws-sdk-core (~> 3, >= 3.239.1) aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.208.0) aws-sdk-s3 (1.212.0)
aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sdk-secretsmanager (1.110.0) aws-sdk-secretsmanager (1.128.0)
aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1) aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.3) azure-blob (0.8.0)
azure-storage-common (~> 2.0) cgi
nokogiri (~> 1, >= 1.10.8) rexml
azure-storage-common (2.0.4)
faraday (~> 1.0)
faraday_middleware (~> 1.0, >= 1.0.0.rc1)
net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8)
base64 (0.3.0) base64 (0.3.0)
bcrypt (3.1.20) bcrypt (3.1.21)
benchmark (0.4.1) better_html (2.2.0)
better_html (2.1.1) actionview (>= 7.0)
actionview (>= 6.0) activesupport (>= 7.0)
activesupport (>= 6.0)
ast (~> 2.0) ast (~> 2.0)
erubi (~> 1.4) erubi (~> 1.4)
parser (>= 2.4) parser (>= 2.4)
smart_properties smart_properties
bigdecimal (3.2.2) bigdecimal (4.0.1)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.18.4) bootsnap (1.21.1)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (7.0.0) brakeman (7.1.2)
racc racc
builder (3.3.0) builder (3.3.0)
bullet (8.0.0) bullet (8.1.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
uniform_notifier (~> 1.11) uniform_notifier (~> 1.11)
camertron-eprun (1.1.1) camertron-eprun (1.1.1)
@@ -138,28 +137,29 @@ GEM
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0) regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2) xpath (~> 3.2)
cgi (0.5.1)
childprocess (5.1.0) childprocess (5.1.0)
logger (~> 1.5) logger (~> 1.5)
chunky_png (1.4.0) chunky_png (1.4.0)
cldr-plurals-runtime-rb (1.1.0) cldr-plurals-runtime-rb (1.1.0)
cmdparse (3.0.7) cmdparse (3.0.7)
coderay (1.1.3) coderay (1.1.3)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.6)
connection_pool (2.5.3) connection_pool (3.0.2)
crack (1.0.0) crack (1.0.1)
bigdecimal bigdecimal
rexml rexml
crass (1.0.6) crass (1.0.6)
css_parser (1.21.0) css_parser (1.21.1)
addressable addressable
csv (3.3.2) csv (3.3.5)
csv-safe (3.3.1) csv-safe (3.3.1)
csv (~> 3.0) csv (~> 3.0)
cuprite (0.15.1) cuprite (0.17)
capybara (~> 3.0) capybara (~> 3.0)
ferrum (~> 0.15.0) ferrum (~> 0.17.0)
date (3.4.1) date (3.5.1)
debug (1.10.0) debug (1.11.1)
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
declarative (0.0.20) declarative (0.0.20)
@@ -169,20 +169,20 @@ GEM
railties (>= 4.1.0) railties (>= 4.1.0)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
devise-two-factor (6.1.0) devise-two-factor (6.3.1)
activesupport (>= 7.0, < 8.1) activesupport (>= 7.0, < 8.2)
devise (~> 4.0) devise (>= 4.0, < 5.0)
railties (>= 7.0, < 8.1) railties (>= 7.0, < 8.2)
rotp (~> 6.0) rotp (~> 6.0)
diff-lcs (1.5.1) diff-lcs (1.6.2)
digest-crc (0.6.5) digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0) rake (>= 12.0.0, < 14.0.0)
docile (1.4.1) docile (1.4.1)
dotenv (3.1.7) dotenv (3.2.0)
drb (2.2.3) drb (2.2.3)
email_typo (0.2.3) email_typo (0.2.3)
erb (5.0.2) erb (6.0.1)
erb_lint (0.7.0) erb_lint (0.9.0)
activesupport activesupport
better_html (>= 2.0.1) better_html (>= 2.0.1)
parser (>= 2.7.1.4) parser (>= 2.7.1.4)
@@ -190,117 +190,101 @@ GEM
rubocop (>= 1) rubocop (>= 1)
smart_properties smart_properties
erubi (1.13.1) erubi (1.13.1)
factory_bot (6.5.0) factory_bot (6.5.6)
activesupport (>= 5.0.0) activesupport (>= 6.1.0)
factory_bot_rails (6.4.4) factory_bot_rails (6.5.1)
factory_bot (~> 6.5) factory_bot (~> 6.5)
railties (>= 5.0.0) railties (>= 6.1.0)
faker (3.5.1) faker (3.6.0)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (1.10.4) faraday (2.14.0)
faraday-em_http (~> 1.0) faraday-net_http (>= 2.0, < 3.5)
faraday-em_synchrony (~> 1.0) json
faraday-excon (~> 1.1) logger
faraday-httpclient (~> 1.0) faraday-follow_redirects (0.5.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3) faraday (>= 1, < 3)
faraday-httpclient (1.0.1) faraday-net_http (3.4.2)
faraday-multipart (1.1.0) net-http (~> 0.5)
multipart-post (~> 2.0) ferrum (0.17.1)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
ferrum (0.15)
addressable (~> 2.5) addressable (~> 2.5)
base64 (~> 0.2)
concurrent-ruby (~> 1.1) concurrent-ruby (~> 1.1)
webrick (~> 1.7) webrick (~> 1.7)
websocket-driver (~> 0.7) websocket-driver (~> 0.7)
ffi (1.17.1) ffi (1.17.3)
ffi (1.17.1-aarch64-linux-gnu) ffi (1.17.3-aarch64-linux-gnu)
ffi (1.17.1-aarch64-linux-musl) ffi (1.17.3-aarch64-linux-musl)
ffi (1.17.1-arm64-darwin) ffi (1.17.3-arm64-darwin)
ffi (1.17.1-x86_64-linux-gnu) ffi (1.17.3-x86_64-linux-gnu)
ffi (1.17.1-x86_64-linux-musl) ffi (1.17.3-x86_64-linux-musl)
foreman (0.88.1) foreman (0.90.0)
thor (~> 1.4)
geom2d (0.4.1) geom2d (0.4.1)
globalid (1.2.1) globalid (1.3.0)
activesupport (>= 6.1) activesupport (>= 6.1)
google-apis-core (0.15.1) google-apis-core (1.0.2)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.8, >= 2.8.7)
googleauth (~> 1.9) faraday (~> 2.13)
httpclient (>= 2.8.3, < 3.a) faraday-follow_redirects (~> 0.3)
mini_mime (~> 1.0) googleauth (~> 1.14)
mutex_m mini_mime (~> 1.1)
representable (~> 3.0) representable (~> 3.0)
retriable (>= 2.0, < 4.a) retriable (~> 3.1)
google-apis-iamcredentials_v1 (0.22.0) google-apis-iamcredentials_v1 (0.26.0)
google-apis-core (>= 0.15.0, < 2.a) google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.49.0) google-apis-storage_v1 (0.59.0)
google-apis-core (>= 0.15.0, < 2.a) google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.7.1) google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a) google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0) google-cloud-errors (~> 1.0)
google-cloud-env (2.2.1) google-cloud-env (2.3.1)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a) faraday (>= 1.0, < 3.a)
google-cloud-errors (1.4.0) google-cloud-errors (1.5.0)
google-cloud-storage (1.54.0) google-cloud-storage (1.58.0)
addressable (~> 2.8) addressable (~> 2.8)
digest-crc (~> 0.4) digest-crc (~> 0.4)
google-apis-core (~> 0.13) google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18) google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (~> 0.38) google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6) google-cloud-core (~> 1.6)
googleauth (~> 1.9) googleauth (~> 1.9)
mini_mime (~> 1.0) mini_mime (~> 1.0)
google-logging-utils (0.1.0) google-logging-utils (0.2.0)
googleauth (1.12.2) googleauth (1.16.1)
faraday (>= 1.0, < 3.a) faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2) google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1) google-logging-utils (~> 0.1)
jwt (>= 1.4, < 3.0) jwt (>= 1.4, < 4.0)
multi_json (~> 1.11) multi_json (~> 1.11)
os (>= 0.9, < 2.0) os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a) signet (>= 0.16, < 2.a)
hashdiff (1.1.2) hashdiff (1.2.1)
hexapdf (1.4.0) hexapdf (1.5.0)
cmdparse (~> 3.0, >= 3.0.3) cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1) geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1) openssl (>= 2.2.1)
strscan (>= 3.1.2) strscan (>= 3.1.2)
htmlentities (4.3.4) htmlentities (4.4.2)
httpclient (2.8.3) i18n (1.14.8)
i18n (1.14.7)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.13.0) image_processing (1.14.0)
mini_magick (>= 4.9.5, < 5) mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3) ruby-vips (>= 2.0.17, < 3)
io-console (0.8.1) io-console (0.8.2)
irb (1.15.2) irb (1.16.0)
pp (>= 0.6.0) pp (>= 0.6.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jmespath (1.6.2) jmespath (1.6.2)
json (2.15.0) json (2.18.0)
jwt (2.9.3) jwt (3.1.2)
base64 base64
language_server-protocol (3.17.0.5) language_server-protocol (3.17.0.5)
launchy (3.0.1) launchy (3.1.1)
addressable (~> 2.8) addressable (~> 2.8)
childprocess (~> 5.0) childprocess (~> 5.0)
logger (~> 1.6)
letter_opener (1.10.0) letter_opener (1.10.0)
launchy (>= 2.2, < 4) launchy (>= 2.2, < 4)
letter_opener_web (3.0.0) letter_opener_web (3.0.0)
@@ -315,29 +299,30 @@ GEM
activesupport (>= 4) activesupport (>= 4)
railties (>= 4) railties (>= 4)
request_store (~> 1.0) request_store (~> 1.0)
loofah (2.24.1) loofah (2.25.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.9.0)
logger
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
marcel (1.0.4) marcel (1.1.0)
matrix (0.4.2) matrix (0.4.3)
method_source (1.1.0) method_source (1.1.0)
mini_magick (4.13.2) mini_magick (5.3.1)
logger
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.9) minitest (6.0.1)
minitest (5.25.5) prism (~> 1.5)
msgpack (1.7.5) msgpack (1.8.0)
multi_json (1.15.0) multi_json (1.19.1)
multipart-post (2.4.1) mysql2 (0.5.7)
mutex_m (0.3.0) bigdecimal
mysql2 (0.5.6) net-http (0.9.1)
net-http-persistent (4.0.5) uri (>= 0.11.1)
connection_pool (~> 2.2) net-imap (0.6.2)
net-imap (0.5.9)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -346,22 +331,19 @@ GEM
timeout timeout
net-smtp (0.5.1) net-smtp (0.5.1)
net-protocol net-protocol
nio4r (2.7.4) nio4r (2.7.5)
nokogiri (1.18.9) nokogiri (1.19.0-aarch64-linux-gnu)
mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.9-aarch64-linux-gnu) nokogiri (1.19.0-aarch64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.9-aarch64-linux-musl) nokogiri (1.19.0-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.9-arm64-darwin) nokogiri (1.19.0-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-gnu) nokogiri (1.19.0-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.9-x86_64-linux-musl) numo-narray-alt (0.9.13)
racc (~> 1.4) oj (3.16.13)
numo-narray (0.9.2.1)
oj (3.16.11)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
ostruct (>= 0.2) ostruct (>= 0.2)
onnxruntime (0.10.1) onnxruntime (0.10.1)
@@ -372,18 +354,25 @@ GEM
ffi ffi
onnxruntime (0.10.1-x86_64-linux) onnxruntime (0.10.1-x86_64-linux)
ffi ffi
openssl (3.3.0) openssl (4.0.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
ostruct (0.6.3) ostruct (0.6.3)
package_json (0.1.0) package_json (0.2.0)
pagy (9.3.3) pagy (43.2.8)
json
yaml
parallel (1.27.0) parallel (1.27.0)
parser (3.3.9.0) parser (3.3.10.1)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pg (1.5.9) pg (1.6.3)
pp (0.6.2) pg (1.6.3-aarch64-linux)
pg (1.6.3-aarch64-linux-musl)
pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl)
pp (0.6.3)
prettyprint prettyprint
premailer (1.27.0) premailer (1.27.0)
addressable addressable
@@ -393,23 +382,24 @@ GEM
actionmailer (>= 3) actionmailer (>= 3)
net-smtp net-smtp
premailer (~> 1.7, >= 1.7.9) premailer (~> 1.7, >= 1.7.9)
pretender (0.5.0) pretender (0.6.0)
actionpack (>= 6.1) actionpack (>= 7.1)
prettyprint (0.2.0) prettyprint (0.2.0)
prism (1.5.1) prism (1.8.0)
pry (0.15.0) pry (0.16.0)
coderay (~> 1.1) coderay (~> 1.1)
method_source (~> 1.0) method_source (~> 1.0)
reline (>= 0.6.0)
pry-rails (0.3.11) pry-rails (0.3.11)
pry (>= 0.13.0) pry (>= 0.13.0)
psych (5.2.6) psych (5.3.1)
date date
stringio stringio
public_suffix (6.0.1) public_suffix (7.0.2)
puma (6.5.0) puma (7.2.0)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.3) rack (3.2.4)
rack-proxy (0.7.7) rack-proxy (0.7.7)
rack rack
rack-session (2.1.1) rack-session (2.1.1)
@@ -417,22 +407,22 @@ GEM
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.2.0) rack-test (2.2.0)
rack (>= 1.3) rack (>= 1.3)
rackup (2.2.1) rackup (2.3.1)
rack (>= 3) rack (>= 3)
rails (8.0.2.1) rails (8.1.2)
actioncable (= 8.0.2.1) actioncable (= 8.1.2)
actionmailbox (= 8.0.2.1) actionmailbox (= 8.1.2)
actionmailer (= 8.0.2.1) actionmailer (= 8.1.2)
actionpack (= 8.0.2.1) actionpack (= 8.1.2)
actiontext (= 8.0.2.1) actiontext (= 8.1.2)
actionview (= 8.0.2.1) actionview (= 8.1.2)
activejob (= 8.0.2.1) activejob (= 8.1.2)
activemodel (= 8.0.2.1) activemodel (= 8.1.2)
activerecord (= 8.0.2.1) activerecord (= 8.1.2)
activestorage (= 8.0.2.1) activestorage (= 8.1.2)
activesupport (= 8.0.2.1) activesupport (= 8.1.2)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.0.2.1) railties (= 8.1.2)
rails-dom-testing (2.3.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
@@ -440,30 +430,32 @@ GEM
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.6.2)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.0.1) rails-i18n (8.1.0)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9) railties (>= 8.0.0, < 9)
rails_autolink (1.1.8) rails_autolink (1.1.8)
actionview (> 3.1) actionview (> 3.1)
activesupport (> 3.1) activesupport (> 3.1)
railties (> 3.1) railties (> 3.1)
railties (8.0.2.1) railties (8.1.2)
actionpack (= 8.0.2.1) actionpack (= 8.1.2)
activesupport (= 8.0.2.1) activesupport (= 8.1.2)
irb (~> 1.13) irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0, >= 1.2.2) thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.3.0) rake (13.3.1)
rdoc (6.14.2) rdoc (7.1.0)
erb erb
psych (>= 4.0.0) psych (>= 4.0.0)
redis-client (0.23.0) tsort
redis-client (0.26.4)
connection_pool connection_pool
regexp_parser (2.11.3) regexp_parser (2.11.3)
reline (0.6.2) reline (0.6.3)
io-console (~> 0.5) io-console (~> 0.5)
representable (3.2.0) representable (3.2.0)
declarative (< 0.1.0) declarative (< 0.1.0)
@@ -471,35 +463,35 @@ GEM
uber (< 0.2.0) uber (< 0.2.0)
request_store (1.7.0) request_store (1.7.0)
rack (>= 1.4) rack (>= 1.4)
responders (3.1.1) responders (3.2.0)
actionpack (>= 5.2) actionpack (>= 7.0)
railties (>= 5.2) railties (>= 7.0)
retriable (3.1.2) retriable (3.1.2)
rexml (3.4.4) rexml (3.4.4)
rotp (6.3.0) rotp (6.3.0)
rouge (4.5.2) rouge (4.7.0)
rqrcode (2.2.0) rqrcode (3.2.0)
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 1.0) rqrcode_core (~> 2.0)
rqrcode_core (1.2.0) rqrcode_core (2.1.0)
rspec-core (3.13.2) rspec-core (3.13.6)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-expectations (3.13.3) rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-mocks (3.13.2) rspec-mocks (3.13.7)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-rails (7.1.0) rspec-rails (8.0.2)
actionpack (>= 7.0) actionpack (>= 7.2)
activesupport (>= 7.0) activesupport (>= 7.2)
railties (>= 7.0) railties (>= 7.2)
rspec-core (~> 3.13) rspec-core (~> 3.13)
rspec-expectations (~> 3.13) rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13) rspec-mocks (~> 3.13)
rspec-support (~> 3.13) rspec-support (~> 3.13)
rspec-support (3.13.2) rspec-support (3.13.6)
rubocop (1.81.1) rubocop (1.82.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
@@ -507,86 +499,89 @@ GEM
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0) regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.47.1, < 2.0) rubocop-ast (>= 1.48.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.47.1) rubocop-ast (1.49.0)
parser (>= 3.3.7.2) parser (>= 3.3.7.2)
prism (~> 1.4) prism (~> 1.7)
rubocop-performance (1.23.0) rubocop-performance (1.26.1)
rubocop (>= 1.48.1, < 2.0) lint_roller (~> 1.1)
rubocop-ast (>= 1.31.1, < 2.0) rubocop (>= 1.75.0, < 2.0)
rubocop-rails (2.27.0) rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.34.3)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.52.0, < 2.0) rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0) rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rspec (3.3.0) rubocop-rspec (3.9.0)
rubocop (~> 1.61) lint_roller (~> 1.1)
rubocop (~> 1.81)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby-vips (2.2.2) ruby-vips (2.3.0)
ffi (~> 1.12) ffi (~> 1.12)
logger logger
ruby2_keywords (0.0.5) rubyXL (3.4.35)
rubyXL (3.4.33)
nokogiri (>= 1.10.8) nokogiri (>= 1.10.8)
rubyzip (>= 1.3.0) rubyzip (>= 3.2.2)
rubyzip (2.3.2) rubyzip (3.2.2)
securerandom (0.4.1) securerandom (0.4.1)
semantic_range (3.1.0) semantic_range (3.1.0)
shakapacker (8.0.2) shakapacker (9.5.0)
activesupport (>= 5.2) activesupport (>= 5.2)
package_json package_json
rack-proxy (>= 0.6.1) rack-proxy (>= 0.6.1)
railties (>= 5.2) railties (>= 5.2)
semantic_range (>= 2.3.0) semantic_range (>= 2.3.0)
sidekiq (7.3.7) sidekiq (8.1.0)
connection_pool (>= 2.3.0) connection_pool (>= 3.0.0)
logger json (>= 2.16.0)
rack (>= 2.2.4) logger (>= 1.7.0)
redis-client (>= 0.22.2) rack (>= 3.2.0)
signet (0.19.0) redis-client (>= 0.26.0)
signet (0.21.0)
addressable (~> 2.8) addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a) faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0) jwt (>= 1.5, < 4.0)
multi_json (~> 1.10) multi_json (~> 1.10)
simplecov (0.22.0) simplecov (0.22.0)
docile (~> 1.1) docile (~> 1.1)
simplecov-html (~> 0.11) simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1) simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.1) simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
smart_properties (1.17.0) smart_properties (1.17.0)
sqlite3 (2.5.0) sqlite3 (2.9.0-aarch64-linux-gnu)
mini_portile2 (~> 2.8.0) sqlite3 (2.9.0-aarch64-linux-musl)
sqlite3 (2.5.0-aarch64-linux-gnu) sqlite3 (2.9.0-arm64-darwin)
sqlite3 (2.5.0-aarch64-linux-musl) sqlite3 (2.9.0-x86_64-linux-gnu)
sqlite3 (2.5.0-arm64-darwin) sqlite3 (2.9.0-x86_64-linux-musl)
sqlite3 (2.5.0-x86_64-linux-gnu) stringio (3.2.0)
sqlite3 (2.5.0-x86_64-linux-musl) strip_attributes (2.0.1)
stringio (3.1.7)
strip_attributes (1.14.1)
activemodel (>= 3.0, < 9.0) activemodel (>= 3.0, < 9.0)
strscan (3.1.5) strscan (3.1.7)
thor (1.4.0) thor (1.5.0)
timeout (0.4.3) timeout (0.6.0)
trailblazer-option (0.1.2) trailblazer-option (0.1.2)
turbo-rails (2.0.11) tsort (0.2.0)
actionpack (>= 6.0.0) turbo-rails (2.0.21)
railties (>= 6.0.0) actionpack (>= 7.1.0)
twitter_cldr (6.12.1) railties (>= 7.1.0)
twitter_cldr (6.14.0)
base64
camertron-eprun camertron-eprun
cldr-plurals-runtime-rb (~> 1.1) cldr-plurals-runtime-rb (~> 1.1)
tzinfo tzinfo
tzinfo (2.0.6) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
tzinfo-data (1.2024.2) tzinfo-data (1.2025.3)
tzinfo (>= 1.0.0) tzinfo (>= 1.0.0)
uber (0.1.0) uber (0.1.0)
unicode-display_width (3.2.0) unicode-display_width (3.2.0)
unicode-emoji (~> 4.1) unicode-emoji (~> 4.1)
unicode-emoji (4.1.0) unicode-emoji (4.2.0)
uniform_notifier (1.16.0) uniform_notifier (1.18.0)
uri (1.1.1) uri (1.1.1)
useragent (0.16.11) useragent (0.16.11)
warden (1.2.9) warden (1.2.9)
@@ -596,24 +591,24 @@ GEM
activemodel (>= 6.0.0) activemodel (>= 6.0.0)
bindex (>= 0.4.0) bindex (>= 0.4.0)
railties (>= 6.0.0) railties (>= 6.0.0)
webmock (3.24.0) webmock (3.26.1)
addressable (>= 2.8.0) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.1) webrick (1.9.2)
websocket-driver (0.8.0) websocket-driver (0.8.0)
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.7.3) yaml (0.4.0)
zeitwerk (2.7.4)
PLATFORMS PLATFORMS
aarch64-linux aarch64-linux
aarch64-linux-musl aarch64-linux-musl
arm64-darwin arm64-darwin
ruby
x86_64-linux x86_64-linux
x86_64-linux-musl x86_64-linux-musl
@@ -622,7 +617,7 @@ DEPENDENCIES
arabic-letter-connector arabic-letter-connector
aws-sdk-s3 aws-sdk-s3
aws-sdk-secretsmanager aws-sdk-secretsmanager
azure-storage-blob azure-blob
better_html better_html
bootsnap bootsnap
brakeman brakeman
@@ -650,7 +645,7 @@ DEPENDENCIES
letter_opener_web letter_opener_web
lograge lograge
mysql2 mysql2
numo-narray numo-narray-alt
oj oj
onnxruntime onnxruntime
pagy pagy
@@ -685,7 +680,7 @@ DEPENDENCIES
webmock webmock
RUBY VERSION RUBY VERSION
ruby 3.4.2p28 ruby 4.0.1
BUNDLED WITH BUNDLED WITH
2.5.3 4.0.3
@@ -0,0 +1,33 @@
# frozen_string_literal: true
class AccountCustomFieldsController < ApplicationController
before_action :load_account_config, only: :create
def create
authorize!(:create, Template)
@account_config.update!(account_config_params)
render json: @account_config.value
end
private
def load_account_config
@account_config =
AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY)
end
def account_config_params
params.permit(
value: [[:uuid, :name, :type,
:required, :readonly, :default_value,
:title, :description,
{ preferences: {},
default_value: [],
options: [%i[value uuid]],
validation: %i[message pattern min max step],
areas: [%i[x y w h cell_w option_uuid]] }]]
)
end
end
+1 -1
View File
@@ -3,7 +3,7 @@
module Api module Api
class ApiBaseController < ActionController::API class ApiBaseController < ActionController::API
include ActiveStorage::SetCurrent include ActiveStorage::SetCurrent
include Pagy::Backend include Pagy::Method
DEFAULT_LIMIT = 10 DEFAULT_LIMIT = 10
MAX_LIMIT = 100 MAX_LIMIT = 100
@@ -5,34 +5,17 @@ module Api
load_and_authorize_resource :submission load_and_authorize_resource :submission
def index def index
is_merge = params[:merge] == 'true' &&
(@submission.schema_documents || @submission.template.schema_documents).size > 1
documents = documents =
if @submission.submitters.all?(&:completed_at?) if @submission.submitters.all?(&:completed_at?)
last_submitter = @submission.submitters.max_by(&:completed_at) build_completed_documents(@submission, merge: is_merge)
if last_submitter.documents_attachments.blank?
last_submitter.documents_attachments = Submissions::EnsureResultGenerated.call(last_submitter)
end
last_submitter.documents_attachments
else else
values_hash = Submissions::GeneratePreviewAttachments.build_values_hash(@submission) build_preview_documents(@submission, merge: is_merge)
if @submission.preview_documents.present? &&
@submission.preview_documents.all? { |s| s.metadata['values_hash'] == values_hash }
@submission.preview_documents
else
ApplicationRecord.no_touching do
@submission.preview_documents.each(&:destroy)
end
Submissions::GeneratePreviewAttachments.call(@submission, values_hash:)
end
end end
ActiveRecord::Associations::Preloader.new( ActiveRecord::Associations::Preloader.new(records: documents, associations: [:blob]).call
records: documents,
associations: [:blob]
).call
expires_at = Accounts.link_expires_at(current_account) expires_at = Accounts.link_expires_at(current_account)
@@ -43,5 +26,50 @@ module Api
end end
} }
end end
private
def build_completed_documents(submission, merge: false)
last_submitter = submission.submitters.max_by(&:completed_at)
if merge
if submission.merged_document_attachment.blank?
submission.merged_document_attachment =
Submissions::GenerateCombinedAttachment.call(last_submitter, with_audit: false)
end
[submission.merged_document_attachment]
else
if last_submitter.documents_attachments.blank?
last_submitter.documents_attachments = Submissions::EnsureResultGenerated.call(last_submitter)
end
last_submitter.documents_attachments
end
end
def build_preview_documents(submission, merge: false)
values_hash = Submissions::GeneratePreviewAttachments.build_values_hash(submission)
if merge
if submission.preview_merged_document_attachment.present? &&
submission.preview_merged_document_attachment.metadata['values_hash'] == values_hash
[submission.preview_merged_document_attachment]
else
ApplicationRecord.no_touching { submission.preview_merged_document_attachment&.destroy }
Submissions::GeneratePreviewAttachments.call(submission, values_hash:, merge: true)
end
elsif submission.preview_documents.present? &&
submission.preview_documents.all? { |s| s.metadata['values_hash'] == values_hash }
submission.preview_documents
else
ApplicationRecord.no_touching do
submission.preview_documents.each(&:destroy)
end
Submissions::GeneratePreviewAttachments.call(submission, values_hash:)
end
end
end end
end end
+8 -3
View File
@@ -4,7 +4,7 @@ class ApplicationController < ActionController::Base
BROWSER_LOCALE_REGEXP = /\A\w{2}(?:-\w{2})?/ BROWSER_LOCALE_REGEXP = /\A\w{2}(?:-\w{2})?/
include ActiveStorage::SetCurrent include ActiveStorage::SetCurrent
include Pagy::Backend include Pagy::Method
check_authorization unless: :devise_controller? check_authorization unless: :devise_controller?
@@ -17,12 +17,13 @@ class ApplicationController < ActionController::Base
helper_method :button_title, helper_method :button_title,
:current_account, :current_account,
:true_ability,
:form_link_host, :form_link_host,
:svg_icon :svg_icon
impersonates :user, with: ->(uuid) { User.find_by(uuid:) } impersonates :user, with: ->(uuid) { User.find_by(uuid:) }
rescue_from Pagy::OverflowError do rescue_from Pagy::RangeError do
redirect_to request.path redirect_to request.path
end end
@@ -59,7 +60,7 @@ class ApplicationController < ActionController::Base
def pagy_auto(collection, **keyword_args) def pagy_auto(collection, **keyword_args)
if current_ability.can?(:manage, :countless) if current_ability.can?(:manage, :countless)
pagy_countless(collection, **keyword_args) pagy(:countless, collection, **keyword_args)
else else
pagy(collection, **keyword_args) pagy(collection, **keyword_args)
end end
@@ -102,6 +103,10 @@ class ApplicationController < ActionController::Base
current_user&.account current_user&.account
end end
def true_ability
@true_ability ||= Ability.new(true_user)
end
def maybe_redirect_to_setup def maybe_redirect_to_setup
redirect_to setup_index_path unless User.exists? redirect_to setup_index_path unless User.exists?
end end
@@ -0,0 +1,56 @@
# frozen_string_literal: true
class TemplatesCloneController < ApplicationController
load_and_authorize_resource :template, instance_name: :base_template
def new
authorize!(:create, Template)
@template = Template.new(name: "#{@base_template.name} (#{I18n.t('clone')})")
end
def create
ActiveRecord::Associations::Preloader.new(
records: [@base_template],
associations: [schema_documents: :preview_images_attachments]
).call
@template = Templates::Clone.call(@base_template, author: current_user,
name: params.dig(:template, :name),
folder_name: params[:folder_name])
authorize!(:create, @template)
if params[:account_id].present? && true_ability.authorize!(:manage, Account.find(params[:account_id]))
@template.account_id = params[:account_id]
@template.author = true_user if true_user.account_id == @template.account_id
@template.folder = @template.account.default_template_folder if @template.account_id != current_account.id
else
@template.account = current_account
end
Templates.maybe_assign_access(@template)
if @template.save
Templates::CloneAttachments.call(template: @template, original_template: @base_template)
SearchEntries.enqueue_reindex(@template)
WebhookUrls.enqueue_events(@template, 'template.created')
maybe_redirect_to_template(@template)
else
render turbo_stream: turbo_stream.replace(:modal, partial: 'templates_clone/form'), status: :unprocessable_content
end
end
private
def maybe_redirect_to_template(template)
if template.account == current_account
redirect_to(edit_template_path(template))
else
redirect_back(fallback_location: root_path, notice: I18n.t('template_has_been_cloned'))
end
end
end
+5 -47
View File
@@ -3,8 +3,6 @@
class TemplatesController < ApplicationController class TemplatesController < ApplicationController
load_and_authorize_resource :template load_and_authorize_resource :template
before_action :load_base_template, only: %i[new create]
def show def show
submissions = @template.submissions.accessible_by(current_ability) submissions = @template.submissions.accessible_by(current_ability)
submissions = submissions.active if @template.archived_at.blank? submissions = submissions.active if @template.archived_at.blank?
@@ -26,9 +24,7 @@ class TemplatesController < ApplicationController
redirect_to root_path redirect_to root_path
end end
def new def new; end
@template.name = "#{@base_template.name} (#{I18n.t('clone')})" if @base_template
end
def edit def edit
ActiveRecord::Associations::Preloader.new( ActiveRecord::Associations::Preloader.new(
@@ -48,37 +44,18 @@ class TemplatesController < ApplicationController
end end
def create def create
if @base_template @template.author = current_user
ActiveRecord::Associations::Preloader.new( @template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
records: [@base_template], @template.account = current_account
associations: [schema_documents: :preview_images_attachments]
).call
@template = Templates::Clone.call(@base_template, author: current_user,
name: params.dig(:template, :name),
folder_name: params[:folder_name])
else
@template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
end
if params[:account_id].present? && authorized_clone_account_id?(params[:account_id])
@template.account_id = params[:account_id]
@template.folder = @template.account.default_template_folder if @template.account_id != current_account.id
else
@template.account = current_account
end
Templates.maybe_assign_access(@template) Templates.maybe_assign_access(@template)
if @template.save if @template.save
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
SearchEntries.enqueue_reindex(@template) SearchEntries.enqueue_reindex(@template)
WebhookUrls.enqueue_events(@template, 'template.created') WebhookUrls.enqueue_events(@template, 'template.created')
maybe_redirect_to_template(@template) redirect_to(edit_template_path(@template))
else else
render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_content render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_content
end end
@@ -132,23 +109,4 @@ class TemplatesController < ApplicationController
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] } areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
) )
end end
def authorized_clone_account_id?(account_id)
true_user.account_id.to_s == account_id.to_s ||
true_user.account.linked_accounts.accessible_by(current_ability).exists?(id: account_id)
end
def maybe_redirect_to_template(template)
if template.account == current_account
redirect_to(edit_template_path(@template))
else
redirect_back(fallback_location: root_path, notice: I18n.t('template_has_been_cloned'))
end
end
def load_base_template
return if params[:base_template_id].blank?
@base_template = Template.accessible_by(current_ability).find_by(id: params[:base_template_id])
end
end end
@@ -11,11 +11,14 @@ class TemplatesDetectFieldsController < ApplicationController
sse = SSE.new(response.stream) sse = SSE.new(response.stream)
documents = @template.schema_documents.preload(:blob) documents = @template.schema_documents.preload(:blob)
documents = documents.where(uuid: params[:attachment_uuid]) if params[:attachment_uuid].present?
page_number = params[:page].presence&.to_i
documents.each do |document| documents.each do |document|
io = StringIO.new(document.download) io = StringIO.new(document.download)
Templates::DetectFields.call(io, attachment: document) do |(attachment_uuid, page, fields)| Templates::DetectFields.call(io, attachment: document, page_number:) do |(attachment_uuid, page, fields)|
sse.write({ attachment_uuid:, page:, fields: }) sse.write({ attachment_uuid:, page:, fields: })
end end
end end
@@ -32,7 +32,7 @@ class TimestampServerController < ApplicationController
uri = Addressable::URI.parse(url) uri = Addressable::URI.parse(url)
conn = Faraday.new(uri.origin) do |c| conn = Faraday.new(uri.origin) do |c|
c.basic_auth(uri.user, uri.password) if uri.password.present? c.request :authorization, :basic, uri.user, uri.password if uri.password.present?
end end
response = conn.post(uri.path, req.to_der, response = conn.post(uri.path, req.to_der,
@@ -15,7 +15,7 @@ class WebhookSettingsController < ApplicationController
@webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status]) @webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status])
@pagy, @webhook_events = pagy_countless(@webhook_events.order(id: :desc)) @pagy, @webhook_events = pagy(:countless, @webhook_events.order(id: :desc))
render :show render :show
end end
@@ -26,7 +26,7 @@ class WebhookSettingsController < ApplicationController
@webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status]) @webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status])
@pagy, @webhook_events = pagy_countless(@webhook_events.order(id: :desc)) @pagy, @webhook_events = pagy(:countless, @webhook_events.order(id: :desc))
end end
def new; end def new; end
+2
View File
@@ -155,6 +155,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
this.app = createApp(TemplateBuilder, { this.app = createApp(TemplateBuilder, {
template: reactive(JSON.parse(this.dataset.template)), template: reactive(JSON.parse(this.dataset.template)),
customFields: reactive(JSON.parse(this.dataset.customFields || '[]')),
backgroundColor: '#faf7f5', backgroundColor: '#faf7f5',
locale: this.dataset.locale, locale: this.dataset.locale,
withPhone: this.dataset.withPhone === 'true', withPhone: this.dataset.withPhone === 'true',
@@ -164,6 +165,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withFieldsDetection: this.dataset.withFieldsDetection === 'true', withFieldsDetection: this.dataset.withFieldsDetection === 'true',
editable: this.dataset.editable !== 'false', editable: this.dataset.editable !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content, authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withCustomFields: true,
withPayment: this.dataset.withPayment === 'true', withPayment: this.dataset.withPayment === 'true',
isPaymentConnected: this.dataset.isPaymentConnected === 'true', isPaymentConnected: this.dataset.isPaymentConnected === 'true',
withFormula: this.dataset.withFormula === 'true', withFormula: this.dataset.withFormula === 'true',
+51 -6
View File
@@ -4,15 +4,26 @@ import { isValidSignatureCanvas } from './submission_form/validate_signature'
window.customElements.define('draw-signature', class extends HTMLElement { window.customElements.define('draw-signature', class extends HTMLElement {
connectedCallback () { connectedCallback () {
const scale = 3 this.setCanvasSize()
this.canvas.width = this.canvas.parentNode.clientWidth * scale
this.canvas.height = this.canvas.parentNode.clientHeight * scale
this.canvas.getContext('2d').scale(scale, scale)
this.pad = new SignaturePad(this.canvas) this.pad = new SignaturePad(this.canvas)
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.canvas) return
const { width, height } = this.canvas
this.setCanvasSize()
if (this.canvas.width !== width || this.canvas.height !== height) {
this.redrawCanvas(width, height)
}
})
})
this.resizeObserver.observe(this.canvas.parentNode)
if (this.dataset.color) { if (this.dataset.color) {
this.pad.penColor = this.dataset.color this.pad.penColor = this.dataset.color
} }
@@ -57,6 +68,40 @@ window.customElements.define('draw-signature', class extends HTMLElement {
this.updateSubmitButtonVisibility() this.updateSubmitButtonVisibility()
} }
disconnectedCallback () {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
}
setCanvasSize () {
const scale = 3
const width = this.canvas.parentNode.clientWidth
const height = this.canvas.parentNode.clientHeight
if (this.canvas.width !== width * scale || this.canvas.height !== height * scale) {
this.canvas.width = width * scale
this.canvas.height = height * scale
this.canvas.getContext('2d').scale(scale, scale)
}
}
redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty()) {
const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight
const scaledData = this.pad.toData().map((stroke) => ({
...stroke,
points: stroke.points.map((p) => ({ ...p, x: p.x * sx, y: p.y * sy }))
}))
this.pad.fromData(scaledData)
}
}
updateSubmitButtonVisibility () { updateSubmitButtonVisibility () {
if (this.pad.isEmpty()) { if (this.pad.isEmpty()) {
this.submitButton.style.display = 'none' this.submitButton.style.display = 'none'
+52 -7
View File
@@ -5,17 +5,28 @@ export default targetable(class extends HTMLElement {
static [target.static] = ['canvas', 'input', 'clear', 'button'] static [target.static] = ['canvas', 'input', 'clear', 'button']
async connectedCallback () { async connectedCallback () {
const scale = 3
this.canvas.width = this.canvas.parentNode.clientWidth * scale
this.canvas.height = this.canvas.parentNode.clientHeight * scale
this.canvas.getContext('2d').scale(scale, scale)
const { default: SignaturePad } = await import('signature_pad') const { default: SignaturePad } = await import('signature_pad')
this.setCanvasSize()
this.pad = new SignaturePad(this.canvas) this.pad = new SignaturePad(this.canvas)
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.canvas) return
const { width, height } = this.canvas
this.setCanvasSize()
if (this.canvas.width !== width || this.canvas.height !== height) {
this.redrawCanvas(width, height)
}
})
})
this.resizeObserver.observe(this.canvas.parentNode)
this.clear.addEventListener('click', (e) => { this.clear.addEventListener('click', (e) => {
e.preventDefault() e.preventDefault()
@@ -47,4 +58,38 @@ export default targetable(class extends HTMLElement {
this.closest('form').requestSubmit() this.closest('form').requestSubmit()
} }
disconnectedCallback () {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
}
setCanvasSize () {
const scale = 3
const width = this.canvas.parentNode.clientWidth
const height = this.canvas.parentNode.clientWidth / 2.5
if (this.canvas.width !== width * scale || this.canvas.height !== height * scale) {
this.canvas.width = width * scale
this.canvas.height = height * scale
this.canvas.getContext('2d').scale(scale, scale)
}
}
redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty()) {
const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight
const scaledData = this.pad.toData().map((stroke) => ({
...stroke,
points: stroke.points.map((p) => ({ ...p, x: p.x * sx, y: p.y * sy }))
}))
this.pad.fromData(scaledData)
}
}
}) })
+1 -1
View File
@@ -461,7 +461,7 @@ export default {
phone: IconPhoneCheck, phone: IconPhoneCheck,
payment: IconCreditCard, payment: IconCreditCard,
verification: IconId, verification: IconId,
kba: IconUserScan, kba: IconUserScan
} }
}, },
image () { image () {
+32 -6
View File
@@ -69,7 +69,7 @@
id="expand_form_button" id="expand_form_button"
class="btn btn-neutral flex text-white absolute bottom-0 w-full mb-3 expand-form-button text-base" class="btn btn-neutral flex text-white absolute bottom-0 w-full mb-3 expand-form-button text-base"
style="width: 96%; margin-left: 2%" style="width: 96%; margin-left: 2%"
@click.prevent="[isFormVisible = true, scrollIntoField(currentField)]" @click.prevent="[isFormVisible = true, $nextTick(() => scrollIntoField(currentField))]"
> >
<template v-if="['initials', 'signature'].includes(currentField.type)"> <template v-if="['initials', 'signature'].includes(currentField.type)">
<IconWritingSign stroke-width="1.5" /> <IconWritingSign stroke-width="1.5" />
@@ -592,6 +592,18 @@ import AppearsOn from './appears_on'
import i18n from './i18n' import i18n from './i18n'
import { sanitizeUrl } from '@braintree/sanitize-url' import { sanitizeUrl } from '@braintree/sanitize-url'
if (typeof URL.canParse !== 'function') {
URL.canParse = function (url, base) {
try {
const parsed = new URL(url, base)
return !!parsed
} catch {
return false
}
}
}
const isEmpty = (obj) => { const isEmpty = (obj) => {
if (obj == null) return true if (obj == null) return true
@@ -1244,17 +1256,31 @@ export default {
} else if (['equal', 'contains'].includes(condition.action) && field) { } else if (['equal', 'contains'].includes(condition.action) && field) {
if (field.options) { if (field.options) {
const option = field.options.find((o) => o.uuid === condition.value) const option = field.options.find((o) => o.uuid === condition.value)
const values = [this.values[condition.field_uuid] ?? defaultValue].flat()
return values.includes(this.optionValue(option, field.options.indexOf(option))) if (option) {
const values = [this.values[condition.field_uuid] ?? defaultValue].flat()
return values.includes(this.optionValue(option, field.options.indexOf(option)))
} else {
return false
}
} else { } else {
return [this.values[condition.field_uuid] ?? defaultValue].flat().includes(condition.value) return [this.values[condition.field_uuid] ?? defaultValue].flat().includes(condition.value)
} }
} else if (['not_equal', 'does_not_contain'].includes(condition.action) && field) { } else if (['not_equal', 'does_not_contain'].includes(condition.action) && field) {
const option = field.options.find((o) => o.uuid === condition.value) if (field.options) {
const values = [this.values[condition.field_uuid] ?? defaultValue].flat() const option = field.options.find((o) => o.uuid === condition.value)
return !values.includes(this.optionValue(option, field.options.indexOf(option))) if (option) {
const values = [this.values[condition.field_uuid] ?? defaultValue].flat()
return !values.includes(this.optionValue(option, field.options.indexOf(option)))
} else {
return false
}
} else {
return false
}
} else { } else {
return true return true
} }
+67 -3
View File
@@ -10,9 +10,15 @@
<template v-else>{{ field.name || 'Knowledge Based Authentication' }}</template> <template v-else>{{ field.name || 'Knowledge Based Authentication' }}</template>
<span <span
v-if="questions" v-if="questions"
class="float-right text-base font-normal text-neutral-500 mt-1 whitespace-nowrap" class="float-right font-normal text-neutral-500 mt-1 whitespace-nowrap text-right text-sm"
> >
Question {{ currentQuestionIndex + 1 }} / {{ questions.length }} <span>Question {{ currentQuestionIndex + 1 }} / {{ questions.length }}</span>
<span
v-if="timeLeftSeconds !== null"
class="block"
>
Time left: {{ formattedTimeLeft }}
</span>
</span> </span>
</label> </label>
<div <div
@@ -54,7 +60,9 @@
<div v-else-if="questions && !error"> <div v-else-if="questions && !error">
<form @submit.prevent="nextQuestion"> <form @submit.prevent="nextQuestion">
<div class="mb-6 px-1"> <div class="mb-6 px-1">
<p class="font-semibold mb-4 text-lg">{{ currentQuestion.prompt }}</p> <p class="font-semibold mb-4 text-lg">
{{ currentQuestion.prompt }}
</p>
<div class="space-y-3.5 mx-auto"> <div class="space-y-3.5 mx-auto">
<div <div
v-for="(answer, index) in currentQuestion.answers" v-for="(answer, index) in currentQuestion.answers"
@@ -316,6 +324,8 @@ export default {
reference: null, reference: null,
answers: {}, answers: {},
error: null, error: null,
timeLeftSeconds: null,
countdownIntervalId: null,
form: { form: {
fn: '', fn: '',
ln: '', ln: '',
@@ -337,6 +347,9 @@ export default {
isRequiredFieldEmpty () { isRequiredFieldEmpty () {
return this.emptyValueRequiredStep && this.emptyValueRequiredStep[0] !== this.field return this.emptyValueRequiredStep && this.emptyValueRequiredStep[0] !== this.field
}, },
kbaTimeLimitSeconds () {
return 90
},
states () { states () {
return [ return [
{ code: 'AL', name: 'Alabama' }, { code: 'AL', name: 'Alabama' },
@@ -394,9 +407,53 @@ export default {
}, },
isLastQuestion () { isLastQuestion () {
return this.questions && this.currentQuestionIndex === this.questions.length - 1 return this.questions && this.currentQuestionIndex === this.questions.length - 1
},
formattedTimeLeft () {
if (this.timeLeftSeconds === null) return ''
const minutes = Math.floor(this.timeLeftSeconds / 60)
const seconds = this.timeLeftSeconds % 60
return `${minutes}:${String(seconds).padStart(2, '0')}`
} }
}, },
beforeUnmount () {
this.clearCountdown()
},
methods: { methods: {
clearCountdown () {
if (this.countdownIntervalId) {
clearInterval(this.countdownIntervalId)
this.countdownIntervalId = null
}
this.timeLeftSeconds = null
},
startCountdown () {
this.clearCountdown()
this.timeLeftSeconds = this.kbaTimeLimitSeconds
this.countdownIntervalId = setInterval(() => {
if (this.timeLeftSeconds === null) return
this.timeLeftSeconds -= 1
if (this.timeLeftSeconds <= 0) {
this.handleTimeout()
}
}, 1000)
},
handleTimeout () {
this.clearCountdown()
this.questions = null
this.token = null
this.reference = null
this.answers = {}
this.currentQuestionIndex = 0
this.error = `Knowledge Based Authentication timed out. You only have ${this.kbaTimeLimitSeconds} seconds to complete the verification. Please retry.`
},
nextQuestion () { nextQuestion () {
if (this.isLastQuestion) { if (this.isLastQuestion) {
this.$emit('submit') this.$emit('submit')
@@ -405,6 +462,8 @@ export default {
} }
}, },
restartKba () { restartKba () {
this.clearCountdown()
this.questions = null this.questions = null
this.token = null this.token = null
this.reference = null this.reference = null
@@ -415,6 +474,7 @@ export default {
async startKba () { async startKba () {
this.isLoading = true this.isLoading = true
this.error = null this.error = null
this.clearCountdown()
try { try {
const payload = { ...this.form, submitter_slug: this.submitterSlug } const payload = { ...this.form, submitter_slug: this.submitterSlug }
@@ -457,6 +517,8 @@ export default {
this.questions.forEach(q => { this.questions.forEach(q => {
this.answers[q.id] = null this.answers[q.id] = null
}) })
this.startCountdown()
} else { } else {
throw new Error('Invalid KBA response') throw new Error('Invalid KBA response')
} }
@@ -467,6 +529,8 @@ export default {
} }
}, },
async submit () { async submit () {
this.clearCountdown()
this.isSubmitting = true this.isSubmitting = true
this.error = null this.error = null
@@ -175,9 +175,7 @@
v-if="isShowQr" v-if="isShowQr"
class="top-0 bottom-0 right-0 left-0 absolute bg-base-content/10 rounded-2xl" class="top-0 bottom-0 right-0 left-0 absolute bg-base-content/10 rounded-2xl"
> >
<div <div class="absolute top-1.5 right-1.5">
class="absolute top-1.5 right-1.5 md:tooltip"
>
<a <a
href="#" href="#"
class="btn btn-sm btn-circle btn-normal btn-outline" class="btn btn-sm btn-circle btn-normal btn-outline"
@@ -448,14 +446,7 @@ export default {
} }
}, },
async mounted () { async mounted () {
this.$nextTick(() => { this.$nextTick(() => this.setCanvasSize())
if (this.$refs.canvas) {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3
this.$refs.canvas.getContext('2d').scale(scale, scale)
}
})
if (this.$refs.canvas) { if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas) this.pad = new SignaturePad(this.$refs.canvas)
@@ -470,13 +461,10 @@ export default {
this.$emit('start') this.$emit('start')
}) })
this.intersectionObserver = new IntersectionObserver((entries, observer) => { this.intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => { entries.forEach(entry => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale this.setCanvasSize()
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3
this.$refs.canvas.getContext('2d').scale(scale, scale)
this.intersectionObserver?.disconnect() this.intersectionObserver?.disconnect()
} }
@@ -484,13 +472,62 @@ export default {
}) })
this.intersectionObserver.observe(this.$refs.canvas) this.intersectionObserver.observe(this.$refs.canvas)
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.$refs.canvas) return
const { width, height } = this.$refs.canvas
this.setCanvasSize()
if (this.$refs.canvas.width !== width || this.$refs.canvas.height !== height) {
this.redrawCanvas(width, height)
}
})
})
this.resizeObserver.observe(this.$refs.canvas.parentNode)
} }
}, },
beforeUnmount () { beforeUnmount () {
this.intersectionObserver?.disconnect() this.intersectionObserver?.disconnect()
this.resizeObserver?.disconnect()
this.stopCheckSignature() this.stopCheckSignature()
}, },
methods: { methods: {
setCanvasSize () {
const canvas = this.$refs.canvas
if (canvas) {
const width = canvas.parentNode.clientWidth
const height = width / 3
if (canvas.width !== width * scale || canvas.height !== height * scale) {
canvas.width = width * scale
canvas.height = height * scale
canvas.getContext('2d').scale(scale, scale)
}
}
},
redrawCanvas (oldWidth, oldHeight) {
const canvas = this.$refs.canvas
if (this.pad && !this.isTextSignature && !this.pad.isEmpty()) {
const sx = canvas.width / oldWidth
const sy = canvas.height / oldHeight
const scaledData = this.pad.toData().map((stroke) => ({
...stroke,
points: stroke.points.map((p) => ({ ...p, x: p.x * sx, y: p.y * sy }))
}))
this.pad.fromData(scaledData)
} else if (this.isTextSignature && this.$refs.textInput) {
this.updateWrittenSignature({ target: { value: this.$refs.textInput.value } })
}
},
remove () { remove () {
this.$emit('update:model-value', '') this.$emit('update:model-value', '')
+61 -18
View File
@@ -8,7 +8,7 @@
@touchstart="startTouchDrag" @touchstart="startTouchDrag"
> >
<div <div
v-if="isSelected || isDraw" v-if="isSelected || isDraw || isInMultiSelection"
class="top-0 bottom-0 right-0 left-0 absolute border border-1.5 pointer-events-none" class="top-0 bottom-0 right-0 left-0 absolute border border-1.5 pointer-events-none"
:class="activeBorderClasses" :class="activeBorderClasses"
/> />
@@ -24,7 +24,7 @@
:style="{ left: (cellW / area.w * 100) + '%' }" :style="{ left: (cellW / area.w * 100) + '%' }"
> >
<span <span
v-if="index === 0 && editable" v-if="index === 0 && editable && !isInMultiSelection"
class="h-2.5 w-2.5 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-ew-resize z-10" class="h-2.5 w-2.5 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-ew-resize z-10"
style="left: -4px" style="left: -4px"
@mousedown.stop="startResizeCell" @mousedown.stop="startResizeCell"
@@ -32,7 +32,7 @@
</div> </div>
</div> </div>
<div <div
v-if="field?.type && (isSelected || isNameFocus)" v-if="field?.type && (isSelected || isNameFocus) && !isInMultiSelection"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls" class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls"
style="top: -25px; height: 25px" style="top: -25px; height: 25px"
@mousedown.stop @mousedown.stop
@@ -48,7 +48,7 @@
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'" :menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'"
:submitters="template.submitters" :submitters="template.submitters"
@update:model-value="save" @update:model-value="save"
@click="selectedAreaRef.value = area" @click="selectedAreasRef.value = [area]"
/> />
<FieldType <FieldType
v-model="field.type" v-model="field.type"
@@ -57,7 +57,7 @@
:button-classes="'px-1'" :button-classes="'px-1'"
:menu-classes="'bg-white rounded-t-none'" :menu-classes="'bg-white rounded-t-none'"
@update:model-value="[maybeUpdateOptions(), save()]" @update:model-value="[maybeUpdateOptions(), save()]"
@click="selectedAreaRef.value = area" @click="selectedAreasRef.value = [area]"
/> />
<span <span
v-if="field.type !== 'checkbox' || field.name" v-if="field.type !== 'checkbox' || field.name"
@@ -145,8 +145,10 @@
@click-formula="isShowFormulaModal = true" @click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true" @click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true" @click-description="isShowDescriptionModal = true"
@add-custom-field="$emit('add-custom-field')"
@click-condition="isShowConditionsModal = true" @click-condition="isShowConditionsModal = true"
@scroll-to="[selectedAreaRef.value = $event, $emit('scroll-to', $event)]" @save="save"
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
/> />
</ul> </ul>
</span> </span>
@@ -266,7 +268,7 @@
ref="defaultValueSelect" ref="defaultValueSelect"
class="bg-transparent outline-none focus:outline-none w-full" class="bg-transparent outline-none focus:outline-none w-full"
@change="[field.default_value = $event.target.value, field.readonly = !!field.default_value?.length, save()]" @change="[field.default_value = $event.target.value, field.readonly = !!field.default_value?.length, save()]"
@focus="selectedAreaRef.value = area" @focus="selectedAreasRef.value = [area]"
@keydown.enter="onDefaultValueEnter" @keydown.enter="onDefaultValueEnter"
> >
<option <option
@@ -293,7 +295,7 @@
:class="{ 'cursor-text': isValueInput }" :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 : (field.type === 'date' ? field.preferences?.format || t('type_value') : t('type_value'))"
@blur="onDefaultValueBlur" @blur="onDefaultValueBlur"
@focus="selectedAreaRef.value = area" @focus="selectedAreasRef.value = [area]"
@paste.prevent="onPaste" @paste.prevent="onPaste"
@keydown.enter="onDefaultValueEnter" @keydown.enter="onDefaultValueEnter"
>{{ field.default_value }}</span> >{{ field.default_value }}</span>
@@ -319,6 +321,7 @@
<span <span
v-if="field?.type && editable" v-if="field?.type && editable"
class="h-4 w-4 lg:h-2.5 lg:w-2.5 -right-1 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-nwse-resize" class="h-4 w-4 lg:h-2.5 lg:w-2.5 -right-1 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-nwse-resize"
:class="{ 'z-30': isInMultiSelection }"
@mousedown.stop="startResize" @mousedown.stop="startResize"
@touchstart="startTouchResize" @touchstart="startTouchResize"
/> />
@@ -398,7 +401,7 @@ export default {
FieldSubmitter, FieldSubmitter,
IconX IconX
}, },
inject: ['template', 'selectedAreaRef', 'save', 't', 'isInlineSize'], inject: ['template', 'save', 't', 'isInlineSize', 'selectedAreasRef', 'isCmdKeyRef'],
props: { props: {
area: { area: {
type: Object, type: Object,
@@ -463,9 +466,14 @@ export default {
type: Object, type: Object,
required: false, required: false,
default: null default: null
},
isSelectMode: {
type: Boolean,
required: false,
default: false
} }
}, },
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to'], emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field'],
data () { data () {
return { return {
isShowFormulaModal: false, isShowFormulaModal: false,
@@ -646,7 +654,10 @@ export default {
] ]
}, },
isSelected () { isSelected () {
return this.selectedAreaRef.value === this.area return this.selectedAreasRef.value.includes(this.area)
},
isInMultiSelection () {
return this.selectedAreasRef.value.length >= 2 && this.isSelected
}, },
positionStyle () { positionStyle () {
const { x, y, w, h } = this.area const { x, y, w, h } = this.area
@@ -683,10 +694,10 @@ export default {
buildAreaOptionValue (area) { buildAreaOptionValue (area) {
const option = this.optionsUuidIndex[area.option_uuid] const option = this.optionsUuidIndex[area.option_uuid]
return option.value || `${this.t('option')} ${this.field.options.indexOf(option) + 1}` return option?.value || `${this.t('option')} ${this.field.options.indexOf(option) + 1}`
}, },
maybeToggleDefaultValue () { maybeToggleDefaultValue () {
if (!this.editable) { if (!this.editable || this.isCmdKeyRef.value) {
return return
} }
@@ -770,7 +781,7 @@ export default {
} }
}, },
onNameFocus (e) { onNameFocus (e) {
this.selectedAreaRef.value = this.area this.selectedAreasRef.value = [this.area]
this.isNameFocus = true this.isNameFocus = true
this.$refs.name.style.minWidth = this.$refs.name.clientWidth + 'px' this.$refs.name.style.minWidth = this.$refs.name.clientWidth + 'px'
@@ -906,6 +917,15 @@ export default {
if (e.target.id === 'mask') { if (e.target.id === 'mask') {
this.area.w = e.offsetX / e.target.clientWidth - this.area.x this.area.w = e.offsetX / e.target.clientWidth - this.area.x
this.area.h = e.offsetY / e.target.clientHeight - this.area.y this.area.h = e.offsetY / e.target.clientHeight - this.area.y
if (this.isInMultiSelection) {
this.selectedAreasRef.value.forEach((area) => {
if (area !== this.area) {
area.w = this.area.w
area.h = this.area.h
}
})
}
} }
}, },
drag (e) { drag (e) {
@@ -931,7 +951,7 @@ export default {
const rect = e.target.getBoundingClientRect() const rect = e.target.getBoundingClientRect()
this.selectedAreaRef.value = this.area this.selectedAreasRef.value = [this.area]
this.dragFrom = { x: rect.left - e.touches[0].clientX, y: rect.top - e.touches[0].clientY } this.dragFrom = { x: rect.left - e.touches[0].clientX, y: rect.top - e.touches[0].clientY }
@@ -980,13 +1000,23 @@ export default {
e.preventDefault() e.preventDefault()
if (e.metaKey || e.ctrlKey) {
if (!this.selectedAreasRef.value.includes(this.area)) {
this.selectedAreasRef.value.push(this.area)
} else {
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(this.area), 1)
}
return
}
if (this.editable) { if (this.editable) {
this.isDragged = true this.isDragged = true
} }
const rect = e.target.getBoundingClientRect() const rect = e.target.getBoundingClientRect()
this.selectedAreaRef.value = this.area this.selectedAreasRef.value = [this.area]
this.dragFrom = { x: rect.left - e.clientX, y: rect.top - e.clientY } this.dragFrom = { x: rect.left - e.clientX, y: rect.top - e.clientY }
@@ -1055,7 +1085,9 @@ export default {
this.$emit('stop-drag') this.$emit('stop-drag')
}, },
startResize () { startResize () {
this.selectedAreaRef.value = this.area if (!this.selectedAreasRef.value.includes(this.area)) {
this.selectedAreasRef.value = [this.area]
}
this.$el.getRootNode().addEventListener('mousemove', this.resize) this.$el.getRootNode().addEventListener('mousemove', this.resize)
this.$el.getRootNode().addEventListener('mouseup', this.stopResize) this.$el.getRootNode().addEventListener('mouseup', this.stopResize)
@@ -1071,7 +1103,9 @@ export default {
this.save() this.save()
}, },
startTouchResize (e) { startTouchResize (e) {
this.selectedAreaRef.value = this.area if (!this.selectedAreasRef.value.includes(this.area)) {
this.selectedAreasRef.value = [this.area]
}
this.$refs?.name?.blur() this.$refs?.name?.blur()
@@ -1088,6 +1122,15 @@ export default {
this.area.w = (e.touches[0].clientX - rect.left) / rect.width - this.area.x this.area.w = (e.touches[0].clientX - rect.left) / rect.width - this.area.x
this.area.h = (e.touches[0].clientY - rect.top) / rect.height - this.area.y this.area.h = (e.touches[0].clientY - rect.top) / rect.height - this.area.y
if (this.isInMultiSelection) {
this.selectedAreasRef.value.forEach((area) => {
if (area !== this.area) {
area.w = this.area.w
area.h = this.area.h
}
})
}
}, },
stopTouchResize () { stopTouchResize () {
this.$el.getRootNode().removeEventListener('touchmove', this.touchResize) this.$el.getRootNode().removeEventListener('touchmove', this.touchResize)
File diff suppressed because it is too large. Load diff
@@ -168,9 +168,19 @@ export default {
buildDefaultName: { buildDefaultName: {
type: Function, type: Function,
required: true required: true
},
withClickSaveEvent: {
type: Boolean,
required: false,
default: false
},
excludeFieldUuids: {
type: Array,
required: false,
default: () => []
} }
}, },
emits: ['close'], emits: ['close', 'click-save'],
data () { data () {
return { return {
conditions: this.item.conditions?.[0] ? JSON.parse(JSON.stringify(this.item.conditions)) : [{}] conditions: this.item.conditions?.[0] ? JSON.parse(JSON.stringify(this.item.conditions)) : [{}]
@@ -183,14 +193,14 @@ export default {
fields () { fields () {
if (this.item.submitter_uuid) { if (this.item.submitter_uuid) {
return this.template.fields.reduce((acc, f) => { return this.template.fields.reduce((acc, f) => {
if (f !== this.item && !this.excludeTypes.includes(f.type) && (!f.conditions?.length || !f.conditions.find((c) => c.field_uuid === this.item.uuid))) { if (f !== this.item && !this.excludeTypes.includes(f.type) && !this.excludeFieldUuids.includes(f.uuid) && (!f.conditions?.length || !f.conditions.find((c) => c.field_uuid === this.item.uuid))) {
acc.push(f) acc.push(f)
} }
return acc return acc
}, []) }, [])
} else { } else {
return this.template.fields return this.template.fields.filter((f) => !this.excludeFieldUuids.includes(f.uuid))
} }
} }
}, },
@@ -234,7 +244,12 @@ export default {
delete this.item.conditions delete this.item.conditions
} }
this.save() if (this.withClickSaveEvent) {
this.$emit('click-save')
} else {
this.save()
}
this.$emit('close') this.$emit('close')
} }
} }
@@ -6,18 +6,24 @@
<span <span
ref="contenteditable" ref="contenteditable"
dir="auto" dir="auto"
:contenteditable="editable" :contenteditable="editable && (!editableOnButton || isEditable)"
style="min-width: 2px" :data-placeholder="placeholder"
:class="[iconInline ? 'inline' : 'block', hideIcon ? 'focus:block' : '']" :data-empty="isEmpty"
class="peer outline-none" :style="{ minWidth }"
:class="[iconInline ? (isEmpty ? 'inline-block' : 'inline') : 'block', hideIcon ? 'focus:block' : '']"
class="peer relative inline-block outline-none before:pointer-events-none before:absolute before:left-0 before:top-0 before:select-none before:whitespace-pre before:text-neutral-400 before:content-[attr(data-placeholder)] before:opacity-0 data-[empty=true]:before:opacity-100"
@paste.prevent="onPaste" @paste.prevent="onPaste"
@keydown.enter.prevent="blurContenteditable" @keydown.enter.prevent="blurContenteditable"
@input="updateInputValue"
@cut="updateInputValue"
@focus="$emit('focus', $event)" @focus="$emit('focus', $event)"
@blur="onBlur" @blur="onBlur"
@click="editable && (!editableOnButton || isEditable) ? '' : $emit('click-contenteditable')"
> >
{{ value }} {{ value }}
</span> </span>
<span <span
v-if="withButton"
class="relative inline" class="relative inline"
:class="{ 'peer-focus:hidden': hideIcon, 'peer-focus:invisible': !hideIcon }" :class="{ 'peer-focus:hidden': hideIcon, 'peer-focus:invisible': !hideIcon }"
> >
@@ -28,7 +34,7 @@
:class="{ invisible: !editable, 'absolute top-1/2 -translate-y-1/2': !iconInline || floatIcon, 'inline align-bottom': iconInline, 'left-0': floatIcon }" :class="{ invisible: !editable, 'absolute top-1/2 -translate-y-1/2': !iconInline || floatIcon, 'inline align-bottom': iconInline, 'left-0': floatIcon }"
:width="iconWidth + 4" :width="iconWidth + 4"
:stroke-width="iconStrokeWidth" :stroke-width="iconStrokeWidth"
@click="[focusContenteditable(), selectOnEditClick && selectContent()]" @click="clickEdit"
/> />
</span> </span>
</div> </div>
@@ -49,6 +55,16 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
placeholder: {
type: String,
required: false,
default: ''
},
withButton: {
type: Boolean,
required: false,
default: true
},
iconInline: { iconInline: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -74,6 +90,16 @@ export default {
required: false, required: false,
default: false default: false
}, },
editableOnButton: {
type: Boolean,
required: false,
default: false
},
minWidth: {
type: String,
required: false,
default: '2px'
},
editable: { editable: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -85,21 +111,34 @@ export default {
default: 2 default: 2
} }
}, },
emits: ['update:model-value', 'focus', 'blur'], emits: ['update:model-value', 'focus', 'blur', 'click-contenteditable'],
data () { data () {
return { return {
isEditable: false,
inputValue: '',
value: '' value: ''
} }
}, },
computed: {
isEmpty () {
return !this.inputValue.replace(/\u200B/g, '').replace(/\u00A0/g, ' ').trim()
}
},
watch: { watch: {
modelValue: { modelValue: {
handler (value) { handler (value) {
this.value = value this.value = value || ''
}, },
immediate: true immediate: true
} }
}, },
mounted () {
this.updateInputValue()
},
methods: { methods: {
updateInputValue () {
this.inputValue = this.$refs.contenteditable?.textContent || ''
},
onPaste (e) { onPaste (e) {
const text = (e.clipboardData || window.clipboardData).getData('text/plain') const text = (e.clipboardData || window.clipboardData).getData('text/plain')
@@ -110,6 +149,20 @@ export default {
selection.getRangeAt(0).insertNode(document.createTextNode(text)) selection.getRangeAt(0).insertNode(document.createTextNode(text))
selection.collapseToEnd() selection.collapseToEnd()
} }
this.updateInputValue()
},
clickEdit (e) {
this.focusContenteditable()
if (this.selectOnEditClick) {
this.selectContent()
}
},
setText (text) {
this.$refs.contenteditable.innerText = text
this.updateInputValue()
}, },
selectContent () { selectContent () {
const el = this.$refs.contenteditable const el = this.$refs.contenteditable
@@ -129,10 +182,18 @@ export default {
this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue
this.$emit('update:model-value', this.value) this.$emit('update:model-value', this.value)
this.$emit('blur', e) this.$emit('blur', e)
this.isEditable = false
}, 1) }, 1)
}, },
focusContenteditable () { focusContenteditable () {
this.$refs.contenteditable.focus() this.isEditable = true
this.$nextTick(() => {
this.$refs.contenteditable.focus()
this.updateInputValue()
})
}, },
blurContenteditable () { blurContenteditable () {
this.$refs.contenteditable.blur() this.$refs.contenteditable.blur()
@@ -0,0 +1,546 @@
<template>
<div>
<div
v-if="!isShowFormulaModal && !isShowFontModal && !isShowConditionsModal && !isShowDescriptionModal"
ref="menu"
class="fixed z-50 p-1 bg-white shadow-lg rounded-lg border border-base-300 min-w-[170px] cursor-default"
:style="menuStyle"
@mousedown.stop
@pointerdown.stop
>
<label
v-if="showRequired"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
:checked="isRequired"
type="checkbox"
class="toggle toggle-xs"
@change="handleToggleRequired($event.target.checked)"
@click.stop
>
<span>{{ t('required') }}</span>
</label>
<label
v-if="showReadOnly"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
:checked="isReadOnly"
type="checkbox"
class="toggle toggle-xs"
@change="handleToggleReadOnly($event.target.checked)"
@click.stop
>
<span>{{ t('read_only') }}</span>
</label>
<hr
v-if="(showRequired || showReadOnly) && (showFont || showDescription || showCondition || showFormula)"
class="my-1 border-base-300"
>
<button
v-if="showFont && !isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openFontModal"
>
<IconTypography class="w-4 h-4" />
<span>{{ t('font') }}</span>
</button>
<button
v-if="showDescription"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openDescriptionModal"
>
<IconInfoCircle class="w-4 h-4" />
<span>{{ t('description') }}</span>
</button>
<button
v-if="showCondition && !isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openConditionModal"
>
<IconRouteAltLeft class="w-4 h-4" />
<span>{{ t('condition') }}</span>
</button>
<button
v-if="showFormula"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openFormulaModal"
>
<IconMathFunction class="w-4 h-4" />
<span>{{ t('formula') }}</span>
</button>
<hr
v-if="((showFont && !isMultiSelection) || showDescription || (showCondition && !isMultiSelection) || showFormula) && (showCopy || showDelete || showPaste)"
class="my-1 border-base-300"
>
<button
v-if="isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('align', 'left')"
>
<IconLayoutAlignLeft class="w-4 h-4" />
<span>{{ t('align_left') }}</span>
</button>
<button
v-if="isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('align', 'right')"
>
<IconLayoutAlignRight class="w-4 h-4" />
<span>{{ t('align_right') }}</span>
</button>
<button
v-if="isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('align', 'top')"
>
<IconLayoutAlignTop class="w-4 h-4" />
<span>{{ t('align_top') }}</span>
</button>
<button
v-if="isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('align', 'bottom')"
>
<IconLayoutAlignBottom class="w-4 h-4" />
<span>{{ t('align_bottom') }}</span>
</button>
<hr
v-if="isMultiSelection && (showFont || showCondition)"
class="my-1 border-base-300"
>
<button
v-if="showFont && isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openFontModal"
>
<IconTypography class="w-4 h-4" />
<span>{{ t('font') }}</span>
</button>
<button
v-if="showCondition && isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openConditionModal"
>
<IconRouteAltLeft class="w-4 h-4" />
<span>{{ t('condition') }}</span>
</button>
<hr
v-if="isMultiSelection"
class="my-1 border-base-300"
>
<button
v-if="showCopy"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm"
@click.stop="$emit('copy')"
>
<span class="flex items-center space-x-2">
<IconCopy class="w-4 h-4" />
<span>{{ t('copy') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘C' : 'Ctrl+C' }}</span>
</button>
<button
v-if="showDelete"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm text-red-600"
@click.stop="$emit('delete')"
>
<span class="flex items-center space-x-2">
<IconTrashX class="w-4 h-4" />
<span>{{ t('remove') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">Del</span>
</button>
<button
v-if="showPaste"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm"
:class="!hasClipboardData ? 'opacity-50 cursor-not-allowed' : 'hover:bg-base-100'"
:disabled="!hasClipboardData"
@click.stop="!hasClipboardData ? null : $emit('paste')"
>
<span class="flex items-center space-x-2">
<IconClipboard class="w-4 h-4" />
<span>{{ t('paste') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘V' : 'Ctrl+V' }}</span>
</button>
<button
v-if="showSelectFields"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm"
@click.stop="handleToggleSelectMode"
>
<span class="flex items-center space-x-2">
<IconClick
v-if="!isSelectModeRef.value"
class="w-4 h-4"
/>
<IconNewSection
v-else
class="w-4 h-4"
/>
<span>{{ isSelectModeRef.value ? t('draw_fields') : t('select_fields') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">Tab</span>
</button>
<hr
v-if="showAutodetectFields"
class="my-1 border-base-300"
>
<button
v-if="showAutodetectFields"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('autodetect-fields')"
>
<IconSparkles class="w-4 h-4" />
<span>{{ t('autodetect_fields') }}</span>
</button>
</div>
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:editable="editable"
:build-default-name="buildDefaultName"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="multiSelectField || field"
:area="contextMenu.area"
:editable="editable"
:build-default-name="buildDefaultName"
:with-click-save-event="isMultiSelection"
@click-save="handleSaveMultiSelectFontModal"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:item="multiSelectField || field"
:build-default-name="buildDefaultName"
:exclude-field-uuids="isMultiSelection ? selectedFields.map(f => f.uuid) : []"
:with-click-save-event="isMultiSelection"
@click-save="handleSaveMultiSelectConditionsModal"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:editable="editable"
:build-default-name="buildDefaultName"
@close="closeModal"
/>
</Teleport>
</div>
</template>
<script>
import { IconCopy, IconClipboard, IconTrashX, IconTypography, IconInfoCircle, IconRouteAltLeft, IconMathFunction, IconClick, IconNewSection, IconLayoutAlignLeft, IconLayoutAlignRight, IconLayoutAlignTop, IconLayoutAlignBottom, IconSparkles } from '@tabler/icons-vue'
import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import Field from './field'
import FieldType from './field_type.vue'
export default {
name: 'ContextMenu',
components: {
IconCopy,
IconClipboard,
IconTrashX,
IconTypography,
IconInfoCircle,
IconRouteAltLeft,
IconMathFunction,
IconClick,
IconNewSection,
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconLayoutAlignTop,
IconLayoutAlignBottom,
IconSparkles,
FormulaModal,
FontModal,
ConditionsModal,
DescriptionModal
},
inject: ['t', 'save', 'selectedAreasRef', 'isSelectModeRef'],
props: {
contextMenu: {
type: Object,
default: null,
required: true
},
field: {
type: Object,
default: null
},
editable: {
type: Boolean,
default: true
},
isMultiSelection: {
type: Boolean,
default: false
},
selectedAreas: {
type: Array,
default: () => []
},
template: {
type: Object,
default: null
},
withFieldsDetection: {
type: Boolean,
default: false
}
},
emits: ['copy', 'paste', 'delete', 'close', 'align', 'autodetect-fields'],
data () {
return {
isShowFormulaModal: false,
isShowFontModal: false,
isShowConditionsModal: false,
isShowDescriptionModal: false,
multiSelectField: null
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels,
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
selectedFields () {
if (!this.isMultiSelection) return []
return this.selectedAreasRef.value.map((area) => {
return this.template.fields.find((f) => f.areas?.includes(area))
}).filter(Boolean)
},
isMac () {
return (navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')
},
hasClipboardData () {
try {
const clipboard = localStorage.getItem('docuseal_clipboard')
if (clipboard) {
const data = JSON.parse(clipboard)
return Date.now() - data.timestamp < 3600000
}
return false
} catch {
return false
}
},
menuStyle () {
return {
left: this.contextMenu.x + 'px',
top: this.contextMenu.y + 'px'
}
},
showCopy () {
return !!this.contextMenu.area || this.isMultiSelection
},
showPaste () {
return !this.contextMenu.area && !this.isMultiSelection
},
showDelete () {
return !!this.contextMenu.area || this.isMultiSelection
},
showFont () {
if (this.isMultiSelection) return true
if (!this.field) return false
return ['text', 'number', 'date', 'select', 'heading'].includes(this.field.type)
},
showDescription () {
if (!this.field) return false
return !['stamp', 'heading', 'strikethrough'].includes(this.field.type)
},
showCondition () {
if (this.isMultiSelection) return true
if (!this.field) return false
return !['stamp', 'heading'].includes(this.field.type)
},
showFormula () {
if (!this.field) return false
return this.field.type === 'number'
},
showRequired () {
if (!this.field) return false
return !['phone', 'stamp', 'verification', 'strikethrough', 'heading'].includes(this.field.type)
},
showReadOnly () {
if (!this.field) return false
return ['text', 'number'].includes(this.field.type)
},
isRequired () {
return this.field?.required || false
},
isReadOnly () {
return this.field?.readonly || false
},
showSelectFields () {
return !this.contextMenu.area && !this.isMultiSelection
},
showAutodetectFields () {
return this.withFieldsDetection && this.editable && !this.contextMenu.area && !this.isMultiSelection
}
},
mounted () {
document.addEventListener('keydown', this.onKeyDown)
document.addEventListener('mousedown', this.handleClickOutside)
this.$nextTick(() => {
this.checkMenuPosition()
})
},
beforeUnmount () {
document.removeEventListener('keydown', this.onKeyDown)
document.removeEventListener('mousedown', this.handleClickOutside)
},
methods: {
buildDefaultName: Field.methods.buildDefaultName,
checkMenuPosition () {
if (this.$refs.menu) {
const rect = this.$refs.menu.getBoundingClientRect()
if (rect.bottom > window.innerHeight) {
this.contextMenu.y = this.contextMenu.y - rect.height
}
if (rect.right > window.innerWidth) {
this.contextMenu.x = this.contextMenu.x - rect.width
}
}
},
handleToggleRequired (value) {
if (this.field) {
this.field.required = value
this.save()
}
},
handleToggleReadOnly (value) {
if (this.field) {
this.field.readonly = value
this.save()
}
},
onKeyDown (event) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
this.$emit('close')
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v' && this.hasClipboardData) {
event.preventDefault()
event.stopPropagation()
this.$emit('paste')
}
},
handleClickOutside (event) {
if (this.$refs.menu && !this.$refs.menu.contains(event.target)) {
this.$emit('close')
}
},
openFontModal () {
if (this.isMultiSelection) {
this.multiSelectField = {
name: this.t('fields_selected').replace('{count}', this.selectedFields.length),
preferences: {}
}
const preferencesStrings = this.selectedFields.map((f) => JSON.stringify(f.preferences || {}))
if (preferencesStrings.every((s) => s === preferencesStrings[0])) {
this.multiSelectField.preferences = JSON.parse(preferencesStrings[0])
}
}
this.isShowFontModal = true
},
openDescriptionModal () {
this.isShowDescriptionModal = true
},
openConditionModal () {
if (this.isMultiSelection) {
this.multiSelectField = {
name: this.t('fields_selected').replace('{count}', this.selectedFields.length),
conditions: []
}
const conditionStrings = this.selectedFields.map((f) => JSON.stringify(f.conditions || []))
if (conditionStrings.every((s) => s === conditionStrings[0])) {
this.multiSelectField.conditions = JSON.parse(conditionStrings[0])
}
}
this.isShowConditionsModal = true
},
openFormulaModal () {
this.isShowFormulaModal = true
},
closeModal () {
this.isShowFormulaModal = false
this.isShowFontModal = false
this.isShowConditionsModal = false
this.isShowDescriptionModal = false
this.multiSelectField = null
this.$emit('close')
},
handleSaveMultiSelectFontModal () {
this.selectedFields.forEach((field) => {
field.preferences = { ...field.preferences, ...this.multiSelectField.preferences }
})
this.save()
this.closeModal()
},
handleSaveMultiSelectConditionsModal () {
this.selectedFields.forEach((field) => {
field.conditions = JSON.parse(JSON.stringify(this.multiSelectField.conditions))
})
this.save()
this.closeModal()
},
handleToggleSelectMode () {
this.isSelectModeRef.value = !this.isSelectModeRef.value
this.$emit('close')
}
}
}
</script>
@@ -0,0 +1,268 @@
<template>
<div
class="list-field group"
>
<div
class="border border-dashed border-base-300 hover:border-base-content/20 rounded relative group fields-list-item transition-colors"
:style="{ backgroundColor: backgroundColor }"
>
<div class="flex items-center justify-between relative group/contenteditable-container">
<div
v-if="!isNew"
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer"
@click="$emit('click', field)"
/>
<div
class="absolute top-0 bottom-0 left-0 flex items-center transition-all cursor-grab group-hover:bg-base-200/50"
@click="$emit('click', field)"
>
<IconDrag style="margin-left: 1px" />
</div>
<div class="flex items-center p-1 pl-6 space-x-1">
<FieldType
v-model="field.type"
:editable="false"
:button-width="20"
@click="$emit('click', field)"
/>
<Contenteditable
ref="name"
:model-value="field.name"
:placeholder="'Field Name'"
:icon-inline="true"
:icon-width="18"
:min-width="isNew ? '100px' : '2px'"
:icon-stroke-width="1.6"
:editable-on-button="!isNew"
:with-button="!isNew"
:class="{ 'cursor-pointer': !isNew }"
@click-contenteditable="$emit('click', field)"
@focus="onNameFocus"
@blur="onNameBlur"
/>
</div>
<div
class="flex items-center space-x-1"
>
<PaymentSettings
v-if="field.type === 'payment' && !isNew"
:field="field"
:with-condition="false"
:with-force-open="false"
@click-description="isShowDescriptionModal = true"
@click-formula="isShowFormulaModal = true"
/>
<span
v-else-if="!isNew"
class="dropdown dropdown-end field-settings-dropdown"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
tabindex="0"
:title="t('settings')"
class="cursor-pointer text-transparent group-hover:text-base-content"
>
<IconSettings
:width="18"
:stroke-width="1.6"
/>
</label>
<ul
v-if="renderDropdown"
tabindex="0"
class="mt-1.5 dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10"
:style="{ backgroundColor: dropdownBgColor }"
draggable="true"
@dragstart.prevent.stop
@click="closeDropdown"
>
<FieldSettings
:field="field"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:background-color="dropdownBgColor"
:with-areas="false"
:with-copy-to-all-pages="false"
:with-condition="false"
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@save="$emit('save')"
/>
</ul>
</span>
<button
v-if="isNew && !$refs.name"
class="relative text-base-content pr-1 field-save-button"
:title="t('save')"
@click="field.name ? $emit('save', field) : focusName()"
>
<IconCheck
:width="18"
:stroke-width="2"
/>
</button>
<button
class="relative group-hover:text-base-content pr-1 field-remove-button"
:class="isNew ? 'text-base-content' : 'text-transparent group-hover:text-base-content'"
:title="t('remove')"
@click="onRemoveClick"
>
<IconTrashX
:width="18"
:stroke-width="1.6"
/>
</button>
</div>
</div>
</div>
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@close="isShowFormulaModal = false"
/>
</Teleport>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="field"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@close="isShowFontModal = false"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:default-field="defaultField"
:build-default-name="buildDefaultName"
@close="isShowDescriptionModal = false"
/>
</Teleport>
</div>
</template>
<script>
import Contenteditable from './contenteditable'
import FieldType from './field_type'
import PaymentSettings from './payment_settings'
import FieldSettings from './field_settings'
import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import DescriptionModal from './description_modal'
import { IconTrashX, IconSettings, IconCheck } from '@tabler/icons-vue'
import IconDrag from './icon_drag'
export default {
name: 'CustomField',
components: {
Contenteditable,
IconSettings,
IconCheck,
FieldSettings,
PaymentSettings,
IconDrag,
FormulaModal,
FontModal,
DescriptionModal,
IconTrashX,
FieldType
},
inject: ['backgroundColor', 't'],
props: {
field: {
type: Object,
required: true
},
isNew: {
type: Boolean,
required: false,
default: false
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
withPrefillable: {
type: Boolean,
required: false,
default: false
}
},
emits: ['remove', 'save', 'click'],
data () {
return {
isShowFormulaModal: false,
isShowFontModal: false,
isShowDescriptionModal: false,
renderDropdown: false
}
},
computed: {
dropdownBgColor () {
return ['', null, 'transparent'].includes(this.backgroundColor) ? 'white' : this.backgroundColor
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
}
},
created () {
this.field.preferences ||= {}
},
mounted () {
if (this.isNew) {
this.focusName()
}
},
methods: {
buildDefaultName () {
return this.t('custom')
},
focusName () {
setTimeout(() => {
this.$refs.name.clickEdit()
}, 1)
},
onNameFocus (e) {
if (!this.field.name) {
setTimeout(() => {
this.$refs.name.$refs.contenteditable.innerText = ' '
}, 1)
}
},
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
},
onRemoveClick () {
if (this.isNew || window.confirm(this.t('are_you_sure_'))) {
this.$emit('remove', this.field)
}
},
onNameBlur (e) {
const text = this.$refs.name.$refs.contenteditable.innerText.trim()
if (text) {
this.field.name = text
} else {
this.$refs.name.setText(this.field.name)
}
if (this.field.name) {
this.$emit('save')
}
}
}
}
</script>
+22 -2
View File
@@ -19,11 +19,21 @@
:default-submitters="defaultSubmitters" :default-submitters="defaultSubmitters"
:draw-field="drawField" :draw-field="drawField"
:draw-field-type="drawFieldType" :draw-field-type="drawFieldType"
:draw-custom-field="drawCustomField"
:selected-submitter="selectedSubmitter" :selected-submitter="selectedSubmitter"
:total-pages="sortedPreviewImages.length" :total-pages="sortedPreviewImages.length"
:image="image" :image="image"
@drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })" :attachment-uuid="document.uuid"
:with-fields-detection="withFieldsDetection"
@drop-field="$emit('drop-field', { ...$event, attachment_uuid: document.uuid })"
@remove-area="$emit('remove-area', $event)" @remove-area="$emit('remove-area', $event)"
@copy-field="$emit('copy-field', $event)"
@paste-field="$emit('paste-field', { ...$event, attachment_uuid: document.uuid })"
@add-custom-field="$emit('add-custom-field', $event)"
@copy-selected-areas="$emit('copy-selected-areas')"
@delete-selected-areas="$emit('delete-selected-areas')"
@align-selected-areas="$emit('align-selected-areas', $event)"
@autodetect-fields="$emit('autodetect-fields', $event)"
@scroll-to="scrollToArea" @scroll-to="scrollToArea"
@draw="$emit('draw', { area: {...$event.area, attachment_uuid: document.uuid }, isTooSmall: $event.isTooSmall })" @draw="$emit('draw', { area: {...$event.area, attachment_uuid: document.uuid }, isTooSmall: $event.isTooSmall })"
/> />
@@ -107,6 +117,11 @@ export default {
required: false, required: false,
default: null default: null
}, },
drawCustomField: {
type: Object,
required: false,
default: null
},
baseUrl: { baseUrl: {
type: String, type: String,
required: false, required: false,
@@ -116,9 +131,14 @@ export default {
type: Boolean, type: Boolean,
required: false, required: false,
default: false default: false
},
withFieldsDetection: {
type: Boolean,
required: false,
default: false
} }
}, },
emits: ['draw', 'drop-field', 'remove-area'], emits: ['draw', 'drop-field', 'remove-area', 'paste-field', 'copy-field', 'copy-selected-areas', 'delete-selected-areas', 'align-selected-areas', 'autodetect-fields', 'add-custom-field'],
data () { data () {
return { return {
pageRefs: [] pageRefs: []
@@ -8,6 +8,13 @@
class="fixed z-20 pointer-events-none" class="fixed z-20 pointer-events-none"
:editable="false" :editable="false"
/> />
<CustomField
v-else-if="dragPlaceholder && isCustom && !isMask && field"
ref="dragPlaceholder"
:style="dragPlaceholderStyle"
:field="field"
class="fixed z-20 pointer-events-none opacity-90"
/>
<div <div
v-else-if="dragPlaceholder && (isDefault || isRequired) && !isMask && field" v-else-if="dragPlaceholder && (isDefault || isRequired) && !isMask && field"
ref="dragPlaceholder" ref="dragPlaceholder"
@@ -57,6 +64,7 @@
<script> <script>
import Field from './field' import Field from './field'
import CustomField from './custom_field'
import IconDrag from './icon_drag' import IconDrag from './icon_drag'
import FieldType from './field_type' import FieldType from './field_type'
@@ -64,6 +72,7 @@ export default {
name: 'DragPlaceholder', name: 'DragPlaceholder',
components: { components: {
Field, Field,
CustomField,
IconDrag IconDrag
}, },
inject: ['t', 'backgroundColor'], inject: ['t', 'backgroundColor'],
@@ -87,6 +96,11 @@ export default {
type: Boolean, type: Boolean,
required: false, required: false,
default: false default: false
},
isCustom: {
type: Boolean,
required: false,
default: false
} }
}, },
data () { data () {
+56 -13
View File
@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="list-field group mb-2" class="list-field group"
> >
<div <div
class="border border-base-300 rounded relative group fields-list-item" class="border border-base-300 rounded relative group fields-list-item"
@@ -18,7 +18,7 @@
:button-width="20" :button-width="20"
:menu-classes="'mt-1.5'" :menu-classes="'mt-1.5'"
:menu-style="{ backgroundColor: dropdownBgColor }" :menu-style="{ backgroundColor: dropdownBgColor }"
@update:model-value="[maybeUpdateOptions(), save()]" @update:model-value="[maybeUpdateOptions(), $emit('save')]"
@click="scrollToFirstArea" @click="scrollToFirstArea"
/> />
<Contenteditable <Contenteditable
@@ -92,9 +92,12 @@
<PaymentSettings <PaymentSettings
v-if="field.type === 'payment'" v-if="field.type === 'payment'"
:field="field" :field="field"
:with-custom-fields="withCustomFields"
@click-condition="isShowConditionsModal = true" @click-condition="isShowConditionsModal = true"
@click-description="isShowDescriptionModal = true" @click-description="isShowDescriptionModal = true"
@add-custom-field="$emit('add-custom-field', $event)"
@click-formula="isShowFormulaModal = true" @click-formula="isShowFormulaModal = true"
@save="$emit('save')"
/> />
<span <span
v-else v-else
@@ -128,12 +131,15 @@
:with-signature-id="withSignatureId" :with-signature-id="withSignatureId"
:with-prefillable="withPrefillable" :with-prefillable="withPrefillable"
:background-color="dropdownBgColor" :background-color="dropdownBgColor"
:with-custom-fields="withCustomFields"
@click-formula="isShowFormulaModal = true" @click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true" @click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true" @click-description="isShowDescriptionModal = true"
@click-condition="isShowConditionsModal = true" @click-condition="isShowConditionsModal = true"
@set-draw="$emit('set-draw', $event)" @set-draw="$emit('set-draw', $event)"
@add-custom-field="$emit('add-custom-field', $event)"
@remove-area="removeArea" @remove-area="removeArea"
@save="$emit('save')"
@scroll-to="$emit('scroll-to', $event)" @scroll-to="$emit('scroll-to', $event)"
/> />
</ul> </ul>
@@ -154,8 +160,10 @@
v-if="field.options && withOptions && (isExpandOptions || field.options.length < 5)" v-if="field.options && withOptions && (isExpandOptions || field.options.length < 5)"
ref="options" ref="options"
class="border-t border-base-300 mx-2 pt-2 space-y-1.5" class="border-t border-base-300 mx-2 pt-2 space-y-1.5"
draggable="true"
@dragover="onOptionDragover" @dragover="onOptionDragover"
@drop="reorderOptions" @drop="reorderOptions"
@dragstart.prevent.stop
> >
<div <div
v-for="(option, index) in field.options" v-for="(option, index) in field.options"
@@ -184,7 +192,8 @@
required required
:placeholder="`${t('option')} ${index + 1}`" :placeholder="`${t('option')} ${index + 1}`"
@keydown.enter="option.value ? addOptionAt(index + 1) : null" @keydown.enter="option.value ? addOptionAt(index + 1) : null"
@blur="save" @blur="$emit('save')"
@paste="onOptionPaste($event, index)"
> >
<button <button
:title="t('draw')" :title="t('draw')"
@@ -208,7 +217,8 @@
dir="auto" dir="auto"
@keydown.enter="option.value ? addOptionAt(index + 1) : null" @keydown.enter="option.value ? addOptionAt(index + 1) : null"
@focus="maybeFocusOnOptionArea(option)" @focus="maybeFocusOnOptionArea(option)"
@blur="save" @blur="$emit('save')"
@paste="onOptionPaste($event, index)"
> >
<button <button
v-if="editable && !defaultField" v-if="editable && !defaultField"
@@ -330,7 +340,7 @@ export default {
IconMathFunction, IconMathFunction,
FieldType FieldType
}, },
inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef', 't', 'locale'], inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale'],
props: { props: {
field: { field: {
type: Object, type: Object,
@@ -341,6 +351,11 @@ export default {
required: false, required: false,
default: null default: null
}, },
withCustomFields: {
type: Boolean,
required: false,
default: false
},
withPrefillable: { withPrefillable: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -362,12 +377,11 @@ export default {
default: true default: true
} }
}, },
emits: ['set-draw', 'remove', 'scroll-to'], emits: ['set-draw', 'remove', 'scroll-to', 'save', 'add-custom-field'],
data () { data () {
return { return {
isExpandOptions: false, isExpandOptions: false,
isNameFocus: false, isNameFocus: false,
showPaymentModal: false,
isShowFormulaModal: false, isShowFormulaModal: false,
isShowFontModal: false, isShowFontModal: false,
isShowConditionsModal: false, isShowConditionsModal: false,
@@ -416,7 +430,7 @@ export default {
removeArea (area) { removeArea (area) {
this.field.areas.splice(this.field.areas.indexOf(area), 1) this.field.areas.splice(this.field.areas.indexOf(area), 1)
this.save() this.$emit('save')
}, },
buildDefaultName (field, fields) { buildDefaultName (field, fields) {
if (field.type === 'payment' && field.preferences?.price && !field.preferences?.formula) { if (field.type === 'payment' && field.preferences?.price && !field.preferences?.formula) {
@@ -451,7 +465,7 @@ export default {
const area = this.field.areas.find((a) => a.option_uuid === option.uuid) const area = this.field.areas.find((a) => a.option_uuid === option.uuid)
if (area) { if (area) {
this.selectedAreaRef.value = area this.selectedAreasRef.value = [area]
} }
}, },
scrollToFirstArea () { scrollToFirstArea () {
@@ -460,6 +474,35 @@ export default {
closeDropdown () { closeDropdown () {
this.$el.getRootNode().activeElement.blur() this.$el.getRootNode().activeElement.blur()
}, },
onOptionPaste (e, index) {
const text = e.clipboardData.getData('text')
if (text.includes('\n')) {
e.preventDefault()
this.isExpandOptions = true
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l)
if (lines.length > 0) {
const currentOption = this.field.options[index]
currentOption.value = (currentOption.value + lines[0]).trim()
const newOptions = lines.slice(1).map((line) => ({ value: line, uuid: v4() }))
this.field.options.splice(index + 1, 0, ...newOptions)
this.$nextTick(() => {
const inputs = this.$refs.options.querySelectorAll('input')
inputs[index + newOptions.length]?.focus()
})
this.$emit('save')
}
}
},
addOptionAt (index) { addOptionAt (index) {
this.isExpandOptions = true this.isExpandOptions = true
@@ -473,7 +516,7 @@ export default {
inputs[insertAt]?.focus() inputs[insertAt]?.focus()
}) })
this.save() this.$emit('save')
}, },
removeOption (option) { removeOption (option) {
this.field.options.splice(this.field.options.indexOf(option), 1) this.field.options.splice(this.field.options.indexOf(option), 1)
@@ -484,7 +527,7 @@ export default {
this.field.areas.splice(this.field.areas.findIndex((a) => a.option_uuid === option.uuid), 1) this.field.areas.splice(this.field.areas.findIndex((a) => a.option_uuid === option.uuid), 1)
} }
this.save() this.$emit('save')
}, },
maybeUpdateOptions () { maybeUpdateOptions () {
delete this.field.default_value delete this.field.default_value
@@ -526,7 +569,7 @@ export default {
this.isNameFocus = false this.isNameFocus = false
this.save() this.$emit('save')
}, },
onOptionDragstart (event, option) { onOptionDragstart (event, option) {
this.optionDragRef = option this.optionDragRef = option
@@ -587,7 +630,7 @@ export default {
if (newOrder.length === this.field.options.length) { if (newOrder.length === this.field.options.length) {
this.field.options.splice(0, this.field.options.length, ...newOrder) this.field.options.splice(0, this.field.options.length, ...newOrder)
this.save() this.$emit('save')
} }
this.optionDragRef = null this.optionDragRef = null
@@ -7,7 +7,7 @@
<select <select
:placeholder="t('method')" :placeholder="t('method')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent" class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[field.preferences ||= {}, field.preferences.method = $event.target.value, save()]" @change="[field.preferences ||= {}, field.preferences.method = $event.target.value, $emit('save')]"
> >
<option <option
v-for="method in ['QeS', 'AeS']" v-for="method in ['QeS', 'AeS']"
@@ -33,7 +33,7 @@
> >
<select <select
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal bg-transparent" class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal bg-transparent"
@change="[field.preferences ||= {}, field.preferences.align = $event.target.value, save()]" @change="[field.preferences ||= {}, field.preferences.align = $event.target.value, $emit('save')]"
> >
<option <option
v-for="value in ['left', 'right', field.type === 'cells' ? null : 'center'].filter(Boolean)" v-for="value in ['left', 'right', field.type === 'cells' ? null : 'center'].filter(Boolean)"
@@ -61,7 +61,7 @@
:placeholder="t('default_value')" :placeholder="t('default_value')"
dir="auto" dir="auto"
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal bg-transparent" class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal bg-transparent"
@change="[field.default_value = $event.target.value, !field.default_value && delete field.default_value, save()]" @change="[field.default_value = $event.target.value, !field.default_value && delete field.default_value, $emit('save')]"
> >
<option <option
value="" value=""
@@ -97,7 +97,7 @@
dir="auto" dir="auto"
:type="field.type" :type="field.type"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent" class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
@blur="save" @blur="$emit('save')"
> >
<label <label
v-if="field.default_value" v-if="field.default_value"
@@ -159,7 +159,7 @@
:value="lengthValidation.min" :value="lengthValidation.min"
class="input input-bordered w-full input-xs h-7 !outline-0 bg-transparent" class="input input-bordered w-full input-xs h-7 !outline-0 bg-transparent"
@input="field.validation.pattern = `.{${$event.target.value || 0},${lengthValidation.max || ''}}`" @input="field.validation.pattern = `.{${$event.target.value || 0},${lengthValidation.max || ''}}`"
@blur="save" @blur="$emit('save')"
> >
<label <label
v-if="lengthValidation.min" v-if="lengthValidation.min"
@@ -178,7 +178,7 @@
class="input input-bordered w-full input-xs h-7 !outline-0 bg-transparent" class="input input-bordered w-full input-xs h-7 !outline-0 bg-transparent"
:value="lengthValidation.max" :value="lengthValidation.max"
@input="field.validation.pattern = `.{${lengthValidation.min},${$event.target.value || ''}}`" @input="field.validation.pattern = `.{${lengthValidation.min},${$event.target.value || ''}}`"
@blur="save" @blur="$emit('save')"
> >
<label <label
v-if="lengthValidation.max" v-if="lengthValidation.max"
@@ -203,7 +203,7 @@
:value="field.validation?.min" :value="field.validation?.min"
class="input input-bordered w-full input-xs h-7 !outline-0 bg-transparent" class="input input-bordered w-full input-xs h-7 !outline-0 bg-transparent"
@input="[field.validation ||= {}, $event.target.value ? field.validation.min = $event.target.value : delete field.validation.min]" @input="[field.validation ||= {}, $event.target.value ? field.validation.min = $event.target.value : delete field.validation.min]"
@blur="save" @blur="$emit('save')"
> >
<label <label
v-if="field.validation?.min" v-if="field.validation?.min"
@@ -222,7 +222,7 @@
class="input input-bordered w-full input-xs h-7 !outline-0 bg-transparent" class="input input-bordered w-full input-xs h-7 !outline-0 bg-transparent"
:value="field.validation?.max" :value="field.validation?.max"
@input="[field.validation ||= {}, $event.target.value ? field.validation.max = $event.target.value : delete field.validation.max]" @input="[field.validation ||= {}, $event.target.value ? field.validation.max = $event.target.value : delete field.validation.max]"
@blur="save" @blur="$emit('save')"
> >
<label <label
v-if="field.validation?.max" v-if="field.validation?.max"
@@ -242,7 +242,7 @@
<select <select
:placeholder="t('format')" :placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent" class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[field.preferences ||= {}, field.preferences.format = $event.target.value, save()]" @change="[field.preferences ||= {}, field.preferences.format = $event.target.value, $emit('save')]"
> >
<option <option
v-for="format in numberFormats" v-for="format in numberFormats"
@@ -272,7 +272,7 @@
:placeholder="t('regexp_validation')" :placeholder="t('regexp_validation')"
dir="auto" dir="auto"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent" class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
@blur="save" @blur="$emit('save')"
> >
<label <label
v-if="field.validation.pattern" v-if="field.validation.pattern"
@@ -293,7 +293,7 @@
:placeholder="t('error_message')" :placeholder="t('error_message')"
dir="auto" dir="auto"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent" class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
@blur="save" @blur="$emit('save')"
> >
<label <label
v-if="field.validation.message" v-if="field.validation.message"
@@ -313,7 +313,7 @@
v-model="field.preferences.format" v-model="field.preferences.format"
:placeholder="t('format')" :placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent" class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="save" @change="$emit('save')"
> >
<option <option
v-for="format in dateFormats" v-for="format in dateFormats"
@@ -339,7 +339,7 @@
<select <select
:placeholder="t('format')" :placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent" class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[field.preferences.format = $event.target.value, save()]" @change="[field.preferences.format = $event.target.value, $emit('save')]"
> >
<option <option
value="any" value="any"
@@ -374,7 +374,7 @@
type="checkbox" type="checkbox"
:disabled="!editable || (defaultField && [true, false].includes(defaultField.required))" :disabled="!editable || (defaultField && [true, false].includes(defaultField.required))"
class="toggle toggle-xs" class="toggle toggle-xs"
@change="[field.preferences ||= {}, field.preferences.with_signature_id = $event.target.checked, save()]" @change="[field.preferences ||= {}, field.preferences.with_signature_id = $event.target.checked, $emit('save')]"
> >
<span class="label-text">{{ t('signature_id') }}</span> <span class="label-text">{{ t('signature_id') }}</span>
</label> </label>
@@ -389,7 +389,7 @@
type="checkbox" type="checkbox"
:disabled="!editable || (defaultField && [true, false].includes(defaultField.required))" :disabled="!editable || (defaultField && [true, false].includes(defaultField.required))"
class="toggle toggle-xs" class="toggle toggle-xs"
@update:model-value="save" @update:model-value="$emit('save')"
> >
<span class="label-text">{{ t('required') }}</span> <span class="label-text">{{ t('required') }}</span>
</label> </label>
@@ -403,7 +403,7 @@
:checked="field.preferences?.with_logo != false" :checked="field.preferences?.with_logo != false"
type="checkbox" type="checkbox"
class="toggle toggle-xs" class="toggle toggle-xs"
@change="[field.preferences ||= {}, field.preferences.with_logo = field.preferences.with_logo == false, save()]" @change="[field.preferences ||= {}, field.preferences.with_logo = field.preferences.with_logo == false, $emit('save')]"
> >
<span class="label-text">{{ t('with_logo') }}</span> <span class="label-text">{{ t('with_logo') }}</span>
</label> </label>
@@ -417,7 +417,7 @@
v-model="field.default_value" v-model="field.default_value"
type="checkbox" type="checkbox"
class="toggle toggle-xs" class="toggle toggle-xs"
@update:model-value="[field.default_value = $event, field.readonly = $event, save()]" @update:model-value="[field.default_value = $event, field.readonly = $event, $emit('save')]"
> >
<span class="label-text">{{ t('checked') }}</span> <span class="label-text">{{ t('checked') }}</span>
</label> </label>
@@ -431,7 +431,7 @@
v-model="field.readonly" v-model="field.readonly"
type="checkbox" type="checkbox"
class="toggle toggle-xs" class="toggle toggle-xs"
@update:model-value="[field.default_value = $event ? '{{date}}' : null, field.readonly = $event, save()]" @update:model-value="[field.default_value = $event ? '{{date}}' : null, field.readonly = $event, $emit('save')]"
> >
<span class="label-text">{{ t('set_signing_date') }}</span> <span class="label-text">{{ t('set_signing_date') }}</span>
</label> </label>
@@ -446,7 +446,7 @@
type="checkbox" type="checkbox"
class="toggle toggle-xs" class="toggle toggle-xs"
:disabled="!editable || (defaultField && [true, false].includes(defaultField.readonly))" :disabled="!editable || (defaultField && [true, false].includes(defaultField.readonly))"
@update:model-value="save" @update:model-value="$emit('save')"
> >
<span class="label-text">{{ t('read_only') }}</span> <span class="label-text">{{ t('read_only') }}</span>
</label> </label>
@@ -461,7 +461,7 @@
type="checkbox" type="checkbox"
:disabled="!editable || (defaultField && [true, false].includes(defaultField.prefillable))" :disabled="!editable || (defaultField && [true, false].includes(defaultField.prefillable))"
class="toggle toggle-xs" class="toggle toggle-xs"
@update:model-value="save" @update:model-value="$emit('save')"
> >
<span class="label-text">{{ t('prefillable') }}</span> <span class="label-text">{{ t('prefillable') }}</span>
</label> </label>
@@ -499,7 +499,7 @@
</label> </label>
</li> </li>
<li <li
v-if="field.type != 'stamp' && field.type != 'heading'" v-if="withCondition && field.type != 'stamp' && field.type != 'heading'"
> >
<label <label
class="label-text cursor-pointer text-center w-full flex items-center" class="label-text cursor-pointer text-center w-full flex items-center"
@@ -526,7 +526,10 @@
</span> </span>
</label> </label>
</li> </li>
<hr class="pb-0.5 mt-0.5"> <hr
v-if="withCopyToAllPages || withAreas || withCustomFields"
class="pb-0.5 mt-0.5"
>
<template v-if="withAreas"> <template v-if="withAreas">
<li <li
v-for="(area, index) in sortedAreas" v-for="(area, index) in sortedAreas"
@@ -564,7 +567,7 @@
</a> </a>
</li> </li>
</template> </template>
<li v-if="field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells', 'stamp'].includes(field.type)"> <li v-if="withCopyToAllPages && field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells', 'stamp'].includes(field.type)">
<a <a
href="#" href="#"
class="text-sm py-1 px-2" class="text-sm py-1 px-2"
@@ -577,10 +580,23 @@
{{ t('copy_to_all_pages') }} {{ t('copy_to_all_pages') }}
</a> </a>
</li> </li>
<li v-if="withCustomFields">
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('add-custom-field', field)"
>
<IconForms
:width="20"
:stroke-width="1.6"
/>
{{ t('save_as_custom_field') }}
</a>
</li>
</template> </template>
<script> <script>
import { IconRouteAltLeft, IconTypography, IconShape, IconX, IconMathFunction, IconNewSection, IconInfoCircle, IconCopy } from '@tabler/icons-vue' import { IconRouteAltLeft, IconTypography, IconShape, IconX, IconMathFunction, IconNewSection, IconInfoCircle, IconCopy, IconForms } from '@tabler/icons-vue'
export default { export default {
name: 'FieldSettings', name: 'FieldSettings',
@@ -589,17 +605,33 @@ export default {
IconInfoCircle, IconInfoCircle,
IconMathFunction, IconMathFunction,
IconRouteAltLeft, IconRouteAltLeft,
IconForms,
IconCopy, IconCopy,
IconNewSection, IconNewSection,
IconTypography, IconTypography,
IconX IconX
}, },
inject: ['template', 'save', 't'], inject: ['template', 't'],
props: { props: {
field: { field: {
type: Object, type: Object,
required: true required: true
}, },
withCondition: {
type: Boolean,
required: false,
default: true
},
withCustomFields: {
type: Boolean,
required: false,
default: false
},
withCopyToAllPages: {
type: Boolean,
required: false,
default: true
},
withSignatureId: { withSignatureId: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -636,7 +668,7 @@ export default {
default: true default: true
} }
}, },
emits: ['set-draw', 'scroll-to', 'click-formula', 'click-description', 'click-condition', 'click-font', 'remove-area'], emits: ['set-draw', 'scroll-to', 'click-formula', 'click-description', 'click-condition', 'click-font', 'remove-area', 'save', 'add-custom-field'],
data () { data () {
return { return {
selectedValidation: '' selectedValidation: ''
@@ -730,7 +762,7 @@ export default {
delete this.field.validation delete this.field.validation
} }
this.save() this.$emit('save')
}, },
copyToAllPages (field) { copyToAllPages (field) {
const areaString = JSON.stringify(field.areas[0]) const areaString = JSON.stringify(field.areas[0])
@@ -747,7 +779,7 @@ export default {
this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1]) this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1])
this.save() this.$emit('save')
}, },
formatNumber (number, format) { formatNumber (number, format) {
if (format === 'comma') { if (format === 'comma') {
@@ -160,7 +160,7 @@ export default {
payment: IconCreditCard, payment: IconCreditCard,
phone: IconPhoneCheck, phone: IconPhoneCheck,
verification: IconId, verification: IconId,
kba: IconUserScan, kba: IconUserScan
} }
}, },
skipTypes () { skipTypes () {
+228 -5
View File
@@ -15,7 +15,8 @@
</div> </div>
<div <div
ref="fields" ref="fields"
class="fields mb-1 mt-2" class="fields mt-2"
:class="{ 'mb-1': !withCustomFields || !customFields.length }"
@dragover.prevent="onFieldDragover" @dragover.prevent="onFieldDragover"
@drop="fieldsDragFieldRef.value ? reorderFields() : null" @drop="fieldsDragFieldRef.value ? reorderFields() : null"
> >
@@ -30,7 +31,11 @@
:with-prefillable="withPrefillable" :with-prefillable="withPrefillable"
:default-field="defaultFieldsIndex[field.name]" :default-field="defaultFieldsIndex[field.name]"
:draggable="editable" :draggable="editable"
:with-custom-fields="withCustomFields"
class="mb-1.5"
@add-custom-field="addCustomField"
@dragstart="[fieldsDragFieldRef.value = field, removeDragOverlay($event), setDragPlaceholder($event)]" @dragstart="[fieldsDragFieldRef.value = field, removeDragOverlay($event), setDragPlaceholder($event)]"
@save="save"
@dragend="[fieldsDragFieldRef.value = null, $emit('set-drag-placeholder', null)]" @dragend="[fieldsDragFieldRef.value = null, $emit('set-drag-placeholder', null)]"
@remove="removeField" @remove="removeField"
@scroll-to="$emit('scroll-to-area', $event)" @scroll-to="$emit('scroll-to-area', $event)"
@@ -105,7 +110,92 @@
</div> </div>
</div> </div>
<div <div
v-if="editable && !onlyDefinedFields" v-if="editable && withCustomFields && (customFields.length || newCustomField)"
class="tabs w-full mb-1.5"
>
<a
class="tab tab-bordered w-1/2 border-base-300"
:class="{ 'tab-active': !showCustomTab }"
:style="{ '--tab-border': showCustomTab ? '0px' : '0.5px' }"
@click="setFieldsTab('default')"
>{{ t('default') }}</a>
<a
class="tab tab-bordered w-1/2 border-base-300"
:class="{ 'tab-active': showCustomTab }"
:style="{ '--tab-border': showCustomTab ? '0.5px' : '0px' }"
@click="setFieldsTab('custom')"
>{{ t('custom') }}</a>
</div>
<div
v-if="showCustomTab && editable"
ref="customFields"
class="custom-fields"
@dragover.prevent="onCustomFieldDragover"
@drop="customDragFieldRef.value ? reorderCustomFields() : null"
>
<template v-if="isShowCustomFieldSearch">
<input
v-model="customFieldsSearch"
:placeholder="t('search_field')"
class="input input-ghost input-xs px-0 text-base mb-1 !outline-0 !rounded bg-transparent w-full"
>
<hr class="mb-2">
</template>
<div
ref="customFieldsList"
class="overflow-auto relative"
:style="{
maxHeight: isShowCustomFieldSearch ? '320px' : '',
minHeight: '320px'
}"
>
<div
v-if="!filteredCustomFields.length && customFieldsSearch"
class="top-0 bottom-0 text-center absolute flex items-center justify-center w-full flex-col"
>
<div>
{{ t('field_not_found') }}
</div>
<a
href="#"
class="link"
@click.prevent="customFieldsSearch = ''"
>
{{ t('clear') }}
</a>
</div>
<CustomField
v-if="newCustomField"
:key="newCustomField.uuid"
ref="newCustomField"
:data-uuid="newCustomField.uuid"
:is-new="true"
:field="newCustomField"
:draggable="false"
class="mb-1.5"
@save="onNewCustomFieldSave"
@remove="newCustomField = null"
/>
<CustomField
v-for="field in filteredCustomFields"
:key="field.uuid"
:data-uuid="field.uuid"
:field="field"
:draggable="true"
class="mb-1.5"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
@save="saveCustomFields"
@click="$emit('set-draw-custom-field', field)"
@dragstart="[customDragFieldRef.value = field, removeDragOverlay($event), setDragPlaceholder($event)]"
@dragend="[customDragFieldRef.value = null, $emit('set-drag-placeholder', null)]"
@remove="removeCustomField"
@set-draw="$emit('set-draw', $event)"
/>
</div>
</div>
<div
v-if="editable && !onlyDefinedFields && (!showCustomTab || (!customFields.length && !newCustomField))"
id="field-types-grid" id="field-types-grid"
class="grid grid-cols-3 gap-1 pb-2 fields-grid" class="grid grid-cols-3 gap-1 pb-2 fields-grid"
> >
@@ -266,15 +356,18 @@
<script> <script>
import Field from './field' import Field from './field'
import CustomField from './custom_field'
import FieldType from './field_type' import FieldType from './field_type'
import FieldSubmitter from './field_submitter' import FieldSubmitter from './field_submitter'
import { IconLock, IconCirclePlus, IconInnerShadowTop, IconSparkles } from '@tabler/icons-vue' import { IconLock, IconCirclePlus, IconInnerShadowTop, IconSparkles } from '@tabler/icons-vue'
import IconDrag from './icon_drag' import IconDrag from './icon_drag'
import { v4 } from 'uuid'
export default { export default {
name: 'TemplateFields', name: 'TemplateFields',
components: { components: {
Field, Field,
CustomField,
FieldType, FieldType,
IconCirclePlus, IconCirclePlus,
IconSparkles, IconSparkles,
@@ -283,12 +376,22 @@ export default {
IconDrag, IconDrag,
IconLock IconLock
}, },
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 't', 'fieldsDragFieldRef', 'baseFetch'], inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'baseFetch', 'selectedAreasRef'],
props: { props: {
fields: { fields: {
type: Array, type: Array,
required: true required: true
}, },
customFields: {
type: Array,
required: false,
default: () => []
},
withCustomFields: {
type: Boolean,
required: false,
default: false
},
withFieldsSearch: { withFieldsSearch: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -372,12 +475,15 @@ export default {
default: false default: false
} }
}, },
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter'], emits: ['add-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter'],
data () { data () {
return { return {
fieldPagesLoaded: null, fieldPagesLoaded: null,
analyzingProgress: 0, analyzingProgress: 0,
defaultFieldsSearch: '' newCustomField: null,
showCustomTab: false,
defaultFieldsSearch: '',
customFieldsSearch: ''
} }
}, },
computed: { computed: {
@@ -430,6 +536,23 @@ export default {
} else { } else {
return this.submitterDefaultFields return this.submitterDefaultFields
} }
},
isShowCustomFieldSearch () {
return this.customFields.length > 8
},
filteredCustomFields () {
if (this.customFieldsSearch) {
return this.customFields.filter((f) => f.name.toLowerCase().includes(this.customFieldsSearch.toLowerCase()))
} else {
return this.customFields
}
}
},
mounted () {
try {
this.showCustomTab = localStorage.getItem('docuseal_builder_tab') === 'custom'
} catch (e) {
console.error(e)
} }
}, },
methods: { methods: {
@@ -440,6 +563,61 @@ export default {
this.$emit('set-drag', field) this.$emit('set-drag', field)
}, },
onNewCustomFieldSave () {
if (this.newCustomField.name) {
this.customFields.unshift(this.newCustomField)
this.newCustomField = null
this.saveCustomFields()
} else {
this.newCustomField = null
}
},
addCustomField (field) {
const customField = JSON.parse(JSON.stringify(field))
customField.uuid = v4()
delete customField.submitter_uuid
delete customField.prefillable
delete customField.conditions
customField.areas?.forEach((area) => {
delete area.attachment_uuid
delete area.page
})
if (customField.name) {
this.customFields.unshift(customField)
this.saveCustomFields()
} else {
this.newCustomField = customField
}
this.setFieldsTab('custom')
},
setFieldsTab (type) {
try {
localStorage.setItem('docuseal_builder_tab', type)
} catch (e) {
console.error(e)
}
this.showCustomTab = type === 'custom'
},
saveCustomFields () {
return this.baseFetch('/account_custom_fields', {
method: 'POST',
body: JSON.stringify({
value: this.customFields
}),
headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => {
const fields = await resp.json()
this.customFields.splice(0, this.customFields.length, ...fields)
})
},
detectFields () { detectFields () {
const fields = [] const fields = []
@@ -610,9 +788,54 @@ export default {
}) })
}) })
field.areas?.forEach((area) => {
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(area), 1)
})
if (save) { if (save) {
this.save() this.save()
} }
},
removeCustomField (field) {
this.customFields.splice(this.customFields.indexOf(field), 1)
if (!this.customFields.length) {
this.setFieldsTab('default')
}
this.saveCustomFields()
},
onCustomFieldDragover (e) {
if (this.customDragFieldRef.value && this.customFields.includes(this.customDragFieldRef.value)) {
const container = this.$refs.customFieldsList
const targetField = e.target.closest('[data-uuid]')
const dragField = container.querySelector(`[data-uuid="${this.customDragFieldRef.value.uuid}"]`)
if (dragField && targetField && targetField !== dragField) {
const fields = Array.from(container.children)
const currentIndex = fields.indexOf(dragField)
const targetIndex = fields.indexOf(targetField)
if (currentIndex < targetIndex) {
targetField.after(dragField)
} else {
targetField.before(dragField)
}
}
}
},
reorderCustomFields () {
if (!this.customFields.includes(this.customDragFieldRef.value)) {
return
}
const reorderedFields = Array.from(this.$refs.customFieldsList.children).map((el) => {
return this.customFields.find((f) => f.uuid === el.dataset.uuid)
}).filter(Boolean)
this.customFields.splice(0, this.customFields.length, ...reorderedFields)
this.saveCustomFields()
} }
} }
} }
+11 -2
View File
@@ -212,12 +212,17 @@ export default {
required: false, required: false,
default: true default: true
}, },
withClickSaveEvent: {
type: Boolean,
required: false,
default: false
},
buildDefaultName: { buildDefaultName: {
type: Function, type: Function,
required: true required: true
} }
}, },
emits: ['close'], emits: ['close', 'click-save'],
data () { data () {
return { return {
preferences: {} preferences: {}
@@ -322,7 +327,11 @@ export default {
Object.assign(this.field.preferences, this.preferences) Object.assign(this.field.preferences, this.preferences)
this.save() if (this.withClickSaveEvent) {
this.$emit('click-save')
} else {
this.save()
}
this.$emit('close') this.$emit('close')
} }
+99 -8
View File
@@ -1,10 +1,12 @@
const en = { const en = {
default: 'Default',
save_as_custom_field: 'Save as Custom Field',
kba: 'KBA', kba: 'KBA',
analyzing_: 'Analyzing...', analyzing_: 'Analyzing...',
download: 'Download', download: 'Download',
downloading_: 'Downloading...', downloading_: 'Downloading...',
view: 'View', view: 'View',
autodetect_fields: 'Autodetect fields', autodetect_fields: 'Autodetect Fields',
payment_link: 'Payment link', payment_link: 'Payment link',
strikeout: 'Strikeout', strikeout: 'Strikeout',
draw_strikethrough_the_document: 'Draw strikethrough the document', draw_strikethrough_the_document: 'Draw strikethrough the document',
@@ -185,10 +187,23 @@ const en = {
start_tour: 'Start Tour', start_tour: 'Start Tour',
or_add_from: 'Or add from', or_add_from: 'Or add from',
sync: 'Sync', sync: 'Sync',
syncing: 'Syncing...' syncing: 'Syncing...',
copy: 'Copy',
paste: 'Paste',
select_fields: 'Select Fields',
draw_fields: 'Draw Fields',
align_left: 'Align Left',
align_right: 'Align Right',
align_top: 'Align Top',
align_bottom: 'Align Bottom',
fields_selected: '{count} Fields Selected',
field_added: '{count} Field Added',
fields_added: '{count} Fields Added'
} }
const es = { const es = {
default: 'Predeterminado',
save_as_custom_field: 'Guardar como personalizado',
kba: 'KBA', kba: 'KBA',
autodetect_fields: 'Autodetectar campos', autodetect_fields: 'Autodetectar campos',
analyzing_: 'Analizando...', analyzing_: 'Analizando...',
@@ -375,10 +390,23 @@ const es = {
start_tour: 'Iniciar guía', start_tour: 'Iniciar guía',
or_add_from: 'O agregar desde', or_add_from: 'O agregar desde',
sync: 'Sincronizar', sync: 'Sincronizar',
syncing: 'Sincronizando...' syncing: 'Sincronizando...',
copy: 'Copiar',
paste: 'Pegar',
select_fields: 'Seleccionar Campos',
draw_fields: 'Dibujar Campos',
align_left: 'Alinear a la izquierda',
align_right: 'Alinear a la derecha',
align_top: 'Alinear arriba',
align_bottom: 'Alinear abajo',
fields_selected: '{count} Campos Seleccionados',
field_added: '{count} Campo Añadido',
fields_added: '{count} Campos Añadidos'
} }
const it = { const it = {
default: 'Predefinito',
save_as_custom_field: 'Salva come personalizzato',
kba: 'KBA', kba: 'KBA',
autodetect_fields: 'Rileva campi', autodetect_fields: 'Rileva campi',
analyzing_: 'Analisi...', analyzing_: 'Analisi...',
@@ -565,10 +593,23 @@ const it = {
start_tour: 'Inizia il tour', start_tour: 'Inizia il tour',
or_add_from: 'O aggiungi da', or_add_from: 'O aggiungi da',
sync: 'Sincronizza', sync: 'Sincronizza',
syncing: 'Sincronizzazione...' syncing: 'Sincronizzazione...',
copy: 'Copia',
paste: 'Incolla',
select_fields: 'Seleziona Campi',
draw_fields: 'Disegna Campi',
align_left: 'Allinea a sinistra',
align_right: 'Allinea a destra',
align_top: 'Allinea in alto',
align_bottom: 'Allinea in basso',
fields_selected: '{count} Campi Selezionati',
field_added: '{count} Campo Aggiunto',
fields_added: '{count} Campi Aggiunti'
} }
const pt = { const pt = {
default: 'Padrão',
save_as_custom_field: 'Salvar como personalizado',
kba: 'KBA', kba: 'KBA',
autodetect_fields: 'Detectar campos', autodetect_fields: 'Detectar campos',
analyzing_: 'Analisando...', analyzing_: 'Analisando...',
@@ -755,10 +796,23 @@ const pt = {
start_tour: 'Iniciar tour', start_tour: 'Iniciar tour',
or_add_from: 'Ou adicionar de', or_add_from: 'Ou adicionar de',
sync: 'Sincronizar', sync: 'Sincronizar',
syncing: 'Sincronizando...' syncing: 'Sincronizando...',
copy: 'Copiar',
paste: 'Colar',
select_fields: 'Selecionar Campos',
draw_fields: 'Desenhar Campos',
align_left: 'Alinhar à esquerda',
align_right: 'Alinhar à direita',
align_top: 'Alinhar ao topo',
align_bottom: 'Alinhar à parte inferior',
fields_selected: '{count} Campos Selecionados',
field_added: '{count} Campo Adicionado',
fields_added: '{count} Campos Adicionados'
} }
const fr = { const fr = {
default: 'Par défaut',
save_as_custom_field: 'Enregistrer comme personnalisé',
kba: 'KBA', kba: 'KBA',
autodetect_fields: 'Détecter les champs', autodetect_fields: 'Détecter les champs',
analyzing_: 'Analyse...', analyzing_: 'Analyse...',
@@ -945,10 +999,23 @@ const fr = {
start_tour: 'Démarrer', start_tour: 'Démarrer',
or_add_from: 'Ou ajouter depuis', or_add_from: 'Ou ajouter depuis',
sync: 'Synchroniser', sync: 'Synchroniser',
syncing: 'Synchronisation...' syncing: 'Synchronisation...',
copy: 'Copier',
paste: 'Coller',
select_fields: 'Sélectionner Champs',
draw_fields: 'Dessiner Champs',
align_left: 'Aligner à gauche',
align_right: 'Aligner à droite',
align_top: 'Aligner en haut',
align_bottom: 'Aligner en bas',
fields_selected: '{count} Champs Sélectionnés',
field_added: '{count} Champ Ajouté',
fields_added: '{count} Champs Ajoutés'
} }
const de = { const de = {
default: 'Standard',
save_as_custom_field: 'Als benutzerdefiniert speichern',
kba: 'KBA', kba: 'KBA',
autodetect_fields: 'Felder erkennen', autodetect_fields: 'Felder erkennen',
analyzing_: 'Analysiere...', analyzing_: 'Analysiere...',
@@ -1135,10 +1202,23 @@ const de = {
start_tour: 'Tour starten', start_tour: 'Tour starten',
or_add_from: 'Oder hinzufügen aus', or_add_from: 'Oder hinzufügen aus',
sync: 'Synchronisieren', sync: 'Synchronisieren',
syncing: 'Synchronisiere...' syncing: 'Synchronisiere...',
copy: 'Kopieren',
paste: 'Einfügen',
select_fields: 'Felder Auswählen',
draw_fields: 'Felder Zeichnen',
align_left: 'Links ausrichten',
align_right: 'Rechts ausrichten',
align_top: 'Oben ausrichten',
align_bottom: 'Unten ausrichten',
fields_selected: '{count} Felder Ausgewählt',
field_added: '{count} Feld Hinzugefügt',
fields_added: '{count} Felder Hinzugefügt'
} }
const nl = { const nl = {
default: 'Standaard',
save_as_custom_field: 'Opslaan als aangepast',
kba: 'KBA', kba: 'KBA',
autodetect_fields: 'Velden detecteren', autodetect_fields: 'Velden detecteren',
analyzing_: 'Analyseren...', analyzing_: 'Analyseren...',
@@ -1325,7 +1405,18 @@ const nl = {
start_tour: 'Rondleiding starten', start_tour: 'Rondleiding starten',
or_add_from: 'Of toevoegen van', or_add_from: 'Of toevoegen van',
sync: 'Synchroniseren', sync: 'Synchroniseren',
syncing: 'Synchroniseren...' syncing: 'Synchroniseren...',
copy: 'Kopiëren',
paste: 'Plakken',
select_fields: 'Velden Selecteren',
draw_fields: 'Velden Tekenen',
align_left: 'Links uitlijnen',
align_right: 'Rechts uitlijnen',
align_top: 'Boven uitlijnen',
align_bottom: 'Onder uitlijnen',
fields_selected: '{count} Velden Geselecteerd',
field_added: '{count} Veld Toegevoegd',
fields_added: '{count} Velden Toegevoegd'
} }
export { en, es, it, pt, fr, de, nl } export { en, es, it, pt, fr, de, nl }
+453 -53
View File
@@ -1,7 +1,7 @@
<template> <template>
<div <div
class="relative select-none mb-4 before:border before:rounded before:top-0 before:bottom-0 before:left-0 before:right-0 before:absolute" class="relative 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': allowDraw && editable, 'touch-none': !!drawField }" :class="{ 'cursor-crosshair': allowDraw && editable && !isSelectMode, 'touch-none': !!drawField }"
style="container-type: size" style="container-type: size"
:style="{ aspectRatio: `${width} / ${height}`}" :style="{ aspectRatio: `${width} / ${height}`}"
> >
@@ -17,7 +17,20 @@
<div <div
class="top-0 bottom-0 left-0 right-0 absolute" class="top-0 bottom-0 left-0 right-0 absolute"
@pointerdown="onStartDraw" @pointerdown="onStartDraw"
@contextmenu="openContextMenu"
> >
<SelectionBox
v-if="showSelectionBox"
:selection-box="selectionBox"
:page-width="width"
:page-height="height"
:is-resizing="!!resizeDirection"
:is-drawing="!!drawFieldType"
:is-drag="isDrag"
@move="onSelectionBoxMove"
@contextmenu="openSelectionContextMenu"
@close-context-menu="closeSelectionContextMenu"
/>
<FieldArea <FieldArea
v-for="(item, i) in areas" v-for="(item, i) in areas"
:key="i" :key="i"
@@ -34,31 +47,70 @@
:default-field="defaultFieldsIndex[item.field.name]" :default-field="defaultFieldsIndex[item.field.name]"
:default-submitters="defaultSubmitters" :default-submitters="defaultSubmitters"
:max-page="totalPages - 1" :max-page="totalPages - 1"
:is-select-mode="isSelectMode"
@start-resize="resizeDirection = $event" @start-resize="resizeDirection = $event"
@stop-resize="resizeDirection = null" @stop-resize="resizeDirection = null"
@remove="$emit('remove-area', item.area)" @remove="$emit('remove-area', item.area)"
@scroll-to="$emit('scroll-to', $event)" @scroll-to="$emit('scroll-to', $event)"
@add-custom-field="$emit('add-custom-field', $event)"
@contextmenu="openAreaContextMenu($event, item.area, item.field)"
/> />
<FieldArea <FieldArea
v-if="newArea" v-for="(area, index) in newAreas"
:key="index"
:is-draw="true" :is-draw="true"
:page-width="width" :page-width="width"
:page-height="height" :page-height="height"
:field="{ submitter_uuid: selectedSubmitter.uuid, type: drawField?.type || dragFieldPlaceholder?.type || defaultFieldType }" :field="{ submitter_uuid: selectedSubmitter.uuid, type: newAreaFieldType }"
:area="newArea" :area="area"
/>
<div
v-if="newAreas.length > 1"
class="absolute outline-dashed outline-gray-400 pointer-events-none z-20"
:style="newAreasBoxStyle"
/>
<div
v-if="selectionRect"
class="absolute outline-dashed outline-gray-400 pointer-events-none z-20"
:style="selectionRectStyle"
/>
<ContextMenu
v-if="contextMenu"
:context-menu="contextMenu"
:field="contextMenu.field"
:editable="editable"
:with-fields-detection="withFieldsDetection"
@copy="handleCopy"
@delete="handleDelete"
@paste="handlePaste"
@autodetect-fields="handleAutodetectFields"
@close="closeContextMenu"
/>
<ContextMenu
v-if="selectionContextMenu"
:context-menu="selectionContextMenu"
:editable="editable"
:is-multi-selection="true"
:selected-areas="selectedAreasRef.value"
:template="template"
@copy="handleSelectionCopy"
@delete="handleSelectionDelete"
@align="handleSelectionAlign"
@close="closeSelectionContextMenu"
/> />
</div> </div>
<div <div
v-show="resizeDirection || isDrag || showMask || (drawField && isMobile) || fieldsDragFieldRef.value" v-show="resizeDirection || isDrag || showMask || (drawField && isMobile) || fieldsDragFieldRef.value || customDragFieldRef?.value || selectionRect"
id="mask" id="mask"
ref="mask" ref="mask"
class="top-0 bottom-0 left-0 right-0 absolute" class="top-0 bottom-0 left-0 right-0 absolute"
:class="{ 'z-10': !isMobile, 'cursor-grab': isDrag, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }" :class="{ 'z-10': !isMobile, 'cursor-grab': isDrag, 'cursor-nwse-resize': drawField && !isSelectMode, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
@pointermove="onPointermove" @pointermove="onPointermove"
@pointerdown="onStartDraw" @pointerdown="onStartDraw"
@contextmenu="openContextMenu"
@dragover.prevent="onDragover" @dragover.prevent="onDragover"
@dragenter="onDragenter" @dragenter="onDragenter"
@dragleave="newArea = null" @dragleave="newAreas = []"
@drop="onDrop" @drop="onDrop"
@pointerup="onPointerup" @pointerup="onPointerup"
/> />
@@ -67,13 +119,17 @@
<script> <script>
import FieldArea from './area' import FieldArea from './area'
import ContextMenu from './context_menu'
import SelectionBox from './selection_box'
export default { export default {
name: 'TemplatePage', name: 'TemplatePage',
components: { components: {
FieldArea FieldArea,
ContextMenu,
SelectionBox
}, },
inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'assignDropAreaSize'], inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'customDragFieldRef', 'assignDropAreaSize', 'selectedAreasRef', 'template', 'isSelectModeRef'],
props: { props: {
image: { image: {
type: Object, type: Object,
@@ -142,6 +198,11 @@ export default {
required: false, required: false,
default: null default: null
}, },
drawCustomField: {
type: Object,
required: false,
default: null
},
editable: { editable: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -155,18 +216,69 @@ export default {
number: { number: {
type: Number, type: Number,
required: true required: true
},
attachmentUuid: {
type: String,
required: false,
default: ''
},
withFieldsDetection: {
type: Boolean,
required: false,
default: false
} }
}, },
emits: ['draw', 'drop-field', 'remove-area', 'scroll-to'], emits: ['draw', 'drop-field', 'remove-area', 'copy-field', 'paste-field', 'scroll-to', 'copy-selected-areas', 'delete-selected-areas', 'align-selected-areas', 'autodetect-fields', 'add-custom-field'],
data () { data () {
return { return {
areaRefs: [], areaRefs: [],
showMask: false, showMask: false,
resizeDirection: null, resizeDirection: null,
newArea: null newAreas: [],
contextMenu: null,
selectionRect: null,
selectionContextMenu: null
} }
}, },
computed: { computed: {
isSelectMode () {
return this.isSelectModeRef.value && !this.drawFieldType && this.editable && !this.drawField
},
pageSelectedAreas () {
if (!this.selectedAreasRef.value) return []
return this.selectedAreasRef.value.filter((a) =>
a.attachment_uuid === this.attachmentUuid && a.page === this.number
)
},
showSelectionBox () {
return this.pageSelectedAreas.length >= 2 && this.editable
},
minSelectionBoxHeight () {
const ys = this.pageSelectedAreas.map((a) => a.y)
return Math.max(...ys) - Math.min(...ys)
},
minSelectionBoxWidth () {
const xs = this.pageSelectedAreas.map((a) => a.x)
return Math.max(...xs) - Math.min(...xs)
},
selectionBox () {
if (!this.pageSelectedAreas.length) return null
const minX = Math.min(...this.pageSelectedAreas.map((a) => a.x))
const minY = Math.min(...this.pageSelectedAreas.map((a) => a.y))
const maxX = Math.max(...this.pageSelectedAreas.map((a) => a.x + a.w))
const maxY = Math.max(...this.pageSelectedAreas.map((a) => a.y + a.h))
return {
x: minX,
y: minY,
w: Math.max(maxX - minX, this.minSelectionBoxWidth),
h: Math.max(maxY - minY, this.minSelectionBoxHeight)
}
},
defaultFieldsIndex () { defaultFieldsIndex () {
return this.defaultFields.reduce((acc, field) => { return this.defaultFields.reduce((acc, field) => {
acc[field.name] = field acc[field.name] = field
@@ -174,6 +286,14 @@ export default {
return acc return acc
}, {}) }, {})
}, },
newAreaFieldType () {
if (this.drawField?.type) return this.drawField.type
if (this.drawCustomField?.type) return this.drawCustomField.type
if (this.dragFieldPlaceholder?.type) return this.dragFieldPlaceholder.type
if (this.customDragFieldRef?.value?.type) return this.customDragFieldRef.value.type
return this.defaultFieldType
},
defaultFieldType () { defaultFieldType () {
if (this.drawFieldType) { if (this.drawFieldType) {
return this.drawFieldType return this.drawFieldType
@@ -201,6 +321,31 @@ export default {
}, },
height () { height () {
return this.image.metadata.height return this.image.metadata.height
},
newAreasBoxStyle () {
if (this.newAreas.length < 2) return {}
const minX = Math.min(...this.newAreas.map(a => a.x))
const minY = Math.min(...this.newAreas.map(a => a.y))
const maxX = Math.max(...this.newAreas.map(a => a.x + a.w))
const maxY = Math.max(...this.newAreas.map(a => a.y + a.h))
return {
left: minX * 100 + '%',
top: minY * 100 + '%',
width: (maxX - minX) * 100 + '%',
height: (maxY - minY) * 100 + '%'
}
},
selectionRectStyle () {
if (!this.selectionRect) return {}
return {
left: this.selectionRect.x * 100 + '%',
top: this.selectionRect.y * 100 + '%',
width: this.selectionRect.w * 100 + '%',
height: this.selectionRect.h * 100 + '%'
}
} }
}, },
beforeUpdate () { beforeUpdate () {
@@ -211,28 +356,180 @@ export default {
this.image.metadata.width = e.target.naturalWidth this.image.metadata.width = e.target.naturalWidth
this.image.metadata.height = e.target.naturalHeight this.image.metadata.height = e.target.naturalHeight
}, },
openContextMenu (event) {
if (!this.editable) {
return
}
event.preventDefault()
event.stopPropagation()
const rect = this.$refs.image.getBoundingClientRect()
this.newAreas = []
this.showMask = false
this.contextMenu = {
x: event.clientX,
y: event.clientY,
relativeX: (event.clientX - rect.left) / rect.width,
relativeY: (event.clientY - rect.top) / rect.height
}
},
openAreaContextMenu (event, area, field) {
if (!this.editable) {
return
}
event.preventDefault()
event.stopPropagation()
const rect = this.$refs.image.getBoundingClientRect()
this.newAreas = []
this.showMask = false
this.contextMenu = {
x: event.clientX,
y: event.clientY,
relativeX: (event.clientX - rect.left) / rect.width,
relativeY: (event.clientY - rect.top) / rect.height,
area,
field
}
},
openSelectionContextMenu (event) {
const rect = this.$el.getBoundingClientRect()
this.selectionContextMenu = {
x: event.clientX,
y: event.clientY,
relativeX: (event.clientX - rect.left) / rect.width,
relativeY: (event.clientY - rect.top) / rect.height
}
},
closeSelectionContextMenu () {
this.selectionContextMenu = null
},
handleSelectionCopy () {
this.$emit('copy-selected-areas')
this.closeSelectionContextMenu()
},
handleSelectionDelete () {
this.$emit('delete-selected-areas')
this.closeSelectionContextMenu()
},
handleSelectionAlign (direction) {
this.$emit('align-selected-areas', direction)
this.closeSelectionContextMenu()
},
closeContextMenu () {
this.contextMenu = null
this.newAreas = []
this.showMask = false
},
handleCopy () {
if (this.contextMenu.area) {
this.selectedAreasRef.value = [this.contextMenu.area]
this.$emit('copy-field')
}
this.closeContextMenu()
},
handleDelete () {
if (this.contextMenu.area) {
this.$emit('remove-area', this.contextMenu.area)
}
this.closeContextMenu()
},
handlePaste () {
this.newAreas = []
this.showMask = false
this.$emit('paste-field', {
page: this.number,
x: this.contextMenu.relativeX,
y: this.contextMenu.relativeY
})
this.closeContextMenu()
},
handleAutodetectFields () {
this.$emit('autodetect-fields', {
page: this.number,
attachmentUuid: this.attachmentUuid
})
this.closeContextMenu()
},
setAreaRefs (el) { setAreaRefs (el) {
if (el) { if (el) {
this.areaRefs.push(el) this.areaRefs.push(el)
} }
}, },
onDragenter (e) { onDragenter (e) {
this.newArea = {} const customField = this.customDragFieldRef?.value
const customAreas = customField?.areas || []
const dropX = (e.offsetX - 6) / this.$refs.mask.clientWidth
const dropY = e.offsetY / this.$refs.mask.clientHeight
this.assignDropAreaSize(this.newArea, this.dragFieldPlaceholder, { if (customAreas.length > 1) {
maskW: this.$refs.mask.clientWidth, const refArea = customAreas[0]
maskH: this.$refs.mask.clientHeight
})
this.newArea.x = (e.offsetX - 6) / this.$refs.mask.clientWidth this.newAreas = customAreas.map((customArea) => ({
this.newArea.y = e.offsetY / this.$refs.mask.clientHeight - this.newArea.h / 2 x: dropX + (customArea.x - refArea.x),
y: dropY + (customArea.y - refArea.y) - (customArea.h / 2),
w: customArea.w,
h: customArea.h
}))
} else {
const newArea = {}
if (customAreas.length === 1) {
newArea.w = customAreas[0].w
newArea.h = customAreas[0].h
} else if (customField) {
this.assignDropAreaSize(newArea, customField, {
maskW: this.$refs.mask.clientWidth,
maskH: this.$refs.mask.clientHeight
})
} else {
this.assignDropAreaSize(newArea, this.dragFieldPlaceholder, {
maskW: this.$refs.mask.clientWidth,
maskH: this.$refs.mask.clientHeight
})
}
newArea.x = dropX
newArea.y = dropY - newArea.h / 2
this.newAreas = [newArea]
}
}, },
onDragover (e) { onDragover (e) {
this.newArea.x = (e.offsetX - 6) / this.$refs.mask.clientWidth const customField = this.customDragFieldRef?.value
this.newArea.y = e.offsetY / this.$refs.mask.clientHeight - this.newArea.h / 2 const customAreas = customField?.areas || []
const dropX = (e.offsetX - 6) / this.$refs.mask.clientWidth
const dropY = e.offsetY / this.$refs.mask.clientHeight
if (customAreas.length > 1) {
const refArea = customAreas[0]
this.newAreas.forEach((newArea, index) => {
const customArea = customAreas[index]
newArea.x = dropX + (customArea.x - refArea.x)
newArea.y = dropY + (customArea.y - refArea.y) - (customArea.h / 2)
})
} else if (this.newAreas.length) {
this.newAreas[0].x = dropX
this.newAreas[0].y = dropY - this.newAreas[0].h / 2
}
}, },
onDrop (e) { onDrop (e) {
this.newArea = null this.newAreas = []
this.$emit('drop-field', { this.$emit('drop-field', {
x: e.offsetX, x: e.offsetX,
@@ -243,11 +540,25 @@ export default {
}) })
}, },
onStartDraw (e) { onStartDraw (e) {
if (e.button === 2) {
return
}
if (this.selectedAreasRef.value.length >= 2) {
this.selectedAreasRef.value = []
}
if (this.isSelectMode) {
this.startSelectionRect(e)
return
}
if (!this.allowDraw) { if (!this.allowDraw) {
return return
} }
if (this.isMobile && !this.drawField) { if (this.isMobile && !this.drawField && !this.drawCustomField) {
return return
} }
@@ -258,65 +569,154 @@ export default {
this.showMask = true this.showMask = true
this.$nextTick(() => { this.$nextTick(() => {
this.newArea = { this.newAreas = [{
initialX: e.offsetX / this.$refs.mask.clientWidth, initialX: e.offsetX / this.$refs.mask.clientWidth,
initialY: e.offsetY / this.$refs.mask.clientHeight, initialY: e.offsetY / this.$refs.mask.clientHeight,
x: e.offsetX / this.$refs.mask.clientWidth, x: e.offsetX / this.$refs.mask.clientWidth,
y: e.offsetY / this.$refs.mask.clientHeight, y: e.offsetY / this.$refs.mask.clientHeight,
w: 0, w: 0,
h: 0 h: 0
}]
})
},
startSelectionRect (e) {
this.selectedAreasRef.value = []
this.showMask = true
this.$nextTick(() => {
const x = e.offsetX / this.$refs.mask.clientWidth
const y = e.offsetY / this.$refs.mask.clientHeight
this.selectionRect = {
initialX: x,
initialY: y,
x,
y,
w: 0,
h: 0
} }
}) })
}, },
onSelectionBoxMove (dx, dy) {
let clampedDx = dx
let clampedDy = dy
this.pageSelectedAreas.forEach((area) => {
const maxDxLeft = -area.x
const maxDxRight = 1 - area.w - area.x
const maxDyTop = -area.y
const maxDyBottom = 1 - area.h - area.y
if (dx < maxDxLeft) clampedDx = Math.max(clampedDx, maxDxLeft)
if (dx > maxDxRight) clampedDx = Math.min(clampedDx, maxDxRight)
if (dy < maxDyTop) clampedDy = Math.max(clampedDy, maxDyTop)
if (dy > maxDyBottom) clampedDy = Math.min(clampedDy, maxDyBottom)
})
this.pageSelectedAreas.forEach((area) => {
area.x += clampedDx
area.y += clampedDy
})
},
onPointermove (e) { onPointermove (e) {
if (this.newArea) { if (this.selectionRect) {
const dx = e.offsetX / this.$refs.mask.clientWidth - this.newArea.initialX const dx = e.offsetX / this.$refs.mask.clientWidth - this.selectionRect.initialX
const dy = e.offsetY / this.$refs.mask.clientHeight - this.newArea.initialY const dy = e.offsetY / this.$refs.mask.clientHeight - this.selectionRect.initialY
if (dx > 0) { if (dx > 0) {
this.newArea.x = this.newArea.initialX this.selectionRect.x = this.selectionRect.initialX
} else { } else {
this.newArea.x = e.offsetX / this.$refs.mask.clientWidth this.selectionRect.x = e.offsetX / this.$refs.mask.clientWidth
} }
if (dy > 0) { if (dy > 0) {
this.newArea.y = this.newArea.initialY this.selectionRect.y = this.selectionRect.initialY
} else { } else {
this.newArea.y = e.offsetY / this.$refs.mask.clientHeight this.selectionRect.y = e.offsetY / this.$refs.mask.clientHeight
} }
if ((this.drawField?.type || this.drawFieldType) === 'cells') { this.selectionRect.w = Math.abs(dx)
this.newArea.cell_w = this.newArea.h * (this.$refs.mask.clientHeight / this.$refs.mask.clientWidth) this.selectionRect.h = Math.abs(dy)
return
}
const drawArea = this.newAreas[0]
if (drawArea?.initialX !== undefined) {
const dx = e.offsetX / this.$refs.mask.clientWidth - drawArea.initialX
const dy = e.offsetY / this.$refs.mask.clientHeight - drawArea.initialY
if (dx > 0) {
drawArea.x = drawArea.initialX
} else {
drawArea.x = e.offsetX / this.$refs.mask.clientWidth
} }
this.newArea.w = Math.abs(dx) if (dy > 0) {
this.newArea.h = Math.abs(dy) drawArea.y = drawArea.initialY
} else {
drawArea.y = e.offsetY / this.$refs.mask.clientHeight
}
if ((this.drawField?.type || this.drawCustomField?.type || this.drawFieldType) === 'cells') {
drawArea.cell_w = drawArea.h * (this.$refs.mask.clientHeight / this.$refs.mask.clientWidth)
}
drawArea.w = Math.abs(dx)
drawArea.h = Math.abs(dy)
} }
}, },
onPointerup (e) { onPointerup (e) {
if (this.newArea) { if (this.selectionRect) {
const area = { const selRect = this.selectionRect
x: this.newArea.x, const areasToSelect = this.areas || []
y: this.newArea.y,
w: this.newArea.w, areasToSelect.forEach((item) => {
h: this.newArea.h, const area = item.area
page: this.number
if (this.rectsOverlap(selRect, area)) {
this.selectedAreasRef.value.push(area)
}
})
this.selectionRect = null
} else {
const drawArea = this.newAreas[0]
if (drawArea?.initialX !== undefined) {
const area = {
x: drawArea.x,
y: drawArea.y,
w: drawArea.w,
h: drawArea.h,
page: this.number
}
if ('cell_w' in drawArea) {
area.cell_w = drawArea.cell_w
}
const dx = Math.abs(e.offsetX - this.$refs.mask.clientWidth * drawArea.initialX)
const dy = Math.abs(e.offsetY - this.$refs.mask.clientHeight * drawArea.initialY)
const isTooSmall = dx < 8 && dy < 8
this.$emit('draw', { area, isTooSmall })
} }
if ('cell_w' in this.newArea) {
area.cell_w = this.newArea.cell_w
}
const dx = Math.abs(e.offsetX - this.$refs.mask.clientWidth * this.newArea.initialX)
const dy = Math.abs(e.offsetY - this.$refs.mask.clientHeight * this.newArea.initialY)
const isTooSmall = dx < 8 && dy < 8
this.$emit('draw', { area, isTooSmall })
} }
this.showMask = false this.showMask = false
this.newArea = null this.newAreas = []
},
rectsOverlap (r1, r2) {
return !(
r1.x + r1.w < r2.x ||
r2.x + r2.w < r1.x ||
r1.y + r1.h < r2.y ||
r2.y + r2.h < r1.y
)
} }
} }
} }
@@ -1,7 +1,7 @@
<template> <template>
<span <span
class="dropdown dropdown-end field-settings-dropdown" class="dropdown dropdown-end field-settings-dropdown"
:class="{ 'dropdown-open': ((!field.preferences?.price && !field.preferences?.formula && !field.preferences?.price_id && !field.preferences?.payment_link_id) || !isConnected) && !isLoading }" :class="{ 'dropdown-open': withForceOpen && ((!field.preferences?.price && !field.preferences?.formula && !field.preferences?.price_id && !field.preferences?.payment_link_id) || !isConnected) && !isLoading }"
> >
<label <label
tabindex="0" tabindex="0"
@@ -231,7 +231,10 @@
</span> </span>
</label> </label>
</li> </li>
<li class="mt-1"> <li
v-if="withCondition"
class="mt-1"
>
<label <label
class="label-text cursor-pointer text-center w-full flex items-center" class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-condition')" @click="$emit('click-condition')"
@@ -244,12 +247,29 @@
</span> </span>
</label> </label>
</li> </li>
<hr
v-if="withCustomFields"
class="pb-0.5 mt-0.5"
>
<li v-if="withCustomFields">
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('add-custom-field', field)"
>
<IconForms
:width="20"
:stroke-width="1.6"
/>
{{ t('save_as_custom_field') }}
</a>
</li>
</ul> </ul>
</span> </span>
</template> </template>
<script> <script>
import { IconMathFunction, IconSettings, IconCircleCheck, IconInfoCircle, IconBrandStripe, IconInnerShadowTop, IconRouteAltLeft } from '@tabler/icons-vue' import { IconMathFunction, IconSettings, IconCircleCheck, IconInfoCircle, IconBrandStripe, IconInnerShadowTop, IconRouteAltLeft, IconForms } from '@tabler/icons-vue'
import { ref } from 'vue' import { ref } from 'vue'
const isConnected = ref(false) const isConnected = ref(false)
@@ -261,6 +281,7 @@ export default {
IconCircleCheck, IconCircleCheck,
IconRouteAltLeft, IconRouteAltLeft,
IconInfoCircle, IconInfoCircle,
IconForms,
IconMathFunction, IconMathFunction,
IconInnerShadowTop, IconInnerShadowTop,
IconBrandStripe IconBrandStripe
@@ -270,9 +291,24 @@ export default {
field: { field: {
type: Object, type: Object,
required: true required: true
},
withForceOpen: {
type: Boolean,
required: false,
default: true
},
withCustomFields: {
type: Boolean,
required: false,
default: false
},
withCondition: {
type: Boolean,
required: false,
default: true
} }
}, },
emits: ['click-condition', 'click-description', 'click-formula'], emits: ['click-condition', 'click-description', 'click-formula', 'add-custom-field'],
data () { data () {
return { return {
isLoading: false isLoading: false
@@ -0,0 +1,100 @@
<template>
<div
class="absolute outline-dashed outline-gray-400 cursor-move"
:class="[isResizing || isCmdKeyRef.value ? 'z-0' : 'z-20', { 'pointer-events-none': isDrawing || fieldsDragFieldRef.value || isDrag }]"
:style="positionStyle"
@pointerdown.stop.prevent="onPointerDown"
@contextmenu.stop.prevent="openContextMenu"
/>
</template>
<script>
export default {
name: 'SelectionBox',
inject: ['save', 'selectedAreasRef', 'isCmdKeyRef', 'fieldsDragFieldRef'],
props: {
selectionBox: {
type: Object,
required: true
},
pageWidth: {
type: Number,
required: true
},
pageHeight: {
type: Number,
required: true
},
isResizing: {
type: Boolean,
default: false
},
isDrag: {
type: Boolean,
default: false
},
isDrawing: {
type: Boolean,
default: false
}
},
emits: ['move', 'contextmenu', 'close-context-menu'],
data () {
return {
isDragging: false,
dragStart: { x: 0, y: 0 }
}
},
computed: {
positionStyle () {
const { x, y, w, h } = this.selectionBox
return {
top: y * 100 + '%',
left: x * 100 + '%',
width: w * 100 + '%',
height: h * 100 + '%'
}
}
},
methods: {
openContextMenu (e) {
this.$emit('contextmenu', e)
},
onPointerDown (e) {
this.$emit('close-context-menu')
this.startDrag(e)
},
startDrag (e) {
this.isDragging = true
this.dragStart = { x: e.clientX, y: e.clientY }
document.addEventListener('pointermove', this.onDrag)
document.addEventListener('pointerup', this.stopDrag)
},
onDrag (e) {
if (!this.isDragging) return
const parent = this.$el.parentElement
const rect = parent.getBoundingClientRect()
const dx = (e.clientX - this.dragStart.x) / rect.width
const dy = (e.clientY - this.dragStart.y) / rect.height
this.$emit('move', dx, dy)
this.dragStart = { x: e.clientX, y: e.clientY }
},
stopDrag () {
this.isDragging = false
document.removeEventListener('pointermove', this.onDrag)
document.removeEventListener('pointerup', this.stopDrag)
this.save()
}
}
}
</script>
+2
View File
@@ -47,12 +47,14 @@ class AccountConfig < ApplicationRecord
WITH_SIGNATURE_ID_REASON_KEY = 'with_signature_id_reason' WITH_SIGNATURE_ID_REASON_KEY = 'with_signature_id_reason'
RECIPIENT_FORM_FIELDS_KEY = 'recipient_form_fields' RECIPIENT_FORM_FIELDS_KEY = 'recipient_form_fields'
WITH_AUDIT_VALUES_KEY = 'with_audit_values' WITH_AUDIT_VALUES_KEY = 'with_audit_values'
WITH_AUDIT_SENDER_KEY = 'with_audit_sender'
WITH_SUBMITTER_TIMEZONE_KEY = 'with_submitter_timezone' WITH_SUBMITTER_TIMEZONE_KEY = 'with_submitter_timezone'
REQUIRE_SIGNING_REASON_KEY = 'require_signing_reason' REQUIRE_SIGNING_REASON_KEY = 'require_signing_reason'
REUSE_SIGNATURE_KEY = 'reuse_signature' REUSE_SIGNATURE_KEY = 'reuse_signature'
WITH_FIELD_LABELS_KEY = 'with_field_labels' WITH_FIELD_LABELS_KEY = 'with_field_labels'
COMBINE_PDF_RESULT_KEY = 'combine_pdf_result_key' COMBINE_PDF_RESULT_KEY = 'combine_pdf_result_key'
DOCUMENT_FILENAME_FORMAT_KEY = 'document_filename_format' DOCUMENT_FILENAME_FORMAT_KEY = 'document_filename_format'
TEMPLATE_CUSTOM_FIELDS_KEY = 'template_custom_fields'
POLICY_LINKS_KEY = 'policy_links' POLICY_LINKS_KEY = 'policy_links'
DEFAULT_VALUES = { DEFAULT_VALUES = {
+5 -2
View File
@@ -63,6 +63,8 @@ class Submission < ApplicationRecord
has_one_attached :audit_trail has_one_attached :audit_trail
has_one_attached :combined_document has_one_attached :combined_document
has_one_attached :merged_document
has_one_attached :preview_merged_document
has_many_attached :preview_documents has_many_attached :preview_documents
has_many_attached :documents has_many_attached :documents
@@ -76,8 +78,9 @@ class Submission < ApplicationRecord
scope :active, -> { where(archived_at: nil) } scope :active, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) } scope :archived, -> { where.not(archived_at: nil) }
scope :pending, lambda { scope :pending, lambda {
where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]) where(expire_at: nil).or(where(expire_at: Time.current..))
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists) .where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
} }
scope :completed, lambda { scope :completed, lambda {
where.not(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]) where.not(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
+1 -1
View File
@@ -235,7 +235,7 @@
<% end %> <% end %>
<% end %> <% end %>
<% end %> <% end %>
<% if can?(:manage, :personalization_advanced) %> <% if !Docuseal.multitenant? || can?(:manage, :personalization_advanced) %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::WITH_FILE_LINKS_KEY) %> <% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::WITH_FILE_LINKS_KEY) %>
<% if can?(:manage, account_config) %> <% if can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %> <%= form_for account_config, url: account_configs_path, method: :post do |f| %>
-1
View File
@@ -1,5 +1,4 @@
<% uuid = local_assigns[:uuid] || SecureRandom.uuid %> <% uuid = local_assigns[:uuid] || SecureRandom.uuid %>
<label id="<%= local_assigns[:button_id] %>" for="<%= uuid %>" class="<%= local_assigns[:btn_class] %>"><%= local_assigns[:btn_text] %></label>
<input type="checkbox" id="<%= uuid %>" class="modal-toggle"> <input type="checkbox" id="<%= uuid %>" class="modal-toggle">
<div id="<%= local_assigns[:id] %>" class="modal items-start !animate-none overflow-y-auto"> <div id="<%= local_assigns[:id] %>" class="modal items-start !animate-none overflow-y-auto">
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none"> <div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none">
+3 -4
View File
@@ -1,4 +1,3 @@
<% link = pagy_anchor(pagy) %>
<% if pagy.pages > 1 %> <% if pagy.pages > 1 %>
<div class="flex my-6 justify-center md:justify-between"> <div class="flex my-6 justify-center md:justify-between">
<div class="hidden md:block text-sm"> <div class="hidden md:block text-sm">
@@ -12,8 +11,8 @@
<div class="flex items-center space-x-1.5"> <div class="flex items-center space-x-1.5">
<%= local_assigns[:right_additional_html] %> <%= local_assigns[:right_additional_html] %>
<div class="join"> <div class="join">
<% if pagy.prev %> <% if pagy.previous %>
<%== link.call(pagy.prev, '«', classes: 'join-item btn min-h-full h-10') %> <%= link_to '«', url_for(params: request.query_parameters.merge('page' => pagy.previous)), class: 'join-item btn min-h-full h-10' %>
<% else %> <% else %>
<span class="join-item btn btn-disabled !bg-base-200 min-h-full h-10">«</span> <span class="join-item btn btn-disabled !bg-base-200 min-h-full h-10">«</span>
<% end %> <% end %>
@@ -23,7 +22,7 @@
<% if local_assigns[:next_page_path].present? %> <% if local_assigns[:next_page_path].present? %>
<%= link_to '»', local_assigns[:next_page_path], class: 'join-item btn min-h-full h-10' %> <%= link_to '»', local_assigns[:next_page_path], class: 'join-item btn min-h-full h-10' %>
<% elsif pagy.next %> <% elsif pagy.next %>
<%== link.call(pagy.next, '»', classes: 'join-item btn min-h-full h-10') %> <%= link_to '»', url_for(params: request.query_parameters.merge('page' => pagy.next)), class: 'join-item btn min-h-full h-10' %>
<% else %> <% else %>
<span class="join-item btn btn-disabled !bg-base-200 min-h-full h-10">»</span> <span class="join-item btn btn-disabled !bg-base-200 min-h-full h-10">»</span>
<% end %> <% end %>
-3
View File
@@ -1,7 +1,4 @@
<form action="<%= url_for %>" method="get" class="items-center flex"> <form action="<%= url_for %>" method="get" class="items-center flex">
<% if params[:status].present? %>
<input name="status" value="<%= params[:status] %>" class="hidden">
<% end %>
<% Submissions::Filter::ALLOWED_PARAMS.each do |key| %> <% Submissions::Filter::ALLOWED_PARAMS.each do |key| %>
<% if params[key].present? %> <% if params[key].present? %>
<input name="<%= key %>" value="<%= params[key] %>" class="hidden"> <input name="<%= key %>" value="<%= params[key] %>" class="hidden">
+1 -1
View File
@@ -135,7 +135,7 @@
<a href="mailto:<%= Docuseal::SUPPORT_EMAIL %>" target="_blank" class="w-full block mt-4 underline text-center"> <a href="mailto:<%= Docuseal::SUPPORT_EMAIL %>" target="_blank" class="w-full block mt-4 underline text-center">
<%= Docuseal::SUPPORT_EMAIL %> <%= Docuseal::SUPPORT_EMAIL %>
</a> </a>
<% if Docuseal.version.present? %> <% if Docuseal.version.present? && !Docuseal.multitenant? && can?(:manage, EncryptedConfig) %>
<a href="https://github.com/docusealco/docuseal/releases" target="_blank" class="badge badge-outline text-xs block mx-auto mt-4"> <a href="https://github.com/docusealco/docuseal/releases" target="_blank" class="badge badge-outline text-xs block mx-auto mt-4">
v<%= Docuseal.version %> v<%= Docuseal.version %>
</a> </a>
@@ -30,14 +30,14 @@
<div class="grid <%= 'md:grid-cols-2 gap-1' if submitters.size == 1 %>"> <div class="grid <%= 'md:grid-cols-2 gap-1' if submitters.size == 1 %>">
<submitters-autocomplete data-field="email"> <submitters-autocomplete data-field="email">
<linked-input data-target-id="<%= "detailed_email_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>"> <linked-input data-target-id="<%= "detailed_email_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<%= tag.input type: 'email', multiple: true, name: 'submission[1][submitters][][email]', autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: (local_assigns[:require_email_2fa] == true || local_assigns[:prefillable_fields].present? ? t(:email) : "#{t('email')} (#{t('optional')})"), value: item['email'].presence || ((params[:selfsign] && index.zero?) || item['is_requester'] ? current_user.email : ''), id: "detailed_email_#{item['uuid']}", required: local_assigns[:require_email_2fa] == true %> <%= tag.input type: 'email', multiple: true, name: 'submission[1][submitters][][email]', autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: (local_assigns[:require_email_2fa] == true || local_assigns[:prefillable_fields].present? ? t(:email) : "#{t('email')} (#{t('optional')})"), value: item['email'].presence || ((params[:selfsign] && index.zero?) || item['is_requester'] ? current_user.email : ''), id: "detailed_email_#{item['uuid']}", required: local_assigns[:require_email_2fa] == true && index.zero? %>
</linked-input> </linked-input>
</submitters-autocomplete> </submitters-autocomplete>
<% has_phone_field = true %> <% has_phone_field = true %>
<custom-validation data-invalid-message="<%= t('use_international_format_1xxx_') %>"> <custom-validation data-invalid-message="<%= t('use_international_format_1xxx_') %>">
<submitters-autocomplete data-field="phone"> <submitters-autocomplete data-field="phone">
<linked-input data-target-id="<%= "detailed_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>"> <linked-input data-target-id="<%= "detailed_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', name: 'submission[1][submitters][][phone]', autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: local_assigns[:require_phone_2fa] == true || local_assigns[:prefillable_fields].present? ? t(:phone) : "#{t('phone')} (#{t('optional')})", id: "detailed_phone_#{item['uuid']}", required: local_assigns[:require_phone_2fa] == true %> <%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', name: 'submission[1][submitters][][phone]', autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: local_assigns[:require_phone_2fa] == true || local_assigns[:prefillable_fields].present? ? t(:phone) : "#{t('phone')} (#{t('optional')})", id: "detailed_phone_#{item['uuid']}", required: local_assigns[:require_phone_2fa] == true && index.zero? %>
</linked-input> </linked-input>
</submitters-autocomplete> </submitters-autocomplete>
</custom-validation> </custom-validation>
@@ -99,6 +99,7 @@
<% if has_phone_field %> <% if has_phone_field %>
<%= render 'send_sms', f: %> <%= render 'send_sms', f: %>
<% end %> <% end %>
<%= render 'extra_fields', f: %>
</div> </div>
<div class="form-control"> <div class="form-control">
<%= f.button button_title(title: t('add_recipients')), class: 'base-button' %> <%= f.button button_title(title: t('add_recipients')), class: 'base-button' %>
+2 -1
View File
@@ -47,8 +47,9 @@
</dynamic-list> </dynamic-list>
<% end %> <% end %>
<div> <div>
<%= render 'send_email', f:, template: %>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %> <%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
<%= render 'send_email', f:, template: %>
<%= render 'extra_fields', f: %>
</div> </div>
<div class="form-control"> <div class="form-control">
<%= content_for(:submit_button) || capture do %> <%= content_for(:submit_button) || capture do %>
Whitespace-only changes.
Whitespace-only changes.
+2 -1
View File
@@ -58,7 +58,8 @@
</dynamic-list> </dynamic-list>
<div> <div>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %> <%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
<%= render 'send_sms', f: %> <%= render 'send_sms', f:, checked: true %>
<%= render 'extra_phone_fields', f: %>
</div> </div>
<div class="form-control"> <div class="form-control">
<%= f.button button_title(title: t('add_recipients')), class: 'base-button' %> <%= f.button button_title(title: t('add_recipients')), class: 'base-button' %>
+1 -1
View File
@@ -133,7 +133,7 @@
<% bg_class = bg_classes[submitter_index % bg_classes.size] %> <% bg_class = bg_classes[submitter_index % bg_classes.size] %>
<div class="absolute overflow-visible" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%;"> <div class="absolute overflow-visible" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%;">
<div class="flex h-full w-full bg-opacity-80 justify-center items-center <%= bg_class %>"> <div class="flex h-full w-full bg-opacity-80 justify-center items-center <%= bg_class %>">
<%= svg_icon(SubmissionsController::FIELD_ICONS[field['type']], class: 'max-h-10 w-full h-full stroke-2 opacity-50') %> <%= svg_icon(SubmissionsController::FIELD_ICONS.fetch(field['type'], 'text_size'), class: 'max-h-10 w-full h-full stroke-2 opacity-50') %>
</div> </div>
</div> </div>
<% end %> <% end %>
+8 -7
View File
@@ -8,6 +8,7 @@
<% page_blob_struct = Struct.new(:url, :metadata, keyword_init: true) %> <% page_blob_struct = Struct.new(:url, :metadata, keyword_init: true) %>
<% schema = Submissions.filtered_conditions_schema(@submitter.submission, values:, include_submitter_uuid: @submitter.uuid) %> <% schema = Submissions.filtered_conditions_schema(@submitter.submission, values:, include_submitter_uuid: @submitter.uuid) %>
<% font_scale = 1000.0 / PdfUtils::US_LETTER_W %> <% font_scale = 1000.0 / PdfUtils::US_LETTER_W %>
<% decline_modal_checkbox_uuid = nil %>
<div style="max-height: -webkit-fill-available;"> <div style="max-height: -webkit-fill-available;">
<div id="scrollbox"> <div id="scrollbox">
<div class="mx-auto block pb-72" style="max-width: 1000px"> <div class="mx-auto block pb-72" style="max-width: 1000px">
@@ -20,12 +21,7 @@
</div> </div>
<div class="flex items-center space-x-2" style="margin-left: 20px; flex-shrink: 0"> <div class="flex items-center space-x-2" style="margin-left: 20px; flex-shrink: 0">
<% if @form_configs[:with_decline] %> <% if @form_configs[:with_decline] %>
<% decline_modal_checkbox_uuid = SecureRandom.uuid %> <label id="decline_button" for="<%= decline_modal_checkbox_uuid = SecureRandom.uuid %>" class="btn btn-sm !px-5"><%= t(:decline) %></label>
<div>
<%= render 'shared/html_modal', title: t(:decline), btn_text: t(:decline), btn_class: 'btn btn-sm !px-5', button_id: 'decline_button', uuid: decline_modal_checkbox_uuid do %>
<%= render 'submit_form/decline_form', submitter: @submitter %>
<% end %>
</div>
<% end %> <% end %>
<% if @form_configs[:with_partial_download] %> <% if @form_configs[:with_partial_download] %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm !px-4"> <download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm !px-4">
@@ -102,11 +98,16 @@
</div> </div>
</div> </div>
</div> </div>
<div class="fixed bottom-0 w-full h-0 z-20"> <div class="fixed bottom-0 w-full h-0 z-50">
<div class="mx-auto" style="max-width: 1000px"> <div class="mx-auto" style="max-width: 1000px">
<div class="relative md:mx-32"> <div class="relative md:mx-32">
<%= render 'submit_form/submission_form', attachments_index: @attachments_index, submitter: @submitter, signature_attachment: @signature_attachment, configs: @form_configs, dry_run: local_assigns[:dry_run], expand: local_assigns[:expand], scroll_padding: local_assigns.fetch(:scroll_padding, '-110px'), schema: %> <%= render 'submit_form/submission_form', attachments_index: @attachments_index, submitter: @submitter, signature_attachment: @signature_attachment, configs: @form_configs, dry_run: local_assigns[:dry_run], expand: local_assigns[:expand], scroll_padding: local_assigns.fetch(:scroll_padding, '-110px'), schema: %>
</div> </div>
</div> </div>
</div> </div>
<% if @form_configs[:with_decline] %>
<%= render 'shared/html_modal', title: t(:decline), uuid: decline_modal_checkbox_uuid do %>
<%= render 'submit_form/decline_form', submitter: @submitter %>
<% end %>
<% end %>
<%= render 'scripts/autosize_field' %> <%= render 'scripts/autosize_field' %>
+2 -10
View File
@@ -1,12 +1,4 @@
<%= form_for @template, data: { turbo_frame: :_top }, html: { autocomplete: :off } do |f| %> <%= form_for @template, data: { turbo_frame: :_top }, html: { autocomplete: :off } do |f| %>
<% if @base_template %>
<%= hidden_field_tag :base_template_id, @base_template.id %>
<% end %>
<% if @base_template && (can?(:manage, :tenants) || true_user != current_user) && true_user.account.linked_accounts.active.accessible_by(current_ability).exists? %>
<div class="form-control -mb-2 mt-2">
<%= select_tag :account_id, options_for_select([true_user.account, *true_user.account.linked_accounts.active.accessible_by(current_ability)].uniq.map { |e| [e.name, e.id] }, current_account.id), required: true, class: 'base-select' %>
</div>
<% end %>
<div class="form-control mt-4"> <div class="form-control mt-4">
<%= f.text_field :name, required: true, placeholder: t('document_name'), class: 'base-input', dir: 'auto' %> <%= f.text_field :name, required: true, placeholder: t('document_name'), class: 'base-input', dir: 'auto' %>
</div> </div>
@@ -16,7 +8,7 @@
</label> </label>
<folder-autocomplete class="flex justify-between w-full"> <folder-autocomplete class="flex justify-between w-full">
<set-value data-on="blur" data-value="<%= TemplateFolder::DEFAULT_NAME %>" data-empty-only="true" class="peer w-full whitespace-nowrap"> <set-value data-on="blur" data-value="<%= TemplateFolder::DEFAULT_NAME %>" data-empty-only="true" class="peer w-full whitespace-nowrap">
<input id="folder_name" placeholder="<%= t('folder_name') %>" type="text" class="w-full outline-none border-transparent focus:border-transparent focus:ring-0 bg-base-100 px-1" name="folder_name" value="<%= params[:folder_name].presence || @base_template&.folder&.full_name || TemplateFolder::DEFAULT_NAME %>" autocomplete="off"> <input id="folder_name" placeholder="<%= t('folder_name') %>" type="text" class="w-full outline-none border-transparent focus:border-transparent focus:ring-0 bg-base-100 px-1" name="folder_name" value="<%= params[:folder_name].presence || TemplateFolder::DEFAULT_NAME %>" autocomplete="off">
</set-value> </set-value>
<set-value data-on="click" data-value="" data-input-id="folder_name" class="peer-focus-within:hidden whitespace-nowrap"> <set-value data-on="click" data-value="" data-input-id="folder_name" class="peer-focus-within:hidden whitespace-nowrap">
<label for="folder_name" data-clear-on-focus="true" class="shrink-0 link mr-1.5 cursor-pointer"> <label for="folder_name" data-clear-on-focus="true" class="shrink-0 link mr-1.5 cursor-pointer">
@@ -26,6 +18,6 @@
</folder-autocomplete> </folder-autocomplete>
</div> </div>
<div class="form-control"> <div class="form-control">
<%= f.button button_title(title: @base_template ? t('submit') : t('create'), disabled_with: t('creating')), class: 'base-button' %> <%= f.button button_title(title: t('create'), disabled_with: t('creating')), class: 'base-button' %>
</div> </div>
<% end %> <% end %>
+1 -1
View File
@@ -53,7 +53,7 @@
<% end %> <% end %>
<% if can?(:create, template) %> <% if can?(:create, template) %>
<span class="tooltip tooltip-left" data-tip="<%= t('clone') %>"> <span class="tooltip tooltip-left" data-tip="<%= t('clone') %>">
<a href="<%= new_template_path(base_template_id: template.id) %>" data-turbo-frame="modal" class="btn btn-xs hover:btn-outline bg-base-200 btn-circle"> <a href="<%= new_template_clone_path(template) %>" data-turbo-frame="modal" class="btn btn-xs hover:btn-outline bg-base-200 btn-circle">
<%= svg_icon('copy', class: 'w-4 h-4') %> <%= svg_icon('copy', class: 'w-4 h-4') %>
</a> </a>
</span> </span>
+1 -1
View File
@@ -63,7 +63,7 @@
<%= button_to button_title(title: t('archive'), disabled_with: t('archiving'), title_class: 'inline', icon: svg_icon('archive', class: 'w-6 h-6')), template_path(template), class: 'btn btn-outline btn-sm w-full', form_class: 'flex-1', method: :delete, data: { turbo_confirm: t('are_you_sure_') } %> <%= button_to button_title(title: t('archive'), disabled_with: t('archiving'), title_class: 'inline', icon: svg_icon('archive', class: 'w-6 h-6')), template_path(template), class: 'btn btn-outline btn-sm w-full', form_class: 'flex-1', method: :delete, data: { turbo_confirm: t('are_you_sure_') } %>
<% end %> <% end %>
<% if can?(:create, current_account.templates.new(author: current_user)) %> <% if can?(:create, current_account.templates.new(author: current_user)) %>
<%= link_to new_template_path(base_template_id: template.id), class: 'btn btn-outline btn-sm flex-1', data: { turbo_frame: :modal } do %> <%= link_to new_template_clone_path(template), class: 'btn btn-outline btn-sm flex-1', data: { turbo_frame: :modal } do %>
<span class="flex items-center justify-center space-x-2"> <span class="flex items-center justify-center space-x-2">
<%= svg_icon('copy', class: 'w-6 h-6') %> <%= svg_icon('copy', class: 'w-6 h-6') %>
<span class="inline"><%= t('clone') %></span> <span class="inline"><%= t('clone') %></span>
+1 -1
View File
@@ -6,4 +6,4 @@
<%= button_to nil, user_configs_path, method: :post, params: { user_config: { key: UserConfig::SHOW_APP_TOUR, value: true } }, class: 'hidden', id: 'start_tour_button' %> <%= button_to nil, user_configs_path, method: :post, params: { user_config: { key: UserConfig::SHOW_APP_TOUR, value: true } }, class: 'hidden', id: 'start_tour_button' %>
<% end %> <% end %>
<% end %> <% end %>
<template-builder class="grid" data-template="<%= @template_data %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder> <template-builder class="grid" data-template="<%= @template_data %>" data-custom-fields="<%= (current_account.account_configs.find_or_initialize_by(key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY).value || []).to_json %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
+29
View File
@@ -0,0 +1,29 @@
<%= form_for @template, url: template_clone_index_path(@base_template), data: { turbo_frame: :_top }, html: { autocomplete: :off } do |f| %>
<% accounts = Account.accessible_by(true_ability).where.not(id: true_user.account.testing_accounts).where(User.where(User.arel_table[:account_id].eq(Account.arel_table[:id])).arel.exists).active %>
<% if (can?(:manage, :tenants) || true_user != current_user) && accounts.where.not(id: current_account.id).exists? %>
<div class="form-control -mb-2 mt-2">
<%= select_tag :account_id, options_for_select([current_account, *accounts.order(:name)].uniq.map { |e| [e.name, e.id] }, current_account.id), required: true, class: 'base-select' %>
</div>
<% end %>
<div class="form-control mt-4">
<%= f.text_field :name, required: true, placeholder: t('document_name'), class: 'base-input', dir: 'auto' %>
</div>
<div class="mt-3 mb-4 flex items-center justify-between">
<label for="folder_name" class="cursor-pointer">
<%= svg_icon('folder', class: 'w-6 h-6') %>
</label>
<folder-autocomplete class="flex justify-between w-full">
<set-value data-on="blur" data-value="<%= TemplateFolder::DEFAULT_NAME %>" data-empty-only="true" class="peer w-full whitespace-nowrap">
<input id="folder_name" placeholder="<%= t('folder_name') %>" type="text" class="w-full outline-none border-transparent focus:border-transparent focus:ring-0 bg-base-100 px-1" name="folder_name" value="<%= params[:folder_name].presence || @base_template&.folder&.full_name || TemplateFolder::DEFAULT_NAME %>" autocomplete="off">
</set-value>
<set-value data-on="click" data-value="" data-input-id="folder_name" class="peer-focus-within:hidden whitespace-nowrap">
<label for="folder_name" data-clear-on-focus="true" class="shrink-0 link mr-1.5 cursor-pointer">
<%= t('change_folder') %>
</label>
</set-value>
</folder-autocomplete>
</div>
<div class="form-control">
<%= f.button button_title(title: t('submit'), disabled_with: t('creating')), class: 'base-button' %>
</div>
<% end %>
+3
View File
@@ -0,0 +1,3 @@
<%= render 'shared/turbo_modal', title: t('clone_template') do %>
<%= render 'templates_clone/form' %>
<% end %>
+3 -3
View File
@@ -105,8 +105,8 @@
</div> </div>
<div class="flex items-center space-x-1.5"> <div class="flex items-center space-x-1.5">
<div class="join"> <div class="join">
<% if @pagy.prev %> <% if @pagy.previous %>
<%= link_to '«', url_for(page: @pagy.prev, anchor: 'log'), class: 'join-item btn min-h-full h-10' %> <%= link_to '«', url_for(params: request.query_parameters.merge('page' => @pagy.previous), anchor: 'log'), class: 'join-item btn min-h-full h-10' %>
<% else %> <% else %>
<span class="join-item btn btn-disabled !bg-base-200 min-h-full h-10">«</span> <span class="join-item btn btn-disabled !bg-base-200 min-h-full h-10">«</span>
<% end %> <% end %>
@@ -114,7 +114,7 @@
<%= "Page #{@pagy.page}" %> <%= "Page #{@pagy.page}" %>
</span> </span>
<% if @pagy.next %> <% if @pagy.next %>
<%= link_to '»', url_for(page: @pagy.next, anchor: 'log'), class: 'join-item btn min-h-full h-10' %> <%= link_to '»', url_for(params: request.query_parameters.merge('page' => @pagy.next), anchor: 'log'), class: 'join-item btn min-h-full h-10' %>
<% else %> <% else %>
<span class="join-item btn btn-disabled !bg-base-200 min-h-full h-10">»</span> <span class="join-item btn btn-disabled !bg-base-200 min-h-full h-10">»</span>
<% end %> <% end %>
+1 -1
View File
@@ -17,7 +17,7 @@ Bundler.require(*Rails.groups)
module DocuSeal module DocuSeal
class Application < Rails::Application class Application < Rails::Application
config.load_defaults 8.0 config.load_defaults 8.1
config.autoload_lib(ignore: %w[assets tasks puma]) config.autoload_lib(ignore: %w[assets tasks puma])
+48
View File
@@ -1,6 +1,20 @@
# frozen_string_literal: true # frozen_string_literal: true
if ENV['RAILS_ENV'] == 'production' if ENV['RAILS_ENV'] == 'production'
if Process.uid.zero?
begin
workdir = ENV.fetch('WORKDIR', '.')
if File.exist?(workdir) && File.stat(workdir).uid != 2000
puts 'Changing the owner of the docuseal directory...' unless Dir.empty?(workdir)
FileUtils.chown_R(2000, 2000, workdir)
end
rescue StandardError
puts 'Unable to change docuseal directory owner'
end
end
if !ENV['AWS_SECRET_MANAGER_ID'].to_s.empty? if !ENV['AWS_SECRET_MANAGER_ID'].to_s.empty?
require 'aws-sdk-secretsmanager' require 'aws-sdk-secretsmanager'
@@ -30,12 +44,46 @@ if ENV['RAILS_ENV'] == 'production'
File.write(dotenv_path, default_env) File.write(dotenv_path, default_env)
end end
if Process.uid.zero?
begin
File.chown(0, 0, dotenv_path)
File.chmod(0o600, dotenv_path)
rescue StandardError
puts 'Unable to set dotenv mod'
end
end
database_url = ENV.fetch('DATABASE_URL', nil) database_url = ENV.fetch('DATABASE_URL', nil)
Dotenv.load(dotenv_path) Dotenv.load(dotenv_path)
ENV['DATABASE_URL'] = ENV['DATABASE_URL'].to_s.empty? ? database_url : ENV.fetch('DATABASE_URL', nil) ENV['DATABASE_URL'] = ENV['DATABASE_URL'].to_s.empty? ? database_url : ENV.fetch('DATABASE_URL', nil)
end end
unless Process.euid == 2000
begin
test_file = "#{ENV.fetch('WORKDIR', '.')}/test"
orig_euid = Process.euid
orig_egid = Process.egid
Process::Sys.setegid(2000)
Process::Sys.seteuid(2000)
File.open(test_file, 'w') { true }
rescue StandardError
Process::Sys.seteuid(orig_euid)
Process::Sys.setegid(orig_egid)
puts "Unable to run as 2000:2000, running as #{orig_euid}:#{orig_egid}"
ensure
begin
File.unlink(test_file)
rescue StandardError
nil
end
end
end
end end
if ENV['DATABASE_URL'].to_s.split('@').last.to_s.split('/').first.to_s.include?('_') if ENV['DATABASE_URL'].to_s.split('@').last.to_s.split('/').first.to_s.include?('_')
+1 -1
View File
@@ -86,7 +86,7 @@ Rails.application.configure do
user_name: ENV.fetch('SMTP_USERNAME', nil), user_name: ENV.fetch('SMTP_USERNAME', nil),
password: ENV.fetch('SMTP_PASSWORD', nil), password: ENV.fetch('SMTP_PASSWORD', nil),
authentication: ENV.fetch('SMTP_PASSWORD', nil).present? ? ENV.fetch('SMTP_AUTHENTICATION', 'plain') : nil, authentication: ENV.fetch('SMTP_PASSWORD', nil).present? ? ENV.fetch('SMTP_AUTHENTICATION', 'plain') : nil,
enable_starttls_auto: ENV['SMTP_ENABLE_STARTTLS_AUTO'] != 'false' enable_starttls: ENV['SMTP_ENABLE_STARTTLS'] != 'false'
}.compact }.compact
end end
+11
View File
@@ -0,0 +1,11 @@
# frozen_string_literal: true
autoload :CSV, 'csv'
autoload :CSVSafe, 'csv-safe'
autoload :RubyXL, 'rubyXL'
autoload :Zip, 'zip'
autoload :Numo, 'numo/narray'
autoload :OnnxRuntime, 'onnxruntime'
autoload :RQRCode, 'rqrcode'
autoload :ArabicLetterConnector, 'arabic-letter-connector/logic'
autoload :JWT, 'jwt'
-3
View File
@@ -1,3 +0,0 @@
# frozen_string_literal: true
require 'csv'
+1 -8
View File
@@ -1,10 +1,3 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'pagy/extras/countless' Pagy.options[:limit] = 10
Pagy::DEFAULT[:limit] = 10
Pagy::DEFAULT.freeze
ActiveSupport.on_load(:action_view) do
include Pagy::Frontend
end
+42
View File
@@ -29,6 +29,12 @@ en: &en
pro: Pro pro: Pro
thanks: Thanks thanks: Thanks
private: Private private: Private
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Make all newly created templates private to their creator and admins by default.
create_templates_with_admin_access_by_default: Create templates with admin access by default
require_email_2fa: Require email 2FA
when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: When checked, each signer must verify their email with a one-time code before accessing the document.
require_phone_2fa: Require phone 2FA
when_checked_each_signer_must_verify_their_phone_with_a_one_time_code_before_accessing_the_document: When checked, each signer must verify their phone with a one-time code before accessing the document.
select: Select select: Select
enabled: Enabled enabled: Enabled
disabled: Disabled disabled: Disabled
@@ -1025,6 +1031,12 @@ es: &es
stripe_account_has_been_connected: La cuenta de Stripe ha sido conectada. stripe_account_has_been_connected: La cuenta de Stripe ha sido conectada.
re_connect_stripe: Volver a conectar Stripe re_connect_stripe: Volver a conectar Stripe
private: Privado private: Privado
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Hacer que todas las plantillas recién creadas sean privadas para su creador y los administradores por defecto.
create_templates_with_admin_access_by_default: Crear plantillas con acceso de administrador por defecto
require_email_2fa: Requerir 2FA por correo electrónico
when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Cuando está marcado, cada firmante debe verificar su correo electrónico con un código de un solo uso antes de acceder al documento.
require_phone_2fa: Requerir 2FA por teléfono
when_checked_each_signer_must_verify_their_phone_with_a_one_time_code_before_accessing_the_document: Cuando está marcado, cada firmante debe verificar su teléfono con un código de un solo uso antes de acceder al documento.
resend_pending: Reenviar pendiente resend_pending: Reenviar pendiente
ensure_unique_recipients: Asegurar destinatarios únicos ensure_unique_recipients: Asegurar destinatarios únicos
require_phone_2fa_to_open: Requiere 2FA por teléfono para abrir require_phone_2fa_to_open: Requiere 2FA por teléfono para abrir
@@ -2003,6 +2015,12 @@ it: &it
stripe_account_has_been_connected: L'account Stripe è stato collegato. stripe_account_has_been_connected: L'account Stripe è stato collegato.
re_connect_stripe: Ricollega Stripe re_connect_stripe: Ricollega Stripe
private: Privato private: Privato
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Rendere tutte le nuove template private per il creatore e gli amministratori per impostazione predefinita.
create_templates_with_admin_access_by_default: Crea modelli con accesso amministratore per impostazione predefinita
require_email_2fa: Richiedi 2FA email
when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Quando selezionato, ogni firmatario deve verificare la propria email con un codice monouso prima di accedere al documento.
require_phone_2fa: Richiedi 2FA telefono
when_checked_each_signer_must_verify_their_phone_with_a_one_time_code_before_accessing_the_document: Quando selezionato, ogni firmatario deve verificare il proprio numero di telefono con un codice monouso prima di accedere al documento.
resend_pending: Reinvia in sospeso resend_pending: Reinvia in sospeso
ensure_unique_recipients: Assicurarsi destinatari unici ensure_unique_recipients: Assicurarsi destinatari unici
require_phone_2fa_to_open: Richiedi l'autenticazione a due fattori tramite telefono per aprire require_phone_2fa_to_open: Richiedi l'autenticazione a due fattori tramite telefono per aprire
@@ -2982,6 +3000,12 @@ fr: &fr
stripe_account_has_been_connected: Le compte Stripe a été connecté. stripe_account_has_been_connected: Le compte Stripe a été connecté.
re_connect_stripe: Reconnecter Stripe re_connect_stripe: Reconnecter Stripe
private: Privé private: Privé
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Rendre tous les nouveaux modèles privés pour leur créateur et les administrateurs par défaut.
create_templates_with_admin_access_by_default: Créer des modèles avec un accès administrateur par défaut
require_email_2fa: Exiger la 2FA par email
when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Lorsque coché, chaque signataire doit vérifier son email avec un code à usage unique avant d'accéder au document.
require_phone_2fa: Exiger la 2FA par téléphone
when_checked_each_signer_must_verify_their_phone_with_a_one_time_code_before_accessing_the_document: Lorsque coché, chaque signataire doit vérifier son numéro de téléphone avec un code à usage unique avant d'accéder au document.
resend_pending: Renvoyer en attente resend_pending: Renvoyer en attente
ensure_unique_recipients: Garantir des destinataires uniques ensure_unique_recipients: Garantir des destinataires uniques
require_phone_2fa_to_open: Exiger la 2FA par téléphone pour ouvrir require_phone_2fa_to_open: Exiger la 2FA par téléphone pour ouvrir
@@ -3957,6 +3981,12 @@ pt: &pt
stripe_account_has_been_connected: Conta Stripe foi conectada. stripe_account_has_been_connected: Conta Stripe foi conectada.
re_connect_stripe: Reconectar Stripe re_connect_stripe: Reconectar Stripe
private: Privado private: Privado
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Tornar todos os modelos recém-criados privados para seu criador e administradores por padrão.
create_templates_with_admin_access_by_default: Criar modelos com acesso de administrador por padrão
require_email_2fa: Exigir 2FA por email
when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Quando marcado, cada signatário deve verificar seu email com um código de uso único antes de acessar o documento.
require_phone_2fa: Exigir 2FA por telefone
when_checked_each_signer_must_verify_their_phone_with_a_one_time_code_before_accessing_the_document: Quando marcado, cada signatário deve verificar seu telefone com um código de uso único antes de acessar o documento.
resend_pending: Re-enviar pendente resend_pending: Re-enviar pendente
ensure_unique_recipients: Garantir destinatários únicos ensure_unique_recipients: Garantir destinatários únicos
require_phone_2fa_to_open: Necessário autenticação de dois fatores via telefone para abrir require_phone_2fa_to_open: Necessário autenticação de dois fatores via telefone para abrir
@@ -4921,6 +4951,12 @@ de: &de
pro: Pro pro: Pro
thanks: Danke thanks: Danke
private: Privat private: Privat
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Alle neu erstellten Vorlagen standardmäßig nur für ihren Ersteller und Administratoren sichtbar machen.
create_templates_with_admin_access_by_default: Vorlagen standardmäßig mit Administratorzugriff erstellen
require_email_2fa: E-Mail 2FA erforderlich
when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Wenn aktiviert, muss jeder Unterzeichner seine E-Mail mit einem einmaligen Code verifizieren, bevor er auf das Dokument zugreifen kann.
require_phone_2fa: Telefon 2FA erforderlich
when_checked_each_signer_must_verify_their_phone_with_a_one_time_code_before_accessing_the_document: Wenn aktiviert, muss jeder Unterzeichner sein Telefon mit einem einmaligen Code verifizieren, bevor er auf das Dokument zugreifen kann.
select: Auswählen select: Auswählen
enabled: Aktiviert enabled: Aktiviert
disabled: Deaktiviert disabled: Deaktiviert
@@ -6287,6 +6323,12 @@ nl: &nl
pro: Pro pro: Pro
thanks: Bedankt thanks: Bedankt
private: Privé private: Privé
make_all_newly_created_templates_private_to_their_creator_and_admins_by_default: Maak alle nieuw aangemaakte sjablonen standaard privé voor hun maker en admins.
create_templates_with_admin_access_by_default: Sjablonen standaard met admin-toegang maken
require_email_2fa: E-mail 2FA vereist
when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Wanneer aangevinkt, moet elke ondertekenaar zijn e-mail verifiëren met een eenmalige code voordat hij toegang krijgt tot het document.
require_phone_2fa: Telefoon 2FA vereist
when_checked_each_signer_must_verify_their_phone_with_a_one_time_code_before_accessing_the_document: Wanneer aangevinkt, moet elke ondertekenaar zijn telefoon verifiëren met een eenmalige code voordat hij toegang krijgt tot het document.
select: Selecteer select: Selecteer
enabled: Ingeschakeld enabled: Ingeschakeld
disabled: Uitgeschakeld disabled: Uitgeschakeld
+2
View File
@@ -53,6 +53,7 @@ Rails.application.routes.draw do
resources :verify_pdf_signature, only: %i[create] resources :verify_pdf_signature, only: %i[create]
resource :mfa_setup, only: %i[show new edit create destroy], controller: 'mfa_setup' resource :mfa_setup, only: %i[show new edit create destroy], controller: 'mfa_setup'
resources :account_configs, only: %i[create destroy] resources :account_configs, only: %i[create destroy]
resources :account_custom_fields, only: %i[create]
resources :user_configs, only: %i[create] resources :user_configs, only: %i[create]
resources :encrypted_user_configs, only: %i[destroy] resources :encrypted_user_configs, only: %i[destroy]
resources :timestamp_server, only: %i[create] resources :timestamp_server, only: %i[create]
@@ -92,6 +93,7 @@ Rails.application.routes.draw do
resources :templates, only: %i[index], controller: 'templates_dashboard' resources :templates, only: %i[index], controller: 'templates_dashboard'
resources :submissions_filters, only: %i[show], param: 'name' resources :submissions_filters, only: %i[show], param: 'name'
resources :templates, only: %i[new create edit update show destroy] do resources :templates, only: %i[new create edit update show destroy] do
resources :clone, only: %i[new create], controller: 'templates_clone'
resource :debug, only: %i[show], controller: 'templates_debug' if Rails.env.development? resource :debug, only: %i[show], controller: 'templates_debug' if Rails.env.development?
resources :documents, only: %i[index create], controller: 'template_documents' resources :documents, only: %i[index create], controller: 'template_documents'
resources :clone_and_replace, only: %i[create], controller: 'templates_clone_and_replace' resources :clone_and_replace, only: %i[create], controller: 'templates_clone_and_replace'
+1 -1
View File
@@ -8,7 +8,7 @@ default: &default
shakapacker_precompile: true shakapacker_precompile: true
webpack_compile_output: true webpack_compile_output: true
additional_paths: [] additional_paths: []
webpack_loader: 'babel' javascript_transpiler: 'babel'
compiler_strategy: digest compiler_strategy: digest
cache_manifest: false cache_manifest: false
ensure_consistent_versioning: false ensure_consistent_versioning: false
+1 -1
View File
@@ -26,7 +26,7 @@ google:
cache_control: "public, max-age=31536000" cache_control: "public, max-age=31536000"
azure: azure:
service: AzureStorage service: AzureBlob
storage_account_name: <%= ENV['AZURE_STORAGE_ACCOUNT_NAME'] %> storage_account_name: <%= ENV['AZURE_STORAGE_ACCOUNT_NAME'] %>
storage_access_key: <%= ENV['AZURE_STORAGE_ACCESS_KEY'] %> storage_access_key: <%= ENV['AZURE_STORAGE_ACCESS_KEY'] %>
container: <%= ENV['AZURE_CONTAINER'] %> container: <%= ENV['AZURE_CONTAINER'] %>
+3 -3
View File
@@ -13,9 +13,9 @@ services:
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/docuseal - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/docuseal
postgres: postgres:
image: postgres:15 image: postgres:18
volumes: volumes:
- './pg_data:/var/lib/postgresql/data' - './pg_data:/var/lib/postgresql/18/docker'
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
@@ -34,6 +34,6 @@ services:
- 443:443 - 443:443
- 443:443/udp - 443:443/udp
volumes: volumes:
- .:/data - ./caddy:/data/caddy
environment: environment:
- HOST=${HOST} - HOST=${HOST}
+6 -3
View File
@@ -45,6 +45,9 @@ module ActionMailerConfigsInterceptor
def build_smtp_configs_hash(email_configs) def build_smtp_configs_hash(email_configs)
value = email_configs.value value = email_configs.value
is_tls = value['security'] == 'tls' || (value['security'].blank? && value['port'].to_s == '465')
is_ssl = value['security'] == 'ssl'
{ {
user_name: value['username'], user_name: value['username'],
password: value['password'], password: value['password'],
@@ -53,11 +56,11 @@ module ActionMailerConfigsInterceptor
domain: value['domain'], domain: value['domain'],
openssl_verify_mode: value['security'] == 'noverify' ? OpenSSL::SSL::VERIFY_NONE : nil, openssl_verify_mode: value['security'] == 'noverify' ? OpenSSL::SSL::VERIFY_NONE : nil,
authentication: value['password'].present? ? value.fetch('authentication', 'plain') : nil, authentication: value['password'].present? ? value.fetch('authentication', 'plain') : nil,
enable_starttls_auto: value['security'] != 'tls', enable_starttls: !is_tls && !is_ssl,
open_timeout: OPEN_TIMEOUT, open_timeout: OPEN_TIMEOUT,
read_timeout: READ_TIMEOUT, read_timeout: READ_TIMEOUT,
ssl: value['security'] == 'ssl', ssl: is_ssl,
tls: value['security'] == 'tls' || (value['security'].blank? && value['port'].to_s == '465') tls: is_tls
}.compact_blank }.compact_blank
end end
end end
+7 -1
View File
@@ -39,6 +39,7 @@ module Docuseal
CERTS = JSON.parse(ENV.fetch('CERTS', '{}')) CERTS = JSON.parse(ENV.fetch('CERTS', '{}'))
TIMESERVER_URL = ENV.fetch('TIMESERVER_URL', nil) TIMESERVER_URL = ENV.fetch('TIMESERVER_URL', nil)
VERSION_FILE_PATH = Rails.root.join('.version') VERSION_FILE_PATH = Rails.root.join('.version')
VERSION_FILE2_PATH = Rails.public_path.join('version')
DEFAULT_URL_OPTIONS = { DEFAULT_URL_OPTIONS = {
host: HOST, host: HOST,
@@ -48,7 +49,12 @@ module Docuseal
module_function module_function
def version def version
@version ||= VERSION_FILE_PATH.read.strip if VERSION_FILE_PATH.exist? @version ||=
if VERSION_FILE_PATH.exist?
VERSION_FILE_PATH.read.strip
elsif VERSION_FILE2_PATH.exist?
VERSION_FILE2_PATH.each_line.first.to_s.strip
end
end end
def multitenant? def multitenant?
+1 -3
View File
@@ -364,9 +364,7 @@ class Pdfium
@closed @closed
end end
def form_handle delegate :form_handle, to: :@document
@document.form_handle
end
def ensure_not_closed! def ensure_not_closed!
raise PdfiumError, 'Page is closed.' if closed? raise PdfiumError, 'Page is closed.' if closed?
+3 -3
View File
@@ -9,7 +9,7 @@ Puma::Plugin.create do
@puma_pid = $PROCESS_ID @puma_pid = $PROCESS_ID
launcher.events.on_booted do launcher.events.after_booted do
@redis_server_pid = fork_redis @redis_server_pid = fork_redis
end end
@@ -19,8 +19,8 @@ Puma::Plugin.create do
stop_redis_server if Process.pid == @puma_pid stop_redis_server if Process.pid == @puma_pid
end end
launcher.events.on_stopped { stop_redis_server } launcher.events.after_stopped { stop_redis_server }
launcher.events.on_restart { stop_redis_server } launcher.events.before_restart { stop_redis_server }
end end
private private
+3 -3
View File
@@ -14,14 +14,14 @@ Puma::Plugin.create do
end end
def start(launcher) def start(launcher)
launcher.events.on_booted do launcher.events.after_booted do
next if Puma.stats_hash[:workers].to_i != 0 next if Puma.stats_hash[:workers].to_i != 0
start_sidekiq! start_sidekiq!
end end
launcher.events.on_stopped { Thread.new { @sidekiq&.stop }.join } launcher.events.after_stopped { Thread.new { @sidekiq&.stop }.join }
launcher.events.on_restart { Thread.new { @sidekiq&.stop }.join } launcher.events.before_restart { Thread.new { @sidekiq&.stop }.join }
end end
def fire_event(config, event) def fire_event(config, event)
@@ -115,6 +115,8 @@ module Submissions
next if item['invite_by_uuid'].blank? && item['optional_invite_by_uuid'].blank? next if item['invite_by_uuid'].blank? && item['optional_invite_by_uuid'].blank?
next if submission.template_submitters.any? { |e| e['uuid'] == item['uuid'] } next if submission.template_submitters.any? { |e| e['uuid'] == item['uuid'] }
item = item.merge('order' => submitter_attr['order']) if submitter_attr && submitter_attr['order'].present?
if index.zero? if index.zero?
submission.template_submitters.insert(1, item) submission.template_submitters.insert(1, item)
elsif submission.template_submitters.size > index elsif submission.template_submitters.size > index
+2 -2
View File
@@ -19,7 +19,7 @@ module Submissions
total_wait_time ||= 0 total_wait_time ||= 0
key = ['result_attachments', submitter.id].join(':') key = ['result_attachments', submitter.id].join(':')
return submitter.documents if ApplicationRecord.uncached { LockEvent.exists?(key:, event_name: :complete) } return submitter.documents.reset if ApplicationRecord.uncached { LockEvent.exists?(key:, event_name: :complete) }
events = ApplicationRecord.uncached { LockEvent.where(key:).order(:id).to_a } events = ApplicationRecord.uncached { LockEvent.where(key:).order(:id).to_a }
@@ -60,7 +60,7 @@ module Submissions
LockEvent.where(key: ['result_attachments', submitter.id].join(':')).order(:id).last LockEvent.where(key: ['result_attachments', submitter.id].join(':')).order(:id).last
end end
break submitter.documents.reload if last_event.event_name.in?(%w[complete fail]) break submitter.documents.reset if last_event.event_name.in?(%w[complete fail])
raise WaitForCompleteTimeout if total_wait_time > CHECK_COMPLETE_TIMEOUT raise WaitForCompleteTimeout if total_wait_time > CHECK_COMPLETE_TIMEOUT
end end
+18 -2
View File
@@ -116,6 +116,7 @@ module Submissions
configs = submission.account.account_configs.where(key: [AccountConfig::WITH_AUDIT_VALUES_KEY, configs = submission.account.account_configs.where(key: [AccountConfig::WITH_AUDIT_VALUES_KEY,
AccountConfig::WITH_SIGNATURE_ID, AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::WITH_FILE_LINKS_KEY, AccountConfig::WITH_FILE_LINKS_KEY,
AccountConfig::WITH_AUDIT_SENDER_KEY,
AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY]) AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY])
last_submitter = submission.submitters.select(&:completed_at).max_by(&:completed_at) last_submitter = submission.submitters.select(&:completed_at).max_by(&:completed_at)
@@ -123,6 +124,7 @@ module Submissions
with_signature_id = configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true with_signature_id = configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true
with_file_links = configs.find { |c| c.key == AccountConfig::WITH_FILE_LINKS_KEY }&.value == true with_file_links = configs.find { |c| c.key == AccountConfig::WITH_FILE_LINKS_KEY }&.value == true
with_audit_values = configs.find { |c| c.key == AccountConfig::WITH_AUDIT_VALUES_KEY }&.value != false with_audit_values = configs.find { |c| c.key == AccountConfig::WITH_AUDIT_VALUES_KEY }&.value != false
with_audit_sender = configs.find { |c| c.key == AccountConfig::WITH_AUDIT_SENDER_KEY }&.value == true
with_submitter_timezone = configs.find { |c| c.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true with_submitter_timezone = configs.find { |c| c.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true
timezone = account.timezone timezone = account.timezone
@@ -464,6 +466,11 @@ module Submissions
name].join(' ') name].join(' ')
I18n.t('submission_event_names.invite_party_by_html', invited_submitter_name:, I18n.t('submission_event_names.invite_party_by_html', invited_submitter_name:,
submitter_name:) submitter_name:)
elsif with_audit_sender && (event.event_type == 'send_email' || event.event_type == 'send_sms')
[
I18n.t("submission_event_names.#{event.event_type}_to_html", submitter_name:),
"<b>#{I18n.t(:from)}</b> #{submission.created_by_user.full_name} #{submission.created_by_user.email}"
].join("\n")
elsif event.event_type.include?('send_') elsif event.event_type.include?('send_')
I18n.t("submission_event_names.#{event.event_type}_to_html", submitter_name:) I18n.t("submission_event_names.#{event.event_type}_to_html", submitter_name:)
else else
@@ -472,11 +479,20 @@ module Submissions
bold_text, normal_text = text.match(%r{<b>(.*?)</b>(.*)}).captures bold_text, normal_text = text.match(%r{<b>(.*?)</b>(.*)}).captures
text_box = [{ text: bold_text, font: [FONT_NAME, { variant: :bold }] }, normal_text]
if text.include?("\n")
text_box = text.split("\n")[1..].reduce(text_box) do |acc, row|
bold_text, normal_text = row.match(%r{<b>(.*?)</b>(.*)}).captures
[*acc, "\n", { text: bold_text, font: [FONT_NAME, { variant: :bold }] }, normal_text]
end
end
[ [
"#{I18n.l(event.event_timestamp.in_time_zone(timezone), format: :long, locale: account.locale)} " \ "#{I18n.l(event.event_timestamp.in_time_zone(timezone), format: :long, locale: account.locale)} " \
"#{TimeUtils.timezone_abbr(timezone, event.event_timestamp)}", "#{TimeUtils.timezone_abbr(timezone, event.event_timestamp)}",
composer.document.layout.formatted_text_box([{ text: bold_text, font: [FONT_NAME, { variant: :bold }] }, composer.document.layout.formatted_text_box(text_box)
normal_text])
] ]
end end
@@ -4,8 +4,8 @@ module Submissions
module GenerateCombinedAttachment module GenerateCombinedAttachment
module_function module_function
def call(submitter) def call(submitter, with_audit: true)
pdf = build_combined_pdf(submitter) pdf = build_combined_pdf(submitter, with_audit:)
submission = submitter.submission submission = submitter.submission
account = submission.account account = submission.account
@@ -39,7 +39,7 @@ module Submissions
blob: ActiveStorage::Blob.create_and_upload!( blob: ActiveStorage::Blob.create_and_upload!(
io: io.tap(&:rewind), filename: "#{submission.name || submission.template.name}.pdf" io: io.tap(&:rewind), filename: "#{submission.name || submission.template.name}.pdf"
), ),
name: 'combined_document', name: with_audit ? 'combined_document' : 'merged_document',
record: submission record: submission
) )
end end
@@ -58,14 +58,16 @@ module Submissions
pdf.sign(io, write_options: { validate: false }, **sign_params) pdf.sign(io, write_options: { validate: false }, **sign_params)
end end
def build_combined_pdf(submitter) def build_combined_pdf(submitter, with_audit:)
pdfs_index = Submissions::GenerateResultAttachments.generate_pdfs(submitter) pdfs_index = Submissions::GenerateResultAttachments.generate_pdfs(submitter)
audit_trail = I18n.with_locale(submitter.account.locale) do if with_audit
Submissions::GenerateAuditTrail.build_audit_trail(submitter.submission) audit_trail = I18n.with_locale(submitter.account.locale) do
end Submissions::GenerateAuditTrail.build_audit_trail(submitter.submission)
end
audit_trail.dispatch_message(:complete_objects) audit_trail.dispatch_message(:complete_objects)
end
result = HexaPDF::Document.new result = HexaPDF::Document.new
@@ -79,7 +81,7 @@ module Submissions
pdf.pages.each { |page| result.pages << result.import(page) } pdf.pages.each { |page| result.pages << result.import(page) }
end end
audit_trail.pages.each { |page| result.pages << result.import(page) } audit_trail&.pages&.each { |page| result.pages << result.import(page) }
result result
end end
+63 -36
View File
@@ -5,7 +5,7 @@ module Submissions
module_function module_function
# rubocop:disable Metrics # rubocop:disable Metrics
def call(submission, values_hash: nil, submitter: nil) def call(submission, values_hash: nil, submitter: nil, merge: false)
values_hash ||= if submitter values_hash ||= if submitter
build_submitter_values_hash(submitter) build_submitter_values_hash(submitter)
else else
@@ -42,48 +42,74 @@ module Submissions
template = submission.template template = submission.template
image_pdfs = [] if merge
original_documents = submission.schema_documents.preload(:blob) result = HexaPDF::Document.new
result_attachments = (submission.template_schema || template.schema).each do |item|
(submission.template_schema || template.schema).filter_map do |item|
pdf = pdfs_index[item['attachment_uuid']] pdf = pdfs_index[item['attachment_uuid']]
next if pdf.nil? next unless pdf
if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image? pdf.dispatch_message(:complete_objects)
pdf = GenerateResultAttachments.normalize_image_pdf(pdf)
image_pdfs << pdf pdf.pages.each { |page| result.pages << result.import(page) }
end
build_pdf_attachment(pdf:, submission:, submitter:,
uuid: item['attachment_uuid'],
values_hash:,
name: item['name'])
end end
return ApplicationRecord.no_touching { result_attachments.map { |e| e.tap(&:save!) } } if image_pdfs.size < 2 attachment = build_pdf_attachment(
pdf: result,
images_pdf =
image_pdfs.each_with_object(HexaPDF::Document.new) do |pdf, doc|
pdf.pages.each { |page| doc.pages << doc.import(page) }
end
images_pdf = GenerateResultAttachments.normalize_image_pdf(images_pdf)
images_pdf_attachment =
build_pdf_attachment(
pdf: images_pdf,
submission:, submission:,
submitter:,
uuid: GenerateResultAttachments.images_pdf_uuid(original_documents.select(&:image?)),
values_hash:, values_hash:,
name: submission.name || template.name name: 'preview_merged_document',
filename: "#{submission.name || template.name}.pdf"
) )
ApplicationRecord.no_touching do ApplicationRecord.no_touching { attachment.save! }
(result_attachments + [images_pdf_attachment]).map { |e| e.tap(&:save!) }
[attachment]
else
image_pdfs = []
original_documents = submission.schema_documents.preload(:blob)
result_attachments =
(submission.template_schema || template.schema).filter_map do |item|
pdf = pdfs_index[item['attachment_uuid']]
next if pdf.nil?
if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image?
pdf = GenerateResultAttachments.normalize_image_pdf(pdf)
image_pdfs << pdf
end
build_pdf_attachment(pdf:, submission:, submitter:,
uuid: item['attachment_uuid'],
values_hash:,
filename: "#{item['name']}.pdf")
end
return ApplicationRecord.no_touching { result_attachments.map { |e| e.tap(&:save!) } } if image_pdfs.size < 2
images_pdf =
image_pdfs.each_with_object(HexaPDF::Document.new) do |pdf, doc|
pdf.pages.each { |page| doc.pages << doc.import(page) }
end
images_pdf = GenerateResultAttachments.normalize_image_pdf(images_pdf)
images_pdf_attachment =
build_pdf_attachment(
pdf: images_pdf,
submission:,
submitter:,
uuid: GenerateResultAttachments.images_pdf_uuid(original_documents.select(&:image?)),
values_hash:,
filename: "#{submission.name || template.name}.pdf"
)
ApplicationRecord.no_touching do
(result_attachments + [images_pdf_attachment]).map { |e| e.tap(&:save!) }
end
end end
end end
@@ -102,7 +128,8 @@ module Submissions
) )
end end
def build_pdf_attachment(pdf:, submission:, submitter:, uuid:, name:, values_hash:) def build_pdf_attachment(pdf:, submission:, filename:, values_hash:, submitter: nil, uuid: nil,
name: 'preview_documents')
io = StringIO.new io = StringIO.new
begin begin
@@ -114,13 +141,13 @@ module Submissions
end end
ActiveStorage::Attachment.new( ActiveStorage::Attachment.new(
blob: ActiveStorage::Blob.create_and_upload!(io: io.tap(&:rewind), filename: "#{name}.pdf"), blob: ActiveStorage::Blob.create_and_upload!(io: io.tap(&:rewind), filename:),
io_data: io.string, io_data: io.string,
metadata: { original_uuid: uuid, metadata: { original_uuid: uuid,
values_hash:, values_hash:,
analyzed: true, analyzed: true,
sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(io.string)) }, sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(io.string)) }.compact,
name: 'preview_documents', name: name,
record: submitter || submission record: submitter || submission
) )
end end
@@ -238,7 +238,7 @@ module Submissions
width = page.box.width width = page.box.width
height = page.box.height height = page.box.height
preferences_font_size = field.dig('preferences', 'font_size').then { |num| num.present? ? num.to_i : nil } preferences_font_size = field.dig('preferences', 'font_size').then { |num| num.presence&.to_i }
font_size = preferences_font_size font_size = preferences_font_size
font_size ||= (([page.box.width, page.box.height].min / A4_SIZE[0].to_f) * FONT_SIZE).to_i font_size ||= (([page.box.width, page.box.height].min / A4_SIZE[0].to_f) * FONT_SIZE).to_i
@@ -318,7 +318,7 @@ module Submissions
timezone = submitter.account.timezone timezone = submitter.account.timezone
timezone = submitter.timezone || submitter.account.timezone if with_submitter_timezone timezone = submitter.timezone || submitter.account.timezone if with_submitter_timezone
if with_signature_id_reason if with_signature_id_reason || field.dig('preferences', 'reasons').present?
"#{"#{I18n.t('reason')}: " if reason_value}#{reason_value || I18n.t('digitally_signed_by')} " \ "#{"#{I18n.t('reason')}: " if reason_value}#{reason_value || I18n.t('digitally_signed_by')} " \
"#{submitter.name}#{" <#{submitter.email}>" if submitter.email.present?}\n" \ "#{submitter.name}#{" <#{submitter.email}>" if submitter.email.present?}\n" \
"#{I18n.l(attachment.created_at.in_time_zone(timezone), format: :long)} " \ "#{I18n.l(attachment.created_at.in_time_zone(timezone), format: :long)} " \
+12 -8
View File
@@ -33,14 +33,18 @@ module Submissions
return submitter_params if default_values.blank? return submitter_params if default_values.blank?
values, new_attachments, new_fields = values, new_attachments, new_fields =
Submitters::NormalizeValues.call(template, Submitters::NormalizeValues.call(
default_values, template,
submitter_name: submitter_params[:role] || default_values,
template.submitters.dig(index, 'name'), submitter_name: submitter_params[:role] ||
role_names: submitter_params[:roles], (submitter_params[:uuid] &&
for_submitter:, template.submitters.find { |s| s['uuid'] == submitter_params[:uuid] }&.dig('name')) ||
add_fields:, template.submitters.dig(index, 'name'),
throw_errors: !with_values) role_names: submitter_params[:roles],
for_submitter:,
add_fields:,
throw_errors: !with_values
)
submitter_params[:values] = values submitter_params[:values] = values
+1 -1
View File
@@ -35,7 +35,7 @@ module Submissions
conn = Faraday.new(uri.origin) do |c| conn = Faraday.new(uri.origin) do |c|
c.options.read_timeout = TIMEOUT c.options.read_timeout = TIMEOUT
c.options.open_timeout = TIMEOUT c.options.open_timeout = TIMEOUT
c.basic_auth(uri.user, uri.password) if uri.password.present? c.request :authorization, :basic, uri.user, uri.password if uri.password.present?
end end
response = conn.post(uri.request_uri, build_payload(digest.digest), response = conn.post(uri.request_uri, build_payload(digest.digest),
+1 -1
View File
@@ -55,7 +55,7 @@ module Submitters
def serialize_events(events) def serialize_events(events)
events.map do |event| events.map do |event|
event.as_json(only: %i[id submitter_id event_type event_timestamp]) event.as_json(only: %i[id submitter_id event_type event_timestamp])
.merge('data' => event.data.slice('reason', 'firstname', 'lastname', 'method', 'country')) .merge('data' => event.data.slice('reason', 'firstname', 'lastname', 'method', 'country', 'idcode'))
end end
end end
end end
+21 -4
View File
@@ -318,21 +318,38 @@ module Submitters
end end
def check_field_condition(condition, submitter_values, fields_uuid_index) def check_field_condition(condition, submitter_values, fields_uuid_index)
value = submitter_values[condition['field_uuid']]
case condition['action'] case condition['action']
when 'empty', 'unchecked' when 'empty', 'unchecked'
submitter_values[condition['field_uuid']].blank? value.blank?
when 'not_empty', 'checked' when 'not_empty', 'checked'
submitter_values[condition['field_uuid']].present? value.present?
when 'equal', 'contains' when 'equal', 'contains'
field = fields_uuid_index[condition['field_uuid']] field = fields_uuid_index[condition['field_uuid']]
return true unless field
values = Array.wrap(value)
return values.include?(condition['value']) unless field['options']
option = field['options'].find { |o| o['uuid'] == condition['value'] } option = field['options'].find { |o| o['uuid'] == condition['value'] }
values = Array.wrap(submitter_values[condition['field_uuid']])
return false unless option
values.include?(option['value'].presence || "#{I18n.t('option')} #{field['options'].index(option) + 1}") values.include?(option['value'].presence || "#{I18n.t('option')} #{field['options'].index(option) + 1}")
when 'not_equal', 'does_not_contain' when 'not_equal', 'does_not_contain'
field = fields_uuid_index[condition['field_uuid']] field = fields_uuid_index[condition['field_uuid']]
return true unless field
return false unless field['options']
option = field['options'].find { |o| o['uuid'] == condition['value'] } option = field['options'].find { |o| o['uuid'] == condition['value'] }
values = Array.wrap(submitter_values[condition['field_uuid']])
return false unless option
values = Array.wrap(value)
values.exclude?(option['value'].presence || "#{I18n.t('option')} #{field['options'].index(option) + 1}") values.exclude?(option['value'].presence || "#{I18n.t('option')} #{field['options'].index(option) + 1}")
else else
+22 -15
View File
@@ -60,24 +60,26 @@ module Templates
# rubocop:disable Metrics, Style # rubocop:disable Metrics, Style
def call(io, attachment: nil, confidence: 0.3, temperature: 1, inference: Templates::ImageToFields, nms: 0.1, def call(io, attachment: nil, confidence: 0.3, temperature: 1, inference: Templates::ImageToFields, nms: 0.1,
split_page: false, aspect_ratio: true, padding: 20, regexp_type: true, &) nmm: 0.5, split_page: false, aspect_ratio: true, padding: 20, regexp_type: true, page_number: nil, &)
fields, head_node = fields, head_node =
if attachment&.image? if attachment&.image?
process_image_attachment(io, attachment:, confidence:, nms:, split_page:, inference:, process_image_attachment(io, attachment:, confidence:, nms:, nmm:, split_page:, inference:,
temperature:, aspect_ratio:, padding:, &) temperature:, aspect_ratio:, padding:, page_number:, &)
else else
process_pdf_attachment(io, attachment:, confidence:, nms:, split_page:, inference:, process_pdf_attachment(io, attachment:, confidence:, nms:, nmm:, split_page:, inference:,
temperature:, aspect_ratio:, regexp_type:, padding:, &) temperature:, aspect_ratio:, regexp_type:, padding:, page_number:, &)
end end
[fields, head_node] [fields, head_node]
end end
def process_image_attachment(io, attachment:, confidence:, nms:, temperature:, inference:, def process_image_attachment(io, attachment:, confidence:, nms:, nmm:, temperature:, inference:,
split_page: false, aspect_ratio: false, padding: nil) split_page: false, aspect_ratio: false, padding: nil, page_number: nil)
return [[], nil] if page_number && page_number != 0
image = Vips::Image.new_from_buffer(io.read, '') image = Vips::Image.new_from_buffer(io.read, '')
fields = inference.call(image, confidence:, nms:, split_page:, fields = inference.call(image, confidence:, nms:, nmm:, split_page:,
temperature:, aspect_ratio:, padding:) temperature:, aspect_ratio:, padding:)
fields = sort_fields(fields, y_threshold: 10.0 / image.height) fields = sort_fields(fields, y_threshold: 10.0 / image.height)
@@ -104,15 +106,20 @@ module Templates
[fields, nil] [fields, nil]
end end
def process_pdf_attachment(io, attachment:, confidence:, nms:, temperature:, inference:, def process_pdf_attachment(io, attachment:, confidence:, nms:, nmm:, temperature:, inference:,
split_page: false, aspect_ratio: false, padding: nil, regexp_type: false) split_page: false, aspect_ratio: false, padding: nil, regexp_type: false,
page_number: nil)
doc = Pdfium::Document.open_bytes(io.read) doc = Pdfium::Document.open_bytes(io.read)
head_node = PageNode.new(elem: ''.b, page: 0, attachment_uuid: attachment&.uuid) head_node = PageNode.new(elem: ''.b, page: 0, attachment_uuid: attachment&.uuid)
tail_node = head_node tail_node = head_node
fields = doc.page_count.times.flat_map do |page_number| page_range = page_number ? [page_number] : (0...doc.page_count)
page = doc.get_page(page_number)
fields = page_range.flat_map do |current_page_number|
next [] if current_page_number >= doc.page_count
page = doc.get_page(current_page_number)
size_key = page.width > page.height ? :width : :height size_key = page.width > page.height ? :width : :height
size = padding ? inference.resolution * 1.5 : inference.resolution size = padding ? inference.resolution * 1.5 : inference.resolution
@@ -121,7 +128,7 @@ module Templates
image = Vips::Image.new_from_memory(data, width, height, 4, :uchar) image = Vips::Image.new_from_memory(data, width, height, 4, :uchar)
fields = inference.call(image, confidence: confidence / 3.0, nms:, split_page:, fields = inference.call(image, confidence: confidence / 3.0, nms:, nmm:, split_page:,
temperature:, aspect_ratio:, padding:) temperature:, aspect_ratio:, padding:)
text_fields = extract_text_fields_from_page(page) text_fields = extract_text_fields_from_page(page)
@@ -149,13 +156,13 @@ module Templates
areas: [{ areas: [{
x: field.x, y: field.y, x: field.x, y: field.y,
w: field.w, h: field.h, w: field.w, h: field.h,
page: page_number, page: current_page_number,
attachment_uuid: attachment&.uuid attachment_uuid: attachment&.uuid
}] }]
} }
end end
yield [attachment&.uuid, page_number, fields] if block_given? yield [attachment&.uuid, current_page_number, fields] if block_given?
fields fields
ensure ensure
+79 -14
View File
@@ -26,7 +26,7 @@ module Templates
CPU_THREADS = Etc.nprocessors CPU_THREADS = Etc.nprocessors
# rubocop:disable Metrics # rubocop:disable Metrics
def call(image, confidence: 0.3, nms: 0.1, temperature: 1, def call(image, confidence: 0.3, nms: 0.1, nmm: 0.9, temperature: 1,
split_page: false, aspect_ratio: true, padding: nil, resolution: self.resolution) split_page: false, aspect_ratio: true, padding: nil, resolution: self.resolution)
image = image.extract_band(0, n: 3) if image.bands > 3 image = image.extract_band(0, n: 3) if image.bands > 3
@@ -68,7 +68,7 @@ module Templates
detections = postprocess_outputs(boxes, logits, transform_info, confidence:, temperature:, resolution:) detections = postprocess_outputs(boxes, logits, transform_info, confidence:, temperature:, resolution:)
end end
detections = apply_nms(detections, nms) detections = apply_nms_nmm(detections, nms_threshold: nms, nmm_threshold: nmm)
build_fields_from_detections(detections, image) build_fields_from_detections(detections, image)
end end
@@ -245,7 +245,7 @@ module Templates
left, top, trim_width, trim_height = image.find_trim(threshold: 10, background: [255, 255, 255]) left, top, trim_width, trim_height = image.find_trim(threshold: 10, background: [255, 255, 255])
trim_width = [trim_width, image.width * 0.7].max trim_width = [trim_width, image.width - (left * 1.9)].max
padded_left = [left - padding, 0].max padded_left = [left - padding, 0].max
padded_top = [top - padding, 0].max padded_top = [top - padding, 0].max
@@ -297,7 +297,10 @@ module Templates
[img_array.reshape(1, 3, resolution, resolution), transform_info] [img_array.reshape(1, 3, resolution, resolution), transform_info]
end end
def nms(boxes, scores, iou_threshold = 0.5) def nms(detections, iou_threshold = 0.5)
boxes = detections[:xyxy]
scores = detections[:confidence]
return Numo::Int32[] if boxes.shape[0].zero? return Numo::Int32[] if boxes.shape[0].zero?
x1 = boxes[true, 0] x1 = boxes[true, 0]
@@ -333,7 +336,73 @@ module Templates
order = order[inds + 1] order = order[inds + 1]
end end
Numo::Int32.cast(keep) {
xyxy: detections[:xyxy][keep, true],
confidence: detections[:confidence][keep],
class_id: detections[:class_id][keep]
}
end
def nmm(detections, overlap_threshold = 0.9, confidence: 0.3)
boxes = detections[:xyxy]
scores = detections[:confidence]
classes = detections[:class_id]
return detections if boxes.shape[0].zero?
x1 = boxes[true, 0]
y1 = boxes[true, 1]
x2 = boxes[true, 2]
y2 = boxes[true, 3]
areas = (x2 - x1) * (y2 - y1)
order = areas.sort_index.reverse
keep = []
while order.size.positive?
i = order[0]
keep << i
break if order.size == 1
xx1 = Numo::SFloat.maximum(x1[i], x1[order[1..]])
yy1 = Numo::SFloat.maximum(y1[i], y1[order[1..]])
xx2 = Numo::SFloat.minimum(x2[i], x2[order[1..]])
yy2 = Numo::SFloat.minimum(y2[i], y2[order[1..]])
w = Numo::SFloat.maximum(0.0, xx2 - xx1)
h = Numo::SFloat.maximum(0.0, yy2 - yy1)
intersection = w * h
overlap = intersection / areas[order[1..]]
merge_mask = scores[i] > confidence ? (overlap.gt(overlap_threshold) & classes[order[1..]].eq(classes[i])) : nil
if merge_mask && (merge_inds = merge_mask.where).size.positive?
candidates = order[merge_inds + 1]
scores[i] = [scores[i], scores[candidates].max].max
x1[i] = [x1[i], x1[candidates].min].min
y1[i] = [y1[i], y1[candidates].min].min
x2[i] = [x2[i], x2[candidates].max].max
y2[i] = [y2[i], y2[candidates].max].max
end
if merge_mask
inds = (~merge_mask).where
order = order[inds + 1]
else
order = order[1..]
end
end
{
xyxy: detections[:xyxy][keep, true],
confidence: detections[:confidence][keep],
class_id: detections[:class_id][keep]
}
end end
def postprocess_outputs(boxes, logits, transform_info, detections = nil, confidence: 0.3, temperature: 1, def postprocess_outputs(boxes, logits, transform_info, detections = nil, confidence: 0.3, temperature: 1,
@@ -429,25 +498,21 @@ module Templates
end end
end end
def apply_nms(detections, threshold = 0.5) def apply_nms_nmm(detections, nms_threshold: 0.5, nmm_threshold: 0.7, confidence: 0.3)
return detections if detections[:xyxy].shape[0].zero? return detections if detections[:xyxy].shape[0].zero?
keep_indices = nms(detections[:xyxy], detections[:confidence], threshold) nms_result = nms(detections, nms_threshold)
{ nmm(nms_result, nmm_threshold, confidence:)
xyxy: detections[:xyxy][keep_indices, true],
confidence: detections[:confidence][keep_indices],
class_id: detections[:class_id][keep_indices]
}
end end
def model def model
@model ||= OnnxRuntime::Model.new( @model ||= OnnxRuntime::Model.new(
MODEL_PATH.to_s, MODEL_PATH.to_s,
inter_op_num_threads: CPU_THREADS, inter_op_num_threads: 1,
intra_op_num_threads: CPU_THREADS, intra_op_num_threads: CPU_THREADS,
enable_mem_pattern: false, enable_mem_pattern: false,
enable_cpu_mem_arena: false, enable_cpu_mem_arena: Docuseal.multitenant? || Rails.env.development?,
providers: ['CPUExecutionProvider'] providers: ['CPUExecutionProvider']
) )
end end
+13 -11
View File
@@ -22,34 +22,36 @@
"canvas-confetti": "^1.6.0", "canvas-confetti": "^1.6.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"compression-webpack-plugin": "10.0.0", "compression-webpack-plugin": "11.1.0",
"css-loader": "^6.7.3", "css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^5.0.0", "css-minimizer-webpack-plugin": "^5.0.0",
"daisyui": "^3.9.4", "daisyui": "^3.9.4",
"driver.js": "^1.3.5", "driver.js": "^1.3.5",
"mathjs": "^12.4.0", "mathjs": "^12.4.0",
"mini-css-extract-plugin": "^2.7.5", "mini-css-extract-plugin": "^2.10.0",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"postcss-import": "^15.1.0", "postcss-import": "^15.1.0",
"postcss-loader": "^7.3.0", "postcss-loader": "^7.3.0",
"qr-creator": "^1.0.0", "qr-creator": "^1.0.0",
"rollbar": "^2.26.4", "rollbar": "^2.26.4",
"sass": "^1.62.1", "sass": "^1.62.1",
"sass-loader": "^13.2.2", "sass-loader": "^16.0.6",
"shakapacker": "8.0.0", "shakapacker": "9.5.0",
"signature_pad": "^4.1.5", "signature_pad": "^4.1.5",
"snarkdown": "^2.0.0", "snarkdown": "^2.0.0",
"style-loader": "^4.0.0",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"terser-webpack-plugin": "5.3.8", "terser-webpack-plugin": "5.3.16",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"vue": "^3.3.2", "vue": "^3.3.2",
"vue-loader": "^17.1.1", "vue-loader": "^17.1.1",
"webpack": "5.94.0", "webpack": "5.104.1",
"webpack-assets-manifest": "5", "webpack-assets-manifest": "6.5.0",
"webpack-bundle-analyzer": "^4.7.0", "webpack-bundle-analyzer": "^4.7.0",
"webpack-cli": "5.1.1", "webpack-cli": "6.0.1",
"webpack-dev-server": "^4.15.0", "webpack-dev-server": "^5.2.3",
"webpack-merge": "5" "webpack-merge": "6.0.1",
"webpack-subresource-integrity": "^5.1.0"
}, },
"packageManager": "yarn@1.0", "packageManager": "yarn@1.0",
"version": "0.1.0", "version": "0.1.0",
+1325 -666
View File
File diff suppressed because it is too large. Load diff