Compare commits

..
89 Commits
Author SHA1 Message Date
Alex Turchyn 4d8942803e Merge from docusealco/wip 2025-05-12 12:31:27 +03:00
Pete Matsyburka 46d9bb2c87 fix optimize query 2025-05-12 11:17:26 +03:00
Pete Matsyburka 34433c0575 adjust conditions 2025-05-12 11:05:48 +03:00
Pete Matsyburka 544306eac4 fix replace 2025-05-11 18:02:24 +03:00
Pete Matsyburka 0eeebfd3cf clear cache 2025-05-10 20:03:35 +03:00
Pete Matsyburka 050224a10d fix refactor expire at 2025-05-10 17:39:53 +03:00
Alex Turchyn 7b6fc7d06f add expiration duration to template preferences 2025-05-10 12:12:15 +03:00
Pete Matsyburka 7fb148c85c drag folders 2025-05-10 12:11:57 +03:00
Alex Turchyn dffa59fecd add translations 2025-05-09 22:20:20 +03:00
Pete Matsyburka 24fe2a1192 fix refactor dropzone 2025-05-09 22:12:20 +03:00
Alex Turchyn 97b8ac4444 add builder dashboard dropzone 2025-05-09 21:35:31 +03:00
Pete Matsyburka e34a763dd9 fix field id 2025-05-09 10:04:22 +03:00
Pete Matsyburka 298b1d3e1e update gem 2025-05-08 22:41:25 +03:00
Pete Matsyburka d9978519cc adjust qes link 2025-05-08 12:32:10 +03:00
Pete Matsyburka b4a68cf79e adjust field types prop 2025-05-08 12:22:18 +03:00
Pete Matsyburka 5663e60f92 fix log 2025-05-06 22:18:53 +03:00
Pete Matsyburka 19e8835809 fix timestamp path 2025-05-06 18:44:29 +03:00
Pete Matsyburka 330fae927f skip bounce from audit 2025-05-06 16:48:50 +03:00
Pete Matsyburka c5a5212947 disable archived template actions 2025-05-06 15:50:58 +03:00
Pete Matsyburka 21d81e38bf add email events 2025-05-06 15:10:02 +03:00
Pete Matsyburka 5b1a4ebffd improve log 2025-05-03 16:30:11 +03:00
Pete Matsyburka 5f5dabd0e5 advanced formats 2025-05-02 19:17:19 +03:00
Pete Matsyburka ff46993de6 fix spec 2025-05-02 12:54:00 +03:00
Pete Matsyburka 7676db2196 hex colors 2025-05-02 12:36:33 +03:00
Alex Turchyn 3ef0d28ffe adjust app tour 2025-05-02 10:27:57 +03:00
Pete Matsyburka 231177f057 add log 2025-05-01 00:14:04 +03:00
Pete Matsyburka e5f7739952 use sha 256 for tsa 2025-04-30 23:48:03 +03:00
Pete Matsyburka 4a8e2a82e5 tsa fallback 2025-04-30 22:58:50 +03:00
Pete Matsyburka 6efe8027f1 disable multiple sign 2025-04-30 16:54:56 +03:00
Pete Matsyburka 96bb4eb6e5 fix standalone radio 2025-04-29 20:15:32 +03:00
Pete Matsyburka 38e68814cf fix color 2025-04-29 18:02:19 +03:00
Alex Turchyn 786c80bee7 Merge pull from docusealco/wip 2025-04-28 12:46:30 +03:00
Pete Matsyburka 98dcce299e resend 2025-04-28 08:59:41 +03:00
Pete Matsyburka cbff242a5d adjust condition fields 2025-04-25 12:14:49 +03:00
Pete Matsyburka 1876b3efba update readme 2025-04-24 19:44:49 +03:00
Alex Turchyn 51091f05f0 add Japanese language to the signing form 2025-04-24 19:38:46 +03:00
Pete Matsyburka d76eee2ae4 add valign 2025-04-24 19:33:26 +03:00
Pete Matsyburka d7bfa96d57 hide order on mobile 2025-04-24 12:59:31 +03:00
Pete Matsyburka be60608211 fix remove listener 2025-04-23 21:24:18 +03:00
Pete Matsyburka 4e5abb3c09 fix query 2025-04-23 12:30:06 +03:00
Pete Matsyburka 7fee82ded2 fix order queries 2025-04-23 10:58:40 +03:00
Alex Turchyn e1cd62ff40 add folders/templates sorting 2025-04-22 21:16:17 +03:00
Pete Matsyburka b498291b62 update gem 2025-04-22 16:20:35 +03:00
Pete Matsyburka def3009846 fix cert disabled 2025-04-22 15:52:03 +03:00
Pete Matsyburka 9d43fd9aa5 fix fields 2025-04-18 18:18:57 +03:00
Pete Matsyburka 22a21fb037 fix field focus 2025-04-18 13:02:17 +03:00
Pete Matsyburka 2a9c375273 adjust header 2025-04-18 12:57:43 +03:00
Alex Turchyn 847e70ec1b improve signing form header 2025-04-18 12:16:11 +03:00
Pete Matsyburka 7964d6e0f0 improve signature validation 2025-04-18 11:34:10 +03:00
Pete Matsyburka d0259791bf add field value email variable 2025-04-16 21:19:13 +03:00
Pete Matsyburka a9dcaeccbc fix erblint 2025-04-16 11:35:24 +03:00
Pete Matsyburka 501b91a603 removed email 2025-04-16 11:35:20 +03:00
Pete Matsyburka 2b5f772feb update ai link 2025-04-15 21:40:22 +03:00
Pete Matsyburka 7cada0af8b older devises 2025-04-15 19:58:31 +03:00
Pete Matsyburka 67be785e69 add slug filter 2025-04-15 13:22:02 +03:00
Pete Matsyburka d1cfc64adc adjust api response 2025-04-14 12:14:24 +03:00
Pete Matsyburka 4e5157d1ee add text valign 2025-04-11 16:25:32 +03:00
Pete Matsyburka 5e27cc36c6 fix default font 2025-04-10 13:22:10 +03:00
Pete Matsyburka 6e81a3be3f fix i18n 2025-04-10 11:51:40 +03:00
Pete Matsyburka 858b2f33fd ensure unique recipients 2025-04-10 00:56:16 +03:00
Alex Turchyn 6adcc87b2b add translations 2025-04-09 20:45:17 +03:00
Alex Turchyn e1d5a5b6a6 prevent an infinite loop of field conditions 2025-04-09 14:04:42 +03:00
Pete Matsyburka f090e79002 fix required 2025-04-09 13:51:43 +03:00
Pete Matsyburka 56b32e35a2 make last name optional 2025-04-08 19:12:04 +03:00
Pete Matsyburka b40c27cdbf fix specs 2025-04-08 16:21:45 +03:00
Pete Matsyburka c4505e67f0 require 2fa placeholder 2025-04-08 15:24:17 +03:00
Alex Turchyn d0799b8cc1 Merge from docusealco/wip 2025-04-07 12:58:13 +03:00
Pete Matsyburka 889fcad859 fix drop position 2025-04-05 12:06:29 +03:00
Pete Matsyburka c133fef99d ruby 3.4.2 2025-04-04 17:20:52 +03:00
Alex Turchyn cfdc978a2a add app tour 2025-04-04 11:40:55 +03:00
Pete Matsyburka bff1651966 fix long font preview 2025-04-03 17:59:05 +03:00
Alex Turchyn b97854f2fe improve account deletion confirmation message 2025-04-03 12:56:48 +03:00
Alex Turchyn 7a4629bf79 improve drag'n'drop in the builder 2025-04-02 16:51:39 +03:00
Pete Matsyburka 2573d8cb19 fix n+1 2025-04-01 10:23:31 +03:00
Pete Matsyburka dd559e90f1 add archived filter 2025-04-01 10:23:30 +03:00
Alex Turchyn e5db61e358 add API specs for completed submitter 2025-03-31 22:40:05 +03:00
Pete Matsyburka 69376c0faf fix acro form fields 2025-03-31 21:50:04 +03:00
Alex Turchyn 1f48be135c Merge from docusealco/wip 2025-03-31 13:02:51 +03:00
Pete Matsyburka 1cae12b728 adjust validation 2025-03-29 23:15:45 +02:00
Alex Turchyn 86d2cd93f8 fix signature uploading 2025-03-29 15:44:19 +02:00
Alex Turchyn b0110d9340 update signature validation 2025-03-29 14:50:26 +02:00
Alex Turchyn 7e70f4fb14 update translations 2025-03-29 13:37:21 +02:00
Pete Matsyburka 28e3d65e63 improve required field handling 2025-03-28 22:42:27 +02:00
Pete Matsyburka f1a2b71b59 fix smtp 2025-03-27 19:50:09 +02:00
Pete Matsyburka 47c7270911 enforce signing order 2025-03-27 19:23:20 +02:00
Pete Matsyburka 4e1fa336a2 add personalization options 2025-03-27 19:23:19 +02:00
Pete Matsyburka 244bc32136 fix preview url 2025-03-27 00:52:35 +02:00
Pete Matsyburka 42c30bdfd0 ensure generated doc completed 2025-03-26 13:14:59 +02:00
Alex Turchyn 0abf48ddba update preview image 2025-03-26 13:14:07 +02:00
151 changed files with 3390 additions and 616 deletions

No files matched your search

