Merge from docusealco/wip

pull/475/head 1.9.7
Alex Turchyn 7 months ago committed by GitHub
commit 1f48be135c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -15,6 +15,7 @@ class AccountConfigsController < ApplicationController
AccountConfig::DOWNLOAD_LINKS_AUTH_KEY, AccountConfig::DOWNLOAD_LINKS_AUTH_KEY,
AccountConfig::FORCE_SSO_AUTH_KEY, AccountConfig::FORCE_SSO_AUTH_KEY,
AccountConfig::FLATTEN_RESULT_PDF_KEY, AccountConfig::FLATTEN_RESULT_PDF_KEY,
AccountConfig::ENFORCE_SIGNING_ORDER_KEY,
AccountConfig::WITH_SIGNATURE_ID, AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::COMBINE_PDF_RESULT_KEY, AccountConfig::COMBINE_PDF_RESULT_KEY,
AccountConfig::REQUIRE_SIGNING_REASON_KEY, AccountConfig::REQUIRE_SIGNING_REASON_KEY,

@ -23,9 +23,9 @@ class SubmissionsDownloadController < ApplicationController
last_submitter = submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last last_submitter = submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
Submissions::EnsureResultGenerated.call(last_submitter) return head :not_found unless last_submitter
return head :not_found unless last_submitter.completed_at? Submissions::EnsureResultGenerated.call(last_submitter)
if last_submitter.completed_at < TTL.ago && !signature_valid && !current_user_submitter?(last_submitter) if last_submitter.completed_at < TTL.ago && !signature_valid && !current_user_submitter?(last_submitter)
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar) Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)

@ -20,7 +20,11 @@ class SubmitFormController < ApplicationController
@submitter.account.archived_at? @submitter.account.archived_at?
return render :expired if submission.expired? return render :expired if submission.expired?
return render :declined if @submitter.declined_at? return render :declined if @submitter.declined_at?
return render :awaiting if submission.template.preferences['submitters_order'] == 'preserved' &&
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
return render :awaiting if (@form_configs[:enforce_signing_order] ||
submission.template.preferences['submitters_order'] == 'preserved') &&
!Submitters.current_submitter_order?(@submitter) !Submitters.current_submitter_order?(@submitter)
Submitters.preload_with_pages(@submitter) Submitters.preload_with_pages(@submitter)
@ -29,8 +33,6 @@ class SubmitFormController < ApplicationController
@attachments_index = build_attachments_index(submission) @attachments_index = build_attachments_index(submission)
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
return unless @form_configs[:prefill_signature] return unless @form_configs[:prefill_signature]
if (user_signature = UserConfigs.load_signature(current_user)) if (user_signature = UserConfigs.load_signature(current_user))
@ -70,6 +72,10 @@ class SubmitFormController < ApplicationController
Submitters::SubmitValues.call(submitter, params, request) Submitters::SubmitValues.call(submitter, params, request)
head :ok head :ok
rescue Submitters::SubmitValues::RequiredFieldError => e
Rollbar.warning("Required field #{submitter.id}: #{e.message}") if defined?(Rollbar)
render json: { field_uuid: e.message }, status: :unprocessable_entity
rescue Submitters::SubmitValues::ValidationError => e rescue Submitters::SubmitValues::ValidationError => e
render json: { error: e.message }, status: :unprocessable_entity render json: { error: e.message }, status: :unprocessable_entity
end end

