Yeah, no.
Initial Investigations
Everything was fine in Chrome, but not in Firefox. I confirmed the fault also existed in IE (and then promptly ignored IE for now).
The responsible element looked like this:
<input class="form-control datepicker" data-date-format="{{ js_datepicker_format }}" type="date" name="departure_date" id="departure_date" value="{{ form.departure_date.value|default:'' }}">
This looks pretty innocent. It’s a date
input, how wrong can that be?
Sit comfortably, there’s a rabbit hole coming up.
On Date Inputs
Date type inputs are a relatively new thing, they’re in the HTML5 Spec. Support for it is pretty mixed. This jumps out as being the cause of it working in Chrome, but nothing else. Onwards investigations (and flapping at colleagues) led to the fact that we use bootstrap-datepicker to provide a JS/CSS based implementation for the browsers that have no native support.
We have an isolated cause for the problem. It is obviously something to do with bootstrap-datepicker, clearly. Right?
On Wire Formats and Localisation
See that data-date-format="{{ js_datepicker_format }}"
attribute of the input element. That’s setting the date format for bootstrap-datepicker. The HTML5 date element doesn’t have similar. I’m going to cite this stackoverflow answer rather than the appropriate sections of the documentation. The HTML5 element has the concept of a wire format
and a presentation format
. The wire format
is YYYY-MM-DD
(iso8601), the presentation format is whatever the user has the locale set to in their browser.
You have no control over this, it will do that and you can do nothing about it.
bootstrap-datepicker, meanwhile has the data-date-format
element, which controls everything about the date that it displays and outputs. There’s only one option for this, the wire
and presentation
formats are not separated.
This leads to an issue. If you set the date in YYYY-MM-DD format for the html5 element value
, then Chrome will work. If you set it to anything else, then Chrome will not work and bootstrap-datepicker might, depending on if the format matches what is expected.
There’s another issue. bootstrap-datepicker doesn’t do anything with the element value
when you start it. So if you set the value to YYYY-MM-DD
format (for Chrome), then a Firefox user will see 2015-06-24
, until they select something, at which point it will change to whatever you specified in data-date-format
. But a Chrome user will see it in their local format (24/06/2015
for me, GB format currently).
It’s all broken, Jim.
A sidetrack into Javascript date formats.
The usual answer for anything to do with dates in JS is ‘use moment.js’. But why? It’s a fairly large module, this is a small problem, surely we can just avoid it?
Give me a date:
>>> var d = new Date();
undefined
Lets make a date string!
>>> d.getYear() + d.getMonth() + d.getDay() + ""
"123"
Wat. (Yeah, I know that’s not how you do string formatting and therefore it’s my fault.)
>>> d.getDay()
3
It’s currently 2015-06-24. Why 3
?.
Oh, that’s day of the week. Clearly.
>>> d.getDate()
24
The method that gets you the day of the month is called getDate()
. It doesn’t, you know, RETURN A DATE.
>>> var d = new Date('10-06-2015')
undefined
>>> d
Tue Oct 06 2015 00:00:00 GMT+0100 (BST)
Oh. Default date format is US format (MM-DD-YYYY
). Right. Wat.
>>> var d = new Date('31-06-2015')
undefined
>>> d
Invalid Date
That’s… reasonable, given the above. Except that’s a magic object that says Invalid Date
. But at least I can compare against it.
>>> var d = new Date('31/06/2015')
undefined
>>> d
Invalid Date
Oh great, same behaviour if I give it UK date formats (/
rather than -
). That’s okay.
>>> var d = new Date('31/06/2015')
undefined
>>> d
"Date 2017-07-05T23:00:00.000Z"
Wat.
What’s going on?
The difference here is that I’ve used Firefox, the previous examples are in Chrome. I tried to give an explanation of what that’s done, but I actually have no idea. I know it’s 31 months from something, as it’s parsed the 31 months and added it to something. But I can’t work out what, and I’ve spent too long on this already. Help. Stop.
So. Why you should use moment.js. Because otherwise the old great ones will be summoned and you will go mad.
Also:
ISO Date Format is not supported in Internet Explorer 8 standards mode and Quirks mode.
Yep.
The Actual Problem
Now I knew all of this, I could see the problem.
- The HTML5 widget expects YYYY-MM-DD
- The JS widget will set whatever you ask it to
- We were outputting GB formats into the form after submission
- This would then be an incorrect format for the HTML 5 widget
- The native widget would not change an existing date until a new one is selected, so changing the output format to YYYY-MM-DD meant that it changed when a user selected something.
A Solution In Two Parts
The solution is to standardise the behaviour and formats across both options. Since I have no control over the HTML5 widget, looks like it’s time to take a dive into bootstrap-datepicker and make that do the same thing.
Deep breath, and here we go…
Part 1
First job is to standardise the output date format in all the places. This means that the template needs to see a datetime
object, not a preformatted date.
Once this is done, can feed the object into the date
template tag, with the format filter. Which takes PHP date format strings. Okay, that’s helpful in 2015. Really.
Figured that out, changed the date parsing Date Input Formats and make sure it has the right ISO format in it.
That made the HTML5 element work consistently. Great.
Then, to the javascript widget.
bootstrap-datepicker does not do anything with the initial value
of the element. To make it behave the same as the HTML5 widget, you need to:
1. Get the locale of the user
2. Get the date format for that locale
3. Set that as the format of the datepicker
4. Read the value
5. Convert the value into the right format
6. Call the setValue
event of the datepicker with that value
This should be relatively straightforward, with a couple of complications.
- moment.js uses a different date format to bootstrap-datepicker
- There is no easy way to get a date format string, so a hardcoded list is the best solution.
// taken from bootstrap-datepicker.js
function parseFormat(format) {
var separator = format.match(/[./-s].*?/),
parts = format.split(/W+/);
if (!separator || !parts || parts.length === 0){
throw new Error("Invalid date format.");
}
return {separator: separator, parts: parts};
}
var momentUserDateFormat = getLocaleDateString(true);
var datepickerUserDateFormat = getLocaleDateString(false);
$datepicker.each(function() {
var $this = $(this);
var presetData = $this.val();
$this.data('datepicker').format = parseFormat(datepickerUserDateFormat);
if (presetData) {
$this.datepicker('setValue', moment(presetData).format(momentUserDateFormat));
}
});
A bit of copy and paste code from the bootstrap-datepicker library, some jquery and moment.js and the problem is solved.
Part 3
Now we have the dates displaying in the right format on page load, we need to ensure they’re sent in the right format after the user has submitted the form. Should just be the reverse operation.
function rewriteDateFormat(event) {
var $this = $(event.data.input);
if ($this.val()) {
var momentUserDateFormat = getLocaleDateString(true);
$this.val(moment($this.val(), [momentUserDateFormat, 'YYYY-MM-DD']).format('YYYY-MM-DD'));
}
}
$datepicker.each(function() {
var $this = $(this);
// set the form handler for rewriting the format on submit
var $form = $this.closest('form');
$form.on('submit', {input: this}, rewriteDateFormat);
});
And we’re done.
Takeaways
Some final points that I’ve learnt.
- Always work in datetime objects until the last possible point. You don’t have to format them.
- Default to ISO format unless otherwise instructed
- Use parsing libraries