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')"
>