@ -35,8 +35,6 @@ import IndeterminateCheckbox from './elements/indeterminate_checkbox'
import * as TurboInstantClick from './lib/turbo_instant_click' import * as TurboInstantClick from './lib/turbo_instant_click'
import './images/preview.png'
TurboInstantClick.start() TurboInstantClick.start()
document.addEventListener('turbo:before-cache', () => { document.addEventListener('turbo:before-cache', () => {

@ -1,5 +1,6 @@
import SignaturePad from 'signature_pad' import SignaturePad from 'signature_pad'
import { cropCanvasAndExportToPNG } from './submission_form/crop_canvas' import { cropCanvasAndExportToPNG } from './submission_form/crop_canvas'
import { isValidSignatureCanvas } from './submission_form/validate_signature'
window.customElements.define('draw-signature', class extends HTMLElement { window.customElements.define('draw-signature', class extends HTMLElement {
connectedCallback () { connectedCallback () {
@ -43,6 +44,8 @@ window.customElements.define('draw-signature', class extends HTMLElement {
return response return response
}) })
}).catch(error => {
console.log(error)
}).finally(() => { }).finally(() => {
this.submitButton.disabled = false this.submitButton.disabled = false
}) })
@ -65,26 +68,26 @@ window.customElements.define('draw-signature', class extends HTMLElement {
} }
async submitImage () { async submitImage () {
return new Promise((resolve, reject) => { if (!isValidSignatureCanvas(this.pad.toData())) {
cropCanvasAndExportToPNG(this.canvas, { errorOnTooSmall: true }).then(async (blob) => { alert('Signature is too small or simple. Please redraw.')
const file = new File([blob], 'signature.png', { type: 'image/png' })
return Promise.reject(new Error('Image too small or simple'))
const formData = new FormData() }
formData.append('file', file) return cropCanvasAndExportToPNG(this.canvas).then(async (blob) => {
formData.append('submitter_slug', this.dataset.slug) const file = new File([blob], 'signature.png', { type: 'image/png' })
formData.append('name', 'attachments')
formData.append('remember_signature', 'true') const formData = new FormData()
return fetch('/api/attachments', { formData.append('file', file)
method: 'POST', formData.append('submitter_slug', this.dataset.slug)
body: formData formData.append('name', 'attachments')
}).then((resp) => resp.json()).then((attachment) => { formData.append('remember_signature', 'true')
return resolve(attachment)
}) return fetch('/api/attachments', {
}).catch((error) => { method: 'POST',
return reject(error) body: formData
}) }).then(resp => resp.json())
}) })
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

@ -1,4 +1,4 @@
function cropCanvasAndExportToPNG (canvas, { errorOnTooSmall } = { errorOnTooSmall: false }) { function cropCanvasAndExportToPNG (canvas) {
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
const width = canvas.width const width = canvas.width
@ -33,10 +33,6 @@ function cropCanvasAndExportToPNG (canvas, { errorOnTooSmall } = { errorOnTooSma
croppedCanvas.height = croppedHeight croppedCanvas.height = croppedHeight
const croppedCtx = croppedCanvas.getContext('2d') const croppedCtx = croppedCanvas.getContext('2d')
if (errorOnTooSmall && (croppedWidth < 20 || croppedHeight < 20)) {
return Promise.reject(new Error('Image too small'))
}
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight) croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

@ -1404,7 +1404,21 @@ export default {
if (response.status === 422 || response.status === 500) { if (response.status === 422 || response.status === 500) {
const data = await response.json() const data = await response.json()
if (data.error) { if (data.field_uuid) {
const field = this.fieldsUuidIndex[data.field_uuid]
if (field) {
const step = this.stepFields.findIndex((fields) => fields.includes(field))
if (step !== -1) {
this.goToStep(step, this.autoscrollFields)
this.showFillAllRequiredFields = true
}
}
return Promise.reject(new Error('Required field: ' + data.field_uuid))
} else if (data.error) {
const i18nKey = data.error.replace(/\s+/g, '_').toLowerCase() const i18nKey = data.error.replace(/\s+/g, '_').toLowerCase()
alert(this.t(i18nKey) !== i18nKey ? this.t(i18nKey) : data.error) alert(this.t(i18nKey) !== i18nKey ? this.t(i18nKey) : data.error)
@ -1439,11 +1453,7 @@ export default {
this.isSubmittingComplete = false this.isSubmittingComplete = false
}) })
}).catch(error => { }).catch(error => {
if (error?.message === 'Image too small') { console.log(error)
alert(this.t('signature_is_too_small_please_redraw'))
} else {
console.log(error)
}
}).finally(() => { }).finally(() => {
this.isSubmitting = false this.isSubmitting = false
this.isSubmittingComplete = false this.isSubmittingComplete = false

@ -93,7 +93,7 @@ const en = {
reupload: 'Reupload', reupload: 'Reupload',
upload: 'Upload', upload: 'Upload',
files: 'Files', files: 'Files',
signature_is_too_small_please_redraw: 'Signature is too small. Please redraw.', signature_is_too_small_or_simple_please_redraw: 'Signature is too small or simple. Please redraw.',
wait_countdown_seconds: 'Wait {countdown} seconds' wait_countdown_seconds: 'Wait {countdown} seconds'
} }
@ -191,7 +191,7 @@ const es = {
reupload: 'Volver a subir', reupload: 'Volver a subir',
upload: 'Subir', upload: 'Subir',
files: 'Archivos', files: 'Archivos',
signature_is_too_small_please_redraw: 'La firma es demasiado pequeña. Por favor, dibújala de nuevo.', signature_is_too_small_or_simple_please_redraw: 'La firma es demasiado pequeña o simple. Por favor, vuelve a dibujarla.',
wait_countdown_seconds: 'Espera {countdown} segundos' wait_countdown_seconds: 'Espera {countdown} segundos'
} }
@ -289,7 +289,7 @@ const it = {
reupload: 'Ricarica', reupload: 'Ricarica',
upload: 'Carica', upload: 'Carica',
files: 'File', files: 'File',
signature_is_too_small_please_redraw: 'La firma è troppo piccola. Ridisegnala per favore.', signature_is_too_small_or_simple_please_redraw: 'La firma è troppo piccola o semplice. Ridisegnala, per favore.',
wait_countdown_seconds: 'Attendi {countdown} secondi' wait_countdown_seconds: 'Attendi {countdown} secondi'
} }
@ -387,7 +387,7 @@ const de = {
reupload: 'Erneut hochladen', reupload: 'Erneut hochladen',
upload: 'Hochladen', upload: 'Hochladen',
files: 'Dateien', files: 'Dateien',
signature_is_too_small_please_redraw: 'Die Unterschrift ist zu klein. Bitte erneut zeichnen.', signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte erneut zeichnen.',
wait_countdown_seconds: 'Warte {countdown} Sekunden' wait_countdown_seconds: 'Warte {countdown} Sekunden'
} }
@ -485,7 +485,7 @@ const fr = {
reupload: 'Recharger', reupload: 'Recharger',
upload: 'Télécharger', upload: 'Télécharger',
files: 'Fichiers', files: 'Fichiers',
signature_is_too_small_please_redraw: 'La signature est trop petite. Veuillez la redessiner.', signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.',
wait_countdown_seconds: 'Attendez {countdown} secondes' wait_countdown_seconds: 'Attendez {countdown} secondes'
} }
@ -583,7 +583,7 @@ const pl = {
reupload: 'Ponowne przesłanie', reupload: 'Ponowne przesłanie',
upload: 'Przesyłanie', upload: 'Przesyłanie',
files: 'Pliki', files: 'Pliki',
signature_is_too_small_please_redraw: 'Podpis jest zbyt mały. Proszę narysować go ponownie.' signature_is_too_small_or_simple_please_redraw: 'Podpis jest zbyt mały lub zbyt prosty. Proszę narysować go ponownie.'
} }
const uk = { const uk = {
@ -680,7 +680,7 @@ const uk = {
reupload: 'Перезавантажити', reupload: 'Перезавантажити',
upload: 'Завантажити', upload: 'Завантажити',
files: 'Файли', files: 'Файли',
signature_is_too_small_please_redraw: 'Підпис занадто малий. Будь ласка, перемалюйте його.', signature_is_too_small_or_simple_please_redraw: 'Підпис занадто маленький або надто простий. Будь ласка, перемалюйте.',
wait_countdown_seconds: 'Зачекайте {countdown} секунд' wait_countdown_seconds: 'Зачекайте {countdown} секунд'
} }
@ -778,7 +778,7 @@ const cs = {
reupload: 'Znovu nahrát', reupload: 'Znovu nahrát',
upload: 'Nahrát', upload: 'Nahrát',
files: 'Soubory', files: 'Soubory',
signature_is_too_small_please_redraw: 'Podpis je příliš malý. Prosím, překreslete ho.', signature_is_too_small_or_simple_please_redraw: 'Podpis je příliš malý nebo jednoduchý. Nakreslete jej prosím znovu.',
wait_countdown_seconds: 'Počkejte {countdown} sekund' wait_countdown_seconds: 'Počkejte {countdown} sekund'
} }
@ -876,7 +876,7 @@ const pt = {
reupload: 'Reenviar', reupload: 'Reenviar',
upload: 'Carregar', upload: 'Carregar',
files: 'Arquivos', files: 'Arquivos',
signature_is_too_small_please_redraw: 'A assinatura é muito pequena. Por favor, redesenhe-a.', signature_is_too_small_or_simple_please_redraw: 'A assinatura é muito pequena ou simples. Por favor, redesenhe.',
wait_countdown_seconds: 'Aguarde {countdown} segundos' wait_countdown_seconds: 'Aguarde {countdown} segundos'
} }
@ -975,7 +975,7 @@ const he = {
reupload: 'העלה שוב', reupload: 'העלה שוב',
upload: 'העלאה', upload: 'העלאה',
files: 'קבצים', files: 'קבצים',
signature_is_too_small_please_redraw: 'החתימה קטנה מדי. אנא צייר מחדש.', signature_is_too_small_or_simple_please_redraw: 'החתימה קטנה או פשוטה מדי. אנא חתום מחדש.',
wait_countdown_seconds: 'המתן {countdown} שניות' wait_countdown_seconds: 'המתן {countdown} שניות'
} }
@ -1074,7 +1074,7 @@ const nl = {
reupload: 'Opnieuw uploaden', reupload: 'Opnieuw uploaden',
upload: 'Uploaden', upload: 'Uploaden',
files: 'Bestanden', files: 'Bestanden',
signature_is_too_small_please_redraw: 'De handtekening is te klein. Teken deze opnieuw, alstublieft.', signature_is_too_small_or_simple_please_redraw: 'De handtekening is te klein of te eenvoudig. Teken opnieuw.',
wait_countdown_seconds: 'Wacht {countdown} seconden' wait_countdown_seconds: 'Wacht {countdown} seconden'
} }
@ -1172,7 +1172,7 @@ const ar = {
reupload: 'إعادة التحميل', reupload: 'إعادة التحميل',
upload: 'تحميل', upload: 'تحميل',
files: 'الملفات', files: 'الملفات',
signature_is_too_small_please_redraw: 'التوقيع صغير جدًا. يرجى إعادة الرسم.', signature_is_too_small_or_simple_please_redraw: 'التوقيع صغير جدًا أو بسيط جدًا. يرجى إعادة رسمه.',
wait_countdown_seconds: 'انتظر {countdown} ثانية' wait_countdown_seconds: 'انتظر {countdown} ثانية'
} }
@ -1269,7 +1269,7 @@ const ko = {
reupload: '다시 업로드', reupload: '다시 업로드',
upload: '업로드', upload: '업로드',
files: '파일', files: '파일',
signature_is_too_small_please_redraw: '서명이 너무 작니다. 다시 그려주세요.', signature_is_too_small_or_simple_please_redraw: '서명이 너무 작거나 단순합니다. 다시 그려주세요.',
wait_countdown_seconds: '{countdown}초 기다리세요' wait_countdown_seconds: '{countdown}초 기다리세요'
} }

