Improving Django Admin’s Date-Time Widget
The datetime widget used in Django admin looks like this:
It’s mostly fine, but there are a few ways that I think it can be improved:
- It takes up too much vertical space. I would prefer that it was along a single line like this:
- It takes two clicks to set the current date and time. I would like the Now link to update both the date and the time (it currently just updates the time). When users set the time to the current time, they also usually intend to set the date to the current date.
- Reduce the width of the inputs so the links don’t drop down on mobile, like this:
Limiting the width of the
input
to10rem
, which is more than enough to fit the date and time values, keeps the links on the same line as theinput
:
Making It Clear to the User What’s Happening
One more thing: because some users might not expect the Now link to update the date as well as the time, we need to make sure that it’s clear that both are getting updated. We can use a little highlighting to do that: (Click image to repeat animation)
When the user clicks the Today link, only the date gets updated. But when the user clicks the Now link, both the date and time get updated. To make sure the user is aware of that, we give the updated fields a green background, which quickly fades out.
Possible Approaches
There are two possible approaches that I considered:
- Override the split_datetime.html template.
- Use JavaScript to find and modify all the datetime widgets.
At first glance, the first approach seems simpler, but it turns out to be pretty tricky. You would think that you could just add your own split_datetime.html template in a templates/admin/widgets folder to override the built-in template. You can do that, but for it to work, you also need to do the following in settings.py (see documentation):
- Add
'django.forms'
to yourINSTALLED_APPS
. - Add
FORM_RENDERER = 'django.forms.renderers.TemplatesSetting'
That‘s not so bad, but for what I want to do, I wouldn’t gain too much by overriding the template. I’m mostly happy with the HTML. There is just one <br>
tag I want to get rid of. The rest is JavaScript. So, as I’d need to write most of the JavaScript anyway, I decided to just use vanilla JavaScript for the whole thing. You could use jQuery, but I don’t recommend it.
My Solution
My solution involves overriding the the change_form.html template to add JavaScript code to the extrahead
block.
Within in your project templates folder, add an admin folder, and within that folder, add a change_form.html file with the following code:
{% extends "admin/change_form.html" %}
{% load i18n admin_urls static admin_modify %}
{% block extrahead %}
{{ block.super }}
<script>
The JavaScript code will go here.
</script>
{% endblock %}
To understand the following code, it helps to have some knowledge of JavaScript. If you want to just copy and paste the solution into your change_form.html template, you can jump to the final section.
Before we dive into the JavaScript code, let’s take a look at the HTML code used to mark up the datetime widget and discuss how we want to change it.
The code creating this widget looks something like this:
<p class="datetime">
Date:
<input type="text" name="last_login_0" value="2021-10-01" class="vDateField" size="10" id="id_last_login_0">
<span class="datetimeshortcuts">
<a href="#">Today</a> |
<a href="#" id="calendarlink0"><span class="date-icon" title="Choose a Date"></span></a>
</span>
<br>
Time:
<input type="text" name="last_login_1" value="18:18:52" class="vTimeField" size="8" id="id_last_login_1">
<span class="datetimeshortcuts">
<a href="#">Now</a> |
<a href="#" id="clocklink0"><span class="clock-icon" title="Choose a Time"></span></a>
</span>
</p>
As I said, I’m mostly happy with the HTML. I just want to get rid of that highlighted <br>
tag; however, when I do that, it will bring the “Time:” label right up against the calendar icon, so I want to add a touch of margin to the left of the first span
with the datetimeshortcuts
class.
Removing the br
I’ll do this by writing a modifyDateTimeWidgets()
function that modifies every p
node with the datetime
class, looping through that set of nodes (called a nodeList
in JavaScript) and removing the br
node:
function modifyDateTimeWidgets() {
// Get nodeList of all p.datetime nodes
const dateTimeWidgets = document.querySelectorAll('p.datetime');
// Loop through dateTimeWidgets
for (const dateTimeWidget of dateTimeWidgets) {
// Remove br
const br = dateTimeWidget.querySelector('br');
br.parentNode.removeChild(br);
}
}
We need this function to execute as soon as the page loads. The following event listener will make that happen:
window.addEventListener('load', modifyDateTimeWidgets);
Now, as soon as the page has finished loading, the first (and only) <br>
tag will be removed from every datetime widget on the page.
The resulting widgets will look like this: Notice how the “Time” label is flushed right up against the calendar icon. We can fix that by adding some margin to the first “datetimeshortcuts”
span
:
for (const dateTimeWidget of dateTimeWidgets) {
// Remove br
const br = dateTimeWidget.querySelector('br');
br.parentNode.removeChild(br);
// Add some margin after first shortcuts
const shortcuts = dateTimeWidget.querySelector('.datetimeshortcuts');
shortcuts.style.marginRight = '1rem';
}
The querySelector()
method gets only the first matching node, so this will add margin to the right of the span
containing the date widget, but not the one containing the time widget. The result will be:
That looks better. But, and I haven’t mentioned this yet, we really only want to remove the <br>
tag if the screen is wider than 1024 pixels (the size of the original iPad). For smaller screens, the date and time widgets don’t fit nicely on a single line. So, we will add some JavaScript to check the screen size:
for (const dateTimeWidget of dateTimeWidgets) {
// If screen is wider than 1024, remove br and add margin to end of first shortcuts
if (window.screen.width >= 1024) {
// Remove br
const br = dateTimeWidget.querySelector('br');
br.parentNode.removeChild(br);
// Add some margin after first shortcuts
const shortcuts = dateTimeWidget.querySelector('.datetimeshortcuts');
shortcuts.style.marginRight = '1rem';
}
}
Reducing the Width of the Inputs
Remember that we also want to reduce the width of the inputs so they don’t wrap on mobile like this:
We want to this regardless of screen width, so we’ll add the following code immediately after the if
condition that checks the screen width (but still within the for
loop):
// Add max-width to inputs (mostly for mobile)
const inputs = dateTimeWidget.querySelectorAll('input');
for (const input of inputs) {
input.style.maxWidth = '10rem';
}
Unlike querySelector()
, the querySelectorAll()
method gets all the matching nodes, so both inputs will be affected.
To see the effect of this, you’ll have to look at the mobile view (or you can refer to the screenshots I showed earlier).
The widget now looks like we want it to look, both on desktop and mobile. But it doesn’t yet function like we want it to function. We still need to make the Now link updates both the date and time. And we need to add highlighting to make it clear to the user which fields are getting updated.
The Now and Today Links
We need to catch click
events on the Now link within the widget. The first step is to identify the correct node: the a
element that contains the text “Now”. There are various ways of doing this. I chose to use the document.evaluate()
method (see documentation), which uses XPath to identify the node:
// Get <a> node with "Now" text
const nowLinkXPath = document.evaluate('.//a[contains(., "Now")]', dateTimeWidget, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
const nowLink = nowLinkXPath.singleNodeValue;
This works by starting at a context node (dateTimeWidget
), finding the nodes that match the XPath ('.//a[contains(., "Now")]'
), and returning the first matched element XPathResult.FIRST_ORDERED_NODE_TYPE
.
We’re also going to need to catch click
events on the “Today” link, so let’s go ahead and add that code too:
// Get <a> node with "Today" text
const todayLinkXPath = document.evaluate('.//a[contains(., "Today")]', dateTimeWidget, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
const todayLink = todayLinkXPath.singleNodeValue;
Now, we will add event listeners to both these links to flash a green background on the relevant input
when the links are clicked:
// Add event listeners to the links that find the input, change its style, and that fade out the change
nowLink.addEventListener('click', (e) => {
// Flash green background
const nowInput = nowLink.closest('p').querySelector('input.vTimeField');
nowInput.style.backgroundColor = 'green';
nowInput.style.color = 'white';
setTimeout(function() {
nowInput.style.backgroundColor='transparent';
nowInput.style.transition='background-color .75s';
nowInput.style.color = 'black';
}, 500);
});
todayLink.addEventListener('click', (e) => {
// Flash green background
const todayInput = nowLink.closest('p').querySelector('input.vDateField');
todayInput.style.backgroundColor = 'green';
todayInput.style.color = 'white';
setTimeout(function() {
todayInput.style.backgroundColor='transparent';
todayInput.style.transition='background-color .75s';
todayInput.style.color = 'black';
}, 500);
});
These two code segments work like this:
- Identify the relevant
input
:const nowInput = nowLink.closest('p').querySelector('input.vTimeField');
- Change the styles of that
input
:nowInput.style.backgroundColor = 'green'; nowInput.style.color = 'white';
- After half a second, transition the styles back to their original styles:
setTimeout(function() { nowInput.style.backgroundColor='transparent'; nowInput.style.transition='background-color .75s'; nowInput.style.color = 'black'; }, 500);
We are almost there! The last thing we need to do is force a click on the Today link when the Now link is clicked. That way, the date will also get updated. We do that in the nowLink
event listener:
nowLink.addEventListener('click', (e) => {
// Force click todayLink when nowLink is clicked, so date gets updated too
todayLink.click();
// Flash green background
const nowInput = nowLink.closest('p').querySelector('input.vTimeField');
…
});
And that’s it.
The Entire Code
Here is the entire code for your change_form.html template:
{% extends "admin/change_form.html" %}
{% load i18n admin_urls static admin_modify %}
{{% block extrahead %}
{{ block.super }}
<script>
function modifyDateTimeWidgets() {
// Get nodeList of all p.datetime nodes
const dateTimeWidgets = document.querySelectorAll('p.datetime');
// Loop through dateTimeWidgets
for (const dateTimeWidget of dateTimeWidgets) {
// If screen is wider than 1024, remove br and add margin to end of first shortcuts
if (window.screen.width >= 1024) {
// Remove br
const br = dateTimeWidget.querySelector('br');
br.parentNode.removeChild(br);
// Add some margin after first shortcuts
const shortcuts = dateTimeWidget.querySelector('.datetimeshortcuts');
shortcuts.style.marginRight = '1rem';
}
// Add max-width to inputs (mostly for mobile)
const inputs = dateTimeWidget.querySelectorAll('input');
for (const input of inputs) {
input.style.maxWidth = '10rem';
}
// Get <a> node with "Now" text
const nowLinkXPath = document.evaluate('.//a[contains(., "Now")]', dateTimeWidget, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
const nowLink = nowLinkXPath.singleNodeValue;
// Get <a> node with "Today" text
const todayLinkXPath = document.evaluate('.//a[contains(., "Today")]', dateTimeWidget, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
const todayLink = todayLinkXPath.singleNodeValue;
// Add event listeners to the links that find the input, change its style, and that fade out the change
nowLink.addEventListener('click', (e) => {
// Force click todayLink when nowLink is clicked, so date gets updated too
todayLink.click();
// Flash green background
const nowInput = nowLink.closest('p').querySelector('input.vTimeField');
nowInput.style.backgroundColor = 'green';
nowInput.style.color = 'white';
setTimeout(function() {
nowInput.style.backgroundColor='transparent';
nowInput.style.transition='background-color .75s';
nowInput.style.color = 'black';
}, 500);
});
todayLink.addEventListener('click', (e) => {
// Flash green background
const todayInput = nowLink.closest('p').querySelector('input.vDateField');
todayInput.style.backgroundColor = 'green';
todayInput.style.color = 'white';
setTimeout(function() {
todayInput.style.backgroundColor='transparent';
todayInput.style.transition='background-color .75s';
todayInput.style.color = 'black';
}, 500);
});
}
}
window.addEventListener('load', modifyDateTimeWidgets);
</script>
{% endblock extrahead %}
After adding this code, all of your datetime widgets in Django admin should use the new design.
Related Articles
- Improving Django Admin’s Date-Time Widget (this article)
- Why I Don’t Use jQuery in Django Admin