Merge branch 'master' into master

pull/697/head
Vs 1 month ago committed by GitHub
commit dfc12095ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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: 4.0.1 ruby-version: 4.0.5
- 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: 4.0.1 ruby-version: 4.0.5
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@ -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: 4.0.1 ruby-version: 4.0.5
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@ -104,7 +104,7 @@ jobs:
bundle install --jobs 4 --retry 4 bundle install --jobs 4 --retry 4
yarn install yarn install
sudo apt-get update sudo apt-get update
sudo apt-get install libvips sudo apt-get install libvips liblept5
- name: Run Brakeman - name: Run Brakeman
run: bundle exec brakeman -q --exit-on-warn run: bundle exec brakeman -q --exit-on-warn
@ -132,7 +132,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 4.0.1 ruby-version: 4.0.5
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
@ -162,8 +162,8 @@ jobs:
bundle install --jobs 4 --retry 4 bundle install --jobs 4 --retry 4
yarn install yarn install
sudo apt-get update sudo apt-get update
sudo apt-get install -y libvips sudo apt-get install -y libvips liblept5
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz"
sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so
rm -f pdfium-linux.tgz rm -f pdfium-linux.tgz
- name: Run - name: Run

@ -1,19 +1,23 @@
FROM ruby:4.0.1-alpine AS download FROM ruby:4.0.5-alpine AS download
WORKDIR /fonts WORKDIR /fonts
RUN apk --no-cache add wget && \ RUN apk --no-cache add wget unzip && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && \ wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && \ wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && \ wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && \
wget https://raw.githubusercontent.com/impallari/DancingScript/master/OFL.txt && \ wget https://raw.githubusercontent.com/impallari/DancingScript/master/OFL.txt && \
wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \ wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \ wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \
wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-musl-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \ wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260613/pdfium-musl-$(uname -m).zip" && \
case "$(uname -m)" in \
x86_64) echo "2c953ff72ee2dda07e7fc577e25841cc3d6464468a7c5adfaea574efcbc3b90b pdfium-linux.zip" ;; \
aarch64) echo "23bbe287d2753fdb05741c7660647eb0ef0d2e4da2ce0722bfa9d9d455bd64e2 pdfium-linux.zip" ;; \
esac | sha256sum -c - && \
mkdir -p /pdfium-linux && \ mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux unzip -q pdfium-linux.zip -d /pdfium-linux
FROM ruby:4.0.1-alpine AS webpack FROM ruby:4.0.5-alpine AS webpack
ENV RAILS_ENV=production ENV RAILS_ENV=production
ENV NODE_ENV=production ENV NODE_ENV=production
@ -40,7 +44,7 @@ COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:4.0.1-alpine AS app FROM ruby:4.0.5-alpine AS app
ENV RAILS_ENV=production ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test" ENV BUNDLE_WITHOUT="development:test"
@ -48,7 +52,8 @@ ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
WORKDIR /app WORKDIR /app
RUN apk add --no-cache libpq vips redis vips-heif onnxruntime RUN apk add --no-cache libpq vips redis onnxruntime leptonica && \
rm -f /usr/bin/onnx_test_runner /usr/bin/onnxruntime_test
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
@ -81,7 +86,7 @@ COPY --chown=docuseal:docuseal .version ./public/version
COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts/LICENSE /fonts/ COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts/LICENSE /fonts/
COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so COPY --from=download /pdfium-linux/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/ /usr/lib/libpdfium-licenses/
COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx
COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs
@ -94,6 +99,7 @@ WORKDIR /data/docuseal
ENV HOME=/home/docuseal ENV HOME=/home/docuseal
ENV WORKDIR=/data/docuseal ENV WORKDIR=/data/docuseal
ENV VIPS_MAX_COORD=17000 ENV VIPS_MAX_COORD=17000
ENV VIPS_BLOCK_UNTRUSTED=1
EXPOSE 3000 EXPOSE 3000
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"] CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]

@ -2,7 +2,7 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '~> 4.0' ruby '4.0.5'
gem 'addressable' gem 'addressable'
gem 'arabic-letter-connector', require: false gem 'arabic-letter-connector', require: false
@ -21,11 +21,9 @@ gem 'faraday'
gem 'faraday-follow_redirects' gem 'faraday-follow_redirects'
gem 'google-cloud-storage', require: false gem 'google-cloud-storage', require: false
gem 'hexapdf' gem 'hexapdf'
gem 'image_processing'
gem 'jwt', require: false gem 'jwt', require: false
gem 'lograge' gem 'lograge'
gem 'numo-narray-alt', require: false gem 'numo-narray-alt', require: false
gem 'oj'
gem 'onnxruntime', require: false gem 'onnxruntime', require: false
gem 'pagy' gem 'pagy'
gem 'pg', require: false gem 'pg', require: false

@ -116,7 +116,7 @@ GEM
erubi (~> 1.4) erubi (~> 1.4)
parser (>= 2.4) parser (>= 2.4)
smart_properties smart_properties
bigdecimal (4.1.0) bigdecimal (4.1.2)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.23.0) bootsnap (1.23.0)
msgpack (~> 1.2) msgpack (~> 1.2)
@ -144,7 +144,7 @@ GEM
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.6) concurrent-ruby (1.3.7)
connection_pool (3.0.2) connection_pool (3.0.2)
crack (1.0.1) crack (1.0.1)
bigdecimal bigdecimal
@ -161,7 +161,7 @@ GEM
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
declarative (0.0.20) declarative (0.0.20)
devise (5.0.3) devise (5.0.4)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
railties (>= 7.0) railties (>= 7.0)
@ -195,13 +195,13 @@ GEM
railties (>= 6.1.0) railties (>= 6.1.0)
faker (3.6.1) faker (3.6.1)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.14.1) faraday (2.14.3)
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.5)
json json
logger logger
faraday-follow_redirects (0.5.0) faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3) faraday (>= 1, < 3)
faraday-net_http (3.4.2) faraday-net_http (3.4.4)
net-http (~> 0.5) net-http (~> 0.5)
ferrum (0.17.2) ferrum (0.17.2)
addressable (~> 2.5) addressable (~> 2.5)
@ -264,18 +264,15 @@ GEM
strscan (>= 3.1.2) strscan (>= 3.1.2)
i18n (1.14.8) i18n (1.14.8)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
io-console (0.8.2) io-console (0.8.2)
irb (1.17.0) irb (1.18.0)
pp (>= 0.6.0) pp (>= 0.6.0)
prism (>= 1.3.0) prism (>= 1.3.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.19.3) json (2.19.9)
jwt (3.1.2) jwt (3.2.0)
base64 base64
language_server-protocol (3.17.0.5) language_server-protocol (3.17.0.5)
launchy (3.1.1) launchy (3.1.1)
@ -308,17 +305,15 @@ GEM
marcel (1.1.0) marcel (1.1.0)
matrix (0.4.3) matrix (0.4.3)
method_source (1.1.0) method_source (1.1.0)
mini_magick (5.3.1)
logger
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (6.0.3) minitest (6.0.6)
drb (~> 2.0) drb (~> 2.0)
prism (~> 1.5) prism (~> 1.5)
msgpack (1.8.0) msgpack (1.8.0)
multi_json (1.19.1) multi_json (1.19.1)
net-http (0.9.1) net-http (0.9.1)
uri (>= 0.11.1) uri (>= 0.11.1)
net-imap (0.6.4) net-imap (0.6.4.1)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@ -328,20 +323,17 @@ GEM
net-smtp (0.5.1) net-smtp (0.5.1)
net-protocol net-protocol
nio4r (2.7.5) nio4r (2.7.5)
nokogiri (1.19.3-aarch64-linux-gnu) nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.3-aarch64-linux-musl) nokogiri (1.19.4-aarch64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.3-arm64-darwin) nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu) nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-musl) nokogiri (1.19.4-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
numo-narray-alt (0.10.3) numo-narray-alt (0.10.3)
oj (3.16.16)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
onnxruntime (0.10.1-aarch64-linux) onnxruntime (0.10.1-aarch64-linux)
ffi ffi
onnxruntime (0.10.1-arm64-darwin) onnxruntime (0.10.1-arm64-darwin)
@ -351,7 +343,6 @@ GEM
openssl (4.0.1) openssl (4.0.1)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
ostruct (0.6.3)
package_json (0.2.0) package_json (0.2.0)
pagy (43.4.4) pagy (43.4.4)
json json
@ -382,7 +373,7 @@ GEM
date date
stringio stringio
public_suffix (7.0.5) public_suffix (7.0.5)
puma (7.2.0) puma (8.0.2)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.6) rack (3.2.6)
@ -429,12 +420,12 @@ GEM
tsort (>= 0.2) tsort (>= 0.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.3.1) rake (13.4.2)
rdoc (7.2.0) rdoc (7.2.0)
erb erb
psych (>= 4.0.0) psych (>= 4.0.0)
tsort tsort
redis-client (0.28.0) redis-client (0.29.0)
connection_pool connection_pool
regexp_parser (2.11.3) regexp_parser (2.11.3)
reline (0.6.3) reline (0.6.3)
@ -516,12 +507,12 @@ GEM
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 (8.1.2) sidekiq (8.1.6)
connection_pool (>= 3.0.0) connection_pool (>= 3.0.0)
json (>= 2.16.0) json (>= 2.16.0)
logger (>= 1.7.0) logger (>= 1.7.0)
rack (>= 3.2.0) rack (>= 3.2.0)
redis-client (>= 0.26.0) redis-client (>= 0.29.0)
signet (0.21.0) signet (0.21.0)
addressable (~> 2.8) addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a) faraday (>= 0.17.5, < 3.a)
@ -624,12 +615,10 @@ DEPENDENCIES
foreman foreman
google-cloud-storage google-cloud-storage
hexapdf hexapdf
image_processing
jwt jwt
letter_opener_web letter_opener_web
lograge lograge
numo-narray-alt numo-narray-alt
oj
onnxruntime onnxruntime
pagy pagy
pg pg
@ -662,7 +651,7 @@ DEPENDENCIES
webmock webmock
RUBY VERSION RUBY VERSION
ruby 4.0.1 ruby 4.0.5
BUNDLED WITH BUNDLED WITH
4.0.3 4.0.3

@ -9,7 +9,9 @@ module Api
before_action :set_cors_headers before_action :set_cors_headers
before_action :set_noindex_headers before_action :set_noindex_headers
before_action :set_security_headers
# rubocop:disable Metrics
def show def show
blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid]) blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
@ -21,6 +23,12 @@ module Api
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid) blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid)
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
Rollbar.error('Dangerous extension') if defined?(Rollbar)
return head :unprocessable_content
end
attachment = blob.attachments.take attachment = blob.attachments.take
@record = attachment.record @record = attachment.record
@ -45,6 +53,7 @@ module Api
end end
end end
end end
# rubocop:enable Metrics
private private

@ -9,6 +9,7 @@ module Api
before_action :set_cors_headers before_action :set_cors_headers
before_action :set_noindex_headers before_action :set_noindex_headers
before_action :set_security_headers
# rubocop:disable Metrics # rubocop:disable Metrics
def show def show
@ -18,6 +19,12 @@ module Api
return head :not_found unless blob return head :not_found unless blob
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
Rollbar.error('Dangerous extension') if defined?(Rollbar)
return head :unprocessable_content
end
is_permitted = blob.attachments.any? do |a| is_permitted = blob.attachments.any? do |a|
(current_user && a.record.account.id == current_user.account_id) || (current_user && a.record.account.id == current_user.account_id) ||
a.record.account.account_configs.any? { |e| e.key == 'legacy_blob_proxy' } || a.record.account.account_configs.any? { |e| e.key == 'legacy_blob_proxy' } ||

@ -102,6 +102,10 @@ module Api
headers['X-Robots-Tag'] = 'noindex' headers['X-Robots-Tag'] = 'noindex'
end end
def set_security_headers
response.headers['X-Content-Type-Options'] = 'nosniff'
end
def set_cors_headers def set_cors_headers
headers['Access-Control-Allow-Origin'] = '*' headers['Access-Control-Allow-Origin'] = '*'
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS' headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'

@ -11,13 +11,13 @@ module Api
@submitter = Submitter.find_by!(slug: params[:submitter_slug]) @submitter = Submitter.find_by!(slug: params[:submitter_slug])
unless can_upload?(@submitter) unless can_upload?(@submitter)
Rollbar.error("Can't upload: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
end end
file = params[:file]
if params[:type].in?(%w[initials signature]) if params[:type].in?(%w[initials signature])
image = Vips::Image.new_from_file(params[:file].path) image = ImageUtils.load_vips(file.read, content_type: file.content_type)
if ImageUtils.blank?(image) if ImageUtils.blank?(image)
Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar) Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar)
@ -31,9 +31,11 @@ module Api
return render json: { error: "#{params[:type]} error, try to sign on another device" }, return render json: { error: "#{params[:type]} error, try to sign on another device" },
status: :unprocessable_content status: :unprocessable_content
end end
metadata = { analyzed: true, identified: true, width: image.width, height: image.height }
end end
attachment = Submitters.create_attachment!(@submitter, params) attachment = Submitters.create_attachment!(@submitter, file, metadata:)
if params[:remember_signature] == 'true' && @submitter.email.present? if params[:remember_signature] == 'true' && @submitter.email.present?
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment) cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)

@ -2,6 +2,10 @@
module Api module Api
class SubmissionsController < ApiBaseController class SubmissionsController < ApiBaseController
SUBMISSION_COLUMNS = %i[id name slug source submitters_order expire_at created_at updated_at
archived_at variables template_id template_submitters created_by_user_id].freeze
TEMPLATE_COLUMNS = %i[id name external_id created_at updated_at folder_id submitters].freeze
load_and_authorize_resource :template, only: :create load_and_authorize_resource :template, only: :create
load_and_authorize_resource :submission, only: %i[show index destroy] load_and_authorize_resource :submission, only: %i[show index destroy]
@ -13,10 +17,22 @@ module Api
submissions = Submissions.search(current_user, @submissions, params[:q]) submissions = Submissions.search(current_user, @submissions, params[:q])
submissions = filter_submissions(submissions, params) submissions = filter_submissions(submissions, params)
submissions = paginate(submissions.preload(:created_by_user, :submitters, with_fields = params[:include].to_s.include?('fields') || params[:include].to_s.include?('combined_document_url')
template: { folder: :parent_folder },
combined_document_attachment: :blob, submissions = paginate(
audit_trail_attachment: :blob)) submissions.select(with_fields ? nil : SUBMISSION_COLUMNS)
.preload(:created_by_user, :submitters, combined_document_attachment: :blob,
audit_trail_attachment: :blob)
)
ActiveRecord::Associations::Preloader.new(
records: submissions,
associations: :template,
scope: with_fields ? nil : Template.select(TEMPLATE_COLUMNS)
).call
ActiveRecord::Associations::Preloader.new(records: submissions.filter_map(&:template),
associations: { folder: :parent_folder }).call
expires_at = Accounts.link_expires_at(current_account) expires_at = Accounts.link_expires_at(current_account)
@ -54,6 +70,12 @@ module Api
return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil? return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil?
if @template.archived_at?
Rollbar.warning("Archived template submission: #{@template.id}") if defined?(Rollbar)
return render json: { error: 'Template has been archived' }, status: :unprocessable_content
end
if @template.fields.blank? if @template.fields.blank?
Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar) Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar)

@ -104,7 +104,7 @@ module Api
private private
def maybe_filder_by_completed_at(submitters, params) def maybe_filter_by_completed_at(submitters, params)
if params[:completed_after].present? if params[:completed_after].present?
submitters = submitters.where(completed_at: Time.zone.parse(params[:completed_after])..) submitters = submitters.where(completed_at: Time.zone.parse(params[:completed_after])..)
end end
@ -177,7 +177,7 @@ module Api
submitters = submitters.joins(:submission).where(submissions: { template_id: params[:template_id] }) submitters = submitters.joins(:submission).where(submissions: { template_id: params[:template_id] })
end end
maybe_filder_by_completed_at(submitters, params) maybe_filter_by_completed_at(submitters, params)
end end
def assign_external_id(submitter, attrs) def assign_external_id(submitter, attrs)

@ -9,20 +9,7 @@ module Api
templates = paginate(templates.preload(:author, folder: :parent_folder)) templates = paginate(templates.preload(:author, folder: :parent_folder))
schema_documents = schema_documents, dynamic_documents, preview_image_attachments = preload_relations(templates)
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
record_type: 'Template',
name: :documents,
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
.preload(:blob)
preview_image_attachments =
ActiveStorage::Attachment.joins(:blob)
.where(blob: { filename: ['0.png', '0.jpg'] })
.where(record_id: schema_documents.map(&:id),
record_type: 'ActiveStorage::Attachment',
name: :preview_images)
.preload(:blob)
expires_at = Accounts.link_expires_at(current_account) expires_at = Accounts.link_expires_at(current_account)
@ -30,6 +17,7 @@ module Api
data: templates.map do |t| data: templates.map do |t|
Templates::SerializeForApi.call(t, Templates::SerializeForApi.call(t,
schema_documents: schema_documents.select { |e| e.record_id == t.id }, schema_documents: schema_documents.select { |e| e.record_id == t.id },
dynamic_documents:,
preview_image_attachments:, preview_image_attachments:,
expires_at:) expires_at:)
end, end,
@ -60,7 +48,7 @@ module Api
archived = params.key?(:archived) ? params[:archived] : params.dig(:template, :archived) archived = params.key?(:archived) ? params[:archived] : params.dig(:template, :archived)
if archived.in?([true, false]) if archived.in?([true, false]) && current_ability.can?(:destroy, @template)
@template.archived_at = archived == true ? Time.current : nil @template.archived_at = archived == true ? Time.current : nil
end end
@ -69,7 +57,10 @@ module Api
SearchEntries.enqueue_reindex(@template) SearchEntries.enqueue_reindex(@template)
WebhookUrls.enqueue_events(@template, 'template.updated') WebhookUrls.enqueue_events(@template, 'template.updated')
WebhookUrls.enqueue_events(@template, 'template.archived') if archived == true
if @template.saved_change_to_archived_at? && @template.archived_at?
WebhookUrls.enqueue_events(@template, 'template.archived')
end
render json: @template.as_json(only: %i[id updated_at]) render json: @template.as_json(only: %i[id updated_at])
end end
@ -88,6 +79,41 @@ module Api
private private
def preload_relations(templates)
schema_documents =
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
record_type: 'Template',
name: :documents,
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
.preload(:blob)
dynamic_document_uuids =
templates.flat_map { |t| t.schema.select { |item| item['dynamic'] }.pluck('attachment_uuid') }
dynamic_documents =
if dynamic_document_uuids.present?
DynamicDocument.where(template: templates.map(&:id))
.where(uuid: dynamic_document_uuids)
.preload(current_version: { document_attachment: :blob })
.select(:id, :uuid, :template_id, :sha1, :created_at, :updated_at)
else
DynamicDocument.none
end
preview_attachment_ids =
schema_documents.map(&:id) + dynamic_documents.filter_map { |d| d.current_version&.document_attachment&.id }
preview_image_attachments =
ActiveStorage::Attachment.joins(:blob)
.where(blob: { filename: ['0.png', '0.jpg'] })
.where(record_id: preview_attachment_ids,
record_type: 'ActiveStorage::Attachment',
name: :preview_images)
.preload(:blob)
[schema_documents, dynamic_documents, preview_image_attachments]
end
def filter_templates(templates, params) def filter_templates(templates, params)
templates = Templates.search(current_user, templates, params[:q]) templates = Templates.search(current_user, templates, params[:q])
templates = params[:archived].in?(['true', true]) ? templates.archived : templates.active templates = params[:archived].in?(['true', true]) ? templates.archived : templates.active

@ -25,7 +25,10 @@ class PreviewDocumentPageController < ActionController::API
preview_image = attachment.preview_images.joins(:blob) preview_image = attachment.preview_images.joins(:blob)
.find_by(blob: { filename: ["#{params[:id]}.png", "#{params[:id]}.jpg"] }) .find_by(blob: { filename: ["#{params[:id]}.png", "#{params[:id]}.jpg"] })
return redirect_to preview_image.url, allow_other_host: true if preview_image if preview_image
return redirect_to preview_image.url(time: ActiveStorage::Attachment.service_url_time),
allow_other_host: true
end
file_path = file_path =
if attachment.service.name == :disk if attachment.service.name == :disk
@ -37,7 +40,7 @@ class PreviewDocumentPageController < ActionController::API
preview_image = preview_image =
Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i) Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
redirect_to preview_image.url, allow_other_host: true redirect_to preview_image.url(time: ActiveStorage::Attachment.service_url_time), allow_other_host: true
end end
def find_or_create_document_tempfile_path(attachment) def find_or_create_document_tempfile_path(attachment)

@ -101,12 +101,23 @@ class StartFormController < ApplicationController
def load_resubmit_submitter def load_resubmit_submitter
@resubmit_submitter = @resubmit_submitter =
if params[:resubmit].present? && !params[:resubmit].in?([true, 'true']) if params[:resubmit].present? && !params[:resubmit].in?([true, 'true'])
Submitter.find_by(slug: params[:resubmit]) submitter = Submitter.find_by(slug: params[:resubmit])
submitter if submitter && can_resubmit?(submitter)
end end
end end
def can_resubmit?(submitter)
submitter.completed_at? && submitter.completed_at > 14.days.ago &&
%w[api embed mcp].exclude?(submitter.submission.source) &&
submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false
end
def authorize_start! def authorize_start!
return redirect_to start_form_path(@template.slug) if @template.archived_at? is_archived = @template.archived_at? || @template.account.archived_at?
return redirect_to submit_form_path(@resubmit_submitter.slug) if @resubmit_submitter && is_archived
return redirect_to start_form_path(@template.slug) if is_archived
return if @resubmit_submitter return if @resubmit_submitter
return if @template.shared_link? || (current_user && current_ability.can?(:read, @template)) return if @template.shared_link? || (current_user && current_ability.can?(:read, @template))

@ -7,7 +7,7 @@ class SubmissionsArchivedController < ApplicationController
@submissions = @submissions.left_joins(:template) @submissions = @submissions.left_joins(:template)
@submissions = @submissions.where.not(archived_at: nil) @submissions = @submissions.where.not(archived_at: nil)
.or(@submissions.where.not(templates: { archived_at: nil })) .or(@submissions.where.not(templates: { archived_at: nil }))
.preload(:template_accesses, :created_by_user, template: :author) .preload(:template_accesses, :created_by_user)
@submissions = Submissions.search(current_user, @submissions, params[:q], search_template: true) @submissions = Submissions.search(current_user, @submissions, params[:q], search_template: true)
@submissions = Submissions::Filter.call(@submissions, current_user, params) @submissions = Submissions::Filter.call(@submissions, current_user, params)
@ -18,6 +18,15 @@ class SubmissionsArchivedController < ApplicationController
@submissions.order(id: :desc) @submissions.order(id: :desc)
end end
@pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events)) @pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events))
template_scope = @submissions.all?(&:template_submitters) ? Template.select_for_list : nil
ActiveRecord::Associations::Preloader.new(records: @submissions,
associations: :template,
scope: template_scope).call
ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template),
associations: :author).call
end end
end end

@ -1,9 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class SubmissionsController < ApplicationController class SubmissionsController < ApplicationController
before_action :load_template, only: %i[new create] load_and_authorize_resource :template, only: %i[new create]
authorize_resource :template, only: %i[new create]
load_and_authorize_resource :submission, only: %i[show destroy] load_and_authorize_resource :submission, only: %i[show destroy]
prepend_before_action :maybe_redirect_com, only: %i[show] prepend_before_action :maybe_redirect_com, only: %i[show]
@ -38,6 +36,8 @@ class SubmissionsController < ApplicationController
end end
def create def create
return redirect_to template_path(@template), alert: I18n.t('template_has_been_archived') if @template.archived_at?
save_template_message(@template, params) if params[:save_message] == '1' save_template_message(@template, params) if params[:save_message] == '1'
[params.delete(:subject), params.delete(:body)] if params[:is_custom_message] != '1' [params.delete(:subject), params.delete(:body)] if params[:is_custom_message] != '1'
@ -111,8 +111,4 @@ class SubmissionsController < ApplicationController
def submissions_params def submissions_params
params.permit(submission: { submitters: [:uuid, :email, :phone, :name, { values: {} }] }) params.permit(submission: { submitters: [:uuid, :email, :phone, :name, { values: {} }] })
end end
def load_template
@template = Template.accessible_by(current_ability).find(params[:template_id])
end
end end

@ -8,7 +8,7 @@ class SubmissionsDashboardController < ApplicationController
@submissions = @submissions.where(archived_at: nil) @submissions = @submissions.where(archived_at: nil)
.where(templates: { archived_at: nil }) .where(templates: { archived_at: nil })
.preload(:template_accesses, :created_by_user, template: :author) .preload(:template_accesses, :created_by_user)
@submissions = Submissions.search(current_user, @submissions, params[:q], search_template: true) @submissions = Submissions.search(current_user, @submissions, params[:q], search_template: true)
@submissions = Submissions::Filter.call(@submissions, current_user, params) @submissions = Submissions::Filter.call(@submissions, current_user, params)
@ -19,6 +19,15 @@ class SubmissionsDashboardController < ApplicationController
@submissions.order(id: :desc) @submissions.order(id: :desc)
end end
@pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events)) @pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events))
template_scope = @submissions.all?(&:template_submitters) ? Template.select_for_list : nil
ActiveRecord::Associations::Preloader.new(records: @submissions,
associations: :template,
scope: template_scope).call
ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template),
associations: :author).call
end end
end end

@ -0,0 +1,39 @@
# frozen_string_literal: true
class SubmissionsResendEmailController < ApplicationController
load_and_authorize_resource :submission
before_action do
authorize!(:manage, :resend_all)
authorize!(:update, @submission)
end
def create
submitters = @submission.submitters.reject(&:completed_at?).select { |s| s.email.present? && !s.declined_at? }
if Docuseal.multitenant?
recent_submitter_ids =
SubmissionEvent.where(submitter_id: submitters.map(&:id),
event_type: 'send_email',
created_at: 10.hours.ago..Time.current).pluck(:submitter_id).to_set
submitters = submitters.reject { |s| recent_submitter_ids.include?(s.id) }
end
submitters.each do |submitter|
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => submitter.id)
submitter.sent_at ||= Time.current
submitter.save!
end
notice =
if submitters.empty?
I18n.t('email_has_been_sent_already')
else
I18n.t('emails_have_been_sent_to_n_recipients', count: submitters.size)
end
redirect_back(fallback_location: submission_path(@submission), notice:)
end
end

@ -4,6 +4,8 @@ class SubmissionsUnarchiveController < ApplicationController
load_and_authorize_resource :submission load_and_authorize_resource :submission
def create def create
authorize!(:update, @submission)
@submission.update!(archived_at: nil) @submission.update!(archived_at: nil)
redirect_to submission_path(@submission), notice: I18n.t('submission_has_been_unarchived') redirect_to submission_path(@submission), notice: I18n.t('submission_has_been_unarchived')