@ -118,11 +118,17 @@
:src="attachmentsIndex[modelValue || computedPreviousValue].url" :src="attachmentsIndex[modelValue || computedPreviousValue].url"
class="mx-auto bg-white border border-base-300 rounded max-h-44" class="mx-auto bg-white border border-base-300 rounded max-h-44"
> >
<canvas <div class="relative">
v-show="!modelValue && !computedPreviousValue" <div
ref="canvas" v-if="!isDrawInitials"
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas" class="absolute top-0 right-0 left-0 bottom-0"
/> />
<canvas
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
/>
</div>
<input <input
v-if="!isDrawInitials && !modelValue && !computedPreviousValue" v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
id="initials_text_input" id="initials_text_input"

@ -292,6 +292,7 @@
<script> <script>
import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2, IconQrcode, IconX } from '@tabler/icons-vue' import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2, IconQrcode, IconX } from '@tabler/icons-vue'
import { cropCanvasAndExportToPNG } from './crop_canvas' import { cropCanvasAndExportToPNG } from './crop_canvas'
import { isValidSignatureCanvas } from './validate_signature'
import SignaturePad from 'signature_pad' import SignaturePad from 'signature_pad'
import AppearsOn from './appears_on' import AppearsOn from './appears_on'
import FileDropzone from './dropzone' import FileDropzone from './dropzone'
@ -598,6 +599,7 @@ export default {
}, },
drawImage (event) { drawImage (event) {
this.remove() this.remove()
this.clear()
this.isSignatureStarted = true this.isSignatureStarted = true
this.drawOnCanvas(event.target.files[0], this.$refs.canvas) this.drawOnCanvas(event.target.files[0], this.$refs.canvas)
@ -680,8 +682,14 @@ export default {
return Promise.resolve({}) return Promise.resolve({})
} }
return new Promise((resolve, reject) => { if (this.isSignatureStarted && this.pad.toData().length > 0 && !isValidSignatureCanvas(this.pad.toData())) {
cropCanvasAndExportToPNG(this.$refs.canvas, { errorOnTooSmall: true }).then(async (blob) => { alert(this.t('signature_is_too_small_or_simple_please_redraw'))
return Promise.reject(new Error('Image too small or simple'))
}
return new Promise((resolve) => {
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
const file = new File([blob], 'signature.png', { type: 'image/png' }) const file = new File([blob], 'signature.png', { type: 'image/png' })
if (this.dryRun) { if (this.dryRun) {
@ -717,12 +725,6 @@ export default {
return resolve(attachment) return resolve(attachment)
}) })
} }
}).catch((error) => {
if (error.message === 'Image too small' && this.field.required === false) {
return resolve({})
} else {
return reject(error)
}
}) })
}) })
} }

@ -0,0 +1,38 @@
function isValidSignatureCanvas (data) {
if (data.length === 0) return false
const strokes = data.filter(stroke => Array.isArray(stroke.points) && stroke.points.length > 2)
if (strokes.length === 0) return false
let skippedStraightLine = 0
const validStrokes = strokes.filter(stroke => {
const points = stroke.points
const first = points[0]
const last = points[points.length - 1]
const A = last.y - first.y
const B = first.x - last.x
const C = last.x * first.y - first.x * last.y
const lineLength = Math.sqrt(A * A + B * B)
const totalDeviation = points.reduce((sum, p) => {
const distanceToLine = Math.abs(A * p.x + B * p.y + C) / lineLength
return sum + distanceToLine
}, 0)
const avgDeviation = totalDeviation / points.length
if (avgDeviation < 3 && skippedStraightLine < 2) {
skippedStraightLine++
return false
}
return true
})
return validStrokes.length > 0
}
export { isValidSignatureCanvas }

@ -94,11 +94,27 @@ class ProcessSubmitterCompletionJob
end end
end end
to = build_to_addresses(submitter) maybe_enqueue_copy_emails(submitter)
end
def maybe_enqueue_copy_emails(submitter)
return if submitter.template.preferences['documents_copy_email_enabled'] == false
configs = AccountConfigs.find_or_initialize_for_key(submitter.account,
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
return if configs.value['enabled'] == false
return if to.blank? || submitter.template.preferences['documents_copy_email_enabled'] == false to = submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false }
.sort_by(&:completed_at).select(&:email?).map(&:friendly_name)
SubmitterMailer.documents_copy_email(submitter, to:).deliver_later! return if to.blank?
if configs.value['bcc_recipients'] == true
to.each { |to| SubmitterMailer.documents_copy_email(submitter, to:).deliver_later! }
else
SubmitterMailer.documents_copy_email(submitter, to: to.join(', ')).deliver_later!
end
end end
def build_bcc_addresses(submission) def build_bcc_addresses(submission)
@ -110,11 +126,6 @@ class ProcessSubmitterCompletionJob
bcc.to_s.scan(User::EMAIL_REGEXP) bcc.to_s.scan(User::EMAIL_REGEXP)
end end
def build_to_addresses(submitter)
submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false }
.sort_by(&:completed_at).select(&:email?).map(&:friendly_name).join(', ')
end
def enqueue_next_submitter_request_notification(submitter) def enqueue_next_submitter_request_notification(submitter)
next_submitter_item = next_submitter_item =
submitter.submission.template_submitters.find do |e| submitter.submission.template_submitters.find do |e|

