From c7afe33c728bd11c75547300b5ff80a877298630 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Thu, 23 Apr 2026 20:25:41 +0300 Subject: [PATCH] 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')" >