app/javascript/js/controllers/fields/date_field_controller.js
import { Controller } from '@hotwired/stimulus'
import { DateTime } from 'luxon'
import flatpickr from 'flatpickr'
// Get the DateTime with the TZ offset applied.
function universalTimestamp(timestampStr) {
return new Date(new Date(timestampStr).getTime() + (new Date(timestampStr).getTimezoneOffset() * 60 * 1000))
}
const RAW_DATE_FORMAT = 'y/LL/dd'
const RAW_TIME_FORMAT = 'TT'
export default class extends Controller {
static targets = ['input', 'fakeInput']
static values = {
view: String,
timezone: String,
format: String,
enableTime: Boolean,
pickerFormat: String,
firstDayOfWeek: Number,
time24Hr: Boolean,
disableMobile: Boolean,
noCalendar: Boolean,
relative: Boolean,
fieldType: { type: String, default: 'dateTime' },
pickerOptions: { type: Object, default: {} },
}
flatpickrInstance;
cachedInitialValue;
get browserZone() {
const time = DateTime.local()
return time.zoneName
}
get initialValue() {
if (this.isOnShow || this.isOnIndex) {
return this.context.element.innerText
} if (this.isOnEdit) {
return this.inputTarget.value
}
return null
}
get isOnIndex() {
return this.viewValue === 'index'
}
get isOnEdit() {
return this.viewValue === 'edit'
}
get isOnShow() {
return this.viewValue === 'show'
}
get fieldIsDate() {
return this.fieldTypeValue === 'date'
}
get fieldIsDateTime() {
return this.fieldTypeValue === 'dateTime'
}
get fieldIsTime() {
return this.fieldTypeValue === 'time'
}
get fieldHasTime() {
return this.fieldIsTime || this.fieldIsDateTime
}
// Parse the time as if it were UTC
get parsedValue() {
return DateTime.fromISO(this.initialValue.trim(), { zone: 'UTC' })
}
get displayTimezone() {
return this.timezoneValue || this.browserZone
}
connect() {
// Cache the initial value so we can fill it back on disconnection.
// We do that so the JS parser will continue to work when the user hits the back button to return on this page.
this.cacheInitialValue()
if (this.isOnShow || this.isOnIndex) {
this.initShow()
} else if (this.isOnEdit) {
this.initEdit()
}
}
disconnect() {
if (this.isOnShow || this.isOnIndex) {
this.context.element.innerText = this.cachedInitialValue
} else if (this.isOnEdit) {
if (this.flatpickrInstance) this.flatpickrInstance.destroy()
}
}
cacheInitialValue() {
this.cachedInitialValue = this.initialValue
}
// Turns the value in the controller wrapper into the timezone of the browser
initShow() {
let value = this.parsedValue
// Set the zone only if the type of field is date time or relative time.
if (this.fieldHasTime && this.relativeValue) {
value = value.setZone(this.displayTimezone)
}
this.context.element.innerText = value.toFormat(this.formatValue)
}
initEdit() {
const options = {
enableTime: false,
enableSeconds: false,
// eslint-disable-next-line camelcase
time_24hr: this.time24HrValue,
locale: {
firstDayOfWeek: 0,
},
altInput: true,
onChange: this.onChange.bind(this),
noCalendar: false,
...this.pickerOptionsValue,
}
// Set the format of the displayed input field.
options.altFormat = this.pickerFormatValue
// Disable native input in mobile browsers
options.disableMobile = this.disableMobileValue
// Set first day of the week.
options.locale.firstDayOfWeek = this.firstDayOfWeekValue
// Enable time if needed.
options.enableTime = this.enableTimeValue
options.enableSeconds = this.enableTimeValue
// Hide calendar and only keep time picker.
options.noCalendar = this.noCalendarValue
if (this.fieldHasTime) {
options.dateFormat = 'Y-m-d H:i:S'
}
if (this.initialValue) {
switch (this.fieldTypeValue) {
case 'date':
options.defaultDate = universalTimestamp(this.initialValue)
break
default:
case 'time':
options.defaultDate = this.parsedValue.setZone(this.displayTimezone, { keepLocalTime: !this.relativeValue }).toISO()
break
case 'dateTime':
options.defaultDate = this.parsedValue.setZone(this.displayTimezone, { keepLocalTime: !this.relativeValue }).toISO()
break
}
}
this.flatpickrInstance = flatpickr(this.fakeInputTarget, options)
// Don't try to parse the value if the input is empty.
if (!this.initialValue) {
return
}
let value
switch (this.fieldTypeValue) {
case 'time':
// For time values, we should maintain the real value and format it to a time-friendly format.
value = this.parsedValue.setZone(this.displayTimezone, { keepLocalTime: true }).toFormat(RAW_TIME_FORMAT)
break
case 'date':
value = DateTime.fromJSDate(universalTimestamp(this.initialValue)).toFormat(RAW_DATE_FORMAT)
break
default:
case 'dateTime':
value = this.parsedValue.setZone(this.displayTimezone).toISO()
break
}
this.updateRealInput(value)
}
onChange(selectedDates) {
// No date has been selected
if (selectedDates.length === 0) {
this.updateRealInput('')
return
}
const timezonedDate = DateTime.fromISO(selectedDates[0].toISOString())
.setZone(this.displayTimezone, { keepLocalTime: true })
.setZone('UTC', { keepLocalTime: !this.relativeValue })
let value
switch (this.fieldTypeValue) {
case 'time':
// For time values, we should maintain the real value and format it to a time-friendly format.
value = timezonedDate.toFormat(RAW_TIME_FORMAT)
break
case 'date':
value = timezonedDate.toFormat(RAW_DATE_FORMAT)
break
default:
case 'dateTime':
value = timezonedDate.toISO()
break
}
this.updateRealInput(value)
}
// Value should be a string
updateRealInput(value) {
this.inputTarget.value = value
}
clear() {
this.fakeInputTarget._flatpickr.clear();
}
}