Merge from docusealco/wip

master 2.3.2
Alex Turchyn 2 days ago committed by GitHub
commit 6ab68cc36b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -51,7 +51,7 @@ ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
WORKDIR /app WORKDIR /app
RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont onnxruntime && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf RUN apk add --no-cache sqlite-dev libpq-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont onnxruntime && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
@ -69,7 +69,7 @@ activate = 1' >> /etc/openssl_legacy.cnf
COPY --chown=docuseal:docuseal ./Gemfile ./Gemfile.lock ./ COPY --chown=docuseal:docuseal ./Gemfile ./Gemfile.lock ./
RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first") RUN apk add --no-cache build-base git && bundle install && apk del --no-cache build-base git && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
COPY --chown=docuseal:docuseal ./bin ./bin COPY --chown=docuseal:docuseal ./bin ./bin
COPY --chown=docuseal:docuseal ./app ./app COPY --chown=docuseal:docuseal ./app ./app

@ -23,7 +23,6 @@ gem 'hexapdf'
gem 'image_processing' gem 'image_processing'
gem 'jwt', require: false gem 'jwt', require: false
gem 'lograge' gem 'lograge'
gem 'mysql2', require: false
gem 'numo-narray-alt', require: false gem 'numo-narray-alt', require: false
gem 'oj' gem 'oj'
gem 'onnxruntime', require: false gem 'onnxruntime', require: false
@ -45,6 +44,7 @@ gem 'shakapacker'
gem 'sidekiq' gem 'sidekiq'
gem 'sqlite3', require: false gem 'sqlite3', require: false
gem 'strip_attributes' gem 'strip_attributes'
gem 'trilogy', github: 'trilogy-libraries/trilogy', glob: 'contrib/ruby/*.gemspec', require: false
gem 'turbo-rails' gem 'turbo-rails'
gem 'twitter_cldr', require: false gem 'twitter_cldr', require: false
gem 'tzinfo-data' gem 'tzinfo-data'

