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/active_storage_blobs_proxy_controller.rb b/app/controllers/api/active_storage_blobs_proxy_controller.rb
index a542c637..8ade86c6 100644
--- a/app/controllers/api/active_storage_blobs_proxy_controller.rb
+++ b/app/controllers/api/active_storage_blobs_proxy_controller.rb
@@ -33,9 +33,15 @@ module Api
else
http_cache_forever public: true do
response.headers['Accept-Ranges'] = 'bytes'
- response.headers['Content-Length'] = blob.byte_size.to_s
- send_blob_stream blob, disposition: params[:disposition]
+ if request.head?
+ response.headers['Content-Type'] = blob.content_type_for_serving
+ head :ok
+ else
+ send_blob_stream blob, disposition: params[:disposition]
+ end
+
+ response.headers['Content-Length'] = blob.byte_size.to_s
end
end
end
@@ -57,8 +63,6 @@ module Api
return if !require_ttl && !require_auth
end
- Rollbar.error('Blob unauthorized') if defined?(Rollbar)
-
raise CanCan::AccessDenied
end
end
diff --git a/app/controllers/api/submitters_controller.rb b/app/controllers/api/submitters_controller.rb
index 71dad23a..fbaf3477 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
@@ -205,10 +203,15 @@ module Api
submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms')
submitter.preferences['reply_to'] = submitter_preferences['reply_to'] if submitter_preferences.key?('reply_to')
+
if submitter_preferences.key?('require_phone_2fa')
submitter.preferences['require_phone_2fa'] = submitter_preferences['require_phone_2fa']
end
+ if submitter_preferences.key?('require_email_2fa')
+ submitter.preferences['require_email_2fa'] = submitter_preferences['require_email_2fa']
+ end
+
if submitter_preferences.key?('go_to_last')
submitter.preferences['go_to_last'] = submitter_preferences['go_to_last']
end
diff --git a/app/controllers/preview_document_page_controller.rb b/app/controllers/preview_document_page_controller.rb
index d0b69f8d..2befe3bf 100644
--- a/app/controllers/preview_document_page_controller.rb
+++ b/app/controllers/preview_document_page_controller.rb
@@ -41,7 +41,7 @@ class PreviewDocumentPageController < ActionController::API
end
def find_or_create_document_tempfile_path(attachment)
- file_path = "#{Dir.tmpdir}/#{attachment.uuid}"
+ file_path = "#{Dir.tmpdir}/attachment-#{Digest::SHA1.hexdigest("#{attachment.id}-#{attachment.uuid}")}"
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f|
f.flock(File::LOCK_EX)
diff --git a/app/controllers/reveal_access_token_controller.rb b/app/controllers/reveal_access_token_controller.rb
index c8959afd..eef24b50 100644
--- a/app/controllers/reveal_access_token_controller.rb
+++ b/app/controllers/reveal_access_token_controller.rb
@@ -1,6 +1,14 @@
# frozen_string_literal: true
class RevealAccessTokenController < ApplicationController
+ rate_limit to: 4, within: 1.minute, only: %i[create], by: -> { current_user.id }, with: lambda {
+ Rollbar.error('Rate limit api key') if defined?(Rollbar)
+
+ render turbo_stream: turbo_stream.replace(:modal, template: 'reveal_access_token/show',
+ locals: { error_message: I18n.t(:too_many_attempts) }),
+ status: :unprocessable_content
+ }
+
def show
authorize!(:manage, current_user.access_token)
end
diff --git a/app/controllers/send_submission_email_controller.rb b/app/controllers/send_submission_email_controller.rb
index 3c4fd9c9..3ef58ee9 100644
--- a/app/controllers/send_submission_email_controller.rb
+++ b/app/controllers/send_submission_email_controller.rb
@@ -15,13 +15,20 @@ class SendSubmissionEmailController < ApplicationController
@submitter = find_completed_submitter
return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter
+
+ @submitter =
+ Submitter.completed.where(submission: template.submissions).find_by(email: params[:email].to_s.downcase)
+ elsif params[:submission_slug]
+ submission = Submission.find_by(slug: params[:submission_slug])
@embed_cors_account = @submitter.account
set_embed_cors_headers
- RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
+ if @submitter
+ RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
- SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
+ SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
+ end
respond_to do |f|
f.html { render :success }
diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb
index 2adb18b3..f32e1e1e 100644
--- a/app/controllers/templates_controller.rb
+++ b/app/controllers/templates_controller.rb
@@ -1,6 +1,10 @@
# frozen_string_literal: true
class TemplatesController < ApplicationController
+ TEMPLATE_FIELDS = %i[id author_id folder_id external_id name slug
+ schema fields submitters variables_schema preferences
+ shared_link source archived_at created_at updated_at].freeze
+
load_and_authorize_resource :template
def show
@@ -33,10 +37,11 @@ class TemplatesController < ApplicationController
).call
@template_data =
- @template.as_json.merge(
+ @template.as_json(only: TEMPLATE_FIELDS).merge(
documents: @template.schema_documents.as_json(
+ only: %i[id uuid],
methods: %i[metadata signed_key],
- include: { preview_images: { methods: %i[url metadata filename] } }
+ include: { preview_images: { only: %i[id], methods: %i[url metadata filename] } }
)
).to_json
diff --git a/app/controllers/testing_accounts_controller.rb b/app/controllers/testing_accounts_controller.rb
index 6c6cf3d1..44274eef 100644
--- a/app/controllers/testing_accounts_controller.rb
+++ b/app/controllers/testing_accounts_controller.rb
@@ -3,7 +3,7 @@
class TestingAccountsController < ApplicationController
skip_authorization_check only: :destroy
- def show
+ def create
authorize!(:manage, current_account)
authorize!(:manage, current_user)
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/draw.js b/app/javascript/draw.js
index 3b95b5ea..30d493a3 100644
--- a/app/javascript/draw.js
+++ b/app/javascript/draw.js
@@ -11,6 +11,7 @@ window.customElements.define('draw-signature', class extends HTMLElement {
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.canvas) return
+ if (!this.canvas.parentNode?.clientWidth) return
const { width, height } = this.canvas
@@ -89,7 +90,7 @@ window.customElements.define('draw-signature', class extends HTMLElement {
}
redrawCanvas (oldWidth, oldHeight) {
- if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
+ if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && this.canvas.width > 0 && this.canvas.height > 0) {
const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight
diff --git a/app/javascript/elements/signature_form.js b/app/javascript/elements/signature_form.js
index 5fc9af48..aa60b6fb 100644
--- a/app/javascript/elements/signature_form.js
+++ b/app/javascript/elements/signature_form.js
@@ -14,6 +14,7 @@ export default targetable(class extends HTMLElement {
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.canvas) return
+ if (!this.canvas.parentNode?.clientWidth) return
const { width, height } = this.canvas
@@ -80,7 +81,7 @@ export default targetable(class extends HTMLElement {
}
redrawCanvas (oldWidth, oldHeight) {
- if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
+ if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && this.canvas.width > 0 && this.canvas.height > 0) {
const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight
diff --git a/app/javascript/submission_form/appears_on.vue b/app/javascript/submission_form/appears_on.vue
index 48bdb028..219ef193 100644
--- a/app/javascript/submission_form/appears_on.vue
+++ b/app/javascript/submission_form/appears_on.vue
@@ -42,10 +42,25 @@ export default {
const areas = {}
this.field.areas?.forEach((area) => {
- areas[area.attachment_uuid + area.page] ||= area
+ areas[area.attachment_uuid] ||= []
+ areas[area.attachment_uuid].push(area)
})
- return Object.values(areas).slice(0, 6)
+ const sortedAreas = Object.values(areas).reduce((acc, group) => {
+ const seen = {}
+ const sortedGroup = [...group].sort((a, b) => a.page - b.page)
+
+ sortedGroup.forEach((area) => {
+ if (!seen[area.page]) {
+ seen[area.page] = true
+ acc.push(area)
+ }
+ })
+
+ return acc
+ }, [])
+
+ return sortedAreas.slice(0, 6)
}
}
}
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/submission_form/signature_step.vue b/app/javascript/submission_form/signature_step.vue
index c1f19be9..e568f59c 100644
--- a/app/javascript/submission_form/signature_step.vue
+++ b/app/javascript/submission_form/signature_step.vue
@@ -544,6 +544,7 @@ export default {
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.$refs.canvas) return
+ if (!this.$refs.canvas.parentNode?.clientWidth) return
const { width, height } = this.$refs.canvas
@@ -586,7 +587,7 @@ export default {
redrawCanvas (oldWidth, oldHeight) {
const canvas = this.$refs.canvas
- if (this.pad && !this.isTextSignature && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
+ if (this.pad && !this.isTextSignature && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && canvas.width > 0 && canvas.height > 0) {
const sx = canvas.width / oldWidth
const sy = canvas.height / oldHeight
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..b805af92 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: {
@@ -973,6 +1012,13 @@ export default {
fieldsDragFieldRef: () => ref(),
customDragFieldRef: () => ref(),
selectedAreasRef: () => ref([]),
+ attachmentUuidsIndex () {
+ return this.template.schema.reduce((acc, e, index) => {
+ acc[e.attachment_uuid] = index
+
+ return acc
+ }, {})
+ },
language () {
return this.locale.split('-')[0].toLowerCase()
},
@@ -1003,6 +1049,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 +1092,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 +1137,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 +1265,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)
},
@@ -1426,32 +1670,25 @@ export default {
this.save()
}
},
- findFieldInsertIndex (field) {
- if (!field.areas?.length) return -1
-
- const area = field.areas[0]
-
- const attachmentUuidsIndex = this.template.schema.reduce((acc, e, index) => {
- acc[e.attachment_uuid] = index
+ compareAreas (a, b) {
+ const aAttIdx = this.attachmentUuidsIndex[a.attachment_uuid]
+ const bAttIdx = this.attachmentUuidsIndex[b.attachment_uuid]
- return acc
- }, {})
+ if (aAttIdx !== bAttIdx) return aAttIdx - bAttIdx
+ if (a.page !== b.page) return a.page - b.page
- const compareAreas = (a, b) => {
- const aAttIdx = attachmentUuidsIndex[a.attachment_uuid]
- const bAttIdx = attachmentUuidsIndex[b.attachment_uuid]
+ const aY = a.y + a.h
+ const bY = b.y + b.h
- if (aAttIdx !== bAttIdx) return aAttIdx - bAttIdx
- if (a.page !== b.page) return a.page - b.page
+ if (Math.abs(aY - bY) < 0.01) return a.x - b.x
+ if (a.h < b.h ? a.y >= b.y && aY <= bY : b.y >= a.y && bY <= aY) return a.x - b.x
- const aY = a.y + a.h
- const bY = b.y + b.h
-
- if (Math.abs(aY - bY) < 0.01) return a.x - b.x
- if (a.h < b.h ? a.y >= b.y && aY <= bY : b.y >= a.y && bY <= aY) return a.x - b.x
+ return aY - bY
+ },
+ findFieldInsertIndex (field) {
+ if (!field.areas?.length) return -1
- return aY - bY
- }
+ const area = field.areas[0]
let closestBeforeIndex = -1
let closestBeforeArea = null
@@ -1461,15 +1698,15 @@ export default {
this.template.fields.forEach((f, index) => {
if (f.submitter_uuid === field.submitter_uuid) {
(f.areas || []).forEach((a) => {
- const cmp = compareAreas(a, area)
+ const cmp = this.compareAreas(a, area)
if (cmp < 0) {
- if (!closestBeforeArea || (compareAreas(a, closestBeforeArea) > 0 && closestBeforeIndex < index)) {
+ if (!closestBeforeArea || (this.compareAreas(a, closestBeforeArea) > 0 && closestBeforeIndex < index)) {
closestBeforeIndex = index
closestBeforeArea = a
}
} else {
- if (!closestAfterArea || (compareAreas(a, closestAfterArea) < 0 && closestAfterIndex > index)) {
+ if (!closestAfterArea || (this.compareAreas(a, closestAfterArea) < 0 && closestAfterIndex > index)) {
closestAfterIndex = index
closestAfterArea = a
}
@@ -1492,6 +1729,41 @@ export default {
this.template.fields.push(field)
}
},
+ insertArea (field, area) {
+ field.areas ||= []
+
+ const insertIndex = field.areas.findIndex((a) => this.compareAreas(a, area) > 0)
+
+ if (insertIndex === -1) {
+ field.areas.push(area)
+ } else {
+ field.areas.splice(insertIndex, 0, area)
+ }
+ },
+ insertDetectedField (field) {
+ if (!this.withDetectExistingFields || !field.name) {
+ this.insertField(field)
+
+ return
+ }
+
+ const role = this.template.submitters.find((s) => s.uuid === field.submitter_uuid)?.name
+ const nameKey = field.name.toLowerCase()
+ const indexKey = [field.name, role].filter(Boolean).join(':').toLowerCase()
+
+ const existingField = this.detectFieldsIndex[indexKey]
+
+ if (existingField) {
+ existingField.areas = existingField.areas || []
+ field.areas.forEach((area) => this.insertArea(existingField, area))
+ } else {
+ const customField = this.detectCustomFieldsIndex[indexKey] || this.detectCustomFieldsIndex[nameKey]
+
+ if (customField) this.applyCustomFieldAttributes(field, customField)
+
+ this.insertField(field)
+ }
+ },
closeDropdown () {
document.activeElement.blur()
},
@@ -1988,7 +2260,7 @@ export default {
fieldUuidIndex[field.uuid] = newField
- newField.areas.push(newArea)
+ this.insertArea(newField, newArea)
newAreas.push(newArea)
if (['radio', 'multiple'].includes(field.type) && field.options?.length) {
@@ -2101,17 +2373,7 @@ export default {
area.y -= area.h / 2
}
- this.drawField.areas ||= []
-
- const insertBeforeAreaIndex = this.drawField.areas.findIndex((a) => {
- return a.attachment_uuid === area.attachment_uuid && a.page > area.page
- })
-
- if (insertBeforeAreaIndex !== -1) {
- this.drawField.areas.splice(insertBeforeAreaIndex, 0, area)
- } else {
- this.drawField.areas.push(area)
- }
+ this.insertArea(this.drawField, area)
if (this.template.fields.indexOf(this.drawField) === -1) {
this.insertField(this.drawField)
@@ -2252,9 +2514,7 @@ export default {
delete field.height
}
- field.areas ||= []
-
- field.areas.push(fieldArea)
+ this.insertArea(field, fieldArea)
if (this.selectedAreasRef.value.length < 2) {
this.selectedAreasRef.value = [fieldArea]
@@ -2324,7 +2584,7 @@ export default {
}
}
- field.areas.push(fieldArea)
+ this.insertArea(field, fieldArea)
})
} else {
const fieldArea = {
@@ -2783,6 +3043,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 +3087,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 +3120,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 +3149,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 +3187,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 +3205,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 +3238,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..4b5a71f0 100644
--- a/app/javascript/template_builder/field_settings.vue
+++ b/app/javascript/template_builder/field_settings.vue
@@ -290,7 +290,7 @@
@change="$emit('save')"
>