diff --git a/Gemfile.lock b/Gemfile.lock index de5ce993..3e602949 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/app/controllers/api/submitters_controller.rb b/app/controllers/api/submitters_controller.rb index 71dad23a..1b20b87d 100644 --- a/app/controllers/api/submitters_controller.rb +++ b/app/controllers/api/submitters_controller.rb @@ -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 diff --git a/app/javascript/application.js b/app/javascript/application.js index 920cba82..ff617caa 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -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), diff --git a/app/javascript/submission_form/area.vue b/app/javascript/submission_form/area.vue index e3138254..69a453ac 100644 --- a/app/javascript/submission_form/area.vue +++ b/app/javascript/submission_form/area.vue @@ -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 hasTime = /[HhAasz]/.test(format) - const dayFormats = { - D: 'numeric', - DD: '2-digit' + const opts = { + day: dayFormats[format.match(/D+/)], + month: monthFormats[format.match(/M+/)], + year: yearFormats[format.match(/Y+/)] } - const yearFormats = { - YYYY: 'numeric', - YYY: 'numeric', - YY: '2-digit' + 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([], { - day: dayFormats[format.match(/D+/)], - month: monthFormats[format.match(/M+/)], - year: yearFormats[format.match(/Y+/)], - timeZone: 'UTC' - }).formatToParts(date) + 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 - 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 (token === 'A') return (value || '').toUpperCase() + if (token === 'a') return (value || '').toLowerCase() + + return value + }) }, updateMultipleSelectValue (value) { if (this.modelValue?.includes(value)) { diff --git a/app/javascript/submission_form/date_step.vue b/app/javascript/submission_form/date_step.vue index ba4a8dce..245573da 100644 --- a/app/javascript/submission_form/date_step.vue +++ b/app/javascript/submission_form/date_step.vue @@ -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" > + @@ -97,14 +103,19 @@ export default { }, emits: ['update:model-value', 'focus', 'submit'], computed: { - dateNowString () { - const today = new Date() + formatType () { + const format = this.field.preferences?.format || '' - const yyyy = today.getFullYear() - const mm = String(today.getMonth() + 1).padStart(2, '0') - const dd = String(today.getDate()).padStart(2, '0') + if (/[HhAasz]/.test(format)) return 'datetime' + if (format && !/[Dd]/.test(format)) return 'month' - return `${yyyy}-${mm}-${dd}` + return 'date' + }, + inputType () { + return { datetime: 'datetime-local', month: 'month', date: 'date' }[this.formatType] + }, + dateNowString () { + 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 - - inputEl.valueAsDate = new Date(parsedDate.getTime() - parsedDate.getTimezoneOffset() * 60000) + if (isNaN(parsedDate)) return - 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 } } } diff --git a/app/javascript/template_builder/area.vue b/app/javascript/template_builder/area.vue index bbf1a357..021f7dd7 100644 --- a/app/javascript/template_builder/area.vue +++ b/app/javascript/template_builder/area.vue @@ -113,7 +113,7 @@
{{ formatNumber(field.default_value, field.preferences?.format) }} + >{{ formatNumber(displayValue, field.preferences?.format) }} - {{ t('signing_date') }} + {{ /[HhAasz]/.test(field.preferences?.format || '') ? t('signing_date_and_time') : t('signing_date') }}
{{ field.default_value }} + >{{ displayValue }}
({}) + }, + 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 diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index 4830f601..8e8d9c30 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -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 }) { diff --git a/app/javascript/template_builder/document.vue b/app/javascript/template_builder/document.vue index 19a524c9..8a96825c 100644 --- a/app/javascript/template_builder/document.vue +++ b/app/javascript/template_builder/document.vue @@ -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}` }) }) }, diff --git a/app/javascript/template_builder/dynamic_variable.vue b/app/javascript/template_builder/dynamic_variable.vue index aaac22db..d0ad3ac2 100644 --- a/app/javascript/template_builder/dynamic_variable.vue +++ b/app/javascript/template_builder/dynamic_variable.vue @@ -119,7 +119,7 @@ @change="[schema.format = $event.target.value, save()]" > @@ -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) + } + + 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 - .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) + 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() diff --git a/app/javascript/template_builder/field.vue b/app/javascript/template_builder/field.vue index 00f6d5d7..8ffacee8 100644 --- a/app/javascript/template_builder/field.vue +++ b/app/javascript/template_builder/field.vue @@ -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: { diff --git a/app/javascript/template_builder/field_context_menu.vue b/app/javascript/template_builder/field_context_menu.vue index 9635f189..388ee80e 100644 --- a/app/javascript/template_builder/field_context_menu.vue +++ b/app/javascript/template_builder/field_context_menu.vue @@ -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 [] diff --git a/app/javascript/template_builder/field_settings.vue b/app/javascript/template_builder/field_settings.vue index 378ab048..511ad6b4 100644 --- a/app/javascript/template_builder/field_settings.vue +++ b/app/javascript/template_builder/field_settings.vue @@ -290,7 +290,7 @@ @change="$emit('save')" >