@ -30,6 +30,7 @@ class AccountConfig < ApplicationRecord
ALLOW_TO_RESUBMIT = 'allow_to_resubmit' ALLOW_TO_RESUBMIT = 'allow_to_resubmit'
ALLOW_TO_DECLINE_KEY = 'allow_to_decline' ALLOW_TO_DECLINE_KEY = 'allow_to_decline'
SUBMITTER_REMINDERS = 'submitter_reminders' SUBMITTER_REMINDERS = 'submitter_reminders'
ENFORCE_SIGNING_ORDER_KEY = 'enforce_signing_order'
FORM_COMPLETED_BUTTON_KEY = 'form_completed_button' FORM_COMPLETED_BUTTON_KEY = 'form_completed_button'
FORM_COMPLETED_MESSAGE_KEY = 'form_completed_message' FORM_COMPLETED_MESSAGE_KEY = 'form_completed_message'
FORM_WITH_CONFETTI_KEY = 'form_with_confetti' FORM_WITH_CONFETTI_KEY = 'form_with_confetti'

@ -151,6 +151,20 @@
</div> </div>
<% end %> <% end %>
<% end %> <% end %>
<% if !Docuseal.multitenant? || can?(:manage, :personalization_advanced) %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ENFORCE_SIGNING_ORDER_KEY) %>
<% if can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
<%= f.hidden_field :key %>
<div class="flex items-center justify-between py-2.5">
<span>
<%= t('always_enforce_signing_order') %>
</span>
<%= f.check_box :value, class: 'toggle', checked: account_config.value, onchange: 'this.form.requestSubmit()' %>
</div>
<% end %>
<% end %>
<% end %>
</div> </div>
<% end %> <% end %>
<%= render 'compliances' %> <%= render 'compliances' %>

@ -8,7 +8,7 @@
<div class="collapse-content"> <div class="collapse-content">
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %> <%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<%= f.hidden_field :key %> <%= f.hidden_field :key %>
<%= f.fields_for :value, Struct.new(:subject, :body, :reply_to, :attach_audit_log, :attach_documents).new(*f.object.value.values_at('subject', 'body', 'reply_to', 'attach_audit_log', 'attach_documents')) do |ff| %> <%= f.fields_for :value, Struct.new(:subject, :body, :reply_to, :attach_audit_log, :attach_documents, :bcc_recipients, :enabled).new(*f.object.value.values_at('subject', 'body', 'reply_to', 'attach_audit_log', 'attach_documents', 'bcc_recipients', 'enabled')) do |ff| %>
<div class="form-control"> <div class="form-control">
<%= ff.label :subject, t('subject'), class: 'label' %> <%= ff.label :subject, t('subject'), class: 'label' %>
<%= ff.text_field :subject, required: true, class: 'base-input', dir: 'auto' %> <%= ff.text_field :subject, required: true, class: 'base-input', dir: 'auto' %>
@ -24,23 +24,41 @@
<%= ff.text_area :body, required: true, class: 'base-input w-full py-2', dir: 'auto' %> <%= ff.text_area :body, required: true, class: 'base-input w-full py-2', dir: 'auto' %>
</autoresize-textarea> </autoresize-textarea>
</div> </div>
<% if can?(:manage, :reply_to) %> <% if can?(:manage, :reply_to) || can?(:manage, :personalization_advanced) %>
<div class="form-control"> <div class="form-control">
<%= ff.label :reply_to, t('reply_to'), class: 'label' %> <%= ff.label :reply_to, t('reply_to'), class: 'label' %>
<%= ff.email_field :reply_to, class: 'base-input', dir: 'auto', placeholder: t(:email) %> <%= ff.email_field :reply_to, class: 'base-input', dir: 'auto', placeholder: t(:email) %>
</div> </div>
<% end %> <% end %>
<div class="flex items-center justify-between pt-2.5 mx-1"> <div class="space-y-3.5">
<span> <div class="flex items-center justify-between mx-1">
<%= t('attach_documents') %> <span>
</span> <%= t('attach_documents') %>
<%= ff.check_box :attach_documents, { checked: ff.object.attach_documents != false, class: 'toggle' }, 'true', 'false' %> </span>
</div> <%= ff.check_box :attach_documents, { checked: ff.object.attach_documents != false, class: 'toggle' }, 'true', 'false' %>
<div class="flex items-center justify-between pb-2.5 mx-1"> </div>
<span> <div class="flex items-center justify-between mx-1">
<%= t('attach_audit_log_pdf') %> <span>
</span> <%= t('attach_audit_log_pdf') %>
<%= ff.check_box :attach_audit_log, { checked: ff.object.attach_audit_log != false, class: 'toggle' }, 'true', 'false' %> </span>
<%= ff.check_box :attach_audit_log, { checked: ff.object.attach_audit_log != false, class: 'toggle' }, 'true', 'false' %>
</div>
<% unless Docuseal.multitenant? %>
<div class="flex items-center justify-between mx-1">
<span>
<%= t('bcc_recipients') %>
</span>
<%= ff.check_box :bcc_recipients, { checked: ff.object.bcc_recipients == true, class: 'toggle' }, 'true', 'false' %>
</div>
<% end %>
<% if !Docuseal.multitenant? || can?(:manage, :personalization_advanced) %>
<div class="flex items-center justify-between mx-1">
<span>
<%= t('send_emails_automatically_on_completion') %>
</span>
<%= ff.check_box :enabled, { checked: ff.object.enabled != false, class: 'toggle' }, 'true', 'false' %>
</div>
<% end %>
</div> </div>
<% end %> <% end %>
<div class="form-control pt-2"> <div class="form-control pt-2">

@ -13,10 +13,10 @@
<meta property="og:image" content=""> <meta property="og:image" content="">
<meta name="twitter:image" content=""> <meta name="twitter:image" content="">
<% else %> <% else %>
<meta property="og:image" content="<%= image_pack_url('images/preview.png') %>"> <meta property="og:image" content="<%= root_url %>preview.png">
<meta property="og:image:width" content="800"> <meta property="og:image:width" content="800">
<meta property="og:image:height" content="800"> <meta property="og:image:height" content="800">
<meta name="twitter:image" content="<%= image_pack_url('images/preview.png') %>"> <meta name="twitter:image" content="<%= root_url %>preview.png">
<% end %> <% end %>
<meta name="twitter:card" content="summary"> <meta name="twitter:card" content="summary">
<meta name="twitter:creator" content="@docusealco"> <meta name="twitter:creator" content="@docusealco">

