Recurrence rules via cascading dropdowns

ciscovlahakis

Cisco Vlahakis

Posted on November 19, 2023

Recurrence rules via cascading dropdowns

Suppose we have the following recurrence rules:

  1. Daily
  2. By day of week (Every Monday, Every Tuesday, etc.)
  3. Monthly, By day of week
  4. Monthly, By day of month
  5. Every certain date (Every 1st, Every 2nd, etc.)
  6. By a range of days of week (From Monday to Wednesday)
  7. By a range of days of month (From 1st to 3rd)
  8. By a range of dates (From 10/1 to 12/7)
  9. Between a start time and end time (From 7 AM to 9 AM)
  10. Monthly (or Every 2 months, Every 3 months, etc.)
  11. Starting on a date (No end date)
  12. Ends on a certain date
  13. Until a number of recurrences
  14. Yearly recurrence: “every year”, “every January 1”, etc.
  15. Recurrence on specific dates: “on January 1”, “on July 4”, etc.
  16. Recurrence every X days/weeks/months/years: “every 2 days”, “every 3 weeks”, “every 4 months”, “every 5 years”, etc.
  17. Recurrence on the last day of the month: “every last day of the month”, “every last Friday of the month”, etc.
  18. Recurrence on a specific day of the week in a month: “every 1st Monday of the month”, “every last Friday of the month”, etc.

There are a few ways to implement recurrence rules. The most popular is a custom form with varying radio buttons for various use cases. A more elegant but complex solution is a single text input that parses the user's recurrence rule using NLP. And a more elegant but simpler solution is to allow the user to build their recurrence rule via dropdowns that suggest phrases depending on the last dropdown selections. For example:

Dropdown 1 = “Every”, “From”, “Starting [Date]”, “For”, “On”

Dropdown 2 = “Monday”, “Tuesday”, …, “Day”, “Week”, “Month”, “Year”, 1, 2, 3, 4 … 30, 12 AM, 1 AM, …, 1/1, 1/2, …, last

Dropdown 3 = “-st”, “-rd”, “-th”, “at”, “until”, “from”, “occurrences”, “days”, “weeks”, “months”, “years”, “day”, “Monday”, “Tuesday”

Dropdown 4 = “12 AM, 1 AM, …, “Monday”, “Tuesday, …, 1/1, 1/2, …”

Dropdown 5 = “Until”

Dropdown 6 = “12 AM, 1 AM, …”

It is important to ensure all your recurrence rules can be implemented via each selection path.

Implementing such a feature can be complex, but let's take it step by step!

Let's make sure that the JavaScript code doesn’t run until the webpage has fully loaded:

window.onload = function () {

}
Enter fullscreen mode Exit fullscreen mode

Let's create a helper function that creates the HTML structure for a dropdown menu with a specific index:

