From c07bc6687c3933d30b2cc270c494e0758e1a4626 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Mon, 20 Apr 2026 15:43:41 +0300 Subject: [PATCH 01/15] add metadata param --- lib/templates/create_attachments.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/templates/create_attachments.rb b/lib/templates/create_attachments.rb index 95358585..15eee218 100644 --- a/lib/templates/create_attachments.rb +++ b/lib/templates/create_attachments.rb @@ -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: From 992a1b26c003f96c157744c45733235733f3f659 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Mon, 20 Apr 2026 20:18:07 +0300 Subject: [PATCH 02/15] adjust tooltip position --- app/views/templates_share_link/show.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/templates_share_link/show.html.erb b/app/views/templates_share_link/show.html.erb index 910559b4..e6b48f4c 100644 --- a/app/views/templates_share_link/show.html.erb +++ b/app/views/templates_share_link/show.html.erb @@ -25,7 +25,7 @@
From 4adb1001b0d3eda454fb611b8af455804532955f Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Tue, 21 Apr 2026 12:35:00 +0300 Subject: [PATCH 03/15] add rotate incremental config --- app/models/account_config.rb | 1 + lib/submissions/generate_preview_attachments.rb | 5 ++++- lib/submissions/generate_result_attachments.rb | 13 ++++++++----- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/app/models/account_config.rb b/app/models/account_config.rb index 8d41bca4..2db37d70 100644 --- a/app/models/account_config.rb +++ b/app/models/account_config.rb @@ -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' diff --git a/lib/submissions/generate_preview_attachments.rb b/lib/submissions/generate_preview_attachments.rb index a5e22632..cbfa373d 100644 --- a/lib/submissions/generate_preview_attachments.rb +++ b/lib/submissions/generate_preview_attachments.rb @@ -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) diff --git a/lib/submissions/generate_result_attachments.rb b/lib/submissions/generate_result_attachments.rb index b538f6bc..55d77f20 100644 --- a/lib/submissions/generate_result_attachments.rb +++ b/lib/submissions/generate_result_attachments.rb @@ -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| @@ -802,7 +805,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 +829,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 +848,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 +863,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 From e82faf83200c231ebc91f7dbd73f4ff27b1ef4f8 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 22 Apr 2026 07:43:45 +0300 Subject: [PATCH 04/15] queue page field detection --- app/javascript/template_builder/builder.vue | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index 4830f601..ae710b18 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -953,6 +953,7 @@ export default { isLoadingBlankPage: false, isSaving: false, isDetectingPageFields: false, + detectFieldsQueue: [], detectingAnalyzingProgress: null, detectingFieldsAddedCount: null, selectedSubmitter: null, @@ -2783,6 +2784,12 @@ export default { }) }, detectFieldsForPage ({ page, attachmentUuid }) { + if (this.isDetectingPageFields) { + this.detectFieldsQueue.push({ page, attachmentUuid }) + + return + } + this.isDetectingPageFields = true this.detectingAnalyzingProgress = null this.detectingFieldsAddedCount = null @@ -2968,6 +2975,10 @@ export default { setTimeout(() => { this.detectingFieldsAddedCount = null }, 1000) + + if (this.detectFieldsQueue.length) { + this.detectFieldsForPage(this.detectFieldsQueue.shift()) + } }) }, save ({ force } = { force: false }) { From 1e2c752937f4b62911919d7137cdc643f4fbeb4b Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 22 Apr 2026 10:27:18 +0300 Subject: [PATCH 05/15] detect existing fields --- app/javascript/application.js | 1 + app/javascript/template_builder/builder.vue | 80 ++++++++++++++++-- app/javascript/template_builder/fields.vue | 91 ++++++++++++++++++++- 3 files changed, 164 insertions(+), 8 deletions(-) diff --git a/app/javascript/application.js b/app/javascript/application.js index 920cba82..29bc52dd 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, diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index ae710b18..26ae49bc 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -506,9 +506,11 @@ :default-fields="[...defaultRequiredFields, ...defaultFields]" :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" @@ -738,6 +740,11 @@ export default { required: false, default: false }, + withDetectExistingFields: { + type: Boolean, + required: false, + default: false + }, withCustomFields: { type: Boolean, required: false, @@ -1053,6 +1060,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) @@ -1148,6 +1188,8 @@ export default { }, methods: { toRaw, + applyCustomFieldAttributes: Fields.methods.applyCustomFieldAttributes, + buildExistingFields: Fields.methods.buildExistingFields, addCustomField (field) { return this.$refs.fields.addCustomField(field) }, @@ -1493,6 +1535,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() }, @@ -2828,7 +2894,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') @@ -2857,7 +2927,7 @@ export default { if (!f.submitter_uuid) { f.submitter_uuid = this.template.submitters[0].uuid } - this.insertField(f) + this.insertDetectedField(f) }) totalFieldsAdded += errorFields.length @@ -2886,7 +2956,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) { @@ -2924,7 +2994,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) { @@ -2942,7 +3012,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) { diff --git a/app/javascript/template_builder/fields.vue b/app/javascript/template_builder/fields.vue index 7155414d..0ff497c9 100644 --- a/app/javascript/template_builder/fields.vue +++ b/app/javascript/template_builder/fields.vue @@ -428,6 +428,11 @@ export default { required: false, default: false }, + withDetectExistingFields: { + type: Boolean, + required: false, + default: false + }, withSignatureId: { type: Boolean, required: false, @@ -495,6 +500,11 @@ export default { type: Object, required: true }, + detectCustomFieldsIndex: { + type: Object, + required: false, + default: () => ({}) + }, showTourStartForm: { type: Boolean, required: false, @@ -656,6 +666,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 +747,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 +772,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 +790,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() From 6d13119ee0d7ece0576850a08dbe6039ba703e18 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Tue, 21 Apr 2026 19:54:18 +0300 Subject: [PATCH 06/15] input mode condition and formula --- app/javascript/template_builder/area.vue | 54 +++++- app/javascript/template_builder/builder.vue | 176 ++++++++++++++++++- app/javascript/template_builder/document.vue | 12 ++ app/javascript/template_builder/page.vue | 12 ++ 4 files changed, 246 insertions(+), 8 deletions(-) diff --git a/app/javascript/template_builder/area.vue b/app/javascript/template_builder/area.vue index bbf1a357..85be8e92 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) }} @@ -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 }} + >{{ 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 26ae49bc..66b3cf86 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -381,6 +381,8 @@ :document="document" :is-drag="!!dragField" :input-mode="inputMode" + :conditional-field-index="conditionalFieldIndex" + :formula-values-index="formulaValuesIndex" :default-fields="[...defaultRequiredFields, ...defaultFields]" :allow-draw="!onlyDefinedFields || drawField || drawCustomField" :with-signature-id="withSignatureId" @@ -619,6 +621,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: { @@ -971,7 +983,8 @@ export default { drawCustomField: null, drawOption: null, dragField: null, - isDragFile: false + isDragFile: false, + isMathLoaded: false } }, computed: { @@ -1052,6 +1065,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) @@ -1190,6 +1240,130 @@ export default { 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) }, diff --git a/app/javascript/template_builder/document.vue b/app/javascript/template_builder/document.vue index 19a524c9..ee9a0836 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, diff --git a/app/javascript/template_builder/page.vue b/app/javascript/template_builder/page.vue index 8580198c..9f1ce2d3 100644 --- a/app/javascript/template_builder/page.vue +++ b/app/javascript/template_builder/page.vue @@ -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, From 7c6a7513fd771c162efd4f1d749fdfce5b95f385 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Fri, 24 Apr 2026 09:32:39 +0300 Subject: [PATCH 07/15] add with custom field tab --- app/javascript/template_builder/builder.vue | 6 ++++ app/javascript/template_builder/fields.vue | 36 ++++++++++++++++++--- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index 66b3cf86..5d7e635a 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -506,6 +506,7 @@ :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" @@ -797,6 +798,11 @@ export default { required: false, default: () => [] }, + withCustomFieldsTab: { + type: Boolean, + required: false, + default: false + }, withSelectedFieldType: { type: Boolean, required: false, diff --git a/app/javascript/template_builder/fields.vue b/app/javascript/template_builder/fields.vue index 0ff497c9..7f15af82 100644 --- a/app/javascript/template_builder/fields.vue +++ b/app/javascript/template_builder/fields.vue @@ -65,13 +65,33 @@ @set-draw="$emit('set-draw', $event)" /> -
-
+ +
+
@@ -218,7 +238,7 @@
@@ -418,6 +438,11 @@ export default { required: false, default: false }, + withCustomFieldsTab: { + type: Boolean, + required: false, + default: false + }, withFieldsSearch: { type: Boolean, required: false, @@ -567,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 () { From fb1c1f117b57a1df74326b6a517d7ca5c86497d0 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Fri, 24 Apr 2026 10:52:13 +0300 Subject: [PATCH 08/15] remove unused const --- lib/docuseal.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/docuseal.rb b/lib/docuseal.rb index 7bba653c..98caaf72 100644 --- a/lib/docuseal.rb +++ b/lib/docuseal.rb @@ -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 From 82500c2d7d24165b52b2ae90cdcd4a0d4fd14442 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Fri, 24 Apr 2026 10:53:53 +0300 Subject: [PATCH 09/15] update dep --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From c7afe33c728bd11c75547300b5ff80a877298630 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Thu, 23 Apr 2026 20:25:41 +0300 Subject: [PATCH 10/15] add time formats --- app/controllers/api/submitters_controller.rb | 4 +- app/javascript/submission_form/area.vue | 70 +++++++----- app/javascript/submission_form/date_step.vue | 71 ++++++++++--- app/javascript/template_builder/area.vue | 2 +- app/javascript/template_builder/builder.vue | 8 ++ .../template_builder/dynamic_variable.vue | 76 +++++++++---- app/javascript/template_builder/field.vue | 5 +- .../template_builder/field_context_menu.vue | 6 +- .../template_builder/field_settings.vue | 91 +++++++++------- app/javascript/template_builder/i18n.js | 7 ++ app/views/submissions/_value.html.erb | 4 +- app/views/submissions/show.html.erb | 2 +- lib/submissions/create_from_submitters.rb | 4 +- lib/submissions/generate_audit_trail.rb | 3 +- .../generate_result_attachments.rb | 5 +- lib/submitters/normalize_values.rb | 34 +++++- lib/submitters/submit_values.rb | 28 +++-- lib/time_utils.rb | 100 +++++++++++++++--- 18 files changed, 380 insertions(+), 140 deletions(-) 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/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 85be8e92..021f7dd7 100644 --- a/app/javascript/template_builder/area.vue +++ b/app/javascript/template_builder/area.vue @@ -137,7 +137,7 @@ - {{ t('signing_date') }} + {{ /[HhAasz]/.test(field.preferences?.format || '') ? t('signing_date_and_time') : t('signing_date') }}
[] }, + dateFormats: { + type: Array, + required: false, + default: () => [] + }, defaultSubmitters: { type: Array, required: false, @@ -1030,6 +1036,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)$/) 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')" >
{{ t('default') }} Date: Fri, 24 Apr 2026 18:35:29 +0300 Subject: [PATCH 12/15] adjust compression --- lib/templates/process_document.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/templates/process_document.rb b/lib/templates/process_document.rb index 6b40a502..f4690fe5 100644 --- a/lib/templates/process_document.rb +++ b/lib/templates/process_document.rb @@ -69,7 +69,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 +141,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) From 39407557f2da0971f943448e8cb2f960e96ec3e0 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Fri, 24 Apr 2026 21:44:57 +0300 Subject: [PATCH 13/15] adjust page preview --- app/javascript/application.js | 1 + app/javascript/template_builder/builder.vue | 6 ++++++ app/javascript/template_builder/document.vue | 7 ++++++- app/views/submissions/show.html.erb | 2 +- app/views/submit_form/show.html.erb | 2 +- lib/templates/process_document.rb | 3 ++- 6 files changed, 17 insertions(+), 4 deletions(-) diff --git a/app/javascript/application.js b/app/javascript/application.js index 29bc52dd..ff617caa 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -181,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/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index e9d6bf91..8e8d9c30 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -383,6 +383,7 @@ :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" @@ -839,6 +840,11 @@ export default { required: false, default: () => [] }, + pagePreviewFormat: { + type: String, + required: false, + default: '.jpg' + }, acceptFileTypes: { type: String, required: false, diff --git a/app/javascript/template_builder/document.vue b/app/javascript/template_builder/document.vue index ee9a0836..8a96825c 100644 --- a/app/javascript/template_builder/document.vue +++ b/app/javascript/template_builder/document.vue @@ -150,6 +150,11 @@ export default { required: false, default: false }, + pagePreviewFormat: { + type: String, + required: false, + default: '.jpg' + }, withFieldsDetection: { type: Boolean, required: false, @@ -180,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/views/submissions/show.html.erb b/app/views/submissions/show.html.erb index ffae0efa..335f1ff6 100644 --- a/app/views/submissions/show.html.erb +++ b/app/views/submissions/show.html.erb @@ -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}")) %> " 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'] %>"> <%= ">
diff --git a/app/views/submit_form/show.html.erb b/app/views/submit_form/show.html.erb index 67c49999..0d9a229d 100644 --- a/app/views/submit_form/show.html.erb +++ b/app/views/submit_form/show.html.erb @@ -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}")) %> <%= ">
diff --git a/lib/templates/process_document.rb b/lib/templates/process_document.rb index f4690fe5..af4e1697 100644 --- a/lib/templates/process_document.rb +++ b/lib/templates/process_document.rb @@ -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} @@ -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!( From 570482e12d20bb62b3e8c30e8e7293d24fc5531b Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sat, 25 Apr 2026 12:39:26 +0300 Subject: [PATCH 14/15] rename error --- lib/submitters.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/submitters.rb b/lib/submitters.rb index ec8330b1..6e14b594 100644 --- a/lib/submitters.rb +++ b/lib/submitters.rb @@ -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!( From b93c7cd261723ddeb27f41dde1b5fac175f12f5a Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sun, 26 Apr 2026 10:24:20 +0300 Subject: [PATCH 15/15] add has_many document_metadata --- app/models/account.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/account.rb b/app/models/account.rb index bc6471bf..d3d53d0c 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -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