@ -206,7 +206,7 @@
<span> <span>
<%= t('send_emails_automatically_on_completion') %> <%= t('send_emails_automatically_on_completion') %>
</span> </span>
<%= ff.check_box :documents_copy_email_enabled, { checked: ff.object.documents_copy_email_enabled != false, class: 'toggle', onchange: 'this.form.requestSubmit()' }, 'true', 'false' %> <%= ff.check_box :documents_copy_email_enabled, { checked: ff.object.documents_copy_email_enabled != false && configs['enabled'] != false, class: 'toggle', onchange: 'this.form.requestSubmit()', disabled: configs['enabled'] == false }, 'true', 'false' %>
</div> </div>
<% end %> <% end %>
<div class="form-control pt-2"> <div class="form-control pt-2">
@ -322,15 +322,17 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-2' }, data: { close_on_submit: false } do |f| %> <% unless current_account.account_configs.exists?(key: AccountConfig::ENFORCE_SIGNING_ORDER_KEY, value: true) %>
<div class="flex items-center pt-4 mt-4 justify-between border-t w-full"> <%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-2' }, data: { close_on_submit: false } do |f| %>
<span> <div class="flex items-center pt-4 mt-4 justify-between border-t w-full">
<%= t('enforce_recipients_order') %> <span>
</span> <%= t('enforce_recipients_order') %>
<%= f.fields_for :preferences, Struct.new(:submitters_order).new(@template.preferences['submitters_order']) do |ff| %> </span>
<%= ff.check_box :submitters_order, { class: 'toggle', onchange: 'this.form.requestSubmit()' }, 'preserved', '' %> <%= f.fields_for :preferences, Struct.new(:submitters_order).new(@template.preferences['submitters_order']) do |ff| %>
<% end %> <%= ff.check_box :submitters_order, { class: 'toggle', onchange: 'this.form.requestSubmit()' }, 'preserved', '' %>
</div> <% end %>
</div>
<% end %>
<% end %> <% end %>
<div class="form-control mt-5 pb-2"> <div class="form-control mt-5 pb-2">
<%= button_tag button_title(title: t('save'), disabled_with: t('updating')), class: 'base-button', form: :submitters_form %> <%= button_tag button_title(title: t('save'), disabled_with: t('updating')), class: 'base-button', form: :submitters_form %>

@ -1,7 +1,7 @@
<div class="flex-wrap space-y-4 md:flex md:flex-nowrap md:space-y-0 md:space-x-10"> <div class="flex-wrap space-y-4 md:flex md:flex-nowrap md:space-y-0 md:space-x-10">
<%= render 'shared/settings_nav' %> <%= render 'shared/settings_nav' %>
<div class="md:flex-grow"> <div class="md:flex-grow">
<div class="flex flex-col md:flex-row gap-2 md:justify-between md:items-end mb-4 min-h-12"> <div class="flex flex-col md:flex-row md:flex-wrap gap-2 md:justify-between md:items-end mb-4 min-h-12">
<h1 class="text-4xl font-bold"> <h1 class="text-4xl font-bold">
<% if params[:status] == 'archived' %> <% if params[:status] == 'archived' %>
<%= t('archived_users') %> <%= t('archived_users') %>

