Compare commits

..

43 Commits

Author SHA1 Message Date
Alex Turchyn 154d706be1 Merge from docusealco/wip 2025-07-21 13:38:05 +03:00
Alex Turchyn aaae257c75 group initial and signature images in audit logs 2025-07-20 19:56:10 +03:00
Pete Matsyburka ec944a2283 add subfolders 2025-07-20 08:28:19 +03:00
Pete Matsyburka 69d63ecffe with signature id reason 2025-07-18 11:08:22 +03:00
Pete Matsyburka 06a1e2992d fix redirect url 2025-07-17 18:33:24 +03:00
Pete Matsyburka ec9293bac0 with signature id 2025-07-17 10:54:44 +03:00
Pete Matsyburka 8bf727c43c adjust font auto 2025-07-15 09:19:49 +03:00
Pete Matsyburka 708b8afdf5 optimize 2025-07-14 17:13:04 +03:00
Pete Matsyburka 6f7b9b1ebf code highligh 2025-07-14 13:43:26 +03:00
Pete Matsyburka 4f48c4fd9f fix gh font 2025-07-14 13:36:34 +03:00
Pete Matsyburka 9dac90e6cc cleanup 2025-07-14 13:29:39 +03:00
Alex Turchyn 0741a6f485 Merge from docusealco/wip 2025-07-14 13:26:14 +03:00
Pete Matsyburka 1f338dfd2b adjust font size 2025-07-14 12:53:15 +03:00
Pete Matsyburka 0310c26edd fix safari template folder drag&drop 2025-07-12 12:35:19 +03:00
Pete Matsyburka e43570d4c1 adjust nav 2025-07-12 11:51:10 +03:00
Pete Matsyburka d7142c2935 adjust setup nav 2025-07-12 11:16:14 +03:00
Pete Matsyburka fa6dc04ef1 add gh tooltip 2025-07-12 10:57:07 +03:00
Pete Matsyburka c5ec7a4b37 image step validation 2025-07-11 21:52:55 +03:00
Pete Matsyburka bd3d643318 simplify nav 2025-07-11 21:24:00 +03:00
Pete Matsyburka 1ae635963c update eid 2025-07-11 19:54:03 +03:00
Alex Turchyn a61c3e798d shared link 2fa 2025-07-11 09:30:24 +03:00
Pete Matsyburka 931addedbe adjust submitter index 2025-07-10 23:10:40 +03:00
Pete Matsyburka 9aa4a547cf with submitter timezone 2025-07-10 20:56:34 +03:00
Pete Matsyburka 82a7df5e3f add pdf/a-3b 2025-07-10 12:58:19 +03:00
Pete Matsyburka 0ff87383d0 fix selfsign 2025-07-08 16:48:57 +03:00
Pete Matsyburka ea3dec443f refactor 2025-07-07 15:40:23 +03:00
Alex Turchyn d7a895b98b Merge from docusealco/wip 2025-07-07 15:36:18 +03:00
Pete Matsyburka b90b7b7845 add select field font 2025-07-07 10:57:21 +03:00
Pete Matsyburka 3d05d75f2d fix search 2025-07-07 07:36:27 +03:00
Pete Matsyburka e701881377 store error body 2025-07-05 16:36:55 +03:00
Pete Matsyburka 4615e3e48f refresh webhook status 2025-07-05 15:39:42 +03:00
Alex Turchyn 988a5361a6 add webhook events 2025-07-04 22:28:01 +03:00
Pete Matsyburka 1b60b42428 text size 2025-07-04 13:33:52 +03:00
Pete Matsyburka 7b74487214 add length validation 2025-07-04 13:03:22 +03:00
Pete Matsyburka 91d98ed267 rotate pages 2025-07-04 09:47:52 +03:00
Pete Matsyburka 24fbb7de06 require phone 2fa 2025-07-03 10:20:30 +03:00
Pete Matsyburka 91df5b2407 optimize image blank 2025-07-02 17:44:18 +03:00
Pete Matsyburka eef2d9d599 use union all 2025-07-01 09:16:50 +03:00
Pete Matsyburka 8ad70b7e74 adjust folder query 2025-07-01 09:02:40 +03:00
Pete Matsyburka 8b775103c7 fix spec 2025-07-01 08:35:25 +03:00
Pete Matsyburka 8c2475aac1 add gh action timouts 2025-07-01 08:05:42 +03:00
Pete Matsyburka d3236ae4a1 adjust sort order 2025-06-30 23:57:49 +03:00
Pete Matsyburka 8fd9af3a6a fix ci 2025-06-30 13:11:21 +03:00
153 changed files with 2477 additions and 466 deletions
+5
View File
@@ -6,6 +6,7 @@ jobs:
rubocop:
name: Rubocop
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install Ruby
@@ -30,6 +31,7 @@ jobs:
erblint:
name: Erblint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install Ruby
@@ -54,6 +56,7 @@ jobs:
eslint:
name: ESLint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install Node.js
@@ -80,6 +83,7 @@ jobs:
brakeman:
name: Brakeman
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install Ruby
@@ -107,6 +111,7 @@ jobs:
rspec:
name: RSpec
runs-on: ubuntu-latest
timeout-minutes: 10
services:
postgres:
+1
View File
@@ -8,6 +8,7 @@ on:
jobs:
build:
runs-on: ubuntu-24.04-arm
timeout-minutes: 20
steps:
- name: Checkout code
+1 -1
View File
@@ -63,7 +63,7 @@ RSpec/MultipleExpectations:
Max: 25
RSpec/ExampleLength:
Max: 50
Max: 500
RSpec/MultipleMemoizedHelpers:
Max: 15
+1
View File
@@ -34,6 +34,7 @@ gem 'rails'
gem 'rails_autolink'
gem 'rails-i18n'
gem 'rotp'
gem 'rouge', require: false
gem 'rqrcode'
gem 'ruby-vips'
gem 'rubyXL'
+2
View File
@@ -453,6 +453,7 @@ GEM
retriable (3.1.2)
rexml (3.4.0)
rotp (6.3.0)
rouge (4.5.2)
rqrcode (2.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
@@ -632,6 +633,7 @@ DEPENDENCIES
rails-i18n
rails_autolink
rotp
rouge
rqrcode
rspec-rails
rubocop
@@ -11,8 +11,9 @@ module Api
params[:before] = Time.zone.at(params[:before].to_i) if params[:before].present?
submitters = paginate(
submitters.preload(template: :folder, submission: [:submitters, { audit_trail_attachment: :blob,
combined_document_attachment: :blob }],
submitters.preload(template: { folder: :parent_folder },
submission: [:submitters, { audit_trail_attachment: :blob,
combined_document_attachment: :blob }],
documents_attachments: :blob, attachments_attachments: :blob),
field: :completed_at
)
+6 -13
View File
@@ -14,7 +14,7 @@ module Api
submissions = filter_submissions(submissions, params)
submissions = paginate(submissions.preload(:created_by_user, :submitters,
template: :folder,
template: { folder: :parent_folder },
combined_document_attachment: :blob,
audit_trail_attachment: :blob))
@@ -63,12 +63,7 @@ module Api
submissions = create_submissions(@template, params)
WebhookUrls.for_account_id(@template.account_id, 'submission.created').each do |webhook_url|
submissions.each do |submission|
SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submission.id,
'webhook_url_id' => webhook_url.id)
end
end
WebhookUrls.enqueue_events(submissions, 'submission.created')
Submissions.send_signature_requests(submissions)
@@ -96,10 +91,7 @@ module Api
else
@submission.update!(archived_at: Time.current)
WebhookUrls.for_account_id(@submission.account_id, 'submission.archived').each do |webhook_url|
SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id,
'webhook_url_id' => webhook_url.id)
end
WebhookUrls.enqueue_events(@submission, 'submission.archived')
end
render json: @submission.as_json(only: %i[id archived_at])
@@ -112,9 +104,10 @@ module Api
submissions = submissions.where(slug: params[:slug]) if params[:slug].present?
if params[:template_folder].present?
folder = TemplateFolder.accessible_by(current_ability).find_by(name: params[:template_folder])
folders =
TemplateFolders.filter_by_full_name(TemplateFolder.accessible_by(current_ability), params[:template_folder])
submissions = folder ? submissions.joins(:template).where(template: { folder_id: folder.id }) : submissions.none
submissions = submissions.joins(:template).where(template: { folder_id: folders.pluck(:id) })
end
if params.key?(:archived)
@@ -13,10 +13,7 @@ module Api
SubmissionEvents.create_with_tracking_data(@submitter, 'view_form', request)
WebhookUrls.for_account_id(@submitter.account_id, 'form.viewed').each do |webhook_url|
SendFormViewedWebhookRequestJob.perform_async('submitter_id' => @submitter.id,
'webhook_url_id' => webhook_url.id)
end
WebhookUrls.enqueue_events(@submitter, 'form.viewed')
render json: {}
end
@@ -28,10 +28,7 @@ module Api
cloned_template.save!
WebhookUrls.for_account_id(cloned_template.account_id, 'template.created').each do |webhook_url|
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => cloned_template.id,
'webhook_url_id' => webhook_url.id)
end
WebhookUrls.enqueue_events(cloned_template, 'template.created')
SearchEntries.enqueue_reindex(cloned_template)
+4 -7
View File
@@ -7,7 +7,7 @@ module Api
def index
templates = filter_templates(@templates, params)
templates = paginate(templates.preload(:author, :folder))
templates = paginate(templates.preload(:author, folder: :parent_folder))
schema_documents =
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
@@ -67,10 +67,7 @@ module Api
SearchEntries.enqueue_reindex(@template)
WebhookUrls.for_account_id(@template.account_id, 'template.updated').each do |webhook_url|
SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id,
'webhook_url_id' => webhook_url.id)
end
WebhookUrls.enqueue_events(@template, 'template.updated')
render json: @template.as_json(only: %i[id updated_at])
end
@@ -95,9 +92,9 @@ module Api
templates = templates.where(slug: params[:slug]) if params[:slug].present?
if params[:folder].present?
folder = TemplateFolder.accessible_by(current_ability).find_by(name: params[:folder])
folders = TemplateFolders.filter_by_full_name(TemplateFolder.accessible_by(current_ability), params[:folder])
templates = folder ? templates.where(folder:) : templates.none
templates = templates.where(folder_id: folders.pluck(:id))
end
templates
+56 -21
View File
@@ -6,12 +6,15 @@ class StartFormController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
around_action :with_browser_locale, only: %i[show completed]
around_action :with_browser_locale, only: %i[show update completed]
before_action :maybe_redirect_com, only: %i[show completed]
before_action :load_resubmit_submitter, only: :update
before_action :load_template
before_action :authorize_start!, only: :update
COOKIES_TTL = 12.hours
COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze
def show
raise ActionController::RoutingError, I18n.t('not_found') if @template.preferences['require_phone_2fa']
@@ -20,6 +23,7 @@ class StartFormController < ApplicationController
.submitters.new(account_id: @template.account_id,
uuid: (filter_undefined_submitters(@template).first ||
@template.submitters.first)['uuid'])
render :email_verification if params[:email_verification]
else
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
@@ -49,17 +53,10 @@ class StartFormController < ApplicationController
@submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent)
end
if @submitter.errors.blank? && @submitter.save
if is_new_record
enqueue_submission_create_webhooks(@submitter)
SearchEntries.enqueue_reindex(@submitter)
if @submitter.submission.expire_at?
ProcessSubmissionExpiredJob.perform_at(@submitter.submission.expire_at,
'submission_id' => @submitter.submission_id)
end
end
if @template.preferences['shared_link_2fa'] == true
handle_require_2fa(@submitter, is_new_record:)
elsif @submitter.errors.blank? && @submitter.save
enqueue_new_submitter_jobs(@submitter) if is_new_record
redirect_to submit_form_path(@submitter.slug)
else
@@ -89,6 +86,16 @@ class StartFormController < ApplicationController
private
def enqueue_new_submitter_jobs(submitter)
WebhookUrls.enqueue_events(submitter.submission, 'submission.created')
SearchEntries.enqueue_reindex(submitter)
return unless submitter.submission.expire_at?
ProcessSubmissionExpiredJob.perform_at(submitter.submission.expire_at, 'submission_id' => submitter.submission_id)
end
def load_resubmit_submitter
@resubmit_submitter =
if params[:resubmit].present? && !params[:resubmit].in?([true, 'true'])
@@ -107,13 +114,6 @@ class StartFormController < ApplicationController
redirect_to start_form_path(@template.slug)
end
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)
required_fields = template.preferences.fetch('link_form_fields', ['email'])
@@ -130,7 +130,7 @@ class StartFormController < ApplicationController
.order(id: :desc)
.where(declined_at: nil)
.where(external_id: nil)
.where(ip: [nil, request.remote_ip])
.where(template.preferences['shared_link_2fa'] == true ? {} : { ip: [nil, request.remote_ip] })
.then { |rel| params[:resubmit].present? || params[:selfsign].present? ? rel.where(completed_at: nil) : rel }
.find_or_initialize_by(find_params)
@@ -180,7 +180,7 @@ class StartFormController < ApplicationController
end
def submitter_params
return current_user.slice(:email) if params[:selfsign]
return { 'email' => current_user.email, 'name' => current_user.full_name } if params[:selfsign]
return @resubmit_submitter.slice(:name, :phone, :email) if @resubmit_submitter.present?
params.require(:submitter).permit(:email, :phone, :name).tap do |attrs|
@@ -204,4 +204,39 @@ class StartFormController < ApplicationController
I18n.t('not_found')
end
end
def handle_require_2fa(submitter, is_new_record:)
return render :show, status: :unprocessable_entity if submitter.errors.present?
is_otp_verified = Submitters.verify_link_otp!(params[:one_time_code], submitter)
if cookies.encrypted[:email_2fa_slug] == submitter.slug || is_otp_verified
if submitter.save
enqueue_new_submitter_jobs(submitter) if is_new_record
if is_otp_verified
SubmissionEvents.create_with_tracking_data(submitter, 'email_verified', request)
cookies.encrypted[:email_2fa_slug] =
{ value: submitter.slug, expires: COOKIES_TTL.from_now, **COOKIES_DEFAULTS }
end
redirect_to submit_form_path(submitter.slug)
else
render :show, status: :unprocessable_entity
end
else
Submitters.send_shared_link_email_verification_code(submitter, request:)
render :email_verification
end
rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e
redirect_to start_form_path(submitter.submission.template.slug,
params: submitter_params.merge(email_verification: true)),
alert: e.message
rescue RateLimit::LimitApproached
redirect_to start_form_path(submitter.submission.template.slug,
params: submitter_params.merge(email_verification: true)),
alert: I18n.t(:too_many_attempts)
end
end
@@ -0,0 +1,31 @@
# frozen_string_literal: true
class StartFormEmail2faSendController < ApplicationController
around_action :with_browser_locale
skip_before_action :authenticate_user!
skip_authorization_check
def create
@template = Template.find_by!(slug: params[:slug])
@submitter = @template.submissions.new(account_id: @template.account_id)
.submitters.new(**submitter_params, account_id: @template.account_id)
Submitters.send_shared_link_email_verification_code(@submitter, request:)
redir_params = { notice: I18n.t(:code_has_been_resent) } if params[:resend]
redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)),
**redir_params
rescue Submitters::UnableToSendCode => e
redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)),
alert: e.message
end
private
def submitter_params
params.require(:submitter).permit(:name, :email, :phone)
end
end
@@ -12,6 +12,7 @@ class SubmissionEventsController < ApplicationController
'send_2fa_sms' => '2fa',
'send_sms' => 'send',
'phone_verified' => 'phone_check',
'email_verified' => 'email_check',
'click_sms' => 'hand_click',
'decline_form' => 'x',
'start_verification' => 'player_play',
+2 -14
View File
@@ -54,7 +54,7 @@ class SubmissionsController < ApplicationController
params: params.merge('send_completed_email' => true))
end
enqueue_submission_created_webhooks(@template, submissions)
WebhookUrls.enqueue_events(submissions, 'submission.created')
Submissions.send_signature_requests(submissions)
@@ -77,10 +77,7 @@ class SubmissionsController < ApplicationController
else
@submission.update!(archived_at: Time.current)
WebhookUrls.for_account_id(@submission.account_id, 'submission.archived').each do |webhook_url|
SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id,
'webhook_url_id' => webhook_url.id)
end
WebhookUrls.enqueue_events(@submission, 'submission.archived')
I18n.t('submission_has_been_archived')
end
@@ -97,15 +94,6 @@ class SubmissionsController < ApplicationController
template.save!
end
def enqueue_submission_created_webhooks(template, submissions)
WebhookUrls.for_account_id(template.account_id, 'submission.created').each do |webhook_url|
submissions.each do |submission|
SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submission.id,
'webhook_url_id' => webhook_url.id)
end
end
end
def submissions_params
params.permit(submission: { submitters: [%i[uuid email phone name]] })
end
@@ -28,7 +28,7 @@ class SubmissionsPreviewController < ApplicationController
raise ActionController::RoutingError, I18n.t('not_found')
end
if !submission_valid_ttl?(@submission) && !signature_valid
if use_signature?(@submission) && !signature_valid
Rollbar.info("TTL: #{@submission.id}") if defined?(Rollbar)
return redirect_to submissions_preview_completed_path(@submission.slug)
@@ -48,9 +48,15 @@ class SubmissionsPreviewController < ApplicationController
private
def submission_valid_ttl?(submission)
return true if current_user && current_user.account.submissions.exists?(id: submission.id)
def use_signature?(submission)
return false if current_user && can?(:read, submission)
return true if submission.submitters.any? { |e| e.preferences['require_phone_2fa'] }
return true if submission.template&.preferences&.dig('require_phone_2fa')
!submission_valid_ttl?(submission)
end
def submission_valid_ttl?(submission)
last_submitter = submission.submitters.select(&:completed_at?).max_by(&:completed_at)
last_submitter && last_submitter.completed_at > TTL.ago
+11 -1
View File
@@ -9,6 +9,7 @@ class SubmitFormController < ApplicationController
before_action :load_submitter, only: %i[show update completed]
before_action :maybe_render_locked_page, only: :show
before_action :maybe_require_link_2fa, only: %i[show update]
CONFIG_KEYS = [].freeze
@@ -50,7 +51,7 @@ class SubmitFormController < ApplicationController
return render json: { error: I18n.t('form_has_been_completed_already') }, status: :unprocessable_entity
end
if @submitter.template&.archived_at? || @submitter.submission.archived_at?
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at?
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_entity
end
@@ -80,6 +81,15 @@ class SubmitFormController < ApplicationController
private
def maybe_require_link_2fa
return if @submitter.submission.source != 'link'
return unless @submitter.submission.template&.preferences&.dig('shared_link_2fa') == true
return if cookies.encrypted[:email_2fa_slug] == @submitter.slug
return if @submitter.email == current_user&.email && current_user&.account_id == @submitter.account_id
redirect_to start_form_path(@submitter.submission.template.slug)
end
def maybe_render_locked_page
return render :archived if @submitter.submission.template&.archived_at? ||
@submitter.submission.archived_at? ||
@@ -25,10 +25,7 @@ class SubmitFormDeclineController < ApplicationController
SubmitterMailer.declined_email(submitter, user).deliver_later!
end
WebhookUrls.for_account_id(submitter.account_id, 'form.declined').each do |webhook_url|
SendFormDeclinedWebhookRequestJob.perform_async('submitter_id' => submitter.id,
'webhook_url_id' => webhook_url.id)
end
WebhookUrls.enqueue_events(submitter, 'form.declined')
redirect_to submit_form_path(submitter.slug)
end
@@ -3,14 +3,31 @@
class TemplateFoldersAutocompleteController < ApplicationController
load_and_authorize_resource :template_folder, parent: false
LIMIT = 100
LIMIT = 30
def index
templates_query = Template.accessible_by(current_ability).where(archived_at: nil)
parent_name, name =
if params[:parent_name].present?
[params[:parent_name], params[:q]]
else
params[:q].to_s.split(' /', 2).map(&:squish)
end
template_folders = @template_folders.where(id: templates_query.select(:folder_id))
template_folders = TemplateFolders.search(template_folders, params[:q]).limit(LIMIT)
if name
parent_folder = @template_folders.find_by(name: parent_name, parent_folder_id: nil)
else
name = parent_name
end
render json: template_folders.as_json(only: %i[name archived_at])
template_folders = TemplateFolders.filter_active_folders(@template_folders.where(parent_folder:),
Template.accessible_by(current_ability))
name = name.to_s.downcase
template_folders = TemplateFolders.search(template_folders, name).order(id: :desc).limit(LIMIT)
render json: template_folders.preload(:parent_folder)
.sort_by { |e| e.name.downcase.index(name) || Float::MAX }
.as_json(only: %i[name archived_at], methods: %i[full_name])
end
end
+61 -6
View File
@@ -3,13 +3,41 @@
class TemplateFoldersController < ApplicationController
load_and_authorize_resource :template_folder
def show
@templates = @template_folder.templates.active.accessible_by(current_ability)
.preload(:author, :template_accesses)
@templates = Templates.search(current_user, @templates, params[:q])
@templates = Templates::Order.call(@templates, current_user, cookies.permanent[:dashboard_templates_order])
helper_method :selected_order
@pagy, @templates = pagy_auto(@templates, limit: 12)
TEMPLATES_PER_PAGE = 12
FOLDERS_PER_PAGE = 18
def show
@templates = Template.active.accessible_by(current_ability)
.where(folder: [@template_folder, *(params[:q].present? ? @template_folder.subfolders : [])])
.preload(:author, :template_accesses)
@template_folders =
@template_folder.subfolders.where(id: Template.accessible_by(current_ability).active.select(:folder_id))
@template_folders = TemplateFolders.search(@template_folders, params[:q])
@template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order)
if @templates.exists?
@templates = Templates.search(current_user, @templates, params[:q])
@templates = Templates::Order.call(@templates, current_user, selected_order)
limit =
if @template_folders.size < 4
TEMPLATES_PER_PAGE
else
(@template_folders.size < 7 ? 9 : 6)
end
@pagy, @templates = pagy_auto(@templates, limit:)
load_related_submissions if params[:q].present? && @templates.blank?
else
@pagy, @template_folders = pagy(@template_folders, limit: FOLDERS_PER_PAGE)
@templates = @templates.none
end
end
def edit; end
@@ -25,7 +53,34 @@ class TemplateFoldersController < ApplicationController
private
def selected_order
@selected_order ||=
if cookies.permanent[:dashboard_templates_order].blank? ||
(cookies.permanent[:dashboard_templates_order] == 'used_at' && can?(:manage, :countless))
'created_at'
else
cookies.permanent[:dashboard_templates_order]
end
end
def template_folder_params
params.require(:template_folder).permit(:name)
end
def load_related_submissions
@related_submissions =
Submission.accessible_by(current_ability)
.where(archived_at: nil)
.where(template_id: current_account.templates.active
.where(folder: [@template_folder, *@template_folder.subfolders])
.select(:id))
.preload(:template_accesses, :created_by_user,
template: :author,
submitters: :start_form_submission_events)
@related_submissions = Submissions.search(current_user, @related_submissions, params[:q])
.order(id: :desc)
@related_submissions_pagy, @related_submissions = pagy_auto(@related_submissions, limit: 5)
end
end
@@ -4,9 +4,27 @@ class TemplatesArchivedController < ApplicationController
load_and_authorize_resource :template, parent: false
def index
@templates = @templates.where.not(archived_at: nil).preload(:author, :folder, :template_accesses).order(id: :desc)
@templates = @templates.where.not(archived_at: nil)
.preload(:author, :template_accesses, folder: :parent_folder)
.order(id: :desc)
@templates = Templates.search(current_user, @templates, params[:q])
@pagy, @templates = pagy_auto(@templates, limit: 12)
return unless params[:q].present? && @templates.blank?
@related_submissions =
Submission.accessible_by(current_ability)
.joins(:template)
.where.not(templates: { archived_at: nil })
.preload(:template_accesses, :created_by_user,
template: :author,
submitters: :start_form_submission_events)
@related_submissions = Submissions.search(current_user, @related_submissions, params[:q])
.order(id: :desc)
@related_submissions_pagy, @related_submissions = pagy_auto(@related_submissions, limit: 5)
end
end
+2 -16
View File
@@ -74,7 +74,7 @@ class TemplatesController < ApplicationController
SearchEntries.enqueue_reindex(@template)
enqueue_template_created_webhooks(@template)
WebhookUrls.enqueue_events(@template, 'template.created')
maybe_redirect_to_template(@template)
else
@@ -91,7 +91,7 @@ class TemplatesController < ApplicationController
SearchEntries.enqueue_reindex(@template) if is_name_changed
enqueue_template_updated_webhooks(@template)
WebhookUrls.enqueue_events(@template, 'template.updated')
head :ok
end
@@ -142,20 +142,6 @@ class TemplatesController < ApplicationController
end
end
def enqueue_template_created_webhooks(template)
WebhookUrls.for_account_id(template.account_id, 'template.created').each do |webhook_url|
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => template.id,
'webhook_url_id' => webhook_url.id)
end
end
def enqueue_template_updated_webhooks(template)
WebhookUrls.for_account_id(template.account_id, 'template.updated').each do |webhook_url|
SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => template.id,
'webhook_url_id' => webhook_url.id)
end
end
def load_base_template
return if params[:base_template_id].blank?
@@ -8,12 +8,14 @@ class TemplatesDashboardController < ApplicationController
TEMPLATES_PER_PAGE = 12
FOLDERS_PER_PAGE = 18
helper_method :selected_order
def index
@template_folders = @template_folders.where(id: @templates.active.select(:folder_id))
@template_folders =
TemplateFolders.filter_active_folders(@template_folders.where(parent_folder_id: nil), @templates)
@template_folders = TemplateFolders.search(@template_folders, params[:q])
@template_folders = sort_template_folders(@template_folders, current_user,
cookies.permanent[:dashboard_templates_order])
@template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order)
@pagy, @template_folders = pagy(
@template_folders,
@@ -26,7 +28,7 @@ class TemplatesDashboardController < ApplicationController
else
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME }
@templates = filter_templates(@templates).preload(:author, :template_accesses)
@templates = Templates::Order.call(@templates, current_user, cookies.permanent[:dashboard_templates_order])
@templates = Templates::Order.call(@templates, current_user, selected_order)
limit =
if @template_folders.size < 4
@@ -56,7 +58,7 @@ class TemplatesDashboardController < ApplicationController
rel = Template.where(
Template.arel_table[:id].in(
rel.where(folder_id: current_account.default_template_folder.id).select(:id).arel
.union(shared_template_ids.arel)
.union(:all, shared_template_ids.arel)
)
)
else
@@ -67,38 +69,14 @@ class TemplatesDashboardController < ApplicationController
Templates.search(current_user, rel, params[:q])
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
def selected_order
@selected_order ||=
if cookies.permanent[:dashboard_templates_order].blank? ||
(cookies.permanent[:dashboard_templates_order] == 'used_at' && can?(:manage, :countless))
'created_at'
else
cookies.permanent[:dashboard_templates_order]
end
end
def load_related_submissions
@@ -6,7 +6,9 @@ class TemplatesFoldersController < ApplicationController
def edit; end
def update
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:name])
name = [params[:parent_name], params[:name]].compact_blank.join(' / ')
@template.folder = TemplateFolders.find_or_create_by_name(current_user, name)
if @template.save
redirect_back(fallback_location: template_path(@template), notice: I18n.t('document_template_has_been_moved'))
@@ -26,7 +26,7 @@ class TemplatesPreferencesController < ApplicationController
completed_notification_email_attach_documents
completed_redirect_url validate_unique_submitters
require_all_submitters submitters_order require_phone_2fa
default_expire_at_duration
default_expire_at_duration shared_link_2fa
default_expire_at
completed_notification_email_subject completed_notification_email_body
completed_notification_email_enabled completed_notification_email_attach_audit] +
@@ -23,7 +23,7 @@ class TemplatesUploadsController < ApplicationController
@template.update!(schema:)
enqueue_template_created_webhooks(@template)
WebhookUrls.enqueue_events(@template, 'template.created')
SearchEntries.enqueue_reindex(@template)
@@ -68,11 +68,4 @@ class TemplatesUploadsController < ApplicationController
{ files: [file] }
end
def enqueue_template_created_webhooks(template)
WebhookUrls.for_account_id(template.account_id, 'template.created').each do |webhook_url|
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => template.id,
'webhook_url_id' => webhook_url.id)
end
end
end
@@ -0,0 +1,66 @@
# frozen_string_literal: true
class WebhookEventsController < ApplicationController
load_and_authorize_resource :webhook_url, parent: false, id_param: :webhook_id
before_action :load_webhook_event
def show
return unless current_ability.can?(:read, @webhook_event.record)
@data =
case @webhook_event.event_type
when 'form.started', 'form.completed', 'form.declined', 'form.viewed'
Submitters::SerializeForWebhook.call(@webhook_event.record)
when 'submission.created', 'submission.completed', 'submission.expired'
Submissions::SerializeForApi.call(@webhook_event.record)
when 'template.created', 'template.updated'
Templates::SerializeForApi.call(@webhook_event.record)
when 'submission.archived'
@webhook_event.record.as_json(only: %i[id archived_at])
end
end
def resend
id_key = WebhookUrls::EVENT_TYPE_ID_KEYS.fetch(@webhook_event.event_type.split('.').first)
last_attempt_id = @webhook_event.webhook_attempts.maximum(:id)
WebhookUrls::EVENT_TYPE_TO_JOB_CLASS[@webhook_event.event_type].perform_async(
id_key => @webhook_event.record_id,
'webhook_url_id' => @webhook_event.webhook_url_id,
'event_uuid' => @webhook_event.uuid,
'attempt' => SendWebhookRequest::MANUAL_ATTEMPT,
'last_status' => 0
)
render turbo_stream: [
turbo_stream.after(
params[:button_id],
helpers.tag.submit_form(
helpers.button_to('', refresh_settings_webhook_event_path(@webhook_url.id, @webhook_event.uuid),
params: { last_attempt_id: }),
class: 'hidden', data: { interval: 3_000 }
)
)
]
end
def refresh
return head :ok if @webhook_event.webhook_attempts.maximum(:id) == params[:last_attempt_id].to_i
render turbo_stream: [
turbo_stream.replace(helpers.dom_id(@webhook_event),
partial: 'event_row',
locals: { with_status: true, webhook_url: @webhook_url, webhook_event: @webhook_event }),
turbo_stream.replace(helpers.dom_id(@webhook_event, :drawer_events),
partial: 'drawer_events',
locals: { webhook_url: @webhook_url, webhook_event: @webhook_event })
]
end
private
def load_webhook_event
@webhook_event = @webhook_url.webhook_events.find_by!(uuid: params[:id])
end
end
+25 -4
View File
@@ -8,10 +8,26 @@ class WebhookSettingsController < ApplicationController
@webhook_urls = @webhook_urls.order(id: :desc)
@webhook_url = @webhook_urls.first_or_initialize
render @webhook_urls.size > 1 ? 'index' : 'show'
if @webhook_urls.size > 1
render :index
else
@webhook_events = @webhook_url.webhook_events
@webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status])
@pagy, @webhook_events = pagy_countless(@webhook_events.order(id: :desc))
render :show
end
end
def show; end
def show
@webhook_events = @webhook_url.webhook_events
@webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status])
@pagy, @webhook_events = pagy_countless(@webhook_events.order(id: :desc))
end
def new; end
@@ -37,13 +53,18 @@ class WebhookSettingsController < ApplicationController
def resend
submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last
authorize!(:read, submitter)
if submitter.blank? || @webhook_url.blank?
return redirect_back(fallback_location: settings_webhooks_path,
alert: I18n.t('unable_to_resend_webhook_request'))
end
SendFormCompletedWebhookRequestJob.perform_async('submitter_id' => submitter.id,
'webhook_url_id' => @webhook_url.id)
SendTestWebhookRequestJob.perform_async(
'submitter_id' => submitter.id,
'event_uuid' => SecureRandom.uuid,
'webhook_url_id' => @webhook_url.id
)
redirect_back(fallback_location: settings_webhooks_path, notice: I18n.t('webhook_request_has_been_sent'))
end
+2
View File
@@ -36,6 +36,7 @@ import IndeterminateCheckbox from './elements/indeterminate_checkbox'
import AppTour from './elements/app_tour'
import DashboardDropzone from './elements/dashboard_dropzone'
import RequiredCheckboxGroup from './elements/required_checkbox_group'
import PageContainer from './elements/page_container'
import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -107,6 +108,7 @@ safeRegisterElement('app-tour', AppTour)
safeRegisterElement('dashboard-dropzone', DashboardDropzone)
safeRegisterElement('check-on-click', CheckOnClick)
safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup)
safeRegisterElement('page-container', PageContainer)
safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () {
+8
View File
@@ -147,3 +147,11 @@ button[disabled] .enabled {
outline-offset: 3px;
outline-color: hsl(var(--bc) / 0.2);
}
.font-times {
font-family: "Times New Roman", Times, ui-serif, serif, Cambria, Georgia;
}
.font-courier {
font-family: "Courier New", Consolas, "Liberation Mono", monospace, ui-monospace, SFMono-Regular, Menlo, Monaco;
}
+29 -3
View File
@@ -15,6 +15,7 @@ export default targetable(class extends HTMLElement {
static [target.static] = [
'form',
'fileDropzone',
'folderDropzone',
'fileDropzoneLoading'
]
@@ -25,12 +26,13 @@ export default targetable(class extends HTMLElement {
window.addEventListener('dragleave', this.onWindowDragleave)
this.fileDropzone?.addEventListener('drop', this.onDropFile)
this.folderDropzone?.addEventListener('drop', this.onDropNewFolder)
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) => {
return [this.fileDropzone, this.folderDropzone, ...this.folderCards, ...this.templateCards].forEach((el) => {
el?.addEventListener('dragover', this.onDragover)
el?.addEventListener('dragleave', this.onDragleave)
})
@@ -46,6 +48,10 @@ export default targetable(class extends HTMLElement {
onTemplateDragStart = (e) => {
const id = e.target.href.split('/').pop()
this.folderCards.forEach((el) => el.classList.remove('bg-base-200', 'before:hidden'))
this.folderDropzone?.classList?.remove('hidden')
window.flash?.remove()
e.dataTransfer.effectAllowed = 'move'
if (id) {
@@ -98,13 +104,13 @@ export default targetable(class extends HTMLElement {
el.classList.add('opacity-50')
if (e.dataTransfer.files.length) {
const params = new URLSearchParams({ folder_name: el.innerText }).toString()
const params = new URLSearchParams({ folder_name: el.innerText.trim() }).toString()
this.uploadFiles(e.dataTransfer.files, `/templates_upload?${params}`)
} else {
const formData = new FormData()
formData.append('name', el.innerText)
formData.append('name', el.dataset.fullName)
fetch(`/templates/${templateId}/folder`, {
method: 'PUT',
@@ -176,6 +182,24 @@ export default targetable(class extends HTMLElement {
}
}
onDropNewFolder (e) {
e.preventDefault()
const templateId = e.dataTransfer.getData('template_id')
const a = document.createElement('a')
a.href = `/templates/${templateId}/folder/edit?autocomplete=false`
a.dataset.turboFrame = 'modal'
a.classList.add('hidden')
document.body.append(a)
a.click()
a.remove()
}
onDragleave () {
this.style.backgroundColor = null
@@ -199,6 +223,7 @@ export default targetable(class extends HTMLElement {
this.isDrag = true
window.flash?.remove()
this.fileDropzone?.classList?.remove('hidden')
this.hiddenOnDrag.forEach((el) => { el.style.display = 'none' })
@@ -212,6 +237,7 @@ export default targetable(class extends HTMLElement {
this.isDrag = false
this.fileDropzone?.classList?.add('hidden')
this.folderDropzone?.classList?.add('hidden')
this.hiddenOnDrag.forEach((el) => { el.style.display = null })
@@ -2,6 +2,8 @@ import autocomplete from 'autocompleter'
export default class extends HTMLElement {
connectedCallback () {
if (this.dataset.enabled === 'false') return
autocomplete({
input: this.input,
preventSubmit: this.dataset.submitOnSelect === 'true' ? 0 : 1,
@@ -14,12 +16,16 @@ export default class extends HTMLElement {
}
onSelect = (item) => {
this.input.value = item.name
this.input.value = this.dataset.parentName ? item.name : item.full_name
}
fetch = (text, resolve) => {
const queryParams = new URLSearchParams({ q: text })
if (this.dataset.parentName) {
queryParams.append('parent_name', this.dataset.parentName)
}
fetch('/template_folders_autocomplete?' + queryParams).then(async (resp) => {
const items = await resp.json()
@@ -34,7 +40,7 @@ export default class extends HTMLElement {
div.setAttribute('dir', 'auto')
div.textContent = item.name
div.textContent = this.dataset.parentName ? item.name : item.full_name
return div
}
+12
View File
@@ -0,0 +1,12 @@
export default class extends HTMLElement {
connectedCallback () {
const image = this.querySelector('img')
image.addEventListener('load', (e) => {
image.setAttribute('width', e.target.naturalWidth)
image.setAttribute('height', e.target.naturalHeight)
this.style.aspectRatio = `${e.target.naturalWidth} / ${e.target.naturalHeight}`
})
}
}
+13 -1
View File
@@ -1,5 +1,17 @@
export default class extends HTMLElement {
connectedCallback () {
this.querySelector('form').requestSubmit()
if (this.dataset.interval) {
this.interval = setInterval(() => {
this.querySelector('form').requestSubmit()
}, parseInt(this.dataset.interval))
} else {
this.querySelector('form').requestSubmit()
}
}
disconnectedCallback () {
if (this.interval) {
clearInterval(this.interval)
}
}
}
+2
View File
@@ -5,6 +5,7 @@ import DownloadButton from './elements/download_button'
import ToggleSubmit from './elements/toggle_submit'
import FetchForm from './elements/fetch_form'
import ScrollButtons from './elements/scroll_buttons'
import PageContainer from './elements/page_container'
const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options)
@@ -12,6 +13,7 @@ safeRegisterElement('download-button', DownloadButton)
safeRegisterElement('toggle-submit', ToggleSubmit)
safeRegisterElement('fetch-form', FetchForm)
safeRegisterElement('scroll-buttons', ScrollButtons)
safeRegisterElement('page-container', PageContainer)
safeRegisterElement('submission-form', class extends HTMLElement {
connectedCallback () {
this.appElem = document.createElement('div')
+8
View File
@@ -70,3 +70,11 @@ button[disabled] .enabled {
.base-radio {
@apply radio bg-white radio-sm;
}
.font-times {
font-family: "Times New Roman", Times, ui-serif, serif, Cambria, Georgia;
}
.font-courier {
font-family: "Courier New", Consolas, "Liberation Mono", monospace, ui-monospace, SFMono-Regular, Menlo, Monaco;
}
+49 -14
View File
@@ -2,8 +2,8 @@
<div
class="flex absolute lg:text-base -outline-offset-1 field-area"
dir="auto"
:style="computedStyle"
:class="{ 'text-[1.6vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars, 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'outline-red-500 outline-dashed outline-2 z-10 field-area-active': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
:style="[computedStyle, fontStyle]"
:class="{ 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'outline-red-500 outline-dashed outline-2 z-10 field-area-active': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
>
<div
v-if="(!withFieldPlaceholder || !field.name || field.type === 'cells') && !isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid"
@@ -56,11 +56,11 @@
<div
v-else-if="field.type === 'signature' && signature"
class="flex justify-between h-full gap-1 overflow-hidden w-full"
:class="isNarrow && (withSignatureId || field.preferences?.reason_field_uuid) ? 'flex-row' : 'flex-col'"
:class="isNarrow && (isShowSignatureId || field.preferences?.reason_field_uuid) ? 'flex-row' : 'flex-col'"
>
<div
class="flex overflow-hidden"
:class="isNarrow && (withSignatureId || field.preferences?.reason_field_uuid) ? 'w-1/2' : 'flex-grow'"
:class="isNarrow && (isShowSignatureId || field.preferences?.reason_field_uuid) ? 'w-1/2' : 'flex-grow'"
style="min-height: 50%"
>
<img
@@ -69,7 +69,7 @@
>
</div>
<div
v-if="withSignatureId || field.preferences?.reason_field_uuid"
v-if="isShowSignatureId || field.preferences?.reason_field_uuid"
class="text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem]"
:class="isNarrow ? 'w-1/2' : 'w-full'"
>
@@ -236,6 +236,11 @@ export default {
type: Object,
required: true
},
isInlineSize: {
type: Boolean,
required: false,
default: true
},
submitter: {
type: Object,
required: false,
@@ -328,6 +333,13 @@ export default {
verification: this.t('verify_id')
}
},
isShowSignatureId () {
if ([true, false].includes(this.field.preferences?.with_signature_id)) {
return this.field.preferences.with_signature_id
} else {
return this.withSignatureId
}
},
alignClasses () {
if (!this.field.preferences) {
return { 'items-center': true }
@@ -348,8 +360,8 @@ export default {
}
return {
'font-mono': this.field.preferences.font === 'Courier',
'font-serif': this.field.preferences.font === 'Times',
'font-courier': this.field.preferences.font === 'Courier',
'font-times': this.field.preferences.font === 'Times',
'font-bold': ['bold_italic', 'bold'].includes(this.field.preferences.font_type),
italic: ['bold_italic', 'italic'].includes(this.field.preferences.font_type)
}
@@ -427,6 +439,34 @@ export default {
return []
}
},
fontStyle () {
let fontSize = ''
if (this.isInlineSize) {
if (this.textOverflowChars) {
fontSize = `${this.fontSizePx / 1.5 / 10}cqmin`
} else {
fontSize = `${this.fontSizePx / 10}cqmin`
}
} else {
if (this.textOverflowChars) {
fontSize = `clamp(1pt, ${this.fontSizePx / 1.5 / 10}vw, ${this.fontSizePx / 1.5}px)`
} else {
fontSize = `clamp(1pt, ${this.fontSizePx / 10}vw, ${this.fontSizePx}px)`
}
}
return { fontSize, lineHeight: `calc(${fontSize} * ${this.lineHeight})` }
},
fontSizePx () {
return parseInt(this.field?.preferences?.font_size || 11) * this.fontScale
},
lineHeight () {
return 1.3
},
fontScale () {
return 1000 / 612.0
},
computedStyle () {
const { x, y, w, h } = this.area
@@ -437,11 +477,6 @@ export default {
height: h * 100 + '%'
}
if (this.field.preferences?.font_size) {
style.fontSize = `clamp(4pt, 1.6vw, ${parseInt(this.field.preferences.font_size) * 1.23}pt)`
style.lineHeight = `clamp(6pt, 2.0vw, ${parseInt(this.field.preferences.font_size) * 1.23 + 3}pt)`
}
if (this.field.preferences?.color) {
style.color = this.field.preferences.color
}
@@ -456,7 +491,7 @@ export default {
modelValue () {
this.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.modelValue}`.length)) {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue || (this.withFieldPlaceholder ? this.field.name : '')}`.length : 0
this.textOverflowChars = this.$refs.textContainer.scrollHeight > (this.$refs.textContainer.clientHeight + 1) ? `${this.modelValue || (this.withFieldPlaceholder ? this.field.name : '')}`.length : 0
}
})
}
@@ -464,7 +499,7 @@ export default {
mounted () {
this.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer) {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue || (this.withFieldPlaceholder ? this.field.name : '')}`.length : 0
this.textOverflowChars = this.$refs.textContainer.scrollHeight > (this.$refs.textContainer.clientHeight + 1) ? `${this.modelValue || (this.withFieldPlaceholder ? this.field.name : '')}`.length : 0
}
})
},
+4
View File
@@ -23,6 +23,7 @@
:area="area"
:submittable="submittable"
:field-index="fieldIndex"
:is-inline-size="isInlineSize"
:scroll-padding="scrollPadding"
:submitter="submitter"
:with-field-placeholder="withFieldPlaceholder"
@@ -110,6 +111,9 @@ export default {
}
},
computed: {
isInlineSize () {
return CSS.supports('container-type: size')
},
isMobileContainer () {
const root = this.$root.$el.parentNode.getRootNode()
const container = root.body || root.querySelector('div')
+13 -1
View File
@@ -95,7 +95,19 @@ export default {
},
methods: {
onDropFiles (e) {
this.uploadFiles(e.dataTransfer.files)
const files = Array.from(e.dataTransfer.files).filter((f) => {
if (this.accept === 'image/*') {
return f.type.startsWith('image')
} else {
return true
}
})
if (this.accept === 'image/*' && !files.length) {
alert(this.t('please_upload_an_image_file'))
} else {
this.uploadFiles(files)
}
},
onSelectFiles (e) {
e.preventDefault()
+1 -1
View File
@@ -67,7 +67,7 @@
<button
v-if="!isFormVisible"
id="expand_form_button"
class="btn btn-neutral flex text-white absolute bottom-0 w-full mb-3 expand-form-button"
class="btn btn-neutral flex text-white absolute bottom-0 w-full mb-3 expand-form-button text-base"
style="width: 96%; margin-left: 2%"
@click.prevent="[isFormVisible = true, scrollIntoField(currentField)]"
>
@@ -14,6 +14,7 @@
<FieldArea
v-if="isMathLoaded"
:model-value="calculateFormula(field)"
:is-inline-size="isInlineSize"
:field="field"
:area="area"
:submittable="false"
@@ -54,6 +55,11 @@ export default {
isMathLoaded: false
}
},
computed: {
isInlineSize () {
return CSS.supports('container-type: size')
}
},
async mounted () {
const {
create,
+28
View File
@@ -1,4 +1,6 @@
const en = {
please_upload_an_image_file: 'Please upload an image file',
must_be_characters_length: 'Must be {number} characters length',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.',
verify_id: 'Verify ID',
identity_verification: 'Identity verification',
@@ -98,6 +100,8 @@ const en = {
}
const es = {
please_upload_an_image_file: 'Por favor, sube un archivo de imagen',
must_be_characters_length: 'Debe tener {number} caracteres de longitud',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.',
verify_id: 'Verificar ID',
identity_verification: 'Verificación de identidad',
@@ -197,6 +201,8 @@ const es = {
}
const it = {
please_upload_an_image_file: 'Per favore carica un file immagine',
must_be_characters_length: 'Deve essere lungo {number} caratteri',
complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.",
verify_id: 'Verifica ID',
identity_verification: "Verifica dell'identità",
@@ -296,6 +302,8 @@ const it = {
}
const de = {
please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch',
must_be_characters_length: 'Muss {number} Zeichen lang sein',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vervollständigen Sie alle erforderlichen Felder, um mit der Identitätsverifizierung fortzufahren.',
verify_id: 'ID überprüfen',
identity_verification: 'Identitätsüberprüfung',
@@ -395,6 +403,8 @@ const de = {
}
const fr = {
please_upload_an_image_file: 'Veuillez télécharger un fichier image',
must_be_characters_length: 'Doit contenir {number} caractères',
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour continuer la vérification de l'identité.",
verify_id: "Vérification de l'ID",
identity_verification: "Vérification de l'identité",
@@ -494,6 +504,8 @@ const fr = {
}
const pl = {
please_upload_an_image_file: 'Proszę przesłać plik obrazu',
must_be_characters_length: 'Musi mieć długość {number} znaków',
complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.',
verify_id: 'Zweryfikuj ID',
identity_verification: 'Weryfikacja tożsamości',
@@ -593,6 +605,8 @@ const pl = {
}
const uk = {
please_upload_an_image_file: 'Будь ласка, завантажте файл зображення',
must_be_characters_length: 'Має містити {number} символів',
complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.",
verify_id: 'Підтвердження ідентичності',
identity_verification: 'Ідентифікація особи',
@@ -692,6 +706,8 @@ const uk = {
}
const cs = {
please_upload_an_image_file: 'Nahrajte prosím obrázkový soubor',
must_be_characters_length: 'Musí mít délku {number} znaků',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.',
verify_id: 'Ověřit ID',
identity_verification: 'Ověření identity',
@@ -791,6 +807,8 @@ const cs = {
}
const pt = {
please_upload_an_image_file: 'Por favor, envie um arquivo de imagem',
must_be_characters_length: 'Deve ter {number} caracteres',
complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.',
verify_id: 'Verificar ID',
identity_verification: 'Verificação de identidade',
@@ -890,6 +908,8 @@ const pt = {
}
const he = {
please_upload_an_image_file: 'אנא העלה קובץ תמונה',
must_be_characters_length: 'חייב להיות באורך של {number} תווים',
complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.',
verify_id: 'אמת מזהה',
identity_verification: 'אימות זהות',
@@ -989,6 +1009,8 @@ const he = {
}
const nl = {
please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand',
must_be_characters_length: 'Moet {number} tekens lang zijn',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.',
verify_id: 'Verifiëren ID',
identity_verification: 'Identiteitsverificatie',
@@ -1088,6 +1110,8 @@ const nl = {
}
const ar = {
please_upload_an_image_file: 'يرجى تحميل ملف صورة',
must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا',
complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.',
verify_id: 'تحقق من الهوية',
identity_verification: 'التحقق من الهوية',
@@ -1187,6 +1211,8 @@ const ar = {
}
const ko = {
please_upload_an_image_file: '이미지 파일을 업로드해 주세요',
must_be_characters_length: '{number}자여야 합니다',
complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.',
verify_id: '아이디 확인',
identity_verification: '신원 확인',
@@ -1286,6 +1312,8 @@ const ko = {
}
const ja = {
please_upload_an_image_file: '画像ファイルをアップロードしてください',
must_be_characters_length: '{number}文字でなければなりません',
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
verify_id: '本人確認',
identity_verification: '本人確認',
+24 -2
View File
@@ -43,8 +43,8 @@
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
type="text"
:name="`values[${field.uuid}]`"
@invalid="field.validation?.message ? $event.target.setCustomValidity(field.validation.message) : ''"
@input="field.validation?.message ? $event.target.setCustomValidity('') : ''"
@invalid="validationMessage ? $event.target.setCustomValidity(validationMessage) : ''"
@input="validationMessage ? $event.target.setCustomValidity('') : ''"
@focus="$emit('focus')"
>
<textarea
@@ -127,6 +127,28 @@ export default {
return null
}
},
lengthValidation () {
if (this.field.validation?.pattern) {
return this.field.validation.pattern.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups
} else {
return null
}
},
validationMessage () {
if (this.field.validation?.message) {
return this.field.validation.message
} else if (this.lengthValidation) {
const number =
[this.lengthValidation.min, this.lengthValidation.max]
.filter(Boolean)
.filter((v, i, a) => a.indexOf(v) === i)
.join('-')
return this.t('must_be_characters_length').replace('{number}', number)
} else {
return ''
}
},
text: {
set (value) {
this.$emit('update:model-value', value)
@@ -149,7 +149,8 @@ export default {
return fetch(this.baseUrl + `/api/identity_verification/${this.field.uuid}`, {
method: 'PUT',
body: JSON.stringify({
submitter_slug: this.submitterSlug
submitter_slug: this.submitterSlug,
redirect_url: document.location.href
}),
headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => {
+41 -6
View File
@@ -140,6 +140,7 @@
:background-color="'white'"
:with-required="false"
:with-areas="false"
:with-signature-id="withSignatureId"
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@@ -172,7 +173,8 @@
>
<div
v-if="isDefaultValuePresent || isValueInput || (withFieldPlaceholder && field.areas && field.type !== 'checkbox')"
:class="{ 'w-full h-full': ['cells', 'checkbox'].includes(field.type), 'text-[1.6vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars }"
:class="{ 'w-full h-full': ['cells', 'checkbox'].includes(field.type) }"
:style="fontStyle"
>
<div
ref="textContainer"
@@ -315,7 +317,7 @@ export default {
FieldSubmitter,
IconX
},
inject: ['template', 'selectedAreaRef', 'save', 't'],
inject: ['template', 'selectedAreaRef', 'save', 't', 'isInlineSize'],
props: {
area: {
type: Object,
@@ -346,6 +348,11 @@ export default {
required: false,
default: false
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
defaultSubmitters: {
type: Array,
required: false,
@@ -385,6 +392,34 @@ export default {
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels,
fieldIcons: FieldType.computed.fieldIcons,
fontStyle () {
let fontSize = ''
if (this.isInlineSize) {
if (this.textOverflowChars) {
fontSize = `${this.fontSizePx / 1.5 / 10}cqmin`
} else {
fontSize = `${this.fontSizePx / 10}cqmin`
}
} else {
if (this.textOverflowChars) {
fontSize = `clamp(1pt, ${this.fontSizePx / 1.5 / 10}vw, ${this.fontSizePx / 1.5}px)`
} else {
fontSize = `clamp(1pt, ${this.fontSizePx / 10}vw, ${this.fontSizePx}px)`
}
}
return { fontSize, lineHeight: `calc(${fontSize} * ${this.lineHeight})` }
},
fontSizePx () {
return parseInt(this.field?.preferences?.font_size || 11) * this.fontScale
},
lineHeight () {
return 1.3
},
fontScale () {
return 1040 / 612.0
},
isDefaultValuePresent () {
if (this.field?.type === 'radio' && this.field?.areas?.length > 1) {
return false
@@ -413,8 +448,8 @@ export default {
'justify-center': this.field.preferences.align === 'center',
'justify-start': this.field.preferences.align === 'left',
'justify-end': this.field.preferences.align === 'right',
'font-mono': this.field.preferences.font === 'Courier',
'font-serif': this.field.preferences.font === 'Times',
'font-courier': this.field.preferences.font === 'Courier',
'font-times': this.field.preferences.font === 'Times',
'font-bold': ['bold_italic', 'bold'].includes(this.field.preferences.font_type),
italic: ['bold_italic', 'italic'].includes(this.field.preferences.font_type)
}
@@ -491,7 +526,7 @@ export default {
'field.default_value' () {
this.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.field.default_value}`.length)) {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0
this.textOverflowChars = (this.$el.clientHeight + 1) < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0
}
})
}
@@ -499,7 +534,7 @@ export default {
mounted () {
this.$nextTick(() => {
if (['date', 'text', 'number'].includes(this.field.type) && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.field.default_value}`.length)) {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0
this.textOverflowChars = (this.$el.clientHeight + 1) < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0
}
})
},
@@ -330,6 +330,7 @@
:input-mode="inputMode"
:default-fields="[...defaultRequiredFields, ...defaultFields]"
:allow-draw="!onlyDefinedFields || drawField"
:with-signature-id="withSignatureId"
:data-document-uuid="document.uuid"
:default-submitters="defaultSubmitters"
:drag-field-placeholder="fieldsDragFieldRef.value || dragField"
@@ -436,6 +437,7 @@
:default-required-fields="defaultRequiredFields"
:field-types="fieldTypes"
:with-sticky-submitters="withStickySubmitters"
:with-signature-id="withSignatureId"
:only-defined-fields="onlyDefinedFields"
:editable="editable"
:show-tour-start-form="showTourStartForm"
@@ -541,7 +543,9 @@ export default {
withPayment: this.withPayment,
isPaymentConnected: this.isPaymentConnected,
withFormula: this.withFormula,
withSignatureId: this.withSignatureId,
withConditions: this.withConditions,
isInlineSize: this.isInlineSize,
defaultDrawFieldType: this.defaultDrawFieldType,
selectedAreaRef: computed(() => this.selectedAreaRef),
fieldsDragFieldRef: computed(() => this.fieldsDragFieldRef)
@@ -562,6 +566,11 @@ export default {
required: false,
default: false
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
backgroundColor: {
type: String,
required: false,
@@ -793,6 +802,9 @@ export default {
language () {
return this.locale.split('-')[0].toLowerCase()
},
isInlineSize () {
return CSS.supports('container-type: size')
},
isMobile () {
const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent)
@@ -1064,6 +1076,11 @@ export default {
}
}
if (type === 'signature' && [true, false].includes(this.withSignatureId)) {
field.preferences ||= {}
field.preferences.with_signature_id = this.withSignatureId
}
this.template.fields.push(field)
this.save()
@@ -1470,6 +1487,11 @@ export default {
field.preferences ||= {}
field.preferences.format ||= this.defaultDateFormat
}
if (field.type === 'signature' && [true, false].includes(this.withSignatureId)) {
field.preferences ||= {}
field.preferences.with_signature_id = this.withSignatureId
}
}
const fieldArea = {
+11 -4
View File
@@ -10,6 +10,7 @@
:data-page="index"
:areas="areasIndex[index]"
:allow-draw="allowDraw"
:with-signature-id="withSignatureId"
:is-drag="isDrag"
:with-field-placeholder="withFieldPlaceholder"
:default-fields="defaultFields"
@@ -29,6 +30,7 @@
</template>
<script>
import Page from './page'
import { reactive } from 'vue'
export default {
name: 'TemplateDocument',
@@ -65,6 +67,11 @@ export default {
required: false,
default: false
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
drawFieldType: {
type: String,
required: false,
@@ -123,14 +130,14 @@ export default {
return this.document.metadata?.pdf?.number_of_pages || this.document.preview_images.length
},
sortedPreviewImages () {
const lazyloadMetadata = this.document.preview_images[this.document.preview_images.length - 1].metadata
const lazyloadMetadata = this.document.preview_images[this.document.preview_images.length - 1]?.metadata || { width: 1400, height: 1812 }
return [...Array(this.numberOfPages).keys()].map((i) => {
return this.previewImagesIndex[i] || {
metadata: lazyloadMetadata,
return this.previewImagesIndex[i] || reactive({
metadata: { ...lazyloadMetadata },
id: Math.random().toString(),
url: this.basePreviewUrl + `/preview/${this.document.signed_uuid || this.document.uuid}/${i}.jpg`
}
})
})
},
previewImagesIndex () {
@@ -125,6 +125,7 @@
:field="field"
:default-field="defaultField"
:editable="editable"
:with-signature-id="withSignatureId"
:background-color="dropdownBgColor"
@click-formula="isShowFormulaModal = true"
@click-font="isShowFontModal = true"
@@ -302,6 +303,11 @@ export default {
type: Object,
required: true
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
withOptions: {
type: Boolean,
required: false,
@@ -153,13 +153,13 @@
<option
v-for="(key, value) in validations"
:key="key"
:selected="field.validation?.pattern ? value === field.validation.pattern : value === 'none'"
:value="value"
:selected="lengthValidation ? key == 'length' : (field.validation?.pattern ? value === field.validation.pattern : key === 'none')"
:value="key"
>
{{ t(key) }}
</option>
<option
:selected="field.validation && !validations[field.validation.pattern]"
:selected="field.validation && !validations[field.validation.pattern] && !lengthValidation"
:value="validations[field.validation?.pattern] || !field.validation?.pattern ? 'custom' : field.validation?.pattern"
>
{{ t('custom') }}
@@ -174,7 +174,51 @@
</label>
</div>
<div
v-if="['text', 'cells'].includes(field.type) && field.validation && !validations[field.validation.pattern]"
v-if="['text', 'cells'].includes(field.type) && field.validation && lengthValidation"
class="py-1.5 px-1 relative flex space-x-1"
@click.stop
>
<div class="w-1/2 relative">
<input
:placeholder="t('min')"
type="number"
min="0"
:value="lengthValidation.min"
class="input input-bordered w-full input-xs h-7 !outline-0 bg-transparent"
@input="field.validation.pattern = `.{${$event.target.value || 0},${lengthValidation.max || ''}}`"
@blur="save"
>
<label
v-if="lengthValidation.min"
:style="{ backgroundColor }"
class="absolute -top-2.5 left-1.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('min') }}
</label>
</div>
<div class="w-1/2 relative">
<input
:placeholder="t('max')"
type="number"
min="1"
class="input input-bordered w-full input-xs h-7 !outline-0 bg-transparent"
:value="lengthValidation.max"
@input="field.validation.pattern = `.{${lengthValidation.min},${$event.target.value || ''}}`"
@blur="save"
>
<label
v-if="lengthValidation.max"
:style="{ backgroundColor }"
class="absolute -top-2.5 left-1.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('max') }}
</label>
</div>
</div>
<div
v-if="['text', 'cells'].includes(field.type) && field.validation && !validations[field.validation.pattern] && !lengthValidation"
class="py-1.5 px-1 relative"
@click.stop
>
@@ -195,6 +239,27 @@
{{ t('regexp_validation') }}
</label>
</div>
<div
v-if="['text', 'cells'].includes(field.type) && field.validation && !validations[field.validation.pattern] && !lengthValidation"
class="py-1.5 px-1 relative"
@click.stop
>
<input
v-model="field.validation.message"
:placeholder="t('error_message')"
dir="auto"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
@blur="save"
>
<label
v-if="field.validation.message"
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('error_message') }}
</label>
</div>
<div
v-if="field.type === 'date'"
class="py-1.5 px-1 relative"
@@ -255,6 +320,21 @@
{{ t('format') }}
</label>
</div>
<li
v-if="[true, false].includes(withSignatureId) && field.type === 'signature'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
:checked="field.preferences?.with_signature_id"
type="checkbox"
:disabled="!editable || (defaultField && [true, false].includes(defaultField.required))"
class="toggle toggle-xs"
@change="[field.preferences ||= {}, field.preferences.with_signature_id = $event.target.checked, save()]"
>
<span class="label-text">{{ t('signature_id') }}</span>
</label>
</li>
<li
v-if="withRequired && field.type !== 'phone' && field.type !== 'stamp' && field.type !== 'verification'"
@click.stop
@@ -330,7 +410,7 @@
v-if="field.type != 'stamp'"
class="pb-0.5 mt-0.5"
>
<li v-if="['text', 'number', 'date'].includes(field.type)">
<li v-if="['text', 'number', 'date', 'select'].includes(field.type)">
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-font')"
@@ -460,6 +540,11 @@ export default {
type: Object,
required: true
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
backgroundColor: {
type: String,
required: false,
@@ -489,6 +574,7 @@ export default {
emits: ['set-draw', 'scroll-to', 'click-formula', 'click-description', 'click-condition', 'click-font', 'remove-area'],
data () {
return {
selectedValidation: ''
}
},
computed: {
@@ -533,8 +619,16 @@ export default {
return formats
},
lengthValidation () {
if (this.field.validation?.pattern && this.selectedValidation !== 'custom') {
return this.field.validation.pattern.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups
} else {
return null
}
},
validations () {
return {
'.{0,}': 'length',
'^[0-9]{3}-[0-9]{2}-[0-9]{4}$': 'ssn',
'^[0-9]{2}-[0-9]{7}$': 'ein',
'^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'email',
@@ -553,13 +647,21 @@ export default {
methods: {
onChangeValidation (event) {
if (event.target.value === 'custom') {
this.field.validation = { pattern: '' }
this.selectedValidation = 'custom'
this.field.validation = { pattern: '', message: '' }
this.$nextTick(() => this.$refs.validationCustom.focus())
} else if (event.target.value) {
this.field.validation ||= {}
this.field.validation.pattern = event.target.value
this.field.validation.pattern =
Object.keys(this.validations).find(key => this.validations[key] === event.target.value)
this.selectedValidation = event.target.value
delete this.field.validation.message
} else {
this.selectedValidation = ''
delete this.field.validation
}
@@ -26,6 +26,7 @@
:field="field"
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)"
:editable="editable"
:with-signature-id="withSignatureId"
:default-field="defaultFieldsIndex[field.name]"
:draggable="editable"
@dragstart="[fieldsDragFieldRef.value = field, removeDragOverlay($event), setDragPlaceholder($event)]"
@@ -253,6 +254,11 @@ export default {
required: false,
default: null
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
template: {
type: Object,
required: true
@@ -163,7 +163,7 @@
class="flex border border-base-content/20 rounded-xl bg-white px-4 h-16 modal-field-font-preview"
:style="{
color: preferences.color || 'black',
fontSize: (preferences.font_size || 12) + 'pt',
fontSize: (preferences.font_size || 11) + 'pt',
}"
:class="textClasses"
>
@@ -222,8 +222,8 @@ export default {
fonts () {
return [
{ value: null, label: 'Default' },
{ value: 'Times', label: 'Times', class: 'font-serif' },
{ value: 'Courier', label: 'Courier', class: 'font-mono' }
{ value: 'Times', label: 'Times', class: 'font-times' },
{ value: 'Courier', label: 'Courier', class: 'font-courier' }
]
},
types () {
@@ -258,8 +258,8 @@ export default {
},
textClasses () {
return {
'font-mono': this.preferences.font === 'Courier',
'font-serif': this.preferences.font === 'Times',
'font-courier': this.preferences.font === 'Courier',
'font-times': this.preferences.font === 'Times',
'justify-center': this.preferences.align === 'center',
'justify-start': this.preferences.align === 'left',
'justify-end': this.preferences.align === 'right',
+30
View File
@@ -1,4 +1,9 @@
const en = {
signature_id: 'Signature ID',
error_message: 'Error message',
length: 'Length',
min: 'Min',
max: 'Max',
font: 'Font',
party: 'Party',
date_signed: 'Date Signed',
@@ -168,6 +173,11 @@ const en = {
}
const es = {
signature_id: 'ID de Firma',
error_message: 'Mensaje de error',
length: 'Longitud',
min: 'Mín',
max: 'Máx',
date_signed: 'Fecha actual',
fuente: 'Fuente',
party: 'Parte',
@@ -337,6 +347,11 @@ const es = {
}
const it = {
signature_id: 'ID firma',
error_message: 'Messaggio di errore',
length: 'Lunghezza',
min: 'Min',
max: 'Max',
date_signed: 'Data attuale',
font: 'Carattere',
party: 'Parte',
@@ -506,6 +521,11 @@ const it = {
}
const pt = {
signature_id: 'ID da Assinatura',
error_message: 'Mensagem de erro',
length: 'Comprimento',
min: 'Mín',
max: 'Máx',
date_signed: 'Data atual',
fonte: 'Fonte',
party: 'Parte',
@@ -675,6 +695,11 @@ const pt = {
}
const fr = {
signature_id: 'ID de signature',
error_message: 'Message d\'erreur',
length: 'Longueur',
min: 'Min',
max: 'Max',
date_signed: 'Date actuelle',
font: 'Police',
party: 'Partie',
@@ -844,6 +869,11 @@ const fr = {
}
const de = {
signature_id: 'Signatur-ID',
error_message: 'Fehlermeldung',
length: 'Länge',
min: 'Min',
max: 'Max',
date_now: 'Akt. Datum',
font: 'Schriftart',
party: 'Partei',
+13 -6
View File
@@ -1,8 +1,9 @@
<template>
<div
class="relative select-none"
:class="{ 'cursor-crosshair': allowDraw }"
:style="drawField ? 'touch-action: none' : ''"
class="relative select-none mb-4 before:border before:rounded before:top-0 before:bottom-0 before:left-0 before:right-0 before:absolute"
:class="{ 'cursor-crosshair': allowDraw, 'touch-none': !!drawField }"
style="container-type: size"
:style="{ aspectRatio: `${width} / ${height}`}"
>
<img
ref="image"
@@ -10,7 +11,7 @@
:src="image.url"
:width="width"
:height="height"
class="border rounded mb-4"
class="rounded"
@load="onImageLoad"
>
<div
@@ -26,6 +27,7 @@
:field="item.field"
:editable="editable"
:with-field-placeholder="withFieldPlaceholder"
:with-signature-id="withSignatureId"
:default-field="defaultFieldsIndex[item.field.name]"
:default-submitters="defaultSubmitters"
:max-page="totalPages - 1"
@@ -77,6 +79,11 @@ export default {
required: false,
default: null
},
withSignatureId: {
type: Boolean,
required: false,
default: null
},
areas: {
type: Array,
required: false,
@@ -191,8 +198,8 @@ export default {
},
methods: {
onImageLoad (e) {
e.target.setAttribute('width', e.target.naturalWidth)
e.target.setAttribute('height', e.target.naturalHeight)
this.image.metadata.width = e.target.naturalWidth
this.image.metadata.height = e.target.naturalHeight
},
setAreaRefs (el) {
if (el) {
+1 -4
View File
@@ -11,9 +11,6 @@ class ProcessSubmissionExpiredJob
return if submission.submitters.where.not(declined_at: nil).exists?
return unless submission.submitters.exists?(completed_at: nil)
WebhookUrls.for_account_id(submission.account_id, %w[submission.expired]).each do |webhook|
SendSubmissionExpiredWebhookRequestJob.perform_async('submission_id' => submission.id,
'webhook_url_id' => webhook.id)
end
WebhookUrls.enqueue_events(submission, 'submission.expired')
end
end
+12 -4
View File
@@ -63,16 +63,24 @@ class ProcessSubmitterCompletionJob
end
def enqueue_completed_webhooks(submitter, is_all_completed: false)
event_uuids = {}
WebhookUrls.for_account_id(submitter.account_id, %w[form.completed submission.completed]).each do |webhook|
if webhook.events.include?('form.completed')
event_uuids['form.completed'] ||= SecureRandom.uuid
SendFormCompletedWebhookRequestJob.perform_async('submitter_id' => submitter.id,
'event_uuid' => event_uuids['form.completed'],
'webhook_url_id' => webhook.id)
end
if webhook.events.include?('submission.completed') && is_all_completed
SendSubmissionCompletedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id,
'webhook_url_id' => webhook.id)
end
next unless webhook.events.include?('submission.completed') && is_all_completed
event_uuids['submission.completed'] ||= SecureRandom.uuid
SendSubmissionCompletedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id,
'event_uuid' => event_uuids['submission.completed'],
'webhook_url_id' => webhook.id)
end
end
@@ -20,6 +20,9 @@ class SendFormCompletedWebhookRequestJob
ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = SendWebhookRequest.call(webhook_url, event_type: 'form.completed',
event_uuid: params['event_uuid'],
record: submitter,
attempt:,
data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
@@ -18,13 +18,15 @@ class SendFormDeclinedWebhookRequestJob
ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = SendWebhookRequest.call(webhook_url, event_type: 'form.declined',
event_uuid: params['event_uuid'],
record: submitter,
attempt:,
data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormDeclinedWebhookRequestJob.perform_in((2**attempt).minutes, {
'submitter_id' => submitter.id,
'webhook_url_id' => webhook_url.id,
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
@@ -18,13 +18,15 @@ class SendFormStartedWebhookRequestJob
ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = SendWebhookRequest.call(webhook_url, event_type: 'form.started',
event_uuid: params['event_uuid'],
record: submitter,
attempt:,
data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormStartedWebhookRequestJob.perform_in((2**attempt).minutes, {
'submitter_id' => submitter.id,
'webhook_url_id' => webhook_url.id,
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
@@ -18,13 +18,15 @@ class SendFormViewedWebhookRequestJob
ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = SendWebhookRequest.call(webhook_url, event_type: 'form.viewed',
event_uuid: params['event_uuid'],
record: submitter,
attempt:,
data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormViewedWebhookRequestJob.perform_in((2**attempt).minutes, {
'submitter_id' => submitter.id,
'webhook_url_id' => webhook_url.id,
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
@@ -16,13 +16,15 @@ class SendSubmissionArchivedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.archived')
resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.archived',
event_uuid: params['event_uuid'],
record: submission,
attempt:,
data: submission.as_json(only: %i[id archived_at]))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
'submission_id' => submission.id,
'webhook_url_id' => webhook_url.id,
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
@@ -16,6 +16,9 @@ class SendSubmissionCompletedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.completed')
resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.completed',
event_uuid: params['event_uuid'],
record: submission,
attempt:,
data: Submissions::SerializeForApi.call(submission))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
@@ -16,13 +16,15 @@ class SendSubmissionCreatedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.created')
resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.created',
event_uuid: params['event_uuid'],
record: submission,
attempt:,
data: Submissions::SerializeForApi.call(submission))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
'submission_id' => submission.id,
'webhook_url_id' => webhook_url.id,
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
@@ -16,13 +16,15 @@ class SendSubmissionExpiredWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.expired')
resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.expired',
event_uuid: params['event_uuid'],
record: submission,
attempt:,
data: Submissions::SerializeForApi.call(submission))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionExpiredWebhookRequestJob.perform_in((2**attempt).minutes, {
'submission_id' => submission.id,
'webhook_url_id' => webhook_url.id,
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
@@ -16,13 +16,15 @@ class SendTemplateCreatedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('template.created')
resp = SendWebhookRequest.call(webhook_url, event_type: 'template.created',
event_uuid: params['event_uuid'],
record: template,
attempt:,
data: Templates::SerializeForApi.call(template))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
'template_id' => template.id,
'webhook_url_id' => webhook_url.id,
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
@@ -16,13 +16,15 @@ class SendTemplateUpdatedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('template.updated')
resp = SendWebhookRequest.call(webhook_url, event_type: 'template.updated',
event_uuid: params['event_uuid'],
record: template,
attempt:,
data: Templates::SerializeForApi.call(template))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateUpdatedWebhookRequestJob.perform_in((2**attempt).minutes, {
'template_id' => template.id,
'webhook_url_id' => webhook_url.id,
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
+26
View File
@@ -0,0 +1,26 @@
# frozen_string_literal: true
class SendTestWebhookRequestJob
include Sidekiq::Job
sidekiq_options retry: 0
USER_AGENT = 'DocuSeal.com Webhook'
def perform(params = {})
submitter = Submitter.find(params['submitter_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
return unless webhook_url && submitter
Faraday.post(webhook_url.url,
{
event_type: 'form.completed',
timestamp: Time.current.iso8601,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT,
**webhook_url.secret.to_h)
end
end
+13
View File
@@ -0,0 +1,13 @@
# frozen_string_literal: true
class TemplateMailer < ApplicationMailer
def otp_verification_email(template, email:)
@template = template
@otp_code = EmailVerificationCodes.generate([email.downcase.strip, template.slug].join(':'))
assign_message_metadata('otp_verification_email', template)
mail(to: email, subject: I18n.t('email_verification'))
end
end
+1
View File
@@ -33,6 +33,7 @@ class Account < ApplicationRecord
has_many :account_linked_accounts, dependent: :destroy
has_many :email_events, dependent: :destroy
has_many :webhook_urls, dependent: :destroy
has_many :webhook_events, dependent: nil
has_many :account_accesses, dependent: :destroy
has_many :account_testing_accounts, -> { testing }, dependent: :destroy,
class_name: 'AccountLinkedAccount',
+2 -1
View File
@@ -41,8 +41,9 @@ class AccountConfig < ApplicationRecord
FORCE_SSO_AUTH_KEY = 'force_sso_auth'
FLATTEN_RESULT_PDF_KEY = 'flatten_result_pdf'
WITH_SIGNATURE_ID = 'with_signature_id'
WITH_SIGNATURE_ID_REASON_KEY = 'with_signature_id_reason'
WITH_AUDIT_VALUES_KEY = 'with_audit_values'
WITH_AUDIT_SUBMITTER_TIMEZONE_KEY = 'with_audit_submitter_timezone'
WITH_SUBMITTER_TIMEZONE_KEY = 'with_submitter_timezone'
REQUIRE_SIGNING_REASON_KEY = 'require_signing_reason'
REUSE_SIGNATURE_KEY = 'reuse_signature'
COMBINE_PDF_RESULT_KEY = 'combine_pdf_result_key'
+1
View File
@@ -47,6 +47,7 @@ class SubmissionEvent < ApplicationRecord
click_email: 'click_email',
click_sms: 'click_sms',
phone_verified: 'phone_verified',
email_verified: 'email_verified',
start_form: 'start_form',
start_verification: 'start_verification',
complete_verification: 'complete_verification',
+4 -2
View File
@@ -72,12 +72,14 @@ class Template < ApplicationRecord
scope :active, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) }
delegate :name, to: :folder, prefix: true
def application_key
external_id
end
def folder_name
folder.full_name
end
private
def maybe_set_default_folder
+23 -9
View File
@@ -4,36 +4,50 @@
#
# Table name: template_folders
#
# id :bigint not null, primary key
# archived_at :datetime
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# author_id :bigint not null
# id :bigint not null, primary key
# archived_at :datetime
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# author_id :bigint not null
# parent_folder_id :bigint
#
# Indexes
#
# index_template_folders_on_account_id (account_id)
# index_template_folders_on_author_id (author_id)
# index_template_folders_on_account_id (account_id)
# index_template_folders_on_author_id (author_id)
# index_template_folders_on_parent_folder_id (parent_folder_id)
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (author_id => users.id)
# fk_rails_... (parent_folder_id => template_folders.id)
#
class TemplateFolder < ApplicationRecord
DEFAULT_NAME = 'Default'
belongs_to :author, class_name: 'User'
belongs_to :account
belongs_to :parent_folder, class_name: 'TemplateFolder', optional: true
has_many :templates, dependent: :destroy, foreign_key: :folder_id, inverse_of: :folder
has_many :subfolders, class_name: 'TemplateFolder', foreign_key: :parent_folder_id, inverse_of: :parent_folder,
dependent: :destroy
has_many :active_templates, -> { where(archived_at: nil) },
class_name: 'Template', dependent: :destroy, foreign_key: :folder_id, inverse_of: :folder
scope :active, -> { where(archived_at: nil) }
def full_name
if parent_folder_id?
[parent_folder.name, name].join(' / ')
else
name
end
end
def default?
name == DEFAULT_NAME
end
+25
View File
@@ -0,0 +1,25 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: webhook_attempts
#
# id :bigint not null, primary key
# attempt :integer not null
# response_body :text
# response_status_code :integer not null
# created_at :datetime not null
# updated_at :datetime not null
# webhook_event_id :bigint not null
#
# Indexes
#
# index_webhook_attempts_on_webhook_event_id (webhook_event_id)
#
class WebhookAttempt < ApplicationRecord
belongs_to :webhook_event
def success?
[2, 3].include?(response_status_code.to_i / 100)
end
end
+32
View File
@@ -0,0 +1,32 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: webhook_events
#
# id :bigint not null, primary key
# event_type :string not null
# record_type :string not null
# status :string not null
# uuid :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# record_id :bigint not null
# webhook_url_id :bigint not null
#
# Indexes
#
# index_webhook_events_error (webhook_url_id,id) WHERE ((status)::text = 'error'::text)
# index_webhook_events_on_uuid_and_webhook_url_id (uuid,webhook_url_id) UNIQUE
# index_webhook_events_on_webhook_url_id_and_id (webhook_url_id,id)
#
class WebhookEvent < ApplicationRecord
attribute :uuid, :string, default: -> { SecureRandom.uuid }
belongs_to :webhook_url, optional: true
belongs_to :account, optional: true
belongs_to :record, polymorphic: true, optional: true
has_many :webhook_attempts, dependent: nil
end
+1
View File
@@ -37,6 +37,7 @@ class WebhookUrl < ApplicationRecord
].freeze
belongs_to :account
has_many :webhook_events, dependent: nil
attribute :events, :string, default: -> { %w[form.viewed form.started form.completed form.declined] }
attribute :secret, :string, default: -> { {} }
+3 -3
View File
@@ -53,7 +53,7 @@
<span class="top-0 right-0 absolute">
<%= render 'shared/clipboard_copy', icon: 'copy', text:, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
</span>
<pre data-prefix="$"><code class="overflow-hidden w-full"><%= text %></code></pre>
<pre data-prefix="$"><code class="overflow-hidden w-full"><%== HighlightCode.call(text, 'Shell', theme: 'base16.dark') %></code></pre>
</div>
</div>
</div>
@@ -79,7 +79,7 @@
<span class="top-0 right-0 absolute">
<%= render 'shared/clipboard_copy', icon: 'copy', text:, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
</span>
<pre data-prefix="$"><code class="overflow-hidden w-full"><%= text %></code></pre>
<pre data-prefix="$"><code class="overflow-hidden w-full"><%== HighlightCode.call(text, 'Shell', theme: 'base16.dark') %></code></pre>
</div>
</div>
</div>
@@ -101,7 +101,7 @@
<span class="top-0 right-0 absolute">
<%= render 'shared/clipboard_copy', icon: 'copy', text:, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
</span>
<pre data-prefix="$"><code class="overflow-hidden w-full"><%= text %></code></pre>
<pre data-prefix="$"><code class="overflow-hidden w-full"><%== HighlightCode.call(text, 'Shell', theme: 'base16.dark') %></code></pre>
</div>
</div>
</div>
+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="M5 12l14 0" /><path d="M5 12l6 6" /><path d="M5 12l6 -6" />
</svg>

After

Width:  |  Height:  |  Size: 351 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="M12 21a9 9 0 1 0 0 -18a9 9 0 0 0 0 18" /><path d="M8 12l4 4" /><path d="M8 12h8" /><path d="M12 8l-4 4" />
</svg>

After

Width:  |  Height:  |  Size: 398 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="M12 21a9 9 0 1 0 0 -18a9 9 0 0 0 0 18" /><path d="M9 12l4 4" /><path d="M13 8l-4 4" />
</svg>

After

Width:  |  Height:  |  Size: 378 B

+6
View File
@@ -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 stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M11 19h-6a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v6" />
<path d="M3 7l9 6l9 -6" />
<path d="M15 19l2 2l4 -4" />
</svg>

After

Width:  |  Height:  |  Size: 420 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="M12 19h-7a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v3.5" /><path d="M16 19h6" /><path d="M19 16v6" />
</svg>

After

Width:  |  Height:  |  Size: 408 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="M17 5l-10 14" />
</svg>

After

Width:  |  Height:  |  Size: 308 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" wi width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="#000000" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M8.243 7.34l-6.38 .925l-.113 .023a1 1 0 0 0 -.44 1.684l4.622 4.499l-1.09 6.355l-.013 .11a1 1 0 0 0 1.464 .944l5.706 -3l5.693 3l.1 .046a1 1 0 0 0 1.352 -1.1l-1.091 -6.355l4.624 -4.5l.078 -.085a1 1 0 0 0 -.633 -1.62l-6.38 -.926l-2.852 -5.78a1 1 0 0 0 -1.794 0l-2.853 5.78z" stroke-width="0" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 604 B

+6 -3
View File
@@ -2,12 +2,15 @@
if (!window.customElements.get('autosize-field')) {
window.customElements.define('autosize-field', class extends HTMLElement {
connectedCallback() {
const originalFontValue = this.field.style.fontSize
if (this.field.scrollHeight > this.field.clientHeight) {
this.field.classList.remove('text-[1.6vw]', 'lg:text-base');
this.field.classList.add('text-[1.0vw]', 'lg:text-[0.70rem]');
this.field.style.fontSize = `calc(${originalFontValue} / 1.5)`;
this.field.style.lineHeight = `calc(${this.field.style.fontSize} * 1.3)`;
if (this.field.scrollHeight > this.field.clientHeight) {
this.field.classList.add('text-[0.8vw]', 'lg:text-[0.50rem]');
this.field.style.fontSize = `calc(${originalFontValue} / 2.0)`;
this.field.style.lineHeight = `calc(${this.field.style.fontSize} * 1.3)`;
}
}
}
+5 -2
View File
@@ -1,3 +1,6 @@
<a href="<%= Docuseal::GITHUB_URL %>" target="_blank" class="inline">
<img alt="GitHub Repo stars" src="https://www.docuseal.com/github-badge.svg" style="height: 22px">
<a target="_blank" href="<%= Docuseal::GITHUB_URL %>" rel="noopener noreferrer nofollow" class="relative flex items-center rounded-full px-2 py-0.5 text-xs leading-4 mt-1 text-base-content border border-base-300 tooltip tooltip-bottom" data-tip="Give a star on GitHub">
<span class="flex items-center justify-between space-x-0.5 font-medium">
<%= svg_icon('start', class: 'h-3 w-3') %>
<span>9k</span>
</span>
</a>
+2 -2
View File
@@ -5,7 +5,7 @@
<%= render 'shared/title' %>
</a>
<span class="hidden sm:inline">
<%= render 'shared/github' %>
<%= render 'shared/github' if request.path.starts_with?('/settings') %>
</span>
</div>
<% if signed_in? %>
@@ -79,7 +79,7 @@
</div>
<% else %>
<div class="flex space-x-2">
<% if request.path != new_user_session_path %>
<% if request.path != new_user_session_path && request.path != setup_index_path %>
<%= link_to new_user_session_path({ lang: params[:lang] }.compact_blank), class: 'font-medium text-lg' do %>
<span class="flex items-center justify-center space-x-1">
<%= svg_icon('login', class: 'w-6 h-6') %>
+5 -5
View File
@@ -1,8 +1,8 @@
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'hidden md:inline-flex btn btn-warning btn-sm', data: { prefetch: false } do %>
<%= t('upgrade') %>
<% end %>
<% if signed_in? && current_user != true_user %>
<span class="hidden md:inline-flex h-3 border-r border-base-content"></span>
<%= render 'shared/test_alert' %>
<% elsif request.path.starts_with?('/settings') %>
<%= link_to "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'hidden md:inline-flex btn btn-warning btn-sm', data: { prefetch: false } do %>
<%= t('upgrade') %>
<% end %>
<span class="hidden md:inline-flex h-3 border-r border-base-content"></span>
<% end %>
<span class="hidden md:inline-flex h-3 border-r border-base-content"></span>
+2 -2
View File
@@ -1,7 +1,7 @@
<div class="block w-full md:w-52 flex-none">
<menu-active>
<ul id="account_settings_menu" class="menu px-0">
<li class="menu-title py-0 !bg-transparent mb-3 -mt-5"><a href="<%= '/' %>" class="!bg-transparent !text-neutral font-medium">&larr; <%= t('back') %></a></li>
<li class="menu-title py-0 !bg-transparent mb-3 -mt-5"><a href="<%= '/' %>" class="!bg-transparent !text-neutral font-medium flex items-center space-x-0.5"><%= svg_icon('arrow_left', class: 'w-4 h-4 stroke-2') %><span><%= t('back') %></span></a></li>
<li class="menu-title py-0 !bg-transparent">
<span class="!bg-transparent"><%= t('settings') %></span>
</li>
@@ -66,7 +66,7 @@
<li>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
<%= t('plans') %>
<span class="badge badge-warning"><%= t('new') %></span>
<span class="badge badge-warning"><%= t('pro') %></span>
<% end %>
</li>
<% end %>
@@ -1,4 +1,3 @@
<% 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') %>
@@ -6,7 +5,7 @@
<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' %>">
<button class="<%= 'bg-base-200' if selected_order == 'created_at' %>">
<%= svg_icon('sort_descending_numbers', class: 'w-4 h-4') %>
<span class="whitespace-nowrap"><%= t('newest_first') %></span>
</button>
@@ -15,7 +14,7 @@
<% 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' %>">
<button class="<%= 'bg-base-200' if selected_order == 'used_at' %>">
<%= svg_icon('sort_descending_small_big', class: 'w-4 h-4') %>
<span class="whitespace-nowrap"><%= t('recently_used') %></span>
</button>
@@ -24,7 +23,7 @@
<% end %>
<toggle-cookies data-value="name" data-key="dashboard_templates_order">
<li>
<button class="<%= 'bg-base-200' if dashboard_templates_order == 'name' %>">
<button class="<%= 'bg-base-200' if selected_order == 'name' %>">
<%= svg_icon('sort_ascending_letters', class: 'w-4 h-4') %>
<span class="whitespace-nowrap"><%= t('name_a_z') %></span>
</button>
+1 -1
View File
@@ -14,7 +14,7 @@
&times;
</span>
<% end %>
<div class="w-full md:w-[620px] overflow-y-auto" style="max-height: calc(100vh - <%= local_assigns[:title] ? '45px' : '0px' %>)">
<div class="w-screen md:w-[620px] overflow-y-auto" style="max-height: calc(100vh - <%= local_assigns[:title] ? '45px' : '0px' %>)">
<%= yield %>
</div>
</div>
@@ -0,0 +1,53 @@
<% content_for(:html_title, "#{@template.name} | DocuSeal") %>
<% I18n.with_locale(@template.account.locale) do %>
<% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @template.account.name)) %>
<% end %>
<div class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto">
<div class="space-y-6">
<div class="flex items-center justify-center">
<%= render 'start_form/banner' %>
</div>
<div class="flex items-center bg-base-200 rounded-xl p-4 mb-4">
<div class="flex items-center">
<div class="mr-3">
<%= svg_icon('writing_sign', class: 'w-10 h-10') %>
</div>
<div>
<p class="text-lg font-bold mb-1"><%= @template.name %></p>
<p dir="auto" class="text-sm"><%= t('invited_by_html', name: @template.account.name) %></p>
</div>
</div>
</div>
</div>
<div>
<%= t('we_sent_a_one_time_verification_code_to_your_email_address_please_enter_the_code_below_to_continue') %>
</div>
<%= form_for '', url: start_form_path(@template.slug), method: :put, html: { class: 'space-y-4', id: 'code_form' } do |f| %>
<div dir="auto" class="form-control !mt-0">
<%= f.hidden_field 'submitter[name]', value: params[:name] || @submitter&.name %>
<%= f.hidden_field 'submitter[email]', value: params[:email] || @submitter&.email %>
<%= f.hidden_field 'submitter[phone]', value: params[:phone] || @submitter&.phone %>
<%= f.text_field :one_time_code, required: true, class: 'base-input text-center', placeholder: 'XXX-XXX' %>
<div class="flex justify-between items-center mt-1">
<span>
<% if flash[:alert] %>
<span class="text-red-500">
<%= flash[:alert] %>
</span>
<% elsif flash[:notice] %>
<%= flash[:notice] %>
<% end %>
</span>
<span>
<label for="resend_code" id="resend_label" class="link"><%= t(:re_send_code) %></label>
</span>
</div>
</div>
<toggle-submit dir="auto" class="form-control">
<%= f.button button_title(title: t('submit')), class: 'base-button' %>
</toggle-submit>
<% end %>
<%= button_to t(:re_send_email), start_form_email_2fa_send_index_path, params: { slug: @template.slug, resend: true, submitter: { name: params[:name] || @submitter&.name, email: params[:email] || @submitter&.email, phone: params[:phone] || @submitter&.phone } }.compact, method: :post, id: 'resend_code', class: 'hidden' %>
</div>
</div>
+13 -9
View File
@@ -3,7 +3,8 @@
<% color = field.dig('preferences', 'color') %>
<% font = field.dig('preferences', 'font') %>
<% 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' ? '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') %>">
<% font_size_px = (field.dig('preferences', 'font_size').presence || Submissions::GenerateResultAttachments::FONT_SIZE).to_i * local_assigns.fetch(:font_scale) { 1000.0 / PdfUtils::US_LETTER_W } %>
<field-value dir="auto" class="flex absolute <%= 'font-courier' if font == 'Courier' %> <%= 'font-times' 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: <%= fs = "clamp(1pt, #{font_size_px / 10}vw, #{font_size_px}px)" %>; line-height: calc(<%= fs %> * 1.3); font-size: <%= fs = "#{font_size_px / 10}cqmin" %>; line-height: calc(<%= fs %> * 1.3)">
<% if field['type'] == 'signature' %>
<% is_narrow = area['h']&.positive? && (area['w'].to_f / area['h']) > 6 %>
<div class="flex justify-between w-full h-full gap-1 <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'flex-row' : 'flex-col' %>">
@@ -15,15 +16,18 @@
<div class="truncate uppercase">
ID: <%= attachment.uuid %>
</div>
<% if local_assigns[:with_signature_id_reason] != false %>
<div>
<% reason_value = submitter.values[field.dig('preferences', 'reason_field_uuid')].presence %>
<% if reason_value %><%= t('reason') %>: <% end %><%= reason_value || t('digitally_signed_by') %> <%= submitter.name %>
<% if submitter.email %>
&lt;<%= submitter.email %>&gt;
<% end %>
</div>
<% end %>
<div>
<% reason_value = submitter.values[field.dig('preferences', 'reason_field_uuid')].presence %>
<% if reason_value %><%= t('reason') %>: <% end %><%= reason_value || t('digitally_signed_by') %> <%= submitter.name %>
<% if submitter.email %>
&lt;<%= submitter.email %>&gt;
<% end %>
</div>
<div>
<%= l(attachment.created_at.in_time_zone(local_assigns[:timezone]), format: :long, locale: local_assigns[:locale]) %> <%= TimeUtils.timezone_abbr(local_assigns[:timezone], attachment.created_at) %>
<% timezone = local_assigns[:with_submitter_timezone] ? (submitter.timezone || local_assigns[:timezone]) : local_assigns[:timezone] %>
<%= l(attachment.created_at.in_time_zone(timezone), format: :long, locale: local_assigns[:locale]) %> <%= TimeUtils.timezone_abbr(timezone, attachment.created_at) %>
</div>
</div>
<% end %>
+14 -8
View File
@@ -1,7 +1,12 @@
<% if params[:controller] == 'submissions_preview' %>
<%= render 'submissions/preview_tags' %>
<% end %>
<% with_signature_id, is_combined_enabled = AccountConfig.where(account_id: @submission.account_id, key: [AccountConfig::COMBINE_PDF_RESULT_KEY, AccountConfig::WITH_SIGNATURE_ID], value: true).then { |configs| [configs.any? { |e| e.key == AccountConfig::WITH_SIGNATURE_ID }, configs.any? { |e| e.key == AccountConfig::COMBINE_PDF_RESULT_KEY }] } %>
<% font_scale = 1040.0 / PdfUtils::US_LETTER_W %>
<% configs = AccountConfig.where(account_id: @submission.account_id, key: [AccountConfig::COMBINE_PDF_RESULT_KEY, AccountConfig::WITH_SIGNATURE_ID, AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY, AccountConfig::WITH_SIGNATURE_ID_REASON_KEY]) %>
<% with_signature_id = configs.find { |e| e.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true %>
<% is_combined_enabled = configs.find { |e| e.key == AccountConfig::COMBINE_PDF_RESULT_KEY }&.value == true %>
<% with_submitter_timezone = configs.find { |e| e.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true %>
<% with_signature_id_reason = configs.find { |e| e.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false %>
<div style="max-width: 1600px" class="mx-auto pl-4">
<div class="flex justify-between py-1.5 items-center pr-4 sticky top-0 md:relative z-10 bg-base-100">
<a href="<%= signed_in? && @submission.account_id == current_account&.id && @submission.template ? template_path(@submission.template) : '/' %>" class="flex items-center space-x-3 py-1">
@@ -94,11 +99,11 @@
<% document = @submission.schema_documents.find { |e| e.uuid == item['attachment_uuid'] } %>
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.first&.metadata || {} %>
<% lazyload_metadata = document.preview_images.first&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_uuid, "#{index}.jpg")) %>
<div id="<%= "page-#{document.uuid}-#{index}" %>" class="relative">
<img loading="lazy" src="<%= Docuseal::URL_CACHE.fetch([document.id, document.uuid, index].join(':'), expires_in: 10.minutes) { page.url } %>" width="<%= page.metadata['width'] %>" class="border rounded mb-4" height="<%= page.metadata['height'] %>">
<page-container id="<%= "page-#{document.uuid}-#{index}" %>" class="block before:border before:absolute before:top-0 before:bottom-0 before:left-0 before:right-0 before:rounded relative mb-4" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>">
<img loading="lazy" src="<%= Docuseal::URL_CACHE.fetch([document.id, document.uuid, index].join(':'), expires_in: 10.minutes) { page.url } %>" width="<%= width %>" class="rounded" height="<%= height %>">
<div class="top-0 bottom-0 left-0 right-0 absolute">
<% document_annots_index[index]&.each do |annot| %>
<%= render 'submissions/annotation', annot: %>
@@ -107,21 +112,22 @@
<% value = values[field['uuid']] %>
<% value ||= field['default_value'] if field['type'] == 'heading' %>
<% next if value.blank? %>
<% submitter = submitters_index[field['submitter_uuid']] %>
<% if (mask = field.dig('preferences', 'mask').presence) && signed_in? && can?(:read, @submission) %>
<span class="group">
<span class="hidden group-hover:inline">
<%= render 'submissions/value', area:, field:, attachments_index:, value:, locale: @submission.account.locale, timezone: @submission.account.timezone, submitter: submitters_index[field['submitter_uuid']], with_signature_id: %>
<%= render 'submissions/value', font_scale:, area:, field:, attachments_index:, value:, locale: @submission.account.locale, timezone: @submission.account.timezone, submitter:, with_signature_id: %>
</span>
<span class="group-hover:hidden">
<%= render 'submissions/value', area:, field:, attachments_index:, value: Array.wrap(value).map { |e| TextUtils.mask_value(e, mask) }.join(', '), locale: @submission.account.locale, timezone: @submission.account.timezone, submitter: submitters_index[field['submitter_uuid']], with_signature_id: %>
<%= render 'submissions/value', font_scale:, area:, field:, attachments_index:, value: Array.wrap(value).map { |e| TextUtils.mask_value(e, mask) }.join(', '), locale: @submission.account.locale, timezone: @submission.account.timezone, submitter:, with_signature_id: %>
</span>
</span>
<% else %>
<%= render 'submissions/value', area:, field:, attachments_index:, value: mask.present? ? Array.wrap(value).map { |e| TextUtils.mask_value(e, mask) }.join(', ') : value, locale: @submission.account.locale, timezone: @submission.account.timezone, submitter: submitters_index[field['submitter_uuid']], with_signature_id: %>
<%= render 'submissions/value', font_scale:, area:, field:, attachments_index:, value: mask.present? ? Array.wrap(value).map { |e| TextUtils.mask_value(e, mask) }.join(', ') : value, locale: @submission.account.locale, timezone: @submission.account.timezone, submitter:, with_signature_id:, with_submitter_timezone:, with_signature_id_reason: %>
<% end %>
<% end %>
</div>
</div>
</page-container>
<% end %>
<% end %>
</div>
@@ -1,14 +1,14 @@
<% filter_params = params.permit(Submissions::Filter::ALLOWED_PARAMS).compact_blank %>
<div>
<%= link_to root_path do %>
&larr;
<span><%= t('back_to_active') %></span>
<%= link_to root_path, class: 'flex items-center' do %>
<%= svg_icon('chevron_left', class: 'w-5 h-5') %>
<span style="margin-left: 3px"><%= t('back_to_active') %></span>
<% end %>
</div>
<div class="flex flex-col md:flex-row md:items-center mb-4 gap-3">
<div class="flex w-full justify-between">
<div class="flex w-full justify-between items-center">
<div>
<h1 class="text-4xl font-bold md:block <%= 'hidden' if params[:q].present? %>"><%= t('submissions') %> <span class="badge badge-outline badge-lg align-middle"><%= t('archived') %></span></h1>
<h1 class="text-2xl md:text-3xl font-bold md:block <%= 'hidden' if params[:q].present? %>"><%= t('submissions') %> <span class="badge badge-outline badge-lg align-middle"><%= t('archived') %></span></h1>
</div>
<div>
<% if params[:q].present? || @pagy.pages > 1 || filter_params.present? %>
@@ -10,6 +10,17 @@
<% end %>
</div>
<% end %>
<% if params[:folder].present? %>
<div class="tooltip tooltip-bottom flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-34 border-neutral-700" data-tip="<%= t('folder') %>">
<%= link_to submissions_filter_path('folder', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal' }, class: 'flex items-center space-x-1 w-full pr-1 md:max-w-[140px]' do %>
<%= svg_icon('folder', class: 'w-5 h-5 shrink-0') %>
<span class="font-normal truncate"><%= params[:folder] %></span>
<% end %>
<%= link_to url_for(params.to_unsafe_h.except(:folder)), class: 'rounded-lg ml-1 hover:bg-base-content hover:text-white' do %>
<%= svg_icon('x', class: 'w-5 h-5') %>
<% end %>
</div>
<% end %>
<% if params[:author].present? %>
<div class="tooltip tooltip-bottom flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-34 border-neutral-700" data-tip="<%= t('author') %>">
<%= link_to submissions_filter_path('author', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal' }, class: 'flex items-center space-x-1 w-full pr-1 md:max-w-[140px]' do %>
+6 -5
View File
@@ -7,6 +7,7 @@
<% submitters_index = @submitter.submission.submitters.index_by(&:uuid) %>
<% page_blob_struct = Struct.new(:url, :metadata, keyword_init: true) %>
<% schema = Submissions.filtered_conditions_schema(@submitter.submission, values:, include_submitter_uuid: @submitter.uuid) %>
<% font_scale = 1000.0 / PdfUtils::US_LETTER_W %>
<div style="max-height: -webkit-fill-available;">
<div id="scrollbox">
<div class="mx-auto block pb-72" style="max-width: 1000px">
@@ -66,11 +67,11 @@
<div id="document-<%= document.uuid %>">
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.last&.metadata || {} %>
<% lazyload_metadata = document.preview_images.last&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_uuid, "#{index}.jpg")) %>
<div class="relative my-4 shadow-md">
<img loading="lazy" src="<%= page.url %>" width="<%= page.metadata['width'] %>" height="<%= page.metadata['height'] %>">
<page-container class="block relative my-4 shadow-md" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>">
<img loading="lazy" src="<%= page.url %>" width="<%= width %>" height="<%= height %>">
<div id="page-<%= [document.uuid, index].join('-') %>" class="top-0 bottom-0 left-0 right-0 absolute">
<% if annots = document_annots_index[index] %>
<%= render 'submit_form/annotations', annots: %>
@@ -83,10 +84,10 @@
<% next if field['conditions'].present? && values[field['uuid']].blank? && field['submitter_uuid'] != @submitter.uuid %>
<% next if field['conditions'].present? && field['submitter_uuid'] == @submitter.uuid %>
<% next if field.dig('preferences', 'formula').present? && field['submitter_uuid'] == @submitter.uuid %>
<%= render 'submissions/value', area:, field:, attachments_index: @attachments_index, value: field.dig('preferences', 'mask').present? ? TextUtils.mask_value(value, field.dig('preferences', 'mask')) : value, locale: @submitter.account.locale, timezone: @submitter.account.timezone, submitter: submitters_index[field['submitter_uuid']], with_signature_id: @form_configs[:with_signature_id] %>
<%= render 'submissions/value', font_scale:, area:, field:, attachments_index: @attachments_index, value: field.dig('preferences', 'mask').present? ? TextUtils.mask_value(value, field.dig('preferences', 'mask')) : value, locale: @submitter.account.locale, timezone: @submitter.account.timezone, submitter: submitters_index[field['submitter_uuid']], with_signature_id: @form_configs[:with_signature_id], with_submitter_timezone: @form_configs[:with_submitter_timezone], with_signature_id_reason: @form_configs[:with_signature_id_reason] %>
<% end %>
</div>
</div>
</page-container>
<% end %>
</div>
<% end %>
+1 -1
View File
@@ -1,5 +1,5 @@
<% 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 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">
<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" data-full-name="<%= folder.full_name %>">
<% if !is_long %>
<%= svg_icon('folder', class: 'w-6 h-6') %>
<% end %>
+27 -10
View File
@@ -1,24 +1,27 @@
<div>
<%= link_to root_path do %>
&larr;
<span><%= t('home') %></span>
<%= link_to @template_folder.parent_folder ? folder_path(@template_folder.parent_folder) : root_path, class: 'flex items-center' do %>
<%= svg_icon('chevron_left', class: 'w-5 h-5') %>
<span style="margin-left: 3px"><%= @template_folder.parent_folder&.name || t('home') %></span>
<% end %>
</div>
<dashboard-dropzone>
<div class="relative flex justify-between items-center w-full mb-4">
<%= form_for '', url: '', id: form_id = SecureRandom.uuid, method: :post, class: 'hidden', data: { target: 'dashboard-dropzone.form' }, html: { enctype: 'multipart/form-data' } do %>
<input name="form_id" value="<%= form_id %>">
<input name="folder_name" value="<%= @template_folder.name %>">
<input name="folder_name" value="<%= @template_folder.full_name %>">
<button type="submit"></button>
<input id="dashboard_dropzone_input" name="files[]" type="file" multiple>
<% end %>
<%= render 'templates/dashboard_dropzone', style: 'height: 137px' %>
<% unless @template_folder.parent_folder %>
<%= render 'templates/dashboard_folder_dropzone', style: 'height: 137px' %>
<% end %>
<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? %>">
<%= svg_icon('folder', class: 'w-9 h-9 flex-shrink-0') %>
<span class="peer truncate">
<%= @template_folder.name %>
</span>
<% if can?(:update, @template_folder) && @template_folder.name != TemplateFolder::DEFAULT_NAME %>
<% if can?(:update, @template_folder) && @template_folder.full_name != TemplateFolder::DEFAULT_NAME %>
<span class="opacity-0 hover:opacity-100 peer-hover:opacity-100">
<a href="<%= edit_folder_path(@template_folder) %>" data-turbo-frame="modal">
<%= svg_icon('pencil', class: 'w-7 h-7') %>
@@ -27,33 +30,47 @@
<% end %>
</h1>
<div class="flex space-x-2">
<% if params[:q].present? || @pagy.pages > 1 %>
<% if params[:q].present? || @pagy.pages > 1 || @template_folders.present? %>
<%= 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 %>
<%= render 'templates/upload_button', folder_name: @template_folder.full_name %>
<%= link_to new_template_path(folder_name: @template_folder.full_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 %>
</div>
</div>
<% 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 @pagy.count.nil? || @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.present? && @pagy.count < 10_000) %>
<%= render 'shared/templates_order_select', with_recently_used: @pagy.count.present? && @pagy.count < 10_000 && !can?(:manage, :countless), selected_order: %>
<% end %>
<% end %>
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', right_additional_html: templates_order_select_html %>
<%= render 'shared/pagination', pagy: @pagy, items_name: @templates.present? ? 'templates' : 'template_folders', 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>
<% if @related_submissions.present? %>
<h1 class="text-2xl md:text-3xl sm:text-4xl font-bold mt-8 md:mt-4">
<%= t('submissions') %>
</h1>
<div class="space-y-4 mt-4">
<%= render partial: 'templates/submission', collection: @related_submissions, locals: { with_template: true } %>
</div>
<%= render 'shared/pagination', pagy: @related_submissions_pagy, items_name: 'submissions', next_page_path: submissions_path(q: params[:q], folder: @template_folder.full_name) %>
<% end %>
<% end %>
</dashboard-dropzone>
@@ -0,0 +1,3 @@
<p><%= t('your_verification_code_to_access_the_name', name: @template.name) %></p>
<p><b><%= @otp_code %></b></p>
<p><%= t('please_reply_to_this_email_if_you_didnt_request_this') %></p>
@@ -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.folderDropzone" 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('folder_plus', class: 'w-9 h-9') %>
</span>
<div class="font-medium mb-1">
<%= t('create_a_new_folder') %>
</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('loading') %>...
</div>
</span>
</div>
</div>
</div>
+2 -2
View File
@@ -1,6 +1,6 @@
<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 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="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="line-height: 1.6rem; overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;">
<% if template.template_accesses.present? %>
<%= svg_icon('lock', class: 'w-6 h-6 inline -translate-y-[4px]') %>
<% end %>
@@ -22,7 +22,7 @@
<% if template.archived_at? %>
<span class="flex items-center space-x-1 w-1/2">
<%= svg_icon('folder', class: 'w-4 h-4 flex-shrink-0') %>
<span class="truncate"><%= template.folder.name %></span>
<span class="truncate"><%= template.folder.full_name %></span>
</span>
<% end %>
</p>

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