Improving Django Admin’s Date-Time Widget

The datetime widget used in Django admin looks like this: Django admin datetime widget

It’s mostly fine, but there are a few ways that I think it can be improved:

  1. It takes up too much vertical space. I would prefer that it was along a single line like this: Django admin datetime widget - single line
  2. 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.
  3. Reduce the width of the inputs so the links don’t drop down on mobile, like this: Django admin datetime widget mobile Limiting the width of the input to 10rem, which is more than enough to fit the date and time values, keeps the links on the same line as the input: Django admin datetime widget mobile - single line

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: Animated datetime widget(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:

  1. Override the split_datetime.html template.
  2. 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):

  1. Add 'django.forms' to your INSTALLED_APPS.
  2. 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: datetime widget with br removed 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: datetime widget with br removed and margin added

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: Django admin datetime widget mobile

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:

  1. Identify the relevant input:
    const nowInput = nowLink.closest('p').querySelector('input.vTimeField');
  2. Change the styles of that input:
    nowInput.style.backgroundColor = 'green';
    nowInput.style.color = 'white';
  3. 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.

Written by Nat Dunn. Follow Nat on Twitter.


Related Articles

  1. Improving Django Admin’s Date-Time Widget (this article)
  2. Why I Don’t Use jQuery in Django Admin