mirror of
https://github.com/docusealco/docuseal.git
synced 2026-08-07 07:14:43 +00:00
Merge from docusealco/wip
This commit is contained in:
@@ -30,7 +30,7 @@ module Api
|
||||
private
|
||||
|
||||
def build_completed_documents(submission, merge: false)
|
||||
last_submitter = submission.submitters.max_by(&:completed_at)
|
||||
last_submitter = submission.submitters.select(&:completed_at?).max_by(&:completed_at)
|
||||
|
||||
if merge
|
||||
if submission.merged_document_attachment.blank?
|
||||
|
||||
@@ -7,7 +7,7 @@ module Api
|
||||
TEMPLATE_COLUMNS = %i[id name external_id created_at updated_at folder_id submitters].freeze
|
||||
|
||||
load_and_authorize_resource :template, only: :create
|
||||
load_and_authorize_resource :submission, only: %i[show index destroy]
|
||||
load_and_authorize_resource :submission, only: %i[show index update destroy]
|
||||
|
||||
before_action only: :create do
|
||||
authorize!(:create, Submission)
|
||||
@@ -80,8 +80,9 @@ module Api
|
||||
Submissions.send_signature_requests(submissions)
|
||||
|
||||
submissions.each do |submission|
|
||||
if submission.submitters.all?(&:completed_at?) && Submissions.maybe_update_completed_at(submission)
|
||||
last_submitter = submission.submitters.max_by(&:completed_at)
|
||||
if submission.submitters.all? { |s| s.viewer? || s.completed_at? } &&
|
||||
Submissions.maybe_update_completed_at(submission)
|
||||
last_submitter = submission.submitters.reject(&:viewer?).max_by(&:completed_at)
|
||||
end
|
||||
|
||||
submission.submitters.each do |submitter|
|
||||
@@ -103,6 +104,25 @@ module Api
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def update
|
||||
@submission = assign_submission_attrs(@submission, submission_params)
|
||||
|
||||
@submission.save!
|
||||
|
||||
if @submission.saved_change_to_archived_at? && @submission.archived_at?
|
||||
WebhookUrls.enqueue_events(@submission, 'submission.archived')
|
||||
end
|
||||
|
||||
if @submission.saved_change_to_expire_at? && @submission.expire_at?
|
||||
ProcessSubmissionExpiredJob.perform_at(@submission.expire_at, 'submission_id' => @submission.id,
|
||||
'expire_at' => @submission.expire_at.to_i)
|
||||
end
|
||||
|
||||
SearchEntries.enqueue_reindex(@submission) if @submission.saved_change_to_name?
|
||||
|
||||
render json: Submissions::SerializeForApi.call(@submission, nil, params, with_events: false)
|
||||
end
|
||||
|
||||
def destroy
|
||||
if params[:permanently].in?(['true', true])
|
||||
@submission.destroy!
|
||||
@@ -117,6 +137,25 @@ module Api
|
||||
|
||||
private
|
||||
|
||||
def assign_submission_attrs(submission, attrs)
|
||||
archived = attrs.key?(:archived) ? attrs[:archived] : attrs[:archived_at]
|
||||
|
||||
if archived.in?([true, false, 'true', 'false']) && current_ability.can?(:destroy, submission)
|
||||
submission.archived_at = archived.in?(Submitters::TRUE_VALUES) ? Time.current : nil
|
||||
end
|
||||
|
||||
submission.name = attrs[:name] if attrs.key?(:name)
|
||||
submission.expire_at = attrs[:expire_at].presence if attrs.key?(:expire_at)
|
||||
|
||||
submission
|
||||
end
|
||||
|
||||
def submission_params
|
||||
submission_params = params.key?(:submission) ? params.require(:submission) : params
|
||||
|
||||
submission_params.permit(:name, :expire_at, :archived, :archived_at)
|
||||
end
|
||||
|
||||
def maybe_return_template_error
|
||||
return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil?
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ module Api
|
||||
private
|
||||
|
||||
def maybe_return_submitter_error
|
||||
if @submitter.completed_at?
|
||||
if @submitter.completed_at? || @submitter.submission.completed_at?
|
||||
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ module Api
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def index
|
||||
@templates = Templates.shared(current_user) if params[:shared].in?(['true', true])
|
||||
|
||||
templates = filter_templates(@templates, params)
|
||||
|
||||
templates = paginate(templates.preload(:author, folder: :parent_folder))
|
||||
@@ -54,7 +56,7 @@ module Api
|
||||
|
||||
@template.update!(template_params)
|
||||
|
||||
SearchEntries.enqueue_reindex(@template)
|
||||
SearchEntries.enqueue_reindex(@template) if @template.saved_change_to_name?
|
||||
|
||||
WebhookUrls.enqueue_events(@template, 'template.updated')
|
||||
|
||||
@@ -115,7 +117,13 @@ module Api
|
||||
end
|
||||
|
||||
def filter_templates(templates, params)
|
||||
templates = Templates.search(current_user, templates, params[:q])
|
||||
templates =
|
||||
if params[:shared].in?(['true', true])
|
||||
Templates.search_shared(current_user, templates, params[:q])
|
||||
else
|
||||
Templates.search(current_user, templates, params[:q])
|
||||
end
|
||||
|
||||
templates = params[:archived].in?(['true', true]) ? templates.archived : templates.active
|
||||
templates = templates.where(external_id: params[:application_key]) if params[:application_key].present?
|
||||
templates = templates.where(external_id: params[:external_id]) if params[:external_id].present?
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
class EmailSmtpSettingsController < ApplicationController
|
||||
before_action :load_encrypted_config
|
||||
authorize_resource :encrypted_config, only: :index
|
||||
authorize_resource :encrypted_config, parent: false, only: :create
|
||||
authorize_resource :encrypted_config, parent: false, only: %i[create destroy]
|
||||
|
||||
def index; end
|
||||
|
||||
@@ -23,6 +23,12 @@ class EmailSmtpSettingsController < ApplicationController
|
||||
render :index, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def destroy
|
||||
@encrypted_config.destroy!
|
||||
|
||||
redirect_to settings_email_index_path, notice: I18n.t('smtp_settings_have_been_reset')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_encrypted_config
|
||||
|
||||
@@ -16,18 +16,16 @@ class SendSubmissionEmailController < ApplicationController
|
||||
@submitter =
|
||||
Submitter.completed.where(submission: template.submissions).find_by(email: params[:email].to_s.downcase)
|
||||
elsif params[:submission_slug]
|
||||
submission = Submission.find_by(slug: params[:submission_slug])
|
||||
submission = Submission.find_by!(slug: params[:submission_slug])
|
||||
|
||||
if submission
|
||||
@submitter = Submitter.completed.find_by(submission: submission, email: params[:email].to_s.downcase)
|
||||
end
|
||||
@submitter = submission.submitters.order(:completed_at).find_by(email: params[:email].to_s.downcase)
|
||||
|
||||
return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter
|
||||
else
|
||||
@submitter = Submitter.completed.find_by!(slug: params[:submitter_slug])
|
||||
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
end
|
||||
|
||||
if @submitter
|
||||
if @submitter && completed_submitter?(@submitter)
|
||||
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
|
||||
|
||||
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
|
||||
@@ -41,6 +39,10 @@ class SendSubmissionEmailController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def completed_submitter?(submitter)
|
||||
submitter.completed_at? || (submitter.viewer? && submitter.submission.completed_at?)
|
||||
end
|
||||
|
||||
def can_send?(submitter)
|
||||
return false if submitter.account.archived_at?
|
||||
return false if EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
|
||||
|
||||
@@ -93,9 +93,12 @@ class StartFormController < ApplicationController
|
||||
|
||||
SearchEntries.enqueue_reindex(submitter)
|
||||
|
||||
return unless submitter.submission.expire_at?
|
||||
expire_at = submitter.submission.expire_at
|
||||
|
||||
ProcessSubmissionExpiredJob.perform_at(submitter.submission.expire_at, 'submission_id' => submitter.submission_id)
|
||||
return unless expire_at
|
||||
|
||||
ProcessSubmissionExpiredJob.perform_at(expire_at, 'submission_id' => submitter.submission_id,
|
||||
'expire_at' => expire_at.to_i)
|
||||
end
|
||||
|
||||
def load_resubmit_submitter
|
||||
@@ -138,8 +141,7 @@ class StartFormController < ApplicationController
|
||||
|
||||
submitter ||=
|
||||
Submitter
|
||||
.where(submission: template.submissions.where(expire_at: Time.current..)
|
||||
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
|
||||
.where(submission: template.submissions.non_expired.active)
|
||||
.order(id: :desc)
|
||||
.where(declined_at: nil)
|
||||
.where(external_id: nil)
|
||||
@@ -147,6 +149,8 @@ class StartFormController < ApplicationController
|
||||
.then { |rel| params[:resubmit].present? || params[:selfsign].present? ? rel.where(completed_at: nil) : rel }
|
||||
.find_or_initialize_by(find_params)
|
||||
|
||||
submitter = Submitter.new(find_params) if submitter.submission&.completed_at? && submitter.viewer?
|
||||
|
||||
submitter.name = required_params['name'] if submitter.new_record?
|
||||
|
||||
unless @resubmit_submitter
|
||||
|
||||
@@ -87,6 +87,8 @@ class SubmissionsController < ApplicationController
|
||||
private
|
||||
|
||||
def create_submissions(template, submissions_params, params)
|
||||
normalize_message_submitter_uuids!(params)
|
||||
|
||||
submissions_attrs = submissions_params[:submission].to_h.values
|
||||
|
||||
submissions_attrs, _, new_fields =
|
||||
@@ -111,4 +113,23 @@ class SubmissionsController < ApplicationController
|
||||
def submissions_params
|
||||
params.permit(submission: { submitters: [:uuid, :email, :phone, :name, { values: {} }] })
|
||||
end
|
||||
|
||||
def normalize_message_submitter_uuids!(params)
|
||||
return if params[:request_email_per_submitter] == '1'
|
||||
|
||||
uuids = params[:email_message_submitter_uuids]
|
||||
|
||||
return if uuids.blank?
|
||||
return if params[:subject].blank? && params[:body].blank?
|
||||
|
||||
params[:submitter_preferences] =
|
||||
Array.wrap(uuids).index_with { { 'subject' => params[:subject], 'body' => params[:body] } }
|
||||
|
||||
params[:request_email_per_submitter] = '1'
|
||||
|
||||
params.delete(:subject)
|
||||
params.delete(:body)
|
||||
|
||||
params
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,7 +19,7 @@ class SubmitFormCompletedDownloadController < ApplicationController
|
||||
|
||||
@submitter ||= Submitter.find_by!(slug: submitter_slug)
|
||||
|
||||
Submissions::EnsureResultGenerated.call(@submitter)
|
||||
Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at?
|
||||
|
||||
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
|
||||
|
||||
@@ -28,11 +28,7 @@ class SubmitFormCompletedDownloadController < ApplicationController
|
||||
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
|
||||
if !signature_valid && !current_user_submitter?(last_submitter)
|
||||
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
return head :not_found unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
|
||||
if last_submitter.completed_at < TTL.ago
|
||||
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
@@ -18,7 +18,10 @@ class SubmitFormController < ApplicationController
|
||||
submission = @submitter.submission
|
||||
|
||||
return render :email_2fa unless Submitters::AuthorizedForForm.pass_email_2fa?(@submitter, request)
|
||||
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
|
||||
|
||||
if @submitter.completed_at? || submission.completed_at?
|
||||
return redirect_to submit_form_completed_path(@submitter.slug)
|
||||
end
|
||||
|
||||
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
|
||||
|
||||
@@ -71,6 +74,12 @@ class SubmitFormController < ApplicationController
|
||||
status: :unprocessable_content
|
||||
end
|
||||
|
||||
if @submitter.viewer?
|
||||
Rollbar.warning("Submit viewer: #{@submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: I18n.t('form_is_view_only') }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
Submitters::SubmitValues.call(@submitter, params, request)
|
||||
|
||||
head :ok
|
||||
|
||||
@@ -13,6 +13,7 @@ class SubmitFormDeclineController < ApplicationController
|
||||
@submitter.submission.archived_at? ||
|
||||
@submitter.submission.expired? ||
|
||||
@submitter.submission.template&.archived_at? ||
|
||||
@submitter.viewer? ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter,
|
||||
current_user,
|
||||
request)
|
||||
|
||||
@@ -12,6 +12,7 @@ class SubmitFormDelegateController < ApplicationController
|
||||
@submitter.submission.archived_at? ||
|
||||
@submitter.submission.expired? ||
|
||||
@submitter.submission.template&.archived_at? ||
|
||||
@submitter.viewer? ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter,
|
||||
current_user,
|
||||
request)
|
||||
|
||||
@@ -14,6 +14,7 @@ class SubmitFormDownloadController < ApplicationController
|
||||
return head :unprocessable_content if @submitter.declined_at? ||
|
||||
@submitter.submission.archived_at? ||
|
||||
@submitter.submission.expired? ||
|
||||
@submitter.submission.completed_at? ||
|
||||
@submitter.submission.template&.archived_at? ||
|
||||
AccountConfig.exists?(account_id: @submitter.account_id,
|
||||
key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
|
||||
|
||||
@@ -12,6 +12,8 @@ class SubmitFormDrawSignatureController < ApplicationController
|
||||
|
||||
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
|
||||
|
||||
return redirect_to submit_form_path(@submitter.slug) if @submitter.viewer?
|
||||
|
||||
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
return redirect_to submit_form_path(@submitter.slug)
|
||||
|
||||
@@ -48,6 +48,7 @@ class SubmitFormInviteController < ApplicationController
|
||||
!submitter.submission.archived_at? &&
|
||||
!submitter.submission.expired? &&
|
||||
!submitter.submission.template&.archived_at? &&
|
||||
!submitter.viewer? &&
|
||||
Submitters::AuthorizedForForm.call(submitter, current_user, request)
|
||||
end
|
||||
|
||||
|
||||
@@ -7,13 +7,7 @@ class SubmitFormMetadataController < ApplicationController
|
||||
def index
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
|
||||
return head :not_found if @submitter.declined_at? ||
|
||||
@submitter.completed_at? ||
|
||||
@submitter.submission.archived_at? ||
|
||||
@submitter.submission.expired? ||
|
||||
@submitter.submission.template&.archived_at? ||
|
||||
@submitter.account.archived_at? ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
return head :not_found unless authorized_submitter?(@submitter)
|
||||
|
||||
submission = @submitter.submission
|
||||
values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
|
||||
@@ -34,4 +28,17 @@ class SubmitFormMetadataController < ApplicationController
|
||||
|
||||
render json: { text_runs: }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authorized_submitter?(submitter)
|
||||
!submitter.declined_at? &&
|
||||
!submitter.completed_at? &&
|
||||
!submitter.submission.archived_at? &&
|
||||
!submitter.submission.completed_at? &&
|
||||
!submitter.submission.expired? &&
|
||||
!submitter.submission.template&.archived_at? &&
|
||||
!submitter.account.archived_at? &&
|
||||
Submitters::AuthorizedForForm.call(submitter, current_user, request)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -50,7 +50,7 @@ class SubmittersController < ApplicationController
|
||||
|
||||
def submitter_editable?(submission)
|
||||
!@submitter.submission_events.exists?(event_type: 'start_form') &&
|
||||
!@submitter.completed_at? && !@submitter.declined_at? &&
|
||||
!@submitter.completed_at? && !@submitter.declined_at? && !submission.completed_at? &&
|
||||
!submission.archived_at? && !submission.expired? && !submission.template&.archived_at?
|
||||
end
|
||||
|
||||
@@ -58,7 +58,7 @@ class SubmittersController < ApplicationController
|
||||
if params[:send_email] == '1' && submitter.email.present?
|
||||
is_sent_recently = Docuseal.multitenant? &&
|
||||
EmailEvent.exists?(email: submitter.email,
|
||||
tag: 'submitter_invitation',
|
||||
tag: %w[submitter_invitation submitter_view_invitation],
|
||||
emailable: submitter,
|
||||
event_type: 'send',
|
||||
created_at: 4.hours.ago..Time.current)
|
||||
|
||||
@@ -5,6 +5,8 @@ class TemplatesPreferencesController < ApplicationController
|
||||
|
||||
RESETTABLE_PREFERENCE_KEYS = {
|
||||
AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY => %w[request_email_subject request_email_body submitters],
|
||||
AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY => %w[invitation_view_email_subject
|
||||
invitation_view_email_body],
|
||||
AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => %w[invitation_reminder_email_subject
|
||||
invitation_reminder_email_body],
|
||||
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[documents_copy_email_subject documents_copy_email_body],
|
||||
@@ -48,12 +50,12 @@ class TemplatesPreferencesController < ApplicationController
|
||||
def template_params
|
||||
params.require(:template).permit(
|
||||
preferences: %i[bcc_completed request_email_subject request_email_body
|
||||
invitation_view_email_subject invitation_view_email_body
|
||||
invitation_reminder_email_subject invitation_reminder_email_body
|
||||
documents_copy_email_subject documents_copy_email_body
|
||||
documents_copy_email_enabled documents_copy_email_attach_audit
|
||||
documents_copy_email_attach_documents documents_copy_email_reply_to
|
||||
completed_notification_email_attach_documents
|
||||
completed_redirect_url validate_unique_submitters
|
||||
completed_notification_email_attach_documents completed_redirect_url validate_unique_submitters
|
||||
require_all_submitters submitters_order require_phone_2fa require_email_2fa
|
||||
default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled
|
||||
completed_notification_email_subject completed_notification_email_body
|
||||
|
||||
@@ -42,6 +42,7 @@ import RequiredCheckboxGroup from './elements/required_checkbox_group'
|
||||
import PageContainer from './elements/page_container'
|
||||
import EmailEditor from './elements/email_editor'
|
||||
import MarkdownEditor from './elements/markdown_editor'
|
||||
import HtmlEditor from './elements/html_editor'
|
||||
import MountOnClick from './elements/mount_on_click'
|
||||
import RemoveOnEvent from './elements/remove_on_event'
|
||||
import ScrollTo from './elements/scroll_to'
|
||||
@@ -135,6 +136,7 @@ safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup)
|
||||
safeRegisterElement('page-container', PageContainer)
|
||||
safeRegisterElement('email-editor', EmailEditor)
|
||||
safeRegisterElement('markdown-editor', MarkdownEditor)
|
||||
safeRegisterElement('html-editor', HtmlEditor)
|
||||
safeRegisterElement('mount-on-click', MountOnClick)
|
||||
safeRegisterElement('remove-on-event', RemoveOnEvent)
|
||||
safeRegisterElement('scroll-to', ScrollTo)
|
||||
|
||||
@@ -9,8 +9,9 @@ function loadCodeMirror () {
|
||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/commands'),
|
||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/language'),
|
||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/lang-html'),
|
||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/lint'),
|
||||
import(/* webpackChunkName: "email-editor" */ '@specious/htmlflow')
|
||||
]).then(([view, commands, language, html, htmlflow]) => {
|
||||
]).then(([view, commands, language, html, lint, htmlflow]) => {
|
||||
return {
|
||||
minimalSetup: [
|
||||
commands.history(),
|
||||
@@ -19,6 +20,8 @@ function loadCodeMirror () {
|
||||
],
|
||||
EditorView: view.EditorView,
|
||||
html: html.html,
|
||||
htmlLanguage: html.htmlLanguage,
|
||||
linter: lint.linter,
|
||||
htmlflow: htmlflow.default || htmlflow
|
||||
}
|
||||
})
|
||||
@@ -46,6 +49,70 @@ export default targetable(class extends HTMLElement {
|
||||
|
||||
this.previewViewTab.addEventListener('click', this.showPreviewView)
|
||||
this.codeViewTab.addEventListener('click', this.showCodeView)
|
||||
|
||||
this.form = this.closest('form')
|
||||
this.form?.addEventListener('submit', this.validateOnSubmit)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.form?.removeEventListener('submit', this.validateOnSubmit)
|
||||
}
|
||||
|
||||
validateOnSubmit = (e) => {
|
||||
if (!this.htmlLanguage) return
|
||||
|
||||
const bodyType = this.form.querySelector('input[name$="[body_type]"]:checked')?.value
|
||||
|
||||
if (bodyType && bodyType !== 'html') return
|
||||
|
||||
const diagnostics = this.buildDiagnostics(this.input.value)
|
||||
|
||||
if (diagnostics.length === 0) return
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
this.showCodeView()
|
||||
|
||||
const pos = Math.min(diagnostics[0].from, this.editorView.state.doc.length)
|
||||
|
||||
this.editorView.dispatch({ selection: { anchor: pos }, scrollIntoView: true })
|
||||
this.editorView.focus()
|
||||
|
||||
alert(diagnostics[0].message)
|
||||
}
|
||||
|
||||
buildDiagnostics (value) {
|
||||
const diagnostics = []
|
||||
|
||||
if (!value.trim()) return diagnostics
|
||||
|
||||
if (!/^\s*(<!doctype[^>]*>\s*)?<html/i.test(value)) {
|
||||
diagnostics.push({
|
||||
from: 0,
|
||||
to: Math.min(5, value.length),
|
||||
severity: 'error',
|
||||
message: 'The email template must start with the <html> tag'
|
||||
})
|
||||
}
|
||||
|
||||
const seen = new Set()
|
||||
|
||||
this.htmlLanguage.parser.parse(value).iterate({
|
||||
enter: (node) => {
|
||||
if (!node.type.isError || seen.has(node.from) || seen.size >= 20) return
|
||||
|
||||
seen.add(node.from)
|
||||
|
||||
diagnostics.push({
|
||||
from: node.from,
|
||||
to: Math.min(node.to + 1, value.length),
|
||||
severity: 'error',
|
||||
message: 'The email template contains invalid HTML'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
showCodeView = () => {
|
||||
@@ -76,7 +143,9 @@ export default targetable(class extends HTMLElement {
|
||||
this.input = this.querySelector('input[type="hidden"]')
|
||||
this.input.style.display = 'none'
|
||||
|
||||
const { EditorView, minimalSetup, html, htmlflow } = await loadCodeMirror()
|
||||
const { EditorView, minimalSetup, html, htmlLanguage, linter, htmlflow } = await loadCodeMirror()
|
||||
|
||||
this.htmlLanguage = htmlLanguage
|
||||
|
||||
this.editorView = new EditorView({
|
||||
doc: this.input.value,
|
||||
@@ -85,8 +154,11 @@ export default targetable(class extends HTMLElement {
|
||||
html(),
|
||||
minimalSetup,
|
||||
EditorView.lineWrapping,
|
||||
linter((view) => this.buildDiagnostics(view.state.doc.toString()), { delay: 600 }),
|
||||
EditorView.updateListener.of(update => {
|
||||
if (update.docChanged) this.input.value = update.state.doc.toString()
|
||||
if (update.docChanged) {
|
||||
this.input.value = update.state.doc.toString()
|
||||
}
|
||||
}),
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
import { target, targetable } from '@github/catalyst/lib/targetable'
|
||||
import { actionable } from '@github/catalyst/lib/actionable'
|
||||
import { LinkTooltip } from './markdown_editor'
|
||||
|
||||
async function loadTiptap () {
|
||||
const [core, document, text, hardBreak, gapcursor, dropcursor, extensions, pmState, pmView] = await Promise.all([
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/core'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-document'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-text'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-hard-break'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-gapcursor'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-dropcursor'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extensions'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/state'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/view')
|
||||
])
|
||||
|
||||
return {
|
||||
Editor: core.Editor,
|
||||
Extension: core.Extension,
|
||||
Node: core.Node,
|
||||
Mark: core.Mark,
|
||||
Document: document.default || document,
|
||||
Text: text.default || text,
|
||||
HardBreak: hardBreak.default || hardBreak,
|
||||
Gapcursor: gapcursor.default || gapcursor,
|
||||
Dropcursor: dropcursor.default || dropcursor,
|
||||
UndoRedo: extensions.UndoRedo,
|
||||
Plugin: pmState.Plugin,
|
||||
Decoration: pmView.Decoration,
|
||||
DecorationSet: pmView.DecorationSet
|
||||
}
|
||||
}
|
||||
|
||||
const editorStylesheet = new CSSStyleSheet()
|
||||
|
||||
editorStylesheet.replaceSync(`
|
||||
:host {
|
||||
display: block;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
border-radius: 0 0 1rem 1rem;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
word-wrap: break-word;
|
||||
-webkit-font-variant-ligatures: none;
|
||||
font-variant-ligatures: none;
|
||||
font-feature-settings: "liga" 0;
|
||||
outline: none;
|
||||
min-height: 220px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
img.ProseMirror-separator {
|
||||
display: inline !important;
|
||||
border: none !important;
|
||||
margin: 0 !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.ProseMirror-gapcursor {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ProseMirror-gapcursor:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 20px;
|
||||
border-top: 1px solid black;
|
||||
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
|
||||
}
|
||||
|
||||
@keyframes ProseMirror-cursor-blink {
|
||||
to {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection *::selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection *::-moz-selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection * {
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror-focused .ProseMirror-gapcursor {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.variable-highlight {
|
||||
background-color: #fef3c7;
|
||||
padding: 1px 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
`)
|
||||
|
||||
function collectDomAttrs (dom) {
|
||||
const attrs = {}
|
||||
|
||||
for (let i = 0; i < dom.attributes.length; i++) {
|
||||
attrs[dom.attributes[i].name] = dom.attributes[i].value
|
||||
}
|
||||
|
||||
return { htmlAttrs: attrs }
|
||||
}
|
||||
|
||||
function collectSpanDomAttrs (dom) {
|
||||
const result = collectDomAttrs(dom)
|
||||
|
||||
if (result.htmlAttrs.style) {
|
||||
const temp = document.createElement('span')
|
||||
|
||||
temp.style.cssText = result.htmlAttrs.style
|
||||
|
||||
if (['bold', '700'].includes(temp.style.fontWeight)) {
|
||||
temp.style.removeProperty('font-weight')
|
||||
}
|
||||
|
||||
if (temp.style.fontStyle === 'italic') {
|
||||
temp.style.removeProperty('font-style')
|
||||
}
|
||||
|
||||
if (temp.style.textDecoration === 'underline') {
|
||||
temp.style.removeProperty('text-decoration')
|
||||
}
|
||||
|
||||
if (temp.style.cssText) {
|
||||
result.htmlAttrs.style = temp.style.cssText
|
||||
} else {
|
||||
delete result.htmlAttrs.style
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function buildExtensions ({ Node, Mark, Extension, Plugin, Decoration, DecorationSet }) {
|
||||
const blockNode = (name, tag, content, extra = {}) => Node.create({
|
||||
name,
|
||||
group: 'block',
|
||||
content: content || 'block+',
|
||||
...extra,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag, getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return [tag, node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const attrsMark = (name, tag) => Mark.create({
|
||||
name,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag, getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ mark }) {
|
||||
return [tag, mark.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const SpanMark = Mark.create({
|
||||
name: 'span',
|
||||
excludes: '',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'span', getAttrs: collectSpanDomAttrs }]
|
||||
},
|
||||
renderHTML ({ mark }) {
|
||||
return ['span', mark.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const toggleMark = (name, renderTag, parseRules, shortcuts) => Mark.create({
|
||||
name,
|
||||
parseHTML () {
|
||||
return parseRules
|
||||
},
|
||||
renderHTML () {
|
||||
return [renderTag, 0]
|
||||
},
|
||||
addCommands () {
|
||||
const commandName = `toggle${name[0].toUpperCase()}${name.slice(1)}`
|
||||
|
||||
return {
|
||||
[commandName]: () => ({ commands }) => commands.toggleMark(name)
|
||||
}
|
||||
},
|
||||
addKeyboardShortcuts () {
|
||||
return {
|
||||
[shortcuts]: () => this.editor.commands.toggleMark(name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const Heading = Node.create({
|
||||
name: 'heading',
|
||||
group: 'block',
|
||||
content: 'inline*',
|
||||
addAttributes () {
|
||||
return {
|
||||
htmlAttrs: { default: {} },
|
||||
level: { default: 1 }
|
||||
}
|
||||
},
|
||||
parseHTML () {
|
||||
return [1, 2, 3, 4, 5, 6].map((level) => ({
|
||||
tag: `h${level}`,
|
||||
getAttrs: (dom) => ({ ...collectDomAttrs(dom), level })
|
||||
}))
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return [`h${node.attrs.level}`, node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const ImageNode = Node.create({
|
||||
name: 'image',
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
draggable: true,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'img', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['img', node.attrs.htmlAttrs]
|
||||
}
|
||||
})
|
||||
|
||||
const HrNode = Node.create({
|
||||
name: 'horizontalRule',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'hr', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['hr', node.attrs.htmlAttrs]
|
||||
}
|
||||
})
|
||||
|
||||
const StyleNode = Node.create({
|
||||
name: 'style',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: false,
|
||||
addAttributes () {
|
||||
return {
|
||||
htmlAttrs: { default: {} },
|
||||
css: { default: '' }
|
||||
}
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'style', getAttrs: (dom) => ({ ...collectDomAttrs(dom), css: dom.textContent }) }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['style', node.attrs.htmlAttrs, node.attrs.css]
|
||||
}
|
||||
})
|
||||
|
||||
const EmptySpanNode = Node.create({
|
||||
name: 'emptySpan',
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
atom: true,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{
|
||||
tag: 'span',
|
||||
priority: 60,
|
||||
getAttrs (dom) {
|
||||
if (dom.childNodes.length === 0 && dom.attributes.length > 0) {
|
||||
return collectDomAttrs(dom)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['span', node.attrs.htmlAttrs]
|
||||
}
|
||||
})
|
||||
|
||||
const LinkMark = Mark.create({
|
||||
name: 'link',
|
||||
inclusive: true,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'a', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ mark }) {
|
||||
return ['a', mark.attrs.htmlAttrs, 0]
|
||||
},
|
||||
addCommands () {
|
||||
return {
|
||||
setLink: ({ href }) => ({ editor, commands }) => {
|
||||
const htmlAttrs = { ...(editor.getAttributes('link').htmlAttrs || {}), href }
|
||||
|
||||
return commands.setMark('link', { htmlAttrs })
|
||||
},
|
||||
unsetLink: () => ({ commands }) => commands.unsetMark('link', { extendEmptyMarkRange: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const buildDecorations = (doc) => {
|
||||
const decorations = []
|
||||
const regex = /\{\{?[a-zA-Z0-9_.-]+\}\}?/g
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isText) return
|
||||
|
||||
let match
|
||||
|
||||
while ((match = regex.exec(node.text)) !== null) {
|
||||
decorations.push(
|
||||
Decoration.inline(pos + match.index, pos + match.index + match[0].length, {
|
||||
class: 'variable-highlight'
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
|
||||
const VariableHighlight = Extension.create({
|
||||
name: 'variableHighlight',
|
||||
addProseMirrorPlugins () {
|
||||
return [new Plugin({
|
||||
state: {
|
||||
init (_, { doc }) {
|
||||
return buildDecorations(doc)
|
||||
},
|
||||
apply (tr, oldSet) {
|
||||
return tr.docChanged ? buildDecorations(tr.doc) : oldSet
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations (state) {
|
||||
return this.getState(state)
|
||||
}
|
||||
}
|
||||
})]
|
||||
}
|
||||
})
|
||||
|
||||
return [
|
||||
blockNode('paragraph', 'p', 'inline*'),
|
||||
Heading,
|
||||
blockNode('section', 'section'),
|
||||
blockNode('article', 'article', null, { isolating: true }),
|
||||
blockNode('header', 'header', null, { isolating: true }),
|
||||
blockNode('footer', 'footer', null, { isolating: true }),
|
||||
blockNode('div', 'div'),
|
||||
blockNode('center', 'center'),
|
||||
blockNode('blockquote', 'blockquote'),
|
||||
blockNode('pre', 'pre'),
|
||||
blockNode('orderedList', 'ol', '(listItem | block)+'),
|
||||
blockNode('bulletList', 'ul', '(listItem | block)+'),
|
||||
blockNode('listItem', 'li', 'block+', { group: null }),
|
||||
blockNode('table', 'table', '(colgroup | tableHead | tableBody | tableFoot | tableRow)+'),
|
||||
blockNode('tableHead', 'thead', 'tableRow+', { group: null }),
|
||||
blockNode('tableBody', 'tbody', 'tableRow+', { group: null }),
|
||||
blockNode('tableFoot', 'tfoot', 'tableRow+', { group: null }),
|
||||
blockNode('tableRow', 'tr', '(tableCell | tableHeader)+', { group: null }),
|
||||
blockNode('tableCell', 'td', 'block*', { group: null }),
|
||||
blockNode('tableHeader', 'th', 'block*', { group: null }),
|
||||
blockNode('colgroup', 'colgroup', 'col*', { group: null }),
|
||||
Node.create({
|
||||
name: 'col',
|
||||
atom: true,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'col', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['col', node.attrs.htmlAttrs]
|
||||
}
|
||||
}),
|
||||
ImageNode,
|
||||
HrNode,
|
||||
StyleNode,
|
||||
EmptySpanNode,
|
||||
SpanMark,
|
||||
LinkMark,
|
||||
toggleMark('bold', 'strong', [{ tag: 'strong' }, { tag: 'b' }, { style: 'font-weight=bold' }, { style: 'font-weight=700' }], 'Mod-b'),
|
||||
toggleMark('italic', 'em', [{ tag: 'em' }, { tag: 'i' }, { style: 'font-style=italic' }], 'Mod-i'),
|
||||
toggleMark('underline', 'u', [{ tag: 'u' }, { style: 'text-decoration=underline' }], 'Mod-u'),
|
||||
toggleMark('strike', 's', [{ tag: 's' }, { tag: 'del' }, { tag: 'strike' }, { style: 'text-decoration=line-through' }], 'Mod-Shift-s'),
|
||||
attrsMark('subscript', 'sub'),
|
||||
attrsMark('superscript', 'sup'),
|
||||
VariableHighlight
|
||||
]
|
||||
}
|
||||
|
||||
export default actionable(targetable(class extends HTMLElement {
|
||||
static [target.static] = [
|
||||
'textarea',
|
||||
'editorElement',
|
||||
'boldButton',
|
||||
'italicButton',
|
||||
'underlineButton',
|
||||
'linkButton',
|
||||
'linkTooltipTemplate'
|
||||
]
|
||||
|
||||
async connectedCallback () {
|
||||
if (!this.textarea || !this.editorElement) return
|
||||
|
||||
this.textarea.style.display = 'none'
|
||||
this.adjustShortcutsForPlatform()
|
||||
|
||||
const tiptap = await loadTiptap()
|
||||
|
||||
const { Editor, Extension, Document, Text, HardBreak, UndoRedo, Gapcursor, Dropcursor } = tiptap
|
||||
|
||||
this.emailDocument = new DOMParser().parseFromString(this.textarea.value, 'text/html')
|
||||
|
||||
const shadow = this.editorElement.attachShadow({ mode: 'open' })
|
||||
|
||||
shadow.adoptedStyleSheets = [editorStylesheet]
|
||||
|
||||
this.emailDocument.head.querySelectorAll('style').forEach((style) => {
|
||||
shadow.appendChild(style.cloneNode(true))
|
||||
})
|
||||
|
||||
const container = document.createElement('div')
|
||||
const bodyStyle = this.emailDocument.body.getAttribute('style')
|
||||
|
||||
if (bodyStyle) container.setAttribute('style', bodyStyle)
|
||||
|
||||
shadow.appendChild(container)
|
||||
|
||||
const LinkShortcut = Extension.create({
|
||||
name: 'linkShortcut',
|
||||
addKeyboardShortcuts: () => ({
|
||||
'Mod-k': () => {
|
||||
this.toggleLink()
|
||||
|
||||
return true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.editor = new Editor({
|
||||
element: container,
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
HardBreak,
|
||||
UndoRedo,
|
||||
Gapcursor,
|
||||
Dropcursor,
|
||||
...buildExtensions(tiptap),
|
||||
LinkShortcut
|
||||
],
|
||||
content: this.emailDocument.body.innerHTML,
|
||||
injectCSS: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
dir: 'auto'
|
||||
},
|
||||
handleDOMEvents: {
|
||||
click: (_, event) => {
|
||||
if (event.target.closest('a')) event.preventDefault()
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
this.emailDocument.body.innerHTML = editor.getHTML()
|
||||
|
||||
this.textarea.value = this.emailDocument.documentElement.outerHTML
|
||||
this.textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
},
|
||||
onSelectionUpdate: ({ editor }) => {
|
||||
this.updateToolbarState()
|
||||
this.handleLinkTooltip(editor)
|
||||
},
|
||||
onBlur: () => {
|
||||
setTimeout(() => {
|
||||
if (!this.linkTooltip.tooltip.contains(document.activeElement)) {
|
||||
this.linkTooltip.hide()
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
|
||||
this.linkTooltip = new LinkTooltip(this, this.editor, this.linkTooltipTemplate)
|
||||
}
|
||||
|
||||
adjustShortcutsForPlatform () {
|
||||
if ((navigator.userAgentData?.platform)?.toLowerCase()?.includes('mac')) {
|
||||
this.querySelectorAll('.tooltip[data-tip]').forEach(tooltip => {
|
||||
const tip = tooltip.getAttribute('data-tip')
|
||||
|
||||
if (tip && tip.includes('Ctrl')) {
|
||||
tooltip.setAttribute('data-tip', tip.replace(/Ctrl/g, '⌘'))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
bold (e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.editor.chain().focus().toggleBold().run()
|
||||
this.updateToolbarState()
|
||||
}
|
||||
|
||||
italic (e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.editor.chain().focus().toggleItalic().run()
|
||||
this.updateToolbarState()
|
||||
}
|
||||
|
||||
underline (e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.editor.chain().focus().toggleUnderline().run()
|
||||
this.updateToolbarState()
|
||||
}
|
||||
|
||||
linkSelection (e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.toggleLink()
|
||||
this.updateToolbarState()
|
||||
}
|
||||
|
||||
undo (e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.editor.chain().focus().undo().run()
|
||||
this.updateToolbarState()
|
||||
}
|
||||
|
||||
redo (e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.editor.chain().focus().redo().run()
|
||||
this.updateToolbarState()
|
||||
}
|
||||
|
||||
updateToolbarState () {
|
||||
this.boldButton.classList.toggle('bg-base-200', this.editor.isActive('bold'))
|
||||
this.italicButton.classList.toggle('bg-base-200', this.editor.isActive('italic'))
|
||||
this.underlineButton.classList.toggle('bg-base-200', this.editor.isActive('underline'))
|
||||
this.linkButton.classList.toggle('bg-base-200', this.editor.isActive('link'))
|
||||
}
|
||||
|
||||
handleLinkTooltip (editor) {
|
||||
const { from } = editor.state.selection
|
||||
const mark = editor.state.doc.resolve(from).marks().find(m => m.type.name === 'link')
|
||||
|
||||
if (!mark) {
|
||||
if (this.linkTooltip.isVisible()) this.linkTooltip.hide()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (this.linkTooltip.isVisible() && this.linkTooltip.currentMark === mark) return
|
||||
|
||||
let linkStart = from
|
||||
const start = editor.state.doc.resolve(from).start()
|
||||
|
||||
for (let i = from - 1; i >= start; i--) {
|
||||
if (editor.state.doc.resolve(i).marks().some(m => m.eq(mark))) {
|
||||
linkStart = i
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
this.linkTooltip.hide()
|
||||
this.linkTooltip.show(mark.attrs.htmlAttrs?.href, linkStart > start ? linkStart - 1 : linkStart)
|
||||
this.linkTooltip.currentMark = mark
|
||||
}
|
||||
|
||||
toggleLink () {
|
||||
if (this.editor.isActive('link')) {
|
||||
this.linkTooltip.hide()
|
||||
this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
this.updateToolbarState()
|
||||
} else {
|
||||
const { from } = this.editor.state.selection
|
||||
|
||||
this.linkTooltip.hide()
|
||||
this.linkTooltip.show(this.editor.getAttributes('link').htmlAttrs?.href, from, { focus: true })
|
||||
}
|
||||
}
|
||||
|
||||
insertVariable (e) {
|
||||
const variable = e.target.closest('[data-variable]')?.dataset.variable
|
||||
|
||||
if (variable) {
|
||||
const { from, to } = this.editor.state.selection
|
||||
|
||||
if (variable.includes('link') && from !== to) {
|
||||
this.editor.chain().focus().setLink({ href: `{${variable}}` }).run()
|
||||
} else {
|
||||
this.editor.chain().focus().insertContent(`{${variable}}`).run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.linkTooltip?.hide()
|
||||
|
||||
if (this.editor) {
|
||||
this.editor.destroy()
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -35,7 +35,7 @@ function loadTiptap () {
|
||||
}))
|
||||
}
|
||||
|
||||
class LinkTooltip {
|
||||
export class LinkTooltip {
|
||||
constructor (container, editor, templateEl) {
|
||||
this.container = container
|
||||
this.editor = editor
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
@submit.prevent="submitStep"
|
||||
/>
|
||||
<button
|
||||
v-if="!isFormVisible"
|
||||
v-if="!isFormVisible && currentField"
|
||||
id="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%"
|
||||
@@ -174,6 +174,7 @@
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="currentField"
|
||||
v-show="isFormVisible"
|
||||
id="form_container"
|
||||
class="shadow-md bg-base-100 absolute bottom-0 w-full border-base-200 border p-4 rounded form-container overflow-hidden"
|
||||
@@ -1172,7 +1173,11 @@ export default {
|
||||
}
|
||||
},
|
||||
isAnonymousChecboxes () {
|
||||
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name && !e.required) && this.currentStepFields.length > 4
|
||||
if (this.currentField) {
|
||||
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name && !e.required) && this.currentStepFields.length > 4
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
},
|
||||
isButtonDisabled () {
|
||||
if (this.recalculateButtonDisabledKey) {
|
||||
|
||||
@@ -3206,15 +3206,6 @@ export default {
|
||||
e.preventDefault()
|
||||
|
||||
alert(this.t('please_draw_fields_to_prepare_the_document'))
|
||||
} else {
|
||||
const submitterWithoutFields =
|
||||
this.template.submitters.find((submitter) => !this.template.fields.some((f) => f.submitter_uuid === submitter.uuid))
|
||||
|
||||
if (submitterWithoutFields) {
|
||||
e.preventDefault()
|
||||
|
||||
alert(this.t('please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed').replaceAll('{submitter_name}', submitterWithoutFields.name))
|
||||
}
|
||||
}
|
||||
},
|
||||
onSaveClick () {
|
||||
@@ -3231,32 +3222,25 @@ export default {
|
||||
if (!this.template.fields.length) {
|
||||
alert(this.t('please_draw_fields_to_prepare_the_document'))
|
||||
} else {
|
||||
const submitterWithoutFields =
|
||||
this.template.submitters.find((submitter) => !this.template.fields.some((f) => f.submitter_uuid === submitter.uuid))
|
||||
this.isSaving = true
|
||||
|
||||
if (submitterWithoutFields) {
|
||||
alert(this.t('please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed').replaceAll('{submitter_name}', submitterWithoutFields.name))
|
||||
} else {
|
||||
this.isSaving = true
|
||||
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
|
||||
|
||||
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
|
||||
dynamicDocumentRefs.map((ref) => ref.update())
|
||||
|
||||
dynamicDocumentRefs.map((ref) => ref.update())
|
||||
this.rebuildVariablesSchema({ disable: false })
|
||||
|
||||
this.rebuildVariablesSchema({ disable: false })
|
||||
const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody())
|
||||
|
||||
const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody())
|
||||
Promise.all([this.save({ force: true }), ...dynamicDocumentSaves]).then(() => {
|
||||
if (this.withRevisions) {
|
||||
this.captureRevision()
|
||||
}
|
||||
|
||||
Promise.all([this.save({ force: true }), ...dynamicDocumentSaves]).then(() => {
|
||||
if (this.withRevisions) {
|
||||
this.captureRevision()
|
||||
}
|
||||
|
||||
window.Turbo.visit(`/templates/${this.template.id}`)
|
||||
}).finally(() => {
|
||||
this.isSaving = false
|
||||
})
|
||||
}
|
||||
window.Turbo.visit(`/templates/${this.template.id}`)
|
||||
}).finally(() => {
|
||||
this.isSaving = false
|
||||
})
|
||||
}
|
||||
},
|
||||
scrollToArea (area) {
|
||||
|
||||
@@ -830,6 +830,7 @@ export default {
|
||||
} else if (format === 'percent') {
|
||||
return `${number}%`
|
||||
} else if (format === 'percent_space') {
|
||||
// eslint-disable-next-line no-irregular-whitespace
|
||||
return `${String(number).replace('.', ',')} %`
|
||||
} else {
|
||||
return number
|
||||
|
||||
@@ -12,6 +12,7 @@ class ProcessSubmissionExpiredJob
|
||||
return if submission.template&.archived_at?
|
||||
return if submission.submitters.where.not(declined_at: nil).exists?
|
||||
return if submission.completed_at?
|
||||
return if params['expire_at'] && submission.expire_at&.to_i != params['expire_at']
|
||||
|
||||
WebhookUrls.enqueue_events(submission, 'submission.expired')
|
||||
end
|
||||
|
||||
@@ -13,7 +13,12 @@ class ProcessSubmitterCompletionJob
|
||||
if params.key?('is_last')
|
||||
params['is_last']
|
||||
else
|
||||
!submission.submitters.exists?(completed_at: nil) &&
|
||||
viewer_uuids = submission.template_submitters.to_a.filter_map { |s| s['uuid'] if s['is_viewer'] }
|
||||
|
||||
incomplete = submission.submitters.where(completed_at: nil)
|
||||
incomplete = incomplete.where.not(uuid: viewer_uuids) if viewer_uuids.present?
|
||||
|
||||
!incomplete.exists? &&
|
||||
submitter.completed_at == submission.submitters.maximum(:completed_at)
|
||||
end
|
||||
|
||||
@@ -33,7 +38,9 @@ class ProcessSubmitterCompletionJob
|
||||
|
||||
if !submission.completed_at && submission.submitters_order_preserved? && params['send_invitation_email'] != false &&
|
||||
Submission.exists?(id: submission.id, completed_at: nil)
|
||||
enqueue_next_submitter_request_notification(submitter)
|
||||
next_submitters = enqueue_next_submitter_request_notification(submitter)
|
||||
|
||||
enqueue_next_submitter_viewer_notification(submission, next_submitters) unless is_last
|
||||
end
|
||||
|
||||
enqueue_completed_webhooks(submitter, is_last:)
|
||||
@@ -145,7 +152,7 @@ class ProcessSubmitterCompletionJob
|
||||
return if configs.value['enabled'] == false
|
||||
|
||||
to = submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false }
|
||||
.sort_by(&:completed_at).select(&:email?).map(&:friendly_name)
|
||||
.sort_by { |e| e.completed_at || Time.current }.select(&:email?).map(&:friendly_name)
|
||||
|
||||
return if to.blank?
|
||||
|
||||
@@ -165,9 +172,9 @@ class ProcessSubmitterCompletionJob
|
||||
bcc.to_s.scan(User::EMAIL_REGEXP)
|
||||
end
|
||||
|
||||
def enqueue_next_submitter_request_notification(submitter)
|
||||
def enqueue_next_submitter_request_notification(submitter) # rubocop:disable Metrics/PerceivedComplexity
|
||||
submission = submitter.submission
|
||||
submitters_index = submission.submitters.index_by(&:uuid)
|
||||
submitters_index = submission.submitters.reject(&:viewer?).index_by(&:uuid)
|
||||
|
||||
next_submitter_items =
|
||||
if submission.template_submitters.any? { |s| s['order'] }
|
||||
@@ -196,5 +203,43 @@ class ProcessSubmitterCompletionJob
|
||||
next_submitters = submitters_index.values_at(*Array.wrap(next_submitter_items).pluck('uuid')).compact
|
||||
|
||||
Submitters.send_signature_requests(next_submitters)
|
||||
|
||||
next_submitters
|
||||
end
|
||||
|
||||
def enqueue_next_submitter_viewer_notification(submission, next_submitters)
|
||||
viewers = submission.submitters.select(&:viewer?)
|
||||
|
||||
return [] if viewers.blank?
|
||||
|
||||
next_submitter_uuids = next_submitters.to_set(&:uuid)
|
||||
viewers_index = viewers.index_by(&:uuid)
|
||||
|
||||
next_viewers =
|
||||
if submission.template_submitters.any? { |s| s['order'] }
|
||||
next_orders = submission.template_submitters
|
||||
.select { |s| next_submitter_uuids.include?(s['uuid']) }
|
||||
.pluck('order')
|
||||
|
||||
submission.template_submitters.filter_map do |s|
|
||||
viewers_index[s['uuid']] if next_orders.include?(s['order'])
|
||||
end
|
||||
else
|
||||
preceding_submitter_uuid = nil
|
||||
|
||||
submission.template_submitters.filter_map do |template_submitter|
|
||||
viewer = viewers_index[template_submitter['uuid']]
|
||||
|
||||
if viewer
|
||||
viewer if next_submitter_uuids.include?(preceding_submitter_uuid)
|
||||
else
|
||||
preceding_submitter_uuid = template_submitter['uuid']
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Submitters.send_signature_requests(next_viewers)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,7 +19,12 @@ class SendSubmitterInvitationEmailJob
|
||||
return
|
||||
end
|
||||
|
||||
mail = SubmitterMailer.invitation_email(submitter)
|
||||
mail =
|
||||
if submitter.viewer?
|
||||
SubmitterMailer.invitation_view_email(submitter)
|
||||
else
|
||||
SubmitterMailer.invitation_email(submitter)
|
||||
end
|
||||
|
||||
Submitters::ValidateSending.call(submitter, mail)
|
||||
|
||||
|
||||
@@ -45,6 +45,45 @@ class SubmitterMailer < ApplicationMailer
|
||||
end
|
||||
end
|
||||
|
||||
def invitation_view_email(submitter)
|
||||
@current_account = submitter.submission.account
|
||||
@submitter = submitter
|
||||
|
||||
if submitter.preferences['email_message_uuid']
|
||||
@email_message = submitter.account.email_messages.find_by(uuid: submitter.preferences['email_message_uuid'])
|
||||
end
|
||||
|
||||
template_submitters_index = @email_message.blank? ? build_submitter_preferences_index(@submitter) : {}
|
||||
|
||||
@body = @email_message&.normalized_body.presence ||
|
||||
@submitter.template&.preferences&.dig('invitation_view_email_body').presence ||
|
||||
template_submitters_index.dig(@submitter.uuid, 'request_email_body').presence
|
||||
|
||||
@subject = @email_message&.subject.presence ||
|
||||
@submitter.template&.preferences&.dig('invitation_view_email_subject').presence ||
|
||||
template_submitters_index.dig(@submitter.uuid, 'request_email_subject').presence
|
||||
|
||||
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY)
|
||||
@body ||= fetch_config_email_body(@email_config, @submitter)
|
||||
|
||||
assign_message_metadata('submitter_view_invitation', @submitter)
|
||||
|
||||
reply_to = build_submitter_reply_to(@submitter, email_config: @email_config)
|
||||
|
||||
maybe_set_custom_domain(@submitter)
|
||||
|
||||
I18n.with_locale(@current_account.locale) do
|
||||
subject = build_invite_subject(@subject, @email_config, submitter)
|
||||
|
||||
mail(
|
||||
to: @submitter.friendly_name,
|
||||
from: from_address_for_submitter(submitter),
|
||||
subject:,
|
||||
reply_to:
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def completed_email(submitter, user, to: nil)
|
||||
@current_account = submitter.submission.account
|
||||
@submitter = submitter
|
||||
@@ -53,8 +92,6 @@ class SubmitterMailer < ApplicationMailer
|
||||
|
||||
template_preferences = @submission.template&.preferences || {}
|
||||
|
||||
Submissions::EnsureResultGenerated.call(submitter)
|
||||
|
||||
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
|
||||
|
||||
add_completed_email_attachments!(
|
||||
@@ -109,8 +146,6 @@ class SubmitterMailer < ApplicationMailer
|
||||
|
||||
template_preferences = @submitter.template&.preferences || {}
|
||||
|
||||
Submissions::EnsureResultGenerated.call(@submitter)
|
||||
|
||||
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
|
||||
|
||||
add_completed_email_attachments!(
|
||||
@@ -172,7 +207,7 @@ class SubmitterMailer < ApplicationMailer
|
||||
end
|
||||
|
||||
def add_completed_email_attachments!(submitter, with_audit_log: true, with_documents: true)
|
||||
documents = with_documents ? Submitters.select_attachments_for_download(submitter) : []
|
||||
documents = with_documents ? select_completed_documents(submitter) : []
|
||||
|
||||
filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id,
|
||||
key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value
|
||||
@@ -216,6 +251,8 @@ class SubmitterMailer < ApplicationMailer
|
||||
def build_invite_subject(subject, email_config, submitter)
|
||||
if email_config || subject
|
||||
ReplaceEmailVariables.call(subject || email_config.value['subject'], submitter:)
|
||||
elsif submitter.viewer?
|
||||
I18n.t(:you_are_invited_to_view_a_document)
|
||||
elsif submitter.with_signature_fields?
|
||||
I18n.t(:you_are_invited_to_sign_a_document)
|
||||
else
|
||||
@@ -227,6 +264,14 @@ class SubmitterMailer < ApplicationMailer
|
||||
submitter.template&.preferences&.dig('submitters').to_a.index_by { |e| e['uuid'] }
|
||||
end
|
||||
|
||||
def select_completed_documents(submitter)
|
||||
last_submitter = Submitter.where(submission_id: submitter.submission_id).completed.order(:completed_at).last
|
||||
|
||||
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
|
||||
Submitters.select_attachments_for_download(last_submitter)
|
||||
end
|
||||
|
||||
def add_attachments_with_size_limit(submitter, storage_attachments, current_size, filename_format = nil)
|
||||
total_size = current_size
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#
|
||||
class AccountConfig < ApplicationRecord
|
||||
SUBMITTER_INVITATION_EMAIL_KEY = 'submitter_invitation_email'
|
||||
SUBMITTER_VIEW_INVITATION_EMAIL_KEY = 'submitter_view_invitation_email'
|
||||
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY = 'submitter_invitation_reminder_email'
|
||||
SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email'
|
||||
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
|
||||
@@ -63,6 +64,7 @@ class AccountConfig < ApplicationRecord
|
||||
|
||||
EMAIL_VARIABLES = {
|
||||
SUBMITTER_INVITATION_EMAIL_KEY => %w[template.name submitter.link account.name].freeze,
|
||||
SUBMITTER_VIEW_INVITATION_EMAIL_KEY => %w[template.name submitter.link account.name].freeze,
|
||||
SUBMITTER_COMPLETED_EMAIL_KEY => %w[template.name submission.submitters submission.link].freeze,
|
||||
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => %w[template.name submitter.link account.name].freeze,
|
||||
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[template.name documents.link account.name].freeze
|
||||
@@ -75,6 +77,12 @@ class AccountConfig < ApplicationRecord
|
||||
'body' => I18n.t(:submitter_invitation_email_sign_body)
|
||||
}
|
||||
},
|
||||
SUBMITTER_VIEW_INVITATION_EMAIL_KEY => lambda {
|
||||
{
|
||||
'subject' => I18n.t(:you_are_invited_to_view_a_document),
|
||||
'body' => I18n.t(:submitter_invitation_email_view_body)
|
||||
}
|
||||
},
|
||||
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => lambda {
|
||||
{
|
||||
'subject' => I18n.t(:you_are_invited_to_sign_a_document),
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
# index_submissions_on_account_id_and_id_pending (account_id,id) WHERE ((completed_at IS NULL) AND (archived_at IS NULL))
|
||||
# index_submissions_on_account_id_and_template_id_and_id (account_id,template_id,id) WHERE (archived_at IS NULL)
|
||||
# index_submissions_on_account_id_and_template_id_and_id_archived (account_id,template_id,id) WHERE (archived_at IS NOT NULL)
|
||||
# index_submissions_on_created_at (created_at)
|
||||
# index_submissions_on_created_by_user_id (created_by_user_id)
|
||||
# index_submissions_on_slug (slug) UNIQUE
|
||||
# index_submissions_on_template_id (template_id)
|
||||
@@ -91,9 +92,8 @@ class Submission < ApplicationRecord
|
||||
|
||||
scope :active, -> { where(archived_at: nil) }
|
||||
scope :archived, -> { where.not(archived_at: nil) }
|
||||
scope :pending, lambda {
|
||||
where(expire_at: nil).or(where(expire_at: Time.current..)).where(completed_at: nil)
|
||||
}
|
||||
scope :non_expired, -> { where(expire_at: nil).or(where(expire_at: Time.current..)) }
|
||||
scope :pending, -> { non_expired.where(completed_at: nil) }
|
||||
scope :completed, -> { where.not(completed_at: nil) }
|
||||
scope :declined, lambda {
|
||||
where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]))
|
||||
|
||||
@@ -116,6 +116,12 @@ class Submitter < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def viewer?
|
||||
return false if submission.template_submitters.blank?
|
||||
|
||||
submission.template_submitters.any? { |s| s['uuid'] == uuid && s['is_viewer'] }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def anonymize_email_events
|
||||
|
||||
@@ -57,6 +57,11 @@
|
||||
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if Docuseal.multitenant? && @encrypted_config.persisted? %>
|
||||
<div class="flex justify-center pt-2">
|
||||
<%= button_to t('reset_default'), settings_email_path(@encrypted_config), method: :delete, class: 'link', data: { turbo_confirm: t('are_you_sure_') } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="w-0 md:w-52"></div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
|
||||
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2" />
|
||||
<path d="M9 15l2 2l4 -4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 437 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
|
||||
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2" />
|
||||
<path d="M12 17v.01" />
|
||||
<path d="M12 14a1.5 1.5 0 1 0 -1.14 -2.474" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 482 B |
@@ -0,0 +1,7 @@
|
||||
<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="M16 7h4" />
|
||||
<path d="M16 16v1l2 2l.5 -.5m1.5 -2.5v-11c0 -1.121 -.879 -2 -2 -2s-2 .879 -2 2v7" />
|
||||
<path d="M18 19h-13a2 2 0 1 1 0 -4h4a2 2 0 1 0 0 -4h-3" />
|
||||
<path d="M3 3l18 18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 480 B |
@@ -0,0 +1,53 @@
|
||||
<div class="flex items-center px-2 py-2 border-b" style="height: 42px;">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('bold') %> (Ctrl+B)">
|
||||
<button type="button" data-action="click:<%= editor_tag %>#bold" data-target="<%= editor_tag %>.boldButton" aria-label="<%= t('bold') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('bold', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('italic') %> (Ctrl+I)">
|
||||
<button type="button" data-action="click:<%= editor_tag %>#italic" data-target="<%= editor_tag %>.italicButton" aria-label="<%= t('italic') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('italic', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('underline') %> (Ctrl+U)">
|
||||
<button type="button" data-action="click:<%= editor_tag %>#underline" data-target="<%= editor_tag %>.underlineButton" aria-label="<%= t('underline') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('underline', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('link') %> (Ctrl+K)">
|
||||
<button type="button" data-action="click:<%= editor_tag %>#linkSelection" data-target="<%= editor_tag %>.linkButton" aria-label="<%= t('link') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('link', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-2 h-5 border-l border-base-content/20"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('undo') %> (Ctrl+Z)">
|
||||
<button type="button" data-action="click:<%= editor_tag %>#undo" data-target="<%= editor_tag %>.undoButton" aria-label="<%= t('undo') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('arrow_back_up', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('redo') %> (Ctrl+Shift+Z)">
|
||||
<button type="button" data-action="click:<%= editor_tag %>#redo" data-target="<%= editor_tag %>.redoButton" aria-label="<%= t('redo') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('arrow_forward_up', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% if local_assigns[:variables]&.any? %>
|
||||
<% variable_labels = { 'account.name' => t('variables.account_name'), 'submitter.link' => t('variables.submitter_link'), 'template.name' => t('variables.template_name'), 'submission.submitters' => t('variables.submission_submitters'), 'submission.link' => t('variables.submission_link'), 'documents.link' => t('variables.documents_link') } %>
|
||||
<div class="dropdown dropdown-end ml-auto">
|
||||
<label tabindex="0" class="flex items-center gap-1 text-sm px-2 py-1 rounded hover:bg-base-200 cursor-pointer">
|
||||
<%= t('add_variable') %>
|
||||
<%= svg_icon('chevron_down', class: 'w-3.5 h-3.5') %>
|
||||
</label>
|
||||
<div tabindex="0" class="dropdown-content right-0 top-full mt-1 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50">
|
||||
<% local_assigns[:variables]&.each do |variable| %>
|
||||
<button type="button" data-variable="<%= variable %>" data-action="click:<%= editor_tag %>#insertVariable" class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 text-left text-sm cursor-pointer whitespace-nowrap">
|
||||
<%= variable_labels.fetch(variable, "{#{variable}}") %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
<%= render 'personalization_settings/markdown_editor', name:, value:, variables: local_assigns[:variables] %>
|
||||
@@ -1,76 +1,18 @@
|
||||
<% if value.to_s.start_with?('<html') %>
|
||||
<autoresize-textarea>
|
||||
<%= text_area_tag name, value, required: true, class: 'base-input w-full py-2 !rounded-2xl', dir: 'auto', style: 'max-height: 400px' %>
|
||||
</autoresize-textarea>
|
||||
<% else %>
|
||||
<markdown-editor>
|
||||
<template data-target="markdown-editor.linkTooltipTemplate">
|
||||
<div class="hidden absolute flex bg-white border border-base-300 rounded-xl shadow p-1 gap-1 items-center z-50" contenteditable="false">
|
||||
<input type="text" placeholder="<%= t('enter_a_url_or_variable_name') %>" class="rounded-lg border border-base-300 px-2 py-1 text-sm outline-none" style="field-sizing: content; min-width: 205px; max-width: 320px;" autocomplete="off">
|
||||
<button type="button" data-role="link-save" class="flex items-center px-1 w-6 h-6 rounded hover:bg-success/10 cursor-pointer">
|
||||
<%= svg_icon('check', class: 'w-4 h-4 text-success') %>
|
||||
</button>
|
||||
<button type="button" data-role="link-remove" class="flex items-center px-1 w-6 h-6 rounded hover:bg-error/10 cursor-pointer">
|
||||
<%= svg_icon('x', class: 'w-4 h-4 text-error') %>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="border border-base-content/20 rounded-2xl bg-white">
|
||||
<div class="flex items-center px-2 py-2 border-b" style="height: 42px;">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('bold') %> (Ctrl+B)">
|
||||
<button type="button" data-action="click:markdown-editor#bold" data-target="markdown-editor.boldButton" aria-label="<%= t('bold') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('bold', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('italic') %> (Ctrl+I)">
|
||||
<button type="button" data-action="click:markdown-editor#italic" data-target="markdown-editor.italicButton" aria-label="<%= t('italic') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('italic', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('underline') %> (Ctrl+U)">
|
||||
<button type="button" data-action="click:markdown-editor#underline" data-target="markdown-editor.underlineButton" aria-label="<%= t('underline') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('underline', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('link') %> (Ctrl+K)">
|
||||
<button type="button" data-action="click:markdown-editor#linkSelection" data-target="markdown-editor.linkButton" aria-label="<%= t('link') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('link', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-2 h-5 border-l border-base-content/20"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('undo') %> (Ctrl+Z)">
|
||||
<button type="button" data-action="click:markdown-editor#undo" data-target="markdown-editor.undoButton" aria-label="<%= t('undo') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('arrow_back_up', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('redo') %> (Ctrl+Shift+Z)">
|
||||
<button type="button" data-action="click:markdown-editor#redo" data-target="markdown-editor.redoButton" aria-label="<%= t('redo') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('arrow_forward_up', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% if local_assigns[:variables]&.any? %>
|
||||
<% variable_labels = { 'account.name' => t('variables.account_name'), 'submitter.link' => t('variables.submitter_link'), 'template.name' => t('variables.template_name'), 'submission.submitters' => t('variables.submission_submitters'), 'submission.link' => t('variables.submission_link'), 'documents.link' => t('variables.documents_link') } %>
|
||||
<div class="dropdown dropdown-end ml-auto">
|
||||
<label tabindex="0" class="flex items-center gap-1 text-sm px-2 py-1 rounded hover:bg-base-200 cursor-pointer">
|
||||
<%= t('add_variable') %>
|
||||
<%= svg_icon('chevron_down', class: 'w-3.5 h-3.5') %>
|
||||
</label>
|
||||
<div tabindex="0" class="dropdown-content right-0 top-full mt-1 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50">
|
||||
<% local_assigns[:variables]&.each do |variable| %>
|
||||
<button type="button" data-variable="<%= variable %>" data-action="click:markdown-editor#insertVariable" class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 text-left text-sm cursor-pointer whitespace-nowrap">
|
||||
<%= variable_labels.fetch(variable, "{#{variable}}") %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div data-target="markdown-editor.editorElement"></div>
|
||||
<markdown-editor>
|
||||
<template data-target="markdown-editor.linkTooltipTemplate">
|
||||
<div class="hidden absolute flex bg-white border border-base-300 rounded-xl shadow p-1 gap-1 items-center z-50" contenteditable="false">
|
||||
<input type="text" placeholder="<%= t('enter_a_url_or_variable_name') %>" class="rounded-lg border border-base-300 px-2 py-1 text-sm outline-none" style="field-sizing: content; min-width: 205px; max-width: 320px;" autocomplete="off">
|
||||
<button type="button" data-role="link-save" class="flex items-center px-1 w-6 h-6 rounded hover:bg-success/10 cursor-pointer">
|
||||
<%= svg_icon('check', class: 'w-4 h-4 text-success') %>
|
||||
</button>
|
||||
<button type="button" data-role="link-remove" class="flex items-center px-1 w-6 h-6 rounded hover:bg-error/10 cursor-pointer">
|
||||
<%= svg_icon('x', class: 'w-4 h-4 text-error') %>
|
||||
</button>
|
||||
</div>
|
||||
<%= hidden_field_tag name, value, required: true, data: { target: 'markdown-editor.textarea' } %>
|
||||
</markdown-editor>
|
||||
<% end %>
|
||||
</template>
|
||||
<div class="border border-base-content/20 rounded-2xl bg-white">
|
||||
<%= render 'personalization_settings/editor_toolbar', editor_tag: 'markdown-editor', variables: local_assigns[:variables] %>
|
||||
<div data-target="markdown-editor.editorElement"></div>
|
||||
</div>
|
||||
<%= hidden_field_tag name, value, required: true, data: { target: 'markdown-editor.textarea' } %>
|
||||
</markdown-editor>
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
<li>
|
||||
<%= link_to t('account'), settings_account_path, class: 'text-base hover:bg-base-300' %>
|
||||
</li>
|
||||
<% if (!Docuseal.multitenant? || EncryptedConfig.exists?(key: EncryptedConfig::EMAIL_SMTP_KEY, account: current_account)) && can?(:read, EncryptedConfig.new(key: EncryptedConfig::EMAIL_SMTP_KEY, account: current_account)) && ENV['SMTP_ADDRESS'].blank? && true_user == current_user %>
|
||||
<li>
|
||||
<%= link_to t('email'), settings_email_index_path, class: 'text-base hover:bg-base-300' %>
|
||||
</li>
|
||||
<% end %>
|
||||
<% unless Docuseal.multitenant? %>
|
||||
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::EMAIL_SMTP_KEY, account: current_account)) && ENV['SMTP_ADDRESS'].blank? && true_user == current_user %>
|
||||
<li>
|
||||
<%= link_to t('email'), settings_email_index_path, class: 'text-base hover:bg-base-300' %>
|
||||
</li>
|
||||
<% end %>
|
||||
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::FILES_STORAGE_KEY, account: current_account)) && true_user == current_user && ENV['S3_ATTACHMENTS_BUCKET'].blank? && ENV['GCS_BUCKET'].blank? && ENV['AZURE_CONTAINER'].blank? %>
|
||||
<li>
|
||||
<%= link_to t('storage'), settings_storage_index_path, class: 'text-base hover:bg-base-300' %>
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
<%= local_assigns[:variables_form] %>
|
||||
<div>
|
||||
<%= render('submitters_order', f:, template:) if can_send_emails %>
|
||||
<%= render 'send_email', f:, template:, can_send_emails: %>
|
||||
<%= render 'send_email', f:, template:, can_send_emails:, viewer_submitter_uuids: local_assigns[:viewer_submitter_uuids] %>
|
||||
<% if has_phone_field %>
|
||||
<%= render 'send_sms', f: %>
|
||||
<% end %>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<%= local_assigns[:variables_form] %>
|
||||
<div>
|
||||
<%= render('submitters_order', f:, template:) if can_send_emails %>
|
||||
<%= render 'send_email', f:, template:, can_send_emails: %>
|
||||
<%= render 'send_email', f:, template:, can_send_emails:, viewer_submitter_uuids: local_assigns[:viewer_submitter_uuids] %>
|
||||
<%= render 'extra_fields', f: %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
|
||||
@@ -38,9 +38,21 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% config = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY) %>
|
||||
<% view_config = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY) %>
|
||||
<% config_body = (config.value['body_type'] == 'html' && config.value['html_body'].presence) || config.value['body'] %>
|
||||
<% view_template_subject = template&.preferences&.dig('invitation_view_email_subject').presence %>
|
||||
<% view_template_body = template&.preferences&.dig('invitation_view_email_body').presence %>
|
||||
<% default_subject = template&.preferences&.dig('request_email_subject').presence || config.value['subject'] %>
|
||||
<% default_body = template&.preferences&.dig('request_email_body').presence || config_body %>
|
||||
<% is_edit_viewer = local_assigns[:submitter] && local_assigns[:viewer_submitter_uuids].include?(local_assigns[:submitter].uuid) %>
|
||||
<div id="<%= message_field_id %>" class="card card-compact bg-base-300/40 hidden">
|
||||
<div class="card-body">
|
||||
<%= tag.input id: toggle_uuid = SecureRandom.uuid, value: '1', name: 'request_email_per_submitter', class: 'peer', type: 'checkbox', hidden: true, checked: local_assigns[:message_per_submitter] != false && template&.preferences&.dig('submitters').to_a.size > 1 %>
|
||||
<%= tag.input id: toggle_uuid = SecureRandom.uuid, value: '1', name: 'request_email_per_submitter', class: 'peer', type: 'checkbox', hidden: true, checked: local_assigns[:message_per_submitter] != false && template_submitters.size < 5 && template&.preferences&.dig('submitters').to_a.size > 1 %>
|
||||
<% if local_assigns[:viewer_submitter_uuids].present? && local_assigns[:submitter].blank? %>
|
||||
<% (template_submitters.pluck('uuid') - local_assigns[:viewer_submitter_uuids].to_a).each do |signer_uuid| %>
|
||||
<%= hidden_field_tag 'email_message_submitter_uuids[]', signer_uuid %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<div class="peer-checked:hidden form-control space-y-2">
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
@@ -51,13 +63,13 @@
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= f.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_subject').presence || template&.preferences&.dig('request_email_subject').presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
|
||||
<%= f.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || (is_edit_viewer ? view_template_subject : nil) || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_subject').presence || (is_edit_viewer ? view_config.value['subject'] : default_subject), required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<%= f.label :message, t('body'), class: 'label' %>
|
||||
<% body_variables = AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY] %>
|
||||
<%= render 'personalization_settings/markdown_editor', name: f.field_name(:body), value: local_assigns[:submitter_email_message]&.normalized_body.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], variables: body_variables %>
|
||||
<% unless local_assigns.fetch(:disable_save_as_default_template_option, false) %>
|
||||
<%= render 'personalization_settings/email_body_editor', name: f.field_name(:body), value: local_assigns[:submitter_email_message]&.normalized_body.presence || (is_edit_viewer ? view_template_body : nil) || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_body').presence || (is_edit_viewer ? view_config.value['body'] : default_body), variables: body_variables %>
|
||||
<% if !local_assigns.fetch(:disable_save_as_default_template_option, false) && config.value['body_type'] != 'html' %>
|
||||
<label for="<%= uuid = SecureRandom.uuid %>" class="flex items-center cursor-pointer">
|
||||
<%= check_box_tag :save_message, id: uuid, class: 'base-checkbox', checked: false %>
|
||||
<span class="label"><%= t('save_as_default_template_message') %></span>
|
||||
@@ -81,17 +93,18 @@
|
||||
</ul>
|
||||
</toggle-visible>
|
||||
<% template_submitters.each_with_index do |submitter, index| %>
|
||||
<% is_viewer = local_assigns[:viewer_submitter_uuids].include?(submitter['uuid']) %>
|
||||
<%= fields_for :submitter_preferences, nil, index: submitter['uuid'] do |ff| %>
|
||||
<div id="request_email_<%= uuid %>_<%= submitter['uuid'] %>" class="<%= 'hidden' if index != 0 %>">
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= ff.label :subject, t('subject'), class: 'label' %>
|
||||
</div>
|
||||
<%= ff.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_subject').presence || template&.preferences&.dig('request_email_subject').presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
|
||||
<%= ff.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || (is_viewer ? view_template_subject : nil) || submitter_preferences_index.dig(submitter['uuid'], 'request_email_subject').presence || (is_viewer ? view_config.value['subject'] : default_subject), required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<%= ff.label :message, t('body'), class: 'label' %>
|
||||
<%= render 'personalization_settings/markdown_editor', name: ff.field_name(:body), value: local_assigns[:submitter_email_message]&.normalized_body.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], variables: body_variables %>
|
||||
<%= render 'personalization_settings/email_body_editor', name: ff.field_name(:body), value: local_assigns[:submitter_email_message]&.normalized_body.presence || (is_viewer ? view_template_body : nil) || submitter_preferences_index.dig(submitter['uuid'], 'request_email_body').presence || (is_viewer ? view_config.value['body'] : default_body), variables: body_variables %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<% require_phone_2fa = @template.preferences['require_phone_2fa'] == true %>
|
||||
<% require_email_2fa = @template.preferences['require_email_2fa'] == true %>
|
||||
<% prefillable_fields = @template.fields.select { |f| f['prefillable'] } %>
|
||||
<% viewer_submitter_uuids = Set.new(@template.submitters.pluck('uuid')) - Set.new(@template.fields.to_a.pluck('submitter_uuid')) %>
|
||||
<% default_tab = cookies.permanent[:add_recipients_tab].presence || 'email' %>
|
||||
<% recipient_form_fields = Accounts.load_recipient_form_fields(current_account) if prefillable_fields.blank? %>
|
||||
<% can_send_emails = Accounts.can_send_emails?(current_account) %>
|
||||
@@ -26,18 +27,18 @@
|
||||
<div class="px-5 mb-5 mt-4">
|
||||
<% unless only_detailed %>
|
||||
<div id="email" class="<%= 'hidden' if default_tab != 'email' %>">
|
||||
<%= render 'email_form', template: @template, variables_form:, can_send_emails: %>
|
||||
<%= render 'email_form', template: @template, variables_form:, can_send_emails:, viewer_submitter_uuids: %>
|
||||
</div>
|
||||
<div id="phone" class="<%= 'hidden' if default_tab != 'phone' %>">
|
||||
<%= render 'phone_form', template: @template, variables_form: %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div id="detailed" class="<%= 'hidden' if !only_detailed && default_tab != 'detailed' %>">
|
||||
<%= render 'detailed_form', template: @template, require_phone_2fa:, require_email_2fa:, prefillable_fields:, recipient_form_fields:, variables_form:, can_send_emails: %>
|
||||
<%= render 'detailed_form', template: @template, require_phone_2fa:, require_email_2fa:, prefillable_fields:, recipient_form_fields:, variables_form:, can_send_emails:, viewer_submitter_uuids: %>
|
||||
</div>
|
||||
<% if with_list %>
|
||||
<div id="list" class="hidden">
|
||||
<%= render 'list_form', template: @template, can_send_emails: %>
|
||||
<%= render 'list_form', template: @template, can_send_emails:, viewer_submitter_uuids: %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= render 'submissions/error' %>
|
||||
|
||||
@@ -178,7 +178,7 @@
|
||||
<%= (@submission.template_submitters || @submission.template.submitters).find { |e| e['uuid'] == submitter&.uuid }&.dig('name') || "#{(index + 1).ordinalize} Submitter" %>
|
||||
</span>
|
||||
</div>
|
||||
<% if signed_in? && can?(:update, @submission) && submitter && !submitter.completed_at? && !submitter.declined_at? && !@submission.archived_at? && !@submission.template&.archived_at? && !@submission.expired? && !submitter.start_form_submission_events.any? %>
|
||||
<% if signed_in? && can?(:update, @submission) && submitter && !submitter.completed_at? && !@submission.completed_at? && !submitter.declined_at? && !@submission.archived_at? && !@submission.template&.archived_at? && !@submission.expired? && !submitter.start_form_submission_events.any? %>
|
||||
<span class="tooltip tooltip-left" data-tip="<%= t('edit') %>">
|
||||
<%= link_to edit_submitter_path(submitter), class: 'shrink-0 inline md:hidden md:group-hover:inline', data: { turbo_frame: 'modal' } do %>
|
||||
<%= svg_icon('pencil', class: 'w-5 h-5') %>
|
||||
@@ -211,7 +211,11 @@
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="flex items-center space-x-1 mt-1">
|
||||
<% if @submission.expire_at? && submitter && !submitter.completed_at? %>
|
||||
<% if submitter&.viewer? %>
|
||||
<%= svg_icon(submitter.opened_at? ? 'file_check' : 'file_unknown', class: 'w-5 h-5') %>
|
||||
<% elsif submitter && !submitter.completed_at? && @submission.completed_at? %>
|
||||
<%= svg_icon('writing_off', class: 'w-5 h-5') %>
|
||||
<% elsif @submission.expire_at? && submitter && !submitter.completed_at? %>
|
||||
<%= svg_icon('clock_exclamation', class: 'w-5 h-5') %>
|
||||
<% else %>
|
||||
<%= svg_icon('writing', class: 'w-5 h-5') %>
|
||||
@@ -219,9 +223,19 @@
|
||||
<span>
|
||||
<% if submitter&.declined_at? %>
|
||||
<%= t('declined_on_time', time: l(submitter.declined_at.in_time_zone(@submission.account.timezone), format: :short, locale: @submission.account.locale)) %>
|
||||
<% elsif submitter&.viewer? %>
|
||||
<% if submitter.opened_at? %>
|
||||
<%= t('viewed_on_time', time: l(submitter.opened_at.in_time_zone(@submission.account.timezone), format: :short, locale: @submission.account.locale)) %>
|
||||
<% elsif @submission.completed_at? %>
|
||||
<%= t('not_viewed') %>
|
||||
<% else %>
|
||||
<%= t('not_viewed_yet') %>
|
||||
<% end %>
|
||||
<% elsif submitter %>
|
||||
<% if submitter.completed_at? %>
|
||||
<%= l(submitter.completed_at.in_time_zone(@submission.account.timezone), format: :long, locale: @submission.account.locale) %>
|
||||
<% elsif @submission.completed_at? %>
|
||||
<%= t('not_completed') %>
|
||||
<% elsif @submission.expire_at? %>
|
||||
<% if @submission.expired? %>
|
||||
<%= t(:expired) %>
|
||||
@@ -244,15 +258,15 @@
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if signed_in? && submitter && submitter.email && !submitter.completed_at && !@submission.archived_at? && !@submission.template&.archived_at? && can?(:update, @submission) && (Docuseal.multitenant? || Accounts.can_send_emails?(current_account)) && !@submission.expired? && !submitter.declined_at? %>
|
||||
<% if signed_in? && submitter && submitter.email && !submitter.completed_at && !@submission.completed_at? && !@submission.archived_at? && !@submission.template&.archived_at? && can?(:update, @submission) && (Docuseal.multitenant? || Accounts.can_send_emails?(current_account)) && !@submission.expired? && !submitter.declined_at? %>
|
||||
<div class="mt-2 mb-1">
|
||||
<%= button_to button_title(title: submitter.sent_at? ? t('re_send_email') : t('send_email'), disabled_with: t('sending')), submitter_send_email_index_path(submitter), class: 'btn btn-sm btn-primary w-full' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if signed_in? && submitter && submitter.phone && !submitter.completed_at && !@submission.archived_at? && !@submission.template&.archived_at? && can?(:update, @submission) && !@submission.expired? && !submitter.declined_at? %>
|
||||
<% if signed_in? && submitter && submitter.phone && !submitter.completed_at && !@submission.completed_at? && !@submission.archived_at? && !@submission.template&.archived_at? && can?(:update, @submission) && !@submission.expired? && !submitter.declined_at? %>
|
||||
<%= render 'submissions/send_sms_button', submitter: %>
|
||||
<% end %>
|
||||
<% if signed_in? && submitter && !submitter.completed_at? && !@submission.archived_at? && !@submission.template&.archived_at? && can?(:create, @submission) && !@submission.expired? && !submitter.declined_at? %>
|
||||
<% if signed_in? && submitter && !submitter.viewer? && !submitter.completed_at? && !@submission.completed_at? && !@submission.archived_at? && !@submission.template&.archived_at? && can?(:create, @submission) && !@submission.expired? && !submitter.declined_at? %>
|
||||
<div class="mt-2 mb-1">
|
||||
<a class="btn btn-sm btn-primary w-full" target="_blank" href="<%= submit_form_path(slug: submitter.slug) %>">
|
||||
<%= t('sign_in_person') %>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<% completed_at = @submitter.completed_at || @submitter.submission.completed_at %>
|
||||
<main class="max-w-md mx-auto px-2 mt-12 mb-4">
|
||||
<div class="space-y-6 mx-auto">
|
||||
<div class="space-y-6">
|
||||
@@ -12,7 +13,7 @@
|
||||
<div>
|
||||
<p dir="auto" class="text-lg font-bold mb-1"><%= @submitter.submission.name || @submitter.submission.template&.name %></p>
|
||||
<p dir="auto" class="text-sm">
|
||||
<%= t(@submitter.with_signature_fields? ? 'signed_on_time' : 'completed_on_time', time: l(@submitter.completed_at.to_date, format: :long)) %>
|
||||
<%= t(@submitter.with_signature_fields? ? 'signed_on_time' : 'completed_on_time', time: l(completed_at.to_date, format: :long)) %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,7 +30,7 @@
|
||||
<div class="py-2"></div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if @submitter.completed_at > 30.minutes.ago || (current_user && current_user.account.submitters.exists?(id: @submitter.id)) %>
|
||||
<% if completed_at > 30.minutes.ago || (current_user && current_user.account.submitters.exists?(id: @submitter.id)) %>
|
||||
<download-button role="button" tabindex="0" aria-label="<%= t('download_documents') %>" data-src="<%= submit_form_documents_path(@submitter.slug) %>" class="base-button w-full">
|
||||
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
|
||||
<%= svg_icon('download', class: 'w-6 h-6') %>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<%= @submitter.submission.name || @submitter.submission.template&.name %>
|
||||
</h1>
|
||||
<div class="flex items-center gap-2 group" style="margin-left: 20px; flex-shrink: 0">
|
||||
<% if @form_configs[:with_decline] %>
|
||||
<% if @form_configs[:with_decline] && !@submitter.viewer? %>
|
||||
<modal-button data-target="<%= decline_modal_id = SecureRandom.uuid %>" class="hidden group-has-[.complete-button]:flex">
|
||||
<button type="button" class="btn btn-sm md:!px-5 px-2" aria-label="<%= t(:decline) %>">
|
||||
<span class="hidden md:inline"><%= t(:decline) %></span>
|
||||
@@ -34,11 +34,11 @@
|
||||
</modal-button>
|
||||
<% end %>
|
||||
<span id="complete_button_container" class="peer contents"></span>
|
||||
<% if @form_configs[:with_delegate] %>
|
||||
<% if @form_configs[:with_delegate] && !@submitter.viewer? %>
|
||||
<modal-button data-target="<%= delegate_modal_id = SecureRandom.uuid %>" class="hidden peer-empty:flex">
|
||||
<button id="delegate_button" type="button" class="btn btn-sm !px-5"><%= t(:delegate) %></button>
|
||||
</modal-button>
|
||||
<% if @form_configs[:with_decline] %>
|
||||
<% if @form_configs[:with_decline] && !@submitter.viewer? %>
|
||||
<modal-button data-target="<%= decline_modal_id %>" class="hidden peer-empty:flex">
|
||||
<button id="decline_button" type="button" class="btn btn-sm px-2" aria-label="<%= t(:decline) %>">
|
||||
<%= svg_icon('x', class: 'w-5 h-5') %>
|
||||
@@ -56,7 +56,7 @@
|
||||
</download-button>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<% if @form_configs[:with_decline] %>
|
||||
<% if @form_configs[:with_decline] && !@submitter.viewer? %>
|
||||
<modal-button data-target="<%= decline_modal_id %>" class="hidden peer-empty:flex">
|
||||
<button id="decline_button" type="button" class="btn btn-sm !px-5"><%= t(:decline) %></button>
|
||||
</modal-button>
|
||||
@@ -77,7 +77,7 @@
|
||||
</div>
|
||||
</header>
|
||||
<scroll-buttons inert class="fixed right-5 top-2 hidden md:flex gap-1 z-50 ease-in-out opacity-0 -translate-y-10 group">
|
||||
<% if @form_configs[:with_decline] %>
|
||||
<% if @form_configs[:with_decline] && !@submitter.viewer? %>
|
||||
<modal-button data-target="<%= decline_modal_id %>" class="hidden group-has-[.complete-button]:flex">
|
||||
<button type="button" class="btn btn-sm px-2" aria-label="<%= t(:decline) %>">
|
||||
<%= svg_icon('x', class: 'w-5 h-5') %>
|
||||
@@ -85,7 +85,7 @@
|
||||
</modal-button>
|
||||
<% end %>
|
||||
<span id="complete_button_container_scroll" class="peer contents"></span>
|
||||
<% if @form_configs[:with_delegate] %>
|
||||
<% if @form_configs[:with_delegate] && !@submitter.viewer? %>
|
||||
<modal-button data-target="<%= delegate_modal_id %>" class="hidden peer-empty:flex">
|
||||
<button id="delegate_button_mobile" type="button" class="btn btn-sm px-0" aria-label="<%= t(:delegate) %>">
|
||||
<span class="min-[1366px]:inline hidden px-3">
|
||||
@@ -96,7 +96,7 @@
|
||||
</span>
|
||||
</button>
|
||||
</modal-button>
|
||||
<% if @form_configs[:with_decline] %>
|
||||
<% if @form_configs[:with_decline] && !@submitter.viewer? %>
|
||||
<modal-button data-target="<%= decline_modal_id %>" class="hidden peer-empty:flex">
|
||||
<button id="decline_button_mobile" type="button" class="btn btn-sm px-2" aria-label="<%= t(:decline) %>">
|
||||
<%= svg_icon('x', class: 'w-5 h-5') %>
|
||||
@@ -104,7 +104,7 @@
|
||||
</modal-button>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<% if @form_configs[:with_decline] %>
|
||||
<% if @form_configs[:with_decline] && !@submitter.viewer? %>
|
||||
<modal-button data-target="<%= decline_modal_id %>" class="hidden peer-empty:flex">
|
||||
<button id="decline_button_mobile" type="button" class="btn btn-sm px-0" aria-label="<%= t(:decline) %>">
|
||||
<span class="min-[1366px]:inline hidden px-3">
|
||||
@@ -177,12 +177,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% if @form_configs[:with_decline] %>
|
||||
<% if @form_configs[:with_decline] && !@submitter.viewer? %>
|
||||
<%= render 'shared/html_modal', title: t(:decline), uuid: decline_modal_id do %>
|
||||
<%= render 'submit_form/decline_form', submitter: @submitter %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if @form_configs[:with_delegate] %>
|
||||
<% if @form_configs[:with_delegate] && !@submitter.viewer? %>
|
||||
<%= render 'shared/html_modal', title: t(:delegate), uuid: delegate_modal_id do %>
|
||||
<%= render 'submit_form/delegate_form', submitter: @submitter %>
|
||||
<% end %>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<% if @body.present? %>
|
||||
<%= render 'custom_content', content: @body, submitter: @submitter %>
|
||||
<% if !@body.match?(ReplaceEmailVariables::SUBMITTER_LINK) && !@body.match?(ReplaceEmailVariables::SUBMITTER_ID) && !@body.match?(ReplaceEmailVariables::SUBMISSION_LINK) && !@body.match?(ReplaceEmailVariables::TEMPLATE_ID) && !@submitter.submission.source.in?(%w[api embed]) %>
|
||||
<p><%= link_to nil, submit_form_url(slug: @submitter.slug, t: SubmissionEvents.build_tracking_param(@submitter, 'click_email'), host: @custom_domain || ENV.fetch('EMAIL_HOST', Docuseal.default_url_options[:host])) %></p>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p><%= t('hi_there') %>,</p>
|
||||
<p><%= I18n.t(:you_have_been_invited_to_view_the_name, name: @submitter.submission.name || @submitter.submission.template&.name) %></p>
|
||||
<p><%= link_to I18n.t(:view_document), submit_form_url(slug: @submitter.slug, t: SubmissionEvents.build_tracking_param(@submitter, 'click_email'), host: @custom_domain || ENV.fetch('EMAIL_HOST', Docuseal.default_url_options[:host])) %></p>
|
||||
<p><%= t('please_contact_us_by_replying_to_this_email_if_you_have_any_questions') %></p>
|
||||
<p>
|
||||
<%= t('thanks') %>,<br><%= @current_account.name %>
|
||||
</p>
|
||||
<% end %>
|
||||
@@ -17,7 +17,8 @@
|
||||
</submitter-item>
|
||||
</div>
|
||||
<div>
|
||||
<%= render 'submissions/send_email', f:, template: @submitter.template, submitter: @submitter, resend_email: @submitter.sent_at?, submitter_email_message: @submitter_email_message, disable_save_as_default_template_option: true, message_per_submitter: false, can_send_emails: Accounts.can_send_emails?(current_account) %>
|
||||
<% viewer_submitter_uuids = Set.new((@submitter.submission.template_submitters || @submitter.template.submitters).pluck('uuid')) - Set.new((@submitter.submission.template_fields || @submitter.template.fields).to_a.pluck('submitter_uuid')) %>
|
||||
<%= render 'submissions/send_email', f:, template: @submitter.template, submitter: @submitter, resend_email: @submitter.sent_at?, submitter_email_message: @submitter_email_message, disable_save_as_default_template_option: true, message_per_submitter: false, can_send_emails: Accounts.can_send_emails?(current_account), viewer_submitter_uuids: %>
|
||||
<%= render 'submissions/send_sms', f:, resend_sms: @submitter.sent_at? %>
|
||||
</div>
|
||||
<div class="form-control mt-4">
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<a href="<%= submission_path(submission) %>" class="text-lg break-all peer">
|
||||
<%= submitter.name || submitter.email || submitter.phone %>
|
||||
</a>
|
||||
<% if !submitter.completed_at? && can?(:update, submission) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? && !submitter.declined_at? %>
|
||||
<% if !submitter.completed_at? && !submission.completed_at? && can?(:update, submission) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? && !submitter.declined_at? %>
|
||||
<span class="pl-0.5 tooltip tooltip-top md:opacity-0 md:hover:opacity-100 md:peer-hover:opacity-100" data-tip="<%= t('edit') %>">
|
||||
<%= link_to edit_submitter_path(submitter), class: 'shrink-0', data: { turbo_frame: 'modal' } do %>
|
||||
<%= svg_icon('pencil', class: 'w-5 h-5') %>
|
||||
@@ -144,7 +144,7 @@
|
||||
<a href="<%= submission_path(submission) %>" class="text-lg break-all peer">
|
||||
<%= submitter.name || submitter.email || submitter.phone %>
|
||||
</a>
|
||||
<% if !submitter.completed_at? && can?(:update, submission) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? && !submitter.declined_at? %>
|
||||
<% if !submitter.completed_at? && !submission.completed_at? && can?(:update, submission) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? && !submitter.declined_at? %>
|
||||
<span class="pl-0.5 tooltip tooltip-top md:opacity-0 md:hover:opacity-100 md:peer-hover:opacity-100" data-tip="<%= t('edit') %>">
|
||||
<%= link_to edit_submitter_path(submitter), class: 'shrink-0', data: { turbo_frame: 'modal' } do %>
|
||||
<%= svg_icon('pencil', class: 'w-5 h-5') %>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<% close_on_submit = local_assigns.fetch(:close_on_submit, true) %>
|
||||
<% is_order_set = template.submitters.any? { |s| s['order'] } %>
|
||||
<% field_submitter_uuids = Set.new(template.fields.pluck('submitter_uuid')) %>
|
||||
<%= form_for template, url: template_recipients_path(template), method: :post, html: { autocomplete: 'off', class: 'mt-1', id: :submitters_form }, data: { close_on_submit: } do |f| %>
|
||||
<% unless close_on_submit %>
|
||||
<toggle-on-submit data-element-id="form_saved_alert"></toggle-on-submit>
|
||||
@@ -10,6 +11,7 @@
|
||||
<%= f.fields_for :submitters, item = Struct.new(:name, :uuid, :is_requester, :email, :invite_by_uuid, :invite_via_field_uuid, :optional_invite_by_uuid, :linked_to_uuid, :order, :option).new(*submitter.values_at('name', 'uuid', 'is_requester', 'email', 'invite_by_uuid', 'invite_via_field_uuid', 'optional_invite_by_uuid', 'linked_to_uuid', 'order')), index: do |ff| %>
|
||||
<% item.option = item.is_requester.present? ? 'is_requester' : (item.email.present? ? 'email' : (item.linked_to_uuid.present? ? "linked_to_#{item.linked_to_uuid}" : (item.invite_by_uuid.present? ? "invite_by_#{item.invite_by_uuid}" : (item.optional_invite_by_uuid.present? ? "optional_invite_by_#{item.optional_invite_by_uuid}" : (item.invite_via_field_uuid.present? ? 'invite_via_field' : ''))))) %>
|
||||
<%= ff.hidden_field :uuid %>
|
||||
<% is_viewer_row = field_submitter_uuids.exclude?(submitter['uuid']) %>
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= ff.text_field :name, class: 'w-full outline-none border-transparent focus:border-transparent focus:ring-0 bg-base-100 px-1 peer mb-2', autocomplete: 'off', placeholder: "#{index + 1}#{(index + 1).ordinal} Party", required: true %>
|
||||
@@ -21,6 +23,8 @@
|
||||
<mount-on-click data-template-id="order_fields" class="link whitespace-nowrap text-sm mt-1 mr-1 block">
|
||||
<%= t('edit_order') %>
|
||||
</mount-on-click>
|
||||
<% elsif is_viewer_row %>
|
||||
<span class="inline-flex items-center justify-center h-6 px-2 rounded-full bg-base-200 text-base-content text-xs font-medium normal-case whitespace-nowrap"><%= t('view_only') %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -141,11 +145,13 @@
|
||||
</div>
|
||||
<% if template.submitters.size > 2 && !is_order_set %>
|
||||
<template id="order_fields">
|
||||
<% last_viewer_order = -1 %>
|
||||
<% template.submitters.each_with_index do |submitter, index| %>
|
||||
<% default_order = field_submitter_uuids.include?(submitter['uuid']) ? (last_viewer_order += 1) : [last_viewer_order, 0].max %>
|
||||
<turbo-stream action="replace" target="order_<%= submitter['uuid'] %>">
|
||||
<template>
|
||||
<div id="order_<%= submitter['uuid'] %>">
|
||||
<%= select_tag "template[submitters][#{index}][order]", options_for_select(template.submitters.map.with_index { |_, i| [(i + 1).ordinalize, i] }, submitter['order'].presence || index), class: 'select select-xs text-sm input-bordered bg-white pl-3.5' %>
|
||||
<%= select_tag "template[submitters][#{index}][order]", options_for_select(template.submitters.map.with_index { |_, i| [(i + 1).ordinalize, i] }, default_order), class: 'select select-xs text-sm input-bordered bg-white pl-3.5' %>
|
||||
</div>
|
||||
</template>
|
||||
</turbo-stream>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<%= ff.label :completed_notification_email_body, t('email_body'), class: 'label' %>
|
||||
<%= render 'personalization_settings/markdown_editor', name: ff.field_name(:completed_notification_email_body), value: ff.object.completed_notification_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY] %>
|
||||
<%= render 'personalization_settings/email_body_editor', name: ff.field_name(:completed_notification_email_body), value: ff.object.completed_notification_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY], html_textarea: true %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<%= ff.label :documents_copy_email_body, t('email_body'), class: 'label' %>
|
||||
<%= render 'personalization_settings/markdown_editor', name: ff.field_name(:documents_copy_email_body), value: ff.object.documents_copy_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY] %>
|
||||
<%= render 'personalization_settings/email_body_editor', name: ff.field_name(:documents_copy_email_body), value: ff.object.documents_copy_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY], html_textarea: true %>
|
||||
</div>
|
||||
<% if can?(:manage, :reply_to) %>
|
||||
<div class="form-control">
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<div id="<%= AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY %>_form">
|
||||
<% template_email_preferences_values = @template.preferences.values_at('request_email_subject', 'request_email_body').compact_blank %>
|
||||
<% default_template_email_preferences_values = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY).value.values_at('subject', 'body') %>
|
||||
<% viewer_default_email_preferences_values = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY).value.values_at('subject', 'body') %>
|
||||
<% viewer_template_email_preferences_values = @template.preferences.values_at('invitation_view_email_subject', 'invitation_view_email_body').compact_blank.presence %>
|
||||
<% field_submitter_uuids = Set.new(@template.fields.pluck('submitter_uuid')) %>
|
||||
<% is_custom_template_email = template_email_preferences_values.present? %>
|
||||
<% multiple_submitters = @template.submitters.size > 1 && @template.submitters.size < 5 %>
|
||||
<% if is_custom_template_email || @template.preferences['submitters'].to_a.any? %>
|
||||
@@ -28,7 +31,7 @@
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<%= ff.label :request_email_body, t('email_body'), class: 'label' %>
|
||||
<%= render 'personalization_settings/markdown_editor', name: ff.field_name(:request_email_body), value: ff.object.request_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY] %>
|
||||
<%= render 'personalization_settings/email_body_editor', name: ff.field_name(:request_email_body), value: ff.object.request_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY], html_textarea: true %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -49,7 +52,7 @@
|
||||
<div id="request_email_<%= submitter['uuid'] %>" class="<%= 'hidden' if index != 0 %>">
|
||||
<% submitter_preferences = f.object.preferences['submitters'].to_a.find { |e| e['uuid'] == submitter['uuid'] } || {} %>
|
||||
<% submitter_email_preferences_values = submitter_preferences.values_at('request_email_subject', 'request_email_body').compact_blank.presence %>
|
||||
<% submitter_email_values = submitter_email_preferences_values || template_email_preferences_values.presence || default_template_email_preferences_values %>
|
||||
<% submitter_email_values = field_submitter_uuids.include?(submitter['uuid']) ? (submitter_email_preferences_values || template_email_preferences_values.presence || default_template_email_preferences_values) : (viewer_template_email_preferences_values || submitter_email_preferences_values || viewer_default_email_preferences_values) %>
|
||||
<%= hidden_field_tag 'template[preferences][submitters][][uuid]', submitter['uuid'] %>
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
@@ -64,7 +67,7 @@
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><%= t('email_body') %></label>
|
||||
<%= render 'personalization_settings/markdown_editor', name: 'template[preferences][submitters][][request_email_body]', value: submitter_email_values.last, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY] %>
|
||||
<%= render 'personalization_settings/email_body_editor', name: 'template[preferences][submitters][][request_email_body]', value: submitter_email_values.last, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY], html_textarea: true %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<div id="<%= AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY %>_form">
|
||||
<% configs = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY).value %>
|
||||
<% template_email_preferences_values = @template.preferences.values_at('invitation_view_email_subject', 'invitation_view_email_body').compact_blank.presence %>
|
||||
<% is_custom_template_email = template_email_preferences_values.present? %>
|
||||
<% if is_custom_template_email %>
|
||||
<%= button_to nil, template_preferences_path(@template), id: 'submitter_view_invitation_email_reset_link', method: :delete, class: 'hidden', params: { config_key: AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY }, data: { turbo_confirm: t('are_you_sure_') }, form: { data: { close_on_submit: false } } %>
|
||||
<% end %>
|
||||
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1', id: 'submitter_view_invitation_email_template_form' }, data: { close_on_submit: false } do |f| %>
|
||||
<toggle-on-submit data-element-id="email_saved_alert_view"></toggle-on-submit>
|
||||
<%= f.fields_for :preferences, Struct.new(:invitation_view_email_subject, :invitation_view_email_body).new(@template.preferences['invitation_view_email_subject'].presence || configs['subject'], @template.preferences['invitation_view_email_body'].presence || configs['body']) do |ff| %>
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= ff.label :invitation_view_email_subject, t('email_subject'), class: 'label' %>
|
||||
<% if is_custom_template_email %>
|
||||
<label for="submitter_view_invitation_email_reset_link" class="label underline">
|
||||
<%= t('reset_default') %>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= ff.text_field :invitation_view_email_subject, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<%= ff.label :invitation_view_email_body, t('email_body'), class: 'label' %>
|
||||
<%= render 'personalization_settings/email_body_editor', name: ff.field_name(:invitation_view_email_body), value: ff.object.invitation_view_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY], html_textarea: true %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<div class="form-control pt-2">
|
||||
<%= button_tag button_title(title: t('save'), disabled_with: t('saving')), form: 'submitter_view_invitation_email_template_form', class: 'base-button' %>
|
||||
<div class="flex justify-center">
|
||||
<span id="email_saved_alert_view" class="text-sm invisible font-normal mt-0.5"><%= t('changes_have_been_saved') %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,6 +109,18 @@
|
||||
<%= render 'templates_preferences/submitter_invitation_email_form' %>
|
||||
</div>
|
||||
</div>
|
||||
<% field_submitter_uuids = Set.new(@template.fields.pluck('submitter_uuid')) %>
|
||||
<% unless @template.submitters.all? { |s| field_submitter_uuids.include?(s['uuid']) } %>
|
||||
<div class="collapse collapse-arrow join-item border border-base-300">
|
||||
<input type="checkbox" name="accordion">
|
||||
<div class="collapse-title text-xl font-medium">
|
||||
<%= t('view_documents_email') %>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<%= render 'templates_preferences/submitter_view_invitation_email_form' %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= render 'templates_preferences/submitter_invitation_reminder_email_collapse' %>
|
||||
<div class="collapse collapse-arrow join-item border border-base-300">
|
||||
<input type="checkbox" name="accordion">
|
||||
|
||||
@@ -92,16 +92,20 @@ en: &en
|
||||
sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Sign documents with trusted certificate provided by DocuSeal. Your documents and data are never shared with DocuSeal. PDF checksum is provided to generate a trusted signature.
|
||||
you_have_been_invited_to_submit_the_name_form: 'You have been invited to submit the "%{name}" form.'
|
||||
you_have_been_invited_to_sign_the_name: 'You have been invited to sign the "%{name}".'
|
||||
you_have_been_invited_to_view_the_name: 'You have been invited to view the "%{name}".'
|
||||
alternatively_you_can_review_and_download_your_copy_using_the_link_below: "Alternatively, you can review and download your copy using the link below:"
|
||||
please_check_the_copy_of_your_name_in_the_email_attachments: 'Please check the copy of your "%{name}" in the email attachments.'
|
||||
awaiting_completion_by_the_other_party: "Awaiting completion by the other party"
|
||||
review_and_sign: Review and Sign
|
||||
view_document: View Document
|
||||
review_and_submit: Review and Submit
|
||||
please_contact_us_by_replying_to_this_email_if_you_have_any_questions: "Please contact us by replying to this email if you have any questions."
|
||||
submitter_invitation_sms_body_sign: '{account.name} has invited you to sign a document: {submitter.link}'
|
||||
submitter_invitation_sms_body_view: '{account.name} has invited you to view a document: {submitter.link}'
|
||||
verification_code_sms_body: 'Verification code: {code}'
|
||||
you_are_invited_to_submit_a_form: 'You are invited to submit a form'
|
||||
you_are_invited_to_sign_a_document: 'You are invited to sign a document'
|
||||
you_are_invited_to_view_a_document: 'You are invited to view a document'
|
||||
you_are_invited_to_sign_documents: 'You are invited to sign documents'
|
||||
your_document_copy: 'Your document copy'
|
||||
name_has_been_completed_by_submitters: '"%{name}" has been completed by %{submitters}.'
|
||||
@@ -115,6 +119,17 @@ en: &en
|
||||
|
||||
Please contact us by replying to this email if you have any questions.
|
||||
|
||||
Thanks,
|
||||
{account.name}
|
||||
submitter_invitation_email_view_body: |
|
||||
Hi there,
|
||||
|
||||
You have been invited to view the "{template.name}".
|
||||
|
||||
[View Document]({submitter.link})
|
||||
|
||||
Please contact us by replying to this email if you have any questions.
|
||||
|
||||
Thanks,
|
||||
{account.name}
|
||||
submitter_completed_email_body: |
|
||||
@@ -307,6 +322,8 @@ en: &en
|
||||
invalid_timeserver: Invalid Timeserver
|
||||
email_templates: Email Templates
|
||||
signature_request_email: Signature request email
|
||||
view_documents_email: View documents email
|
||||
view_only: View only
|
||||
signature_request_reminder_email: Signature request reminder email
|
||||
signature_request_sms: Signature Request SMS
|
||||
verification_code_sms: Verification Code SMS
|
||||
@@ -569,6 +586,7 @@ en: &en
|
||||
form_has_been_archived: Form has been archived.
|
||||
form_has_been_expired: Form has been expired.
|
||||
form_has_been_declined: Form has been declined.
|
||||
form_is_view_only: Form is view only.
|
||||
file_is_missing: File is missing
|
||||
folder_name_has_been_updated: Folder name has been updated.
|
||||
unable_to_rename_folder: Unable to rename folder.
|
||||
@@ -653,6 +671,10 @@ en: &en
|
||||
signers: Signers
|
||||
not_invited_yet: Not invited yet
|
||||
not_completed_yet: Not completed yet
|
||||
not_completed: Not completed
|
||||
not_viewed_yet: Not viewed yet
|
||||
not_viewed: Not viewed
|
||||
viewed_on_time: 'Viewed on %{time}'
|
||||
declined_on_time: 'Declined on %{time}'
|
||||
expire_on_time: 'Expire on %{time}'
|
||||
sign_in_person: Sign In-person
|
||||
@@ -877,6 +899,7 @@ en: &en
|
||||
mobile: Mobile
|
||||
tablet: Tablet
|
||||
reset_default: Reset default
|
||||
smtp_settings_have_been_reset: SMTP settings have been reset.
|
||||
send_signature_request_email: Send signature request email
|
||||
last_3_months: Last 3 months
|
||||
last_6_months: Last 6 months
|
||||
@@ -1151,15 +1174,19 @@ es: &es
|
||||
thanks: Gracias
|
||||
you_have_been_invited_to_submit_the_name_form: 'Has sido invitado/a a enviar el formulario "%{name}".'
|
||||
you_have_been_invited_to_sign_the_name: 'Has sido invitado/a a firmar el "%{name}".'
|
||||
you_have_been_invited_to_view_the_name: 'Has sido invitado/a a ver el "%{name}".'
|
||||
alternatively_you_can_review_and_download_your_copy_using_the_link_below: "Alternativamente, puedes revisar y descargar tu copia usando el enlace a continuación:"
|
||||
please_check_the_copy_of_your_name_in_the_email_attachments: 'Por favor, revisa la copia de tu "%{name}" en los archivos adjuntos del correo electrónico.'
|
||||
review_and_sign: Revisar y Firmar
|
||||
view_document: Ver documento
|
||||
review_and_submit: Revisar y Enviar
|
||||
please_contact_us_by_replying_to_this_email_if_you_have_any_questions: "Por favor, contáctanos respondiendo a este correo si tienes alguna pregunta."
|
||||
submitter_invitation_sms_body_sign: '{account.name} te ha invitado a firmar un documento: {submitter.link}'
|
||||
submitter_invitation_sms_body_view: '{account.name} te ha invitado a ver un documento: {submitter.link}'
|
||||
verification_code_sms_body: 'Código de verificación: {code}'
|
||||
you_are_invited_to_submit_a_form: 'Estás invitado/a a enviar un formulario'
|
||||
you_are_invited_to_sign_a_document: 'Estás invitado/a a firmar un documento'
|
||||
you_are_invited_to_view_a_document: 'Estás invitado/a a ver un documento'
|
||||
you_are_invited_to_sign_documents: 'Estás invitado/a a firmar documentos'
|
||||
your_document_copy: 'Tu copia del documento'
|
||||
name_has_been_completed_by_submitters: '"%{name}" ha sido completado por %{submitters}.'
|
||||
@@ -1173,6 +1200,17 @@ es: &es
|
||||
|
||||
Por favor, contáctanos respondiendo a este correo si tienes alguna pregunta.
|
||||
|
||||
Gracias,
|
||||
{account.name}
|
||||
submitter_invitation_email_view_body: |
|
||||
Hola,
|
||||
|
||||
Has sido invitado/a a ver el "{template.name}".
|
||||
|
||||
[Ver documento]({submitter.link})
|
||||
|
||||
Por favor, contáctanos respondiendo a este correo si tienes alguna pregunta.
|
||||
|
||||
Gracias,
|
||||
{account.name}
|
||||
submitter_completed_email_body: |
|
||||
@@ -1364,6 +1402,8 @@ es: &es
|
||||
invalid_timeserver: Servidor de tiempo inválido
|
||||
email_templates: Plantillas de correo electrónico
|
||||
signature_request_email: Correo de solicitud de firma
|
||||
view_documents_email: Correo de visualización de documentos
|
||||
view_only: Solo lectura
|
||||
signature_request_reminder_email: Correo de recordatorio de solicitud de firma
|
||||
signature_request_sms: SMS de solicitud de firma
|
||||
verification_code_sms: SMS de código de verificación
|
||||
@@ -1626,6 +1666,7 @@ es: &es
|
||||
form_has_been_archived: El formulario ha sido archivado.
|
||||
form_has_been_expired: El formulario ha expirado.
|
||||
form_has_been_declined: El formulario ha sido rechazado.
|
||||
form_is_view_only: El formulario es de solo lectura.
|
||||
file_is_missing: Falta el archivo
|
||||
folder_name_has_been_updated: El nombre de la carpeta ha sido actualizado.
|
||||
unable_to_rename_folder: No se pudo renombrar la carpeta.
|
||||
@@ -1710,6 +1751,10 @@ es: &es
|
||||
signers: Firmantes
|
||||
not_invited_yet: Aún no invitado
|
||||
not_completed_yet: Aún no completado
|
||||
not_completed: No completado
|
||||
not_viewed_yet: Aún no visto
|
||||
not_viewed: No visto
|
||||
viewed_on_time: 'Visto el %{time}'
|
||||
declined_on_time: 'Rechazado el %{time}'
|
||||
expire_on_time: 'Expira el %{time}'
|
||||
sign_in_person: Firma en persona
|
||||
@@ -1931,6 +1976,7 @@ es: &es
|
||||
mobile: Móvil
|
||||
tablet: Tableta
|
||||
reset_default: Restablecer por defecto
|
||||
smtp_settings_have_been_reset: La configuración SMTP ha sido restablecida.
|
||||
send_signature_request_email: Enviar correo de solicitud de firma
|
||||
last_3_months: Últimos 3 meses
|
||||
last_6_months: Últimos 6 meses
|
||||
@@ -2205,15 +2251,19 @@ it: &it
|
||||
thanks: Grazie
|
||||
you_have_been_invited_to_submit_the_name_form: 'Sei stato invitato a inviare il modulo "%{name}".'
|
||||
you_have_been_invited_to_sign_the_name: 'Sei stato invitato a firmare il "%{name}".'
|
||||
you_have_been_invited_to_view_the_name: 'Sei stato invitato a visualizzare il "%{name}".'
|
||||
alternatively_you_can_review_and_download_your_copy_using_the_link_below: "In alternativa, puoi rivedere e scaricare la tua copia utilizzando il link qui sotto:"
|
||||
please_check_the_copy_of_your_name_in_the_email_attachments: "Per favore, controlla la copia del tuo \"%{name}\" negli allegati dell'email."
|
||||
review_and_sign: Rivedi e Firma
|
||||
view_document: Visualizza documento
|
||||
review_and_submit: Rivedi e Invia
|
||||
please_contact_us_by_replying_to_this_email_if_you_have_any_questions: "Per favore, contattaci rispondendo a questa email se hai domande."
|
||||
submitter_invitation_sms_body_sign: '{account.name} ti ha invitato a firmare un documento: {submitter.link}'
|
||||
submitter_invitation_sms_body_view: '{account.name} ti ha invitato a visualizzare un documento: {submitter.link}'
|
||||
verification_code_sms_body: 'Codice di verifica: {code}'
|
||||
you_are_invited_to_submit_a_form: 'Sei stato invitato a inviare un modulo'
|
||||
you_are_invited_to_sign_a_document: 'Sei stato invitato a firmare un documento'
|
||||
you_are_invited_to_view_a_document: 'Sei stato invitato a visualizzare un documento'
|
||||
you_are_invited_to_sign_documents: 'Sei stato invitato a firmare dei documenti'
|
||||
your_document_copy: 'La tua copia del documento'
|
||||
name_has_been_completed_by_submitters: '"%{name}" è stato completato da %{submitters}.'
|
||||
@@ -2227,6 +2277,17 @@ it: &it
|
||||
|
||||
Per favore, contattaci rispondendo a questa email se hai domande.
|
||||
|
||||
Grazie,
|
||||
{account.name}
|
||||
submitter_invitation_email_view_body: |
|
||||
Ciao,
|
||||
|
||||
Sei stato invitato a visualizzare il "{template.name}".
|
||||
|
||||
[Visualizza documento]({submitter.link})
|
||||
|
||||
Per favore, contattaci rispondendo a questa email se hai domande.
|
||||
|
||||
Grazie,
|
||||
{account.name}
|
||||
submitter_completed_email_body: |
|
||||
@@ -2418,6 +2479,8 @@ it: &it
|
||||
invalid_timeserver: Server di timestamp non valido
|
||||
email_templates: Modelli email
|
||||
signature_request_email: Email di richiesta di firma
|
||||
view_documents_email: Email di visualizzazione documenti
|
||||
view_only: Sola lettura
|
||||
signature_request_reminder_email: Email di promemoria di richiesta di firma
|
||||
signature_request_sms: SMS di richiesta di firma
|
||||
verification_code_sms: SMS con codice di verifica
|
||||
@@ -2680,6 +2743,7 @@ it: &it
|
||||
form_has_been_archived: Il modulo è stato archiviato.
|
||||
form_has_been_expired: Il modulo è scaduto.
|
||||
form_has_been_declined: Il modulo è stato rifiutato.
|
||||
form_is_view_only: Il modulo è di sola lettura.
|
||||
file_is_missing: File mancante
|
||||
folder_name_has_been_updated: Il nome della cartella è stato aggiornato.
|
||||
unable_to_rename_folder: Impossibile rinominare la cartella.
|
||||
@@ -2764,6 +2828,10 @@ it: &it
|
||||
signers: Firmatari
|
||||
not_invited_yet: Non ancora invitato
|
||||
not_completed_yet: Non ancora completato
|
||||
not_completed: Non completato
|
||||
not_viewed_yet: Non ancora visualizzato
|
||||
not_viewed: Non visualizzato
|
||||
viewed_on_time: 'Visualizzato il %{time}'
|
||||
declined_on_time: 'Rifiutato il %{time}'
|
||||
expire_on_time: 'Scade il %{time}'
|
||||
sign_in_person: Firma di persona
|
||||
@@ -2985,6 +3053,7 @@ it: &it
|
||||
mobile: Mobile
|
||||
tablet: Tablet
|
||||
reset_default: Reimposta predefinito
|
||||
smtp_settings_have_been_reset: Le impostazioni SMTP sono state ripristinate.
|
||||
send_signature_request_email: Invia email di richiesta firma
|
||||
last_3_months: Ultimi 3 mesi
|
||||
last_6_months: Ultimi 6 mesi
|
||||
@@ -3259,15 +3328,19 @@ fr: &fr
|
||||
thanks: Merci
|
||||
you_have_been_invited_to_submit_the_name_form: Vous avez été invité à soumettre le formulaire "%{name}".
|
||||
you_have_been_invited_to_sign_the_name: Vous avez été invité à signer "%{name}".
|
||||
you_have_been_invited_to_view_the_name: Vous avez été invité à consulter "%{name}".
|
||||
alternatively_you_can_review_and_download_your_copy_using_the_link_below: 'Vous pouvez également consulter et télécharger votre exemplaire à l’aide du lien ci‑dessous :'
|
||||
please_check_the_copy_of_your_name_in_the_email_attachments: Veuillez vérifier la copie de votre "%{name}" dans les pièces jointes de l’e‑mail.
|
||||
review_and_sign: Examiner et signer
|
||||
view_document: Consulter le document
|
||||
review_and_submit: Examiner et soumettre
|
||||
please_contact_us_by_replying_to_this_email_if_you_have_any_questions: Veuillez nous contacter en répondant à cet e‑mail si vous avez des questions.
|
||||
submitter_invitation_sms_body_sign: "{account.name} vous a invité à signer un document : {submitter.link}"
|
||||
submitter_invitation_sms_body_view: "{account.name} vous a invité à consulter un document : {submitter.link}"
|
||||
verification_code_sms_body: 'Code de vérification : {code}'
|
||||
you_are_invited_to_submit_a_form: Vous êtes invité à soumettre un formulaire
|
||||
you_are_invited_to_sign_a_document: Vous êtes invité à signer un document
|
||||
you_are_invited_to_view_a_document: Vous êtes invité à consulter un document
|
||||
you_are_invited_to_sign_documents: Vous êtes invité à signer des documents
|
||||
your_document_copy: Votre copie de document
|
||||
name_has_been_completed_by_submitters: '"%{name}" a été complété par %{submitters}.'
|
||||
@@ -3281,6 +3354,17 @@ fr: &fr
|
||||
|
||||
Veuillez nous contacter en répondant à cet e‑mail si vous avez des questions.
|
||||
|
||||
Merci,
|
||||
{account.name}
|
||||
submitter_invitation_email_view_body: |
|
||||
Bonjour,
|
||||
|
||||
Vous avez été invité à consulter "{template.name}".
|
||||
|
||||
[Consulter le document]({submitter.link})
|
||||
|
||||
Veuillez nous contacter en répondant à cet e-mail si vous avez des questions.
|
||||
|
||||
Merci,
|
||||
{account.name}
|
||||
submitter_completed_email_body: |
|
||||
@@ -3472,6 +3556,8 @@ fr: &fr
|
||||
invalid_timeserver: Serveur d’horodatage invalide
|
||||
email_templates: Modèles d’e‑mail
|
||||
signature_request_email: E‑mail de demande de signature
|
||||
view_documents_email: E-mail de consultation des documents
|
||||
view_only: Lecture seule
|
||||
signature_request_reminder_email: E‑mail de rappel de demande de signature
|
||||
signature_request_sms: SMS de demande de signature
|
||||
verification_code_sms: SMS de code de vérification
|
||||
@@ -3734,6 +3820,7 @@ fr: &fr
|
||||
form_has_been_archived: Le formulaire a été archivé.
|
||||
form_has_been_expired: Le formulaire a expiré.
|
||||
form_has_been_declined: Le formulaire a été refusé.
|
||||
form_is_view_only: Le formulaire est en lecture seule.
|
||||
file_is_missing: Fichier manquant
|
||||
folder_name_has_been_updated: Le nom du dossier a été mis à jour.
|
||||
unable_to_rename_folder: Impossible de renommer le dossier.
|
||||
@@ -3818,6 +3905,10 @@ fr: &fr
|
||||
signers: Signataires
|
||||
not_invited_yet: Pas encore invité
|
||||
not_completed_yet: Pas encore terminé
|
||||
not_completed: Non terminé
|
||||
not_viewed_yet: Pas encore consulté
|
||||
not_viewed: Non consulté
|
||||
viewed_on_time: Consulté le %{time}
|
||||
declined_on_time: Refusé le %{time}
|
||||
expire_on_time: Expire le %{time}
|
||||
sign_in_person: Signer en personne
|
||||
@@ -4035,6 +4126,7 @@ fr: &fr
|
||||
mobile: Mobile
|
||||
tablet: Tablette
|
||||
reset_default: Réinitialiser par défaut
|
||||
smtp_settings_have_been_reset: Les paramètres SMTP ont été réinitialisés.
|
||||
send_signature_request_email: Envoyer un e-mail de demande de signature
|
||||
last_month: Mois dernier
|
||||
last_3_months: 3 derniers mois
|
||||
@@ -4310,15 +4402,19 @@ pt: &pt
|
||||
thanks: Obrigado
|
||||
you_have_been_invited_to_submit_the_name_form: 'Você foi convidado a submeter o formulário "%{name}".'
|
||||
you_have_been_invited_to_sign_the_name: 'Você foi convidado a assinar "%{name}".'
|
||||
you_have_been_invited_to_view_the_name: 'Você foi convidado a visualizar "%{name}".'
|
||||
alternatively_you_can_review_and_download_your_copy_using_the_link_below: 'Você pode revisar e baixar sua cópia usando o link abaixo:'
|
||||
please_check_the_copy_of_your_name_in_the_email_attachments: 'Por favor, verifique a cópia de "%{name}" nos anexos do e-mail.'
|
||||
review_and_sign: Revisar e assinar
|
||||
view_document: Visualizar documento
|
||||
review_and_submit: Revisar e submeter
|
||||
please_contact_us_by_replying_to_this_email_if_you_have_any_questions: 'Por favor, entre em contato conosco respondendo a este e-mail se você tiver alguma dúvida.'
|
||||
submitter_invitation_sms_body_sign: '{account.name} convidou você para assinar um documento: {submitter.link}'
|
||||
submitter_invitation_sms_body_view: '{account.name} convidou você para visualizar um documento: {submitter.link}'
|
||||
verification_code_sms_body: 'Código de verificação: {code}'
|
||||
you_are_invited_to_submit_a_form: Você foi convidado a submeter um formulário
|
||||
you_are_invited_to_sign_a_document: Você foi convidado a assinar um documento
|
||||
you_are_invited_to_view_a_document: Você foi convidado a visualizar um documento
|
||||
you_are_invited_to_sign_documents: Você foi convidado a assinar documentos
|
||||
your_document_copy: Sua cópia do documento
|
||||
name_has_been_completed_by_submitters: '"%{name}" foi concluído por %{submitters}.'
|
||||
@@ -4332,6 +4428,17 @@ pt: &pt
|
||||
|
||||
Por favor, entre em contato conosco respondendo a este e-mail se você tiver alguma dúvida.
|
||||
|
||||
Obrigado,
|
||||
{account.name}
|
||||
submitter_invitation_email_view_body: |
|
||||
Olá,
|
||||
|
||||
Você foi convidado a visualizar "{template.name}".
|
||||
|
||||
[Visualizar documento]({submitter.link})
|
||||
|
||||
Por favor, entre em contato conosco respondendo a este e-mail se você tiver alguma dúvida.
|
||||
|
||||
Obrigado,
|
||||
{account.name}
|
||||
submitter_completed_email_body: |
|
||||
@@ -4523,6 +4630,8 @@ pt: &pt
|
||||
invalid_timeserver: Servidor de carimbo de tempo inválido
|
||||
email_templates: Modelos de e-mail
|
||||
signature_request_email: E-mail de solicitação de assinatura
|
||||
view_documents_email: E-mail de visualização de documentos
|
||||
view_only: Somente leitura
|
||||
signature_request_reminder_email: E-mail de lembrete de solicitação de assinatura
|
||||
signature_request_sms: SMS de solicitação de assinatura
|
||||
verification_code_sms: SMS com código de verificação
|
||||
@@ -4785,6 +4894,7 @@ pt: &pt
|
||||
form_has_been_archived: O formulário foi arquivado.
|
||||
form_has_been_expired: O formulário expirou.
|
||||
form_has_been_declined: O formulário foi recusado.
|
||||
form_is_view_only: O formulário é somente leitura.
|
||||
file_is_missing: O arquivo está ausente
|
||||
folder_name_has_been_updated: O nome da pasta foi atualizado.
|
||||
unable_to_rename_folder: Não foi possível renomear a pasta.
|
||||
@@ -4869,6 +4979,10 @@ pt: &pt
|
||||
signers: Signatários
|
||||
not_invited_yet: Ainda não convidado
|
||||
not_completed_yet: Ainda não concluído
|
||||
not_completed: Não concluído
|
||||
not_viewed_yet: Ainda não visualizado
|
||||
not_viewed: Não visualizado
|
||||
viewed_on_time: 'Visualizado em %{time}'
|
||||
declined_on_time: 'Recusado em %{time}'
|
||||
expire_on_time: 'Expira em %{time}'
|
||||
sign_in_person: Assinar pessoalmente
|
||||
@@ -5090,6 +5204,7 @@ pt: &pt
|
||||
mobile: Celular
|
||||
tablet: Tablet
|
||||
reset_default: Redefinir para padrão
|
||||
smtp_settings_have_been_reset: As configurações SMTP foram redefinidas.
|
||||
send_signature_request_email: Enviar e-mail de solicitação de assinatura
|
||||
last_3_months: Últimos 3 meses
|
||||
last_6_months: Últimos 6 meses
|
||||
@@ -5362,16 +5477,20 @@ de: &de
|
||||
sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Unterzeichnen Sie Dokumente mit einem vertrauenswürdigen Zertifikat von DocuSeal. Ihre Dokumente und Daten werden niemals mit DocuSeal geteilt. Eine PDF-Prüfsumme wird bereitgestellt, um eine vertrauenswürdige Signatur zu generieren.
|
||||
you_have_been_invited_to_submit_the_name_form: 'Sie wurden eingeladen, das Formular "%{name}" einzureichen.'
|
||||
you_have_been_invited_to_sign_the_name: 'Sie wurden eingeladen, "%{name}" zu unterschreiben.'
|
||||
you_have_been_invited_to_view_the_name: 'Sie wurden eingeladen, "%{name}" anzusehen.'
|
||||
alternatively_you_can_review_and_download_your_copy_using_the_link_below: 'Alternativ können Sie Ihre Kopie über den untenstehenden Link ansehen und herunterladen:'
|
||||
please_check_the_copy_of_your_name_in_the_email_attachments: 'Bitte prüfen Sie die Kopie von "%{name}" im E-Mail-Anhang.'
|
||||
awaiting_completion_by_the_other_party: "Warten auf die Fertigstellung durch die andere Partei"
|
||||
review_and_sign: Prüfen und unterschreiben
|
||||
view_document: Dokument ansehen
|
||||
review_and_submit: Prüfen und einreichen
|
||||
please_contact_us_by_replying_to_this_email_if_you_have_any_questions: 'Bitte kontaktieren Sie uns, indem Sie auf diese E-Mail antworten, falls Sie Fragen haben.'
|
||||
submitter_invitation_sms_body_sign: '{account.name} hat Sie eingeladen, ein Dokument zu unterschreiben: {submitter.link}'
|
||||
submitter_invitation_sms_body_view: '{account.name} hat Sie eingeladen, ein Dokument anzusehen: {submitter.link}'
|
||||
verification_code_sms_body: 'Verifizierungscode: {code}'
|
||||
you_are_invited_to_submit_a_form: Sie sind eingeladen, ein Formular einzureichen
|
||||
you_are_invited_to_sign_a_document: Sie sind eingeladen, ein Dokument zu unterschreiben
|
||||
you_are_invited_to_view_a_document: Sie sind eingeladen, ein Dokument anzusehen
|
||||
you_are_invited_to_sign_documents: Sie sind eingeladen, Dokumente zu unterschreiben
|
||||
your_document_copy: Ihre Dokumentkopie
|
||||
name_has_been_completed_by_submitters: '"%{name}" wurde von %{submitters} abgeschlossen.'
|
||||
@@ -5385,6 +5504,17 @@ de: &de
|
||||
|
||||
Bitte kontaktieren Sie uns, indem Sie auf diese E-Mail antworten, falls Sie Fragen haben.
|
||||
|
||||
Danke,
|
||||
{account.name}
|
||||
submitter_invitation_email_view_body: |
|
||||
Hallo,
|
||||
|
||||
Sie wurden eingeladen, "{template.name}" anzusehen.
|
||||
|
||||
[Dokument ansehen]({submitter.link})
|
||||
|
||||
Bitte kontaktieren Sie uns, indem Sie auf diese E-Mail antworten, falls Sie Fragen haben.
|
||||
|
||||
Danke,
|
||||
{account.name}
|
||||
submitter_completed_email_body: |
|
||||
@@ -5577,6 +5707,8 @@ de: &de
|
||||
invalid_timeserver: Ungültiger Zeitstempelserver
|
||||
email_templates: E-Mail-Vorlagen
|
||||
signature_request_email: E-Mail für Signaturanfrage
|
||||
view_documents_email: E-Mail zur Dokumentenansicht
|
||||
view_only: Nur Ansicht
|
||||
signature_request_reminder_email: E-Mail-Erinnerung für Signaturanfrage
|
||||
signature_request_sms: SMS für Signaturanfrage
|
||||
verification_code_sms: SMS mit Verifizierungscode
|
||||
@@ -5839,6 +5971,7 @@ de: &de
|
||||
form_has_been_archived: Das Formular wurde archiviert.
|
||||
form_has_been_expired: Das Formular ist abgelaufen.
|
||||
form_has_been_declined: Das Formular wurde abgelehnt.
|
||||
form_is_view_only: Das Formular ist schreibgeschützt.
|
||||
file_is_missing: Datei fehlt
|
||||
folder_name_has_been_updated: Der Ordnername wurde aktualisiert.
|
||||
unable_to_rename_folder: Der Ordner konnte nicht umbenannt werden.
|
||||
@@ -5923,6 +6056,10 @@ de: &de
|
||||
signers: Unterzeichner
|
||||
not_invited_yet: Noch nicht eingeladen
|
||||
not_completed_yet: Noch nicht abgeschlossen
|
||||
not_completed: Nicht abgeschlossen
|
||||
not_viewed_yet: Noch nicht angesehen
|
||||
not_viewed: Nicht angesehen
|
||||
viewed_on_time: 'Angesehen am %{time}'
|
||||
declined_on_time: 'Abgelehnt am %{time}'
|
||||
expire_on_time: 'Läuft ab am %{time}'
|
||||
sign_in_person: Vor Ort unterschreiben
|
||||
@@ -6144,6 +6281,7 @@ de: &de
|
||||
mobile: Mobil
|
||||
tablet: Tablet
|
||||
reset_default: Standard zurücksetzen
|
||||
smtp_settings_have_been_reset: Die SMTP-Einstellungen wurden zurückgesetzt.
|
||||
send_signature_request_email: Signaturanfrage-E-Mail senden
|
||||
last_3_months: Letzte 3 Monate
|
||||
last_6_months: Letzte 6 Monate
|
||||
@@ -6821,16 +6959,20 @@ nl: &nl
|
||||
: Onderteken documenten met een vertrouwd certificaat geleverd door DocuSeal. Uw documenten en gegevens worden nooit gedeeld met DocuSeal. PDF-checksum wordt verstrekt om een vertrouwde handtekening te genereren.
|
||||
you_have_been_invited_to_submit_the_name_form: U bent uitgenodigd om het formulier "%{name}" in te dienen.
|
||||
you_have_been_invited_to_sign_the_name: U bent uitgenodigd om "%{name}" te ondertekenen.
|
||||
you_have_been_invited_to_view_the_name: U bent uitgenodigd om "%{name}" te bekijken.
|
||||
alternatively_you_can_review_and_download_your_copy_using_the_link_below: 'U kunt uw exemplaar ook bekijken en downloaden via de onderstaande link:'
|
||||
please_check_the_copy_of_your_name_in_the_email_attachments: Controleer de kopie van uw "%{name}" in de e-mailbijlagen.
|
||||
awaiting_completion_by_the_other_party: In afwachting van voltooiing door de andere partij
|
||||
review_and_sign: Bekijken en ondertekenen
|
||||
view_document: Document bekijken
|
||||
review_and_submit: Bekijken en indienen
|
||||
please_contact_us_by_replying_to_this_email_if_you_have_any_questions: Neem contact met ons op door op deze e-mail te antwoorden als u vragen heeft.
|
||||
submitter_invitation_sms_body_sign: "{account.name} heeft u uitgenodigd om een document te ondertekenen: {submitter.link}"
|
||||
submitter_invitation_sms_body_view: "{account.name} heeft u uitgenodigd om een document te bekijken: {submitter.link}"
|
||||
verification_code_sms_body: 'Verificatiecode: {code}'
|
||||
you_are_invited_to_submit_a_form: U bent uitgenodigd om een formulier in te dienen
|
||||
you_are_invited_to_sign_a_document: U bent uitgenodigd om een document te ondertekenen
|
||||
you_are_invited_to_view_a_document: U bent uitgenodigd om een document te bekijken
|
||||
you_are_invited_to_sign_documents: U bent uitgenodigd om documenten te ondertekenen
|
||||
your_document_copy: Uw documentkopie
|
||||
name_has_been_completed_by_submitters: '"%{name}" is voltooid door %{submitters}.'
|
||||
@@ -6844,6 +6986,17 @@ nl: &nl
|
||||
|
||||
Neem contact met ons op door op deze e-mail te antwoorden als u vragen heeft.
|
||||
|
||||
Bedankt,
|
||||
{account.name}
|
||||
submitter_invitation_email_view_body: |
|
||||
Hallo,
|
||||
|
||||
U bent uitgenodigd om "{template.name}" te bekijken.
|
||||
|
||||
[Document bekijken]({submitter.link})
|
||||
|
||||
Neem contact met ons op door op deze e-mail te antwoorden als u vragen heeft.
|
||||
|
||||
Bedankt,
|
||||
{account.name}
|
||||
submitter_completed_email_body: |
|
||||
@@ -7036,6 +7189,8 @@ nl: &nl
|
||||
invalid_timeserver: Ongeldige tijdserver
|
||||
email_templates: E-mailsjablonen
|
||||
signature_request_email: E-mail voor handtekeningverzoek
|
||||
view_documents_email: E-mail voor documentweergave
|
||||
view_only: Alleen bekijken
|
||||
signature_request_reminder_email: E-mailherinnering voor handtekeningverzoek
|
||||
signature_request_sms: SMS voor handtekeningverzoek
|
||||
verification_code_sms: Verificatiecode-SMS
|
||||
@@ -7298,6 +7453,7 @@ nl: &nl
|
||||
form_has_been_archived: Formulier is gearchiveerd.
|
||||
form_has_been_expired: Formulier is verlopen.
|
||||
form_has_been_declined: Formulier is geweigerd.
|
||||
form_is_view_only: Formulier is alleen-lezen.
|
||||
file_is_missing: Bestand ontbreekt
|
||||
folder_name_has_been_updated: Mapnaam is bijgewerkt.
|
||||
unable_to_rename_folder: Kan map niet hernoemen.
|
||||
@@ -7382,6 +7538,10 @@ nl: &nl
|
||||
signers: Ondertekenaars
|
||||
not_invited_yet: Nog niet uitgenodigd
|
||||
not_completed_yet: Nog niet voltooid
|
||||
not_completed: Niet voltooid
|
||||
not_viewed_yet: Nog niet bekeken
|
||||
not_viewed: Niet bekeken
|
||||
viewed_on_time: Bekeken op %{time}
|
||||
declined_on_time: Geweigerd op %{time}
|
||||
expire_on_time: Verloopt op %{time}
|
||||
sign_in_person: In persoon ondertekenen
|
||||
@@ -7599,6 +7759,7 @@ nl: &nl
|
||||
mobile: Mobiel
|
||||
tablet: Tablet
|
||||
reset_default: Standaard herstellen
|
||||
smtp_settings_have_been_reset: De SMTP-instellingen zijn hersteld.
|
||||
send_signature_request_email: E-mail met handtekeningaanvraag verzenden
|
||||
last_3_months: Afgelopen 3 maanden
|
||||
last_6_months: Afgelopen 6 maanden
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@ Rails.application.routes.draw do
|
||||
resources :submitter_email_clicks, only: %i[create]
|
||||
resources :submitter_form_views, only: %i[create]
|
||||
resources :submitters, only: %i[index show update]
|
||||
resources :submissions, only: %i[index show create destroy] do
|
||||
resources :submissions, only: %i[index show create update destroy] do
|
||||
resources :documents, only: %i[index], controller: 'submission_documents'
|
||||
collection do
|
||||
resources :init, only: %i[create], controller: 'submissions'
|
||||
@@ -189,7 +189,7 @@ Rails.application.routes.draw do
|
||||
resources :api, only: %i[index create], controller: 'api_settings'
|
||||
resource :reveal_access_token, only: %i[show create], controller: 'reveal_access_token'
|
||||
end
|
||||
resources :email, only: %i[index create], controller: 'email_smtp_settings'
|
||||
resources :email, only: %i[index create destroy], controller: 'email_smtp_settings'
|
||||
resources :sso, only: %i[index], controller: 'sso_settings'
|
||||
resources :notifications, only: %i[index create], controller: 'notifications_settings'
|
||||
resource :esign, only: %i[show create new update destroy], controller: 'esign_settings'
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddSubmissionCreatedAtIndex < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_index :submissions, :created_at, if_not_exists: true
|
||||
end
|
||||
end
|
||||
+2
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_07_01_165617) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_07_07_055354) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "btree_gin"
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
@@ -379,6 +379,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_07_01_165617) do
|
||||
t.index ["account_id", "id"], name: "index_submissions_on_account_id_and_id_pending", where: "((completed_at IS NULL) AND (archived_at IS NULL))"
|
||||
t.index ["account_id", "template_id", "id"], name: "index_submissions_on_account_id_and_template_id_and_id", where: "(archived_at IS NULL)"
|
||||
t.index ["account_id", "template_id", "id"], name: "index_submissions_on_account_id_and_template_id_and_id_archived", where: "(archived_at IS NOT NULL)"
|
||||
t.index ["created_at"], name: "index_submissions_on_created_at"
|
||||
t.index ["created_by_user_id"], name: "index_submissions_on_created_by_user_id"
|
||||
t.index ["slug"], name: "index_submissions_on_slug", unique: true
|
||||
t.index ["template_id"], name: "index_submissions_on_template_id"
|
||||
|
||||
@@ -8,9 +8,14 @@ module EmailMessages
|
||||
ASSET_REGEXP = Regexp.union(STYLE_REGEXP, BASE64_REGEXP)
|
||||
ASSET_PREFIX = '[[asset:'
|
||||
PLACEHOLDER_REGEXP = /\[\[asset:(\h{40})\]\]/
|
||||
HTML_MIME_TYPES = ['text/html', 'application/xhtml+xml'].freeze
|
||||
|
||||
module_function
|
||||
|
||||
def html_body?(content)
|
||||
content.present? && HTML_MIME_TYPES.include?(Marcel::MimeType.for(content.dup))
|
||||
end
|
||||
|
||||
def find_or_create_for_account_user(account, user, subject, body)
|
||||
subject = I18n.t(:you_are_invited_to_sign_a_document) if subject.blank?
|
||||
|
||||
|
||||
+25
-5
@@ -6,7 +6,10 @@ module Submissions
|
||||
module_function
|
||||
|
||||
def maybe_update_completed_at(submission)
|
||||
incomplete_submitter = Submitter.where(submission_id: submission.id, completed_at: nil).select(1)
|
||||
viewer_uuids = submission.template_submitters.to_a.filter_map { |s| s['uuid'] if s['is_viewer'] }
|
||||
|
||||
incomplete_submitter = Submitter.where(submission_id: submission.id, completed_at: nil).limit(1)
|
||||
incomplete_submitter = incomplete_submitter.where.not(uuid: viewer_uuids) if viewer_uuids.present?
|
||||
|
||||
max_completed_at =
|
||||
Arel::Nodes::Grouping.new(
|
||||
@@ -16,7 +19,7 @@ module Submissions
|
||||
)
|
||||
|
||||
Submission.where(id: submission.id, completed_at: nil)
|
||||
.where.not(incomplete_submitter.arel.exists)
|
||||
.where.not(incomplete_submitter.select(1).arel.exists)
|
||||
.update_all(completed_at: max_completed_at)
|
||||
.positive?
|
||||
end
|
||||
@@ -124,10 +127,13 @@ module Submissions
|
||||
|
||||
Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(submission)
|
||||
|
||||
Submissions::CreateFromSubmitters.assign_submitters_is_viewer(submission)
|
||||
|
||||
submission.save!
|
||||
|
||||
if submission.expire_at?
|
||||
ProcessSubmissionExpiredJob.perform_at(submission.expire_at, 'submission_id' => submission.id)
|
||||
ProcessSubmissionExpiredJob.perform_at(submission.expire_at, 'submission_id' => submission.id,
|
||||
'expire_at' => submission.expire_at.to_i)
|
||||
end
|
||||
|
||||
submission
|
||||
@@ -163,15 +169,29 @@ module Submissions
|
||||
|
||||
Submitters.send_signature_requests(first_submitters, delay_seconds:)
|
||||
elsif submission.submitters_order_preserved?
|
||||
first_submitter = template_submitters.filter_map { |s| submitters_index[s['uuid']] }.first
|
||||
first_submitter = template_submitters.filter_map { |s| submitters_index[s['uuid']] }.find { |s| !s.viewer? }
|
||||
|
||||
Submitters.send_signature_requests([first_submitter], delay_seconds:) if first_submitter
|
||||
if first_submitter
|
||||
first_viewers = find_first_viewers(first_submitter, submitters_index)
|
||||
|
||||
Submitters.send_signature_requests([first_submitter, *first_viewers], delay_seconds:)
|
||||
end
|
||||
else
|
||||
Submitters.send_signature_requests(submitters_index.values, delay_seconds:)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def find_first_viewers(first_submitter, submitters_index)
|
||||
first_submitter.submission.template_submitters.each_with_object([]) do |s, viewers|
|
||||
submitter = submitters_index[s['uuid']]
|
||||
|
||||
break viewers if submitter && !submitter.viewer? && submitter != first_submitter
|
||||
|
||||
viewers << submitter if submitter&.viewer?
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_email(email)
|
||||
return if email.blank?
|
||||
return if email.is_a?(Numeric)
|
||||
|
||||
@@ -10,6 +10,8 @@ module Submissions
|
||||
assign_defined_submitters(submission)
|
||||
assign_linked_submitters(submission)
|
||||
|
||||
Submissions::CreateFromSubmitters.assign_submitters_is_viewer(submission)
|
||||
|
||||
submission
|
||||
end
|
||||
|
||||
|
||||
@@ -90,6 +90,8 @@ module Submissions
|
||||
|
||||
maybe_add_invite_submitters(submission, template, attrs[:submitters])
|
||||
|
||||
assign_submitters_is_viewer(submission)
|
||||
|
||||
submission.template = nil unless with_template
|
||||
|
||||
submission.tap(&:save!)
|
||||
@@ -100,6 +102,34 @@ module Submissions
|
||||
submissions
|
||||
end
|
||||
|
||||
def assign_submitters_is_viewer(submission)
|
||||
template_fields = submission.template_fields || submission.template.fields
|
||||
field_submitter_uuids = Set.new(template_fields.pluck('submitter_uuid'))
|
||||
|
||||
viewer_template_submitters =
|
||||
submission.template_submitters.select { |s| field_submitter_uuids.exclude?(s['uuid']) }
|
||||
|
||||
return submission if viewer_template_submitters.blank?
|
||||
|
||||
viewer_template_submitters.each { |s| s['is_viewer'] = true }
|
||||
|
||||
return submission if submission.template_submitters.any? { |s| s['order'] }
|
||||
|
||||
first_submitter = submission.submitters.find(&:sent_at)
|
||||
|
||||
return submission unless first_submitter
|
||||
|
||||
submitters_index = submission.submitters.reject(&:completed_at?).index_by(&:uuid)
|
||||
|
||||
Submissions.find_first_viewers(first_submitter, submitters_index).each do |viewer|
|
||||
next if viewer.preferences['send_email'] == false || viewer.email.blank?
|
||||
|
||||
viewer.sent_at ||= first_submitter.sent_at
|
||||
end
|
||||
|
||||
submission
|
||||
end
|
||||
|
||||
def maybe_set_dynamic_documents(submission)
|
||||
return submission unless submission.template_id?
|
||||
|
||||
@@ -148,7 +178,8 @@ module Submissions
|
||||
submissions.each do |submission|
|
||||
next unless submission.expire_at?
|
||||
|
||||
ProcessSubmissionExpiredJob.perform_at(submission.expire_at, 'submission_id' => submission.id)
|
||||
ProcessSubmissionExpiredJob.perform_at(submission.expire_at, 'submission_id' => submission.id,
|
||||
'expire_at' => submission.expire_at.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ module Submissions
|
||||
def call(submitter)
|
||||
return nil unless submitter
|
||||
|
||||
raise NotCompletedYet unless submitter.completed_at?
|
||||
raise NotCompletedYet if !submitter.completed_at? ||
|
||||
!submitter.submission.completed_at?
|
||||
|
||||
total_wait_time ||= 0
|
||||
key = [KEY_PREFIX, submitter.id].join(':')
|
||||
|
||||
@@ -19,6 +19,8 @@ module Submissions
|
||||
created_at_to
|
||||
].freeze
|
||||
|
||||
BIGINT_MAX = (2**63) - 1
|
||||
|
||||
module_function
|
||||
|
||||
def call(submissions, current_user, params)
|
||||
@@ -71,15 +73,39 @@ module Submissions
|
||||
end
|
||||
|
||||
def filter_by_created_at(submissions, filters)
|
||||
submissions = submissions.where(created_at: filters[:created_at_from]..) if filters[:created_at_from].present?
|
||||
if filters[:created_at_from].present?
|
||||
submissions = submissions.where(min_created_at_id_arel(filters[:created_at_from]))
|
||||
end
|
||||
|
||||
if filters[:created_at_to].present?
|
||||
submissions = submissions.where(created_at: ..filters[:created_at_to].end_of_day)
|
||||
submissions = submissions.where(max_created_at_id_arel(filters[:created_at_to].end_of_day))
|
||||
end
|
||||
|
||||
submissions
|
||||
end
|
||||
|
||||
def min_created_at_id_arel(time)
|
||||
submissions = Submission.arel_table
|
||||
|
||||
first_id = submissions.project(submissions[:id])
|
||||
.where(submissions[:created_at].gteq(time))
|
||||
.order(submissions[:created_at].asc, submissions[:id].asc)
|
||||
.take(1)
|
||||
|
||||
submissions[:id].gteq(Arel::Nodes::NamedFunction.new('COALESCE', [first_id, BIGINT_MAX]))
|
||||
end
|
||||
|
||||
def max_created_at_id_arel(time)
|
||||
submissions = Submission.arel_table
|
||||
|
||||
last_id = submissions.project(submissions[:id])
|
||||
.where(submissions[:created_at].lteq(time))
|
||||
.order(submissions[:created_at].desc, submissions[:id].desc)
|
||||
.take(1)
|
||||
|
||||
submissions[:id].lteq(Arel::Nodes::NamedFunction.new('COALESCE', [last_id, 0]))
|
||||
end
|
||||
|
||||
def filter_by_folder(submissions, filters, current_user)
|
||||
return submissions if filters[:folder].blank?
|
||||
|
||||
|
||||
@@ -282,6 +282,9 @@ module Submissions
|
||||
[
|
||||
composer.document.layout.formatted_text_box(
|
||||
[
|
||||
submitter.viewer? && {
|
||||
text: "#{I18n.t('view_only')}\n"
|
||||
},
|
||||
submitter.email && (click_email_event || verify_email_event) && {
|
||||
text: "#{I18n.t('email_verification')}: #{I18n.t('verified')}\n"
|
||||
},
|
||||
|
||||
@@ -996,7 +996,8 @@ module Submissions
|
||||
end
|
||||
|
||||
def single_sign_reason(submitter)
|
||||
signers = submitter.submission.submitters.sort_by(&:completed_at).map { |s| s.email || s.name || s.phone }
|
||||
signers = submitter.submission.submitters.reject(&:viewer?)
|
||||
.sort_by(&:completed_at).map { |s| s.email || s.name || s.phone }
|
||||
|
||||
format(SIGN_REASON, name: signers.reverse.join(', '))
|
||||
end
|
||||
@@ -1015,7 +1016,7 @@ module Submissions
|
||||
|
||||
return sign_reason(reason_name) if config.value == 'multiple'
|
||||
|
||||
if !submitter.submission.submitters.exists?(completed_at: nil) &&
|
||||
if submitter.submission.completed_at? &&
|
||||
submitter.completed_at == submitter.submission.submitters.maximum(:completed_at)
|
||||
return single_sign_reason(submitter)
|
||||
end
|
||||
|
||||
+20
-8
@@ -84,7 +84,7 @@ module Submitters
|
||||
submitter_ids = SearchEntry.where(record_type: 'Submitter')
|
||||
.where(account_id: current_user.account_id)
|
||||
.where(*query)
|
||||
.limit(keyword.length > 2 ? 500 : 5000)
|
||||
.limit(keyword.strip.length > 2 ? 500 : 5000)
|
||||
.pluck(:record_id)
|
||||
|
||||
submitters.where(id: submitter_ids.first(100))
|
||||
@@ -105,7 +105,7 @@ module Submitters
|
||||
end
|
||||
|
||||
def select_attachments_for_download(submitter)
|
||||
if AccountConfig.exists?(account_id: submitter.submission.account_id,
|
||||
if AccountConfig.exists?(account_id: submitter.account_id,
|
||||
key: AccountConfig::COMBINE_PDF_RESULT_KEY,
|
||||
value: true) &&
|
||||
submitter.submission.completed_at? &&
|
||||
@@ -177,6 +177,7 @@ module Submitters
|
||||
end
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics
|
||||
def current_submitter_order?(submitter)
|
||||
submission = submitter.submission
|
||||
|
||||
@@ -189,6 +190,14 @@ module Submitters
|
||||
current_group_index = submitter_groups.find_index { |_, group| group.any? { |s| s['uuid'] == submitter.uuid } }
|
||||
|
||||
submitter_groups.first(current_group_index).flat_map(&:last)
|
||||
elsif submitter.viewer?
|
||||
current_index = submitter_items.find_index { |e| e['uuid'] == submitter.uuid }
|
||||
|
||||
preceding_submitter_index = submitter_items[0...current_index].rindex do |e|
|
||||
!submission.submitters.find { |s| s.uuid == e['uuid'] }&.viewer?
|
||||
end
|
||||
|
||||
submitter_items.first(preceding_submitter_index || 0)
|
||||
else
|
||||
submitter_items.first(submitter_items.find_index { |e| e['uuid'] == submitter.uuid })
|
||||
end
|
||||
@@ -196,9 +205,10 @@ module Submitters
|
||||
before_items.all? do |item|
|
||||
submitter = submission.submitters.find { |e| e.uuid == item['uuid'] }
|
||||
|
||||
submitter.nil? || submitter.completed_at?
|
||||
submitter.nil? || submitter.viewer? || submitter.completed_at?
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
|
||||
def build_document_filename(submitter, blob, filename_format)
|
||||
return blob.filename.to_s if filename_format.blank?
|
||||
@@ -219,10 +229,12 @@ module Submitters
|
||||
end
|
||||
end
|
||||
|
||||
filename = filename.gsub(
|
||||
'{submission.completed_at}',
|
||||
I18n.l(submitter.completed_at.in_time_zone(submitter.account.timezone), format: :short)
|
||||
)
|
||||
filename = filename.gsub('{submission.completed_at}') do
|
||||
completed_at = submitter.submission.completed_at ||
|
||||
submitter.submission.submitters.select(&:completed_at).max_by(&:completed_at).completed_at
|
||||
|
||||
I18n.l(completed_at.in_time_zone(submitter.account.timezone), format: :short)
|
||||
end
|
||||
|
||||
"#{filename}.#{blob.filename.extension}"
|
||||
end
|
||||
@@ -265,7 +277,7 @@ module Submitters
|
||||
|
||||
def build_combined_url(submitter, ttl: FILES_TTL)
|
||||
return unless submitter.submission.completed_at?
|
||||
return if submitter.submission.submitters.order(:completed_at).last != submitter
|
||||
return if submitter.submission.submitters.completed.order(:completed_at).last != submitter
|
||||
|
||||
attachment = submitter.submission.combined_document_attachment
|
||||
attachment ||= Submissions::EnsureCombinedGenerated.call(submitter)
|
||||
|
||||
@@ -4,14 +4,15 @@ module Submitters
|
||||
module GenerateFontImage
|
||||
WIDTH = 3000
|
||||
HEIGHT = 160
|
||||
SHEAR = 0.25
|
||||
|
||||
FONTS = {
|
||||
'Dancing Script Regular' => '/fonts/DancingScript-Regular.otf',
|
||||
'Go Noto Kurrent-Bold Bold' => '/fonts/GoNotoKurrent-Bold.ttf'
|
||||
'Go Noto Kurrent-Regular Regular' => '/fonts/GoNotoKurrent-Regular.ttf'
|
||||
}.freeze
|
||||
|
||||
FONT_ALIASES = {
|
||||
'initials' => 'Go Noto Kurrent-Bold Bold',
|
||||
'initials' => 'Go Noto Kurrent-Regular Regular',
|
||||
'signature' => 'Dancing Script Regular'
|
||||
}.freeze
|
||||
|
||||
@@ -25,6 +26,10 @@ module Submitters
|
||||
text_image = Vips::Image.text(text, font:, fontfile: FONTS[font],
|
||||
width: WIDTH, height: HEIGHT, wrap: :none)
|
||||
|
||||
text_image = text_image.affine([1, -SHEAR, 0, 1], background: [0])
|
||||
|
||||
text_image = text_image.crop(*text_image.find_trim(background: [0], threshold: 0))
|
||||
|
||||
text_mask = Vips::Image.black(text_image.width, text_image.height)
|
||||
|
||||
image = text_mask.bandjoin(text_image).copy(interpretation: :b_w)
|
||||
|
||||
@@ -305,7 +305,13 @@ module Submitters
|
||||
|
||||
return unless blob
|
||||
|
||||
return blob if blob.attachments.take&.record&.account_id == account.id
|
||||
attachment = blob.attachments.take
|
||||
|
||||
return if attachment.nil? ||
|
||||
attachment.record_type != 'Submitter' ||
|
||||
attachment.name != 'attachments'
|
||||
|
||||
return blob if attachment.record&.account_id == account.id
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
@@ -59,6 +59,7 @@ module Templates
|
||||
.select(1).arel.exists
|
||||
|
||||
Template.where(id: TemplateSharing.where(account_id: shared_account_ids).select(:template_id))
|
||||
.where(account_id: account.linked_account_account.account_id)
|
||||
.where.not(exists_access)
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe ProcessSubmissionExpiredJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account:) }
|
||||
let(:template) { create(:template, account:, author: user) }
|
||||
let(:expire_at) { 2.days.from_now.change(usec: 0) }
|
||||
let(:submission) do
|
||||
create(:submission, :with_submitters, template:, created_by_user: user, expire_at:)
|
||||
end
|
||||
|
||||
before { allow(WebhookUrls).to receive(:enqueue_events) }
|
||||
|
||||
describe '#perform' do
|
||||
it 'enqueues the expired event when the scheduled expire_at still matches' do
|
||||
described_class.new.perform('submission_id' => submission.id, 'expire_at' => expire_at.to_i)
|
||||
|
||||
expect(WebhookUrls).to have_received(:enqueue_events).with(submission, 'submission.expired')
|
||||
end
|
||||
|
||||
it 'enqueues the expired event for legacy jobs scheduled without an expire_at param' do
|
||||
described_class.new.perform('submission_id' => submission.id)
|
||||
|
||||
expect(WebhookUrls).to have_received(:enqueue_events).with(submission, 'submission.expired')
|
||||
end
|
||||
|
||||
it 'skips a stale job scheduled for an earlier expire_at that was extended' do
|
||||
submission.update!(expire_at: 3.days.from_now)
|
||||
|
||||
described_class.new.perform('submission_id' => submission.id, 'expire_at' => expire_at.to_i)
|
||||
|
||||
expect(WebhookUrls).not_to have_received(:enqueue_events)
|
||||
end
|
||||
|
||||
it 'skips a stale job scheduled for a later expire_at that was shortened' do
|
||||
submission.update!(expire_at: 1.day.from_now)
|
||||
|
||||
described_class.new.perform('submission_id' => submission.id, 'expire_at' => expire_at.to_i)
|
||||
|
||||
expect(WebhookUrls).not_to have_received(:enqueue_events)
|
||||
end
|
||||
|
||||
it 'skips a stale job when the expiration has been cleared' do
|
||||
submission.update!(expire_at: nil)
|
||||
|
||||
described_class.new.perform('submission_id' => submission.id, 'expire_at' => expire_at.to_i)
|
||||
|
||||
expect(WebhookUrls).not_to have_received(:enqueue_events)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
describe 'Submit Form' do
|
||||
let(:account) { create(:account) }
|
||||
let(:author) { create(:user, account:) }
|
||||
let(:template) { create(:template, account:, author:, submitter_count: 2, only_field_types: %w[text]) }
|
||||
let(:viewer_uuid) { template.submitters.second['uuid'] }
|
||||
let(:submission) do
|
||||
create(:submission, :with_submitters, template:).tap do |s|
|
||||
s.update!(template_submitters: s.template_submitters.map do |ts|
|
||||
ts['uuid'] == viewer_uuid ? ts.merge('is_viewer' => true) : ts
|
||||
end)
|
||||
end
|
||||
end
|
||||
let(:viewer) { submission.submitters.find { |e| e.uuid == viewer_uuid } }
|
||||
|
||||
describe 'PUT /s/:slug' do
|
||||
it 'blocks form submission for a view-only party' do
|
||||
put submit_form_path(slug: viewer.slug), params: { completed: 'true' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('form_is_view_only'))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -259,6 +259,91 @@ describe 'Submission API' do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/submissions/:id' do
|
||||
it 'updates the submission name' do
|
||||
submission = create(:submission, :with_submitters, template: templates[0], created_by_user: author)
|
||||
|
||||
put "/api/submissions/#{submission.id}", headers: { 'x-auth-token': author.access_token.token }, params: {
|
||||
name: 'Updated Name'
|
||||
}.to_json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(submission.reload.name).to eq('Updated Name')
|
||||
expect(response.parsed_body['name']).to eq('Updated Name')
|
||||
end
|
||||
|
||||
it 'updates the expiration date' do
|
||||
submission = create(:submission, :with_submitters, template: templates[0], created_by_user: author)
|
||||
expire_at = 1.week.from_now.change(usec: 0)
|
||||
|
||||
put "/api/submissions/#{submission.id}", headers: { 'x-auth-token': author.access_token.token }, params: {
|
||||
expire_at: expire_at.iso8601
|
||||
}.to_json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(submission.reload.expire_at).to be_within(1.second).of(expire_at)
|
||||
end
|
||||
|
||||
it 'clears the expiration date when passed nil' do
|
||||
submission = create(:submission, :with_submitters, template: templates[0], created_by_user: author,
|
||||
expire_at: 1.week.from_now)
|
||||
|
||||
put "/api/submissions/#{submission.id}", headers: { 'x-auth-token': author.access_token.token }, params: {
|
||||
expire_at: nil
|
||||
}.to_json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(submission.reload.expire_at).to be_nil
|
||||
end
|
||||
|
||||
it 'archives and unarchives the submission' do
|
||||
submission = create(:submission, :with_submitters, template: templates[0], created_by_user: author)
|
||||
|
||||
put "/api/submissions/#{submission.id}", headers: { 'x-auth-token': author.access_token.token }, params: {
|
||||
archived: true
|
||||
}.to_json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(submission.reload.archived_at).not_to be_nil
|
||||
|
||||
put "/api/submissions/#{submission.id}", headers: { 'x-auth-token': author.access_token.token }, params: {
|
||||
archived: false
|
||||
}.to_json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(submission.reload.archived_at).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe 'view-only (CC) party' do
|
||||
let(:viewer_template) { create(:template, account:, author:, submitter_count: 2, only_field_types: %w[text]) }
|
||||
let(:viewer_uuid) { viewer_template.submitters.second['uuid'] }
|
||||
|
||||
before do
|
||||
viewer_template.update!(fields: viewer_template.fields.reject { |f| f['submitter_uuid'] == viewer_uuid })
|
||||
end
|
||||
|
||||
it 'stamps the field-less party as view-only on POST /api/submissions' do
|
||||
post '/api/submissions', headers: { 'x-auth-token': author.access_token.token }, params: {
|
||||
template_id: viewer_template.id,
|
||||
submitters: [
|
||||
{ role: 'First Party', email: 'signer@example.com' },
|
||||
{ role: 'Second Party', email: 'viewer@example.com' }
|
||||
]
|
||||
}.to_json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
submission = Submission.last
|
||||
viewer = submission.submitters.find { |e| e.uuid == viewer_uuid }
|
||||
signer = submission.submitters.find { |e| e.uuid != viewer_uuid }
|
||||
|
||||
expect(viewer.viewer?).to be(true)
|
||||
expect(signer.viewer?).to be(false)
|
||||
expect(submission.template_submitters.find { |e| e['uuid'] == viewer_uuid }['is_viewer']).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def index_submission_body(submission)
|
||||
|
||||
@@ -1317,4 +1317,27 @@ RSpec.describe 'Signing Form' do
|
||||
expect(field_value(submitter, 'First Name')).to eq 'John Doe'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a view-only party opens the form' do
|
||||
let(:template) { create(:template, account:, author:, submitter_count: 2, only_field_types: %w[text]) }
|
||||
let(:viewer_uuid) { template.submitters.second['uuid'] }
|
||||
let(:submission) do
|
||||
create(:submission, template:).tap do |s|
|
||||
s.update!(
|
||||
template_fields: s.template_fields.reject { |f| f['submitter_uuid'] == viewer_uuid },
|
||||
template_submitters: s.template_submitters.map do |ts|
|
||||
ts['uuid'] == viewer_uuid ? ts.merge('is_viewer' => true) : ts
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
let(:viewer) { create(:submitter, submission:, uuid: viewer_uuid, account:, email: 'viewer@example.com') }
|
||||
|
||||
it 'opens the document form read-only for a view-only party' do
|
||||
visit submit_form_path(slug: viewer.slug)
|
||||
|
||||
expect(page).to have_content(template.name)
|
||||
expect(page).to have_no_css('#form_container')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user