@ -20,6 +20,8 @@ en: &en
language_ko: 한국어 language_ko: 한국어
hi_there: Hi there hi_there: Hi there
thanks: Thanks thanks: Thanks
bcc_recipients: BCC recipients
always_enforce_signing_order: Always enforce the signing order
edit_per_party: Edit per party edit_per_party: Edit per party
reply_to: Reply to reply_to: Reply to
pending_by_me: Pending by me pending_by_me: Pending by me
@ -432,7 +434,7 @@ en: &en
share_template_with_test_mode: Share template with Test mode share_template_with_test_mode: Share template with Test mode
share_template_with_all_tenants: Share template with all Tenants share_template_with_all_tenants: Share template with all Tenants
use_following_placeholders_text_: 'Use following placeholders text:' use_following_placeholders_text_: 'Use following placeholders text:'
upgrade_plan_to_add_more_users: Upgrade plan to add more users users_count_total_users_count_pro_users_limit_was_reached_to_invite_additional_users_please_purchase_more_pro_user_seats_via_the_manage_plan_button: '%{users_count}/%{total_users_count} Pro users limit was reached. To invite additional users, please purchase more Pro user seats via the "Manage plan" button.'
move_into_folder: Move Into Folder move_into_folder: Move Into Folder
new_folder_name: New Folder Name new_folder_name: New Folder Name
exit_preview: Exit Preview exit_preview: Exit Preview
@ -473,8 +475,6 @@ en: &en
team_accounts: Team Accounts team_accounts: Team Accounts
tenant_account: Tenant account tenant_account: Tenant account
tenant_accounts: Tenant Accounts tenant_accounts: Tenant Accounts
upgrade_plan: Upgrade Plan
add_user: Add User
impersonate: Impersonate impersonate: Impersonate
loading: Loading loading: Loading
documents: Documents documents: Documents
@ -745,6 +745,8 @@ en: &en
read: Read your data read: Read your data
es: &es es: &es
always_enforce_signing_order: Siempre imponer el orden de firma
bcc_recipients: Destinatarios CCO
edit_per_party: Editar por parte edit_per_party: Editar por parte
signed: Firmado signed: Firmado
reply_to: Responder a reply_to: Responder a
@ -1159,7 +1161,7 @@ es: &es
share_template_with_test_mode: Compartir plantilla con el modo de prueba share_template_with_test_mode: Compartir plantilla con el modo de prueba
share_template_with_all_tenants: Compartir plantilla con todos los inquilinos share_template_with_all_tenants: Compartir plantilla con todos los inquilinos
use_following_placeholders_text_: 'Usa los siguientes marcadores de posición:' use_following_placeholders_text_: 'Usa los siguientes marcadores de posición:'
upgrade_plan_to_add_more_users: Actualiza el plan para agregar más usuarios users_count_total_users_count_pro_users_limit_was_reached_to_invite_additional_users_please_purchase_more_pro_user_seats_via_the_manage_plan_button: 'Se alcanzó el límite de %{users_count}/%{total_users_count} usuarios Pro. Para invitar a más usuarios, compra más plazas Pro usando el botón "Gestionar plan".'
move_into_folder: Mover a la carpeta move_into_folder: Mover a la carpeta
new_folder_name: Nuevo nombre de la carpeta new_folder_name: Nuevo nombre de la carpeta
exit_preview: Salir de la vista previa exit_preview: Salir de la vista previa
@ -1200,8 +1202,6 @@ es: &es
team_accounts: Cuentas de equipo team_accounts: Cuentas de equipo
tenant_account: Cuenta de inquilino tenant_account: Cuenta de inquilino
tenant_accounts: Cuentas de inquilino tenant_accounts: Cuentas de inquilino
upgrade_plan: Actualizar plan
add_user: Agregar usuario
impersonate: Suplantar impersonate: Suplantar
loading: Cargando loading: Cargando
documents: Documentos documents: Documentos
@ -1472,6 +1472,8 @@ es: &es
read: Leer tus datos read: Leer tus datos
it: &it it: &it
always_enforce_signing_order: Applicare sempre l'ordine di firma
bcc_recipients: Destinatari BCC
edit_per_party: Modifica per partito edit_per_party: Modifica per partito
signed: Firmato signed: Firmato
reply_to: Rispondi a reply_to: Rispondi a
@ -1885,7 +1887,7 @@ it: &it
share_template_with_test_mode: Condividi modello con la modalità di test share_template_with_test_mode: Condividi modello con la modalità di test
share_template_with_all_tenants: Condividi il modello con tutti i tenant share_template_with_all_tenants: Condividi il modello con tutti i tenant
use_following_placeholders_text_: 'Usa i seguenti segnaposto:' use_following_placeholders_text_: 'Usa i seguenti segnaposto:'
upgrade_plan_to_add_more_users: Aggiorna il piano per aggiungere più utenti users_count_total_users_count_pro_users_limit_was_reached_to_invite_additional_users_please_purchase_more_pro_user_seats_via_the_manage_plan_button: 'È stato raggiunto il limite di %{users_count}/%{total_users_count} utenti Pro. Per invitare altri utenti, acquista più posti Pro tramite il pulsante "Gestisci piano".'
move_into_folder: Sposta nella cartella move_into_folder: Sposta nella cartella
new_folder_name: Nuovo nome della cartella new_folder_name: Nuovo nome della cartella
exit_preview: "Esci dall'anteprima" exit_preview: "Esci dall'anteprima"
@ -1926,8 +1928,6 @@ it: &it
team_accounts: Account di squadra team_accounts: Account di squadra
tenant_account: Account tenant tenant_account: Account tenant
tenant_accounts: Account tenant tenant_accounts: Account tenant
upgrade_plan: Aggiorna piano
add_user: Aggiungi utente
impersonate: Impersona impersonate: Impersona
loading: Caricamento in corso loading: Caricamento in corso
documents: Documenti documents: Documenti
@ -2198,6 +2198,8 @@ it: &it
read: Leggi i tuoi dati read: Leggi i tuoi dati
fr: &fr fr: &fr
always_enforce_signing_order: Toujours appliquer l'ordre de signature
bcc_recipients: Destinataires en CCI
edit_per_party: Éditer par partie edit_per_party: Éditer par partie
signed: Signé signed: Signé
reply_to: Répondre à reply_to: Répondre à
@ -2613,7 +2615,7 @@ fr: &fr
share_template_with_test_mode: Partager le modèle avec le mode test share_template_with_test_mode: Partager le modèle avec le mode test
share_template_with_all_tenants: Partager le modèle avec tous les locataires share_template_with_all_tenants: Partager le modèle avec tous les locataires
use_following_placeholders_text_: 'Utilisez les espaces réservés suivants:' use_following_placeholders_text_: 'Utilisez les espaces réservés suivants:'
upgrade_plan_to_add_more_users: "Mettez à jour le plan pour ajouter plus d'utilisateurs" users_count_total_users_count_pro_users_limit_was_reached_to_invite_additional_users_please_purchase_more_pro_user_seats_via_the_manage_plan_button: 'La limite de %{users_count}/%{total_users_count} utilisateurs Pro a été atteinte. Pour inviter d''autres utilisateurs, veuillez acheter plus de places Pro via le bouton "Gérer le plan".'
move_into_folder: Déplacer dans le dossier move_into_folder: Déplacer dans le dossier
new_folder_name: Nouveau nom du dossier new_folder_name: Nouveau nom du dossier
exit_preview: "Quitter l'aperçu" exit_preview: "Quitter l'aperçu"
@ -2654,8 +2656,6 @@ fr: &fr
team_accounts: "Comptes d'équipe" team_accounts: "Comptes d'équipe"
tenant_account: Compte locataire tenant_account: Compte locataire
tenant_accounts: Comptes locataires tenant_accounts: Comptes locataires
upgrade_plan: Mettre à jour le plan
add_user: Ajouter un utilisateur
impersonate: Usurper impersonate: Usurper
loading: Chargement en cours loading: Chargement en cours
documents: Documents documents: Documents
@ -2926,6 +2926,8 @@ fr: &fr
read: Lire vos données read: Lire vos données
pt: &pt pt: &pt
always_enforce_signing_order: Sempre impor a ordem de assinatura
bcc_recipients: Destinatários BCC
edit_per_party: Edita por festa edit_per_party: Edita por festa
signed: Assinado signed: Assinado
reply_to: Responder a reply_to: Responder a
@ -3340,7 +3342,7 @@ pt: &pt
share_template_with_test_mode: Compartilhar modelo com o modo de teste share_template_with_test_mode: Compartilhar modelo com o modo de teste
share_template_with_all_tenants: Compartilhar modelo com todos os locatários share_template_with_all_tenants: Compartilhar modelo com todos os locatários
use_following_placeholders_text_: 'Use os seguintes textos de substituição:' use_following_placeholders_text_: 'Use os seguintes textos de substituição:'
upgrade_plan_to_add_more_users: Faça upgrade do plano para adicionar mais usuários users_count_total_users_count_pro_users_limit_was_reached_to_invite_additional_users_please_purchase_more_pro_user_seats_via_the_manage_plan_button: 'O limite de %{users_count}/%{total_users_count} usuários Pro foi atingido. Para convidar mais usuários, adquira mais licenças Pro através do botão "Gerenciar plano".'
move_into_folder: Mover para pasta move_into_folder: Mover para pasta
new_folder_name: Novo nome da pasta new_folder_name: Novo nome da pasta
exit_preview: Sair da pré-visualização exit_preview: Sair da pré-visualização
@ -3381,8 +3383,6 @@ pt: &pt
team_accounts: Contas de equipe team_accounts: Contas de equipe
tenant_account: Conta de locatário tenant_account: Conta de locatário
tenant_accounts: Contas de locatário tenant_accounts: Contas de locatário
upgrade_plan: Fazer upgrade do plano
add_user: Adicionar usuário
impersonate: Usurpar impersonate: Usurpar
loading: Carregando loading: Carregando
documents: Documentos documents: Documentos
@ -3653,6 +3653,8 @@ pt: &pt
read: Ler seus dados read: Ler seus dados
de: &de de: &de
always_enforce_signing_order: Immer die Reihenfolge der Unterschriften erzwingen
bcc_recipients: BCC-Empfänger
edit_per_party: Bearbeiten pro Partei edit_per_party: Bearbeiten pro Partei
signed: Unterschrieben signed: Unterschrieben
reply_to: Antworten auf reply_to: Antworten auf
@ -4067,7 +4069,7 @@ de: &de
share_template_with_test_mode: Vorlage mit dem Testmodus teilen share_template_with_test_mode: Vorlage mit dem Testmodus teilen
share_template_with_all_tenants: Vorlage mit allen Mietern teilen share_template_with_all_tenants: Vorlage mit allen Mietern teilen
use_following_placeholders_text_: 'Verwende die folgenden Platzhaltertexte:' use_following_placeholders_text_: 'Verwende die folgenden Platzhaltertexte:'
upgrade_plan_to_add_more_users: Upgrade des Plans, um weitere Benutzer hinzuzufügen users_count_total_users_count_pro_users_limit_was_reached_to_invite_additional_users_please_purchase_more_pro_user_seats_via_the_manage_plan_button: 'Das Limit von %{users_count}/%{total_users_count} Pro-Benutzern wurde erreicht. Um weitere Benutzer einzuladen, kaufen Sie bitte zusätzliche Pro-Benutzerplätze über die Schaltfläche "Plan verwalten".'
move_into_folder: In Ordner verschieben move_into_folder: In Ordner verschieben
new_folder_name: Neuer Ordnername new_folder_name: Neuer Ordnername
exit_preview: Vorschau beenden exit_preview: Vorschau beenden
@ -4108,8 +4110,6 @@ de: &de
team_accounts: Teamkonten team_accounts: Teamkonten
tenant_account: Mieterkonto tenant_account: Mieterkonto
tenant_accounts: Mieterkonten tenant_accounts: Mieterkonten
upgrade_plan: Plan upgraden
add_user: Benutzer hinzufügen
impersonate: Nachahmen impersonate: Nachahmen
loading: Wird geladen loading: Wird geladen
documents: Dokumente documents: Dokumente

