Compare commits

..
87 Commits
Author SHA1 Message Date
Alex Turchyn 019222ae40 Merge from docusealco/wip 2025-05-19 13:07:42 +03:00
Pete Matsyburka 8a10852fe2 docs 2025-05-18 17:38:51 +03:00
Pete Matsyburka 01232aed5a add account access 2025-05-18 15:16:53 +03:00
Alex Turchyn 82f3ff4824 fix narrow signatures 2025-05-18 09:44:55 +03:00
Pete Matsyburka 7be3f5a931 fix android 2025-05-16 18:28:55 +03:00
Pete Matsyburka 9d013a7384 fix email normalize 2025-05-15 22:16:06 +03:00
Alex Turchyn 0c71d7bff8 fix EU server selection note 2025-05-15 18:05:07 +03:00
Pete Matsyburka fdd030652e adjust border 2025-05-15 14:39:25 +03:00
Pete Matsyburka f2bf1461f0 adjust selector 2025-05-15 13:50:02 +03:00
Alex Turchyn fc6f796f30 adjust server selector 2025-05-15 13:16:43 +03:00
Alex Turchyn 5f44806287 adjust dropoze border style 2025-05-15 13:16:40 +03:00
Pete Matsyburka 318b65af18 fix i18n 2025-05-14 21:28:52 +03:00
Pete Matsyburka 91b182b04a adjust email validate 2025-05-14 20:40:59 +03:00
Pete Matsyburka 15b886d4ff update reminder durations 2025-05-14 20:22:34 +03:00
Pete Matsyburka 0122a237e3 remove request helper 2025-05-14 13:21:57 +03:00
Alex Turchyn 2dca7d8a8e add builder 'replace' button specs 2025-05-14 13:06:03 +03:00
Pete Matsyburka 87265e2f22 fix prefill same name field 2025-05-14 13:04:35 +03:00
Pete Matsyburka 3166defa1a add submitter value var 2025-05-13 12:27:27 +03:00
Pete Matsyburka 93ba17f42e expire at webhook 2025-05-12 18:23:17 +03:00
Pete Matsyburka 751be1dae5 fix template filter 2025-05-12 15:38:13 +03:00
Pete Matsyburka 3f8d2831e2 allow double dash email 2025-05-12 13:22:11 +03:00
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
155 changed files with 37269 additions and 509 deletions

No files matched your search