@ -1,3 +1,11 @@
GIT
remote: https://github.com/trilogy-libraries/trilogy.git
revision: 3963d490459df7a2b5bedb42424c3285f25eab22
glob: contrib/ruby/*.gemspec
specs:
trilogy (2.10.0)
bigdecimal
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
@ -318,8 +326,6 @@ GEM
prism (~> 1.5) prism (~> 1.5)
msgpack (1.8.0) msgpack (1.8.0)
multi_json (1.19.1) multi_json (1.19.1)
mysql2 (0.5.7)
bigdecimal
net-http (0.9.1) net-http (0.9.1)
uri (>= 0.11.1) uri (>= 0.11.1)
net-imap (0.6.2) net-imap (0.6.2)
@ -644,7 +650,6 @@ DEPENDENCIES
jwt jwt
letter_opener_web letter_opener_web
lograge lograge
mysql2
numo-narray-alt numo-narray-alt
oj oj
onnxruntime onnxruntime
@ -673,6 +678,7 @@ DEPENDENCIES
simplecov simplecov
sqlite3 sqlite3
strip_attributes strip_attributes
trilogy!
turbo-rails turbo-rails
twitter_cldr twitter_cldr
tzinfo-data tzinfo-data

@ -188,7 +188,7 @@ module Api
message: %i[subject body], message: %i[subject body],
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role, submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id, :reply_to, :go_to_last, :completed, :phone, :application_key, :external_id, :reply_to, :go_to_last,
:require_phone_2fa, :require_email_2fa, :order, :invite_by, :require_phone_2fa, :require_email_2fa, :order, :index, :invite_by,
{ metadata: {}, values: {}, roles: [], readonly_fields: [], message: %i[subject body], { metadata: {}, values: {}, roles: [], readonly_fields: [], message: %i[subject body],
fields: [:name, :uuid, :default_value, :value, :title, :description, fields: [:name, :uuid, :default_value, :value, :title, :description,
:readonly, :required, :validation_pattern, :invalid_message, :readonly, :required, :validation_pattern, :invalid_message,

@ -89,7 +89,7 @@ window.customElements.define('draw-signature', class extends HTMLElement {
} }
redrawCanvas (oldWidth, oldHeight) { redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty()) { if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
const sx = this.canvas.width / oldWidth const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight const sy = this.canvas.height / oldHeight

@ -64,6 +64,22 @@ export default class extends HTMLElement {
if (action === 'empty' || action === 'unchecked') return this.isEmpty(actual) if (action === 'empty' || action === 'unchecked') return this.isEmpty(actual)
if (action === 'not_empty' || action === 'checked') return !this.isEmpty(actual) if (action === 'not_empty' || action === 'checked') return !this.isEmpty(actual)
if (['equal', 'not_equal', 'greater_than', 'less_than'].includes(action) && this.sourceEl?.getAttribute('type') === 'number') {
if (this.isEmpty(actual) || this.isEmpty(expected)) return false
const actualNumber = parseFloat(actual)
const expectedNumber = parseFloat(expected)
if (Number.isNaN(actualNumber) || Number.isNaN(expectedNumber)) return false
if (action === 'equal') return Math.abs(actualNumber - expectedNumber) < Number.EPSILON
if (action === 'not_equal') return Math.abs(actualNumber - expectedNumber) > Number.EPSILON
if (action === 'greater_than') return actualNumber > expectedNumber
if (action === 'less_than') return actualNumber < expectedNumber
return false
}
if (action === 'equal') { if (action === 'equal') {
const list = Array.isArray(actual) ? actual : [actual] const list = Array.isArray(actual) ? actual : [actual]
return list.filter((v) => v !== null && v !== undefined).map(String).includes(String(expected)) return list.filter((v) => v !== null && v !== undefined).map(String).includes(String(expected))

@ -80,7 +80,7 @@ export default targetable(class extends HTMLElement {
} }
redrawCanvas (oldWidth, oldHeight) { redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty()) { if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
const sx = this.canvas.width / oldWidth const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight const sy = this.canvas.height / oldHeight

@ -182,8 +182,8 @@
</div> </div>
<div <div
v-else-if="field.type === 'cells'" v-else-if="field.type === 'cells'"
class="w-full flex items-center" class="w-full flex"
:class="{ 'justify-end': field.preferences?.align === 'right', ...fontClasses }" :class="{ 'justify-end': field.preferences?.align === 'right', ...alignClasses, ...fontClasses }"
> >
<div <div
v-for="(char, index) in modelValue" v-for="(char, index) in modelValue"

@ -395,7 +395,9 @@
v-model="values[currentField.uuid]" v-model="values[currentField.uuid]"
:reason="values[currentField.preferences?.reason_field_uuid]" :reason="values[currentField.preferences?.reason_field_uuid]"
:field="currentField" :field="currentField"
:values="values"
:previous-value="previousSignatureValueFor(currentField) || previousSignatureValue" :previous-value="previousSignatureValueFor(currentField) || previousSignatureValue"
:touch-attachment-uuid="previousSignatureValue"
:with-typed-signature="withTypedSignature" :with-typed-signature="withTypedSignature"
:remember-signature="rememberSignature" :remember-signature="rememberSignature"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
@ -407,6 +409,7 @@
:submitter="submitter" :submitter="submitter"
:show-field-names="showFieldNames" :show-field-names="showFieldNames"
@update:reason="values[currentField.preferences?.reason_field_uuid] = $event" @update:reason="values[currentField.preferences?.reason_field_uuid] = $event"
@touch-attachment="attachmentsIndex[previousSignatureValue] ? attachmentsIndex[previousSignatureValue].created_at = new Date() : null"
@attached="attachments.push($event)" @attached="attachments.push($event)"
@start="scrollIntoField(currentField)" @start="scrollIntoField(currentField)"
@minimize="minimizeForm" @minimize="minimizeForm"
@ -926,10 +929,12 @@ export default {
}, {}) }, {})
}, },
attachmentConditionsIndex () { attachmentConditionsIndex () {
const cache = {}
return this.schema.reduce((acc, item) => { return this.schema.reduce((acc, item) => {
if (item.conditions?.length) { if (item.conditions?.length) {
if (item.conditions.every((c) => this.fieldsUuidIndex[c.field_uuid])) { if (item.conditions.every((c) => this.fieldsUuidIndex[c.field_uuid])) {
acc[item.attachment_uuid] = this.checkFieldConditions(item) acc[item.attachment_uuid] = this.checkFieldConditions(item, cache)
} else { } else {
acc[item.attachment_uuid] = true acc[item.attachment_uuid] = true
} }
@ -996,7 +1001,9 @@ export default {
}, },
previousInitialsValue () { previousInitialsValue () {
if (this.reuseSignature !== false) { if (this.reuseSignature !== false) {
const initialsField = [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid]) const initialsField = this.fields.findLast
? this.fields.findLast((field) => field.type === 'initials' && !!this.values[field.uuid])
: [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
return this.values[initialsField?.uuid] return this.values[initialsField?.uuid]
} else { } else {
@ -1023,7 +1030,9 @@ export default {
return this.readonlyFields.filter((f) => f.conditions?.length) return this.readonlyFields.filter((f) => f.conditions?.length)
}, },
readonlyFields () { readonlyFields () {
return this.fields.filter((f) => f.readonly && this.checkFieldConditions(f) && this.checkFieldDocumentsConditions(f)) const cache = {}
return this.fields.filter((f) => f.readonly && this.checkFieldConditions(f, cache) && this.checkFieldDocumentsConditions(f))
}, },
stepFields () { stepFields () {
const verificationFields = [] const verificationFields = []
@ -1078,10 +1087,12 @@ export default {
sortedFields.push(verificationFields.pop()) sortedFields.push(verificationFields.pop())
} }
const cache = {}
return sortedFields.reduce((acc, f) => { return sortedFields.reduce((acc, f) => {
const prevStep = acc[acc.length - 1] const prevStep = acc[acc.length - 1]
if (this.checkFieldConditions(f) && this.checkFieldDocumentsConditions(f)) { if (this.checkFieldConditions(f, cache) && this.checkFieldDocumentsConditions(f)) {
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox' && !f.description) { if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox' && !f.description) {
prevStep.push(f) prevStep.push(f)
} else { } else {
@ -1093,7 +1104,9 @@ export default {
}, []) }, [])
}, },
formulaFields () { formulaFields () {
return this.fields.filter((f) => f.preferences?.formula && f.type !== 'payment' && this.checkFieldConditions(f) && this.checkFieldDocumentsConditions(f)) const cache = {}
return this.fields.filter((f) => f.preferences?.formula && f.type !== 'payment' && this.checkFieldConditions(f, cache) && this.checkFieldDocumentsConditions(f))
}, },
attachmentsIndex () { attachmentsIndex () {
return this.attachments.reduce((acc, a) => { return this.attachments.reduce((acc, a) => {
@ -1155,7 +1168,9 @@ export default {
this.currentStep = Math.max(stepIndex, 0) this.currentStep = Math.max(stepIndex, 0)
} else if (this.goToLast) { } else if (this.goToLast) {
const requiredEmptyStepIndex = this.stepFields.indexOf(this.stepFields.find((fields) => fields.some((f) => f.required && !this.submittedValues[f.uuid]))) const requiredEmptyStepIndex = this.stepFields.indexOf(this.stepFields.find((fields) => fields.some((f) => f.required && !this.submittedValues[f.uuid])))
const lastFilledStepIndex = this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))) + 1 const lastFilledStepIndex = this.stepFields.indexOf(this.stepFields.findLast
? this.stepFields.findLast((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))
: [...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))) + 1
const indexesList = [this.stepFields.length - 1] const indexesList = [this.stepFields.length - 1]
@ -1223,27 +1238,33 @@ export default {
return true return true
} }
}, },
checkFieldConditions (field) { checkFieldConditions (field, cache = {}) {
if (cache[field.uuid] !== undefined) {
return cache[field.uuid]
}
cache[field.uuid] = true
if (field.conditions?.length) { if (field.conditions?.length) {
const result = field.conditions.reduce((acc, cond) => { const result = field.conditions.reduce((acc, cond) => {
if (cond.operation === 'or') { if (cond.operation === 'or') {
acc.push(acc.pop() || this.checkFieldCondition(cond)) acc.push(acc.pop() || this.checkFieldCondition(cond, cache))
} else { } else {
acc.push(this.checkFieldCondition(cond)) acc.push(this.checkFieldCondition(cond, cache))
} }
return acc return acc
}, []) }, [])
return !result.includes(false) cache[field.uuid] = !result.includes(false)
} else {
return true
} }
return cache[field.uuid]
}, },
checkFieldCondition (condition) { checkFieldCondition (condition, cache = {}) {
const field = this.fieldsUuidIndex[condition.field_uuid] const field = this.fieldsUuidIndex[condition.field_uuid]
if (['not_empty', 'checked', 'equal', 'contains'].includes(condition.action) && field && !this.checkFieldConditions(field)) { if (['not_empty', 'checked', 'equal', 'contains', 'greater_than', 'less_than'].includes(condition.action) && field && !this.checkFieldConditions(field, cache)) {
return false return false
} }
@ -1253,6 +1274,22 @@ export default {
return isEmpty(this.values[condition.field_uuid] ?? defaultValue) return isEmpty(this.values[condition.field_uuid] ?? defaultValue)
} else if (['not_empty', 'checked'].includes(condition.action)) { } else if (['not_empty', 'checked'].includes(condition.action)) {
return !isEmpty(this.values[condition.field_uuid] ?? defaultValue) return !isEmpty(this.values[condition.field_uuid] ?? defaultValue)
} else if (field?.type === 'number' && ['equal', 'not_equal', 'greater_than', 'less_than'].includes(condition.action)) {
const value = this.values[condition.field_uuid] ?? defaultValue
if (isEmpty(value) || isEmpty(condition.value)) return false
const actual = parseFloat(value)
const expected = parseFloat(condition.value)
if (Number.isNaN(actual) || Number.isNaN(expected)) return false
if (condition.action === 'equal') return Math.abs(actual - expected) < Number.EPSILON
if (condition.action === 'not_equal') return Math.abs(actual - expected) > Number.EPSILON
if (condition.action === 'greater_than') return actual > expected
if (condition.action === 'less_than') return actual < expected
return false
} else if (['equal', 'contains'].includes(condition.action) && field) { } else if (['equal', 'contains'].includes(condition.action) && field) {
if (field.options) { if (field.options) {
const option = field.options.find((o) => o.uuid === condition.value) const option = field.options.find((o) => o.uuid === condition.value)
@ -1353,9 +1390,9 @@ export default {
}, },
previousSignatureValueFor (field) { previousSignatureValueFor (field) {
if (this.reuseSignature !== false) { if (this.reuseSignature !== false) {
const signatureField = [...this.fields].reverse().find((f) => const signatureField = this.fields.findLast
f.type === 'signature' && field.preferences?.format === f.preferences?.format && !!this.values[f.uuid] ? this.fields.findLast((f) => f.type === 'signature' && field.preferences?.format === f.preferences?.format && !!this.values[f.uuid])
) : [...this.fields].reverse().find((f) => f.type === 'signature' && field.preferences?.format === f.preferences?.format && !!this.values[f.uuid])
return this.values[signatureField?.uuid] return this.values[signatureField?.uuid]
} else { } else {

@ -497,10 +497,10 @@ export default {
body: JSON.stringify(payload) body: JSON.stringify(payload)
}) })
if (!resp.ok) throw new Error('Failed to start KBA')
const data = await resp.json() const data = await resp.json()
if (!resp.ok) throw new Error(data.error || 'Failed to start KBA')
if (data.result && data.result.action === 'FAIL') { if (data.result && data.result.action === 'FAIL') {
if (data.result.detail === 'NO MATCH') { if (data.result.detail === 'NO MATCH') {
throw new Error('Unfortunately, we were unable to start Knowledge Based Authentication with the details provided. Please review and confirm that all your personal details are correct.') throw new Error('Unfortunately, we were unable to start Knowledge Based Authentication with the details provided. Please review and confirm that all your personal details are correct.')
@ -555,7 +555,11 @@ export default {
const data = await resp.json() const data = await resp.json()
if (data.result?.action !== 'PASS') { if (data.result?.action !== 'PASS') {
if (data.result?.issues?.length) {
this.error = `Knowledge Based Authentication Failed - make sure you provide correct details for the Knowledge Based authentication: ${data.result.issues.join(', ')}`
} else {
this.error = 'Knowledge Based Authentication Failed - make sure you provide correct answers for the Knowledge Based authentication.' this.error = 'Knowledge Based Authentication Failed - make sure you provide correct answers for the Knowledge Based authentication.'
}
throw new Error('Knowledge Based Authentication Failed') throw new Error('Knowledge Based Authentication Failed')
} }

@ -127,6 +127,12 @@
type="hidden" type="hidden"
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
> >
<input
v-if="isTouchAttachment"
:value="touchAttachmentUuid"
type="hidden"
name="touch_attachment_uuid"
>
<img <img
v-if="modelValue || computedPreviousValue" v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url" :src="attachmentsIndex[modelValue || computedPreviousValue].url"
@ -334,6 +340,10 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
values: {
type: Object,
required: true
},
requireSigningReason: { requireSigningReason: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -388,6 +398,11 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
touchAttachmentUuid: {
type: String,
required: false,
default: ''
},
reason: { reason: {
type: String, type: String,
required: false, required: false,
@ -399,13 +414,14 @@ export default {
default: '' default: ''
} }
}, },
emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason'], emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason', 'touch-attachment'],
data () { data () {
return { return {
isSignatureStarted: false, isSignatureStarted: false,
isShowQr: false, isShowQr: false,
isOtherReason: false, isOtherReason: false,
isUsePreviousValue: true, isUsePreviousValue: true,
isTouchAttachment: false,
isTextSignature: this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload', isTextSignature: this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload',
uploadImageInputKey: Math.random().toString() uploadImageInputKey: Math.random().toString()
} }
@ -514,7 +530,7 @@ export default {
redrawCanvas (oldWidth, oldHeight) { redrawCanvas (oldWidth, oldHeight) {
const canvas = this.$refs.canvas const canvas = this.$refs.canvas
if (this.pad && !this.isTextSignature && !this.pad.isEmpty()) { if (this.pad && !this.isTextSignature && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
const sx = canvas.width / oldWidth const sx = canvas.width / oldWidth
const sy = canvas.height / oldHeight const sy = canvas.height / oldHeight
@ -731,6 +747,13 @@ export default {
}, },
async submit () { async submit () {
if (this.modelValue || this.computedPreviousValue) { if (this.modelValue || this.computedPreviousValue) {
if (this.touchAttachmentUuid && this.computedPreviousValue === this.touchAttachmentUuid && !Object.values(this.values).includes(this.touchAttachmentUuid)) {
this.isTouchAttachment = true
this.$emit('touch-attachment', this.touchAttachmentUuid)
} else {
this.isTouchAttachment = false
}
if (this.computedPreviousValue) { if (this.computedPreviousValue) {
this.$emit('update:model-value', this.computedPreviousValue) this.$emit('update:model-value', this.computedPreviousValue)
} }

@ -119,6 +119,7 @@ export default {
docId: this.eidEasyData.doc_id, docId: this.eidEasyData.doc_id,
language: this.locale, language: this.locale,
countryCode: this.countryCode, countryCode: this.countryCode,
sandbox: ['demo.docuseal.tech'].includes(location.host),
enabledMethods: { enabledMethods: {
signature: this.eidEasyData.available_methods signature: this.eidEasyData.available_methods
}, },

@ -134,6 +134,7 @@
@focusout="maybeBlurSettings" @focusout="maybeBlurSettings"
> >
<FieldSettings <FieldSettings
v-if="isMobile"
:field="field" :field="field"
:default-field="defaultField" :default-field="defaultField"
:editable="editable" :editable="editable"
@ -150,6 +151,12 @@
@save="save" @save="save"
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]" @scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
/> />
<div
v-else
class="whitespace-normal"
>
The dots menu is retired in favor of the field context menu. Right-click the field to access field settings. Double-click the field to set a default value.
</div>
</ul> </ul>
</span> </span>
</div> </div>
@ -252,7 +259,8 @@
</span> </span>
<div <div
v-else-if="field.type === 'cells' && field.default_value" v-else-if="field.type === 'cells' && field.default_value"
class="w-full flex items-center" class="w-full flex"
:class="fontClasses"
> >
<div <div
v-for="(char, index) in field.default_value" v-for="(char, index) in field.default_value"
@ -334,6 +342,7 @@
:editable="editable && !defaultField" :editable="editable && !defaultField"
:default-field="defaultField" :default-field="defaultField"
:build-default-name="buildDefaultName" :build-default-name="buildDefaultName"
@save="save"
@close="isShowFormulaModal = false" @close="isShowFormulaModal = false"
/> />
</Teleport> </Teleport>
@ -346,6 +355,7 @@
:editable="editable && !defaultField" :editable="editable && !defaultField"
:default-field="defaultField" :default-field="defaultField"
:build-default-name="buildDefaultName" :build-default-name="buildDefaultName"
@save="save"
@close="isShowFontModal = false" @close="isShowFontModal = false"
/> />
</Teleport> </Teleport>
@ -357,6 +367,7 @@
:item="field" :item="field"
:build-default-name="buildDefaultName" :build-default-name="buildDefaultName"
:default-field="defaultField" :default-field="defaultField"
@save="save"
@close="isShowConditionsModal = false" @close="isShowConditionsModal = false"
/> />
</Teleport> </Teleport>
@ -369,6 +380,7 @@
:editable="editable && !defaultField" :editable="editable && !defaultField"
:default-field="defaultField" :default-field="defaultField"
:build-default-name="buildDefaultName" :build-default-name="buildDefaultName"
@save="save"
@close="isShowDescriptionModal = false" @close="isShowDescriptionModal = false"
/> />
</Teleport> </Teleport>
@ -401,7 +413,7 @@ export default {
FieldSubmitter, FieldSubmitter,
IconX IconX
}, },
inject: ['template', 'save', 't', 'isInlineSize', 'selectedAreasRef', 'isCmdKeyRef'], inject: ['template', 'save', 't', 'isInlineSize', 'selectedAreasRef', 'isCmdKeyRef', 'getFieldTypeIndex'],
props: { props: {
area: { area: {
type: Object, type: Object,
@ -467,6 +479,11 @@ export default {
required: false, required: false,
default: null default: null
}, },
isMobile: {
type: Boolean,
required: false,
default: false
},
isSelectMode: { isSelectMode: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -579,7 +596,7 @@ export default {
return this.$el.getRootNode().querySelector('#docuseal_modal_container') return this.$el.getRootNode().querySelector('#docuseal_modal_container')
}, },
defaultName () { defaultName () {
return this.buildDefaultName(this.field, this.template.fields) return this.buildDefaultName(this.field)
}, },
fontClasses () { fontClasses () {
if (!this.field.preferences) { if (!this.field.preferences) {

@ -374,6 +374,7 @@
:draw-field-type="drawFieldType" :draw-field-type="drawFieldType"
:draw-custom-field="drawCustomField" :draw-custom-field="drawCustomField"
:editable="editable" :editable="editable"
:is-mobile="isMobile"
:base-url="baseUrl" :base-url="baseUrl"
:with-fields-detection="withFieldsDetection" :with-fields-detection="withFieldsDetection"
@draw="[onDraw($event), withSelectedFieldType ? '' : drawFieldType = '', drawCustomField = null, showDrawField = false]" @draw="[onDraw($event), withSelectedFieldType ? '' : drawFieldType = '', drawCustomField = null, showDrawField = false]"
@ -382,9 +383,9 @@
@paste-field="pasteField" @paste-field="pasteField"
@copy-field="copyField" @copy-field="copyField"
@add-custom-field="addCustomField" @add-custom-field="addCustomField"
@set-draw="[drawField = $event.field, drawOption = $event.option]"
@copy-selected-areas="copySelectedAreas" @copy-selected-areas="copySelectedAreas"
@delete-selected-areas="deleteSelectedAreas" @delete-selected-areas="deleteSelectedAreas"
@align-selected-areas="alignSelectedAreas"
@autodetect-fields="detectFieldsForPage" @autodetect-fields="detectFieldsForPage"
/> />
<DocumentControls <DocumentControls
@ -645,7 +646,8 @@ export default {
fieldsDragFieldRef: computed(() => this.fieldsDragFieldRef), fieldsDragFieldRef: computed(() => this.fieldsDragFieldRef),
customDragFieldRef: computed(() => this.customDragFieldRef), customDragFieldRef: computed(() => this.customDragFieldRef),
isSelectModeRef: computed(() => this.isSelectModeRef), isSelectModeRef: computed(() => this.isSelectModeRef),
isCmdKeyRef: computed(() => this.isCmdKeyRef) isCmdKeyRef: computed(() => this.isCmdKeyRef),
getFieldTypeIndex: this.getFieldTypeIndex
} }
}, },
props: { props: {
@ -989,6 +991,18 @@ export default {
return areas return areas
}, },
fieldTypeIndexMap () {
const map = {}
const typeCounters = {}
this.template.fields.forEach((f) => {
typeCounters[f.type] ||= 0
map[f.uuid] = typeCounters[f.type]
typeCounters[f.type]++
})
return map
},
isAllRequiredFieldsAdded () { isAllRequiredFieldsAdded () {
return !this.defaultRequiredFields?.some((f) => { return !this.defaultRequiredFields?.some((f) => {
return !this.template.fields?.some((field) => field.name === f.name) return !this.template.fields?.some((field) => field.name === f.name)
@ -1085,6 +1099,9 @@ export default {
addCustomField (field) { addCustomField (field) {
return this.$refs.fields.addCustomField(field) return this.$refs.fields.addCustomField(field)
}, },
getFieldTypeIndex (field) {
return this.fieldTypeIndexMap[field.uuid]
},
addCustomFieldWithoutDraw () { addCustomFieldWithoutDraw () {
const customField = this.drawCustomField const customField = this.drawCustomField
@ -1160,27 +1177,6 @@ export default {
this.debouncedSave() this.debouncedSave()
}, },
alignSelectedAreas (direction) {
const areas = this.selectedAreasRef.value
let targetValue
if (direction === 'left') {
targetValue = Math.min(...areas.map(a => a.x))
areas.forEach((area) => { area.x = targetValue })
} else if (direction === 'right') {
targetValue = Math.max(...areas.map(a => a.x + a.w))
areas.forEach((area) => { area.x = targetValue - area.w })
} else if (direction === 'top') {
targetValue = Math.min(...areas.map(a => a.y))
areas.forEach((area) => { area.y = targetValue })
} else if (direction === 'bottom') {
targetValue = Math.max(...areas.map(a => a.y + a.h))
areas.forEach((area) => { area.y = targetValue - area.h })
}
this.save()
},
download () { download () {
this.isDownloading = true this.isDownloading = true
@ -2065,7 +2061,9 @@ export default {
} }
if (type === 'checkbox' && !this.drawFieldType && (this.template.fields[this.template.fields.length - 1]?.type === 'checkbox' || area.w)) { if (type === 'checkbox' && !this.drawFieldType && (this.template.fields[this.template.fields.length - 1]?.type === 'checkbox' || area.w)) {
const previousField = [...this.template.fields].reverse().find((f) => f.type === type) const previousField = this.template.fields.findLast
? this.template.fields.findLast((f) => f.type === type)
: [...this.template.fields].reverse().find((f) => f.type === type)
const previousArea = previousField?.areas?.[previousField.areas.length - 1] const previousArea = previousField?.areas?.[previousField.areas.length - 1]
if (previousArea || area.w) { if (previousArea || area.w) {
@ -2329,7 +2327,9 @@ export default {
assignDropAreaSize (fieldArea, field, area) { assignDropAreaSize (fieldArea, field, area) {
const fieldType = field.type || 'text' const fieldType = field.type || 'text'
const previousField = [...this.template.fields].reverse().find((f) => f.type === fieldType) const previousField = this.template.fields.findLast
? this.template.fields.findLast((f) => f.type === fieldType)
: [...this.template.fields].reverse().find((f) => f.type === fieldType)
let baseArea let baseArea

@ -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="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"> <div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title"> <span class="modal-title">
{{ t('condition') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item, template.fields) }} {{ t('condition') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item) }}
</span> </span>
<a <a
href="#" href="#"
@ -83,7 +83,7 @@
class="text-base-content" class="text-base-content"
:selected="condition.field_uuid === f.uuid" :selected="condition.field_uuid === f.uuid"
> >
{{ f.name || buildDefaultName(f, template.fields) }} {{ f.name || buildDefaultName(f) }}
</option> </option>
</select> </select>
<select <select
@ -124,6 +124,16 @@
{{ option.value || `${t('option')} ${index + 1}` }} {{ option.value || `${t('option')} ${index + 1}` }}
</option> </option>
</select> </select>
<input
v-else-if="conditionField(condition)?.type === 'number' && ['equal', 'not_equal', 'greater_than', 'less_than'].includes(condition.action)"
v-model="condition.value"
type="number"
step="any"
class="input input-bordered input-sm w-full bg-white h-11 pl-4 text-base font-normal"
:class="{ 'text-gray-300': !condition.value }"
:placeholder="t('type_value')"
required
>
</div> </div>
</div> </div>
<a <a
@ -154,7 +164,7 @@
<script> <script>
export default { export default {
name: 'ConditionModal', name: 'ConditionModal',
inject: ['t', 'save', 'template', 'withConditions'], inject: ['t', 'template', 'withConditions'],
props: { props: {
item: { item: {
type: Object, type: Object,
@ -169,18 +179,13 @@ export default {
type: Function, type: Function,
required: true required: true
}, },
withClickSaveEvent: {
type: Boolean,
required: false,
default: false
},
excludeFieldUuids: { excludeFieldUuids: {
type: Array, type: Array,
required: false, required: false,
default: () => [] default: () => []
} }
}, },
emits: ['close', 'click-save'], emits: ['close', 'save'],
data () { data () {
return { return {
conditions: this.item.conditions?.[0] ? JSON.parse(JSON.stringify(this.item.conditions)) : [{}] conditions: this.item.conditions?.[0] ? JSON.parse(JSON.stringify(this.item.conditions)) : [{}]
@ -227,6 +232,8 @@ export default {
actions.push('equal', 'not_equal') actions.push('equal', 'not_equal')
} else if (['multiple'].includes(field.type)) { } else if (['multiple'].includes(field.type)) {
actions.push('contains', 'does_not_contain') actions.push('contains', 'does_not_contain')
} else if (field.type === 'number') {
actions.push('not_empty', 'empty', 'equal', 'not_equal', 'greater_than', 'less_than')
} else { } else {
actions.push('not_empty', 'empty') actions.push('not_empty', 'empty')
} }
@ -244,12 +251,7 @@ export default {
delete this.item.conditions delete this.item.conditions
} }
if (this.withClickSaveEvent) { this.$emit('save')
this.$emit('click-save')
} else {
this.save()
}
this.$emit('close') this.$emit('close')
} }
} }

@ -179,8 +179,11 @@ export default {
}, },
onBlur (e) { onBlur (e) {
setTimeout(() => { setTimeout(() => {
if (this.$refs.contenteditable) {
this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue
this.$emit('update:model-value', this.value) this.$emit('update:model-value', this.value)
}
this.$emit('blur', e) this.$emit('blur', e)
this.isEditable = false this.isEditable = false

@ -1,546 +0,0 @@
<template>
<div>
<div
v-if="!isShowFormulaModal && !isShowFontModal && !isShowConditionsModal && !isShowDescriptionModal"
ref="menu"
class="fixed z-50 p-1 bg-white shadow-lg rounded-lg border border-base-300 min-w-[170px] cursor-default"
:style="menuStyle"
@mousedown.stop
@pointerdown.stop
>
<label
v-if="showRequired"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
:checked="isRequired"
type="checkbox"
class="toggle toggle-xs"
@change="handleToggleRequired($event.target.checked)"
@click.stop
>
<span>{{ t('required') }}</span>
</label>
<label
v-if="showReadOnly"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
:checked="isReadOnly"
type="checkbox"
class="toggle toggle-xs"
@change="handleToggleReadOnly($event.target.checked)"
@click.stop
>
<span>{{ t('read_only') }}</span>
</label>
<hr
v-if="(showRequired || showReadOnly) && (showFont || showDescription || showCondition || showFormula)"
class="my-1 border-base-300"
>
<button
v-if="showFont && !isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openFontModal"
>
<IconTypography class="w-4 h-4" />
<span>{{ t('font') }}</span>
</button>
<button
v-if="showDescription"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openDescriptionModal"
>
<IconInfoCircle class="w-4 h-4" />
<span>{{ t('description') }}</span>
</button>
<button
v-if="showCondition && !isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openConditionModal"
>
<IconRouteAltLeft class="w-4 h-4" />
<span>{{ t('condition') }}</span>
</button>
<button
v-if="showFormula"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openFormulaModal"
>
<IconMathFunction class="w-4 h-4" />
<span>{{ t('formula') }}</span>
</button>
<hr
v-if="((showFont && !isMultiSelection) || showDescription || (showCondition && !isMultiSelection) || showFormula) && (showCopy || showDelete || showPaste)"
class="my-1 border-base-300"
>
<button
v-if="isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('align', 'left')"
>
<IconLayoutAlignLeft class="w-4 h-4" />
<span>{{ t('align_left') }}</span>
</button>
<button
v-if="isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('align', 'right')"
>
<IconLayoutAlignRight class="w-4 h-4" />
<span>{{ t('align_right') }}</span>
</button>
<button
v-if="isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('align', 'top')"
>
<IconLayoutAlignTop class="w-4 h-4" />
<span>{{ t('align_top') }}</span>
</button>
<button
v-if="isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('align', 'bottom')"
>
<IconLayoutAlignBottom class="w-4 h-4" />
<span>{{ t('align_bottom') }}</span>
</button>
<hr
v-if="isMultiSelection && (showFont || showCondition)"
class="my-1 border-base-300"
>
<button
v-if="showFont && isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openFontModal"
>
<IconTypography class="w-4 h-4" />
<span>{{ t('font') }}</span>
</button>
<button
v-if="showCondition && isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openConditionModal"
>
<IconRouteAltLeft class="w-4 h-4" />
<span>{{ t('condition') }}</span>
</button>
<hr
v-if="isMultiSelection"
class="my-1 border-base-300"
>
<button
v-if="showCopy"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm"
@click.stop="$emit('copy')"
>
<span class="flex items-center space-x-2">
<IconCopy class="w-4 h-4" />
<span>{{ t('copy') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘C' : 'Ctrl+C' }}</span>
</button>
<button
v-if="showDelete"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm text-red-600"
@click.stop="$emit('delete')"
>
<span class="flex items-center space-x-2">
<IconTrashX class="w-4 h-4" />
<span>{{ t('remove') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">Del</span>
</button>
<button
v-if="showPaste"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm"
:class="!hasClipboardData ? 'opacity-50 cursor-not-allowed' : 'hover:bg-base-100'"
:disabled="!hasClipboardData"
@click.stop="!hasClipboardData ? null : $emit('paste')"
>
<span class="flex items-center space-x-2">
<IconClipboard class="w-4 h-4" />
<span>{{ t('paste') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘V' : 'Ctrl+V' }}</span>
</button>
<button
v-if="showSelectFields"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm"
@click.stop="handleToggleSelectMode"
>
<span class="flex items-center space-x-2">
<IconClick
v-if="!isSelectModeRef.value"
class="w-4 h-4"
/>
<IconNewSection
v-else
class="w-4 h-4"
/>
<span>{{ isSelectModeRef.value ? t('draw_fields') : t('select_fields') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">Tab</span>
</button>
<hr
v-if="showAutodetectFields"
class="my-1 border-base-300"
>
<button
v-if="showAutodetectFields"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('autodetect-fields')"
>
<IconSparkles class="w-4 h-4" />
<span>{{ t('autodetect_fields') }}</span>
</button>
</div>
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:editable="editable"
:build-default-name="buildDefaultName"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="multiSelectField || field"
:area="contextMenu.area"
:editable="editable"
:build-default-name="buildDefaultName"
:with-click-save-event="isMultiSelection"
@click-save="handleSaveMultiSelectFontModal"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:item="multiSelectField || field"
:build-default-name="buildDefaultName"
:exclude-field-uuids="isMultiSelection ? selectedFields.map(f => f.uuid) : []"
:with-click-save-event="isMultiSelection"
@click-save="handleSaveMultiSelectConditionsModal"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:editable="editable"
:build-default-name="buildDefaultName"
@close="closeModal"
/>
</Teleport>
</div>
</template>
<script>
import { IconCopy, IconClipboard, IconTrashX, IconTypography, IconInfoCircle, IconRouteAltLeft, IconMathFunction, IconClick, IconNewSection, IconLayoutAlignLeft, IconLayoutAlignRight, IconLayoutAlignTop, IconLayoutAlignBottom, IconSparkles } from '@tabler/icons-vue'
import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import Field from './field'
import FieldType from './field_type.vue'
export default {
name: 'ContextMenu',
components: {
IconCopy,
IconClipboard,
IconTrashX,
IconTypography,
IconInfoCircle,
IconRouteAltLeft,
IconMathFunction,
IconClick,
IconNewSection,
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconLayoutAlignTop,
IconLayoutAlignBottom,
IconSparkles,
FormulaModal,
FontModal,
ConditionsModal,
DescriptionModal
},
inject: ['t', 'save', 'selectedAreasRef', 'isSelectModeRef'],
props: {
contextMenu: {
type: Object,
default: null,
required: true
},
field: {
type: Object,
default: null
},
editable: {
type: Boolean,
default: true
},
isMultiSelection: {
type: Boolean,
default: false
},
selectedAreas: {
type: Array,
default: () => []
},
template: {
type: Object,
default: null
},
withFieldsDetection: {
type: Boolean,
default: false
}
},
emits: ['copy', 'paste', 'delete', 'close', 'align', 'autodetect-fields'],
data () {
return {
isShowFormulaModal: false,
isShowFontModal: false,
isShowConditionsModal: false,
isShowDescriptionModal: false,
multiSelectField: null
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels,
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
selectedFields () {
if (!this.isMultiSelection) return []
return this.selectedAreasRef.value.map((area) => {
return this.template.fields.find((f) => f.areas?.includes(area))
}).filter(Boolean)
},
isMac () {
return (navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')
},
hasClipboardData () {
try {
const clipboard = localStorage.getItem('docuseal_clipboard')
if (clipboard) {
const data = JSON.parse(clipboard)
return Date.now() - data.timestamp < 3600000
}
return false
} catch {
return false
}
},
menuStyle () {
return {
left: this.contextMenu.x + 'px',
top: this.contextMenu.y + 'px'
}
},
showCopy () {
return !!this.contextMenu.area || this.isMultiSelection
},
showPaste () {
return !this.contextMenu.area && !this.isMultiSelection
},
showDelete () {
return !!this.contextMenu.area || this.isMultiSelection
},
showFont () {
if (this.isMultiSelection) return true
if (!this.field) return false
return ['text', 'number', 'date', 'select', 'heading'].includes(this.field.type)
},
showDescription () {
if (!this.field) return false
return !['stamp', 'heading', 'strikethrough'].includes(this.field.type)
},
showCondition () {
if (this.isMultiSelection) return true
if (!this.field) return false
return !['stamp', 'heading'].includes(this.field.type)
},
showFormula () {
if (!this.field) return false
return this.field.type === 'number'
},
showRequired () {
if (!this.field) return false
return !['phone', 'stamp', 'verification', 'strikethrough', 'heading'].includes(this.field.type)
},
showReadOnly () {
if (!this.field) return false
return ['text', 'number'].includes(this.field.type)
},
isRequired () {
return this.field?.required || false
},
isReadOnly () {
return this.field?.readonly || false
},
showSelectFields () {
return !this.contextMenu.area && !this.isMultiSelection
},
showAutodetectFields () {
return this.withFieldsDetection && this.editable && !this.contextMenu.area && !this.isMultiSelection
}
},
mounted () {
document.addEventListener('keydown', this.onKeyDown)
document.addEventListener('mousedown', this.handleClickOutside)
this.$nextTick(() => {
this.checkMenuPosition()
})
},
beforeUnmount () {
document.removeEventListener('keydown', this.onKeyDown)
document.removeEventListener('mousedown', this.handleClickOutside)
},
methods: {
buildDefaultName: Field.methods.buildDefaultName,
checkMenuPosition () {
if (this.$refs.menu) {
const rect = this.$refs.menu.getBoundingClientRect()
if (rect.bottom > window.innerHeight) {
this.contextMenu.y = this.contextMenu.y - rect.height
}
if (rect.right > window.innerWidth) {
this.contextMenu.x = this.contextMenu.x - rect.width
}
}
},
handleToggleRequired (value) {
if (this.field) {
this.field.required = value
this.save()
}
},
handleToggleReadOnly (value) {
if (this.field) {
this.field.readonly = value
this.save()
}
},
onKeyDown (event) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
this.$emit('close')
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v' && this.hasClipboardData) {
event.preventDefault()
event.stopPropagation()
this.$emit('paste')
}
},
handleClickOutside (event) {
if (this.$refs.menu && !this.$refs.menu.contains(event.target)) {
this.$emit('close')
}
},
openFontModal () {
if (this.isMultiSelection) {
this.multiSelectField = {
name: this.t('fields_selected').replace('{count}', this.selectedFields.length),
preferences: {}
}
const preferencesStrings = this.selectedFields.map((f) => JSON.stringify(f.preferences || {}))
if (preferencesStrings.every((s) => s === preferencesStrings[0])) {
this.multiSelectField.preferences = JSON.parse(preferencesStrings[0])
}
}
this.isShowFontModal = true
},
openDescriptionModal () {
this.isShowDescriptionModal = true
},
openConditionModal () {
if (this.isMultiSelection) {
this.multiSelectField = {
name: this.t('fields_selected').replace('{count}', this.selectedFields.length),
conditions: []
}
const conditionStrings = this.selectedFields.map((f) => JSON.stringify(f.conditions || []))
if (conditionStrings.every((s) => s === conditionStrings[0])) {
this.multiSelectField.conditions = JSON.parse(conditionStrings[0])
}
}
this.isShowConditionsModal = true
},
openFormulaModal () {
this.isShowFormulaModal = true
},
closeModal () {
this.isShowFormulaModal = false
this.isShowFontModal = false
this.isShowConditionsModal = false
this.isShowDescriptionModal = false
this.multiSelectField = null
this.$emit('close')
},
handleSaveMultiSelectFontModal () {
this.selectedFields.forEach((field) => {
field.preferences = { ...field.preferences, ...this.multiSelectField.preferences }
})
this.save()
this.closeModal()
},
handleSaveMultiSelectConditionsModal () {
this.selectedFields.forEach((field) => {
field.conditions = JSON.parse(JSON.stringify(this.multiSelectField.conditions))
})
this.save()
this.closeModal()
},
handleToggleSelectMode () {
this.isSelectModeRef.value = !this.isSelectModeRef.value
this.$emit('close')
}
}
}
</script>

@ -125,6 +125,7 @@
:field="field" :field="field"
:default-field="defaultField" :default-field="defaultField"
:build-default-name="buildDefaultName" :build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowFormulaModal = false" @close="isShowFormulaModal = false"
/> />
</Teleport> </Teleport>
@ -136,6 +137,7 @@
:field="field" :field="field"
:default-field="defaultField" :default-field="defaultField"
:build-default-name="buildDefaultName" :build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowFontModal = false" @close="isShowFontModal = false"
/> />
</Teleport> </Teleport>
@ -147,6 +149,7 @@
:field="field" :field="field"
:default-field="defaultField" :default-field="defaultField"
:build-default-name="buildDefaultName" :build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowDescriptionModal = false" @close="isShowDescriptionModal = false"
/> />
</Teleport> </Teleport>

@ -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="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"> <div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title"> <span class="modal-title">
{{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field, template.fields) }} {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }}
</span> </span>
<a <a
href="#" href="#"
@ -67,7 +67,7 @@
<script> <script>
export default { export default {
name: 'DescriptionModal', name: 'DescriptionModal',
inject: ['t', 'save', 'template'], inject: ['t', 'template'],
props: { props: {
field: { field: {
type: Object, type: Object,
@ -88,7 +88,7 @@ export default {
required: true required: true
} }
}, },
emits: ['close'], emits: ['close', 'save'],
data () { data () {
return { return {
description: this.field.description, description: this.field.description,
@ -103,7 +103,7 @@ export default {
this.field.description = this.description this.field.description = this.description
this.field.title = this.title this.field.title = this.title
this.save() this.$emit('save')
this.$emit('close') this.$emit('close')
}, },
resizeTextarea () { resizeTextarea () {

@ -13,6 +13,7 @@
:with-signature-id="withSignatureId" :with-signature-id="withSignatureId"
:with-prefillable="withPrefillable" :with-prefillable="withPrefillable"
:is-drag="isDrag" :is-drag="isDrag"
:is-mobile="isMobile"
:with-field-placeholder="withFieldPlaceholder" :with-field-placeholder="withFieldPlaceholder"
:default-fields="defaultFields" :default-fields="defaultFields"
:drag-field-placeholder="dragFieldPlaceholder" :drag-field-placeholder="dragFieldPlaceholder"
@ -30,9 +31,9 @@
@copy-field="$emit('copy-field', $event)" @copy-field="$emit('copy-field', $event)"
@paste-field="$emit('paste-field', { ...$event, attachment_uuid: document.uuid })" @paste-field="$emit('paste-field', { ...$event, attachment_uuid: document.uuid })"
@add-custom-field="$emit('add-custom-field', $event)" @add-custom-field="$emit('add-custom-field', $event)"
@set-draw="$emit('set-draw', $event)"
@copy-selected-areas="$emit('copy-selected-areas')" @copy-selected-areas="$emit('copy-selected-areas')"
@delete-selected-areas="$emit('delete-selected-areas')" @delete-selected-areas="$emit('delete-selected-areas')"
@align-selected-areas="$emit('align-selected-areas', $event)"
@autodetect-fields="$emit('autodetect-fields', $event)" @autodetect-fields="$emit('autodetect-fields', $event)"
@scroll-to="scrollToArea" @scroll-to="scrollToArea"
@draw="$emit('draw', { area: {...$event.area, attachment_uuid: document.uuid }, isTooSmall: $event.isTooSmall })" @draw="$emit('draw', { area: {...$event.area, attachment_uuid: document.uuid }, isTooSmall: $event.isTooSmall })"
@ -98,6 +99,11 @@ export default {
required: false, required: false,
default: () => [] default: () => []
}, },
isMobile: {
type: Boolean,
required: false,
default: false
},
allowDraw: { allowDraw: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -138,7 +144,7 @@ export default {
default: false default: false
} }
}, },
emits: ['draw', 'drop-field', 'remove-area', 'paste-field', 'copy-field', 'copy-selected-areas', 'delete-selected-areas', 'align-selected-areas', 'autodetect-fields', 'add-custom-field'], emits: ['draw', 'drop-field', 'remove-area', 'paste-field', 'copy-field', 'copy-selected-areas', 'delete-selected-areas', 'autodetect-fields', 'add-custom-field', 'set-draw'],
data () { data () {
return { return {
pageRefs: [] pageRefs: []
@ -180,7 +186,15 @@ export default {
methods: { methods: {
scrollToArea (area) { scrollToArea (area) {
this.$nextTick(() => { this.$nextTick(() => {
this.pageRefs[area.page].areaRefs.find((e) => e.area === area).$el.scrollIntoView({ behavior: 'smooth', block: 'center' }) const pageRef = this.pageRefs[area.page]
if (pageRef && pageRef.areaRefs) {
const areaRef = pageRef.areaRefs.find((e) => e.area === area)
if (areaRef && areaRef.$el) {
areaRef.$el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
}) })
}, },
setPageRefs (el) { setPageRefs (el) {

@ -269,6 +269,7 @@
:editable="editable && !defaultField" :editable="editable && !defaultField"
:default-field="defaultField" :default-field="defaultField"
:build-default-name="buildDefaultName" :build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowFormulaModal = false" @close="isShowFormulaModal = false"
/> />
</Teleport> </Teleport>
@ -281,6 +282,7 @@
:editable="editable && !defaultField" :editable="editable && !defaultField"
:default-field="defaultField" :default-field="defaultField"
:build-default-name="buildDefaultName" :build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowFontModal = false" @close="isShowFontModal = false"
/> />
</Teleport> </Teleport>
@ -292,6 +294,7 @@
:item="field" :item="field"
:default-field="defaultField" :default-field="defaultField"
:build-default-name="buildDefaultName" :build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowConditionsModal = false" @close="isShowConditionsModal = false"
/> />
</Teleport> </Teleport>
@ -304,6 +307,7 @@
:editable="editable && !defaultField" :editable="editable && !defaultField"
:default-field="defaultField" :default-field="defaultField"
:build-default-name="buildDefaultName" :build-default-name="buildDefaultName"
@save="$emit('save')"
@close="isShowDescriptionModal = false" @close="isShowDescriptionModal = false"
/> />
</Teleport> </Teleport>
@ -340,7 +344,7 @@ export default {
IconMathFunction, IconMathFunction,
FieldType FieldType
}, },
inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale'], inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale', 'getFieldTypeIndex'],
props: { props: {
field: { field: {
type: Object, type: Object,
@ -412,7 +416,7 @@ export default {
return this.$el.getRootNode().querySelector('#docuseal_modal_container') return this.$el.getRootNode().querySelector('#docuseal_modal_container')
}, },
defaultName () { defaultName () {
return this.buildDefaultName(this.field, this.template.fields) return this.buildDefaultName(this.field)
}, },
areas () { areas () {
return this.field.areas || [] return this.field.areas || []
@ -432,7 +436,7 @@ export default {
this.$emit('save') this.$emit('save')
}, },
buildDefaultName (field, fields) { buildDefaultName (field) {
if (field.type === 'payment' && field.preferences?.price && !field.preferences?.formula) { if (field.type === 'payment' && field.preferences?.price && !field.preferences?.formula) {
const { price, currency } = field.preferences || {} const { price, currency } = field.preferences || {}
@ -443,7 +447,7 @@ export default {
return `${this.fieldNames[field.type]} ${formattedPrice}` return `${this.fieldNames[field.type]} ${formattedPrice}`
} else { } else {
const typeIndex = fields.filter((f) => f.type === field.type).indexOf(field) const typeIndex = this.getFieldTypeIndex(field)
if (field.type === 'heading' || field.type === 'strikethrough') { if (field.type === 'heading' || field.type === 'strikethrough') {
return `${this.fieldNames[field.type]} ${typeIndex + 1}` return `${this.fieldNames[field.type]} ${typeIndex + 1}`

File diff suppressed because it is too large Load Diff

@ -0,0 +1,49 @@
<template>
<Teleport :to="modalContainerEl">
<div class="modal modal-open items-start !animate-none overflow-y-auto">
<div
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<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 class="modal-title">
{{ title }}
</span>
<a
href="#"
class="text-xl modal-close-button"
@click.prevent="$emit('close')"
>&times;</a>
</div>
<form @submit.prevent="$emit('save')">
<slot />
<button
class="base-button w-full mt-4 modal-save-button"
type="submit"
>
{{ t('save') }}
</button>
</form>
</div>
</div>
</Teleport>
</template>
<script>
export default {
name: 'ContextModal',
inject: ['t'],
props: {
title: {
type: String,
required: true
},
modalContainerEl: {
type: Element,
required: true
}
},
emits: ['close', 'save']
}
</script>

@ -0,0 +1,147 @@
<template>
<div
class="relative"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@click.stop="isOpen ? close() : open()"
>
<span class="flex items-center space-x-2">
<component
:is="icon || 'span'"
class="w-4 h-4"
/>
<span>{{ label }}</span>
</span>
<IconChevronRight class="w-4 h-4" />
</button>
<div
v-if="isOpen"
ref="submenu"
class="absolute p-1 z-50 left-full bg-white shadow-lg rounded-lg border border-neutral-200 cursor-default"
style="min-width: 170px"
:style="submenuStyle"
:class="menuClass"
@click.stop
>
<slot>
<button
v-for="option in options"
:key="option.value"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between space-x-2 text-sm cursor-pointer"
@click="handleSelect(option.value)"
>
<span class="whitespace-nowrap">{{ option.label }}</span>
<IconCheck
v-if="modelValue === option.value"
class="w-4 h-4"
/>
</button>
</slot>
</div>
</div>
</template>
<script>
import { IconChevronRight, IconCheck } from '@tabler/icons-vue'
export default {
name: 'ContextSubmenu',
components: {
IconChevronRight,
IconCheck
},
props: {
icon: {
type: [Function],
required: false,
default: null
},
label: {
type: String,
required: true
},
options: {
type: Array,
default: () => []
},
modelValue: {
type: [String, Number],
default: null
},
menuClass: {
type: String,
default: ''
}
},
emits: ['select', 'update:modelValue'],
data () {
return {
isOpen: false,
topOffset: 0
}
},
computed: {
submenuStyle () {
return {
top: this.topOffset + 'px'
}
}
},
beforeUnmount () {
this.clearTimeout()
},
methods: {
handleMouseEnter () {
clearTimeout(this.closeTimeout)
this.openTimeout = setTimeout(() => this.open(), 200)
},
handleMouseLeave () {
clearTimeout(this.openTimeout)
this.closeTimeout = setTimeout(() => this.close(), 200)
},
open () {
this.clearTimeout()
this.isOpen = true
this.topOffset = 0
this.$nextTick(() => setTimeout(() => this.adjustPosition(), 0))
},
clearTimeout () {
if (this.openTimeout) {
clearTimeout(this.openTimeout)
}
if (this.closeTimeout) {
clearTimeout(this.closeTimeout)
}
},
close () {
this.clearTimeout()
this.isOpen = false
},
handleSelect (value) {
this.$emit('select', value)
this.$emit('update:modelValue', value)
},
adjustPosition () {
if (!this.$refs.submenu) return
const rect = this.$refs.submenu.getBoundingClientRect()
const overflow = rect.bottom - window.innerHeight
if (overflow > 0) {
this.topOffset = -overflow - 4
} else {
this.topOffset = 0
}
}
}
}
</script>

@ -10,7 +10,7 @@
@change="[field.preferences ||= {}, field.preferences.method = $event.target.value, $emit('save')]" @change="[field.preferences ||= {}, field.preferences.method = $event.target.value, $emit('save')]"
> >
<option <option
v-for="method in ['QeS', 'AeS']" v-for="method in verificationMethods"
:key="method" :key="method"
:value="method.toLowerCase()" :value="method.toLowerCase()"
:selected="method.toLowerCase() === field.preferences?.method || (method === 'QeS' && !field.preferences?.method)" :selected="method.toLowerCase() === field.preferences?.method || (method === 'QeS' && !field.preferences?.method)"
@ -26,32 +26,6 @@
{{ t('method') }} {{ t('method') }}
</label> </label>
</div> </div>
<div
v-if="['cells'].includes(field.type)"
class="py-1.5 px-1 relative"
@click.stop
>
<select
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal bg-transparent"
@change="[field.preferences ||= {}, field.preferences.align = $event.target.value, $emit('save')]"
>
<option
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"
>
{{ t(value) }}
</option>
</select>
<label
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('align') }}
</label>
</div>
<div <div
v-if="['select', 'radio'].includes(field.type) && !defaultField" v-if="['select', 'radio'].includes(field.type) && !defaultField"
class="py-1.5 px-1 relative" class="py-1.5 px-1 relative"
@ -348,7 +322,7 @@
{{ t('any') }} {{ t('any') }}
</option> </option>
<option <option
v-for="type in ['drawn', 'typed', 'drawn_or_typed', 'drawn_or_upload', 'upload']" v-for="type in signatureFormats"
:key="type" :key="type"
:value="type" :value="type"
:selected="field.preferences?.format === type" :selected="field.preferences?.format === type"
@ -437,7 +411,7 @@
</label> </label>
</li> </li>
<li <li
v-if="['text', 'number'].includes(field.type)" v-if="['text', 'number', 'radio', 'multiple', 'select'].includes(field.type)"
@click.stop @click.stop
> >
<label class="cursor-pointer py-1.5"> <label class="cursor-pointer py-1.5">
@ -452,7 +426,7 @@
</label> </label>
</li> </li>
<li <li
v-if="withPrefillable && ['text', 'number', 'cells', 'date', 'checkbox', 'select', 'radio', 'phone'].includes(field['type'])" v-if="withPrefillable && prefillableFieldTypes.includes(field['type'])"
@click.stop @click.stop
> >
<label class="cursor-pointer py-1.5"> <label class="cursor-pointer py-1.5">
@ -470,7 +444,7 @@
v-if="field.type != 'stamp'" v-if="field.type != 'stamp'"
class="pb-0.5 mt-0.5" class="pb-0.5 mt-0.5"
> >
<li v-if="['text', 'number', 'date', 'select', 'heading'].includes(field.type)"> <li v-if="['text', 'number', 'date', 'select', 'heading', 'cells'].includes(field.type)">
<label <label
class="label-text cursor-pointer text-center w-full flex items-center" class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-font')" @click="$emit('click-font')"
@ -718,7 +692,7 @@ export default {
}, },
lengthValidation () { lengthValidation () {
if (this.field.validation?.pattern && this.selectedValidation !== 'custom') { if (this.field.validation?.pattern && this.selectedValidation !== 'custom') {
return this.field.validation.pattern.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups return this.parseLengthPattern(this.field.validation.pattern)
} else { } else {
return null return null
} }
@ -735,6 +709,15 @@ export default {
'^[a-zA-Z]+$': 'letters_only' '^[a-zA-Z]+$': 'letters_only'
} }
}, },
signatureFormats () {
return ['drawn', 'typed', 'drawn_or_typed', 'drawn_or_upload', 'upload']
},
verificationMethods () {
return ['QeS', 'AeS']
},
prefillableFieldTypes () {
return ['text', 'number', 'cells', 'date', 'checkbox', 'select', 'radio', 'phone']
},
sortedAreas () { sortedAreas () {
return (this.field.areas || []).sort((a, b) => { return (this.field.areas || []).sort((a, b) => {
return this.schemaAttachmentsIndexes[a.attachment_uuid] - this.schemaAttachmentsIndexes[b.attachment_uuid] return this.schemaAttachmentsIndexes[a.attachment_uuid] - this.schemaAttachmentsIndexes[b.attachment_uuid]
@ -798,6 +781,9 @@ export default {
return number return number
} }
}, },
parseLengthPattern (pattern) {
return pattern?.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups || null
},
formatDate (date, format) { formatDate (date, format) {
const monthFormats = { const monthFormats = {
M: 'numeric', M: 'numeric',

@ -25,7 +25,7 @@
:key="field.uuid" :key="field.uuid"
:data-uuid="field.uuid" :data-uuid="field.uuid"
:field="field" :field="field"
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)" :type-index="getFieldTypeIndex(field)"
:editable="editable" :editable="editable"
:with-signature-id="withSignatureId" :with-signature-id="withSignatureId"
:with-prefillable="withPrefillable" :with-prefillable="withPrefillable"
@ -127,7 +127,7 @@
>{{ t('custom') }}</a> >{{ t('custom') }}</a>
</div> </div>
<div <div
v-if="showCustomTab && editable" v-if="showCustomTab && editable && (customFields.length || newCustomField)"
ref="customFields" ref="customFields"
class="custom-fields" class="custom-fields"
@dragover.prevent="onCustomFieldDragover" @dragover.prevent="onCustomFieldDragover"
@ -376,7 +376,7 @@ export default {
IconDrag, IconDrag,
IconLock IconLock
}, },
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'baseFetch', 'selectedAreasRef'], inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'baseFetch', 'selectedAreasRef', 'getFieldTypeIndex'],
props: { props: {
fields: { fields: {
type: Array, type: Array,

@ -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="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"> <div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title"> <span class="modal-title">
{{ t('font') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field, template.fields) }} {{ t('font') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }}
</span> </span>
<a <a
href="#" href="#"
@ -171,7 +171,7 @@
contenteditable="true" contenteditable="true"
class="outline-none whitespace-nowrap truncate" class="outline-none whitespace-nowrap truncate"
> >
{{ field.default_value || field.name || buildDefaultName(field, template.fields) }} {{ field.default_value || field.name || buildDefaultName(field) }}
</span> </span>
</div> </div>
</div> </div>
@ -196,7 +196,7 @@ export default {
components: { components: {
IconChevronDown IconChevronDown
}, },
inject: ['t', 'save', 'template'], inject: ['t', 'template'],
props: { props: {
field: { field: {
type: Object, type: Object,
@ -212,17 +212,12 @@ export default {
required: false, required: false,
default: true default: true
}, },
withClickSaveEvent: {
type: Boolean,
required: false,
default: false
},
buildDefaultName: { buildDefaultName: {
type: Function, type: Function,
required: true required: true
} }
}, },
emits: ['close', 'click-save'], emits: ['close', 'save'],
data () { data () {
return { return {
preferences: {} preferences: {}
@ -262,6 +257,7 @@ export default {
colors () { colors () {
return [ return [
{ label: '⬛', value: 'black' }, { label: '⬛', value: 'black' },
{ label: '⬜', value: 'white' },
{ label: '🟦', value: 'blue' }, { label: '🟦', value: 'blue' },
{ label: '🟥', value: 'red' } { label: '🟥', value: 'red' }
] ]
@ -276,6 +272,7 @@ export default {
'items-center': !this.preferences.valign || this.preferences.valign === 'center', 'items-center': !this.preferences.valign || this.preferences.valign === 'center',
'items-start': this.preferences.valign === 'top', 'items-start': this.preferences.valign === 'top',
'items-end': this.preferences.valign === 'bottom', 'items-end': this.preferences.valign === 'bottom',
'bg-black': this.preferences.color === 'white',
'font-bold': ['bold_italic', 'bold'].includes(this.preferences.font_type), 'font-bold': ['bold_italic', 'bold'].includes(this.preferences.font_type),
italic: ['bold_italic', 'italic'].includes(this.preferences.font_type) italic: ['bold_italic', 'italic'].includes(this.preferences.font_type)
} }
@ -327,12 +324,7 @@ export default {
Object.assign(this.field.preferences, this.preferences) Object.assign(this.field.preferences, this.preferences)
if (this.withClickSaveEvent) { this.$emit('save')
this.$emit('click-save')
} else {
this.save()
}
this.$emit('close') this.$emit('close')
} }
} }

@ -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="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"> <div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title"> <span class="modal-title">
{{ t('formula') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field, template.fields) }} {{ t('formula') }} - {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }}
</span> </span>
<a <a
href="#" href="#"
@ -33,7 +33,7 @@
v-for="f in fields" v-for="f in fields"
:key="f.uuid" :key="f.uuid"
class="mr-1 flex btn btn-neutral btn-outline border-base-content/20 btn-sm normal-case font-normal bg-white !rounded-xl" class="mr-1 flex btn btn-neutral btn-outline border-base-content/20 btn-sm normal-case font-normal bg-white !rounded-xl"
@click.prevent="insertTextUnderCursor(`{{${f.name || buildDefaultName(f, template.fields)}}}`)" @click.prevent="insertTextUnderCursor(`{{${f.name || buildDefaultName(f)}}}`)"
> >
<IconMathFunction <IconMathFunction
v-if="f.preferences?.formula" v-if="f.preferences?.formula"
@ -47,7 +47,7 @@
height="20" height="20"
stroke-width="1.5" stroke-width="1.5"
/> />
{{ f.name || buildDefaultName(f, template.fields) }} {{ f.name || buildDefaultName(f) }}
</button> </button>
</div> </div>
<div> <div>
@ -131,7 +131,7 @@ export default {
IconCodePlus, IconCodePlus,
IconMathFunction IconMathFunction
}, },
inject: ['t', 'save', 'template', 'withFormula'], inject: ['t', 'template', 'withFormula'],
props: { props: {
field: { field: {
type: Object, type: Object,
@ -152,7 +152,7 @@ export default {
required: true required: true
} }
}, },
emits: ['close'], emits: ['close', 'save'],
data () { data () {
return { return {
formula: '' formula: ''
@ -181,7 +181,7 @@ export default {
const foundField = this.fields.find((f) => f.uuid === uuid) const foundField = this.fields.find((f) => f.uuid === uuid)
if (foundField) { if (foundField) {
return `{{${foundField.name || this.buildDefaultName(foundField, this.template.fields)}}}` return `{{${foundField.name || this.buildDefaultName(foundField)}}}`
} else { } else {
return '{{FIELD NOT FOUND}}' return '{{FIELD NOT FOUND}}'
} }
@ -190,7 +190,7 @@ export default {
normalizeFormula (text) { normalizeFormula (text) {
return text.replace(/{{(.*?)}}/g, (match, name) => { return text.replace(/{{(.*?)}}/g, (match, name) => {
const foundField = this.fields.find((f) => { const foundField = this.fields.find((f) => {
return (f.name || this.buildDefaultName(f, this.template.fields)).trim() === name.trim() return (f.name || this.buildDefaultName(f)).trim() === name.trim()
}) })
if (foundField) { if (foundField) {
@ -212,11 +212,14 @@ export default {
} else { } else {
this.field.preferences.formula = normalizedFormula this.field.preferences.formula = normalizedFormula
if (this.field.type !== 'payment') { if (this.field.type === 'payment') {
delete this.field.preferences.price
delete this.field.preferences.payment_link_id
} else {
this.field.readonly = !!normalizedFormula this.field.readonly = !!normalizedFormula
} }
this.save() this.$emit('save')
this.$emit('close') this.$emit('close')
} }

@ -1,6 +1,7 @@
const en = { const en = {
fixed: 'Fixed',
default: 'Default', default: 'Default',
save_as_custom_field: 'Save as Custom Field', save_as_custom_field: 'Save as custom field',
kba: 'KBA', kba: 'KBA',
analyzing_: 'Analyzing...', analyzing_: 'Analyzing...',
download: 'Download', download: 'Download',
@ -31,6 +32,9 @@ const en = {
field_not_found: 'Field not found', field_not_found: 'Field not found',
clear: 'Clear', clear: 'Clear',
align: 'Align', align: 'Align',
resize: 'Resize',
width: 'Width',
height: 'Height',
add_all_required_fields_to_continue: 'Add all required fields to continue', add_all_required_fields_to_continue: 'Add all required fields to continue',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Uploaded PDF contains form fields. Keep or remove them?', uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Uploaded PDF contains form fields. Keep or remove them?',
keep: 'Keep', keep: 'Keep',
@ -48,6 +52,8 @@ const en = {
type_value: 'Type value', type_value: 'Type value',
equal: 'Equal', equal: 'Equal',
not_equal: 'Not equal', not_equal: 'Not equal',
greater_than: 'Greater than',
less_than: 'Less than',
contains: 'Contains', contains: 'Contains',
does_not_contain: 'Does not contain', does_not_contain: 'Does not contain',
not_empty: 'Not empty', not_empty: 'Not empty',
@ -91,8 +97,9 @@ const en = {
format: 'Format', format: 'Format',
read_only: 'Read-only', read_only: 'Read-only',
page: 'Page', page: 'Page',
draw_new_area: 'Draw New Area', draw_new_area: 'Draw new area',
copy_to_all_pages: 'Copy to All Pages', copy_to_all_pages: 'Copy to all pages',
more: 'More',
add_option: 'Add option', add_option: 'Add option',
option: 'Option', option: 'Option',
options: 'Options', options: 'Options',
@ -173,6 +180,9 @@ const en = {
numbers_only: 'Numbers only', numbers_only: 'Numbers only',
letters_only: 'Letters only', letters_only: 'Letters only',
regexp_validation: 'Regexp validation', regexp_validation: 'Regexp validation',
custom_validation: 'Custom Validation',
length_validation: 'Length Validation',
number_range: 'Number Range',
enter_pdf_password: 'Enter PDF password', enter_pdf_password: 'Enter PDF password',
wrong_password: 'Wrong password', wrong_password: 'Wrong password',
currency: 'Currency', currency: 'Currency',
@ -202,6 +212,7 @@ const en = {
} }
const es = { const es = {
fixed: 'Fijo',
default: 'Predeterminado', default: 'Predeterminado',
save_as_custom_field: 'Guardar como personalizado', save_as_custom_field: 'Guardar como personalizado',
kba: 'KBA', kba: 'KBA',
@ -235,6 +246,9 @@ const es = {
clear: 'Borrar', clear: 'Borrar',
type_value: 'Escriba valor', type_value: 'Escriba valor',
align: 'Alinear', align: 'Alinear',
resize: 'Redimensionar',
width: 'Ancho',
height: 'Alto',
add_all_required_fields_to_continue: 'Agregar todos los campos requeridos para continuar', add_all_required_fields_to_continue: 'Agregar todos los campos requeridos para continuar',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'El PDF cargado tiene campos. ¿Mantenerlos o eliminarlos?', uploaded_pdf_contains_form_fields_keep_or_remove_them: 'El PDF cargado tiene campos. ¿Mantenerlos o eliminarlos?',
keep: 'Mantener', keep: 'Mantener',
@ -252,6 +266,8 @@ const es = {
price: 'Precio', price: 'Precio',
equal: 'Igual', equal: 'Igual',
not_equal: 'No es igual', not_equal: 'No es igual',
greater_than: 'Mayor que',
less_than: 'Menor que',
contains: 'Contiene', contains: 'Contiene',
does_not_contain: 'No contiene', does_not_contain: 'No contiene',
not_empty: 'No vacío', not_empty: 'No vacío',
@ -292,6 +308,7 @@ const es = {
page: 'Página', page: 'Página',
draw_new_area: 'Dibujar nueva área', draw_new_area: 'Dibujar nueva área',
copy_to_all_pages: 'Copiar a todas las páginas', copy_to_all_pages: 'Copiar a todas las páginas',
more: 'Más',
add_option: 'Agregar opción', add_option: 'Agregar opción',
option: 'Opción', option: 'Opción',
options: 'Opciones', options: 'Opciones',
@ -376,6 +393,9 @@ const es = {
numbers_only: 'Solo números', numbers_only: 'Solo números',
letters_only: 'Solo letras', letters_only: 'Solo letras',
regexp_validation: 'Validación de expresión regular', regexp_validation: 'Validación de expresión regular',
custom_validation: 'Validación Personalizada',
length_validation: 'Validación de Longitud',
number_range: 'Rango de Números',
enter_pdf_password: 'Ingrese la contraseña del PDF', enter_pdf_password: 'Ingrese la contraseña del PDF',
wrong_password: 'Contraseña incorrecta', wrong_password: 'Contraseña incorrecta',
currency: 'Moneda', currency: 'Moneda',
@ -405,6 +425,7 @@ const es = {
} }
const it = { const it = {
fixed: 'Fisso',
default: 'Predefinito', default: 'Predefinito',
save_as_custom_field: 'Salva come personalizzato', save_as_custom_field: 'Salva come personalizzato',
kba: 'KBA', kba: 'KBA',
@ -437,6 +458,9 @@ const it = {
field_not_found: 'Campo non trovato', field_not_found: 'Campo non trovato',
clear: 'Cancella', clear: 'Cancella',
align: 'Allinea', align: 'Allinea',
resize: 'Ridimensiona',
width: 'Larghezza',
height: 'Altezza',
add_all_required_fields_to_continue: 'Aggiungi tutti i campi obbligatori per continuare', add_all_required_fields_to_continue: 'Aggiungi tutti i campi obbligatori per continuare',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Il PDF caricato contiene campi del modulo. Mantenerli o rimuoverli?', uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Il PDF caricato contiene campi del modulo. Mantenerli o rimuoverli?',
keep: 'Mantieni', keep: 'Mantieni',
@ -454,6 +478,8 @@ const it = {
type_value: 'Inserisci valore', type_value: 'Inserisci valore',
equal: 'Uguale', equal: 'Uguale',
not_equal: 'Non uguale', not_equal: 'Non uguale',
greater_than: 'Maggiore di',
less_than: 'Minore di',
contains: 'Contiene', contains: 'Contiene',
does_not_contain: 'Non contiene', does_not_contain: 'Non contiene',
not_empty: 'Non vuoto', not_empty: 'Non vuoto',
@ -499,6 +525,7 @@ const it = {
page: 'Pagina', page: 'Pagina',
draw_new_area: 'Disegna nuova area', draw_new_area: 'Disegna nuova area',
copy_to_all_pages: 'Copia in tutte le pagine', copy_to_all_pages: 'Copia in tutte le pagine',
more: 'Altro',
add_option: 'Aggiungi opzione', add_option: 'Aggiungi opzione',
option: 'Opzione', option: 'Opzione',
options: 'Opzioni', options: 'Opzioni',
@ -579,6 +606,9 @@ const it = {
numbers_only: 'Solo numeri', numbers_only: 'Solo numeri',
letters_only: 'Solo lettere', letters_only: 'Solo lettere',
regexp_validation: 'Validazione regexp', regexp_validation: 'Validazione regexp',
custom_validation: 'Validazione Personalizzata',
length_validation: 'Validazione Lunghezza',
number_range: 'Intervallo Numerico',
enter_pdf_password: 'Inserisci password PDF', enter_pdf_password: 'Inserisci password PDF',
wrong_password: 'Password errata', wrong_password: 'Password errata',
currency: 'Valuta', currency: 'Valuta',
@ -608,6 +638,7 @@ const it = {
} }
const pt = { const pt = {
fixed: 'Fixo',
default: 'Padrão', default: 'Padrão',
save_as_custom_field: 'Salvar como personalizado', save_as_custom_field: 'Salvar como personalizado',
kba: 'KBA', kba: 'KBA',
@ -644,6 +675,9 @@ const pt = {
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'O PDF carregado contém campos. Manter ou removê-los?', uploaded_pdf_contains_form_fields_keep_or_remove_them: 'O PDF carregado contém campos. Manter ou removê-los?',
keep: 'Manter', keep: 'Manter',
align: 'Alinhar', align: 'Alinhar',
resize: 'Redimensionar',
width: 'Largura',
height: 'Altura',
left: 'Esquerda', left: 'Esquerda',
heading: 'Cabeçalho', heading: 'Cabeçalho',
validation: 'Validação', validation: 'Validação',
@ -658,6 +692,8 @@ const pt = {
price: 'Preço', price: 'Preço',
equal: 'Igual', equal: 'Igual',
not_equal: 'Não é igual', not_equal: 'Não é igual',
greater_than: 'Maior que',
less_than: 'Menor que',
contains: 'Contém', contains: 'Contém',
does_not_contain: 'Não contém', does_not_contain: 'Não contém',
not_empty: 'Não vazio', not_empty: 'Não vazio',
@ -698,6 +734,7 @@ const pt = {
page: 'Página', page: 'Página',
draw_new_area: 'Desenhar nova área', draw_new_area: 'Desenhar nova área',
copy_to_all_pages: 'Copiar para todas as páginas', copy_to_all_pages: 'Copiar para todas as páginas',
more: 'Mais',
add_option: 'Adicionar opção', add_option: 'Adicionar opção',
option: 'Opção', option: 'Opção',
options: 'Opções', options: 'Opções',
@ -782,6 +819,9 @@ const pt = {
numbers_only: 'Somente números', numbers_only: 'Somente números',
letters_only: 'Somente letras', letters_only: 'Somente letras',
regexp_validation: 'Validação de expressão regular', regexp_validation: 'Validação de expressão regular',
custom_validation: 'Validação Personalizada',
length_validation: 'Validação de Comprimento',
number_range: 'Intervalo de Números',
enter_pdf_password: 'Digite a senha do PDF', enter_pdf_password: 'Digite a senha do PDF',
wrong_password: 'Senha incorreta', wrong_password: 'Senha incorreta',
currency: 'Moeda', currency: 'Moeda',
@ -811,6 +851,7 @@ const pt = {
} }
const fr = { const fr = {
fixed: 'Fixe',
default: 'Par défaut', default: 'Par défaut',
save_as_custom_field: 'Enregistrer comme personnalisé', save_as_custom_field: 'Enregistrer comme personnalisé',
kba: 'KBA', kba: 'KBA',
@ -843,6 +884,9 @@ const fr = {
field_not_found: 'Champ introuvable', field_not_found: 'Champ introuvable',
clear: 'Effacer', clear: 'Effacer',
align: 'Aligner', align: 'Aligner',
resize: 'Redimensionner',
width: 'Largeur',
height: 'Hauteur',
add_all_required_fields_to_continue: 'Ajoutez tous les champs obligatoires pour continuer', add_all_required_fields_to_continue: 'Ajoutez tous les champs obligatoires pour continuer',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Le PDF téléversé contient des champs de formulaire. Les conserver ou les supprimer ?', uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Le PDF téléversé contient des champs de formulaire. Les conserver ou les supprimer ?',
keep: 'Conserver', keep: 'Conserver',
@ -860,6 +904,8 @@ const fr = {
type_value: 'Saisir une valeur', type_value: 'Saisir une valeur',
equal: 'Égal', equal: 'Égal',
not_equal: 'Différent', not_equal: 'Différent',
greater_than: 'Supérieur à',
less_than: 'Inférieur à',
contains: 'Contient', contains: 'Contient',
does_not_contain: 'Ne contient pas', does_not_contain: 'Ne contient pas',
not_empty: 'Non vide', not_empty: 'Non vide',
@ -905,6 +951,7 @@ const fr = {
page: 'Page', page: 'Page',
draw_new_area: 'Dessiner une zone', draw_new_area: 'Dessiner une zone',
copy_to_all_pages: 'Copier sur toutes les pages', copy_to_all_pages: 'Copier sur toutes les pages',
more: 'Plus',
add_option: 'Ajouter une option', add_option: 'Ajouter une option',
option: 'Option', option: 'Option',
options: 'Options', options: 'Options',
@ -985,6 +1032,9 @@ const fr = {
numbers_only: 'Chiffres uniquement', numbers_only: 'Chiffres uniquement',
letters_only: 'Lettres uniquement', letters_only: 'Lettres uniquement',
regexp_validation: 'Validation par expression régulière', regexp_validation: 'Validation par expression régulière',
custom_validation: 'Validation Personnalisée',
length_validation: 'Validation de Longueur',
number_range: 'Plage de Nombres',
enter_pdf_password: 'Saisir le mot de passe du PDF', enter_pdf_password: 'Saisir le mot de passe du PDF',
wrong_password: 'Mot de passe incorrect', wrong_password: 'Mot de passe incorrect',
currency: 'Devise', currency: 'Devise',
@ -1014,6 +1064,7 @@ const fr = {
} }
const de = { const de = {
fixed: 'Fest',
default: 'Standard', default: 'Standard',
save_as_custom_field: 'Als benutzerdefiniert speichern', save_as_custom_field: 'Als benutzerdefiniert speichern',
kba: 'KBA', kba: 'KBA',
@ -1046,6 +1097,9 @@ const de = {
field_not_found: 'Feld nicht gefunden', field_not_found: 'Feld nicht gefunden',
clear: 'Leeren', clear: 'Leeren',
align: 'Ausrichten', align: 'Ausrichten',
resize: 'Größe ändern',
width: 'Breite',
height: 'Höhe',
add_all_required_fields_to_continue: 'Fügen Sie alle erforderlichen Felder hinzu, um fortzufahren', add_all_required_fields_to_continue: 'Fügen Sie alle erforderlichen Felder hinzu, um fortzufahren',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Das hochgeladene PDF enthält Formularfelder. Beibehalten oder entfernen?', uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Das hochgeladene PDF enthält Formularfelder. Beibehalten oder entfernen?',
keep: 'Beibehalten', keep: 'Beibehalten',
@ -1063,6 +1117,8 @@ const de = {
type_value: 'Wert eingeben', type_value: 'Wert eingeben',
equal: 'Gleich', equal: 'Gleich',
not_equal: 'Ungleich', not_equal: 'Ungleich',
greater_than: 'Größer als',
less_than: 'Kleiner als',
contains: 'Enthält', contains: 'Enthält',
does_not_contain: 'Enthält nicht', does_not_contain: 'Enthält nicht',
not_empty: 'Nicht leer', not_empty: 'Nicht leer',
@ -1108,6 +1164,7 @@ const de = {
page: 'Seite', page: 'Seite',
draw_new_area: 'Bereich zeichnen', draw_new_area: 'Bereich zeichnen',
copy_to_all_pages: 'Auf alle Seiten kopieren', copy_to_all_pages: 'Auf alle Seiten kopieren',
more: 'Mehr',
add_option: 'Option hinzufügen', add_option: 'Option hinzufügen',
option: 'Option', option: 'Option',
options: 'Optionen', options: 'Optionen',
@ -1188,6 +1245,9 @@ const de = {
numbers_only: 'Nur Zahlen', numbers_only: 'Nur Zahlen',
letters_only: 'Nur Buchstaben', letters_only: 'Nur Buchstaben',
regexp_validation: 'RegExp-Validierung', regexp_validation: 'RegExp-Validierung',
custom_validation: 'Benutzerdefinierte Validierung',
length_validation: 'Längenvalidierung',
number_range: 'Zahlenbereich',
enter_pdf_password: 'PDF-Passwort eingeben', enter_pdf_password: 'PDF-Passwort eingeben',
wrong_password: 'Falsches Passwort', wrong_password: 'Falsches Passwort',
currency: 'Währung', currency: 'Währung',
@ -1217,6 +1277,7 @@ const de = {
} }
const nl = { const nl = {
fixed: 'Vast',
default: 'Standaard', default: 'Standaard',
save_as_custom_field: 'Opslaan als aangepast', save_as_custom_field: 'Opslaan als aangepast',
kba: 'KBA', kba: 'KBA',
@ -1249,6 +1310,9 @@ const nl = {
field_not_found: 'Veld niet gevonden', field_not_found: 'Veld niet gevonden',
clear: 'Wissen', clear: 'Wissen',
align: 'Uitlijnen', align: 'Uitlijnen',
resize: 'Formaat wijzigen',
width: 'Breedte',
height: 'Hoogte',
add_all_required_fields_to_continue: 'Voeg alle vereiste velden toe om door te gaan', add_all_required_fields_to_continue: 'Voeg alle vereiste velden toe om door te gaan',
uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Geüploade PDF bevat formuliervelden. Behouden of verwijderen?', uploaded_pdf_contains_form_fields_keep_or_remove_them: 'Geüploade PDF bevat formuliervelden. Behouden of verwijderen?',
keep: 'Behouden', keep: 'Behouden',
@ -1266,6 +1330,8 @@ const nl = {
type_value: 'Typ waarde', type_value: 'Typ waarde',
equal: 'Gelijk aan', equal: 'Gelijk aan',
not_equal: 'Niet gelijk aan', not_equal: 'Niet gelijk aan',
greater_than: 'Groter dan',
less_than: 'Kleiner dan',
contains: 'Bevat', contains: 'Bevat',
does_not_contain: 'Bevat niet', does_not_contain: 'Bevat niet',
not_empty: 'Niet leeg', not_empty: 'Niet leeg',
@ -1311,6 +1377,7 @@ const nl = {
page: 'Pagina', page: 'Pagina',
draw_new_area: 'Nieuw gebied tekenen', draw_new_area: 'Nieuw gebied tekenen',
copy_to_all_pages: 'Kopieer naar alle pag.', copy_to_all_pages: 'Kopieer naar alle pag.',
more: 'Meer',
add_option: 'Optie toevoegen', add_option: 'Optie toevoegen',
option: 'Optie', option: 'Optie',
options: 'Opties', options: 'Opties',
@ -1391,6 +1458,9 @@ const nl = {
numbers_only: 'Alleen cijfers', numbers_only: 'Alleen cijfers',
letters_only: 'Alleen letters', letters_only: 'Alleen letters',
regexp_validation: 'Regex validatie', regexp_validation: 'Regex validatie',
custom_validation: 'Aangepaste Validatie',
length_validation: 'Lengte Validatie',
number_range: 'Getalbereik',
enter_pdf_password: 'Voer PDF-wachtwoord in', enter_pdf_password: 'Voer PDF-wachtwoord in',
wrong_password: 'Onjuist wachtwoord', wrong_password: 'Onjuist wachtwoord',
currency: 'Valuta', currency: 'Valuta',

@ -29,7 +29,7 @@
:is-drag="isDrag" :is-drag="isDrag"
@move="onSelectionBoxMove" @move="onSelectionBoxMove"
@contextmenu="openSelectionContextMenu" @contextmenu="openSelectionContextMenu"
@close-context-menu="closeSelectionContextMenu" @close-context-menu="closeContextMenu"
/> />
<FieldArea <FieldArea
v-for="(item, i) in areas" v-for="(item, i) in areas"
@ -48,6 +48,7 @@
:default-submitters="defaultSubmitters" :default-submitters="defaultSubmitters"
:max-page="totalPages - 1" :max-page="totalPages - 1"
:is-select-mode="isSelectMode" :is-select-mode="isSelectMode"
:is-mobile="isMobile"
@start-resize="resizeDirection = $event" @start-resize="resizeDirection = $event"
@stop-resize="resizeDirection = null" @stop-resize="resizeDirection = null"
@remove="$emit('remove-area', item.area)" @remove="$emit('remove-area', item.area)"
@ -74,29 +75,39 @@
class="absolute outline-dashed outline-gray-400 pointer-events-none z-20" class="absolute outline-dashed outline-gray-400 pointer-events-none z-20"
:style="selectionRectStyle" :style="selectionRectStyle"
/> />
<ContextMenu <FieldContextMenu
v-if="contextMenu" v-if="contextMenu && contextMenu.field"
:context-menu="contextMenu" :context-menu="contextMenu"
:field="contextMenu.field" :field="contextMenu.field"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:editable="editable" :editable="editable"
:with-fields-detection="withFieldsDetection" :default-field="defaultFieldsIndex[contextMenu.field.name]"
@copy="handleCopy" @copy="handleCopy"
@delete="handleDelete" @delete="handleDelete"
@paste="handlePaste"
@autodetect-fields="handleAutodetectFields"
@close="closeContextMenu" @close="closeContextMenu"
@set-draw="$emit('set-draw', $event)"
@scroll-to="$emit('scroll-to', $event)"
@save="save"
@add-custom-field="$emit('add-custom-field', $event)"
/> />
<ContextMenu <SelectionContextMenu
v-if="selectionContextMenu" v-else-if="contextMenu && contextMenu.areas"
:context-menu="selectionContextMenu" :context-menu="contextMenu"
:editable="editable" :editable="editable"
:is-multi-selection="true"
:selected-areas="selectedAreasRef.value"
:template="template" :template="template"
@copy="handleSelectionCopy" @copy="handleSelectionCopy"
@delete="handleSelectionDelete" @delete="handleSelectionDelete"
@align="handleSelectionAlign" @close="closeContextMenu"
@close="closeSelectionContextMenu" />
<PageContextMenu
v-else-if="contextMenu && !contextMenu.field && !contextMenu.areas"
:context-menu="contextMenu"
:editable="editable"
:with-fields-detection="withFieldsDetection"
@paste="handlePaste"
@autodetect-fields="handleAutodetectFields"
@close="closeContextMenu"
/> />
</div> </div>
<div <div
@ -119,17 +130,21 @@
<script> <script>
import FieldArea from './area' import FieldArea from './area'
import ContextMenu from './context_menu' import FieldContextMenu from './field_context_menu'
import SelectionContextMenu from './selection_context_menu'
import PageContextMenu from './page_context_menu'
import SelectionBox from './selection_box' import SelectionBox from './selection_box'
export default { export default {
name: 'TemplatePage', name: 'TemplatePage',
components: { components: {
FieldArea, FieldArea,
ContextMenu, FieldContextMenu,
SelectionContextMenu,
PageContextMenu,
SelectionBox SelectionBox
}, },
inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'customDragFieldRef', 'assignDropAreaSize', 'selectedAreasRef', 'template', 'isSelectModeRef'], inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'customDragFieldRef', 'assignDropAreaSize', 'selectedAreasRef', 'template', 'isSelectModeRef', 'save'],
props: { props: {
image: { image: {
type: Object, type: Object,
@ -140,6 +155,11 @@ export default {
required: false, required: false,
default: null default: null
}, },
isMobile: {
type: Boolean,
required: false,
default: false
},
withSignatureId: { withSignatureId: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -228,7 +248,7 @@ export default {
default: false default: false
} }
}, },
emits: ['draw', 'drop-field', 'remove-area', 'copy-field', 'paste-field', 'scroll-to', 'copy-selected-areas', 'delete-selected-areas', 'align-selected-areas', 'autodetect-fields', 'add-custom-field'], emits: ['draw', 'drop-field', 'remove-area', 'copy-field', 'paste-field', 'scroll-to', 'copy-selected-areas', 'delete-selected-areas', 'autodetect-fields', 'add-custom-field', 'set-draw'],
data () { data () {
return { return {
areaRefs: [], areaRefs: [],
@ -236,8 +256,7 @@ export default {
resizeDirection: null, resizeDirection: null,
newAreas: [], newAreas: [],
contextMenu: null, contextMenu: null,
selectionRect: null, selectionRect: null
selectionContextMenu: null
} }
}, },
computed: { computed: {
@ -305,11 +324,6 @@ export default {
return 'text' return 'text'
} }
}, },
isMobile () {
const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent)
return isMobileSafariIos || /android|iphone|ipad/i.test(navigator.userAgent)
},
resizeDirectionClasses () { resizeDirectionClasses () {
return { return {
nwse: 'cursor-nwse-resize', nwse: 'cursor-nwse-resize',
@ -399,29 +413,32 @@ export default {
} }
}, },
openSelectionContextMenu (event) { openSelectionContextMenu (event) {
if (!this.editable) {
return
}
event.preventDefault()
event.stopPropagation()
const rect = this.$el.getBoundingClientRect() const rect = this.$el.getBoundingClientRect()
this.selectionContextMenu = { this.contextMenu = {
x: event.clientX, x: event.clientX,
y: event.clientY, y: event.clientY,
relativeX: (event.clientX - rect.left) / rect.width, relativeX: (event.clientX - rect.left) / rect.width,
relativeY: (event.clientY - rect.top) / rect.height relativeY: (event.clientY - rect.top) / rect.height,
areas: this.selectedAreasRef.value
} }
}, },
closeSelectionContextMenu () {
this.selectionContextMenu = null
},
handleSelectionCopy () { handleSelectionCopy () {
this.$emit('copy-selected-areas') this.$emit('copy-selected-areas')
this.closeSelectionContextMenu()
this.closeContextMenu()
}, },
handleSelectionDelete () { handleSelectionDelete () {
this.$emit('delete-selected-areas') this.$emit('delete-selected-areas')
this.closeSelectionContextMenu()
}, this.closeContextMenu()
handleSelectionAlign (direction) {
this.$emit('align-selected-areas', direction)
this.closeSelectionContextMenu()
}, },
closeContextMenu () { closeContextMenu () {
this.contextMenu = null this.contextMenu = null

@ -0,0 +1,159 @@
<template>
<div
ref="menu"
class="fixed z-50 p-1 bg-white shadow-lg rounded-lg border border-neutral-200 cursor-default"
style="min-width: 170px"
:style="menuStyle"
@mousedown.stop
@pointerdown.stop
>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
:class="!hasClipboardData ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-100'"
:disabled="!hasClipboardData"
@click.stop="!hasClipboardData ? null : $emit('paste')"
>
<span class="flex items-center space-x-2">
<IconClipboard class="w-4 h-4" />
<span>{{ t('paste') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘V' : 'Ctrl+V' }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@click.stop="handleToggleSelectMode"
>
<span class="flex items-center space-x-2">
<IconClick
v-if="!isSelectModeRef.value"
class="w-4 h-4"
/>
<IconNewSection
v-else
class="w-4 h-4"
/>
<span>{{ isSelectModeRef.value ? t('draw_fields') : t('select_fields') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">Tab</span>
</button>
<hr
v-if="showAutodetectFields"
class="my-1 border-neutral-200"
>
<button
v-if="showAutodetectFields"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('autodetect-fields')"
>
<IconSparkles class="w-4 h-4" />
<span>{{ t('autodetect_fields') }}</span>
</button>
</div>
</template>
<script>
import { IconClipboard, IconClick, IconNewSection, IconSparkles } from '@tabler/icons-vue'
export default {
name: 'PageContextMenu',
components: {
IconClipboard,
IconClick,
IconNewSection,
IconSparkles
},
inject: ['t', 'isSelectModeRef'],
props: {
contextMenu: {
type: Object,
default: null,
required: true
},
editable: {
type: Boolean,
default: true
},
withFieldsDetection: {
type: Boolean,
default: false
}
},
emits: ['paste', 'close', 'autodetect-fields'],
computed: {
isMac () {
return (navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')
},
hasClipboardData () {
try {
const clipboard = localStorage.getItem('docuseal_clipboard')
if (clipboard) {
const data = JSON.parse(clipboard)
return Date.now() - data.timestamp < 3600000
}
return false
} catch {
return false
}
},
menuStyle () {
return {
left: this.contextMenu.x + 'px',
top: this.contextMenu.y + 'px'
}
},
showAutodetectFields () {
return this.withFieldsDetection && this.editable
}
},
mounted () {
document.addEventListener('keydown', this.onKeyDown)
document.addEventListener('mousedown', this.handleClickOutside)
this.$nextTick(() => {
this.checkMenuPosition()
})
},
beforeUnmount () {
document.removeEventListener('keydown', this.onKeyDown)
document.removeEventListener('mousedown', this.handleClickOutside)
},
methods: {
checkMenuPosition () {
if (this.$refs.menu) {
const rect = this.$refs.menu.getBoundingClientRect()
const overflow = rect.bottom - window.innerHeight
if (overflow > 0) {
this.contextMenu.y = this.contextMenu.y - overflow - 4
}
}
},
onKeyDown (event) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
this.$emit('close')
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v' && this.hasClipboardData) {
event.preventDefault()
event.stopPropagation()
this.$emit('paste')
}
},
handleClickOutside (event) {
if (this.$refs.menu && !this.$refs.menu.contains(event.target)) {
this.$emit('close')
}
},
handleToggleSelectMode () {
this.isSelectModeRef.value = !this.isSelectModeRef.value
this.$emit('close')
}
}
}
</script>

@ -119,6 +119,7 @@
<ConditionsModal <ConditionsModal
:item="item" :item="item"
:build-default-name="buildDefaultName" :build-default-name="buildDefaultName"
@save="$emit('change')"
@close="isShowConditionsModal = false" @close="isShowConditionsModal = false"
/> />
</Teleport> </Teleport>
@ -145,7 +146,7 @@ export default {
GoogleDriveDocumentSettings, GoogleDriveDocumentSettings,
IconSortDescending2 IconSortDescending2
}, },
inject: ['t'], inject: ['t', 'getFieldTypeIndex'],
props: { props: {
item: { item: {
type: Object, type: Object,

@ -0,0 +1,348 @@
<template>
<div>
<div
v-if="!isShowFontModal && !isShowConditionsModal"
ref="menu"
class="fixed z-50 p-1 bg-white shadow-lg rounded-lg border border-neutral-200 cursor-default"
style="min-width: 170px"
:style="menuStyle"
@mousedown.stop
@pointerdown.stop
>
<ContextSubmenu
:icon="IconLayoutAlignMiddle"
:label="t('align')"
>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="alignSelectedAreas('left')"
>
<IconLayoutAlignLeft class="w-4 h-4" />
<span>{{ t('align_left') }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="alignSelectedAreas('right')"
>
<IconLayoutAlignRight class="w-4 h-4" />
<span>{{ t('align_right') }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="alignSelectedAreas('top')"
>
<IconLayoutAlignTop class="w-4 h-4" />
<span>{{ t('align_top') }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="alignSelectedAreas('bottom')"
>
<IconLayoutAlignBottom class="w-4 h-4" />
<span>{{ t('align_bottom') }}</span>
</button>
</ContextSubmenu>
<ContextSubmenu
:icon="IconAspectRatio"
:label="t('resize')"
>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="resizeSelectedAreas('width')"
>
<IconArrowsHorizontal class="w-4 h-4" />
<span>{{ t('width') }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="resizeSelectedAreas('height')"
>
<IconArrowsVertical class="w-4 h-4" />
<span>{{ t('height') }}</span>
</button>
</ContextSubmenu>
<hr
v-if="showFont || showCondition"
class="my-1 border-neutral-200"
>
<button
v-if="showFont"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="openFontModal"
>
<IconTypography class="w-4 h-4" />
<span>{{ t('font') }}</span>
</button>
<button
v-if="showCondition"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="openConditionModal"
>
<IconRouteAltLeft class="w-4 h-4" />
<span>{{ t('condition') }}</span>
</button>
<hr class="my-1 border-neutral-200">
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@click.stop="$emit('copy')"
>
<span class="flex items-center space-x-2">
<IconCopy class="w-4 h-4" />
<span>{{ t('copy') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘C' : 'Ctrl+C' }}</span>
</button>
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm text-red-600"
@click.stop="$emit('delete')"
>
<span class="flex items-center space-x-2">
<IconTrashX class="w-4 h-4" />
<span>{{ t('remove') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">Del</span>
</button>
</div>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="multiSelectField"
:area="contextMenu.area"
:editable="editable"
:build-default-name="buildDefaultName"
@save="handleSaveMultiSelectFontModal"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:item="multiSelectField"
:build-default-name="buildDefaultName"
:exclude-field-uuids="selectedFields.map(f => f.uuid)"
@save="handleSaveMultiSelectConditionsModal"
@close="closeModal"
/>
</Teleport>
</div>
</template>
<script>
import { IconCopy, IconTrashX, IconTypography, IconRouteAltLeft, IconLayoutAlignLeft, IconLayoutAlignRight, IconLayoutAlignTop, IconLayoutAlignBottom, IconLayoutAlignMiddle, IconAspectRatio, IconArrowsHorizontal, IconArrowsVertical } from '@tabler/icons-vue'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import ContextSubmenu from './field_context_submenu'
import Field from './field'
import FieldType from './field_type'
export default {
name: 'SelectionContextMenu',
components: {
IconCopy,
IconTrashX,
IconTypography,
IconRouteAltLeft,
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconLayoutAlignTop,
IconLayoutAlignBottom,
FontModal,
IconArrowsHorizontal,
IconArrowsVertical,
ConditionsModal,
ContextSubmenu
},
inject: ['t', 'save', 'selectedAreasRef', 'getFieldTypeIndex'],
props: {
contextMenu: {
type: Object,
required: true
},
editable: {
type: Boolean,
default: true
},
template: {
type: Object,
required: true
},
withCondition: {
type: Boolean,
default: true
}
},
emits: ['copy', 'delete', 'close'],
data () {
return {
isShowFontModal: false,
isShowConditionsModal: false,
multiSelectField: null
}
},
computed: {
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
selectedFields () {
return this.selectedAreasRef.value.map((area) => {
return this.template.fields.find((f) => f.areas?.includes(area))
}).filter(Boolean)
},
isMac () {
return (navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')
},
menuStyle () {
return {
left: this.contextMenu.x + 'px',
top: this.contextMenu.y + 'px'
}
},
showFont () {
return true
},
showCondition () {
return this.withCondition
},
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels
},
mounted () {
document.addEventListener('keydown', this.onKeyDown)
document.addEventListener('mousedown', this.handleClickOutside)
this.$nextTick(() => this.checkMenuPosition())
},
beforeUnmount () {
document.removeEventListener('keydown', this.onKeyDown)
document.removeEventListener('mousedown', this.handleClickOutside)
},
methods: {
IconLayoutAlignMiddle,
IconAspectRatio,
buildDefaultName: Field.methods.buildDefaultName,
checkMenuPosition () {
if (this.$refs.menu) {
const rect = this.$refs.menu.getBoundingClientRect()
const overflow = rect.bottom - window.innerHeight
if (overflow > 0) {
this.contextMenu.y = this.contextMenu.y - overflow - 4
}
}
},
onKeyDown (event) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
this.$emit('close')
}
},
handleClickOutside (event) {
if (this.$refs.menu && !this.$refs.menu.contains(event.target)) {
this.$emit('close')
}
},
openFontModal () {
this.multiSelectField = {
name: this.t('fields_selected').replace('{count}', this.selectedFields.length),
preferences: {}
}
const preferencesStrings = this.selectedFields.map((f) => JSON.stringify(f.preferences || {}))
if (preferencesStrings.every((s) => s === preferencesStrings[0])) {
this.multiSelectField.preferences = JSON.parse(preferencesStrings[0])
}
this.isShowFontModal = true
},
openConditionModal () {
this.multiSelectField = {
name: this.t('fields_selected').replace('{count}', this.selectedFields.length),
conditions: []
}
const conditionStrings = this.selectedFields.map((f) => JSON.stringify(f.conditions || []))
if (conditionStrings.every((s) => s === conditionStrings[0])) {
this.multiSelectField.conditions = JSON.parse(conditionStrings[0])
}
this.isShowConditionsModal = true
},
closeModal () {
this.isShowFontModal = false
this.isShowConditionsModal = false
this.multiSelectField = null
this.$emit('close')
},
alignSelectedAreas (direction) {
const areas = this.selectedAreasRef.value
let targetValue
if (direction === 'left') {
targetValue = Math.min(...areas.map(a => a.x))
areas.forEach((area) => { area.x = targetValue })
} else if (direction === 'right') {
targetValue = Math.max(...areas.map(a => a.x + a.w))
areas.forEach((area) => { area.x = targetValue - area.w })
} else if (direction === 'top') {
targetValue = Math.min(...areas.map(a => a.y))
areas.forEach((area) => { area.y = targetValue })
} else if (direction === 'bottom') {
targetValue = Math.max(...areas.map(a => a.y + a.h))
areas.forEach((area) => { area.y = targetValue - area.h })
}
this.save()
this.$emit('close')
},
resizeSelectedAreas (dimension) {
const areas = this.selectedAreasRef.value
const values = areas.map(a => dimension === 'width' ? a.w : a.h).sort((a, b) => a - b)
const medianValue = values[Math.floor(values.length / 2)]
if (dimension === 'width') {
areas.forEach((area) => { area.w = medianValue })
} else if (dimension === 'height') {
areas.forEach((area) => {
const diff = medianValue - area.h
area.y = area.y - diff
area.h = medianValue
})
}
this.save()
this.$emit('close')
},
handleSaveMultiSelectFontModal () {
this.selectedFields.forEach((field) => {
field.preferences = { ...field.preferences, ...this.multiSelectField.preferences }
})
this.save()
this.closeModal()
},
handleSaveMultiSelectConditionsModal () {
this.selectedFields.forEach((field) => {
field.conditions = JSON.parse(JSON.stringify(this.multiSelectField.conditions))
})
this.save()
this.closeModal()
}
}
}
</script>

@ -290,7 +290,11 @@ export default {
if (resp.ok) { if (resp.ok) {
resp.json().then((data) => { resp.json().then((data) => {
this.$emit('success', data) this.$emit('success', data)
if (this.$refs.input) {
this.$refs.input.value = '' this.$refs.input.value = ''
}
this.isLoading = false this.isLoading = false
}) })
} else if (resp.status === 422) { } else if (resp.status === 422) {

@ -164,11 +164,13 @@ class ProcessSubmitterCompletionJob
next_submitter_items = next_submitter_items =
if submission.template_submitters.any? { |s| s['order'] } if submission.template_submitters.any? { |s| s['order'] }
submitter_groups = submitter_groups =
submission.template_submitters.group_by.with_index { |s, index| s['order'] || index } submission.template_submitters
.group_by.with_index { |s, index| s['order'] || index }
.sort_by(&:first).pluck(1)
current_group_index = submitter_groups.find { |_, group| group.any? { |s| s['uuid'] == submitter.uuid } }&.first current_group_index = submitter_groups.index { |group| group.any? { |s| s['uuid'] == submitter.uuid } }
if submitter_groups[current_group_index + 1] && if current_group_index && submitter_groups[current_group_index + 1] &&
submitters_index.values_at(*submitter_groups[current_group_index].pluck('uuid')) submitters_index.values_at(*submitter_groups[current_group_index].pluck('uuid'))
.compact.all?(&:completed_at?) .compact.all?(&:completed_at?)
submitter_groups[current_group_index + 1] submitter_groups[current_group_index + 1]

@ -39,7 +39,7 @@
<div class="form-control"> <div class="form-control">
<%= ff.label :security_label, 'SMTP Security', class: 'label' %> <%= ff.label :security_label, 'SMTP Security', class: 'label' %>
<div class="flex items-center space-x-6"> <div class="flex items-center space-x-6">
<% [%w[Auto none], %w[SSL ssl], %w[TLS tls], %w[Noverify noverify]].each do |(label, val)| %> <% [%w[STARTTLS none], %w[TLS tls], %w[SSL ssl], %w[Noverify noverify]].each do |(label, val)| %>
<%= ff.label :security, value: val, for: "#{val}_radio", class: 'label' do %> <%= ff.label :security, value: val, for: "#{val}_radio", class: 'label' do %>
<%= ff.radio_button :security, val, checked: (value['security'].blank? && val == 'none') || value['security'] == val, id: "#{val}_radio", class: 'base-radio mr-2' %> <%= ff.radio_button :security, val, checked: (value['security'].blank? && val == 'none') || value['security'] == val, id: "#{val}_radio", class: 'base-radio mr-2' %>
<%= label %> <%= label %>

@ -60,7 +60,7 @@
<% end %> <% end %>
<% elsif field['type'] == 'cells' && area['cell_w'].to_f > 0.0 %> <% elsif field['type'] == 'cells' && area['cell_w'].to_f > 0.0 %>
<% cell_width = area['cell_w'] / area['w'] * 100 %> <% cell_width = area['cell_w'] / area['w'] * 100 %>
<div class="w-full flex items-center <%= 'justify-end' if align == 'right' %>"> <div class="w-full flex <%= valign == 'top' ? 'items-start' : (valign == 'bottom' ? 'items-end' : 'items-center') %> <%= 'justify-end' if align == 'right' %>">
<% (0..(area['w'] / area['cell_w']).ceil).each do |index| %> <% (0..(area['w'] / area['cell_w']).ceil).each do |index| %>
<% if value[index] %> <% if value[index] %>
<div class="text-center flex-none" style="width: <%= cell_width %>%;"><%= value[index] %></div> <div class="text-center flex-none" style="width: <%= cell_width %>%;"><%= value[index] %></div>

@ -30,9 +30,8 @@ production:
<% elsif ENV['DATABASE_URL'].match?(/\Apostgres/) %> <% elsif ENV['DATABASE_URL'].match?(/\Apostgres/) %>
<<: *default <<: *default
url: <%= ENV['DATABASE_URL'] %> url: <%= ENV['DATABASE_URL'] %>
<% elsif ENV['DATABASE_URL'].match?(/\Amysql/) %> <% elsif ENV['DATABASE_URL'].match?(/\Amysql|\Atrilogy/) %>
adapter: mysql2
encoding: utf8mb4 encoding: utf8mb4
pool: <%= ENV.fetch('RAILS_MAX_THREADS', 15).to_i + ENV.fetch('SIDEKIQ_THREADS', 5).to_i %> pool: <%= ENV.fetch('RAILS_MAX_THREADS', 15).to_i + ENV.fetch('SIDEKIQ_THREADS', 5).to_i %>
url: <%= ENV['DATABASE_URL'] %> url: <%= ENV['DATABASE_URL'].sub(/\Amysql2?/, 'trilogy') %>
<% end %> <% end %>

@ -85,6 +85,7 @@ Rails.application.configure do
domain: ENV.fetch('SMTP_DOMAIN', nil), domain: ENV.fetch('SMTP_DOMAIN', nil),
user_name: ENV.fetch('SMTP_USERNAME', nil), user_name: ENV.fetch('SMTP_USERNAME', nil),
password: ENV.fetch('SMTP_PASSWORD', nil), password: ENV.fetch('SMTP_PASSWORD', nil),
openssl_verify_mode: ENV['SMTP_SSL_VERIFY'] == 'false' ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER,
authentication: ENV.fetch('SMTP_PASSWORD', nil).present? ? ENV.fetch('SMTP_AUTHENTICATION', 'plain') : nil, authentication: ENV.fetch('SMTP_PASSWORD', nil).present? ? ENV.fetch('SMTP_AUTHENTICATION', 'plain') : nil,
enable_starttls: ENV['SMTP_ENABLE_STARTTLS'] != 'false' enable_starttls: ENV['SMTP_ENABLE_STARTTLS'] != 'false'
}.compact }.compact

@ -47,6 +47,9 @@ module ActionMailerConfigsInterceptor
is_tls = value['security'] == 'tls' || (value['security'].blank? && value['port'].to_s == '465') is_tls = value['security'] == 'tls' || (value['security'].blank? && value['port'].to_s == '465')
is_ssl = value['security'] == 'ssl' is_ssl = value['security'] == 'ssl'
is_noverify = value['security'] == 'noverify'
enable_starttls = is_noverify ? :enable_starttls_auto : :enable_starttls
{ {
user_name: value['username'], user_name: value['username'],
@ -54,9 +57,9 @@ module ActionMailerConfigsInterceptor
address: value['host'], address: value['host'],
port: value['port'], port: value['port'],
domain: value['domain'], domain: value['domain'],
openssl_verify_mode: value['security'] == 'noverify' ? OpenSSL::SSL::VERIFY_NONE : nil, openssl_verify_mode: is_noverify ? OpenSSL::SSL::VERIFY_NONE : nil,
authentication: value['password'].present? ? value.fetch('authentication', 'plain') : nil, authentication: value['password'].present? ? value.fetch('authentication', 'plain') : nil,
enable_starttls: !is_tls && !is_ssl, enable_starttls => !is_tls && !is_ssl,
open_timeout: OPEN_TIMEOUT, open_timeout: OPEN_TIMEOUT,
read_timeout: READ_TIMEOUT, read_timeout: READ_TIMEOUT,
ssl: is_ssl, ssl: is_ssl,

@ -6,11 +6,18 @@ class NormalizeClientIpMiddleware
end end
def call(env) def call(env)
if env['HTTP_CLIENT_IP'].present? && env['HTTP_X_CLIENT_IP'].present? && if env['HTTP_CLIENT_IP'].present?
if env['HTTP_X_CLIENT_IP'].present? &&
env['HTTP_CLIENT_IP'].starts_with?("#{env['HTTP_X_CLIENT_IP']}:") env['HTTP_CLIENT_IP'].starts_with?("#{env['HTTP_X_CLIENT_IP']}:")
env['HTTP_CLIENT_IP'] = env['HTTP_X_CLIENT_IP'] env['HTTP_CLIENT_IP'] = env['HTTP_X_CLIENT_IP']
end end
if env['HTTP_X_FORWARDED_FOR'].present? &&
env['HTTP_X_FORWARDED_FOR'].sub(/:\d+\z/, '') == env['HTTP_CLIENT_IP']
env['HTTP_X_FORWARDED_FOR'] = env['HTTP_CLIENT_IP']
end
end
@app.call(env) @app.call(env)
end end
end end

@ -61,7 +61,8 @@ module Submissions
submission.template_submitters << template_submitter submission.template_submitters << template_submitter
is_order_sent = submitters_order == 'random' || (template_submitter['order'] || index).zero? is_order_sent = submitters_order == 'random' ||
(template_submitter['order'] || submitter_attrs[:index] || index).zero?
build_submitter(submission:, attrs: submitter_attrs, build_submitter(submission:, attrs: submitter_attrs,
uuid:, is_order_sent:, user:, params:, uuid:, is_order_sent:, user:, params:,
@ -308,7 +309,7 @@ module Submissions
uuid = attrs[:uuid].presence uuid = attrs[:uuid].presence
uuid ||= submitters.find { |e| e['name'].to_s.casecmp(attrs[:role].to_s).zero? }&.dig('uuid') uuid ||= submitters.find { |e| e['name'].to_s.casecmp(attrs[:role].to_s).zero? }&.dig('uuid')
uuid || submitters[index]&.dig('uuid') uuid || submitters[attrs[:index] || index]&.dig('uuid')
end end
def build_submitter(submission:, attrs:, uuid:, is_order_sent:, user:, preferences:, params:) def build_submitter(submission:, attrs:, uuid:, is_order_sent:, user:, preferences:, params:)

@ -19,7 +19,7 @@ module Submissions
total_wait_time ||= 0 total_wait_time ||= 0
key = ['result_attachments', submitter.id].join(':') key = ['result_attachments', submitter.id].join(':')
return submitter.documents.reset if ApplicationRecord.uncached { LockEvent.exists?(key:, event_name: :complete) } return submitter.documents.reload if ApplicationRecord.uncached { LockEvent.exists?(key:, event_name: :complete) }
events = ApplicationRecord.uncached { LockEvent.where(key:).order(:id).to_a } events = ApplicationRecord.uncached { LockEvent.where(key:).order(:id).to_a }
@ -32,6 +32,8 @@ module Submissions
LockEvent.create!(key:, event_name: :complete) LockEvent.create!(key:, event_name: :complete)
submitter.documents.reset
documents documents
end end
rescue ActiveRecord::RecordNotUnique rescue ActiveRecord::RecordNotUnique
@ -60,7 +62,7 @@ module Submissions
LockEvent.where(key: ['result_attachments', submitter.id].join(':')).order(:id).last LockEvent.where(key: ['result_attachments', submitter.id].join(':')).order(:id).last
end end
break submitter.documents.reset if last_event.event_name.in?(%w[complete fail]) break submitter.documents.reload if last_event.event_name.in?(%w[complete fail])
raise WaitForCompleteTimeout if total_wait_time > CHECK_COMPLETE_TIMEOUT raise WaitForCompleteTimeout if total_wait_time > CHECK_COMPLETE_TIMEOUT
end end

@ -200,7 +200,9 @@ module Submissions
def fill_submitter_fields(submitter, account, pdfs_index, with_signature_id:, is_flatten:, with_headings: nil, def fill_submitter_fields(submitter, account, pdfs_index, with_signature_id:, is_flatten:, with_headings: nil,
with_submitter_timezone: false, with_signature_id_reason: true, with_file_links: nil) with_submitter_timezone: false, with_signature_id_reason: true, with_file_links: nil)
cell_layouter = HexaPDF::Layout::TextLayouter.new(text_valign: :center, text_align: :center) cell_layouters = Hash.new do |hash, valign|
hash[valign] = HexaPDF::Layout::TextLayouter.new(text_valign: valign.to_sym, text_align: :center)
end
attachments_data_cache = {} attachments_data_cache = {}
@ -550,6 +552,8 @@ module Submissions
) )
when ->(type) { type == 'cells' && !area['cell_w'].to_f.zero? } when ->(type) { type == 'cells' && !area['cell_w'].to_f.zero? }
cell_width = area['cell_w'] * width cell_width = area['cell_w'] * width
cell_valign = field.dig('preferences', 'valign').to_s.presence || 'center'
cell_layouter = cell_layouters[cell_valign]
if (mask = field.dig('preferences', 'mask').presence) if (mask = field.dig('preferences', 'mask').presence)
value = TextUtils.mask_value(value, mask) value = TextUtils.mask_value(value, mask)
@ -790,10 +794,10 @@ module Submissions
def build_pdfs_index(submission, submitter: nil, flatten: true) def build_pdfs_index(submission, submitter: nil, flatten: true)
latest_submitter = find_last_submitter(submission, submitter:) latest_submitter = find_last_submitter(submission, submitter:)
Submissions::EnsureResultGenerated.call(latest_submitter) if latest_submitter documents = Submissions::EnsureResultGenerated.call(latest_submitter) if latest_submitter
documents ||= submission.schema_documents
documents = latest_submitter&.documents&.preload(:blob).to_a.presence ActiveRecord::Associations::Preloader.new(records: documents, associations: [:blob]).call
documents ||= submission.schema_documents.preload(:blob)
attachment_uuids = Submissions.filtered_conditions_schema(submission).pluck('attachment_uuid') attachment_uuids = Submissions.filtered_conditions_schema(submission).pluck('attachment_uuid')
attachments_index = documents.index_by { |a| a.metadata['original_uuid'] || a.uuid } attachments_index = documents.index_by { |a| a.metadata['original_uuid'] || a.uuid }

@ -10,7 +10,8 @@ module Submissions
Array.wrap(submissions_params).each do |submission| Array.wrap(submissions_params).each do |submission|
submission[:submitters].each_with_index do |submitter, index| submission[:submitters].each_with_index do |submitter, index|
_, new_attachments, new_fields = normalize_submitter_params!(submitter, template, index, add_fields:) _, new_attachments, new_fields =
normalize_submitter_params!(submitter, template, submitter[:index] || index, add_fields:)
attachments.push(*new_attachments) attachments.push(*new_attachments)
fields.push(*new_fields) fields.push(*new_fields)

@ -14,6 +14,7 @@ module Submissions
module_function module_function
# rubocop:disable Metrics
def call(submission, submitters = nil, params = {}, with_events: true, with_documents: true, with_values: true, def call(submission, submitters = nil, params = {}, with_events: true, with_documents: true, with_values: true,
expires_at: Accounts.link_expires_at(Account.new(id: submission.account_id))) expires_at: Accounts.link_expires_at(Account.new(id: submission.account_id)))
submitters ||= submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob) submitters ||= submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob)
@ -32,6 +33,10 @@ module Submissions
json['submission_events'] = Submitters::SerializeForApi.serialize_events(submission.submission_events) json['submission_events'] = Submitters::SerializeForApi.serialize_events(submission.submission_events)
end end
if params[:include].to_s.include?('fields')
json['fields'] = submission.template_fields || submission.template&.fields
end
if submitters.all?(&:completed_at?) if submitters.all?(&:completed_at?)
last_submitter = submitters.max_by(&:completed_at) last_submitter = submitters.max_by(&:completed_at)
@ -57,6 +62,7 @@ module Submissions
json json
end end
# rubocop:enable Metrics
def build_status(submission, submitters) def build_status(submission, submitters)
if submitters.any?(&:declined_at?) if submitters.any?(&:declined_at?)

@ -48,6 +48,10 @@ module Submitters
maybe_set_signature_reason!(values, submitter, params) maybe_set_signature_reason!(values, submitter, params)
validate_values!(values, submitter, params, request) validate_values!(values, submitter, params, request)
if (touch_attachment_uuid = params[:touch_attachment_uuid].presence)
ActiveStorage::Attachment.where(uuid: touch_attachment_uuid, record: submitter).touch_all(:created_at)
end
SubmissionEvents.create_with_tracking_data(submitter, 'complete_form', request) if params[:completed] == 'true' SubmissionEvents.create_with_tracking_data(submitter, 'complete_form', request) if params[:completed] == 'true'
submitter.save! submitter.save!
@ -317,17 +321,33 @@ module Submitters
end.exclude?(false) end.exclude?(false)
end end
# rubocop:disable Metrics
def check_field_condition(condition, submitter_values, fields_uuid_index) def check_field_condition(condition, submitter_values, fields_uuid_index)
value = submitter_values[condition['field_uuid']] value = submitter_values[condition['field_uuid']]
field = fields_uuid_index[condition['field_uuid']]
case condition['action'] case condition['action']
when 'empty', 'unchecked' when 'empty', 'unchecked'
value.blank? value.blank?
when 'not_empty', 'checked' when 'not_empty', 'checked'
value.present? value.present?
when 'equal', 'contains' when ->(action) { action == 'equal' && field&.dig('type') == 'number' }
field = fields_uuid_index[condition['field_uuid']] return false if value.blank? || condition['value'].blank?
(value.to_f - condition['value'].to_f).abs < Float::EPSILON
when ->(action) { action == 'not_equal' && field&.dig('type') == 'number' }
return false if value.blank? || condition['value'].blank?
(value.to_f - condition['value'].to_f).abs > Float::EPSILON
when 'greater_than'
return false if field.nil? || value.blank? || condition['value'].blank?
value.to_f > condition['value'].to_f
when 'less_than'
return false if field.nil? || value.blank? || condition['value'].blank?
value.to_f < condition['value'].to_f
when 'equal', 'contains'
return true unless field return true unless field
values = Array.wrap(value) values = Array.wrap(value)
@ -340,8 +360,6 @@ module Submitters
values.include?(option['value'].presence || "#{I18n.t('option')} #{field['options'].index(option) + 1}") values.include?(option['value'].presence || "#{I18n.t('option')} #{field['options'].index(option) + 1}")
when 'not_equal', 'does_not_contain' when 'not_equal', 'does_not_contain'
field = fields_uuid_index[condition['field_uuid']]
return true unless field return true unless field
return false unless field['options'] return false unless field['options']
@ -356,6 +374,7 @@ module Submitters
true true
end end
end end
# rubocop:enable Metrics
def replace_default_variables(value, attrs, submission, with_time: false) def replace_default_variables(value, attrs, submission, with_time: false)
return value if value.in?([true, false]) || value.is_a?(Numeric) || value.is_a?(Array) return value if value.in?([true, false]) || value.is_a?(Numeric) || value.is_a?(Array)

Loading…
Cancel
Save