Compare commits

...
37 Commits
Author SHA1 Message Date
Alex Turchyn bb3b38e76f Merge from docusealco/wip 2024-12-18 21:30:59 +02:00
Alex Turchyn bc4c21f804 fix CI 2024-12-18 21:11:07 +02:00
Pete Matsyburka 2fb5de4fa9 fix submission schema 2024-12-18 16:53:35 +02:00
Pete Matsyburka 2e58b2e37b disable certs 2024-12-17 23:25:04 +02:00
Alex Turchyn 780f5512f4 Merge from docusealco/wip 2024-12-16 18:24:32 +02:00
Pete Matsyburka 2cf4908d53 fix filter 2024-12-13 23:19:38 +02:00
Pete Matsyburka 4a9b898713 edit required field 2024-12-13 21:24:30 +02:00
Alex Turchyn bb552d13d4 don't raise validation error if the message is empty 2024-12-13 20:55:37 +02:00
Pete Matsyburka 15d06f4fb0 add status filters 2024-12-13 20:46:26 +02:00
Pete Matsyburka 35a2aba887 adjust permanently remove 2024-12-13 13:31:54 +02:00
Pete Matsyburka 48a82b1d49 add import image 2024-12-13 12:48:44 +02:00
Pete Matsyburka b8453e14c4 add version 2024-12-13 12:04:45 +02:00
Pete Matsyburka 29fbdfbe2f cells align 2024-12-13 00:24:29 +02:00
Pete Matsyburka 5340af0bc6 remove reason: 2024-12-12 19:16:24 +02:00
Pete Matsyburka 1881b6c50f add sidekiq queue 2024-12-12 18:38:14 +02:00
Alex Turchyn b6a2aae970 restrict user invites 2024-12-12 14:04:09 +02:00
Pete Matsyburka b317094192 custom field width height 2024-12-11 19:32:29 +02:00
Pete Matsyburka ab1f4cf297 refactor clone 2024-12-10 23:25:53 +02:00
Pete Matsyburka d3033c072a update document name on clone 2024-12-10 22:27:37 +02:00
Pete Matsyburka f380200e03 add clone document params 2024-12-10 17:42:49 +02:00
Pete Matsyburka a4928268ea fix folder templates 2024-12-09 21:48:47 +02:00
Alex Turchyn dd18c51384 Merge from docusealco/wip 2024-12-09 17:25:23 +02:00
Alex Turchyn c2014a1549 fix field drawings on tablets 2024-12-09 14:43:44 +02:00
Pete Matsyburka 46edc7dc37 adjust title 2024-12-09 12:55:02 +02:00
Pete Matsyburka d62d83ef8e fix duplciate email 2024-12-09 01:06:57 +02:00
Pete Matsyburka 79350c8fba fix submission page 2024-12-08 17:50:23 +02:00
Pete Matsyburka 6702dd52b6 update dep 2024-12-08 15:19:39 +02:00
Pete Matsyburka c8b74a2c43 adjust role 2024-12-08 15:12:02 +02:00
Pete Matsyburka 1746920eb5 add template access 2024-12-08 14:52:22 +02:00
Pete Matsyburka bacbc4309c skip heading 2024-12-07 12:59:28 +02:00
Pete Matsyburka 4429d742b3 document conditions 2024-12-07 02:04:50 +02:00
Pete Matsyburka 38b5eccb65 adjust readonly fields 2024-12-06 00:47:40 +02:00
Pete Matsyburka d8126c4a47 uniq submission.submitters 2024-12-05 19:39:10 +02:00
Pete Matsyburka b1c31672ee use tz country code 2024-12-04 17:40:46 +02:00
Pete Matsyburka 975f3a95fe add db search path 2024-12-04 14:00:13 +02:00
Alex Turchyn e3bed5ae67 add iso codes to phone data 2024-12-04 12:16:55 +02:00
Pete Matsyburka 4737b103c0 fix i18n 2024-12-02 20:17:39 +02:00
79 changed files with 992 additions and 232 deletions

No files matched your search

