Merge branch 'master' into master

pull/668/head
Eros Stein 1 month ago committed by GitHub
commit ad022c319e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -179,7 +179,7 @@ GEM
dotenv (3.2.0) dotenv (3.2.0)
drb (2.2.3) drb (2.2.3)
email_typo (0.2.3) email_typo (0.2.3)
erb (6.0.2) erb (6.0.4)
erb_lint (0.9.0) erb_lint (0.9.0)
activesupport activesupport
better_html (>= 2.0.1) better_html (>= 2.0.1)

@ -33,9 +33,15 @@ module Api
else else
http_cache_forever public: true do http_cache_forever public: true do
response.headers['Accept-Ranges'] = 'bytes' response.headers['Accept-Ranges'] = 'bytes'
response.headers['Content-Length'] = blob.byte_size.to_s
send_blob_stream blob, disposition: params[:disposition] if request.head?
response.headers['Content-Type'] = blob.content_type_for_serving
head :ok
else
send_blob_stream blob, disposition: params[:disposition]
end
response.headers['Content-Length'] = blob.byte_size.to_s
end end
end end
end end
@ -57,8 +63,6 @@ module Api
return if !require_ttl && !require_auth return if !require_ttl && !require_auth
end end
Rollbar.error('Blob unauthorized') if defined?(Rollbar)
raise CanCan::AccessDenied raise CanCan::AccessDenied
end end
end end

@ -161,9 +161,7 @@ module Api
submitter.values = Submitters::SubmitValues.maybe_remove_condition_values(submitter) submitter.values = Submitters::SubmitValues.maybe_remove_condition_values(submitter)
end end
submitter.values = submitter.values.transform_values do |v| submitter.values = Submitters::SubmitValues.replace_current_date_placeholders(submitter)
v == '{{date}}' ? Time.current.in_time_zone(submitter.account.timezone).to_date.to_s : v
end
end end
submitter submitter
@ -205,10 +203,15 @@ module Api
submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms') submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms')
submitter.preferences['reply_to'] = submitter_preferences['reply_to'] if submitter_preferences.key?('reply_to') submitter.preferences['reply_to'] = submitter_preferences['reply_to'] if submitter_preferences.key?('reply_to')
if submitter_preferences.key?('require_phone_2fa') if submitter_preferences.key?('require_phone_2fa')
submitter.preferences['require_phone_2fa'] = submitter_preferences['require_phone_2fa'] submitter.preferences['require_phone_2fa'] = submitter_preferences['require_phone_2fa']
end end
if submitter_preferences.key?('require_email_2fa')
submitter.preferences['require_email_2fa'] = submitter_preferences['require_email_2fa']
end
if submitter_preferences.key?('go_to_last') if submitter_preferences.key?('go_to_last')
submitter.preferences['go_to_last'] = submitter_preferences['go_to_last'] submitter.preferences['go_to_last'] = submitter_preferences['go_to_last']
end end

@ -41,7 +41,7 @@ class PreviewDocumentPageController < ActionController::API
end end
def find_or_create_document_tempfile_path(attachment) def find_or_create_document_tempfile_path(attachment)
file_path = "#{Dir.tmpdir}/#{attachment.uuid}" file_path = "#{Dir.tmpdir}/attachment-#{Digest::SHA1.hexdigest("#{attachment.id}-#{attachment.uuid}")}"
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f| File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f|
f.flock(File::LOCK_EX) f.flock(File::LOCK_EX)

@ -1,6 +1,14 @@
# frozen_string_literal: true # frozen_string_literal: true
class RevealAccessTokenController < ApplicationController class RevealAccessTokenController < ApplicationController
rate_limit to: 4, within: 1.minute, only: %i[create], by: -> { current_user.id }, with: lambda {
Rollbar.error('Rate limit api key') if defined?(Rollbar)
render turbo_stream: turbo_stream.replace(:modal, template: 'reveal_access_token/show',
locals: { error_message: I18n.t(:too_many_attempts) }),
status: :unprocessable_content
}
def show def show
authorize!(:manage, current_user.access_token) authorize!(:manage, current_user.access_token)
end end

@ -15,13 +15,20 @@ class SendSubmissionEmailController < ApplicationController
@submitter = find_completed_submitter @submitter = find_completed_submitter
return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter
@submitter =
Submitter.completed.where(submission: template.submissions).find_by(email: params[:email].to_s.downcase)
elsif params[:submission_slug]
submission = Submission.find_by(slug: params[:submission_slug])
@embed_cors_account = @submitter.account @embed_cors_account = @submitter.account
set_embed_cors_headers set_embed_cors_headers
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes) if @submitter
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter) SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
end
respond_to do |f| respond_to do |f|
f.html { render :success } f.html { render :success }

@ -1,6 +1,10 @@
# frozen_string_literal: true # frozen_string_literal: true
class TemplatesController < ApplicationController class TemplatesController < ApplicationController
TEMPLATE_FIELDS = %i[id author_id folder_id external_id name slug
schema fields submitters variables_schema preferences
shared_link source archived_at created_at updated_at].freeze
load_and_authorize_resource :template load_and_authorize_resource :template
def show def show
@ -33,10 +37,11 @@ class TemplatesController < ApplicationController
).call ).call
@template_data = @template_data =
@template.as_json.merge( @template.as_json(only: TEMPLATE_FIELDS).merge(
documents: @template.schema_documents.as_json( documents: @template.schema_documents.as_json(
only: %i[id uuid],
methods: %i[metadata signed_key], methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } } include: { preview_images: { only: %i[id], methods: %i[url metadata filename] } }
) )
).to_json ).to_json

@ -3,7 +3,7 @@
class TestingAccountsController < ApplicationController class TestingAccountsController < ApplicationController
skip_authorization_check only: :destroy skip_authorization_check only: :destroy
def show def create
authorize!(:manage, current_account) authorize!(:manage, current_account)
authorize!(:manage, current_user) authorize!(:manage, current_user)

@ -169,6 +169,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withKba: ['true', 'false'].includes(this.dataset.withKba) ? this.dataset.withKba === 'true' : null, withKba: ['true', 'false'].includes(this.dataset.withKba) ? this.dataset.withKba === 'true' : null,
withLogo: this.dataset.withLogo !== 'false', withLogo: this.dataset.withLogo !== 'false',
withFieldsDetection: this.dataset.withFieldsDetection === 'true', withFieldsDetection: this.dataset.withFieldsDetection === 'true',
withDetectExistingFields: this.dataset.withDetectExistingFields === 'true',
editable: this.dataset.editable !== 'false', editable: this.dataset.editable !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content, authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withCustomFields: true, withCustomFields: true,
@ -180,6 +181,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withConditions: this.dataset.withConditions === 'true', withConditions: this.dataset.withConditions === 'true',
withDynamicDocuments: this.dataset.withDynamicDocuments === 'true', withDynamicDocuments: this.dataset.withDynamicDocuments === 'true',
withGoogleDrive: this.dataset.withGoogleDrive === 'true', withGoogleDrive: this.dataset.withGoogleDrive === 'true',
pagePreviewFormat: this.dataset.pagePreviewFormat || '.jpg',
withReplaceAndCloneUpload: true, withReplaceAndCloneUpload: true,
withDownload: true, withDownload: true,
currencies: (this.dataset.currencies || '').split(',').filter(Boolean), currencies: (this.dataset.currencies || '').split(',').filter(Boolean),

@ -11,6 +11,7 @@ window.customElements.define('draw-signature', class extends HTMLElement {
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!this.canvas) return if (!this.canvas) return
if (!this.canvas.parentNode?.clientWidth) return
const { width, height } = this.canvas const { width, height } = this.canvas
@ -89,7 +90,7 @@ window.customElements.define('draw-signature', class extends HTMLElement {
} }
redrawCanvas (oldWidth, oldHeight) { redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) { if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && this.canvas.width > 0 && this.canvas.height > 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

@ -14,6 +14,7 @@ export default targetable(class extends HTMLElement {
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!this.canvas) return if (!this.canvas) return
if (!this.canvas.parentNode?.clientWidth) return
const { width, height } = this.canvas const { width, height } = this.canvas
@ -80,7 +81,7 @@ export default targetable(class extends HTMLElement {
} }
redrawCanvas (oldWidth, oldHeight) { redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) { if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && this.canvas.width > 0 && this.canvas.height > 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

@ -42,10 +42,25 @@ export default {
const areas = {} const areas = {}
this.field.areas?.forEach((area) => { this.field.areas?.forEach((area) => {
areas[area.attachment_uuid + area.page] ||= area areas[area.attachment_uuid] ||= []
areas[area.attachment_uuid].push(area)
}) })
return Object.values(areas).slice(0, 6) const sortedAreas = Object.values(areas).reduce((acc, group) => {
const seen = {}
const sortedGroup = [...group].sort((a, b) => a.page - b.page)
sortedGroup.forEach((area) => {
if (!seen[area.page]) {
seen[area.page] = true
acc.push(area)
}
})
return acc
}, [])
return sortedAreas.slice(0, 6)
} }
} }
} }