@ -28,7 +28,7 @@ module ActionMailerConfigsInterceptor
end end
unless Docuseal.multitenant? unless Docuseal.multitenant?
email_configs = EncryptedConfig.find_by(key: EncryptedConfig::EMAIL_SMTP_KEY) email_configs = EncryptedConfig.order(:account_id).find_by(key: EncryptedConfig::EMAIL_SMTP_KEY)
if email_configs if email_configs
message.delivery_method(:smtp, build_smtp_configs_hash(email_configs)) message.delivery_method(:smtp, build_smtp_configs_hash(email_configs))

@ -196,4 +196,20 @@ module Submissions
end end
end.exclude?(false) end.exclude?(false)
end end
def regenerate_documents(submission)
submitters = submission.submitters.where.not(completed_at: nil).preload(:documents_attachments)
submitters.each { |submitter| submitter.documents.each(&:destroy!) }
submission.submitters.where.not(completed_at: nil).order(:completed_at).each do |submitter|
GenerateResultAttachments.call(submitter)
end
return if submission.combined_document_attachment.blank?
submission.combined_document_attachment.destroy!
Submissions::GenerateCombinedAttachment.call(submission.submitters.completed.order(:completed_at).last)
end
end end

@ -7,11 +7,15 @@ module Submissions
CHECK_COMPLETE_TIMEOUT = 90.seconds CHECK_COMPLETE_TIMEOUT = 90.seconds
WaitForCompleteTimeout = Class.new(StandardError) WaitForCompleteTimeout = Class.new(StandardError)
NotCompletedYet = Class.new(StandardError)
module_function module_function
def call(submitter) def call(submitter)
return [] unless submitter return [] unless submitter
raise NotCompletedYet unless submitter.completed_at?
return submitter.documents if ApplicationRecord.uncached { submitter.document_generation_events.complete.exists? } return submitter.documents if ApplicationRecord.uncached { submitter.document_generation_events.complete.exists? }
events = events =

