Compare commits

...

12 Commits

Author SHA1 Message Date
DocuSeal 0dd5bbe25a fix erblint 2023-10-01 21:31:29 +03:00
DocuSeal 3975c963c5 make SMTP username and password non required 2023-10-01 21:25:54 +03:00
DocuSeal 1f41807412 add more api endpoints 2023-10-01 16:51:00 +03:00
DocuSeal 872fbbc875 add file dropzone to dashboard 2023-10-01 16:50:42 +03:00
DocuSeal b98a874e20 add with logo builder prop 2023-09-30 15:14:29 +03:00
DocuSeal 50e123c221 generate audit trail for templates without documents 2023-09-30 12:36:18 +03:00
DocuSeal 7f97bfb3bd ensure rollbar required with sidekiq 2023-09-30 11:33:51 +03:00
DocuSeal 784665b549 skip pdf reader errors 2023-09-29 18:50:57 +03:00
DocuSeal c651709e45 make form completted button configurable 2023-09-29 01:25:13 +03:00
DocuSeal 881a2acbfc add undo redo to form builder 2023-09-28 10:13:23 +03:00
DocuSeal c49cb4b0c8 improve phone field 2023-09-27 23:28:33 +03:00
Alex Turchyn 17b8354c40 ensure form link is always sent 2023-09-27 09:42:19 +03:00
30 changed files with 451 additions and 82 deletions
@@ -3,6 +3,12 @@
module Api
class ApiBaseController < ActionController::API
include ActiveStorage::SetCurrent
include Pagy::Backend
DEFAULT_LIMIT = 10
MAX_LIMIT = 100
wrap_parameters false
before_action :authenticate_user!
check_authorization
@@ -17,6 +23,16 @@ module Api
private
def paginate(relation)
result = relation.order(id: :desc)
.limit([params[:limit] || DEFAULT_LIMIT, MAX_LIMIT].min)
result = result.where('id < ?', params[:after]) if params[:after].present?
result = result.where('id > ?', params[:before]) if params[:before].present?
result
end
def current_account
current_user&.account
end
+63 -3
View File
@@ -2,17 +2,57 @@
module Api
class SubmissionsController < ApiBaseController
load_and_authorize_resource :template
load_and_authorize_resource :template, only: :create
load_and_authorize_resource :submission, only: %i[show index]
before_action do
before_action only: :create do
authorize!(:create, Submission)
end
def index
submissions = Submissions.search(@submissions, params[:q])
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
submissions = paginate(submissions.preload(:created_by_user, :template, :submitters))
render json: {
data: submissions.as_json(serialize_params),
pagination: {
count: submissions.size,
next: submissions.last&.id,
prev: submissions.first&.id
}
}
end
def show
serialized_subbmitters =
@submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob).map do |submitter|
Submissions::EnsureResultGenerated.call(submitter) if submitter.completed_at?
Submitters::SerializeForApi.call(submitter)
end
json = @submission.as_json(
serialize_params.deep_merge(
include: {
submission_events: {
only: %i[id submitter_id event_type event_timestamp]
}
}
)
)
json[:submitters] = serialized_subbmitters
render json:
end
def create
is_send_email = !params[:send_email].in?(['false', false])
submissions =
if (emails = (params[:emails] || params[:email]).presence)
if (emails = (params[:emails] || params[:email]).presence) && params[:submission].blank?
Submissions.create_from_emails(template: @template,
user: current_user,
source: :api,
@@ -42,8 +82,28 @@ module Api
render json: { error: e.message }, status: :unprocessable_entity
end
def destroy
@submission.update!(deleted_at: Time.current)
render json: @submission.as_json(only: %i[id deleted_at])
end
private
def serialize_params
{
only: %i[id source submitters_order created_at updated_at],
include: {
submitters: { only: %i[id slug uuid name email phone
completed_at opened_at sent_at
created_at updated_at],
methods: %i[status] },
template: { only: %i[id name created_at updated_at] },
created_by_user: { only: %i[id email first_name last_name] }
}
}
end
def submissions_params
params.permit(submission: [{
submitters: [[:uuid, :name, :email, :role, :completed, :phone, { values: {} }]]
@@ -0,0 +1,13 @@
# frozen_string_literal: true
module Api
class SubmittersController < ApiBaseController
load_and_authorize_resource :submitter
def show
Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at?
render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true)
end
end
end
+33 -4
View File
@@ -5,22 +5,51 @@ module Api
load_and_authorize_resource :template
def index
render json: @templates
templates = Templates.search(@templates, params[:q])
templates = params[:archived] ? templates.archived : templates.active
templates = paginate(templates.preload(:author, documents_attachments: :blob))
render json: {
data: templates.as_json(serialize_params),
pagination: {
count: templates.size,
next: templates.last&.id,
prev: templates.first&.id
}
}
end
def show
render json: @template.as_json(include: { author: { only: %i[id email first_name last_name] },
documents: { only: %i[id uuid], methods: %i[url filename] } })
render json: @template.as_json(serialize_params)
end
def update
if (folder_name = params.dig(:template, :folder_name))
@template.folder = TemplateFolders.find_or_create_by_name(current_user, folder_name)
end
@template.update!(template_params)
render :ok
render json: @template.as_json(only: %i[id updated_at])
end
def destroy
@template.update!(deleted_at: Time.current)
render json: @template.as_json(only: %i[id deleted_at])
end
private
def serialize_params
{
include: { author: { only: %i[id email first_name last_name] },
documents: { only: %i[id uuid], methods: %i[url filename] } }
}
end
def template_params
params.require(:template).permit(:name,
schema: [%i[attachment_uuid name]],
+7
View File
@@ -118,3 +118,10 @@ button[disabled] .enabled {
@apply bg-base-300;
cursor: pointer;
}
.input-outlined {
outline-style: solid;
outline-width: 1px;
outline-offset: 3px;
outline-color: hsl(var(--bc) / 0.2);
}
+4 -2
View File
@@ -91,11 +91,13 @@ export default actionable(targetable(class extends HTMLElement {
this.append(input)
})
if (this.dataset.submitOnUpload) {
if (this.dataset.submitOnUpload === 'true') {
this.closest('form').querySelector('button[type="submit"]').click()
}
}).finally(() => {
this.toggleLoading()
if (this.dataset.submitOnUpload !== 'true') {
this.toggleLoading()
}
})
} else {
if (this.dataset.submitOnUpload) {
+1
View File
@@ -18,6 +18,7 @@ window.customElements.define('submission-form', class extends HTMLElement {
attribution: this.dataset.attribution !== 'false',
withConfetti: true,
values: reactive(JSON.parse(this.dataset.values)),
completedButton: JSON.parse(this.dataset.completedButton),
attachments: reactive(JSON.parse(this.dataset.attachments)),
fields: JSON.parse(this.dataset.fields)
})
@@ -52,6 +52,15 @@
Star on Github
</span>
</a>
<a
v-if="completedButton.url"
:href="completedButton.url"
class="white-button flex items-center space-x-1 w-full"
>
<span>
{{ completedButton.title || 'Back to Website' }}
</span>
</a>
<a
v-if="isDemo"
href="https://docuseal.co/sign_up"
@@ -115,6 +124,11 @@ export default {
type: Boolean,
required: false,
default: false
},
completedButton: {
type: Object,
required: false,
default: () => ({})
}
},
data () {
+46 -4
View File
@@ -317,6 +317,7 @@
v-else
:is-demo="isDemo"
:attribution="attribution"
:completed-button="completedButton"
:with-confetti="withConfetti"
:can-send-email="canSendEmail && !!submitter.email"
:submitter-slug="submitterSlug"
@@ -434,6 +435,11 @@ export default {
type: Object,
required: false,
default: () => ({})
},
completedButton: {
type: Object,
required: false,
default: () => ({})
}
},
data () {
@@ -442,6 +448,7 @@ export default {
isFormVisible: true,
currentStep: 0,
isSubmitting: false,
submittedValues: {},
recalculateButtonDisabledKey: ''
}
},
@@ -493,6 +500,8 @@ export default {
}
},
mounted () {
this.submittedValues = JSON.parse(JSON.stringify(this.values))
if (this.goToLast) {
this.currentStep = Math.min(
this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.values[f.uuid]))) + 1,
@@ -517,7 +526,10 @@ export default {
this.$nextTick(() => {
this.recalculateButtonDisabledKey = Math.random()
this.maybeTrackEmailClick().finally(() => {
Promise.all([
this.maybeTrackEmailClick(),
this.maybeTrackSmsClick()
]).finally(() => {
this.trackViewForm()
})
})
@@ -548,6 +560,30 @@ export default {
return Promise.resolve({})
}
},
maybeTrackSmsClick () {
const queryParams = new URLSearchParams(window.location.search)
if (queryParams.has('c')) {
const c = queryParams.get('c')
queryParams.delete('c')
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
window.history.replaceState({}, document.title, newUrl)
return fetch(this.baseUrl + '/api/submitter_sms_clicks', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
c,
submitter_slug: this.submitterSlug
})
})
} else {
return Promise.resolve({})
}
},
trackViewForm () {
fetch(this.baseUrl + '/api/submitter_form_views', {
method: 'POST',
@@ -594,9 +630,13 @@ export default {
: () => Promise.resolve({})
stepPromise().then(async () => {
const emptyRequiredField = this.stepFields.find((fields, index) => {
return index < this.currentStep && fields[0].required && fields[0].type === 'phone' && !this.submittedValues[fields[0].uuid]
})
const formData = new FormData(this.$refs.form)
if (this.currentStep === this.stepFields.length - 1) {
if (this.currentStep === this.stepFields.length - 1 && !emptyRequiredField) {
formData.append('completed', 'true')
}
@@ -609,10 +649,12 @@ export default {
return Promise.reject(new Error(data.error))
}
const nextStep = this.stepFields[this.currentStep + 1]
this.submittedValues[this.currentField.uuid] = this.values[this.currentField.uuid]
const nextStep = emptyRequiredField || this.stepFields[this.currentStep + 1]
if (nextStep) {
this.goToStep(this.stepFields[this.currentStep + 1], true)
this.goToStep(nextStep, true)
} else {
this.isCompleted = true
}
+63 -3
View File
@@ -6,12 +6,11 @@
<div class="flex justify-between py-1.5 items-center pr-4">
<div class="flex space-x-3">
<a
v-if="withLogoLink"
v-if="withLogo"
href="/"
>
<Logo />
</a>
<Logo v-else />
<Contenteditable
:model-value="template.name"
class="text-3xl font-semibold focus:text-clip"
@@ -269,7 +268,7 @@ export default {
required: false,
default: ''
},
withLogoLink: {
withLogo: {
type: Boolean,
required: false,
default: true
@@ -330,16 +329,21 @@ export default {
this.selectedSubmitter = this.template.submitters[0]
},
mounted () {
this.undoStack = [JSON.stringify(this.template)]
this.redoStack = []
this.$nextTick(() => {
this.onWindowResize()
})
document.addEventListener('keyup', this.onKeyUp)
window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('resize', this.onWindowResize)
},
unmounted () {
document.removeEventListener('keyup', this.onKeyUp)
window.removeEventListener('keydown', this.onKeyDown)
window.removeEventListener('resize', this.onWindowResize)
},
@@ -347,6 +351,36 @@ export default {
this.documentRefs = []
},
methods: {
undo () {
if (this.undoStack.length > 1) {
this.undoStack.pop()
const stringData = this.undoStack[this.undoStack.length - 1]
const currentStringData = JSON.stringify(this.template)
if (stringData && stringData !== currentStringData) {
this.redoStack.push(currentStringData)
Object.assign(this.template, JSON.parse(stringData))
this.save()
}
}
},
redo () {
const stringData = this.redoStack.pop()
this.lastRedoData = stringData
const currentStringData = JSON.stringify(this.template)
if (stringData && stringData !== currentStringData) {
if (this.undoStack[this.undoStack.length - 1] !== currentStringData) {
this.undoStack.push(currentStringData)
}
Object.assign(this.template, JSON.parse(stringData))
this.save()
}
},
onWindowResize (e) {
const breakpointLg = 1024
@@ -374,6 +408,19 @@ export default {
this.selectedAreaRef.value = null
}
},
onKeyDown (event) {
if ((event.metaKey && event.shiftKey && event.key === 'z') || (event.ctrlKey && event.key === 'Z')) {
event.stopImmediatePropagation()
event.preventDefault()
this.redo()
} else if ((event.ctrlKey || event.metaKey) && event.key === 'z') {
event.stopImmediatePropagation()
event.preventDefault()
this.undo()
}
},
removeArea (area) {
const field = this.template.fields.find((f) => f.areas?.includes(area))
@@ -385,6 +432,17 @@ export default {
this.save()
},
pushUndo () {
const stringData = JSON.stringify(this.template)
if (this.undoStack[this.undoStack.length - 1] !== stringData) {
this.undoStack.push(stringData)
if (this.lastRedoData !== stringData) {
this.redoStack = []
}
}
},
onDraw (area) {
if (this.drawField) {
this.drawField.areas ||= []
@@ -597,6 +655,8 @@ export default {
this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template)
}
this.pushUndo()
return this.baseFetch(`/api/templates/${this.template.id}`, {
method: 'PUT',
body: JSON.stringify({
+1
View File
@@ -26,6 +26,7 @@ class AccountConfig < ApplicationRecord
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
BCC_EMAILS = 'bcc_emails'
SUBMITTER_REMAILERS = 'submitter_reminders'
FORM_COMPLETED_BUTTON_KEY = 'form_completed_button'
DEFAULT_VALUES = {
SUBMITTER_INVITATION_EMAIL_KEY => {
+1
View File
@@ -42,6 +42,7 @@ class SubmissionEvent < ApplicationRecord
open_email: 'open_email',
click_email: 'click_email',
click_sms: 'click_sms',
phone_verified: 'phone_verified',
start_form: 'start_form',
view_form: 'view_form',
complete_form: 'complete_form'
+1
View File
@@ -58,6 +58,7 @@ class Template < ApplicationRecord
has_many :submissions, dependent: :destroy
scope :active, -> { where(deleted_at: nil) }
scope :archived, -> { where.not(deleted_at: nil) }
private
+30 -27
View File
@@ -12,32 +12,6 @@
</div>
</div>
<div class="space-y-4 mt-4">
<div class="collapse collapse-plus bg-base-200 px-1">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
<div>
Request signature, single submitter
</div>
<div class="mt-1">
<div class="badge badge-warning badge-lg">POST</div>
<div class="badge badge-primary badge-lg"><%= api_submissions_path %></div>
</div>
</div>
<div class="collapse-content" style="display: inherit">
<div class="mockup-code overflow-hidden">
<% text = capture do %>curl --location '<%= api_submissions_url %>' \
--header 'X-Auth-Token: <%= current_user.access_token.token %>' \
--data-raw '{
"template_id": <%= current_account.templates.last&.id || 1 %>,
"emails": "<%= current_user.email.sub('@', '+test@') %>, <%= current_user.email.sub('@', '+test2@') %>"
}'<% end.to_str %>
<span class="top-0 right-0 absolute">
<%= render 'shared/clipboard_copy', icon: 'copy', text:, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy', copied_title: 'Copied' %>
</span>
<pre data-prefix="$"><code class="overflow-hidden w-full"><%= text %></code></pre>
</div>
</div>
</div>
<div class="collapse collapse-plus bg-base-200 px-1">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
@@ -66,7 +40,7 @@
"Form Text Field Name": "Default Value"
}
},
{ "name": "Second Submitter", "email": "<%= current_user.email.sub('@', '+test2@') %>" }
{ "role": "Second Submitter", "email": "<%= current_user.email.sub('@', '+test2@') %>" }
]
}
]
@@ -78,6 +52,32 @@
</div>
</div>
</div>
<div class="collapse collapse-plus bg-base-200 px-1">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
<div>
Request signature, single submitter
</div>
<div class="mt-1">
<div class="badge badge-warning badge-lg">POST</div>
<div class="badge badge-primary badge-lg"><%= api_submissions_emails_path %></div>
</div>
</div>
<div class="collapse-content" style="display: inherit">
<div class="mockup-code overflow-hidden">
<% text = capture do %>curl --location '<%= api_submissions_emails_url %>' \
--header 'X-Auth-Token: <%= current_user.access_token.token %>' \
--data-raw '{
"template_id": <%= current_account.templates.last&.id || 1 %>,
"emails": "<%= current_user.email.sub('@', '+test@') %>, <%= current_user.email.sub('@', '+test2@') %>"
}'<% end.to_str %>
<span class="top-0 right-0 absolute">
<%= render 'shared/clipboard_copy', icon: 'copy', text:, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy', copied_title: 'Copied' %>
</span>
<pre data-prefix="$"><code class="overflow-hidden w-full"><%= text %></code></pre>
</div>
</div>
</div>
<div class="collapse collapse-plus bg-base-200 px-1">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
@@ -101,5 +101,8 @@
</div>
</div>
</div>
<div class="text-center">
<%= link_to 'Open Full API Reference', "#{Docuseal::PRODUCT_URL}/docs/api", class: 'btn btn-warning text-base mt-4 px-8', target: '_blank', rel: 'noopener' %>
</div>
</div>
</div>
+33 -9
View File
@@ -26,20 +26,44 @@
<div class="grid gap-4 md:grid-cols-3 <%= 'mb-6' if @templates.present? %>">
<%= render partial: 'template_folders/folder', collection: @template_folders, as: :folder %>
</div>
<% if @templates.blank? %>
<% if @pagy.pages > 1 %>
<%= render 'shared/pagination', pagy: @pagy, left_additional_html: view_archived_html %>
<% elsif params[:q].blank? %>
<div class="mt-2">
<%= view_archived_html %>
</div>
<% end %>
<% end %>
<% end %>
<% if @templates.present? %>
<div class="grid gap-4 md:grid-cols-3">
<%= render partial: 'templates/template', collection: @templates %>
</div>
<% end %>
<% if params[:q].blank? && @pagy.pages == 1 && ((@template_folders.size < 10 && @templates.size.zero?) || (@template_folders.size < 7 && @templates.size < 4) || (@template_folders.size < 4 && @templates.size < 7)) %>
<%= form_for '', url: templates_upload_path, method: :post, class: 'mt-8 block', html: { enctype: 'multipart/form-data' } do %>
<button type="submit" class="hidden"></button>
<file-dropzone data-submit-on-upload="true" class="w-full">
<label for="file_dropzone_input" class="w-full block h-52 relative hover:bg-base-200/30 rounded-xl border border-2 border-base-300 border-dashed">
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
<div class="flex flex-col items-center">
<span data-target="file-dropzone.icon" class="flex flex-col items-center">
<span>
<%= svg_icon('cloud_upload', class: 'w-10 h-10') %>
</span>
<div class="font-medium mb-1">
Upload New Document
</div>
<div class="text-xs">
<span class="font-medium">Click to upload</span> or drag and drop
</div>
</span>
<span data-target="file-dropzone.loading" class="flex flex-col items-center hidden">
<%= svg_icon('loader', class: 'w-10 h-10 animate-spin') %>
<div class="font-medium mb-1">
Uploading...
</div>
</span>
</div>
<input id="file_dropzone_input" name="files[]" class="hidden" data-action="change:file-dropzone#onSelectFiles" data-target="file-dropzone.input" type="file" accept="image/*, application/pdf<%= ', .docx, .doc, .xlsx, .xls' if Docuseal.multitenant? %>" multiple>
</div>
</label>
</file-dropzone>
<% end %>
<% end %>
<% if @templates.present? || params[:q].blank? %>
<% if @pagy.pages > 1 %>
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', left_additional_html: view_archived_html %>
<% else %>
+2 -2
View File
@@ -18,11 +18,11 @@
<div class="grid md:grid-cols-2 gap-4">
<div class="form-control">
<%= ff.label :username, class: 'label' %>
<%= ff.text_field :username, value: value['username'], required: true, class: 'base-input' %>
<%= ff.text_field :username, value: value['username'], class: 'base-input' %>
</div>
<div class="form-control">
<%= ff.label :password, class: 'label' %>
<%= ff.password_field :password, value: value['password'], required: true, class: 'base-input' %>
<%= ff.password_field :password, value: value['password'], class: 'base-input' %>
</div>
</div>
<div class="grid md:grid-cols-2 gap-4">
@@ -0,0 +1,26 @@
<div class="collapse collapse-plus bg-base-200">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
<div>
Completed Form Redirect Button
</div>
</div>
<div class="collapse-content">
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::FORM_COMPLETED_BUTTON_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<%= f.hidden_field :key %>
<%= f.fields_for :value, Struct.new(:title, :url).new(*(f.object.value || {}).values_at('title', 'url')) do |ff| %>
<div class="form-control">
<%= ff.label :title, 'Button title', class: 'label' %>
<%= ff.text_field :title, class: 'base-input' %>
</div>
<div class="form-control">
<%= ff.label :url, 'Button URL', class: 'label' %>
<%= ff.url_field :url, class: 'base-input' %>
</div>
<% end %>
<div class="form-control pt-2">
<%= f.button button_title(title: 'Save', disabled_with: 'Saving'), class: 'base-button' %>
</div>
<% end %>
</div>
</div>
@@ -9,6 +9,8 @@
</div>
<p class="text-4xl font-bold mb-4 mt-8">Company Logo</p>
<%= render 'logo_form' %>
<p class="text-4xl font-bold mb-4 mt-8">Submitter Form</p>
<%= render 'form_completed_button_form' %>
</div>
<div class="w-0 md:w-52"></div>
</div>
+1 -1
View File
@@ -6,7 +6,7 @@
</a>
<% end %>
</div>
<input id="search" name="q" value="<%= params[:q] %>" class="input input-ghost text-lg pr-10 -mr-12 <%= 'pl-8 input-bordered' if params[:q].present? %>">
<input id="search" name="q" value="<%= params[:q] %>" class="input input-ghost text-lg pr-10 -mr-12 <%= 'pl-8 input-outlined' if params[:q].present? %>">
<button type="submit" title="Search" class="btn btn-ghost btn-circle" onclick="window.search.value || document.activeElement === window.search ? null : [event.preventDefault(), window.search.focus()]">
<span class="enabled">
<%= svg_icon('search', class: 'w-6 h-6 stroke-2') %>
@@ -1,3 +1,4 @@
<% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid], methods: %i[url filename content_type]) %>
<% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| f['submitter_uuid'] == submitter.uuid }.to_json %>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-go-to-last="<%= submitter.opened_at? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-authenticity-token="<%= form_authenticity_token %>" data-values="<%= submitter.values.to_json %>"></submission-form>
<% completed_button_params = submitter.submission.template.account.account_configs.find_by(key: AccountConfig::FORM_COMPLETED_BUTTON_KEY)&.value || {} %>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-completed-button="<%= completed_button_params.to_json %>" data-go-to-last="<%= submitter.opened_at? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-authenticity-token="<%= form_authenticity_token %>" data-values="<%= submitter.values.to_json %>"></submission-form>
@@ -1,5 +1,8 @@
<% if @email_config || @body.present? %>
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(@body.presence || @email_config.value['body'], submitter: @submitter)))) %>
<% if !(@body.presence || @email_config.value['body']).include?(ReplaceEmailVariables::SUBMITTER_LINK) %>
<p><%= link_to nil, submit_form_url(slug: @submitter.slug, t: SubmissionEvents.build_tracking_param(@submitter, 'click_email')) %></p>
<% end %>
<% else %>
<p>Hi there,</p>
<p>You have been invited to submit the "<%= @submitter.submission.template.name %>" form</p>
+2
View File
@@ -1,5 +1,7 @@
# frozen_string_literal: true
require 'rollbar' if ENV.key?('ROLLBAR_ACCESS_TOKEN')
if defined?(Rollbar)
Rollbar.configure do |config|
config.access_token = ENV.fetch('ROLLBAR_ACCESS_TOKEN', nil)
+8 -3
View File
@@ -35,9 +35,14 @@ Rails.application.routes.draw do
resources :template_folders_autocomplete, only: %i[index]
resources :submitter_email_clicks, only: %i[create]
resources :submitter_form_views, only: %i[create]
resources :submissions, only: %i[create]
resources :templates, only: %i[update show index] do
resources :submissions, only: %i[create]
resources :submitters, only: %i[show]
resources :submissions, only: %i[index show create destroy] do
collection do
resources :emails, only: %i[create], controller: 'submissions', as: :submissions_emails
end
end
resources :templates, only: %i[update show index destroy] do
resources :submissions, only: %i[index create]
resources :documents, only: %i[create], controller: 'templates_documents'
end
end
+16 -8
View File
@@ -12,8 +12,8 @@ module ReplaceEmailVariables
module_function
def call(text, submitter:)
submitter_link = build_submitter_link(submitter)
def call(text, submitter:, tracking_event_type: 'click_email')
submitter_link = build_submitter_link(submitter, tracking_event_type)
submission_link = build_submission_link(submitter.submission) if submitter.submission
@@ -43,12 +43,20 @@ module ReplaceEmailVariables
end.join
end
def build_submitter_link(submitter)
Rails.application.routes.url_helpers.submit_form_url(
slug: submitter.slug,
t: SubmissionEvents.build_tracking_param(submitter, 'click_email'),
**Docuseal.default_url_options
)
def build_submitter_link(submitter, tracking_event_type)
if tracking_event_type == 'click_email'
Rails.application.routes.url_helpers.submit_form_url(
slug: submitter.slug,
t: SubmissionEvents.build_tracking_param(submitter, 'click_email'),
**Docuseal.default_url_options
)
else
Rails.application.routes.url_helpers.submit_form_url(
slug: submitter.slug,
c: SubmissionEvents.build_tracking_param(submitter, 'click_sms'),
**Docuseal.default_url_options
)
end
end
def build_submission_link(submission)
+1
View File
@@ -10,6 +10,7 @@ module SubmissionEvents
open_email: 'Email opened',
click_email: 'Email link clicked',
click_sms: 'SMS link clicked',
phone_verified: 'Phone verified',
start_form: 'Submission started',
view_form: 'Form viewed',
complete_form: 'Submission completed'
+6 -4
View File
@@ -119,9 +119,11 @@ module Submissions
]
end
composer.table(documents_data, cell_style: { padding: [0, 0, 25, 0], border: { width: 0 } })
if documents_data.present?
composer.table(documents_data, cell_style: { padding: [0, 0, 25, 0], border: { width: 0 } })
composer.draw_box(divider)
composer.draw_box(divider)
end
submission.template_submitters.filter_map do |item|
submitter = submission.submitters.find { |e| e.uuid == item['uuid'] }
@@ -239,10 +241,10 @@ module Submissions
{ text: SubmissionEvents::EVENT_NAMES[event.event_type.to_sym],
font: [FONT_BOLD_NAME, { variant: :bold }] },
event.event_type.include?('send_') ? ' to ' : ' by ',
if event.event_type.include?('sms')
if event.event_type.include?('sms') || event.event_type.include?('phone')
submitter.phone
else
(submitter.email || submitter.name || submitter.phone)
(submitter.name || submitter.email || submitter.phone)
end
]
)
+38
View File
@@ -0,0 +1,38 @@
# frozen_string_literal: true
module Submitters
module SerializeForApi
module_function
def call(submitter, with_template: false, with_events: false)
ActiveRecord::Associations::Preloader.new(
records: [submitter],
associations: [documents_attachments: :blob, attachments_attachments: :blob]
).call
values = SerializeForWebhook.build_values_array(submitter)
documents = SerializeForWebhook.build_documents_array(submitter)
submitter_name = (submitter.submission.template_submitters ||
submitter.submission.template.submitters).find { |e| e['uuid'] == submitter.uuid }['name']
serialize_params = {
include: {},
only: %i[id slug uuid name email phone completed_at
opened_at sent_at created_at updated_at]
}
serialize_params[:include][:template] = { only: %i[id name created_at updated_at] } if with_template
if with_events
serialize_params[:include][:submission_events] =
{ as: :events, only: %i[id submitter_id event_type event_timestamp] }
end
submitter.as_json(serialize_params)
.merge('values' => values,
'documents' => documents,
'role' => submitter_name)
end
end
end
+7 -2
View File
@@ -5,6 +5,11 @@ module Submitters
module_function
def call(submitter)
ActiveRecord::Associations::Preloader.new(
records: [submitter],
associations: [documents_attachments: :blob, attachments_attachments: :blob]
).call
values = build_values_array(submitter)
documents = build_documents_array(submitter)
@@ -21,7 +26,7 @@ module Submitters
def build_values_array(submitter)
fields_index = (submitter.submission.template_fields ||
submitter.submission.template.fields).index_by { |e| e['uuid'] }
attachments_index = submitter.attachments.preload(:blob).index_by(&:uuid)
attachments_index = submitter.attachments.index_by(&:uuid)
submitter_field_counters = Hash.new { 0 }
submitter.values.map do |uuid, value|
@@ -38,7 +43,7 @@ module Submitters
end
def build_documents_array(submitter)
submitter.documents.preload(:blob).map do |attachment|
submitter.documents.map do |attachment|
{ name: attachment.filename.base, url: rails_storage_proxy_url(attachment) }
end
end
+10 -8
View File
@@ -29,8 +29,6 @@ module Submitters
def update_submitter!(submitter, params, request)
values = normalized_values(params)
validate_values!(values, submitter, params)
submitter.values.merge!(values)
submitter.opened_at ||= Time.current
@@ -38,11 +36,15 @@ module Submitters
submitter.completed_at = Time.current
submitter.ip = request.remote_ip
submitter.ua = request.user_agent
SubmissionEvents.create_with_tracking_data(submitter, 'complete_form', request)
end
submitter.save!
ApplicationRecord.transaction do
validate_values!(values, submitter, params, request)
SubmissionEvents.create_with_tracking_data(submitter, 'complete_form', request) if params[:completed] == 'true'
submitter.save!
end
submitter
end
@@ -59,15 +61,15 @@ module Submitters
end
end
def validate_values!(values, submitter, params)
def validate_values!(values, submitter, params, request)
values.each do |key, value|
field = submitter.submission.template_fields.find { |e| e['uuid'] == key }
validate_value!(value, field, params)
validate_value!(value, field, params, submitter, request)
end
end
def validate_value!(_value, _field, _params)
def validate_value!(_value, _field, _params, _submitter, _request)
true
end
end
+1 -1
View File
@@ -17,7 +17,7 @@ module Templates
build_external_link_hash(page, annot).merge('page' => index)
end
end
rescue PDF::Reader::MalformedPDFError
rescue PDF::Reader::MalformedPDFError, OpenSSL::Cipher::CipherError
[]
end