+1
View File
@@ -68,6 +68,7 @@ COPY ./lib ./lib
COPY ./public ./public
COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru .version ./
COPY .version ./public/version
COPY --from=fonts /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --from=fonts /fonts/FreeSans.ttf /usr/share/fonts/freefont
+7 -7
View File
@@ -298,7 +298,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.22.0)
loofah (2.23.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -311,7 +311,7 @@ GEM
method_source (1.1.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
mini_portile2 (2.8.7)
mini_portile2 (2.8.8)
minitest (5.25.1)
msgpack (1.7.3)
multi_json (1.15.0)
@@ -330,12 +330,12 @@ GEM
net-smtp (0.5.0)
net-protocol
nio4r (2.7.3)
nokogiri (1.16.7)
nokogiri (1.16.8)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.16.7-aarch64-linux)
nokogiri (1.16.8-aarch64-linux)
racc (~> 1.4)
nokogiri (1.16.7-x86_64-linux)
nokogiri (1.16.8-x86_64-linux)
racc (~> 1.4)
oj (3.16.6)
bigdecimal (>= 3.0)
@@ -400,9 +400,9 @@ GEM
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0)
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
nokogiri (~> 1.14)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (7.0.9)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
@@ -78,7 +78,9 @@ module Api
submissions.each do |submission|
submission.submitters.each do |submitter|
ProcessSubmitterCompletionJob.perform_async('submitter_id' => submitter.id) if submitter.completed_at?
next unless submitter.completed_at?
ProcessSubmitterCompletionJob.perform_async('submitter_id' => submitter.id, 'send_invitation_email' => false)
end
end
@@ -91,7 +93,7 @@ module Api
end
def destroy
if params[:permanently] == 'true'
if params[:permanently].in?(['true', true])
@submission.destroy!
else
@submission.update!(archived_at: Time.current)
@@ -23,7 +23,9 @@ module Api
cloned_template.source = :api
cloned_template.save!
schema_documents = Templates::CloneAttachments.call(template: cloned_template, original_template: @template)
schema_documents = Templates::CloneAttachments.call(template: cloned_template,
original_template: @template,
documents: params[:documents])
WebhookUrls.for_account_id(cloned_template.account_id, 'template.created').each do |webhook_url|
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => cloned_template.id,
+1 -1
View File
@@ -74,7 +74,7 @@ module Api
end
def destroy
if params[:permanently] == 'true'
if params[:permanently].in?(['true', true])
@template.destroy!
else
@template.update!(archived_at: Time.current)
@@ -8,6 +8,9 @@ class SubmissionsArchivedController < ApplicationController
@submissions = @submissions.where.not(archived_at: nil)
.or(@submissions.where.not(templates: { archived_at: nil }))
.preload(:created_by_user, template: :author)
@submissions = @submissions.preload(:template_accesses) unless current_user.role.in?(%w[admin superadmin])
@submissions = Submissions.search(@submissions, params[:q], search_template: true)
@submissions = Submissions::Filter.call(@submissions, current_user, params)
+1 -1
View File
@@ -61,7 +61,7 @@ class SubmissionsController < ApplicationController
def destroy
notice =
if params[:permanently].present?
if params[:permanently].in?(['true', true])
@submission.destroy!
I18n.t('submission_has_been_removed')
@@ -10,12 +10,11 @@ class SubmissionsDashboardController < ApplicationController
.where(templates: { archived_at: nil })
.preload(:created_by_user, template: :author)
@submissions = @submissions.preload(:template_accesses) unless current_user.role.in?(%w[admin superadmin])
@submissions = Submissions.search(@submissions, params[:q], search_template: true)
@submissions = Submissions::Filter.call(@submissions, current_user, params)
@submissions = @submissions.pending if params[:status] == 'pending'
@submissions = @submissions.completed if params[:status] == 'completed'
@submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
@submissions.order(Submitter.arel_table[:completed_at].maximum.desc)
else
@@ -4,6 +4,7 @@ class SubmissionsFiltersController < ApplicationController
ALLOWED_NAMES = %w[
author
completed_at
status
created_at
].freeze
@@ -9,7 +9,8 @@ class SubmittersAutocompleteController < ApplicationController
def index
submitters = search_submitters(@submitters)
values = submitters.limit(LIMIT).group(SELECT_COLUMNS.join(', ')).pluck(SELECT_COLUMNS.join(', '))
arel_columns = SELECT_COLUMNS.map { |col| Submitter.arel_table[col] }
values = submitters.limit(LIMIT).group(arel_columns).pluck(arel_columns)
attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h }
attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present?
@@ -4,7 +4,8 @@ class TemplateFoldersController < ApplicationController
load_and_authorize_resource :template_folder
def show
@templates = @template_folder.templates.active.preload(:author).order(id: :desc)
@templates = @template_folder.templates.active.accessible_by(current_ability)
.preload(:author, :template_accesses).order(id: :desc)
@templates = Templates.search(@templates, params[:q])
@pagy, @templates = pagy(@templates, limit: 12)
@@ -4,7 +4,7 @@ class TemplatesArchivedController < ApplicationController
load_and_authorize_resource :template, parent: false
def index
@templates = @templates.where.not(archived_at: nil).preload(:author, :folder).order(id: :desc)
@templates = @templates.where.not(archived_at: nil).preload(:author, :folder, :template_accesses).order(id: :desc)
@templates = Templates.search(@templates, params[:q])
@pagy, @templates = pagy(@templates, limit: 12)
+6 -5
View File
@@ -9,12 +9,11 @@ class TemplatesController < ApplicationController
submissions = @template.submissions.accessible_by(current_ability)
submissions = submissions.active if @template.archived_at.blank?
submissions = Submissions.search(submissions, params[:q], search_values: true)
submissions = Submissions::Filter.call(submissions, current_user, params)
submissions = Submissions::Filter.call(submissions, current_user, params.except(:status))
@base_submissions = submissions
submissions = submissions.pending if params[:status] == 'pending'
submissions = submissions.completed if params[:status] == 'completed'
submissions = Submissions::Filter.filter_by_status(submissions, params)
submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
submissions.order(Submitter.arel_table[:completed_at].maximum.desc)
@@ -22,6 +21,8 @@ class TemplatesController < ApplicationController
submissions.order(id: :desc)
end
submissions = submissions.preload(:template_accesses) unless current_user.role.in?(%w[admin superadmin])
@pagy, @submissions = pagy(submissions.preload(submitters: :start_form_submission_events))
rescue ActiveRecord::RecordNotFound
redirect_to root_path
@@ -91,7 +92,7 @@ class TemplatesController < ApplicationController
def destroy
notice =
if params[:permanently].present?
if params[:permanently].in?(['true', true])
@template.destroy!
I18n.t('template_has_been_removed')
@@ -109,7 +110,7 @@ class TemplatesController < ApplicationController
def template_params
params.require(:template).permit(
:name,
{ schema: [%i[attachment_uuid name]],
{ schema: [[:attachment_uuid, :name, { conditions: [%i[field_uuid value action operation]] }]],
submitters: [%i[name uuid is_requester linked_to_uuid invite_by_uuid email]],
fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value,
@@ -9,7 +9,9 @@ class TemplatesDashboardController < ApplicationController
FOLDERS_PER_PAGE = 18
def index
@template_folders = filter_template_folders(@template_folders)
@template_folders = @template_folders.where(id: @templates.active.select(:folder_id)).order(id: :desc)
@template_folders = TemplateFolders.search(@template_folders, params[:q])
@pagy, @template_folders = pagy(
@template_folders,
@@ -36,16 +38,8 @@ class TemplatesDashboardController < ApplicationController
private
def filter_template_folders(template_folders)
rel = template_folders.joins(:active_templates)
.order(id: :desc)
.distinct
TemplateFolders.search(rel, params[:q])
end
def filter_templates(templates)
rel = templates.active.preload(:author).order(id: :desc)
rel = templates.active.preload(:author, :template_accesses).order(id: :desc)
if params[:q].blank?
if Docuseal.multitenant? && !current_account.testing?
+6 -15
View File
@@ -24,16 +24,14 @@ class UsersController < ApplicationController
def edit; end
def create
existing_user = User.accessible_by(current_ability).find_by(email: @user.email)
if User.accessible_by(current_ability).exists?(email: @user.email)
@user.errors.add(:email, I18n.t('already_exists'))
if existing_user
existing_user.archived_at = nil
existing_user.assign_attributes(user_params)
existing_user.account = current_account
@user = existing_user
return render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_entity
end
@user.role = User::ADMIN_ROLE unless role_valid?(@user.role)
if @user.save
UserMailer.invitation_email(@user).deliver_later!
@@ -81,14 +79,7 @@ class UsersController < ApplicationController
end
def build_user
@user = current_account.users.find_by(email: user_params[:email])&.tap do |user|
user.assign_attributes(user_params)
user.archived_at = nil
end
@user ||= current_account.users.new(user_params)
@user
@user = current_account.users.new(user_params)
end
def user_params
+1
View File
@@ -15,6 +15,7 @@ safeRegisterElement('submission-form', class extends HTMLElement {
this.app = createApp(Form, {
submitter: JSON.parse(this.dataset.submitter),
inviteSubmitters: JSON.parse(this.dataset.inviteSubmitters),
schema: JSON.parse(this.dataset.schema),
canSendEmail: this.dataset.canSendEmail === 'true',
previousSignatureValue: this.dataset.previousSignatureValue,
goToLast: this.dataset.goToLast === 'true',
+2 -1
View File
@@ -74,7 +74,7 @@
ID: {{ signature.uuid }}
</div>
<div>
{{ t('reason') }}: {{ values[field.preferences?.reason_field_uuid] || t('digitally_signed_by') }} {{ submitter.name }}
<span v-if="values[field.preferences?.reason_field_uuid]">{{ t('reason') }}: </span>{{ values[field.preferences?.reason_field_uuid] || t('digitally_signed_by') }} {{ submitter.name }}
<template v-if="submitter.email">
&lt;{{ submitter.email }}&gt;
</template>
@@ -168,6 +168,7 @@
<div
v-else-if="field.type === 'cells'"
class="w-full flex items-center"
:class="{ 'justify-end': field.preferences?.align === 'right' }"
>
<div
v-for="(char, index) in modelValue"
+50 -3
View File
@@ -15,8 +15,9 @@
/>
<FieldAreas
:steps="readonlyConditionalFields.map((e) => [e])"
:values="readonlyConditionalFields.reduce((acc, f) => { acc[f.uuid] = f.default_value; return acc }, {})"
:values="readonlyConditionalFields.reduce((acc, f) => { acc[f.uuid] = (values[f.uuid] || f.default_value); return acc }, {})"
:submitter="submitter"
:attachments-index="attachmentsIndex"
:submittable="false"
/>
<FormulaFieldAreas
@@ -651,6 +652,11 @@ export default {
required: false,
default: null
},
schema: {
type: Array,
required: false,
default: () => []
},
attachments: {
type: Array,
required: false,
@@ -840,6 +846,21 @@ export default {
isMobile () {
return /android|iphone|ipad/i.test(navigator.userAgent)
},
attachmentConditionsIndex () {
return this.schema.reduce((acc, item) => {
if (item.conditions?.length) {
if (item.conditions.every((c) => this.fieldsUuidIndex[c.field_uuid])) {
acc[item.attachment_uuid] = this.checkFieldConditions(item)
} else {
acc[item.attachment_uuid] = true
}
} else {
acc[item.attachment_uuid] = true
}
return acc
}, {})
},
emptyValueRequiredStep () {
return this.stepFields.find((fields, index) => {
return fields.some((f) => {
@@ -920,7 +941,7 @@ export default {
return this.currentStepFields[0]
},
readonlyConditionalFields () {
return this.fields.filter((f) => f.readonly && f.conditions?.length && this.checkFieldConditions(f))
return this.fields.filter((f) => f.readonly && f.conditions?.length && this.checkFieldConditions(f) && this.checkFieldDocumentsConditions(f))
},
stepFields () {
const verificationFields = []
@@ -942,7 +963,7 @@ export default {
return sortedFields.reduce((acc, f) => {
const prevStep = acc[acc.length - 1]
if (this.checkFieldConditions(f)) {
if (this.checkFieldConditions(f) && this.checkFieldDocumentsConditions(f)) {
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox' && !f.description) {
prevStep.push(f)
} else {
@@ -975,6 +996,23 @@ export default {
if (isEmpty(value) && this.currentStep > 0) {
this.currentStep -= 1
}
},
attachmentConditionsIndex: {
deep: true,
immediate: true,
handler (value) {
this.$nextTick(() => {
const root = this.$root.$el.parentNode.getRootNode()
for (const key in value) {
const doc = root.querySelector(`[id="document-${key}"`)
if (doc) {
doc.classList.toggle('hidden', !value[key])
}
}
})
}
}
},
beforeUnmount () {
@@ -1056,6 +1094,15 @@ export default {
onOrientationChange (event) {
this.orientation = event.target.type
},
checkFieldDocumentsConditions (field) {
if (field.areas?.length) {
return field.areas.some((area) => {
return this.attachmentConditionsIndex[area.attachment_uuid]
})
} else {
return true
}
},
checkFieldConditions (field) {
if (field.conditions?.length) {
const result = field.conditions.reduce((acc, cond) => {
+2 -2
View File
@@ -780,7 +780,7 @@ const pt = {
verify_id: 'Verificar ID',
identity_verification: 'Verificação de identidade',
complete: 'Completar',
preencher_todos_os_campos_obrigatórios_para_concluir: 'Preencher todos os campos obrigatórios para concluir',
fill_all_required_fields_to_complete: 'Preencher todos os campos obrigatórios para concluir',
sign_and_complete: 'Assinar e Completar',
invite: 'Convidar',
email: 'E-mail',
@@ -975,7 +975,7 @@ const nl = {
verify_id: 'Verifiëren ID',
identity_verification: 'Identiteitsverificatie',
complete: 'Voltooien',
vul_alle_verplichte_velden_in_om_te_voltooien: 'Vul alle verplichte velden in om te voltooien',
fill_all_required_fields_to_complete: 'Vul alle verplichte velden in om te voltooien',
sign_and_complete: 'Ondertekenen en voltooien',
invite: 'Uitnodigen',
email: 'E-mail',
File diff suppressed because it is too large. Load diff
@@ -180,8 +180,8 @@ export default {
},
computed: {
countries () {
return phoneData.map(([name, dial, flag, tz]) => {
return { name, dial, flag, tz }
return phoneData.map(([iso, name, dial, flag, tz]) => {
return { iso, name, dial, flag, tz }
})
},
countriesDialIndex () {
@@ -95,7 +95,7 @@ export default {
countryCode () {
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
const browserTz = browserTimeZone.split('/')[1]
const country = phoneData.find(([a, b, c, tz]) => tz.includes(browserTz))
const country = phoneData.find(([a, b, c, e, tz]) => tz.includes(browserTz))
return country[0]
},
@@ -107,7 +107,7 @@ export default {
clientId: this.eidEasyData.client_id,
docId: this.eidEasyData.doc_id,
language: this.locale,
countryCode: this.browserCountry,
countryCode: this.countryCode,
enabledMethods: {
signature: this.eidEasyData.available_methods
},
+2 -2
View File
@@ -237,7 +237,7 @@
/>
<span
v-if="field?.type && editable"
class="h-4 w-4 md:h-2.5 md:w-2.5 -right-1 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-nwse-resize"
class="h-4 w-4 lg:h-2.5 lg:w-2.5 -right-1 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-nwse-resize"
@mousedown.stop="startResize"
@touchstart="startTouchResize"
/>
@@ -257,7 +257,7 @@
:to="modalContainerEl"
>
<ConditionsModal
:field="field"
:item="field"
:build-default-name="buildDefaultName"
@close="isShowConditionsModal = false"
/>
+40 -7
View File
@@ -1,7 +1,8 @@
<template>
<div
style="max-width: 1600px"
class="mx-auto pl-3 md:pl-4 h-full"
class="mx-auto pl-3 h-full"
:class="isMobile ? 'pl-4' : 'md:pl-4'"
>
<div
v-if="pendingFieldAttachmentUuids.length && editable"
@@ -163,7 +164,7 @@
<div
id="main_container"
class="flex"
:class="$slots.buttons || withTitle ? 'md:max-h-[calc(100%_-_60px)]' : 'md:max-h-[100%]'"
:class="$slots.buttons || withTitle ? (isMobile ? 'max-h-[calc(100%_-_60px)]' : 'md:max-h-[calc(100%_-_60px)]') : (isMobile ? 'max-h-[100%]' : 'md:max-h-[100%]')"
>
<div
v-if="withDocumentsList"
@@ -219,7 +220,8 @@
</div>
<div
id="pages_container"
class="w-full overflow-y-hidden md:overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5"
class="w-full overflow-y-hidden overflow-x-hidden mt-0.5 pt-0.5"
:class="isMobile ? 'overflow-y-auto' : 'md:overflow-y-auto'"
>
<div
ref="documents"
@@ -320,7 +322,7 @@
</div>
</div>
<div
v-if="withFieldsList"
v-if="withFieldsList && !isMobile"
id="fields_list_container"
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block"
:class="drawField ? 'overflow-hidden' : 'overflow-y-auto overflow-x-hidden'"
@@ -362,6 +364,7 @@
:default-submitters="defaultSubmitters"
:draw-field-type="drawFieldType"
:default-fields="[...defaultRequiredFields, ...defaultFields]"
:template="template"
:default-required-fields="defaultRequiredFields"
:field-types="fieldTypes"
:with-sticky-submitters="withStickySubmitters"
@@ -378,14 +381,14 @@
</div>
</div>
</div>
<div class="sticky bottom-0">
<div class="sticky bottom-0 z-10">
<MobileDrawField
v-if="drawField && isBreakpointLg"
v-if="drawField && (isBreakpointLg || isMobile)"
:draw-field="drawField"
:fields="template.fields"
:submitters="template.submitters"
:selected-submitter="selectedSubmitter"
class="md:hidden"
:class="{ 'md:hidden': !isMobile }"
:editable="editable"
@cancel="[drawField = null, drawOption = null]"
@change-submitter="[selectedSubmitter = $event, drawField.submitter_uuid = $event.uuid]"
@@ -396,6 +399,7 @@
:default-fields="[...defaultRequiredFields, ...defaultFields]"
:default-required-fields="defaultRequiredFields"
:field-types="fieldTypes"
:class="{ 'md:hidden': !isMobile }"
:selected-submitter="selectedSubmitter"
@select="startFieldDraw($event)"
/>
@@ -695,6 +699,9 @@ export default {
language () {
return this.locale.split('-')[0].toLowerCase()
},
isMobile () {
return /android|iphone|ipad/i.test(navigator.userAgent)
},
defaultDateFormat () {
const isUsBrowser = Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US')
const isUsTimezone = new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).format(new Date()).match(/\s(?:CST|CDT|PST|PDT|EST|EDT)$/)
@@ -1023,6 +1030,22 @@ export default {
if (!field.areas.length) {
this.template.fields.splice(this.template.fields.indexOf(field), 1)
this.template.fields.forEach((f) => {
(f.conditions || []).forEach((c) => {
if (c.field_uuid === field.uuid) {
f.conditions.splice(f.conditions.indexOf(c), 1)
}
})
})
this.template.schema.forEach((item) => {
(item.conditions || []).forEach((c) => {
if (c.field_uuid === field.uuid) {
item.conditions.splice(item.conditions.indexOf(c), 1)
}
})
})
}
this.save()
@@ -1281,6 +1304,16 @@ export default {
fieldArea.h = lastArea.h
}
if (field.width) {
fieldArea.w = field.width / area.maskW
delete field.width
}
if (field.height) {
fieldArea.h = field.height / area.maskH
delete field.height
}
field.areas.push(fieldArea)
this.selectedAreaRef.value = fieldArea
@@ -9,7 +9,7 @@
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span>
{{ t('condition') }} - {{ field.name || buildDefaultName(field, template.fields) }}
{{ t('condition') }} - {{ item.name || buildDefaultName(item, template.fields) }}
</span>
<a
href="#"
@@ -138,12 +138,12 @@
</button>
</form>
<div
v-if="field.conditions?.[0]?.field_uuid"
v-if="item.conditions?.[0]?.field_uuid"
class="text-center w-full mt-4"
>
<button
class="link"
@click="[conditions = [], delete field.conditions, validateSaveAndClose()]"
@click="[conditions = [], delete item.conditions, validateSaveAndClose()]"
>
{{ t('remove_condition') }}
</button>
@@ -158,7 +158,7 @@ export default {
name: 'ConditionModal',
inject: ['t', 'save', 'template', 'withConditions'],
props: {
field: {
item: {
type: Object,
required: true
},
@@ -170,22 +170,26 @@ export default {
emits: ['close'],
data () {
return {
conditions: this.field.conditions?.[0] ? JSON.parse(JSON.stringify(this.field.conditions)) : [{}]
conditions: this.item.conditions?.[0] ? JSON.parse(JSON.stringify(this.item.conditions)) : [{}]
}
},
computed: {
fields () {
return this.template.fields.reduce((acc, f) => {
if (f !== this.field && f.submitter_uuid === this.field.submitter_uuid) {
acc.push(f)
}
if (this.item.submitter_uuid) {
return this.template.fields.reduce((acc, f) => {
if (f !== this.item && f.submitter_uuid === this.item.submitter_uuid) {
acc.push(f)
}
return acc
}, [])
return acc
}, [])
} else {
return this.template.fields
}
}
},
created () {
this.field.conditions ||= []
this.item.conditions ||= []
},
methods: {
conditionField (condition) {
@@ -219,9 +223,9 @@ export default {
}
if (this.conditions.find((f) => f.field_uuid)) {
this.field.conditions = this.conditions
this.item.conditions = this.conditions
} else {
delete this.field.conditions
delete this.item.conditions
}
this.save()
+2 -2
View File
@@ -236,7 +236,7 @@
:to="modalContainerEl"
>
<ConditionsModal
:field="field"
:item="field"
:build-default-name="buildDefaultName"
@close="isShowConditionsModal = false"
/>
@@ -365,7 +365,7 @@ export default {
} else {
const typeIndex = fields.filter((f) => f.type === field.type).indexOf(field)
if (this.field.type === 'heading') {
if (field.type === 'heading') {
return `${this.fieldNames[field.type]} ${typeIndex + 1}`
} else {
return `${this.fieldLabels[field.type]} ${typeIndex + 1}`
@@ -27,7 +27,7 @@
</label>
</div>
<div
v-if="['number'].includes(field.type)"
v-if="['number', 'cells'].includes(field.type)"
class="py-1.5 px-1 relative"
@click.stop
>
@@ -36,7 +36,7 @@
@change="[field.preferences ||= {}, field.preferences.align = $event.target.value, save()]"
>
<option
v-for="value in ['left', 'right', 'center']"
v-for="value in ['left', 'right', field.type === 'cells' ? null : 'center'].filter(Boolean)"
:key="value"
:selected="field.preferences?.align ? value === field.preferences.align : value === 'left'"
:value="value"
@@ -240,7 +240,7 @@
<input
v-model="field.required"
type="checkbox"
:disabled="!editable || defaultField"
:disabled="!editable || (defaultField && [true, false].includes(defaultField.required))"
class="toggle toggle-xs"
@update:model-value="save"
>
@@ -229,6 +229,10 @@ export default {
type: Array,
required: true
},
template: {
type: Object,
required: true
},
withHelp: {
type: Boolean,
required: false,
@@ -380,6 +384,14 @@ export default {
})
})
this.template.schema.forEach((item) => {
(item.conditions || []).forEach((c) => {
if (c.field_uuid === field.uuid) {
item.conditions.splice(item.conditions.indexOf(c), 1)
}
})
})
this.save()
}
}
@@ -346,7 +346,7 @@ export default {
return this.$el.closest('form')
},
fieldTypes () {
return ['text', 'cells', 'date', 'number', 'radio', 'select', 'checkbox']
return ['text', 'cells', 'date', 'number', 'radio', 'select', 'checkbox', 'image']
},
defaultFields () {
return [
@@ -1,6 +1,6 @@
<template>
<div class="absolute text-center w-full bottom-0 pr-3 mb-4">
<span class="w-full bg-base-200 px-4 py-2 rounded-md inline-flex space-x-2 mx-auto items-center justify-between mb-2 z-20 md:hidden">
<span class="w-full bg-base-200 px-4 py-2 rounded-md inline-flex space-x-2 mx-auto items-center justify-between mb-2 z-20">
<div class="flex items-center space-x-2">
<component
:is="fieldIcons[drawField.type]"
@@ -1,6 +1,6 @@
<template>
<span
class="dropdown dropdown-top dropdown-end absolute bottom-4 right-4 z-10 md:hidden"
class="dropdown dropdown-top dropdown-end absolute bottom-4 right-4 z-10"
>
<label
class="btn btn-neutral text-white btn-circle btn-lg group"
@@ -12,9 +12,10 @@
height="28"
/>
<IconX
class="hidden group-focus:inline"
width="28"
height="28"
class="hidden group-focus:inline p-3"
width="64"
height="50"
@click="closeDropdown"
/>
</label>
<ul
+46 -2
View File
@@ -9,14 +9,30 @@
loading="lazy"
>
<div
class="group flex justify-end cursor-pointer top-0 bottom-0 left-0 right-0 absolute p-1"
class="group flex justify-end cursor-pointer top-0 bottom-0 left-0 right-0 absolute p-1 hover:bg-black/10 transition-colors"
@click="$emit('scroll-to', item)"
>
<div
v-if="editable"
class="flex justify-between w-full"
>
<div style="width: 26px" />
<div
style="width: 26px"
class="flex flex-col justify-between group-hover:opacity-100"
:class="{'opacity-0': !item.conditions?.length }"
>
<div>
<button
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors p-0"
@click.stop="isShowConditionsModal = true"
>
<IconRouteAltLeft
:width="14"
:stroke-width="1.6"
/>
</button>
</div>
</div>
<div class="">
<ReplaceButton
v-if="withReplaceButton"
@@ -73,19 +89,36 @@
/>
</div>
</div>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:item="item"
:build-default-name="buildDefaultName"
@close="isShowConditionsModal = false"
/>
</Teleport>
</template>
<script>
import Contenteditable from './contenteditable'
import Upload from './upload'
import { IconRouteAltLeft } from '@tabler/icons-vue'
import ConditionsModal from './conditions_modal'
import ReplaceButton from './replace'
import Field from './field'
import FieldType from './field_type'
export default {
name: 'DocumentPreview',
components: {
Contenteditable,
IconRouteAltLeft,
ConditionsModal,
ReplaceButton
},
inject: ['t'],
props: {
item: {
type: Object,
@@ -121,13 +154,24 @@ export default {
}
},
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace'],
data () {
return {
isShowConditionsModal: false
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels,
previewImage () {
return [...this.document.preview_images].sort((a, b) => parseInt(a.filename) - parseInt(b.filename))[0]
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
}
},
methods: {
upload: Upload.methods.upload,
buildDefaultName: Field.methods.buildDefaultName,
onUpdateName (value) {
this.item.name = value
+4 -4
View File
@@ -10,10 +10,6 @@ class ProcessSubmitterCompletionJob
is_all_completed = !submitter.submission.submitters.exists?(completed_at: nil)
if !is_all_completed && submitter.submission.submitters_order_preserved?
enqueue_next_submitter_request_notification(submitter)
end
Submissions::EnsureResultGenerated.call(submitter)
if is_all_completed && submitter.completed_at == submitter.submission.submitters.maximum(:completed_at)
@@ -28,6 +24,10 @@ class ProcessSubmitterCompletionJob
create_completed_documents!(submitter)
if !is_all_completed && submitter.submission.submitters_order_preserved? && params['send_invitation_email'] != false
enqueue_next_submitter_request_notification(submitter)
end
enqueue_completed_webhooks(submitter, is_all_completed:)
end
+4
View File
@@ -57,6 +57,8 @@ class Submission < ApplicationRecord
has_many_attached :preview_documents
has_many :template_accesses, primary_key: :template_id, foreign_key: :template_id, dependent: nil, inverse_of: false
has_many :template_schema_documents,
->(e) { where(uuid: (e.template_schema.presence || e.template.schema).pluck('attachment_uuid')) },
through: :template, source: :documents_attachments
@@ -67,6 +69,8 @@ class Submission < ApplicationRecord
where.not(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
}
scope :declined, -> { joins(:submitters).where.not(submitters: { declined_at: nil }).group(:id) }
scope :expired, -> { where(expire_at: ..Time.current) }
enum :source, {
invite: 'invite',
+1
View File
@@ -54,6 +54,7 @@ class Submitter < ApplicationRecord
has_many_attached :documents
has_many_attached :attachments
has_many_attached :preview_documents
has_many :template_accesses, through: :template
has_many :document_generation_events, dependent: :destroy
has_many :submission_events, dependent: :destroy
+1
View File
@@ -62,6 +62,7 @@ class Template < ApplicationRecord
has_many :submissions, dependent: :destroy
has_many :template_sharings, dependent: :destroy
has_many :template_accesses, dependent: :destroy
scope :active, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) }
+26
View File
@@ -0,0 +1,26 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: template_accesses
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# template_id :bigint not null
# user_id :bigint not null
#
# Indexes
#
# index_template_accesses_on_template_id_and_user_id (template_id,user_id) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (template_id => templates.id)
#
class TemplateAccess < ApplicationRecord
ADMIN_USER_ID = -1
belongs_to :template
belongs_to :user, optional: true
end
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M20.997 12.25a9 9 0 1 0 -8.718 8.745" /><path d="M19 19m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /><path d="M17 21l4 -4" /><path d="M12 7v5l2 2" />
</svg>

After

Width:  |  Height:  |  Size: 433 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M21 12a9 9 0 1 0 -9.972 8.948c.32 .034 .644 .052 .972 .052" /><path d="M12 7v5l2 2" /><path d="M18.42 15.61a2.1 2.1 0 0 1 2.97 2.97l-3.39 3.42h-3v-3l3.42 -3.39z" />
</svg>

After

Width:  |  Height:  |  Size: 456 B

+1 -1
View File
@@ -1,4 +1,4 @@
<title>
<%= content_for(:html_title) || 'DocuSeal | Open Source Document Signing' %>
<%= content_for(:html_title) || (signed_in? ? 'DocuSeal' : 'DocuSeal | Open Source Document Signing') %>
</title>
<%= render 'shared/meta' %>
+1 -1
View File
@@ -1,7 +1,7 @@
<% if Docuseal.demo? || (request.path != '/' && !devise_controller?) %>
<meta name="robots" content="noindex">
<% end %>
<% title = content_for(:html_title) || 'DocuSeal | Open Source Document Filling and Signing' %>
<% title = content_for(:html_title) || (signed_in? ? 'DocuSeal' : 'DocuSeal | Open Source Document Signing') %>
<% description = content_for(:html_description) || 'Open source, self-hosted tool to streamline document filling and signing. Create custom PDF forms to complete and sign with an easy to use online tool.' %>
<meta name="description" content="<%= description %>">
<meta property="og:title" content="<%= title %>">
+3 -2
View File
@@ -12,7 +12,8 @@
ID: <%= attachment.uuid %>
</div>
<div>
<%= t('reason') %>: <%= submitter.values[field.dig('preferences', 'reason_field_uuid')].presence || t('digitally_signed_by') %> <%= submitter.name %>
<% reason_value = submitter.values[field.dig('preferences', 'reason_field_uuid')].presence %>
<% if reason_value %><%= t('reason') %>: <% end %><%= reason_value || t('digitally_signed_by') %> <%= submitter.name %>
<% if submitter.email %>
&lt;<%= submitter.email %>&gt;
<% end %>
@@ -49,7 +50,7 @@
<% end %>
<% elsif field['type'] == 'cells' && area['cell_w'].to_f > 0.0 %>
<% cell_width = area['cell_w'] / area['w'] * 100 %>
<div class="w-full flex items-center">
<div class="w-full flex items-center <%= 'justify-end' if align == 'right' %>">
<% (0..(area['w'] / area['cell_w']).ceil).each do |index| %>
<% if value[index] %>
<div class="text-center flex-none" style="width: <%= cell_width %>%;"><%= value[index] %></div>
+9 -7
View File
@@ -65,7 +65,9 @@
</div>
<div class="flex md:max-h-[calc(100vh-60px)]">
<div class="overflow-y-auto overflow-x-hidden hidden lg:block w-52 flex-none pr-3 mt-0.5 pt-0.5">
<% (@submission.template_schema || @submission.template.schema).each do |item| %>
<% values = @submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %>
<% schema = Submissions.filtered_conditions_schema(@submission, values:) %>
<% schema.each do |item| %>
<% document = @submission.template_schema_documents.find { |a| item['attachment_uuid'] == a.uuid } %>
<a href="#<%= "page-#{document.uuid}-0" %>" onclick="[event.preventDefault(), window[event.target.closest('a').href.split('#')[1]].scrollIntoView({ behavior: 'smooth', block: 'start' })]" class="block cursor-pointer">
<img src="<%= Docuseal::URL_CACHE.fetch([document.id, document.uuid, 0].join(':'), expires_in: 10.minutes) { document.preview_images.first.url } %>" width="<%= document.preview_images.first.metadata['width'] %>" height="<%= document.preview_images.first.metadata['height'] %>" class="rounded border" loading="lazy">
@@ -79,10 +81,9 @@
<div class="pr-3.5 pl-0.5">
<% fields_index = Templates.build_field_areas_index(@submission.template_fields || @submission.template.fields) %>
<% submitters_index = @submission.submitters.index_by(&:uuid) %>
<% values = @submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %>
<% attachments_index = ActiveStorage::Attachment.where(record: @submission.submitters, name: :attachments).preload(:blob).index_by(&:uuid) %>
<% page_blob_struct = Struct.new(:url, :metadata, keyword_init: true) %>
<% (@submission.template_schema || @submission.template.schema).each do |item| %>
<% schema.each do |item| %>
<% document = @submission.template_schema_documents.find { |e| e.uuid == item['attachment_uuid'] } %>
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
@@ -121,7 +122,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, submitter) && !submitter.completed_at? && !submitter.declined_at? && !@submission.archived_at? && !@submission.expired? && !submitter.start_form_submission_events.any? %>
<% if signed_in? && can?(:update, @submission) && submitter && !submitter.completed_at? && !submitter.declined_at? && !@submission.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') %>
@@ -173,15 +174,15 @@
</span>
</div>
<% end %>
<% if signed_in? && submitter && submitter.email && !submitter.completed_at && !@submission.archived_at? && can?(:update, submitter) && Accounts.can_send_emails?(current_account) && !@submission.expired? && !submitter.declined_at? %>
<% if signed_in? && submitter && submitter.email && !submitter.completed_at && !@submission.archived_at? && can?(:update, @submission) && 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_slug: submitter.slug), class: 'btn btn-sm btn-primary w-full' %>
</div>
<% end %>
<% if signed_in? && submitter && submitter.phone && !submitter.completed_at && !@submission.archived_at? && can?(:update, submitter) && !@submission.expired? && !submitter.declined_at? %>
<% if signed_in? && submitter && submitter.phone && !submitter.completed_at && !@submission.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? && can?(:create, submitter) && !@submission.expired? && !submitter.declined_at? %>
<% if signed_in? && submitter && !submitter.completed_at? && !@submission.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') %>
@@ -195,6 +196,7 @@
<% submitter_field_counters[field['type']] += 1 %>
<% value = values[field['uuid']] %>
<% next if value.blank? %>
<% next if field['type'] == 'heading' %>
<div class="pt-2.5 border-b border-base-300">
<div class="text-xs font-medium uppercase mb-0.5" dir="auto">
<%= field['name'].presence || "#{t("#{field['type']}_field")} #{submitter_field_counters[field['type']]}" %>
@@ -1,4 +1,26 @@
<% query_params = params.permit(:q, :status).merge(filter_params) %>
<% query_params = params.permit(:q).merge(filter_params) %>
<% if icon = { 'declined' => 'x_circle', 'expired' => 'clock_cancel', 'partially_completed' => 'clock_edit' }[params[:status]] %>
<div class="flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-34 border-neutral-700">
<%= link_to submissions_filter_path('status', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal' }, class: 'flex items-center space-x-1 w-full pr-1 md:max-w-[140px]' do %>
<%= svg_icon(icon, class: 'w-5 h-5 shrink-0') %>
<span class="font-normal truncate"><%= t(params[:status]) %></span>
<% end %>
<%= link_to url_for(params.to_unsafe_h.except(:status)), class: 'rounded-lg ml-1 hover:bg-base-content hover:text-white' do %>
<%= svg_icon('x', class: 'w-5 h-5') %>
<% end %>
</div>
<% end %>
<% if params[:author].present? %>
<div class="tooltip tooltip-bottom flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-34 border-neutral-700" data-tip="<%= t('author') %>">
<%= link_to submissions_filter_path('author', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal' }, class: 'flex items-center space-x-1 w-full pr-1 md:max-w-[140px]' do %>
<%= svg_icon('user', class: 'w-5 h-5 shrink-0') %>
<span class="font-normal truncate"><%= current_account.users.accessible_by(current_ability).where(account: current_account).find_by(email: params[:author])&.full_name || 'NA' %></span>
<% end %>
<%= link_to url_for(params.to_unsafe_h.except(:author)), class: 'rounded-lg ml-1 hover:bg-base-content hover:text-white' do %>
<%= svg_icon('x', class: 'w-5 h-5') %>
<% end %>
</div>
<% end %>
<% if query_params[:completed_at_from].present? || query_params[:completed_at_to].present? %>
<div class="tooltip tooltip-bottom flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-34 border-neutral-700" data-tip="<%= t('completed_at') %>">
<%= link_to submissions_filter_path('completed_at', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal' }, class: 'flex items-center space-x-1 w-full pr-1 md:max-w-[140px]' do %>
@@ -37,14 +59,3 @@
<% end %>
</div>
<% end %>
<% if params[:author].present? %>
<div class="tooltip tooltip-bottom flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-34 border-neutral-700" data-tip="<%= t('author') %>">
<%= link_to submissions_filter_path('author', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal' }, class: 'flex items-center space-x-1 w-full pr-1 md:max-w-[140px]' do %>
<%= svg_icon('user', class: 'w-5 h-5 shrink-0') %>
<span class="font-normal truncate"><%= current_account.users.accessible_by(current_ability).where(account: current_account).find_by(email: params[:author])&.full_name || 'NA' %></span>
<% end %>
<%= link_to url_for(params.to_unsafe_h.except(:author)), class: 'rounded-lg ml-1 hover:bg-base-content hover:text-white' do %>
<%= svg_icon('x', class: 'w-5 h-5') %>
<% end %>
</div>
<% end %>
@@ -1,8 +1,8 @@
<% query_params = params.permit(:q, :status).merge(filter_params) %>
<% query_params = params.permit(:q).merge(filter_params) %>
<div class="dropdown dropdown-end">
<label tabindex="0" class="cursor-pointer flex h-10 px-3 py-1 space-x-1 text-lg items-center justify-between border text-center text-neutral rounded-xl border-neutral-300 hover:border-neutral-700">
<%= svg_icon('filter', class: 'w-5 h-5 flex-shrink-0 stroke-2') %>
<span class="<%= filter_params.present? ? 'md:hidden' : '' %>">Filter</span>
<span class="<%= filter_params.then { |f| f[:status].in?(%w[pending completed]) ? f.except(:status) : f }.present? ? 'md:hidden' : '' %>">Filter</span>
</label>
<ul tabindex="0" class="z-10 dropdown-content p-2 mt-2 shadow menu text-base bg-base-100 rounded-box min-w-[180px] text-right">
<li class="flex">
@@ -17,6 +17,12 @@
<span><%= t('created_at') %></span>
<% end %>
</li>
<li class="flex">
<%= link_to submissions_filter_path('status', query_params.merge(path: url_for)), data: { turbo_frame: 'modal' } do %>
<%= svg_icon('info_circle', class: 'w-5 h-5 flex-shrink-0 stroke-2') %>
<span><%= t('status') %></span>
<% end %>
</li>
<li class="flex">
<%= link_to submissions_filter_path('author', query_params.merge(path: url_for)), data: { turbo_frame: 'modal' } do %>
<%= svg_icon('user', class: 'w-5 h-5 flex-shrink-0 stroke-2') %>
@@ -1,6 +1,5 @@
<%= render 'shared/turbo_modal', title: local_assigns[:title] do %>
<%= form_for '', url: params[:path], method: :get, data: { turbo_frame: :_top }, html: { autocomplete: :off } do |f| %>
<%= hidden_field_tag :status, params[:status] if params[:status].present? %>
<%= hidden_field_tag :q, params[:q] if params[:q].present? %>
<% local_assigns[:default_params].each do |key, value| %>
<%= hidden_field_tag(key, value) if value.present? %>
@@ -11,7 +10,7 @@
</div>
<% if params[:with_remove] %>
<div class="text-center w-full mt-4">
<%= link_to t('remove_filter'), "#{params[:path]}?#{params.to_unsafe_h.slice(:q, :status).merge(local_assigns[:default_params]).to_query}", class: 'link', data: { turbo_frame: :_top } %>
<%= link_to t('remove_filter'), "#{params[:path]}?#{params.to_unsafe_h.slice(:q).merge(local_assigns[:default_params]).to_query}", class: 'link', data: { turbo_frame: :_top } %>
</div>
<% end %>
<% end %>
@@ -0,0 +1,14 @@
<%= render 'filter_modal', title: t('status'), default_params: params.permit(*(Submissions::Filter::ALLOWED_PARAMS - %w[status])) do %>
<div class="flex flex-col md:flex-row gap-2 mt-5">
<div class="form-control w-full">
<div id="status" class="radio-select grid grid-cols-2 gap-2 px-1">
<% ['', 'pending', 'completed', 'partially_completed', 'declined', 'expired'].each do |status| %>
<label class="radio-label cursor-pointer inline-flex items-center space-x-2">
<%= radio_button_tag 'status', status, params[:status] == status || (status == '' && params[:status].blank?), class: 'base-radio' %>
<span><%= t(status.presence || 'all') %></span>
</label>
<% end %>
</div>
</div>
</div>
<% end %>
@@ -1,4 +1,4 @@
<% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid created_at], 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 %>
<% invite_submitters = (submitter.submission.template_submitters || submitter.submission.template.submitters).select { |s| s['invite_by_uuid'] == submitter.uuid && submitter.submission.submitters.none? { |e| e.uuid == s['uuid'] } }.to_json %>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-reuse-signature="<%= configs[:reuse_signature] %>" data-require-signing-reason="<%= configs[:require_signing_reason] %>" data-with-signature-id="<%= configs[:with_signature_id] %>" data-with-confetti="<%= configs[:with_confetti] %>" data-completed-redirect-url="<%= submitter.preferences['completed_redirect_url'].presence || submitter.submission.template.preferences['completed_redirect_url'] %>" data-completed-message="<%= (configs[:completed_message]&.compact_blank.presence || submitter.submission.template.preferences['completed_message'] || {}).to_json %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.preferences.key?('go_to_last') ? submitter.preferences['go_to_last'] : submitter.opened_at? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(submitter.submission.account) %>" data-invite-submitters="<%= invite_submitters %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>" data-previous-signature-value="<%= local_assigns[:signature_attachment]&.uuid %>" data-remember-signature="<%= configs[:prefill_signature] %>" data-dry-run="<%= local_assigns[:dry_run] %>" data-expand="<%= local_assigns[:expand] %>" data-scroll-padding="<%= local_assigns[:scroll_padding] %>" data-language="<%= I18n.locale.to_s.split('-').first %>"></submission-form>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-schema="<%= schema.to_json %>" data-reuse-signature="<%= configs[:reuse_signature] %>" data-require-signing-reason="<%= configs[:require_signing_reason] %>" data-with-signature-id="<%= configs[:with_signature_id] %>" data-with-confetti="<%= configs[:with_confetti] %>" data-completed-redirect-url="<%= submitter.preferences['completed_redirect_url'].presence || submitter.submission.template.preferences['completed_redirect_url'] %>" data-completed-message="<%= (configs[:completed_message]&.compact_blank.presence || submitter.submission.template.preferences['completed_message'] || {}).to_json %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.preferences.key?('go_to_last') ? submitter.preferences['go_to_last'] : submitter.opened_at? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(submitter.submission.account) %>" data-invite-submitters="<%= invite_submitters %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>" data-previous-signature-value="<%= local_assigns[:signature_attachment]&.uuid %>" data-remember-signature="<%= configs[:prefill_signature] %>" data-dry-run="<%= local_assigns[:dry_run] %>" data-expand="<%= local_assigns[:expand] %>" data-scroll-padding="<%= local_assigns[:scroll_padding] %>" data-language="<%= I18n.locale.to_s.split('-').first %>"></submission-form>
+28 -25
View File
@@ -4,6 +4,7 @@
<% values = @submitter.submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %>
<% submitters_index = @submitter.submission.submitters.index_by(&:uuid) %>
<% page_blob_struct = Struct.new(:url, :metadata, keyword_init: true) %>
<% schema = Submissions.filtered_conditions_schema(@submitter.submission, values:, include_submitter_uuid: @submitter.uuid) %>
<div style="max-height: -webkit-fill-available;">
<div id="scrollbox">
<div class="mx-auto block pb-72" style="max-width: 1000px">
@@ -35,32 +36,34 @@
</div>
</div>
<% end %>
<% (@submitter.submission.template_schema || @submitter.submission.template.schema).each do |item| %>
<% schema.each do |item| %>
<% document = @submitter.submission.template_schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %>
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.last.metadata %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_uuid, "#{index}.jpg")) %>
<div class="relative my-4 shadow-md">
<img loading="lazy" src="<%= page.url %>" width="<%= page.metadata['width'] %>" height="<%= page.metadata['height'] %>">
<div id="page-<%= [document.uuid, index].join('-') %>" class="top-0 bottom-0 left-0 right-0 absolute">
<% document_annots_index[index]&.each do |annot| %>
<%= render 'submissions/annotation', annot: %>
<% end %>
<% fields_index.dig(document.uuid, index)&.each do |(area, field)| %>
<% value = values[field['uuid']].presence || (field['default_value'].present? ? Submitters::SubmitValues.template_default_value_for_submitter(field['default_value'], @submitter.submission.submitters.find { |e| e.uuid == field['submitter_uuid'] }, with_time: false) : nil) %>
<% next if value.blank? %>
<% next if !field['readonly'] && field['submitter_uuid'] == @submitter.uuid %>
<% next if value == '{{date}}' && field['submitter_uuid'] != @submitter.uuid %>
<% next if field['conditions'].present? && values[field['uuid']].blank? && field['submitter_uuid'] != @submitter.uuid %>
<% next if field['conditions'].present? && field['submitter_uuid'] == @submitter.uuid %>
<% next if field.dig('preferences', 'formula').present? && field['submitter_uuid'] == @submitter.uuid %>
<%= render 'submissions/value', area:, field:, attachments_index: @attachments_index, value:, locale: @submitter.account.locale, timezone: @submitter.account.timezone, submitter: submitters_index[field['submitter_uuid']], with_signature_id: @form_configs[:with_signature_id] %>
<% end %>
<div id="document-<%= document.uuid %>">
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.last.metadata %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_uuid, "#{index}.jpg")) %>
<div class="relative my-4 shadow-md">
<img loading="lazy" src="<%= page.url %>" width="<%= page.metadata['width'] %>" height="<%= page.metadata['height'] %>">
<div id="page-<%= [document.uuid, index].join('-') %>" class="top-0 bottom-0 left-0 right-0 absolute">
<% document_annots_index[index]&.each do |annot| %>
<%= render 'submissions/annotation', annot: %>
<% end %>
<% fields_index.dig(document.uuid, index)&.each do |(area, field)| %>
<% value = values[field['uuid']].presence || (field['default_value'].present? ? Submitters::SubmitValues.template_default_value_for_submitter(field['default_value'], @submitter.submission.submitters.find { |e| e.uuid == field['submitter_uuid'] }, with_time: false) : nil) %>
<% next if value.blank? %>
<% next if !field['readonly'] && field['submitter_uuid'] == @submitter.uuid %>
<% next if value == '{{date}}' && field['submitter_uuid'] != @submitter.uuid %>
<% next if field['conditions'].present? && values[field['uuid']].blank? && field['submitter_uuid'] != @submitter.uuid %>
<% next if field['conditions'].present? && field['submitter_uuid'] == @submitter.uuid %>
<% next if field.dig('preferences', 'formula').present? && field['submitter_uuid'] == @submitter.uuid %>
<%= render 'submissions/value', area:, field:, attachments_index: @attachments_index, value:, locale: @submitter.account.locale, timezone: @submitter.account.timezone, submitter: submitters_index[field['submitter_uuid']], with_signature_id: @form_configs[:with_signature_id] %>
<% end %>
</div>
</div>
</div>
<% end %>
<% end %>
</div>
<% end %>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account, with_style: false %>
<% if @form_configs[:policy_links].present? %>
@@ -76,7 +79,7 @@
<div class="fixed bottom-0 w-full h-0 z-20">
<div class="mx-auto" style="max-width: 1000px">
<div class="relative md:mx-32">
<%= render 'submit_form/submission_form', attachments_index: @attachments_index, submitter: @submitter, signature_attachment: @signature_attachment, configs: @form_configs, dry_run: local_assigns[:dry_run], expand: local_assigns[:expand], scroll_padding: local_assigns.fetch(:scroll_padding, '-110px') %>
<%= render 'submit_form/submission_form', attachments_index: @attachments_index, submitter: @submitter, signature_attachment: @signature_attachment, configs: @form_configs, dry_run: local_assigns[:dry_run], expand: local_assigns[:expand], scroll_padding: local_assigns.fetch(:scroll_padding, '-110px'), schema: %>
</div>
</div>
</div>
@@ -2,6 +2,6 @@
<%= render 'custom_content', content: @body, submitter: @submitter %>
<% else %>
<p><%= t('hi_there') %>,</p>
<p><%= I18n.t(:name_has_been_completed_by_submitters, name: @submitter.submission.template.name, submitters: @submitter.submission.submitters.order(:completed_at).map { |e| e.name || e.email || e.phone }.join(', ')) %></p>
<p><%= I18n.t(:name_has_been_completed_by_submitters, name: @submitter.submission.template.name, submitters: @submitter.submission.submitters.order(:completed_at).map { |e| e.name || e.email || e.phone }.uniq.join(', ')) %></p>
<p><%= link_to submission_url(@submitter.submission), submission_url(@submitter.submission) %></p>
<% end %>
Whitespace-only changes.
+2 -2
View File
@@ -51,7 +51,7 @@
<a href="<%= submission_path(submission) %>" class="text-lg break-all peer">
<%= submitter.name || submitter.email || submitter.phone %>
</a>
<% if can?(:update, submitter) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? && !submitter.declined_at? %>
<% if 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 can?(:update, submitter) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? && !submitter.declined_at? %>
<% if 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') %>
+3
View File
@@ -1,6 +1,9 @@
<div class="h-36 relative group">
<a href="<%= template_path(template) %>" class="flex h-full flex-col justify-between rounded-2xl pt-6 px-7 w-full bg-base-200 peer">
<div class="pb-4 text-xl font-semibold" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;">
<% if template.template_accesses.present? %>
<%= svg_icon('lock', class: 'w-6 h-6 inline -translate-y-[4px]') %>
<% end %>
<% template.name.split(/(_)/).each do |item| %><%= item %><wbr><% end %>
</div>
<div class="pb-6 pt-1 space-y-1">
+9 -6
View File
@@ -1,12 +1,15 @@
<div class="flex flex-col items-start md:flex-row space-y-2 md:space-y-0 md:space-x-2 md:justify-between md:items-start mb-6 md:mb-3">
<div class="relative flex items-start justify-between w-full space-x-0">
<div>
<h1 class="text-3xl md:text-[2em] font-semibold" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;">
<% template.name.split(/(_)/).each do |item| %><%= item %><wbr><% end %>
<% if template.archived_at? %>
<span class="ml-1 badge badge-outline badge-lg align-middle"><%= t('archived') %></span>
<% end %>
</h1>
<div id="template_title" class="flex">
<h1 class="text-3xl md:text-[2em] font-semibold" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;">
<% template.name.split(/(_)/).each do |item| %><%= item %><wbr><% end %>
<% if template.archived_at? %>
<span class="ml-1 badge badge-outline badge-lg align-middle"><%= t('archived') %></span>
<% end %>
</h1>
<%= render 'templates/access_icon', template: @template %>
</div>
<% if @template.account_id == current_account.id %>
<div class="flex items-center justify-between">
<div class="flex items-center">
Whitespace-only changes.
@@ -19,6 +19,7 @@
</toggle-visible>
<% end %>
<div id="general" class="px-5 mb-4">
<%= render 'access' %>
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-2' }, data: { close_on_submit: false } do |f| %>
<toggle-on-submit data-element-id="bcc_saved_alert"></toggle-on-submit>
<%= f.fields_for :preferences, Struct.new(:bcc_completed).new(@template.preferences['bcc_completed']) do |ff| %>
+3
View File
@@ -19,6 +19,9 @@ production:
username: <%= ENV['DATABASE_USER'] %>
password: <%= ENV['DATABASE_PASSWORD'] %>
database: <%= ENV['DATABASE_NAME'] %>
<% if !ENV['DATABASE_SEARCH_PATH'].to_s.empty? %>
search_path: <%= ENV['DATABASE_SEARCH_PATH'] %>
<% end %>
<% elsif ENV['DATABASE_URL'].to_s.empty? %>
adapter: sqlite3
database: <%= ENV['WORKDIR'] || '.' %>/db.sqlite3
+71
View File
@@ -20,9 +20,20 @@ en: &en
language_ko: 한국어
hi_there: Hi there
thanks: Thanks
pending_by_me: Pending by me
partially_completed: Partially completed
unarchive: Unarchive
first_party: 'First Party'
remove_filter: Remove filter
add: Add
adding: Adding
owner: Owner
select_user: Select user
team_member_permissions: Team member permissions
entire_team: Entire team
admin_only: Admin only
accessiable_by: Accessiable by
team_access: Team access
document_download_filename_format: Document download filename format
document_name: Document Name
docuseal_trusted_signature: DocuSeal Trusted Signature
@@ -651,6 +662,7 @@ en: &en
policy_links: Policy Links
markdown_content_e_g: Markdown content, e.g.
privacy_policy: Privacy Policy
use_the_edit_form_to_move_it_to_another_team: Use the edit form to move it to another team.
submission_event_names:
send_email_to_html: '<b>Email sent</b> to %{submitter_name}'
send_reminder_email_to_html: '<b>Reminder email sent</b> to %{submitter_name}'
@@ -689,6 +701,17 @@ en: &en
read: Read your data
es: &es
partially_completed: Parcialmente completado
pending_by_me: Pendiente por mi
add: Agregar
adding: Agregando
owner: Propietario
select_user: Seleccionar usuario
team_member_permissions: Permisos de miembros del equipo
entire_team: Todo el equipo
admin_only: Solo administrador
accessiable_by: Accesible por
team_access: Acceso del equipo
remove_filter: Eliminar filtro
document_download_filename_format: Formato del nombre del archivo de descarga del documento
document_name: Nombre del documento
@@ -1322,6 +1345,7 @@ es: &es
policy_links: Enlaces de Políticas
markdown_content_e_g: Contenido Markdown, por ej.
privacy_policy: Política de Privacidad
use_the_edit_form_to_move_it_to_another_team: Usa el formulario de edición para moverlo a otro equipo.
submission_event_names:
send_email_to_html: '<b>Correo electrónico enviado</b> a %{submitter_name}'
send_reminder_email_to_html: '<b>Correo de recordatorio enviado</b> a %{submitter_name}'
@@ -1360,6 +1384,16 @@ es: &es
read: Leer tus datos
it: &it
pending_by_me: In sospeso da me
add: Aggiungi
adding: Aggiungendo
owner: Proprietario
select_user: Seleziona utente
team_member_permissions: Permessi membri del team
entire_team: Intero team
admin_only: Solo amministratore
accessiable_by: Accessibile da
team_access: Accesso al team
remove_filter: Rimuovi filtro
document_download_filename_format: Formato del nome file scaricato
document_name: Nome del Documento
@@ -1993,6 +2027,7 @@ it: &it
policy_links: Collegamenti alle Politiche
markdown_content_e_g: Contenuto Markdown, ad es.
privacy_policy: Politica sulla Privacy
use_the_edit_form_to_move_it_to_another_team: Usa il modulo di modifica per spostarlo in un altro team.
submission_event_names:
send_email_to_html: '<b>E-mail inviato</b> a %{submitter_name}'
send_reminder_email_to_html: '<b>E-mail di promemoria inviato</b> a %{submitter_name}'
@@ -2031,6 +2066,17 @@ it: &it
read: Leggi i tuoi dati
fr: &fr
partially_completed: Partiellement complété
pending_by_me: En attente par moi
add: Ajouter
adding: Ajout
owner: Propriétaire
select_user: Sélectionner un utilisateur
team_member_permissions: Permissions des membres de l'équipe
entire_team: Équipe entière
admin_only: Administrateur uniquement
accessiable_by: Accessible par
team_access: Accès à l'équipe
remove_filter: Supprimer le filtre
document_download_filename_format: Format du nom de fichier du téléchargement de document
document_name: Nom du document
@@ -2665,6 +2711,7 @@ fr: &fr
policy_links: Liens des Politiques
markdown_content_e_g: Contenu Markdown, par ex.
privacy_policy: Politique de Confidentialité
use_the_edit_form_to_move_it_to_another_team: Utilisez le formulaire de modification pour le déplacer vers une autre équipe.
submission_event_names:
send_email_to_html: '<b>E-mail envoyé</b> à %{submitter_name}'
send_reminder_email_to_html: '<b>E-mail de rappel envoyé</b> à %{submitter_name}'
@@ -2703,6 +2750,17 @@ fr: &fr
read: Lire vos données
pt: &pt
partially_completed: Parcialmente concluído
pending_by_me: Pendente por mim
add: Adicionar
adding: Adicionando
owner: Proprietário
select_user: Selecionar usuário
team_member_permissions: Permissões de membro da equipe
entire_team: Toda a equipe
admin_only: Somente administrador
accessiable_by: Acessível por
team_access: Acesso à equipe
remove_filter: Remover filtro
document_download_filename_format: Formato do nome do arquivo de download do documento
document_name: Nome do documento
@@ -3336,6 +3394,7 @@ pt: &pt
policy_links: Links de Políticas
markdown_content_e_g: Conteúdo Markdown, ex.
privacy_policy: Política de Privacidade
use_the_edit_form_to_move_it_to_another_team: Use o formulário de edição para movê-lo para outra equipe.
submission_event_names:
send_email_to_html: '<b>E-mail enviado</b> para %{submitter_name}'
send_reminder_email_to_html: '<b>E-mail de lembrete enviado</b> para %{submitter_name}'
@@ -3374,6 +3433,17 @@ pt: &pt
read: Ler seus dados
de: &de
partially_completed: Teilweise abgeschlossen
pending_by_me: Ausstehend von mir
add: Hinzufügen
adding: Hinzufügen
owner: Eigentümer
select_user: Benutzer auswählen
team_member_permissions: Berechtigungen für Teammitglieder
entire_team: Gesamtes Team
admin_only: Nur Administratoren
accessiable_by: Zugänglich für
team_access: Teamzugang
remove_filter: Filter entfernen
document_download_filename_format: Format des Dateinamens beim Herunterladen von Dokumenten
document_name: Dokumentname
@@ -4007,6 +4077,7 @@ de: &de
policy_links: Richtlinien-Links
markdown_content_e_g: Markdown-Inhalt, z. B.
privacy_policy: Datenschutzrichtlinie
use_the_edit_form_to_move_it_to_another_team: Verwenden Sie das Bearbeitungsformular, um ihn in ein anderes Team zu verschieben.
submission_event_names:
send_email_to_html: '<b>E-Mail gesendet</b> an %{submitter_name}'
send_reminder_email_to_html: '<b>Erinnerungs-E-Mail gesendet</b> an %{submitter_name}'
+1
View File
@@ -1,6 +1,7 @@
queues:
- [default, 1]
- [webhooks, 1]
- [sms, 2]
- [images, 1]
- [mailers, 1]
- [recurrent, 1]
@@ -0,0 +1,14 @@
# frozen_string_literal: true
class CreateTemplateAccesses < ActiveRecord::Migration[7.2]
def change
create_table :template_accesses do |t|
t.references :template, null: false, foreign_key: true, index: false
t.references :user, null: false, foreign_key: false, index: false
t.index %i[template_id user_id], unique: true
t.timestamps
end
end
end
+10 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_10_29_192232) do
ActiveRecord::Schema[7.2].define(version: 2024_12_07_172237) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -275,6 +275,14 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_29_192232) do
t.index ["submission_id"], name: "index_submitters_on_submission_id"
end
create_table "template_accesses", force: :cascade do |t|
t.bigint "template_id", null: false
t.bigint "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["template_id", "user_id"], name: "index_template_accesses_on_template_id_and_user_id", unique: true
end
create_table "template_folders", force: :cascade do |t|
t.string "name", null: false
t.bigint "author_id", null: false
@@ -393,6 +401,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_29_192232) do
add_foreign_key "submissions", "templates"
add_foreign_key "submissions", "users", column: "created_by_user_id"
add_foreign_key "submitters", "submissions"
add_foreign_key "template_accesses", "templates"
add_foreign_key "template_folders", "accounts"
add_foreign_key "template_folders", "users", column: "author_id"
add_foreign_key "template_sharings", "templates"
+2
View File
@@ -64,6 +64,8 @@ module Docuseal
end
def default_pkcs
return if Docuseal::CERTS['enabled'] == false
@default_pkcs ||= GenerateCertificate.load_pkcs(Docuseal::CERTS)
end
+2 -1
View File
@@ -89,12 +89,13 @@ module Params
raise_error(message || "#{key} must be unique")
end
def in_path(params, path = [])
def in_path(params, path = [], skip_blank: false)
old_path = @current_path
@current_path = [old_path, *path].compact_blank.map(&:to_s).join('.')
param = params.dig(*path)
param = nil if skip_blank && param.blank?
yield params.dig(*path) if param
+1 -1
View File
@@ -49,7 +49,7 @@ module Params
type(params, :message, Hash)
type(params, :submitters, Array)
in_path(params, :message) do |message_params|
in_path(params, :message, skip_blank: true) do |message_params|
type(message_params, :subject, String)
type(message_params, :body, String)
+1 -1
View File
@@ -92,6 +92,6 @@ module ReplaceEmailVariables
end
def build_submission_submitters(submission)
submission.submitters.order(:completed_at).map { |e| e.name || e.email || e.phone }.join(', ')
submission.submitters.order(:completed_at).map { |e| e.name || e.email || e.phone }.uniq.join(', ')
end
end
+38
View File
@@ -124,4 +124,42 @@ module Submissions
fixed_email
end
def filtered_conditions_schema(submission, values: nil, include_submitter_uuid: nil)
fields_uuid_index = nil
(submission.template_schema || submission.template.schema).filter_map do |item|
if item['conditions'].present?
fields_uuid_index ||=
(submission.template_fields || submission.template.fields).index_by { |f| f['uuid'] }
values ||= submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
next unless check_document_conditions(item, values, fields_uuid_index, include_submitter_uuid:)
end
item
end
end
def check_document_conditions(item, values, fields_index, include_submitter_uuid: nil)
return true if item['conditions'].blank?
item['conditions'].all? do |condition|
result =
if fields_index[condition['field_uuid']]['submitter_uuid'] == include_submitter_uuid
true
else
Submitters::SubmitValues.check_field_condition(condition, values, fields_index)
end
item['conditions'].each_with_object([]) do |c, acc|
if c['operation'] == 'or'
acc.push(acc.pop || result)
else
acc.push(result)
end
end.exclude?(false)
end
end
end
+1 -1
View File
@@ -77,7 +77,7 @@ module Submissions
if template_fields != (submission.template_fields || submission.template.fields) ||
submitters_attrs.any? { |e| e[:completed].present? }
submission.template_fields = template_fields
submission.template_schema = submission.template.schema
submission.template_schema = submission.template.schema if submission.template_schema.blank?
end
submission
+51 -13
View File
@@ -4,6 +4,7 @@ module Submissions
module Filter
ALLOWED_PARAMS = %w[
author
status
completed_at_from
completed_at_to
created_at_from
@@ -22,31 +23,68 @@ module Submissions
def call(submissions, current_user, params)
filters = normalize_filter_params(params, current_user)
if filters[:author].present?
user = current_user.account.users.find_by(email: filters[:author])
submissions = submissions.where(created_by_user_id: user&.id || -1)
submissions = filter_by_author(submissions, filters, current_user)
submissions = filter_by_status(submissions, filters)
submissions = filter_by_created_at(submissions, filters)
filter_by_completed_at(submissions, filters)
end
def filter_by_author(submissions, filters, current_user)
return submissions if filters[:author].blank?
user = current_user.account.users.find_by(email: filters[:author])
submissions.where(created_by_user_id: user&.id || -1)
end
def filter_by_status(submissions, filters)
submissions = submissions.pending if filters[:status] == 'pending'
submissions = submissions.completed if filters[:status] == 'completed'
submissions = submissions.declined if filters[:status] == 'declined'
submissions = submissions.expired if filters[:status] == 'expired'
if filters[:status] == 'partially_completed'
submissions =
submissions.joins(:submitters)
.group(:id)
.having(Arel::Nodes::NamedFunction.new(
'COUNT', [Arel::Nodes::NamedFunction.new('NULLIF',
[Submitter.arel_table[:completed_at].eq(nil),
Arel::Nodes.build_quoted(false)])]
).gt(0))
.having(Arel::Nodes::NamedFunction.new(
'COUNT', [Arel::Nodes::NamedFunction.new('NULLIF',
[Submitter.arel_table[:completed_at].not_eq(nil),
Arel::Nodes.build_quoted(false)])]
).gt(0))
end
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_to].present?
submissions = submissions.where(created_at: ..filters[:created_at_to].end_of_day)
end
if filters[:completed_at_from].present? || filters[:completed_at_to].present?
completed_arel = Submitter.arel_table[:completed_at].maximum
submissions = submissions.completed.joins(:submitters).group(:id)
submissions
end
if filters[:completed_at_from].present?
submissions = submissions.having(completed_arel.gteq(filters[:completed_at_from]))
end
def filter_by_completed_at(submissions, filters)
return submissions unless filters[:completed_at_from].present? || filters[:completed_at_to].present?
if filters[:completed_at_to].present?
submissions = submissions.having(completed_arel.lteq(filters[:completed_at_to].end_of_day))
end
completed_arel = Submitter.arel_table[:completed_at].maximum
submissions = submissions.completed.joins(:submitters).group(:id)
if filters[:completed_at_from].present?
submissions = submissions.having(completed_arel.gteq(filters[:completed_at_from]))
end
submissions
return submissions if filters[:completed_at_to].blank?
submissions.having(completed_arel.lteq(filters[:completed_at_to].end_of_day))
end
def normalize_filter_params(params, current_user)
+9 -5
View File
@@ -43,12 +43,16 @@ module Submissions
last_submitter = submission.submitters.select(&:completed_at).max_by(&:completed_at)
sign_params = {
reason: sign_reason,
**Submissions::GenerateResultAttachments.build_signing_params(last_submitter, pkcs, tsa_url)
}
if pkcs
sign_params = {
reason: sign_reason,
**Submissions::GenerateResultAttachments.build_signing_params(last_submitter, pkcs, tsa_url)
}
document.sign(io, **sign_params)
document.sign(io, **sign_params)
else
document.write(io)
end
Submissions::GenerateResultAttachments.maybe_enable_ltv(io, sign_params)
@@ -17,12 +17,16 @@ module Submissions
pdf.trailer.info[:Creator] = "#{Docuseal.product_name} (#{Docuseal::PRODUCT_URL})"
sign_params = {
reason: Submissions::GenerateResultAttachments.single_sign_reason(submitter),
**Submissions::GenerateResultAttachments.build_signing_params(submitter, pkcs, tsa_url)
}
if pkcs
sign_params = {
reason: Submissions::GenerateResultAttachments.single_sign_reason(submitter),
**Submissions::GenerateResultAttachments.build_signing_params(submitter, pkcs, tsa_url)
}
pdf.sign(io, **sign_params)
pdf.sign(io, **sign_params)
else
pdf.write(io)
end
Submissions::GenerateResultAttachments.maybe_enable_ltv(io, sign_params)
@@ -49,6 +53,8 @@ module Submissions
submitter.submission.template_schema.each do |item|
pdf = pdfs_index[item['attachment_uuid']]
next unless pdf
pdf.dispatch_message(:complete_objects)
pdf.pages.each { |page| result.pages << result.import(page) }
@@ -37,9 +37,11 @@ module Submissions
original_documents = template.documents.preload(:blob)
result_attachments =
(submission.template_schema || template.schema).map do |item|
(submission.template_schema || template.schema).filter_map do |item|
pdf = pdfs_index[item['attachment_uuid']]
next if pdf.nil?
if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image?
pdf = GenerateResultAttachments.normalize_image_pdf(pdf)
+37 -18
View File
@@ -56,9 +56,11 @@ module Submissions
original_documents = template.documents.preload(:blob)
result_attachments =
submitter.submission.template_schema.map do |item|
submitter.submission.template_schema.filter_map do |item|
pdf = pdfs_index[item['attachment_uuid']]
next if pdf.nil?
if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image?
pdf = normalize_image_pdf(pdf)
@@ -234,7 +236,7 @@ module Submissions
reason_string =
I18n.with_locale(submitter.account.locale) do
"#{I18n.t('reason')}: #{reason_value || I18n.t('digitally_signed_by')} " \
"#{reason_value ? "#{I18n.t('reason')}: " : ''}#{reason_value || I18n.t('digitally_signed_by')} " \
"#{submitter.name}#{submitter.email.present? ? " <#{submitter.email}>" : ''}\n" \
"#{I18n.l(attachment.created_at.in_time_zone(submitter.account.timezone), format: :long)} " \
"#{TimeUtils.timezone_abbr(submitter.account.timezone, attachment.created_at)}"
@@ -380,7 +382,10 @@ module Submissions
when ->(type) { type == 'cells' && !area['cell_w'].to_f.zero? }
cell_width = area['cell_w'] * width
TextUtils.maybe_rtl_reverse(value).chars.each_with_index do |char, index|
chars = TextUtils.maybe_rtl_reverse(value).chars
chars = chars.reverse if field.dig('preferences', 'align') == 'right'
chars.each_with_index do |char, index|
next if char.blank?
text = HexaPDF::Layout::TextFragment.create(char, font:,
@@ -407,9 +412,15 @@ module Submissions
line_height = layouter.fit([text], cell_width, height).lines.first.height
end
x =
if field.dig('preferences', 'align') == 'right'
((area['x'] + area['w']) * width) - (cell_width * (index + 1))
else
(area['x'] * width) + (cell_width * index)
end
cell_layouter.fit([text], cell_width, [line_height, area['h'] * height].max)
.draw(canvas, ((area['x'] * width) + (cell_width * index)),
height - (area['y'] * height))
.draw(canvas, x, height - (area['y'] * height))
end
else
if field['type'] == 'date'
@@ -476,7 +487,7 @@ module Submissions
sign_reason = fetch_sign_reason(submitter)
if sign_reason
if sign_reason && pkcs
sign_params = {
reason: sign_reason,
**build_signing_params(submitter, pkcs, tsa_url)
@@ -545,7 +556,15 @@ module Submissions
documents = latest_submitter&.documents&.preload(:blob).to_a.presence
documents ||= submission.template_schema_documents.preload(:blob)
documents.to_h do |attachment|
attachment_uuids = Submissions.filtered_conditions_schema(submission).pluck('attachment_uuid')
attachments_index = documents.index_by { |a| a.metadata['original_uuid'] || a.uuid }
attachment_uuids.each_with_object({}) do |uuid, acc|
attachment = attachments_index[uuid]
attachment ||= submission.template_schema_documents.preload(:blob).find { |a| a.uuid == uuid }
next unless attachment
pdf =
if attachment.image?
build_pdf_from_image(attachment)
@@ -555,23 +574,23 @@ module Submissions
pdf = maybe_rotate_pdf(pdf)
if flatten
begin
pdf.acro_form.create_appearances(force: true) if pdf.acro_form && pdf.acro_form[:NeedAppearances]
pdf.acro_form&.flatten
rescue HexaPDF::MissingGlyphError
nil
rescue StandardError => e
Rollbar.error(e) if defined?(Rollbar)
end
end
maybe_flatten_pdf(pdf) if flatten
pdf.config['font.on_missing_glyph'] = method(:on_missing_glyph).to_proc
[attachment.metadata['original_uuid'] || attachment.uuid, pdf]
acc[uuid] = pdf
end
end
def maybe_flatten_pdf(pdf)
pdf.acro_form.create_appearances(force: true) if pdf.acro_form && pdf.acro_form[:NeedAppearances]
pdf.acro_form&.flatten
rescue HexaPDF::MissingGlyphError
nil
rescue StandardError => e
Rollbar.error(e) if defined?(Rollbar)
end
def maybe_rotate_pdf(pdf)
return pdf if pdf.pages.size > MAX_PAGE_ROTATE
+7
View File
@@ -183,10 +183,17 @@ module Submitters
def maybe_remove_condition_values(submitter)
fields_uuid_index = submitter.submission.template_fields.index_by { |e| e['uuid'] }
attachments_index =
Submissions.filtered_conditions_schema(submitter.submission).index_by { |i| i['attachment_uuid'] }
submitter.submission.template_fields.each do |field|
next if field['submitter_uuid'] != submitter.uuid
submitter.values.delete(field['uuid']) unless check_field_conditions(submitter, field, fields_uuid_index)
if field['areas'].present? && field['areas'].none? { |area| attachments_index[area['attachment_uuid']] }
submitter.values.delete(field['uuid'])
end
end
submitter.values
+29 -13
View File
@@ -10,24 +10,32 @@ module Templates
template.external_id = external_id
template.author = author
template.preferences = original_template.preferences.deep_dup
template.name = name || "#{original_template.name} (#{I18n.t('clone')})"
template.name = name.presence || "#{original_template.name} (#{I18n.t('clone')})"
template.assign_attributes(original_template.slice(:folder_id, :schema))
if folder_name.present?
template.folder = TemplateFolders.find_or_create_by_name(author, folder_name)
else
template.folder_id = original_template.folder_id
end
template.folder = TemplateFolders.find_or_create_by_name(author, folder_name) if folder_name.present?
template.submitters, template.fields, template.schema =
update_submitters_and_fields_and_schema(original_template.submitters.deep_dup,
original_template.fields.deep_dup,
original_template.schema.deep_dup)
template.submitters, template.fields = clone_submitters_and_fields(original_template)
if name.present? && template.schema.size == 1 &&
original_template.schema.first['name'] == original_template.name &&
template.name != "#{original_template.name} (#{I18n.t('clone')})"
template.schema.first['name'] = template.name
end
template
end
def clone_submitters_and_fields(original_template)
def update_submitters_and_fields_and_schema(cloned_submitters, cloned_fields, cloned_schema)
submitter_uuids_replacements = {}
field_uuids_replacements = {}
cloned_submitters = original_template['submitters'].deep_dup
cloned_fields = original_template['fields'].deep_dup
cloned_submitters.each do |submitter|
new_submitter_uuid = SecureRandom.uuid
@@ -44,20 +52,28 @@ module Templates
field['submitter_uuid'] = submitter_uuids_replacements[field['submitter_uuid']]
end
replace_fields_regexp = Regexp.union(field_uuids_replacements.keys)
replace_fields_regexp = nil
cloned_fields.each do |field|
Array.wrap(field['conditions']).each do |condition|
condition['field_uuid'] = field_uuids_replacements[condition['field_uuid']]
end
if field.dig('preferences', 'formula').present?
field['preferences']['formula'] =
field['preferences']['formula'].gsub(replace_fields_regexp, field_uuids_replacements)
next if field.dig('preferences', 'formula').blank?
replace_fields_regexp ||= Regexp.union(field_uuids_replacements.keys)
field['preferences']['formula'] =
field['preferences']['formula'].gsub(replace_fields_regexp, field_uuids_replacements)
end
cloned_schema.each do |field|
Array.wrap(field['conditions']).each do |condition|
condition['field_uuid'] = field_uuids_replacements[condition['field_uuid']]
end
end
[cloned_submitters, cloned_fields]
[cloned_submitters, cloned_fields, cloned_schema]
end
end
end
+8 -7
View File
@@ -4,20 +4,21 @@ module Templates
module CloneAttachments
module_function
def call(template:, original_template:)
def call(template:, original_template:, documents: [])
schema_uuids_replacements = {}
cloned_schema = original_template.schema.deep_dup
cloned_fields = template.fields.deep_dup
cloned_schema.each do |schema_item|
template.schema.each_with_index do |schema_item, index|
new_schema_item_uuid = SecureRandom.uuid
schema_uuids_replacements[schema_item['attachment_uuid']] = new_schema_item_uuid
schema_item['attachment_uuid'] = new_schema_item_uuid
new_name = documents&.dig(index, 'name')
schema_item['name'] = new_name if new_name.present?
end
cloned_fields.each do |field|
template.fields.each do |field|
next if field['areas'].blank?
field['areas'].each do |area|
@@ -25,7 +26,7 @@ module Templates
end
end
template.update!(schema: cloned_schema, fields: cloned_fields)
template.save!
original_template.schema_documents.map do |document|
new_document =
+15
View File
@@ -6,6 +6,10 @@ FactoryBot.define do
locale { 'en-US' }
timezone { 'UTC' }
transient do
teams_count { 2 }
end
trait :with_testing_account do
after(:create) do |account|
testing_account = account.dup.tap { |a| a.name = "Testing - #{account.name}" }
@@ -14,5 +18,16 @@ FactoryBot.define do
account.save!
end
end
trait :with_teams do
after(:create) do |account, evaluator|
Array.new(evaluator.teams_count) do |i|
Account.create!(
name: "Team #{i}",
linked_account_account: AccountLinkedAccount.new(account_type: :linked, account:)
)
end
end
end
end
end
+8 -2
View File
@@ -25,7 +25,8 @@ Capybara.register_driver(:headless_cuprite) do |app|
Capybara::Cuprite::Driver.new(app, window_size: [1200, 800],
process_timeout: 20,
timeout: 20,
js_errors: true)
js_errors: true,
browser_options: { 'no-sandbox' => nil })
end
Capybara.register_driver(:headful_cuprite) do |app|
@@ -33,7 +34,8 @@ Capybara.register_driver(:headful_cuprite) do |app|
headless: false,
process_timeout: 20,
timeout: 20,
js_errors: true)
js_errors: true,
browser_options: { 'no-sandbox' => nil })
end
Rails.root.glob('spec/support/**/*.rb').each { |f| require f }
@@ -68,6 +70,10 @@ RSpec.configure do |config|
config.before do |example|
Sidekiq::Testing.inline! if example.metadata[:sidekiq] == :inline
end
config.before(multitenant: true) do
allow(Docuseal).to receive(:multitenant?).and_return(true)
end
end
ActiveSupport.run_load_hooks(:rails_specs, self)
+31
View File
@@ -89,6 +89,21 @@ describe 'Submission API', type: :request do
expect(response.parsed_body).to eq(JSON.parse(create_submission_body(submission).to_json))
end
it 'creates a submission when the message is empty' do
post '/api/submissions', headers: { 'x-auth-token': author.access_token.token }, params: {
template_id: templates[0].id,
send_email: true,
submitters: [{ role: 'First Party', email: 'john.doe@example.com' }],
message: {}
}.to_json
expect(response).to have_http_status(:ok)
submission = Submission.last
expect(response.parsed_body).to eq(JSON.parse(create_submission_body(submission).to_json))
end
it 'creates a submission when some submitter roles are not provided' do
post '/api/submissions', headers: { 'x-auth-token': author.access_token.token }, params: {
template_id: multiple_submitters_template.id,
@@ -168,6 +183,22 @@ describe 'Submission API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body).to eq({ 'error' => 'Defined more signing parties than in template' })
end
it 'returns an error if the message has no body value' do
post '/api/submissions', headers: { 'x-auth-token': author.access_token.token }, params: {
template_id: templates[0].id,
send_email: true,
submitters: [
{ role: 'First Party', email: 'john.doe@example.com' }
],
message: {
subject: 'Custom Email Subject'
}
}.to_json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body).to eq({ 'error' => 'body is required in `message`.' })
end
end
describe 'POST /api/submissions/emails' do
+38
View File
@@ -4,6 +4,7 @@ require 'rails_helper'
RSpec.describe 'Team Settings' do
let(:account) { create(:account) }
let(:second_account) { create(:account) }
let(:current_user) { create(:user, account:) }
before do
@@ -56,6 +57,43 @@ RSpec.describe 'Team Settings' do
end
end
it "doesn't create a new user if a user already exists" do
click_link 'New User'
within '#modal' do
fill_in 'First name', with: 'Michael'
fill_in 'Last name', with: 'Jordan'
fill_in 'Email', with: users.first.email
fill_in 'Password', with: 'password'
expect do
click_button 'Submit'
end.not_to change(User, :count)
end
expect(page).to have_content('Email already exists')
end
it "doesn't create a new user if a user belongs to another account" do
user = create(:user, account: second_account)
visit settings_users_path
click_link 'New User'
within '#modal' do
fill_in 'First name', with: 'Michael'
fill_in 'Last name', with: 'Jordan'
fill_in 'Email', with: user.email
fill_in 'Password', with: 'password'
expect do
click_button 'Submit'
end.not_to change(User, :count)
expect(page).to have_content('Email has already been taken')
end
end
it 'updates a user' do
first(:link, 'Edit').click