+7 -7
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)
+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
+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 -11
View File
@@ -11,17 +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
if params.key?(:archived)
submissions = params[:archived].in?(['true', true]) ? submissions.archived : submissions.active
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,
@@ -115,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(submissions: { 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"] })
+17 -4
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
@@ -40,9 +43,11 @@ class StartFormController < ApplicationController
if @submitter.save if @submitter.save
if is_new_record if is_new_record
WebhookUrls.for_account_id(@submitter.account_id, 'submission.created').each do |webhook_url| enqueue_submission_create_webhooks(@submitter)
SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => @submitter.submission_id,
'webhook_url_id' => webhook_url.id) if @submitter.submission.expire_at?
ProcessSubmissionExpiredJob.perform_at(@submitter.submission.expire_at,
'submission_id' => @submitter.submission_id)
end end
end end
@@ -61,6 +66,13 @@ class StartFormController < ApplicationController
private private
def enqueue_submission_create_webhooks(submitter)
WebhookUrls.for_account_id(submitter.account_id, 'submission.created').each do |webhook_url|
SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id,
'webhook_url_id' => webhook_url.id)
end
end
def find_or_initialize_submitter(template, submitter_params) def find_or_initialize_submitter(template, submitter_params)
Submitter.where(submission: template.submissions.where(expire_at: Time.current..) Submitter.where(submission: template.submissions.where(expire_at: Time.current..)
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil)) .or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
@@ -94,6 +106,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,20 +8,20 @@ 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 return head :not_found unless last_submitter
@@ -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]
+13 -13
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?
@@ -50,44 +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 rescue Submitters::SubmitValues::RequiredFieldError => e
Rollbar.warning("Required field #{submitter.id}: #{e.message}") if defined?(Rollbar) Rollbar.warning("Required field #{@submitter.id}: #{e.message}") if defined?(Rollbar)
render json: { field_uuid: e.message }, status: :unprocessable_entity 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'
+1 -1
View File
@@ -16,7 +16,7 @@ class UsersController < ApplicationController
@users.active.where.not(role: 'integration') @users.active.where.not(role: 'integration')
end end
@pagy, @users = pagy(@users.where(account: current_account).order(id: :desc)) @pagy, @users = pagy(@users.preload(account: :account_accesses).where(account: current_account).order(id: :desc))
end end
def new; end def new; end
+3
View File
@@ -33,6 +33,7 @@ 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 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'
@@ -101,6 +102,7 @@ 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('app-tour', AppTour)
safeRegisterElement('dashboard-dropzone', DashboardDropzone)
safeRegisterElement('template-builder', class extends HTMLElement { safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {
@@ -125,6 +127,7 @@ 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' showTourStartForm: this.dataset.showTourStartForm === 'true'
@@ -0,0 +1,222 @@
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'
if (this.classList.contains('before:border-base-300')) {
this.classList.remove('before:border-base-300')
this.classList.add('before:border-base-content/30')
} else if (this.classList.contains('border-base-300')) {
this.classList.remove('border-base-300')
this.classList.add('border-base-content/30')
}
}
}
onDragleave () {
this.style.backgroundColor = null
if (this.classList.contains('before:border-base-content/30')) {
this.classList.remove('before:border-base-content/30')
this.classList.add('before:border-base-300')
} else if (this.classList.contains('border-base-content/30')) {
this.classList.remove('border-base-content/30')
this.classList.add('border-base-300')
}
}
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')
}
}
+22 -4
View File
@@ -5,19 +5,37 @@ export default actionable(targetable(class extends HTMLElement {
static [target.static] = [ static [target.static] = [
'loading', 'loading',
'icon', 'icon',
'input' 'input',
'area'
] ]
connectedCallback () { connectedCallback () {
this.addEventListener('drop', this.onDrop)
this.addEventListener('dragover', (e) => e.preventDefault()) this.addEventListener('dragover', (e) => e.preventDefault())
this.addEventListener('drop', this.onDrop)
document.addEventListener('turbo:submit-end', this.toggleLoading) document.addEventListener('turbo:submit-end', this.toggleLoading)
this.area?.addEventListener('dragover', this.onDragover)
this.area?.addEventListener('dragleave', this.onDragleave)
} }
disconnectedCallback () { disconnectedCallback () {
this.removeEventListener('drop', this.onDrop)
document.removeEventListener('turbo:submit-end', this.toggleLoading) document.removeEventListener('turbo:submit-end', this.toggleLoading)
this.area?.removeEventListener('dragover', this.onDragover)
this.area?.removeEventListener('dragleave', this.onDragleave)
}
onDragover (e) {
if (e.dataTransfer?.types?.includes('Files')) {
this.style.backgroundColor = '#F7F3F0'
this.classList.remove('border-base-300', 'hover:bg-base-200/30')
this.classList.add('border-base-content/30')
}
}
onDragleave () {
this.style.backgroundColor = null
this.classList.remove('border-base-content/30')
this.classList.add('border-base-300', 'hover:bg-base-200/30')
} }
onDrop (e) { onDrop (e) {
+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')
+15 -6
View File
@@ -55,10 +55,12 @@
> >
<div <div
v-else-if="field.type === 'signature' && signature" v-else-if="field.type === 'signature' && signature"
class="flex flex-col justify-between h-full overflow-hidden" class="flex justify-between h-full gap-1 overflow-hidden"
:class="isNarrow ? 'flex-row' : 'flex-col'"
> >
<div <div
class="flex-grow flex overflow-hidden" class="flex overflow-hidden"
:class="isNarrow ? 'w-1/2' : 'flex-grow'"
style="min-height: 50%" style="min-height: 50%"
> >
<img <img
@@ -68,7 +70,8 @@
</div> </div>
<div <div
v-if="withSignatureId" v-if="withSignatureId"
class="w-full mt-1 text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem]" class="text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem]"
:class="isNarrow ? 'w-1/2' : 'w-full'"
> >
<div class="truncate uppercase"> <div class="truncate uppercase">
ID: {{ signature.uuid }} ID: {{ signature.uuid }}
@@ -183,7 +186,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 +330,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 () {
@@ -441,6 +447,9 @@ export default {
} }
return style return style
},
isNarrow () {
return this.area.h > 0 && (this.area.w / this.area.h) > 6
} }
}, },
watch: { watch: {
+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 () {
@@ -1,4 +1,4 @@
function cropCanvasAndExportToPNG (canvas) { function cropCanvasAndExportToPNG (canvas, { errorOnTooSmall } = { errorOnTooSmall: false }) {
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
const width = canvas.width const width = canvas.width
@@ -33,6 +33,10 @@ function cropCanvasAndExportToPNG (canvas) {
croppedCanvas.height = croppedHeight croppedCanvas.height = croppedHeight
const croppedCtx = croppedCanvas.getContext('2d') const croppedCtx = croppedCanvas.getContext('2d')
if (errorOnTooSmall && (croppedWidth < 20 || croppedHeight < 20)) {
return Promise.reject(new Error('Image too small'))
}
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight) croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
+3 -3
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])"
@@ -93,7 +93,7 @@
<div <div
v-show="isFormVisible" v-show="isFormVisible"
id="form_container" id="form_container"
class="shadow-md bg-base-100 absolute bottom-0 w-full border-base-200 border p-4 rounded form-container" class="shadow-md bg-base-100 absolute bottom-0 w-full border-base-200 border p-4 rounded form-container overflow-hidden"
:class="{ 'md:bottom-4': isBreakpointMd }" :class="{ 'md:bottom-4': isBreakpointMd }"
:style="{ backgroundColor: backgroundColor }" :style="{ backgroundColor: backgroundColor }"
> >
@@ -1133,7 +1133,7 @@ export default {
parent.style.overflow = 'hidden' parent.style.overflow = 'hidden'
scrollbox.classList.add('h-full', 'overflow-y-auto') scrollbox.classList.add('h-full', 'overflow-y-auto')
scrollbox.parentNode.classList.add('h-screen', 'overflow-y-auto') scrollbox.parentNode.classList.add('h-screen', 'h-[100dvh]', 'overflow-y-auto')
scrollbox.parentNode.style.maxHeight = '-webkit-fill-available' scrollbox.parentNode.style.maxHeight = '-webkit-fill-available'
}) })
} }
+117 -6
View File
@@ -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',
@@ -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',
@@ -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',
@@ -393,8 +396,8 @@ const de = {
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',
@@ -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_or_simple_please_redraw: 'Podpis jest zbyt mały lub zbyt prosty. 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-значний код',
@@ -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',
@@ -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',
@@ -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}"، أنت توافق على',
@@ -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: '서명',
@@ -1273,6 +1285,105 @@ const ko = {
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
@@ -339,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) {
@@ -373,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({})
}
}) })
}) })
} }
@@ -683,13 +683,17 @@ export default {
} }
if (this.isSignatureStarted && this.pad.toData().length > 0 && !isValidSignatureCanvas(this.pad.toData())) { if (this.isSignatureStarted && this.pad.toData().length > 0 && !isValidSignatureCanvas(this.pad.toData())) {
alert(this.t('signature_is_too_small_or_simple_please_redraw')) 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')) return Promise.reject(new Error('Image too small or simple'))
} else {
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], 'signature.png', { type: 'image/png' }) const file = new File([blob], 'signature.png', { type: 'image/png' })
if (this.dryRun) { if (this.dryRun) {
@@ -725,6 +729,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({})
}
}) })
}) })
} }
+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',
+56 -1
View File
@@ -5,7 +5,19 @@
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" @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"
@add="[updateFromUpload($event), isDragFile = false]"
@replace="[onDocumentsReplace($event), isDragFile = false]"
@replace-and-clone="onDocumentsReplaceAndTemplateClone($event)"
@error="[onUploadFailed($event), isDragFile = false]"
/>
<DragPlaceholder <DragPlaceholder
ref="dragPlaceholder" ref="dragPlaceholder"
:field="fieldsDragFieldRef.value || toRaw(dragField)" :field="fieldsDragFieldRef.value || toRaw(dragField)"
@@ -433,6 +445,7 @@
<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 DragPlaceholder from './drag_placeholder'
import Fields from './fields' import Fields from './fields'
import MobileDrawField from './mobile_draw_field' import MobileDrawField from './mobile_draw_field'
@@ -462,6 +475,7 @@ export default {
MobileFields, MobileFields,
Logo, Logo,
Dropzone, Dropzone,
HoverDropzone,
DocumentPreview, DocumentPreview,
DocumentControls, DocumentControls,
IconInnerShadowTop, IconInnerShadowTop,
@@ -665,6 +679,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,
@@ -724,7 +743,8 @@ export default {
copiedArea: null, copiedArea: null,
drawFieldType: null, drawFieldType: null,
drawOption: null, drawOption: null,
dragField: null dragField: null,
isDragFile: false
} }
}, },
computed: { computed: {
@@ -836,6 +856,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')) {
@@ -854,6 +875,7 @@ 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 = []
@@ -868,6 +890,13 @@ export default {
ref.x = e.clientX - ref.offsetX ref.x = e.clientX - ref.offsetX
ref.y = e.clientY - ref.offsetY ref.y = e.clientY - ref.offsetY
} else if (e.dataTransfer?.types?.includes('Files')) {
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) {
@@ -1529,6 +1558,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)
@@ -1649,6 +1681,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)
} }
+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/50': withHoverClass && !isDragEntering, 'bg-base-200/50 border-base-content/30': isDragEntering }"
> >
<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
}, },
withHoverClass: {
type: Boolean,
required: false,
default: true
},
icon: {
type: String,
required: false,
default: 'IconCloudUpload'
},
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) {
@@ -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"
+2 -2
View File
@@ -112,7 +112,7 @@
: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`" :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"
@@ -168,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 }"
+43 -7
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',
@@ -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"
icon="IconFilePlus"
:template-id="templateId"
:accept-file-types="acceptFileTypes"
:with-hover-class="false"
:with-description="false"
:title="t('add_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"
icon="IconFileSymlink"
:template-id="templateId"
:accept-file-types="acceptFileTypes"
:with-hover-class="false"
: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"
icon="IconFiles"
:template-id="templateId"
:accept-file-types="acceptFileTypes"
:with-hover-class="false"
: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>
+18
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',
add_a_new_document: 'Add 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',
@@ -225,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',
add_a_new_document: 'Agregar 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',
@@ -393,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',
add_a_new_document: 'Aggiungi 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',
@@ -555,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',
add_a_new_document: 'Ajouter un nouveau document',
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',
@@ -719,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',
add_a_new_document: 'Adicionar um novo documento',
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',
@@ -884,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',
add_a_new_document: 'Neues Dokument hinzufügen',
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',
@@ -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"
+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
}) })
} }
+1
View File
@@ -33,6 +33,7 @@ class Account < ApplicationRecord
has_many :account_linked_accounts, dependent: :destroy has_many :account_linked_accounts, dependent: :destroy
has_many :email_events, dependent: :destroy has_many :email_events, dependent: :destroy
has_many :webhook_urls, dependent: :destroy has_many :webhook_urls, dependent: :destroy
has_many :account_accesses, dependent: :destroy
has_many :account_testing_accounts, -> { testing }, dependent: :destroy, has_many :account_testing_accounts, -> { testing }, dependent: :destroy,
class_name: 'AccountLinkedAccount', class_name: 'AccountLinkedAccount',
inverse_of: :account inverse_of: :account
+24
View File
@@ -0,0 +1,24 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: account_accesses
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# user_id :bigint not null
#
# Indexes
#
# index_account_accesses_on_account_id_and_user_id (account_id,user_id) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
#
class AccountAccess < ApplicationRecord
belongs_to :account
belongs_to :user
end
+4
View File
@@ -90,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
+51 -11
View File
@@ -1,12 +1,52 @@
<div class="text-center"> <% eu_server = request.host == 'docuseal.eu' %>
<div class="join"> <server-selector>
<a href="https://docuseal.com<%= request.fullpath.gsub('docuseal.eu', 'docuseal.com') %>" class="btn bg-base-200 join-item w-32 <%= 'bg-base-300' if request.host == 'docuseal.com' %>"> <div id="global_server_selector" class="text-center hidden">
<%= svg_icon 'world', class: 'w-5 h-5' %> <div class="join">
Global <a href="https://docuseal.com<%= request.fullpath.gsub('docuseal.eu', 'docuseal.com') %>" class="btn bg-base-200 join-item w-40 <%= 'bg-base-300' unless eu_server %>">
</a> <%= svg_icon 'world', class: 'w-5 h-5' %>
<a href="https://docuseal.eu<%= request.fullpath.gsub('docuseal.com', 'docuseal.eu') %>" class="btn bg-base-200 join-item w-32 <%= 'bg-base-300' if request.host == 'docuseal.eu' %>"> Global
<%= svg_icon 'eu_flag', class: 'w-5 h-5' %> </a>
Europe <a href="https://docuseal.eu<%= request.fullpath.gsub('docuseal.com', 'docuseal.eu') %>" class="relative btn bg-base-200 join-item w-40 <%= 'bg-base-300' if eu_server %>">
</a> <%= svg_icon 'eu_flag', class: 'w-5 h-5' %>
Europe
<% unless eu_server %>
<span id="eu_server_alert" class="absolute flex space-x-0.5 hidden" style="top: -1.5rem;">
<span class="text-xs font-normal leading-none text-base-content normal-case">
<%= t('eu_data_residency') %>
</span>
<%= svg_icon 'corner_right_down', class: 'w-4 h-5 stroke-1 shrink-0 pt-1' %>
</span>
<% end %>
</a>
</div>
</div> </div>
</div> <div id="us_server_selector" class="flex justify-center hidden">
<div class="dropdown">
<label tabindex="0" class="relative btn btn-sm bg-transparent font-medium normal-case border-base-content/20 justify-start" style="width: 141px; padding: 0 20px">
<% if eu_server %>
<%= svg_icon 'eu_flag', class: 'w-5 h-5' %>
<span>EU Cloud</span>
<% else %>
<%= svg_icon 'usa_flag', class: 'w-5 h-5' %>
<span>US Cloud</span>
<% end %>
<%= svg_icon 'chevron_down', class: 'mr-1 w-4 h-4 absolute right-1' %>
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu border border-base-content/20 mt-1 bg-base-100 rounded-box w-36">
<li>
<a href="https://docuseal.com<%= request.fullpath.gsub('docuseal.eu', 'docuseal.com') %>" class="flex items-center space-x-2 <%= 'bg-base-300' unless eu_server %>">
<%= svg_icon 'usa_flag', class: 'w-5 h-5' %>
US Cloud
</a>
</li>
<li>
<a href="https://docuseal.eu<%= request.fullpath.gsub('docuseal.com', 'docuseal.eu') %>" class="flex items-center space-x-2 <%= 'bg-base-300' if eu_server %>">
<%= svg_icon 'eu_flag', class: 'w-5 h-5' %>
EU Cloud
</a>
</li>
</ul>
</div>
</div>
</server-selector>
<%= render 'scripts/server_selector' %>
+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

@@ -0,0 +1,3 @@
<svg class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
<path d="M6 6h6a3 3 0 0 1 3 3v10l-4 -4m8 0l-4 4"></path>
</svg>

After

Width:  |  Height:  |  Size: 285 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

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" viewBox="0 0 640 480">
<path fill="#bd3d44" d="M0 0h640v480H0" />
<path stroke="#fff" stroke-width="37" d="M0 55.3h640M0 129h640M0 203h640M0 277h640M0 351h640M0 425h640" />
<path fill="#192f5d" d="M0 0h364.8v258.5H0" />
<marker id="um-a" markerHeight="30" markerWidth="30">
<path fill="#fff" d="m14 0 9 27L0 10h28L5 27z" />
</marker>
<path fill="none" marker-mid="url(#um-a)" d="m0 0 16 11h61 61 61 61 60L47 37h61 61 60 61L16 63h61 61 61 61 60L47 89h61 61 60 61L16 115h61 61 61 61 60L47 141h61 61 60 61L16 166h61 61 61 61 60L47 192h61 61 60 61L16 218h61 61 61 61 60z" />
</svg>

After

Width:  |  Height:  |  Size: 671 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>
+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">
@@ -0,0 +1,22 @@
<script>
if (!window.customElements.get('server-selector')) {
customElements.define('server-selector', class extends HTMLElement {
connectedCallback() {
const usServerSelector = this.querySelector('#us_server_selector');
const globalServerSelector = this.querySelector('#global_server_selector');
const euServerAlert = this.querySelector('#eu_server_alert');
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const usTimezones = /^(?:America\/(?:New_York|Detroit|Kentucky|Indiana|Chicago|Menominee|North_Dakota|Denver|Boise|Phoenix|Los_Angeles|Anchorage|Juneau|Sitka|Metlakatla|Yakutat|Nome|Adak)|Pacific\/Honolulu)/;
if (usTimezones.test(timezone)) {
usServerSelector.classList.remove('hidden');
} else if (timezone.includes('Europe')) {
globalServerSelector.classList.remove('hidden');
euServerAlert?.classList?.remove('hidden');
} else {
globalServerSelector.classList.remove('hidden');
}
}
});
}
</script>
+1 -1
View File
@@ -1,5 +1,5 @@
<% if current_user.created_at > 2.weeks.ago || params[:tour] == 'true' %> <% if current_user.created_at > 2.weeks.ago || params[:tour] == 'true' %>
<% user_config = current_user.user_configs.find_or_initialize_by(key: UserConfig::SHOW_APP_TOUR) %> <% 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' %> <% 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> <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 dropdown-open' %> <% 'stats stat stat-figure stat-title stat-value text-accent w-fit hover:bg-white dropdown-open border-base-content/30 before:border-base-content/30' %>
+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">
+8
View File
@@ -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 %>
@@ -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>
+13 -9
View File
@@ -1,15 +1,17 @@
<% 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"> <% is_narrow = area['h']&.positive? && (area['w'].to_f / area['h']) > 6 %>
<div class="flex-grow flex overflow-hidden" style="min-height: 50%"> <div class="flex justify-between w-full h-full gap-1 <%= is_narrow ? 'flex-row' : 'flex-col' %>">
<div class="flex overflow-hidden <%= is_narrow ? 'w-1/2' : 'flex-grow' %>" style="min-height: 50%">
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>"> <img class="object-contain mx-auto" src="<%= attachments_index[value].url %>">
</div> </div>
<% if local_assigns[:with_signature_id] && attachment = attachments_index[value] %> <% if local_assigns[:with_signature_id] && attachment = attachments_index[value] %>
<div class="w-full mt-1 text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem]"> <div class="text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem] <%= is_narrow ? 'w-1/2' : 'w-full' %>">
<div class="truncate uppercase"> <div class="truncate uppercase">
ID: <%= attachment.uuid %> ID: <%= attachment.uuid %>
</div> </div>
@@ -61,17 +63,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 %>
Whitespace-only changes.
+24 -3
View File
@@ -8,17 +8,18 @@
<div style="max-height: -webkit-fill-available;"> <div style="max-height: -webkit-fill-available;">
<div id="scrollbox"> <div id="scrollbox">
<div class="mx-auto block pb-72" style="max-width: 1000px"> <div class="mx-auto block pb-72" style="max-width: 1000px">
<%# flex block w-full sticky top-0 z-50 space-x-2 items-center bg-yellow-100 p-2 border-y border-yellow-200 %> <%# flex block w-full sticky top-0 z-50 space-x-2 items-center bg-yellow-100 p-2 border-y border-yellow-200 transition-transform duration-300 %>
<%= local_assigns[:banner_html] || capture do %> <%= local_assigns[:banner_html] || capture do %>
<%= render('submit_form/banner') %> <%= render('submit_form/banner') %>
<div class="sticky top-0 z-50 bg-base-100 py-2 px-2 flex items-center md:-mx-[8px]" style="margin-bottom: -16px"> <div id="signing_form_header" class="sticky min-[1230px]:static top-0 z-50 bg-base-100 py-2 px-2 flex items-center md:-mx-[8px]" style="margin-bottom: -16px">
<div class="text-xl md:text-2xl font-medium focus:text-clip" style="width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"> <div class="text-xl md:text-2xl font-medium focus:text-clip" style="width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<%= @submitter.submission.template.name %> <%= @submitter.submission.template.name %>
</div> </div>
<div class="flex items-center space-x-2" style="margin-left: 20px; flex-shrink: 0"> <div class="flex items-center space-x-2" style="margin-left: 20px; flex-shrink: 0">
<% if @form_configs[:with_decline] %> <% if @form_configs[:with_decline] %>
<% decline_modal_checkbox_uuid = SecureRandom.uuid %>
<div> <div>
<%= render 'shared/html_modal', title: t(:decline), btn_text: t(:decline), btn_class: 'btn btn-sm !px-5', button_id: 'decline_button' do %> <%= render 'shared/html_modal', title: t(:decline), btn_text: t(:decline), btn_class: 'btn btn-sm !px-5', button_id: 'decline_button', uuid: decline_modal_checkbox_uuid do %>
<%= render 'submit_form/decline_form', submitter: @submitter %> <%= render 'submit_form/decline_form', submitter: @submitter %>
<% end %> <% end %>
</div> </div>
@@ -35,6 +36,26 @@
</download-button> </download-button>
</div> </div>
</div> </div>
<scroll-buttons class="fixed right-5 top-2 hidden md:flex gap-1 z-50 ease-in-out opacity-0 -translate-y-10">
<% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid %>" class="btn btn-sm px-0">
<span class="min-[1366px]:inline hidden px-3">
<%= t(:decline) %>
</span>
<span class="inline min-[1366px]:hidden px-2">
<%= svg_icon('x', class: 'w-5 h-5') %>
</span>
</label>
<% end %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2">
<span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %>
</span>
<span class="hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
</span>
</download-button>
</scroll-buttons>
<% end %> <% end %>
<% schema.each do |item| %> <% schema.each do |item| %>
<% document = @submitter.submission.template_schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %> <% document = @submitter.submission.template_schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %>
+1 -1
View File
@@ -1,5 +1,5 @@
<% is_long = folder.name.size > 32 %> <% is_long = folder.name.size > 32 %>
<a href="<%= folder_path(folder) %>" class="flex h-full flex-col justify-between rounded-2xl py-5 px-6 w-full bg-base-200"> <a href="<%= folder_path(folder) %>" class="flex h-full flex-col justify-between rounded-2xl py-5 px-6 w-full bg-base-200 before:border-2 before:border-base-300 before:border-dashed before:absolute before:left-0 before:right-0 before:top-0 before:bottom-0 before:hidden before:rounded-2xl relative" data-targets="dashboard-dropzone.folderCards">
<% if !is_long %> <% if !is_long %>
<%= svg_icon('folder', class: 'w-6 h-6') %> <%= svg_icon('folder', class: 'w-6 h-6') %>
<% end %> <% end %>
+49 -35
View File
@@ -4,42 +4,56 @@
<span><%= t('home') %></span> <span><%= t('home') %></span>
<% end %> <% end %>
</div> </div>
<div class="flex justify-between items-center w-full mb-4"> <dashboard-dropzone>
<h1 class="text-4xl font-bold flex flex-grow min-w-0 space-x-2 md:flex <%= 'hidden' if params[:q].present? %>"> <div class="relative flex justify-between items-center w-full mb-4">
<%= svg_icon('folder', class: 'w-9 h-9 flex-shrink-0') %> <%= form_for '', url: '', id: form_id = SecureRandom.uuid, method: :post, class: 'hidden', data: { target: 'dashboard-dropzone.form' }, html: { enctype: 'multipart/form-data' } do %>
<span class="peer truncate"> <input name="form_id" value="<%= form_id %>">
<%= @template_folder.name %> <input name="folder_name" value="<%= @template_folder.name %>">
</span> <button type="submit"></button>
<% if can?(:update, @template_folder) && @template_folder.name != TemplateFolder::DEFAULT_NAME %> <input id="dashboard_dropzone_input" name="files[]" type="file" multiple>
<span class="pl-1 opacity-0 hover:opacity-100 peer-hover:opacity-100"> <% end %>
<a href="<%= edit_folder_path(@template_folder) %>" data-turbo-frame="modal"> <%= render 'templates/dashboard_dropzone', style: 'height: 137px' %>
<%= svg_icon('pencil', class: 'w-7 h-7') %> <h1 class="text-2xl truncate md:text-3xl font-bold flex items-center flex-grow min-w-0 space-x-2 md:flex <%= 'hidden' if params[:q].present? %>">
</a> <%= svg_icon('folder', class: 'w-9 h-9 flex-shrink-0') %>
<span class="peer truncate">
<%= @template_folder.name %>
</span> </span>
<% end %> <% if can?(:update, @template_folder) && @template_folder.name != TemplateFolder::DEFAULT_NAME %>
</h1> <span class="opacity-0 hover:opacity-100 peer-hover:opacity-100">
<div class="flex space-x-2"> <a href="<%= edit_folder_path(@template_folder) %>" data-turbo-frame="modal">
<% if params[:q].present? || @pagy.pages > 1 %> <%= svg_icon('pencil', class: 'w-7 h-7') %>
<%= render 'shared/search_input' %> </a>
<% end %> </span>
<% if can?(:create, ::Template) %> <% end %>
<%= render 'templates/upload_button', folder_name: @template_folder.name %> </h1>
<%= link_to new_template_path(folder_name: @template_folder.name), class: 'white-button !border gap-2', data: { turbo_frame: :modal } do %> <div class="flex space-x-2">
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %> <% if params[:q].present? || @pagy.pages > 1 %>
<span class="hidden md:block"><%= t('create') %></span> <%= render 'shared/search_input' %>
<% end %>
<% if can?(:create, ::Template) %>
<%= render 'templates/upload_button', folder_name: @template_folder.name %>
<%= link_to new_template_path(folder_name: @template_folder.name), class: 'white-button !border gap-2', data: { turbo_frame: :modal } do %>
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
<span class="hidden md:block"><%= t('create') %></span>
<% end %>
<% end %> <% end %>
<% end %>
</div>
</div>
<% if @pagy.count > 0 %>
<div class="grid gap-4 md:grid-cols-3">
<%= render partial: 'templates/template', collection: @templates %>
</div>
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates' %>
<% elsif params[:q].present? %>
<div class="text-center">
<div class="mt-16 text-3xl font-semibold">
<%= t('templates_not_found') %>
</div> </div>
</div> </div>
<% end %> <% if @pagy.count > 0 %>
<div class="grid gap-4 md:grid-cols-3">
<%= render partial: 'templates/template', collection: @templates %>
</div>
<% templates_order_select_html = capture do %>
<% if params[:q].blank? && @pagy.pages > 1 %>
<%= render('shared/templates_order_select', with_recently_used: @pagy.count < 10_000) %>
<% end %>
<% end %>
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', right_additional_html: templates_order_select_html %>
<% elsif params[:q].present? %>
<div class="text-center">
<div class="mt-16 text-3xl font-semibold">
<%= t('templates_not_found') %>
</div>
</div>
<% end %>
</dashboard-dropzone>
@@ -0,0 +1,20 @@
<div class="absolute bottom-0 w-full cursor-pointer rounded-xl bg-base-100 border-2 border-base-300 border-dashed hidden z-50" data-target="dashboard-dropzone.fileDropzone" style="<%= local_assigns[:style] %>">
<div class="absolute top-0 right-0 left-0 bottom-0 flex justify-center p-2 items-center pointer-events-none">
<div class="flex flex-col items-center text-center" data-target="dashboard-dropzone.toggleLoading">
<span class="flex flex-col items-center">
<span>
<%= svg_icon('cloud_upload', class: 'w-9 h-9') %>
</span>
<div class="font-medium mb-1">
<%= t('upload_new_document') %>
</div>
</span>
<span class="flex flex-col items-center hidden" data-target="dashboard-dropzone.fileDropzoneLoading">
<%= svg_icon('loader', class: 'w-9 h-9 animate-spin') %>
<div class="font-medium mb-1">
<%= t('uploading') %>...
</div>
</span>
</div>
</div>
</div>
+2 -2
View File
@@ -2,8 +2,8 @@
<input type="hidden" name="form_id" value="<%= form_id %>"> <input type="hidden" name="form_id" value="<%= form_id %>">
<button type="submit" class="hidden"></button> <button type="submit" class="hidden"></button>
<file-dropzone data-submit-on-upload="true" class="w-full"> <file-dropzone data-submit-on-upload="true" class="w-full">
<label for="file_dropzone_input" class="w-full block h-52 relative hover:bg-base-200/30 rounded-xl border border-2 border-base-300 border-dashed"> <label for="file_dropzone_input" class="w-full block h-52 relative hover:bg-base-200/30 rounded-xl border-2 border-base-300 border-dashed" data-target="file-dropzone.area">
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center p-2"> <div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center p-2 pointer-events-none">
<div class="flex flex-col items-center text-center"> <div class="flex flex-col items-center text-center">
<span data-target="file-dropzone.icon" class="flex flex-col items-center"> <span data-target="file-dropzone.icon" class="flex flex-col items-center">
<span> <span>
+4 -4
View File
@@ -1,12 +1,12 @@
<div class="h-36 relative group"> <div class="h-36 relative group">
<a href="<%= template_path(template) %>" class="flex h-full flex-col justify-between rounded-2xl pt-6 px-7 w-full bg-base-200 peer"> <a href="<%= template_path(template) %>" class="flex h-full flex-col justify-between rounded-2xl pt-6 px-7 w-full bg-base-200 before:border-2 before:border-base-300 before:border-dashed before:absolute before:left-0 before:right-0 before:top-0 before:bottom-0 before:hidden before:rounded-2xl" data-targets="dashboard-dropzone.templateCards">
<div class="pb-4 text-xl font-semibold" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;"> <div class="text-xl font-semibold" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;">
<% if template.template_accesses.present? %> <% if template.template_accesses.present? %>
<%= svg_icon('lock', class: 'w-6 h-6 inline -translate-y-[4px]') %> <%= svg_icon('lock', class: 'w-6 h-6 inline -translate-y-[4px]') %>
<% end %> <% end %>
<% template.name.split(/(_)/).each do |item| %><%= item %><wbr><% end %> <% template.name.split(/(_)/).each do |item| %><%= item %><wbr><% end %>
</div> </div>
<div class="pb-6 pt-1 space-y-1"> <div class="pb-6 pt-1 space-y-1" data-targets="dashboard-dropzone.hiddenOnDrag">
<p class="flex items-center space-x-1 text-xs text-base-content/60"> <p class="flex items-center space-x-1 text-xs text-base-content/60">
<%= svg_icon('user', class: 'w-4 h-4') %> <%= svg_icon('user', class: 'w-4 h-4') %>
<span><%= template.author.full_name.presence || template.author.email.to_s.sub(/\+\w+@/, '@') %></span> <span><%= template.author.full_name.presence || template.author.email.to_s.sub(/\+\w+@/, '@') %></span>
@@ -28,7 +28,7 @@
</p> </p>
</div> </div>
</a> </a>
<div class="absolute top-0 bottom-0 w-0 flex items-center hidden md:group-hover:flex" style="right: 37px"> <div class="absolute top-0 bottom-0 w-0 items-center hidden md:group-hover:flex" style="right: 37px" data-targets="dashboard-dropzone.hiddenOnDrag">
<div class="space-y-1"> <div class="space-y-1">
<% if can?(:update, template) && !template.archived_at? && template.account_id == current_account.id %> <% if can?(:update, template) && !template.archived_at? && template.account_id == current_account.id %>
<span class="tooltip tooltip-left" data-tip="<%= t('move') %>"> <span class="tooltip tooltip-left" data-tip="<%= t('move') %>">
+1 -1
View File
@@ -14,6 +14,6 @@
</span> </span>
</button> </button>
<input type="hidden" name="form_id" value="<%= form_id %>"> <input type="hidden" name="form_id" value="<%= form_id %>">
<input id="upload_template" name="files[]" class="hidden" onchange="this.form.requestSubmit()" type="file" accept="image/*, application/pdf<%= ', .docx, .doc, .xlsx, .xls, .odt, .rtf' if Docuseal.multitenant? %>" multiple> <input id="upload_template" name="files[]" class="hidden" onchange="this.form.requestSubmit()" type="file" accept="image/*, application/pdf<%= ', .docx, .doc, .xlsx, .xls, .odt, .rtf' if Docuseal.advanced_formats? %>" multiple>
<input hidden name="folder_name" value="<%= local_assigns[:folder_name] %>"> <input hidden name="folder_name" value="<%= local_assigns[:folder_name] %>">
<% end %> <% end %>
+3
View File
@@ -61,6 +61,9 @@
<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: %>
<% unless @template.archived_at? %>
<%= render 'submissions_filters/actions', filter_params: filter_params.merge(template_id: @template.id) %>
<% end %>
</div> </div>
</div> </div>
<% end %> <% end %>
+77 -62
View File
@@ -1,77 +1,92 @@
<% has_archived = current_account.templates.where.not(archived_at: nil).exists? %> <% has_archived = current_account.templates.where.not(archived_at: nil).exists? %>
<% show_dropzone = params[:q].blank? && @pagy.pages == 1 && ((@template_folders.size < 10 && @templates.size.zero?) || (@template_folders.size < 7 && @templates.size < 4) || (@template_folders.size < 4 && @templates.size < 7)) %> <% show_dropzone = params[:q].blank? && @pagy.pages == 1 && ((@template_folders.size < 10 && @templates.size.zero?) || (@template_folders.size < 7 && @templates.size < 4) || (@template_folders.size < 4 && @templates.size < 7)) %>
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %> <% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
<div class="flex justify-between items-center w-full mb-4"> <dashboard-dropzone>
<div class="flex items-center flex-grow min-w-0"> <%= form_for '', url: '', id: form_id = SecureRandom.uuid, method: :post, class: 'hidden', data: { target: 'dashboard-dropzone.form' }, html: { enctype: 'multipart/form-data' } do %>
<% if has_archived || @pagy.count > 0 || @template_folders.present? %> <input name="form_id" value="<%= form_id %>">
<div class="mr-2"> <button type="submit"></button>
<%= render 'dashboard/toggle_view', selected: 'templates' %> <input id="dashboard_dropzone_input" name="files[]" type="file" multiple>
</div>
<% end %>
<h1 class="text-2xl truncate md:text-3xl sm:text-4xl font-bold md:block <%= 'hidden' if params[:q].present? %>">
<%= t('document_templates_html') %>
</h1>
</div>
<div class="flex space-x-2">
<% if params[:q].present? || @pagy.pages > 1 || @template_folders.present? %>
<%= render 'shared/search_input' %>
<% end %>
<% if can?(:create, ::Template) %>
<span class="hidden sm:block">
<%= render 'templates/upload_button' %>
</span>
<%= link_to new_template_path, class: 'white-button !border gap-2', data: { turbo_frame: :modal } do %>
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
<span class="hidden md:block"><%= t('create') %></span>
<% end %>
<% end %>
</div>
</div>
<% view_archived_html = capture do %>
<% if has_archived %>
<div>
<a href="<%= templates_archived_index_path %>" class="link text-sm"><%= t('view_archived') %></a>
</div>
<% end %> <% end %>
<% end %> <div class="flex justify-between items-center w-full mb-4 relative">
<% if @template_folders.present? %> <% unless show_dropzone %>
<div class="grid gap-4 md:grid-cols-3 <%= 'mb-6' if @templates.present? %>"> <%= render 'templates/dashboard_dropzone', style: 'height: 114px' %>
<%= render partial: 'template_folders/folder', collection: @template_folders, as: :folder %> <% end %>
</div> <div class="flex items-center flex-grow min-w-0">
<% end %> <% if has_archived || @pagy.count > 0 || @template_folders.present? %>
<% if @templates.present? %> <div class="mr-2">
<div class="grid gap-4 md:grid-cols-3"> <%= render 'dashboard/toggle_view', selected: 'templates' %>
<%= render partial: 'templates/template', collection: @templates %>
<% if show_dropzone && current_user.created_at > 2.weeks.ago || params[:tour] == 'true' %>
<% user_config = current_user.user_configs.find_or_initialize_by(key: UserConfig::SHOW_APP_TOUR) %>
<% if user_config.new_record? || user_config.value || params[:tour] == 'true' %>
<div class="hidden md:block">
<app-tour id="app_tour" data-show-tour="<%= params[:tour] == 'true' || user_config.value %>" data-type="dashboard" data-next-page-path="<%= @templates.first && can?(:edit, @templates.first) ? edit_template_path(@templates.first, params.permit(:tour)) : settings_account_path %>" data-i18n="<%= t('app_tour').to_json %>"></app-tour>
<% if user_config.new_record? && !params.key?(:tour) %>
<div class="h-36 rounded-2xl pt-3 px-7 w-full border border-dashed border-base-300">
<div class="text-xl text-center font-semibold text-base-content">
<%= t('welcome_to_docuseal') %>
</div>
<div class="my-2 text-center text-xs text-base-content/70">
<%= t('start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document') %>
</div>
<div class="flex gap-2 mt-3 w-full">
<%= button_to button_title(title: t('skip'), icon_disabled: svg_icon('loader', class: 'w-4 h-4 animate-spin')), user_configs_path, params: { user_config: { key: UserConfig::SHOW_APP_TOUR, value: false } }, class: 'btn btn-sm btn-outline w-full', form_class: 'flex-1', method: :post, form: { onsubmit: 'window.app_tour.parentNode.remove()' } %>
<%= button_to t('start_tour'), user_configs_path, params: { user_config: { key: UserConfig::SHOW_APP_TOUR, value: true } }, class: 'btn btn-sm btn-warning w-full', form_class: 'flex-1', method: :post, form: { onsubmit: 'window.app_tour.start()' } %>
</div>
</div>
<% end %>
</div> </div>
<% end %> <% end %>
<% end %> <h1 class="text-2xl truncate md:text-3xl sm:text-4xl font-bold md:block <%= 'hidden' if params[:q].present? %>">
<%= t('document_templates_html') %>
</h1>
</div>
<div class="flex space-x-2">
<% if params[:q].present? || @pagy.pages > 1 || @template_folders.present? %>
<%= render 'shared/search_input' %>
<% end %>
<% if can?(:create, ::Template) %>
<span class="hidden sm:block">
<%= render 'templates/upload_button' %>
</span>
<%= link_to new_template_path, class: 'white-button !border gap-2', data: { turbo_frame: :modal } do %>
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
<span class="hidden md:block"><%= t('create') %></span>
<% end %>
<% end %>
</div>
</div> </div>
<% end %> <% view_archived_html = capture do %>
<% if has_archived %>
<div>
<a href="<%= templates_archived_index_path %>" class="link text-sm"><%= t('view_archived') %></a>
</div>
<% end %>
<% end %>
<% templates_order_select_html = capture do %>
<% if params[:q].blank? && @pagy.pages > 1 %>
<%= render('shared/templates_order_select', with_recently_used: @pagy.count < 10_000) %>
<% end %>
<% end %>
<% if @template_folders.present? %>
<div class="grid gap-4 md:grid-cols-3 <%= 'mb-6' if @templates.present? %>">
<%= render partial: 'template_folders/folder', collection: @template_folders, as: :folder %>
</div>
<% end %>
<% if @templates.present? %>
<div class="grid gap-4 md:grid-cols-3">
<%= render partial: 'templates/template', collection: @templates %>
<% if show_dropzone && (current_user.created_at > 2.weeks.ago || params[:tour] == 'true') && current_user == true_user %>
<% user_config = current_user.user_configs.find_or_initialize_by(key: UserConfig::SHOW_APP_TOUR) %>
<% if user_config.new_record? || user_config.value || params[:tour] == 'true' %>
<div class="hidden md:block">
<app-tour id="app_tour" data-show-tour="<%= params[:tour] == 'true' || user_config.value %>" data-type="dashboard" data-next-page-path="<%= @templates.first && can?(:edit, @templates.first) ? edit_template_path(@templates.first, params.permit(:tour)) : settings_account_path %>" data-i18n="<%= t('app_tour').to_json %>"></app-tour>
<% if user_config.new_record? && !params.key?(:tour) %>
<div class="h-36 rounded-2xl pt-3 px-7 w-full border border-dashed border-base-300">
<div class="text-xl text-center font-semibold text-base-content">
<%= t('welcome_to_docuseal') %>
</div>
<div class="my-2 text-center text-xs text-base-content/70">
<%= t('start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document') %>
</div>
<div class="flex gap-2 mt-3 w-full">
<%= button_to button_title(title: t('skip'), icon_disabled: svg_icon('loader', class: 'w-4 h-4 animate-spin')), user_configs_path, params: { user_config: { key: UserConfig::SHOW_APP_TOUR, value: false } }, class: 'btn btn-sm btn-outline w-full', form_class: 'flex-1', method: :post, form: { onsubmit: 'window.app_tour.parentNode.remove()' } %>
<%= button_to t('start_tour'), user_configs_path, params: { user_config: { key: UserConfig::SHOW_APP_TOUR, value: true } }, class: 'btn btn-sm btn-warning w-full', form_class: 'flex-1', method: :post, form: { onsubmit: 'window.app_tour.start()' } %>
</div>
</div>
<% end %>
</div>
<% end %>
<% end %>
</div>
<% end %>
</dashboard-dropzone>
<% if show_dropzone %> <% if show_dropzone %>
<%= render 'templates/dropzone' %> <%= render 'templates/dropzone' %>
<% end %> <% end %>
<% if @templates.present? || params[:q].blank? %> <% if @templates.present? || params[:q].blank? %>
<% if @pagy.pages > 1 %> <% if @pagy.pages > 1 %>
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', left_additional_html: view_archived_html %> <%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', left_additional_html: view_archived_html, right_additional_html: templates_order_select_html %>
<% else %> <% else %>
<div class="mt-2"> <div class="mt-2">
<%= view_archived_html %> <%= view_archived_html %>
Whitespace-only changes.
+28 -3
View File
@@ -31,7 +31,7 @@
</span> </span>
</span> </span>
<% end %> <% end %>
<%= tag.input type: 'email', multiple: true, name: 'template[preferences][bcc_completed]', autocomplete: 'off', class: 'base-input', value: ff.object.bcc_completed %> <%= tag.input type: 'email', multiple: true, name: 'template[preferences][bcc_completed]', autocomplete: 'off', class: 'base-input', value: ff.object.bcc_completed, id: ff.field_id(:bcc_completed) %>
</div> </div>
<% end %> <% end %>
<div class="form-control pt-3"> <div class="form-control pt-3">
@@ -41,7 +41,18 @@
</div> </div>
</div> </div>
<% end %> <% end %>
<%= render 'templates_code_modal/preferences' %> <%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mb-5' }, data: { close_on_submit: false } do |f| %>
<%= f.fields_for :preferences, Struct.new(:default_expire_at_duration, :default_expire_at).new(@template.preferences['default_expire_at_duration'], @template.preferences['default_expire_at'] ? Time.zone.parse(@template.preferences['default_expire_at']).in_time_zone(current_account.timezone) : nil) do |ff| %>
<div class="form-control">
<% duration_options = Templates::EXPIRATION_DURATIONS.keys.map { |duration| [t(duration), duration] } + [[t('specified_date'), 'specified_date']] %>
<%= ff.label :default_expire_at_duration, t('default_expiration'), class: 'label pt-0' %>
<div class="flex items-center gap-2">
<%= ff.select :default_expire_at_duration, duration_options, { include_blank: t('none') }, required: false, class: 'base-select flex-1', dir: 'auto', autocomplete: 'off', onchange: "this.value == 'specified_date' ? window.template_preferences_default_expire_at.classList.remove('hidden') : [window.template_preferences_default_expire_at.classList.add('hidden'), window.template_preferences_default_expire_at.value = '', this.form.requestSubmit()]" %>
<%= ff.datetime_field :default_expire_at, required: false, class: ['base-input flex-1', ff.object.default_expire_at.blank? && 'hidden'].compact_blank.join(' '), dir: 'auto', autocomplete: 'off', onchange: 'this.value && this.form.requestSubmit()' %>
</div>
</div>
<% end %>
<% end %>
<div class="collapse collapse-arrow join-item border border-base-300 mb-2"> <div class="collapse collapse-arrow join-item border border-base-300 mb-2">
<input type="checkbox" name="accordion"> <input type="checkbox" name="accordion">
<div class="collapse-title text-xl font-medium"> <div class="collapse-title text-xl font-medium">
@@ -51,7 +62,7 @@
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1' }, data: { close_on_submit: false } do |f| %> <%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1' }, data: { close_on_submit: false } do |f| %>
<toggle-on-submit data-element-id="form_saved_alert"></toggle-on-submit> <toggle-on-submit data-element-id="form_saved_alert"></toggle-on-submit>
<% configs = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY).value %> <% configs = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY).value %>
<%= f.fields_for :preferences, Struct.new(:completed_redirect_url, :completed_message).new(@template.preferences['completed_redirect_url'].presence, Struct.new(:title, :body).new(*(@template.preferences['completed_message'] || {}).values_at('title', 'body'))) do |ff| %> <%= f.fields_for :preferences, Struct.new(:completed_redirect_url, :completed_message, :require_phone_2fa).new(@template.preferences['completed_redirect_url'].presence, Struct.new(:title, :body).new(*(@template.preferences['completed_message'] || {}).values_at('title', 'body')), @template.preferences['require_phone_2fa'] == true) do |ff| %>
<div class="form-control mb-2"> <div class="form-control mb-2">
<%= ff.label :completed_redirect_url, t('redirect_on_completion_url'), class: 'label' %> <%= ff.label :completed_redirect_url, t('redirect_on_completion_url'), class: 'label' %>
<%= ff.url_field :completed_redirect_url, required: false, class: 'base-input', dir: 'auto' %> <%= ff.url_field :completed_redirect_url, required: false, class: 'base-input', dir: 'auto' %>
@@ -64,6 +75,7 @@
</autoresize-textarea> </autoresize-textarea>
</div> </div>
<% end %> <% end %>
<%= render 'templates_preferences/form_fields', ff: %>
<% end %> <% end %>
<div class="form-control pt-2"> <div class="form-control pt-2">
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %> <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
@@ -272,6 +284,7 @@
</div> </div>
</div> </div>
</div> </div>
<%= render 'templates_code_modal/preferences', class: 'pt-2' %>
</div> </div>
<% if show_recipients %> <% if show_recipients %>
<div id="recipients" class="hidden mt-2 mb-4 px-5"> <div id="recipients" class="hidden mt-2 mb-4 px-5">
@@ -334,6 +347,18 @@
</div> </div>
<% end %> <% end %>
<% end %> <% end %>
<% if can?(:manage, :personalization_advanced) %>
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-2' }, data: { close_on_submit: false } do |f| %>
<div class="flex items-center mt-4 justify-between w-full">
<span>
<%= t('ensure_unique_recipients') %>
</span>
<%= f.fields_for :preferences, Struct.new(:validate_unique_submitters).new(@template.preferences['validate_unique_submitters']) do |ff| %>
<%= ff.check_box :validate_unique_submitters, { class: 'toggle', onchange: 'this.form.requestSubmit()' }, 'true', '' %>
<% end %>
</div>
<% end %>
<% end %>
<div class="form-control mt-5 pb-2"> <div class="form-control mt-5 pb-2">
<%= button_tag button_title(title: t('save'), disabled_with: t('updating')), class: 'base-button', form: :submitters_form %> <%= button_tag button_title(title: t('save'), disabled_with: t('updating')), class: 'base-button', form: :submitters_form %>
</div> </div>
+2 -2
View File
@@ -55,8 +55,8 @@
<%= user.email %> <%= user.email %>
</td> </td>
<td> <td>
<span class="badge badge-info badge-outline"> <span class="badge badge-info badge-outline whitespace-nowrap">
<%= user.role %> <%= t(user.role) %>
</span> </span>
</td> </td>
<td> <td>
+2 -1
View File
@@ -25,7 +25,8 @@ module DocuSeal
config.active_storage.draw_routes = ENV['MULTITENANT'] != 'true' config.active_storage.draw_routes = ENV['MULTITENANT'] != 'true'
config.i18n.available_locales = %i[en en-US en-GB es-ES fr-FR pt-PT de-DE it-IT es it de fr pl uk cs pt he nl ar ko] config.i18n.available_locales = %i[en en-US en-GB es-ES fr-FR pt-PT de-DE it-IT
es it de fr pl uk cs pt he nl ar ko ja]
config.i18n.fallbacks = [:en] config.i18n.fallbacks = [:en]
config.exceptions_app = ->(env) { ErrorsController.action(:show).call(env) } config.exceptions_app = ->(env) { ErrorsController.action(:show).call(env) }
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff. Show more