@ -7,7 +7,8 @@ class SubmitFormDeclineController < ApplicationController
before_action :load_submitter before_action :load_submitter
def create def create
return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? || return redirect_to submit_form_path(@submitter.slug) if declining_disabled? ||
@submitter.declined_at? ||
@submitter.completed_at? || @submitter.completed_at? ||
@submitter.submission.archived_at? || @submitter.submission.archived_at? ||
@submitter.submission.expired? || @submitter.submission.expired? ||
@ -35,6 +36,10 @@ class SubmitFormDeclineController < ApplicationController
private private
def declining_disabled?
@submitter.account.account_configs.find_by(key: AccountConfig::ALLOW_TO_DECLINE_KEY)&.value == false
end
def load_submitter def load_submitter
@submitter = Submitter.find_by!(slug: params[:submit_form_slug]) @submitter = Submitter.find_by!(slug: params[:submit_form_slug])
end end

@ -13,7 +13,7 @@ class SubmittersController < ApplicationController
def update def update
submission = @submitter.submission submission = @submitter.submission
if @submitter.submission_events.exists?(event_type: 'start_form') || submission.archived_at? || submission.expired? unless submitter_editable?(submission)
return redirect_back fallback_location: submission_path(submission), alert: I18n.t('submitter_cannot_be_updated') return redirect_back fallback_location: submission_path(submission), alert: I18n.t('submitter_cannot_be_updated')
end end
@ -48,6 +48,12 @@ class SubmittersController < ApplicationController
private private
def submitter_editable?(submission)
!@submitter.submission_events.exists?(event_type: 'start_form') &&
!@submitter.completed_at? && !@submitter.declined_at? &&
!submission.archived_at? && !submission.expired? && !submission.template&.archived_at?
end
def maybe_resend_email_sms(submitter, params) def maybe_resend_email_sms(submitter, params)
if params[:send_email] == '1' && submitter.email.present? if params[:send_email] == '1' && submitter.email.present?
is_sent_recently = Docuseal.multitenant? && is_sent_recently = Docuseal.multitenant? &&

@ -5,6 +5,8 @@ class SubmittersResubmitController < ApplicationController
def update def update
return redirect_to submit_form_path(slug: @submitter.slug) if @submitter.email != current_user.email return redirect_to submit_form_path(slug: @submitter.slug) if @submitter.email != current_user.email
return redirect_to submit_form_path(slug: @submitter.slug) if @submitter.completed_at.blank? ||
@submitter.completed_at < 1.month.ago
submission = @submitter.account.submissions.new(created_by_user: current_user, submission = @submitter.account.submissions.new(created_by_user: current_user,
submitters_order: :preserved, submitters_order: :preserved,

@ -4,6 +4,8 @@ class SubmittersSendEmailController < ApplicationController
load_and_authorize_resource :submitter load_and_authorize_resource :submitter
def create def create
authorize!(:update, @submitter)
if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter, if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter,
event_type: 'send_email', event_type: 'send_email',
created_at: 10.hours.ago..Time.current) created_at: 10.hours.ago..Time.current)

@ -10,6 +10,8 @@ class TemplateDocumentsController < ApplicationController
end end
def create def create
authorize!(:update, @template)
if params[:blobs].blank? && params[:files].blank? if params[:blobs].blank? && params[:files].blank?
return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_content return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_content
end end

@ -0,0 +1,39 @@
# frozen_string_literal: true
class TemplateDocumentsCropController < ApplicationController
load_and_authorize_resource :template
before_action :load_attachment
rescue_from Leptonica::LeptonicaError do
render json: { error: I18n.t(:unable_to_save) }, status: :unprocessable_content
end
def index
render json: { corners: Leptonica.detect_document_corners(@attachment.download) }
end
def create
authorize!(:update, @template)
document = Templates::CreateDocumentCrop.call(@template, @attachment, crop_params)
render json: {
document: document.as_json(
methods: %i[metadata signed_key],
include: {
preview_images: { methods: %i[url metadata filename] }
}
)
}
end
private
def load_attachment
@attachment = @template.documents_attachments.find_by!(uuid: params[:attachment_uuid])
end
def crop_params
params.permit(:scan, :rotate, :flip_h, :flip_v, corners: [%i[x y]])
end
end

@ -0,0 +1,32 @@
# frozen_string_literal: true
class TemplateDocumentsModifyController < ApplicationController
load_and_authorize_resource :template
def create
authorize!(:update, @template)
documents_layout =
params.require(:documents).map do |item|
item.permit(:attachment_uuid,
pages: [:attachment_uuid, :page, :rotate,
{ redact: [%i[x y w h color]], replaced_page: %i[attachment_uuid page] }]).to_h
end
Templates::ModifyDocuments.call(@template, documents_layout)
render json: {
schema: @template.schema,
fields: @template.fields,
submitters: @template.submitters,
documents: @template.schema_documents.reload.preload(:blob, preview_images_attachments: :blob).as_json(
methods: %i[metadata signed_key],
include: {
preview_images: { methods: %i[url metadata filename] }
}
)
}
rescue Templates::ModifyDocuments::InvalidLayout
render json: { error: I18n.t(:unable_to_save) }, status: :unprocessable_content
end
end

@ -0,0 +1,11 @@
# frozen_string_literal: true
class TemplateDocumentsPageObjectsController < ApplicationController
load_and_authorize_resource :template
def index
attachment = @template.documents_attachments.find_by!(uuid: params[:attachment_uuid])
render json: Templates::ModifyDocuments.page_objects(attachment, params[:page].to_i)
end
end

@ -30,9 +30,11 @@ class TemplateFoldersController < ApplicationController
(@template_folders.size < 7 ? 9 : 6) (@template_folders.size < 7 ? 9 : 6)
end end
@pagy, @templates = pagy_auto(@templates, limit:) @pagy, @templates = pagy_auto(@templates.select_for_list, limit:)
load_related_submissions if params[:q].present? && @templates.blank? if params[:q].present? && @templates.blank?
@related_submissions_pagy, @related_submissions = load_related_submissions(@template_folder)
end
else else
@pagy, @template_folders = pagy(@template_folders, limit: FOLDERS_PER_PAGE) @pagy, @template_folders = pagy(@template_folders, limit: FOLDERS_PER_PAGE)
@ -55,11 +57,10 @@ class TemplateFoldersController < ApplicationController
def selected_order def selected_order
@selected_order ||= @selected_order ||=
if cookies.permanent[:dashboard_templates_order].blank? || if can?(:manage, :countless)
(cookies.permanent[:dashboard_templates_order] == 'used_at' && can?(:manage, :countless))
'created_at' 'created_at'
else else
cookies.permanent[:dashboard_templates_order] cookies.permanent[:dashboard_templates_order].presence || 'created_at'
end end
end end
@ -67,20 +68,20 @@ class TemplateFoldersController < ApplicationController
params.require(:template_folder).permit(:name) params.require(:template_folder).permit(:name)
end end
def load_related_submissions def load_related_submissions(template_folder)
@related_submissions = related_submissions =
Submission.accessible_by(current_ability) Submission.accessible_by(current_ability)
.where(archived_at: nil) .where(archived_at: nil)
.where(template_id: current_account.templates.active .where(template_id: current_account.templates.active
.where(folder: [@template_folder, *@template_folder.subfolders]) .where(folder: [template_folder, *template_folder.subfolders])
.select(:id)) .select(:id))
.preload(:template_accesses, :created_by_user, .preload(:template_accesses, :created_by_user,
template: :author, template: :author,
submitters: :start_form_submission_events) submitters: :start_form_submission_events)
@related_submissions = Submissions.search(current_user, @related_submissions, params[:q]) related_submissions = Submissions.search(current_user, related_submissions, params[:q])
.order(id: :desc) .order(id: :desc)
@related_submissions_pagy, @related_submissions = pagy_auto(@related_submissions, limit: 5) pagy_auto(related_submissions.select_for_list, limit: 5)
end end
end end

@ -10,11 +10,17 @@ class TemplatesArchivedController < ApplicationController
@templates = Templates.search(current_user, @templates, params[:q]) @templates = Templates.search(current_user, @templates, params[:q])
@pagy, @templates = pagy_auto(@templates, limit: 12) @pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12)
return unless params[:q].present? && @templates.blank? return unless params[:q].present? && @templates.blank?
@related_submissions = @related_submissions_pagy, @related_submissions = load_related_submissions
end
private
def load_related_submissions
related_submissions =
Submission.accessible_by(current_ability) Submission.accessible_by(current_ability)
.joins(:template) .joins(:template)
.where.not(templates: { archived_at: nil }) .where.not(templates: { archived_at: nil })
@ -22,9 +28,9 @@ class TemplatesArchivedController < ApplicationController
template: :author, template: :author,
submitters: :start_form_submission_events) submitters: :start_form_submission_events)
@related_submissions = Submissions.search(current_user, @related_submissions, params[:q]) related_submissions = Submissions.search(current_user, related_submissions, params[:q])
.order(id: :desc) .order(id: :desc)
@related_submissions_pagy, @related_submissions = pagy_auto(@related_submissions, limit: 5) pagy_auto(related_submissions.select_for_list, limit: 5)
end end
end end

@ -15,7 +15,7 @@ class TemplatesArchivedSubmissionsController < ApplicationController
@submissions.order(id: :desc) @submissions.order(id: :desc)
end end
@pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events)) @pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events))
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
redirect_to root_path redirect_to root_path
end end

@ -13,6 +13,9 @@ class TemplatesCloneAndReplaceController < ApplicationController
cloned_template = Templates::Clone.call(@template, author: current_user) cloned_template = Templates::Clone.call(@template, author: current_user)
cloned_template.name = File.basename(params[:files].first.original_filename, '.*') cloned_template.name = File.basename(params[:files].first.original_filename, '.*')
authorize!(:create, cloned_template)
cloned_template.save! cloned_template.save!
documents = Templates::ReplaceAttachments.call(cloned_template, params, extract_fields: true) documents = Templates::ReplaceAttachments.call(cloned_template, params, extract_fields: true)

@ -1,10 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class TemplatesController < ApplicationController class TemplatesController < ApplicationController
TEMPLATE_FIELDS = %i[id author_id folder_id external_id name slug
schema fields submitters variables_schema preferences
shared_link source archived_at created_at updated_at].freeze
load_and_authorize_resource :template load_and_authorize_resource :template
def show def show
@ -23,7 +19,8 @@ class TemplatesController < ApplicationController
submissions.order(id: :desc) submissions.order(id: :desc)
end end
@pagy, @submissions = pagy_auto(submissions.preload(:template_accesses, submitters: :start_form_submission_events)) @pagy, @submissions =
pagy_auto(submissions.select_for_list.preload(:template_accesses, submitters: :start_form_submission_events))
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
redirect_to root_path redirect_to root_path
end end
@ -31,19 +28,7 @@ class TemplatesController < ApplicationController
def new; end def new; end
def edit def edit
ActiveRecord::Associations::Preloader.new( @template_data = Templates.serialize_for_builder(@template)
records: [@template],
associations: [{ schema_documents: [:blob, { preview_images_attachments: :blob }] }]
).call
@template_data =
@template.as_json(only: TEMPLATE_FIELDS).merge(
documents: @template.schema_documents.as_json(
only: %i[id uuid],
methods: %i[metadata signed_key],
include: { preview_images: { only: %i[id], methods: %i[url metadata filename] } }
)
).to_json
render :edit, layout: 'plain' render :edit, layout: 'plain'
end end
@ -77,8 +62,6 @@ class TemplatesController < ApplicationController
WebhookUrls.enqueue_events(@template, 'template.updated') WebhookUrls.enqueue_events(@template, 'template.updated')
TemplateVersions.find_or_create_for(@template, author: current_user) if params[:revision]
head :ok head :ok
end end

@ -11,86 +11,126 @@ class TemplatesDashboardController < ApplicationController
helper_method :selected_order helper_method :selected_order
def index def index
@default_folder = current_account.default_template_folder
@template_folders = @template_folders =
TemplateFolders.filter_active_folders(@template_folders.where(parent_folder_id: nil), @templates) TemplateFolders.filter_active_folders(@template_folders.where(parent_folder_id: nil), @templates)
@template_folders = @template_folders.where.not(id: @default_folder.id) if params[:q].blank?
@template_folders = TemplateFolders.search(@template_folders, params[:q]) @template_folders = TemplateFolders.search(@template_folders, params[:q])
@template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order) @template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order)
@pagy, @template_folders = pagy( @shared_templates = Templates.shared(current_user).active
@template_folders,
limit: FOLDERS_PER_PAGE, @pagy, @template_folders, @show_default_folder, @show_shared_folder, @show_shared_inline =
page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1 load_folders(@template_folders, @templates, @shared_templates)
)
if @pagy.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD if @pagy.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD
@templates = @templates.none @templates = @templates.none
else else
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME } if @show_shared_inline
@templates = filter_templates(@templates).preload(:author, :template_accesses) @templates = @shared_templates.preload(:template_sharings)
@templates = Templates::Order.call(@templates, current_user, selected_order) else
@templates = @templates.active
limit = @templates = @templates.where(folder_id: @default_folder.id) if params[:q].blank?
if @template_folders.size < 4 end
TEMPLATES_PER_PAGE
else
(@template_folders.size < 7 ? 9 : 6)
end
@pagy, @templates = pagy_auto(@templates, limit:) @pagy, @templates = load_templates(@templates.select_for_list, @pagy.count,
show_shared_inline: @show_shared_inline)
load_related_submissions if params[:q].present? && @templates.blank? if params[:q].present? && @templates.blank?
@related_submissions_pagy, @related_submissions = load_related_submissions
end
end end
end end
private private
def filter_templates(templates) def load_templates(templates, folders_count, show_shared_inline: false)
rel = templates.active templates = templates.preload(:author, :template_accesses)
if params[:q].blank? templates =
if Docuseal.multitenant? ? current_account.testing? : current_account.linked_account_account if show_shared_inline
shared_account_ids = [current_user.account_id] Templates.search_shared(current_user, templates, params[:q])
shared_account_ids << TemplateSharing::ALL_ID if !Docuseal.multitenant? && !current_account.testing? else
Templates.search(current_user, templates, params[:q])
end
shared_template_ids = TemplateSharing.where(account_id: shared_account_ids).select(:template_id) templates = Templates::Order.call(templates, current_user, selected_order)
rel = Template.where( limit =
Template.arel_table[:id].in( if folders_count < 4
rel.where(folder_id: current_account.default_template_folder.id).select(:id).arel TEMPLATES_PER_PAGE
.union(:all, shared_template_ids.arel)
)
)
else else
rel = rel.where(folder_id: current_account.default_template_folder.id) (folders_count < 7 ? 9 : 6)
end end
pagy_auto(templates, limit:)
end
def load_folders(template_folders, templates, shared_templates)
if params[:q].present?
pagy(template_folders, limit: FOLDERS_PER_PAGE,
page: template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1)
else
load_folders_with_pinned(template_folders, templates, shared_templates)
end end
end
def load_folders_with_pinned(template_folders, templates, shared_templates)
folders_count = template_folders.count
shared_exists = shared_templates.exists?
default_has_templates = templates.active.exists?(folder_id: current_account.default_template_folder.id)
show_inline_folders =
folders_count + (shared_exists && default_has_templates ? 1 : 0) <= SHOW_TEMPLATES_FOLDERS_THRESHOLD
show_shared_inline = shared_exists && !default_has_templates && show_inline_folders
show_shared_in_grid = shared_exists && !show_shared_inline
show_default_in_grid = !show_inline_folders && default_has_templates
pinned_count = (show_default_in_grid ? 1 : 0) + (show_shared_in_grid ? 1 : 0)
pagy = Pagy::Offset.new(count: folders_count + pinned_count,
page: show_inline_folders ? 1 : [params[:page].to_s.to_i, 1].max,
limit: FOLDERS_PER_PAGE,
raise_range_error: true)
show_default_folder = show_default_in_grid && pagy.page == 1
show_shared_folder = show_shared_in_grid && pagy.page == 1
folder_offset = pagy.page == 1 ? 0 : pagy.offset - pinned_count
folder_limit = pagy.page == 1 ? FOLDERS_PER_PAGE - pinned_count : FOLDERS_PER_PAGE
template_folders = template_folders.offset(folder_offset).limit(folder_limit)
Templates.search(current_user, rel, params[:q]) [pagy, template_folders, show_default_folder, show_shared_folder, show_shared_inline]
end end
def selected_order def selected_order
@selected_order ||= @selected_order ||=
if cookies.permanent[:dashboard_templates_order].blank? || if can?(:manage, :countless)
(cookies.permanent[:dashboard_templates_order] == 'used_at' && can?(:manage, :countless))
'created_at' 'created_at'
else else
cookies.permanent[:dashboard_templates_order] cookies.permanent[:dashboard_templates_order].presence || 'created_at'
end end
end end
def load_related_submissions def load_related_submissions
@related_submissions = Submission.accessible_by(current_ability) related_submissions = Submission.accessible_by(current_ability)
.left_joins(:template) .left_joins(:template)
.where(archived_at: nil) .where(archived_at: nil)
.where(templates: { archived_at: nil }) .where(templates: { archived_at: nil })
.preload(:template_accesses, :created_by_user, .preload(:template_accesses, :created_by_user,
template: :author, template: :author,
submitters: :start_form_submission_events) submitters: :start_form_submission_events)
@related_submissions = Submissions.search(current_user, @related_submissions, params[:q]) related_submissions = Submissions.search(current_user, related_submissions, params[:q])
.order(id: :desc) .order(id: :desc)
@related_submissions_pagy, @related_submissions = pagy_auto(@related_submissions, limit: 5) pagy_auto(related_submissions.select_for_list, limit: 5)
end end
end end

@ -16,7 +16,12 @@ class TemplatesDetectFieldsController < ApplicationController
page_number = params[:page].presence&.to_i page_number = params[:page].presence&.to_i
documents.each do |document| documents.each do |document|
io = StringIO.new(document.download) io =
if document.image?
StringIO.new(document.preview_images.joins(:blob).find_by(blob: { filename: ['0.png', '0.jpg'] }).download)
else
StringIO.new(document.download)
end
Templates::DetectFields.call(io, attachment: document, page_number:) 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: })

@ -6,6 +6,8 @@ class TemplatesFoldersController < ApplicationController
def edit; end def edit; end
def update def update
authorize!(:update, @template)
name = [params[:parent_name], params[:name]].compact_blank.join(' / ') name = [params[:parent_name], params[:name]].compact_blank.join(' / ')
@template.folder = TemplateFolders.find_or_create_by_name(current_user, name) @template.folder = TemplateFolders.find_or_create_by_name(current_user, name)

@ -4,18 +4,7 @@ class TemplatesPreviewController < ApplicationController
load_and_authorize_resource :template load_and_authorize_resource :template
def show def show
ActiveRecord::Associations::Preloader.new( @template_data = Templates.serialize_for_builder(@template)
records: [@template],
associations: [{ schema_documents: { preview_images_attachments: :blob } }]
).call
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
render :show, layout: 'plain' render :show, layout: 'plain'
end end

@ -4,6 +4,8 @@ class TemplatesRestoreController < ApplicationController
load_and_authorize_resource :template load_and_authorize_resource :template
def create def create
authorize!(:destroy, @template)
@template.update!(archived_at: nil) @template.update!(archived_at: nil)
WebhookUrls.enqueue_events(@template, 'template.updated') WebhookUrls.enqueue_events(@template, 'template.updated')

@ -0,0 +1,47 @@
# frozen_string_literal: true
class TemplatesSharedController < ApplicationController
def index
authorize!(:read, Template)
@is_archived = params[:archived] == 'true'
@templates = Templates.shared(current_user)
@has_archived = !@is_archived && @templates.archived.exists?
@templates = @is_archived ? @templates.archived : @templates.active
@templates = @templates.preload(:author, :template_accesses, :template_sharings)
.order(id: :desc)
@templates = Templates.search_shared(current_user, @templates, params[:q])
@pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12)
return unless params[:q].present? && @templates.blank?
@related_submissions_pagy, @related_submissions = load_related_submissions(is_archived: @is_archived)
end
private
def load_related_submissions(is_archived:)
shared_templates = Templates.shared(current_user)
shared_templates = is_archived ? shared_templates.archived : shared_templates.active
related_submissions =
Submission.accessible_by(current_ability)
.where(template_id: shared_templates.select(:id))
.preload(:template_accesses, :created_by_user,
template: :author,
submitters: :start_form_submission_events)
related_submissions = related_submissions.where(archived_at: nil) unless is_archived
related_submissions = Submissions.search(current_user, related_submissions, params[:q])
.order(id: :desc)
pagy_auto(related_submissions.select_for_list, limit: 5)
end
end

@ -5,7 +5,9 @@ class TemplatesUploadsController < ApplicationController
layout 'plain' layout 'plain'
def show; end def show
redirect_to root_path if params[:url].blank?
end
def create def create
url_params = create_file_params_from_url if params[:url].present? url_params = create_file_params_from_url if params[:url].present?

@ -14,4 +14,12 @@ class TemplatesVersionsController < ApplicationController
render json: TemplateVersions.serialize(version) render json: TemplateVersions.serialize(version)
end end
def create
authorize!(:update, @template)
TemplateVersions.find_or_create_for(@template, author: current_user)
head :ok
end
end end

@ -11,6 +11,12 @@ class UserInitialsController < ApplicationController
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_initials') if file.blank? return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_initials') if file.blank?
extension = File.extname(file.original_filename).delete_prefix('.').downcase
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
blob = ActiveStorage::Blob.create_and_upload!(io: file.open, blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename, filename: file.original_filename,
content_type: file.content_type) content_type: file.content_type)

@ -11,6 +11,12 @@ class UserSignaturesController < ApplicationController
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_signature') if file.blank? return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_signature') if file.blank?
extension = File.extname(file.original_filename).delete_prefix('.').downcase
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
end
blob = ActiveStorage::Blob.create_and_upload!(io: file.open, blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename, filename: file.original_filename,
content_type: file.content_type) content_type: file.content_type)

@ -21,6 +21,7 @@ import SubmittersAutocomplete from './elements/submitter_autocomplete'
import FolderAutocomplete from './elements/folder_autocomplete' import FolderAutocomplete from './elements/folder_autocomplete'
import SignatureForm from './elements/signature_form' import SignatureForm from './elements/signature_form'
import SubmitForm from './elements/submit_form' import SubmitForm from './elements/submit_form'
import ConvertUpload from './elements/convert_upload'
import PromptPassword from './elements/prompt_password' import PromptPassword from './elements/prompt_password'
import EmailsTextarea from './elements/emails_textarea' import EmailsTextarea from './elements/emails_textarea'
import ToggleSubmit from './elements/toggle_submit' import ToggleSubmit from './elements/toggle_submit'
@ -47,13 +48,13 @@ import ScrollTo from './elements/scroll_to'
import SetValue from './elements/set_value' import SetValue from './elements/set_value'
import ReviewForm from './elements/review_form' import ReviewForm from './elements/review_form'
import ShowOnValue from './elements/show_on_value' import ShowOnValue from './elements/show_on_value'
import CustomValidation from './elements/custom_validation'
import ToggleClasses from './elements/toggle_classes' import ToggleClasses from './elements/toggle_classes'
import AutosizeField from './elements/autosize_field' import AutosizeField from './elements/autosize_field'
import GoogleDriveFilePicker from './elements/google_drive_file_picker' import GoogleDriveFilePicker from './elements/google_drive_file_picker'
import OpenModal from './elements/open_modal' import OpenModal from './elements/open_modal'
import BarChart from './elements/bar_chart' import BarChart from './elements/bar_chart'
import FieldCondition from './elements/field_condition' import FieldCondition from './elements/field_condition'
import ConfirmUpload from './elements/confirm_upload'
import * as TurboInstantClick from './lib/turbo_instant_click' import * as TurboInstantClick from './lib/turbo_instant_click'
@ -112,6 +113,7 @@ safeRegisterElement('submitters-autocomplete', SubmittersAutocomplete)
safeRegisterElement('folder-autocomplete', FolderAutocomplete) safeRegisterElement('folder-autocomplete', FolderAutocomplete)
safeRegisterElement('signature-form', SignatureForm) safeRegisterElement('signature-form', SignatureForm)
safeRegisterElement('submit-form', SubmitForm) safeRegisterElement('submit-form', SubmitForm)
safeRegisterElement('convert-upload', ConvertUpload)
safeRegisterElement('prompt-password', PromptPassword) safeRegisterElement('prompt-password', PromptPassword)
safeRegisterElement('emails-textarea', EmailsTextarea) safeRegisterElement('emails-textarea', EmailsTextarea)
safeRegisterElement('toggle-cookies', ToggleCookies) safeRegisterElement('toggle-cookies', ToggleCookies)
@ -139,13 +141,13 @@ safeRegisterElement('scroll-to', ScrollTo)
safeRegisterElement('set-value', SetValue) safeRegisterElement('set-value', SetValue)
safeRegisterElement('review-form', ReviewForm) safeRegisterElement('review-form', ReviewForm)
safeRegisterElement('show-on-value', ShowOnValue) safeRegisterElement('show-on-value', ShowOnValue)
safeRegisterElement('custom-validation', CustomValidation)
safeRegisterElement('toggle-classes', ToggleClasses) safeRegisterElement('toggle-classes', ToggleClasses)
safeRegisterElement('autosize-field', AutosizeField) safeRegisterElement('autosize-field', AutosizeField)
safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker) safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker)
safeRegisterElement('open-modal', OpenModal) safeRegisterElement('open-modal', OpenModal)
safeRegisterElement('bar-chart', BarChart) safeRegisterElement('bar-chart', BarChart)
safeRegisterElement('field-condition', FieldCondition) safeRegisterElement('field-condition', FieldCondition)
safeRegisterElement('confirm-upload', ConfirmUpload)
safeRegisterElement('template-builder', class extends HTMLElement { safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {
@ -197,10 +199,35 @@ safeRegisterElement('template-builder', class extends HTMLElement {
} }
onSubmit = (e) => { onSubmit = (e) => {
if (e.detail.success && e.detail?.formSubmission?.formElement?.id === 'submitters_form') { if (e.detail.success) {
e.detail.fetchResponse.response.json().then((data) => { if (e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
this.component.template.submitters = data.submitters e.detail.fetchResponse.response.json().then((data) => {
}) this.component.template.submitters = data.submitters
})
}
if (e.detail?.formSubmission?.formElement?.action?.endsWith('/prefillable_fields')) {
e.detail.fetchResponse.response.text().then((data) => {
const doc = new DOMParser().parseFromString(data, 'text/html')
const fragment = doc.querySelector('turbo-stream template').content
const prefillableUuidsIndex = {}
fragment.querySelectorAll('[name="field_uuid"]').forEach((field) => {
prefillableUuidsIndex[field.value] = true
})
this.component.template.fields.forEach((field) => {
if (prefillableUuidsIndex[field.uuid]) {
field.prefillable = true
field.readonly = true
} else if (field.prefillable) {
delete field.prefillable
delete field.readonly
}
})
})
}
} }
} }

@ -0,0 +1,27 @@
import { target, targetable } from '@github/catalyst/lib/targetable'
export default targetable(class extends HTMLElement {
static [target.static] = [
'prompt',
'processing',
'logo'
]
connectedCallback () {
this.form.addEventListener('submit', this.onSubmit)
}
disconnectedCallback () {
this.form.removeEventListener('submit', this.onSubmit)
}
onSubmit = () => {
this.prompt.classList.add('hidden')
this.processing.classList.remove('hidden')
this.logo.classList.add('animate-bounce')
}
get form () {
return this.querySelector('form')
}
})

@ -0,0 +1,73 @@
export function convertImage (sourceFile, targetType, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
resolve(newFile)
}, targetType, quality)
}
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(sourceFile)
})
}
export async function convertImagesInInput (input) {
if (!input.files || input.files.length === 0) return
const dt = new DataTransfer()
let didConvert = false
for (const file of Array.from(input.files)) {
let converted = file
try {
if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
converted = await convertImage(file, 'image/png')
didConvert = true
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
converted = await convertImage(file, 'image/jpeg', 0.9)
didConvert = true
}
} catch (e) {
alert(e.message)
}
dt.items.add(converted)
}
if (didConvert) {
input.files = dt.files
}
}
export default class extends HTMLElement {
connectedCallback () {
const input = this.querySelector('input[type="file"]')
const form = input.form
input.addEventListener('change', async () => {
await convertImagesInInput(input)
form.querySelector('[type="submit"]')?.setAttribute('disabled', true)
form.requestSubmit()
})
}
}