@ -527,7 +527,8 @@ export default {
try { try {
return this.formatDate( return this.formatDate(
this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue), this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue),
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY') this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'),
{ withTimePlaceholders: this.modelValue === '{{date}}' }
) )
} catch { } catch {
return this.modelValue return this.modelValue
@ -646,36 +647,55 @@ export default {
return number return number
} }
}, },
formatDate (date, format) { formatDate (date, format, { withTimePlaceholders = false } = {}) {
const monthFormats = { const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
M: 'numeric', const dayFormats = { D: 'numeric', DD: '2-digit' }
MM: '2-digit', const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' }
MMM: 'short', const hourFormats = { H: 'numeric', HH: '2-digit', h: 'numeric', hh: '2-digit' }
MMMM: 'long' const minuteFormats = { m: 'numeric', mm: '2-digit' }
} const secondFormats = { s: 'numeric', ss: '2-digit' }
const hasTime = /[HhAasz]/.test(format)
const dayFormats = { const opts = {
D: 'numeric', day: dayFormats[format.match(/D+/)],
DD: '2-digit' month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)]
} }
const yearFormats = { if (format.match(/H+/)) { opts.hour = hourFormats[format.match(/H+/)[0]]; opts.hour12 = false }
YYYY: 'numeric', if (format.match(/h+/)) { opts.hour = hourFormats[format.match(/h+/)[0]]; opts.hour12 = true }
YYY: 'numeric', if (/[Aa]/.test(format) && opts.hour12 === undefined) opts.hour12 = true
YY: '2-digit' if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]]
if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]]
if (/z/.test(format)) opts.timeZoneName = 'short'
if (!hasTime) opts.timeZone = 'UTC'
const partTypes = {
M: 'month',
D: 'day',
Y: 'year',
H: 'hour',
h: 'hour',
m: 'minute',
s: 'second',
z: 'timeZoneName',
A: 'dayPeriod',
a: 'dayPeriod'
} }
const parts = new Intl.DateTimeFormat([], { const parts = new Intl.DateTimeFormat([], opts).formatToParts(date)
day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)], return format.replace(/MMMM|MMM|MM|M|DD|D|YYYY|YYY|YY|HH|hh|H|h|mm|m|ss|s|A|a|z/g, (token) => {
year: yearFormats[format.match(/Y+/)], if (withTimePlaceholders && /^(HH|hh|H|h|mm|m|ss|s|A|a)$/.test(token)) return '--'
timeZone: 'UTC'
}).formatToParts(date) const value = parts.find((p) => p.type === partTypes[token[0]])?.value
return format if (token === 'A') return (value || '').toUpperCase()
.replace(/D+/, parts.find((p) => p.type === 'day').value) if (token === 'a') return (value || '').toLowerCase()
.replace(/M+/, parts.find((p) => p.type === 'month').value)
.replace(/Y+/, parts.find((p) => p.type === 'year').value) return value
})
}, },
updateMultipleSelectValue (value) { updateMultipleSelectValue (value) {
if (this.modelValue?.includes(value)) { if (this.modelValue?.includes(value)) {

@ -56,12 +56,18 @@
class="base-input !text-2xl text-center w-full" class="base-input !text-2xl text-center w-full"
:required="field.required" :required="field.required"
:aria-describedby="field.description ? field.uuid + '-desc' : undefined" :aria-describedby="field.description ? field.uuid + '-desc' : undefined"
type="date" :type="inputType"
:name="`values[${field.uuid}]`" :name="formatType === 'datetime' ? undefined : `values[${field.uuid}]`"
@keydown.enter="onEnter" @keydown.enter="onEnter"
@focus="$emit('focus')" @focus="$emit('focus')"
@paste="onPaste" @paste="onPaste"
> >
<input
v-if="formatType === 'datetime'"
type="hidden"
:name="`values[${field.uuid}]`"
:value="modelValue"
>
</div> </div>
</div> </div>
</template> </template>
@ -97,14 +103,19 @@ export default {
}, },
emits: ['update:model-value', 'focus', 'submit'], emits: ['update:model-value', 'focus', 'submit'],
computed: { computed: {
dateNowString () { formatType () {
const today = new Date() const format = this.field.preferences?.format || ''
const yyyy = today.getFullYear() if (/[HhAasz]/.test(format)) return 'datetime'
const mm = String(today.getMonth() + 1).padStart(2, '0') if (format && !/[Dd]/.test(format)) return 'month'
const dd = String(today.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}` return 'date'
},
inputType () {
return { datetime: 'datetime-local', month: 'month', date: 'date' }[this.formatType]
},
dateNowString () {
return this.formatDateValue(new Date())
}, },
validationMin () { validationMin () {
if (this.field.validation?.min) { if (this.field.validation?.min) {
@ -121,6 +132,8 @@ export default {
} }
}, },
withToday () { withToday () {
if (this.formatType === 'datetime') return false
const todayDate = new Date().setHours(0, 0, 0, 0) const todayDate = new Date().setHours(0, 0, 0, 0)
if (this.validationMin) { if (this.validationMin) {
@ -137,9 +150,25 @@ export default {
}, },
value: { value: {
set (value) { set (value) {
if (this.formatType === 'datetime' && value) {
const d = new Date(value)
if (!isNaN(d)) {
this.$emit('update:model-value', d.toISOString())
return
}
}
this.$emit('update:model-value', value) this.$emit('update:model-value', value)
}, },
get () { get () {
if (this.formatType === 'datetime') {
const d = new Date(this.modelValue)
return isNaN(d) ? '' : this.formatDateValue(d)
}
return this.modelValue return this.modelValue
} }
} }
@ -163,20 +192,32 @@ export default {
const parsedDate = new Date(pasteData) const parsedDate = new Date(pasteData)
if (!isNaN(parsedDate)) { if (isNaN(parsedDate)) return
const inputEl = this.$refs.input
inputEl.valueAsDate = new Date(parsedDate.getTime() - parsedDate.getTimezoneOffset() * 60000)
inputEl.dispatchEvent(new Event('input', { bubbles: true })) this.setInputValue(parsedDate)
}
}, },
setCurrentDate () { setCurrentDate () {
this.setInputValue(new Date())
},
setInputValue (date) {
const inputEl = this.$refs.input const inputEl = this.$refs.input
inputEl.valueAsDate = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000) if (this.formatType === 'date') {
inputEl.valueAsDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
} else {
inputEl.value = this.formatDateValue(date)
}
inputEl.dispatchEvent(new Event('input', { bubbles: true })) inputEl.dispatchEvent(new Event('input', { bubbles: true }))
},
formatDateValue (date) {
const pad = (n) => String(n).padStart(2, '0')
const ymd = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
if (this.formatType === 'month') return ymd.slice(0, 7)
if (this.formatType === 'datetime') return `${ymd}T${pad(date.getHours())}:${pad(date.getMinutes())}`
return ymd
} }
} }
} }

@ -544,6 +544,7 @@ export default {
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!this.$refs.canvas) return if (!this.$refs.canvas) return
if (!this.$refs.canvas.parentNode?.clientWidth) return
const { width, height } = this.$refs.canvas const { width, height } = this.$refs.canvas
@ -586,7 +587,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() && oldWidth > 0 && oldHeight > 0) { if (this.pad && !this.isTextSignature && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && canvas.width > 0 && canvas.height > 0) {
const sx = canvas.width / oldWidth const sx = canvas.width / oldWidth
const sy = canvas.height / oldHeight const sy = canvas.height / oldHeight

@ -113,7 +113,7 @@
<div <div
ref="textContainer" ref="textContainer"
class="flex items-center px-0.5" class="flex items-center px-0.5"
:style="{ color: field.preferences?.color }" :style="{ color: isConditionMatch ? field.preferences?.color : '#9ca3af' }"
:class="{ 'w-full h-full': isWFullType }" :class="{ 'w-full h-full': isWFullType }"
> >
<IconCheck <IconCheck
@ -131,13 +131,13 @@
/> />
</template> </template>
<span <span
v-else-if="field.type === 'number' && !isValueInput && (field.default_value || field.default_value == 0)" v-else-if="field.type === 'number' && !isContenteditable && (displayValue || displayValue == 0)"
class="whitespace-pre-wrap" class="whitespace-pre-wrap"
>{{ formatNumber(field.default_value, field.preferences?.format) }}</span> >{{ formatNumber(displayValue, field.preferences?.format) }}</span>
<span <span
v-else-if="field.default_value === '{{date}}'" v-else-if="field.default_value === '{{date}}'"
> >
{{ t('signing_date') }} {{ /[HhAasz]/.test(field.preferences?.format || '') ? t('signing_date_and_time') : t('signing_date') }}
</span> </span>
<div <div
v-else-if="field.type === 'cells' && field.default_value" v-else-if="field.type === 'cells' && field.default_value"
@ -183,12 +183,12 @@
:contenteditable="isValueInput" :contenteditable="isValueInput"
class="whitespace-pre-wrap outline-none empty:before:content-[attr(placeholder)] before:text-base-content/30" class="whitespace-pre-wrap outline-none empty:before:content-[attr(placeholder)] before:text-base-content/30"
:class="{ 'cursor-text': isValueInput }" :class="{ 'cursor-text': isValueInput }"
:placeholder="withFieldPlaceholder && !isValueInput ? defaultField?.title || field.title || field.name || defaultName : (field.type === 'date' ? field.preferences?.format || t('type_value') : t('type_value'))" :placeholder="withFieldPlaceholder && !isValueInput ? defaultField?.title || field.title || field.name || defaultName : (isConditionMatch ? (field.type === 'date' ? field.preferences?.format || t('type_value') : t('type_value')) : '')"
@blur="onDefaultValueBlur" @blur="onDefaultValueBlur"
@focus="selectedAreasRef.value = [area]" @focus="selectedAreasRef.value = [area]"
@paste.prevent="onPaste" @paste.prevent="onPaste"
@keydown.enter="onDefaultValueEnter" @keydown.enter="onDefaultValueEnter"
>{{ field.default_value }}</span> >{{ displayValue }}</span>
</div> </div>
</div> </div>
<component <component
@ -241,6 +241,16 @@ export default {
required: false, required: false,
default: false default: false
}, },
conditionalFieldIndex: {
type: Object,
required: false,
default: () => ({})
},
formulaValuesIndex: {
type: Object,
required: false,
default: () => ({})
},
isDraw: { isDraw: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -323,11 +333,27 @@ export default {
fieldNames: FieldType.computed.fieldNames, fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels, fieldLabels: FieldType.computed.fieldLabels,
fieldIcons: FieldType.computed.fieldIcons, fieldIcons: FieldType.computed.fieldIcons,
isConditionMatch () {
return !this.inputMode || this.conditionalFieldIndex[this.field.uuid] !== false
},
displayValue () {
if (this.field.preferences?.formula && this.field.type !== 'payment') {
const computed = this.formulaValuesIndex[this.field.uuid]
if (computed != null) {
return computed
}
}
return this.field.default_value
},
bgClasses () { bgClasses () {
if (this.field.type === 'heading') { if (this.field.type === 'heading') {
return 'bg-gray-50' return 'bg-gray-50'
} else if (this.field.type === 'strikethrough') { } else if (this.field.type === 'strikethrough') {
return 'bg-transparent' return 'bg-transparent'
} else if (!this.isConditionMatch) {
return 'bg-gray-100'
} else { } else {
return this.bgColors[this.submitterIndex % this.bgColors.length] return this.bgColors[this.submitterIndex % this.bgColors.length]
} }
@ -337,6 +363,8 @@ export default {
return '' return ''
} else if (this.field.type === 'strikethrough') { } else if (this.field.type === 'strikethrough') {
return 'border-dashed border-gray-300' return 'border-dashed border-gray-300'
} else if (!this.isConditionMatch) {
return 'border-gray-300'
} else { } else {
return this.borderColors[this.submitterIndex % this.borderColors.length] return this.borderColors[this.submitterIndex % this.borderColors.length]
} }
@ -390,7 +418,7 @@ export default {
return this.basePageWidth / 612.0 return this.basePageWidth / 612.0
}, },
isDefaultValuePresent () { isDefaultValuePresent () {
return this.field?.default_value || this.field?.default_value === 0 return this.field?.default_value || this.field?.default_value === 0 || this.displayValue || this.displayValue === 0
}, },
isSelectInput () { isSelectInput () {
return this.inputMode && (this.field.type === 'select' || (this.field.type === 'radio' && this.field.areas?.length < 2)) return this.inputMode && (this.field.type === 'select' || (this.field.type === 'radio' && this.field.areas?.length < 2))
@ -399,6 +427,8 @@ export default {
return this.inputMode && (this.field.type === 'checkbox' || (['radio', 'multiple'].includes(this.field.type) && this.area.option_uuid)) return this.inputMode && (this.field.type === 'checkbox' || (['radio', 'multiple'].includes(this.field.type) && this.area.option_uuid))
}, },
isValueInput () { isValueInput () {
if (this.inputMode && this.field.preferences?.formula) return false
return (this.field.type === 'heading' && this.isHeadingSelected) || this.isContenteditable || return (this.field.type === 'heading' && this.isHeadingSelected) || this.isContenteditable ||
(this.inputMode && (['text', 'number'].includes(this.field.type) || (this.field.type === 'date' && this.field.default_value !== '{{date}}'))) (this.inputMode && (['text', 'number'].includes(this.field.type) || (this.field.type === 'date' && this.field.default_value !== '{{date}}')))
}, },
@ -511,7 +541,7 @@ export default {
return option?.value || `${this.t('option')} ${this.field.options.indexOf(option) + 1}` return option?.value || `${this.t('option')} ${this.field.options.indexOf(option) + 1}`
}, },
maybeToggleDefaultValue () { maybeToggleDefaultValue () {
if (!this.editable || this.isCmdKeyRef.value) { if (!this.editable || this.isCmdKeyRef.value || this.field.preferences?.formula) {
return return
} }
@ -559,6 +589,10 @@ export default {
} }
}, },
focusValueInput (e) { focusValueInput (e) {
if (this.inputMode && this.field.type === 'number' && !this.isContenteditable && !this.field.preferences?.formula) {
this.isContenteditable = true
}
this.$nextTick(() => { this.$nextTick(() => {
if (this.$refs.defaultValue && this.$refs.defaultValue !== document.activeElement) { if (this.$refs.defaultValue && this.$refs.defaultValue !== document.activeElement) {
this.$refs.defaultValue.focus() this.$refs.defaultValue.focus()
@ -624,6 +658,12 @@ export default {
} }
}, },
onDefaultValueBlur (e) { onDefaultValueBlur (e) {
if (this.field.preferences?.formula) {
this.isContenteditable = false
return
}
const text = this.$refs.defaultValue.innerText.trim() const text = this.$refs.defaultValue.innerText.trim()
this.isContenteditable = false this.isContenteditable = false

@ -381,6 +381,9 @@
:document="document" :document="document"
:is-drag="!!dragField" :is-drag="!!dragField"
:input-mode="inputMode" :input-mode="inputMode"
:conditional-field-index="conditionalFieldIndex"
:formula-values-index="formulaValuesIndex"
:page-preview-format="pagePreviewFormat"
:default-fields="[...defaultRequiredFields, ...defaultFields]" :default-fields="[...defaultRequiredFields, ...defaultFields]"
:allow-draw="!onlyDefinedFields || drawField || drawCustomField" :allow-draw="!onlyDefinedFields || drawField || drawCustomField"
:with-signature-id="withSignatureId" :with-signature-id="withSignatureId"
@ -504,11 +507,14 @@
:with-custom-fields="withCustomFields" :with-custom-fields="withCustomFields"
:with-fields-search="withFieldsSearch" :with-fields-search="withFieldsSearch"
:default-fields="[...defaultRequiredFields, ...defaultFields]" :default-fields="[...defaultRequiredFields, ...defaultFields]"
:with-custom-fields-tab="withCustomFieldsTab"
:template="template" :template="template"
:default-required-fields="defaultRequiredFields" :default-required-fields="defaultRequiredFields"
:detect-custom-fields-index="detectCustomFieldsIndex"
:field-types="fieldTypes" :field-types="fieldTypes"
:with-sticky-submitters="withStickySubmitters" :with-sticky-submitters="withStickySubmitters"
:with-fields-detection="withFieldsDetection" :with-fields-detection="withFieldsDetection"
:with-detect-existing-fields="withDetectExistingFields"
:with-signature-id="withSignatureId" :with-signature-id="withSignatureId"
:with-prefillable="withPrefillable" :with-prefillable="withPrefillable"
:only-defined-fields="onlyDefinedFields" :only-defined-fields="onlyDefinedFields"
@ -617,6 +623,16 @@ import { v4 } from 'uuid'
import { ref, computed, toRaw, defineAsyncComponent } from 'vue' import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
import * as i18n from './i18n' import * as i18n from './i18n'
const isEmpty = (obj) => {
if (obj == null) return true
if (Array.isArray(obj)) return obj.length === 0
if (typeof obj === 'string') return obj.trim().length === 0
if (typeof obj === 'object') return Object.keys(obj).length === 0
if (obj === false) return true
return false
}
export default { export default {
name: 'TemplateBuilder', name: 'TemplateBuilder',
components: { components: {
@ -654,6 +670,7 @@ export default {
locale: this.locale, locale: this.locale,
baseFetch: this.baseFetch, baseFetch: this.baseFetch,
fieldTypes: this.fieldTypes, fieldTypes: this.fieldTypes,
dateFormats: this.dateFormats,
backgroundColor: this.backgroundColor, backgroundColor: this.backgroundColor,
withPhone: this.withPhone, withPhone: this.withPhone,
withVerification: this.withVerification, withVerification: this.withVerification,
@ -738,6 +755,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
withDetectExistingFields: {
type: Boolean,
required: false,
default: false
},
withCustomFields: { withCustomFields: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -778,6 +800,11 @@ export default {
required: false, required: false,
default: () => [] default: () => []
}, },
withCustomFieldsTab: {
type: Boolean,
required: false,
default: false
},
withSelectedFieldType: { withSelectedFieldType: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -798,6 +825,11 @@ export default {
required: false, required: false,
default: () => [] default: () => []
}, },
dateFormats: {
type: Array,
required: false,
default: () => []
},
defaultSubmitters: { defaultSubmitters: {
type: Array, type: Array,
required: false, required: false,
@ -808,6 +840,11 @@ export default {
required: false, required: false,
default: () => [] default: () => []
}, },
pagePreviewFormat: {
type: String,
required: false,
default: '.jpg'
},
acceptFileTypes: { acceptFileTypes: {
type: String, type: String,
required: false, required: false,
@ -953,6 +990,7 @@ export default {
isLoadingBlankPage: false, isLoadingBlankPage: false,
isSaving: false, isSaving: false,
isDetectingPageFields: false, isDetectingPageFields: false,
detectFieldsQueue: [],
detectingAnalyzingProgress: null, detectingAnalyzingProgress: null,
detectingFieldsAddedCount: null, detectingFieldsAddedCount: null,
selectedSubmitter: null, selectedSubmitter: null,
@ -963,7 +1001,8 @@ export default {
drawCustomField: null, drawCustomField: null,
drawOption: null, drawOption: null,
dragField: null, dragField: null,
isDragFile: false isDragFile: false,
isMathLoaded: false
} }
}, },
computed: { computed: {
@ -973,6 +1012,13 @@ export default {
fieldsDragFieldRef: () => ref(), fieldsDragFieldRef: () => ref(),
customDragFieldRef: () => ref(), customDragFieldRef: () => ref(),
selectedAreasRef: () => ref([]), selectedAreasRef: () => ref([]),
attachmentUuidsIndex () {
return this.template.schema.reduce((acc, e, index) => {
acc[e.attachment_uuid] = index
return acc
}, {})
},
language () { language () {
return this.locale.split('-')[0].toLowerCase() return this.locale.split('-')[0].toLowerCase()
}, },
@ -1003,6 +1049,8 @@ export default {
return isMobileSafariIos || /android|iphone|ipad/i.test(navigator.userAgent) return isMobileSafariIos || /android|iphone|ipad/i.test(navigator.userAgent)
}, },
defaultDateFormat () { defaultDateFormat () {
if (this.dateFormats.length) return this.dateFormats[0]
const isUsBrowser = Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') const isUsBrowser = Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US')
const isUsTimezone = new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).format(new Date()).match(/\s(?:CST|CDT|PST|PDT|EST|EDT)$/) const isUsTimezone = new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).format(new Date()).match(/\s(?:CST|CDT|PST|PDT|EST|EDT)$/)
@ -1044,6 +1092,43 @@ export default {
return map return map
}, },
fieldsUuidIndex () {
return this.template.fields.reduce((acc, f) => {
acc[f.uuid] = f
return acc
}, {})
},
conditionalFieldIndex () {
if (!this.inputMode) return {}
const cache = {}
return this.template.fields.reduce((acc, f) => {
acc[f.uuid] = this.checkFieldConditions(f, cache)
return acc
}, {})
},
formulaValuesIndex () {
const formulaFields = this.template.fields.filter((f) => f.preferences?.formula && f.type !== 'payment' && this.hasFormulaDependencyValue(f))
if (!formulaFields.length) return {}
if (!this.isMathLoaded) {
this.loadCalculator()
return {}
}
return formulaFields.reduce((acc, f) => {
if (this.conditionalFieldIndex[f.uuid] !== false) {
acc[f.uuid] = this.calculateFormula(f)
}
return acc
}, {})
},
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)
@ -1052,6 +1137,39 @@ export default {
selectedField () { selectedField () {
return this.template.fields.find((f) => f.areas?.includes(this.lastSelectedArea)) return this.template.fields.find((f) => f.areas?.includes(this.lastSelectedArea))
}, },
detectFieldsIndex () {
const submittersByUuid = {}
this.template.submitters.forEach((s) => {
submittersByUuid[s.uuid] = s
})
const index = {}
this.template.fields.forEach((f) => {
if (!f.name) return
const role = submittersByUuid[f.submitter_uuid]?.name
const key = [f.name, role].filter(Boolean).join(':').toLowerCase()
if (!index[key]) index[key] = f
})
return index
},
detectCustomFieldsIndex () {
const index = {}
;[...this.customFields, ...this.defaultRequiredFields, ...this.defaultFields].forEach((c) => {
if (!c.name) return
const key = [c.name, c.role].filter(Boolean).join(':').toLowerCase()
if (!index[key]) index[key] = c
})
return index
},
sortedDocuments () { sortedDocuments () {
return this.template.schema.map((item) => { return this.template.schema.map((item) => {
return this.template.documents.find(doc => doc.uuid === item.attachment_uuid) return this.template.documents.find(doc => doc.uuid === item.attachment_uuid)
@ -1147,6 +1265,132 @@ export default {
}, },
methods: { methods: {
toRaw, toRaw,
applyCustomFieldAttributes: Fields.methods.applyCustomFieldAttributes,
buildExistingFields: Fields.methods.buildExistingFields,
async loadCalculator () {
if (this.math) return
const { Calculator } = await import('../submission_form/calculator')
this.math = new Calculator()
this.isMathLoaded = true
},
optionValue (option, index) {
if (option.value) {
return option.value
} else {
return `${this.t('option')} ${index + 1}`
}
},
checkFieldConditions (field, cache = {}) {
const cacheKey = field.uuid || field.attachment_uuid
if (cache[cacheKey] !== undefined) {
return cache[cacheKey]
}
if (field.conditions?.length) {
const result = field.conditions.reduce((acc, cond) => {
if (cond.operation === 'or') {
acc.push(acc.pop() || this.checkFieldCondition(cond, cache))
} else {
acc.push(this.checkFieldCondition(cond, cache))
}
return acc
}, [])
cache[cacheKey] = !result.includes(false)
} else {
cache[cacheKey] = true
}
return cache[cacheKey]
},
checkFieldCondition (condition, cache = {}) {
const field = this.fieldsUuidIndex[condition.field_uuid]
if (['not_empty', 'checked', 'equal', 'contains', 'greater_than', 'less_than'].includes(condition.action) && field && !this.checkFieldConditions(field, cache)) {
return false
}
const defaultValue = !field || isEmpty(field.default_value) ? null : field.default_value
if (['empty', 'unchecked'].includes(condition.action)) {
return isEmpty(defaultValue)
} else if (['not_empty', 'checked'].includes(condition.action)) {
return !isEmpty(defaultValue)
} else if (field?.type === 'number' && ['equal', 'not_equal', 'greater_than', 'less_than'].includes(condition.action)) {
const value = 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) {
if (field.options) {
const option = field.options.find((o) => o.uuid === condition.value)
if (option) {
const values = [defaultValue].flat()
return values.includes(this.optionValue(option, field.options.indexOf(option)))
} else {
return false
}
} else {
return [defaultValue].flat().includes(condition.value)
}
} else if (['not_equal', 'does_not_contain'].includes(condition.action) && field) {
if (field.options) {
const option = field.options.find((o) => o.uuid === condition.value)
if (option) {
const values = [defaultValue].flat()
return !values.includes(this.optionValue(option, field.options.indexOf(option)))
} else {
return false
}
} else {
return false
}
} else {
return true
}
},
normalizeFormula (formula, depth = 0) {
if (depth > 10) return formula
return formula.replace(/{{(.*?)}}/g, (match, uuid) => {
if (this.fieldsUuidIndex[uuid]?.preferences?.formula) {
return `(${this.normalizeFormula(this.fieldsUuidIndex[uuid].preferences.formula, depth + 1)})`
} else {
return match
}
})
},
calculateFormula (field) {
const transformedFormula = this.normalizeFormula(field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => {
return this.fieldsUuidIndex[uuid]?.default_value || 0.0
})
return this.math.evaluate(transformedFormula.toLowerCase())
},
hasFormulaDependencyValue (field) {
const normalized = this.normalizeFormula(field.preferences.formula)
return [...normalized.matchAll(/{{(.*?)}}/g)].some(([, uuid]) => !isEmpty(this.fieldsUuidIndex[uuid]?.default_value))
},
addCustomField (field) { addCustomField (field) {
return this.$refs.fields.addCustomField(field) return this.$refs.fields.addCustomField(field)
}, },
@ -1426,32 +1670,25 @@ export default {
this.save() this.save()
} }
}, },
findFieldInsertIndex (field) { compareAreas (a, b) {
if (!field.areas?.length) return -1 const aAttIdx = this.attachmentUuidsIndex[a.attachment_uuid]
const bAttIdx = this.attachmentUuidsIndex[b.attachment_uuid]
const area = field.areas[0]
const attachmentUuidsIndex = this.template.schema.reduce((acc, e, index) => {
acc[e.attachment_uuid] = index
return acc if (aAttIdx !== bAttIdx) return aAttIdx - bAttIdx
}, {}) if (a.page !== b.page) return a.page - b.page
const compareAreas = (a, b) => { const aY = a.y + a.h
const aAttIdx = attachmentUuidsIndex[a.attachment_uuid] const bY = b.y + b.h
const bAttIdx = attachmentUuidsIndex[b.attachment_uuid]
if (aAttIdx !== bAttIdx) return aAttIdx - bAttIdx if (Math.abs(aY - bY) < 0.01) return a.x - b.x
if (a.page !== b.page) return a.page - b.page if (a.h < b.h ? a.y >= b.y && aY <= bY : b.y >= a.y && bY <= aY) return a.x - b.x
const aY = a.y + a.h return aY - bY
const bY = b.y + b.h },
findFieldInsertIndex (field) {
if (Math.abs(aY - bY) < 0.01) return a.x - b.x if (!field.areas?.length) return -1
if (a.h < b.h ? a.y >= b.y && aY <= bY : b.y >= a.y && bY <= aY) return a.x - b.x
return aY - bY const area = field.areas[0]
}
let closestBeforeIndex = -1 let closestBeforeIndex = -1
let closestBeforeArea = null let closestBeforeArea = null
@ -1461,15 +1698,15 @@ export default {
this.template.fields.forEach((f, index) => { this.template.fields.forEach((f, index) => {
if (f.submitter_uuid === field.submitter_uuid) { if (f.submitter_uuid === field.submitter_uuid) {
(f.areas || []).forEach((a) => { (f.areas || []).forEach((a) => {
const cmp = compareAreas(a, area) const cmp = this.compareAreas(a, area)
if (cmp < 0) { if (cmp < 0) {
if (!closestBeforeArea || (compareAreas(a, closestBeforeArea) > 0 && closestBeforeIndex < index)) { if (!closestBeforeArea || (this.compareAreas(a, closestBeforeArea) > 0 && closestBeforeIndex < index)) {
closestBeforeIndex = index closestBeforeIndex = index
closestBeforeArea = a closestBeforeArea = a
} }
} else { } else {
if (!closestAfterArea || (compareAreas(a, closestAfterArea) < 0 && closestAfterIndex > index)) { if (!closestAfterArea || (this.compareAreas(a, closestAfterArea) < 0 && closestAfterIndex > index)) {
closestAfterIndex = index closestAfterIndex = index
closestAfterArea = a closestAfterArea = a
} }
@ -1492,6 +1729,41 @@ export default {
this.template.fields.push(field) this.template.fields.push(field)
} }
}, },
insertArea (field, area) {
field.areas ||= []
const insertIndex = field.areas.findIndex((a) => this.compareAreas(a, area) > 0)
if (insertIndex === -1) {
field.areas.push(area)
} else {
field.areas.splice(insertIndex, 0, area)
}
},
insertDetectedField (field) {
if (!this.withDetectExistingFields || !field.name) {
this.insertField(field)
return
}
const role = this.template.submitters.find((s) => s.uuid === field.submitter_uuid)?.name
const nameKey = field.name.toLowerCase()
const indexKey = [field.name, role].filter(Boolean).join(':').toLowerCase()
const existingField = this.detectFieldsIndex[indexKey]
if (existingField) {
existingField.areas = existingField.areas || []
field.areas.forEach((area) => this.insertArea(existingField, area))
} else {
const customField = this.detectCustomFieldsIndex[indexKey] || this.detectCustomFieldsIndex[nameKey]
if (customField) this.applyCustomFieldAttributes(field, customField)
this.insertField(field)
}
},
closeDropdown () { closeDropdown () {
document.activeElement.blur() document.activeElement.blur()
}, },
@ -1988,7 +2260,7 @@ export default {
fieldUuidIndex[field.uuid] = newField fieldUuidIndex[field.uuid] = newField
newField.areas.push(newArea) this.insertArea(newField, newArea)
newAreas.push(newArea) newAreas.push(newArea)
if (['radio', 'multiple'].includes(field.type) && field.options?.length) { if (['radio', 'multiple'].includes(field.type) && field.options?.length) {
@ -2101,17 +2373,7 @@ export default {
area.y -= area.h / 2 area.y -= area.h / 2
} }
this.drawField.areas ||= [] this.insertArea(this.drawField, area)
const insertBeforeAreaIndex = this.drawField.areas.findIndex((a) => {
return a.attachment_uuid === area.attachment_uuid && a.page > area.page
})
if (insertBeforeAreaIndex !== -1) {
this.drawField.areas.splice(insertBeforeAreaIndex, 0, area)
} else {
this.drawField.areas.push(area)
}
if (this.template.fields.indexOf(this.drawField) === -1) { if (this.template.fields.indexOf(this.drawField) === -1) {
this.insertField(this.drawField) this.insertField(this.drawField)
@ -2252,9 +2514,7 @@ export default {
delete field.height delete field.height
} }
field.areas ||= [] this.insertArea(field, fieldArea)
field.areas.push(fieldArea)
if (this.selectedAreasRef.value.length < 2) { if (this.selectedAreasRef.value.length < 2) {
this.selectedAreasRef.value = [fieldArea] this.selectedAreasRef.value = [fieldArea]
@ -2324,7 +2584,7 @@ export default {
} }
} }
field.areas.push(fieldArea) this.insertArea(field, fieldArea)
}) })
} else { } else {
const fieldArea = { const fieldArea = {
@ -2783,6 +3043,12 @@ export default {
}) })
}, },
detectFieldsForPage ({ page, attachmentUuid }) { detectFieldsForPage ({ page, attachmentUuid }) {
if (this.isDetectingPageFields) {
this.detectFieldsQueue.push({ page, attachmentUuid })
return
}
this.isDetectingPageFields = true this.isDetectingPageFields = true
this.detectingAnalyzingProgress = null this.detectingAnalyzingProgress = null
this.detectingFieldsAddedCount = null this.detectingFieldsAddedCount = null
@ -2821,7 +3087,11 @@ export default {
this.baseFetch(`/templates/${this.template.id}/detect_fields`, { this.baseFetch(`/templates/${this.template.id}/detect_fields`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ attachment_uuid: attachmentUuid, page }) body: JSON.stringify({
attachment_uuid: attachmentUuid,
page,
...(this.withDetectExistingFields ? { fields: this.buildExistingFields() } : {})
})
}).then(async (response) => { }).then(async (response) => {
const reader = response.body.getReader() const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8') const decoder = new TextDecoder('utf-8')
@ -2850,7 +3120,7 @@ export default {
if (!f.submitter_uuid) { if (!f.submitter_uuid) {
f.submitter_uuid = this.template.submitters[0].uuid f.submitter_uuid = this.template.submitters[0].uuid
} }
this.insertField(f) this.insertDetectedField(f)
}) })
totalFieldsAdded += errorFields.length totalFieldsAdded += errorFields.length
@ -2879,7 +3149,7 @@ export default {
const nonOverlappingFields = filterNonOverlappingFields(finalFields) const nonOverlappingFields = filterNonOverlappingFields(finalFields)
nonOverlappingFields.forEach((f) => this.insertField(f)) nonOverlappingFields.forEach((f) => this.insertDetectedField(f))
totalFieldsAdded += nonOverlappingFields.length totalFieldsAdded += nonOverlappingFields.length
if (nonOverlappingFields.length) { if (nonOverlappingFields.length) {
@ -2917,7 +3187,7 @@ export default {
const nonOverlappingFields = filterNonOverlappingFields(finalFields) const nonOverlappingFields = filterNonOverlappingFields(finalFields)
nonOverlappingFields.forEach((f) => this.insertField(f)) nonOverlappingFields.forEach((f) => this.insertDetectedField(f))
totalFieldsAdded += nonOverlappingFields.length totalFieldsAdded += nonOverlappingFields.length
if (nonOverlappingFields.length) { if (nonOverlappingFields.length) {
@ -2935,7 +3205,7 @@ export default {
const nonOverlappingFields = filterNonOverlappingFields(finalFields) const nonOverlappingFields = filterNonOverlappingFields(finalFields)
nonOverlappingFields.forEach((f) => this.insertField(f)) nonOverlappingFields.forEach((f) => this.insertDetectedField(f))
totalFieldsAdded += nonOverlappingFields.length totalFieldsAdded += nonOverlappingFields.length
if (nonOverlappingFields.length) { if (nonOverlappingFields.length) {
@ -2968,6 +3238,10 @@ export default {
setTimeout(() => { setTimeout(() => {
this.detectingFieldsAddedCount = null this.detectingFieldsAddedCount = null
}, 1000) }, 1000)
if (this.detectFieldsQueue.length) {
this.detectFieldsForPage(this.detectFieldsQueue.shift())
}
}) })
}, },
save ({ force } = { force: false }) { save ({ force } = { force: false }) {

@ -5,6 +5,8 @@
:key="image.id" :key="image.id"
:ref="setPageRefs" :ref="setPageRefs"
:input-mode="inputMode" :input-mode="inputMode"
:conditional-field-index="conditionalFieldIndex"
:formula-values-index="formulaValuesIndex"
:number="index" :number="index"
:editable="editable" :editable="editable"
:data-page="index" :data-page="index"
@ -64,6 +66,16 @@ export default {
required: false, required: false,
default: false default: false
}, },
conditionalFieldIndex: {
type: Object,
required: false,
default: () => ({})
},
formulaValuesIndex: {
type: Object,
required: false,
default: () => ({})
},
areasIndex: { areasIndex: {
type: Object, type: Object,
required: false, required: false,
@ -138,6 +150,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
pagePreviewFormat: {
type: String,
required: false,
default: '.jpg'
},
withFieldsDetection: { withFieldsDetection: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -168,7 +185,7 @@ export default {
return this.previewImagesIndex[i] || reactive({ return this.previewImagesIndex[i] || reactive({
metadata: { ...lazyloadMetadata }, metadata: { ...lazyloadMetadata },
id: Math.random().toString(), id: Math.random().toString(),
url: this.basePreviewUrl + `/preview/${this.document.signed_key || this.document.signed_uuid || this.document.uuid}/${i}.jpg` url: this.basePreviewUrl + `/preview/${this.document.signed_key || this.document.signed_uuid || this.document.uuid}/${i}${this.pagePreviewFormat}`
}) })
}) })
}, },

@ -119,7 +119,7 @@
@change="[schema.format = $event.target.value, save()]" @change="[schema.format = $event.target.value, save()]"
> >
<option <option
v-for="format in dateFormats" v-for="format in availableDateFormats"
:key="format" :key="format"
:value="format" :value="format"
>{{ formatDate(new Date(), format) }}</option> >{{ formatDate(new Date(), format) }}</option>
@ -248,7 +248,7 @@ export default {
FieldType, FieldType,
IconSettings IconSettings
}, },
inject: ['t', 'save', 'backgroundColor'], inject: ['t', 'save', 'backgroundColor', 'dateFormats'],
provide () { provide () {
return { return {
fieldTypes: ['text', 'number', 'date', 'checkbox', 'radio', 'select'] fieldTypes: ['text', 'number', 'date', 'checkbox', 'radio', 'select']
@ -318,20 +318,23 @@ export default {
'space' 'space'
] ]
}, },
dateFormats () { availableDateFormats () {
const formats = [ const formats = this.dateFormats.length
'MM/DD/YYYY', ? [...this.dateFormats]
'DD/MM/YYYY', : [
'YYYY-MM-DD', 'MM/DD/YYYY',
'DD-MM-YYYY', 'DD/MM/YYYY',
'DD.MM.YYYY', 'YYYY-MM-DD',
'MMM D, YYYY', 'DD-MM-YYYY',
'MMMM D, YYYY', 'DD.MM.YYYY',
'D MMM YYYY', 'MMM D, YYYY',
'D MMMM YYYY' 'MMMM D, YYYY',
] 'MMMM YYYY',
'D MMM YYYY',
'D MMMM YYYY'
]
if (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko')) { if (!this.dateFormats.length && (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko'))) {
formats.push('YYYY년 MM월 DD일') formats.push('YYYY년 MM월 DD일')
} }
@ -401,18 +404,47 @@ export default {
formatDate (date, format) { formatDate (date, format) {
const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' } const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
const dayFormats = { D: 'numeric', DD: '2-digit' } const dayFormats = { D: 'numeric', DD: '2-digit' }
const yearFormats = { YYYY: 'numeric', YY: '2-digit' } const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' }
const hourFormats = { H: 'numeric', HH: '2-digit', h: 'numeric', hh: '2-digit' }
const minuteFormats = { m: 'numeric', mm: '2-digit' }
const secondFormats = { s: 'numeric', ss: '2-digit' }
const parts = new Intl.DateTimeFormat([], { const opts = {
day: dayFormats[format.match(/D+/)], day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)], month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)] year: yearFormats[format.match(/Y+/)]
}).formatToParts(date) }
if (format.match(/H+/)) { opts.hour = hourFormats[format.match(/H+/)[0]]; opts.hour12 = false }
if (format.match(/h+/)) { opts.hour = hourFormats[format.match(/h+/)[0]]; opts.hour12 = true }
if (/[Aa]/.test(format) && opts.hour12 === undefined) opts.hour12 = true
if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]]
if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]]
if (/z/.test(format)) opts.timeZoneName = 'short'
const partTypes = {
M: 'month',
D: 'day',
Y: 'year',
H: 'hour',
h: 'hour',
m: 'minute',
s: 'second',
z: 'timeZoneName',
A: 'dayPeriod',
a: 'dayPeriod'
}
return format const parts = new Intl.DateTimeFormat([], opts).formatToParts(date)
.replace(/D+/, parts.find((p) => p.type === 'day').value)
.replace(/M+/, parts.find((p) => p.type === 'month').value) return format.replace(/MMMM|MMM|MM|M|DD|D|YYYY|YYY|YY|HH|hh|H|h|mm|m|ss|s|A|a|z/g, (token) => {
.replace(/Y+/, parts.find((p) => p.type === 'year').value) const value = parts.find((p) => p.type === partTypes[token[0]])?.value
if (token === 'A') return (value || '').toUpperCase()
if (token === 'a') return (value || '').toLowerCase()
return value
})
}, },
closeDropdown () { closeDropdown () {
this.$el.getRootNode().activeElement.blur() this.$el.getRootNode().activeElement.blur()

@ -345,7 +345,7 @@ export default {
IconMathFunction, IconMathFunction,
FieldType FieldType
}, },
inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale', 'getFieldTypeIndex'], inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale', 'getFieldTypeIndex', 'dateFormats'],
props: { props: {
field: { field: {
type: Object, type: Object,
@ -428,7 +428,8 @@ export default {
if (this.field.type === 'date') { if (this.field.type === 'date') {
this.field.preferences.format ||= this.field.preferences.format ||=
({ 'de-DE': 'DD.MM.YYYY' }[this.locale] || ((Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') || new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).format(new Date()).match(/\s(?:CST|CDT|PST|PDT|EST|EDT)$/)) ? 'MM/DD/YYYY' : 'DD/MM/YYYY')) this.dateFormats[0] ||
({ 'de-DE': 'DD.MM.YYYY' }[this.locale] || ((Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') || new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).format(new Date()).match(/\s(?:CST|CDT|PST|PDT|EST|EDT)$/)) ? 'MM/DD/YYYY' : 'DD/MM/YYYY'))
} }
}, },
methods: { methods: {

@ -514,7 +514,7 @@ export default {
ContextSubmenu, ContextSubmenu,
ContextModal ContextModal
}, },
inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies'], inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies', 'dateFormats'],
props: { props: {
contextMenu: { contextMenu: {
type: Object, type: Object,
@ -580,7 +580,7 @@ export default {
fieldNames: FieldType.computed.fieldNames, fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels, fieldLabels: FieldType.computed.fieldLabels,
validationOptions: FieldSettings.computed.validations, validationOptions: FieldSettings.computed.validations,
dateFormats: FieldSettings.computed.dateFormats, availableDateFormats: FieldSettings.computed.availableDateFormats,
numberFormats: FieldSettings.computed.numberFormats, numberFormats: FieldSettings.computed.numberFormats,
prefillableFieldTypes: FieldSettings.computed.prefillableFieldTypes, prefillableFieldTypes: FieldSettings.computed.prefillableFieldTypes,
verificationMethods: FieldSettings.computed.verificationMethods, verificationMethods: FieldSettings.computed.verificationMethods,
@ -686,7 +686,7 @@ export default {
}, },
formatOptions () { formatOptions () {
switch (this.field.type) { switch (this.field.type) {
case 'date': return this.dateFormats.map(f => ({ value: f, label: this.formatDate(new Date(), f) })) case 'date': return this.availableDateFormats.map(f => ({ value: f, label: this.formatDate(new Date(), f) }))
case 'number': return this.numberFormats.map(f => ({ value: f, label: this.formatNumber(123456789.567, f) })) case 'number': return this.numberFormats.map(f => ({ value: f, label: this.formatNumber(123456789.567, f) }))
case 'signature': return this.signatureFormats.map(f => ({ value: f, label: this.t(f) })) case 'signature': return this.signatureFormats.map(f => ({ value: f, label: this.t(f) }))
default: return [] default: return []

@ -290,7 +290,7 @@
@change="$emit('save')" @change="$emit('save')"
> >
<option <option
v-for="format in dateFormats" v-for="format in availableDateFormats"
:key="format" :key="format"
:value="format" :value="format"
> >
@ -610,7 +610,7 @@ export default {
IconTypography, IconTypography,
IconX IconX
}, },
inject: ['template', 't'], inject: ['template', 't', 'dateFormats'],
props: { props: {
field: { field: {
type: Object, type: Object,
@ -701,20 +701,23 @@ export default {
'space' 'space'
] ]
}, },
dateFormats () { availableDateFormats () {
const formats = [ const formats = this.dateFormats.length
'MM/DD/YYYY', ? [...this.dateFormats]
'DD/MM/YYYY', : [
'YYYY-MM-DD', 'MM/DD/YYYY',
'DD-MM-YYYY', 'DD/MM/YYYY',
'DD.MM.YYYY', 'YYYY-MM-DD',
'MMM D, YYYY', 'DD-MM-YYYY',
'MMMM D, YYYY', 'DD.MM.YYYY',
'D MMM YYYY', 'MMM D, YYYY',
'D MMMM YYYY' 'MMMM D, YYYY',
] 'MMMM YYYY',
'D MMM YYYY',
'D MMMM YYYY'
]
if (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko')) { if (!this.dateFormats.length && (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko'))) {
formats.push('YYYY년 MM월 DD일') formats.push('YYYY년 MM월 DD일')
} }
@ -783,17 +786,27 @@ export default {
}, },
copyToAllPages (field) { copyToAllPages (field) {
const areaString = JSON.stringify(field.areas[0]) const areaString = JSON.stringify(field.areas[0])
const newAreas = []
const existingAreasIndex = field.areas.reduce((acc, area) => {
acc[`${area.attachment_uuid}-${area.page}`] = area
return acc
}, {})
this.template.schema.forEach((item) => {
const attachment = this.template.documents.find((d) => d.uuid === item.attachment_uuid)
this.template.documents.forEach((attachment) => {
const numberOfPages = attachment.metadata?.pdf?.number_of_pages || attachment.preview_images.length const numberOfPages = attachment.metadata?.pdf?.number_of_pages || attachment.preview_images.length
for (let page = 0; page <= numberOfPages - 1; page++) { for (let page = 0; page <= numberOfPages - 1; page++) {
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === page)) { const existing = existingAreasIndex[`${attachment.uuid}-${page}`]
field.areas.push({ ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page })
} newAreas.push(existing || { ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page })
} }
}) })
field.areas = newAreas
this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1]) this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1])
this.$emit('save') this.$emit('save')
@ -819,33 +832,49 @@ export default {
return pattern?.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups || null return pattern?.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups || null
}, },
formatDate (date, format) { formatDate (date, format) {
const monthFormats = { const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
M: 'numeric', const dayFormats = { D: 'numeric', DD: '2-digit' }
MM: '2-digit', const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' }
MMM: 'short', const hourFormats = { H: 'numeric', HH: '2-digit', h: 'numeric', hh: '2-digit' }
MMMM: 'long' const minuteFormats = { m: 'numeric', mm: '2-digit' }
} const secondFormats = { s: 'numeric', ss: '2-digit' }
const dayFormats = { const opts = {
D: 'numeric', day: dayFormats[format.match(/D+/)],
DD: '2-digit' month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)]
} }
const yearFormats = { if (format.match(/H+/)) { opts.hour = hourFormats[format.match(/H+/)[0]]; opts.hour12 = false }
YYYY: 'numeric', if (format.match(/h+/)) { opts.hour = hourFormats[format.match(/h+/)[0]]; opts.hour12 = true }
YY: '2-digit' if (/[Aa]/.test(format) && opts.hour12 === undefined) opts.hour12 = true
if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]]
if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]]
if (/z/.test(format)) opts.timeZoneName = 'short'
const partTypes = {
M: 'month',
D: 'day',
Y: 'year',
H: 'hour',
h: 'hour',
m: 'minute',
s: 'second',
z: 'timeZoneName',
A: 'dayPeriod',
a: 'dayPeriod'
} }
const parts = new Intl.DateTimeFormat([], { const parts = new Intl.DateTimeFormat([], opts).formatToParts(date)
day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)], return format.replace(/MMMM|MMM|MM|M|DD|D|YYYY|YYY|YY|HH|hh|H|h|mm|m|ss|s|A|a|z/g, (token) => {
year: yearFormats[format.match(/Y+/)] const value = parts.find((p) => p.type === partTypes[token[0]])?.value
}).formatToParts(date)
return format if (token === 'A') return (value || '').toUpperCase()
.replace(/D+/, parts.find((p) => p.type === 'day').value) if (token === 'a') return (value || '').toLowerCase()
.replace(/M+/, parts.find((p) => p.type === 'month').value)
.replace(/Y+/, parts.find((p) => p.type === 'year').value) return value
})
} }
} }
} }

@ -65,13 +65,33 @@
@set-draw="$emit('set-draw', $event)" @set-draw="$emit('set-draw', $event)"
/> />
</div> </div>
<div v-if="!isShowVariables && submitterDefaultFields.length && editable"> <div
<hr class="mb-2"> v-if="editable && withCustomFieldsTab"
class="tabs w-full mb-1.5 custom-fields-tabs"
>
<a
class="tab tab-bordered w-1/2 border-base-300 custom-fields-tab"
:class="{ 'tab-active': !showCustomTab }"
:style="{ '--tab-border': showCustomTab ? '0px' : '0.5px' }"
@click="setFieldsTab('default')"
>{{ t('default') }}</a>
<a
class="tab tab-bordered w-1/2 border-base-300 custom-fields-tab"
:class="{ 'tab-active': showCustomTab }"
:style="{ '--tab-border': showCustomTab ? '0.5px' : '0px' }"
@click="setFieldsTab('custom')"
>{{ t('custom') }}</a>
</div>
<div v-if="!isShowVariables && submitterDefaultFields.length && editable && (!withCustomFieldsTab || showCustomTab)">
<hr
v-if="!withCustomFieldsTab"
class="mb-1.5"
>
<template v-if="isShowFieldSearch"> <template v-if="isShowFieldSearch">
<input <input
v-model="defaultFieldsSearch" v-model="defaultFieldsSearch"
:placeholder="t('search_field')" :placeholder="t('search_field')"
class="input input-ghost input-xs px-0 text-base mb-2 !outline-0 !rounded bg-transparent w-full" class="input input-ghost input-xs px-0 text-base mb-1.5 !outline-0 !rounded bg-transparent w-full"
> >
<hr class="mb-2"> <hr class="mb-2">
</template> </template>
@ -218,7 +238,7 @@
</div> </div>
</div> </div>
<div <div
v-if="!isShowVariables && editable && !onlyDefinedFields && (!showCustomTab || (!customFields.length && !newCustomField))" v-if="!isShowVariables && editable && !onlyDefinedFields && (!showCustomTab || (!customFields.length && !newCustomField)) && (!withCustomFieldsTab || !showCustomTab)"
id="field-types-grid" id="field-types-grid"
class="grid grid-cols-3 gap-1 pb-2 fields-grid" class="grid grid-cols-3 gap-1 pb-2 fields-grid"
> >
@ -418,6 +438,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
withCustomFieldsTab: {
type: Boolean,
required: false,
default: false
},
withFieldsSearch: { withFieldsSearch: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -428,6 +453,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
withDetectExistingFields: {
type: Boolean,
required: false,
default: false
},
withSignatureId: { withSignatureId: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -495,6 +525,11 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
detectCustomFieldsIndex: {
type: Object,
required: false,
default: () => ({})
},
showTourStartForm: { showTourStartForm: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -557,7 +592,8 @@ export default {
}, },
submitterDefaultFields () { submitterDefaultFields () {
return this.defaultFields.filter((f) => { return this.defaultFields.filter((f) => {
return !this.submitterFields.find((field) => field.name === f.name) && (!f.role || f.role === this.selectedSubmitter.name) return (this.withCustomFieldsTab ? true : !this.submitterFields.find((field) => field.name === f.name)) &&
(!f.role || f.role === this.selectedSubmitter.name)
}) })
}, },
filteredSubmitterDefaultFields () { filteredSubmitterDefaultFields () {
@ -656,6 +692,78 @@ export default {
this.customFields.splice(0, this.customFields.length, ...fields) this.customFields.splice(0, this.customFields.length, ...fields)
}) })
}, },
buildExistingFields () {
const existing = []
const seen = new Set()
const submittersByUuid = this.template.submitters.reduce((acc, s) => {
acc[s.uuid] = s
return acc
}, {})
const add = (field, role) => {
if (!field?.name) return
const key = field.name.toLowerCase() + ':' + (role || '').toLowerCase()
if (seen.has(key)) return
seen.add(key)
const item = { name: field.name, type: field.type || 'text' }
if (role) item.role = role
const optionValues = Array.isArray(field.options)
? field.options.map((o) => (typeof o === 'string' ? o : o?.value)).filter(Boolean)
: []
if (optionValues.length) item.options = optionValues
existing.push(item)
}
this.template.fields.forEach((f) => add(f, submittersByUuid[f.submitter_uuid]?.name))
this.defaultRequiredFields.forEach((f) => add(f, f.role))
this.defaultFields.forEach((f) => add(f, f.role))
this.customFields.forEach((f) => add(f, f.role))
return existing
},
enrichDetectedField (field) {
if (!this.withDetectExistingFields || !field.name) return field
const role = this.template.submitters.find((s) => s.uuid === field.submitter_uuid)?.name
const nameKey = field.name.toLowerCase()
const indexKey = [field.name, role].filter(Boolean).join(':').toLowerCase()
const customField = this.detectCustomFieldsIndex[indexKey] || this.detectCustomFieldsIndex[nameKey]
if (customField) this.applyCustomFieldAttributes(field, customField)
return field
},
applyCustomFieldAttributes (field, customField) {
const skipKeys = new Set(['uuid', 'areas', 'submitter_uuid', 'conditions', 'prefillable', 'role'])
Object.entries(customField).forEach(([key, value]) => {
if (skipKeys.has(key)) return
if (value === null || value === undefined) return
if (key === 'options') {
if (Array.isArray(value) && !Array.isArray(field.options)) {
field.options = value.map((o) => (
typeof o === 'string' ? { value: o, uuid: v4() } : { ...o, uuid: v4() }
))
}
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
field[key] = JSON.parse(JSON.stringify(value))
} else {
field[key] = value
}
})
},
detectFields () { detectFields () {
const fields = [] const fields = []
@ -665,7 +773,10 @@ export default {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} },
...(this.withDetectExistingFields
? { body: JSON.stringify({ fields: this.buildExistingFields() }) }
: {})
}).then(async (response) => { }).then(async (response) => {
const reader = response.body.getReader() const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8') const decoder = new TextDecoder('utf-8')
@ -687,7 +798,7 @@ export default {
if (data.error) { if (data.error) {
if ((data.fields || fields).length) { if ((data.fields || fields).length) {
this.template.fields = data.fields || fields this.template.fields = (data.fields || fields).map((f) => this.enrichDetectedField(f))
this.save() this.save()
} else { } else {
@ -705,7 +816,7 @@ export default {
this.$emit('select-submitter', this.template.submitters[0]) this.$emit('select-submitter', this.template.submitters[0])
} }
this.template.fields = data.fields || fields this.template.fields = (data.fields || fields).map((f) => this.enrichDetectedField(f))
this.save() this.save()

@ -70,6 +70,7 @@ const en = {
sign_yourself: 'Sign Yourself', sign_yourself: 'Sign Yourself',
set_signing_date: 'Set signing date', set_signing_date: 'Set signing date',
signing_date: 'Signing Date', signing_date: 'Signing Date',
signing_date_and_time: 'Signing Date and Time',
send: 'Send', send: 'Send',
remove: 'Remove', remove: 'Remove',
edit: 'Edit', edit: 'Edit',
@ -271,6 +272,7 @@ const es = {
with_logo: 'Con logotipo', with_logo: 'Con logotipo',
description: 'Descripción', description: 'Descripción',
signing_date: 'Fecha de Firma', signing_date: 'Fecha de Firma',
signing_date_and_time: 'Fecha y Hora de Firma',
display_title: 'Título de visualización', display_title: 'Título de visualización',
unchecked: 'No marcado', unchecked: 'No marcado',
price: 'Precio', price: 'Precio',
@ -508,6 +510,7 @@ const it = {
sign_yourself: 'Firma te stesso', sign_yourself: 'Firma te stesso',
set_signing_date: 'Imposta data di firma', set_signing_date: 'Imposta data di firma',
signing_date: 'Data di firma', signing_date: 'Data di firma',
signing_date_and_time: 'Data e ora di firma',
send: 'Invia', send: 'Invia',
remove: 'Rimuovi', remove: 'Rimuovi',
edit: 'Modifica', edit: 'Modifica',
@ -710,6 +713,7 @@ const pt = {
description: 'Descrição', description: 'Descrição',
display_title: 'Título de exibição', display_title: 'Título de exibição',
signing_date: 'Data da Assinatura', signing_date: 'Data da Assinatura',
signing_date_and_time: 'Data e Hora da Assinatura',
unchecked: 'Não marcado', unchecked: 'Não marcado',
price: 'Preço', price: 'Preço',
equal: 'Igual', equal: 'Igual',
@ -946,6 +950,7 @@ const fr = {
sign_yourself: 'Signer vous-même', sign_yourself: 'Signer vous-même',
set_signing_date: 'Définir la date de signature', set_signing_date: 'Définir la date de signature',
signing_date: 'Date de signature', signing_date: 'Date de signature',
signing_date_and_time: 'Date et heure de signature',
send: 'Envoyer', send: 'Envoyer',
remove: 'Supprimer', remove: 'Supprimer',
edit: 'Modifier', edit: 'Modifier',
@ -1165,6 +1170,7 @@ const de = {
sign_yourself: 'Selbst unterschreiben', sign_yourself: 'Selbst unterschreiben',
set_signing_date: 'Unterzeichnungsdatum festlegen', set_signing_date: 'Unterzeichnungsdatum festlegen',
signing_date: 'Unterzeichnungsdatum', signing_date: 'Unterzeichnungsdatum',
signing_date_and_time: 'Unterzeichnungsdatum und -uhrzeit',
send: 'Senden', send: 'Senden',
remove: 'Entfernen', remove: 'Entfernen',
edit: 'Bearbeiten', edit: 'Bearbeiten',
@ -1384,6 +1390,7 @@ const nl = {
sign_yourself: 'Zelf ondertekenen', sign_yourself: 'Zelf ondertekenen',
set_signing_date: 'Ondertekeningsdatum instellen', set_signing_date: 'Ondertekeningsdatum instellen',
signing_date: 'Ondertekeningsdatum', signing_date: 'Ondertekeningsdatum',
signing_date_and_time: 'Ondertekeningsdatum en -tijd',
send: 'Verzenden', send: 'Verzenden',
remove: 'Verwijderen', remove: 'Verwijderen',
edit: 'Bewerken', edit: 'Bewerken',

@ -37,6 +37,8 @@
:ref="setAreaRefs" :ref="setAreaRefs"
:area="item.area" :area="item.area"
:input-mode="inputMode" :input-mode="inputMode"
:conditional-field-index="conditionalFieldIndex"
:formula-values-index="formulaValuesIndex"
:page-width="width" :page-width="width"
:page-height="height" :page-height="height"
:field="item.field" :field="item.field"
@ -180,6 +182,16 @@ export default {
required: false, required: false,
default: false default: false
}, },
conditionalFieldIndex: {
type: Object,
required: false,
default: () => ({})
},
formulaValuesIndex: {
type: Object,
required: false,
default: () => ({})
},
defaultFields: { defaultFields: {
type: Array, type: Array,
required: false, required: false,

@ -143,8 +143,8 @@
> >
<input <input
type="hidden" type="hidden"
name="state" name="oauth_data"
:value="oauthState" :value="oauthData"
autocomplete="off" autocomplete="off"
> >
<input <input
@ -334,7 +334,7 @@ export default {
authenticityToken () { authenticityToken () {
return document.querySelector('meta[name="csrf-token"]')?.content return document.querySelector('meta[name="csrf-token"]')?.content
}, },
oauthState () { oauthData () {
const params = new URLSearchParams('') const params = new URLSearchParams('')
params.set('redir', document.location.href) params.set('redir', document.location.href)

@ -228,7 +228,7 @@ export default {
'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/drive.file' 'https://www.googleapis.com/auth/drive.file'
].join(' '), ].join(' '),
state: new URLSearchParams({ oauth_data: new URLSearchParams({
redir: `/templates/${this.templateId}/edit?google_drive_open=1` redir: `/templates/${this.templateId}/edit?google_drive_open=1`
}).toString() }).toString()
} }

@ -35,6 +35,7 @@ class Account < ApplicationRecord
has_many :submitters, dependent: :destroy has_many :submitters, dependent: :destroy
has_many :account_linked_accounts, dependent: :destroy has_many :account_linked_accounts, dependent: :destroy
has_many :email_events, dependent: :destroy has_many :email_events, dependent: :destroy
has_many :document_metadata, class_name: 'DocumentMetadata', dependent: :destroy
has_many :webhook_urls, dependent: :destroy has_many :webhook_urls, dependent: :destroy
has_many :webhook_events, dependent: nil has_many :webhook_events, dependent: nil
has_many :account_accesses, dependent: :destroy has_many :account_accesses, dependent: :destroy

@ -43,6 +43,7 @@ class AccountConfig < ApplicationRecord
DOWNLOAD_LINKS_EXPIRE_KEY = 'download_links_expire' DOWNLOAD_LINKS_EXPIRE_KEY = 'download_links_expire'
FORCE_SSO_AUTH_KEY = 'force_sso_auth' FORCE_SSO_AUTH_KEY = 'force_sso_auth'
FLATTEN_RESULT_PDF_KEY = 'flatten_result_pdf' FLATTEN_RESULT_PDF_KEY = 'flatten_result_pdf'
ROTATE_INCREMENTAL_PDF_KEY = 'rotate_incremental_pdf'
WITH_SIGNATURE_ID = 'with_signature_id' WITH_SIGNATURE_ID = 'with_signature_id'
WITH_FILE_LINKS_KEY = 'with_file_links' WITH_FILE_LINKS_KEY = 'with_file_links'
WITH_SIGNATURE_ID_REASON_KEY = 'with_signature_id_reason' WITH_SIGNATURE_ID_REASON_KEY = 'with_signature_id_reason'

@ -101,13 +101,18 @@ class Submission < ApplicationRecord
where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]) where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:declined_at].not_eq(nil))).select(1).arel.exists) .and(Submitter.arel_table[:declined_at].not_eq(nil))).select(1).arel.exists)
} }
scope :expired, -> { pending.where(expire_at: ..Time.current) } scope :expired, lambda {
where(expire_at: ..Time.current)
.where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
}
enum :source, { enum :source, {
invite: 'invite', invite: 'invite',
bulk: 'bulk', bulk: 'bulk',
api: 'api', api: 'api',
embed: 'embed', embed: 'embed',
mcp: 'mcp',
link: 'link' link: 'link'
}, scope: false, prefix: true }, scope: false, prefix: true

@ -18,24 +18,7 @@
<%= f.button button_title(title: t(:sign_in), disabled_with: t(:signing_in)), class: 'base-button' %> <%= f.button button_title(title: t(:sign_in), disabled_with: t(:signing_in)), class: 'base-button' %>
</div> </div>
<% end %> <% end %>
<% if devise_mapping.omniauthable? %> <%= render 'omniauthable' %>
<div class="space-y-4">
<% if User.omniauth_providers.include?(:google_oauth2) %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
<%= f.button button_title(title: t('sign_in_with_google'), icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
<% end %>
<% end %>
<% if User.omniauth_providers.include?(:microsoft_office365) %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %>
<%= f.button button_title(title: t('sign_in_with_microsoft'), icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %>
<% end %>
<% end %>
</div>
<% end %>
<%= render 'extra_links' %> <%= render 'extra_links' %>
<%= render 'devise/shared/links' %> <%= render 'devise/shared/links' %>
</div> </div>

@ -20,9 +20,10 @@
</head> </head>
<body> <body>
<% if params[:modal].present? %> <% if params[:modal].present? %>
<% url_params = Rails.application.routes.recognize_path(params[:modal], method: :get) %> <% modal_uri = Addressable::URI.parse(params[:modal]) %>
<% url_params = Rails.application.routes.recognize_path(modal_uri.path, method: :get) %>
<% if url_params[:action] == 'new' %> <% if url_params[:action] == 'new' %>
<open-modal src="<%= url_for(url_params) %>"></open-modal> <open-modal src="<%= url_for(**url_params, params: modal_uri.query_values) %>"></open-modal>
<% end %> <% end %>
<% end %> <% end %>
<turbo-frame id="modal"></turbo-frame> <turbo-frame id="modal"></turbo-frame>

@ -1,6 +1,6 @@
<a target="_blank" href="<%= Docuseal::GITHUB_URL %>" rel="noopener noreferrer nofollow" class="relative flex items-center rounded-full px-2 py-0.5 text-xs leading-4 mt-1 text-base-content border border-base-300 tooltip tooltip-bottom" data-tip="Give a star on GitHub"> <a target="_blank" href="<%= Docuseal::GITHUB_URL %>" rel="noopener noreferrer nofollow" class="relative flex items-center rounded-full px-2 py-0.5 text-xs leading-4 mt-1 text-base-content border border-base-300 tooltip tooltip-bottom" data-tip="Give a star on GitHub">
<span class="flex items-center justify-between space-x-0.5 font-medium"> <span class="flex items-center justify-between space-x-0.5 font-medium">
<%= svg_icon('start', class: 'h-3 w-3') %> <%= svg_icon('start', class: 'h-3 w-3') %>
<span>11k</span> <span>12k</span>
</span> </span>
</a> </a>

@ -59,7 +59,7 @@
</li> </li>
<% end %> <% end %>
<% if (can?(:manage, EncryptedConfig) && current_user == true_user) || (current_user != true_user && current_account.testing?) %> <% if (can?(:manage, EncryptedConfig) && current_user == true_user) || (current_user != true_user && current_account.testing?) %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :get, html: { class: 'w-full py-1' } do |f| %> <%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :post, html: { class: 'w-full py-1' } do |f| %>
<label class="flex items-center pl-6 pr-4 py-2 border-y border-base-300 -ml-2 -mr-2" for="testing_toggle"> <label class="flex items-center pl-6 pr-4 py-2 border-y border-base-300 -ml-2 -mr-2" for="testing_toggle">
<submit-form data-on="change" class="flex"> <submit-form data-on="change" class="flex">
<%= f.check_box :testing_toggle, class: 'toggle', checked: current_account.testing?, style: 'height: 0.885rem; width: 1.35rem; --handleoffset: 0.395rem; margin-left: -2px; margin-right: 8px' %> <%= f.check_box :testing_toggle, class: 'toggle', checked: current_account.testing?, style: 'height: 0.885rem; width: 1.35rem; --handleoffset: 0.395rem; margin-left: -2px; margin-right: 8px' %>

@ -97,7 +97,7 @@
<% end %> <% end %>
<%= render 'shared/settings_nav_extra2' %> <%= render 'shared/settings_nav_extra2' %>
<% if (can?(:manage, EncryptedConfig) && current_user == true_user) || (current_user != true_user && current_account.testing?) %> <% if (can?(:manage, EncryptedConfig) && current_user == true_user) || (current_user != true_user && current_account.testing?) %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :get, html: { class: 'w-full' } do |f| %> <%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :post, html: { class: 'w-full' } do |f| %>
<li> <li>
<label class="flex items-center text-base hover:bg-base-300 w-full justify-between" for="testing_toggle"> <label class="flex items-center text-base hover:bg-base-300 w-full justify-between" for="testing_toggle">
<span class="mr-2 w-full"> <span class="mr-2 w-full">

@ -1,5 +1,5 @@
<% if can?(:manage, EncryptedConfig) || (current_user != true_user && current_account.testing?) %> <% if can?(:manage, EncryptedConfig) || (current_user != true_user && current_account.testing?) %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :get, html: { class: 'flex' } do |f| %> <%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :post, html: { class: 'flex' } do |f| %>
<label class="flex items-center justify-between" for="testing_toggle"> <label class="flex items-center justify-between" for="testing_toggle">
<span class="mr-2 text-lg"> <span class="mr-2 text-lg">
<%= t('test_mode') %> <%= t('test_mode') %>

@ -5,9 +5,9 @@
<% font = field.dig('preferences', 'font') %> <% font = field.dig('preferences', 'font') %>
<% font_type = field.dig('preferences', 'font_type') %> <% font_type = field.dig('preferences', 'font_type') %>
<% font_size_px = (field.dig('preferences', 'font_size').presence || Submissions::GenerateResultAttachments::FONT_SIZE).to_i * local_assigns.fetch(:font_scale) { 1000.0 / PdfUtils::US_LETTER_W } %> <% font_size_px = (field.dig('preferences', 'font_size').presence || Submissions::GenerateResultAttachments::FONT_SIZE).to_i * local_assigns.fetch(:font_scale) { 1000.0 / PdfUtils::US_LETTER_W } %>
<field-value dir="auto" aria-hidden="true" class="flex absolute <%= 'font-courier' if font == 'Courier' %> <%= 'font-times' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'text-right' : (align == 'center' ? 'text-center' : '') %>" style="<%= "color: #{color}; " if color.present? %><%= "background: #{bg_color}; " if bg_color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; font-size: <%= fs = "clamp(1pt, #{font_size_px / 10}vw, #{font_size_px}px)" %>; line-height: calc(<%= fs %> * 1.3); font-size: <%= fs = "#{font_size_px / 10}cqmin" %>; line-height: calc(<%= fs %> * 1.3)"> <field-value dir="auto" aria-hidden="true" class="flex absolute <%= 'font-courier' if font == 'Courier' %> <%= 'font-times' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'text-right' : (align == 'center' ? 'text-center' : '') %>" style="<%= "color: #{color}; " if color.present? && color.match?(Templates::COLOR_REGEXP) %><%= "background: #{bg_color}; " if bg_color.present? && bg_color.match?(Templates::COLOR_REGEXP) %>width: <%= area['w'].to_f * 100 %>%; height: <%= area['h'].to_f * 100 %>%; left: <%= area['x'].to_f * 100 %>%; top: <%= area['y'].to_f * 100 %>%; font-size: <%= fs = "clamp(1pt, #{font_size_px / 10}vw, #{font_size_px}px)" %>; line-height: calc(<%= fs %> * 1.3); font-size: <%= fs = "#{font_size_px / 10}cqmin" %>; line-height: calc(<%= fs %> * 1.3)">
<% if field['type'] == 'signature' %> <% if field['type'] == 'signature' %>
<% is_narrow = area['h'].positive? && ((area['w'] * local_assigns[:page_width]).to_f / (area['h'] * local_assigns[:page_height])) > 4.5 %> <% is_narrow = area['h'].to_f.positive? && ((area['w'].to_f * local_assigns[:page_width]) / (area['h'].to_f * local_assigns[:page_height])) > 4.5 %>
<div class="flex justify-between w-full h-full gap-1 <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'flex-row' : 'flex-col' %>"> <div class="flex justify-between w-full h-full gap-1 <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'flex-row' : 'flex-col' %>">
<div class="flex overflow-hidden <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'w-1/2' : 'flex-grow' %>" style="min-height: 50%"> <div class="flex overflow-hidden <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'w-1/2' : 'flex-grow' %>" style="min-height: 50%">
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" alt="<%= field['name'].presence || field['title'].presence || field['type'] %>"> <img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" alt="<%= field['name'].presence || field['title'].presence || field['type'] %>">
@ -62,9 +62,9 @@
<% end %> <% end %>
<% 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'].to_f / area['w'] * 100 %>
<div class="w-full flex <%= valign == 'top' ? 'items-start' : (valign == 'bottom' ? 'items-end' : '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'].to_f / 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>
<% end %> <% end %>
@ -73,8 +73,8 @@
<% elsif field['type'] == 'date' %> <% elsif field['type'] == 'date' %>
<autosize-field></autosize-field> <autosize-field></autosize-field>
<div class="flex w-full px-0.5 <%= valign == 'top' ? 'items-start' : (valign == 'bottom' ? 'items-end' : 'items-center') %>"> <div class="flex w-full px-0.5 <%= valign == 'top' ? 'items-start' : (valign == 'bottom' ? 'items-end' : 'items-center') %>">
<% value = Time.current.in_time_zone(local_assigns[:timezone]).to_date.to_s if value == '{{date}}' %> <% tz = local_assigns[:with_submitter_timezone] ? (submitter.timezone.presence || local_assigns[:timezone]) : local_assigns[:timezone] %>
<div class="w-full"><%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), local_assigns[:locale]) %></div> <div class="w-full"><%= value == '{{date}}' ? TimeUtils.format_date_preview(field.dig('preferences', 'format'), local_assigns[:locale], tz) : TimeUtils.format_date_string(value, field.dig('preferences', 'format'), local_assigns[:locale], timezone: tz) %></div>
</div> </div>
<% elsif field['type'] == 'number' %> <% elsif field['type'] == 'number' %>
<autosize-field></autosize-field> <autosize-field></autosize-field>
@ -83,14 +83,14 @@
</div> </div>
<% elsif field['type'] == 'strikethrough' %> <% elsif field['type'] == 'strikethrough' %>
<div class="w-full h-full flex items-center justify-center"> <div class="w-full h-full flex items-center justify-center">
<% if (((1000.0 / local_assigns[:page_width]) * local_assigns[:page_height]) * area['h']) < 40 %> <% if (((1000.0 / local_assigns[:page_width]) * local_assigns[:page_height]) * area['h'].to_f) < 40 %>
<svg width="100%" height="100%"> <svg width="100%" height="100%">
<line x1="0" y1="50%" x2="100%" y2="50%" stroke="<%= field.dig('preferences', 'color').presence || 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line> <line x1="0" y1="50%" x2="100%" y2="50%" stroke="<%= color.present? && color.match?(Templates::COLOR_REGEXP) ? color : 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line>
</svg> </svg>
<% else %> <% else %>
<svg xmlns="http://www.w3.org/2000/svg" style="overflow: visible; width: calc(100% - 6px); height: calc(100% - 6px); width: calc(100% - 0.6cqmin); height: calc(100% - 0.6cqmin)"> <svg xmlns="http://www.w3.org/2000/svg" style="overflow: visible; width: calc(100% - 6px); height: calc(100% - 6px); width: calc(100% - 0.6cqmin); height: calc(100% - 0.6cqmin)">
<line x1="0" y1="0" x2="100%" y2="100%" stroke="<%= field.dig('preferences', 'color').presence || 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line> <line x1="0" y1="0" x2="100%" y2="100%" stroke="<%= color.present? && color.match?(Templates::COLOR_REGEXP) ? color : 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line>
<line x1="100%" y1="0" x2="0" y2="100%" stroke="<%= field.dig('preferences', 'color').presence || 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line> <line x1="100%" y1="0" x2="0" y2="100%" stroke="<%= color.present? && color.match?(Templates::COLOR_REGEXP) ? color : 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line>
</svg> </svg>
<% end %> <% end %>
</div> </div>

@ -103,7 +103,7 @@
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %> <% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.first&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %> <% lazyload_metadata = document.preview_images.first&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %> <% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_key, "#{index}.jpg")) %> <% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_key, "#{index}#{Templates::ProcessDocument::PREVIEW_FORMAT}")) %>
<page-container id="<%= "page-#{document.uuid}-#{index}" %>" class="block before:border before:absolute before:top-0 before:bottom-0 before:left-0 before:right-0 before:rounded relative mb-4" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>"> <page-container id="<%= "page-#{document.uuid}-#{index}" %>" class="block before:border before:absolute before:top-0 before:bottom-0 before:left-0 before:right-0 before:rounded relative mb-4" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>">
<img loading="lazy" src="<%= page.url %>" width="<%= width %>" class="rounded" height="<%= height %>" alt="<%= "#{item['name']} - #{t('page')} #{index + 1}" %>"> <img loading="lazy" src="<%= page.url %>" width="<%= width %>" class="rounded" height="<%= height %>" alt="<%= "#{item['name']} - #{t('page')} #{index + 1}" %>">
<div class="top-0 bottom-0 left-0 right-0 absolute"> <div class="top-0 bottom-0 left-0 right-0 absolute">
@ -131,7 +131,7 @@
<% submitters_order_index ||= (@submission.template_submitters || @submission.template.submitters).each_with_index.to_h { |s, i| [s['uuid'], i] } %> <% submitters_order_index ||= (@submission.template_submitters || @submission.template.submitters).each_with_index.to_h { |s, i| [s['uuid'], i] } %>
<% submitter_index = submitters_order_index[submitter.uuid] %> <% submitter_index = submitters_order_index[submitter.uuid] %>
<% bg_class = bg_classes[submitter_index % bg_classes.size] %> <% bg_class = bg_classes[submitter_index % bg_classes.size] %>
<div class="absolute overflow-visible" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%;"> <div class="absolute overflow-visible" style="width: <%= area['w'].to_f * 100 %>%; height: <%= area['h'].to_f * 100 %>%; left: <%= area['x'].to_f * 100 %>%; top: <%= area['y'].to_f * 100 %>%;">
<div class="flex h-full w-full bg-opacity-80 justify-center items-center <%= bg_class %>"> <div class="flex h-full w-full bg-opacity-80 justify-center items-center <%= bg_class %>">
<%= svg_icon(SubmissionsController::FIELD_ICONS.fetch(field['type'], 'text_size'), class: 'max-h-10 w-full h-full stroke-2 opacity-50') %> <%= svg_icon(SubmissionsController::FIELD_ICONS.fetch(field['type'], 'text_size'), class: 'max-h-10 w-full h-full stroke-2 opacity-50') %>
</div> </div>
@ -281,7 +281,7 @@
<% if field['type'] == 'number' %> <% if field['type'] == 'number' %>
<% value = NumberUtils.format_number(value, field.dig('preferences', 'format')) %> <% value = NumberUtils.format_number(value, field.dig('preferences', 'format')) %>
<% elsif field['type'] == 'date' %> <% elsif field['type'] == 'date' %>
<% value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), @submission.account.locale) %> <% value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), @submission.account.locale, timezone: (with_submitter_timezone ? (submitter.timezone.presence || @submission.account.timezone) : @submission.account.timezone)) %>
<% end %> <% end %>
<% if (mask = field.dig('preferences', 'mask').presence) %> <% if (mask = field.dig('preferences', 'mask').presence) %>
<% if signed_in? && can?(:read, @submission) %> <% if signed_in? && can?(:read, @submission) %>

@ -123,7 +123,7 @@
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %> <% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.last&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %> <% lazyload_metadata = document.preview_images.last&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %> <% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_key, "#{index}.jpg")) %> <% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_key, "#{index}#{Templates::ProcessDocument::PREVIEW_FORMAT}")) %>
<page-container class="block relative my-4 shadow-md" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>"> <page-container class="block relative my-4 shadow-md" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>">
<img loading="lazy" src="<%= page.url %>" width="<%= width %>" height="<%= height %>" alt="<%= "#{item['name']} - #{t('page')} #{index + 1}" %>"> <img loading="lazy" src="<%= page.url %>" width="<%= width %>" height="<%= height %>" alt="<%= "#{item['name']} - #{t('page')} #{index + 1}" %>">
<div id="page-<%= [document.uuid, index].join('-') %>" class="top-0 bottom-0 left-0 right-0 absolute"> <div id="page-<%= [document.uuid, index].join('-') %>" class="top-0 bottom-0 left-0 right-0 absolute">

@ -25,9 +25,11 @@
<div class="flex gap-2 mt-3"> <div class="flex gap-2 mt-3">
<div class="relative flex-grow"> <div class="relative flex-grow">
<input id="embedding_url" type="text" value="<%= start_form_url(slug: @template.slug, host: form_link_host) %>" class="base-input w-full pr-10" autocomplete="off" readonly> <input id="embedding_url" type="text" value="<%= start_form_url(slug: @template.slug, host: form_link_host) %>" class="base-input w-full pr-10" autocomplete="off" readonly>
<a href="<%= template_share_link_qr_path(@template) %>" target="_blank" rel="noopener" class="absolute top-1/2 -translate-y-1/2 right-2 flex items-center justify-center tooltip tooltip-left text-base-content/70 hover:text-base-content bg-white rounded px-1 py-0.5" data-tip="<%= t('qr_code') %>" aria-label="<%= t('qr_code') %>"> <% if @template.variables_schema.blank? && !@template.preferences&.dig('require_email_2fa') && !@template.preferences&.dig('require_phone_2fa') %>
<%= svg_icon('qrcode', class: 'w-6 h-6') %> <a href="<%= template_share_link_qr_path(@template) %>" target="_blank" rel="noopener" class="absolute top-1/2 -translate-y-1/2 right-2 flex items-center justify-center tooltip text-base-content/70 hover:text-base-content bg-white rounded px-1 py-0.5" data-tip="<%= t('qr_code') %>" aria-label="<%= t('qr_code') %>">
</a> <%= svg_icon('qrcode', class: 'w-6 h-6') %>
</a>
<% end %>
</div> </div>
<check-on-click data-element-id="template_shared_link"> <check-on-click data-element-id="template_shared_link">
<%= render 'shared/clipboard_copy', icon: 'copy', text: start_form_url(slug: @template.slug, host: form_link_host), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %> <%= render 'shared/clipboard_copy', icon: 'copy', text: start_form_url(slug: @template.slug, host: form_link_host), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>

@ -945,6 +945,7 @@ en: &en
embed: Embedding embed: Embedding
invite: Invite invite: Invite
link: Link link: Link
mcp: MCP
submission_event_names: submission_event_names:
send_email_to_html: '<b>Email sent</b> to %{submitter_name}' send_email_to_html: '<b>Email sent</b> to %{submitter_name}'
bounce_email_html: '<b>Email bounced</b> %{submitter_name}' bounce_email_html: '<b>Email bounced</b> %{submitter_name}'
@ -1985,6 +1986,7 @@ es: &es
embed: Integración embed: Integración
invite: Invitación invite: Invitación
link: Enlace link: Enlace
mcp: MCP
submission_event_names: submission_event_names:
send_email_to_html: '<b>Correo electrónico enviado</b> a %{submitter_name}' send_email_to_html: '<b>Correo electrónico enviado</b> a %{submitter_name}'
bounce_email_html: '<b>Correo electrónico rebotado</b> %{submitter_name}' bounce_email_html: '<b>Correo electrónico rebotado</b> %{submitter_name}'
@ -3026,6 +3028,7 @@ it: &it
embed: Incorporamento embed: Incorporamento
invite: Invito invite: Invito
link: Link link: Link
mcp: MCP
submission_event_names: submission_event_names:
send_email_to_html: '<b>Email inviata</b> a %{submitter_name}' send_email_to_html: '<b>Email inviata</b> a %{submitter_name}'
bounce_email_html: '<b>Email respinta</b> %{submitter_name}' bounce_email_html: '<b>Email respinta</b> %{submitter_name}'
@ -4063,6 +4066,7 @@ fr: &fr
embed: Embedding embed: Embedding
invite: Invitation invite: Invitation
link: Lien link: Lien
mcp: MCP
submission_event_names: submission_event_names:
send_email_to_html: "<b>Email envoyé</b> à %{submitter_name}" send_email_to_html: "<b>Email envoyé</b> à %{submitter_name}"
bounce_email_html: "<b>Email rejeté</b> %{submitter_name}" bounce_email_html: "<b>Email rejeté</b> %{submitter_name}"
@ -5103,6 +5107,7 @@ pt: &pt
embed: Incorporação embed: Incorporação
invite: Convite invite: Convite
link: Link link: Link
mcp: MCP
submission_event_names: submission_event_names:
send_email_to_html: '<b>Email enviado</b> para %{submitter_name}' send_email_to_html: '<b>Email enviado</b> para %{submitter_name}'
bounce_email_html: '<b>Email não entregue</b> %{submitter_name}' bounce_email_html: '<b>Email não entregue</b> %{submitter_name}'
@ -6143,6 +6148,7 @@ de: &de
embed: Einbettung embed: Einbettung
invite: Einladung invite: Einladung
link: Link link: Link
mcp: MCP
submission_event_names: submission_event_names:
send_email_to_html: '<b>E-Mail gesendet</b> an %{submitter_name}' send_email_to_html: '<b>E-Mail gesendet</b> an %{submitter_name}'
bounce_email_html: '<b>E-Mail unzustellbar</b> %{submitter_name}' bounce_email_html: '<b>E-Mail unzustellbar</b> %{submitter_name}'
@ -7584,6 +7590,7 @@ nl: &nl
embed: Insluiten embed: Insluiten
invite: Uitnodiging invite: Uitnodiging
link: Link link: Link
mcp: MCP
submission_event_names: submission_event_names:
send_email_to_html: "<b>E-mail verzonden</b> naar %{submitter_name}" send_email_to_html: "<b>E-mail verzonden</b> naar %{submitter_name}"
bounce_email_html: "<b>E-mail gebounced</b> %{submitter_name}" bounce_email_html: "<b>E-mail gebounced</b> %{submitter_name}"

@ -79,7 +79,7 @@ Rails.application.routes.draw do
resources :console_redirect, only: %i[index] resources :console_redirect, only: %i[index]
resources :upgrade, only: %i[index], controller: 'console_redirect' resources :upgrade, only: %i[index], controller: 'console_redirect'
resources :manage, only: %i[index], controller: 'console_redirect' resources :manage, only: %i[index], controller: 'console_redirect'
resource :testing_account, only: %i[show destroy] resource :testing_account, only: %i[create destroy]
resources :testing_api_settings, only: %i[index] resources :testing_api_settings, only: %i[index]
resources :submitters_autocomplete, only: %i[index] resources :submitters_autocomplete, only: %i[index]
resources :submitters_resubmit, only: %i[update] resources :submitters_resubmit, only: %i[update]

@ -1,7 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
module Docuseal module Docuseal
URL_CACHE = ActiveSupport::Cache::MemoryStore.new
PRODUCT_URL = 'https://www.docuseal.com' PRODUCT_URL = 'https://www.docuseal.com'
PRODUCT_EMAIL_URL = ENV.fetch('PRODUCT_EMAIL_URL', PRODUCT_URL) PRODUCT_EMAIL_URL = ENV.fetch('PRODUCT_EMAIL_URL', PRODUCT_URL)
NEWSLETTER_URL = "#{PRODUCT_URL}/newsletters".freeze NEWSLETTER_URL = "#{PRODUCT_URL}/newsletters".freeze

@ -27,7 +27,7 @@ module Mcp
readOnlyHint: false, readOnlyHint: false,
destructiveHint: false, destructiveHint: false,
idempotentHint: false, idempotentHint: false,
openWorldHint: false openWorldHint: true
} }
}.freeze }.freeze
@ -43,6 +43,7 @@ module Mcp
account:, account:,
author: current_user, author: current_user,
folder: account.default_template_folder, folder: account.default_template_folder,
source: :mcp,
name: arguments['name'].to_s.presence || 'New Template', name: arguments['name'].to_s.presence || 'New Template',
fields: [], fields: [],
schema: [] schema: []

@ -61,7 +61,7 @@ module Mcp
}, },
annotations: { annotations: {
readOnlyHint: false, readOnlyHint: false,
destructiveHint: false, destructiveHint: true,
idempotentHint: false, idempotentHint: false,
openWorldHint: true openWorldHint: true
} }
@ -96,7 +96,7 @@ module Mcp
submissions = Submissions.create_from_submitters( submissions = Submissions.create_from_submitters(
template:, template:,
user: current_user, user: current_user,
source: :api, source: :mcp,
submitters_order: 'random', submitters_order: 'random',
submissions_attrs: { submitters: submitters }, submissions_attrs: { submitters: submitters },
params: { 'send_email' => true, 'submitters' => submitters } params: { 'send_email' => true, 'submitters' => submitters }

@ -54,7 +54,7 @@ Puma::Plugin.create do
Dir.chdir(ENV.fetch('WORKDIR', nil)) unless ENV['WORKDIR'].to_s.empty? Dir.chdir(ENV.fetch('WORKDIR', nil)) unless ENV['WORKDIR'].to_s.empty?
exec('redis-server', '--requirepass', Digest::SHA1.hexdigest("redis#{ENV.fetch('SECRET_KEY_BASE', '')}"), exec('redis-server', '--requirepass', Digest::SHA1.hexdigest("redis#{ENV.fetch('SECRET_KEY_BASE', '')}"),
out: '/dev/null') '--loglevel', 'warning')
end end
end end

@ -56,7 +56,8 @@ module SearchEntries
end end
[sql, number, number.length > 1 ? number.delete_prefix('0') : number, keyword] [sql, number, number.length > 1 ? number.delete_prefix('0') : number, keyword]
elsif keyword.match?(/[^\p{L}\d&@.-]/) || keyword.match?(/\A['"].*['"]\z/) || keyword.match?(/[.-]{2,}/) elsif keyword.start_with?('@') || keyword.match?(/[^\p{L}\d&@.-]/) ||
keyword.match?(/\A['"].*['"]\z/) || keyword.match?(/[.-]{2,}/)
['tsvector @@ plainto_tsquery(?)', TextUtils.transliterate(keyword.downcase)] ['tsvector @@ plainto_tsquery(?)', TextUtils.transliterate(keyword.downcase)]
else else
keyword = TextUtils.transliterate(keyword.downcase).squish keyword = TextUtils.transliterate(keyword.downcase).squish

@ -162,7 +162,7 @@ module Submissions
return email.downcase.sub(/@gmail?\z/i, '@gmail.com') if email.match?(/@gmail?\z/i) return email.downcase.sub(/@gmail?\z/i, '@gmail.com') if email.match?(/@gmail?\z/i)
return email.downcase if email.include?(',') || return email.downcase if email.include?(',') ||
email.match?(/\.(?:gob|om|mm|cm|et|mo|nz|za|ie)\z/) || email.match?(/\.(?:gob|om|mm|cm|et|mo|nz|za|ie|ed\.jp)\z/i) ||
email.exclude?('.') email.exclude?('.')
fixed_email = EmailTypo.call(email.delete_prefix('<')) fixed_email = EmailTypo.call(email.delete_prefix('<'))

@ -410,9 +410,7 @@ module Submissions
submitter.values = Submitters::SubmitValues.maybe_remove_condition_values(submitter) submitter.values = Submitters::SubmitValues.maybe_remove_condition_values(submitter)
end end
submitter.values = submitter.values.transform_values do |v| submitter.values = Submitters::SubmitValues.replace_current_date_placeholders(submitter)
v == '{{date}}' ? Time.current.in_time_zone(submitter.submission.account.timezone).to_date.to_s : v
end
submitter submitter
end end

@ -415,7 +415,8 @@ module Submissions
composer.formatted_text_box([{ text: value.to_s.titleize }], padding: [0, 0, 10, 0]) composer.formatted_text_box([{ text: value.to_s.titleize }], padding: [0, 0, 10, 0])
else else
if field['type'] == 'date' if field['type'] == 'date'
value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), account.locale) value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), account.locale,
timezone:)
end end
value = NumberUtils.format_number(value, field.dig('preferences', 'format')) if field['type'] == 'number' value = NumberUtils.format_number(value, field.dig('preferences', 'format')) if field['type'] == 'number'

@ -16,18 +16,21 @@ module Submissions
AccountConfig::WITH_SIGNATURE_ID, AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY, AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY,
AccountConfig::WITH_TIMESTAMP_SECONDS_KEY, AccountConfig::WITH_TIMESTAMP_SECONDS_KEY,
AccountConfig::ROTATE_INCREMENTAL_PDF_KEY,
AccountConfig::WITH_FILE_LINKS_KEY, AccountConfig::WITH_FILE_LINKS_KEY,
AccountConfig::WITH_SIGNATURE_ID_REASON_KEY]) AccountConfig::WITH_SIGNATURE_ID_REASON_KEY])
with_signature_id = configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true with_signature_id = configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true
with_file_links = configs.find { |c| c.key == AccountConfig::WITH_FILE_LINKS_KEY }&.value == true with_file_links = configs.find { |c| c.key == AccountConfig::WITH_FILE_LINKS_KEY }&.value == true
is_flatten = configs.find { |c| c.key == AccountConfig::FLATTEN_RESULT_PDF_KEY }&.value != false is_flatten = configs.find { |c| c.key == AccountConfig::FLATTEN_RESULT_PDF_KEY }&.value != false
is_rotate_incremental = configs.find { |c| c.key == AccountConfig::ROTATE_INCREMENTAL_PDF_KEY }&.value == true
with_submitter_timezone = configs.find { |c| c.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true with_submitter_timezone = configs.find { |c| c.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true
with_timestamp_seconds = configs.find { |c| c.key == AccountConfig::WITH_TIMESTAMP_SECONDS_KEY }&.value == true with_timestamp_seconds = configs.find { |c| c.key == AccountConfig::WITH_TIMESTAMP_SECONDS_KEY }&.value == true
with_signature_id_reason = with_signature_id_reason =
configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false
pdfs_index = GenerateResultAttachments.build_pdfs_index(submission, flatten: is_flatten) pdfs_index = GenerateResultAttachments.build_pdfs_index(submission, flatten: is_flatten,
incremental: is_rotate_incremental)
submitters = if submitter submitters = if submitter
submission.submitters.where(id: submitter.id) submission.submitters.where(id: submitter.id)

@ -141,18 +141,21 @@ module Submissions
AccountConfig::WITH_SIGNATURE_ID, AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::WITH_FILE_LINKS_KEY, AccountConfig::WITH_FILE_LINKS_KEY,
AccountConfig::WITH_TIMESTAMP_SECONDS_KEY, AccountConfig::WITH_TIMESTAMP_SECONDS_KEY,
AccountConfig::ROTATE_INCREMENTAL_PDF_KEY,
AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY, AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY,
AccountConfig::WITH_SIGNATURE_ID_REASON_KEY]) AccountConfig::WITH_SIGNATURE_ID_REASON_KEY])
with_signature_id = configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true with_signature_id = configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true
is_flatten = configs.find { |c| c.key == AccountConfig::FLATTEN_RESULT_PDF_KEY }&.value != false is_flatten = configs.find { |c| c.key == AccountConfig::FLATTEN_RESULT_PDF_KEY }&.value != false
is_rotate_incremental = configs.find { |c| c.key == AccountConfig::ROTATE_INCREMENTAL_PDF_KEY }&.value == true
with_timestamp_seconds = configs.find { |c| c.key == AccountConfig::WITH_TIMESTAMP_SECONDS_KEY }&.value == true with_timestamp_seconds = configs.find { |c| c.key == AccountConfig::WITH_TIMESTAMP_SECONDS_KEY }&.value == true
with_submitter_timezone = configs.find { |c| c.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true with_submitter_timezone = configs.find { |c| c.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true
with_file_links = configs.find { |c| c.key == AccountConfig::WITH_FILE_LINKS_KEY }&.value == true with_file_links = configs.find { |c| c.key == AccountConfig::WITH_FILE_LINKS_KEY }&.value == true
with_signature_id_reason = with_signature_id_reason =
configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false
pdfs_index = build_pdfs_index(submitter.submission, submitter:, flatten: is_flatten) pdfs_index = build_pdfs_index(submitter.submission, submitter:, flatten: is_flatten,
incremental: is_rotate_incremental)
if with_signature_id || submitter.account.testing? if with_signature_id || submitter.account.testing?
pdfs_index.each_value do |pdf| pdfs_index.each_value do |pdf|
@ -649,7 +652,10 @@ module Submissions
end end
else else
if field['type'] == 'date' if field['type'] == 'date'
value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), locale) timezone = submitter.account.timezone
timezone = submitter.timezone || submitter.account.timezone if with_submitter_timezone
value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), locale, timezone:)
end end
value = NumberUtils.format_number(value, field.dig('preferences', 'format')) if field['type'] == 'number' value = NumberUtils.format_number(value, field.dig('preferences', 'format')) if field['type'] == 'number'
@ -802,7 +808,7 @@ module Submissions
Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, attachments.map(&:uuid).sort.join(':')) Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, attachments.map(&:uuid).sort.join(':'))
end end
def build_pdfs_index(submission, submitter: nil, flatten: true) def build_pdfs_index(submission, submitter: nil, flatten: true, incremental: false)
latest_submitter = find_last_submitter(submission, submitter:) latest_submitter = find_last_submitter(submission, submitter:)
documents = Submissions::EnsureResultGenerated.call(latest_submitter) if latest_submitter documents = Submissions::EnsureResultGenerated.call(latest_submitter) if latest_submitter
@ -826,7 +832,7 @@ module Submissions
HexaPDF::Document.new(io: StringIO.new(attachment.download)) HexaPDF::Document.new(io: StringIO.new(attachment.download))
end end
pdf = maybe_rotate_pdf(pdf) pdf = maybe_rotate_pdf(pdf, incremental:)
maybe_flatten_pdf(pdf) if flatten maybe_flatten_pdf(pdf) if flatten
@ -845,7 +851,7 @@ module Submissions
Rollbar.error(e) if defined?(Rollbar) Rollbar.error(e) if defined?(Rollbar)
end end
def maybe_rotate_pdf(pdf) def maybe_rotate_pdf(pdf, incremental: false)
return pdf if pdf.pages.size > MAX_PAGE_ROTATE return pdf if pdf.pages.size > MAX_PAGE_ROTATE
is_pages_rotated = pdf.pages.root[:Rotate].present? && pdf.pages.root[:Rotate] != 0 is_pages_rotated = pdf.pages.root[:Rotate].present? && pdf.pages.root[:Rotate] != 0
@ -860,7 +866,7 @@ module Submissions
io = StringIO.new io = StringIO.new
pdf.write(io, incremental: false, validate: false) pdf.write(io, incremental:, validate: false)
HexaPDF::Document.new(io:) HexaPDF::Document.new(io:)
rescue StandardError => e rescue StandardError => e

@ -13,7 +13,7 @@ module Submitters
UnableToSendCode = Class.new(StandardError) UnableToSendCode = Class.new(StandardError)
InvalidOtp = Class.new(StandardError) InvalidOtp = Class.new(StandardError)
MaliciousFileExtension = Class.new(StandardError) MaliciousFileExtension = Class.new(StandardError)
ArgumentError = Class.new(StandardError) ParamsError = Class.new(StandardError)
DANGEROUS_EXTENSIONS = Set.new(%w[ DANGEROUS_EXTENSIONS = Set.new(%w[
exe com bat cmd scr pif vbs vbe js jse wsf wsh msi msp exe com bat cmd scr pif vbs vbe js jse wsf wsh msi msp
@ -135,7 +135,7 @@ module Submitters
filename: file.original_filename, filename: file.original_filename,
content_type: file.content_type) content_type: file.content_type)
else else
raise ArgumentError, 'file param is missing' raise ParamsError, 'file param is missing'
end end
ActiveStorage::Attachment.create!( ActiveStorage::Attachment.create!(

@ -103,17 +103,43 @@ module Submitters
end end
def normalize_date(field, value) def normalize_date(field, value)
if value.is_a?(Integer) format = field.dig('preferences', 'format')
if TimeUtils.format_with_time?(format)
normalize_date_time(value, format)
elsif TimeUtils.month_only_format?(format)
normalize_date_month(value, format)
elsif value.is_a?(Integer)
Time.zone.at(value.to_s.first(10).to_i).to_date.to_s Time.zone.at(value.to_s.first(10).to_i).to_date.to_s
elsif value.gsub(/\w/, '0') == field.dig('preferences', 'format').to_s.gsub(/\w/, '0') elsif value.gsub(/\w/, '0') == format.to_s.gsub(/\w/, '0')
TimeUtils.parse_date_string(value, field.dig('preferences', 'format')).to_s TimeUtils.parse_date_string(value, format).to_s
else else
Date.parse(value).to_s Date.parse(value).to_s
end end
rescue Date::Error rescue ArgumentError
value value
end end
def normalize_date_time(value, format)
if value.is_a?(Integer)
Time.zone.at(value.to_s.first(10).to_i).utc.iso8601
elsif value.to_s.match?(/T\d{2}:\d{2}/)
Time.iso8601(value).utc.iso8601
else
TimeUtils.parse_date_string(value, format).utc.iso8601
end
end
def normalize_date_month(value, format)
if value.is_a?(Integer)
Time.zone.at(value.to_s.first(10).to_i).strftime('%Y-%m')
elsif value.to_s.match?(/\A\d{4}-\d{2}\z/)
value
else
TimeUtils.parse_date_string(value, format).strftime('%Y-%m')
end
end
def fetch_fields(template, submitter_name: nil, for_submitter: nil) def fetch_fields(template, submitter_name: nil, for_submitter: nil)
if submitter_name && !for_submitter if submitter_name && !for_submitter
submitter = submitter =

@ -86,9 +86,7 @@ module Submitters
submitter.values = maybe_remove_condition_values(submitter, required_field_uuids_acc:) submitter.values = maybe_remove_condition_values(submitter, required_field_uuids_acc:)
end end
submitter.values = submitter.values.transform_values do |v| submitter.values = replace_current_date_placeholders(submitter)
v == '{{date}}' ? Time.current.in_time_zone(submitter.account.timezone).to_date.to_s : v
end
required_field_uuids_acc.each do |uuid| required_field_uuids_acc.each do |uuid|
next if submitter.values[uuid].present? next if submitter.values[uuid].present?
@ -194,7 +192,7 @@ module Submitters
next if value.blank? next if value.blank?
acc[field['uuid']] = template_default_value_for_submitter(value, submitter, with_time: true) acc[field['uuid']] = template_default_value_for_submitter(value, submitter, field:, with_time: true)
end end
default_values.compact_blank.merge(submitter.values) default_values.compact_blank.merge(submitter.values)
@ -248,7 +246,20 @@ module Submitters
0 0
end end
def template_default_value_for_submitter(value, submitter, with_time: false) def replace_current_date_placeholders(submitter)
submitter.values.each_with_object({}) do |(uuid, v), acc|
acc[uuid] =
if v == '{{date}}'
field = submitter.submission.fields_uuid_index[uuid]
TimeUtils.current_date_value(field&.dig('preferences', 'format'), submitter.account.timezone)
else
v
end
end
end
def template_default_value_for_submitter(value, submitter, with_time: false, field: nil)
return if value.blank? return if value.blank?
return if submitter.blank? return if submitter.blank?
@ -257,7 +268,8 @@ module Submitters
replace_default_variables(value, replace_default_variables(value,
submitter.attributes.merge('role' => role), submitter.attributes.merge('role' => role),
submitter.submission, submitter.submission,
with_time:) with_time:,
field:)
end end
def maybe_remove_condition_values(submitter, required_field_uuids_acc: nil) def maybe_remove_condition_values(submitter, required_field_uuids_acc: nil)
@ -394,7 +406,7 @@ module Submitters
end end
# rubocop:enable Metrics # rubocop:enable Metrics
def replace_default_variables(value, attrs, submission, with_time: false) def replace_default_variables(value, attrs, submission, with_time: false, field: nil)
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)
return if value.blank? return if value.blank?
@ -412,7 +424,7 @@ module Submitters
when 'hour', 'minute', 'day', 'month', 'year' when 'hour', 'minute', 'day', 'month', 'year'
with_time ? Time.current.in_time_zone(submission.account.timezone).strftime(STRFTIME_MAP[key]) : e with_time ? Time.current.in_time_zone(submission.account.timezone).strftime(STRFTIME_MAP[key]) : e
when 'date' when 'date'
with_time ? Time.current.in_time_zone(submission.account.timezone).to_date.to_s : e with_time ? TimeUtils.current_date_value(field&.dig('preferences', 'format'), submission.account.timezone) : e
when 'role', 'email', 'phone', 'name' when 'role', 'email', 'phone', 'name'
attrs[key] || e attrs[key] || e
else else

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
module Templates module Templates
COLOR_REGEXP = /\A(#(?:[0-9a-f]{3}|[0-9a-f]{6})|[a-z]+)\z/i
EXPIRATION_DURATIONS = { EXPIRATION_DURATIONS = {
one_day: 1.day, one_day: 1.day,
two_days: 2.days, two_days: 2.days,

@ -6,7 +6,7 @@ module Templates
# rubocop:disable Metrics # rubocop:disable Metrics
def call(original_template, author:, external_id: nil, name: nil, folder_name: nil) def call(original_template, author:, external_id: nil, name: nil, folder_name: nil)
template = original_template.account.templates.new template = author.account.templates.new
template.external_id = external_id template.external_id = external_id
template.shared_link = original_template.shared_link template.shared_link = original_template.shared_link
@ -16,8 +16,10 @@ module Templates
if folder_name.present? if folder_name.present?
template.folder = TemplateFolders.find_or_create_by_name(author, folder_name) template.folder = TemplateFolders.find_or_create_by_name(author, folder_name)
else elsif author.account_id == original_template.account_id
template.folder_id = original_template.folder_id template.folder_id = original_template.folder_id
else
template.folder = author.account.default_template_folder
end end
template.submitters, template.fields, template.schema, template.preferences = template.submitters, template.fields, template.schema, template.preferences =

@ -38,7 +38,7 @@ module Templates
[documents, dynamic_documents] [documents, dynamic_documents]
end end
def handle_pdf_or_image(template, file, document_data = nil, params = {}, extract_fields: false) def handle_pdf_or_image(template, file, document_data = nil, params = {}, extract_fields: false, metadata: {})
document_data ||= file.read document_data ||= file.read
if file.content_type == PDF_CONTENT_TYPE if file.content_type == PDF_CONTENT_TYPE
@ -54,6 +54,7 @@ module Templates
io: StringIO.new(document_data), io: StringIO.new(document_data),
filename: file.original_filename, filename: file.original_filename,
metadata: { metadata: {
**metadata,
identified: file.content_type == PDF_CONTENT_TYPE, identified: file.content_type == PDF_CONTENT_TYPE,
analyzed: file.content_type == PDF_CONTENT_TYPE, analyzed: file.content_type == PDF_CONTENT_TYPE,
pdf: { annotations: }.compact_blank, sha256: pdf: { annotations: }.compact_blank, sha256:

@ -4,6 +4,7 @@ module Templates
module ProcessDocument module ProcessDocument
DPI = 200 DPI = 200
FORMAT = '.png' FORMAT = '.png'
PREVIEW_FORMAT = '.jpg'
ATTACHMENT_NAME = 'preview_images' ATTACHMENT_NAME = 'preview_images'
BMP_REGEXP = %r{\Aimage/(?:bmp|x-bmp|x-ms-bmp)\z} BMP_REGEXP = %r{\Aimage/(?:bmp|x-bmp|x-ms-bmp)\z}
@ -69,7 +70,7 @@ module Templates
bitdepth = 2**image.stats.to_a[1..3].pluck(2).uniq.size bitdepth = 2**image.stats.to_a[1..3].pluck(2).uniq.size
io = StringIO.new(image.write_to_buffer(FORMAT, compression: 7, filter: 0, bitdepth:, io = StringIO.new(image.write_to_buffer(FORMAT, compression: 6, filter: 0, bitdepth:,
palette: true, Q: Q, dither: 0)) palette: true, Q: Q, dither: 0))
ActiveStorage::Attachment.create!( ActiveStorage::Attachment.create!(
@ -141,7 +142,7 @@ module Templates
if format == FORMAT if format == FORMAT
bitdepth = 2**page.stats.to_a[1..3].pluck(2).uniq.size bitdepth = 2**page.stats.to_a[1..3].pluck(2).uniq.size
page.write_to_buffer(format, compression: 7, filter: 0, bitdepth:, page.write_to_buffer(format, compression: 6, filter: 0, bitdepth:,
palette: true, Q: Q, dither: 0) palette: true, Q: Q, dither: 0)
else else
page.write_to_buffer(format, interlace: true, Q: JPEG_Q) page.write_to_buffer(format, interlace: true, Q: JPEG_Q)
@ -205,7 +206,7 @@ module Templates
def generate_pdf_preview_from_file(attachment, file_path, page_number) def generate_pdf_preview_from_file(attachment, file_path, page_number)
doc = Pdfium::Document.open_file(file_path) doc = Pdfium::Document.open_file(file_path)
blob = build_and_upload_blob(doc, page_number, '.jpeg') blob = build_and_upload_blob(doc, page_number, PREVIEW_FORMAT)
ApplicationRecord.no_touching do ApplicationRecord.no_touching do
ActiveStorage::Attachment.create!( ActiveStorage::Attachment.create!(

@ -19,6 +19,47 @@ module TimeUtils
'YY' => '%y' 'YY' => '%y'
}.freeze }.freeze
HOUR_FORMATS = {
'H' => '%-H',
'HH' => '%H',
'h' => '%-I',
'hh' => '%I'
}.freeze
MINUTE_FORMATS = {
'm' => '%-M',
'mm' => '%M'
}.freeze
SECOND_FORMATS = {
's' => '%-S',
'ss' => '%S'
}.freeze
AMPM_FORMATS = {
'A' => '%p',
'a' => '%P'
}.freeze
TIMEZONE_FORMATS = {
'z' => '%Z'
}.freeze
TIME_FORMATS = HOUR_FORMATS.merge(MINUTE_FORMATS)
.merge(SECOND_FORMATS)
.merge(AMPM_FORMATS)
.freeze
ALL_FORMATS = MONTH_FORMATS.merge(DAY_FORMATS)
.merge(YEAR_FORMATS)
.merge(TIME_FORMATS)
.merge(TIMEZONE_FORMATS)
.freeze
TOKEN_REGEX = /MMMM|MMM|MM|M|DD|D|YYYY|YYY|YY|HH|hh|H|h|mm|m|ss|s|A|a|z/
MONTH_ONLY_VALUE_REGEX = /\A\d{4}-\d{2}\z/
DEFAULT_DATE_FORMAT_US = 'MM/DD/YYYY' DEFAULT_DATE_FORMAT_US = 'MM/DD/YYYY'
DEFAULT_DATE_FORMAT = 'DD/MM/YYYY' DEFAULT_DATE_FORMAT = 'DD/MM/YYYY'
@ -42,27 +83,60 @@ module TimeUtils
end end
end end
def parse_date_string(string, pattern) def format_with_time?(format)
pattern = pattern.sub(/Y+/, YEAR_FORMATS) format.to_s.match?(/[HhAasz]/)
.sub(/M+/, MONTH_FORMATS) end
.sub(/D+/, DAY_FORMATS)
Date.strptime(string, pattern) def month_only_format?(format)
format.to_s.present? && !format.to_s.match?(/[DdHhAasz]/)
end end
def format_date_string(string, format, locale) def format_date_preview(format, locale, timezone)
date = Date.parse(string.to_s) return '' if format.blank?
format = format.upcase if format format = format.upcase unless format_with_time?(format)
preview_pattern = format.gsub(TOKEN_REGEX) { |token| TIME_FORMATS.key?(token) ? '--' : ALL_FORMATS[token] }
I18n.l(Time.current.in_time_zone(timezone.presence || Time.zone.name), format: preview_pattern, locale:)
end
def current_date_value(format, timezone)
tz = timezone.presence || Time.zone.name
if format_with_time?(format)
Time.current.utc.iso8601
elsif month_only_format?(format)
Time.current.in_time_zone(tz).strftime('%Y-%m')
else
Time.current.in_time_zone(tz).to_date.to_s
end
end
def parse_date_string(string, pattern)
with_time = format_with_time?(pattern)
pattern = pattern.upcase unless with_time
pattern = pattern.gsub(TOKEN_REGEX, ALL_FORMATS)
with_time ? Time.zone.strptime(string, pattern) : Date.strptime(string, pattern)
end
def format_date_string(string, format, locale, timezone: nil)
format = format.upcase if format && !format_with_time?(format)
format ||= locale.to_s.ends_with?('US') ? DEFAULT_DATE_FORMAT_US : DEFAULT_DATE_FORMAT format ||= locale.to_s.ends_with?('US') ? DEFAULT_DATE_FORMAT_US : DEFAULT_DATE_FORMAT
i18n_format = format.sub(/D+/) { DAY_FORMATS[format[/D+/]] } date =
.sub(/M+/) { MONTH_FORMATS[format[/M+/]] } if format_with_time?(format)
.sub(/Y+/) { YEAR_FORMATS[format[/Y+/]] } Time.iso8601(string.to_s).in_time_zone(timezone.presence || Time.zone.name)
elsif string.to_s.match?(MONTH_ONLY_VALUE_REGEX)
year, month = string.to_s.split('-').map(&:to_i)
Date.new(year, month, 1)
else
Date.parse(string.to_s)
end
I18n.l(date, format: i18n_format, locale:) I18n.l(date, format: format.gsub(TOKEN_REGEX, ALL_FORMATS), locale:)
rescue Date::Error rescue ArgumentError
string string
end end
end end

Loading…
Cancel
Save