window.onload = function () {

  function createDropdownHTML(index) {
    return `
      <div class="dropdown" id="dropdown${index}" style="display: none;">
        <button class="button">Select:</button>
        <div class="dropdown-content"></div>
        <input type="hidden" id="recurrence${index}" name="recurrence${index}">
      </div>
    `;
  }
Enter fullscreen mode Exit fullscreen mode

Now we add four dropdown menus to the ‘dropdowns-container’ element. We can change 4 to be a larger number in the future the more use cases are added:

window.onload = function () {
...
     var dropdownsContainer = document.getElementById('dropdowns-container');
     var dropdownsCount = 4;
     for (let i = 1; i <= dropdownsCount; i++) {
         dropdownsContainer.innerHTML += createDropdownHTML(i);
     }
}
Enter fullscreen mode Exit fullscreen mode

Now, we create an object dropdownDependencies that maps each possible option to its corresponding options in the next dropdown menu:

window.onload = function () {
...
    var dropdownDependencies = {
    'START': ['Every', 'For'],
    'Every': ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday', 'Day', 'Week', 'Month', 'Year'],
    'For': ['1', '2', '3'],
    'Monday': ['For', 'Until'],
    'Tuesday': ['For', 'Until'],
    'Wednesday': ['For', 'Until'],
    'Thursday': ['For', 'Until'],
    'Friday': ['For', 'Until'],
    'Saturday': ['For', 'Until'],
    'Sunday': ['For', 'Until'],
    'Day': ['For', 'Until'],
    'Week': ['For', 'Until'],
    'Month': ['For', 'Until'],
    'Year': ['For', 'Until'],
    'For': ['1', '2', '3'],
    'Until': ['Date'],
    '1': [], // Add a corresponding array for '1'
    '2': [], // Add a corresponding array for '2'
    '3': [], // Add a corresponding array for '3'
    'Date': [] // Add a corresponding array for 'Date'
  };
}
Enter fullscreen mode Exit fullscreen mode

Now we loop to create each dropdown menu and add them to an array. We then set the initial options for the first dropdown menu:

window.onload = function () {
...
     // Initialize the dropdowns
     var dropdowns = [];
     for (let i = 1; i <= dropdownsCount; i++) {
         dropdowns.push(createDropdown(`dropdown${i}`, ".button", ".dropdown-content", `recurrence${i}`));
     }

     // Set the initial options for the first dropdown
     updateDropdownOptions(dropdowns[0], dropdownDependencies['START']);
}
Enter fullscreen mode Exit fullscreen mode

Suppose we want to show or hide the dropdown menus depending on if a user even wants to create a recurrence rule while they are viewing a form. We can reference a checkbox that will show or hide the dropdown menus:

window.onload = function () {
...
     var isRecurrent = document.getElementById("is-recurrent");
}
Enter fullscreen mode Exit fullscreen mode

Now, let's create a helper function that updates the options of a given dropdown and sets up the behavior for when an option is selected:

function updateDropdownOptions(dropdown, options) {

}
Enter fullscreen mode Exit fullscreen mode

Clear any existing options in the dropdown:

function updateDropdownOptions(dropdown, options) {
     dropdown.content.innerHTML = '';
}
Enter fullscreen mode Exit fullscreen mode

Ensure that “Select:” is always an option in the dropdown:

function updateDropdownOptions(dropdown, options) {
     ...
     if (options[0] !== "Select:") {
      options.unshift("Select:");
    }
}
Enter fullscreen mode Exit fullscreen mode

Go through each option in the options array:

function updateDropdownOptions(dropdown, options) {
     ...
     for (let i = 0; i < options.length; i++) {

     }
}
Enter fullscreen mode Exit fullscreen mode

Set the text of the created paragraph element to the option. We will be using a custom hidden input instead of a <select>:

function updateDropdownOptions(dropdown, options) {
     ...
     for (let i = 0; i < options.length; i++) {
      let p = document.createElement('p');
      p.textContent = options[i];
     }
}
Enter fullscreen mode Exit fullscreen mode

Set a data attribute on the paragraph element to the option. This will be used to get the selected option when it’s clicked:

function updateDropdownOptions(dropdown, options) {
     ...
     for (let i = 0; i < options.length; i++) {
      ...
      p.setAttribute('data-value', options[i]);
     }
}
Enter fullscreen mode Exit fullscreen mode

Set up the behavior when an option is clicked. Prevent the click event from bubbling up to parent elements and stop the default action of the click event. Without these, clicking on an option could trigger other unrelated click events on parent elements or cause the page to refresh:

function updateDropdownOptions(dropdown, options) {
     ...
     for (let i = 0; i < options.length; i++) {
      ...
      p.onclick = function (e) {
        e.preventDefault();
        e.stopPropagation();
     }
}
Enter fullscreen mode Exit fullscreen mode

Store the previous value of the dropdown menu. This is used later to check if the selected option has changed:

function updateDropdownOptions(dropdown, options) {
     ...
     for (let i = 0; i < options.length; i++) {
      ...
      p.onclick = function (e) {
        ...
        var previousValue = dropdown.input.value;
     }
}
Enter fullscreen mode Exit fullscreen mode

Update the displayed value of the dropdown to the selected option and set the hidden input’s value to the selected option:

function updateDropdownOptions(dropdown, options) {
     ...
     for (let i = 0; i < options.length; i++) {
      ...
      p.onclick = function (e) {
        ...
        var previousValue = dropdown.input.value;
        dropdown.button.textContent = p.textContent;
        dropdown.input.value = p.getAttribute('data-value');
     }
}
Enter fullscreen mode Exit fullscreen mode

Hide the dropdown menu after an option is selected:

function updateDropdownOptions(dropdown, options) {
     ...
     for (let i = 0; i < options.length; i++) {
      ...
      p.onclick = function (e) {
        ...
        dropdown.content.style.display = 'none';
     }
}
Enter fullscreen mode Exit fullscreen mode

Check if the selected option is different from the previously selected option. If so, we hide all the following dropdown menus and reset their selections. This is done because changing an option could change the options in the following dropdown menus:

function updateDropdownOptions(dropdown, options) {
     ...
     for (let i = 0; i < options.length; i++) {
      ...
      p.onclick = function (e) {
        ...
        if (dropdown.input.value !== previousValue) {
          var currentDropdown = dropdown.dropdown.nextElementSibling;
          while (currentDropdown) {
            var currentButton = currentDropdown.querySelector(".button");
            var currentInput = currentDropdown.querySelector('input[type="hidden"]');
            currentDropdown.style.display = 'none';
            currentButton.textContent = 'Select:';
            currentInput.value = '';
            currentDropdown = currentDropdown.nextElementSibling;
          }
        }
     }
}
Enter fullscreen mode Exit fullscreen mode

If there is a dropdown menu following the current one, update the following dropdown’s options based on the selected option. This is where the cascading behavior of the dropdown menus comes from. The options in the following dropdown depend on the selected option in the current dropdown:

function updateDropdownOptions(dropdown, options) {
     ...
     for (let i = 0; i < options.length; i++) {
      ...
      p.onclick = function (e) {
        ...
        if (dropdown.dropdown.nextElementSibling) {
          var nextDropdown = dropdown.dropdown.nextElementSibling;
          var nextDropdownContent = nextDropdown.querySelector(".dropdown-content");
          var nextOptions = dropdownDependencies[dropdown.input.value];
          if (nextOptions) {
            nextDropdown.style.display = 'inline-block';
            updateDropdownOptions({
              dropdown: nextDropdown,
              button: nextDropdown.querySelector(".button"),
              content: nextDropdownContent,
              input: nextDropdown.querySelector('input[type="hidden"]')
            }, nextOptions);
          }
        }
     }
}
Enter fullscreen mode Exit fullscreen mode

That is it for the p.onclick function. Now, add the paragraph element and it's onclick listener as an option to the dropdown:

function updateDropdownOptions(dropdown, options) {
     ...
     for (let i = 0; i < options.length; i++) {
      ...
      p.onclick = function (e) {
        ...
     }
     dropdown.content.appendChild(p);
}
Enter fullscreen mode Exit fullscreen mode

That is it for updateDropdownOptions. Next, let's create a helper function called createDropdown. The function createDropdown is defined with four parameters: dropdownId, buttonId, contentId, inputId. These are supposed to be the id of the dropdown, the button, the content of the dropdown, and the input field within the dropdown, respectively:

window.onload = function () {
...
  function createDropdown(dropdownId, buttonId, contentId, inputId, nextDropdownId) {

  }
}
Enter fullscreen mode Exit fullscreen mode

Select the corresponding HTML elements based on the provided id or class:

window.onload = function () {
...
  function createDropdown(dropdownId, buttonId, contentId, inputId, nextDropdownId) {
    var dropdown = document.getElementById(dropdownId);
    var button = dropdown.querySelector(buttonId);
    var content = dropdown.querySelector(contentId);
    var input = document.getElementById(inputId);
  }
}
Enter fullscreen mode Exit fullscreen mode

Define what should happen when the button in the dropdown is clicked. We toggle the display style of the dropdown content between ‘block’ (visible) and ‘none’ (hidden):

window.onload = function () {
...
  function createDropdown(dropdownId, buttonId, contentId, inputId, nextDropdownId) {
    ...
    button.onclick = function (e) {
         e.preventDefault();
         e.stopPropagation();
         content.style.display = content.style.display === 'block' ? 'none' : 'block';
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Get the initial options from the dropdown content by selecting all p elements within the content (which represent the options), and mapping these elements to their data-value attribute:

window.onload = function () {
...
  function createDropdown(dropdownId, buttonId, contentId, inputId, nextDropdownId) {
    ...
    var initialOptions = Array.from(content.querySelectorAll('p')).map(function (p) {
      return p.getAttribute('data-value');
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Call the updateDropdownOptions function to update the dropdown’s options to the initialOptions. Pass an object that includes the dropdown, button, content, and input elements, as well as the initialOptions:

window.onload = function () {
...
  function createDropdown(dropdownId, buttonId, contentId, inputId, nextDropdownId) {
    ...
    updateDropdownOptions({
      dropdown: dropdown,
      button: button,
      content: content,
      input: input
    }, initialOptions);
  }
}
Enter fullscreen mode Exit fullscreen mode

Finally, return an object including the dropdown, button, content, and input elements. This is used later to manipulate the dropdown:

window.onload = function () {
...
  function createDropdown(dropdownId, buttonId, contentId, inputId, nextDropdownId) {
    ...
    updateDropdownOptions({
      dropdown: dropdown,
      button: button,
      content: content,
      input: input
    }, initialOptions);

    return {
      dropdown: dropdown,
      button: button,
      content: content,
      input: input
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

The createDropdown function is complete. Now, we need to set up the behavior when the checkbox is checked or unchecked. When it’s checked, the first dropdown menu is shown. When it’s unchecked, all the dropdown menus are hidden:

isRecurrent.onchange = function () {
    if (this.checked) {
      dropdowns[0].dropdown.style.display = "inline-block";
    } else {
      dropdowns.forEach(function (dropdown) {
        dropdown.dropdown.style.display = "none";
        dropdown.button.textContent = "Select:";
        dropdown.input.value = "";
      });
    }
  }
Enter fullscreen mode Exit fullscreen mode

And make sure to set the initial visibility of the dropdowns:

isRecurrent.onchange();
Enter fullscreen mode Exit fullscreen mode

The JS is complete! Now let's add the checkbox and dropdowns-container to our HTML:

<span>Is this a recurrent event?</span>
<input type="checkbox" id="is-recurrent" name="is-recurrent" class="large-checkbox">
<div id="recurrence-wrapper">
  <div id="dropdowns-container"></div>
</div>
Enter fullscreen mode Exit fullscreen mode

Now onto styling:

// styles the wrapper for the dropdown menus.
#recurrence-wrapper {
  display: block;
}

// styles the dropdown select menus.
.recurrence-select {
  display: none;
}

// styles the checkbox that shows or hides the dropdown menus.
.large-checkbox {
  height: 20px;
  width: 20px;
  vertical-align: middle;
}

// styles the dropdown menus.
.dropdown {
  position: relative;
  display: inline-block;
}

// styles the content within the dropdown menus. The zIndex is set to 9999 because this component is within a modal for my use case.
.dropdown-content {
  display: none;
  position: absolute;
  padding: 0;
  margin: 0;
  z-index: 9999;
}

// styles the individual options within the dropdown menus.
.dropdown-content p {
  padding: 10px 20px;
  margin: 0;
  background-color: #fff;
  color: #000;
  font-size: 14px;
  cursor: pointer;
}

// styles the options when they’re hovered over or selected.
.dropdown-content p:hover,
.dropdown-content p.selected {
  background-color: #ccc;
}
Enter fullscreen mode Exit fullscreen mode

This is the full JS from earlier:

window.onload = function () {

  function createDropdownHTML(index) {
    return `
      <div class="dropdown" id="dropdown${index}" style="display: none;">
        <button class="button">Select:</button>
        <div class="dropdown-content"></div>
        <input type="hidden" id="recurrence${index}" name="recurrence${index}">
      </div>
    `;
  }

  var dropdownsContainer = document.getElementById('dropdowns-container');
  var dropdownsCount = 4;
  for (let i = 1; i <= dropdownsCount; i++) {
    dropdownsContainer.innerHTML += createDropdownHTML(i);
  }  

  var dropdownDependencies = {
    'START': ['Every', 'For'],
    'Every': ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday', 'Day', 'Week', 'Month', 'Year'],
    'For': ['1', '2', '3'],
    'Monday': ['For', 'Until'],
    'Tuesday': ['For', 'Until'],
    'Wednesday': ['For', 'Until'],
    'Thursday': ['For', 'Until'],
    'Friday': ['For', 'Until'],
    'Saturday': ['For', 'Until'],
    'Sunday': ['For', 'Until'],
    'Day': ['For', 'Until'],
    'Week': ['For', 'Until'],
    'Month': ['For', 'Until'],
    'Year': ['For', 'Until'],
    'For': ['1', '2', '3'],
    'Until': ['Date'],
    '1': [], // Add a corresponding array for '1'
    '2': [], // Add a corresponding array for '2'
    '3': [], // Add a corresponding array for '3'
    'Date': [] // Add a corresponding array for 'Date'
  };

  // Initialize the dropdowns
  var dropdowns = [];
  for (let i = 1; i <= dropdownsCount; i++) {
    dropdowns.push(createDropdown(`dropdown${i}`, ".button", ".dropdown-content", `recurrence${i}`));
  }

  // Set the initial options for the first dropdown
  updateDropdownOptions(dropdowns[0], dropdownDependencies['START']);

  var isRecurrent = document.getElementById("is-recurrent");

  function updateDropdownOptions(dropdown, options) {

    dropdown.content.innerHTML = '';

    // Add "Select:" as an option
    if (options[0] !== "Select:") {
      options.unshift("Select:");
    }

    for (let i = 0; i < options.length; i++) {
      let p = document.createElement('p');
      p.textContent = options[i];
      p.setAttribute('data-value', options[i]);

      p.onclick = function (e) {
        e.preventDefault();
        e.stopPropagation();
        var previousValue = dropdown.input.value;
        dropdown.button.textContent = p.textContent;
        dropdown.input.value = p.getAttribute('data-value');
        dropdown.content.style.display = 'none';
        if (dropdown.input.value !== previousValue) {
          var currentDropdown = dropdown.dropdown.nextElementSibling;
          while (currentDropdown) {
            var currentButton = currentDropdown.querySelector(".button");
            var currentInput = currentDropdown.querySelector('input[type="hidden"]');
            currentDropdown.style.display = 'none';
            currentButton.textContent = 'Select:';
            currentInput.value = '';
            currentDropdown = currentDropdown.nextElementSibling;
          }
        }
        if (dropdown.dropdown.nextElementSibling) {
          var nextDropdown = dropdown.dropdown.nextElementSibling;
          var nextDropdownContent = nextDropdown.querySelector(".dropdown-content");
          var nextOptions = dropdownDependencies[dropdown.input.value];
          if (nextOptions) {
            nextDropdown.style.display = 'inline-block';
            updateDropdownOptions({
              dropdown: nextDropdown,
              button: nextDropdown.querySelector(".button"),
              content: nextDropdownContent,
              input: nextDropdown.querySelector('input[type="hidden"]')
            }, nextOptions);
          }
        }
      };

      dropdown.content.appendChild(p);
    }

    if (dropdown.dropdown.nextElementSibling) {
      var currentDropdown = dropdown.dropdown.nextElementSibling;
      while (currentDropdown) {
        var currentButton = currentDropdown.querySelector(".button");
        var currentInput = currentDropdown.querySelector('input[type="hidden"]');
        currentDropdown.style.display = 'none';
        currentButton.textContent = 'Select:';
        currentInput.value = '';
        currentDropdown = currentDropdown.nextElementSibling;
      }
    }
  }

  function createDropdown(dropdownId, buttonId, contentId, inputId, nextDropdownId) {
    var dropdown = document.getElementById(dropdownId);
    var button = dropdown.querySelector(buttonId);
    var content = dropdown.querySelector(contentId);
    var input = document.getElementById(inputId);

    button.onclick = function (e) {
      e.preventDefault();
      e.stopPropagation();
      content.style.display = content.style.display === 'block' ? 'none' : 'block';
    };

    var initialOptions = Array.from(content.querySelectorAll('p')).map(function (p) {
      return p.getAttribute('data-value');
    });

    updateDropdownOptions({
      dropdown: dropdown,
      button: button,
      content: content,
      input: input
    }, initialOptions);

    return {
      dropdown: dropdown,
      button: button,
      content: content,
      input: input
    };
  }

  // If checkbox is unchecked, hide all dropdowns and reset their selections
  isRecurrent.onchange = function () {
    if (this.checked) {
      dropdowns[0].dropdown.style.display = "inline-block";
    } else {
      dropdowns.forEach(function (dropdown) {
        dropdown.dropdown.style.display = "none";
        dropdown.button.textContent = "Select:";
        dropdown.input.value = "";
      });
    }
  }

  // Call the onchange function to set initial visibility of dropdowns
  isRecurrent.onchange();
}
Enter fullscreen mode Exit fullscreen mode

Whew! We have created a cascading dropdowns component for recurrence rules.

💖 💪 🙅 🚩
ciscovlahakis
Cisco Vlahakis

Posted on November 19, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related