@ -1,14 +0,0 @@
export default class extends HTMLElement {
connectedCallback () {
const input = this.querySelector('input')
const invalidMessage = this.dataset.invalidMessage || ''
input.addEventListener('invalid', () => {
input.setCustomValidity(input.value ? invalidMessage : '')
})
input.addEventListener('input', () => {
input.setCustomValidity('')
})
}
}

@ -1,4 +1,5 @@
import { target, targets, targetable } from '@github/catalyst/lib/targetable' import { target, targets, targetable } from '@github/catalyst/lib/targetable'
import { convertImagesInInput } from './convert_upload'
const loadingIconHtml = `<svg xmlns="http://www.w3.org/2000/svg" class="animate-spin" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> const loadingIconHtml = `<svg xmlns="http://www.w3.org/2000/svg" class="animate-spin" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none" d="M0 0h24v24H0z" fill="none" />
@ -150,12 +151,16 @@ export default targetable(class extends HTMLElement {
if (!this.isLoading) this.hideDraghover() if (!this.isLoading) this.hideDraghover()
} }
uploadFiles (files, url) { async uploadFiles (files, url) {
this.isLoading = true this.isLoading = true
this.form.action = url this.form.action = url
this.form.querySelector('[type="file"]').files = files const input = this.form.querySelector('[type="file"]')
input.files = files
await convertImagesInInput(input)
this.form.querySelector('[type="submit"]').click() this.form.querySelector('[type="submit"]').click()
} }

@ -1,5 +1,6 @@
import { actionable } from '@github/catalyst/lib/actionable' import { actionable } from '@github/catalyst/lib/actionable'
import { target, targetable } from '@github/catalyst/lib/targetable' import { target, targetable } from '@github/catalyst/lib/targetable'
import { convertImagesInInput } from './convert_upload'
export default actionable(targetable(class extends HTMLElement { export default actionable(targetable(class extends HTMLElement {
static [target.static] = [ static [target.static] = [
@ -38,17 +39,21 @@ export default actionable(targetable(class extends HTMLElement {
this.classList.add('border-base-300', 'hover:bg-base-200/30') this.classList.add('border-base-300', 'hover:bg-base-200/30')
} }
onDrop (e) { async onDrop (e) {
e.preventDefault() e.preventDefault()
this.input.files = e.dataTransfer.files this.input.files = e.dataTransfer.files
this.uploadFiles(e.dataTransfer.files) await convertImagesInInput(this.input)
this.uploadFiles(this.input.files)
} }
onSelectFiles (e) { async onSelectFiles (e) {
e.preventDefault() e.preventDefault()
await convertImagesInInput(this.input)
this.uploadFiles(this.input.files) this.uploadFiles(this.input.files)
} }

@ -52,7 +52,9 @@ safeRegisterElement('submission-form', class extends HTMLElement {
completedMessage: JSON.parse(this.dataset.completedMessage || '{}'), completedMessage: JSON.parse(this.dataset.completedMessage || '{}'),
completedRedirectUrl: this.dataset.completedRedirectUrl, completedRedirectUrl: this.dataset.completedRedirectUrl,
attachments: reactive(JSON.parse(this.dataset.attachments)), attachments: reactive(JSON.parse(this.dataset.attachments)),
fields: JSON.parse(this.dataset.fields) fields: JSON.parse(this.dataset.fields),
completeButtonContainer: document.getElementById('complete_button_container'),
completeButtonScrollContainer: document.getElementById('complete_button_container_scroll')
}) })
this.app.mount(this.appElem) this.app.mount(this.appElem)

@ -528,7 +528,7 @@ export default {
return this.formatDate( return this.formatDate(
this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue), this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue),
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'), this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'),
{ withTimePlaceholders: this.modelValue === '{{date}}' } { withTimePlaceholders: this.modelValue === '{{date}}', utc: this.modelValue !== '{{date}}' }
) )
} catch { } catch {
return this.modelValue return this.modelValue
@ -643,11 +643,15 @@ export default {
return new Intl.NumberFormat('de-DE').format(number) return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') { } else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number) return new Intl.NumberFormat('fr-FR').format(number)
} else if (format === 'percent') {
return `${number}%`
} else if (format === 'percent_space') {
return `${String(number).replace('.', ',')} %`
} else { } else {
return number return number
} }
}, },
formatDate (date, format, { withTimePlaceholders = false } = {}) { formatDate (date, format, { withTimePlaceholders = false, utc = true } = {}) {
const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' } const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
const dayFormats = { D: 'numeric', DD: '2-digit' } const dayFormats = { D: 'numeric', DD: '2-digit' }
const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' } const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' }
@ -669,7 +673,7 @@ export default {
if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]] if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]]
if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]] if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]]
if (/z/.test(format)) opts.timeZoneName = 'short' if (/z/.test(format)) opts.timeZoneName = 'short'
if (!hasTime) opts.timeZone = 'UTC' if (!hasTime && utc) opts.timeZone = 'UTC'
const partTypes = { const partTypes = {
M: 'month', M: 'month',

@ -27,6 +27,7 @@
</label> </label>
<button <button
v-if="withToday" v-if="withToday"
type="button"
class="btn btn-outline btn-sm !normal-case font-normal set-current-date-button" class="btn btn-outline btn-sm !normal-case font-normal set-current-date-button"
@click.prevent="[setCurrentDate(), $emit('focus')]" @click.prevent="[setCurrentDate(), $emit('focus')]"
> >

@ -51,6 +51,36 @@
<script> <script>
import { IconCloudUpload, IconInnerShadowTop } from '@tabler/icons-vue' import { IconCloudUpload, IconInnerShadowTop } from '@tabler/icons-vue'
function convertImage (sourceFile, targetType, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
resolve(newFile)
}, targetType, quality)
}
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(sourceFile)
})
}
export default { export default {
name: 'FileDropzone', name: 'FileDropzone',
components: { components: {
@ -153,8 +183,14 @@ export default {
} }
}) })
} else { } else {
if (file.type === 'image/bmp' || file.type === 'image/vnd.microsoft.icon') { try {
file = await this.convertBmpToPng(file) if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
file = await convertImage(file, 'image/png')
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
file = await convertImage(file, 'image/jpeg', 0.9)
}
} catch (e) {
alert(e.message)
} }
formData.append('file', file) formData.append('file', file)
@ -181,32 +217,6 @@ export default {
}).finally(() => { }).finally(() => {
this.isLoading = false this.isLoading = false
}) })
},
convertBmpToPng (bmpFile) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const newFile = new File([blob], bmpFile.name.replace(/\.\w+$/, '.png'), { type: 'image/png' })
resolve(newFile)
}, 'image/png')
}
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(bmpFile)
})
} }
} }
} }

