Merge from docusealco/wip
This commit is contained in:
+1
-1
@@ -179,7 +179,7 @@ GEM
|
||||
dotenv (3.2.0)
|
||||
drb (2.2.3)
|
||||
email_typo (0.2.3)
|
||||
erb (6.0.2)
|
||||
erb (6.0.4)
|
||||
erb_lint (0.9.0)
|
||||
activesupport
|
||||
better_html (>= 2.0.1)
|
||||
|
||||
@@ -161,9 +161,7 @@ module Api
|
||||
submitter.values = Submitters::SubmitValues.maybe_remove_condition_values(submitter)
|
||||
end
|
||||
|
||||
submitter.values = submitter.values.transform_values do |v|
|
||||
v == '{{date}}' ? Time.current.in_time_zone(submitter.account.timezone).to_date.to_s : v
|
||||
end
|
||||
submitter.values = Submitters::SubmitValues.replace_current_date_placeholders(submitter)
|
||||
end
|
||||
|
||||
submitter
|
||||
|
||||
@@ -169,6 +169,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
withKba: ['true', 'false'].includes(this.dataset.withKba) ? this.dataset.withKba === 'true' : null,
|
||||
withLogo: this.dataset.withLogo !== 'false',
|
||||
withFieldsDetection: this.dataset.withFieldsDetection === 'true',
|
||||
withDetectExistingFields: this.dataset.withDetectExistingFields === 'true',
|
||||
editable: this.dataset.editable !== 'false',
|
||||
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
|
||||
withCustomFields: true,
|
||||
@@ -180,6 +181,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
withConditions: this.dataset.withConditions === 'true',
|
||||
withDynamicDocuments: this.dataset.withDynamicDocuments === 'true',
|
||||
withGoogleDrive: this.dataset.withGoogleDrive === 'true',
|
||||
pagePreviewFormat: this.dataset.pagePreviewFormat || '.jpg',
|
||||
withReplaceAndCloneUpload: true,
|
||||
withDownload: true,
|
||||
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
|
||||
|
||||
@@ -527,7 +527,8 @@ export default {
|
||||
try {
|
||||
return this.formatDate(
|
||||
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 {
|
||||
return this.modelValue
|
||||
@@ -646,36 +647,55 @@ export default {
|
||||
return number
|
||||
}
|
||||
},
|
||||
formatDate (date, format) {
|
||||
const monthFormats = {
|
||||
M: 'numeric',
|
||||
MM: '2-digit',
|
||||
MMM: 'short',
|
||||
MMMM: 'long'
|
||||
}
|
||||
formatDate (date, format, { withTimePlaceholders = false } = {}) {
|
||||
const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
|
||||
const dayFormats = { D: 'numeric', DD: '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 dayFormats = {
|
||||
D: 'numeric',
|
||||
DD: '2-digit'
|
||||
}
|
||||
const hasTime = /[HhAasz]/.test(format)
|
||||
|
||||
const yearFormats = {
|
||||
YYYY: 'numeric',
|
||||
YYY: 'numeric',
|
||||
YY: '2-digit'
|
||||
}
|
||||
|
||||
const parts = new Intl.DateTimeFormat([], {
|
||||
const opts = {
|
||||
day: dayFormats[format.match(/D+/)],
|
||||
month: monthFormats[format.match(/M+/)],
|
||||
year: yearFormats[format.match(/Y+/)],
|
||||
timeZone: 'UTC'
|
||||
}).formatToParts(date)
|
||||
year: yearFormats[format.match(/Y+/)]
|
||||
}
|
||||
|
||||
return format
|
||||
.replace(/D+/, parts.find((p) => p.type === 'day').value)
|
||||
.replace(/M+/, parts.find((p) => p.type === 'month').value)
|
||||
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
|
||||
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'
|
||||
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([], opts).formatToParts(date)
|
||||
|
||||
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) => {
|
||||
if (withTimePlaceholders && /^(HH|hh|H|h|mm|m|ss|s|A|a)$/.test(token)) return '--'
|
||||
|
||||
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
|
||||
})
|
||||
},
|
||||
updateMultipleSelectValue (value) {
|
||||
if (this.modelValue?.includes(value)) {
|
||||
|
||||
@@ -56,12 +56,18 @@
|
||||
class="base-input !text-2xl text-center w-full"
|
||||
:required="field.required"
|
||||
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
|
||||
type="date"
|
||||
:name="`values[${field.uuid}]`"
|
||||
:type="inputType"
|
||||
:name="formatType === 'datetime' ? undefined : `values[${field.uuid}]`"
|
||||
@keydown.enter="onEnter"
|
||||
@focus="$emit('focus')"
|
||||
@paste="onPaste"
|
||||
>
|
||||
<input
|
||||
v-if="formatType === 'datetime'"
|
||||
type="hidden"
|
||||
:name="`values[${field.uuid}]`"
|
||||
:value="modelValue"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -97,14 +103,19 @@ export default {
|
||||
},
|
||||
emits: ['update:model-value', 'focus', 'submit'],
|
||||
computed: {
|
||||
formatType () {
|
||||
const format = this.field.preferences?.format || ''
|
||||
|
||||
if (/[HhAasz]/.test(format)) return 'datetime'
|
||||
if (format && !/[Dd]/.test(format)) return 'month'
|
||||
|
||||
return 'date'
|
||||
},
|
||||
inputType () {
|
||||
return { datetime: 'datetime-local', month: 'month', date: 'date' }[this.formatType]
|
||||
},
|
||||
dateNowString () {
|
||||
const today = new Date()
|
||||
|
||||
const yyyy = today.getFullYear()
|
||||
const mm = String(today.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(today.getDate()).padStart(2, '0')
|
||||
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
return this.formatDateValue(new Date())
|
||||
},
|
||||
validationMin () {
|
||||
if (this.field.validation?.min) {
|
||||
@@ -121,6 +132,8 @@ export default {
|
||||
}
|
||||
},
|
||||
withToday () {
|
||||
if (this.formatType === 'datetime') return false
|
||||
|
||||
const todayDate = new Date().setHours(0, 0, 0, 0)
|
||||
|
||||
if (this.validationMin) {
|
||||
@@ -137,9 +150,25 @@ export default {
|
||||
},
|
||||
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)
|
||||
},
|
||||
get () {
|
||||
if (this.formatType === 'datetime') {
|
||||
const d = new Date(this.modelValue)
|
||||
|
||||
return isNaN(d) ? '' : this.formatDateValue(d)
|
||||
}
|
||||
|
||||
return this.modelValue
|
||||
}
|
||||
}
|
||||
@@ -163,20 +192,32 @@ export default {
|
||||
|
||||
const parsedDate = new Date(pasteData)
|
||||
|
||||
if (!isNaN(parsedDate)) {
|
||||
const inputEl = this.$refs.input
|
||||
if (isNaN(parsedDate)) return
|
||||
|
||||
inputEl.valueAsDate = new Date(parsedDate.getTime() - parsedDate.getTimezoneOffset() * 60000)
|
||||
|
||||
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
this.setInputValue(parsedDate)
|
||||
},
|
||||
setCurrentDate () {
|
||||
this.setInputValue(new Date())
|
||||
},
|
||||
setInputValue (date) {
|
||||
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 }))
|
||||
},
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
<div
|
||||
ref="textContainer"
|
||||
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 }"
|
||||
>
|
||||
<IconCheck
|
||||
@@ -131,13 +131,13 @@
|
||||
/>
|
||||
</template>
|
||||
<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"
|
||||
>{{ formatNumber(field.default_value, field.preferences?.format) }}</span>
|
||||
>{{ formatNumber(displayValue, field.preferences?.format) }}</span>
|
||||
<span
|
||||
v-else-if="field.default_value === '{{date}}'"
|
||||
>
|
||||
{{ t('signing_date') }}
|
||||
{{ /[HhAasz]/.test(field.preferences?.format || '') ? t('signing_date_and_time') : t('signing_date') }}
|
||||
</span>
|
||||
<div
|
||||
v-else-if="field.type === 'cells' && field.default_value"
|
||||
@@ -183,12 +183,12 @@
|
||||
:contenteditable="isValueInput"
|
||||
class="whitespace-pre-wrap outline-none empty:before:content-[attr(placeholder)] before:text-base-content/30"
|
||||
: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"
|
||||
@focus="selectedAreasRef.value = [area]"
|
||||
@paste.prevent="onPaste"
|
||||
@keydown.enter="onDefaultValueEnter"
|
||||
>{{ field.default_value }}</span>
|
||||
>{{ displayValue }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<component
|
||||
@@ -241,6 +241,16 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
conditionalFieldIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
formulaValuesIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
isDraw: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -323,11 +333,27 @@ export default {
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldLabels: FieldType.computed.fieldLabels,
|
||||
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 () {
|
||||
if (this.field.type === 'heading') {
|
||||
return 'bg-gray-50'
|
||||
} else if (this.field.type === 'strikethrough') {
|
||||
return 'bg-transparent'
|
||||
} else if (!this.isConditionMatch) {
|
||||
return 'bg-gray-100'
|
||||
} else {
|
||||
return this.bgColors[this.submitterIndex % this.bgColors.length]
|
||||
}
|
||||
@@ -337,6 +363,8 @@ export default {
|
||||
return ''
|
||||
} else if (this.field.type === 'strikethrough') {
|
||||
return 'border-dashed border-gray-300'
|
||||
} else if (!this.isConditionMatch) {
|
||||
return 'border-gray-300'
|
||||
} else {
|
||||
return this.borderColors[this.submitterIndex % this.borderColors.length]
|
||||
}
|
||||
@@ -390,7 +418,7 @@ export default {
|
||||
return this.basePageWidth / 612.0
|
||||
},
|
||||
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 () {
|
||||
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))
|
||||
},
|
||||
isValueInput () {
|
||||
if (this.inputMode && this.field.preferences?.formula) return false
|
||||
|
||||
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}}')))
|
||||
},
|
||||
@@ -511,7 +541,7 @@ export default {
|
||||
return option?.value || `${this.t('option')} ${this.field.options.indexOf(option) + 1}`
|
||||
},
|
||||
maybeToggleDefaultValue () {
|
||||
if (!this.editable || this.isCmdKeyRef.value) {
|
||||
if (!this.editable || this.isCmdKeyRef.value || this.field.preferences?.formula) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -559,6 +589,10 @@ export default {
|
||||
}
|
||||
},
|
||||
focusValueInput (e) {
|
||||
if (this.inputMode && this.field.type === 'number' && !this.isContenteditable && !this.field.preferences?.formula) {
|
||||
this.isContenteditable = true
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.defaultValue && this.$refs.defaultValue !== document.activeElement) {
|
||||
this.$refs.defaultValue.focus()
|
||||
@@ -624,6 +658,12 @@ export default {
|
||||
}
|
||||
},
|
||||
onDefaultValueBlur (e) {
|
||||
if (this.field.preferences?.formula) {
|
||||
this.isContenteditable = false
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const text = this.$refs.defaultValue.innerText.trim()
|
||||
|
||||
this.isContenteditable = false
|
||||
|
||||
@@ -381,6 +381,9 @@
|
||||
:document="document"
|
||||
:is-drag="!!dragField"
|
||||
:input-mode="inputMode"
|
||||
:conditional-field-index="conditionalFieldIndex"
|
||||
:formula-values-index="formulaValuesIndex"
|
||||
:page-preview-format="pagePreviewFormat"
|
||||
:default-fields="[...defaultRequiredFields, ...defaultFields]"
|
||||
:allow-draw="!onlyDefinedFields || drawField || drawCustomField"
|
||||
:with-signature-id="withSignatureId"
|
||||
@@ -504,11 +507,14 @@
|
||||
:with-custom-fields="withCustomFields"
|
||||
:with-fields-search="withFieldsSearch"
|
||||
:default-fields="[...defaultRequiredFields, ...defaultFields]"
|
||||
:with-custom-fields-tab="withCustomFieldsTab"
|
||||
:template="template"
|
||||
:default-required-fields="defaultRequiredFields"
|
||||
:detect-custom-fields-index="detectCustomFieldsIndex"
|
||||
:field-types="fieldTypes"
|
||||
:with-sticky-submitters="withStickySubmitters"
|
||||
:with-fields-detection="withFieldsDetection"
|
||||
:with-detect-existing-fields="withDetectExistingFields"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-prefillable="withPrefillable"
|
||||
:only-defined-fields="onlyDefinedFields"
|
||||
@@ -617,6 +623,16 @@ import { v4 } from 'uuid'
|
||||
import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
|
||||
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 {
|
||||
name: 'TemplateBuilder',
|
||||
components: {
|
||||
@@ -654,6 +670,7 @@ export default {
|
||||
locale: this.locale,
|
||||
baseFetch: this.baseFetch,
|
||||
fieldTypes: this.fieldTypes,
|
||||
dateFormats: this.dateFormats,
|
||||
backgroundColor: this.backgroundColor,
|
||||
withPhone: this.withPhone,
|
||||
withVerification: this.withVerification,
|
||||
@@ -738,6 +755,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withDetectExistingFields: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withCustomFields: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -778,6 +800,11 @@ export default {
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
withCustomFieldsTab: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withSelectedFieldType: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -798,6 +825,11 @@ export default {
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
dateFormats: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
defaultSubmitters: {
|
||||
type: Array,
|
||||
required: false,
|
||||
@@ -808,6 +840,11 @@ export default {
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
pagePreviewFormat: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '.jpg'
|
||||
},
|
||||
acceptFileTypes: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -953,6 +990,7 @@ export default {
|
||||
isLoadingBlankPage: false,
|
||||
isSaving: false,
|
||||
isDetectingPageFields: false,
|
||||
detectFieldsQueue: [],
|
||||
detectingAnalyzingProgress: null,
|
||||
detectingFieldsAddedCount: null,
|
||||
selectedSubmitter: null,
|
||||
@@ -963,7 +1001,8 @@ export default {
|
||||
drawCustomField: null,
|
||||
drawOption: null,
|
||||
dragField: null,
|
||||
isDragFile: false
|
||||
isDragFile: false,
|
||||
isMathLoaded: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -1003,6 +1042,8 @@ export default {
|
||||
return isMobileSafariIos || /android|iphone|ipad/i.test(navigator.userAgent)
|
||||
},
|
||||
defaultDateFormat () {
|
||||
if (this.dateFormats.length) return this.dateFormats[0]
|
||||
|
||||
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)$/)
|
||||
|
||||
@@ -1044,6 +1085,43 @@ export default {
|
||||
|
||||
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 () {
|
||||
return !this.defaultRequiredFields?.some((f) => {
|
||||
return !this.template.fields?.some((field) => field.name === f.name)
|
||||
@@ -1052,6 +1130,39 @@ export default {
|
||||
selectedField () {
|
||||
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 () {
|
||||
return this.template.schema.map((item) => {
|
||||
return this.template.documents.find(doc => doc.uuid === item.attachment_uuid)
|
||||
@@ -1147,6 +1258,132 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
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) {
|
||||
return this.$refs.fields.addCustomField(field)
|
||||
},
|
||||
@@ -1492,6 +1729,30 @@ export default {
|
||||
this.template.fields.push(field)
|
||||
}
|
||||
},
|
||||
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 || []
|
||||
existingField.areas.push(...(field.areas || []))
|
||||
} else {
|
||||
const customField = this.detectCustomFieldsIndex[indexKey] || this.detectCustomFieldsIndex[nameKey]
|
||||
|
||||
if (customField) this.applyCustomFieldAttributes(field, customField)
|
||||
|
||||
this.insertField(field)
|
||||
}
|
||||
},
|
||||
closeDropdown () {
|
||||
document.activeElement.blur()
|
||||
},
|
||||
@@ -2783,6 +3044,12 @@ export default {
|
||||
})
|
||||
},
|
||||
detectFieldsForPage ({ page, attachmentUuid }) {
|
||||
if (this.isDetectingPageFields) {
|
||||
this.detectFieldsQueue.push({ page, attachmentUuid })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.isDetectingPageFields = true
|
||||
this.detectingAnalyzingProgress = null
|
||||
this.detectingFieldsAddedCount = null
|
||||
@@ -2821,7 +3088,11 @@ export default {
|
||||
this.baseFetch(`/templates/${this.template.id}/detect_fields`, {
|
||||
method: 'POST',
|
||||
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) => {
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
@@ -2850,7 +3121,7 @@ export default {
|
||||
if (!f.submitter_uuid) {
|
||||
f.submitter_uuid = this.template.submitters[0].uuid
|
||||
}
|
||||
this.insertField(f)
|
||||
this.insertDetectedField(f)
|
||||
})
|
||||
|
||||
totalFieldsAdded += errorFields.length
|
||||
@@ -2879,7 +3150,7 @@ export default {
|
||||
|
||||
const nonOverlappingFields = filterNonOverlappingFields(finalFields)
|
||||
|
||||
nonOverlappingFields.forEach((f) => this.insertField(f))
|
||||
nonOverlappingFields.forEach((f) => this.insertDetectedField(f))
|
||||
totalFieldsAdded += nonOverlappingFields.length
|
||||
|
||||
if (nonOverlappingFields.length) {
|
||||
@@ -2917,7 +3188,7 @@ export default {
|
||||
|
||||
const nonOverlappingFields = filterNonOverlappingFields(finalFields)
|
||||
|
||||
nonOverlappingFields.forEach((f) => this.insertField(f))
|
||||
nonOverlappingFields.forEach((f) => this.insertDetectedField(f))
|
||||
totalFieldsAdded += nonOverlappingFields.length
|
||||
|
||||
if (nonOverlappingFields.length) {
|
||||
@@ -2935,7 +3206,7 @@ export default {
|
||||
|
||||
const nonOverlappingFields = filterNonOverlappingFields(finalFields)
|
||||
|
||||
nonOverlappingFields.forEach((f) => this.insertField(f))
|
||||
nonOverlappingFields.forEach((f) => this.insertDetectedField(f))
|
||||
totalFieldsAdded += nonOverlappingFields.length
|
||||
|
||||
if (nonOverlappingFields.length) {
|
||||
@@ -2968,6 +3239,10 @@ export default {
|
||||
setTimeout(() => {
|
||||
this.detectingFieldsAddedCount = null
|
||||
}, 1000)
|
||||
|
||||
if (this.detectFieldsQueue.length) {
|
||||
this.detectFieldsForPage(this.detectFieldsQueue.shift())
|
||||
}
|
||||
})
|
||||
},
|
||||
save ({ force } = { force: false }) {
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
:key="image.id"
|
||||
:ref="setPageRefs"
|
||||
:input-mode="inputMode"
|
||||
:conditional-field-index="conditionalFieldIndex"
|
||||
:formula-values-index="formulaValuesIndex"
|
||||
:number="index"
|
||||
:editable="editable"
|
||||
:data-page="index"
|
||||
@@ -64,6 +66,16 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
conditionalFieldIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
formulaValuesIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
areasIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
@@ -138,6 +150,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
pagePreviewFormat: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '.jpg'
|
||||
},
|
||||
withFieldsDetection: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -168,7 +185,7 @@ export default {
|
||||
return this.previewImagesIndex[i] || reactive({
|
||||
metadata: { ...lazyloadMetadata },
|
||||
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()]"
|
||||
>
|
||||
<option
|
||||
v-for="format in dateFormats"
|
||||
v-for="format in availableDateFormats"
|
||||
:key="format"
|
||||
:value="format"
|
||||
>{{ formatDate(new Date(), format) }}</option>
|
||||
@@ -248,7 +248,7 @@ export default {
|
||||
FieldType,
|
||||
IconSettings
|
||||
},
|
||||
inject: ['t', 'save', 'backgroundColor'],
|
||||
inject: ['t', 'save', 'backgroundColor', 'dateFormats'],
|
||||
provide () {
|
||||
return {
|
||||
fieldTypes: ['text', 'number', 'date', 'checkbox', 'radio', 'select']
|
||||
@@ -318,20 +318,23 @@ export default {
|
||||
'space'
|
||||
]
|
||||
},
|
||||
dateFormats () {
|
||||
const formats = [
|
||||
'MM/DD/YYYY',
|
||||
'DD/MM/YYYY',
|
||||
'YYYY-MM-DD',
|
||||
'DD-MM-YYYY',
|
||||
'DD.MM.YYYY',
|
||||
'MMM D, YYYY',
|
||||
'MMMM D, YYYY',
|
||||
'D MMM YYYY',
|
||||
'D MMMM YYYY'
|
||||
]
|
||||
availableDateFormats () {
|
||||
const formats = this.dateFormats.length
|
||||
? [...this.dateFormats]
|
||||
: [
|
||||
'MM/DD/YYYY',
|
||||
'DD/MM/YYYY',
|
||||
'YYYY-MM-DD',
|
||||
'DD-MM-YYYY',
|
||||
'DD.MM.YYYY',
|
||||
'MMM D, 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일')
|
||||
}
|
||||
|
||||
@@ -401,18 +404,47 @@ export default {
|
||||
formatDate (date, format) {
|
||||
const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
|
||||
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+/)],
|
||||
month: monthFormats[format.match(/M+/)],
|
||||
year: yearFormats[format.match(/Y+/)]
|
||||
}).formatToParts(date)
|
||||
}
|
||||
|
||||
return format
|
||||
.replace(/D+/, parts.find((p) => p.type === 'day').value)
|
||||
.replace(/M+/, parts.find((p) => p.type === 'month').value)
|
||||
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
|
||||
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'
|
||||
}
|
||||
|
||||
const parts = new Intl.DateTimeFormat([], opts).formatToParts(date)
|
||||
|
||||
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) => {
|
||||
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 () {
|
||||
this.$el.getRootNode().activeElement.blur()
|
||||
|
||||
@@ -345,7 +345,7 @@ export default {
|
||||
IconMathFunction,
|
||||
FieldType
|
||||
},
|
||||
inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale', 'getFieldTypeIndex'],
|
||||
inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale', 'getFieldTypeIndex', 'dateFormats'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
@@ -428,7 +428,8 @@ export default {
|
||||
|
||||
if (this.field.type === 'date') {
|
||||
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: {
|
||||
|
||||
@@ -514,7 +514,7 @@ export default {
|
||||
ContextSubmenu,
|
||||
ContextModal
|
||||
},
|
||||
inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies'],
|
||||
inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies', 'dateFormats'],
|
||||
props: {
|
||||
contextMenu: {
|
||||
type: Object,
|
||||
@@ -580,7 +580,7 @@ export default {
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldLabels: FieldType.computed.fieldLabels,
|
||||
validationOptions: FieldSettings.computed.validations,
|
||||
dateFormats: FieldSettings.computed.dateFormats,
|
||||
availableDateFormats: FieldSettings.computed.availableDateFormats,
|
||||
numberFormats: FieldSettings.computed.numberFormats,
|
||||
prefillableFieldTypes: FieldSettings.computed.prefillableFieldTypes,
|
||||
verificationMethods: FieldSettings.computed.verificationMethods,
|
||||
@@ -686,7 +686,7 @@ export default {
|
||||
},
|
||||
formatOptions () {
|
||||
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 'signature': return this.signatureFormats.map(f => ({ value: f, label: this.t(f) }))
|
||||
default: return []
|
||||
|
||||
@@ -290,7 +290,7 @@
|
||||
@change="$emit('save')"
|
||||
>
|
||||
<option
|
||||
v-for="format in dateFormats"
|
||||
v-for="format in availableDateFormats"
|
||||
:key="format"
|
||||
:value="format"
|
||||
>
|
||||
@@ -610,7 +610,7 @@ export default {
|
||||
IconTypography,
|
||||
IconX
|
||||
},
|
||||
inject: ['template', 't'],
|
||||
inject: ['template', 't', 'dateFormats'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
@@ -701,20 +701,23 @@ export default {
|
||||
'space'
|
||||
]
|
||||
},
|
||||
dateFormats () {
|
||||
const formats = [
|
||||
'MM/DD/YYYY',
|
||||
'DD/MM/YYYY',
|
||||
'YYYY-MM-DD',
|
||||
'DD-MM-YYYY',
|
||||
'DD.MM.YYYY',
|
||||
'MMM D, YYYY',
|
||||
'MMMM D, YYYY',
|
||||
'D MMM YYYY',
|
||||
'D MMMM YYYY'
|
||||
]
|
||||
availableDateFormats () {
|
||||
const formats = this.dateFormats.length
|
||||
? [...this.dateFormats]
|
||||
: [
|
||||
'MM/DD/YYYY',
|
||||
'DD/MM/YYYY',
|
||||
'YYYY-MM-DD',
|
||||
'DD-MM-YYYY',
|
||||
'DD.MM.YYYY',
|
||||
'MMM D, 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일')
|
||||
}
|
||||
|
||||
@@ -819,33 +822,49 @@ export default {
|
||||
return pattern?.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups || null
|
||||
},
|
||||
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 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 dayFormats = {
|
||||
D: 'numeric',
|
||||
DD: '2-digit'
|
||||
}
|
||||
|
||||
const yearFormats = {
|
||||
YYYY: 'numeric',
|
||||
YY: '2-digit'
|
||||
}
|
||||
|
||||
const parts = new Intl.DateTimeFormat([], {
|
||||
const opts = {
|
||||
day: dayFormats[format.match(/D+/)],
|
||||
month: monthFormats[format.match(/M+/)],
|
||||
year: yearFormats[format.match(/Y+/)]
|
||||
}).formatToParts(date)
|
||||
}
|
||||
|
||||
return format
|
||||
.replace(/D+/, parts.find((p) => p.type === 'day').value)
|
||||
.replace(/M+/, parts.find((p) => p.type === 'month').value)
|
||||
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
|
||||
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'
|
||||
}
|
||||
|
||||
const parts = new Intl.DateTimeFormat([], opts).formatToParts(date)
|
||||
|
||||
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) => {
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,13 +65,33 @@
|
||||
@set-draw="$emit('set-draw', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!isShowVariables && submitterDefaultFields.length && editable">
|
||||
<hr class="mb-2">
|
||||
<div
|
||||
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">
|
||||
<input
|
||||
v-model="defaultFieldsSearch"
|
||||
: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">
|
||||
</template>
|
||||
@@ -218,7 +238,7 @@
|
||||
</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"
|
||||
class="grid grid-cols-3 gap-1 pb-2 fields-grid"
|
||||
>
|
||||
@@ -418,6 +438,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withCustomFieldsTab: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withFieldsSearch: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -428,6 +453,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withDetectExistingFields: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withSignatureId: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -495,6 +525,11 @@ export default {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
detectCustomFieldsIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
showTourStartForm: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -557,7 +592,8 @@ export default {
|
||||
},
|
||||
submitterDefaultFields () {
|
||||
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 () {
|
||||
@@ -656,6 +692,78 @@ export default {
|
||||
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 () {
|
||||
const fields = []
|
||||
|
||||
@@ -665,7 +773,10 @@ export default {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
},
|
||||
...(this.withDetectExistingFields
|
||||
? { body: JSON.stringify({ fields: this.buildExistingFields() }) }
|
||||
: {})
|
||||
}).then(async (response) => {
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
@@ -687,7 +798,7 @@ export default {
|
||||
|
||||
if (data.error) {
|
||||
if ((data.fields || fields).length) {
|
||||
this.template.fields = data.fields || fields
|
||||
this.template.fields = (data.fields || fields).map((f) => this.enrichDetectedField(f))
|
||||
|
||||
this.save()
|
||||
} else {
|
||||
@@ -705,7 +816,7 @@ export default {
|
||||
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()
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ const en = {
|
||||
sign_yourself: 'Sign Yourself',
|
||||
set_signing_date: 'Set signing date',
|
||||
signing_date: 'Signing Date',
|
||||
signing_date_and_time: 'Signing Date and Time',
|
||||
send: 'Send',
|
||||
remove: 'Remove',
|
||||
edit: 'Edit',
|
||||
@@ -271,6 +272,7 @@ const es = {
|
||||
with_logo: 'Con logotipo',
|
||||
description: 'Descripción',
|
||||
signing_date: 'Fecha de Firma',
|
||||
signing_date_and_time: 'Fecha y Hora de Firma',
|
||||
display_title: 'Título de visualización',
|
||||
unchecked: 'No marcado',
|
||||
price: 'Precio',
|
||||
@@ -508,6 +510,7 @@ const it = {
|
||||
sign_yourself: 'Firma te stesso',
|
||||
set_signing_date: 'Imposta data di firma',
|
||||
signing_date: 'Data di firma',
|
||||
signing_date_and_time: 'Data e ora di firma',
|
||||
send: 'Invia',
|
||||
remove: 'Rimuovi',
|
||||
edit: 'Modifica',
|
||||
@@ -710,6 +713,7 @@ const pt = {
|
||||
description: 'Descrição',
|
||||
display_title: 'Título de exibição',
|
||||
signing_date: 'Data da Assinatura',
|
||||
signing_date_and_time: 'Data e Hora da Assinatura',
|
||||
unchecked: 'Não marcado',
|
||||
price: 'Preço',
|
||||
equal: 'Igual',
|
||||
@@ -946,6 +950,7 @@ const fr = {
|
||||
sign_yourself: 'Signer vous-même',
|
||||
set_signing_date: 'Définir la date de signature',
|
||||
signing_date: 'Date de signature',
|
||||
signing_date_and_time: 'Date et heure de signature',
|
||||
send: 'Envoyer',
|
||||
remove: 'Supprimer',
|
||||
edit: 'Modifier',
|
||||
@@ -1165,6 +1170,7 @@ const de = {
|
||||
sign_yourself: 'Selbst unterschreiben',
|
||||
set_signing_date: 'Unterzeichnungsdatum festlegen',
|
||||
signing_date: 'Unterzeichnungsdatum',
|
||||
signing_date_and_time: 'Unterzeichnungsdatum und -uhrzeit',
|
||||
send: 'Senden',
|
||||
remove: 'Entfernen',
|
||||
edit: 'Bearbeiten',
|
||||
@@ -1384,6 +1390,7 @@ const nl = {
|
||||
sign_yourself: 'Zelf ondertekenen',
|
||||
set_signing_date: 'Ondertekeningsdatum instellen',
|
||||
signing_date: 'Ondertekeningsdatum',
|
||||
signing_date_and_time: 'Ondertekeningsdatum en -tijd',
|
||||
send: 'Verzenden',
|
||||
remove: 'Verwijderen',
|
||||
edit: 'Bewerken',
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
:ref="setAreaRefs"
|
||||
:area="item.area"
|
||||
:input-mode="inputMode"
|
||||
:conditional-field-index="conditionalFieldIndex"
|
||||
:formula-values-index="formulaValuesIndex"
|
||||
:page-width="width"
|
||||
:page-height="height"
|
||||
:field="item.field"
|
||||
@@ -180,6 +182,16 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
conditionalFieldIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
formulaValuesIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
defaultFields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
|
||||
@@ -32,6 +32,7 @@ class Account < ApplicationRecord
|
||||
has_many :submitters, dependent: :destroy
|
||||
has_many :account_linked_accounts, 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_events, dependent: nil
|
||||
has_many :account_accesses, dependent: :destroy
|
||||
|
||||
@@ -43,6 +43,7 @@ class AccountConfig < ApplicationRecord
|
||||
DOWNLOAD_LINKS_EXPIRE_KEY = 'download_links_expire'
|
||||
FORCE_SSO_AUTH_KEY = 'force_sso_auth'
|
||||
FLATTEN_RESULT_PDF_KEY = 'flatten_result_pdf'
|
||||
ROTATE_INCREMENTAL_PDF_KEY = 'rotate_incremental_pdf'
|
||||
WITH_SIGNATURE_ID = 'with_signature_id'
|
||||
WITH_FILE_LINKS_KEY = 'with_file_links'
|
||||
WITH_SIGNATURE_ID_REASON_KEY = 'with_signature_id_reason'
|
||||
|
||||
@@ -73,8 +73,8 @@
|
||||
<% elsif field['type'] == 'date' %>
|
||||
<autosize-field></autosize-field>
|
||||
<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}}' %>
|
||||
<div class="w-full"><%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), local_assigns[:locale]) %></div>
|
||||
<% tz = local_assigns[:with_submitter_timezone] ? (submitter.timezone.presence || local_assigns[:timezone]) : local_assigns[:timezone] %>
|
||||
<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>
|
||||
<% elsif field['type'] == 'number' %>
|
||||
<autosize-field></autosize-field>
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
<% 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 %>
|
||||
<% (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'] %>">
|
||||
<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">
|
||||
@@ -281,7 +281,7 @@
|
||||
<% if field['type'] == 'number' %>
|
||||
<% value = NumberUtils.format_number(value, field.dig('preferences', 'format')) %>
|
||||
<% 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 %>
|
||||
<% if (mask = field.dig('preferences', 'mask').presence) %>
|
||||
<% 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 } : {} %>
|
||||
<% 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| %>
|
||||
<% 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'] %>">
|
||||
<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">
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="flex gap-2 mt-3">
|
||||
<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>
|
||||
<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') %>">
|
||||
<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') %>">
|
||||
<%= svg_icon('qrcode', class: 'w-6 h-6') %>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Docuseal
|
||||
URL_CACHE = ActiveSupport::Cache::MemoryStore.new
|
||||
PRODUCT_URL = 'https://www.docuseal.com'
|
||||
PRODUCT_EMAIL_URL = ENV.fetch('PRODUCT_EMAIL_URL', PRODUCT_URL)
|
||||
NEWSLETTER_URL = "#{PRODUCT_URL}/newsletters".freeze
|
||||
|
||||
@@ -410,9 +410,7 @@ module Submissions
|
||||
submitter.values = Submitters::SubmitValues.maybe_remove_condition_values(submitter)
|
||||
end
|
||||
|
||||
submitter.values = submitter.values.transform_values do |v|
|
||||
v == '{{date}}' ? Time.current.in_time_zone(submitter.submission.account.timezone).to_date.to_s : v
|
||||
end
|
||||
submitter.values = Submitters::SubmitValues.replace_current_date_placeholders(submitter)
|
||||
|
||||
submitter
|
||||
end
|
||||
|
||||
@@ -415,7 +415,8 @@ module Submissions
|
||||
composer.formatted_text_box([{ text: value.to_s.titleize }], padding: [0, 0, 10, 0])
|
||||
else
|
||||
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
|
||||
|
||||
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_SUBMITTER_TIMEZONE_KEY,
|
||||
AccountConfig::WITH_TIMESTAMP_SECONDS_KEY,
|
||||
AccountConfig::ROTATE_INCREMENTAL_PDF_KEY,
|
||||
AccountConfig::WITH_FILE_LINKS_KEY,
|
||||
AccountConfig::WITH_SIGNATURE_ID_REASON_KEY])
|
||||
|
||||
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
|
||||
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_timestamp_seconds = configs.find { |c| c.key == AccountConfig::WITH_TIMESTAMP_SECONDS_KEY }&.value == true
|
||||
with_signature_id_reason =
|
||||
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
|
||||
submission.submitters.where(id: submitter.id)
|
||||
|
||||
@@ -141,18 +141,21 @@ module Submissions
|
||||
AccountConfig::WITH_SIGNATURE_ID,
|
||||
AccountConfig::WITH_FILE_LINKS_KEY,
|
||||
AccountConfig::WITH_TIMESTAMP_SECONDS_KEY,
|
||||
AccountConfig::ROTATE_INCREMENTAL_PDF_KEY,
|
||||
AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY,
|
||||
AccountConfig::WITH_SIGNATURE_ID_REASON_KEY])
|
||||
|
||||
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_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_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_signature_id_reason =
|
||||
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?
|
||||
pdfs_index.each_value do |pdf|
|
||||
@@ -649,7 +652,10 @@ module Submissions
|
||||
end
|
||||
else
|
||||
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
|
||||
|
||||
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(':'))
|
||||
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:)
|
||||
|
||||
documents = Submissions::EnsureResultGenerated.call(latest_submitter) if latest_submitter
|
||||
@@ -826,7 +832,7 @@ module Submissions
|
||||
HexaPDF::Document.new(io: StringIO.new(attachment.download))
|
||||
end
|
||||
|
||||
pdf = maybe_rotate_pdf(pdf)
|
||||
pdf = maybe_rotate_pdf(pdf, incremental:)
|
||||
|
||||
maybe_flatten_pdf(pdf) if flatten
|
||||
|
||||
@@ -845,7 +851,7 @@ module Submissions
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
end
|
||||
|
||||
def maybe_rotate_pdf(pdf)
|
||||
def maybe_rotate_pdf(pdf, incremental: false)
|
||||
return pdf if pdf.pages.size > MAX_PAGE_ROTATE
|
||||
|
||||
is_pages_rotated = pdf.pages.root[:Rotate].present? && pdf.pages.root[:Rotate] != 0
|
||||
@@ -860,7 +866,7 @@ module Submissions
|
||||
|
||||
io = StringIO.new
|
||||
|
||||
pdf.write(io, incremental: false, validate: false)
|
||||
pdf.write(io, incremental:, validate: false)
|
||||
|
||||
HexaPDF::Document.new(io:)
|
||||
rescue StandardError => e
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ module Submitters
|
||||
UnableToSendCode = Class.new(StandardError)
|
||||
InvalidOtp = Class.new(StandardError)
|
||||
MaliciousFileExtension = Class.new(StandardError)
|
||||
ArgumentError = Class.new(StandardError)
|
||||
ParamsError = Class.new(StandardError)
|
||||
|
||||
DANGEROUS_EXTENSIONS = Set.new(%w[
|
||||
exe com bat cmd scr pif vbs vbe js jse wsf wsh msi msp
|
||||
@@ -135,7 +135,7 @@ module Submitters
|
||||
filename: file.original_filename,
|
||||
content_type: file.content_type)
|
||||
else
|
||||
raise ArgumentError, 'file param is missing'
|
||||
raise ParamsError, 'file param is missing'
|
||||
end
|
||||
|
||||
ActiveStorage::Attachment.create!(
|
||||
|
||||
@@ -103,17 +103,43 @@ module Submitters
|
||||
end
|
||||
|
||||
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
|
||||
elsif value.gsub(/\w/, '0') == field.dig('preferences', 'format').to_s.gsub(/\w/, '0')
|
||||
TimeUtils.parse_date_string(value, field.dig('preferences', 'format')).to_s
|
||||
elsif value.gsub(/\w/, '0') == format.to_s.gsub(/\w/, '0')
|
||||
TimeUtils.parse_date_string(value, format).to_s
|
||||
else
|
||||
Date.parse(value).to_s
|
||||
end
|
||||
rescue Date::Error
|
||||
rescue ArgumentError
|
||||
value
|
||||
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)
|
||||
if submitter_name && !for_submitter
|
||||
submitter =
|
||||
|
||||
@@ -86,9 +86,7 @@ module Submitters
|
||||
submitter.values = maybe_remove_condition_values(submitter, required_field_uuids_acc:)
|
||||
end
|
||||
|
||||
submitter.values = submitter.values.transform_values do |v|
|
||||
v == '{{date}}' ? Time.current.in_time_zone(submitter.account.timezone).to_date.to_s : v
|
||||
end
|
||||
submitter.values = replace_current_date_placeholders(submitter)
|
||||
|
||||
required_field_uuids_acc.each do |uuid|
|
||||
next if submitter.values[uuid].present?
|
||||
@@ -194,7 +192,7 @@ module Submitters
|
||||
|
||||
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
|
||||
|
||||
default_values.compact_blank.merge(submitter.values)
|
||||
@@ -248,7 +246,20 @@ module Submitters
|
||||
0
|
||||
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 submitter.blank?
|
||||
|
||||
@@ -257,7 +268,8 @@ module Submitters
|
||||
replace_default_variables(value,
|
||||
submitter.attributes.merge('role' => role),
|
||||
submitter.submission,
|
||||
with_time:)
|
||||
with_time:,
|
||||
field:)
|
||||
end
|
||||
|
||||
def maybe_remove_condition_values(submitter, required_field_uuids_acc: nil)
|
||||
@@ -394,7 +406,7 @@ module Submitters
|
||||
end
|
||||
# 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 if value.blank?
|
||||
|
||||
@@ -412,7 +424,7 @@ module Submitters
|
||||
when 'hour', 'minute', 'day', 'month', 'year'
|
||||
with_time ? Time.current.in_time_zone(submission.account.timezone).strftime(STRFTIME_MAP[key]) : e
|
||||
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'
|
||||
attrs[key] || e
|
||||
else
|
||||
|
||||
@@ -38,7 +38,7 @@ module Templates
|
||||
[documents, dynamic_documents]
|
||||
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
|
||||
|
||||
if file.content_type == PDF_CONTENT_TYPE
|
||||
@@ -54,6 +54,7 @@ module Templates
|
||||
io: StringIO.new(document_data),
|
||||
filename: file.original_filename,
|
||||
metadata: {
|
||||
**metadata,
|
||||
identified: file.content_type == PDF_CONTENT_TYPE,
|
||||
analyzed: file.content_type == PDF_CONTENT_TYPE,
|
||||
pdf: { annotations: }.compact_blank, sha256:
|
||||
|
||||
@@ -4,6 +4,7 @@ module Templates
|
||||
module ProcessDocument
|
||||
DPI = 200
|
||||
FORMAT = '.png'
|
||||
PREVIEW_FORMAT = '.jpg'
|
||||
ATTACHMENT_NAME = 'preview_images'
|
||||
|
||||
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
|
||||
|
||||
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))
|
||||
|
||||
ActiveStorage::Attachment.create!(
|
||||
@@ -141,7 +142,7 @@ module Templates
|
||||
if format == FORMAT
|
||||
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)
|
||||
else
|
||||
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)
|
||||
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
|
||||
ActiveStorage::Attachment.create!(
|
||||
|
||||
+88
-14
@@ -19,6 +19,47 @@ module TimeUtils
|
||||
'YY' => '%y'
|
||||
}.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 = 'DD/MM/YYYY'
|
||||
|
||||
@@ -42,27 +83,60 @@ module TimeUtils
|
||||
end
|
||||
end
|
||||
|
||||
def parse_date_string(string, pattern)
|
||||
pattern = pattern.sub(/Y+/, YEAR_FORMATS)
|
||||
.sub(/M+/, MONTH_FORMATS)
|
||||
.sub(/D+/, DAY_FORMATS)
|
||||
|
||||
Date.strptime(string, pattern)
|
||||
def format_with_time?(format)
|
||||
format.to_s.match?(/[HhAasz]/)
|
||||
end
|
||||
|
||||
def format_date_string(string, format, locale)
|
||||
date = Date.parse(string.to_s)
|
||||
def month_only_format?(format)
|
||||
format.to_s.present? && !format.to_s.match?(/[DdHhAasz]/)
|
||||
end
|
||||
|
||||
format = format.upcase if format
|
||||
def format_date_preview(format, locale, timezone)
|
||||
return '' if format.blank?
|
||||
|
||||
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
|
||||
|
||||
i18n_format = format.sub(/D+/) { DAY_FORMATS[format[/D+/]] }
|
||||
.sub(/M+/) { MONTH_FORMATS[format[/M+/]] }
|
||||
.sub(/Y+/) { YEAR_FORMATS[format[/Y+/]] }
|
||||
date =
|
||||
if format_with_time?(format)
|
||||
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)
|
||||
|
||||
I18n.l(date, format: i18n_format, locale:)
|
||||
rescue Date::Error
|
||||
Date.new(year, month, 1)
|
||||
else
|
||||
Date.parse(string.to_s)
|
||||
end
|
||||
|
||||
I18n.l(date, format: format.gsub(TOKEN_REGEX, ALL_FORMATS), locale:)
|
||||
rescue ArgumentError
|
||||
string
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user