Recurrence rules via cascading dropdowns
Published: 2023-11-19 16:58:06

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 () {

}
```

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>
    `;
  }
```

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);
     }
}
```

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'
  };
}
```
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']);
}
```

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");
}
```

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) {

}
```

Clear any existing options in the dropdown:

```
function updateDropdownOptions(dropdown, options) {
     dropdown.content.innerHTML = '';
}
```

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

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

Go through each option in the options array:

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

     }
}
```

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];
     }
}
```

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]);
     }
}
```

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();
     }
}
```

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;
     }
}
```

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');
     }
}
```

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';
     }
}
```

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;
          }
        }
     }
}
```

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);
          }
        }
     }
}
```

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);
}
```

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) {

  }
}
```

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);
  }
}
```

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';
    };
  }
}
```

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');
    });
  }
}
```

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);
  }
}
```

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
    };
  }
}
```
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 = "";
      });
    }
  }
```

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

```
isRecurrent.onchange();
```

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>
```

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;
}
```

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();
}
```

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