@ -95,7 +95,7 @@
v-else v-else
id="complete_form_button" id="complete_form_button"
class="btn btn-sm btn-neutral text-white px-4 w-full flex justify-center" class="btn btn-sm btn-neutral text-white px-4 w-full flex justify-center"
form="steps_form" form="complete_form"
type="submit" type="submit"
name="completed" name="completed"
value="true" value="true"
@ -113,6 +113,39 @@
</span> </span>
</button> </button>
</Teleport> </Teleport>
<Teleport
v-for="ref in (showCompleteButton ? [completeButtonContainer, completeButtonScrollContainer].filter(Boolean) : [])"
:key="ref"
:to="ref"
>
<button
class="complete-button btn btn-sm btn-neutral text-white px-4"
form="complete_form"
type="submit"
name="completed"
value="true"
:disabled="isSubmittingComplete"
>
<span class="flex items-center">
<IconInnerShadowTop
v-if="isSubmittingComplete"
class="mr-1 animate-spin w-5 h-5"
aria-hidden="true"
/>
<span>
{{ t('complete') }}
</span>
</span>
</button>
</Teleport>
<form
v-if="!isCompleted && !isInvite"
id="complete_form"
class="hidden"
:action="submitPath"
method="post"
@submit.prevent="submitStep"
/>
<button <button
v-if="!isFormVisible" v-if="!isFormVisible"
id="expand_form_button" id="expand_form_button"
@ -256,6 +289,7 @@
:id="currentField.uuid" :id="currentField.uuid"
dir="auto" dir="auto"
:required="currentField.required" :required="currentField.required"
:aria-label="showFieldNames && (currentField.name || currentField.title) ? undefined : (currentField.name || currentField.title || t('select_your_option'))"
:aria-describedby="currentField.description ? currentField.uuid + '-desc' : undefined" :aria-describedby="currentField.description ? currentField.uuid + '-desc' : undefined"
class="select base-input !text-2xl w-full text-center font-normal" class="select base-input !text-2xl w-full text-center font-normal"
:class="{ 'text-gray-300': !values[currentField.uuid] }" :class="{ 'text-gray-300': !values[currentField.uuid] }"
@ -284,7 +318,7 @@
<div v-else-if="currentField.type === 'radio'"> <div v-else-if="currentField.type === 'radio'">
<label <label
v-if="showFieldNames && (currentField.name || currentField.title)" v-if="showFieldNames && (currentField.name || currentField.title)"
:for="currentField.uuid" :id="currentField.uuid + '-radio-label'"
dir="auto" dir="auto"
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label" class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
:class="{ 'mb-2': !currentField.description }" :class="{ 'mb-2': !currentField.description }"
@ -323,6 +357,8 @@
</div> </div>
<div <div
class="space-y-3.5 mx-auto" class="space-y-3.5 mx-auto"
role="radiogroup"
:aria-labelledby="(currentField.name || currentField.title) ? currentField.uuid + '-radio-label' : null"
:class="{ hidden: !showFieldNames || (currentField.options.every((e) => !e.value) && currentField.options.length > 4) }" :class="{ hidden: !showFieldNames || (currentField.options.every((e) => !e.value) && currentField.options.length > 4) }"
> >
<div <div
@ -791,6 +827,16 @@ export default {
required: false, required: false,
default: null default: null
}, },
completeButtonContainer: {
type: Object,
required: false,
default: null
},
completeButtonScrollContainer: {
type: Object,
required: false,
default: null
},
schema: { schema: {
type: Array, type: Array,
required: false, required: false,
@ -1008,6 +1054,7 @@ export default {
isSubmitting: false, isSubmitting: false,
isSubmittingComplete: false, isSubmittingComplete: false,
submittedValues: {}, submittedValues: {},
isFormStarted: false,
recalculateButtonDisabledKey: '', recalculateButtonDisabledKey: '',
isAccessibilityMode: false isAccessibilityMode: false
} }
@ -1056,6 +1103,10 @@ export default {
}) })
}) })
}, },
showCompleteButton () {
return this.completeButtonContainer && !this.isCompleted && !this.isInvite && this.isFormStarted &&
!this.stepFields.find((fields) => fields.some((f) => f.required && isEmpty(this.submittedValues[f.uuid])))
},
submitButtonText () { submitButtonText () {
if (this.alwaysMinimize) { if (this.alwaysMinimize) {
return this.t('submit') return this.t('submit')
@ -1657,6 +1708,8 @@ export default {
return Promise.reject(new Error(data.error)) return Promise.reject(new Error(data.error))
} }
this.isFormStarted = true
const nextStep = (isLastStep && emptyRequiredField) || (forceComplete ? null : this.findNextStep(submitStepIndex)) const nextStep = (isLastStep && emptyRequiredField) || (forceComplete ? null : this.findNextStep(submitStepIndex))
if (nextStep) { if (nextStep) {
@ -1693,6 +1746,7 @@ export default {
}, },
async performComplete (resp) { async performComplete (resp) {
this.isCompleted = true this.isCompleted = true
this.isFormVisible = true
if (resp?.text) { if (resp?.text) {
const respData = await resp.text() const respData = await resp.text()

@ -4,9 +4,17 @@ const en = {
close: 'Close', close: 'Close',
uploaded_files: 'Uploaded files', uploaded_files: 'Uploaded files',
signature_drawing_area: 'Signature drawing area. Use mouse or touch to draw your signature.', signature_drawing_area: 'Signature drawing area. Use mouse or touch to draw your signature.',
initials_drawing_area: 'Initials drawing area. Use mouse or touch to draw your initials.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Please upload an image file', please_upload_an_image_file: 'Please upload an image file',
must_be_characters_length: 'Must be {number} characters long', must_be_characters_length: 'Must be {number} characters long',
must_be_valid_ssn: 'Must be a valid SSN (XXX-XX-XXXX)',
must_be_valid_ein: 'Must be a valid EIN (XX-XXXXXXX)',
must_be_valid_email: 'Must be a valid email address',
must_be_valid_url: 'Must be a valid URL (starting with http:// or https://)',
must_be_valid_zip: 'Must be a valid ZIP code (XXXXX or XXXXX-XXXX)',
must_contain_numbers_only: 'Must contain numbers only',
must_contain_letters_only: 'Must contain letters only',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.', complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.',
verify_id: 'Verify ID', verify_id: 'Verify ID',
identity_verification: 'Identity verification', identity_verification: 'Identity verification',
@ -81,6 +89,7 @@ const en = {
please_check_the_box_to_continue: 'Please check the box to continue.', please_check_the_box_to_continue: 'Please check the box to continue.',
open_source_documents_software: 'open source documents software', open_source_documents_software: 'open source documents software',
verified_phone_number: 'Verify Phone Number', verified_phone_number: 'Verify Phone Number',
country_code: 'Country code',
use_international_format: 'Use international format: +1xxx', use_international_format: 'Use international format: +1xxx',
six_digits_code: '6-digit code', six_digits_code: '6-digit code',
change_phone_number: 'Change phone number', change_phone_number: 'Change phone number',
@ -113,9 +122,17 @@ const es = {
close: 'Cerrar', close: 'Cerrar',
uploaded_files: 'Archivos subidos', uploaded_files: 'Archivos subidos',
signature_drawing_area: 'Área de dibujo de firma. Use el ratón o toque para dibujar su firma.', signature_drawing_area: 'Área de dibujo de firma. Use el ratón o toque para dibujar su firma.',
initials_drawing_area: 'Área de dibujo de iniciales. Use el ratón o toque para dibujar sus iniciales.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Por favor, sube un archivo de imagen', please_upload_an_image_file: 'Por favor, sube un archivo de imagen',
must_be_characters_length: 'Debe tener {number} caracteres de longitud', must_be_characters_length: 'Debe tener {number} caracteres de longitud',
must_be_valid_ssn: 'Debe ser un SSN válido (XXX-XX-XXXX)',
must_be_valid_ein: 'Debe ser un EIN válido (XX-XXXXXXX)',
must_be_valid_email: 'Debe ser una dirección de correo electrónico válida',
must_be_valid_url: 'Debe ser una URL válida (que comience con http:// o https://)',
must_be_valid_zip: 'Debe ser un ZIP válido (XXXXX o XXXXX-XXXX)',
must_contain_numbers_only: 'Debe contener solo números',
must_contain_letters_only: 'Debe contener solo letras',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.', complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.',
verify_id: 'Verificar ID', verify_id: 'Verificar ID',
identity_verification: 'Verificación de identidad', identity_verification: 'Verificación de identidad',
@ -190,6 +207,7 @@ const es = {
please_check_the_box_to_continue: 'Por favor marque la casilla para continuar.', please_check_the_box_to_continue: 'Por favor marque la casilla para continuar.',
open_source_documents_software: 'software de documentos de código abierto', open_source_documents_software: 'software de documentos de código abierto',
verified_phone_number: 'Verificar número de teléfono', verified_phone_number: 'Verificar número de teléfono',
country_code: 'Código de país',
use_international_format: 'Usar formato internacional: +1xxx', use_international_format: 'Usar formato internacional: +1xxx',
six_digits_code: 'Código de 6 dígitos', six_digits_code: 'Código de 6 dígitos',
change_phone_number: 'Cambiar número de teléfono', change_phone_number: 'Cambiar número de teléfono',
@ -222,9 +240,17 @@ const it = {
close: 'Chiudi', close: 'Chiudi',
uploaded_files: 'File caricati', uploaded_files: 'File caricati',
signature_drawing_area: 'Area di disegno della firma. Usa il mouse o il tocco per disegnare la tua firma.', signature_drawing_area: 'Area di disegno della firma. Usa il mouse o il tocco per disegnare la tua firma.',
initials_drawing_area: 'Area di disegno delle iniziali. Usa il mouse o il tocco per disegnare le tue iniziali.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Per favore carica un file immagine', please_upload_an_image_file: 'Per favore carica un file immagine',
must_be_characters_length: 'Deve essere lungo {number} caratteri', must_be_characters_length: 'Deve essere lungo {number} caratteri',
must_be_valid_ssn: 'Deve essere un SSN valido (XXX-XX-XXXX)',
must_be_valid_ein: 'Deve essere un EIN valido (XX-XXXXXXX)',
must_be_valid_email: 'Deve essere un indirizzo email valido',
must_be_valid_url: 'Deve essere un URL valido (che inizia con http:// o https://)',
must_be_valid_zip: 'Deve essere uno ZIP valido (XXXXX o XXXXX-XXXX)',
must_contain_numbers_only: 'Deve contenere solo numeri',
must_contain_letters_only: 'Deve contenere solo lettere',
complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.", complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.",
verify_id: 'Verifica ID', verify_id: 'Verifica ID',
identity_verification: "Verifica dell'identità", identity_verification: "Verifica dell'identità",
@ -299,6 +325,7 @@ const it = {
please_check_the_box_to_continue: 'Si prega di spuntare la casella per continuare.', please_check_the_box_to_continue: 'Si prega di spuntare la casella per continuare.',
open_source_documents_software: 'software di documenti open source', open_source_documents_software: 'software di documenti open source',
verified_phone_number: 'Verifica numero di telefono', verified_phone_number: 'Verifica numero di telefono',
country_code: 'Prefisso internazionale',
use_international_format: 'Usa formato internazionale: +1xxx', use_international_format: 'Usa formato internazionale: +1xxx',
six_digits_code: 'Codice a 6 cifre', six_digits_code: 'Codice a 6 cifre',
change_phone_number: 'Cambia numero di telefono', change_phone_number: 'Cambia numero di telefono',
@ -331,9 +358,17 @@ const de = {
close: 'Schließen', close: 'Schließen',
uploaded_files: 'Hochgeladene Dateien', uploaded_files: 'Hochgeladene Dateien',
signature_drawing_area: 'Unterschriftszeichenbereich. Verwenden Sie die Maus oder Berührung, um Ihre Unterschrift zu zeichnen.', signature_drawing_area: 'Unterschriftszeichenbereich. Verwenden Sie die Maus oder Berührung, um Ihre Unterschrift zu zeichnen.',
initials_drawing_area: 'Zeichenbereich für Initialen. Verwenden Sie die Maus oder Berührung, um Ihre Initialen zu zeichnen.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch', please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch',
must_be_characters_length: 'Muss {number} Zeichen lang sein', must_be_characters_length: 'Muss {number} Zeichen lang sein',
must_be_valid_ssn: 'Muss eine gültige SSN sein (XXX-XX-XXXX)',
must_be_valid_ein: 'Muss eine gültige EIN sein (XX-XXXXXXX)',
must_be_valid_email: 'Muss eine gültige E-Mail-Adresse sein',
must_be_valid_url: 'Muss eine gültige URL sein (beginnend mit http:// oder https://)',
must_be_valid_zip: 'Muss eine gültige ZIP sein (XXXXX oder XXXXX-XXXX)',
must_contain_numbers_only: 'Darf nur Zahlen enthalten',
must_contain_letters_only: 'Darf nur Buchstaben enthalten',
complete_all_required_fields_to_proceed_with_identity_verification: 'Füllen Sie alle Pflichtfelder aus, um mit der Identitätsprüfung fortzufahren.', complete_all_required_fields_to_proceed_with_identity_verification: 'Füllen Sie alle Pflichtfelder aus, um mit der Identitätsprüfung fortzufahren.',
verify_id: 'ID überprüfen', verify_id: 'ID überprüfen',
identity_verification: 'Identitätsprüfung', identity_verification: 'Identitätsprüfung',
@ -408,6 +443,7 @@ const de = {
please_check_the_box_to_continue: 'Bitte aktivieren Sie das Kontrollkästchen, um fortzufahren.', please_check_the_box_to_continue: 'Bitte aktivieren Sie das Kontrollkästchen, um fortzufahren.',
open_source_documents_software: 'Open-Source-Dokumentensoftware', open_source_documents_software: 'Open-Source-Dokumentensoftware',
verified_phone_number: 'Telefonnummer verifizieren', verified_phone_number: 'Telefonnummer verifizieren',
country_code: 'Ländervorwahl',
use_international_format: 'Internationales Format verwenden: +1xxx', use_international_format: 'Internationales Format verwenden: +1xxx',
six_digits_code: '6-stelliger Code', six_digits_code: '6-stelliger Code',
change_phone_number: 'Telefonnummer ändern', change_phone_number: 'Telefonnummer ändern',
@ -440,9 +476,17 @@ const fr = {
close: 'Fermer', close: 'Fermer',
uploaded_files: 'Fichiers téléchargés', uploaded_files: 'Fichiers téléchargés',
signature_drawing_area: 'Zone de dessin de signature. Utilisez la souris ou le toucher pour dessiner votre signature.', signature_drawing_area: 'Zone de dessin de signature. Utilisez la souris ou le toucher pour dessiner votre signature.',
initials_drawing_area: 'Zone de dessin des initiales. Utilisez la souris ou le toucher pour dessiner vos initiales.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Veuillez téléverser un fichier image', please_upload_an_image_file: 'Veuillez téléverser un fichier image',
must_be_characters_length: 'Doit comporter {number} caractères', must_be_characters_length: 'Doit comporter {number} caractères',
must_be_valid_ssn: 'Doit être un SSN valide (XXX-XX-XXXX)',
must_be_valid_ein: 'Doit être un EIN valide (XX-XXXXXXX)',
must_be_valid_email: 'Doit être une adresse e-mail valide',
must_be_valid_url: 'Doit être une URL valide (commençant par http:// ou https://)',
must_be_valid_zip: 'Doit être un ZIP valide (XXXXX ou XXXXX-XXXX)',
must_contain_numbers_only: 'Doit contenir uniquement des chiffres',
must_contain_letters_only: 'Doit contenir uniquement des lettres',
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour poursuivre la vérification d'identité.", complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour poursuivre la vérification d'identité.",
verify_id: "Vérifier l'ID", verify_id: "Vérifier l'ID",
identity_verification: "Vérification d'identité", identity_verification: "Vérification d'identité",
@ -517,6 +561,7 @@ const fr = {
please_check_the_box_to_continue: 'Veuillez cocher la case pour continuer.', please_check_the_box_to_continue: 'Veuillez cocher la case pour continuer.',
open_source_documents_software: 'logiciel de documents open source', open_source_documents_software: 'logiciel de documents open source',
verified_phone_number: 'Vérifier le numéro de téléphone', verified_phone_number: 'Vérifier le numéro de téléphone',
country_code: 'Indicatif du pays',
use_international_format: 'Utilisez le format international : +1xxx', use_international_format: 'Utilisez le format international : +1xxx',
six_digits_code: 'Code à 6 chiffres', six_digits_code: 'Code à 6 chiffres',
change_phone_number: 'Changer de numéro de téléphone', change_phone_number: 'Changer de numéro de téléphone',
@ -549,9 +594,17 @@ const pl = {
close: 'Zamknij', close: 'Zamknij',
uploaded_files: 'Przesłane pliki', uploaded_files: 'Przesłane pliki',
signature_drawing_area: 'Obszar rysowania podpisu. Użyj myszy lub dotyku, aby narysować swój podpis.', signature_drawing_area: 'Obszar rysowania podpisu. Użyj myszy lub dotyku, aby narysować swój podpis.',
initials_drawing_area: 'Obszar rysowania inicjałów. Użyj myszy lub dotyku, aby narysować swoje inicjały.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Proszę przesłać plik obrazu', please_upload_an_image_file: 'Proszę przesłać plik obrazu',
must_be_characters_length: 'Musi mieć długość {number} znaków', must_be_characters_length: 'Musi mieć długość {number} znaków',
must_be_valid_ssn: 'Musi być prawidłowym numerem SSN (XXX-XX-XXXX)',
must_be_valid_ein: 'Musi być prawidłowym numerem EIN (XX-XXXXXXX)',
must_be_valid_email: 'Musi być prawidłowym adresem e-mail',
must_be_valid_url: 'Musi być prawidłowym adresem URL (zaczynającym się od http:// lub https://)',
must_be_valid_zip: 'Musi być prawidłowym ZIP (XXXXX lub XXXXX-XXXX)',
must_contain_numbers_only: 'Może zawierać tylko cyfry',
must_contain_letters_only: 'Może zawierać tylko litery',
complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.', complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.',
verify_id: 'Zweryfikuj ID', verify_id: 'Zweryfikuj ID',
identity_verification: 'Weryfikacja tożsamości', identity_verification: 'Weryfikacja tożsamości',
@ -626,6 +679,7 @@ const pl = {
please_check_the_box_to_continue: 'Proszę zaznaczyć pole, aby kontynuować.', please_check_the_box_to_continue: 'Proszę zaznaczyć pole, aby kontynuować.',
open_source_documents_software: 'oprogramowanie do dokumentów open source', open_source_documents_software: 'oprogramowanie do dokumentów open source',
verified_phone_number: 'Zweryfikuj numer telefonu', verified_phone_number: 'Zweryfikuj numer telefonu',
country_code: 'Kod kraju',
use_international_format: 'Użyj międzynarodowego formatu: +1xxx', use_international_format: 'Użyj międzynarodowego formatu: +1xxx',
six_digits_code: '6-cyfrowy kod', six_digits_code: '6-cyfrowy kod',
change_phone_number: 'Zmień numer telefonu', change_phone_number: 'Zmień numer telefonu',
@ -658,9 +712,17 @@ const uk = {
close: 'Закрити', close: 'Закрити',
uploaded_files: 'Завантажені файли', uploaded_files: 'Завантажені файли',
signature_drawing_area: 'Область малювання підпису. Використовуйте мишу або дотик, щоб намалювати свій підпис.', signature_drawing_area: 'Область малювання підпису. Використовуйте мишу або дотик, щоб намалювати свій підпис.',
initials_drawing_area: 'Область малювання ініціалів. Використовуйте мишу або дотик, щоб намалювати свої ініціали.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Будь ласка, завантажте файл зображення', please_upload_an_image_file: 'Будь ласка, завантажте файл зображення',
must_be_characters_length: 'Має містити {number} символів', must_be_characters_length: 'Має містити {number} символів',
must_be_valid_ssn: 'Має бути дійсним SSN (XXX-XX-XXXX)',
must_be_valid_ein: 'Має бути дійсним EIN (XX-XXXXXXX)',
must_be_valid_email: 'Має бути дійсною адресою електронної пошти',
must_be_valid_url: 'Має бути дійсним URL (починаючи з http:// або https://)',
must_be_valid_zip: 'Має бути дійсним ZIP (XXXXX або XXXXX-XXXX)',
must_contain_numbers_only: 'Має містити лише цифри',
must_contain_letters_only: 'Має містити лише літери',
complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.", complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.",
verify_id: 'Підтвердження ідентичності', verify_id: 'Підтвердження ідентичності',
identity_verification: 'Ідентифікація особи', identity_verification: 'Ідентифікація особи',
@ -735,6 +797,7 @@ const uk = {
please_check_the_box_to_continue: 'Будь ласка, позначте прапорець, щоб продовжити.', please_check_the_box_to_continue: 'Будь ласка, позначте прапорець, щоб продовжити.',
open_source_documents_software: 'відкрите програмне забезпечення для документів', open_source_documents_software: 'відкрите програмне забезпечення для документів',
verified_phone_number: 'Підтвердіть номер телефону', verified_phone_number: 'Підтвердіть номер телефону',
country_code: 'Код країни',
use_international_format: 'Використовуйте міжнародний формат: +1xxx', use_international_format: 'Використовуйте міжнародний формат: +1xxx',
six_digits_code: '6-значний код', six_digits_code: '6-значний код',
change_phone_number: 'Змінити номер телефону', change_phone_number: 'Змінити номер телефону',
@ -767,9 +830,17 @@ const cs = {
close: 'Zavřít', close: 'Zavřít',
uploaded_files: 'Nahrané soubory', uploaded_files: 'Nahrané soubory',
signature_drawing_area: 'Oblast pro kreslení podpisu. Použijte myš nebo dotyk k nakreslení podpisu.', signature_drawing_area: 'Oblast pro kreslení podpisu. Použijte myš nebo dotyk k nakreslení podpisu.',
initials_drawing_area: 'Oblast pro kreslení iniciál. Použijte myš nebo dotyk k nakreslení iniciál.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Nahrajte prosím obrázkový soubor', please_upload_an_image_file: 'Nahrajte prosím obrázkový soubor',
must_be_characters_length: 'Musí mít délku {number} znaků', must_be_characters_length: 'Musí mít délku {number} znaků',
must_be_valid_ssn: 'Musí být platné SSN (XXX-XX-XXXX)',
must_be_valid_ein: 'Musí být platné EIN (XX-XXXXXXX)',
must_be_valid_email: 'Musí být platná e-mailová adresa',
must_be_valid_url: 'Musí být platná URL (začínající http:// nebo https://)',
must_be_valid_zip: 'Musí být platný ZIP (XXXXX nebo XXXXX-XXXX)',
must_contain_numbers_only: 'Musí obsahovat pouze čísla',
must_contain_letters_only: 'Musí obsahovat pouze písmena',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.', complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.',
verify_id: 'Ověřit ID', verify_id: 'Ověřit ID',
identity_verification: 'Ověření identity', identity_verification: 'Ověření identity',
@ -844,6 +915,7 @@ const cs = {
please_check_the_box_to_continue: 'Prosím, zaškrtněte políčko pro pokračování.', please_check_the_box_to_continue: 'Prosím, zaškrtněte políčko pro pokračování.',
open_source_documents_software: 'open source software pro dokumenty', open_source_documents_software: 'open source software pro dokumenty',
verified_phone_number: 'Ověřte telefonní číslo', verified_phone_number: 'Ověřte telefonní číslo',
country_code: 'Předvolba země',
use_international_format: 'Použijte mezinárodní formát: +1xxx', use_international_format: 'Použijte mezinárodní formát: +1xxx',
six_digits_code: '6-místný kód', six_digits_code: '6-místný kód',
change_phone_number: 'Změnit telefonní číslo', change_phone_number: 'Změnit telefonní číslo',
@ -876,9 +948,17 @@ const pt = {
close: 'Fechar', close: 'Fechar',
uploaded_files: 'Arquivos enviados', uploaded_files: 'Arquivos enviados',
signature_drawing_area: 'Área de desenho da assinatura. Use o mouse ou toque para desenhar sua assinatura.', signature_drawing_area: 'Área de desenho da assinatura. Use o mouse ou toque para desenhar sua assinatura.',
initials_drawing_area: 'Área de desenho das iniciais. Use o mouse ou toque para desenhar suas iniciais.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Por favor, envie um arquivo de imagem', please_upload_an_image_file: 'Por favor, envie um arquivo de imagem',
must_be_characters_length: 'Deve ter {number} caracteres', must_be_characters_length: 'Deve ter {number} caracteres',
must_be_valid_ssn: 'Deve ser um SSN válido (XXX-XX-XXXX)',
must_be_valid_ein: 'Deve ser um EIN válido (XX-XXXXXXX)',
must_be_valid_email: 'Deve ser um endereço de e-mail válido',
must_be_valid_url: 'Deve ser uma URL válida (começando com http:// ou https://)',
must_be_valid_zip: 'Deve ser um ZIP válido (XXXXX ou XXXXX-XXXX)',
must_contain_numbers_only: 'Deve conter apenas números',
must_contain_letters_only: 'Deve conter apenas letras',
complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.', complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.',
verify_id: 'Verificar ID', verify_id: 'Verificar ID',
identity_verification: 'Verificação de identidade', identity_verification: 'Verificação de identidade',
@ -953,6 +1033,7 @@ const pt = {
please_check_the_box_to_continue: 'Por favor, marque a caixa para continuar.', please_check_the_box_to_continue: 'Por favor, marque a caixa para continuar.',
open_source_documents_software: 'software de documentos de código aberto', open_source_documents_software: 'software de documentos de código aberto',
verified_phone_number: 'Verificar Número de Telefone', verified_phone_number: 'Verificar Número de Telefone',
country_code: 'Código do país',
use_international_format: 'Use formato internacional: +1xxx', use_international_format: 'Use formato internacional: +1xxx',
six_digits_code: 'Código de 6 dígitos', six_digits_code: 'Código de 6 dígitos',
change_phone_number: 'Alterar número de telefone', change_phone_number: 'Alterar número de telefone',
@ -985,9 +1066,17 @@ const he = {
close: 'סגור', close: 'סגור',
uploaded_files: 'קבצים שהועלו', uploaded_files: 'קבצים שהועלו',
signature_drawing_area: 'אזור ציור חתימה. השתמש בעכבר או במגע כדי לצייר את החתימה שלך.', signature_drawing_area: 'אזור ציור חתימה. השתמש בעכבר או במגע כדי לצייר את החתימה שלך.',
initials_drawing_area: 'אזור ציור ראשי תיבות. השתמש בעכבר או במגע כדי לצייר את ראשי התיבות שלך.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'אנא העלה קובץ תמונה', please_upload_an_image_file: 'אנא העלה קובץ תמונה',
must_be_characters_length: 'חייב להיות באורך של {number} תווים', must_be_characters_length: 'חייב להיות באורך של {number} תווים',
must_be_valid_ssn: 'חייב להיות SSN חוקי (XXX-XX-XXXX)',
must_be_valid_ein: 'חייב להיות EIN חוקי (XX-XXXXXXX)',
must_be_valid_email: 'חייבת להיות כתובת אימייל חוקית',
must_be_valid_url: 'חייבת להיות כתובת URL חוקית (המתחילה ב-http:// או https://)',
must_be_valid_zip: 'חייב להיות ZIP חוקי (XXXXX או XXXXX-XXXX)',
must_contain_numbers_only: 'חייב להכיל מספרים בלבד',
must_contain_letters_only: 'חייב להכיל אותיות בלבד',
complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.', complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.',
verify_id: 'אמת מזהה', verify_id: 'אמת מזהה',
identity_verification: 'אימות זהות', identity_verification: 'אימות זהות',
@ -1062,6 +1151,7 @@ const he = {
please_check_the_box_to_continue: 'אנא סמן את התיבה כדי להמשיך.', please_check_the_box_to_continue: 'אנא סמן את התיבה כדי להמשיך.',
open_source_documents_software: 'תוכנה פתוחה למסמכים', open_source_documents_software: 'תוכנה פתוחה למסמכים',
verified_phone_number: 'אימות מספר טלפון', verified_phone_number: 'אימות מספר טלפון',
country_code: 'קידומת מדינה',
use_international_format: 'השתמש בפורמט בינלאומי: +1xxx', use_international_format: 'השתמש בפורמט בינלאומי: +1xxx',
six_digits_code: 'קוד שש ספרות', six_digits_code: 'קוד שש ספרות',
change_phone_number: 'שינוי מספר טלפון', change_phone_number: 'שינוי מספר טלפון',
@ -1094,9 +1184,17 @@ const nl = {
close: 'Sluiten', close: 'Sluiten',
uploaded_files: 'Geüploade bestanden', uploaded_files: 'Geüploade bestanden',
signature_drawing_area: 'Handtekening tekengebied. Gebruik de muis of aanraking om uw handtekening te tekenen.', signature_drawing_area: 'Handtekening tekengebied. Gebruik de muis of aanraking om uw handtekening te tekenen.',
initials_drawing_area: 'Tekengebied voor initialen. Gebruik de muis of aanraking om uw initialen te tekenen.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand', please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand',
must_be_characters_length: 'Moet {number} tekens lang zijn', must_be_characters_length: 'Moet {number} tekens lang zijn',
must_be_valid_ssn: 'Moet een geldig SSN zijn (XXX-XX-XXXX)',
must_be_valid_ein: 'Moet een geldig EIN zijn (XX-XXXXXXX)',
must_be_valid_email: 'Moet een geldig e-mailadres zijn',
must_be_valid_url: 'Moet een geldige URL zijn (beginnend met http:// of https://)',
must_be_valid_zip: 'Moet een geldige ZIP zijn (XXXXX of XXXXX-XXXX)',
must_contain_numbers_only: 'Mag alleen cijfers bevatten',
must_contain_letters_only: 'Mag alleen letters bevatten',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.', complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.',
verify_id: 'ID verifiëren', verify_id: 'ID verifiëren',
identity_verification: 'Identiteitsverificatie', identity_verification: 'Identiteitsverificatie',
@ -1171,6 +1269,7 @@ const nl = {
please_check_the_box_to_continue: 'Vink het vakje aan om door te gaan.', please_check_the_box_to_continue: 'Vink het vakje aan om door te gaan.',
open_source_documents_software: 'Open source documenten software', open_source_documents_software: 'Open source documenten software',
verified_phone_number: 'Telefoonnummer verifiëren', verified_phone_number: 'Telefoonnummer verifiëren',
country_code: 'Landcode',
use_international_format: 'Gebruik internationaal formaat: +1xxx', use_international_format: 'Gebruik internationaal formaat: +1xxx',
six_digits_code: '6-cijferige code', six_digits_code: '6-cijferige code',
change_phone_number: 'Wijzig telefoonnummer', change_phone_number: 'Wijzig telefoonnummer',
@ -1203,9 +1302,17 @@ const ar = {
close: 'إغلاق', close: 'إغلاق',
uploaded_files: 'الملفات المرفوعة', uploaded_files: 'الملفات المرفوعة',
signature_drawing_area: 'منطقة رسم التوقيع. استخدم الماوس أو اللمس لرسم توقيعك.', signature_drawing_area: 'منطقة رسم التوقيع. استخدم الماوس أو اللمس لرسم توقيعك.',
initials_drawing_area: 'منطقة رسم الحروف الأولى. استخدم الماوس أو اللمس لرسم الحروف الأولى.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'يرجى تحميل ملف صورة', please_upload_an_image_file: 'يرجى تحميل ملف صورة',
must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا', must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا',
must_be_valid_ssn: 'يجب أن يكون SSN صالحًا (XXX-XX-XXXX)',
must_be_valid_ein: 'يجب أن يكون EIN صالحًا (XX-XXXXXXX)',
must_be_valid_email: 'يجب أن يكون عنوان بريد إلكتروني صالحًا',
must_be_valid_url: 'يجب أن يكون عنوان URL صالحًا (يبدأ بـ http:// أو https://)',
must_be_valid_zip: 'يجب أن يكون ZIP صالحًا (XXXXX أو XXXXX-XXXX)',
must_contain_numbers_only: 'يجب أن يحتوي على أرقام فقط',
must_contain_letters_only: 'يجب أن يحتوي على أحرف فقط',
complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.', complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.',
verify_id: 'تحقق من الهوية', verify_id: 'تحقق من الهوية',
identity_verification: 'التحقق من الهوية', identity_verification: 'التحقق من الهوية',
@ -1280,6 +1387,7 @@ const ar = {
please_check_the_box_to_continue: 'الرجاء التحقق من الخانة للمتابعة.', please_check_the_box_to_continue: 'الرجاء التحقق من الخانة للمتابعة.',
open_source_documents_software: 'برنامج وثائق مفتوح المصدر', open_source_documents_software: 'برنامج وثائق مفتوح المصدر',
verified_phone_number: 'تحقق من رقم الهاتف', verified_phone_number: 'تحقق من رقم الهاتف',
country_code: 'رمز الدولة',
use_international_format: 'استخدم الشكل الدولي: +1xxx', use_international_format: 'استخدم الشكل الدولي: +1xxx',
six_digits_code: 'رمز مكون من 6 أرقام', six_digits_code: 'رمز مكون من 6 أرقام',
change_phone_number: 'تغيير رقم الهاتف', change_phone_number: 'تغيير رقم الهاتف',
@ -1312,9 +1420,17 @@ const ko = {
close: '닫기', close: '닫기',
uploaded_files: '업로드된 파일', uploaded_files: '업로드된 파일',
signature_drawing_area: '서명 그리기 영역. 마우스 또는 터치를 사용하여 서명을 그리세요.', signature_drawing_area: '서명 그리기 영역. 마우스 또는 터치를 사용하여 서명을 그리세요.',
initials_drawing_area: '이니셜 그리기 영역. 마우스 또는 터치를 사용하여 이니셜을 그리세요.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: '이미지 파일을 업로드해 주세요', please_upload_an_image_file: '이미지 파일을 업로드해 주세요',
must_be_characters_length: '{number}자여야 합니다', must_be_characters_length: '{number}자여야 합니다',
must_be_valid_ssn: '유효한 SSN이어야 합니다 (XXX-XX-XXXX)',
must_be_valid_ein: '유효한 EIN이어야 합니다 (XX-XXXXXXX)',
must_be_valid_email: '유효한 이메일 주소여야 합니다',
must_be_valid_url: '유효한 URL이어야 합니다 (http:// 또는 https://로 시작)',
must_be_valid_zip: '유효한 ZIP이어야 합니다 (XXXXX 또는 XXXXX-XXXX)',
must_contain_numbers_only: '숫자만 포함해야 합니다',
must_contain_letters_only: '문자만 포함해야 합니다',
complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.', complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.',
verify_id: '아이디 확인', verify_id: '아이디 확인',
identity_verification: '신원 확인', identity_verification: '신원 확인',
@ -1389,6 +1505,7 @@ const ko = {
please_check_the_box_to_continue: '계속하려면 확인란을 선택하십시오.', please_check_the_box_to_continue: '계속하려면 확인란을 선택하십시오.',
open_source_documents_software: '오픈 소스 문서 소프트웨어', open_source_documents_software: '오픈 소스 문서 소프트웨어',
verified_phone_number: '전화번호 확인됨', verified_phone_number: '전화번호 확인됨',
country_code: '국가 번호',
use_international_format: '국제 포맷 사용: +1xxx', use_international_format: '국제 포맷 사용: +1xxx',
six_digits_code: '6자리 코드', six_digits_code: '6자리 코드',
change_phone_number: '전화번호 변경', change_phone_number: '전화번호 변경',
@ -1421,9 +1538,17 @@ const ja = {
close: '閉じる', close: '閉じる',
uploaded_files: 'アップロードされたファイル', uploaded_files: 'アップロードされたファイル',
signature_drawing_area: '署名描画エリア。マウスまたはタッチを使用して署名を描いてください。', signature_drawing_area: '署名描画エリア。マウスまたはタッチを使用して署名を描いてください。',
initials_drawing_area: 'イニシャル描画エリア。マウスまたはタッチを使用してイニシャルを描いてください。',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: '画像ファイルをアップロードしてください', please_upload_an_image_file: '画像ファイルをアップロードしてください',
must_be_characters_length: '{number}文字でなければなりません', must_be_characters_length: '{number}文字でなければなりません',
must_be_valid_ssn: '有効なSSNである必要があります (XXX-XX-XXXX)',
must_be_valid_ein: '有効なEINである必要があります (XX-XXXXXXX)',
must_be_valid_email: '有効なメールアドレスである必要があります',
must_be_valid_url: '有効なURLである必要があります (http://またはhttps://で始まる)',
must_be_valid_zip: '有効なZIPである必要があります (XXXXX または XXXXX-XXXX)',
must_contain_numbers_only: '数字のみを含む必要があります',
must_contain_letters_only: '文字のみを含む必要があります',
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。', complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
verify_id: '本人確認', verify_id: '本人確認',
identity_verification: '本人確認', identity_verification: '本人確認',
@ -1498,6 +1623,7 @@ const ja = {
please_check_the_box_to_continue: '続行するにはボックスにチェックを入れてください。', please_check_the_box_to_continue: '続行するにはボックスにチェックを入れてください。',
open_source_documents_software: 'オープンソースのドキュメントソフトウェア', open_source_documents_software: 'オープンソースのドキュメントソフトウェア',
verified_phone_number: '電話番号を認証', verified_phone_number: '電話番号を認証',
country_code: '国番号',
use_international_format: '国際形式を使用してください:+1xxx', use_international_format: '国際形式を使用してください:+1xxx',
six_digits_code: '6桁のコード', six_digits_code: '6桁のコード',
change_phone_number: '電話番号を変更', change_phone_number: '電話番号を変更',

@ -15,6 +15,7 @@
</template> </template>
</label> </label>
<button <button
type="button"
class="btn btn-outline btn-sm reupload-button" class="btn btn-outline btn-sm reupload-button"
@click.prevent="remove" @click.prevent="remove"
> >

@ -141,6 +141,7 @@
<img <img
v-if="modelValue || computedPreviousValue" v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url" :src="attachmentsIndex[modelValue || computedPreviousValue].url"
:alt="field.name || t('initials')"
class="mx-auto bg-white border border-base-300 rounded max-h-44" class="mx-auto bg-white border border-base-300 rounded max-h-44"
> >
<div class="relative"> <div class="relative">
@ -156,6 +157,8 @@
<canvas <canvas
v-show="!modelValue && !computedPreviousValue" v-show="!modelValue && !computedPreviousValue"
ref="canvas" ref="canvas"
role="img"
:aria-label="t('initials_drawing_area')"
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas" class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
/> />
</div> </div>
@ -165,6 +168,7 @@
ref="textInput" ref="textInput"
class="base-input !text-2xl w-full mt-6 text-center" class="base-input !text-2xl w-full mt-6 text-center"
:required="field.required && !isInitialsStarted" :required="field.required && !isInitialsStarted"
:aria-label="field.name || t('initials')"
:placeholder="`${t('type_initial_here')}...`" :placeholder="`${t('type_initial_here')}...`"
type="text" type="text"
@focus="$emit('focus')" @focus="$emit('focus')"

@ -35,7 +35,7 @@
:placeholder="t('email')" :placeholder="t('email')"
type="email" type="email"
:required="submitters.includes(submitter)" :required="submitters.includes(submitter)"
autofocus="true" :autofocus="index === 0"
name="submission[submitters][][email]" name="submission[submitters][][email]"
> >
</div> </div>

@ -42,6 +42,7 @@
{{ error }} {{ error }}
</div> </div>
<button <button
type="button"
class="base-button w-full flex justify-center submit-form-button" class="base-button w-full flex justify-center submit-form-button"
@click="restartKba" @click="restartKba"
> >

@ -1,7 +1,7 @@
<template> <template>
<label <label
v-if="showFieldNames && (field.name || field.title)" v-if="showFieldNames && (field.name || field.title)"
:for="field.uuid" :id="field.uuid + '-group-label'"
dir="auto" dir="auto"
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label" class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
:class="{ 'mb-2': !field.description }" :class="{ 'mb-2': !field.description }"
@ -44,6 +44,8 @@
<div <div
class="space-y-3.5 mx-auto" class="space-y-3.5 mx-auto"
:class="{ hidden: !showOptions }" :class="{ hidden: !showOptions }"
role="group"
:aria-labelledby="showFieldNames && (field.name || field.title) ? field.uuid + '-group-label' : undefined"
> >
<div <div
v-for="(option, index) in field.options" v-for="(option, index) in field.options"

@ -33,6 +33,7 @@
<div v-else> <div v-else>
<button <button
v-if="sessionId" v-if="sessionId"
type="button"
disabled disabled
class="base-button w-full modal-save-button" class="base-button w-full modal-save-button"
> >
@ -47,6 +48,7 @@
<button <button
v-else v-else
:id="field.uuid" :id="field.uuid"
type="button"
class="btn bg-[#7B73FF] text-white hover:bg-[#0A2540] text-lg w-full" class="btn bg-[#7B73FF] text-white hover:bg-[#0A2540] text-lg w-full"
:class="{ disabled: isCreatingCheckout }" :class="{ disabled: isCreatingCheckout }"
:disabled="isCreatingCheckout" :disabled="isCreatingCheckout"

@ -81,6 +81,7 @@
<select <select
id="country_code_select" id="country_code_select"
class="absolute top-0 bottom-0 right-0 left-0 opacity-0 w-full h-full cursor-pointer" class="absolute top-0 bottom-0 right-0 left-0 opacity-0 w-full h-full cursor-pointer"
:aria-label="t('country_code')"
:disabled="!!defaultValue" :disabled="!!defaultValue"
@change="onCountrySelect(countries.find((country) => country.flag === $event.target.value))" @change="onCountrySelect(countries.find((country) => country.flag === $event.target.value))"
> >

@ -27,6 +27,7 @@
> >
<button <button
id="type_text_button" id="type_text_button"
ref="typeTextButton"
type="button" type="button"
:aria-label="t('draw_signature')" :aria-label="t('draw_signature')"
class="btn btn-outline btn-sm font-medium type-text-button" class="btn btn-outline btn-sm font-medium type-text-button"
@ -49,6 +50,7 @@
> >
<button <button
id="type_text_button" id="type_text_button"
ref="typeTextButton"
type="button" type="button"
:aria-label="t('type_text')" :aria-label="t('type_text')"
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap type-text-button" class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap type-text-button"
@ -71,7 +73,7 @@
> >
<button <button
type="button" type="button"
:aria-label="t('take_photo')" :aria-label="t('upload')"
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button" class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button"
@click="$refs.takePhotoInput.click()" @click="$refs.takePhotoInput.click()"
> >
@ -199,7 +201,7 @@
<canvas <canvas
v-show="!modelValue && !computedPreviousValue" v-show="!modelValue && !computedPreviousValue"
ref="canvas" ref="canvas"
role="application" role="img"
:aria-label="t('signature_drawing_area')" :aria-label="t('signature_drawing_area')"
style="padding: 1px; 0" style="padding: 1px; 0"
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas" class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
@ -228,6 +230,8 @@
> >
<canvas <canvas
ref="qrCanvas" ref="qrCanvas"
role="img"
:aria-label="t('scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature')"
class="h-full" class="h-full"
width="132" width="132"
height="132" height="132"
@ -242,6 +246,7 @@
ref="textInput" ref="textInput"
class="base-input !text-2xl w-full mt-6" class="base-input !text-2xl w-full mt-6"
:required="field.required && !isSignatureStarted" :required="field.required && !isSignatureStarted"
:aria-label="field.name || t('signature')"
:placeholder="`${t('type_signature_here')}...`" :placeholder="`${t('type_signature_here')}...`"
type="text" type="text"
@input="updateWrittenSignature" @input="updateWrittenSignature"
@ -251,6 +256,7 @@
class="select base-input !text-2xl w-full mt-6 text-center" class="select base-input !text-2xl w-full mt-6 text-center"
:class="{ 'text-gray-300': !reason }" :class="{ 'text-gray-300': !reason }"
required required
:aria-label="t('select_a_reason')"
:name="`values[${field.preferences.reason_field_uuid}]`" :name="`values[${field.preferences.reason_field_uuid}]`"
@change="$event.target.value === 'other' ? [reason = '', isOtherReason = true] : $emit('update:reason', $event.target.value)" @change="$event.target.value === 'other' ? [reason = '', isOtherReason = true] : $emit('update:reason', $event.target.value)"
> >
@ -296,6 +302,7 @@
class="base-input !text-2xl w-full mt-6" class="base-input !text-2xl w-full mt-6"
required required
:name="`values[${field.preferences.reason_field_uuid}]`" :name="`values[${field.preferences.reason_field_uuid}]`"
:aria-label="t('select_a_reason')"
:placeholder="t('type_here_')" :placeholder="t('type_here_')"
:value="reason" :value="reason"
type="text" type="text"
@ -716,10 +723,12 @@ export default {
this.clear() this.clear()
this.isTextSignature = !this.isTextSignature this.isTextSignature = !this.isTextSignature
if (this.isTextSignature) { this.$nextTick(() => {
this.$nextTick(() => { if (this.isTextSignature) {
if (this.$refs.textInput) { if (this.$refs.textInput) {
if (!this.submitter.name) { if (this.submitter.name) {
this.$refs.typeTextButton?.focus()
} else {
this.$refs.textInput.focus() this.$refs.textInput.focus()
} }
@ -727,8 +736,10 @@ export default {
this.$emit('start') this.$emit('start')
} }
}) } else {
} this.$refs.typeTextButton?.focus()
}
})
}, },
async initTypedSignature () { async initTypedSignature () {
if (this.signatureText) { if (this.signatureText) {

@ -41,12 +41,11 @@
:class="{ '!pr-11 -mr-10': !field.validation?.pattern }" :class="{ '!pr-11 -mr-10': !field.validation?.pattern }"
:required="field.required" :required="field.required"
:pattern="field.validation?.pattern" :pattern="field.validation?.pattern"
:title="validationMessage"
:aria-describedby="field.description ? field.uuid + '-desc' : undefined" :aria-describedby="field.description ? field.uuid + '-desc' : undefined"
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`" :placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
type="text" type="text"
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
@invalid="validationMessage ? $event.target.setCustomValidity(validationMessage) : ''"
@input="validationMessage ? $event.target.setCustomValidity('') : ''"
@focus="$emit('focus')" @focus="$emit('focus')"
> >
<textarea <textarea
@ -138,6 +137,17 @@ export default {
return null return null
} }
}, },
patternMessageKeys () {
return {
'^[0-9]{3}-[0-9]{2}-[0-9]{4}$': 'must_be_valid_ssn',
'^[0-9]{2}-[0-9]{7}$': 'must_be_valid_ein',
'^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'must_be_valid_email',
'^https?://.*': 'must_be_valid_url',
'^[0-9]{5}(?:-[0-9]{4})?$': 'must_be_valid_zip',
'^[0-9]+$': 'must_contain_numbers_only',
'^[a-zA-Z]+$': 'must_contain_letters_only'
}
},
validationMessage () { validationMessage () {
if (this.field.validation?.message) { if (this.field.validation?.message) {
return this.field.validation.message return this.field.validation.message
@ -149,6 +159,10 @@ export default {
.join('-') .join('-')
return this.t('must_be_characters_length').replace('{number}', number) return this.t('must_be_characters_length').replace('{number}', number)
} else if (this.field.validation?.pattern) {
const key = this.patternMessageKeys[this.field.validation.pattern]
return key ? this.t(key) : ''
} else { } else {
return '' return ''
} }

@ -197,7 +197,8 @@
v-else-if="!isCheckboxInput" v-else-if="!isCheckboxInput"
width="100%" width="100%"
height="100%" height="100%"
class="max-h-10 opacity-50" class="opacity-50"
:style="{ maxHeight: isInlineSize ? '4.4cqmin' : '40px' }"
/> />
</span> </span>
</div> </div>
@ -620,6 +621,10 @@ export default {
return new Intl.NumberFormat('de-DE').format(number) return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') { } else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number) return new Intl.NumberFormat('fr-FR').format(number)
} else if (format === 'percent') {
return `${number}%`
} else if (format === 'percent_space') {
return `${String(number).replace('.', ',')} %`
} else { } else {
return number return number
} }
@ -716,19 +721,15 @@ export default {
} }
} }
}, },
drag (e) {
if (e.target.id === 'mask' && this.editable) {
this.isDragged = true
this.area.x = (e.offsetX - this.dragFrom.x) / e.target.clientWidth
this.area.y = (e.offsetY - this.dragFrom.y) / e.target.clientHeight
}
},
startTouchDrag (e) { startTouchDrag (e) {
if (e.target !== this.$refs.touchTarget && e.target !== this.$refs.touchValueTarget) { if (e.target !== this.$refs.touchTarget && e.target !== this.$refs.touchValueTarget) {
return return
} }
if (this.inputMode && (this.isValueInput || this.isCheckboxInput || this.isSelectInput)) {
return
}
document.activeElement?.blur() document.activeElement?.blur()
e.preventDefault() e.preventDefault()
@ -769,6 +770,8 @@ export default {
this.maybeChangeAreaPage(this.area) this.maybeChangeAreaPage(this.area)
this.clampAreaBounds(this.area)
if (this.isDragged) { if (this.isDragged) {
this.save() this.save()
} }
@ -836,6 +839,8 @@ export default {
this.maybeChangeAreaPage(this.area) this.maybeChangeAreaPage(this.area)
this.clampAreaBounds(this.area)
if (this.isMoved) { if (this.isMoved) {
this.save() this.save()
} }
@ -860,18 +865,6 @@ export default {
area.y = area.y - 1 - (16.0 / this.$parent.$refs.mask.previousSibling.offsetHeight) area.y = area.y - 1 - (16.0 / this.$parent.$refs.mask.previousSibling.offsetHeight)
} }
}, },
stopDrag () {
this.$el.getRootNode().removeEventListener('mousemove', this.drag)
this.$el.getRootNode().removeEventListener('mouseup', this.stopDrag)
if (this.isDragged) {
this.save()
}
this.isDragged = false
this.$emit('stop-drag')
},
startResize () { startResize () {
if (!this.selectedAreasRef.value.includes(this.area)) { if (!this.selectedAreasRef.value.includes(this.area)) {
this.selectedAreasRef.value = [this.area] this.selectedAreasRef.value = [this.area]
@ -886,6 +879,8 @@ export default {
this.$el.getRootNode().removeEventListener('mousemove', this.resize) this.$el.getRootNode().removeEventListener('mousemove', this.resize)
this.$el.getRootNode().removeEventListener('mouseup', this.stopResize) this.$el.getRootNode().removeEventListener('mouseup', this.stopResize)
this.clampAreaBounds(this.area)
this.$emit('stop-resize') this.$emit('stop-resize')
this.save() this.save()
@ -924,9 +919,17 @@ export default {
this.$el.getRootNode().removeEventListener('touchmove', this.touchResize) this.$el.getRootNode().removeEventListener('touchmove', this.touchResize)
this.$el.getRootNode().removeEventListener('touchend', this.stopTouchResize) this.$el.getRootNode().removeEventListener('touchend', this.stopTouchResize)
this.clampAreaBounds(this.area)
this.$emit('stop-resize') this.$emit('stop-resize')
this.save() this.save()
},
clampAreaBounds (area) {
area.x = Math.min(Math.max(area.x, 0), 1)
area.y = Math.min(Math.max(area.y, 0), 1)
area.w = Math.min(Math.max(area.w, 0), 1)
area.h = Math.min(Math.max(area.h, 0), 1)
} }
} }
} }

@ -309,6 +309,8 @@
:data-document-uuid="item.attachment_uuid" :data-document-uuid="item.attachment_uuid"
:accept-file-types="acceptFileTypes" :accept-file-types="acceptFileTypes"
:with-replace-button="withUploadButton" :with-replace-button="withUploadButton"
:with-google-drive="withGoogleDrive"
:authenticity-token="authenticityToken"
:editable="editable" :editable="editable"
:dynamic-documents="dynamicDocuments" :dynamic-documents="dynamicDocuments"
:with-dynamic-documents="withDynamicDocuments" :with-dynamic-documents="withDynamicDocuments"
@ -318,6 +320,7 @@
@replace="onDocumentReplace" @replace="onDocumentReplace"
@up="moveDocument(item, -1)" @up="moveDocument(item, -1)"
@reorder="reorderFields" @reorder="reorderFields"
@edit="editModalDocumentUuid = item.attachment_uuid"
@down="moveDocument(item, 1)" @down="moveDocument(item, 1)"
@change="save" @change="save"
/> />
@ -355,12 +358,18 @@
</div> </div>
<div <div
id="pages_container" id="pages_container"
class="w-full overflow-x-hidden mt-0.5 pt-0.5" ref="pagesContainer"
:class="isMobile ? 'overflow-y-auto' : 'overflow-y-hidden md:overflow-y-auto'" class="w-full mt-0.5 pt-0.5"
:class="[
isMobile ? 'overflow-y-auto' : 'overflow-y-hidden md:overflow-y-auto',
zoomLevel > 1 ? 'overflow-x-auto' : 'overflow-x-hidden'
]"
@wheel="onPagesWheel"
> >
<div <div
ref="documents" ref="documents"
class="pr-3.5 pl-0.5" class="pr-3.5 pl-0.5"
:style="zoomLevel > 1 ? { width: `${zoomLevel * 100}%` } : null"
> >
<template v-if="!sortedDocuments.length && (withUploadButton || withAddPageButton)"> <template v-if="!sortedDocuments.length && (withUploadButton || withAddPageButton)">
<Dropzone <Dropzone
@ -451,6 +460,8 @@
:with-arrows="template.schema.length > 1" :with-arrows="template.schema.length > 1"
:item="template.schema.find((item) => item.attachment_uuid === document.uuid)" :item="template.schema.find((item) => item.attachment_uuid === document.uuid)"
:with-replace-button="withUploadButton" :with-replace-button="withUploadButton"
:with-google-drive="withGoogleDrive"
:authenticity-token="authenticityToken"
:accept-file-types="acceptFileTypes" :accept-file-types="acceptFileTypes"
:document="document" :document="document"
:template="template" :template="template"
@ -633,6 +644,36 @@
</div> </div>
</div> </div>
</Transition> </Transition>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="translate-y-4 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-4 opacity-0"
>
<div
v-if="zoomLevel > 1"
class="sticky bottom-0 z-40 pointer-events-none"
>
<div class="absolute left-0 right-0 bottom-4 flex justify-center">
<div class="join shadow pointer-events-auto">
<span class="join-item bg-base-content text-white pl-2 pr-2.5 h-9 items-center text-sm font-medium cursor-default w-16 flex justify-end">
<span>
{{ Math.round(zoomLevel * 100) }}%
</span>
</span>
<button
type="button"
class="join-item bg-base-content text-white h-9 pl-2 pr-3 inline-flex items-center justify-center cursor-pointer hover:opacity-90 border-l border-white/20"
@click="zoomLevel = 1"
>
<IconX class="w-4 h-4 stroke-2" />
</button>
</div>
</div>
</div>
</Transition>
<div <div
id="docuseal_modal_container" id="docuseal_modal_container"
class="modal-container" class="modal-container"
@ -645,6 +686,17 @@
@close="isRevisionsModalOpen = false" @close="isRevisionsModalOpen = false"
@apply="onRevisionApply" @apply="onRevisionApply"
/> />
<DocumentsEditorModal
v-if="editModalDocumentUuid"
:template="template"
:authenticity-token="authenticityToken"
:accept-file-types="acceptFileTypes"
:base-url="baseUrl"
:page-preview-format="pagePreviewFormat"
:scroll-to-attachment-uuid="editModalDocumentUuid"
@saved="onDocumentsModified"
@close="editModalDocumentUuid = null"
/>
</div> </div>
</div> </div>
</template> </template>
@ -664,7 +716,8 @@ import DocumentControls from './controls'
import MobileFields from './mobile_fields' import MobileFields from './mobile_fields'
import FieldSubmitter from './field_submitter' import FieldSubmitter from './field_submitter'
import RevisionsModal from './revisions_modal' import RevisionsModal from './revisions_modal'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload, IconHistory } from '@tabler/icons-vue' import DocumentsEditorModal from './documents_editor_modal'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload, IconHistory, IconX } from '@tabler/icons-vue'
import { v4 } from 'uuid' import { v4 } from 'uuid'
import { ref, computed, toRaw, defineAsyncComponent } from 'vue' import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
import * as i18n from './i18n' import * as i18n from './i18n'
@ -706,7 +759,9 @@ export default {
IconEye, IconEye,
IconHistory, IconHistory,
IconDeviceFloppy, IconDeviceFloppy,
RevisionsModal IconX,
RevisionsModal,
DocumentsEditorModal
}, },
provide () { provide () {
return { return {
@ -1062,8 +1117,10 @@ export default {
isDragFile: false, isDragFile: false,
isMathLoaded: false, isMathLoaded: false,
isRevisionsModalOpen: false, isRevisionsModalOpen: false,
editModalDocumentUuid: null,
revisions: [], revisions: [],
beforeRevisionSnapshot: null beforeRevisionSnapshot: null,
zoomLevel: 1
} }
}, },
computed: { computed: {
@ -1639,7 +1696,7 @@ export default {
ref.x = e.clientX - ref.offsetX ref.x = e.clientX - ref.offsetX
ref.y = e.clientY - ref.offsetY ref.y = e.clientY - ref.offsetY
} else if (e.dataTransfer?.types?.includes('Files')) { } else if (e.dataTransfer?.types?.includes('Files') && !this.editModalDocumentUuid) {
this.isDragFile = true this.isDragFile = true
} }
}, },
@ -2021,6 +2078,30 @@ export default {
this.isBreakpointLg = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth < breakpointLg this.isBreakpointLg = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth < breakpointLg
}, },
onPagesWheel (event) {
if (!event.ctrlKey && !event.metaKey) return
event.preventDefault()
const oldZoom = this.zoomLevel
const nextZoom = Math.max(1, Math.min(3, oldZoom - event.deltaY * 0.006))
if (nextZoom === oldZoom) return
const rect = this.$refs.pagesContainer.getBoundingClientRect()
const cursorX = event.clientX - rect.left
const cursorY = event.clientY - rect.top
const ratio = nextZoom / oldZoom
const nextScrollLeft = (this.$refs.pagesContainer.scrollLeft + cursorX) * ratio - cursorX
const nextScrollTop = (this.$refs.pagesContainer.scrollTop + cursorY) * ratio - cursorY
this.zoomLevel = nextZoom
this.$nextTick(() => {
this.$refs.pagesContainer.scrollLeft = nextScrollLeft
this.$refs.pagesContainer.scrollTop = nextScrollTop
})
},
setDocumentRefs (el) { setDocumentRefs (el) {
if (el) { if (el) {
this.documentRefs.push(el) this.documentRefs.push(el)
@ -2058,6 +2139,10 @@ export default {
} }
}, },
onKeyDown (event) { onKeyDown (event) {
if (this.editModalDocumentUuid) {
return
}
if (event.key === 'Tab' && document.activeElement === document.body) { if (event.key === 'Tab' && document.activeElement === document.body) {
event.stopImmediatePropagation() event.stopImmediatePropagation()
event.preventDefault() event.preventDefault()
@ -2515,6 +2600,11 @@ export default {
this.selectedAreasRef.value = [area] this.selectedAreasRef.value = [area]
area.x = Math.min(Math.max(area.x, 0), 1)
area.y = Math.min(Math.max(area.y, 0), 1)
area.w = Math.min(Math.max(area.w, 0), 1)
area.h = Math.min(Math.max(area.h, 0), 1)
this.save() this.save()
} else { } else {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid) const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
@ -3071,6 +3161,22 @@ export default {
onDocumentsReplaceAndTemplateClone (template) { onDocumentsReplaceAndTemplateClone (template) {
window.Turbo.visit(`/templates/${template.id}/edit`) window.Turbo.visit(`/templates/${template.id}/edit`)
}, },
onDocumentsModified (data) {
this.template.schema = data.schema
this.template.fields = data.fields
this.template.submitters = data.submitters
this.template.documents = data.documents
this.selectedAreasRef.value = []
if (!this.template.submitters.find((s) => s.uuid === this.selectedSubmitter?.uuid)) {
this.selectedSubmitter = this.template.submitters[0]
}
this.editModalDocumentUuid = null
this.save()
},
moveDocument (item, direction) { moveDocument (item, direction) {
const currentIndex = this.template.schema.indexOf(item) const currentIndex = this.template.schema.indexOf(item)
@ -3144,10 +3250,14 @@ export default {
const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody()) const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody())
Promise.all([this.save({ force: true, revision: this.withRevisions }), ...dynamicDocumentSaves]).then(() => { Promise.all([this.save({ force: true }), ...dynamicDocumentSaves]).then(() => {
if (window.parent !== window) { if (this.withRevisions) {
this.captureRevision()
}
if (window.parent !== window) {
window.parent.postMessage({ source: 'docuseal-embed', type: 'save', template_id: this.template.id }, '*') window.parent.postMessage({ source: 'docuseal-embed', type: 'save', template_id: this.template.id }, '*')
} }
window.Turbo.visit(`/templates/${this.template.id}`) window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => { }).finally(() => {
this.isSaving = false this.isSaving = false
@ -3378,7 +3488,7 @@ export default {
} }
}) })
}, },
save ({ force = false, revision = false } = {}) { save ({ force = false } = {}) {
this.pendingFieldAttachmentUuids = [] this.pendingFieldAttachmentUuids = []
if (this.beforeRevisionSnapshot) { if (this.beforeRevisionSnapshot) {
@ -3410,8 +3520,7 @@ export default {
submitters: this.template.submitters, submitters: this.template.submitters,
fields: this.template.fields, fields: this.template.fields,
variables_schema: this.template.variables_schema variables_schema: this.template.variables_schema
}, }
...(revision ? { revision: true } : {})
}), }),
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}).then(() => { }).then(() => {
@ -3420,6 +3529,12 @@ export default {
} }
}) })
}, },
captureRevision () {
return this.baseFetch(`/templates/${this.template.id}/versions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
},
onDynamicDocumentUpdate () { onDynamicDocumentUpdate () {
this.rebuildVariablesSchema() this.rebuildVariablesSchema()

@ -10,6 +10,9 @@
v-if="withReplaceButton" v-if="withReplaceButton"
:template-id="template.id" :template-id="template.id"
:accept-file-types="acceptFileTypes" :accept-file-types="acceptFileTypes"
:authenticity-token="authenticityToken"
:with-google-drive="withGoogleDrive"
:google-drive-file-id="item.google_drive_file_id"
@click.stop @click.stop
@success="$emit('replace', { replaceSchemaItem: item, ...$event })" @success="$emit('replace', { replaceSchemaItem: item, ...$event })"
/> />
@ -73,6 +76,16 @@ export default {
required: true, required: true,
default: true default: true
}, },
withGoogleDrive: {
type: Boolean,
required: false,
default: false
},
authenticityToken: {
type: String,
required: false,
default: ''
},
withArrows: { withArrows: {
type: Boolean, type: Boolean,
required: false, required: false,

@ -0,0 +1,353 @@
<template>
<div class="flex flex-1 min-h-0">
<div
class="flex-1 min-h-0 flex items-center justify-center px-6 py-4"
style="container-type: size"
>
<div
ref="pageEl"
class="relative select-none"
:style="pageStyle"
>
<img
:src="imageUrl"
:width="metadata.width"
:height="metadata.height"
class="absolute border rounded pointer-events-none"
style="left: 50%; top: 50%; width: 100%; height: auto"
:style="imageStyle"
>
<svg
class="absolute inset-0 w-full h-full pointer-events-none"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
<path
:d="dimPath"
fill="black"
fill-opacity="0.4"
fill-rule="evenodd"
/>
<polygon
:points="polygonPoints"
fill="none"
stroke="white"
stroke-width="0.4"
vector-effect="non-scaling-stroke"
/>
</svg>
<div
v-for="(corner, cornerIndex) in displayCorners"
:key="cornerIndex"
class="absolute w-5 h-5 -ml-2.5 -mt-2.5 rounded-full bg-white border-2 border-neutral-600 cursor-move shadow"
:style="{ left: `${corner.x * 100}%`, top: `${corner.y * 100}%` }"
@mousedown.prevent="onCornerMousedown(cornerIndex)"
@touchstart.prevent="onCornerTouchstart(cornerIndex)"
/>
</div>
</div>
<div class="w-56 flex-none border-l px-4 py-4 space-y-2 flex flex-col">
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
:disabled="!!isProcessing"
@click.prevent="submit(true)"
>
<IconInnerShadowTop
v-if="isProcessing === 'scan'"
class="w-4 h-4 animate-spin"
/>
<IconScan
v-else
class="w-4 h-4"
/>
{{ t('crop_and_scan') }}
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
:disabled="!!isProcessing"
@click.prevent="submit(false)"
>
<IconInnerShadowTop
v-if="isProcessing === 'crop'"
class="w-4 h-4 animate-spin"
/>
<IconCrop
v-else
width="22"
height="22"
style="margin-left: -3px"
:stroke-width="1.5"
/>
<span :style="{ 'margin-left': isProcessing === 'crop' ? '0px' : '-3px' }">
{{ t('crop') }}
</span>
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
@click.prevent="$emit('cancel')"
>
<IconX class="w-4 h-4" />
{{ t('cancel') }}
</button>
<div class="border-t !mt-3 !mb-1" />
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
@click.prevent="rotateCw"
>
<IconRotateClockwise class="w-4 h-4" />
{{ t('rotate') }}
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
:class="{ 'btn-active': flipH }"
@click.prevent="toggleFlip('flipH')"
>
<IconFlipVertical class="w-4 h-4" />
{{ t('flip_horizontal') }}
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
:class="{ 'btn-active': flipV }"
@click.prevent="toggleFlip('flipV')"
>
<IconFlipHorizontal class="w-4 h-4" />
{{ t('flip_vertical') }}
</button>
</div>
</div>
</template>
<script>
import { IconCrop, IconScan, IconInnerShadowTop, IconX, IconRotateClockwise, IconFlipHorizontal, IconFlipVertical } from '@tabler/icons-vue'
export default {
name: 'DocumentsEditorCrop',
components: {
IconCrop,
IconScan,
IconInnerShadowTop,
IconX,
IconRotateClockwise,
IconFlipHorizontal,
IconFlipVertical
},
inject: ['t', 'baseFetch', 'isInlineSize'],
props: {
templateId: {
type: [Number, String],
required: true
},
page: {
type: Object,
required: true
},
imageUrl: {
type: String,
required: true
},
metadata: {
type: Object,
required: true
}
},
emits: ['apply', 'cancel'],
data () {
return {
corners: [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 0, y: 1 }
],
cornersTouched: false,
rotate: this.page.rotate || 0,
flipH: false,
flipV: false,
draggingIndex: null,
isProcessing: null
}
},
computed: {
displayWidth () {
return this.rotate % 180 ? this.metadata.height : this.metadata.width
},
displayHeight () {
return this.rotate % 180 ? this.metadata.width : this.metadata.height
},
pageStyle () {
const ratio = this.displayWidth / this.displayHeight
return {
aspectRatio: `${this.displayWidth} / ${this.displayHeight}`,
width: this.isInlineSize ? `min(100cqw, calc(100cqh * ${ratio}))` : `min(100%, calc(78vh * ${ratio}))`
}
},
imageStyle () {
const scale = this.rotate % 180 ? this.metadata.width / this.metadata.height : 1
const scaleX = (this.flipH ? -1 : 1) * scale
const scaleY = (this.flipV ? -1 : 1) * scale
return {
transform: `translate(-50%, -50%) rotate(${this.rotate}deg) scale(${scaleX}, ${scaleY})`
}
},
displayCorners () {
return this.corners.map((corner) => this.transformPoint(corner, this.rotate, this.flipH, this.flipV))
},
polygonPoints () {
return this.displayCorners.map((corner) => `${corner.x * 100},${corner.y * 100}`).join(' ')
},
dimPath () {
const quad = this.displayCorners.map((corner) => `${corner.x * 100} ${corner.y * 100}`)
return `M 0 0 H 100 V 100 H 0 Z M ${quad.join(' L ')} Z`
}
},
created () {
const query = new URLSearchParams({ attachment_uuid: this.page.sourceUuid })
this.baseFetch(`/templates/${this.templateId}/documents_crop?${query}`).then(async (resp) => {
if (resp.ok) {
const data = await resp.json()
if (data.corners?.length === 4 && !this.cornersTouched) {
this.corners = data.corners.map((corner) => ({ x: corner.x, y: corner.y }))
}
}
})
},
beforeUnmount () {
window.removeEventListener('mousemove', this.onMousemove)
window.removeEventListener('mouseup', this.onMouseup)
window.removeEventListener('touchmove', this.onTouchmove)
window.removeEventListener('touchend', this.onTouchend)
window.removeEventListener('touchcancel', this.onTouchend)
},
methods: {
transformPoint (point, rotate, flipH, flipV) {
let { x, y } = point
if (flipH) {
x = 1 - x
}
if (flipV) {
y = 1 - y
}
if (rotate === 90) {
return { x: 1 - y, y: x }
} else if (rotate === 180) {
return { x: 1 - x, y: 1 - y }
} else if (rotate === 270) {
return { x: y, y: 1 - x }
} else {
return { x, y }
}
},
inverseTransformPoint (point, rotate, flipH, flipV) {
let { x, y } = point
if (rotate === 90) {
[x, y] = [y, 1 - x]
} else if (rotate === 180) {
[x, y] = [1 - x, 1 - y]
} else if (rotate === 270) {
[x, y] = [1 - y, x]
}
if (flipH) {
x = 1 - x
}
if (flipV) {
y = 1 - y
}
return { x, y }
},
rotateCw () {
this.rotate = (this.rotate + 90) % 360
},
toggleFlip (key) {
this[key] = !this[key]
},
pagePoint (event) {
const rect = this.$refs.pageEl.getBoundingClientRect()
return {
x: Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1),
y: Math.min(Math.max((event.clientY - rect.top) / rect.height, 0), 1)
}
},
startCornerDrag (index) {
this.draggingIndex = index
this.cornersTouched = true
},
dragCorner (point) {
if (this.draggingIndex === null) {
return
}
this.corners[this.draggingIndex] = this.inverseTransformPoint(this.pagePoint(point), this.rotate, this.flipH, this.flipV)
},
onCornerMousedown (index) {
this.startCornerDrag(index)
window.addEventListener('mousemove', this.onMousemove)
window.addEventListener('mouseup', this.onMouseup, { once: true })
},
onMousemove (event) {
this.dragCorner(event)
},
onMouseup () {
window.removeEventListener('mousemove', this.onMousemove)
this.draggingIndex = null
},
onCornerTouchstart (index) {
this.startCornerDrag(index)
window.addEventListener('touchmove', this.onTouchmove, { passive: false })
window.addEventListener('touchend', this.onTouchend)
window.addEventListener('touchcancel', this.onTouchend)
},
onTouchmove (event) {
this.dragCorner(event.touches[0])
},
onTouchend () {
window.removeEventListener('touchmove', this.onTouchmove)
window.removeEventListener('touchend', this.onTouchend)
window.removeEventListener('touchcancel', this.onTouchend)
this.draggingIndex = null
},
submit (scan) {
this.isProcessing = scan ? 'scan' : 'crop'
this.baseFetch(`/templates/${this.templateId}/documents_crop`, {
method: 'POST',
body: JSON.stringify({
attachment_uuid: this.page.sourceUuid,
corners: this.corners,
rotate: this.rotate || undefined,
flip_h: this.flipH,
flip_v: this.flipV,
scan
}),
headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => {
const data = await resp.json().catch(() => ({}))
if (resp.ok) {
this.$emit('apply', data.document)
} else if (data.error) {
alert(data.error)
}
}).finally(() => {
this.isProcessing = null
})
}
}
}
</script>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,225 @@
<template>
<div class="relative">
<div
class="relative"
:style="boxStyle"
>
<img
:src="imageUrl"
:width="metadata.width"
:height="metadata.height"
class="rounded border pointer-events-none outline outline-1 -outline-offset-1 transition-[outline-color] duration-75"
:class="[
page.rotate % 180 ? 'absolute inset-0 m-auto w-full' : 'w-full',
selected ? 'outline-neutral-400' : 'outline-transparent'
]"
:style="imageStyle"
:loading="lazy ? 'lazy' : 'eager'"
>
<div
v-if="areas.length || page.redact.length"
class="absolute pointer-events-none"
:style="overlayStyle"
>
<div
v-for="(item, areaIndex) in areas"
:key="areaIndex"
class="absolute border rounded-sm opacity-70"
:class="[areaBorderColor(item.submitterIndex), areaBgColor(item.submitterIndex)]"
:style="{
left: `${item.area.x * 100}%`,
top: `${item.area.y * 100}%`,
width: `${item.area.w * 100}%`,
height: `${item.area.h * 100}%`
}"
/>
<div
v-for="(rect, rectIndex) in page.redact"
:key="`redact-${rectIndex}`"
class="absolute"
:class="rect.color === 'white' ? 'bg-white' : 'bg-black'"
:style="{
left: `${rect.x * 100}%`,
top: `${rect.y * 100}%`,
width: `${rect.w * 100}%`,
height: `${rect.h * 100}%`
}"
/>
</div>
<div
v-if="withActions"
class="absolute top-1 right-1 flex space-x-1 group-hover:opacity-100"
:class="selected ? 'opacity-100' : 'opacity-0'"
>
<span
v-if="extraAction"
class="tooltip tooltip-bottom"
:data-tip="t(extraAction)"
>
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content transition-colors p-0"
style="width: 22px; height: 22px; min-height: 22px"
@click.stop.prevent="$emit(extraAction)"
>
<IconEraser
v-if="extraAction === 'redact'"
:width="14"
:height="14"
:stroke-width="1.6"
/>
<IconCrop
v-else
:width="20"
:height="20"
:stroke-width="1.2"
/>
</button>
</span>
<span
class="tooltip tooltip-bottom"
:data-tip="t('rotate')"
>
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content transition-colors p-0"
style="width: 22px; height: 22px; min-height: 22px"
@click.stop.prevent="$emit('rotate')"
>
<IconRotateClockwise
:width="14"
:height="14"
:stroke-width="1.6"
/>
</button>
</span>
<span
class="tooltip tooltip-bottom"
:data-tip="t('remove')"
>
<button
class="btn border-gray-300 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content transition-colors p-0"
style="width: 22px; height: 22px; min-height: 22px"
@click.stop.prevent="$emit('remove')"
>
<IconX
:width="14"
:height="14"
:stroke-width="1.6"
/>
</button>
</span>
</div>
</div>
<div
v-if="pageNumber"
class="text-center text-sm pt-1 pointer-events-none"
>
{{ t('page') }} {{ pageNumber }}
</div>
</div>
</template>
<script>
import { IconRotateClockwise, IconX, IconEraser, IconCrop } from '@tabler/icons-vue'
import Area from './area.vue'
export default {
name: 'DocumentsEditorPage',
components: {
IconRotateClockwise,
IconX,
IconEraser,
IconCrop
},
inject: ['t'],
props: {
page: {
type: Object,
required: true
},
imageUrl: {
type: String,
required: true
},
metadata: {
type: Object,
required: true
},
areas: {
type: Array,
required: false,
default: () => []
},
selected: {
type: Boolean,
required: false,
default: false
},
withActions: {
type: Boolean,
required: false,
default: false
},
pageNumber: {
type: Number,
required: false,
default: null
},
lazy: {
type: Boolean,
required: false,
default: true
},
extraAction: {
type: String,
required: false,
default: null
}
},
emits: ['rotate', 'remove', 'redact', 'crop'],
computed: {
borderColors: Area.computed.borderColors,
bgColors: Area.computed.bgColors,
boxStyle () {
if (!this.page.rotate || !(this.page.rotate % 180)) {
return null
}
return { aspectRatio: `${this.metadata.height} / ${this.metadata.width}` }
},
imageStyle () {
if (!this.page.rotate) {
return null
}
let transform = `rotate(${this.page.rotate}deg)`
if (this.page.rotate % 180) {
transform += ` scale(${this.metadata.width / this.metadata.height})`
}
return { transform }
},
overlayStyle () {
if (!this.page.rotate || !(this.page.rotate % 180)) {
return { inset: '0', transform: this.page.rotate ? `rotate(${this.page.rotate}deg)` : undefined }
}
return {
left: '50%',
top: '50%',
width: '100%',
aspectRatio: `${this.metadata.width} / ${this.metadata.height}`,
transform: `translate(-50%, -50%) rotate(${this.page.rotate}deg) scale(${this.metadata.width / this.metadata.height})`
}
}
},
methods: {
areaBorderColor (submitterIndex) {
return this.borderColors[Math.max(submitterIndex, 0) % this.borderColors.length]
},
areaBgColor (submitterIndex) {
return this.bgColors[Math.max(submitterIndex, 0) % this.bgColors.length]
}
}
}
</script>

@ -0,0 +1,490 @@
<template>
<div class="flex flex-1 min-h-0">
<div class="flex-1 overflow-y-auto px-6 py-4">
<div
ref="pageEl"
class="relative mx-auto select-none cursor-crosshair"
:style="pageStyle"
@mousedown.prevent="onMousedown"
@touchstart.prevent="onTouchstart"
>
<img
:src="imageUrl"
:width="metadata.width"
:height="metadata.height"
class="absolute border rounded pointer-events-none"
style="left: 50%; top: 50%; width: 100%; height: auto"
:style="imageStyle"
>
<div
class="absolute pointer-events-none"
:style="overlayStyle"
>
<div
v-for="(rect, rectIndex) in redactRects"
:key="`rect-${rectIndex}`"
class="absolute pointer-events-none"
:class="color === 'white' ? 'bg-white' : 'bg-black'"
:style="{
left: `${rect.x * 100}%`,
top: `${rect.y * 100}%`,
width: `${rect.w * 100}%`,
height: `${rect.h * 100}%`
}"
/>
</div>
<div
v-if="marquee"
class="absolute border border-neutral-600 bg-neutral-600/10 pointer-events-none"
:style="marqueeStyle"
/>
<div
v-if="!imagePage && textNodes && !textNodes.length && !imageNodes.length"
class="absolute inset-x-0 top-0 flex justify-center pt-4 pointer-events-none"
>
<span class="bg-base-100/90 border border-neutral-200 rounded-lg shadow px-4 py-2 text-sm">
{{ t('there_is_no_text_to_redact_on_this_page') }}
</span>
</div>
</div>
</div>
<div class="w-56 flex-none border-l px-4 py-4 space-y-2">
<div class="flex items-center justify-between mb-1">
<span class="text-sm pl-1">{{ t('color') }}</span>
<div
class="join rounded"
style="height: 28px"
>
<button
v-for="option in colors"
:key="option"
class="btn btn-sm join-item !h-7 !min-h-0 bg-white input-bordered hover:border-base-content/20 hover:bg-base-100/50 px-2"
:class="{ '!bg-base-200': color === option }"
@click.prevent="color = option"
>
<span
class="block w-10 h-4 border border-base-content/30"
:style="{ backgroundColor: option }"
/>
</button>
</div>
</div>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
:disabled="!hasRedactions && !wasReset"
@click.prevent="apply"
>
<IconCheck class="w-4 h-4" />
{{ t('apply') }}
</button>
<div class="border-t !mt-3 !mb-1" />
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
:disabled="!hasRedactions"
@click.prevent="reset"
>
<IconRotate class="w-4 h-4" />
{{ t('reset') }}
</button>
<button
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
@click.prevent="$emit('cancel')"
>
<IconX class="w-4 h-4" />
{{ t('cancel') }}
</button>
</div>
</div>
</template>
<script>
import { IconCheck, IconRotate, IconX } from '@tabler/icons-vue'
export default {
name: 'DocumentsEditorRedact',
components: {
IconCheck,
IconRotate,
IconX
},
inject: ['t', 'baseFetch'],
props: {
templateId: {
type: [Number, String],
required: true
},
page: {
type: Object,
required: true
},
imageUrl: {
type: String,
required: true
},
metadata: {
type: Object,
required: true
},
imagePage: {
type: Boolean,
required: false,
default: false
},
pageObjectsCache: {
type: Object,
required: false,
default: () => ({})
}
},
emits: ['apply', 'cancel'],
data () {
return {
textNodes: null,
imageNodes: [],
selectedNodes: {},
freeRects: [],
rects: [],
color: 'black',
wasReset: false,
marquee: null
}
},
computed: {
colors () {
return ['black', 'white']
},
rotate () {
return this.page.rotate || 0
},
displayWidth () {
return this.rotate % 180 ? this.metadata.height : this.metadata.width
},
displayHeight () {
return this.rotate % 180 ? this.metadata.width : this.metadata.height
},
pageStyle () {
return { aspectRatio: `${this.displayWidth} / ${this.displayHeight}` }
},
imageStyle () {
const scale = this.rotate % 180 ? this.metadata.width / this.metadata.height : 1
return {
transform: `translate(-50%, -50%) rotate(${this.rotate}deg) scale(${scale})`
}
},
overlayStyle () {
if (!this.rotate || !(this.rotate % 180)) {
return { inset: '0', transform: this.rotate ? `rotate(${this.rotate}deg)` : undefined }
}
return {
left: '50%',
top: '50%',
width: '100%',
aspectRatio: `${this.metadata.width} / ${this.metadata.height}`,
transform: `translate(-50%, -50%) rotate(${this.rotate}deg) scale(${this.metadata.width / this.metadata.height})`
}
},
hasRedactions () {
if (this.imagePage) {
return this.rects.length > 0
}
return Object.keys(this.selectedNodes).length > 0 || this.freeRects.length > 0
},
redactRects () {
if (this.imagePage) {
return this.rects
}
return this.textNodes ? [...this.buildRedactRects(), ...this.freeRects] : []
},
marqueeStyle () {
const left = Math.min(this.marquee.x1, this.marquee.x2)
const top = Math.min(this.marquee.y1, this.marquee.y2)
return {
left: `${left * 100}%`,
top: `${top * 100}%`,
width: `${Math.abs(this.marquee.x2 - this.marquee.x1) * 100}%`,
height: `${Math.abs(this.marquee.y2 - this.marquee.y1) * 100}%`
}
}
},
created () {
if ((this.page.redact || []).some((rect) => rect.color === 'white')) {
this.color = 'white'
}
if (this.imagePage) {
this.rects = (this.page.redact || []).map((rect) => ({ ...rect }))
return
}
const cacheKey = `${this.page.sourceUuid}-${this.page.sourcePage}`
if (this.pageObjectsCache[cacheKey]) {
this.textNodes = this.pageObjectsCache[cacheKey].text_nodes
this.imageNodes = this.pageObjectsCache[cacheKey].image_nodes
this.preselectNodes()
return
}
const query = new URLSearchParams({ attachment_uuid: this.page.sourceUuid, page: this.page.sourcePage })
this.baseFetch(`/templates/${this.templateId}/documents_page_objects?${query}`).then(async (resp) => {
if (resp.ok) {
const data = await resp.json()
this.pageObjectsCache[cacheKey] = data
this.textNodes = data.text_nodes
this.imageNodes = data.image_nodes
this.preselectNodes()
} else {
this.$emit('cancel')
}
})
},
beforeUnmount () {
window.removeEventListener('mousemove', this.onMousemove)
window.removeEventListener('mouseup', this.onMouseup)
window.removeEventListener('touchmove', this.onTouchmove)
window.removeEventListener('touchend', this.onTouchend)
window.removeEventListener('touchcancel', this.onTouchend)
},
methods: {
inverseRotatePoint (point, rotate) {
let { x, y } = point
if (rotate === 90) {
[x, y] = [y, 1 - x]
} else if (rotate === 180) {
[x, y] = [1 - x, 1 - y]
} else if (rotate === 270) {
[x, y] = [1 - y, x]
}
return { x, y }
},
apply () {
const rects = this.redactRects.map((rect) => {
const next = { x: rect.x, y: rect.y, w: rect.w, h: rect.h }
if (this.color === 'white') {
next.color = 'white'
}
return next
})
this.$emit('apply', rects)
},
reset () {
this.wasReset = true
if (this.imagePage) {
this.rects = []
} else {
this.selectedNodes = {}
this.freeRects = []
}
},
boxesIntersect (a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
},
preselectNodes () {
const nodeRects = []
;(this.page.redact || []).forEach((rect) => {
if (this.imageNodes.some((node) => this.boxesIntersect(node, rect))) {
this.freeRects.push({ ...rect })
} else {
nodeRects.push(rect)
}
})
this.textNodes.forEach((node, index) => {
const centerX = node.x + (node.w / 2)
const centerY = node.y + (node.h / 2)
const isInside = nodeRects.some((rect) => {
return centerX >= rect.x && centerX <= rect.x + rect.w &&
centerY >= rect.y && centerY <= rect.y + rect.h
})
if (isInside) {
this.selectedNodes[index] = true
}
})
},
buildRedactRects () {
const nodes = this.textNodes.filter((_, index) => this.selectedNodes[index])
const sorted = nodes.slice().sort((a, b) => {
const diff = (a.y + (a.h / 2)) - (b.y + (b.h / 2))
return Math.abs(diff) < Math.min(a.h, b.h) / 2 ? a.x - b.x : diff
})
const rects = []
sorted.forEach((node) => {
const last = rects[rects.length - 1]
const sameLine = last &&
Math.abs((node.y + (node.h / 2)) - (last.y + (last.h / 2))) < Math.max(last.h, node.h) / 2
if (sameLine && node.x <= last.x + last.w + (node.h * 0.7)) {
const right = Math.max(last.x + last.w, node.x + node.w)
const bottom = Math.max(last.y + last.h, node.y + node.h)
last.x = Math.min(last.x, node.x)
last.y = Math.min(last.y, node.y)
last.w = right - last.x
last.h = bottom - last.y
} else {
rects.push({ x: node.x, y: node.y, w: node.w, h: node.h })
}
})
return rects
},
pagePoint (event) {
const rect = this.$refs.pageEl.getBoundingClientRect()
return {
x: Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1),
y: Math.min(Math.max((event.clientY - rect.top) / rect.height, 0), 1)
}
},
startMarquee (point) {
const start = this.pagePoint(point)
this.marquee = { x1: start.x, y1: start.y, x2: start.x, y2: start.y }
},
updateMarquee (point) {
if (!this.marquee) {
return
}
const next = this.pagePoint(point)
this.marquee.x2 = next.x
this.marquee.y2 = next.y
},
onMousedown (event) {
if (event.button !== 0 || (!this.imagePage && !this.textNodes)) {
return
}
this.startMarquee(event)
window.addEventListener('mousemove', this.onMousemove)
window.addEventListener('mouseup', this.onMouseup, { once: true })
},
onMousemove (event) {
this.updateMarquee(event)
},
onMouseup () {
window.removeEventListener('mousemove', this.onMousemove)
this.finishMarquee()
},
onTouchstart (event) {
if (!this.imagePage && !this.textNodes) {
return
}
this.startMarquee(event.touches[0])
window.addEventListener('touchmove', this.onTouchmove, { passive: false })
window.addEventListener('touchend', this.onTouchend)
window.addEventListener('touchcancel', this.onTouchend)
},
onTouchmove (event) {
this.updateMarquee(event.touches[0])
},
onTouchend () {
window.removeEventListener('touchmove', this.onTouchmove)
window.removeEventListener('touchend', this.onTouchend)
window.removeEventListener('touchcancel', this.onTouchend)
this.finishMarquee()
},
finishMarquee () {
if (!this.marquee) {
return
}
const start = this.inverseRotatePoint({ x: this.marquee.x1, y: this.marquee.y1 }, this.rotate)
const finish = this.inverseRotatePoint({ x: this.marquee.x2, y: this.marquee.y2 }, this.rotate)
const left = Math.min(start.x, finish.x)
const right = Math.max(start.x, finish.x)
const top = Math.min(start.y, finish.y)
const bottom = Math.max(start.y, finish.y)
this.marquee = null
if (right - left < 0.005 && bottom - top < 0.005) {
if (this.imagePage) {
const index = this.rects.findIndex((rect) => {
return left >= rect.x && left <= rect.x + rect.w && top >= rect.y && top <= rect.y + rect.h
})
if (index !== -1) {
this.rects.splice(index, 1)
}
} else {
const rectIndex = this.freeRects.findIndex((rect) => {
return left >= rect.x && left <= rect.x + rect.w && top >= rect.y && top <= rect.y + rect.h
})
if (rectIndex !== -1) {
this.freeRects.splice(rectIndex, 1)
} else {
const index = this.textNodes.findIndex((node) => {
return left >= node.x && left <= node.x + node.w && top >= node.y && top <= node.y + node.h
})
if (index !== -1) {
if (this.selectedNodes[index]) {
delete this.selectedNodes[index]
} else {
this.selectedNodes[index] = true
}
}
}
}
} else if (this.imagePage) {
this.rects.push({ x: left, y: top, w: right - left, h: bottom - top })
} else {
const marqueeBox = { x: left, y: top, w: right - left, h: bottom - top }
this.textNodes.forEach((node, index) => {
if (node.x < right && node.x + node.w > left && node.y < bottom && node.y + node.h > top) {
this.selectedNodes[index] = true
}
})
this.imageNodes.forEach((node) => {
if (!this.boxesIntersect(node, marqueeBox)) {
return
}
const x = Math.max(node.x, marqueeBox.x)
const y = Math.max(node.y, marqueeBox.y)
const w = Math.min(node.x + node.w, marqueeBox.x + marqueeBox.w) - x
const h = Math.min(node.y + node.h, marqueeBox.y + marqueeBox.h) - y
this.freeRects.push({ x, y, w, h })
})
}
}
}
}
</script>

@ -62,7 +62,7 @@
name="files[]" name="files[]"
:accept="acceptFileTypes" :accept="acceptFileTypes"
multiple multiple
@change="upload" @change="upload()"
> >
</form> </form>
</label> </label>

@ -94,6 +94,8 @@ img.ProseMirror-separator {
} }
dynamic-variable { dynamic-variable {
background-color: #fef3c7; background-color: #fef3c7;
word-break: break-all;
overflow-wrap: anywhere;
}`) }`)
function collectDomAttrs (dom) { function collectDomAttrs (dom) {
@ -136,11 +138,12 @@ function collectSpanDomAttrs (dom) {
return result return result
} }
function createBlockNode (name, tag, content) { function createBlockNode (name, tag, content, extra = {}) {
return Node.create({ return Node.create({
name, name,
group: 'block', group: 'block',
content: content || 'block+', content: content || 'block+',
...extra,
addAttributes () { addAttributes () {
return { return {
htmlAttrs: { default: {} } htmlAttrs: { default: {} }
@ -194,9 +197,9 @@ const CustomHeading = Node.create({
}) })
const SectionNode = createBlockNode('section', 'section') const SectionNode = createBlockNode('section', 'section')
const ArticleNode = createBlockNode('article', 'article') const ArticleNode = createBlockNode('article', 'article', null, { isolating: true })
const HeaderNode = createBlockNode('header', 'header') const HeaderNode = createBlockNode('header', 'header', null, { isolating: true })
const FooterNode = createBlockNode('footer', 'footer') const FooterNode = createBlockNode('footer', 'footer', null, { isolating: true })
const DivNode = createBlockNode('div', 'div') const DivNode = createBlockNode('div', 'div')
const BlockquoteNode = createBlockNode('blockquote', 'blockquote') const BlockquoteNode = createBlockNode('blockquote', 'blockquote')
const PreNode = createBlockNode('pre', 'pre') const PreNode = createBlockNode('pre', 'pre')

@ -248,7 +248,7 @@ export default {
FieldType, FieldType,
IconSettings IconSettings
}, },
inject: ['t', 'save', 'backgroundColor', 'dateFormats'], inject: ['t', 'save', 'backgroundColor', 'dateFormats', 'locale'],
provide () { provide () {
return { return {
fieldTypes: ['text', 'number', 'date', 'checkbox', 'radio', 'select'] fieldTypes: ['text', 'number', 'date', 'checkbox', 'radio', 'select']
@ -308,15 +308,18 @@ export default {
return { text: 'string', number: 'number', date: 'date', checkbox: 'boolean', radio: 'string', select: 'string' } return { text: 'string', number: 'number', date: 'date', checkbox: 'boolean', radio: 'string', select: 'string' }
}, },
numberFormats () { numberFormats () {
return [ const formats = ['none', 'usd', 'eur', 'gbp', 'comma', 'dot', 'space']
'none', const spaceLocales = ['fr-FR', 'es-ES', 'pt-PT', 'de-DE', 'it-IT', 'nl-NL']
'usd',
'eur', formats.push(spaceLocales.includes(this.locale) ? 'percent_space' : 'percent')
'gbp',
'comma', const selectedFormat = this.schema.format
'dot',
'space' if (selectedFormat && !formats.includes(selectedFormat)) {
] formats.push(selectedFormat)
}
return formats
}, },
availableDateFormats () { availableDateFormats () {
const formats = this.dateFormats.length const formats = this.dateFormats.length
@ -397,6 +400,10 @@ export default {
return new Intl.NumberFormat('de-DE').format(number) return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') { } else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number) return new Intl.NumberFormat('fr-FR').format(number)
} else if (format === 'percent') {
return `${number}%`
} else if (format === 'percent_space') {
return `${String(number).replace('.', ',')} %`
} else { } else {
return number return number
} }

@ -514,7 +514,7 @@ export default {
ContextSubmenu, ContextSubmenu,
ContextModal ContextModal
}, },
inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies', 'dateFormats'], inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies', 'dateFormats', 'locale'],
props: { props: {
contextMenu: { contextMenu: {
type: Object, type: Object,

@ -610,7 +610,7 @@ export default {
IconTypography, IconTypography,
IconX IconX
}, },
inject: ['template', 't', 'dateFormats'], inject: ['template', 't', 'dateFormats', 'locale'],
props: { props: {
field: { field: {
type: Object, type: Object,
@ -691,15 +691,18 @@ export default {
} }
}, },
numberFormats () { numberFormats () {
return [ const formats = ['none', 'usd', 'eur', 'gbp', 'comma', 'dot', 'space']
'none', const spaceLocales = ['fr-FR', 'es-ES', 'pt-PT', 'de-DE', 'it-IT', 'nl-NL']
'usd',
'eur', formats.push(spaceLocales.includes(this.locale) ? 'percent_space' : 'percent')
'gbp',
'comma', const selectedFormat = this.field.preferences?.format
'dot',
'space' if (selectedFormat && !formats.includes(selectedFormat)) {
] formats.push(selectedFormat)
}
return formats
}, },
availableDateFormats () { availableDateFormats () {
const formats = this.dateFormats.length const formats = this.dateFormats.length
@ -824,6 +827,10 @@ export default {
return new Intl.NumberFormat('de-DE').format(number) return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') { } else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number) return new Intl.NumberFormat('fr-FR').format(number)
} else if (format === 'percent') {
return `${number}%`
} else if (format === 'percent_space') {
return `${String(number).replace('.', ',')} %`
} else { } else {
return number return number
} }

@ -0,0 +1,165 @@
<template>
<Teleport :to="modalContainerEl">
<div
class="modal modal-open items-start !animate-none overflow-y-auto"
>
<div
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title">
Google Drive
</span>
<a
href="#"
class="text-xl modal-close-button"
@click.prevent="$emit('close')"
>&times;</a>
</div>
<div>
<form
v-if="showOauthButton"
method="post"
:action="oauthPath"
@submit="isConnectClicked = true"
>
<input
type="hidden"
name="authenticity_token"
:value="authenticityToken"
autocomplete="off"
>
<button
class="btn bg-white btn-outline w-full text-base font-medium mt-4"
data-turbo="false"
type="submit"
:disabled="isConnectClicked"
>
<span v-if="isConnectClicked">
<span class="flex items-center justify-center space-x-2">
<IconInnerShadowTop class="animate-spin" />
<span>{{ t('submitting') }}...</span>
</span>
</span>
<span v-else>
<span class="flex items-center justify-center space-x-2">
<IconBrandGoogleDrive />
<span>{{ t('connect_google_drive') }}</span>
</span>
</span>
</button>
</form>
<div
v-else
class="relative"
>
<iframe
class="border border-base-300 rounded-lg"
style="width: 100%; height: 440px; background: white;"
src="/template_google_drive"
/>
<div v-if="loading">
<div
class="bg-white absolute top-0 bottom-0 left-0 right-0 opacity-80 rounded-lg"
style="margin: 1px"
/>
<div class="absolute top-0 bottom-0 left-0 right-0 flex items-center justify-center">
<IconInnerShadowTop class="animate-spin" />
</div>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script>
import { IconBrandGoogleDrive, IconInnerShadowTop } from '@tabler/icons-vue'
export default {
name: 'GoogleDrivePickerModal',
components: {
IconBrandGoogleDrive,
IconInnerShadowTop
},
inject: ['t'],
props: {
templateId: {
type: [Number, String],
required: true
},
authenticityToken: {
type: String,
required: false,
default: ''
},
reopenAfterAuth: {
type: Boolean,
required: false,
default: false
},
modalContainerEl: {
type: [Object, String],
required: true
},
loading: {
type: Boolean,
required: false,
default: true
}
},
emits: ['close', 'picked', 'update:loading'],
data () {
return {
isConnectClicked: false,
showOauthButton: false
}
},
computed: {
oauthPath () {
const redir = this.reopenAfterAuth
? `/templates/${this.templateId}/edit?google_drive_open=1`
: `/templates/${this.templateId}/edit`
const params = {
access_type: 'offline',
include_granted_scopes: 'true',
prompt: 'consent',
scope: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/drive.file'
].join(' '),
oauth_data: new URLSearchParams({ redir }).toString()
}
return `/auth/google_oauth2?${new URLSearchParams(params).toString()}`
}
},
mounted () {
window.addEventListener('message', this.messageHandler)
},
beforeUnmount () {
window.removeEventListener('message', this.messageHandler)
},
methods: {
messageHandler (event) {
if (event.data.type === 'google-drive-files-picked') {
const files = event.data.files || []
if (!files.length) return
this.$emit('update:loading', true)
this.$emit('picked', files)
} else if (event.data.type === 'google-drive-picker-loaded') {
this.$emit('update:loading', false)
} else if (event.data.type === 'google-drive-picker-request-oauth') {
this.showOauthButton = true
this.$emit('update:loading', false)
}
}
}
}
</script>

@ -96,6 +96,24 @@ const en = {
add_pdf_documents_or_images: 'Add PDF documents or images', add_pdf_documents_or_images: 'Add PDF documents or images',
add_documents_or_images: 'Add documents or images', add_documents_or_images: 'Add documents or images',
add_a_new_document: 'Add a new document', add_a_new_document: 'Add a new document',
edit_documents: 'Edit documents',
move_forward: 'Move forward',
move_backward: 'Move backward',
remove_page: 'Remove page',
merge_with_previous: 'Merge with previous',
merge_with_next: 'Merge with next',
move_up: 'Move up',
move_down: 'Move down',
rotate: 'Rotate',
redact: 'Redact',
crop: 'Crop',
crop_and_scan: 'Crop & Scan',
flip_horizontal: 'Flip horizontal',
flip_vertical: 'Flip vertical',
there_is_no_text_to_redact_on_this_page: 'This page contains only images. Redact tool can be used only with text pages',
color: 'Color',
reset: 'Reset',
upload_to_document: 'Upload to "{document}"',
replace_existing_document: 'Replace existing document', replace_existing_document: 'Replace existing document',
clone_and_replace_documents: 'Clone and replace documents', clone_and_replace_documents: 'Clone and replace documents',
required: 'Required', required: 'Required',
@ -219,7 +237,9 @@ const en = {
revisions: 'Revisions', revisions: 'Revisions',
apply: 'Apply', apply: 'Apply',
no_revisions_yet: 'No revisions yet', no_revisions_yet: 'No revisions yet',
viewing_revision_from: 'Viewing revision from {date}' viewing_revision_from: 'Viewing revision from {date}',
connect_google_drive: 'Connect Google Drive',
submitting: 'Submitting'
} }
const es = { const es = {
@ -317,6 +337,24 @@ const es = {
add_pdf_documents_or_images: 'Agregar documentos PDF o imágenes', add_pdf_documents_or_images: 'Agregar documentos PDF o imágenes',
add_documents_or_images: 'Agregar documentos o imágenes', add_documents_or_images: 'Agregar documentos o imágenes',
add_a_new_document: 'Agregar un nuevo documento', add_a_new_document: 'Agregar un nuevo documento',
edit_documents: 'Editar documentos',
move_forward: 'Mover adelante',
move_backward: 'Mover atrás',
remove_page: 'Eliminar página',
merge_with_previous: 'Combinar con el anterior',
merge_with_next: 'Combinar con el siguiente',
move_up: 'Mover arriba',
move_down: 'Mover abajo',
rotate: 'Rotar',
redact: 'Censurar',
crop: 'Recortar',
crop_and_scan: 'Recortar y escanear',
flip_horizontal: 'Voltear horizontal',
flip_vertical: 'Voltear vertical',
there_is_no_text_to_redact_on_this_page: 'Esta página contiene solo imágenes. La herramienta de censura solo puede usarse con páginas de texto',
color: 'Color',
reset: 'Restablecer',
upload_to_document: 'Subir a "{document}"',
replace_existing_document: 'Reemplazar documento existente', replace_existing_document: 'Reemplazar documento existente',
clone_and_replace_documents: 'Clonar y reemplazar documentos', clone_and_replace_documents: 'Clonar y reemplazar documentos',
required: 'Requerido', required: 'Requerido',
@ -443,7 +481,9 @@ const es = {
revisions: 'Revisiones', revisions: 'Revisiones',
apply: 'Aplicar', apply: 'Aplicar',
no_revisions_yet: 'Aún no hay revisiones', no_revisions_yet: 'Aún no hay revisiones',
viewing_revision_from: 'Viendo revisión del {date}' viewing_revision_from: 'Viendo revisión del {date}',
connect_google_drive: 'Conectar Google Drive',
submitting: 'Enviando'
} }
const it = { const it = {
@ -544,6 +584,24 @@ const it = {
add_pdf_documents_or_images: 'Aggiungi documenti PDF o immagini', add_pdf_documents_or_images: 'Aggiungi documenti PDF o immagini',
add_documents_or_images: 'Aggiungi documenti o immagini', add_documents_or_images: 'Aggiungi documenti o immagini',
add_a_new_document: 'Aggiungi un nuovo documento', add_a_new_document: 'Aggiungi un nuovo documento',
edit_documents: 'Modifica documenti',
move_forward: 'Sposta avanti',
move_backward: 'Sposta indietro',
remove_page: 'Rimuovi pagina',
merge_with_previous: 'Unisci al precedente',
merge_with_next: 'Unisci al successivo',
move_up: 'Sposta su',
move_down: 'Sposta giù',
rotate: 'Ruota',
redact: 'Oscura',
crop: 'Ritaglia',
crop_and_scan: 'Ritaglia e scansiona',
flip_horizontal: 'Rifletti orizzontale',
flip_vertical: 'Rifletti verticale',
there_is_no_text_to_redact_on_this_page: 'Questa pagina contiene solo immagini. Lo strumento di oscuramento può essere usato solo con pagine di testo',
color: 'Colore',
reset: 'Reimposta',
upload_to_document: 'Carica in "{document}"',
replace_existing_document: 'Sostituisci documento esistente', replace_existing_document: 'Sostituisci documento esistente',
clone_and_replace_documents: 'Clona e sostituisci documenti', clone_and_replace_documents: 'Clona e sostituisci documenti',
required: 'Obbligatorio', required: 'Obbligatorio',
@ -667,7 +725,9 @@ const it = {
revisions: 'Revisioni', revisions: 'Revisioni',
apply: 'Applica', apply: 'Applica',
no_revisions_yet: 'Nessuna revisione ancora', no_revisions_yet: 'Nessuna revisione ancora',
viewing_revision_from: 'Visualizzazione revisione del {date}' viewing_revision_from: 'Visualizzazione revisione del {date}',
connect_google_drive: 'Connetti Google Drive',
submitting: 'Invio'
} }
const pt = { const pt = {
@ -679,7 +739,7 @@ const pt = {
analyzing_: 'Analisando...', analyzing_: 'Analisando...',
download: 'Baixar', download: 'Baixar',
downloading_: 'Baixando...', downloading_: 'Baixando...',
view: 'Visualizar', view: 'Ver',
payment_link: 'Link de pagamento', payment_link: 'Link de pagamento',
strikeout: 'Tachado', strikeout: 'Tachado',
draw_strikethrough_the_document: 'Desenhe uma linha de tachado no documento', draw_strikethrough_the_document: 'Desenhe uma linha de tachado no documento',
@ -765,6 +825,24 @@ const pt = {
add_pdf_documents_or_images: 'Adicionar documentos PDF ou imagens', add_pdf_documents_or_images: 'Adicionar documentos PDF ou imagens',
add_documents_or_images: 'Adicionar documentos ou imagens', add_documents_or_images: 'Adicionar documentos ou imagens',
add_a_new_document: 'Adicionar um novo documento', add_a_new_document: 'Adicionar um novo documento',
edit_documents: 'Editar documentos',
move_forward: 'Mover para frente',
move_backward: 'Mover para trás',
remove_page: 'Remover página',
merge_with_previous: 'Mesclar com o anterior',
merge_with_next: 'Mesclar com o próximo',
move_up: 'Mover para cima',
move_down: 'Mover para baixo',
rotate: 'Girar',
redact: 'Censurar',
crop: 'Cortar',
crop_and_scan: 'Cortar e digitalizar',
flip_horizontal: 'Inverter horizontal',
flip_vertical: 'Inverter vertical',
there_is_no_text_to_redact_on_this_page: 'Esta página contém apenas imagens. A ferramenta de censura só pode ser usada com páginas de texto',
color: 'Cor',
reset: 'Redefinir',
upload_to_document: 'Enviar para "{document}"',
replace_existing_document: 'Substituir documento existente', replace_existing_document: 'Substituir documento existente',
clone_and_replace_documents: 'Clonar e substituir documentos', clone_and_replace_documents: 'Clonar e substituir documentos',
required: 'Obrigatório', required: 'Obrigatório',
@ -891,7 +969,9 @@ const pt = {
revisions: 'Revisões', revisions: 'Revisões',
apply: 'Aplicar', apply: 'Aplicar',
no_revisions_yet: 'Nenhuma revisão ainda', no_revisions_yet: 'Nenhuma revisão ainda',
viewing_revision_from: 'Visualizando revisão de {date}' viewing_revision_from: 'Visualizando revisão de {date}',
connect_google_drive: 'Conectar Google Drive',
submitting: 'Enviando'
} }
const fr = { const fr = {
@ -992,6 +1072,24 @@ const fr = {
add_pdf_documents_or_images: 'Ajouter des documents PDF ou des images', add_pdf_documents_or_images: 'Ajouter des documents PDF ou des images',
add_documents_or_images: 'Ajouter des documents ou des images', add_documents_or_images: 'Ajouter des documents ou des images',
add_a_new_document: 'Ajouter un nouveau document', add_a_new_document: 'Ajouter un nouveau document',
edit_documents: 'Modifier les documents',
move_forward: 'Déplacer en avant',
move_backward: 'Déplacer en arrière',
remove_page: 'Supprimer la page',
merge_with_previous: 'Fusionner avec le précédent',
merge_with_next: 'Fusionner avec le suivant',
move_up: 'Déplacer vers le haut',
move_down: 'Déplacer vers le bas',
rotate: 'Pivoter',
redact: 'Caviarder',
crop: 'Rogner',
crop_and_scan: 'Rogner et numériser',
flip_horizontal: 'Miroir horizontal',
flip_vertical: 'Miroir vertical',
there_is_no_text_to_redact_on_this_page: 'Cette page ne contient que des images. L\'outil de caviardage ne peut être utilisé qu\'avec des pages de texte',
color: 'Couleur',
reset: 'Réinitialiser',
upload_to_document: 'Téléverser dans "{document}"',
replace_existing_document: 'Remplacer le document existant', replace_existing_document: 'Remplacer le document existant',
clone_and_replace_documents: 'Cloner et remplacer des documents', clone_and_replace_documents: 'Cloner et remplacer des documents',
required: 'Obligatoire', required: 'Obligatoire',
@ -1115,7 +1213,9 @@ const fr = {
revisions: 'Révisions', revisions: 'Révisions',
apply: 'Appliquer', apply: 'Appliquer',
no_revisions_yet: 'Aucune révision pour le moment', no_revisions_yet: 'Aucune révision pour le moment',
viewing_revision_from: 'Affichage de la révision du {date}' viewing_revision_from: 'Affichage de la révision du {date}',
connect_google_drive: 'Connecter Google Drive',
submitting: 'Soumission'
} }
const de = { const de = {
@ -1216,6 +1316,24 @@ const de = {
add_pdf_documents_or_images: 'PDF-Dokumente oder Bilder hinzufügen', add_pdf_documents_or_images: 'PDF-Dokumente oder Bilder hinzufügen',
add_documents_or_images: 'Dokumente oder Bilder hinzufügen', add_documents_or_images: 'Dokumente oder Bilder hinzufügen',
add_a_new_document: 'Neues Dokument hinzufügen', add_a_new_document: 'Neues Dokument hinzufügen',
edit_documents: 'Dokumente bearbeiten',
move_forward: 'Nach vorne',
move_backward: 'Nach hinten',
remove_page: 'Seite entfernen',
merge_with_previous: 'Mit vorherigem zusammenführen',
merge_with_next: 'Mit nächstem zusammenführen',
move_up: 'Nach oben verschieben',
move_down: 'Nach unten verschieben',
rotate: 'Drehen',
redact: 'Schwärzen',
crop: 'Zuschneiden',
crop_and_scan: 'Zuschneiden & Scan',
flip_horizontal: 'Horizontal spiegeln',
flip_vertical: 'Vertikal spiegeln',
there_is_no_text_to_redact_on_this_page: 'Diese Seite enthält nur Bilder. Das Schwärzungswerkzeug kann nur mit Textseiten verwendet werden',
color: 'Farbe',
reset: 'Zurücksetzen',
upload_to_document: 'In "{document}" hochladen',
replace_existing_document: 'Vorhandenes Dokument ersetzen', replace_existing_document: 'Vorhandenes Dokument ersetzen',
clone_and_replace_documents: 'Dokumente klonen und ersetzen', clone_and_replace_documents: 'Dokumente klonen und ersetzen',
required: 'Erforderlich', required: 'Erforderlich',
@ -1339,7 +1457,9 @@ const de = {
revisions: 'Revisionen', revisions: 'Revisionen',
apply: 'Anwenden', apply: 'Anwenden',
no_revisions_yet: 'Noch keine Revisionen', no_revisions_yet: 'Noch keine Revisionen',
viewing_revision_from: 'Ansicht der Revision vom {date}' viewing_revision_from: 'Ansicht der Revision vom {date}',
connect_google_drive: 'Google Drive verbinden',
submitting: 'Übermittlung'
} }
const nl = { const nl = {
@ -1440,6 +1560,24 @@ const nl = {
add_pdf_documents_or_images: 'PDF-documenten of afbeeldingen toevoegen', add_pdf_documents_or_images: 'PDF-documenten of afbeeldingen toevoegen',
add_documents_or_images: 'Documenten of afbeeldingen toevoegen', add_documents_or_images: 'Documenten of afbeeldingen toevoegen',
add_a_new_document: 'Nieuw document toevoegen', add_a_new_document: 'Nieuw document toevoegen',
edit_documents: 'Documenten bewerken',
move_forward: 'Naar voren',
move_backward: 'Naar achteren',
remove_page: 'Pagina verwijderen',
merge_with_previous: 'Samenvoegen met vorige',
merge_with_next: 'Samenvoegen met volgende',
move_up: 'Omhoog verplaatsen',
move_down: 'Omlaag verplaatsen',
rotate: 'Draaien',
redact: 'Redigeren',
crop: 'Bijsnijden',
crop_and_scan: 'Bijsnijden en scannen',
flip_horizontal: 'Horizontaal spiegelen',
flip_vertical: 'Verticaal spiegelen',
there_is_no_text_to_redact_on_this_page: 'Deze pagina bevat alleen afbeeldingen. De redactietool kan alleen worden gebruikt met tekstpagina\'s',
color: 'Kleur',
reset: 'Opnieuw instellen',
upload_to_document: 'Uploaden naar "{document}"',
replace_existing_document: 'Bestaand document vervangen', replace_existing_document: 'Bestaand document vervangen',
clone_and_replace_documents: 'Documenten klonen en vervangen', clone_and_replace_documents: 'Documenten klonen en vervangen',
required: 'Vereist', required: 'Vereist',
@ -1563,7 +1701,9 @@ const nl = {
revisions: 'Revisies', revisions: 'Revisies',
apply: 'Toepassen', apply: 'Toepassen',
no_revisions_yet: 'Nog geen revisies', no_revisions_yet: 'Nog geen revisies',
viewing_revision_from: 'Revisie van {date} bekijken' viewing_revision_from: 'Revisie van {date} bekijken',
connect_google_drive: 'Verbind Google Drive',
submitting: 'Indienen'
} }
export { en, es, it, pt, fr, de, nl } export { en, es, it, pt, fr, de, nl }

@ -11,7 +11,7 @@
:src="image.url" :src="image.url"
:width="width" :width="width"
:height="height" :height="height"
class="rounded" class="w-full h-full rounded"
@load="onImageLoad" @load="onImageLoad"
> >
<div <div

@ -36,7 +36,10 @@
v-if="withReplaceButton" v-if="withReplaceButton"
:template-id="template.id" :template-id="template.id"
:accept-file-types="acceptFileTypes" :accept-file-types="acceptFileTypes"
class="opacity-0 group-hover:opacity-100" :authenticity-token="authenticityToken"
:with-google-drive="withGoogleDrive"
:google-drive-file-id="item.google_drive_file_id"
class="opacity-0 group-hover:opacity-100 has-[label:focus]:opacity-100"
@click.stop @click.stop
@success="$emit('replace', { replaceSchemaItem: item, ...$event })" @success="$emit('replace', { replaceSchemaItem: item, ...$event })"
/> />
@ -69,6 +72,15 @@
style="min-width: 170px" style="min-width: 170px"
@click="closeDropdown" @click="closeDropdown"
> >
<li v-if="!item.dynamic">
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm whitespace-nowrap"
@click.stop="$emit('edit', item); closeDropdown()"
>
<IconPencil class="w-4 h-4" />
<span>{{ t('edit') }}</span>
</button>
</li>
<li> <li>
<button <button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm" class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@ -181,7 +193,7 @@
<script> <script>
import Contenteditable from './contenteditable' import Contenteditable from './contenteditable'
import Upload from './upload' import Upload from './upload'
import { IconRouteAltLeft, IconSortDescending2, IconDotsVertical, IconTrashX, IconBolt, IconInnerShadowTop } from '@tabler/icons-vue' import { IconRouteAltLeft, IconSortDescending2, IconDotsVertical, IconTrashX, IconBolt, IconInnerShadowTop, IconPencil } from '@tabler/icons-vue'
import ConditionsModal from './conditions_modal' import ConditionsModal from './conditions_modal'
import ReplaceButton from './replace' import ReplaceButton from './replace'
import GoogleDriveDocumentSettings from './google_drive_document_settings' import GoogleDriveDocumentSettings from './google_drive_document_settings'
@ -201,7 +213,8 @@ export default {
IconSortDescending2, IconSortDescending2,
IconDotsVertical, IconDotsVertical,
IconTrashX, IconTrashX,
IconBolt IconBolt,
IconPencil
}, },
inject: ['t', 'getFieldTypeIndex', 'baseFetch'], inject: ['t', 'getFieldTypeIndex', 'baseFetch'],
props: { props: {
@ -232,6 +245,16 @@ export default {
required: true, required: true,
default: true default: true
}, },
withGoogleDrive: {
type: Boolean,
required: false,
default: false
},
authenticityToken: {
type: String,
required: false,
default: ''
},
dynamicDocuments: { dynamicDocuments: {
type: Array, type: Array,
required: true required: true
@ -247,7 +270,7 @@ export default {
default: true default: true
} }
}, },
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace', 'reorder'], emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace', 'reorder', 'edit'],
data () { data () {
return { return {
isShowConditionsModal: false, isShowConditionsModal: false,

@ -1,37 +1,109 @@
<template> <template>
<label <div
:for="inputId" class="inline-flex items-stretch replace-document-control"
class="btn btn-neutral btn-xs text-white transition-none replace-document-button"
:class="{ 'opacity-100': isLoading }" :class="{ 'opacity-100': isLoading }"
> >
{{ message }} <label
:for="inputId"
class="btn btn-neutral btn-xs transition-none replace-document-button"
:class="{ 'opacity-100': isLoading, 'pr-0': showGoogleDriveDropdown && !isLoading }"
>
<div class="flex items-center justify-between w-full h-full">
<span class="flex items-center space-x-2 w-full justify-center text-white">
{{ message }}
</span>
<span
v-if="showGoogleDriveDropdown && !isLoading"
class="dropdown dropdown-end inline h-full"
style="width: 30px"
>
<label
tabindex="0"
class="flex items-center h-full cursor-pointer text-white"
>
<IconChevronDown class="w-4 h-4 flex-shrink-0" />
</label>
<ul
tabindex="0"
:style="{ backgroundColor }"
class="dropdown-content p-2 mt-2 shadow menu text-base mb-1 rounded-box text-right !text-base-content"
>
<li>
<button
type="button"
@click.prevent="openGoogleDriveModal"
>
<IconBrandGoogleDrive class="w-4 h-4 flex-shrink-0" />
<span class="whitespace-nowrap text-sm normal-case font-medium">Google Drive</span>
</button>
</li>
</ul>
</span>
</div>
</label>
<form <form
ref="form" ref="form"
class="hidden" class="hidden"
> >
<input
v-if="googleDriveFile"
name="google_drive_file_ids[]"
:value="googleDriveFile.id"
>
<input <input
:id="inputId" :id="inputId"
ref="input" ref="input"
name="files[]" name="files[]"
type="file" type="file"
:accept="acceptFileTypes" :accept="acceptFileTypes"
@change="upload" @change="upload()"
> >
</form> </form>
</label> <GoogleDrivePickerModal
v-if="showGoogleDriveModal"
v-model:loading="isLoadingGoogleDrive"
:template-id="templateId"
:authenticity-token="authenticityToken"
:modal-container-el="modalContainerEl"
@close="showGoogleDriveModal = false"
@picked="onGoogleDrivePicked"
/>
</div>
</template> </template>
<script> <script>
import Upload from './upload' import Upload from './upload'
import GoogleDrivePickerModal from './google_drive_picker_modal'
import { IconChevronDown, IconBrandGoogleDrive } from '@tabler/icons-vue'
export default { export default {
name: 'ReplaceDocument', name: 'ReplaceDocument',
inject: ['baseFetch', 't'], components: {
IconChevronDown,
IconBrandGoogleDrive,
GoogleDrivePickerModal
},
inject: ['baseFetch', 't', 'backgroundColor'],
props: { props: {
templateId: { templateId: {
type: [Number, String], type: [Number, String],
required: true required: true
}, },
authenticityToken: {
type: String,
required: false,
default: ''
},
googleDriveFileId: {
type: String,
required: false,
default: ''
},
withGoogleDrive: {
type: Boolean,
required: false,
default: false
},
acceptFileTypes: { acceptFileTypes: {
type: String, type: String,
required: false, required: false,
@ -41,16 +113,25 @@ export default {
emits: ['success'], emits: ['success'],
data () { data () {
return { return {
isLoading: false isLoading: false,
isLoadingGoogleDrive: true,
googleDriveFile: null,
showGoogleDriveModal: false
} }
}, },
computed: { computed: {
inputId () { inputId () {
return 'el' + Math.random().toString(32).split('.')[1] return 'el' + Math.random().toString(32).split('.')[1]
}, },
showGoogleDriveDropdown () {
return this.withGoogleDrive && !!this.googleDriveFileId
},
uploadUrl () { uploadUrl () {
return `/templates/${this.templateId}/documents` return `/templates/${this.templateId}/documents`
}, },
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
message () { message () {
if (this.isLoading) { if (this.isLoading) {
return this.t('uploading_') return this.t('uploading_')
@ -60,7 +141,27 @@ export default {
} }
}, },
methods: { methods: {
upload: Upload.methods.upload upload: Upload.methods.upload,
openGoogleDriveModal () {
this.showGoogleDriveModal = true
this.isLoadingGoogleDrive = true
this.googleDriveFile = null
this.$el.getRootNode().activeElement?.blur()
},
onGoogleDrivePicked (files) {
this.googleDriveFile = files[0]
this.$nextTick(() => {
this.upload({ path: `/templates/${this.templateId}/google_drive_documents` }).then((resp) => {
if (resp.ok) {
this.showGoogleDriveModal = false
}
}).finally(() => {
this.isLoadingGoogleDrive = false
this.googleDriveFile = null
})
})
}
} }
} }
</script> </script>

@ -61,87 +61,16 @@
</span> </span>
</div> </div>
</label> </label>
<Teleport <GoogleDrivePickerModal
v-if="showGoogleDriveModal" v-if="showGoogleDriveModal"
:to="modalContainerEl" v-model:loading="isLoadingGoogleDrive"
> :template-id="templateId"
<div :authenticity-token="authenticityToken"
class="modal modal-open items-start !animate-none overflow-y-auto" :modal-container-el="modalContainerEl"
> :reopen-after-auth="true"
<div @close="showGoogleDriveModal = false"
class="absolute top-0 bottom-0 right-0 left-0" @picked="onGoogleDrivePicked"
@click.prevent="showGoogleDriveModal = false" />
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title">
Google Drive
</span>
<a
href="#"
class="text-xl modal-close-button"
@click.prevent="showGoogleDriveModal = false"
>&times;</a>
</div>
<div>
<form
v-if="showGoogleDriveOauthButton"
method="post"
:action="googleDriveOauthPath"
@submit="isConnectGoogleDriveClicked = true"
>
<input
type="hidden"
name="authenticity_token"
:value="authenticityToken"
autocomplete="off"
>
<button
id="gdrive_oauth_button"
class="btn bg-white btn-outline w-full text-base font-medium mt-4"
data-turbo="false"
type="submit"
:disabled="isConnectGoogleDriveClicked"
>
<span v-if="isConnectGoogleDriveClicked">
<span class="flex items-center justify-center space-x-2">
<IconInnerShadowTop class="animate-spin" />
<span class="">Submitting...</span>
</span>
</span>
<span
v-else
>
<span class="flex items-center justify-center space-x-2">
<IconBrandGoogleDrive />
<span>Connect Google Drive</span>
</span>
</span>
</button>
</form>
<div
v-else
class="relative"
>
<iframe
class="border border-base-300 rounded-lg"
style="width: 100%; height: 440px; background: white;"
src="/template_google_drive"
/>
<div v-if="isLoadingGoogleDrive">
<div
class="bg-white absolute top-0 bottom-0 left-0 right-0 opacity-80 rounded-lg"
style="margin: 1px"
/>
<div class="absolute top-0 bottom-0 left-0 right-0 flex items-center justify-center">
<IconInnerShadowTop class="animate-spin" />
</div>
</div>
</div>
</div>
</div>
</div>
</Teleport>
<form <form
ref="form" ref="form"
class="hidden" class="hidden"
@ -159,7 +88,7 @@
type="file" type="file"
:accept="acceptFileTypes" :accept="acceptFileTypes"
multiple multiple
@change="upload" @change="upload()"
> >
</form> </form>
</div> </div>
@ -167,6 +96,74 @@
<script> <script>
import { IconUpload, IconInnerShadowTop, IconChevronDown, IconBrandGoogleDrive } from '@tabler/icons-vue' import { IconUpload, IconInnerShadowTop, IconChevronDown, IconBrandGoogleDrive } from '@tabler/icons-vue'
import GoogleDrivePickerModal from './google_drive_picker_modal'
function convertImage (sourceFile, targetType, quality) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const ext = targetType === 'image/jpeg' ? '.jpg' : '.png'
const newFile = new File([blob], sourceFile.name.replace(/\.\w+$/, ext), { type: targetType })
resolve(newFile)
}, targetType, quality)
}
img.onerror = () => reject(new Error(`browser cannot decode ${sourceFile.type || sourceFile.name}`))
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(sourceFile)
})
}
export async function convertUnsupportedImages (files) {
const converted = []
for (const file of Array.from(files)) {
let result = file
try {
if (['image/bmp', 'image/vnd.microsoft.icon', 'image/svg+xml', 'image/gif'].includes(file.type)) {
result = await convertImage(file, 'image/png')
} else if (['image/heic', 'image/heif', 'image/heic-sequence', 'image/heif-sequence', 'image/avif', 'image/avif-sequence', 'image/webp'].includes(file.type)) {
result = await convertImage(file, 'image/jpeg', 0.9)
}
} catch (e) {
alert(e.message)
}
converted.push(result)
}
return converted
}
async function convertImagesInInput (input) {
if (!input.files || input.files.length === 0) return
const originals = Array.from(input.files)
const converted = await convertUnsupportedImages(originals)
if (converted.some((file, index) => file !== originals[index])) {
const dt = new DataTransfer()
converted.forEach((file) => dt.items.add(file))
input.files = dt.files
}
}
export default { export default {
name: 'DocumentsUpload', name: 'DocumentsUpload',
@ -174,7 +171,8 @@ export default {
IconUpload, IconUpload,
IconInnerShadowTop, IconInnerShadowTop,
IconChevronDown, IconChevronDown,
IconBrandGoogleDrive IconBrandGoogleDrive,
GoogleDrivePickerModal
}, },
inject: ['baseFetch', 't', 'backgroundColor'], inject: ['baseFetch', 't', 'backgroundColor'],
props: { props: {
@ -202,11 +200,9 @@ export default {
data () { data () {
return { return {
isLoading: false, isLoading: false,
isConnectGoogleDriveClicked: false, isLoadingGoogleDrive: true,
isLoadingGoogleDrive: false,
googleDriveFiles: [], googleDriveFiles: [],
showGoogleDriveModal: false, showGoogleDriveModal: false
showGoogleDriveOauthButton: false
} }
}, },
computed: { computed: {
@ -219,69 +215,43 @@ export default {
uploadUrl () { uploadUrl () {
return `/templates/${this.templateId}/documents` return `/templates/${this.templateId}/documents`
}, },
googleDriveOauthPath () {
const params = {
access_type: 'offline',
include_granted_scopes: 'true',
prompt: 'consent',
scope: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/drive.file'
].join(' '),
oauth_data: new URLSearchParams({
redir: `/templates/${this.templateId}/edit?google_drive_open=1`
}).toString()
}
const query = new URLSearchParams(params).toString()
return `/auth/google_oauth2?${query}`
},
modalContainerEl () { modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container') return this.$el.getRootNode().querySelector('#docuseal_modal_container')
} }
}, },
mounted () { mounted () {
window.addEventListener('message', this.messageHandler)
if (this.queryParams.get('google_drive_open') === '1') { if (this.queryParams.get('google_drive_open') === '1') {
this.openGoogleDriveModal() this.openGoogleDriveModal()
window.history.replaceState({}, document.title, window.location.pathname) window.history.replaceState({}, document.title, window.location.pathname)
} }
}, },
beforeUnmount () {
window.removeEventListener('message', this.messageHandler)
},
methods: { methods: {
openGoogleDriveModal () { openGoogleDriveModal () {
this.showGoogleDriveModal = true this.showGoogleDriveModal = true
this.isLoadingGoogleDrive = true this.isLoadingGoogleDrive = true
}, },
messageHandler (event) { onGoogleDrivePicked (files) {
if (event.data.type === 'google-drive-files-picked') { this.googleDriveFiles = files
this.googleDriveFiles = event.data.files || []
this.$nextTick(() => {
this.isLoadingGoogleDrive = true
this.upload({ path: `/templates/${this.templateId}/google_drive_documents` }).then((resp) => { this.$nextTick(() => {
if (resp.ok) { this.upload({ path: `/templates/${this.templateId}/google_drive_documents` }).then((resp) => {
this.showGoogleDriveModal = false if (resp.ok) {
} this.showGoogleDriveModal = false
}).finally(() => { }
this.isLoadingGoogleDrive = false }).finally(() => {
}) this.isLoadingGoogleDrive = false
this.googleDriveFiles = []
}) })
} else if (event.data.type === 'google-drive-picker-loaded') { })
this.isLoadingGoogleDrive = false
} else if (event.data.type === 'google-drive-picker-request-oauth') {
this.showGoogleDriveOauthButton = true
}
}, },
async upload ({ path } = {}) { async upload ({ path } = {}) {
this.isLoading = true this.isLoading = true
if (this.$refs.input) {
await convertImagesInInput(this.$refs.input)
}
return this.baseFetch(path || this.uploadUrl, { return this.baseFetch(path || this.uploadUrl, {
method: 'POST', method: 'POST',
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },

@ -3,8 +3,6 @@
class ReindexSearchEntryJob class ReindexSearchEntryJob
include Sidekiq::Job include Sidekiq::Job
InvalidFormat = Class.new(StandardError)
def perform(params = {}) def perform(params = {})
entry = SearchEntry.find_or_initialize_by(params.slice('record_type', 'record_id')) entry = SearchEntry.find_or_initialize_by(params.slice('record_type', 'record_id'))

@ -7,7 +7,9 @@ class SendSubmitterInvitationEmailJob
submitter = Submitter.find(params['submitter_id']) submitter = Submitter.find(params['submitter_id'])
return if submitter.completed_at? return if submitter.completed_at?
return if submitter.declined_at?
return if submitter.submission.archived_at? return if submitter.submission.archived_at?
return if submitter.submission.expired?
return if submitter.template&.archived_at? return if submitter.template&.archived_at?
return if submitter.submission.source == 'invite' && !Accounts.can_send_emails?(submitter.account, on_events: true) return if submitter.submission.source == 'invite' && !Accounts.can_send_emails?(submitter.account, on_events: true)

@ -97,7 +97,7 @@ class SubmitterMailer < ApplicationMailer
to: user.role == 'integration' ? user.friendly_name.sub(/\+\w+@/, '@') : user.friendly_name, to: user.role == 'integration' ? user.friendly_name.sub(/\+\w+@/, '@') : user.friendly_name,
reply_to: @submitter.friendly_name, reply_to: @submitter.friendly_name,
subject: I18n.t(:name_declined_by_submitter, subject: I18n.t(:name_declined_by_submitter,
name: (@submission.name || @submission.template.name).truncate(20), name: (@submission.name || @submission.template&.name).to_s.truncate(20),
submitter: @submitter.name || @submitter.email || @submitter.phone)) submitter: @submitter.name || @submitter.email || @submitter.phone))
end end
end end
@ -144,6 +144,7 @@ class SubmitterMailer < ApplicationMailer
end end
def otp_verification_email(submitter, locale: nil) def otp_verification_email(submitter, locale: nil)
@current_account = submitter.account
@submitter = submitter @submitter = submitter
@otp_code = EmailVerificationCodes.generate([submitter.email.downcase.strip, submitter.slug].join(':')) @otp_code = EmailVerificationCodes.generate([submitter.email.downcase.strip, submitter.slug].join(':'))

@ -28,6 +28,12 @@ class DynamicDocument < ApplicationRecord
has_many :versions, class_name: 'DynamicDocumentVersion', dependent: :destroy has_many :versions, class_name: 'DynamicDocumentVersion', dependent: :destroy
has_one :current_version, class_name: 'DynamicDocumentVersion',
primary_key: %i[id sha1],
foreign_key: %i[dynamic_document_id sha1],
dependent: :destroy,
inverse_of: :dynamic_document
attribute :fields, :json attribute :fields, :json
before_validation :set_sha1 before_validation :set_sha1

@ -107,6 +107,11 @@ class Submission < ApplicationRecord
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists) .and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
} }
scope :select_for_list, lambda {
select(:id, :name, :created_by_user_id, :account_id,
:created_at, :archived_at, :expire_at, :template_id, :template_submitters)
}
enum :source, { enum :source, {
invite: 'invite', invite: 'invite',
bulk: 'bulk', bulk: 'bulk',

@ -28,8 +28,8 @@
# #
# Indexes # Indexes
# #
# index_submitters_on_account_id_and_completed_at (account_id,completed_at) WHERE (completed_at IS NOT NULL)
# index_submitters_on_account_id_and_id (account_id,id) # index_submitters_on_account_id_and_id (account_id,id)
# index_submitters_on_completed_at_and_account_id (completed_at,account_id)
# index_submitters_on_email (email) # index_submitters_on_email (email)
# index_submitters_on_external_id (external_id) # index_submitters_on_external_id (external_id)
# index_submitters_on_slug (slug) UNIQUE # index_submitters_on_slug (slug) UNIQUE

@ -75,12 +75,14 @@ class Template < ApplicationRecord
has_many :dynamic_document_versions, through: :dynamic_documents, source: :versions has_many :dynamic_document_versions, through: :dynamic_documents, source: :versions
has_many :schema_dynamic_documents, lambda { |e| has_many :schema_dynamic_documents, lambda { |e|
where(uuid: e.schema.select { |e| e['dynamic'] }.pluck('attachment_uuid')) where(uuid: e.schema.select { |item| item['dynamic'] }.pluck('attachment_uuid'))
}, class_name: 'DynamicDocument', dependent: :destroy, inverse_of: :template }, class_name: 'DynamicDocument', dependent: :destroy, inverse_of: :template
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 :select_for_list, -> { select(:id, :name, :author_id, :account_id, :created_at, :archived_at, :folder_id) }
def application_key def application_key
external_id external_id
end end

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M10 19h-5a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v2.5"></path>
<path d="M17.8 20.817l-2.172 1.138a.392 .392 0 0 1 -.568 -.41l.415 -2.411l-1.757 -1.707a.389 .389 0 0 1 .217 -.665l2.428 -.352l1.086 -2.193a.392 .392 0 0 1 .702 0l1.086 2.193l2.428 .352a.39 .39 0 0 1 .217 .665l-1.757 1.707l.414 2.41a.39 .39 0 0 1 -.567 .411l-2.172 -1.138"></path>
</svg>

After

Width:  |  Height:  |  Size: 662 B

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 19h-7a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v3.5"></path>
<path d="M19 22v-6"></path>
<path d="M22 19l-3 -3l-3 3"></path>
</svg>

After

Width:  |  Height:  |  Size: 447 B

@ -36,7 +36,7 @@
<% if signature %> <% if signature %>
<div class="flex justify-center mb-4 relative"> <div class="flex justify-center mb-4 relative">
<%= button_to button_title(title: t('remove'), disabled_with: t('removing')), user_signature_path, method: :delete, class: 'right-0 top-0 absolute link' %> <%= button_to button_title(title: t('remove'), disabled_with: t('removing')), user_signature_path, method: :delete, class: 'right-0 top-0 absolute link' %>
<img src="<%= signature.url %>" style="max-height: 200px; width: auto" width="<%= signature.metadata['width'] %>" height="<%= signature.metadata['height'] %>"> <img src="<%= signature.url(time: ActiveStorage::Attachment.service_url_time) %>" style="height: 130px; width: 100%; object-fit: contain;">
</div> </div>
<% end %> <% end %>
<a href="<%= edit_user_signature_path %>" data-turbo-frame="modal" class="base-button w-full"> <a href="<%= edit_user_signature_path %>" data-turbo-frame="modal" class="base-button w-full">
@ -49,7 +49,7 @@
<% if initials %> <% if initials %>
<div class="flex justify-center mb-4 relative"> <div class="flex justify-center mb-4 relative">
<%= button_to button_title(title: t('remove'), disabled_with: t('removing')), user_initials_path, method: :delete, class: 'right-0 top-0 absolute link' %> <%= button_to button_title(title: t('remove'), disabled_with: t('removing')), user_initials_path, method: :delete, class: 'right-0 top-0 absolute link' %>
<img src="<%= initials.url %>" style="max-height: 200px; width: auto" width="<%= initials.metadata['width'] %>" height="<%= initials.metadata['height'] %>"> <img src="<%= initials.url(time: ActiveStorage::Attachment.service_url_time) %>" style="height: 130px; width: 100%; object-fit: contain;">
</div> </div>
<% end %> <% end %>
<a href="<%= edit_user_initials_path %>" data-turbo-frame="modal" class="base-button w-full"> <a href="<%= edit_user_initials_path %>" data-turbo-frame="modal" class="base-button w-full">
@ -70,7 +70,7 @@
<div class="form-control"> <div class="form-control">
<%= f.label :current_password, t('current_password'), class: 'label' %> <%= f.label :current_password, t('current_password'), class: 'label' %>
<%= f.password_field :current_password, autocomplete: 'current-password', class: 'base-input' %> <%= f.password_field :current_password, autocomplete: 'current-password', class: 'base-input' %>
<% if Accounts.can_send_emails?(current_account) %> <% if Docuseal.multitenant? || Accounts.can_send_emails?(current_account) %>
<span class="label-text-alt mt-1"> <span class="label-text-alt mt-1">
<%= t('dont_remember_your_current_password_click_here_to_reset_it_html') %> <%= t('dont_remember_your_current_password_click_here_to_reset_it_html') %>
</span> </span>

@ -1,6 +1,6 @@
<a target="_blank" href="<%= Docuseal::GITHUB_URL %>" rel="noopener noreferrer nofollow" class="relative flex items-center rounded-full px-2 py-0.5 text-xs leading-4 mt-1 text-base-content border border-base-300 tooltip tooltip-bottom" data-tip="Give a star on GitHub"> <a target="_blank" href="<%= Docuseal::GITHUB_URL %>" rel="noopener noreferrer nofollow" class="relative flex items-center rounded-full px-2 py-0.5 text-xs leading-4 mt-1 text-base-content border border-base-300 tooltip tooltip-bottom" data-tip="Give a star on GitHub">
<span class="flex items-center justify-between space-x-0.5 font-medium"> <span class="flex items-center justify-between space-x-0.5 font-medium">
<%= svg_icon('start', class: 'h-3 w-3') %> <%= svg_icon('start', class: 'h-3 w-3') %>
<span>16k</span> <span>17k</span>
</span> </span>
</a> </a>

@ -4,6 +4,9 @@
<input name="<%= key %>" value="<%= params[key] %>" class="hidden"> <input name="<%= key %>" value="<%= params[key] %>" class="hidden">
<% end %> <% end %>
<% end %> <% end %>
<% if params[:archived].present? %>
<input name="archived" value="<%= params[:archived] %>" class="hidden">
<% end %>
<% if params[:q].present? %> <% if params[:q].present? %>
<div class="relative"> <div class="relative">
<a href="<%= url_for(params: request.query_parameters.except('q')) %>" title="<%= t('clear') %>" class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-auto text-neutral text-2xl font-extralight"> <a href="<%= url_for(params: request.query_parameters.except('q')) %>" title="<%= t('clear') %>" class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-auto text-neutral text-2xl font-extralight">

@ -10,7 +10,7 @@
<%= svg_icon('writing_sign', class: 'w-10 h-10') %> <%= svg_icon('writing_sign', class: 'w-10 h-10') %>
</div> </div>
<div> <div>
<p dir="auto" class="text-lg font-bold mb-1"><%= @submitter.submission.template.name %></p> <p dir="auto" class="text-lg font-bold mb-1"><%= @submitter.submission.template&.name %></p>
<p dir="auto" class="text-sm"> <p dir="auto" class="text-sm">
<%= t(@submitter.with_signature_fields? ? 'signed_on_time' : 'completed_on_time', time: l(@submitter.completed_at.to_date, format: :long)) %> <%= t(@submitter.with_signature_fields? ? 'signed_on_time' : 'completed_on_time', time: l(@submitter.completed_at.to_date, format: :long)) %>
</p> </p>
@ -26,7 +26,7 @@
<%= button_to button_title(title: t('send_copy_to_email'), disabled_with: t('sending'), icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { template_slug: @template.slug, email: params[:email] }, class: 'base-button w-full' %> <%= button_to button_title(title: t('send_copy_to_email'), disabled_with: t('sending'), icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { template_slug: @template.slug, email: params[:email] }, class: 'base-button w-full' %>
</toggle-submit> </toggle-submit>
<% end %> <% end %>
<% if Templates.filter_undefined_submitters(@template.submitters).size == 1 && %w[api embed].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false && @template.shared_link? %> <% if Templates.filter_undefined_submitters(@template.submitters).size == 1 && %w[api embed mcp].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false && @template.shared_link? %>
<toggle-submit class="block"> <toggle-submit class="block">
<%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@template.slug), params: { submitter: params.permit(:name, :email, :phone).compact_blank, resubmit: true }, method: :put, class: 'white-button w-full' %> <%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@template.slug), params: { submitter: params.permit(:name, :email, :phone).compact_blank, resubmit: true }, method: :put, class: 'white-button w-full' %>
</toggle-submit> </toggle-submit>

@ -52,9 +52,7 @@
<% if link_form_fields.include?('phone') %> <% if link_form_fields.include?('phone') %>
<div dir="auto" class="form-control !mt-0"> <div dir="auto" class="form-control !mt-0">
<%= f.label :phone, t('phone'), class: 'label' %> <%= f.label :phone, t('phone'), class: 'label' %>
<custom-validation data-invalid-message="<%= t('use_international_format_1xxx_') %>"> <%= f.telephone_field :phone, value: params[:phone] || @submitter.phone, pattern: '^\+[0-9\s\-]+$', required: true, title: t('use_international_format_1xxx_'), class: 'base-input w-full', placeholder: t(multiple_fields ? 'provide_your_phone_in_international_format' : 'provide_your_phone_in_international_format_to_start') %>
<%= f.telephone_field :phone, value: params[:phone] || @submitter.phone, pattern: '^\+[0-9\s\-]+$', required: true, class: 'base-input w-full', placeholder: t(multiple_fields ? 'provide_your_phone_in_international_format' : 'provide_your_phone_in_international_format_to_start') %>
</custom-validation>
</div> </div>
<% end %> <% end %>
<toggle-submit dir="auto" class="form-control"> <toggle-submit dir="auto" class="form-control">

@ -34,13 +34,11 @@
</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_') %>"> <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', title: t('use_international_format_1xxx_'), 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? %>
<%= 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>
</div> </div>
<% end %> <% end %>
<% if prefillable_fields.present? %> <% if prefillable_fields.present? %>
@ -51,13 +49,11 @@
</submitters-autocomplete> </submitters-autocomplete>
<% if local_assigns[:require_phone_2fa] == true || prefillable_fields.any? { |f| f['type'] == 'phone' } %> <% if local_assigns[:require_phone_2fa] == true || prefillable_fields.any? { |f| f['type'] == 'phone' } %>
<% has_phone_field = true %> <% has_phone_field = true %>
<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', title: t('use_international_format_1xxx_'), class: 'base-input !h-10 mt-1.5 w-full', placeholder: t(:phone), id: "detailed_phone_#{item['uuid']}", required: 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: t(:phone), id: "detailed_phone_#{item['uuid']}", required: true %> </linked-input>
</linked-input> </submitters-autocomplete>
</submitters-autocomplete>
</custom-validation>
<% end %> <% end %>
<% prefillable_fields.each do |field| %> <% prefillable_fields.each do |field| %>
<% field_id = "detailed_field_#{index}_#{field['uuid'] || field['name'].parameterize}" %> <% field_id = "detailed_field_#{index}_#{field['uuid'] || field['name'].parameterize}" %>
@ -95,8 +91,8 @@
</dynamic-list> </dynamic-list>
<%= local_assigns[:variables_form] %> <%= local_assigns[:variables_form] %>
<div> <div>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %> <%= render('submitters_order', f:, template:) if can_send_emails %>
<%= render 'send_email', f:, template: %> <%= render 'send_email', f:, template:, can_send_emails: %>
<% if has_phone_field %> <% if has_phone_field %>
<%= render 'send_sms', f: %> <%= render 'send_sms', f: %>
<% end %> <% end %>

@ -69,8 +69,8 @@
<% end %> <% end %>
<%= local_assigns[:variables_form] %> <%= local_assigns[:variables_form] %>
<div> <div>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %> <%= render('submitters_order', f:, template:) if can_send_emails %>
<%= render 'send_email', f:, template: %> <%= render 'send_email', f:, template:, can_send_emails: %>
<%= render 'extra_fields', f: %> <%= render 'extra_fields', f: %>
</div> </div>
<div class="form-control"> <div class="form-control">

@ -19,13 +19,11 @@
</label> </label>
<% end %> <% end %>
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>"> <input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<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="<%= "phone_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<linked-input data-target-id="<%= "phone_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', title: t('use_international_format_1xxx_'), class: 'base-input !h-10 w-full', placeholder: t('phone'), required: index.zero? || template.preferences['require_all_submitters'], id: "phone_phone_#{item['uuid']}" %>
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', name: 'submission[1][submitters][][phone]', autocomplete: 'off', class: 'base-input !h-10 w-full', placeholder: t('phone'), required: index.zero? || template.preferences['require_all_submitters'], id: "phone_phone_#{item['uuid']}" %> </linked-input>
</linked-input> </submitters-autocomplete>
</submitters-autocomplete>
</custom-validation>
<% if submitters.size > 1 %> <% if submitters.size > 1 %>
<submitters-autocomplete data-field="name"> <submitters-autocomplete data-field="name">
<linked-input data-target-id="<%= "phone_name_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>"> <linked-input data-target-id="<%= "phone_name_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
@ -58,7 +56,7 @@
</dynamic-list> </dynamic-list>
<%= local_assigns[:variables_form] %> <%= local_assigns[:variables_form] %>
<div> <div>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %> <%= render 'submitters_order', f:, template: %>
<%= render 'send_sms', f:, checked: true %> <%= render 'send_sms', f:, checked: true %>
<%= render 'extra_phone_fields', f: %> <%= render 'extra_phone_fields', f: %>
</div> </div>

@ -2,15 +2,14 @@
<% template_submitters = local_assigns[:submitter]&.submission&.template_submitters || template.submitters %> <% template_submitters = local_assigns[:submitter]&.submission&.template_submitters || template.submitters %>
<% message_field_id = "message_field_#{SecureRandom.hex(3)}" %> <% message_field_id = "message_field_#{SecureRandom.hex(3)}" %>
<div class="form-control"> <div class="form-control">
<% can_send_emails = Accounts.can_send_emails?(current_account) %>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<%= f.label :send_email, for: uuid = SecureRandom.uuid, class: 'flex items-center cursor-pointer' do %> <%= f.label :send_email, for: uuid = SecureRandom.uuid, class: 'flex items-center cursor-pointer' do %>
<%= f.check_box :send_email, id: uuid, class: 'base-checkbox', disabled: !can_send_emails || local_assigns[:disable_email], checked: can_send_emails && !local_assigns.key?(:resend_email) && !local_assigns[:disable_email] && template&.preferences&.dig('request_email_enabled') != false %> <%= f.check_box :send_email, id: uuid, class: 'base-checkbox', disabled: !can_send_emails || local_assigns[:disable_email], checked: can_send_emails && !local_assigns.key?(:resend_email) && !local_assigns[:disable_email] && template&.preferences&.dig('request_email_enabled') != false %>
<span class="label"><%= local_assigns[:resend_email] ? t('re_send_email') : t('send_email') %></span> <span class="label"><%= local_assigns[:resend_email] ? t('re_send_email') : t('send_email') %></span>
<% end %> <% end %>
<div> <div>
<%= render 'submissions/email_stats' %>
<% if can_send_emails %> <% if can_send_emails %>
<%= render 'submissions/email_stats' %>
<%= content_for(:edit_button) || capture do %> <%= content_for(:edit_button) || capture do %>
<toggle-visible data-element-ids="<%= [message_field_id].to_json %>" class="flex"> <toggle-visible data-element-ids="<%= [message_field_id].to_json %>" class="flex">
<label> <label>

@ -1,9 +1,11 @@
<% if template.preferences['submitters_order'] == 'preserved' || template.submitters.any? { |s| s['order'] } || (template.submitters.size > 1 && template.fields.any? { |f| f['type'] == 'verification' }) %> <% if template.preferences['submitters_order'] == 'preserved' || template.submitters.any? { |s| s['order'] } || (template.submitters.size > 1 && template.fields.any? { |f| f['type'] == 'verification' }) %>
<%= f.hidden_field :preserve_order, value: '1' %> <%= f.hidden_field :preserve_order, value: '1' %>
<% elsif template.submitters.size > 1 %> <% elsif template.submitters.size > 1 %>
<% account_submissions = template.submissions.where(account_id: current_account.id) %>
<% last_submission = account_submissions.active.last || account_submissions.archived.last %>
<div class="form-control"> <div class="form-control">
<%= f.label :preserve_order, for: uuid = SecureRandom.uuid, class: 'flex items-center cursor-pointer' do %> <%= f.label :preserve_order, for: uuid = SecureRandom.uuid, class: 'flex items-center cursor-pointer' do %>
<%= f.check_box :preserve_order, id: uuid, class: 'base-checkbox', checked: template.submissions.last&.submitters_order.in?(['preserved', nil]) %> <%= f.check_box :preserve_order, id: uuid, class: 'base-checkbox', checked: last_submission&.submitters_order.in?(['preserved', nil]) %>
<span class="label"><%= t('preserve_order') %></span> <span class="label"><%= t('preserve_order') %></span>
<span class="tooltip" data-tip="<%= t('when_checked_notifications_will_be_sent_to_the_second_party_once_the_form_is_completed_by_the_previous_party_uncheck_this_option_to_send_notifications_to_all_parties_simultaneously_right_away') %>"> <span class="tooltip" data-tip="<%= t('when_checked_notifications_will_be_sent_to_the_second_party_once_the_form_is_completed_by_the_previous_party_uncheck_this_option_to_send_notifications_to_all_parties_simultaneously_right_away') %>">
<%= svg_icon('info_circle', class: 'w-4 h-4') %> <%= svg_icon('info_circle', class: 'w-4 h-4') %>

@ -10,7 +10,7 @@
<% is_narrow = area['h'].to_f.positive? && ((area['w'].to_f * local_assigns[:page_width]) / (area['h'].to_f * local_assigns[:page_height])) > 4.5 %> <% is_narrow = area['h'].to_f.positive? && ((area['w'].to_f * local_assigns[:page_width]) / (area['h'].to_f * local_assigns[:page_height])) > 4.5 %>
<div class="flex justify-between w-full h-full gap-1 <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'flex-row' : 'flex-col' %>"> <div class="flex justify-between w-full h-full gap-1 <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'flex-row' : 'flex-col' %>">
<div class="flex overflow-hidden <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'w-1/2' : 'flex-grow' %>" style="min-height: 50%"> <div class="flex overflow-hidden <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'w-1/2' : 'flex-grow' %>" style="min-height: 50%">
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" alt="<%= field['name'].presence || field['title'].presence || field['type'] %>"> <img class="object-contain mx-auto" src="<%= attachments_index[value].url(time: ActiveStorage::Attachment.service_url_time) %>" alt="<%= field['name'].presence || field['title'].presence || field['type'] %>">
</div> </div>
<% if (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) && attachment = attachments_index[value] %> <% if (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) && attachment = attachments_index[value] %>
<div class="text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem] <%= is_narrow ? 'w-1/2' : 'w-full' %>"> <div class="text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem] <%= is_narrow ? 'w-1/2' : 'w-full' %>">
@ -35,7 +35,7 @@
<% end %> <% end %>
</div> </div>
<% elsif field['type'].in?(['image', 'initials', 'stamp', 'kba']) && attachments_index[value].image? %> <% elsif field['type'].in?(['image', 'initials', 'stamp', 'kba']) && attachments_index[value].image? %>
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy" alt="<%= field['name'].presence || field['title'].presence || field['type'] %>"> <img class="object-contain mx-auto" src="<%= attachments_index[value].url(time: ActiveStorage::Attachment.service_url_time) %>" loading="lazy" alt="<%= field['name'].presence || field['title'].presence || field['type'] %>">
<% elsif field['type'].in?(['file', 'payment', 'image']) %> <% elsif field['type'].in?(['file', 'payment', 'image']) %>
<autosize-field></autosize-field> <autosize-field></autosize-field>
<div class="px-0.5 flex flex-col justify-center"> <div class="px-0.5 flex flex-col justify-center">

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

Loading…
Cancel
Save