+4 -4
View File
@@ -11,7 +11,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 3.4.1 ruby-version: 3.4.2
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -35,7 +35,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 3.4.1 ruby-version: 3.4.2
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -85,7 +85,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 3.4.1 ruby-version: 3.4.2
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -127,7 +127,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 3.4.1 ruby-version: 3.4.2
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
+3 -3
View File
@@ -1,4 +1,4 @@
FROM ruby:3.4.1-alpine AS fonts FROM ruby:3.4.2-alpine AS fonts
WORKDIR /fonts WORKDIR /fonts
@@ -6,7 +6,7 @@ RUN apk --no-cache add fontforge wget && wget https://github.com/satbyy/go-noto-
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")' RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
FROM ruby:3.4.1-alpine AS webpack FROM ruby:3.4.2-alpine AS webpack
ENV RAILS_ENV=production ENV RAILS_ENV=production
ENV NODE_ENV=production ENV NODE_ENV=production
@@ -32,7 +32,7 @@ COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:3.4.1-alpine AS app FROM ruby:3.4.2-alpine AS app
ENV RAILS_ENV=production ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test" ENV BUNDLE_WITHOUT="development:test"
+1 -1
View File
@@ -2,7 +2,7 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '3.4.1' ruby '3.4.2'
gem 'arabic-letter-connector', require: 'arabic-letter-connector/logic' gem 'arabic-letter-connector', require: 'arabic-letter-connector/logic'
gem 'aws-sdk-s3', require: false gem 'aws-sdk-s3', require: false
+8 -8
View File
@@ -338,18 +338,18 @@ GEM
net-smtp (0.5.0) net-smtp (0.5.0)
net-protocol net-protocol
nio4r (2.7.4) nio4r (2.7.4)
nokogiri (1.18.5) nokogiri (1.18.8)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.5-aarch64-linux-gnu) nokogiri (1.18.8-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.5-aarch64-linux-musl) nokogiri (1.18.8-aarch64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.5-arm64-darwin) nokogiri (1.18.8-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.5-x86_64-linux-gnu) nokogiri (1.18.8-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.5-x86_64-linux-musl) nokogiri (1.18.8-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
oj (3.16.8) oj (3.16.8)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
@@ -387,7 +387,7 @@ GEM
puma (6.5.0) puma (6.5.0)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.1.12) rack (3.1.14)
rack-proxy (0.7.7) rack-proxy (0.7.7)
rack rack
rack-session (2.0.0) rack-session (2.0.0)
@@ -652,7 +652,7 @@ DEPENDENCIES
webmock webmock
RUBY VERSION RUBY VERSION
ruby 3.4.1p0 ruby 3.4.2p28
BUNDLED WITH BUNDLED WITH
2.5.3 2.5.3
+1 -1
View File
@@ -42,7 +42,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- PDF signature verification - PDF signature verification
- Users management - Users management
- Mobile-optimized - Mobile-optimized
- 6 UI languages with signing available in 13 languages - 6 UI languages with signing available in 14 languages
- API and Webhooks for integrations - API and Webhooks for integrations
- Easy to deploy in minutes - Easy to deploy in minutes
@@ -15,6 +15,7 @@ class AccountConfigsController < ApplicationController
AccountConfig::DOWNLOAD_LINKS_AUTH_KEY, AccountConfig::DOWNLOAD_LINKS_AUTH_KEY,
AccountConfig::FORCE_SSO_AUTH_KEY, AccountConfig::FORCE_SSO_AUTH_KEY,
AccountConfig::FLATTEN_RESULT_PDF_KEY, AccountConfig::FLATTEN_RESULT_PDF_KEY,
AccountConfig::ENFORCE_SIGNING_ORDER_KEY,
AccountConfig::WITH_SIGNATURE_ID, AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::COMBINE_PDF_RESULT_KEY, AccountConfig::COMBINE_PDF_RESULT_KEY,
AccountConfig::REQUIRE_SIGNING_REASON_KEY, AccountConfig::REQUIRE_SIGNING_REASON_KEY,
+1 -1
View File
@@ -45,7 +45,7 @@ class AccountsController < ApplicationController
def destroy def destroy
authorize!(:manage, current_account) authorize!(:manage, current_account)
true_user.update!(locked_at: Time.current) true_user.update!(locked_at: Time.current, email: true_user.email.sub('@', '+removed@'))
# rubocop:disable Layout/LineLength # rubocop:disable Layout/LineLength
render turbo_stream: turbo_stream.replace( render turbo_stream: turbo_stream.replace(
@@ -21,7 +21,11 @@ module Api
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid) blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid)
authorization_check!(blob) if exp.blank? attachment = blob.attachments.take
@record = attachment.record
authorization_check!(attachment) if exp.blank?
if request.headers['Range'].present? if request.headers['Range'].present?
send_blob_byte_range_data blob, request.headers['Range'] send_blob_byte_range_data blob, request.headers['Range']
@@ -37,9 +41,7 @@ module Api
private private
def authorization_check!(blob) def authorization_check!(attachment)
attachment = blob.attachments.take
is_authorized = attachment.name.in?(%w[logo preview_images]) || is_authorized = attachment.name.in?(%w[logo preview_images]) ||
(current_user && attachment.record.account.id == current_user.account_id) || (current_user && attachment.record.account.id == current_user.account_id) ||
(current_user && !Docuseal.multitenant? && current_user.role == 'superadmin') || (current_user && !Docuseal.multitenant? && current_user.role == 'superadmin') ||
+16 -7
View File
@@ -11,13 +11,7 @@ module Api
def index def index
submissions = Submissions.search(@submissions, params[:q]) submissions = Submissions.search(@submissions, params[:q])
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present? submissions = filter_submissions(submissions, params)
if params[:template_folder].present?
submissions = submissions.joins(template: :folder).where(folder: { name: params[:template_folder] })
end
submissions = Submissions::Filter.call(submissions, current_user, params)
submissions = paginate(submissions.preload(:created_by_user, :submitters, submissions = paginate(submissions.preload(:created_by_user, :submitters,
template: :folder, template: :folder,
@@ -111,6 +105,21 @@ module Api
private private
def filter_submissions(submissions, params)
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
submissions = submissions.where(slug: params[:slug]) if params[:slug].present?
if params[:template_folder].present?
submissions = submissions.joins(template: :folder).where(folder: { name: params[:template_folder] })
end
if params.key?(:archived)
submissions = params[:archived].in?(['true', true]) ? submissions.archived : submissions.active
end
Submissions::Filter.call(submissions, current_user, params)
end
def build_create_json(submissions) def build_create_json(submissions)
json = submissions.flat_map do |submission| json = submissions.flat_map do |submission|
submission.submitters.map do |s| submission.submitters.map do |s|
@@ -6,10 +6,10 @@ module Api
skip_authorization_check skip_authorization_check
def create def create
submitter = Submitter.find_by!(slug: params[:submitter_slug]) @submitter = Submitter.find_by!(slug: params[:submitter_slug])
if params[:t] == SubmissionEvents.build_tracking_param(submitter, 'click_email') if params[:t] == SubmissionEvents.build_tracking_param(@submitter, 'click_email')
SubmissionEvents.create_with_tracking_data(submitter, 'click_email', request) SubmissionEvents.create_with_tracking_data(@submitter, 'click_email', request)
end end
render json: {} render json: {}
@@ -6,15 +6,15 @@ module Api
skip_authorization_check skip_authorization_check
def create def create
submitter = Submitter.find_by!(slug: params[:submitter_slug]) @submitter = Submitter.find_by!(slug: params[:submitter_slug])
submitter.opened_at = Time.current @submitter.opened_at = Time.current
submitter.save @submitter.save
SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request) SubmissionEvents.create_with_tracking_data(@submitter, 'view_form', request)
WebhookUrls.for_account_id(submitter.account_id, 'form.viewed').each do |webhook_url| WebhookUrls.for_account_id(@submitter.account_id, 'form.viewed').each do |webhook_url|
SendFormViewedWebhookRequestJob.perform_async('submitter_id' => submitter.id, SendFormViewedWebhookRequestJob.perform_async('submitter_id' => @submitter.id,
'webhook_url_id' => webhook_url.id) 'webhook_url_id' => webhook_url.id)
end end
+14 -9
View File
@@ -7,15 +7,7 @@ module Api
def index def index
submitters = Submitters.search(@submitters, params[:q]) submitters = Submitters.search(@submitters, params[:q])
submitters = submitters.where(external_id: params[:application_key]) if params[:application_key].present? submitters = filter_submitters(submitters, params)
submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present?
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
if params[:template_id].present?
submitters = submitters.joins(:submission).where(submission: { template_id: params[:template_id] })
end
submitters = maybe_filder_by_completed_at(submitters, params)
submitters = paginate( submitters = paginate(
submitters.preload(:template, :submission, :submission_events, submitters.preload(:template, :submission, :submission_events,
@@ -163,6 +155,19 @@ module Api
submitter submitter
end end
def filter_submitters(submitters, params)
submitters = submitters.where(external_id: params[:application_key]) if params[:application_key].present?
submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present?
submitters = submitters.where(slug: params[:slug]) if params[:slug].present?
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
if params[:template_id].present?
submitters = submitters.joins(:submission).where(submission: { template_id: params[:template_id] })
end
maybe_filder_by_completed_at(submitters, params)
end
def assign_external_id(submitter, attrs) def assign_external_id(submitter, attrs)
submitter.external_id = attrs[:application_key] if attrs.key?(:application_key) submitter.external_id = attrs[:application_key] if attrs.key?(:application_key)
submitter.external_id = attrs[:external_id] if attrs.key?(:external_id) submitter.external_id = attrs[:external_id] if attrs.key?(:external_id)
@@ -5,7 +5,7 @@ module Api
load_and_authorize_resource :template load_and_authorize_resource :template
def create def create
authorize!(:manage, @template) authorize!(:create, @template)
ActiveRecord::Associations::Preloader.new( ActiveRecord::Associations::Preloader.new(
records: [@template], records: [@template],
@@ -90,6 +90,7 @@ module Api
templates = params[:archived].in?(['true', true]) ? templates.archived : templates.active templates = params[:archived].in?(['true', true]) ? templates.archived : templates.active
templates = templates.where(external_id: params[:application_key]) if params[:application_key].present? templates = templates.where(external_id: params[:application_key]) if params[:application_key].present?
templates = templates.where(external_id: params[:external_id]) if params[:external_id].present? templates = templates.where(external_id: params[:external_id]) if params[:external_id].present?
templates = templates.where(slug: params[:slug]) if params[:slug].present?
templates = templates.joins(:folder).where(folder: { name: params[:folder] }) if params[:folder].present? templates = templates.joins(:folder).where(folder: { name: params[:folder] }) if params[:folder].present?
templates templates
@@ -12,6 +12,8 @@ class PreviewDocumentPageController < ActionController::API
return head :not_found unless attachment return head :not_found unless attachment
@template = attachment.record
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"] })
+5 -1
View File
@@ -11,8 +11,11 @@ class StartFormController < ApplicationController
before_action :load_template before_action :load_template
def show def show
raise ActionController::RoutingError, I18n.t('not_found') if @template.preferences['require_phone_2fa'] == true
@submitter = @template.submissions.new(account_id: @template.account_id) @submitter = @template.submissions.new(account_id: @template.account_id)
.submitters.new(uuid: (filter_undefined_submitters(@template).first || .submitters.new(account_id: @template.account_id,
uuid: (filter_undefined_submitters(@template).first ||
@template.submitters.first)['uuid']) @template.submitters.first)['uuid'])
end end
@@ -94,6 +97,7 @@ class StartFormController < ApplicationController
submitter.submission ||= Submission.new(template:, submitter.submission ||= Submission.new(template:,
account_id: template.account_id, account_id: template.account_id,
template_submitters: template.submitters, template_submitters: template.submitters,
expire_at: Templates.build_default_expire_at(template),
submitters: [submitter], submitters: [submitter],
source: :link) source: :link)
@@ -7,9 +7,7 @@ class SubmissionsArchivedController < ApplicationController
@submissions = @submissions.joins(:template) @submissions = @submissions.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(:created_by_user, template: :author) .preload(:template_accesses, :created_by_user, template: :author)
@submissions = @submissions.preload(:template_accesses) unless current_user.role.in?(%w[admin superadmin])
@submissions = Submissions.search(@submissions, params[:q], search_template: true) @submissions = Submissions.search(@submissions, params[:q], search_template: true)
@submissions = Submissions::Filter.call(@submissions, current_user, params) @submissions = Submissions::Filter.call(@submissions, current_user, params)
@@ -57,6 +57,11 @@ class SubmissionsController < ApplicationController
Submissions.send_signature_requests(submissions) Submissions.send_signature_requests(submissions)
redirect_to template_path(@template), notice: I18n.t('new_recipients_have_been_added') redirect_to template_path(@template), notice: I18n.t('new_recipients_have_been_added')
rescue Submissions::CreateFromSubmitters::BaseError => e
render turbo_stream: turbo_stream.replace(:submitters_error,
partial: 'submissions/error',
locals: { error: e.message }),
status: :unprocessable_entity
end end
def destroy def destroy
@@ -8,9 +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(:created_by_user, template: :author) .preload(:template_accesses, :created_by_user, template: :author)
@submissions = @submissions.preload(:template_accesses) unless current_user.role.in?(%w[admin superadmin])
@submissions = Submissions.search(@submissions, params[:q], search_template: true) @submissions = Submissions.search(@submissions, params[:q], search_template: true)
@submissions = Submissions::Filter.call(@submissions, current_user, params) @submissions = Submissions::Filter.call(@submissions, current_user, params)
@@ -8,25 +8,25 @@ class SubmissionsDownloadController < ApplicationController
FILES_TTL = 5.minutes FILES_TTL = 5.minutes
def index def index
submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present? @submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
signature_valid = signature_valid =
if submitter&.slug == params[:submitter_slug] if @submitter&.slug == params[:submitter_slug]
true true
else else
submitter = nil @submitter = nil
end end
submitter ||= Submitter.find_by!(slug: params[:submitter_slug]) @submitter ||= Submitter.find_by!(slug: params[:submitter_slug])
Submissions::EnsureResultGenerated.call(submitter) Submissions::EnsureResultGenerated.call(@submitter)
last_submitter = submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
return head :not_found unless last_submitter
Submissions::EnsureResultGenerated.call(last_submitter) Submissions::EnsureResultGenerated.call(last_submitter)
return head :not_found unless last_submitter.completed_at?
if last_submitter.completed_at < TTL.ago && !signature_valid && !current_user_submitter?(last_submitter) if last_submitter.completed_at < TTL.ago && !signature_valid && !current_user_submitter?(last_submitter)
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar) Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
@@ -34,7 +34,7 @@ class SubmissionsDownloadController < ApplicationController
end end
if params[:combined] == 'true' if params[:combined] == 'true'
url = build_combined_url(submitter) url = build_combined_url(@submitter)
if url if url
render json: [url] render json: [url]
+21 -15
View File
@@ -7,11 +7,11 @@ class SubmitFormController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_authorization_check skip_authorization_check
before_action :load_submitter, only: %i[show update completed]
CONFIG_KEYS = [].freeze CONFIG_KEYS = [].freeze
def show def show
@submitter = Submitter.find_by!(slug: params[:slug])
submission = @submitter.submission submission = @submitter.submission
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at? return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
@@ -20,7 +20,11 @@ class SubmitFormController < ApplicationController
@submitter.account.archived_at? @submitter.account.archived_at?
return render :expired if submission.expired? return render :expired if submission.expired?
return render :declined if @submitter.declined_at? return render :declined if @submitter.declined_at?
return render :awaiting if submission.template.preferences['submitters_order'] == 'preserved' &&
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
return render :awaiting if (@form_configs[:enforce_signing_order] ||
submission.template.preferences['submitters_order'] == 'preserved') &&
!Submitters.current_submitter_order?(@submitter) !Submitters.current_submitter_order?(@submitter)
Submitters.preload_with_pages(@submitter) Submitters.preload_with_pages(@submitter)
@@ -29,8 +33,6 @@ class SubmitFormController < ApplicationController
@attachments_index = build_attachments_index(submission) @attachments_index = build_attachments_index(submission)
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
return unless @form_configs[:prefill_signature] return unless @form_configs[:prefill_signature]
if (user_signature = UserConfigs.load_signature(current_user)) if (user_signature = UserConfigs.load_signature(current_user))
@@ -48,40 +50,44 @@ class SubmitFormController < ApplicationController
end end
def update def update
submitter = Submitter.find_by!(slug: params[:slug]) if @submitter.completed_at?
if submitter.completed_at?
return render json: { error: I18n.t('form_has_been_completed_already') }, status: :unprocessable_entity return render json: { error: I18n.t('form_has_been_completed_already') }, status: :unprocessable_entity
end end
if submitter.template.archived_at? || submitter.submission.archived_at? if @submitter.template.archived_at? || @submitter.submission.archived_at?
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_entity return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_entity
end end
if submitter.submission.expired? if @submitter.submission.expired?
return render json: { error: I18n.t('form_has_been_expired') }, status: :unprocessable_entity return render json: { error: I18n.t('form_has_been_expired') }, status: :unprocessable_entity
end end
if submitter.declined_at? if @submitter.declined_at?
return render json: { error: I18n.t('form_has_been_declined') }, return render json: { error: I18n.t('form_has_been_declined') },
status: :unprocessable_entity status: :unprocessable_entity
end end
Submitters::SubmitValues.call(submitter, params, request) Submitters::SubmitValues.call(@submitter, params, request)
head :ok head :ok
rescue Submitters::SubmitValues::RequiredFieldError => e
Rollbar.warning("Required field #{@submitter.id}: #{e.message}") if defined?(Rollbar)
render json: { field_uuid: e.message }, status: :unprocessable_entity
rescue Submitters::SubmitValues::ValidationError => e rescue Submitters::SubmitValues::ValidationError => e
render json: { error: e.message }, status: :unprocessable_entity render json: { error: e.message }, status: :unprocessable_entity
end end
def completed def completed; end
@submitter = Submitter.completed.find_by!(slug: params[:submit_form_slug])
end
def success; end def success; end
private private
def load_submitter
@submitter = Submitter.find_by!(slug: params[:slug] || params[:submit_form_slug])
end
def build_attachments_index(submission) def build_attachments_index(submission)
ActiveStorage::Attachment.where(record: submission.submitters, name: :attachments) ActiveStorage::Attachment.where(record: submission.submitters, name: :attachments)
.preload(:blob).index_by(&:uuid) .preload(:blob).index_by(&:uuid)
@@ -7,25 +7,25 @@ class SubmitFormDownloadController < ApplicationController
FILES_TTL = 5.minutes FILES_TTL = 5.minutes
def index def index
submitter = Submitter.find_by!(slug: params[:submit_form_slug]) @submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return redirect_to submitter_download_index_path(submitter.slug) if submitter.completed_at? return redirect_to submitter_download_index_path(@submitter.slug) if @submitter.completed_at?
return head :unprocessable_entity if submitter.declined_at? || return head :unprocessable_entity if @submitter.declined_at? ||
submitter.submission.archived_at? || @submitter.submission.archived_at? ||
submitter.submission.expired? || @submitter.submission.expired? ||
submitter.submission.template.archived_at? @submitter.submission.template.archived_at?
last_completed_submitter = submitter.submission.submitters last_completed_submitter = @submitter.submission.submitters
.where.not(id: submitter.id) .where.not(id: @submitter.id)
.where.not(completed_at: nil) .where.not(completed_at: nil)
.max_by(&:completed_at) .max_by(&:completed_at)
attachments = attachments =
if last_completed_submitter if last_completed_submitter
Submitters.select_attachments_for_download(last_completed_submitter) Submitters.select_attachments_for_download(last_completed_submitter)
else else
submitter.submission.template.schema_documents.preload(:blob) @submitter.submission.template.schema_documents.preload(:blob)
end end
urls = attachments.map do |attachment| urls = attachments.map do |attachment|
@@ -5,8 +5,9 @@ class TemplateFoldersController < ApplicationController
def show def show
@templates = @template_folder.templates.active.accessible_by(current_ability) @templates = @template_folder.templates.active.accessible_by(current_ability)
.preload(:author, :template_accesses).order(id: :desc) .preload(:author, :template_accesses)
@templates = Templates.search(@templates, params[:q]) @templates = Templates.search(@templates, params[:q])
@templates = Templates::Order.call(@templates, current_user, cookies.permanent[:dashboard_templates_order])
@pagy, @templates = pagy(@templates, limit: 12) @pagy, @templates = pagy(@templates, limit: 12)
end end
@@ -0,0 +1,35 @@
# frozen_string_literal: true
class TemplatesCloneAndReplaceController < ApplicationController
load_and_authorize_resource :template
def create
return head :unprocessable_entity if params[:files].blank?
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: :preview_images_attachments]
).call
cloned_template = Templates::Clone.call(@template, author: current_user)
cloned_template.name = File.basename(params[:files].first.original_filename, '.*')
cloned_template.save!
documents = Templates::ReplaceAttachments.call(cloned_template, params, extract_fields: true)
cloned_template.save!
Templates::CloneAttachments.call(template: cloned_template, original_template: @template,
excluded_attachment_uuids: documents.map(&:uuid))
respond_to do |f|
f.html { redirect_to edit_template_path(cloned_template) }
f.json { render json: { id: cloned_template.id } }
end
rescue Templates::CreateAttachments::PdfEncrypted
respond_to do |f|
f.html { render turbo_stream: turbo_stream.append(params[:form_id], html: helpers.tag.prompt_password) }
f.json { render json: { error: 'PDF encrypted', status: 'pdf_encrypted' }, status: :unprocessable_entity }
end
end
end
+1 -3
View File
@@ -21,9 +21,7 @@ class TemplatesController < ApplicationController
submissions.order(id: :desc) submissions.order(id: :desc)
end end
submissions = submissions.preload(:template_accesses) unless current_user.role.in?(%w[admin superadmin]) @pagy, @submissions = pagy(submissions.preload(:template_accesses, submitters: :start_form_submission_events))
@pagy, @submissions = pagy(submissions.preload(submitters: :start_form_submission_events))
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
redirect_to root_path redirect_to root_path
end end
@@ -9,9 +9,11 @@ class TemplatesDashboardController < ApplicationController
FOLDERS_PER_PAGE = 18 FOLDERS_PER_PAGE = 18
def index def index
@template_folders = @template_folders.where(id: @templates.active.select(:folder_id)).order(id: :desc) @template_folders = @template_folders.where(id: @templates.active.select(:folder_id))
@template_folders = TemplateFolders.search(@template_folders, params[:q]) @template_folders = TemplateFolders.search(@template_folders, params[:q])
@template_folders = sort_template_folders(@template_folders, current_user,
cookies.permanent[:dashboard_templates_order])
@pagy, @template_folders = pagy( @pagy, @template_folders = pagy(
@template_folders, @template_folders,
@@ -24,6 +26,7 @@ class TemplatesDashboardController < ApplicationController
else else
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME } @template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME }
@templates = filter_templates(@templates) @templates = filter_templates(@templates)
@templates = Templates::Order.call(@templates, current_user, cookies.permanent[:dashboard_templates_order])
limit = limit =
if @template_folders.size < 4 if @template_folders.size < 4
@@ -39,19 +42,55 @@ class TemplatesDashboardController < ApplicationController
private private
def filter_templates(templates) def filter_templates(templates)
rel = templates.active.preload(:author, :template_accesses).order(id: :desc) rel = templates.active.preload(:author, :template_accesses)
if params[:q].blank? if params[:q].blank?
if Docuseal.multitenant? && !current_account.testing? if Docuseal.multitenant? ? current_account.testing? : current_account.linked_account_account
rel = rel.where(folder_id: current_account.default_template_folder.id) shared_account_ids = [current_user.account_id]
else shared_account_ids << TemplateSharing::ALL_ID if !Docuseal.multitenant? && !current_account.testing?
shared_template_ids =
TemplateSharing.where(account_id: [current_account.id, TemplateSharing::ALL_ID]).select(:template_id) shared_template_ids = TemplateSharing.where(account_id: shared_account_ids).select(:template_id)
rel = rel.where(folder_id: current_account.default_template_folder.id).or(rel.where(id: shared_template_ids)) rel = rel.where(folder_id: current_account.default_template_folder.id).or(rel.where(id: shared_template_ids))
else
rel = rel.where(folder_id: current_account.default_template_folder.id)
end end
end end
Templates.search(rel, params[:q]) Templates.search(rel, params[:q])
end end
def sort_template_folders(template_folders, current_user, order)
case order
when 'used_at'
subquery =
Template.left_joins(:submissions)
.group(:folder_id)
.where(account_id: current_user.account_id)
.select(
:folder_id,
Template.arel_table[:updated_at].maximum.as('updated_at_max'),
Submission.arel_table[:created_at].maximum.as('submission_created_at_max')
)
template_folders = template_folders.joins(
Template.arel_table
.join(subquery.arel.as('templates'), Arel::Nodes::OuterJoin)
.on(TemplateFolder.arel_table[:id].eq(Template.arel_table[:folder_id]))
.join_sources
)
template_folders.order(
Arel::Nodes::Case.new
.when(Template.arel_table[:submission_created_at_max].gt(Template.arel_table[:updated_at_max]))
.then(Template.arel_table[:submission_created_at_max])
.else(Template.arel_table[:updated_at_max])
.desc
)
when 'name'
template_folders.order(name: :asc)
else
template_folders.order(id: :desc)
end
end
end end
@@ -24,14 +24,22 @@ class TemplatesPreferencesController < ApplicationController
documents_copy_email_enabled documents_copy_email_attach_audit documents_copy_email_enabled documents_copy_email_attach_audit
documents_copy_email_attach_documents documents_copy_email_reply_to documents_copy_email_attach_documents documents_copy_email_reply_to
completed_notification_email_attach_documents completed_notification_email_attach_documents
completed_redirect_url completed_redirect_url validate_unique_submitters
submitters_order submitters_order require_phone_2fa
default_expire_at_duration
default_expire_at
completed_notification_email_subject completed_notification_email_body completed_notification_email_subject completed_notification_email_body
completed_notification_email_enabled completed_notification_email_attach_audit] + completed_notification_email_enabled completed_notification_email_attach_audit] +
[completed_message: %i[title body], [completed_message: %i[title body],
submitters: [%i[uuid request_email_subject request_email_body]]] submitters: [%i[uuid request_email_subject request_email_body]]]
).tap do |attrs| ).tap do |attrs|
attrs[:preferences].delete(:submitters) if params[:request_email_per_submitter] != '1' attrs[:preferences].delete(:submitters) if params[:request_email_per_submitter] != '1'
if (default_expire_at = attrs.dig(:preferences, :default_expire_at).presence)
attrs[:preferences][:default_expire_at] =
(ActiveSupport::TimeZone[current_account.timezone] || Time.zone).parse(default_expire_at).utc
end
attrs[:preferences] = attrs[:preferences].transform_values do |value| attrs[:preferences] = attrs[:preferences].transform_values do |value|
if %w[true false].include?(value) if %w[true false].include?(value)
value == 'true' value == 'true'
+3 -1
View File
@@ -5,7 +5,8 @@ class UserConfigsController < ApplicationController
authorize_resource :user_config authorize_resource :user_config
ALLOWED_KEYS = [ ALLOWED_KEYS = [
UserConfig::RECEIVE_COMPLETED_EMAIL UserConfig::RECEIVE_COMPLETED_EMAIL,
UserConfig::SHOW_APP_TOUR
].freeze ].freeze
InvalidKey = Class.new(StandardError) InvalidKey = Class.new(StandardError)
@@ -28,6 +29,7 @@ class UserConfigsController < ApplicationController
def user_config_params def user_config_params
params.required(:user_config).permit(:key, :value, { value: {} }, { value: [] }).tap do |attrs| params.required(:user_config).permit(:key, :value, { value: {} }, { value: [] }).tap do |attrs|
attrs[:value] = attrs[:value] == '1' if attrs[:value].in?(%w[1 0]) attrs[:value] = attrs[:value] == '1' if attrs[:value].in?(%w[1 0])
attrs[:value] = attrs[:value] == 'true' if attrs[:value].in?(%w[true false])
end end
end end
end end
+7 -3
View File
@@ -32,11 +32,11 @@ import CheckboxGroup from './elements/checkbox_group'
import MaskedInput from './elements/masked_input' import MaskedInput from './elements/masked_input'
import SetDateButton from './elements/set_date_button' import SetDateButton from './elements/set_date_button'
import IndeterminateCheckbox from './elements/indeterminate_checkbox' import IndeterminateCheckbox from './elements/indeterminate_checkbox'
import AppTour from './elements/app_tour'
import DashboardDropzone from './elements/dashboard_dropzone'
import * as TurboInstantClick from './lib/turbo_instant_click' import * as TurboInstantClick from './lib/turbo_instant_click'
import './images/preview.png'
TurboInstantClick.start() TurboInstantClick.start()
document.addEventListener('turbo:before-cache', () => { document.addEventListener('turbo:before-cache', () => {
@@ -101,6 +101,8 @@ safeRegisterElement('checkbox-group', CheckboxGroup)
safeRegisterElement('masked-input', MaskedInput) safeRegisterElement('masked-input', MaskedInput)
safeRegisterElement('set-date-button', SetDateButton) safeRegisterElement('set-date-button', SetDateButton)
safeRegisterElement('indeterminate-checkbox', IndeterminateCheckbox) safeRegisterElement('indeterminate-checkbox', IndeterminateCheckbox)
safeRegisterElement('app-tour', AppTour)
safeRegisterElement('dashboard-dropzone', DashboardDropzone)
safeRegisterElement('template-builder', class extends HTMLElement { safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {
@@ -125,8 +127,10 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withSendButton: this.dataset.withSendButton !== 'false', withSendButton: this.dataset.withSendButton !== 'false',
withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false', withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false',
withConditions: this.dataset.withConditions === 'true', withConditions: this.dataset.withConditions === 'true',
withReplaceAndCloneUpload: true,
currencies: (this.dataset.currencies || '').split(',').filter(Boolean), currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
acceptFileTypes: this.dataset.acceptFileTypes acceptFileTypes: this.dataset.acceptFileTypes,
showTourStartForm: this.dataset.showTourStartForm === 'true'
}) })
this.component = this.app.mount(this.appElem) this.component = this.app.mount(this.appElem)
+20 -17
View File
@@ -1,5 +1,6 @@
import SignaturePad from 'signature_pad' import SignaturePad from 'signature_pad'
import { cropCanvasAndExportToPNG } from './submission_form/crop_canvas' import { cropCanvasAndExportToPNG } from './submission_form/crop_canvas'
import { isValidSignatureCanvas } from './submission_form/validate_signature'
window.customElements.define('draw-signature', class extends HTMLElement { window.customElements.define('draw-signature', class extends HTMLElement {
connectedCallback () { connectedCallback () {
@@ -43,6 +44,8 @@ window.customElements.define('draw-signature', class extends HTMLElement {
return response return response
}) })
}).catch(error => {
console.log(error)
}).finally(() => { }).finally(() => {
this.submitButton.disabled = false this.submitButton.disabled = false
}) })
@@ -65,26 +68,26 @@ window.customElements.define('draw-signature', class extends HTMLElement {
} }
async submitImage () { async submitImage () {
return new Promise((resolve, reject) => { if (!isValidSignatureCanvas(this.pad.toData())) {
cropCanvasAndExportToPNG(this.canvas, { errorOnTooSmall: true }).then(async (blob) => { alert('Signature is too small or simple. Please redraw.')
const file = new File([blob], 'signature.png', { type: 'image/png' })
const formData = new FormData() return Promise.reject(new Error('Image too small or simple'))
}
formData.append('file', file) return cropCanvasAndExportToPNG(this.canvas).then(async (blob) => {
formData.append('submitter_slug', this.dataset.slug) const file = new File([blob], 'signature.png', { type: 'image/png' })
formData.append('name', 'attachments')
formData.append('remember_signature', 'true')
return fetch('/api/attachments', { const formData = new FormData()
method: 'POST',
body: formData formData.append('file', file)
}).then((resp) => resp.json()).then((attachment) => { formData.append('submitter_slug', this.dataset.slug)
return resolve(attachment) formData.append('name', 'attachments')
}) formData.append('remember_signature', 'true')
}).catch((error) => {
return reject(error) return fetch('/api/attachments', {
}) method: 'POST',
body: formData
}).then(resp => resp.json())
}) })
} }
+338
View File
@@ -0,0 +1,338 @@
export default class extends HTMLElement {
async connectedCallback () {
this.tourType = this.dataset.type
this.nextPagePath = this.dataset.nextPagePath
this.I18n = JSON.parse(this.dataset.i18n || '{}')
if (this.dataset.showTour === 'true') this.start()
}
async start () {
if (window.innerWidth < 768) return
const [{ driver }] = await Promise.all([
import('driver.js'),
import('driver.js/dist/driver.css')
])
this.driverObj = driver({
showProgress: true,
nextBtnText: this.I18n.next,
prevBtnText: this.I18n.previous,
doneBtnText: this.I18n.done,
onDestroyStarted: () => {
this.disableAppGuide().finally(() => { this.destroy() })
},
onHighlightStarted: (element) => {
if (element) {
const clickHandler = () => {
this.disableAppGuide().finally(() => { this.destroy() })
element.removeEventListener('click', clickHandler)
}
element.addEventListener('click', clickHandler)
}
}
})
if (this.tourType === 'dashboard') {
this.showDashboardTour()
} else if (this.tourType === 'builder') {
this.showTemplateBuilderTour()
} else if (this.tourType === 'account') {
this.showAccountTour()
} else if (this.tourType === 'template') {
this.showTemplateTour()
}
}
disconnectedCallback () {
if (this.driverObj) this.destroy()
}
destroy () {
if (this.builderTemplate) this.builderTemplate.fields.shift()
if (this.driverObj) this.driverObj.destroy()
}
showTemplateTour () {
const steps = [
{
element: '#share_link_clipboard',
popover: {
title: this.I18n.copy_and_share_link,
description: this.I18n.copy_and_share_link_description,
side: 'bottom',
align: 'end'
}
},
{
element: '#sign_yourself_button',
popover: {
title: this.I18n.sign_the_document,
description: this.I18n.sign_the_document_description,
side: 'top',
align: 'center'
}
},
{
element: '#send_to_recipients_button',
popover: {
title: this.I18n.send_for_signing,
description: this.I18n.add_recipients_description,
side: 'top',
align: 'center'
}
},
{
element: '#add_recipients_button',
popover: {
title: this.I18n.add_recipients,
description: this.I18n.add_recipients_description,
side: 'bottom',
align: 'end'
}
},
{
element: '#account_settings_button',
popover: {
title: this.I18n.settings,
description: this.I18n.settings_template_description,
side: 'right',
align: 'start',
showButtons: this.nextPagePath ? ['next', 'previous', 'close'] : ['previous', 'close'],
onNextClick: () => {
if (this.nextPagePath) {
window.Turbo.visit(this.nextPagePath)
}
}
}
}
].filter((step) => document.querySelector(step.element))
this.driverObj.setSteps(steps)
this.driverObj.drive()
}
showDashboardTour () {
this.driverObj.setSteps([
{
element: '#templates_submissions_toggle',
popover: {
title: this.I18n.template_and_submissions,
description: this.I18n.template_and_submissions_description,
side: 'right',
align: 'start'
}
},
{
element: '#templates_upload_button',
popover: {
title: this.I18n.upload_a_pdf_file,
description: this.I18n.upload_a_pdf_file_description,
side: 'left',
align: 'start',
showButtons: this.nextPagePath ? ['next', 'previous', 'close'] : ['previous', 'close'],
onNextClick: () => {
if (this.nextPagePath) {
window.Turbo.visit(this.nextPagePath)
}
}
},
onHighlightStarted: () => {}
}
])
this.driverObj.drive()
}
showAccountTour () {
this.driverObj.setSteps([
{
element: '#account_settings_menu',
popover: {
title: this.I18n.settings,
description: this.I18n.settings_account_description,
side: 'right',
align: 'start'
}
},
{
element: '#support_channels',
popover: {
title: this.I18n.support,
description: this.I18n.support_description,
side: 'left',
align: 'start'
}
}
].filter((step) => document.querySelector(step.element)))
this.driverObj.drive()
}
showTemplateBuilderTour () {
const builderComponent = document.querySelector('template-builder')?.component
this.builderTemplate = builderComponent?.template
if (this.builderTemplate) {
this.builderTemplate.fields.unshift({
uuid: 'b387399b-88dc-4345-9d37-743e97a9b2b3',
submitter_uuid: this.builderTemplate.submitters[0].uuid,
name: 'First Name',
type: 'text'
})
builderComponent.$nextTick(() => {
this.driverObj.setSteps([
{
element: '.roles-dropdown',
popover: {
title: this.I18n.select_a_signer_party,
description: this.I18n.select_a_signer_party_description,
side: 'left',
align: 'start',
onPopoverRender: () => {
const rolesDropdown = document.querySelector('.roles-dropdown')
rolesDropdown.dispatchEvent(new Event('mouseenter', { bubbles: true, cancelable: true }))
rolesDropdown.classList.add('dropdown-open')
}
}
},
{
element: '.roles-dropdown .dropdown-content',
popover: {
title: this.I18n.available_parties,
description: this.I18n.available_parties_description,
side: 'left',
align: 'start',
onPopoverRender: () => {
document.querySelector('.roles-dropdown .dropdown-content').classList.remove('driver-active-element')
},
onNextClick: () => {
document.querySelector('.roles-dropdown').classList.remove('dropdown-open')
this.driverObj.moveNext()
}
}
},
{
element: '#field-types-grid',
popover: {
title: this.I18n.available_field_types,
description: this.I18n.available_field_types_description,
side: 'right',
align: 'start',
onPrevClick: () => {
document.querySelector('.roles-dropdown').classList.add('dropdown-open')
this.driverObj.movePrevious()
}
}
},
{
element: '#text_type_field_button',
popover: {
title: this.I18n.text_input_field,
description: this.I18n.text_input_field_description,
side: 'left',
align: 'start'
}
},
{
element: '#signature_type_field_button',
popover: {
title: this.I18n.signature_field,
description: this.I18n.signature_field_description,
side: 'left',
align: 'start'
}
},
{
element: '.fields',
popover: {
title: this.I18n.added_fields,
description: this.I18n.added_fields_description,
side: 'right',
align: 'start'
}
},
{
element: '.list-field label:has(svg.tabler-icon-settings)',
popover: {
title: this.I18n.open_field_settings,
description: this.I18n.open_field_settings_description,
side: 'bottom',
align: 'end',
onPopoverRender: () => {
const settingsDropdown = document.querySelector('.list-field div:first-child span:has(svg.tabler-icon-settings)')
document.querySelectorAll('.list-field div:first-child .text-transparent').forEach((e) => e.classList.remove('text-transparent'))
settingsDropdown.dispatchEvent(new Event('mouseenter', { bubbles: true, cancelable: true }))
settingsDropdown.classList.add('dropdown-open')
}
}
},
{
element: '.list-field div:first-child span:has(svg.tabler-icon-settings) .dropdown-content',
popover: {
title: this.I18n.field_settings,
description: this.I18n.field_settings_description,
side: 'left',
align: 'start',
onPopoverRender: () => {
document.querySelector('.list-field div:first-child span:has(svg.tabler-icon-settings) .dropdown-content').classList.remove('driver-active-element')
},
onNextClick: () => {
document.querySelector('.list-field div:first-child span:has(svg.tabler-icon-settings)').classList.remove('dropdown-open')
this.driverObj.moveNext()
}
}
},
{
element: '#send_button',
popover: {
title: this.I18n.send_document,
description: this.I18n.send_document_description,
side: 'bottom',
align: 'end',
onPrevClick: () => {
document.querySelector('.list-field div:first-child span:has(svg.tabler-icon-settings)').classList.add('dropdown-open')
this.driverObj.movePrevious()
}
}
},
{
element: '#sign_yourself_button',
popover: {
title: this.I18n.sign_yourself,
description: this.I18n.sign_yourself_description,
side: 'bottom',
align: 'end',
onNextClick: () => {
if (this.nextPagePath) {
window.Turbo.visit(this.nextPagePath)
} else {
this.destroy()
}
}
}
}
])
this.driverObj.drive()
})
}
}
async disableAppGuide () {
return fetch('/user_configs', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ key: 'show_app_tour', value: false })
})
}
}
@@ -0,0 +1,206 @@
import { target, targets, targetable } from '@github/catalyst/lib/targetable'
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 d="M12 3a9 9 0 1 0 9 9" />
</svg>`
export default targetable(class extends HTMLElement {
static [targets.static] = [
'hiddenOnDrag',
'folderCards',
'templateCards'
]
static [target.static] = [
'form',
'fileDropzone',
'fileDropzoneLoading'
]
connectedCallback () {
document.addEventListener('drop', this.onWindowDragdrop)
document.addEventListener('dragover', this.onWindowDropover)
window.addEventListener('dragleave', this.onWindowDragleave)
this.fileDropzone?.addEventListener('drop', this.onDropFile)
this.folderCards.forEach((el) => el.addEventListener('drop', (e) => this.onDropFolder(e, el)))
this.templateCards.forEach((el) => el.addEventListener('drop', this.onDropTemplate))
this.templateCards.forEach((el) => el.addEventListener('dragstart', this.onTemplateDragStart))
return [this.fileDropzone, ...this.folderCards, ...this.templateCards].forEach((el) => {
el?.addEventListener('dragover', this.onDragover)
el?.addEventListener('dragleave', this.onDragleave)
})
}
disconnectedCallback () {
document.removeEventListener('drop', this.onWindowDragdrop)
document.removeEventListener('dragover', this.onWindowDropover)
window.removeEventListener('dragleave', this.onWindowDragleave)
}
onTemplateDragStart = (e) => {
const id = e.target.href.split('/').pop()
e.dataTransfer.effectAllowed = 'move'
if (id) {
e.dataTransfer.setData('template_id', id)
const dragPreview = e.target.cloneNode(true)
const rect = e.target.getBoundingClientRect()
const height = e.target.children[0].getBoundingClientRect().height + 50
dragPreview.children[1].remove()
dragPreview.style.width = `${rect.width}px`
dragPreview.style.height = `${height}px`
dragPreview.style.position = 'absolute'
dragPreview.style.top = '-1000px'
dragPreview.style.pointerEvents = 'none'
dragPreview.style.opacity = '0.9'
document.body.appendChild(dragPreview)
e.dataTransfer.setDragImage(dragPreview, rect.width / 2, height / 2)
setTimeout(() => document.body.removeChild(dragPreview), 0)
}
}
onDropFile = (e) => {
e.preventDefault()
this.fileDropzoneLoading.classList.remove('hidden')
this.fileDropzoneLoading.previousElementSibling.classList.add('hidden')
this.fileDropzoneLoading.classList.add('opacity-50')
this.uploadFiles(e.dataTransfer.files, '/templates_upload')
}
onDropFolder = (e, el) => {
e.preventDefault()
const templateId = e.dataTransfer.getData('template_id')
if (e.dataTransfer.files.length || templateId) {
const loading = document.createElement('div')
const svg = el.querySelector('svg')
loading.innerHTML = loadingIconHtml
loading.children[0].classList.add(...svg.classList)
el.replaceChild(loading.children[0], svg)
el.classList.add('opacity-50')
if (e.dataTransfer.files.length) {
const params = new URLSearchParams({ folder_name: el.innerText }).toString()
this.uploadFiles(e.dataTransfer.files, `/templates_upload?${params}`)
} else {
const formData = new FormData()
formData.append('name', el.innerText)
fetch(`/templates/${templateId}/folder`, {
method: 'PUT',
redirect: 'manual',
body: formData,
headers: {
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
}
}).finally(() => {
window.Turbo.cache.clear()
window.Turbo.visit(location.href)
})
}
}
}
onDropTemplate = (e) => {
e.preventDefault()
if (e.dataTransfer.files.length) {
const loading = document.createElement('div')
loading.classList.add('bottom-5', 'left-0', 'flex', 'justify-center', 'w-full', 'absolute')
loading.innerHTML = loadingIconHtml
e.target.appendChild(loading)
e.target.classList.add('opacity-50')
const id = e.target.href.split('/').pop()
this.uploadFiles(e.dataTransfer.files, `/templates/${id}/clone_and_replace`)
}
}
onWindowDragdrop = (e) => {
e.preventDefault()
if (!this.isLoading) this.hideDraghover()
}
uploadFiles (files, url) {
this.isLoading = true
this.form.action = url
this.form.querySelector('[type="file"]').files = files
this.form.querySelector('[type="submit"]').click()
}
onWindowDropover = (e) => {
e.preventDefault()
if (e.dataTransfer?.types?.includes('Files')) {
this.showDraghover()
}
}
onDragover (e) {
if (e.dataTransfer?.types?.includes('Files') || this.dataset.targets !== 'dashboard-dropzone.templateCards') {
this.style.backgroundColor = '#F7F3F0'
}
}
onDragleave () {
this.style.backgroundColor = null
}
onWindowDragleave = (e) => {
if (e.clientX <= 0 || e.clientY <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) {
this.hideDraghover()
}
}
showDraghover = () => {
if (this.isDrag) return
this.isDrag = true
this.fileDropzone?.classList?.remove('hidden')
this.hiddenOnDrag.forEach((el) => { el.style.display = 'none' })
return [...this.folderCards, ...this.templateCards].forEach((el) => {
el.classList.remove('bg-base-200', 'before:hidden')
})
}
hideDraghover = () => {
this.isDrag = false
this.fileDropzone?.classList?.add('hidden')
this.hiddenOnDrag.forEach((el) => { el.style.display = null })
return [...this.folderCards, ...this.templateCards].forEach((el) => {
el.classList.add('bg-base-200', 'before:hidden')
})
}
})
+36
View File
@@ -0,0 +1,36 @@
export default class extends HTMLElement {
connectedCallback () {
this.form.addEventListener('submit', (e) => {
e.preventDefault()
this.submit()
})
if (this.dataset.onload === 'true') {
this.form.querySelector('button').click()
}
}
submit () {
fetch(this.form.action, {
method: this.form.method,
body: new FormData(this.form)
}).then(async (resp) => {
if (!resp.ok) {
try {
const data = JSON.parse(await resp.text())
if (data.error) {
alert(data.error)
}
} catch (err) {
console.error(err)
}
}
})
}
get form () {
return this.querySelector('form')
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ export default actionable(targetable(class extends HTMLElement {
} }
toggleLoading = (e) => { toggleLoading = (e) => {
if (e && e.target && !e.target.contains(this)) { if (e && e.target && (!e.target.contains(this) || !e.detail?.formSubmission?.formElement?.contains(this))) {
return return
} }
+62
View File
@@ -0,0 +1,62 @@
export default class extends HTMLElement {
connectedCallback () {
this.header = document.querySelector('#signing_form_header')
window.addEventListener('scroll', this.onScroll)
window.addEventListener('resize', this.onResize)
if (!this.isNarrow() && this.isHeaderNotVisible()) {
this.showButtons({ animate: false })
}
}
disconnectedCallback () {
window.removeEventListener('scroll', this.onScroll)
window.removeEventListener('resize', this.onResize)
}
onResize = () => {
if (this.isNarrow()) {
this.hideButtons(true)
} else if (this.isHeaderNotVisible()) {
this.showButtons()
}
}
isNarrow () {
return window.innerWidth < 1230
}
onScroll = () => {
if (this.isHeaderNotVisible() && !this.isNarrow()) {
this.showButtons()
} else {
this.hideButtons()
}
}
isHeaderNotVisible () {
const rect = this.header.getBoundingClientRect()
return rect.bottom <= 0 || rect.top >= window.innerHeight
}
showButtons ({ animate } = { animate: true }) {
if (animate) {
this.classList.add('transition-transform', 'duration-300')
}
this.classList.remove('hidden', '-translate-y-10', 'opacity-0')
this.classList.add('translate-y-0', 'opacity-100')
}
hideButtons () {
this.classList.remove('translate-y-0', 'opacity-100')
this.classList.add('-translate-y-10', 'opacity-0')
setTimeout(() => {
if (this.classList.contains('-translate-y-10')) {
this.classList.add('hidden')
}
}, 300)
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ export default actionable(class extends HTMLElement {
const elementIds = JSON.parse(this.dataset.elementIds) const elementIds = JSON.parse(this.dataset.elementIds)
elementIds.forEach((elementId) => { elementIds.forEach((elementId) => {
document.getElementById(elementId).classList.toggle('hidden', event.target.value !== elementId) document.getElementById(elementId).classList.toggle('hidden', (event.target.dataset.toggleId || event.target.value) !== elementId)
}) })
} }
}) })
+4
View File
@@ -3,11 +3,15 @@ import { createApp, reactive } from 'vue'
import Form from './submission_form/form' import Form from './submission_form/form'
import DownloadButton from './elements/download_button' import DownloadButton from './elements/download_button'
import ToggleSubmit from './elements/toggle_submit' import ToggleSubmit from './elements/toggle_submit'
import FetchForm from './elements/fetch_form'
import ScrollButtons from './elements/scroll_buttons'
const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options) const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options)
safeRegisterElement('download-button', DownloadButton) safeRegisterElement('download-button', DownloadButton)
safeRegisterElement('toggle-submit', ToggleSubmit) safeRegisterElement('toggle-submit', ToggleSubmit)
safeRegisterElement('fetch-form', FetchForm)
safeRegisterElement('scroll-buttons', ScrollButtons)
safeRegisterElement('submission-form', class extends HTMLElement { safeRegisterElement('submission-form', class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

+6 -3
View File
@@ -183,7 +183,7 @@
v-else v-else
ref="textContainer" ref="textContainer"
dir="auto" dir="auto"
class="flex items-center px-0.5 w-full" class="flex px-0.5 w-full"
:class="{ ...alignClasses, ...fontClasses }" :class="{ ...alignClasses, ...fontClasses }"
> >
<span <span
@@ -327,13 +327,16 @@ export default {
}, },
alignClasses () { alignClasses () {
if (!this.field.preferences) { if (!this.field.preferences) {
return {} return { 'items-center': true }
} }
return { return {
'text-center': this.field.preferences.align === 'center', 'text-center': this.field.preferences.align === 'center',
'text-left': this.field.preferences.align === 'left', 'text-left': this.field.preferences.align === 'left',
'text-right': this.field.preferences.align === 'right' 'text-right': this.field.preferences.align === 'right',
'items-center': !this.field.preferences.valign || this.field.preferences.valign === 'center',
'items-start': this.field.preferences.valign === 'top',
'items-end': this.field.preferences.valign === 'bottom'
} }
}, },
fontClasses () { fontClasses () {
+3 -3
View File
@@ -193,9 +193,9 @@ export default {
}) })
} }
if (window.decline_button) { document.querySelectorAll('#decline_button').forEach((button) => {
window.decline_button.setAttribute('disabled', 'true') button.setAttribute('disabled', 'true')
} })
}, },
methods: { methods: {
sendCopyToEmail () { sendCopyToEmail () {
+17 -7
View File
@@ -11,7 +11,7 @@
:with-label="!isAnonymousChecboxes && showFieldNames" :with-label="!isAnonymousChecboxes && showFieldNames"
:current-step="currentStepFields" :current-step="currentStepFields"
:scroll-padding="scrollPadding" :scroll-padding="scrollPadding"
@focus-step="[saveStep(), goToStep($event, false, true), currentField.type !== 'checkbox' ? isFormVisible = true : '']" @focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]"
/> />
<FieldAreas <FieldAreas
:steps="readonlyConditionalFields.map((e) => [e])" :steps="readonlyConditionalFields.map((e) => [e])"
@@ -1404,7 +1404,21 @@ export default {
if (response.status === 422 || response.status === 500) { if (response.status === 422 || response.status === 500) {
const data = await response.json() const data = await response.json()
if (data.error) { if (data.field_uuid) {
const field = this.fieldsUuidIndex[data.field_uuid]
if (field) {
const step = this.stepFields.findIndex((fields) => fields.includes(field))
if (step !== -1) {
this.goToStep(step, this.autoscrollFields)
this.showFillAllRequiredFields = true
}
}
return Promise.reject(new Error('Required field: ' + data.field_uuid))
} else if (data.error) {
const i18nKey = data.error.replace(/\s+/g, '_').toLowerCase() const i18nKey = data.error.replace(/\s+/g, '_').toLowerCase()
alert(this.t(i18nKey) !== i18nKey ? this.t(i18nKey) : data.error) alert(this.t(i18nKey) !== i18nKey ? this.t(i18nKey) : data.error)
@@ -1439,11 +1453,7 @@ export default {
this.isSubmittingComplete = false this.isSubmittingComplete = false
}) })
}).catch(error => { }).catch(error => {
if (error?.message === 'Image too small') { console.log(error)
alert(this.t('signature_is_too_small_please_redraw'))
} else {
console.log(error)
}
}).finally(() => { }).finally(() => {
this.isSubmitting = false this.isSubmitting = false
this.isSubmittingComplete = false this.isSubmittingComplete = false
+129 -18
View File
@@ -93,7 +93,7 @@ const en = {
reupload: 'Reupload', reupload: 'Reupload',
upload: 'Upload', upload: 'Upload',
files: 'Files', files: 'Files',
signature_is_too_small_please_redraw: 'Signature is too small. Please redraw.', signature_is_too_small_or_simple_please_redraw: 'Signature is too small or simple. Please redraw.',
wait_countdown_seconds: 'Wait {countdown} seconds' wait_countdown_seconds: 'Wait {countdown} seconds'
} }
@@ -148,6 +148,7 @@ const es = {
sign_now: 'Firmar ahora', sign_now: 'Firmar ahora',
type_here_: 'Escribe aquí...', type_here_: 'Escribe aquí...',
optional: 'opcional', optional: 'opcional',
option: 'Opción',
appears_on: 'Aparece en', appears_on: 'Aparece en',
page: 'Página', page: 'Página',
select_your_option: 'Selecciona tu opción', select_your_option: 'Selecciona tu opción',
@@ -191,7 +192,7 @@ const es = {
reupload: 'Volver a subir', reupload: 'Volver a subir',
upload: 'Subir', upload: 'Subir',
files: 'Archivos', files: 'Archivos',
signature_is_too_small_please_redraw: 'La firma es demasiado pequeña. Por favor, dibújala de nuevo.', signature_is_too_small_or_simple_please_redraw: 'La firma es demasiado pequeña o simple. Por favor, vuelve a dibujarla.',
wait_countdown_seconds: 'Espera {countdown} segundos' wait_countdown_seconds: 'Espera {countdown} segundos'
} }
@@ -245,6 +246,7 @@ const it = {
sign_now: 'Firma ora', sign_now: 'Firma ora',
type_here_: 'Digita qui...', type_here_: 'Digita qui...',
optional: 'opzionale', optional: 'opzionale',
option: 'Opzione',
appears_on: 'Compare su', appears_on: 'Compare su',
page: 'Pagina', page: 'Pagina',
take_photo: 'Scattare una foto', take_photo: 'Scattare una foto',
@@ -265,7 +267,7 @@ const it = {
document_has_been_signed: 'Il documento è stato firmato!', document_has_been_signed: 'Il documento è stato firmato!',
documents_have_been_signed: 'I documenti sono stati firmati!', documents_have_been_signed: 'I documenti sono stati firmati!',
create_a_free_account: 'Crea un Account Gratuito', create_a_free_account: 'Crea un Account Gratuito',
powered_by: 'Desarrollado por', powered_by: 'Fornito da',
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',
@@ -289,7 +291,7 @@ const it = {
reupload: 'Ricarica', reupload: 'Ricarica',
upload: 'Carica', upload: 'Carica',
files: 'File', files: 'File',
signature_is_too_small_please_redraw: 'La firma è troppo piccola. Ridisegnala per favore.', signature_is_too_small_or_simple_please_redraw: 'La firma è troppo piccola o semplice. Ridisegnala, per favore.',
wait_countdown_seconds: 'Attendi {countdown} secondi' wait_countdown_seconds: 'Attendi {countdown} secondi'
} }
@@ -343,6 +345,7 @@ const de = {
sign_now: 'Jetzt unterschreiben', sign_now: 'Jetzt unterschreiben',
type_here_: 'Hier eingeben...', type_here_: 'Hier eingeben...',
optional: 'optional', optional: 'optional',
option: 'Option',
appears_on: 'Erscheint auf', appears_on: 'Erscheint auf',
page: 'Seite', page: 'Seite',
take_photo: 'Foto aufnehmen', take_photo: 'Foto aufnehmen',
@@ -387,14 +390,14 @@ const de = {
reupload: 'Erneut hochladen', reupload: 'Erneut hochladen',
upload: 'Hochladen', upload: 'Hochladen',
files: 'Dateien', files: 'Dateien',
signature_is_too_small_please_redraw: 'Die Unterschrift ist zu klein. Bitte erneut zeichnen.', signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte erneut zeichnen.',
wait_countdown_seconds: 'Warte {countdown} Sekunden' wait_countdown_seconds: 'Warte {countdown} Sekunden'
} }
const fr = { const fr = {
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour continuer la vérification de l'identité.", complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour continuer la vérification de l'identité.",
verif_id: "Vérification de l'ID", verify_id: "Vérification de l'ID",
verif_identite: "Vérification de l'identité", identity_verification: "Vérification de l'identité",
complete: 'Terminer', complete: 'Terminer',
fill_all_required_fields_to_complete: 'Veuillez remplir tous les champs obligatoires pour compléter', fill_all_required_fields_to_complete: 'Veuillez remplir tous les champs obligatoires pour compléter',
sign_and_complete: 'Signer et Terminer', sign_and_complete: 'Signer et Terminer',
@@ -441,6 +444,7 @@ const fr = {
sign_now: 'Signer maintenant', sign_now: 'Signer maintenant',
type_here_: 'Tapez ici...', type_here_: 'Tapez ici...',
optional: 'facultatif', optional: 'facultatif',
option: 'Option',
appears_on: 'Apparaît sur', appears_on: 'Apparaît sur',
page: 'Page', page: 'Page',
take_photo: 'Prendre une photo', take_photo: 'Prendre une photo',
@@ -485,7 +489,7 @@ const fr = {
reupload: 'Recharger', reupload: 'Recharger',
upload: 'Télécharger', upload: 'Télécharger',
files: 'Fichiers', files: 'Fichiers',
signature_is_too_small_please_redraw: 'La signature est trop petite. Veuillez la redessiner.', signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.',
wait_countdown_seconds: 'Attendez {countdown} secondes' wait_countdown_seconds: 'Attendez {countdown} secondes'
} }
@@ -539,6 +543,7 @@ const pl = {
sign_now: 'Podpisz teraz', sign_now: 'Podpisz teraz',
type_here_: 'Wpisz tutaj...', type_here_: 'Wpisz tutaj...',
optional: 'opcjonalny', optional: 'opcjonalny',
option: 'Opcja',
appears_on: 'Pojawia się na', appears_on: 'Pojawia się na',
page: 'Strona', page: 'Strona',
select_your_option: 'Wybierz swoją opcję', select_your_option: 'Wybierz swoją opcję',
@@ -583,7 +588,8 @@ const pl = {
reupload: 'Ponowne przesłanie', reupload: 'Ponowne przesłanie',
upload: 'Przesyłanie', upload: 'Przesyłanie',
files: 'Pliki', files: 'Pliki',
signature_is_too_small_please_redraw: 'Podpis jest zbyt mały. Proszę narysować go ponownie.' signature_is_too_small_or_simple_please_redraw: 'Podpis jest zbyt mały lub zbyt prosty. Proszę narysować go ponownie.',
wait_countdown_seconds: 'Poczekaj {countdown} sekund'
} }
const uk = { const uk = {
@@ -636,6 +642,7 @@ const uk = {
sign_now: 'Підписати зараз', sign_now: 'Підписати зараз',
type_here_: 'Введіть тут', type_here_: 'Введіть тут',
optional: 'необов’язково', optional: 'необов’язково',
option: 'Опція',
appears_on: "З'являється на", appears_on: "З'являється на",
page: 'Сторінка', page: 'Сторінка',
take_photo: 'Зробити фото', take_photo: 'Зробити фото',
@@ -658,7 +665,7 @@ const uk = {
create_a_free_account: 'Створити безкоштовний обліковий запис', create_a_free_account: 'Створити безкоштовний обліковий запис',
powered_by: 'Працює на базі', powered_by: 'Працює на базі',
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: 'Підтвердіть номер телефону',
use_international_format: 'Використовуйте міжнародний формат: +1xxx', use_international_format: 'Використовуйте міжнародний формат: +1xxx',
six_digits_code: '6-значний код', six_digits_code: '6-значний код',
@@ -680,7 +687,7 @@ const uk = {
reupload: 'Перезавантажити', reupload: 'Перезавантажити',
upload: 'Завантажити', upload: 'Завантажити',
files: 'Файли', files: 'Файли',
signature_is_too_small_please_redraw: 'Підпис занадто малий. Будь ласка, перемалюйте його.', signature_is_too_small_or_simple_please_redraw: 'Підпис занадто маленький або надто простий. Будь ласка, перемалюйте.',
wait_countdown_seconds: 'Зачекайте {countdown} секунд' wait_countdown_seconds: 'Зачекайте {countdown} секунд'
} }
@@ -734,6 +741,7 @@ const cs = {
sign_now: 'Podepsat nyní', sign_now: 'Podepsat nyní',
type_here_: 'Zadejte zde', type_here_: 'Zadejte zde',
optional: 'volitelné', optional: 'volitelné',
option: 'Možnost',
appears_on: 'Zobrazuje se na', appears_on: 'Zobrazuje se na',
page: 'Stránka', page: 'Stránka',
select_your_option: 'Vyberte svou volbu', select_your_option: 'Vyberte svou volbu',
@@ -778,7 +786,7 @@ const cs = {
reupload: 'Znovu nahrát', reupload: 'Znovu nahrát',
upload: 'Nahrát', upload: 'Nahrát',
files: 'Soubory', files: 'Soubory',
signature_is_too_small_please_redraw: 'Podpis je příliš malý. Prosím, překreslete ho.', signature_is_too_small_or_simple_please_redraw: 'Podpis je příliš malý nebo jednoduchý. Nakreslete jej prosím znovu.',
wait_countdown_seconds: 'Počkejte {countdown} sekund' wait_countdown_seconds: 'Počkejte {countdown} sekund'
} }
@@ -832,6 +840,7 @@ const pt = {
sign_now: 'Assinar agora', sign_now: 'Assinar agora',
type_here_: 'Digite aqui', type_here_: 'Digite aqui',
optional: 'opcional', optional: 'opcional',
option: 'Opção',
appears_on: 'Aparece em', appears_on: 'Aparece em',
page: 'Página', page: 'Página',
take_photo: 'Tirar foto', take_photo: 'Tirar foto',
@@ -876,7 +885,7 @@ const pt = {
reupload: 'Reenviar', reupload: 'Reenviar',
upload: 'Carregar', upload: 'Carregar',
files: 'Arquivos', files: 'Arquivos',
signature_is_too_small_please_redraw: 'A assinatura é muito pequena. Por favor, redesenhe-a.', signature_is_too_small_or_simple_please_redraw: 'A assinatura é muito pequena ou simples. Por favor, redesenhe.',
wait_countdown_seconds: 'Aguarde {countdown} segundos' wait_countdown_seconds: 'Aguarde {countdown} segundos'
} }
@@ -975,7 +984,7 @@ const he = {
reupload: 'העלה שוב', reupload: 'העלה שוב',
upload: 'העלאה', upload: 'העלאה',
files: 'קבצים', files: 'קבצים',
signature_is_too_small_please_redraw: 'החתימה קטנה מדי. אנא צייר מחדש.', signature_is_too_small_or_simple_please_redraw: 'החתימה קטנה או פשוטה מדי. אנא חתום מחדש.',
wait_countdown_seconds: 'המתן {countdown} שניות' wait_countdown_seconds: 'המתן {countdown} שניות'
} }
@@ -1074,7 +1083,7 @@ const nl = {
reupload: 'Opnieuw uploaden', reupload: 'Opnieuw uploaden',
upload: 'Uploaden', upload: 'Uploaden',
files: 'Bestanden', files: 'Bestanden',
signature_is_too_small_please_redraw: 'De handtekening is te klein. Teken deze opnieuw, alstublieft.', signature_is_too_small_or_simple_please_redraw: 'De handtekening is te klein of te eenvoudig. Teken opnieuw.',
wait_countdown_seconds: 'Wacht {countdown} seconden' wait_countdown_seconds: 'Wacht {countdown} seconden'
} }
@@ -1097,6 +1106,7 @@ const ar = {
select_a_reason: 'اختر سببًا', select_a_reason: 'اختر سببًا',
value_is_invalid: 'القيمة غير صالحة', value_is_invalid: 'القيمة غير صالحة',
verification_code_is_invalid: 'رمز التحقق غير صالح', verification_code_is_invalid: 'رمز التحقق غير صالح',
already_paid: 'تم الدفع بالفعل',
drawn_signature_on_a_touchscreen_device: 'توقيع مرسوم على جهاز بشاشة تعمل باللمس', drawn_signature_on_a_touchscreen_device: 'توقيع مرسوم على جهاز بشاشة تعمل باللمس',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'امسح رمز الاستجابة السريعة باستخدام تطبيق الكاميرا لفتح النموذج على الهاتف المحمول ورسم توقيعك', scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'امسح رمز الاستجابة السريعة باستخدام تطبيق الكاميرا لفتح النموذج على الهاتف المحمول ورسم توقيعك',
by_clicking_you_agree_to_the: 'بالنقر فوق "{button}"، أنت توافق على', by_clicking_you_agree_to_the: 'بالنقر فوق "{button}"، أنت توافق على',
@@ -1172,7 +1182,7 @@ const ar = {
reupload: 'إعادة التحميل', reupload: 'إعادة التحميل',
upload: 'تحميل', upload: 'تحميل',
files: 'الملفات', files: 'الملفات',
signature_is_too_small_please_redraw: 'التوقيع صغير جدًا. يرجى إعادة الرسم.', signature_is_too_small_or_simple_please_redraw: 'التوقيع صغير جدًا أو بسيط جدًا. يرجى إعادة رسمه.',
wait_countdown_seconds: 'انتظر {countdown} ثانية' wait_countdown_seconds: 'انتظر {countdown} ثانية'
} }
@@ -1198,6 +1208,8 @@ const ko = {
by_clicking_you_agree_to_the: '"{button}"를 클릭함으로써, 다음에 동의하게 됩니다', by_clicking_you_agree_to_the: '"{button}"를 클릭함으로써, 다음에 동의하게 됩니다',
electronic_signature_disclosure: '전자 서명 공개', electronic_signature_disclosure: '전자 서명 공개',
esignature_disclosure: '전자 서명 공개', esignature_disclosure: '전자 서명 공개',
value_is_invalid: '값이 올바르지 않습니다',
verification_code_is_invalid: '인증 코드가 올바르지 않습니다',
already_paid: '이미 지불됨', already_paid: '이미 지불됨',
text: '텍스트', text: '텍스트',
signature: '서명', signature: '서명',
@@ -1269,10 +1281,109 @@ const ko = {
reupload: '다시 업로드', reupload: '다시 업로드',
upload: '업로드', upload: '업로드',
files: '파일', files: '파일',
signature_is_too_small_please_redraw: '서명이 너무 작습니다. 다시 그려주세요.', signature_is_too_small_or_simple_please_redraw: '서명이 너무 작거나 단순합니다. 다시 그려주세요.',
wait_countdown_seconds: '{countdown}초 기다리세요' wait_countdown_seconds: '{countdown}초 기다리세요'
} }
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar, ko } const ja = {
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
verify_id: '本人確認',
identity_verification: '本人確認',
complete: '完了',
fill_all_required_fields_to_complete: '完了するにはすべての必須項目を入力してください',
sign_and_complete: '署名して完了',
text: 'テキスト',
by_clicking_you_agree_to_the: '"{button}" をクリックすることで、次に同意したことになります:',
electronic_signature_disclosure: '電子署名に関する開示',
esignature_disclosure: '電子署名開示',
signature: '署名',
initials: 'イニシャル',
drawn_signature_on_a_touchscreen_device: 'タッチスクリーンデバイスで描かれた署名',
approved: '承認済み',
reviewed: '確認済み',
other: 'その他',
authored_by_me: '自分が作成',
invite: '招待',
email: 'メール',
approved_by: '承認者',
reviewed_by: '確認者',
authored_by: '作成者',
select_a_reason: '理由を選択',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'QRコードをカメラアプリで読み取り、モバイルでフォームを開いて署名を描いてください',
date: '日付',
number: '数値',
value_is_invalid: '無効な値です',
verification_code_is_invalid: '認証コードが無効です',
already_paid: 'すでに支払い済み',
image: '画像',
pay: '支払う',
take_photo: '写真を撮る',
number_phone_is_invalid: '{number} の電話番号は無効です',
file: 'ファイル',
digitally_signed_by: '電子署名者:',
reason: '理由',
select: '選択',
checkbox: 'チェックボックス',
multiple: '複数選択',
radio: 'ラジオボタン',
cells: 'セル',
stamp: 'スタンプ',
minimize: '最小化',
payment: '支払い',
phone: '電話',
start_now: '今すぐ開始',
continue: '続行',
sign_now: '今すぐ署名',
type_here_: 'ここに入力...',
optional: '任意',
option: 'オプション',
appears_on: '表示ページ',
page: 'ページ',
select_your_option: 'オプションを選択',
complete_hightlighted_checkboxes_and_click: 'ハイライトされたチェックボックスを完了し、クリックしてください',
submit: '送信',
next: '次へ',
click_to_upload: 'クリックしてアップロード',
or_drag_and_drop_files: 'またはファイルをドラッグ&ドロップ',
send_copy_via_email: 'メールでコピーを送信',
download: 'ダウンロード',
clear: 'クリア',
redraw: '描き直す',
draw_initials: 'イニシャルを描く',
type_signature_here: 'ここに署名を入力',
type_initial_here: 'ここにイニシャルを入力',
form_has_been_completed: 'フォームが完了しました!',
document_has_been_signed: 'ドキュメントが署名されました!',
documents_have_been_signed: 'ドキュメントがすべて署名されました!',
create_a_free_account: '無料アカウントを作成',
powered_by: '提供元:',
please_check_the_box_to_continue: '続行するにはボックスにチェックを入れてください。',
open_source_documents_software: 'オープンソースのドキュメントソフトウェア',
verified_phone_number: '電話番号を認証',
use_international_format: '国際形式を使用してください:+1xxx',
six_digits_code: '6桁のコード',
change_phone_number: '電話番号を変更',
sending: '送信中...',
resend_code: 'コードを再送信',
verification_code_has_been_resent: '認証コードがSMSで再送信されました',
please_fill_all_required_fields: 'すべての必須項目を入力してください',
set_today: '今日の日付を設定',
toggle_multiline_text: '複数行テキスト切り替え',
draw_signature: '署名を描く',
type_initial: 'イニシャルを入力',
draw: '描く',
type: '入力',
type_text: 'テキストを入力',
email_has_been_sent: 'メールが送信されました',
processing: '処理中',
pay_with_strip: 'Stripeで支払う',
reupload: '再アップロード',
upload: 'アップロード',
files: 'ファイル',
signature_is_too_small_or_simple_please_redraw: '署名が小さすぎるか単純すぎます。もう一度描いてください。',
wait_countdown_seconds: '{countdown} 秒お待ちください'
}
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar, ko, ja }
export default i18n export default i18n
@@ -118,11 +118,17 @@
:src="attachmentsIndex[modelValue || computedPreviousValue].url" :src="attachmentsIndex[modelValue || computedPreviousValue].url"
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"
> >
<canvas <div class="relative">
v-show="!modelValue && !computedPreviousValue" <div
ref="canvas" v-if="!isDrawInitials"
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas" class="absolute top-0 right-0 left-0 bottom-0"
/> />
<canvas
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
/>
</div>
<input <input
v-if="!isDrawInitials && !modelValue && !computedPreviousValue" v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
id="initials_text_input" id="initials_text_input"
@@ -333,8 +339,8 @@ export default {
return Promise.resolve({}) return Promise.resolve({})
} }
return new Promise((resolve) => { return new Promise((resolve, reject) => {
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => { cropCanvasAndExportToPNG(this.$refs.canvas, { errorOnTooSmall: true }).then(async (blob) => {
const file = new File([blob], 'initials.png', { type: 'image/png' }) const file = new File([blob], 'initials.png', { type: 'image/png' })
if (this.dryRun) { if (this.dryRun) {
@@ -367,6 +373,14 @@ export default {
return resolve(attachment) return resolve(attachment)
}) })
} }
}).catch((error) => {
if (this.field.required === true) {
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
return reject(error)
} else {
return resolve({})
}
}) })
}) })
} }
@@ -292,6 +292,7 @@
<script> <script>
import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2, IconQrcode, IconX } from '@tabler/icons-vue' import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2, IconQrcode, IconX } from '@tabler/icons-vue'
import { cropCanvasAndExportToPNG } from './crop_canvas' import { cropCanvasAndExportToPNG } from './crop_canvas'
import { isValidSignatureCanvas } from './validate_signature'
import SignaturePad from 'signature_pad' import SignaturePad from 'signature_pad'
import AppearsOn from './appears_on' import AppearsOn from './appears_on'
import FileDropzone from './dropzone' import FileDropzone from './dropzone'
@@ -598,6 +599,7 @@ export default {
}, },
drawImage (event) { drawImage (event) {
this.remove() this.remove()
this.clear()
this.isSignatureStarted = true this.isSignatureStarted = true
this.drawOnCanvas(event.target.files[0], this.$refs.canvas) this.drawOnCanvas(event.target.files[0], this.$refs.canvas)
@@ -680,6 +682,16 @@ export default {
return Promise.resolve({}) return Promise.resolve({})
} }
if (this.isSignatureStarted && this.pad.toData().length > 0 && !isValidSignatureCanvas(this.pad.toData())) {
if (this.field.required === true || this.pad.toData().length > 0) {
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
return Promise.reject(new Error('Image too small or simple'))
} else {
Promise.resolve({})
}
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
cropCanvasAndExportToPNG(this.$refs.canvas, { errorOnTooSmall: true }).then(async (blob) => { cropCanvasAndExportToPNG(this.$refs.canvas, { errorOnTooSmall: true }).then(async (blob) => {
const file = new File([blob], 'signature.png', { type: 'image/png' }) const file = new File([blob], 'signature.png', { type: 'image/png' })
@@ -718,10 +730,12 @@ export default {
}) })
} }
}).catch((error) => { }).catch((error) => {
if (error.message === 'Image too small' && this.field.required === false) { if (this.field.required === true) {
return resolve({}) alert(this.t('signature_is_too_small_or_simple_please_redraw'))
} else {
return reject(error) return reject(error)
} else {
return resolve({})
} }
}) })
}) })
@@ -0,0 +1,38 @@
function isValidSignatureCanvas (data) {
if (data.length === 0) return false
const strokes = data.filter(stroke => Array.isArray(stroke.points) && stroke.points.length > 2)
if (strokes.length === 0) return false
let skippedStraightLine = 0
const validStrokes = strokes.filter(stroke => {
const points = stroke.points
const first = points[0]
const last = points[points.length - 1]
const A = last.y - first.y
const B = first.x - last.x
const C = last.x * first.y - first.x * last.y
const lineLength = Math.sqrt(A * A + B * B)
const totalDeviation = points.reduce((sum, p) => {
const distanceToLine = Math.abs(A * p.x + B * p.y + C) / lineLength
return sum + distanceToLine
}, 0)
const avgDeviation = totalDeviation / points.length
if (avgDeviation < 3 && skippedStraightLine < 2) {
skippedStraightLine++
return false
}
return true
})
return validStrokes.length > 0
}
export { isValidSignatureCanvas }
+7 -4
View File
@@ -160,15 +160,15 @@
</div> </div>
<div <div
ref="touchValueTarget" ref="touchValueTarget"
class="flex items-center h-full w-full field-area" class="flex h-full w-full field-area"
dir="auto" dir="auto"
:class="[isValueInput ? 'bg-opacity-50' : 'bg-opacity-80', field.type === 'heading' ? 'bg-gray-50' : bgColors[submitterIndex % bgColors.length], isDefaultValuePresent || isValueInput || (withFieldPlaceholder && field.areas) ? fontClasses : 'justify-center']" :class="[isValueInput ? 'bg-opacity-50' : 'bg-opacity-80', field.type === 'heading' ? 'bg-gray-50' : bgColors[submitterIndex % bgColors.length], isDefaultValuePresent || isValueInput || (withFieldPlaceholder && field.areas) ? fontClasses : 'justify-center items-center']"
@click="focusValueInput" @click="focusValueInput"
> >
<span <span
v-if="field" v-if="field"
class="flex justify-center items-center space-x-1" class="flex justify-center items-center space-x-1"
:class="{ 'w-full': ['cells', 'checkbox'].includes(field.type), 'h-full': !isValueInput }" :class="{ 'w-full': ['cells', 'checkbox'].includes(field.type), 'h-full': !isValueInput && !isDefaultValuePresent }"
> >
<div <div
v-if="isDefaultValuePresent || isValueInput || (withFieldPlaceholder && field.areas && field.type !== 'checkbox')" v-if="isDefaultValuePresent || isValueInput || (withFieldPlaceholder && field.areas && field.type !== 'checkbox')"
@@ -403,10 +403,13 @@ export default {
}, },
fontClasses () { fontClasses () {
if (!this.field.preferences) { if (!this.field.preferences) {
return {} return { 'items-center': true }
} }
return { return {
'items-center': !this.field.preferences.valign || this.field.preferences.valign === 'center',
'items-start': this.field.preferences.valign === 'top',
'items-end': this.field.preferences.valign === 'bottom',
'justify-center': this.field.preferences.align === 'center', 'justify-center': this.field.preferences.align === 'center',
'justify-start': this.field.preferences.align === 'left', 'justify-start': this.field.preferences.align === 'left',
'justify-end': this.field.preferences.align === 'right', 'justify-end': this.field.preferences.align === 'right',
+170 -58
View File
@@ -1,9 +1,31 @@
<template> <template>
<div <div
ref="dragContainer"
style="max-width: 1600px" style="max-width: 1600px"
class="mx-auto pl-3 h-full" class="mx-auto pl-3 h-full"
:class="isMobile ? 'pl-4' : 'md:pl-4'" :class="isMobile ? 'pl-4' : 'md:pl-4'"
@dragover="onDragover"
@drop="isDragFile = false"
> >
<HoverDropzone
v-if="sortedDocuments.length && withUploadButton && editable"
:is-dragging="isDragFile"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
:with-replace-and-clone="withReplaceAndCloneUpload"
hover-class="bg-base-200/50"
@add="[updateFromUpload($event), isDragFile = false]"
@replace="[onDocumentsReplace($event), isDragFile = false]"
@replace-and-clone="onDocumentsReplaceAndTemplateClone($event)"
@error="[onUploadFailed($event), isDragFile = false]"
/>
<DragPlaceholder
ref="dragPlaceholder"
:field="fieldsDragFieldRef.value || toRaw(dragField)"
:is-field="template.fields.includes(fieldsDragFieldRef.value)"
:is-default="defaultFields.includes(toRaw(dragField))"
:is-required="defaultRequiredFields.includes(toRaw(dragField))"
/>
<div <div
v-if="pendingFieldAttachmentUuids.length && editable" v-if="pendingFieldAttachmentUuids.length && editable"
class="top-1.5 sticky h-0 z-20 max-w-2xl mx-auto" class="top-1.5 sticky h-0 z-20 max-w-2xl mx-auto"
@@ -60,6 +82,7 @@
<template v-else> <template v-else>
<a <a
v-if="withSignYourselfButton" v-if="withSignYourselfButton"
id="sign_yourself_button"
:href="template.submitters.length > 1 ? `/templates/${template.id}/submissions/new?selfsign=true` : `/d/${template.slug}`" :href="template.submitters.length > 1 ? `/templates/${template.id}/submissions/new?selfsign=true` : `/d/${template.slug}`"
class="btn btn-primary btn-ghost text-base hidden md:flex" class="btn btn-primary btn-ghost text-base hidden md:flex"
:target="template.submitters.length > 1 ? '' : '_blank'" :target="template.submitters.length > 1 ? '' : '_blank'"
@@ -76,6 +99,7 @@
</a> </a>
<a <a
v-if="withSendButton" v-if="withSendButton"
id="send_button"
:href="`/templates/${template.id}/submissions/new?with_link=true`" :href="`/templates/${template.id}/submissions/new?with_link=true`"
data-turbo-frame="modal" data-turbo-frame="modal"
class="white-button md:!px-6" class="white-button md:!px-6"
@@ -91,6 +115,7 @@
</a> </a>
<span <span
v-if="editable" v-if="editable"
id="save_button_container"
class="flex" class="flex"
> >
<button <button
@@ -269,6 +294,7 @@
:allow-draw="!onlyDefinedFields || drawField" :allow-draw="!onlyDefinedFields || drawField"
:data-document-uuid="document.uuid" :data-document-uuid="document.uuid"
:default-submitters="defaultSubmitters" :default-submitters="defaultSubmitters"
:drag-field-placeholder="fieldsDragFieldRef.value || dragField"
:with-field-placeholder="withFieldPlaceholder" :with-field-placeholder="withFieldPlaceholder"
:draw-field="drawField" :draw-field="drawField"
:draw-field-type="drawFieldType" :draw-field-type="drawFieldType"
@@ -374,12 +400,14 @@
:with-sticky-submitters="withStickySubmitters" :with-sticky-submitters="withStickySubmitters"
:only-defined-fields="onlyDefinedFields" :only-defined-fields="onlyDefinedFields"
:editable="editable" :editable="editable"
:show-tour-start-form="showTourStartForm"
@add-field="addField" @add-field="addField"
@set-draw="[drawField = $event.field, drawOption = $event.option]" @set-draw="[drawField = $event.field, drawOption = $event.option]"
@set-draw-type="[drawFieldType = $event, showDrawField = true]" @set-draw-type="[drawFieldType = $event, showDrawField = true]"
@set-drag="dragField = $event" @set-drag="dragField = $event"
@set-drag-placeholder="$refs.dragPlaceholder.dragPlaceholder = $event"
@change-submitter="selectedSubmitter = $event" @change-submitter="selectedSubmitter = $event"
@drag-end="dragField = null" @drag-end="[dragField = null, $refs.dragPlaceholder.dragPlaceholder = null]"
@scroll-to-area="scrollToArea" @scroll-to-area="scrollToArea"
/> />
</div> </div>
@@ -418,6 +446,8 @@
<script> <script>
import Upload from './upload' import Upload from './upload'
import Dropzone from './dropzone' import Dropzone from './dropzone'
import HoverDropzone from './hover_dropzone'
import DragPlaceholder from './drag_placeholder'
import Fields from './fields' import Fields from './fields'
import MobileDrawField from './mobile_draw_field' import MobileDrawField from './mobile_draw_field'
import Document from './document' import Document from './document'
@@ -429,13 +459,14 @@ import MobileFields from './mobile_fields'
import FieldSubmitter from './field_submitter' import FieldSubmitter from './field_submitter'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments } from '@tabler/icons-vue' import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments } from '@tabler/icons-vue'
import { v4 } from 'uuid' import { v4 } from 'uuid'
import { ref, computed } from 'vue' import { ref, computed, toRaw } from 'vue'
import * as i18n from './i18n' import * as i18n from './i18n'
export default { export default {
name: 'TemplateBuilder', name: 'TemplateBuilder',
components: { components: {
Upload, Upload,
DragPlaceholder,
Document, Document,
Fields, Fields,
IconInfoCircle, IconInfoCircle,
@@ -445,6 +476,7 @@ export default {
MobileFields, MobileFields,
Logo, Logo,
Dropzone, Dropzone,
HoverDropzone,
DocumentPreview, DocumentPreview,
DocumentControls, DocumentControls,
IconInnerShadowTop, IconInnerShadowTop,
@@ -460,6 +492,7 @@ export default {
template: this.template, template: this.template,
save: this.save, save: this.save,
t: this.t, t: this.t,
assignDropAreaSize: this.assignDropAreaSize,
currencies: this.currencies, currencies: this.currencies,
locale: this.locale, locale: this.locale,
baseFetch: this.baseFetch, baseFetch: this.baseFetch,
@@ -647,6 +680,11 @@ export default {
required: false, required: false,
default: true default: true
}, },
withReplaceAndCloneUpload: {
type: Boolean,
required: false,
default: false
},
withPhone: { withPhone: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -686,6 +724,11 @@ export default {
type: Object, type: Object,
required: false, required: false,
default: () => ({ headers: {} }) default: () => ({ headers: {} })
},
showTourStartForm: {
type: Boolean,
required: false,
default: false
} }
}, },
data () { data () {
@@ -701,7 +744,8 @@ export default {
copiedArea: null, copiedArea: null,
drawFieldType: null, drawFieldType: null,
drawOption: null, drawOption: null,
dragField: null dragField: null,
isDragFile: false
} }
}, },
computed: { computed: {
@@ -813,6 +857,7 @@ export default {
window.addEventListener('keydown', this.onKeyDown) window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('resize', this.onWindowResize) window.addEventListener('resize', this.onWindowResize)
window.addEventListener('dragleave', this.onWindowDragLeave)
this.$nextTick(() => { this.$nextTick(() => {
if (document.location.search?.includes('stripe_connect_success')) { if (document.location.search?.includes('stripe_connect_success')) {
@@ -831,11 +876,30 @@ export default {
window.removeEventListener('keydown', this.onKeyDown) window.removeEventListener('keydown', this.onKeyDown)
window.removeEventListener('resize', this.onWindowResize) window.removeEventListener('resize', this.onWindowResize)
window.removeEventListener('dragleave', this.onWindowDragLeave)
}, },
beforeUpdate () { beforeUpdate () {
this.documentRefs = [] this.documentRefs = []
}, },
methods: { methods: {
toRaw,
onDragover (e) {
if (this.$refs.dragPlaceholder?.dragPlaceholder) {
this.$refs.dragPlaceholder.isMask = e.target.id === 'mask'
const ref = this.$refs.dragPlaceholder.dragPlaceholder
ref.x = e.clientX - ref.offsetX
ref.y = e.clientY - ref.offsetY
} else if (e.dataTransfer?.types?.includes('Files')) {
this.isDragFile = true
}
},
onWindowDragLeave (event) {
if (event.clientX <= 0 || event.clientY <= 0 || event.clientX >= window.innerWidth || event.clientY >= window.innerHeight) {
this.isDragFile = false
}
},
reorderFields (item) { reorderFields (item) {
const itemFields = [] const itemFields = []
const fields = [] const fields = []
@@ -1319,6 +1383,10 @@ export default {
} }
}, },
onDropfield (area) { onDropfield (area) {
if (this.$refs.dragPlaceholder) {
this.$refs.dragPlaceholder.dragPlaceholder = null
}
if (!this.editable) { if (!this.editable) {
return return
} }
@@ -1331,6 +1399,10 @@ export default {
...this.dragField ...this.dragField
} }
if (!field.type) {
field.type = 'text'
}
if (!this.fieldsDragFieldRef.value) { if (!this.fieldsDragFieldRef.value) {
if (['select', 'multiple', 'radio'].includes(field.type)) { if (['select', 'multiple', 'radio'].includes(field.type)) {
if (this.dragField?.options?.length) { if (this.dragField?.options?.length) {
@@ -1357,70 +1429,18 @@ export default {
attachment_uuid: area.attachment_uuid attachment_uuid: area.attachment_uuid
} }
const previousField = [...this.template.fields].reverse().find((f) => f.type === field.type) this.assignDropAreaSize(fieldArea, field, area)
let baseArea
if (this.selectedField?.type === field.type) {
baseArea = this.selectedAreaRef.value
} else if (previousField?.areas?.length) {
baseArea = previousField.areas[previousField.areas.length - 1]
} else {
if (['checkbox'].includes(field.type)) {
baseArea = {
w: area.maskW / 30 / area.maskW,
h: area.maskW / 30 / area.maskW * (area.maskW / area.maskH)
}
} else if (field.type === 'image') {
baseArea = {
w: area.maskW / 5 / area.maskW,
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH)
}
} else if (field.type === 'signature' || field.type === 'stamp' || field.type === 'verification') {
baseArea = {
w: area.maskW / 5 / area.maskW,
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) / 2
}
} else if (field.type === 'initials') {
baseArea = {
w: area.maskW / 10 / area.maskW,
h: area.maskW / 35 / area.maskW
}
} else {
baseArea = {
w: area.maskW / 5 / area.maskW,
h: area.maskW / 35 / area.maskW
}
}
}
fieldArea.w = baseArea.w
fieldArea.h = baseArea.h
fieldArea.y = fieldArea.y - baseArea.h / 2
if (field.type === 'cells') {
fieldArea.cell_w = baseArea.cell_w || (baseArea.w / 5)
}
field.areas ||= []
const lastArea = field.areas[field.areas.length - 1]
if (lastArea) {
fieldArea.w = lastArea.w
fieldArea.h = lastArea.h
}
if (field.width) { if (field.width) {
fieldArea.w = field.width / area.maskW
delete field.width delete field.width
} }
if (field.height) { if (field.height) {
fieldArea.h = field.height / area.maskH
delete field.height delete field.height
} }
field.areas ||= []
field.areas.push(fieldArea) field.areas.push(fieldArea)
this.selectedAreaRef.value = fieldArea this.selectedAreaRef.value = fieldArea
@@ -1444,6 +1464,72 @@ export default {
}) })
} }
}, },
assignDropAreaSize (fieldArea, field, area) {
const fieldType = field.type || 'text'
const previousField = [...this.template.fields].reverse().find((f) => f.type === fieldType)
let baseArea
if (this.selectedField?.type === fieldType) {
baseArea = this.selectedAreaRef.value
} else if (previousField?.areas?.length) {
baseArea = previousField.areas[previousField.areas.length - 1]
} else {
if (['checkbox'].includes(fieldType)) {
baseArea = {
w: area.maskW / 30 / area.maskW,
h: area.maskW / 30 / area.maskW * (area.maskW / area.maskH)
}
} else if (fieldType === 'image') {
baseArea = {
w: area.maskW / 5 / area.maskW,
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH)
}
} else if (fieldType === 'signature' || fieldType === 'stamp' || fieldType === 'verification') {
baseArea = {
w: area.maskW / 5 / area.maskW,
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) / 2
}
} else if (fieldType === 'initials') {
baseArea = {
w: area.maskW / 10 / area.maskW,
h: area.maskW / 35 / area.maskW
}
} else {
baseArea = {
w: area.maskW / 5 / area.maskW,
h: area.maskW / 35 / area.maskW
}
}
}
fieldArea.w = baseArea.w
fieldArea.h = baseArea.h
if (fieldType === 'cells') {
fieldArea.cell_w = baseArea.cell_w || (baseArea.w / 5)
}
if (field.areas?.length) {
const lastArea = field.areas[field.areas.length - 1]
if (lastArea) {
fieldArea.w = lastArea.w
fieldArea.h = lastArea.h
}
}
if (field.width) {
fieldArea.w = field.width / area.maskW
}
if (field.height) {
fieldArea.h = field.height / area.maskH
}
fieldArea.y = fieldArea.y - fieldArea.h / 2
},
addBlankPage () { addBlankPage () {
this.isLoadingBlankPage = true this.isLoadingBlankPage = true
@@ -1473,6 +1559,9 @@ export default {
}) })
}, 'image/png') }, 'image/png')
}, },
onUploadFailed (error) {
if (error) alert(error)
},
updateFromUpload (data) { updateFromUpload (data) {
this.template.schema.push(...data.schema) this.template.schema.push(...data.schema)
this.template.documents.push(...data.documents) this.template.documents.push(...data.documents)
@@ -1593,6 +1682,29 @@ export default {
this.save() this.save()
}, },
onDocumentsReplace (data) {
data.schema.forEach((schemaItem, index) => {
const existingSchemaItem = this.template.schema[index]
if (this.template.schema[index]) {
this.onDocumentReplace({
replaceSchemaItem: existingSchemaItem,
schema: [schemaItem],
documents: [data.documents.find((doc) => doc.uuid === schemaItem.attachment_uuid)]
})
} else {
this.updateFromUpload({
schema: [schemaItem],
documents: [data.documents.find((doc) => doc.uuid === schemaItem.attachment_uuid)],
fields: data.fields,
submitters: data.submitters
})
}
})
},
onDocumentsReplaceAndTemplateClone (template) {
window.Turbo.visit(`/templates/${template.id}/edit`)
},
moveDocument (item, direction) { moveDocument (item, direction) {
const currentIndex = this.template.schema.indexOf(item) const currentIndex = this.template.schema.indexOf(item)
@@ -175,7 +175,7 @@ export default {
fields () { fields () {
if (this.item.submitter_uuid) { if (this.item.submitter_uuid) {
return this.template.fields.reduce((acc, f) => { return this.template.fields.reduce((acc, f) => {
if (f !== this.item) { if (f !== this.item && (!f.conditions?.length || !f.conditions.find((c) => c.field_uuid === this.item.uuid))) {
acc.push(f) acc.push(f)
} }
@@ -13,6 +13,7 @@
:is-drag="isDrag" :is-drag="isDrag"
:with-field-placeholder="withFieldPlaceholder" :with-field-placeholder="withFieldPlaceholder"
:default-fields="defaultFields" :default-fields="defaultFields"
:drag-field-placeholder="dragFieldPlaceholder"
:default-submitters="defaultSubmitters" :default-submitters="defaultSubmitters"
:draw-field="drawField" :draw-field="drawField"
:draw-field-type="drawFieldType" :draw-field-type="drawFieldType"
@@ -39,6 +40,11 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
dragFieldPlaceholder: {
type: Object,
required: false,
default: null
},
inputMode: { inputMode: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -0,0 +1,115 @@
<template>
<Field
v-if="dragPlaceholder && isField && !isMask && field"
ref="dragPlaceholder"
:style="dragPlaceholderStyle"
:field="field"
:with-options="false"
class="fixed z-20 pointer-events-none"
:editable="false"
/>
<div
v-else-if="dragPlaceholder && (isDefault || isRequired) && !isMask && field"
ref="dragPlaceholder"
:style="[dragPlaceholderStyle, { backgroundColor: backgroundColor }]"
class="fixed z-20 border border-base-300 rounded group default-field fields-list-item pointer-events-none"
>
<div class="flex items-center justify-between relative cursor-grab">
<div class="flex items-center p-1 space-x-1">
<IconDrag />
<component
:is="fieldIcons[field.type || 'text']"
:stroke-width="1.6"
:width="20"
/>
<span class="block pl-0.5">
{{ field.title || field.name }}
</span>
</div>
<span
v-if="isRequired"
:data-tip="t('required')"
class="text-red-400 text-3xl pr-1.5 tooltip tooltip-left h-8"
>
*
</span>
</div>
</div>
<button
v-else-if="dragPlaceholder && !isMask && field"
ref="dragPlaceholder"
class="fixed field-type-button z-20 flex items-center justify-center border border-dashed w-full rounded border-base-content/20 opacity-90 pointer-events-none"
:style="[dragPlaceholderStyle, { backgroundColor }]"
>
<div
class="flex items-console cursor-grab h-full absolute left-0 bg-base-200/50"
>
<IconDrag class="my-auto" />
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="fieldIcons[field.type || 'text']" />
<span class="text-xs mt-1">
{{ fieldNames[field.type || 'text'] }}
</span>
</div>
</button>
</template>
<script>
import Field from './field'
import IconDrag from './icon_drag'
import FieldType from './field_type'
export default {
name: 'DragPlaceholder',
components: {
Field,
IconDrag
},
inject: ['t', 'backgroundColor'],
props: {
field: {
type: Object,
required: false,
default: null
},
isDefault: {
type: Boolean,
required: false,
default: false
},
isRequired: {
type: Boolean,
required: false,
default: false
},
isField: {
type: Boolean,
required: false,
default: false
}
},
data () {
return {
isMask: false,
dragPlaceholder: null
}
},
computed: {
dragPlaceholderStyle () {
if (this.dragPlaceholder) {
return {
left: this.dragPlaceholder.x + 'px',
top: this.dragPlaceholder.y + 'px',
width: this.dragPlaceholder.w + 'px',
height: this.dragPlaceholder.h + 'px'
}
} else {
return {}
}
},
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons
}
}
</script>
+61 -15
View File
@@ -2,34 +2,42 @@
<div <div
class="flex h-60 w-full" class="flex h-60 w-full"
@dragover.prevent @dragover.prevent
@dragenter="isDragEntering = true"
@dragleave="isDragEntering = false"
@drop.prevent="onDropFiles" @drop.prevent="onDropFiles"
> >
<label <label
id="document_dropzone" id="document_dropzone"
class="w-full relative hover:bg-base-200/30 rounded-md border border-2 border-base-content/10 border-dashed" class="w-full relative rounded-md border-2 border-base-content/10 border-dashed"
:for="inputId" :for="inputId"
:class="{ 'opacity-50': isLoading || isProcessing }" :class="[{ 'opacity-50': isLoading, 'hover:bg-base-200/30': !hoverClass }, isDragEntering && hoverClass ? hoverClass : '']"
> >
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center"> <div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center pointer-events-none">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<IconInnerShadowTop <IconInnerShadowTop
v-if="isLoading || isProcessing" v-if="isLoading"
class="animate-spin" class="animate-spin"
:width="40" :width="40"
:height="40" :height="40"
/> />
<IconCloudUpload <component
:is="icon"
v-else v-else
class="stroke-[1.5px]"
:width="40" :width="40"
:height="40" :height="40"
/> />
<div <div
v-if="message" v-if="message"
class="font-medium text-lg mb-1" class="text-lg mb-1"
:class="{ 'mt-1': !withDescription, 'font-medium': withDescription }"
> >
{{ message }} {{ message }}
</div> </div>
<div class="text-sm"> <div
v-if="withDescription"
class="text-sm"
>
<span class="font-medium">{{ t('click_to_upload') }}</span> {{ t('or_drag_and_drop_files') }} <span class="font-medium">{{ t('click_to_upload') }}</span> {{ t('or_drag_and_drop_files') }}
</div> </div>
</div> </div>
@@ -54,13 +62,16 @@
<script> <script>
import Upload from './upload' import Upload from './upload'
import { IconCloudUpload, IconInnerShadowTop } from '@tabler/icons-vue' import { IconCloudUpload, IconFilePlus, IconFileSymlink, IconFiles, IconInnerShadowTop } from '@tabler/icons-vue'
export default { export default {
name: 'FileDropzone', name: 'FileDropzone',
components: { components: {
IconFilePlus,
IconCloudUpload, IconCloudUpload,
IconInnerShadowTop IconInnerShadowTop,
IconFileSymlink,
IconFiles
}, },
inject: ['baseFetch', 't'], inject: ['baseFetch', 't'],
props: { props: {
@@ -68,35 +79,70 @@ export default {
type: [Number, String], type: [Number, String],
required: true required: true
}, },
icon: {
type: String,
required: false,
default: 'IconCloudUpload'
},
hoverClass: {
type: String,
required: false,
default: null
},
cloneTemplateOnUpload: {
type: Boolean,
required: false,
default: false
},
withDescription: {
type: Boolean,
required: false,
default: true
},
title: {
type: String,
required: false,
default: ''
},
acceptFileTypes: { acceptFileTypes: {
type: String, type: String,
required: false, required: false,
default: 'image/*, application/pdf' default: 'image/*, application/pdf'
} }
}, },
emits: ['success'], emits: ['success', 'error', 'loading'],
data () { data () {
return { return {
isLoading: false, isLoading: false,
isProcessing: false isDragEntering: false
} }
}, },
computed: { computed: {
inputId () { inputId () {
return 'el' + Math.random().toString(32).split('.')[1] return 'el' + Math.random().toString(32).split('.')[1]
}, },
uploadUrl () {
if (this.cloneTemplateOnUpload) {
return `/templates/${this.templateId}/clone_and_replace`
} else {
return `/templates/${this.templateId}/documents`
}
},
message () { message () {
if (this.isLoading) { if (this.isLoading) {
return this.t('uploading') return this.t('uploading')
} else if (this.isProcessing) {
return this.t('processing_')
} else if (this.acceptFileTypes === 'image/*, application/pdf') { } else if (this.acceptFileTypes === 'image/*, application/pdf') {
return this.t('add_pdf_documents_or_images') return this.title || this.t('add_pdf_documents_or_images')
} else { } else {
return this.t('add_documents_or_images') return this.title || this.t('add_documents_or_images')
} }
} }
}, },
watch: {
isLoading (value) {
this.$emit('loading', value)
}
},
methods: { methods: {
upload: Upload.methods.upload, upload: Upload.methods.upload,
onDropFiles (e) { onDropFiles (e) {
+7 -2
View File
@@ -3,7 +3,7 @@
class="list-field group mb-2" class="list-field group mb-2"
> >
<div <div
class="border border-base-300 rounded rounded-tr-none relative group fields-list-item" class="border border-base-300 rounded relative group fields-list-item"
:style="{ backgroundColor: backgroundColor }" :style="{ backgroundColor: backgroundColor }"
> >
<div class="flex items-center justify-between relative group/contenteditable-container"> <div class="flex items-center justify-between relative group/contenteditable-container">
@@ -149,7 +149,7 @@
</div> </div>
</div> </div>
<div <div
v-if="field.options" v-if="field.options && withOptions"
ref="options" ref="options"
class="border-t border-base-300 mx-2 pt-2 space-y-1.5" class="border-t border-base-300 mx-2 pt-2 space-y-1.5"
draggable="true" draggable="true"
@@ -302,6 +302,11 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
withOptions: {
type: Boolean,
required: false,
default: true
},
defaultField: { defaultField: {
type: Object, type: Object,
required: false, required: false,
@@ -30,7 +30,7 @@
v-for="(icon, type) in fieldIconsSorted" v-for="(icon, type) in fieldIconsSorted"
:key="type" :key="type"
> >
<li v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification')"> <li v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification'))">
<a <a
href="#" href="#"
class="text-sm py-1 px-2" class="text-sm py-1 px-2"
+79 -21
View File
@@ -25,11 +25,11 @@
:data-uuid="field.uuid" :data-uuid="field.uuid"
:field="field" :field="field"
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)" :type-index="fields.filter((f) => f.type === field.type).indexOf(field)"
:editable="editable && (!fieldsDragFieldRef.value || fieldsDragFieldRef.value !== field)" :editable="editable"
:default-field="defaultFieldsIndex[field.name]" :default-field="defaultFieldsIndex[field.name]"
:draggable="editable" :draggable="editable"
@dragstart="fieldsDragFieldRef.value = field" @dragstart="[fieldsDragFieldRef.value = field, removeDragOverlay($event), setDragPlaceholder($event)]"
@dragend="fieldsDragFieldRef.value = null" @dragend="[fieldsDragFieldRef.value = null, $emit('set-drag-placeholder', null)]"
@remove="removeField" @remove="removeField"
@scroll-to="$emit('scroll-to-area', $event)" @scroll-to="$emit('scroll-to-area', $event)"
@set-draw="$emit('set-draw', $event)" @set-draw="$emit('set-draw', $event)"
@@ -74,8 +74,8 @@
<div <div
:style="{ backgroundColor }" :style="{ backgroundColor }"
draggable="true" draggable="true"
class="border border-base-300 rounded rounded-tr-none relative group mb-2 default-field fields-list-item" class="border border-base-300 rounded relative group mb-2 default-field fields-list-item"
@dragstart="onDragstart({ type: 'text', ...field })" @dragstart="onDragstart($event, field)"
@dragend="$emit('drag-end')" @dragend="$emit('drag-end')"
> >
<div class="flex items-center justify-between relative cursor-grab"> <div class="flex items-center justify-between relative cursor-grab">
@@ -104,6 +104,7 @@
</div> </div>
<div <div
v-if="editable && !onlyDefinedFields" v-if="editable && !onlyDefinedFields"
id="field-types-grid"
class="grid grid-cols-3 gap-1 pb-2 fields-grid" class="grid grid-cols-3 gap-1 pb-2 fields-grid"
> >
<template <template
@@ -111,12 +112,13 @@
:key="type" :key="type"
> >
<button <button
v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification')" v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification'))"
:id="`${type}_type_field_button`"
draggable="true" draggable="true"
class="field-type-button group flex items-center justify-center border border-dashed w-full rounded relative fields-grid-item" class="field-type-button group flex items-center justify-center border border-dashed w-full rounded relative fields-grid-item"
:style="{ backgroundColor }" :style="{ backgroundColor }"
:class="drawFieldType === type ? 'border-base-content/40' : 'border-base-300 hover:border-base-content/20'" :class="drawFieldType === type ? 'border-base-content/40' : 'border-base-300 hover:border-base-content/20'"
@dragstart="onDragstart({ type: type })" @dragstart="onDragstart($event, { type: type })"
@dragend="$emit('drag-end')" @dragend="$emit('drag-end')"
@click="['file', 'payment', 'verification'].includes(type) ? $emit('add-field', type) : $emit('set-draw-type', type)" @click="['file', 'payment', 'verification'].includes(type) ? $emit('add-field', type) : $emit('set-draw-type', type)"
> >
@@ -166,7 +168,7 @@
:data-tip="t('obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more')" :data-tip="t('obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more')"
> >
<a <a
href="https://www.docuseal.com/contact" href="https://www.docuseal.com/qualified-electronic-signature"
target="_blank" target="_blank"
class="opacity-50 flex items-center justify-center border border-dashed border-base-300 w-full rounded relative fields-grid-item" class="opacity-50 flex items-center justify-center border border-dashed border-base-300 w-full rounded relative fields-grid-item"
:style="{ backgroundColor }" :style="{ backgroundColor }"
@@ -189,7 +191,7 @@
</template> </template>
</div> </div>
<div <div
v-if="fields.length < 4 && editable && withHelp" v-if="fields.length < 4 && editable && withHelp && !showTourStartForm"
class="text-xs p-2 border border-base-200 rounded" class="text-xs p-2 border border-base-200 rounded"
> >
<ul class="list-disc list-outside ml-3"> <ul class="list-disc list-outside ml-3">
@@ -204,6 +206,23 @@
</li> </li>
</ul> </ul>
</div> </div>
<div
v-show="fields.length < 4 && editable && withHelp && showTourStartForm"
class="rounded py-2 px-4 w-full border border-dashed border-base-300"
>
<div class="text-center text-sm">
{{ t('start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document') }}
</div>
<div class="flex justify-center">
<label
for="start_tour_button"
class="btn btn-sm btn-warning w-40 mt-2"
@click="startTour"
>
{{ t('start_tour') }}
</label>
</div>
</div>
</template> </template>
<script> <script>
@@ -290,9 +309,14 @@ export default {
selectedSubmitter: { selectedSubmitter: {
type: Object, type: Object,
required: true required: true
},
showTourStartForm: {
type: Boolean,
required: false,
default: false
} }
}, },
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter'], emits: ['add-field', 'set-draw', 'set-draw-type', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder'],
data () { data () {
return { return {
defaultFieldsSearch: '' defaultFieldsSearch: ''
@@ -343,22 +367,56 @@ export default {
} }
}, },
methods: { methods: {
onDragstart (field) { onDragstart (event, field) {
this.removeDragOverlay(event)
this.setDragPlaceholder(event)
this.$emit('set-drag', field) this.$emit('set-drag', field)
}, },
setDragPlaceholder (event) {
this.$emit('set-drag-placeholder', {
offsetX: event.offsetX,
offsetY: event.offsetY,
x: event.clientX - event.offsetX,
y: event.clientY - event.offsetY,
w: event.currentTarget.clientWidth + 2,
h: event.currentTarget.clientHeight + 2
})
},
removeDragOverlay (event) {
const root = this.$el.getRootNode()
const hiddenEl = document.createElement('div')
hiddenEl.style.width = '1px'
hiddenEl.style.height = '1px'
hiddenEl.style.opacity = '0'
hiddenEl.style.position = 'fixed'
root.querySelector('#docuseal_modal_container').appendChild(hiddenEl)
event.dataTransfer.setDragImage(hiddenEl, 0, 0)
setTimeout(() => { hiddenEl.remove() }, 1000)
},
startTour () {
document.querySelector('app-tour').start()
},
onFieldDragover (e) { onFieldDragover (e) {
const targetField = e.target.closest('[data-uuid]') if (this.fieldsDragFieldRef.value) {
const dragField = this.$refs.fields.querySelector(`[data-uuid="${this.fieldsDragFieldRef.value.uuid}"]`) const targetField = e.target.closest('[data-uuid]')
const dragField = this.$refs.fields.querySelector(`[data-uuid="${this.fieldsDragFieldRef.value.uuid}"]`)
if (dragField && targetField && targetField !== dragField) { if (dragField && targetField && targetField !== dragField) {
const fields = Array.from(this.$refs.fields.children) const fields = Array.from(this.$refs.fields.children)
const currentIndex = fields.indexOf(dragField) const currentIndex = fields.indexOf(dragField)
const targetIndex = fields.indexOf(targetField) const targetIndex = fields.indexOf(targetField)
if (currentIndex < targetIndex) { if (currentIndex < targetIndex) {
targetField.after(dragField) targetField.after(dragField)
} else { } else {
targetField.before(dragField) targetField.before(dragField)
}
} }
} }
}, },
+44 -8
View File
@@ -57,7 +57,7 @@
<span class="relative"> <span class="relative">
<select <select
class="select input-bordered bg-white select-sm text-center pl-2" class="select input-bordered bg-white select-sm text-center pl-2"
style="font-size: 16px; width: 86px; text-align-last: center;" style="font-size: 16px; line-height: 12px; width: 86px; text-align-last: center;"
@change="$event.target.value ? preferences.font_size = parseInt($event.target.value) : delete preferences.font_size" @change="$event.target.value ? preferences.font_size = parseInt($event.target.value) : delete preferences.font_size"
> >
<option <option
@@ -77,12 +77,12 @@
</select> </select>
<span <span
class="border-l pl-1.5 absolute bg-white bottom-0 pointer-events-none text-sm h-5" class="border-l pl-1.5 absolute bg-white bottom-0 pointer-events-none text-sm h-5"
style="right: 13px; top: 7px" style="right: 13px; top: 6px"
> >
pt pt
</span> </span>
</span> </span>
<span> <span class="flex">
<div <div
class="join" class="join"
style="height: 32px" style="height: 32px"
@@ -98,7 +98,7 @@
</button> </button>
</div> </div>
</span> </span>
<span> <span class="flex">
<div <div
class="join" class="join"
style="height: 32px" style="height: 32px"
@@ -114,6 +114,32 @@
</button> </button>
</div> </div>
</span> </span>
<span class="flex">
<div class="dropdown modal-field-font-dropdown">
<label
tabindex="0"
class="cursor-pointer flex bg-white border input-bordered rounded-md h-8 items-center justify-center px-1"
style="-webkit-appearance: none; -moz-appearance: none;"
>
<component :is="valigns.find((v) => v.value === (preferences.valign || 'center'))?.icon" />
</label>
<div
tabindex="0"
class="dropdown-content p-0 mt-1 block z-10 menu shadow bg-white border border-base-300 rounded-md"
>
<div
v-for="(valign, index) in valigns"
:key="index"
:value="valign.value"
:class="{ 'bg-base-300': preferences.valign == valign.value }"
class="hover:bg-base-300 px-2 py-1.5 cursor-pointer"
@click="[valign.value ? preferences.valign = valign.value : delete preferences.valign, closeDropdown()]"
>
<component :is="valign.icon" />
</div>
</div>
</div>
</span>
<span> <span>
<select <select
class="input input-bordered bg-white input-sm text-lg rounded-md" class="input input-bordered bg-white input-sm text-lg rounded-md"
@@ -134,7 +160,7 @@
</div> </div>
<div class="mt-4"> <div class="mt-4">
<div <div
class="flex items-center border border-base-content/20 rounded-xl bg-white px-4 h-16 modal-field-font-preview" class="flex border border-base-content/20 rounded-xl bg-white px-4 h-16 modal-field-font-preview"
:style="{ :style="{
color: preferences.color || 'black', color: preferences.color || 'black',
fontSize: (preferences.font_size || 12) + 'pt', fontSize: (preferences.font_size || 12) + 'pt',
@@ -143,7 +169,7 @@
> >
<span <span
contenteditable="true" contenteditable="true"
class="outline-none" class="outline-none whitespace-nowrap truncate"
> >
{{ field.default_value || field.name || buildDefaultName(field, template.fields) }} {{ field.default_value || field.name || buildDefaultName(field, template.fields) }}
</span> </span>
@@ -163,7 +189,7 @@
</template> </template>
<script> <script>
import { IconChevronDown, IconBold, IconItalic, IconAlignLeft, IconAlignRight, IconAlignCenter } from '@tabler/icons-vue' import { IconChevronDown, IconBold, IconItalic, IconAlignLeft, IconAlignRight, IconAlignCenter, IconAlignBoxCenterTop, IconAlignBoxCenterBottom, IconAlignBoxCenterMiddle } from '@tabler/icons-vue'
export default { export default {
name: 'FontModal', name: 'FontModal',
@@ -213,6 +239,13 @@ export default {
{ icon: IconAlignRight, value: 'right' } { icon: IconAlignRight, value: 'right' }
] ]
}, },
valigns () {
return [
{ icon: IconAlignBoxCenterTop, value: 'top' },
{ icon: IconAlignBoxCenterMiddle, value: 'center' },
{ icon: IconAlignBoxCenterBottom, value: 'bottom' }
]
},
sizes () { sizes () {
return [...Array(23).keys()].map(i => i + 6) return [...Array(23).keys()].map(i => i + 6)
}, },
@@ -230,12 +263,15 @@ export default {
'justify-center': this.preferences.align === 'center', 'justify-center': this.preferences.align === 'center',
'justify-start': this.preferences.align === 'left', 'justify-start': this.preferences.align === 'left',
'justify-end': this.preferences.align === 'right', 'justify-end': this.preferences.align === 'right',
'items-center': !this.preferences.valign || this.preferences.valign === 'center',
'items-start': this.preferences.valign === 'top',
'items-end': this.preferences.valign === 'bottom',
'font-bold': ['bold_italic', 'bold'].includes(this.preferences.font_type), 'font-bold': ['bold_italic', 'bold'].includes(this.preferences.font_type),
italic: ['bold_italic', 'italic'].includes(this.preferences.font_type) italic: ['bold_italic', 'italic'].includes(this.preferences.font_type)
} }
}, },
keys () { keys () {
return ['font_type', 'font_size', 'color', 'align', 'font'] return ['font_type', 'font_size', 'color', 'align', 'valign', 'font']
} }
}, },
created () { created () {
@@ -154,7 +154,7 @@ export default {
computed: { computed: {
fields () { fields () {
return this.template.fields.reduce((acc, f) => { return this.template.fields.reduce((acc, f) => {
if (f !== this.field && f.submitter_uuid === this.field.submitter_uuid && ['number'].includes(f.type) && !f.preferences?.formula) { if (f !== this.field && ['number'].includes(f.type) && (!f.preferences?.formula || f.submitter_uuid !== this.field.submitter_uuid)) {
acc.push(f) acc.push(f)
} }
@@ -0,0 +1,89 @@
<template>
<div
v-if="isDragging || isLoading"
class="modal modal-open"
>
<div class="flex flex-col gap-2 p-4 items-center bg-base-100 h-full max-h-[85vh] max-w-6xl rounded-2xl w-full">
<Dropzone
class="flex-1 h-full"
hover-class="bg-base-200/50"
icon="IconFilePlus"
:template-id="templateId"
:accept-file-types="acceptFileTypes"
:with-description="false"
:title="t('upload_a_new_document')"
type="add_files"
@loading="isLoading = $event"
@success="$emit('add', $event)"
@error="$emit('error', $event)"
/>
<div class="flex-1 flex gap-2 w-full">
<Dropzone
class="flex-1 h-full"
hover-class="bg-base-200/50"
icon="IconFileSymlink"
:template-id="templateId"
:accept-file-types="acceptFileTypes"
:with-description="false"
:title="t('replace_existing_document')"
@loading="isLoading = $event"
@success="$emit('replace', $event)"
@error="$emit('error', $event)"
/>
<Dropzone
v-if="withReplaceAndClone"
class="flex-1 h-full"
hover-class="bg-base-200/50"
icon="IconFiles"
:template-id="templateId"
:accept-file-types="acceptFileTypes"
:with-description="false"
:clone-template-on-upload="true"
:title="t('clone_and_replace_documents')"
@loading="isLoading = $event"
@success="$emit('replace-and-clone', $event)"
@error="$emit('error', $event)"
/>
</div>
</div>
</div>
</template>
<script>
import Dropzone from './dropzone'
export default {
name: 'HoverDropzone',
components: {
Dropzone
},
inject: ['t'],
props: {
isDragging: {
type: Boolean,
required: true,
default: false
},
templateId: {
type: [Number, String],
required: true
},
withReplaceAndClone: {
type: Boolean,
required: false,
default: true
},
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
}
},
emits: ['add', 'replace', 'replace-and-clone', 'error'],
data () {
return {
isLoading: false
}
}
}
</script>
+36 -6
View File
@@ -63,6 +63,9 @@ const en = {
processing_: 'Processing...', processing_: 'Processing...',
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',
upload_a_new_document: 'Upload a new document',
replace_existing_document: 'Replace existing document',
clone_and_replace_documents: 'Clone and replace documents',
required: 'Required', required: 'Required',
default_value: 'Default value', default_value: 'Default value',
format: 'Format', format: 'Format',
@@ -158,7 +161,9 @@ const en = {
some_fields_are_missing_in_the_formula: 'Some fields are missing in the formula.', some_fields_are_missing_in_the_formula: 'Some fields are missing in the formula.',
learn_more: 'Learn more', learn_more: 'Learn more',
and: 'and', and: 'and',
or: 'or' or: 'or',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Start a quick tour to learn how to create an send your first document',
start_tour: 'Start Tour'
} }
const es = { const es = {
@@ -223,6 +228,9 @@ const es = {
processing_: 'Procesando...', processing_: 'Procesando...',
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',
upload_a_new_document: 'Subir un nuevo documento',
replace_existing_document: 'Reemplazar documento existente',
clone_and_replace_documents: 'Clonar y reemplazar documentos',
required: 'Requerido', required: 'Requerido',
default_value: 'Valor predeterminado', default_value: 'Valor predeterminado',
format: 'Formato', format: 'Formato',
@@ -321,7 +329,9 @@ const es = {
some_fields_are_missing_in_the_formula: 'Faltan algunos campos en la fórmula.', some_fields_are_missing_in_the_formula: 'Faltan algunos campos en la fórmula.',
learn_more: 'Aprende más', learn_more: 'Aprende más',
and: 'y', and: 'y',
or: 'o' or: 'o',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Inicia una guía rápida para aprender a crear y enviar tu primer documento.',
start_tour: 'Iniciar guía'
} }
const it = { const it = {
@@ -389,6 +399,9 @@ const it = {
processing_: 'Elaborazione...', processing_: 'Elaborazione...',
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',
upload_a_new_document: 'Carica un nuovo documento',
replace_existing_document: 'Sostituisci documento esistente',
clone_and_replace_documents: 'Clona e sostituisci documenti',
required: 'Obbligatorio', required: 'Obbligatorio',
default_value: 'Valore predefinito', default_value: 'Valore predefinito',
format: 'Formato', format: 'Formato',
@@ -484,7 +497,9 @@ const it = {
some_fields_are_missing_in_the_formula: 'Alcuni campi mancano nella formula.', some_fields_are_missing_in_the_formula: 'Alcuni campi mancano nella formula.',
learn_more: 'Scopri di più', learn_more: 'Scopri di più',
and: 'e', and: 'e',
or: 'o' or: 'o',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Inizia un tour rapido per imparare a creare e inviare il tuo primo documento.',
start_tour: 'Inizia il tour'
} }
const pt = { const pt = {
@@ -549,6 +564,9 @@ const pt = {
processing_: 'Processando...', processing_: 'Processando...',
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',
upload_a_new_document: 'Enviar um novo documento',
replace_existing_document: 'Substituir documento existente',
clone_and_replace_documents: 'Clonar e substituir documentos',
required: 'Obrigatório', required: 'Obrigatório',
default_value: 'Valor padrão', default_value: 'Valor padrão',
format: 'Formato', format: 'Formato',
@@ -647,7 +665,9 @@ const pt = {
some_fields_are_missing_in_the_formula: 'Faltam alguns campos na fórmula.', some_fields_are_missing_in_the_formula: 'Faltam alguns campos na fórmula.',
learn_more: 'Saiba mais', learn_more: 'Saiba mais',
and: 'e', and: 'e',
or: 'ou' or: 'ou',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Comece um tour rápido para aprender a criar e enviar seu primeiro documento.',
start_tour: 'Iniciar tour'
} }
const fr = { const fr = {
@@ -711,6 +731,9 @@ const fr = {
processing_: 'Traitement en cours...', processing_: 'Traitement en cours...',
add_pdf_documents_or_images: 'Ajoutez des documents PDF ou des images', add_pdf_documents_or_images: 'Ajoutez des documents PDF ou des images',
add_documents_or_images: 'Ajoutez des documents ou des images', add_documents_or_images: 'Ajoutez des documents ou des images',
upload_a_new_document: 'Téléverser un nouveau document',
replace_existing_document: 'Remplacer le document existant',
clone_and_replace_documents: 'Cloner et remplacer les documents',
required: 'Requis', required: 'Requis',
default_value: 'Valeur par défaut', default_value: 'Valeur par défaut',
format: 'Format', format: 'Format',
@@ -810,7 +833,9 @@ const fr = {
some_fields_are_missing_in_the_formula: 'Certains champs manquent dans la formule.', some_fields_are_missing_in_the_formula: 'Certains champs manquent dans la formule.',
learn_more: 'En savoir plus', learn_more: 'En savoir plus',
and: 'et', and: 'et',
or: 'ou' or: 'ou',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Lancez une visite rapide pour apprendre à créer et envoyer votre premier document.',
start_tour: 'Démarrer'
} }
const de = { const de = {
@@ -874,6 +899,9 @@ const de = {
processing_: 'Verarbeitung...', processing_: 'Verarbeitung...',
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',
upload_a_new_document: 'Neues Dokument hochladen',
replace_existing_document: 'Vorhandenes Dokument ersetzen',
clone_and_replace_documents: 'Dokumente klonen und ersetzen',
required: 'Erforderlich', required: 'Erforderlich',
default_value: 'Standardwert', default_value: 'Standardwert',
format: 'Format', format: 'Format',
@@ -973,7 +1001,9 @@ const de = {
some_fields_are_missing_in_the_formula: 'Einige Felder fehlen in der Formel.', some_fields_are_missing_in_the_formula: 'Einige Felder fehlen in der Formel.',
learn_more: 'Erfahren Sie mehr', learn_more: 'Erfahren Sie mehr',
and: 'und', and: 'und',
or: 'oder' or: 'oder',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Starte eine kurze Tour, um zu lernen, wie du dein erstes Dokument erstellst und versendest.',
start_tour: 'Starten'
} }
export { en, es, it, pt, fr, de } export { en, es, it, pt, fr, de }
@@ -54,7 +54,7 @@
v-for="(icon, type) in fieldIconsSorted" v-for="(icon, type) in fieldIconsSorted"
:key="type" :key="type"
> >
<li v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification')"> <li v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification'))">
<a <a
href="#" href="#"
class="text-sm py-1 px-2" class="text-sm py-1 px-2"
+27 -3
View File
@@ -37,7 +37,7 @@
<FieldArea <FieldArea
v-if="newArea" v-if="newArea"
:is-draw="true" :is-draw="true"
:field="{ submitter_uuid: selectedSubmitter.uuid, type: drawField?.type || defaultFieldType }" :field="{ submitter_uuid: selectedSubmitter.uuid, type: drawField?.type || dragFieldPlaceholder?.type || defaultFieldType }"
:area="newArea" :area="newArea"
/> />
</div> </div>
@@ -49,7 +49,9 @@
:class="{ 'z-10': !isMobile, 'cursor-grab': isDrag, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }" :class="{ 'z-10': !isMobile, 'cursor-grab': isDrag, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
@pointermove="onPointermove" @pointermove="onPointermove"
@pointerdown="onStartDraw" @pointerdown="onStartDraw"
@dragover.prevent @dragover.prevent="onDragover"
@dragenter="onDragenter"
@dragleave="newArea = null"
@drop="onDrop" @drop="onDrop"
@pointerup="onPointerup" @pointerup="onPointerup"
/> />
@@ -64,12 +66,17 @@ export default {
components: { components: {
FieldArea FieldArea
}, },
inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef'], inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'assignDropAreaSize'],
props: { props: {
image: { image: {
type: Object, type: Object,
required: true required: true
}, },
dragFieldPlaceholder: {
type: Object,
required: false,
default: null
},
areas: { areas: {
type: Array, type: Array,
required: false, required: false,
@@ -192,7 +199,24 @@ export default {
this.areaRefs.push(el) this.areaRefs.push(el)
} }
}, },
onDragenter (e) {
this.newArea = {}
this.assignDropAreaSize(this.newArea, this.dragFieldPlaceholder, {
maskW: this.$refs.mask.clientWidth,
maskH: this.$refs.mask.clientHeight
})
this.newArea.x = (e.offsetX - 6) / this.$refs.mask.clientWidth
this.newArea.y = e.offsetY / this.$refs.mask.clientHeight - this.newArea.h / 2
},
onDragover (e) {
this.newArea.x = (e.offsetX - 6) / this.$refs.mask.clientWidth
this.newArea.y = e.offsetY / this.$refs.mask.clientHeight - this.newArea.h / 2
},
onDrop (e) { onDrop (e) {
this.newArea = null
this.$emit('drop-field', { this.$emit('drop-field', {
x: e.offsetX, x: e.offsetX,
y: e.offsetY, y: e.offsetY,
+5 -5
View File
@@ -2,7 +2,7 @@
<label <label
:for="inputId" :for="inputId"
class="btn btn-neutral btn-xs text-white transition-none replace-document-button" class="btn btn-neutral btn-xs text-white transition-none replace-document-button"
:class="{ 'opacity-100': isLoading || isProcessing }" :class="{ 'opacity-100': isLoading }"
> >
{{ message }} {{ message }}
<form <form
@@ -41,19 +41,19 @@ export default {
emits: ['success'], emits: ['success'],
data () { data () {
return { return {
isLoading: false, isLoading: false
isProcessing: false
} }
}, },
computed: { computed: {
inputId () { inputId () {
return 'el' + Math.random().toString(32).split('.')[1] return 'el' + Math.random().toString(32).split('.')[1]
}, },
uploadUrl () {
return `/templates/${this.templateId}/documents`
},
message () { message () {
if (this.isLoading) { if (this.isLoading) {
return this.t('uploading_') return this.t('uploading_')
} else if (this.isProcessing) {
return this.t('processing_')
} else { } else {
return this.t('replace') return this.t('replace')
} }
+24 -11
View File
@@ -4,10 +4,10 @@
id="add_document_button" id="add_document_button"
:for="inputId" :for="inputId"
class="btn btn-outline w-full add-document-button" class="btn btn-outline w-full add-document-button"
:class="{ 'btn-disabled': isLoading || isProcessing }" :class="{ 'btn-disabled': isLoading }"
> >
<IconInnerShadowTop <IconInnerShadowTop
v-if="isLoading || isProcessing" v-if="isLoading"
width="20" width="20"
class="animate-spin" class="animate-spin"
/> />
@@ -18,9 +18,6 @@
<span v-if="isLoading"> <span v-if="isLoading">
{{ t('uploading_') }} {{ t('uploading_') }}
</span> </span>
<span v-else-if="isProcessing">
{{ t('processing_') }}
</span>
<span v-else> <span v-else>
{{ t('add_document') }} {{ t('add_document') }}
</span> </span>
@@ -63,30 +60,34 @@ export default {
default: 'image/*, application/pdf' default: 'image/*, application/pdf'
} }
}, },
emits: ['success'], emits: ['success', 'error'],
data () { data () {
return { return {
isLoading: false, isLoading: false
isProcessing: false
} }
}, },
computed: { computed: {
inputId () { inputId () {
return 'el' + Math.random().toString(32).split('.')[1] return 'el' + Math.random().toString(32).split('.')[1]
},
uploadUrl () {
return `/templates/${this.templateId}/documents`
} }
}, },
methods: { methods: {
async upload () { async upload () {
this.isLoading = true this.isLoading = true
this.baseFetch(`/templates/${this.templateId}/documents`, { this.baseFetch(this.uploadUrl, {
method: 'POST', method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(this.$refs.form) body: new FormData(this.$refs.form)
}).then((resp) => { }).then((resp) => {
if (resp.ok) { if (resp.ok) {
resp.json().then((data) => { resp.json().then((data) => {
this.$emit('success', data) this.$emit('success', data)
this.$refs.input.value = '' this.$refs.input.value = ''
this.isLoading = false
}) })
} else if (resp.status === 422) { } else if (resp.status === 422) {
resp.json().then((data) => { resp.json().then((data) => {
@@ -95,21 +96,33 @@ export default {
formData.append('password', prompt(this.t('enter_pdf_password'))) formData.append('password', prompt(this.t('enter_pdf_password')))
this.baseFetch(`/templates/${this.templateId}/documents`, { this.baseFetch(this.uploadUrl, {
method: 'POST', method: 'POST',
body: formData body: formData
}).then(async (resp) => { }).then(async (resp) => {
if (resp.ok) { if (resp.ok) {
this.$emit('success', await resp.json()) this.$emit('success', await resp.json())
this.$refs.input.value = '' this.$refs.input.value = ''
this.isLoading = false
} else { } else {
alert(this.t('wrong_password')) alert(this.t('wrong_password'))
this.$emit('error', await resp.json().error)
this.isLoading = false
} }
}) })
} else {
this.$emit('error', data.error)
this.isLoading = false
} }
}) })
} else {
resp.json().then((data) => {
this.$emit('error', data.error)
this.isLoading = false
})
} }
}).finally(() => { }).catch(() => {
this.isLoading = false this.isLoading = false
}) })
} }
+19 -8
View File
@@ -94,11 +94,27 @@ class ProcessSubmitterCompletionJob
end end
end end
to = build_to_addresses(submitter) maybe_enqueue_copy_emails(submitter)
end
return if to.blank? || submitter.template.preferences['documents_copy_email_enabled'] == false def maybe_enqueue_copy_emails(submitter)
return if submitter.template.preferences['documents_copy_email_enabled'] == false
SubmitterMailer.documents_copy_email(submitter, to:).deliver_later! configs = AccountConfigs.find_or_initialize_for_key(submitter.account,
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
return if configs.value['enabled'] == false
to = submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false }
.sort_by(&:completed_at).select(&:email?).map(&:friendly_name)
return if to.blank?
if configs.value['bcc_recipients'] == true
to.each { |to| SubmitterMailer.documents_copy_email(submitter, to:).deliver_later! }
else
SubmitterMailer.documents_copy_email(submitter, to: to.join(', ')).deliver_later!
end
end end
def build_bcc_addresses(submission) def build_bcc_addresses(submission)
@@ -110,11 +126,6 @@ class ProcessSubmitterCompletionJob
bcc.to_s.scan(User::EMAIL_REGEXP) bcc.to_s.scan(User::EMAIL_REGEXP)
end end
def build_to_addresses(submitter)
submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false }
.sort_by(&:completed_at).select(&:email?).map(&:friendly_name).join(', ')
end
def enqueue_next_submitter_request_notification(submitter) def enqueue_next_submitter_request_notification(submitter)
next_submitter_item = next_submitter_item =
submitter.submission.template_submitters.find do |e| submitter.submission.template_submitters.find do |e|
+1
View File
@@ -30,6 +30,7 @@ class AccountConfig < ApplicationRecord
ALLOW_TO_RESUBMIT = 'allow_to_resubmit' ALLOW_TO_RESUBMIT = 'allow_to_resubmit'
ALLOW_TO_DECLINE_KEY = 'allow_to_decline' ALLOW_TO_DECLINE_KEY = 'allow_to_decline'
SUBMITTER_REMINDERS = 'submitter_reminders' SUBMITTER_REMINDERS = 'submitter_reminders'
ENFORCE_SIGNING_ORDER_KEY = 'enforce_signing_order'
FORM_COMPLETED_BUTTON_KEY = 'form_completed_button' FORM_COMPLETED_BUTTON_KEY = 'form_completed_button'
FORM_COMPLETED_MESSAGE_KEY = 'form_completed_message' FORM_COMPLETED_MESSAGE_KEY = 'form_completed_message'
FORM_WITH_CONFETTI_KEY = 'form_with_confetti' FORM_WITH_CONFETTI_KEY = 'form_with_confetti'
+5
View File
@@ -64,6 +64,7 @@ class Submission < ApplicationRecord
through: :template, source: :documents_attachments through: :template, source: :documents_attachments
scope :active, -> { where(archived_at: nil) } scope :active, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) }
scope :pending, -> { joins(:submitters).where(submitters: { completed_at: nil }).group(:id) } scope :pending, -> { joins(:submitters).where(submitters: { completed_at: nil }).group(:id) }
scope :completed, lambda { scope :completed, lambda {
where.not(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]) where.not(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
@@ -89,6 +90,10 @@ class Submission < ApplicationRecord
expire_at && expire_at <= Time.current expire_at && expire_at <= Time.current
end end
def fields_uuid_index
@fields_uuid_index ||= (template_fields || template.fields).index_by { |f| f['uuid'] }
end
def audit_trail_url def audit_trail_url
return if audit_trail.blank? return if audit_trail.blank?
+2
View File
@@ -38,6 +38,8 @@ class SubmissionEvent < ApplicationRecord
enum :event_type, { enum :event_type, {
send_email: 'send_email', send_email: 'send_email',
bounce_email: 'bounce_email',
complaint_email: 'complaint_email',
send_reminder_email: 'send_reminder_email', send_reminder_email: 'send_reminder_email',
send_sms: 'send_sms', send_sms: 'send_sms',
send_2fa_sms: 'send_2fa_sms', send_2fa_sms: 'send_2fa_sms',
+1 -1
View File
@@ -54,7 +54,7 @@ class Submitter < ApplicationRecord
has_many_attached :documents has_many_attached :documents
has_many_attached :attachments has_many_attached :attachments
has_many_attached :preview_documents has_many_attached :preview_documents
has_many :template_accesses, through: :template has_many :template_accesses, through: :submission
has_many :email_events, as: :emailable, dependent: (Docuseal.multitenant? ? nil : :destroy) has_many :email_events, as: :emailable, dependent: (Docuseal.multitenant? ? nil : :destroy)
has_many :document_generation_events, dependent: :destroy has_many :document_generation_events, dependent: :destroy
+1
View File
@@ -25,6 +25,7 @@ class UserConfig < ApplicationRecord
INITIALS_KEY = 'initials' INITIALS_KEY = 'initials'
RECEIVE_COMPLETED_EMAIL = 'receive_completed_email' RECEIVE_COMPLETED_EMAIL = 'receive_completed_email'
RECEIVE_DECLINED_EMAIL = 'receive_declined_email' RECEIVE_DECLINED_EMAIL = 'receive_declined_email'
SHOW_APP_TOUR = 'show_app_tour'
belongs_to :user belongs_to :user
+16 -1
View File
@@ -151,6 +151,20 @@
</div> </div>
<% end %> <% end %>
<% end %> <% end %>
<% if !Docuseal.multitenant? || can?(:manage, :personalization_advanced) %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ENFORCE_SIGNING_ORDER_KEY) %>
<% if can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
<%= f.hidden_field :key %>
<div class="flex items-center justify-between py-2.5">
<span>
<%= t('always_enforce_signing_order') %>
</span>
<%= f.check_box :value, class: 'toggle', checked: account_config.value, onchange: 'this.form.requestSubmit()' %>
</div>
<% end %>
<% end %>
<% end %>
</div> </div>
<% end %> <% end %>
<%= render 'compliances' %> <%= render 'compliances' %>
@@ -160,9 +174,10 @@
<h2 class="text-2xl font-bold mb-2"> <h2 class="text-2xl font-bold mb-2">
<%= t('danger_zone') %> <%= t('danger_zone') %>
</h2> </h2>
<%= button_to button_title(title: t('delete_my_account')), settings_account_path, class: 'btn btn-outline btn-error block', data: { turbo_confirm: t('schedule_account_for_deletion_') }, method: :delete, id: :account_delete_button %> <%= button_to button_title(title: t('delete_my_account')), settings_account_path, class: 'btn btn-outline btn-error block', data: { turbo_confirm: t('you_are_scheduling_your_account_for_deletion_after_deletion_your_data_will_be_permanently_removed_and_cannot_be_recovered_click_ok_if_you_would_like_to_continue') }, method: :delete, id: :account_delete_button %>
</div> </div>
<% end %> <% end %>
</div> </div>
<div class="w-0 md:w-52"></div> <div class="w-0 md:w-52"></div>
</div> </div>
<%= render 'shared/app_tour', type: 'account' %>
+1 -1
View File
@@ -1,4 +1,4 @@
<form action="<%= root_path %>" method="get" class="bg-base-200 px-1.5 rounded-xl py-1 whitespace-nowrap"> <form action="<%= root_path %>" method="get" id="templates_submissions_toggle" class="bg-base-200 px-1.5 rounded-xl py-1 whitespace-nowrap">
<toggle-cookies data-value="templates" data-key="dashboard_view" class="sm:tooltip tooltip-top" data-tip="<%= t('templates') %>"> <toggle-cookies data-value="templates" data-key="dashboard_view" class="sm:tooltip tooltip-top" data-tip="<%= t('templates') %>">
<button class="<%= local_assigns[:selected] == 'submissions' ? 'btn !border !rounded-lg btn-square !p-0 !btn-sm !h-8 !w-9' : 'btn btn-neutral !rounded-lg btn-square !p-0 hover:text-neutral-300 !btn-sm !h-8 !w-9 disabled:btn-neutral' %>"> <button class="<%= local_assigns[:selected] == 'submissions' ? 'btn !border !rounded-lg btn-square !p-0 !btn-sm !h-8 !w-9' : 'btn btn-neutral !rounded-lg btn-square !p-0 hover:text-neutral-300 !btn-sm !h-8 !w-9 disabled:btn-neutral' %>">
<%= svg_icon('layout_grid', class: 'w-6 h-6 stroke-2') %> <%= svg_icon('layout_grid', class: 'w-6 h-6 stroke-2') %>
+1 -1
View File
@@ -141,7 +141,7 @@
<%= t('preferences') %> <%= t('preferences') %>
</h2> </h2>
</div> </div>
<% if can?(:manage, account_config) %> <% if can?(:manage, account_config) && (can?(:manage, :personalization_advanced) || !Docuseal.multitenant?) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %> <%= form_for account_config, url: account_configs_path, method: :post do |f| %>
<%= f.hidden_field :key %> <%= f.hidden_field :key %>
<div class="flex items-center justify-between py-2.5"> <div class="flex items-center justify-between py-2.5">
+5
View File
@@ -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 d="M3 9l4 -4l4 4m-4 -4v14" />
<path d="M21 15l-4 4l-4 -4m4 4v-14" />
</svg>

After

Width:  |  Height:  |  Size: 362 B

@@ -0,0 +1,3 @@
<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 d="M20.986 12.502a9 9 0 1 0 -5.973 7.98" /><path d="M12 7v5l3 3" /><path d="M19 16v3" /><path d="M19 22v.01" />
</svg>

After

Width:  |  Height:  |  Size: 400 B

+3
View File
@@ -0,0 +1,3 @@
<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 d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" /><path d="M12 11v6" /><path d="M9.5 13.5l2.5 -2.5l2.5 2.5" />
</svg>

After

Width:  |  Height:  |  Size: 462 B

+6
View File
@@ -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 d="M15 3v4a1 1 0 0 0 1 1h4" />
<path d="M18 17h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h4l5 5v7a2 2 0 0 1 -2 2z" />
<path d="M16 17v2a2 2 0 0 1 -2 2h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h2" />
</svg>

After

Width:  |  Height:  |  Size: 485 B

+1 -7
View File
@@ -1,9 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> <svg 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 stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M4 8v-2a2 2 0 0 1 2 -2h2" /><path d="M4 16v2a2 2 0 0 0 2 2h2" /><path d="M16 4h2a2 2 0 0 1 2 2v2" /><path d="M16 20h2a2 2 0 0 0 2 -2v-2" /><path d="M8 11m0 1a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1v3a1 1 0 0 1 -1 1h-6a1 1 0 0 1 -1 -1z" /><path d="M10 11v-2a2 2 0 1 1 4 0v2" />
<path d="M4 8v-2a2 2 0 0 1 2 -2h2"></path>
<path d="M4 16v2a2 2 0 0 0 2 2h2"></path>
<path d="M16 4h2a2 2 0 0 1 2 2v2"></path>
<path d="M16 20h2a2 2 0 0 0 2 -2v-2"></path>
<path d="M8 11m0 1a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1v3a1 1 0 0 1 -1 1h-6a1 1 0 0 1 -1 -1z"></path>
<path d="M10 11v-2a2 2 0 1 1 4 0v2"></path>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 619 B

After

Width:  |  Height:  |  Size: 559 B

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

After

Width:  |  Height:  |  Size: 437 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
<path d="M15 10v-5c0 -1.38 .62 -2 2 -2s2 .62 2 2v5m0 -3h-4"></path>
<path d="M19 21h-4l4 -7h-4"></path> <path d="M4 15l3 3l3 -3"></path>
<path d="M7 6v12"></path>
</svg>

After

Width:  |  Height:  |  Size: 395 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
<path d="M4 15l3 3l3 -3"></path>
<path d="M7 6v12"></path>
<path d="M17 14a2 2 0 0 1 2 2v3a2 2 0 1 1 -4 0v-3a2 2 0 0 1 2 -2z"></path>
<path d="M17 5m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"></path>
<path d="M19 5v3a2 2 0 0 1 -2 2h-1.5"></path>
</svg>

After

Width:  |  Height:  |  Size: 474 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
<path d="M10 15l-3 3l-3 -3"></path>
<path d="M7 6v12"></path>
<path d="M14 18.333c0 .369 .298 .667 .667 .667h2.666a.667 .667 0 0 0 .667 -.667v-2.666a.667 .667 0 0 0 -.667 -.667h-2.666a.667 .667 0 0 0 -.667 .667v2.666z"></path>
<path d="M14 10.833c0 .645 .522 1.167 1.167 1.167h4.666c.645 0 1.167 -.522 1.167 -1.167v-4.666c0 -.645 -.522 -1.167 -1.167 -1.167h-4.666c-.645 0 -1.167 .522 -1.167 1.167v4.666z"></path>
</svg>

After

Width:  |  Height:  |  Size: 647 B

+3
View File
@@ -0,0 +1,3 @@
<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" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M16 18a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2zm0 -12a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2zm-7 12a6 6 0 0 1 6 -6a6 6 0 0 1 -6 -6a6 6 0 0 1 -6 6a6 6 0 0 1 6 6z" />
</svg>

After

Width:  |  Height:  |  Size: 497 B

@@ -7,19 +7,19 @@
<div class="w-full"> <div class="w-full">
<%= f.fields_for :value, record do |ff| %> <%= f.fields_for :value, record do |ff| %>
<%= ff.label :first_duration, t('first_reminder_in'), class: 'label truncate' %> <%= ff.label :first_duration, t('first_reminder_in'), class: 'label truncate' %>
<%= ff.select :first_duration, durations, { include_blank: 'None' }, class: 'base-select' %> <%= ff.select :first_duration, durations, { include_blank: t('none') }, class: 'base-select' %>
<% end %> <% end %>
</div> </div>
<div class="w-full"> <div class="w-full">
<%= f.fields_for :value, record do |ff| %> <%= f.fields_for :value, record do |ff| %>
<%= ff.label :second_duration, t('second_reminder_in'), class: 'label truncate' %> <%= ff.label :second_duration, t('second_reminder_in'), class: 'label truncate' %>
<%= ff.select :second_duration, durations, { include_blank: 'None' }, class: 'base-select' %> <%= ff.select :second_duration, durations, { include_blank: t('none') }, class: 'base-select' %>
<% end %> <% end %>
</div> </div>
<div class="w-full"> <div class="w-full">
<%= f.fields_for :value, record do |ff| %> <%= f.fields_for :value, record do |ff| %>
<%= ff.label :third_duration, t('third_reminder_in'), class: 'label truncate' %> <%= ff.label :third_duration, t('third_reminder_in'), class: 'label truncate' %>
<%= ff.select :third_duration, durations, { include_blank: 'None' }, class: 'base-select' %> <%= ff.select :third_duration, durations, { include_blank: t('none') }, class: 'base-select' %>
<% end %> <% end %>
</div> </div>
</div> </div>
@@ -8,7 +8,7 @@
<div class="collapse-content"> <div class="collapse-content">
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %> <%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<%= f.hidden_field :key %> <%= f.hidden_field :key %>
<%= f.fields_for :value, Struct.new(:subject, :body, :reply_to, :attach_audit_log, :attach_documents).new(*f.object.value.values_at('subject', 'body', 'reply_to', 'attach_audit_log', 'attach_documents')) do |ff| %> <%= f.fields_for :value, Struct.new(:subject, :body, :reply_to, :attach_audit_log, :attach_documents, :bcc_recipients, :enabled).new(*f.object.value.values_at('subject', 'body', 'reply_to', 'attach_audit_log', 'attach_documents', 'bcc_recipients', 'enabled')) do |ff| %>
<div class="form-control"> <div class="form-control">
<%= ff.label :subject, t('subject'), class: 'label' %> <%= ff.label :subject, t('subject'), class: 'label' %>
<%= ff.text_field :subject, required: true, class: 'base-input', dir: 'auto' %> <%= ff.text_field :subject, required: true, class: 'base-input', dir: 'auto' %>
@@ -24,23 +24,41 @@
<%= ff.text_area :body, required: true, class: 'base-input w-full py-2', dir: 'auto' %> <%= ff.text_area :body, required: true, class: 'base-input w-full py-2', dir: 'auto' %>
</autoresize-textarea> </autoresize-textarea>
</div> </div>
<% if can?(:manage, :reply_to) %> <% if can?(:manage, :reply_to) || can?(:manage, :personalization_advanced) %>
<div class="form-control"> <div class="form-control">
<%= ff.label :reply_to, t('reply_to'), class: 'label' %> <%= ff.label :reply_to, t('reply_to'), class: 'label' %>
<%= ff.email_field :reply_to, class: 'base-input', dir: 'auto', placeholder: t(:email) %> <%= ff.email_field :reply_to, class: 'base-input', dir: 'auto', placeholder: t(:email) %>
</div> </div>
<% end %> <% end %>
<div class="flex items-center justify-between pt-2.5 mx-1"> <div class="space-y-3.5">
<span> <div class="flex items-center justify-between mx-1">
<%= t('attach_documents') %> <span>
</span> <%= t('attach_documents') %>
<%= ff.check_box :attach_documents, { checked: ff.object.attach_documents != false, class: 'toggle' }, 'true', 'false' %> </span>
</div> <%= ff.check_box :attach_documents, { checked: ff.object.attach_documents != false, class: 'toggle' }, 'true', 'false' %>
<div class="flex items-center justify-between pb-2.5 mx-1"> </div>
<span> <div class="flex items-center justify-between mx-1">
<%= t('attach_audit_log_pdf') %> <span>
</span> <%= t('attach_audit_log_pdf') %>
<%= ff.check_box :attach_audit_log, { checked: ff.object.attach_audit_log != false, class: 'toggle' }, 'true', 'false' %> </span>
<%= ff.check_box :attach_audit_log, { checked: ff.object.attach_audit_log != false, class: 'toggle' }, 'true', 'false' %>
</div>
<% unless Docuseal.multitenant? %>
<div class="flex items-center justify-between mx-1">
<span>
<%= t('bcc_recipients') %>
</span>
<%= ff.check_box :bcc_recipients, { checked: ff.object.bcc_recipients == true, class: 'toggle' }, 'true', 'false' %>
</div>
<% end %>
<% if !Docuseal.multitenant? || can?(:manage, :personalization_advanced) %>
<div class="flex items-center justify-between mx-1">
<span>
<%= t('send_emails_automatically_on_completion') %>
</span>
<%= ff.check_box :enabled, { checked: ff.object.enabled != false, class: 'toggle' }, 'true', 'false' %>
</div>
<% end %>
</div> </div>
<% end %> <% end %>
<div class="form-control pt-2"> <div class="form-control pt-2">
+1 -1
View File
@@ -12,7 +12,7 @@
</div> </div>
<div class="form-control"> <div class="form-control">
<%= f.label :last_name, t('last_name'), class: 'label' %> <%= f.label :last_name, t('last_name'), class: 'label' %>
<%= f.text_field :last_name, required: true, class: 'base-input', dir: 'auto' %> <%= f.text_field :last_name, required: false, class: 'base-input', dir: 'auto' %>
</div> </div>
</div> </div>
<div class="form-control"> <div class="form-control">
+6
View File
@@ -0,0 +1,6 @@
<% if current_user.created_at > 2.weeks.ago || params[:tour] == 'true' %>
<% user_config = current_user.user_configs.find_by(key: UserConfig::SHOW_APP_TOUR) || UserConfig.new(key: UserConfig::SHOW_APP_TOUR, user: current_user) %>
<% if user_config.new_record? || user_config.value || params[:tour] == 'true' %>
<app-tour data-show-tour="<%= params[:tour] == 'true' || user_config.value %>" data-type="<%= local_assigns[:type] %>" data-next-page-path="<%= local_assigns[:next_page_path] %>" data-i18n="<%= t('app_tour').to_json %>"></app-tour>
<% end %>
<% end %>
+1 -1
View File
@@ -1 +1 @@
<% 'stats stat stat-figure stat-title stat-value text-accent w-fit hover:bg-white' %> <% 'stats stat stat-figure stat-title stat-value text-accent w-fit hover:bg-white dropdown-open' %>
+1 -1
View File
@@ -1,5 +1,5 @@
<clipboard-copy data-text="<%= text %>"> <clipboard-copy data-text="<%= text %>">
<label class="<%= local_assigns[:class] %>"> <label id="<%= local_assigns[:id] %>" class="<%= local_assigns[:class] %>">
<input type="radio" class="peer hidden"> <input type="radio" class="peer hidden">
<span class="peer-checked:hidden flex items-center space-x-2"> <span class="peer-checked:hidden flex items-center space-x-2">
<%= svg_icon(local_assigns[:icon] || 'link', class: local_assigns[:icon_class] || 'w-6 h-6 text-white') %> <%= svg_icon(local_assigns[:icon] || 'link', class: local_assigns[:icon_class] || 'w-6 h-6 text-white') %>
+1 -1
View File
@@ -1,4 +1,4 @@
<% uuid = SecureRandom.uuid %> <% uuid = local_assigns[:uuid] || SecureRandom.uuid %>
<label id="<%= local_assigns[:button_id] %>" for="<%= uuid %>" class="<%= local_assigns[:btn_class] %>"><%= local_assigns[:btn_text] %></label> <label id="<%= local_assigns[:button_id] %>" for="<%= uuid %>" class="<%= local_assigns[:btn_class] %>"><%= local_assigns[:btn_text] %></label>
<input type="checkbox" id="<%= uuid %>" class="modal-toggle"> <input type="checkbox" id="<%= uuid %>" class="modal-toggle">
<div id="<%= local_assigns[:id] %>" class="modal items-start !animate-none overflow-y-auto"> <div id="<%= local_assigns[:id] %>" class="modal items-start !animate-none overflow-y-auto">
+2 -2
View File
@@ -13,10 +13,10 @@
<meta property="og:image" content=""> <meta property="og:image" content="">
<meta name="twitter:image" content=""> <meta name="twitter:image" content="">
<% else %> <% else %>
<meta property="og:image" content="<%= image_pack_url('images/preview.png') %>"> <meta property="og:image" content="<%= root_url %>preview.png">
<meta property="og:image:width" content="800"> <meta property="og:image:width" content="800">
<meta property="og:image:height" content="800"> <meta property="og:image:height" content="800">
<meta name="twitter:image" content="<%= image_pack_url('images/preview.png') %>"> <meta name="twitter:image" content="<%= root_url %>preview.png">
<% end %> <% end %>
<meta name="twitter:card" content="summary"> <meta name="twitter:card" content="summary">
<meta name="twitter:creator" content="@docusealco"> <meta name="twitter:creator" content="@docusealco">
+9 -1
View File
@@ -20,7 +20,7 @@
<% else %> <% else %>
<div class="flex items-center justify-center space-x-4 mr-1"> <div class="flex items-center justify-center space-x-4 mr-1">
<%= render 'shared/navbar_buttons' %> <%= render 'shared/navbar_buttons' %>
<%= link_to t('settings'), settings_profile_index_path, class: 'hidden md:inline-flex font-medium text-lg' %> <%= link_to t('settings'), settings_profile_index_path, class: 'hidden md:inline-flex font-medium text-lg', id: 'account_settings_button' %>
</div> </div>
<% end %> <% end %>
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
@@ -50,6 +50,14 @@
<% end %> <% end %>
</li> </li>
<% end %> <% end %>
<% if Docuseal.multitenant? || current_user.role == 'superadmin' %>
<li>
<%= link_to Docuseal::CHATGPT_URL, target: 'blank', class: 'flex items-center' do %>
<%= svg_icon('sparkles', class: 'w-5 h-5 flex-shrink-0 stroke-2') %>
<span class="mr-1 whitespace-nowrap"><%= t('ask_ai') %></span>
<% end %>
</li>
<% end %>
<% if (can?(:manage, EncryptedConfig) && current_user == true_user) || (current_user != true_user && current_account.testing?) %> <% if (can?(:manage, EncryptedConfig) && current_user == true_user) || (current_user != true_user && current_account.testing?) %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :get, html: { class: 'w-full py-1' } do |f| %> <%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :get, html: { class: 'w-full py-1' } do |f| %>
<label class="flex items-center pl-6 pr-4 py-2 border-y border-base-300 -ml-2 -mr-2" for="testing_toggle"> <label class="flex items-center pl-6 pr-4 py-2 border-y border-base-300 -ml-2 -mr-2" for="testing_toggle">
+17 -14
View File
@@ -5,20 +5,23 @@
<%= @pagy.from %>-<%= local_assigns.fetch(:to, @pagy.to) %> of <%= local_assigns.fetch(:count, @pagy.count) %> <%= local_assigns[:items_name] || 'items' %> <%= @pagy.from %>-<%= local_assigns.fetch(:to, @pagy.to) %> of <%= local_assigns.fetch(:count, @pagy.count) %> <%= local_assigns[:items_name] || 'items' %>
<%= local_assigns[:left_additional_html] %> <%= local_assigns[:left_additional_html] %>
</div> </div>
<div class="join"> <div class="flex items-center space-x-1.5">
<% if @pagy.prev %> <%= local_assigns[:right_additional_html] %>
<%== link.call(@pagy.prev, '«', classes: 'join-item btn min-h-full h-10') %> <div class="join">
<% else %> <% if @pagy.prev %>
<span class="join-item btn btn-disabled !bg-base-200 min-h-full h-10">«</span> <%== link.call(@pagy.prev, '«', classes: 'join-item btn min-h-full h-10') %>
<% end %> <% else %>
<span class="join-item btn uppercase min-h-full h-10"> <span class="join-item btn btn-disabled !bg-base-200 min-h-full h-10">«</span>
<%= t('page_number', number: @pagy.page) %> <% end %>
</span> <span class="join-item btn font-medium uppercase min-h-full h-10">
<% if @pagy.next %> <%= t('page_number', number: @pagy.page) %>
<%== link.call(@pagy.next, '»', classes: 'join-item btn min-h-full h-10') %> </span>
<% else %> <% if @pagy.next %>
<span class="join-item btn btn-disabled !bg-base-200 min-h-full h-10">»</span> <%== link.call(@pagy.next, '»', classes: 'join-item btn min-h-full h-10') %>
<% end %> <% else %>
<span class="join-item btn btn-disabled !bg-base-200 min-h-full h-10">»</span>
<% end %>
</div>
</div> </div>
</div> </div>
<% end %> <% end %>
+2 -2
View File
@@ -1,6 +1,6 @@
<div class="block w-full md:w-52 flex-none"> <div class="block w-full md:w-52 flex-none">
<menu-active> <menu-active>
<ul class="menu px-0"> <ul id="account_settings_menu" class="menu px-0">
<li class="menu-title py-0 !bg-transparent mb-3 -mt-5"><a href="<%= '/' %>" class="!bg-transparent !text-neutral font-medium">&larr; <%= t('back') %></a></li> <li class="menu-title py-0 !bg-transparent mb-3 -mt-5"><a href="<%= '/' %>" class="!bg-transparent !text-neutral font-medium">&larr; <%= t('back') %></a></li>
<li class="menu-title py-0 !bg-transparent"> <li class="menu-title py-0 !bg-transparent">
<span class="!bg-transparent"><%= t('settings') %></span> <span class="!bg-transparent"><%= t('settings') %></span>
@@ -105,7 +105,7 @@
</ul> </ul>
</menu-active> </menu-active>
<% if Docuseal.multitenant? || cannot?(:manage, :tenants) %> <% if Docuseal.multitenant? || cannot?(:manage, :tenants) %>
<div class="mx-4 border-t border-base-300 hidden md:block"> <div id="support_channels" class="mx-4 border-t border-base-300 hidden md:block">
<div class="text-sm mt-3"> <div class="text-sm mt-3">
<%= t('need_help_ask_a_question_') %> <%= t('need_help_ask_a_question_') %>
</div> </div>
@@ -0,0 +1,34 @@
<% dashboard_templates_order = cookies.permanent[:dashboard_templates_order] || 'created_at' %>
<form action="<%= url_for %>" method="get" class="dropdown dropdown-top hidden md:inline">
<label tabindex="0" class="btn btn-sm h-10">
<%= svg_icon('arrow_sort', class: 'w-5 h-5') %>
</label>
<ul tabindex="0" class="dropdown-content z-[10] menu p-2 shadow bg-base-100 rounded-box mb-1 min-w-48">
<toggle-cookies data-value="created_at" data-key="dashboard_templates_order">
<li>
<button class="<%= 'bg-base-200' if dashboard_templates_order == 'created_at' %>">
<%= svg_icon('sort_descending_numbers', class: 'w-4 h-4') %>
<span class="whitespace-nowrap"><%= t('newest_first') %></span>
</button>
</li>
</toggle-cookies>
<% if local_assigns[:with_recently_used] != false %>
<toggle-cookies data-value="used_at" data-key="dashboard_templates_order">
<li>
<button class="<%= 'bg-base-200' if dashboard_templates_order == 'used_at' %>">
<%= svg_icon('sort_descending_small_big', class: 'w-4 h-4') %>
<span class="whitespace-nowrap"><%= t('recently_used') %></span>
</button>
</li>
</toggle-cookies>
<% end %>
<toggle-cookies data-value="name" data-key="dashboard_templates_order">
<li>
<button class="<%= 'bg-base-200' if dashboard_templates_order == 'name' %>">
<%= svg_icon('sort_ascending_letters', class: 'w-4 h-4') %>
<span class="whitespace-nowrap"><%= t('name_a_z') %></span>
</button>
</li>
</toggle-cookies>
</ul>
</form>
@@ -38,6 +38,8 @@
<%= t('submission_event_names.invite_party_by_html', invited_submitter_name: [invited_submitter.name || invited_submitter.email || invited_submitter.phone, name].join(' '), submitter_name:) %> <%= t('submission_event_names.invite_party_by_html', invited_submitter_name: [invited_submitter.name || invited_submitter.email || invited_submitter.phone, name].join(' '), submitter_name:) %>
<% elsif event.event_type.include?('send_') %> <% elsif event.event_type.include?('send_') %>
<%= t("submission_event_names.#{event.event_type}_to_html", submitter_name:) %> <%= t("submission_event_names.#{event.event_type}_to_html", submitter_name:) %>
<% elsif event.event_type.start_with?('bounce_') || event.event_type.start_with?('complaint_') %>
<%= t("submission_event_names.#{event.event_type}_html", submitter_name:) %>
<% else %> <% else %>
<%= t("submission_event_names.#{event.event_type}_by_html", submitter_name:) %> <%= t("submission_event_names.#{event.event_type}_by_html", submitter_name:) %>
<% end %> <% end %>
@@ -31,7 +31,7 @@
</submitters-autocomplete> </submitters-autocomplete>
<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? %>">
<input type="tel" pattern="^\+[0-9\s\-]+$" oninvalid="this.value ? this.setCustomValidity('<%= t('use_international_format_1xxx_') %>') : ''" oninput="this.setCustomValidity('')" name="submission[1][submitters][][phone]" autocomplete="off" class="base-input !h-10 mt-1.5 w-full" placeholder="<%= "#{t('phone')} (#{t('optional')})" %>" id="detailed_phone_<%= item['uuid'] %>"> <%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', oninvalid: "this.value ? this.setCustomValidity('#{t('use_international_format_1xxx_')}') : ''", oninput: "this.setCustomValidity('')", name: 'submission[1][submitters][][phone]', autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: local_assigns[:require_phone_2fa] == true ? t(:phone) : "#{t('phone')} (#{t('optional')})", id: "detailed_phone_#{item['uuid']}", required: local_assigns[:require_phone_2fa] == true %>
</linked-input> </linked-input>
</submitters-autocomplete> </submitters-autocomplete>
</div> </div>
+7
View File
@@ -0,0 +1,7 @@
<div id="submitters_error">
<% if local_assigns[:error] %>
<div class="text-center text-red-500">
<%= local_assigns[:error] %>
</div>
<% end %>
</div>
+9 -6
View File
@@ -1,8 +1,9 @@
<% align = field.dig('preferences', 'align') %> <% align = field.dig('preferences', 'align') %>
<% valign = field.dig('preferences', 'valign') %>
<% color = field.dig('preferences', 'color') %> <% color = field.dig('preferences', 'color') %>
<% font = field.dig('preferences', 'font') %> <% font = field.dig('preferences', 'font') %>
<% font_type = field.dig('preferences', 'font_type') %> <% font_type = field.dig('preferences', 'font_type') %>
<field-value dir="auto" class="flex absolute text-[1.6vw] lg:text-base <%= 'font-mono' if font == 'Courier' %> <%= 'font-serif' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'justify-end' : (align == 'center' ? 'justify-center' : '') %>" style="<%= "color: #{color}; " if color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; <%= "font-size: clamp(4pt, 1.6vw, #{field['preferences']['font_size'].to_i * 1.23}pt); line-height: `clamp(6pt, 2.0vw, #{(field['preferences']['font_size'].to_i * 1.23) + 3}pt)`" if field.dig('preferences', 'font_size') %>"> <field-value dir="auto" class="flex absolute text-[1.6vw] lg:text-base <%= 'font-mono' if font == 'Courier' %> <%= 'font-serif' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'text-right' : (align == 'center' ? 'text-center' : '') %>" style="<%= "color: #{color}; " if color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; <%= "font-size: clamp(4pt, 1.6vw, #{field['preferences']['font_size'].to_i * 1.23}pt); line-height: `clamp(6pt, 2.0vw, #{(field['preferences']['font_size'].to_i * 1.23) + 3}pt)`" if field.dig('preferences', 'font_size') %>">
<% if field['type'] == 'signature' %> <% if field['type'] == 'signature' %>
<div class="flex flex-col justify-between h-full overflow-hidden"> <div class="flex flex-col justify-between h-full overflow-hidden">
<div class="flex-grow flex overflow-hidden" style="min-height: 50%"> <div class="flex-grow flex overflow-hidden" style="min-height: 50%">
@@ -61,17 +62,19 @@
</div> </div>
<% elsif field['type'] == 'date' %> <% elsif field['type'] == 'date' %>
<autosize-field></autosize-field> <autosize-field></autosize-field>
<div class="flex items-center px-0.5"> <div class="flex w-full px-0.5 <%= valign == 'top' ? 'items-start' : (valign == 'bottom' ? 'items-end' : 'items-center') %>">
<% value = Time.current.in_time_zone(local_assigns[:timezone]).to_date.to_s if value == '{{date}}' %> <% value = Time.current.in_time_zone(local_assigns[:timezone]).to_date.to_s if value == '{{date}}' %>
<%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), local_assigns[:locale]) %> <div class="w-full"><%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), local_assigns[:locale]) %></div>
</div> </div>
<% elsif field['type'] == 'number' %> <% elsif field['type'] == 'number' %>
<autosize-field></autosize-field> <autosize-field></autosize-field>
<div class="flex items-center px-0.5 whitespace-nowrap"> <div class="flex w-full px-0.5 whitespace-nowrap <%= valign == 'top' ? 'items-start' : (valign == 'bottom' ? 'items-end' : 'items-center') %>">
<%= NumberUtils.format_number(value, field.dig('preferences', 'format')) %> <div class="w-full"><%= NumberUtils.format_number(value, field.dig('preferences', 'format')) %></div>
</div> </div>
<% else %> <% else %>
<autosize-field></autosize-field> <autosize-field></autosize-field>
<div class="flex items-center px-0.5 whitespace-pre-wrap"><%= Array.wrap(value).join(', ') %></div> <div class="flex w-full px-0.5 whitespace-pre-wrap <%= valign == 'top' ? 'items-start' : (valign == 'bottom' ? 'items-end' : 'items-center') %>">
<div class="w-full"><%= Array.wrap(value).join(', ') %></div>
</div>
<% end %> <% end %>
</field-value> </field-value>
+14 -10
View File
@@ -1,10 +1,11 @@
<% require_phone_2fa = @template.preferences['require_phone_2fa'] == true %>
<%= render 'shared/turbo_modal_large', title: params[:selfsign] ? t('add_recipients') : t('add_new_recipients') do %> <%= render 'shared/turbo_modal_large', title: params[:selfsign] ? t('add_recipients') : t('add_new_recipients') do %>
<% options = [[t('via_email'), 'email'], [t('via_phone'), 'phone'], [t('detailed'), 'detailed'], [t('upload_list'), 'list']].compact %> <% options = [require_phone_2fa ? nil : [t('via_email'), 'email'], require_phone_2fa ? nil : [t('via_phone'), 'phone'], [t('detailed'), 'detailed'], [t('upload_list'), 'list']].compact %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center px-2 mt-4 block"> <toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center px-2 mt-4 block">
<div class="flex justify-center"> <div class="flex justify-center">
<% options.each_with_index do |(label, value), index| %> <% options.each_with_index do |(label, value), index| %>
<div> <div>
<%= radio_button_tag 'option', value, value == 'email', class: 'peer hidden', data: { action: 'change:toggle-visible#trigger' } %> <%= radio_button_tag 'option', value, value == (require_phone_2fa ? 'detailed' : 'email'), class: 'peer hidden', data: { action: 'change:toggle-visible#trigger' } %>
<label for="option_<%= value %>" class="block bg-base-200 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 peer-checked:bg-base-300 <%= 'hidden sm:inline-block' if value == 'list' %> <%= 'rounded-l-3xl' if index.zero? %> <%= 'rounded-r-3xl sm:rounded-r-none' if value == 'detailed' %> <%= 'rounded-r-3xl' if index == options.size - 1 %>"> <label for="option_<%= value %>" class="block bg-base-200 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 peer-checked:bg-base-300 <%= 'hidden sm:inline-block' if value == 'list' %> <%= 'rounded-l-3xl' if index.zero? %> <%= 'rounded-r-3xl sm:rounded-r-none' if value == 'detailed' %> <%= 'rounded-r-3xl' if index == options.size - 1 %>">
<%= label %> <%= label %>
</label> </label>
@@ -13,18 +14,21 @@
</div> </div>
</toggle-visible> </toggle-visible>
<div class="px-5 mb-5 mt-4"> <div class="px-5 mb-5 mt-4">
<div id="email"> <% unless require_phone_2fa %>
<%= render 'email_form', template: @template %> <div id="email">
</div> <%= render 'email_form', template: @template %>
<div id="phone" class="hidden"> </div>
<%= render 'phone_form', template: @template %> <div id="phone" class="hidden">
</div> <%= render 'phone_form', template: @template %>
<div id="detailed" class="hidden"> </div>
<%= render 'detailed_form', template: @template %> <% end %>
<div id="detailed" class="<%= 'hidden' unless require_phone_2fa %>">
<%= render 'detailed_form', template: @template, require_phone_2fa: %>
</div> </div>
<div id="list" class="hidden"> <div id="list" class="hidden">
<%= render 'list_form', template: @template %> <%= render 'list_form', template: @template %>
</div> </div>
<%= render 'submissions/error' %>
</div> </div>
<%= content_for(:modal_extra) %> <%= content_for(:modal_extra) %>
<% end %> <% end %>
+16 -2
View File
@@ -172,12 +172,26 @@
</div> </div>
<% end %> <% end %>
<div class="flex items-center space-x-1 mt-1"> <div class="flex items-center space-x-1 mt-1">
<%= svg_icon('writing', class: 'w-5 h-5') %> <% if @submission.expire_at? && submitter && !submitter.completed_at? %>
<%= svg_icon('clock_exclamation', class: 'w-5 h-5') %>
<% else %>
<%= svg_icon('writing', class: 'w-5 h-5') %>
<% end %>
<span> <span>
<% if submitter&.declined_at? %> <% if submitter&.declined_at? %>
<%= t('declined_on_time', time: l(submitter.declined_at.in_time_zone(@submission.account.timezone), format: :short, locale: @submission.account.locale)) %> <%= t('declined_on_time', time: l(submitter.declined_at.in_time_zone(@submission.account.timezone), format: :short, locale: @submission.account.locale)) %>
<% elsif submitter %> <% elsif submitter %>
<%= submitter.completed_at? ? l(submitter.completed_at.in_time_zone(@submission.account.timezone), format: :long, locale: @submission.account.locale) : t('not_completed_yet') %> <% if submitter.completed_at? %>
<%= l(submitter.completed_at.in_time_zone(@submission.account.timezone), format: :long, locale: @submission.account.locale) %>
<% elsif @submission.expire_at? %>
<% if @submission.expired? %>
<%= t(:expired) %>
<% else %>
<%= t('expire_on_time', time: l(@submission.expire_at.in_time_zone(@submission.account.timezone), format: :short, locale: @submission.account.locale)) %>
<% end %>
<% else %>
<%= t('not_completed_yet') %>
<% end %>
<% else %> <% else %>
<%= t('not_invited_yet') %> <%= t('not_invited_yet') %>
<% end %> <% end %>
@@ -57,6 +57,7 @@
<div class="flex items-end flex-col md:flex-row gap-2 w-full md:w-fit"> <div class="flex items-end flex-col md:flex-row gap-2 w-full md:w-fit">
<%= render 'submissions_filters/applied_filters', filter_params: %> <%= render 'submissions_filters/applied_filters', filter_params: %>
<%= render 'submissions_filters/filter_button', filter_params: %> <%= render 'submissions_filters/filter_button', filter_params: %>
<%= render 'submissions_filters/actions', filter_params: %>
</div> </div>
</div> </div>
<% end %> <% end %>
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff. Show more