Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f627277b45 | |||
| 809c3ea270 | |||
| 9caa9d79d4 | |||
| a8f1c1c323 | |||
| 59fbea5c5f | |||
| d3273d879a | |||
| ea227e82c9 | |||
| 676b330aa3 | |||
| 8d3295e125 | |||
| 8cb74e0bcc | |||
| 26749a4dbc | |||
| 14915d37b4 | |||
| 16d6d58efb | |||
| b2b97a313a | |||
| 2974e6a64f | |||
| dae34d0aa5 | |||
| e9d0671184 | |||
| 897e33bf6a | |||
| f37e93a409 | |||
| 561ac63c93 | |||
| 149e4be07a | |||
| eb98dc9e06 | |||
| ce7d965358 | |||
| bb73024859 | |||
| 631a442e2a | |||
| 20a082ae8e | |||
| 154d706be1 | |||
| aaae257c75 | |||
| ec944a2283 | |||
| 69d63ecffe | |||
| 06a1e2992d | |||
| ec9293bac0 | |||
| 8bf727c43c | |||
| 708b8afdf5 | |||
| 6f7b9b1ebf | |||
| 4f48c4fd9f | |||
| 9dac90e6cc | |||
| 0741a6f485 | |||
| 1f338dfd2b | |||
| 0310c26edd | |||
| e43570d4c1 | |||
| d7142c2935 | |||
| fa6dc04ef1 | |||
| c5ec7a4b37 | |||
| bd3d643318 | |||
| 1ae635963c | |||
| a61c3e798d | |||
| 931addedbe | |||
| 9aa4a547cf | |||
| 82a7df5e3f | |||
| 0ff87383d0 | |||
| ea3dec443f | |||
| d7a895b98b | |||
| b90b7b7845 | |||
| 3d05d75f2d | |||
| e701881377 | |||
| 4615e3e48f | |||
| 988a5361a6 | |||
| 1b60b42428 | |||
| 7b74487214 | |||
| 91d98ed267 | |||
| 24fbb7de06 | |||
| 91df5b2407 | |||
| eef2d9d599 | |||
| 8ad70b7e74 | |||
| 8b775103c7 | |||
| 8c2475aac1 | |||
| d3236ae4a1 | |||
| 8fd9af3a6a |
@@ -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,13 +1,27 @@
|
||||
name: Build Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*.*.*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Version
|
||||
type: string
|
||||
required: true
|
||||
image:
|
||||
description: QEMU image
|
||||
type: string
|
||||
required: false
|
||||
default: tonistiigi/binfmt:latest
|
||||
os:
|
||||
description: OS
|
||||
type: string
|
||||
required: false
|
||||
default: ubuntu-24.04-arm
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ${{ inputs.os }}
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -15,23 +29,23 @@ jobs:
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
-
|
||||
name: Docker meta
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
docuseal/docuseal
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
images: docuseal/docuseal
|
||||
tags: latest,${{ inputs.version }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: ${{ inputs.image }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create .version file
|
||||
run: echo ${{ github.ref_name }} > .version
|
||||
run: echo ${{ inputs.version }} > .version
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
|
||||
@@ -63,7 +63,7 @@ RSpec/MultipleExpectations:
|
||||
Max: 25
|
||||
|
||||
RSpec/ExampleLength:
|
||||
Max: 50
|
||||
Max: 500
|
||||
|
||||
RSpec/MultipleMemoizedHelpers:
|
||||
Max: 15
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -319,7 +319,7 @@ GEM
|
||||
method_source (1.1.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.8)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (5.25.4)
|
||||
msgpack (1.7.5)
|
||||
multi_json (1.15.0)
|
||||
@@ -338,18 +338,18 @@ GEM
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.8)
|
||||
nokogiri (1.18.9)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-aarch64-linux-gnu)
|
||||
nokogiri (1.18.9-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-aarch64-linux-musl)
|
||||
nokogiri (1.18.9-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-arm64-darwin)
|
||||
nokogiri (1.18.9-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-x86_64-linux-gnu)
|
||||
nokogiri (1.18.9-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-x86_64-linux-musl)
|
||||
nokogiri (1.18.9-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
oj (3.16.8)
|
||||
bigdecimal (>= 3.0)
|
||||
@@ -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)
|
||||
@@ -540,7 +541,7 @@ GEM
|
||||
stringio (3.1.2)
|
||||
strip_attributes (1.14.1)
|
||||
activemodel (>= 3.0, < 9.0)
|
||||
thor (1.3.2)
|
||||
thor (1.4.0)
|
||||
timeout (0.4.3)
|
||||
trailblazer-option (0.1.2)
|
||||
turbo-rails (2.0.11)
|
||||
@@ -632,6 +633,7 @@ DEPENDENCIES
|
||||
rails-i18n
|
||||
rails_autolink
|
||||
rotp
|
||||
rouge
|
||||
rqrcode
|
||||
rspec-rails
|
||||
rubocop
|
||||
|
||||
@@ -10,10 +10,21 @@ module Api
|
||||
def create
|
||||
submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
if params[:type].in?(%w[initials signature]) && ImageUtils.blank?(Vips::Image.new_from_file(params[:file].path))
|
||||
Rollbar.error("Empty signature: #{submitter.id}") if defined?(Rollbar)
|
||||
if params[:type].in?(%w[initials signature])
|
||||
image = Vips::Image.new_from_file(params[:file].path)
|
||||
|
||||
return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_entity
|
||||
if ImageUtils.blank?(image)
|
||||
Rollbar.error("Empty signature: #{submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if ImageUtils.error?(image)
|
||||
Rollbar.error("Error signature: #{submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: "#{params[:type]} error, try to sign on another device" },
|
||||
status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
attachment = Submitters.create_attachment!(submitter, params)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -192,7 +185,7 @@ module Api
|
||||
message: %i[subject body],
|
||||
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
|
||||
:completed, :phone, :application_key, :external_id, :reply_to, :go_to_last,
|
||||
:require_phone_2fa,
|
||||
:require_phone_2fa, :order,
|
||||
{ metadata: {}, values: {}, roles: [], readonly_fields: [], message: %i[subject body],
|
||||
fields: [:name, :uuid, :default_value, :value, :title, :description,
|
||||
:readonly, :required, :validation_pattern, :invalid_message,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -109,10 +106,10 @@ module Api
|
||||
:external_id,
|
||||
:shared_link,
|
||||
{
|
||||
submitters: [%i[name uuid is_requester invite_by_uuid optional_invite_by_uuid linked_to_uuid email]],
|
||||
submitters: [%i[name uuid is_requester invite_by_uuid optional_invite_by_uuid linked_to_uuid email order]],
|
||||
fields: [[:uuid, :submitter_uuid, :name, :type,
|
||||
:required, :readonly, :default_value,
|
||||
:title, :description,
|
||||
:title, :description, :prefillable,
|
||||
{ preferences: {},
|
||||
conditions: [%i[field_uuid value action operation]],
|
||||
options: [%i[value uuid]],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -32,10 +32,7 @@ class SubmissionsController < ApplicationController
|
||||
def create
|
||||
save_template_message(@template, params) if params[:save_message] == '1'
|
||||
|
||||
if params[:is_custom_message] != '1'
|
||||
params.delete(:subject)
|
||||
params.delete(:body)
|
||||
end
|
||||
[params.delete(:subject), params.delete(:body)] if params[:is_custom_message] != '1'
|
||||
|
||||
submissions =
|
||||
if params[:emails].present?
|
||||
@@ -46,15 +43,20 @@ class SubmissionsController < ApplicationController
|
||||
emails: params[:emails],
|
||||
params: params.merge('send_completed_email' => true))
|
||||
else
|
||||
submissions_attrs = submissions_params[:submission].to_h.values
|
||||
|
||||
submissions_attrs, =
|
||||
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, @template)
|
||||
|
||||
Submissions.create_from_submitters(template: @template,
|
||||
user: current_user,
|
||||
source: :invite,
|
||||
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
|
||||
submissions_attrs: submissions_params[:submission].to_h.values,
|
||||
submissions_attrs:,
|
||||
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)
|
||||
|
||||
@@ -62,9 +64,8 @@ class SubmissionsController < ApplicationController
|
||||
|
||||
redirect_to template_path(@template), notice: I18n.t('new_recipients_have_been_added')
|
||||
rescue Submissions::CreateFromSubmitters::BaseError => e
|
||||
render turbo_stream: turbo_stream.replace(:submitters_error,
|
||||
partial: 'submissions/error',
|
||||
locals: { error: e.message }),
|
||||
render turbo_stream: turbo_stream.replace(:submitters_error, partial: 'submissions/error',
|
||||
locals: { error: e.message }),
|
||||
status: :unprocessable_entity
|
||||
end
|
||||
|
||||
@@ -77,10 +78,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,17 +95,8 @@ 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]] })
|
||||
params.permit(submission: { submitters: [:uuid, :email, :phone, :name, { values: {} }] })
|
||||
end
|
||||
|
||||
def load_template
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -117,10 +117,10 @@ class TemplatesController < ApplicationController
|
||||
params.require(:template).permit(
|
||||
:name,
|
||||
{ schema: [[:attachment_uuid, :name, { conditions: [%i[field_uuid value action operation]] }]],
|
||||
submitters: [%i[name uuid is_requester linked_to_uuid invite_by_uuid optional_invite_by_uuid email]],
|
||||
submitters: [%i[name uuid is_requester linked_to_uuid invite_by_uuid optional_invite_by_uuid email order]],
|
||||
fields: [[:uuid, :submitter_uuid, :name, :type,
|
||||
:required, :readonly, :default_value,
|
||||
:title, :description,
|
||||
:title, :description, :prefillable,
|
||||
{ preferences: {},
|
||||
conditions: [%i[field_uuid value action operation]],
|
||||
options: [%i[value uuid]],
|
||||
@@ -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,8 +26,8 @@ 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
|
||||
default_expire_at_duration shared_link_2fa
|
||||
default_expire_at request_email_enabled
|
||||
completed_notification_email_subject completed_notification_email_body
|
||||
completed_notification_email_enabled completed_notification_email_attach_audit] +
|
||||
[completed_message: %i[title body],
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesPrefillableFieldsController < ApplicationController
|
||||
PREFILLABLE_FIELD_TYPES = %w[text number cells date checkbox select radio phone].freeze
|
||||
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def create
|
||||
authorize!(:update, @template)
|
||||
|
||||
field = @template.fields.find { |f| f['uuid'] == params[:field_uuid] }
|
||||
|
||||
if params[:prefillable] == 'false'
|
||||
field.delete('prefillable')
|
||||
field.delete('readonly')
|
||||
elsif params[:prefillable] == 'true'
|
||||
field['prefillable'] = true
|
||||
field['readonly'] = true
|
||||
end
|
||||
|
||||
@template.save!
|
||||
|
||||
render turbo_stream: turbo_stream.replace(:prefillable_fields_list, partial: 'list',
|
||||
locals: { template: @template })
|
||||
end
|
||||
end
|
||||
@@ -9,6 +9,10 @@ class TemplatesRecipientsController < ApplicationController
|
||||
@template.submitters =
|
||||
submitters_params.map { |s| s.reject { |_, v| v.is_a?(String) && v.blank? } }
|
||||
|
||||
if @template.submitters.each_with_index.all? { |s, index| s['order'] == index }
|
||||
@template.submitters.each { |s| s.delete('order') }
|
||||
end
|
||||
|
||||
@template.save!
|
||||
|
||||
render json: { submitters: @template.submitters }
|
||||
@@ -18,7 +22,7 @@ class TemplatesRecipientsController < ApplicationController
|
||||
|
||||
def submitters_params
|
||||
permit_params = { submitters: [%i[name uuid is_requester optional_invite_by_uuid
|
||||
invite_by_uuid linked_to_uuid email option]] }
|
||||
invite_by_uuid linked_to_uuid email option order]] }
|
||||
|
||||
params.require(:template).permit(permit_params).fetch(:submitters, {}).values.filter_map do |s|
|
||||
next if s[:uuid].blank?
|
||||
@@ -29,6 +33,7 @@ class TemplatesRecipientsController < ApplicationController
|
||||
s.delete(:is_requester)
|
||||
end
|
||||
|
||||
s[:order] = s[:order].to_i if s[:order].present?
|
||||
s.delete(:invite_by_uuid) if s[:invite_by_uuid].blank?
|
||||
s.delete(:optional_invite_by_uuid) if s[:optional_invite_by_uuid].blank?
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -36,6 +36,9 @@ 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 EmailEditor from './elements/email_editor'
|
||||
import MountOnClick from './elements/mount_on_click'
|
||||
|
||||
import * as TurboInstantClick from './lib/turbo_instant_click'
|
||||
|
||||
@@ -107,6 +110,9 @@ safeRegisterElement('app-tour', AppTour)
|
||||
safeRegisterElement('dashboard-dropzone', DashboardDropzone)
|
||||
safeRegisterElement('check-on-click', CheckOnClick)
|
||||
safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup)
|
||||
safeRegisterElement('page-container', PageContainer)
|
||||
safeRegisterElement('email-editor', EmailEditor)
|
||||
safeRegisterElement('mount-on-click', MountOnClick)
|
||||
|
||||
safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { target, targetable } from '@github/catalyst/lib/targetable'
|
||||
|
||||
let loaderPromise = null
|
||||
|
||||
function loadCodeMirror () {
|
||||
if (!loaderPromise) {
|
||||
loaderPromise = Promise.all([
|
||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/view'),
|
||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/commands'),
|
||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/language'),
|
||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/lang-html'),
|
||||
import(/* webpackChunkName: "email-editor" */ '@specious/htmlflow')
|
||||
]).then(([view, commands, language, html, htmlflow]) => {
|
||||
return {
|
||||
minimalSetup: [
|
||||
commands.history(),
|
||||
language.syntaxHighlighting(language.defaultHighlightStyle, { fallback: true }),
|
||||
view.keymap.of([...commands.defaultKeymap, ...commands.historyKeymap])
|
||||
],
|
||||
EditorView: view.EditorView,
|
||||
html: html.html,
|
||||
htmlflow: htmlflow.default || htmlflow
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return loaderPromise
|
||||
}
|
||||
|
||||
export default targetable(class extends HTMLElement {
|
||||
static [target.static] = [
|
||||
'codeViewTab',
|
||||
'previewViewTab',
|
||||
'editorContainer',
|
||||
'previewIframe'
|
||||
]
|
||||
|
||||
connectedCallback () {
|
||||
this.mount()
|
||||
|
||||
if (this.input.value) {
|
||||
this.showPreviewView()
|
||||
} else {
|
||||
this.showCodeView()
|
||||
}
|
||||
|
||||
this.previewViewTab.addEventListener('click', this.showPreviewView)
|
||||
this.codeViewTab.addEventListener('click', this.showCodeView)
|
||||
}
|
||||
|
||||
showCodeView = () => {
|
||||
this.editorView.dispatch({
|
||||
changes: { from: 0, to: this.editorView.state.doc.length, insert: this.input.value }
|
||||
})
|
||||
|
||||
this.previewViewTab.classList.remove('tab-active', 'tab-bordered')
|
||||
this.previewViewTab.classList.add('pb-[3px]')
|
||||
this.codeViewTab.classList.remove('pb-[3px]')
|
||||
this.codeViewTab.classList.add('tab-active', 'tab-bordered')
|
||||
this.editorContainer.classList.remove('hidden')
|
||||
this.previewIframe.classList.add('hidden')
|
||||
}
|
||||
|
||||
showPreviewView = () => {
|
||||
this.previewIframe.srcdoc = this.input.value
|
||||
|
||||
this.codeViewTab.classList.remove('tab-active', 'tab-bordered')
|
||||
this.codeViewTab.classList.add('pb-[3px]')
|
||||
this.previewViewTab.classList.remove('pb-[3px]')
|
||||
this.previewViewTab.classList.add('tab-active', 'tab-bordered')
|
||||
this.editorContainer.classList.add('hidden')
|
||||
this.previewIframe.classList.remove('hidden')
|
||||
}
|
||||
|
||||
async mount () {
|
||||
this.input = this.querySelector('input[type="hidden"]')
|
||||
this.input.style.display = 'none'
|
||||
|
||||
const { EditorView, minimalSetup, html, htmlflow } = await loadCodeMirror()
|
||||
|
||||
this.editorView = new EditorView({
|
||||
doc: this.input.value,
|
||||
parent: this.editorContainer,
|
||||
extensions: [
|
||||
html(),
|
||||
minimalSetup,
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of(update => {
|
||||
if (update.docChanged) this.input.value = update.state.doc.toString()
|
||||
}),
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
backgroundColor: 'white',
|
||||
color: 'black',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'monospace'
|
||||
},
|
||||
'&.cm-focused': {
|
||||
outline: 'none'
|
||||
},
|
||||
'&.cm-editor': {
|
||||
borderRadius: '0.375rem',
|
||||
border: 'none'
|
||||
},
|
||||
'.cm-gutters': {
|
||||
display: 'none'
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
this.previewIframe.srcdoc = this.editorView.state.doc.toString()
|
||||
|
||||
this.previewIframe.onload = () => {
|
||||
const previewIframeDoc = this.previewIframe.contentDocument
|
||||
|
||||
if (previewIframeDoc.body) {
|
||||
previewIframeDoc.body.contentEditable = true
|
||||
}
|
||||
|
||||
const contentDocument = this.previewIframe.contentDocument || this.previewIframe.contentWindow.document
|
||||
|
||||
contentDocument.body.addEventListener('input', async () => {
|
||||
const html = contentDocument.documentElement.outerHTML.replace(' contenteditable="true"', '')
|
||||
const prettifiedHtml = await htmlflow(html)
|
||||
|
||||
this.input.value = prettifiedHtml
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.addEventListener('click', () => {
|
||||
document.body.append(this.template.content)
|
||||
})
|
||||
}
|
||||
|
||||
get template () {
|
||||
return document.getElementById(this.dataset.templateId)
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,9 @@ export default actionable(class extends HTMLElement {
|
||||
elementIds.forEach((elementId) => {
|
||||
document.getElementById(elementId).classList.toggle('hidden', (event.target.dataset.toggleId || event.target.value) !== elementId)
|
||||
})
|
||||
|
||||
if (this.dataset.focusId) {
|
||||
document.getElementById(this.dataset.focusId)?.focus()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -95,16 +95,40 @@ 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()
|
||||
|
||||
this.uploadFiles(this.$refs.input.files).then(() => {
|
||||
if (this.$refs.input) {
|
||||
this.$refs.input.value = ''
|
||||
const files = Array.from(this.$refs.input.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).then(() => {
|
||||
if (this.$refs.input) {
|
||||
this.$refs.input.value = ''
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
async uploadFiles (files) {
|
||||
this.isLoading = true
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '本人確認',
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</label>
|
||||
<div class="space-x-2 flex flex-none">
|
||||
<span
|
||||
v-if="isTextSignature && format !== 'typed' && format !== 'upload'"
|
||||
v-if="isTextSignature && format !== 'typed_or_upload' && format !== 'typed' && format !== 'upload'"
|
||||
class="tooltip"
|
||||
:data-tip="t('draw_signature')"
|
||||
>
|
||||
@@ -38,7 +38,7 @@
|
||||
</a>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="withTypedSignature && format !== 'typed' && format !== 'drawn' && format !== 'upload'"
|
||||
v-else-if="withTypedSignature && format !== 'drawn_or_upload' && format !== 'typed_or_upload' && format !== 'typed' && format !== 'drawn' && format !== 'upload'"
|
||||
class="tooltip ml-2"
|
||||
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
|
||||
:data-tip="t('type_text')"
|
||||
@@ -85,8 +85,8 @@
|
||||
{{ t(format === 'upload' ? 'reupload' : 'redraw') }}
|
||||
</a>
|
||||
<span
|
||||
v-if="withQrButton && !modelValue && !computedPreviousValue && format !== 'typed' && format !== 'upload'"
|
||||
class=" tooltip"
|
||||
v-if="withQrButton && !modelValue && !computedPreviousValue && format !== 'typed_or_upload' && format !== 'typed' && format !== 'upload'"
|
||||
class="tooltip before:translate-x-[-90%]"
|
||||
:data-tip="t('drawn_signature_on_a_touchscreen_device')"
|
||||
>
|
||||
<a
|
||||
@@ -395,7 +395,7 @@ export default {
|
||||
isShowQr: false,
|
||||
isOtherReason: false,
|
||||
isUsePreviousValue: true,
|
||||
isTextSignature: this.field.preferences?.format === 'typed',
|
||||
isTextSignature: this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload',
|
||||
uploadImageInputKey: Math.random().toString()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -140,6 +140,8 @@
|
||||
:background-color="'white'"
|
||||
:with-required="false"
|
||||
:with-areas="false"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-prefillable="withPrefillable"
|
||||
@click-formula="isShowFormulaModal = true"
|
||||
@click-font="isShowFontModal = true"
|
||||
@click-description="isShowDescriptionModal = true"
|
||||
@@ -172,7 +174,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 +318,7 @@ export default {
|
||||
FieldSubmitter,
|
||||
IconX
|
||||
},
|
||||
inject: ['template', 'selectedAreaRef', 'save', 't'],
|
||||
inject: ['template', 'selectedAreaRef', 'save', 't', 'isInlineSize'],
|
||||
props: {
|
||||
area: {
|
||||
type: Object,
|
||||
@@ -346,6 +349,16 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withSignatureId: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withPrefillable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
defaultSubmitters: {
|
||||
type: Array,
|
||||
required: false,
|
||||
@@ -385,6 +398,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 +454,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 +532,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 +540,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,8 @@
|
||||
:input-mode="inputMode"
|
||||
:default-fields="[...defaultRequiredFields, ...defaultFields]"
|
||||
:allow-draw="!onlyDefinedFields || drawField"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-prefillable="withPrefillable"
|
||||
:data-document-uuid="document.uuid"
|
||||
:default-submitters="defaultSubmitters"
|
||||
:drag-field-placeholder="fieldsDragFieldRef.value || dragField"
|
||||
@@ -436,6 +438,8 @@
|
||||
:default-required-fields="defaultRequiredFields"
|
||||
:field-types="fieldTypes"
|
||||
:with-sticky-submitters="withStickySubmitters"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-prefillable="withPrefillable"
|
||||
:only-defined-fields="onlyDefinedFields"
|
||||
:editable="editable"
|
||||
:show-tour-start-form="showTourStartForm"
|
||||
@@ -542,6 +546,7 @@ export default {
|
||||
isPaymentConnected: this.isPaymentConnected,
|
||||
withFormula: this.withFormula,
|
||||
withConditions: this.withConditions,
|
||||
isInlineSize: this.isInlineSize,
|
||||
defaultDrawFieldType: this.defaultDrawFieldType,
|
||||
selectedAreaRef: computed(() => this.selectedAreaRef),
|
||||
fieldsDragFieldRef: computed(() => this.fieldsDragFieldRef)
|
||||
@@ -562,6 +567,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withSignatureId: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
backgroundColor: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -793,6 +803,16 @@ export default {
|
||||
language () {
|
||||
return this.locale.split('-')[0].toLowerCase()
|
||||
},
|
||||
withPrefillable () {
|
||||
if (this.template.fields) {
|
||||
return this.template.fields.some((f) => f.prefillable)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
},
|
||||
isInlineSize () {
|
||||
return CSS.supports('container-type: size')
|
||||
},
|
||||
isMobile () {
|
||||
const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent)
|
||||
|
||||
@@ -1064,6 +1084,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 +1495,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 = {
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
:data-page="index"
|
||||
:areas="areasIndex[index]"
|
||||
:allow-draw="allowDraw"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-prefillable="withPrefillable"
|
||||
:is-drag="isDrag"
|
||||
:with-field-placeholder="withFieldPlaceholder"
|
||||
:default-fields="defaultFields"
|
||||
@@ -29,6 +31,7 @@
|
||||
</template>
|
||||
<script>
|
||||
import Page from './page'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export default {
|
||||
name: 'TemplateDocument',
|
||||
@@ -65,6 +68,16 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withSignatureId: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withPrefillable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
drawFieldType: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -123,14 +136,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,8 @@
|
||||
:field="field"
|
||||
:default-field="defaultField"
|
||||
:editable="editable"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-prefillable="withPrefillable"
|
||||
:background-color="dropdownBgColor"
|
||||
@click-formula="isShowFormulaModal = true"
|
||||
@click-font="isShowFontModal = true"
|
||||
@@ -302,6 +304,16 @@ export default {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
withSignatureId: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withPrefillable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
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"
|
||||
@@ -239,7 +304,7 @@
|
||||
{{ t('any') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="type in ['drawn', 'typed', 'drawn_or_typed', 'upload']"
|
||||
v-for="type in ['drawn', 'typed', 'drawn_or_typed', 'drawn_or_upload', 'upload']"
|
||||
:key="type"
|
||||
:value="type"
|
||||
:selected="field.preferences?.format === type"
|
||||
@@ -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
|
||||
@@ -326,11 +406,26 @@
|
||||
<span class="label-text">{{ t('read_only') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="withPrefillable && ['text', 'number', 'cells', 'date', 'checkbox', 'select', 'radio', 'phone'].includes(field['type'])"
|
||||
@click.stop
|
||||
>
|
||||
<label class="cursor-pointer py-1.5">
|
||||
<input
|
||||
v-model="field.prefillable"
|
||||
type="checkbox"
|
||||
:disabled="!editable || (defaultField && [true, false].includes(defaultField.prefillable))"
|
||||
class="toggle toggle-xs"
|
||||
@update:model-value="save"
|
||||
>
|
||||
<span class="label-text">{{ t('prefillable') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<hr
|
||||
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 +555,11 @@ export default {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
withSignatureId: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
backgroundColor: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -470,6 +570,11 @@ export default {
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withPrefillable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withRequired: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -489,6 +594,7 @@ export default {
|
||||
emits: ['set-draw', 'scroll-to', 'click-formula', 'click-description', 'click-condition', 'click-font', 'remove-area'],
|
||||
data () {
|
||||
return {
|
||||
selectedValidation: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -533,8 +639,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 +667,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,8 @@
|
||||
:field="field"
|
||||
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)"
|
||||
:editable="editable"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-prefillable="withPrefillable"
|
||||
:default-field="defaultFieldsIndex[field.name]"
|
||||
:draggable="editable"
|
||||
@dragstart="[fieldsDragFieldRef.value = field, removeDragOverlay($event), setDragPlaceholder($event)]"
|
||||
@@ -253,6 +255,16 @@ export default {
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withSignatureId: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withPrefillable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
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',
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<div
|
||||
v-if="isDragging || isLoading"
|
||||
class="modal modal-open"
|
||||
@dragover.prevent
|
||||
@drop.prevent="$emit('error')"
|
||||
>
|
||||
<div class="flex flex-col gap-2 p-4 items-center bg-base-100 h-full max-h-[85vh] max-w-6xl rounded-2xl w-full">
|
||||
<Dropzone
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
const en = {
|
||||
prefillable: 'Prefillable',
|
||||
signature_id: 'Signature ID',
|
||||
error_message: 'Error message',
|
||||
length: 'Length',
|
||||
min: 'Min',
|
||||
max: 'Max',
|
||||
font: 'Font',
|
||||
party: 'Party',
|
||||
date_signed: 'Date Signed',
|
||||
@@ -54,6 +60,7 @@ const en = {
|
||||
any: 'Any',
|
||||
drawn: 'Drawn',
|
||||
drawn_or_typed: 'Drawn or Typed',
|
||||
drawn_or_upload: 'Drawn or Upload',
|
||||
upload: 'Upload',
|
||||
formula: 'Formula',
|
||||
typed: 'Typed',
|
||||
@@ -168,6 +175,12 @@ const en = {
|
||||
}
|
||||
|
||||
const es = {
|
||||
prefillable: 'Rellenable',
|
||||
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',
|
||||
@@ -310,6 +323,7 @@ const es = {
|
||||
any: 'Cualquier',
|
||||
drawn: 'Dibujado',
|
||||
drawn_or_typed: 'Dibujado o Escrito',
|
||||
drawn_or_upload: 'Dibujado o Subido',
|
||||
upload: 'Subir',
|
||||
typed: 'Escrito',
|
||||
none: 'Ninguno',
|
||||
@@ -337,6 +351,12 @@ const es = {
|
||||
}
|
||||
|
||||
const it = {
|
||||
prefillable: 'Precompilabile',
|
||||
signature_id: 'ID firma',
|
||||
error_message: 'Messaggio di errore',
|
||||
length: 'Lunghezza',
|
||||
min: 'Min',
|
||||
max: 'Max',
|
||||
date_signed: 'Data attuale',
|
||||
font: 'Carattere',
|
||||
party: 'Parte',
|
||||
@@ -392,6 +412,7 @@ const it = {
|
||||
any: 'Qualsiasi',
|
||||
drawn: 'Disegnato',
|
||||
drawn_or_typed: 'Disegnato o Digitato',
|
||||
drawn_or_upload: 'Disegnato o Caricato',
|
||||
upload: 'Caricare',
|
||||
formula: 'Formula',
|
||||
typed: 'Digitato',
|
||||
@@ -506,6 +527,12 @@ const it = {
|
||||
}
|
||||
|
||||
const pt = {
|
||||
prefillable: 'Pré-preenchível',
|
||||
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',
|
||||
@@ -648,6 +675,7 @@ const pt = {
|
||||
any: 'Qualquer',
|
||||
drawn: 'Desenhado',
|
||||
drawn_or_typed: 'Desenhado ou Digitado',
|
||||
drawn_or_upload: 'Desenhado ou Enviado',
|
||||
upload: 'Carregar',
|
||||
typed: 'Digitado',
|
||||
none: 'Nenhum',
|
||||
@@ -675,6 +703,12 @@ const pt = {
|
||||
}
|
||||
|
||||
const fr = {
|
||||
prefillable: 'Pré-remplissable',
|
||||
signature_id: 'ID de signature',
|
||||
error_message: 'Message d\'erreur',
|
||||
length: 'Longueur',
|
||||
min: 'Min',
|
||||
max: 'Max',
|
||||
date_signed: 'Date actuelle',
|
||||
font: 'Police',
|
||||
party: 'Partie',
|
||||
@@ -817,6 +851,7 @@ const fr = {
|
||||
any: 'Tout',
|
||||
drawn: 'Dessiné',
|
||||
drawn_or_typed: 'Dessiné ou Tapé',
|
||||
drawn_or_upload: 'Dessiné ou Téléchargé',
|
||||
upload: 'Téléverser',
|
||||
typed: 'Tapé',
|
||||
none: 'Aucun',
|
||||
@@ -844,6 +879,12 @@ const fr = {
|
||||
}
|
||||
|
||||
const de = {
|
||||
prefillable: 'Vorausfüllbar',
|
||||
signature_id: 'Signatur-ID',
|
||||
error_message: 'Fehlermeldung',
|
||||
length: 'Länge',
|
||||
min: 'Min',
|
||||
max: 'Max',
|
||||
date_now: 'Akt. Datum',
|
||||
font: 'Schriftart',
|
||||
party: 'Partei',
|
||||
@@ -986,6 +1027,7 @@ const de = {
|
||||
any: 'Jede',
|
||||
drawn: 'Gezeichnet',
|
||||
drawn_or_typed: 'Gezeichnet oder getippt',
|
||||
drawn_or_upload: 'Gezeichnet oder hochgeladen',
|
||||
upload: 'Upload',
|
||||
typed: 'Getippt',
|
||||
none: 'Keine',
|
||||
|
||||
@@ -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,8 @@
|
||||
:field="item.field"
|
||||
:editable="editable"
|
||||
:with-field-placeholder="withFieldPlaceholder"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-prefillable="withPrefillable"
|
||||
:default-field="defaultFieldsIndex[item.field.name]"
|
||||
:default-submitters="defaultSubmitters"
|
||||
:max-page="totalPages - 1"
|
||||
@@ -77,6 +80,16 @@ export default {
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withSignatureId: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withPrefillable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
areas: {
|
||||
type: Array,
|
||||
required: false,
|
||||
@@ -191,8 +204,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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -127,19 +135,32 @@ class ProcessSubmitterCompletionJob
|
||||
end
|
||||
|
||||
def enqueue_next_submitter_request_notification(submitter)
|
||||
next_submitter_item =
|
||||
submitter.submission.template_submitters.find do |e|
|
||||
sub = submitter.submission.submitters.find { |s| s.uuid == e['uuid'] }
|
||||
submission = submitter.submission
|
||||
submitters_index = submission.submitters.index_by(&:uuid)
|
||||
|
||||
next unless sub
|
||||
next_submitter_items =
|
||||
if submission.template_submitters.any? { |s| s['order'] }
|
||||
submitter_groups =
|
||||
submission.template_submitters.group_by.with_index { |s, index| s['order'] || index }
|
||||
|
||||
sub.completed_at.blank? && sub.sent_at.blank?
|
||||
current_group_index = submitter_groups.find { |_, group| group.any? { |s| s['uuid'] == submitter.uuid } }&.first
|
||||
|
||||
if submitter_groups[current_group_index + 1] &&
|
||||
submitters_index.values_at(*submitter_groups[current_group_index].pluck('uuid')).all?(&:completed_at?)
|
||||
submitter_groups[current_group_index + 1]
|
||||
end
|
||||
else
|
||||
submission.template_submitters.find do |e|
|
||||
sub = submitters_index[e['uuid']]
|
||||
|
||||
next unless sub
|
||||
|
||||
sub.completed_at.blank? && sub.sent_at.blank?
|
||||
end
|
||||
end
|
||||
|
||||
return unless next_submitter_item
|
||||
next_submitters = submitters_index.values_at(*Array.wrap(next_submitter_items).pluck('uuid'))
|
||||
|
||||
next_submitter = submitter.submission.submitters.find { |s| s.uuid == next_submitter_item['uuid'] }
|
||||
|
||||
Submitters.send_signature_requests([next_submitter])
|
||||
Submitters.send_signature_requests(next_submitters)
|
||||
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
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ class SendSubmitterInvitationEmailJob
|
||||
def perform(params = {})
|
||||
submitter = Submitter.find(params['submitter_id'])
|
||||
|
||||
return if submitter.completed_at?
|
||||
return if submitter.submission.source == 'invite' && !Accounts.can_send_emails?(submitter.account, on_events: true)
|
||||
|
||||
unless Accounts.can_send_invitation_emails?(submitter.account)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -30,6 +30,7 @@ class SubmitterMailer < ApplicationMailer
|
||||
@submitter.template&.preferences&.dig('request_email_subject').presence
|
||||
|
||||
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY)
|
||||
@body ||= fetch_config_email_body(@email_config, @submitter)
|
||||
|
||||
assign_message_metadata('submitter_invitation', @submitter)
|
||||
|
||||
@@ -71,7 +72,7 @@ class SubmitterMailer < ApplicationMailer
|
||||
@subject ||= @email_config.value['subject'] if @email_config
|
||||
|
||||
@body = template_preferences['completed_notification_email_body'].presence
|
||||
@body ||= @email_config.value['body'] if @email_config
|
||||
@body ||= fetch_config_email_body(@email_config, @submitter)
|
||||
|
||||
assign_message_metadata('submitter_completed', @submitter)
|
||||
|
||||
@@ -127,7 +128,7 @@ class SubmitterMailer < ApplicationMailer
|
||||
@subject ||= @email_config.value['subject'] if @email_config
|
||||
|
||||
@body = template_preferences['documents_copy_email_body'].presence
|
||||
@body ||= @email_config.value['body'] if @email_config
|
||||
@body ||= fetch_config_email_body(@email_config, @submitter)
|
||||
|
||||
assign_message_metadata('submitter_documents_copy', @submitter)
|
||||
reply_to = build_submitter_reply_to(submitter, email_config: @email_config, documents_copy_email: true)
|
||||
@@ -246,4 +247,8 @@ class SubmitterMailer < ApplicationMailer
|
||||
user.friendly_name
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_config_email_body(email_config, _submitter = nil)
|
||||
email_config ? email_config.value['body'].presence : nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplateMailer < ApplicationMailer
|
||||
def otp_verification_email(template, email:)
|
||||
@current_account = template.account
|
||||
@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
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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: -> { {} }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -35,17 +35,19 @@
|
||||
<%= ff.select :authentication, options_for_select([%w[Plain plain], %w[Login login], %w[CRAM-MD5 cram_md5]], value.fetch('authentication', 'plain')), { prompt: true }, required: true, class: 'base-select' %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<%= ff.label :security_label, 'SMTP Security', class: 'label' %>
|
||||
<div class="flex items-center space-x-6">
|
||||
<% [%w[Auto none], %w[SSL ssl], %w[TLS tls], %w[Noverify noverify]].each do |(label, val)| %>
|
||||
<%= ff.label :security, value: val, for: "#{val}_radio", class: 'label' do %>
|
||||
<%= ff.radio_button :security, val, checked: (value['security'].blank? && val == 'none') || value['security'] == val, id: "#{val}_radio", class: 'base-radio mr-2' %>
|
||||
<%= label %>
|
||||
<% if !Docuseal.multitenant? || can?(:manage, :personalization_advanced) %>
|
||||
<div class="form-control">
|
||||
<%= ff.label :security_label, 'SMTP Security', class: 'label' %>
|
||||
<div class="flex items-center space-x-6">
|
||||
<% [%w[Auto none], %w[SSL ssl], %w[TLS tls], %w[Noverify noverify]].each do |(label, val)| %>
|
||||
<%= ff.label :security, value: val, for: "#{val}_radio", class: 'label' do %>
|
||||
<%= ff.radio_button :security, val, checked: (value['security'].blank? && val == 'none') || value['security'] == val, id: "#{val}_radio", class: 'base-radio mr-2' %>
|
||||
<%= label %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="form-control">
|
||||
<%= ff.label :from_email, t('send_from_email'), class: 'label' %>
|
||||
<%= ff.email_field :from_email, value: value['from_email'], required: !Docuseal.multitenant?, class: 'base-input' %>
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
<%= t('preferences') %>
|
||||
</h2>
|
||||
</div>
|
||||
<% if can?(:manage, account_config) && (can?(:manage, :personalization_advanced) || !Docuseal.multitenant?) %>
|
||||
<% if can?(:manage, account_config) && (can?(:manage, :personalization_advanced) || account_config.persisted?) %>
|
||||
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<div class="flex items-center justify-between py-2.5">
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -18,7 +18,6 @@
|
||||
<link href="<%= canonical_url %>" rel="canonical">
|
||||
<% end %>
|
||||
<%= stylesheet_pack_tag 'application', media: 'all' %>
|
||||
<%= render 'shared/posthog' if ENV['POSTHOG_TOKEN'] %>
|
||||
<%= render 'shared/plausible' if !signed_in? && ENV['PLAUSIBLE_DOMAIN'] %>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
<%= javascript_pack_tag 'form', defer: true %>
|
||||
<% end %>
|
||||
<%= stylesheet_pack_tag 'form', media: 'all' %>
|
||||
<%= render 'shared/posthog' if ENV['POSTHOG_TOKEN'] %>
|
||||
</head>
|
||||
<body>
|
||||
<%= yield %>
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
<%= javascript_pack_tag 'application', defer: true %>
|
||||
<% end %>
|
||||
<%= stylesheet_pack_tag 'application', media: 'all' %>
|
||||
<%= render 'shared/posthog' if ENV['POSTHOG_TOKEN'] %>
|
||||
</head>
|
||||
<body>
|
||||
<% if flash.present? %><%= render 'shared/flash' %><% end %>
|
||||
|
||||
@@ -13,17 +13,7 @@
|
||||
<%= ff.label :subject, t('subject'), class: 'label' %>
|
||||
<%= ff.text_field :subject, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= ff.label :body, t('body'), class: 'label' %>
|
||||
<span class="tooltip" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= ff.text_area :body, required: true, class: 'base-input w-full py-2', dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
<%= render 'personalization_settings/email_body_field', ff:, config: f.object %>
|
||||
<% if can?(:manage, :reply_to) || can?(:manage, :personalization_advanced) %>
|
||||
<div class="form-control">
|
||||
<%= ff.label :reply_to, t('reply_to'), class: 'label' %>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= ff.label :body, t('body'), class: 'label' %>
|
||||
<span class="tooltip" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[local_assigns[:config].key].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= ff.text_area :body, required: true, class: 'base-input w-full !rounded-2xl py-2', dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
@@ -13,17 +13,7 @@
|
||||
<%= ff.label :subject, t('subject'), class: 'label' %>
|
||||
<%= ff.text_field :subject, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="flex items-center">
|
||||
<%= ff.label :body, t('body'), class: 'label' %>
|
||||
<span class="tooltip" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</div>
|
||||
<autoresize-textarea>
|
||||
<%= ff.text_area :body, required: true, class: 'base-input w-full py-2', dir: 'auto' %>
|
||||
</autoresize-textarea>
|
||||
</div>
|
||||
<%= render 'personalization_settings/email_body_field', ff:, config: f.object %>
|
||||
<% end %>
|
||||
<div class="form-control pt-2">
|
||||
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
|
||||
|
||||