@ -8,6 +8,7 @@ module Submitters
AccountConfig::FORM_PREFILL_SIGNATURE_KEY, AccountConfig::FORM_PREFILL_SIGNATURE_KEY,
AccountConfig::WITH_SIGNATURE_ID, AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::ALLOW_TO_DECLINE_KEY, AccountConfig::ALLOW_TO_DECLINE_KEY,
AccountConfig::ENFORCE_SIGNING_ORDER_KEY,
AccountConfig::REQUIRE_SIGNING_REASON_KEY, AccountConfig::REQUIRE_SIGNING_REASON_KEY,
AccountConfig::REUSE_SIGNATURE_KEY, AccountConfig::REUSE_SIGNATURE_KEY,
AccountConfig::ALLOW_TYPED_SIGNATURE, AccountConfig::ALLOW_TYPED_SIGNATURE,
@ -27,6 +28,7 @@ module Submitters
with_decline = find_safe_value(configs, AccountConfig::ALLOW_TO_DECLINE_KEY) != false with_decline = find_safe_value(configs, AccountConfig::ALLOW_TO_DECLINE_KEY) != false
with_signature_id = find_safe_value(configs, AccountConfig::WITH_SIGNATURE_ID) == true with_signature_id = find_safe_value(configs, AccountConfig::WITH_SIGNATURE_ID) == true
require_signing_reason = find_safe_value(configs, AccountConfig::REQUIRE_SIGNING_REASON_KEY) == true require_signing_reason = find_safe_value(configs, AccountConfig::REQUIRE_SIGNING_REASON_KEY) == true
enforce_signing_order = find_safe_value(configs, AccountConfig::ENFORCE_SIGNING_ORDER_KEY) == true
policy_links = find_safe_value(configs, AccountConfig::POLICY_LINKS_KEY) policy_links = find_safe_value(configs, AccountConfig::POLICY_LINKS_KEY)
attrs = { attrs = {
@ -36,6 +38,7 @@ module Submitters
reuse_signature:, reuse_signature:,
with_decline:, with_decline:,
policy_links:, policy_links:,
enforce_signing_order:,
completed_message:, completed_message:,
require_signing_reason:, require_signing_reason:,
prefill_signature:, prefill_signature:,

@ -3,12 +3,14 @@
module Submitters module Submitters
module SubmitValues module SubmitValues
ValidationError = Class.new(StandardError) ValidationError = Class.new(StandardError)
RequiredFieldError = Class.new(StandardError)
VARIABLE_REGEXP = /\{\{?(\w+)\}\}?/ VARIABLE_REGEXP = /\{\{?(\w+)\}\}?/
NONEDITABLE_FIELD_TYPES = %w[stamp heading].freeze
module_function module_function
def call(submitter, params, request) def call(submitter, params, request, validate_required: true)
Submissions.update_template_fields!(submitter.submission) if submitter.submission.template_fields.blank? Submissions.update_template_fields!(submitter.submission) if submitter.submission.template_fields.blank?
unless submitter.submission_events.exists?(event_type: 'start_form') unless submitter.submission_events.exists?(event_type: 'start_form')
@ -20,7 +22,7 @@ module Submitters
end end
end end
update_submitter!(submitter, params, request) update_submitter!(submitter, params, request, validate_required:)
submitter.submission.save! submitter.submission.save!
@ -29,13 +31,13 @@ module Submitters
submitter submitter
end end
def update_submitter!(submitter, params, request) def update_submitter!(submitter, params, request, validate_required: true)
values = normalized_values(params) values = normalized_values(params)
submitter.values.merge!(values) submitter.values.merge!(values)
submitter.opened_at ||= Time.current submitter.opened_at ||= Time.current
assign_completed_attributes(submitter, request) if params[:completed] == 'true' assign_completed_attributes(submitter, request, validate_required:) if params[:completed] == 'true'
ApplicationRecord.transaction do ApplicationRecord.transaction do
maybe_set_signature_reason!(values, submitter, params) maybe_set_signature_reason!(values, submitter, params)
@ -49,25 +51,36 @@ module Submitters
submitter submitter
end end
def assign_completed_attributes(submitter, request) def assign_completed_attributes(submitter, request, validate_required: true)
submitter.completed_at = Time.current submitter.completed_at = Time.current
submitter.ip = request.remote_ip submitter.ip = request.remote_ip
submitter.ua = request.user_agent submitter.ua = request.user_agent
submitter.values = merge_default_values(submitter) submitter.values = merge_default_values(submitter)
submitter.values = maybe_remove_condition_values(submitter)
required_field_uuids_acc = Set.new
submitter.values = maybe_remove_condition_values(submitter, required_field_uuids_acc:)
formula_values = build_formula_values(submitter) formula_values = build_formula_values(submitter)
if formula_values.present? if formula_values.present?
submitter.values = submitter.values.merge(formula_values) submitter.values = submitter.values.merge(formula_values)
submitter.values = maybe_remove_condition_values(submitter) submitter.values = maybe_remove_condition_values(submitter, required_field_uuids_acc:)
end end
submitter.values = submitter.values.transform_values do |v| submitter.values = submitter.values.transform_values do |v|
v == '{{date}}' ? Time.current.in_time_zone(submitter.account.timezone).to_date.to_s : v v == '{{date}}' ? Time.current.in_time_zone(submitter.account.timezone).to_date.to_s : v
end end
required_field_uuids_acc.each do |uuid|
next if submitter.values[uuid].present?
raise RequiredFieldError, uuid if validate_required
Rollbar.warning("Required field #{submitter.id}: #{uuid}") if defined?(Rollbar)
end
submitter submitter
end end
@ -188,37 +201,67 @@ module Submitters
with_time:) with_time:)
end end
def maybe_remove_condition_values(submitter) def maybe_remove_condition_values(submitter, required_field_uuids_acc: nil)
fields_uuid_index = submitter.submission.template_fields.index_by { |e| e['uuid'] } submission = submitter.submission
attachments_index = fields_uuid_index = submission.template_fields.index_by { |e| e['uuid'] }
Submissions.filtered_conditions_schema(submitter.submission).index_by { |i| i['attachment_uuid'] }
submitter_values = nil submitters_values = nil
is_other_submitter_conditions = submitter.submission.template_submitters.size > 1 has_other_submitters = submission.template_submitters.size > 1
submitter.submission.template_fields.each do |field| has_document_conditions =
next if field['submitter_uuid'] != submitter.uuid (submission.template_schema || submission.template.schema).any? { |e| e['conditions'].present? }
attachments_index =
if has_document_conditions
Submissions.filtered_conditions_schema(submission).index_by { |i| i['attachment_uuid'] }
end
submitter_values ||= submitter.values submission.template_fields.each do |field|
next if field['submitter_uuid'] != submitter.uuid
is_other_submitter_conditions &&= field_conditions_other_submitter?(submitter, field, fields_uuid_index) required_field_uuids_acc.add(field['uuid']) if required_field_uuids_acc && required_editable_field?(field)
if is_other_submitter_conditions if has_document_conditions && !check_field_areas_attachments(field, attachments_index)
submitter_values = submitter.submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } submitter.values.delete(field['uuid'])
required_field_uuids_acc.delete(field['uuid'])
end end
submitter.values.delete(field['uuid']) unless check_field_conditions(submitter_values, field, fields_uuid_index) if has_other_submitters && !submitters_values &&
field_conditions_other_submitter?(submitter, field, fields_uuid_index)
submitters_values = merge_submitters_values(submitter)
end
if field['areas'].present? && field['areas'].none? { |area| attachments_index[area['attachment_uuid']] } unless check_field_conditions(submitters_values || submitter.values, field, fields_uuid_index)
submitter.values.delete(field['uuid']) submitter.values.delete(field['uuid'])
required_field_uuids_acc.delete(field['uuid'])
end end
end end
submitter.values submitter.values
end end
def required_editable_field?(field)
return false if NONEDITABLE_FIELD_TYPES.include?(field['type'])
field['required'].present? && field['readonly'].blank?
end
def check_field_areas_attachments(field, attachments_index)
return true if field['areas'].blank?
field['areas'].any? { |area| attachments_index[area['attachment_uuid']] }
end
def merge_submitters_values(submitter)
submitter.submission.submitters
.reduce({}) { |acc, sub| acc.merge(sub.values) }
.merge(submitter.values)
end
def field_conditions_other_submitter?(submitter, field, fields_uuid_index) def field_conditions_other_submitter?(submitter, field, fields_uuid_index)
return false if field['conditions'].blank?
field['conditions'].to_a.any? do |c| field['conditions'].to_a.any? do |c|
fields_uuid_index.dig(c['field_uuid'], 'submitter_uuid') != submitter.uuid fields_uuid_index.dig(c['field_uuid'], 'submitter_uuid') != submitter.uuid
end end

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@ -4,21 +4,49 @@ module SigningFormHelper
module_function module_function
def draw_canvas def draw_canvas
page.find('canvas').click([], { x: 150, y: 100 })
page.execute_script <<~JS page.execute_script <<~JS
const canvas = document.getElementsByTagName('canvas')[0]; const canvas = document.getElementsByTagName('canvas')[0];
const ctx = canvas.getContext('2d'); const rect = canvas.getBoundingClientRect();
ctx.beginPath(); const startX = rect.left + 50;
ctx.moveTo(150, 100); const startY = rect.top + 100;
ctx.lineTo(450, 100);
ctx.stroke();
ctx.beginPath(); const amplitude = 20;
ctx.moveTo(150, 100); const wavelength = 30;
ctx.lineTo(150, 150); const length = 300;
ctx.stroke();
function dispatchPointerEvent(type, x, y) {
const event = new PointerEvent(type, {
pointerId: 1,
pointerType: 'pen',
isPrimary: true,
clientX: x,
clientY: y,
bubbles: true,
pressure: 0.5
});
canvas.dispatchEvent(event);
}
dispatchPointerEvent('pointerdown', startX, startY);
let x = 0;
function drawStep() {
if (x > length) {
dispatchPointerEvent('pointerup', startX + x, startY);
return;
}
const y = startY + amplitude * Math.sin((x / wavelength) * 2 * Math.PI);
dispatchPointerEvent('pointermove', startX + x, y);
x += 5;
requestAnimationFrame(drawStep);
}
drawStep();
JS JS
sleep 0.5 sleep 0.5
end end

@ -360,6 +360,19 @@ RSpec.describe 'Signing Form', type: :system do
expect(field_value(submitter, 'Signature')).to be_present expect(field_value(submitter, 'Signature')).to be_present
end end
it 'shows an error message if the canvas is not drawn or too simple' do
visit submit_form_path(slug: submitter.slug)
find('#expand_form_button').click
page.find('canvas').click([], { x: 150, y: 100 })
alert_text = page.accept_alert do
click_button 'Sign and Complete'
end
expect(alert_text).to eq 'Signature is too small or simple. Please redraw.'
end
it 'completes the form if the canvas is typed' do it 'completes the form if the canvas is typed' do
visit submit_form_path(slug: submitter.slug) visit submit_form_path(slug: submitter.slug)

Loading…
Cancel
Save