WeatherWidget.io - Uncaught DOMException: Failed to execute 'removeChild' on 'Node' issue

With the rest of the linting, I am told that there are 13 issues remaining. But that can be misleading where other types of things are found after the current lot are taken care of.

Here’s the JS code as it stands right now:

jQuery(document).ready(function rankWeather($) {
    var StatJSON = {
        "Option1": {
            "ColHeading": "New York",
            "Ranking": "",
            "Rating": "4.5",
            "Row1Heading": "Hello",
            "Weather": "40d71n74d01/new-york"
        },
        "Option2": {
            "ColHeading": "San Francisco",
            "Ranking": "",
            "Rating": "3.2",
            "Row1Heading": "Whats",
            "Weather": "37d77n122d42/san-francisco"
        },
        "Option3": {
            "ColHeading": "Chicago",
            "Ranking": "",
            "Rating": "3.7",
            "Row1Heading": "Up",
            "Weather": "41d88n87d63/chicago"
        },
        "Option4": {
            "ColHeading": "Los Angeles",
            "Ranking": "",
            "Rating": "5.0",
            "Row1Heading": "With",
            "Weather": "34d05n118d24/los-angeles"
        }
    };

    function printTable(data) {
        var html = `<table class="compTable"><thead><tr><th>`;
        if (data && data.length) {
            html += "</th>";
            $.each(data, function addColumnHeading(i) {
                html += "<th>" + StatJSON[data[i]].ColHeading + "</th>";
            });
            html += "</tr>";
            html += "<tbody>";
            $.each(StatJSON[data[0]], function addRow(rowTitle) {
                html += "<tr>";
                if (rowTitle !== "ColHeading") {
                    html += "<td>" + rowTitle + "</td>";
                }
                $.each(data, function addData(cityInfo) {
                    if (rowTitle !== "ColHeading") {
                        var cityData = StatJSON[data[cityInfo]][rowTitle];
                        html += "<td>" + cityData + "</td>";
                    }
                });
                html += "</tr>";
            });
        } else {
            html += "No results found</td></tr>";
        }
        html += "</tbody></table>";
        return html;
    }

    function updateWeatherTable() {
        var getCity = (option) => option.value;
        var data = $("#selection").find(":selected").toArray().map(getCity);
        $("#divResult").empty().append(printTable(data));
    }

    function cleanupTextAndClasses() {
        var headings = $(".divResult table th:not(:first)");
        headings.toArray().forEach(function(header) {
            var columnHead = $(header).text().split(" ").join("");
            $(header).addClass($(header).text().split(" ").join(""));
        });

        $(".divResult table th:first-child").removeAttr("name");

        $("table thead th[class]").each(function(index, th) {
            $(`table tbody td:nth-child(${index + 1})`).addClass(th.className);
        });

        var firstCells = $(".divResult tbody td:first-child");
        firstCells.toArray().forEach(function(cell) {
            $(cell).addClass(((cell.textContent).replace("#", "").replace(/[()]/g, "")).replace(/s/g, "").replace(/\\|\//g, ""));
            $(cell).siblings("td").addClass(((cell.textContent).replace("#", "").replace(/[()]/g, "")).replace(/s/g, "").replace(/\\|\//g, ""));
            $(cell).parent("tr").addClass(((cell.textContent).replace("#", "").replace(/[()]/g, "")).replace(/s/g, "").replace(/\\|\//g, ""));
        });
    }
    function updateRatings() {
        function updateRating(cell) {
            var rating = parseFloat($(cell).text()).toFixed(2);
            $(cell).html(`<div><div class="rating">${rating}</div></div>`);
        }

        function updateRowRatings(ratingCell) {
            $(ratingCell).nextAll("td").toArray().forEach(updateRating);
        }
        $(".divResult .Rating").toArray().forEach(updateRowRatings);
    }

    function updateRankings() {
        function rankByRatings() {
            var ratings = [];
            $("tr.Rating > td:not(:first)").toArray().forEach(function(td) {
                var element = $(td).text();
                ratings.push(element);
            });

            var sorted = ratings.slice().sort(function(a, b) {
                return b - a;
            });
            var ranks = ratings.slice().map(function(v) {
                return sorted.indexOf(v) + 1;
            });
            return ranks;
        }

        function sortColumnsByRanking() {
            var Rows = $(".compTable tr");
            var RowRanking = $(".compTable tr.Ranking");

            RowRanking.find("td:not(:first)").sort(function(a, b) {
                return $(a).text().replace(/[^0-9]/gi, "").localeCompare($(b).text().replace(/[^0-9]/gi, ""));
            }).each(function(new_Index, cell) {
                var original_Index = $(cell).index();

                Rows.toArray().forEach(function(row) {
                    var td = $(row).find("td, th");
                    if (original_Index !== new_Index) {
                        td.eq(original_Index).insertAfter(td.eq(new_Index));
                    }
                });
            });
        }
        function updateRanking(cell, rank) {
            cell.empty().append($(`<div><div "ranking">#${rank}</div></div>`));
        }

        var ranks = rankByRatings();
        $(".divResult table tbody tr").toArray().forEach(function(tr) {
            var $td = $(tr).children();
            if ($td.eq(0).text() === "Ranking") {
                ranks.forEach(function(rank, i) {
                    updateRanking($td.eq(i + 1), rank);
                    if (rank === 1) {
                        $td.eq(i + 1).addClass("ranking1");
                    }
                });
            }
        });
        sortColumnsByRanking();
    }

    function addWeatherWidget() {
        function showWeatherWidget(el) {
            var path = $(el).text();
            var linkHtml = `<a href="https://forecast7.com/en/${path}/"`;
            linkHtml += ` class="weatherwidget-io" data-label_2="WEATHER"`;
            linkHtml += ` data-days="3" data-theme="original">WEATHER</a>`;
            $(el).html(linkHtml);
        }
        $("#divResult table tbody tr td").toArray().forEach(function(td) {
            if ($(td).text() === "Weather") {
                $(td).nextAll("td").toArray().forEach(showWeatherWidget);
            }
            __weatherwidget_init();
        });
    }

    $("#btnSubmit").click(function citiesSubmitHandler() {
        updateWeatherTable();
        cleanupTextAndClasses();
        updateRatings();
        updateRankings();
        addWeatherWidget();
    });
});

Most of the remaining issues are about naming functions.

Linting the column class function

Here’s the start of the cleanupTextAndClasses function

        var headings = $(".divResult table th:not(:first)");
        headings.toArray().forEach(function(header) {
            var columnHead = $(header).text().split(" ").join("");
            $(header).addClass($(header).text().split(" ").join(""));
        });

Calling the function addHeadingClass seems to work well. There is a columnHead variable that’s not used though, so we can fix that up while we’re here.

        var headings = $(".divResult table th:not(:first)");
        headings.toArray().forEach(function addHeadingClass(header) {
            var columnHead = $(header).text().split(" ").join("");
            $(header).addClass(columnHead);
        });

Linting the cell class code

This next piece of code seems weird.

        $("table thead th[class]").each(function(index, th) {
            $(`table tbody td:nth-child(${index + 1})`).addClass(th.className);
        });

It’s supposed to copy the class name of the heading down to each of the table cells below that heading, but that doesn’t seem to happen. Instead, with New York and Los Angeles selected for example, it is the far left column with the row titles that has the classes New York added to the cells in that far left column.

The problem is that the index is being incorrectly calculated.

The headings have an index of 0 and 1. Adding 1 to those gives us 1 and 2, but nth-child starts at 1, so 1 is going to give you the first column with the row titles. As a result you need to add an extra 1 to get to the correct column for the headings.

That’s too much complication. If we instead remove the [class] part of the selector, the we can use a simple +1 to get to the correct column instead.

        $("table thead th").each(function propagateHeadingClass(index, th) {
            $(`table tbody td:nth-child(${index + 1})`).addClass(th.className);
        });

I’ve also gone with calling the function propagateHeadingClass, because we take the heading class and we copy it to all of the cells that are below it in the same column.

Linting the propagate row class code

Here is the next piece of code that we’re linting.

        var firstCells = $(".divResult tbody td:first-child");
        firstCells.toArray().forEach(function(cell) {
            $(cell).addClass(((cell.textContent).replace("#", "").replace(/[()]/g, "")).replace(/s/g, "").replace(/\\|\//g, ""));
            $(cell).siblings("td").addClass(((cell.textContent).replace("#", "").replace(/[()]/g, "")).replace(/s/g, "").replace(/\\|\//g, ""));
            $(cell).parent("tr").addClass(((cell.textContent).replace("#", "").replace(/[()]/g, "")).replace(/s/g, "").replace(/\\|\//g, ""));
        });

Before deciding on a function name, I’ll clean up some of the code so that I end up with a better idea of what it does.

All of that replacing code can be moved out to a separate function. The replacing code sanitizes the text, so I’ll call it sanitize. Now that I understand what is being done there more clearly, the looping function can be called propagateRowClass.

        var firstCells = $(".divResult tbody td:first-child");
        firstCells.toArray().forEach(function propagateRowClass(cell) {
            function sanitize(text) {
                text = text.replace("#", "");
                text = text.replace(/[()]/g, "");
                text = text.replace(/s/g, "");
                text = text.replace(/\\|\//g, "");
                return text;
            }
            var className = sanitize(cell.textContent);
            $(cell).addClass(className);
            $(cell).siblings("td").addClass(className);
            $(cell).parent("tr").addClass(className);
        });

That works, but I want to update the sanitize function so that it has each of those regular expressions in an array, and uses the reduce method to apply each of them to the text. That means first placing each regular expression into an array:

            function sanitize(text) {
                var removals = ["#", /[()]/g, /s/g, /\\|\//g];
                text = text.replace(removals[0], "");
                text = text.replace(removals[1], "");
                text = text.replace(removals[2], "");
                text = text.replace(removals[3], "");
                return text;
            }

Now that those replace lines are all identical but for the array index, I can use a reduce method to apply each array item to the text.

            function sanitize(text) {
                var removals = ["#", /[()]/g, /s/g, /\\|\//g];
                return removals.reduce(function sanitizeText(text, regex) {
                    return text.replace(regex, "");
                }, text);
            }
            var className = sanitize(cell.textContent);

Do I now need that sanitize function? Let’s see how things look without it.

        var firstCells = $(".divResult tbody td:first-child");
        firstCells.toArray().forEach(function propagateRowClass(cell) {
            var removals = ["#", /[()]/g, /s/g, /\\|\//g];
            var className = removals.reduce(function sanitizeText(text, regex) {
                return text.replace(regex, "");
            }, cell.textContent);

            $(cell).addClass(className);
            $(cell).siblings("td").addClass(className);
            $(cell).parent("tr").addClass(className);
        });

I’m okay with that. What I’m not okay with though is that the same classname is being copied across all of the cells of row, and on the row element itself too. Normally only one classname is placed on the row, as its styles tend to apply to everything inside of the row too. I’ll leave things as they are with the row title being propagated as a classname to the row and to all of the cells in that row, but this is something for you to consider. Only one class on the row itself is all that is normally needed.

Linting the retrieval of ratings

The next code that the linter says needs a function name is the following:

            var ratings = [];
            $("tr.Rating > td:not(:first)").toArray().forEach(function(td) {
                var element = $(td).text();
                ratings.push(element);
            });

I’ve been noticing that there are a lot of adjustments in the code for the first column not being used by a city. Did you know that tables let you use a TH element on the row? That makes things a lot easier because only the td rows will be of interest.

Aside from that potential improvement for later on, adding items to the array means that a map is more suitable than using forEach.

            var ratingCells = $("tr.Rating > td:not(:first)");
            var getText = (td) => $(td).text();
            var ratings = ratingCells.toArray().map(getText);

The idea of getting the rankings is important enough to move that code into a separate function too.

Linting the sorting of the rankings

Here’s the sorting part of the rankByRatings function:

            var sorted = ratings.slice().sort(numericSort);
            var ranks = ratings.slice().map(function(v) {
                return sorted.indexOf(v) + 1;
            });
            return ranks;

The sort function and the map function are wanting names.

With the sort function, I’ll just call that numericSort and move it to be above the rankByRatings function. As for the map function, I’ll call that decideRank.

        function numericSort(a, b) {
            return b - a;
        }
...
            var sorted = ratings.slice().sort(numericSort);
            var ranks = ratings.slice().map(function decideRank(ratingIndex) {
                return sorted.indexOf(ratingIndex) + 1;
            });
            return ranks;

The slice of the ranks isn’t needed. It is wanted for sorted because of how sort changes the array, but with the map, slice is of no use at all, and can be removed.

Lastly, because we have the function called decideRank to help inform is about what’s going on, we can extract the decideRank function and instead of assigning the map to a ranks variable, we can just return the map.

The rankByRatings function is now reduced to be the following:

        function rankByRatings() {
            var ratings = getRatings();
            var sorted = ratings.slice().sort(numericSort);
            return ratings.map(function decideRank(ratingIndex) {
                return sorted.indexOf(ratingIndex) + 1;
            });
        }

Linting sort rankings

The next three sets of functions that want names for them, is in the sortColumnsByRanking function.

        function sortColumnsByRanking() {
            var Rows = $(".compTable tr");
            var RowRanking = $(".compTable tr.Ranking");

            RowRanking.find("td:not(:first)").sort(function(a, b) {
                return $(a).text().replace(/[^0-9]/gi, "").localeCompare($(b).text().replace(/[^0-9]/gi, ""));
            }).each(function(new_Index, cell) {
                var original_Index = $(cell).index();

                Rows.toArray().forEach(function(row) {
                    var td = $(row).find("td, th");
                    if (original_Index !== new_Index) {
                        td.eq(original_Index).insertAfter(td.eq(new_Index));
                    }
                });
            });
        }

The sorting function can reuse the numericSort that we had before. There shouldn’t be all of that activity in the sort function. Instead, we can use map to clean up the text to numbers, and then sort them.

            function toNumber(el) {
                var digits = $(el).text().replace(/[^0-9]/gi, "");
                return Number(digits);
            }
            var rankings = RowRanking.find("td:not(:first)").map(toNumber);

Linting reorder columns

Just after sorting the rankings, is reordering the columns.

        function sortColumnsByRanking() {
            var Rows = $(".compTable tr");
            var RowRanking = $(".compTable tr.Ranking");
            ...
            rankings.sort(numericSort).each(function(new_Index, cell) {
                var original_Index = $(cell).index();

                Rows.toArray().forEach(function(row) {
                    var td = $(row).find("td, th");
                    if (original_Index !== new_Index) {
                        td.eq(original_Index).insertAfter(td.eq(new_Index));
                    }
                });
            });

Underscores don’t tend to belong in JS variables, so I’ve removed those, and the Rows variable shouldn’t be out of the map function, so it gets moved in to the map function as a lowercase rows instead.

When it comes to naming the functions, I don’t know what to call the map function yet, so I’ll work on the forEach function instead to gain a better understanding of what happens there.

The name swapColumns seems to explain well what happens in the forEach code. I will though move the comparision up to the top of the function, so that an early return can be done when both indexes are the same. That way the comparison acts as what is called a Guard Clause, preventing the execution of code from progressing any further unless the conditions are met.

                var originalIndex = $(cell).index();
                rows.toArray().forEach(function swapColumns(row) {
                    if (originalIndex === newIndex) {
                        return;
                    }
                    var td = $(row).find("td, th");
                    td.eq(originalIndex).insertAfter(td.eq(newIndex));
                });

Actually, that guard clause can be moved further up in the code, so that the forEach doesn’t even occur if it doesn’t have to.

                var originalIndex = $(cell).index();
                rows.toArray().forEach(function swapColumns(row) {
                    if (originalIndex === newIndex) {
                        return;
                    }
                    var td = $(row).find("td, th");
                    td.eq(originalIndex).insertAfter(td.eq(newIndex));
                });

I now feel comfortable enough with the code that I can provide a suitable name for the each function, which is to call it moveColumn.

            var RowRanking = $(".compTable tr.Ranking");
            var rankings = RowRanking.find("td:not(:first)").map(toNumber);
            rankings.sort(numericSort).each(function moveColumn(newIndex, cell) {
                ...
            });

The only problem is that the linter isn’t happy about the length of the line. That’s alright, for it seems that the code will make better sense if the map and the sort are kept together on the same line.

            var RowRanking = $(".compTable tr.Ranking").find("td:not(:first)");
            var rankings = RowRanking.map(toNumber).sort(numericSort);
            rankings.each(function moveColumn(newIndex, cell) {

There are two more sets of function names to go, according to the linter.

Linting showRankings function

Here’s the showRankings function where the forEach functions need naming.

        function showRankings(ranks) {
            function updateRanking(cell, rank) {
                cell.empty().append($(`<div><div "ranking">#${rank}</div></div>`));
            }
            $(".divResult table tbody tr").toArray().forEach(function(tr) {
                var $td = $(tr).children();
                if ($td.eq(0).text() === "Ranking") {
                    ranks.forEach(function(rank, i) {
                        updateRanking($td.eq(i + 1), rank);
                        if (rank === 1) {
                            $td.eq(i + 1).addClass("ranking1");
                        }
                    });
                }
            });
        }

First observation is that there is a large arrow of indenting. We can easily improve that by extracting the functions, but that means naming them first.

The inner forEach function needs a name, but it’s hard to think of one other than updateRanking. That means that the code isn’t different enough, and should be in the same function. I moved some code out to a separate updateRanking function to help cope with too-long lines. I will temporarily inline that updateRanking function, so that more of the code can late ron be extracted out

        function showRankings(ranks) {
            $(".divResult table tbody tr").toArray().forEach(function(tr) {
                var $td = $(tr).children();
                if ($td.eq(0).text() === "Ranking") {
                    ranks.forEach(function (rank, i) {
                        $td.eq(i + 1).empty().append($(`<div><div "ranking">#${rank}</div></div>`));
                        if (rank === 1) {
                            $td.eq(i + 1).addClass("ranking1");
                        }
                    });
                }
            });
        }

The inner forEach function can now be extracted out fully, as an updateRanking function.

        function showRankings(ranks) {
            function updateRow(tr) {
                function updateCell(rank, i) {
                    var html = $(`<div><div "ranking">#${rank}</div></div>`);
                    $td.eq(i + 1).empty().append(html);
                    if (rank === 1) {
                        $td.eq(i + 1).addClass("ranking1");
                    }
                }
                var $td = $(tr).children();
                if ($td.eq(0).text() === "Ranking") {
                    ranks.forEach(updateCell);
                }
            }
            $(".divResult table tbody tr").toArray().forEach(updateRow);
        }

There is some other work that I want to do with that code, such as to separate apart the collection of data and using that data to update the table. But that is something that can wait until after the linting is finished.

Linting addWeatherWidgets

Here the code that we currently have for addWeatherWidgets

    function addWeatherWidgets() {
        function showWeatherWidget(el) {
            var path = $(el).text();
            var linkHtml = `<a href="https://forecast7.com/en/${path}/"`;
            linkHtml += ` class="weatherwidget-io" data-label_2="WEATHER"`;
            linkHtml += ` data-days="3" data-theme="original">WEATHER</a>`;
            $(el).html(linkHtml);
        }
        $("#divResult tbody tr td").toArray().forEach(function(td) {
            if ($(td).text() === "Weather") {
                $(td).nextAll("td").toArray().forEach(showWeatherWidget);
            }
            __weatherwidget_init();
        });
    }

The forEach function can be extracted out to showWeatherWidgets (plural instead of singular), and the __weatherwidget_init() can be moved out of the loop. That way, you don’t end up with the weather attempting to load multiple times.

    function addWeatherWidgets() {
        function showWeatherWidget(el) {
            var path = $(el).text();
            var linkHtml = `<a href="https://forecast7.com/en/${path}/"`;
            linkHtml += ` class="weatherwidget-io" data-label_2="WEATHER"`;
            linkHtml += ` data-days="3" data-theme="original">WEATHER</a>`;
            $(el).html(linkHtml);
        }
        function showWeatherWidgets(td) {
            if ($(td).text() === "Weather") {
                $(td).nextAll("td").toArray().forEach(showWeatherWidget);
            }
        }
        $("#divResult tbody tr td").toArray().forEach(showWeatherWidgets);
        __weatherwidget_init();
    }

That’s all for the naming of functions. The linter still has a few things to say about moving var statements up to the top of functions, but it’s going to be the next post where I next take a look at that.

1 Like

Next up with linting the code, var statements should be at the top of each function.

Here’s the JS code that we’re starting with:

/*jslint browser */
/*global __weatherwidget_init jQuery */
jQuery(document).ready(function rankWeather($) {
    var StatJSON = {
        "Option1": {
            "ColHeading": "New York",
            "Ranking": "",
            "Rating": "4.5",
            "Row1Heading": "Hello",
            "Weather": "40d71n74d01/new-york"
        },
        "Option2": {
            "ColHeading": "San Francisco",
            "Ranking": "",
            "Rating": "3.2",
            "Row1Heading": "Whats",
            "Weather": "37d77n122d42/san-francisco"
        },
        "Option3": {
            "ColHeading": "Chicago",
            "Ranking": "",
            "Rating": "3.7",
            "Row1Heading": "Up",
            "Weather": "41d88n87d63/chicago"
        },
        "Option4": {
            "ColHeading": "Los Angeles",
            "Ranking": "",
            "Rating": "5.0",
            "Row1Heading": "With",
            "Weather": "34d05n118d24/los-angeles"
        }
    };

    function printTable(data) {
        var html = `<table class="compTable"><thead><tr><th>`;
        if (data && data.length) {
            html += "</th>";
            $.each(data, function addColumnHeading(i) {
                html += "<th>" + StatJSON[data[i]].ColHeading + "</th>";
            });
            html += "</tr>";
            html += "<tbody>";
            $.each(StatJSON[data[0]], function addRow(rowTitle) {
                html += "<tr>";
                if (rowTitle !== "ColHeading") {
                    html += "<td>" + rowTitle + "</td>";
                }
                $.each(data, function addData(cityInfo) {
                    if (rowTitle !== "ColHeading") {
                        var cityData = StatJSON[data[cityInfo]][rowTitle];
                        html += "<td>" + cityData + "</td>";
                    }
                });
                html += "</tr>";
            });
        } else {
            html += "No results found</td></tr>";
        }
        html += "</tbody></table>";
        return html;
    }

    function updateWeatherTable() {
        var getCity = (option) => option.value;
        var data = $("#selection").find(":selected").toArray().map(getCity);
        $("#divResult").empty().append(printTable(data));
    }

    function cleanupTextAndClasses() {
        var headings = $(".divResult table th:not(:first)");
        headings.toArray().forEach(function addHeadingClass(header) {
            var columnHead = $(header).text().split(" ").join("");
            $(header).addClass(columnHead);
        });

        $(".divResult table th:first-child").removeAttr("name");

        $("table thead th").each(function propagateHeadingClass(index, th) {
            $(`table tbody td:nth-child(${index + 1})`).addClass(th.className);
        });

        var firstCells = $(".divResult tbody td:first-child");
        firstCells.toArray().forEach(function propagateRowClass(cell) {
            var removals = ["#", /[()]/g, /s/g, /\\|\//g];
            var className = removals.reduce(function sanitizeText(text, regex) {
                return text.replace(regex, "");
            }, cell.textContent);

            $(cell).addClass(className);
            $(cell).siblings("td").addClass(className);
            $(cell).parent("tr").addClass(className);
        });
    }
    function updateRatings() {
        function updateRating(cell) {
            var rating = parseFloat($(cell).text()).toFixed(2);
            $(cell).html(`<div><div class="rating">${rating}</div></div>`);
        }

        function updateRowRatings(ratingCell) {
            $(ratingCell).nextAll("td").toArray().forEach(updateRating);
        }
        $(".divResult .Rating").toArray().forEach(updateRowRatings);
    }

    function updateRankings() {
        function getRatings() {
            var ratingCells = $("tr.Rating > td:not(:first)");
            var getText = (td) => $(td).text();
            return ratingCells.toArray().map(getText);
        }
        function numericSort(a, b) {
            return b - a;
        }
        function rankByRatings() {
            var ratings = getRatings();
            var sorted = ratings.slice().sort(numericSort);
            return ratings.map(function decideRank(ratingIndex) {
                return sorted.indexOf(ratingIndex) + 1;
            });
        }

        function sortColumnsByRanking() {
            function toNumber(el) {
                var digits = $(el).text().replace(/[^0-9]/gi, "");
                return Number(digits);
            }
            var RowRanking = $(".compTable tr.Ranking").find("td:not(:first)");
            var rankings = RowRanking.map(toNumber).sort(numericSort);
            rankings.each(function moveColumn(newIndex, cell) {
                var rows = $(".compTable tr");
                var originalIndex = $(cell).index();
                if (originalIndex === newIndex) {
                    return;
                }
                rows.toArray().forEach(function swapColumns(row) {
                    var td = $(row).find("td, th");
                    td.eq(originalIndex).insertAfter(td.eq(newIndex));
                });
            });
        }
        function showRankings(ranks) {
            function updateRow(tr) {
                function updateCell(rank, i) {
                    var html = $(`<div><div "ranking">#${rank}</div></div>`);
                    $td.eq(i + 1).empty().append(html);
                    if (rank === 1) {
                        $td.eq(i + 1).addClass("ranking1");
                    }
                }
                var $td = $(tr).children();
                if ($td.eq(0).text() === "Ranking") {
                    ranks.forEach(updateCell);
                }
            }
            $(".divResult table tbody tr").toArray().forEach(updateRow);
        }

        var ranks = rankByRatings();
        showRankings(ranks);
        sortColumnsByRanking();
    }

    function addWeatherWidgets() {
        function showWeatherWidget(el) {
            var path = $(el).text();
            var linkHtml = `<a href="https://forecast7.com/en/${path}/"`;
            linkHtml += ` class="weatherwidget-io" data-label_2="WEATHER"`;
            linkHtml += ` data-days="3" data-theme="original">WEATHER</a>`;
            $(el).html(linkHtml);
        }
        function showWeatherWidgets(td) {
            if ($(td).text() === "Weather") {
                $(td).nextAll("td").toArray().forEach(showWeatherWidget);
            }
        }
        $("#divResult tbody tr td").toArray().forEach(showWeatherWidgets);
        __weatherwidget_init();
    }

    $("#btnSubmit").click(function citiesSubmitHandler() {
        updateWeatherTable();
        cleanupTextAndClasses();
        updateRatings();
        updateRankings();
        addWeatherWidgets();
    });
});

Linting addData

The addData function in printTable is our first target.

                html += `<tr><td>${rowTitle}</td>`;
                $.each(data, function addData(cityInfo) {
                    if (rowTitle !== "ColHeading") {
                        var cityData = StatJSON[data[cityInfo]][rowTitle];
                        html += "<td>" + cityData + "</td>";
                    }
                });

Here we can inline the cityData variable, and rename the addData function to be addCityData instead, helping to retain the information about it being cityData.

                html += `<tr><td>${rowTitle}</td>`;
                $.each(data, function addCityData(cityInfo) {
                    if (rowTitle !== "ColHeading") {
                        html += `<td>${StatJSON[data[cityInfo]][rowTitle]}</td>`;
                    }
                });

The only problem there is that the line is too long, so I’ll use a guard clause to help remove an indent from the html line.

                html += `<tr><td>${rowTitle}</td>`;
                $.each(data, function addCityData(cityInfo) {
                    if (rowTitle === "ColHeading") {
                        return;
                    }
                    html += `<td>${StatJSON[data[cityInfo]][rowTitle]}</td>`;
                });

A further improvement from here is to get the data organised into an array first, and to then operate on that array.

            $.each(StatJSON[data[0]], function addRow(rowTitle) {
                var cityNames = $(data).toArray();
                var rowData = cityNames.map(function getRowData(cityName) {
                    return StatJSON[cityName][rowTitle];
                });
                if (rowTitle === "ColHeading") {
                    return;
                }
                html += `<tr><td>${rowTitle}</td>`;
                rowData.forEach(function addCityData(cityData) {
                    html += `<td>${cityData}</td>`;
                });
                html += "</tr>";
            });

After the linting is finished, I’ll want to come back to the printTable function and complete the separation of data from presentation. That way, future possible improvements are then much easier to make, such as by plugging in different presentation techniques. Ideally that means moving away from tables. That way of presentation was known to be bad about 20 years ago. Other much improved techniques can be explored when the data isn’t mixed in with the presentation method.

Linting cleanupTextAndClasses

Halfway down this function is a firstCells variable that’s used in propagating the row class.

    function cleanupTextAndClasses() {
        var headings = $(".divResult table th:not(:first)");
        headings.toArray().forEach(function addHeadingClass(header) {
            var columnHead = $(header).text().split(" ").join("");
            $(header).addClass(columnHead);
        });

        $(".divResult table th:first-child").removeAttr("name");

        $("table thead th").each(function propagateHeadingClass(index, th) {
            $(`table tbody td:nth-child(${index + 1})`).addClass(th.className);
        });

        var firstCells = $(".divResult tbody td:first-child");
        firstCells.toArray().forEach(function propagateRowClass(cell) {
            var removals = ["#", /[()]/g, /s/g, /\\|\//g];
            var className = removals.reduce(function sanitizeText(text, regex) {
                return text.replace(regex, "");
            }, cell.textContent);

            $(cell).addClass(className);
            $(cell).siblings("td").addClass(className);
            $(cell).parent("tr").addClass(className);
        });
    }

The linter says to: “Move variable declaration to top of function or script”.

That could be thoughtlessly done by defining the variable as being undefined at the top of the function, then filling it in afterwards.

    function cleanupTextAndClasses() {
        var firstCells;
        ...
        firstCells = $(".divResult tbody td:first-child");
        firstCells.toArray().forEach(function propagateRowClass(cell) {
        ...
    }

That doesn’t help to solve the problem though. The problem here is that we have a significant amount of code for propagating the row class, that should be in a separate function, so I move the code out to a function called propagateRowClasses()

    function propagateRowClasses() {
        var firstCells = $(".divResult tbody td:first-child");
        firstCells.toArray().forEach(function propagateRowClass(cell) {
            var removals = ["#", /[()]/g, /s/g, /\\|\//g];
            var className = removals.reduce(function sanitizeText(text, regex) {
                return text.replace(regex, "");
            }, cell.textContent);

            $(cell).addClass(className);
            $(cell).siblings("td").addClass(className);
            $(cell).parent("tr").addClass(className);
        });
    }
    function cleanupTextAndClasses() {
        ...
        propagateRowClasses();
    }

There is also code in the cleanupTextAndClasses function for propagating the heading classes, so I’ll move that out to a separate function called propagateHeadingClasses

Most of the remaining code is to add heading classes, so a new function is used called addHeadingClasses.

That leaves us with a function that is much easier to understand:

    function cleanupTextAndClasses() {
        $(".divResult table th:first-child").removeAttr("name");
        addHeadingClasses();
        propagateHeadingClasses();
        propagateRowClasses();
    }

and now a better name can be given to that function. After moving the code out to separately named functions, we now have a much better idea about what to call it. The function name cleanupTextAndClasses is not suitable anymore. That gets renamed instead to be addHeadingAndRowClasses.

The updated code is now the following:

    function addHeadingClasses() {
        var headings = $(".divResult table th:not(:first)");
        headings.toArray().forEach(function addHeadingClass(header) {
            var columnHead = $(header).text().split(" ").join("");
            $(header).addClass(columnHead);
        });
    }
    function propagateHeadingClasses() {
        $("table thead th").each(function propagateHeadingClass(index, th) {
            $(`table tbody td:nth-child(${index + 1})`).addClass(th.className);
        });
    }
    function propagateRowClasses() {
        var firstCells = $(".divResult tbody td:first-child");
        firstCells.toArray().forEach(function propagateRowClass(cell) {
            var removals = ["#", /[()]/g, /s/g, /\\|\//g];
            var className = removals.reduce(function sanitizeText(text, regex) {
                return text.replace(regex, "");
            }, cell.textContent);

            $(cell).addClass(className);
            $(cell).siblings("td").addClass(className);
            $(cell).parent("tr").addClass(className);
        });
    }
    function addHeadingAndRowClasses() {
        $(".divResult table th:first-child").removeAttr("name");
        addHeadingClasses();
        propagateHeadingClasses();
        propagateRowClasses();
    }

Linting sortColumnsByRanking

The next issue that the linter complains about is here:

        function sortColumnsByRanking() {
            function toNumber(el) {
                var digits = $(el).text().replace(/[^0-9]/gi, "");
                return Number(digits);
            }
            var RowRanking = $(".compTable tr.Ranking").find("td:not(:first)");
            var rankings = RowRanking.map(toNumber).sort(numericSort);
            rankings.each(function moveColumn(newIndex, cell) {
            ...

This one is easily dealt with by moving the function up and out of the sortColumnsByRanking function.

        function toNumber(el) {
            var digits = $(el).text().replace(/[^0-9]/gi, "");
            return Number(digits);
        }
        function sortColumnsByRanking() {
            var RowRanking = $(".compTable tr.Ranking").find("td:not(:first)");
            var rankings = RowRanking.map(toNumber).sort(numericSort);
            rankings.each(function moveColumn(newIndex, cell) {
            ...

There’s only two more linting issues to go.

Linting showRankings function

Here’s the code that we’re starting with:

        function showRankings(ranks) {
            function updateRow(tr) {
                function updateCell(rank, i) {
                    var html = $(`<div><div "ranking">#${rank}</div></div>`);
                    $td.eq(i + 1).empty().append(html);
                    if (rank === 1) {
                        $td.eq(i + 1).addClass("ranking1");
                    }
                }
                var $td = $(tr).children();
                if ($td.eq(0).text() === "Ranking") {
                    ranks.forEach(updateCell);
                }
            }
            $(".divResult table tbody tr").toArray().forEach(updateRow);
        }

That updateCell function was extracted from forEach purely for line length issues. Let’s inline the updateCell function and rethink about things further from there.

        function showRankings(ranks) {
            function updateRow(tr) {
                var $td = $(tr).children();
                if ($td.eq(0).text() === "Ranking") {
                    ranks.forEach(function updateCell(rank, i) {
                        var html = $(`<div><div "ranking">#${rank}</div></div>`);
                        $td.eq(i + 1).empty().append(html);
                        if (rank === 1) {
                            $td.eq(i + 1).addClass("ranking1");
                        }
                    });
                }
            }
            $(".divResult table tbody tr").toArray().forEach(updateRow);
        }

Instead of extracting the updateCell function, I’ll extract a little bit more and move the entire contents of the if statement to an updateCells function instead.

It now makes sense to inline the updateRow function, and extract a row variable to help simplify things. We end up with much-less indented code, that seems to make more sense than before.

        function updateCells(ranks, $td) {
            ranks.forEach(function updateCell(rank, i) {
                var html = $(`<div><div "ranking">#${rank}</div></div>`);
                $td.eq(i + 1).empty().append(html);
                if (rank === 1) {
                    $td.eq(i + 1).addClass("ranking1");
                }
            });
        }
        function showRankings(ranks) {
            var rows = $(".divResult table tbody tr");
            rows.toArray().forEach(function updateRow(tr) {
                var $td = $(tr).children();
                if ($td.eq(0).text() === "Ranking") {
                    updateCells(ranks, $td);
                }
            });
        }

Linting updateRankings

Near the end of the updateRankings function is this code:

        var ranks = rankByRatings();
        showRankings(ranks);
        sortColumnsByRanking();

That ranks variable really doesn’t need to be there. That ranks line can be pushed down in to the showRankings function itself.

        function showRankings() {
            var ranks = rankByRatings();
            ...
        }
        ...
        showRankings();
        sortColumnsByRanking();

With that, the linting is over where a computer looks for obvious problems that need fixing.

Now it’s my turn to look over things and seek improvements.

1 Like

Here’s the code that we ended up with at the end of linting.

/*jslint browser */
/*global __weatherwidget_init jQuery */
jQuery(document).ready(function rankWeather($) {
    var StatJSON = {
        "Option1": {
            "ColHeading": "New York",
            "Ranking": "",
            "Rating": "4.5",
            "Row1Heading": "Hello",
            "Weather": "40d71n74d01/new-york"
        },
        "Option2": {
            "ColHeading": "San Francisco",
            "Ranking": "",
            "Rating": "3.2",
            "Row1Heading": "Whats",
            "Weather": "37d77n122d42/san-francisco"
        },
        "Option3": {
            "ColHeading": "Chicago",
            "Ranking": "",
            "Rating": "3.7",
            "Row1Heading": "Up",
            "Weather": "41d88n87d63/chicago"
        },
        "Option4": {
            "ColHeading": "Los Angeles",
            "Ranking": "",
            "Rating": "5.0",
            "Row1Heading": "With",
            "Weather": "34d05n118d24/los-angeles"
        }
    };

    function printTable(data) {
        var html = `<table class="compTable"><thead><tr><th>`;
        if (data && data.length) {
            html += "</th>";
            $.each(data, function addColumnHeading(i) {
                html += "<th>" + StatJSON[data[i]].ColHeading + "</th>";
            });
            html += "</tr>";
            html += "<tbody>";
            $.each(StatJSON[data[0]], function addRow(rowTitle) {
                var cityNames = $(data).toArray();
                var rowData = cityNames.map(function getRowData(cityName) {
                    return StatJSON[cityName][rowTitle];
                });
                if (rowTitle === "ColHeading") {
                    return;
                }
                html += `<tr><td>${rowTitle}</td>`;
                rowData.forEach(function addCityData(cityData) {
                    html += `<td>${cityData}</td>`;
                });
                html += "</tr>";
            });
        } else {
            html += "No results found</td></tr>";
        }
        html += "</tbody></table>";
        return html;
    }

    function updateWeatherTable() {
        var getCity = (option) => option.value;
        var data = $("#selection").find(":selected").toArray().map(getCity);
        $("#divResult").empty().append(printTable(data));
    }

    function addHeadingClasses() {
        var headings = $(".divResult table th:not(:first)");
        headings.toArray().forEach(function addHeadingClass(header) {
            var columnHead = $(header).text().split(" ").join("");
            $(header).addClass(columnHead);
        });
    }
    function propagateHeadingClasses() {
        $("table thead th").each(function propagateHeadingClass(index, th) {
            $(`table tbody td:nth-child(${index + 1})`).addClass(th.className);
        });
    }
    function propagateRowClasses() {
        var firstCells = $(".divResult tbody td:first-child");
        firstCells.toArray().forEach(function propagateRowClass(cell) {
            var removals = ["#", /[()]/g, /s/g, /\\|\//g];
            var className = removals.reduce(function sanitizeText(text, regex) {
                return text.replace(regex, "");
            }, cell.textContent);

            $(cell).addClass(className);
            $(cell).siblings("td").addClass(className);
            $(cell).parent("tr").addClass(className);
        });
    }
    function addHeadingAndRowClasses() {
        $(".divResult table th:first-child").removeAttr("name");
        addHeadingClasses();
        propagateHeadingClasses();
        propagateRowClasses();
    }

    function updateRatings() {
        function updateRating(cell) {
            var rating = parseFloat($(cell).text()).toFixed(2);
            $(cell).html(`<div><div class="rating">${rating}</div></div>`);
        }

        function updateRowRatings(ratingCell) {
            $(ratingCell).nextAll("td").toArray().forEach(updateRating);
        }
        $(".divResult .Rating").toArray().forEach(updateRowRatings);
    }

    function updateRankings() {
        function getRatings() {
            var ratingCells = $("tr.Rating > td:not(:first)");
            var getText = (td) => $(td).text();
            return ratingCells.toArray().map(getText);
        }
        function numericSort(a, b) {
            return b - a;
        }
        function rankByRatings() {
            var ratings = getRatings();
            var sorted = ratings.slice().sort(numericSort);
            return ratings.map(function decideRank(ratingIndex) {
                return sorted.indexOf(ratingIndex) + 1;
            });
        }

        function updateCells(ranks, $td) {
            ranks.forEach(function updateCell(rank, i) {
                var html = $(`<div><div "ranking">#${rank}</div></div>`);
                $td.eq(i + 1).empty().append(html);
                if (rank === 1) {
                    $td.eq(i + 1).addClass("ranking1");
                }
            });
        }
        function showRankings() {
            var ranks = rankByRatings();
            var rows = $(".divResult table tbody tr");
            rows.toArray().forEach(function updateRow(tr) {
                var $td = $(tr).children();
                if ($td.eq(0).text() === "Ranking") {
                    updateCells(ranks, $td);
                }
            });
        }

        function toNumber(el) {
            var digits = $(el).text().replace(/[^0-9]/gi, "");
            return Number(digits);
        }
        function sortColumnsByRanking() {
            var RowRanking = $(".compTable tr.Ranking").find("td:not(:first)");
            var rankings = RowRanking.map(toNumber).sort(numericSort);
            rankings.each(function moveColumn(newIndex, cell) {
                var rows = $(".compTable tr");
                var originalIndex = $(cell).index();
                if (originalIndex === newIndex) {
                    return;
                }
                rows.toArray().forEach(function swapColumns(row) {
                    var td = $(row).find("td, th");
                    td.eq(originalIndex).insertAfter(td.eq(newIndex));
                });
            });
        }

        showRankings();
        sortColumnsByRanking();
    }

    function addWeatherWidgets() {
        function showWeatherWidget(el) {
            var path = $(el).text();
            var linkHtml = `<a href="https://forecast7.com/en/${path}/"`;
            linkHtml += ` class="weatherwidget-io" data-label_2="WEATHER"`;
            linkHtml += ` data-days="3" data-theme="original">WEATHER</a>`;
            $(el).html(linkHtml);
        }
        function showWeatherWidgets(td) {
            if ($(td).text() === "Weather") {
                $(td).nextAll("td").toArray().forEach(showWeatherWidget);
            }
        }
        $("#divResult tbody tr td").toArray().forEach(showWeatherWidgets);
        __weatherwidget_init();
    }

    $("#btnSubmit").click(function citiesSubmitHandler() {
        updateWeatherTable();
        addHeadingAndRowClasses();
        updateRatings();
        updateRankings();
        addWeatherWidgets();
    });
});

There were several issues that I noticed while working through the linting. A non-exhaustive list is:

  • Similar (but different) selectors were being used to access the same thing
  • Data is mixed in with the html
  • Too much fiddling occurs to get the city column data cells
  • A lot of classes are being added when only one should do
  • HTML code should not be in JavaScript code
  • Tables are being used for presentation

I’ll take a look at the selectors today, and might make a start on the data.

A consistent divResult

In several places you have used “#divResult” or “.divResult” which contains the table. A unique id is used when only one is expected to ever exist on the page, and a class is used when multiple ones may exist. The class tends to be more difficult to handle in the code, so I’ll standardize on “#divResult” instead.

    function addHeadingClasses() {
        // var headings = $(".divResult table th:not(:first)");
        var headings = $("#divResult table th:not(:first)");
...
    function propagateRowClasses() {
        // var firstCells = $(".divResult tbody td:first-child");
        var firstCells = $("#divResult tbody td:first-child");
...
    function addHeadingAndRowClasses() {
        // $(".divResult table th:first-child").removeAttr("name");
        $("#divResult table th:first-child").removeAttr("name");
...
        function updateRowRatings(ratingCell) {
            $(ratingCell).nextAll("td").toArray().forEach(updateRating);
        }
        // $(".divResult .Rating").toArray().forEach(updateRowRatings);
        $("#divResult .Rating").toArray().forEach(updateRowRatings);
...
        function showRankings() {
            var ranks = rankByRatings();
            // var rows = $(".divResult table tbody tr");
            var rows = $("#divResult table tbody tr");

We can now remove the divResult class from the element, as there is no CSS and no JS that uses it.

<!--<div id="divResult" class="divResult"></div>-->
    <div id="divResult"></div>

Improve access to the table

That divResult is being used to help us get to the table. Frequently only parts of the table are wanted, such as the thead, or the tbody. So I’m going to return the table from the updateWeatherTable function, so that it’s easy to gain access to the head and the body.

    function updateWeatherTable() {
        var getCity = (option) => option.value;
        var data = $("#selection").find(":selected").toArray().map(getCity);
        $("#divResult").empty().append(printTable(data));
        return $("#divResult table");
    }
...
    $("#btnSubmit").click(function citiesSubmitHandler() {
        var table = updateWeatherTable();
        var thead = $("thead", table);
        var tbody = $("tbody", table);

It would be possible to just use table, but having thead and tbody means that we can restrict the searches to the smallest relevant area.

Use thead and tbody

We can now use those thead and tbody references throughout the code, helping to make it more consistent. The only issue is do we have thead and tbody as global variables? Or do we pass them as function parameters where needed. I prefer to err on the side of caution and pass them as function parameters.

I won’t take you through all of the changes, but here are some of them to give you an idea about the types of change being made.

    // function addHeadingClasses() {
    function addHeadingClasses(thead) {
        // var headings = $("#divResult table th:not(:first)");
        var headings = $("th:not(:first)", thead);
...
    // function propagateHeadingClasses() {
    function propagateHeadingClasses(thead, tbody) {
        // $("table thead th").each(function propagateHeadingClass(index, th) {
        $("th", thead).each(function propagateHeadingClass(index, th) {
            // $(`table tbody td:nth-child(${index + 1})`).addClass(th.className);
            $(`td:nth-child(${index + 1})`, tbody).addClass(th.className);
        });
    }
...
    // function addHeadingAndRowClasses() {
    function addHeadingAndRowClasses(thead, tbody) {
        $("th:first-child", thead).removeAttr("name");
        addHeadingClasses(thead);
        propagateHeadingClasses(thead, tbody);
        propagateRowClasses(tbody);
    }
...
        addHeadingAndRowClasses(thead, tbody);

In practice what this means is tracking down each #divResult and figuring out if that selector needs either a thead or a tbody and put that in as the second parameter of the search, letting me remove everything from the selector that relates to it. Then I use function parameters to help me “connect the plumbing” so that we can get the thead or tbody parameters that are needed.

Once each #divResult has been tracked down and dealt with, I do a visual scan through all of the code for any remaining selectors that might be left over. I end up finding .compTable as a weird selector. That’s another selector for the table, which we can replace with a consistent reference to the tbody instead.

        // function sortColumnsByRanking() {
        function sortColumnsByRanking(tbody) {
            // var RowRanking = $(".compTable tr.Ranking").find("td:not(:first)");
            var RowRanking = $("tr.Ranking", tbody).find("td:not(:first)");
            var rankings = RowRanking.map(toNumber).sort(numericSort);
            rankings.each(function moveColumn(newIndex, cell) {
                // var rows = $(".compTable tr");
                var rows = $("tr", tbody);

We can even move that rows variable further up in the function, so that we can make good use of the rows.

        function sortColumnsByRanking(tbody) {
            var rows = $("tr", tbody);
            // var RowRanking = $("tr.Ranking", tbody).find("td:not(:first)");
            var RowRanking = $(".Ranking", rows).find("td:not(:first)");
            var rankings = RowRanking.map(toNumber).sort(numericSort);
            rankings.each(function moveColumn(newIndex, cell) {

The code now makes good use of the thead and tbody sections of the table. Doing that helps to prepare us for a future plan where the left column has all TD elements replaced to be TH elements, so that getting the relevant TD elements on a row becomes incredibly easy. That’s an idea for the future though.

Here’s the updated code as it stands now.

/*jslint browser */
/*global __weatherwidget_init jQuery */
jQuery(document).ready(function rankWeather($) {
    var StatJSON = {
        "Option1": {
            "ColHeading": "New York",
            "Ranking": "",
            "Rating": "4.5",
            "Row1Heading": "Hello",
            "Weather": "40d71n74d01/new-york"
        },
        "Option2": {
            "ColHeading": "San Francisco",
            "Ranking": "",
            "Rating": "3.2",
            "Row1Heading": "Whats",
            "Weather": "37d77n122d42/san-francisco"
        },
        "Option3": {
            "ColHeading": "Chicago",
            "Ranking": "",
            "Rating": "3.7",
            "Row1Heading": "Up",
            "Weather": "41d88n87d63/chicago"
        },
        "Option4": {
            "ColHeading": "Los Angeles",
            "Ranking": "",
            "Rating": "5.0",
            "Row1Heading": "With",
            "Weather": "34d05n118d24/los-angeles"
        }
    };

    function printTable(data) {
        var html = `<table class="compTable"><thead><tr><th>`;
        if (data && data.length) {
            html += "</th>";
            $.each(data, function addColumnHeading(i) {
                html += "<th>" + StatJSON[data[i]].ColHeading + "</th>";
            });
            html += "</tr>";
            html += "<tbody>";
            $.each(StatJSON[data[0]], function addRow(rowTitle) {
                var cityNames = $(data).toArray();
                var rowData = cityNames.map(function getRowData(cityName) {
                    return StatJSON[cityName][rowTitle];
                });
                if (rowTitle === "ColHeading") {
                    return;
                }
                html += `<tr><td>${rowTitle}</td>`;
                rowData.forEach(function addCityData(cityData) {
                    html += `<td>${cityData}</td>`;
                });
                html += "</tr>";
            });
        } else {
            html += "No results found</td></tr>";
        }
        html += "</tbody></table>";
        return html;
    }

    function updateWeatherTable() {
        var getCity = (option) => option.value;
        var data = $("#selection").find(":selected").toArray().map(getCity);
        $("#divResult").empty().append(printTable(data));
        return $("#divResult table");
    }

    function addHeadingClasses(thead) {
        var headings = $("th:not(:first)", thead);
        headings.toArray().forEach(function addHeadingClass(header) {
            var columnHead = $(header).text().split(" ").join("");
            $(header).addClass(columnHead);
        });
    }
    function propagateHeadingClasses(thead, tbody) {
        $("th", thead).each(function propagateHeadingClass(index, th) {
            $(`td:nth-child(${index + 1})`, tbody).addClass(th.className);
        });
    }
    function propagateRowClasses(tbody) {
        var firstCells = $("td:first-child", tbody);
        firstCells.toArray().forEach(function propagateRowClass(cell) {
            var removals = ["#", /[()]/g, /s/g, /\\|\//g];
            var className = removals.reduce(function sanitizeText(text, regex) {
                return text.replace(regex, "");
            }, cell.textContent);

            $(cell).addClass(className);
            $(cell).siblings("td").addClass(className);
            $(cell).parent("tr").addClass(className);
        });
    }
    function addHeadingAndRowClasses(thead, tbody) {
        $("th:first-child", thead).removeAttr("name");
        addHeadingClasses(thead);
        propagateHeadingClasses(thead, tbody);
        propagateRowClasses(tbody);
    }

    function updateRatings(tbody) {
        function updateRating(cell) {
            var rating = parseFloat($(cell).text()).toFixed(2);
            $(cell).html(`<div><div class="rating">${rating}</div></div>`);
        }

        function updateRowRatings(ratingCell) {
            $(ratingCell).nextAll("td").toArray().forEach(updateRating);
        }
        $(".Rating", tbody).toArray().forEach(updateRowRatings);
    }

    function updateRankings(tbody) {
        function getRatings() {
            var ratingCells = $("tr.Rating > td:not(:first)", tbody);
            var getText = (td) => $(td).text();
            return ratingCells.toArray().map(getText);
        }
        function numericSort(a, b) {
            return b - a;
        }
        function rankByRatings() {
            var ratings = getRatings();
            var sorted = ratings.slice().sort(numericSort);
            return ratings.map(function decideRank(ratingIndex) {
                return sorted.indexOf(ratingIndex) + 1;
            });
        }

        function updateCells(ranks, $td) {
            ranks.forEach(function updateCell(rank, i) {
                var html = $(`<div><div "ranking">#${rank}</div></div>`);
                $td.eq(i + 1).empty().append(html);
                if (rank === 1) {
                    $td.eq(i + 1).addClass("ranking1");
                }
            });
        }
        function showRankings(tbody) {
            var ranks = rankByRatings();
            var rows = $("tr", tbody);
            rows.toArray().forEach(function updateRow(tr) {
                var $td = $(tr).children();
                if ($td.eq(0).text() === "Ranking") {
                    updateCells(ranks, $td);
                }
            });
        }

        function toNumber(el) {
            var digits = $(el).text().replace(/[^0-9]/gi, "");
            return Number(digits);
        }
        function sortColumnsByRanking(tbody) {
            var rows = $("tr", tbody);
            var RowRanking = $(".Ranking", rows).find("td:not(:first)");
            var rankings = RowRanking.map(toNumber).sort(numericSort);
            rankings.each(function moveColumn(newIndex, cell) {
                var originalIndex = $(cell).index();
                if (originalIndex === newIndex) {
                    return;
                }
                rows.toArray().forEach(function swapColumns(row) {
                    var td = $(row).find("td, th");
                    td.eq(originalIndex).insertAfter(td.eq(newIndex));
                });
            });
        }

        showRankings(tbody);
        sortColumnsByRanking(tbody);
    }

    function addWeatherWidgets(tbody) {
        function showWeatherWidget(el) {
            var path = $(el).text();
            var linkHtml = `<a href="https://forecast7.com/en/${path}/"`;
            linkHtml += ` class="weatherwidget-io" data-label_2="WEATHER"`;
            linkHtml += ` data-days="3" data-theme="original">WEATHER</a>`;
            $(el).html(linkHtml);
        }
        function showWeatherWidgets(td) {
            if ($(td).text() === "Weather") {
                $(td).nextAll("td").toArray().forEach(showWeatherWidget);
            }
        }
        $("tr td", tbody).toArray().forEach(showWeatherWidgets);
        __weatherwidget_init();
    }

    $("#btnSubmit").click(function citiesSubmitHandler() {
        var table = updateWeatherTable();
        var thead = $("thead", table);
        var tbody = $("tbody", table);

        addHeadingAndRowClasses(thead, tbody);
        updateRatings(tbody);
        updateRankings(tbody);
        addWeatherWidgets(tbody);
    });
});
1 Like

Because we are passing tbody to most of the functions, I want the event handler to just pass the table to one function, and for that one function to get thead and tbody from there, before passing it to the functions that need it. That will be nice and consistent with what event handlers are supposed to do, by getting the info that’s needed and passing it to where it needs to go.

    function updateTableAndWidgets(table) {
        var thead = $("thead", table);
        var tbody = $("tbody", table);
        addHeadingAndRowClasses(thead, tbody);
        updateRatings(tbody);
        updateRankings(tbody);
        addWeatherWidgets(tbody);
    }

    $("#btnSubmit").click(function citiesSubmitHandler() {
        var table = updateWeatherTable().get(0);
        updateTableAndWidgets(table);
    });

Separate data from presentation

This next part can get tricky, but ultimately we are wanting to collect all of the data together first, before sending that data to the presenter.

I’ll start by moving the data up to the top of a function, then move that data out of the function by passing it to the function. That way what remains in the function is the presenter. This is a good separation to have, for the same data can be passed to a different presenter, or the presenter can be more easily modified without needing to worry that you may be accidentally changing the data.

Here’s the printTable as it is right now.

    function printTable(data) {
        var html = `<table class="compTable"><thead><tr><th>`;
        if (data && data.length) {
            html += "</th>";
            $.each(data, function addColumnHeading(i) {
                html += "<th>" + StatJSON[data[i]].ColHeading + "</th>";
            });
            html += "</tr>";
            html += "<tbody>";
            $.each(StatJSON[data[0]], function addRow(rowIndex) {
                var cityNames = $(data).toArray();
                var rowData = cityNames.map(function getRowData(cityName) {
                    return StatJSON[cityName][rowIndex];
                });
                if (rowIndex === "ColHeading") {
                    return;
                }
                html += `<tr><td>${rowIndex}</td>`;
                rowData.forEach(function addCityData(cityData) {
                    html += `<td>${cityData}</td>`;
                });
                html += "</tr>";
            });
        } else {
            html += "No results found</td></tr>";
        }
        html += "</tbody></table>";
        return html;
    }

Significant structural problem with the way that the HTML is being composed, is that the no results section expects a TH element to already exist, but carries on to close off an unused TD and a further TR. I’ll update that so that the no results section is consistent, by starting and closing the same TR.’

    function printTable(data) {
        // var html = `<table class="compTable"><thead><tr><th>`;
        var html = `<table class="compTable"><thead>`;
        if (data && data.length) {
            // html += "</th>";
            html += "<tr><th></th>";
            ...
        } else {
            // html += "No results found</td></tr>";
            html += "<tr><td>No results found</td></tr>";
        }
        html += "</tbody></table>";
        return html;
    }

Let’s now work at moving some of collation of data up and out of the presentation code.

    function printTable(data) {
        var headings = Object.values(data).map(function getHeading(option) {
            return StatJSON[option].ColHeading;
        });
...
            headings.forEach(function addColumnHeading(heading) {
                html += `<th>${heading}</th>`;
            });

Something similar can be done with the rowData a bit further into that function: Here is is before the update:

            $.each(StatJSON[data[0]], function addRow(rowIndex) {
                var cityData = Object.values(data);
                var rowData = cityData.map(function getRowData(option) {
                    return StatJSON[option][rowIndex];
                });
                if (rowIndex === "ColHeading") {
                    return;
                }
                html += `<tr><td>${rowIndex}</td>`;
                rowData.forEach(function addCityData(cityData) {
                    html += `<td>${cityData}</td>`;
                });
                html += "</tr>";
            });

Fortunately earlier work brought cityData and rowData up to the top of the addRow function. We just need to take it further up from there.

The cityData name is misleading. That is an array that has “Option1” or “Option4” names so a better name for that could be options. We can then move those options up to the top of the printTable function, so that the headings code can use it too.

        var options = Object.values(data);
        // var headings = Object.values(data).map(function getHeading(option) {
        var headings = options.map(function getHeading(option) {
            return StatJSON[option].ColHeading;
        });

On investigating more closely, the data is just an array of selected option values, so data can be renamed to selectedOptions instead.

    // function printTable(data) {
    function printTable(selectedOptions) {
        // var options = Object.values(data);
        // var headings = options.map(function getHeading(option) {
        var headings = selectedOptions.map(function getHeading(option) {
            return StatJSON[option].ColHeading;
        });
...
        // if (data && data.length) {
        if (selectedOptions && selectedOptions.length) {
        // $.each(StatJSON[data[0]], function addRow(rowIndex) {
        $.each(StatJSON[selectedOptions[0]], function addRow(rowIndex) {

Here is what we now have in regard to the rowData.

```javascript
            $.each(StatJSON[selectedOptions[0]], function addRow(rowIndex) {
                var rowData = selectedOptions.map(function getRowData(option) {
                    return StatJSON[option][rowIndex];
                });

Before moving out the rowData to something called rowsData, I want to have a better idea about what StatJSON[selectedOptions[0]] means. Those are the entries that relate to one of the cities, so we should name it that to help avoid confusion.

            var cityData = StatJSON[selectedOptions[0]];
            $.each(cityData, function addRow(rowIndex) {

Now that cityData isn’t being used at all. Instead it’s just used to do all of ColHeading, then all of Ranking, then all of Rating, and so on.

Instead of getting cityData it makes more sense to just get the property names, or as we have called them in the code, the row titles.

            // var cityData = StatJSON[selectedOptions[0]];
            var rowTitles = Object.keys(StatJSON[0]);
            // $.each(cityData, function addRow(rowIndex) {
            rowTitles.forEach(function addRow(rowTitle) {
                var rowData = selectedOptions.map(function getRowData(option) {
                    // return StatJSON[option][rowIndex];
                    return StatJSON[option][rowTitle];
                });

I can now move the rowData up out of the addRow function, where the rowData is used to create a new rowsData object.

            var rowTitles = Object.keys(StatJSON[selectedOptions[0]]);
            var rowsData = rowTitles.map(function addRow(rowTitle) {
                return selectedOptions.map(function getRowData(option) {
                    return StatJSON[option][rowTitle];
                });
            });
            rowTitles.forEach(function addRow(rowTitle) {

That rowsData can now be used for the addRow line instead of rowTitles.

            var rowTitles = Object.keys(StatJSON[selectedOptions[0]]);
            var rowsData = rowTitles.map(function addRow(rowTitle) {
                return selectedOptions.map(function getRowData(option) {
                    return StatJSON[option][rowTitle];
                });
            });
            rowsData.forEach(function addRow(rowData, rowIndex) {
                var rowTitle = rowTitles[rowIndex];

Those vars can now be moved to the top of the printTable function, and all of the data is now above the presentation.

    function printTable(selectedOptions) {
        var headings = selectedOptions.map(function getHeading(option) {
            return StatJSON[option].ColHeading;
        });
        var rowTitles = Object.keys(StatJSON[selectedOptions[0]]);
        var rowsData = rowTitles.map(function addRow(rowTitle) {
            return selectedOptions.map(function getRowData(option) {
                return StatJSON[option][rowTitle];
            });
        });
        ...
    }

To complete this, we need to move the data into a separate function called getData, and the presentation below it out to a separate function called showTable. I’ll start with the showTable function, as we can define the data structure more easily in that way.

Instead of the selectedOptions being available, we can use the rowsData array instead.

    function showTable({headings, rowTitles, rowsData}) {
        var html = `<table class="compTable"><thead>`;
        // if (selectedOptions && selectedOptions.length) {
        if (rowsData && rowsData.length) {
            html += "<tr><th></th>";
            headings.forEach(function addColumnHeading(heading) {
                html += `<th>${heading}</th>`;
            });
            html += "</tr>";
            html += "<tbody>";
            rowsData.forEach(function addRow(rowData, rowIndex) {
                var rowTitle = rowTitles[rowIndex];
                if (rowTitle === "ColHeading") {
                    return;
                }
                html += `<tr><td>${rowTitle}</td>`;
                rowData.forEach(function addCityData(cityData) {
                    html += `<td>${cityData}</td>`;
                });
                html += "</tr>";
            });
        } else {
            html += "<tr><td>No results found</td></tr>";
        }
        html += "</tbody></table>";
        return html;
    }
    function printTable(selectedOptions) {
        ...
        return showTable({headings, rowTitles, rowsData});
    }

The data part of the printTable can now be moved out to a getCityData function.

    function getCityData(StatJSON, selectedOptions) {
        var headings = selectedOptions.map(function getHeading(option) {
            return StatJSON[option].ColHeading;
        });
        var rowTitles = Object.keys(StatJSON[selectedOptions[0]]);
        var rowsData = rowTitles.map(function addRow(rowTitle) {
            return selectedOptions.map(function getRowData(option) {
                return StatJSON[option][rowTitle];
            });
        });
        return {
            headings,
            rowTitles,
            rowsData
        };
    }
...
    function printTable(selectedOptions) {
        var cityData = getCityData(StatJSON, selectedOptions);
        // return showTable({headings, rowTitles, rowsData});
        return showTable(cityData);
    }

Here’s the full scripting code as it is now:

jQuery(document).ready(function rankWeather($) {
    var StatJSON = {
        "Option1": {
            "ColHeading": "New York",
            "Ranking": "",
            "Rating": "4.5",
            "Row1Heading": "Hello",
            "Weather": "40d71n74d01/new-york"
        },
        "Option2": {
            "ColHeading": "San Francisco",
            "Ranking": "",
            "Rating": "3.2",
            "Row1Heading": "Whats",
            "Weather": "37d77n122d42/san-francisco"
        },
        "Option3": {
            "ColHeading": "Chicago",
            "Ranking": "",
            "Rating": "3.7",
            "Row1Heading": "Up",
            "Weather": "41d88n87d63/chicago"
        },
        "Option4": {
            "ColHeading": "Los Angeles",
            "Ranking": "",
            "Rating": "5.0",
            "Row1Heading": "With",
            "Weather": "34d05n118d24/los-angeles"
        }
    };

    function getCityData(StatJSON, selectedOptions) {
        var headings = selectedOptions.map(function getHeading(option) {
            return StatJSON[option].ColHeading;
        });
        var rowTitles = Object.keys(StatJSON[selectedOptions[0]]);
        var rowsData = rowTitles.map(function addRow(rowTitle) {
            return selectedOptions.map(function getRowData(option) {
                return StatJSON[option][rowTitle];
            });
        });
        return {
            headings,
            rowTitles,
            rowsData
        };
    }
    function showTable({headings, rowTitles, rowsData}) {
        var html = `<table class="compTable"><thead>`;
        if (rowsData && rowsData.length) {
            html += "<tr><th></th>";
            headings.forEach(function addColumnHeading(heading) {
                html += `<th>${heading}</th>`;
            });
            html += "</tr>";
            html += "<tbody>";
            rowsData.forEach(function addRow(rowData, rowIndex) {
                var rowTitle = rowTitles[rowIndex];
                if (rowTitle === "ColHeading") {
                    return;
                }
                html += `<tr><td>${rowTitle}</td>`;
                rowData.forEach(function addCityData(cityData) {
                    html += `<td>${cityData}</td>`;
                });
                html += "</tr>";
            });
        } else {
            html += "<tr><td>No results found</td></tr>";
        }
        html += "</tbody></table>";
        return html;
    }
    function printTable(selectedOptions) {
        var cityData = getCityData(StatJSON, selectedOptions);
        return showTable(cityData);
    }

    function updateWeatherTable() {
        var selected = $("#selection").find(":selected");
        var getValue = (option) => option.value;
        var options = selected.toArray().map(getValue);
        $("#divResult").empty().append(printTable(options));
        return $("#divResult table");
    }

    function addHeadingClasses(thead) {
        var headings = $("th:not(:first)", thead);
        headings.toArray().forEach(function addHeadingClass(header) {
            var columnHead = $(header).text().split(" ").join("");
            $(header).addClass(columnHead);
        });
    }
    function propagateHeadingClasses(thead, tbody) {
        $("th", thead).each(function propagateHeadingClass(index, th) {
            $(`td:nth-child(${index + 1})`, tbody).addClass(th.className);
        });
    }
    function propagateRowClasses(tbody) {
        var firstCells = $("td:first-child", tbody);
        firstCells.toArray().forEach(function propagateRowClass(cell) {
            var removals = ["#", /[()]/g, /s/g, /\\|\//g];
            var className = removals.reduce(function sanitizeText(text, regex) {
                return text.replace(regex, "");
            }, cell.textContent);

            $(cell).addClass(className);
            $(cell).siblings("td").addClass(className);
            $(cell).parent("tr").addClass(className);
        });
    }
    function addHeadingAndRowClasses(thead, tbody) {
        $("th:first-child", thead).removeAttr("name");
        addHeadingClasses(thead);
        propagateHeadingClasses(thead, tbody);
        propagateRowClasses(tbody);
    }

    function updateRatings(tbody) {
        function updateRating(cell) {
            var rating = parseFloat($(cell).text()).toFixed(2);
            $(cell).html(`<div><div class="rating">${rating}</div></div>`);
        }

        function updateRowRatings(ratingCell) {
            $(ratingCell).nextAll("td").toArray().forEach(updateRating);
        }
        $(".Rating", tbody).toArray().forEach(updateRowRatings);
    }

    function updateRankings(tbody) {
        function getRatings() {
            var ratingCells = $("tr.Rating > td:not(:first)", tbody);
            var getText = (td) => $(td).text();
            return ratingCells.toArray().map(getText);
        }
        function numericSort(a, b) {
            return b - a;
        }
        function rankByRatings() {
            var ratings = getRatings();
            var sorted = ratings.slice().sort(numericSort);
            return ratings.map(function decideRank(ratingIndex) {
                return sorted.indexOf(ratingIndex) + 1;
            });
        }

        function updateCells(ranks, $td) {
            ranks.forEach(function updateCell(rank, i) {
                var html = $(`<div><div "ranking">#${rank}</div></div>`);
                $td.eq(i + 1).empty().append(html);
                if (rank === 1) {
                    $td.eq(i + 1).addClass("ranking1");
                }
            });
        }
        function showRankings(tbody) {
            var ranks = rankByRatings();
            var rows = $("tr", tbody);
            rows.toArray().forEach(function updateRow(tr) {
                var $td = $(tr).children();
                if ($td.eq(0).text() === "Ranking") {
                    updateCells(ranks, $td);
                }
            });
        }

        function toNumber(el) {
            var digits = $(el).text().replace(/[^0-9]/gi, "");
            return Number(digits);
        }
        function sortColumnsByRanking(tbody) {
            var rows = $("tr", tbody);
            var RowRanking = $(".Ranking", rows).find("td:not(:first)");
            var rankings = RowRanking.map(toNumber).sort(numericSort);
            rankings.each(function moveColumn(newIndex, cell) {
                var originalIndex = $(cell).index();
                if (originalIndex === newIndex) {
                    return;
                }
                rows.toArray().forEach(function swapColumns(row) {
                    var td = $(row).find("td, th");
                    td.eq(originalIndex).insertAfter(td.eq(newIndex));
                });
            });
        }

        showRankings(tbody);
        sortColumnsByRanking(tbody);
    }

    function addWeatherWidgets(tbody) {
        function showWeatherWidget(el) {
            var path = $(el).text();
            var linkHtml = `<a href="https://forecast7.com/en/${path}/"`;
            linkHtml += ` class="weatherwidget-io" data-label_2="WEATHER"`;
            linkHtml += ` data-days="3" data-theme="original">WEATHER</a>`;
            $(el).html(linkHtml);
        }
        function showWeatherWidgets(td) {
            if ($(td).text() === "Weather") {
                $(td).nextAll("td").toArray().forEach(showWeatherWidget);
            }
        }
        $("tr td", tbody).toArray().forEach(showWeatherWidgets);
        __weatherwidget_init();
    }

    function updateTableAndWidgets(table) {
        var thead = $("thead", table);
        var tbody = $("tbody", table);
        addHeadingAndRowClasses(thead, tbody);
        updateRatings(tbody);
        updateRankings(tbody);
        addWeatherWidgets(tbody);
    }

    $("#btnSubmit").click(function citiesSubmitHandler() {
        var table = updateWeatherTable();
        updateTableAndWidgets(table);
    });
});

Now that the data is separate from the presentation, I can work on improving the showTable function. I’ll still keep it as a table, but I want the head/body/row parts to be much more clearly defined in there. That’s what I’ll be doing in my next post.

1 Like

Next up on the improvements is making the showTable code less a steam-of-consciousness and more of an organised system.

I want the thead and tbody to be assigned to separate variables, and at the end of things for them to be assigned to the html variable.

Here is how the code begins:

    function showTable({headings, rowTitles, rowsData}) {
        var html = `<table class="compTable"><thead>`;
        if (rowsData && rowsData.length) {
            html += "<tr><th></th>";
            headings.forEach(function addColumnHeading(heading) {
                html += `<th>${heading}</th>`;
            });
            html += "</tr>";
            html += "<tbody>";
            rowsData.forEach(function addRow(rowData, rowIndex) {
                var rowTitle = rowTitles[rowIndex];
                if (rowTitle === "ColHeading") {
                    return;
                }
                html += `<tr><td>${rowTitle}</td>`;
                rowData.forEach(function addCityData(cityData) {
                    html += `<td>${cityData}</td>`;
                });
                html += "</tr>";
            });
        } else {
            html += "<tr><td>No results found</td></tr>";
        }
        html += "</tbody></table>";
        return html;
    }

Separate headRow and bodyRows variables

And here is a partial implementation where separate headRow and bodyRows variables are used to organise things.

    function showTable({headings, rowTitles, rowsData}) {
        var headRow = "";
        var bodyRows = "";
        var html = "";
        if (rowsData && rowsData.length) {
            headRow += "<tr><th></th>";
            headings.forEach(function addColumnHeading(heading) {
                headRow += `<th>${heading}</th>`;
            });
            headRow += "</tr>";
            rowsData.forEach(function addRow(rowData, rowIndex) {
                var rowTitle = rowTitles[rowIndex];
                if (rowTitle === "ColHeading") {
                    return;
                }
                bodyRows += `<tr><td>${rowTitle}</td>`;
                rowData.forEach(function addCityData(cityData) {
                    bodyRows += `<td>${cityData}</td>`;
                });
                bodyRows += "</tr>";
            });
        } else {
            bodyRows = "<tr><td>No results found</td></tr>";
        }
        html = `<table class="compTable"><thead>${headRow}</thead>`;
        html += `<tbody>${bodyRows}</tbody></table>`;
        return html;
    }

Adding a createTable() function

The html code at the end of the function can pass headRows and bodyRows out to a separate createTable function.

    function createTable(headRow, bodyRows) {
        var thead = `<thead>${headRow}</thead>`;
        var tbody = `<tbody>${bodyRows}</tbody>`;
        return `<table class="compTable">${thead}${tbody}</table>`;
    }
    function showTable({headings, rowTitles, rowsData}) {
        var headRow = "";
        var bodyRows = "";
        // var html = "";
        ...
        // html = `<table class="compTable"><thead>${headRow}</thead>`;
        // html += `<tbody>${bodyRows}</tbody></table>`;
        // return html;
        return createTable(headRow, bodyRows);
    }

Adding a createHeadRow() function

I can now move the code that creates the head row out to a separate function:

    function createHeadRow(headings) {
        var headRow = "<th></th>";
        headings.forEach(function addColumnHeading(heading) {
            headRow += `<th>${heading}</th>`;
        });
        return `<tr>${headRow}</tr>`;
    }
...
    function showTable({headings, rowTitles, rowsData}) {
        var headRow = "";
        var bodyRows = "";
        if (rowsData && rowsData.length) {
            headRow = createHeadRow(headings);
...

And I can move the code that creates the body rows out to a separate function:

    function createBodyRows(rowTitles, rowsData) {
        var bodyRows = "";
        rowsData.forEach(function addRow(rowData, rowIndex) {
            var rowTitle = rowTitles[rowIndex];
            if (rowTitle === "ColHeading") {
                return;
            }
            bodyRows += `<tr><td>${rowTitle}</td>`;
            rowData.forEach(function addCityData(cityData) {
                bodyRows += `<td>${cityData}</td>`;
            });
            bodyRows += "</tr>";
        });
        return bodyRows;
    }
...
    function showTable({headings, rowTitles, rowsData}) {
        var headRow = "";
        var bodyRows = "";
        if (rowsData && rowsData.length) {
            headRow = createHeadRow(headings);
            bodyRows = createBodyRows(rowTitles, rowsData);

The createHeadRow function is pretty good and doesn’t need much more updating.
I will use reduce though, so that the forEach is not updating the headRow string. Having a forEach method changing a variable is one of the evils that are best avoided.

    function createHeadRow(headings) {
        var headRow = headings.reduce(function addHeading(headRow, heading) {
            return headRow + `<th>${heading}</th>`;
        }, "<th></th>");
        return `<tr>${headRow}</tr>`;
    }

Updating createBodyRows()

With the createBodyRows function there’s a lot of action so far as forEach updating variables is concerned. I’ll try to convert that to using the map method first, before thinking about things like reduce.

Here is createBodyRows that uses the map method.

    function createBodyRows(rowTitles, rowsData) {
        var bodyRows = rowsData.map(function addRow(rowData, rowIndex) {
            var row = "";
            var rowTitle = rowTitles[rowIndex];
            if (rowTitle === "ColHeading") {
                return "";
            }
            row += `<td>${rowTitle}</td>`;
            row += rowData.map(function addCityData(cityData) {
                return `<td>${cityData}</td>`;
            }).join("");
            return `<tr>${row}</tr>`;
        }).join("");
        return bodyRows;
    }

The rowTitle === "ColHeading" part really is an unwanted piece in there. Instead of doing that, I’ll filter it out of the row titles.
By removing that if statement, we find that the unwanted top row comes from the getCityData function. We can remove the 0-index item from the array by slicing everything from index 1 onwards.

    function getCityData(StatJSON, selectedOptions) {
        ...
        return {
            headings,
            rowTitles: rowTitles.slice(1),
            rowsData: rowsData.slice(1)
        };
    }
...
    function createBodyRows(rowTitles, rowsData) {
        var bodyRows = rowsData.map(function addRow(rowData, rowIndex) {
            var rowTitle = rowTitles[rowIndex];
            var row = `<td>${rowTitle}</td>`;
            row += rowData.map(function addCityData(cityData) {
                return `<td>${cityData}</td>`;
            }).join("");
            return `<tr>${row}</tr>`;
        }).join("");
        return bodyRows;
    }

In the createBodyRows function we can remove that last string addition in there by combining together the rowTitle and the rowData before mapping over them.

    function createBodyRows(rowTitles, rowsData) {
        var bodyRows = rowsData.map(function addRow(rowData, rowIndex) {
            var rowTitle = rowTitles[rowIndex];
            var row = [rowTitle, ...rowData].map(function addData(data) {
                return `<td>${data}</td>`;
            }).join("");
            return `<tr>${row}</tr>`;
        }).join("");
        return bodyRows;
    }

That [rowTitle, ...rowData] part of the code now fully represents what happens on each row.

Updating showTable()

The last part I’ll take a look at in this post is the showTable function. Here’s how it starts:

    function showTable({headings, rowTitles, rowsData}) {
        var headRow = "";
        var bodyRows = "";
        if (rowsData && rowsData.length) {
            headRow = createHeadRow(headings);
            bodyRows = createBodyRows(rowTitles, rowsData);

        } else {
            return createTable("", "<tr><td>No results found</td></tr>");
        }
        return createTable(headRow, bodyRows);
    }

The headRow and bodyRows variables aren’t needed in here now, as we can inline those variables into the createTable function call.

    function showTable({headings, rowTitles, rowsData}) {
        if (rowsData && rowsData.length) {
        } else {
            return createTable("", "<tr><td>No results found</td></tr>");
        }
        return createTable(
            createHeadRow(headings),
            createBodyRows(rowTitles, rowsData)
        );
    }

We can now invert that condition, so that it acts like a guard clause.

    function showTable({headings, rowTitles, rowsData}) {
        if (!rowsData || !rowsData.length) {
            return createTable("", "<tr><td>No results found</td></tr>");
        }
        return createTable(
            createHeadRow(headings),
            createBodyRows(rowTitles, rowsData)
        );
    }

The printTable code has now been significantly improved. It’s a lot clearer which parts do what, such as creating the table, the head, the body, etc. and if you ever want to update it so that it uses list items or other types of techniques, that will be a lot easier from what you have there now.

Here’s the printTable code after these updates:

    function createTable(headRow, bodyRows) {
        var thead = `<thead>${headRow}</thead>`;
        var tbody = `<tbody>${bodyRows}</tbody>`;
        return `<table class="compTable">${thead}${tbody}</table>`;
    }
    function createHeadRow(headings) {
        function addHeading(headRow, heading) {
            return headRow + `<th>${heading}</th>`;
        }
        var headRow = ["", ...headings].reduce(addHeading, "");
        return `<tr>${headRow}</tr>`;
    }
    function createBodyRows(rowTitles, rowsData) {
        var bodyRows = rowsData.map(function addRow(rowData, rowIndex) {
            var rowTitle = rowTitles[rowIndex];
            var row = [rowTitle, ...rowData].map(function addData(data) {
                return `<td>${data}</td>`;
            }).join("");
            return `<tr>${row}</tr>`;
        }).join("");
        return bodyRows;
    }
    function showTable({headings, rowTitles, rowsData}) {
        if (!rowsData || !rowsData.length) {
            return createTable("", "<tr><td>No results found</td></tr>");
        }
        return createTable(
            createHeadRow(headings),
            createBodyRows(rowTitles, rowsData)
        );
    }
    function printTable(StatJSON, selectedOptions) {
        var cityData = getCityData(StatJSON, selectedOptions);
        return showTable(cityData);
    }
2 Likes

Thanks Paul! The code is looking so much more cleaner and looking much clearer and simpler. It does make a lot of sense to use functions instead of nesting the entire code, as I had done.

Thanks a million for all the effort and time you are taking here!! Looking forward for the rest! :grinning::grinning::cowboy_hat_face:

1 Like

This will be a continuation of the separation of data and presentation.

Updating updateWeatherTable

The updateWeatherTable function is a good example of where data and presentation are both being done together.

    function updateWeatherTable() {
        var selected = $("#selection").find(":selected");
        var getValue = (option) => option.value;
        var options = selected.toArray().map(getValue);
        $("#divResult").empty().append(printTable(StatJSON, options));
        return $("#divResult table");
    }

The function really shouldn’t reach out to blindly grab variables such as StatJSON. It’s much better when they are supplied in a controlled manner via function parameters.

    function updateWeatherTable(StatJSON, options) {
        var selected = $("#selection").find(":selected");
        var getValue = (option) => option.value;
        var options = selected.toArray().map(getValue);
        $("#divResult").empty().append(printTable(StatJSON, options));
        return $("#divResult table");
    }
...
    $("#btnSubmit").click(function citiesSubmitHandler() {
        var table = updateWeatherTable();
        updateTableAndWidgets(table);
    });

Adding a getSelectedOptions() function

The process of getting the options can be moved out to a separate function called getSelectedOptions, and we update the call to updateWeatherTable so that it gets the options and passes them in to the function.

    function getSelectedOptions() {
        var selected = $("#selection").find(":selected");
        var getValue = (option) => option.value;
        var options = selected.toArray().map(getValue);
    }
    function updateWeatherTable(StatJSON, options) {
        $("#divResult").empty().append(printTable(StatJSON, options));
        return $("#divResult table");
    }
...
    $("#btnSubmit").click(function citiesSubmitHandler() {
        var options = getSelectedOptions();
        var table = updateWeatherTable(StatJSON, options);
        updateTableAndWidgets(table);
    });

Improving updateWeatherTable() function

That updateWeatherTable function isn’t good enough yet. I expect that sort of function to be given two function parameters. One for where to update the table, and another parameter that is the new table itself.

Let’s extract that table out to a separate variable, so that we can do further things with it instead. I want the table to be an actual table element, so that we can just return it from the end of the function.

    function updateWeatherTable(StatJSON, options) {
        var table = $(printTable(StatJSON, options)).get(0);
        $("#divResult").empty().append(table);
        return table;
    }

We can now move that table out of the function, and pass it into there as a function parameter.

    function updateWeatherTable(container, table) {
        $(container).empty().append(table);
        return table;
    }
...
    $("#btnSubmit").click(function citiesSubmitHandler() {
        var options = getSelectedOptions();
        var table = $(printTable(StatJSON, options)).get(0);
        updateWeatherTable(table);
        updateTableAndWidgets(table);
    });

We found an updateContainer() function

There are some interesting possibilities with this code now. The updateWeatherTable function is found to be something different. Instead of just updating the weather, it actually removes all of the contents of the container and replaces it with what it provided in the table parameter. We can rename updateWeatherTable to just be updateContainer instead.

And, the code in the event handler can all be moved out to the updateTableAndWidgits function.

    // function updateContainer(container, table) {
    function updateWeatherTable(container, content) {
        // $(container).empty().append(table);
        $(container).empty().append(content);
    }
...
    // function updateTableAndWidgets(table) {
    function updateTable(container, table) {
        var thead = $("thead", table);
        var tbody = $("tbody", table);
        updateContainer(container, table);
        addHeadingAndRowClasses(thead, tbody);
        updateRatings(tbody);
        updateRankings(tbody);
        addWeatherWidgets(tbody);
    }
...
    $("#btnSubmit").click(function citiesSubmitHandler() {
        // var options = getSelectedOptions();
        // var table = $(printTable(StatJSON, options)).get(0);
        // updateWeatherTable(table);
        // updateTableAndWidgets(table);
        var options = getSelectedOptions();
        var table = $(printTable(StatJSON, options)).get(0);
        updateTable("#divResult", table);
    });

Fixing the printTable() function

We can have that printTable now create the table instead, returning the table as actual DOM elements. It’s better to have a createWeatherTable function than a printTable one, because printTable implies both creation and updating.

    // function printTable(StatJSON, selectedOptions) {
    function createWeatherTable(StatJSON, selectedOptions) {
        var cityData = getCityData(StatJSON, selectedOptions);
        // return showTable(cityData);
        return showTable(cityData);
    }
...
    $("#btnSubmit").click(function citiesSubmitHandler() {
        var options = getSelectedOptions();
        var table = $(createWeatherTable(StatJSON, options));
        updateTable("#divResult", table);
    });

Improving the showTable() function

But now, I’m noticing that the showTable function needs work:

    function showTable({headings, rowTitles, rowsData}) {
        if (!rowsData || !rowsData.length) {
            return createTable("", "<tr><td>No results found</td></tr>");
        }
        return createTable(
            createHeadRow(headings),
            createBodyRows(rowTitles, rowsData)
        );
    }

That function name isn’t suitable, and the code could do with being inlined into the createWeatherTable function instead. I do want to tidy up that code in there though. I think that we can achieve some benefit by moving the “No Results” part out to a noResultsTable function instead.

    function noResultsTable() {
        return createTable("", "<tr><td>No results found</td></tr>");
    }
    function showTable({headings, rowTitles, rowsData}) {
        if (!rowsData || !rowsData.length) {
            return noResultsTable();
        }
        return createTable(
            createHeadRow(headings),
            createBodyRows(rowTitles, rowsData)
        );
    }

We can now use a ternary, so that the results of either noResultsTable or createTable are used.

    function showTable({headings, rowTitles, rowsData}) {
        return (
            (!rowsData || !rowsData.length)
            ? noResultsTable()
            : createTable(
                createHeadRow(headings),
                createBodyRows(rowTitles, rowsData)
            )
        );
    }

And, that showTable function can now be inlined into the createWeatherTable function.

    function createWeatherTable(StatJSON, selectedOptions) {
        var cityData = getCityData(StatJSON, selectedOptions);
        const {headings, rowTitles, rowsData} = cityData;
        return (
            (!rowsData || !rowsData.length)
            ? noResultsTable()
            : createTable(
                createHeadRow(headings),
                createBodyRows(rowTitles, rowsData)
            )
        );
    }

Improving the createWeatherTable() function parameters

I can now see that things will be better when cityData is outside of the createWeatherTable function.

    function createWeatherTable(cityData) {
        const {headings, rowTitles, rowsData} = cityData;
        return (
            (!rowsData || !rowsData.length)
            ? noResultsTable()
            : createTable(
                createHeadRow(headings),
                createBodyRows(rowTitles, rowsData)
            )
        );
    }
...
    $("#btnSubmit").click(function citiesSubmitHandler() {
        var options = getSelectedOptions();
        var cityData = getCityData(StatJSON, options);
        // var table = $(createWeatherTable(StatJSON, options));
        var table = $(createWeatherTable(cityData));
        updateTable("#divResult", table);
    });

It took a bit of readjustment, but we’re finally ready to move that code into an updateWeatherTable function.

    function updateWeatherTable(container, cityData) {
        var table = $(createWeatherTable(cityData));
        updateTable(container, table);
    }
    $("#btnSubmit").click(function citiesSubmitHandler() {
        var selectedOptions = getSelectedOptions();
        var cityData = getCityData(StatJSON, selectedOptions);
        updateWeatherTable("#divResult", cityData);
    });

Conclusion

Sometimes the journey that code goes on is a twisting path through the forest. You’re not exactly sure where you’re going to end up, but you can definitely see that some parts of the code aren’t working well and can work better with a few tweaks here, and some tweaks there.

Here’s the full updated code that we have now:

jQuery(document).ready(function rankWeather($) {
    var StatJSON = {
        "Option1": {
            "ColHeading": "New York",
            "Ranking": "",
            "Rating": "4.5",
            "Row1Heading": "Hello",
            "Weather": "40d71n74d01/new-york"
        },
        "Option2": {
            "ColHeading": "San Francisco",
            "Ranking": "",
            "Rating": "3.2",
            "Row1Heading": "Whats",
            "Weather": "37d77n122d42/san-francisco"
        },
        "Option3": {
            "ColHeading": "Chicago",
            "Ranking": "",
            "Rating": "3.7",
            "Row1Heading": "Up",
            "Weather": "41d88n87d63/chicago"
        },
        "Option4": {
            "ColHeading": "Los Angeles",
            "Ranking": "",
            "Rating": "5.0",
            "Row1Heading": "With",
            "Weather": "34d05n118d24/los-angeles"
        }
    };

    function getCityData(StatJSON, selectedOptions) {
        var headings = selectedOptions.map(function getHeading(option) {
            return StatJSON[option].ColHeading;
        });
        var rowTitles = Object.keys(StatJSON[selectedOptions[0]]);
        var rowsData = rowTitles.map(function addRow(rowTitle) {
            return selectedOptions.map(function getRowData(option) {
                return StatJSON[option][rowTitle];
            });
        });
        return {
            headings,
            rowTitles: rowTitles.slice(1),
            rowsData: rowsData.slice(1)
        };
    }

    function createTable(headRow, bodyRows) {
        var thead = `<thead>${headRow}</thead>`;
        var tbody = `<tbody>${bodyRows}</tbody>`;
        return `<table class="compTable">${thead}${tbody}</table>`;
    }
    function createHeadRow(headings) {
        function addHeading(headRow, heading) {
            return headRow + `<th>${heading}</th>`;
        }
        var rowHeadings = ["", ...headings];
        var headRow = rowHeadings.reduce(addHeading, "");
        return `<tr>${headRow}</tr>`;
    }
    function createBodyRows(rowTitles, rowsData) {
        var bodyRows = rowsData.map(function addRow(rowData, rowIndex) {
            var rowTitle = rowTitles[rowIndex];
            var row = [rowTitle, ...rowData].map(function addData(data) {
                return `<td>${data}</td>`;
            }).join("");
            return `<tr>${row}</tr>`;
        }).join("");
        return bodyRows;
    }
    function noResultsTable() {
        return createTable("", "<tr><td>No results found</td></tr>");
    }
    function createWeatherTable(cityData) {
        const {headings, rowTitles, rowsData} = cityData;
        return (
            (!rowsData || !rowsData.length)
            ? noResultsTable()
            : createTable(
                createHeadRow(headings),
                createBodyRows(rowTitles, rowsData)
            )
        );
    }

    function getSelectedOptions() {
        var selected = $("#selection").find(":selected");
        var getValue = (option) => option.value;
        return selected.toArray().map(getValue);
    }
    function updateContainer(container, content) {
        $(container).empty().append(content);
    }

    function addHeadingClasses(thead) {
        var headings = $("th:not(:first)", thead);
        // console.log(headings);
        headings.toArray().forEach(function addHeadingClass(header) {
            var columnHead = $(header).text().split(" ").join("");
            $(header).addClass(columnHead);
        });
    }
    function propagateHeadingClasses(thead, tbody) {
        $("th", thead).each(function propagateHeadingClass(index, th) {
            $(`td:nth-child(${index + 1})`, tbody).addClass(th.className);
        });
    }
    function propagateRowClasses(tbody) {
        var firstCells = $("td:first-child", tbody);
        firstCells.toArray().forEach(function propagateRowClass(cell) {
            var removals = ["#", /[()]/g, /s/g, /\\|\//g];
            var className = removals.reduce(function sanitizeText(text, regex) {
                return text.replace(regex, "");
            }, cell.textContent);

            $(cell).addClass(className);
            $(cell).siblings("td").addClass(className);
            $(cell).parent("tr").addClass(className);
        });
    }
    function addHeadingAndRowClasses(thead, tbody) {
        $("th:first-child", thead).removeAttr("name");
        addHeadingClasses(thead);
        propagateHeadingClasses(thead, tbody);
        propagateRowClasses(tbody);
    }

    function updateRatings(tbody) {
        function updateRating(cell) {
            var rating = parseFloat($(cell).text()).toFixed(2);
            $(cell).html(`<div><div class="rating">${rating}</div></div>`);
        }

        function updateRowRatings(ratingCell) {
            $(ratingCell).nextAll("td").toArray().forEach(updateRating);
        }
        $(".Rating", tbody).toArray().forEach(updateRowRatings);
    }

    function updateRankings(tbody) {
        function getRatings() {
            var ratingCells = $("tr.Rating > td:not(:first)", tbody);
            var getText = (td) => $(td).text();
            return ratingCells.toArray().map(getText);
        }
        function numericSort(a, b) {
            return b - a;
        }
        function rankByRatings() {
            var ratings = getRatings();
            var sorted = ratings.slice().sort(numericSort);
            return ratings.map(function decideRank(ratingIndex) {
                return sorted.indexOf(ratingIndex) + 1;
            });
        }

        function updateCells(ranks, $td) {
            ranks.forEach(function updateCell(rank, i) {
                var html = $(`<div><div "ranking">#${rank}</div></div>`);
                $td.eq(i + 1).empty().append(html);
                if (rank === 1) {
                    $td.eq(i + 1).addClass("ranking1");
                }
            });
        }
        function showRankings(tbody) {
            var ranks = rankByRatings();
            var rows = $("tr", tbody);
            rows.toArray().forEach(function updateRow(tr) {
                var $td = $(tr).children();
                if ($td.eq(0).text() === "Ranking") {
                    updateCells(ranks, $td);
                }
            });
        }

        function toNumber(el) {
            var digits = $(el).text().replace(/[^0-9]/gi, "");
            return Number(digits);
        }
        function sortColumnsByRanking(tbody) {
            var rows = $("tr", tbody);
            var RowRanking = $(".Ranking", rows).find("td:not(:first)");
            var rankings = RowRanking.map(toNumber).sort(numericSort);
            rankings.each(function moveColumn(newIndex, cell) {
                var originalIndex = $(cell).index();
                if (originalIndex === newIndex) {
                    return;
                }
                rows.toArray().forEach(function swapColumns(row) {
                    var td = $(row).find("td, th");
                    td.eq(originalIndex).insertAfter(td.eq(newIndex));
                });
            });
        }

        showRankings(tbody);
        sortColumnsByRanking(tbody);
    }

    function addWeatherWidgets(tbody) {
        function showWeatherWidget(el) {
            var path = $(el).text();
            var linkHtml = `<a href="https://forecast7.com/en/${path}/"`;
            linkHtml += ` class="weatherwidget-io" data-label_2="WEATHER"`;
            linkHtml += ` data-days="3" data-theme="original">WEATHER</a>`;
            $(el).html(linkHtml);
        }
        function showWeatherWidgets(td) {
            if ($(td).text() === "Weather") {
                $(td).nextAll("td").toArray().forEach(showWeatherWidget);
            }
        }
        $("tr td", tbody).toArray().forEach(showWeatherWidgets);
        __weatherwidget_init();
    }

    function updateTable(container, table) {
        var thead = $("thead", table);
        var tbody = $("tbody", table);
        updateContainer(container, table);
        addHeadingAndRowClasses(thead, tbody);
        updateRatings(tbody);
        updateRankings(tbody);
        addWeatherWidgets(tbody);
    }

    function updateWeatherTable(container, cityData) {
        var table = $(createWeatherTable(cityData));
        updateTable(container, table);
    }
    $("#btnSubmit").click(function citiesSubmitHandler() {
        var selectedOptions = getSelectedOptions();
        var cityData = getCityData(StatJSON, selectedOptions);
        updateWeatherTable("#divResult", cityData);
    });
});

Using the updateWeatherTable function as an example, it’s entirely possible for more of the event handler code to have been inside of the updateWeatherTable function, but I think that it’s important to have as much of the configurable information in one place. With the way we have things now, it’s the handler where that config information is all managed. The selected options, the JSON object, and the container id for where the table will be placed.

1 Like

Working further with separating data from presentation, the createHeadRow function is our next target.

Improving the createHeadRow() function

Here’s the function right now:

    function createHeadRow(headings) {
        function addHeading(headRow, heading) {
            return headRow + `<th>${heading}</th>`;
        }
        var headRow = ["", ...headings].reduce(addHeading, "");
        return `<tr>${headRow}</tr>`;
    }

Some problems that are immediately noticed are the nested addHeading function, and the combining of headings in this presentation function. They immediately solved an earlier problem, but there are better ways to deal with things.

The first thing we can do is to use map instead of reduce, so that we can more easily extract the addHeading function.

    function createHeadRow(headings) {
        var headRow = ["", ...headings].map(function createHTMLHeading(heading) {
            return `<th>${heading}</th>`;
        }).join("");
        return `<tr>${headRow}</tr>`;
    }

The array update can be extracted as an update to the existing headings variable.

    function createHeadRow(headings) {
        headings = ["", ...headings];
        // var headRow = ["", ...headings].map(function createHTMLHeading(heading) {
        var headRow = headings.map(function createHTMLHeading(heading) {
            return `<th>${heading}</th>`;
        }).join("");
        return `<tr>${headRow}</tr>`;
    }

That update to the headings really shouldn’t be there at all. The job of this createHeadRow function is to create the head row. It’s not to manipulate the information coming in. Instead of updating the headings in there, we can do it from somewhere else. It’s not right to add the empty first heading when building the table, and it’s not right to add it in getCityData() function when the headings are obtained. Instead, it has to be done at some middle-layer, where we get the city data and prepare it for presentation.

What that means is that we can push the headings change up out of the createHeadRow() function, until we get somewhere suitable. Here we push the array up out of the function, to the createWeatherTable() function.

    function createHeadRow(headings) {
        // headings = ["", ...headings];
        var headRow = headings.map(function createHTMLHeading(heading) {
            return `<th>${heading}</th>`;
        }).join("");
        return `<tr>${headRow}</tr>`;
    }
...
    function createWeatherTable(cityData) {
        ...
            : createTable(
                createHeadRow(["", ...cityData.headings]),
                createBodyRows(cityData.rowTitles, cityData.rowsData)
            )
        );
    }

That looks to be a good place for the updated headings to be placed.

Improve createBodyRows() function

Here’s the code that we’re starting with:

    function createBodyRows(rowTitles, rowsData) {
        var bodyRows = rowsData.map(function addRow(rowData, rowIndex) {
            var rowTitle = rowTitles[rowIndex];
            var row = [rowTitle, ...rowData].map(function addData(data) {
                return `<td>${data}</td>`;
            }).join("");
            return `<tr>${row}</tr>`;
        }).join("");
        return bodyRows;
    }

The main issue that I see here is that much more is being done here than just creating the body rows.
The title is being added to the start of the row data. That is something that we should be doing earlier, before passing the rowsData into the function.

Thanks to updating the createHeadRow code earlier, I now know the best place to start looking for where that combining needs to take place, in the createWeatherTable() function.

    function createWeatherTable(cityData) {
        return (
            (!cityData.rowsData || !cityData.rowsData.length)
            ? noResultsTable()
            : createTable(
                createHeadRow(["", ...cityData.headings]),
                createBodyRows(cityData.rowTitles, cityData.rowsData)
            )
        );
    }

In there, we can combine the rowTitles with the rowsData. Before doing that though, because I plan to remove the rowTitles function parameter, I’ll switch them around so that when I later on remove rowTitles, it will have less impact on the remaining code.

    // function createBodyRows(rowTitles, rowsData) {
    function createBodyRows(rowsData, rowTitles) {
        ...
    }
...
                // createBodyRows(appendTitles(rowTitles, rowsData))
                createBodyRows(appendTitles(rowsData, rowTitles))

Now for the rowsData, I want to append the titles to the front of the rows. That means another function called appendTitles() to do that job.

    function appendTitles(rowTitles, rowsData) {
        return rowsData.map(function appendTitle(rowData, rowIndex) {
            return [rowTitles[rowIndex], ...rowData];
        });
    }
    function appendTitles(rowTitles, rowsData) {
        return rowsData.map(function appendTitle(rowData, rowIndex) {
            return [rowTitles[rowIndex], ...rowData];
        });
    }
    function createWeatherTable(cityData) {
        var titledRows = appendTitles(cityData.rowTitles, cityData.rowsData);
        return (
            (!titledRows || !titledRows.length)
            ? noResultsTable()
            : createTable(
                createHeadRow(["", ...cityData.headings]),
                // createBodyRows(rowsData, rowTitles)
                createBodyRows(titledRows, rowTitles)
            )
        );
    }

That causes the table to be somewhat messed up, which means that we can now remove from the createBodyRows() function the array appending that we were doing in there, and the table returns back to working well.

    function createBodyRows(rowsData, rowTitles) {
        var bodyRows = rowsData.map(function addRow(rowData, rowIndex) {
            var rowTitle = rowTitles[rowIndex];
            // var row = rowData.map(function addData(data) {
            var row = [rowTitle, ...rowData].map(function addData(data) {
                return `<td>${data}</td>`;
            }).join("");
            return `<tr>${row}</tr>`;
        }).join("");
        return bodyRows;
    }

Now that the table is working properly, we can remove the row titles from the createBodyRows() function.

    // function createBodyRows(rowsData, rowTitles) {
    function createBodyRows(rowsData) {
        // var bodyRows = rowsData.map(function addRow(rowData, rowIndex) {
        var bodyRows = rowsData.map(function addRow(rowData) {
            // var rowTitle = rowTitles[rowIndex];
            var row = rowData.map(function addData(data) {
                return `<td>${data}</td>`;
            }).join("");
            return `<tr>${row}</tr>`;
        }).join("");
        return bodyRows;
    }
...
                // createBodyRows(titledRows, rowTitles)
                createBodyRows(titledRows)

We now have a nice and simplified createBodyRows function that works on any set of rows data. The benefit here is that the rowsData is also nice and simplified, making it easier to replace a table presentation with some other presentation such as lists, or CSS grids.

While we’re at it, we really should move the `[“”, …headings] into a matching variable too.

    function createWeatherTable(cityData) {
        var headRows = ["", ...cityData.headings];
        var titledRows = appendTitles(cityData.rowTitles, cityData.rowsData);
        return (
            (!titledRows || !titledRows.length)
            ? noResultsTable()
            : createTable(
                createHeadRow(["", ...cityData.headings]),
                createBodyRows(titledRows)
            )
        );
    }

Ensuring that No Results still works

There’s only one detail left to this. The “no results” table has suffered from some of these changes. The getCityData function has an error when no option is selected.

That happens when getting the row titles which currently depends on a selected option to retrieve the titles. We can remove that dependency and instead just get the row titles from the first item in the StatJSON obect.

    function getCityData(StatJSON, selectedOptions) {
        var headings = selectedOptions.map(function getHeading(option) {
            return StatJSON[option].ColHeading;
        });
        var firstCityData = Object.entries(StatJSON)[0][1];
        var rowTitles = Object.keys(firstCityData);
        // var rowTitles = Object.keys(StatJSON[selectedOptions[0]]);

To help explain what we’re doing there with Object.entries, we can move it in to a function called getFirstValue. I have a code-completion tool that gives me this code for that type of function:

    function getFirstValue(obj) {
        var key;
        for (key in obj) {
            if (obj.hasOwnProperty(key)) {
                return obj[key];
            }
        }
    }

I’ve chosen to use Object.entries instead to help simplify that.

    function getFirstValue(obj) {
        var keyValuePair = Object.entries(obj)[0];
        return keyValuePair[1];
    }
...
        var firstCityData = getFirstValue(StatJSON);

That’s one hurdle towards getting the “no results” table working.

Because of how getCityData works, we are always going to have some data in the cityData object. Checking if it exists at all is no longer a good solution. Instead, we can check the length of cityData.headings

    function createWeatherTable(cityData) {
        var headRows = ["", ...cityData.headings];
        var titledRows = appendTitles(cityData.rowTitles, cityData.rowsData);
        return (
            (cityData.headings.length > 0)
            ? createTable(
                createHeadRow(headRows),
                createBodyRows(titledRows)
            )
            : createTable("", "<tr><td>No results found</td></tr>")
        );
    }

Summary

Things are back to working when there are no results, and we have a much better organisation of data in those sections.

The three tier architecture of full-stack web development seems to be naturally emerging, where we have:

  • Presentation layer
  • Business Logic layer
  • Data Access layer

It might help to simplify things by grouping the code in those layers. But first, I’ll carry on examining the code further, starting next time from the getSelectedOptions() function.

Here’s the code that we currently have:

jQuery(document).ready(function rankWeather($) {
    var StatJSON = {
        "Option1": {
            "ColHeading": "New York",
            "Ranking": "",
            "Rating": "4.5",
            "Row1Heading": "Hello",
            "Weather": "40d71n74d01/new-york"
        },
        "Option2": {
            "ColHeading": "San Francisco",
            "Ranking": "",
            "Rating": "3.2",
            "Row1Heading": "Whats",
            "Weather": "37d77n122d42/san-francisco"
        },
        "Option3": {
            "ColHeading": "Chicago",
            "Ranking": "",
            "Rating": "3.7",
            "Row1Heading": "Up",
            "Weather": "41d88n87d63/chicago"
        },
        "Option4": {
            "ColHeading": "Los Angeles",
            "Ranking": "",
            "Rating": "5.0",
            "Row1Heading": "With",
            "Weather": "34d05n118d24/los-angeles"
        }
    };

    function getFirstValue(obj) {
        var keyValuePair = Object.entries(obj)[0];
        return keyValuePair[1];
    }
    function getCityData(StatJSON, selectedOptions) {
        var headings = selectedOptions.map(function getHeading(option) {
            return StatJSON[option].ColHeading;
        });
        var firstCityData = getFirstValue(StatJSON);
        var rowTitles = Object.keys(firstCityData);
        var rowsData = rowTitles.map(function addRow(rowTitle) {
            return selectedOptions.map(function getRowData(option) {
                return StatJSON[option][rowTitle];
            });
        });
        return {
            headings,
            rowTitles: rowTitles.slice(1),
            rowsData: rowsData.slice(1)
        };
    }

    function createTable(headRow, bodyRows) {
        var thead = `<thead>${headRow}</thead>`;
        var tbody = `<tbody>${bodyRows}</tbody>`;
        return `<table class="compTable">${thead}${tbody}</table>`;
    }
    function createHeadRow(headings) {
        var headRow = headings.map(function createHTMLHeading(heading) {
            return `<th>${heading}</th>`;
        }).join("");
        return `<tr>${headRow}</tr>`;
    }

    function createBodyRows(rowsData) {
        var bodyRows = rowsData.map(function addRow(rowData) {
            var row = rowData.map(function addData(data) {
                return `<td>${data}</td>`;
            }).join("");
            return `<tr>${row}</tr>`;
        }).join("");
        return bodyRows;
    }
    function appendTitles(rowTitles, rowsData) {
        return rowsData.map(function appendTitle(rowData, rowIndex) {
            return [rowTitles[rowIndex], ...rowData];
        });
    }
    function createWeatherTable(cityData) {
        var headRows = ["", ...cityData.headings];
        var titledRows = appendTitles(cityData.rowTitles, cityData.rowsData);
        return (
            (cityData.headings.length > 0)
            ? createTable(
                createHeadRow(headRows),
                createBodyRows(titledRows)
            )
            : createTable("", "<tr><td>No results found</td></tr>")
        );
    }

    function getSelectedOptions() {
        var selected = $("#selection").find(":selected");
        var getValue = (option) => option.value;
        return selected.toArray().map(getValue);
    }
    function updateContainer(container, content) {
        $(container).empty().append(content);
    }

    function addHeadingClasses(thead) {
        var headings = $("th:not(:first)", thead);
        // console.log(headings);
        headings.toArray().forEach(function addHeadingClass(header) {
            var columnHead = $(header).text().split(" ").join("");
            $(header).addClass(columnHead);
        });
    }
    function propagateHeadingClasses(thead, tbody) {
        $("th", thead).each(function propagateHeadingClass(index, th) {
            $(`td:nth-child(${index + 1})`, tbody).addClass(th.className);
        });
    }
    function propagateRowClasses(tbody) {
        var firstCells = $("td:first-child", tbody);
        firstCells.toArray().forEach(function propagateRowClass(cell) {
            var removals = ["#", /[()]/g, /s/g, /\\|\//g];
            var className = removals.reduce(function sanitizeText(text, regex) {
                return text.replace(regex, "");
            }, cell.textContent);

            $(cell).addClass(className);
            $(cell).siblings("td").addClass(className);
            $(cell).parent("tr").addClass(className);
        });
    }
    function addHeadingAndRowClasses(thead, tbody) {
        $("th:first-child", thead).removeAttr("name");
        addHeadingClasses(thead);
        propagateHeadingClasses(thead, tbody);
        propagateRowClasses(tbody);
    }

    function updateRatings(tbody) {
        function updateRating(cell) {
            var rating = parseFloat($(cell).text()).toFixed(2);
            $(cell).html(`<div><div class="rating">${rating}</div></div>`);
        }

        function updateRowRatings(ratingCell) {
            $(ratingCell).nextAll("td").toArray().forEach(updateRating);
        }
        $(".Rating", tbody).toArray().forEach(updateRowRatings);
    }

    function updateRankings(tbody) {
        function getRatings() {
            var ratingCells = $("tr.Rating > td:not(:first)", tbody);
            var getText = (td) => $(td).text();
            return ratingCells.toArray().map(getText);
        }
        function numericSort(a, b) {
            return b - a;
        }
        function rankByRatings() {
            var ratings = getRatings();
            var sorted = ratings.slice().sort(numericSort);
            return ratings.map(function decideRank(ratingIndex) {
                return sorted.indexOf(ratingIndex) + 1;
            });
        }

        function updateCells(ranks, $td) {
            ranks.forEach(function updateCell(rank, i) {
                var html = $(`<div><div "ranking">#${rank}</div></div>`);
                $td.eq(i + 1).empty().append(html);
                if (rank === 1) {
                    $td.eq(i + 1).addClass("ranking1");
                }
            });
        }
        function showRankings(tbody) {
            var ranks = rankByRatings();
            var rows = $("tr", tbody);
            rows.toArray().forEach(function updateRow(tr) {
                var $td = $(tr).children();
                if ($td.eq(0).text() === "Ranking") {
                    updateCells(ranks, $td);
                }
            });
        }

        function toNumber(el) {
            var digits = $(el).text().replace(/[^0-9]/gi, "");
            return Number(digits);
        }
        function sortColumnsByRanking(tbody) {
            var rows = $("tr", tbody);
            var RowRanking = $(".Ranking", rows).find("td:not(:first)");
            var rankings = RowRanking.map(toNumber).sort(numericSort);
            rankings.each(function moveColumn(newIndex, cell) {
                var originalIndex = $(cell).index();
                if (originalIndex === newIndex) {
                    return;
                }
                rows.toArray().forEach(function swapColumns(row) {
                    var td = $(row).find("td, th");
                    td.eq(originalIndex).insertAfter(td.eq(newIndex));
                });
            });
        }

        showRankings(tbody);
        sortColumnsByRanking(tbody);
    }

    function addWeatherWidgets(tbody) {
        function showWeatherWidget(el) {
            var path = $(el).text();
            var linkHtml = `<a href="https://forecast7.com/en/${path}/"`;
            linkHtml += ` class="weatherwidget-io" data-label_2="WEATHER"`;
            linkHtml += ` data-days="3" data-theme="original">WEATHER</a>`;
            $(el).html(linkHtml);
        }
        function showWeatherWidgets(td) {
            if ($(td).text() === "Weather") {
                $(td).nextAll("td").toArray().forEach(showWeatherWidget);
            }
        }
        $("tr td", tbody).toArray().forEach(showWeatherWidgets);
        __weatherwidget_init();
    }

    function updateTable(container, table) {
        var thead = $("thead", table);
        var tbody = $("tbody", table);
        updateContainer(container, table);
        addHeadingAndRowClasses(thead, tbody);
        updateRatings(tbody);
        updateRankings(tbody);
        addWeatherWidgets(tbody);
    }

    function updateWeatherTable(container, cityData) {
        var table = $(createWeatherTable(cityData));
        updateTable(container, table);
    }
    $("#btnSubmit").click(function citiesSubmitHandler() {
        var selectedOptions = getSelectedOptions();
        var cityData = getCityData(StatJSON, selectedOptions);
        updateWeatherTable("#divResult", cityData);
    });
});

Finishing off createWeatherTable() function

After updating createWeatherTable I noticed that I was combining both business logic and presentation.

    function createWeatherTable(cityData) {
        var headRows = ["", ...cityData.headings];
        var titledRows = appendTitles(cityData.rowTitles, cityData.rowsData);
        return (
            (cityData.headings.length > 0)
            ? createTable(
                createHeadRow(headRows),
                createBodyRows(titledRows)
            )
            : createTable("", "<tr><td>No results found</td></tr>")
        );
    }

We can improve that by updating the lower createTable function so that it too uses the createHeadRow and createBodyRows functions.

    function createWeatherTable(cityData) {
        var headRows = ["", ...cityData.headings];
        var titledRows = appendTitles(cityData.rowTitles, cityData.rowsData);
        return (
            (cityData.headings.length > 0)
            ? createTable(
                createHeadRow(headRows),
                createBodyRows(titledRows)
            )
            // : createTable("", "<tr><td>No results found</td></tr>")
            : createTable(
                createHeadRow([]),
                createBodyRows([["No results found"]])
            )
        );
    }

Both parts of that ternary expression are now functionally identical, all but for the parameters.
I can assign the “No results found” as default values, and update them when there is table data. That way we can get rid of that ternary expression to the createTable() function.

    function createWeatherTable(cityData) {
        var headRows = [];
        var titledRows = [["No results found"]];
        if (cityData.headings.length) {
            headRows = ["", ...cityData.headings];
            titledRows = appendTitles(cityData.rowTitles, cityData.rowsData);
        }
        return createTable(
            createHeadRow(headRows),
            createBodyRows(titledRows)
        );
    }

Improving getSelectedOptions()

Working my way down the code, I now come to the getSelectedOptions() function.

    function getSelectedOptions() {
        var selected = $("#selection").find(":selected");
        var getValue = (option) => option.value;
        return selected.toArray().map(getValue);
    }

We don’t need the separate find, for the “:selected” pseudo-selector works well in the same string as the id.

I’ve just now realised that we don’t need to use the toArray. I was concerned with making the linter happy by avoiding an unused index in the function parameter. By using $.map(arr, callback), jQuery doesn’t use (i, v) for the callback and instead uses (v, i) for the callback instead.

We can inline the getValue function, and the selected variable really isn’t needed either because the strings tell us quite clearly what they are.

    function getSelectedOptions() {
        var selection = $("#selection :selected");
        return $.map(selection, (option) => option.value);
    }

I now know what I’m going to do for the rest of this post, and that is to replace all of the .toArray compromises with $.map(arr, callback) type things instead.

Replacing .toArray in the code

There are only 9 situations where .toArray is currently being used.

        headings.toArray().forEach(function addHeadingClass(header) {
...
        firstCells.toArray().forEach(function propagateRowClass(cell) {
...
            $(ratingCell).nextAll("td").toArray().forEach(updateRating);
...
        $(".Rating", tbody).toArray().forEach(updateRowRatings);
...
            return ratingCells.toArray().map(getText);
...
            rows.toArray().forEach(function updateRow(tr) {
...
                rows.toArray().forEach(function swapColumns(row) {
...
                $(td).nextAll("td").toArray().forEach(showWeatherWidget);
...
        $("tr td", tbody).toArray().forEach(showWeatherWidgets);

We can remove all of those .toArray parts, with $.map callbacks instead.
With jQuery, the following calls all have (index, el) parameters:

It is only the $.map callback that has (el, i) parameters instead.

Yes, that is broken, but jQuery was too popular when they realised that (index, el) properties were the wrong way around. They couldn’t fix them without breaking everything.

We will replace those .toArray compromises with $.map instead.

        $.map(headings, function addHeadingClass(header) {
...
        $.map(firstCells, function propagateRowClass(cell) {
...
            $.map($(ratingCell).nextAll("td"), updateRating);
...
        $.map($(".Rating", tbody), updateRowRatings);
...
            return $.map(ratingCells, getText);
...
            $.map(rows, function updateRow(tr) {
...
                $.map(rows, function swapColumns(row) {
...
                $.map($(td).nextAll("td"), showWeatherWidget);
...
        $.map($("tr td", tbody), showWeatherWidgets);

The code, the linter, and I are much happier now that the need for toArray has been removed. It can be useful, but these are not the situations where toArray should be used.

Improving the addHeadingClasses() function

Here is the addHeadingClasses() function as it looks right now:

    function addHeadingClasses(thead) {
        var headings = $("th:not(:first)", thead);
        $.map(headings, function addHeadingClass(header) {
            var columnHead = $(header).text().split(" ").join("");
            $(header).addClass(columnHead);
        });
    }

That’s pretty good, but why is the function both getting the headings (data) and updating the classes (presentation) at the same time? We should separate the data and presentation by passing the data to the function instead.

    function getHeadings(thead) {
        return $("th:not(:first)", thead);
    }
    // function addHeadingClasses(thead) {
    function addHeadingClasses(headings) {
        // var headings = $("th:not(:first)", thead);
        $.map(headings, function addHeadingClass(header) {
            var columnHead = $(header).text().split(" ").join("");
            $(header).addClass(columnHead);
        });
    }
...
    function addHeadingAndRowClasses(thead, tbody) {
        // addHeadingClasses(thead);
        addHeadingClasses(getHeadings(thead));

Also, the manipulation of the header should be moved out of the addHeadingClasses function too.

    // function addHeadingClasses(headings) {
    function addHeadingClasses(headings, classNames) {
        // $.map(headings, function addHeadingClass(header) {
        $(headings).map(function addHeadingClass(header, index) {
            // var columnHead = $(header).text().split(" ").join("");
            // $(header).addClass(columnHead);
            $(header).addClass(classNames[index]);
        });
    }
...
    function addHeadingAndRowClasses(thead, tbody) {
        var headings = $("th:not(:first)", thead);
        var headingClassNames = $.map(headings, removeSpacesFromText);
        addHeadingClasses(headings, headingClassNames);

Summary

That’s a good place to take a break for today.

The code as it currently stands is:

jQuery(document).ready(function rankWeather($) {
    var StatJSON = {
        "Option1": {
            "ColHeading": "New York",
            "Ranking": "",
            "Rating": "4.5",
            "Row1Heading": "Hello",
            "Weather": "40d71n74d01/new-york"
        },
        "Option2": {
            "ColHeading": "San Francisco",
            "Ranking": "",
            "Rating": "3.2",
            "Row1Heading": "Whats",
            "Weather": "37d77n122d42/san-francisco"
        },
        "Option3": {
            "ColHeading": "Chicago",
            "Ranking": "",
            "Rating": "3.7",
            "Row1Heading": "Up",
            "Weather": "41d88n87d63/chicago"
        },
        "Option4": {
            "ColHeading": "Los Angeles",
            "Ranking": "",
            "Rating": "5.0",
            "Row1Heading": "With",
            "Weather": "34d05n118d24/los-angeles"
        }
    };

    function getFirstValue(obj) {
        var keyValuePair = Object.entries(obj)[0];
        return keyValuePair[1];
    }
    function getCityData(StatJSON, selectedOptions) {
        var headings = selectedOptions.map(function getHeading(option) {
            return StatJSON[option].ColHeading;
        });
        var firstCityData = getFirstValue(StatJSON);
        var rowTitles = Object.keys(firstCityData);
        var rowsData = rowTitles.map(function addRow(rowTitle) {
            return selectedOptions.map(function getRowData(option) {
                return StatJSON[option][rowTitle];
            });
        });
        return {
            headings,
            rowTitles: rowTitles.slice(1),
            rowsData: rowsData.slice(1)
        };
    }

    function createTable(headRow, bodyRows) {
        var thead = `<thead>${headRow}</thead>`;
        var tbody = `<tbody>${bodyRows}</tbody>`;
        return `<table class="compTable">${thead}${tbody}</table>`;
    }
    function createHeadRow(headings) {
        var headRow = headings.map(function createHTMLHeading(heading) {
            return `<th>${heading}</th>`;
        }).join("");
        return `<tr>${headRow}</tr>`;
    }

    function createBodyRows(rowsData) {
        var bodyRows = rowsData.map(function addRow(rowData) {
            var row = rowData.map(function addData(data) {
                return `<td>${data}</td>`;
            }).join("");
            return `<tr>${row}</tr>`;
        }).join("");
        return bodyRows;
    }
    function appendTitles(rowTitles, rowsData) {
        return rowsData.map(function appendTitle(rowData, rowIndex) {
            return [rowTitles[rowIndex], ...rowData];
        });
    }
    function createWeatherTable(cityData) {
        var headRows = [];
        var titledRows = [["No results found"]];
        if (cityData.headings.length) {
            headRows = ["", ...cityData.headings];
            titledRows = appendTitles(cityData.rowTitles, cityData.rowsData);
        }
        return createTable(
            createHeadRow(headRows),
            createBodyRows(titledRows)
        );
    }

    function getSelectedOptions() {
        var selection = $("#selection :selected");
        return $.map(selection, (option) => option.value);
    }

    function updateContainer(container, content) {
        $(container).empty().append(content);
    }

    function addHeadingClasses(headings, classNames) {
        $(headings).map(function addHeadingClass(index, header) {
            $(header).addClass(classNames[index]);
        });
    }
    function propagateHeadingClasses(thead, tbody) {
        $("th", thead).each(function propagateHeadingClass(index, th) {
            $(`td:nth-child(${index + 1})`, tbody).addClass(th.className);
        });
    }
    function propagateRowClasses(tbody) {
        var firstCells = $("td:first-child", tbody);
        $.map(firstCells, function propagateRowClass(cell) {
            var removals = ["#", /[()]/g, /s/g, /\\|\//g];
            var className = removals.reduce(function sanitizeText(text, regex) {
                return text.replace(regex, "");
            }, cell.textContent);

            $(cell).addClass(className);
            $(cell).siblings("td").addClass(className);
            $(cell).parent("tr").addClass(className);
        });
    }
    function removeSpacesFromText(el) {
        return $(el).text().replace(" ", "");
    }
    function addHeadingAndRowClasses(thead, tbody) {
        var headings = $("th:not(:first)", thead);
        var headingClassNames = $.map(headings, removeSpacesFromText);
        addHeadingClasses(headings, headingClassNames);
        $("th:first-child", thead).removeAttr("name");
        propagateHeadingClasses(thead, tbody);
        propagateRowClasses(tbody);
    }

    function updateRatings(tbody) {
        function updateRating(cell) {
            var rating = parseFloat($(cell).text()).toFixed(2);
            $(cell).html(`<div><div class="rating">${rating}</div></div>`);
        }

        function updateRowRatings(ratingCell) {
            $.map($(ratingCell).nextAll("td"), updateRating);
        }
        $.map($(".Rating", tbody), updateRowRatings);
    }

    function updateRankings(tbody) {
        function getRatings() {
            var ratingCells = $("tr.Rating > td:not(:first)", tbody);
            var getText = (td) => $(td).text();
            return $.map(ratingCells, getText);
        }
        function numericSort(a, b) {
            return b - a;
        }
        function rankByRatings() {
            var ratings = getRatings();
            var sorted = ratings.slice().sort(numericSort);
            return ratings.map(function decideRank(ratingIndex) {
                return sorted.indexOf(ratingIndex) + 1;
            });
        }

        function updateCells(ranks, $td) {
            ranks.forEach(function updateCell(rank, i) {
                var html = $(`<div><div "ranking">#${rank}</div></div>`);
                $td.eq(i + 1).empty().append(html);
                if (rank === 1) {
                    $td.eq(i + 1).addClass("ranking1");
                }
            });
        }
        function showRankings(tbody) {
            var ranks = rankByRatings();
            var rows = $("tr", tbody);
            $.map(rows, function updateRow(tr) {
                var $td = $(tr).children();
                if ($td.eq(0).text() === "Ranking") {
                    updateCells(ranks, $td);
                }
            });
        }

        function toNumber(el) {
            var digits = $(el).text().replace(/[^0-9]/gi, "");
            return Number(digits);
        }
        function sortColumnsByRanking(tbody) {
            var rows = $("tr", tbody);
            var RowRanking = $(".Ranking", rows).find("td:not(:first)");
            var rankings = RowRanking.map(toNumber).sort(numericSort);
            rankings.each(function moveColumn(newIndex, cell) {
                var originalIndex = $(cell).index();
                if (originalIndex === newIndex) {
                    return;
                }
                $.map(rows, function swapColumns(row) {
                    var td = $(row).find("td, th");
                    td.eq(originalIndex).insertAfter(td.eq(newIndex));
                });
            });
        }

        showRankings(tbody);
        sortColumnsByRanking(tbody);
    }

    function addWeatherWidgets(tbody) {
        function showWeatherWidget(el) {
            var path = $(el).text();
            var linkHtml = `<a href="https://forecast7.com/en/${path}/"`;
            linkHtml += ` class="weatherwidget-io" data-label_2="WEATHER"`;
            linkHtml += ` data-days="3" data-theme="original">WEATHER</a>`;
            $(el).html(linkHtml);
        }
        function showWeatherWidgets(td) {
            if ($(td).text() === "Weather") {
                $.map($(td).nextAll("td"), showWeatherWidget);
            }
        }
        $.map($("tr td", tbody), showWeatherWidgets);
        __weatherwidget_init();
    }

    function updateTable(container, table) {
        var thead = $("thead", table);
        var tbody = $("tbody", table);
        updateContainer(container, table);
        addHeadingAndRowClasses(thead, tbody);
        updateRatings(tbody);
        updateRankings(tbody);
        addWeatherWidgets(tbody);
    }

    function updateWeatherTable(container, cityData) {
        var table = $(createWeatherTable(cityData));
        updateTable(container, table);
    }
    $("#btnSubmit").click(function citiesSubmitHandler() {
        var selectedOptions = getSelectedOptions();
        var cityData = getCityData(StatJSON, selectedOptions);
        updateWeatherTable("#divResult", cityData);
    });
});

The createWeatherTable(), getSelectedOptions(), and addHeadingClasses() are all improved, and I feel good about the progress that this is taking, especially with being able to remove the need for .toArray and replace it with either $().map() or $.map(), depending on whether the index value is needed or not.

1 Like

Carrying on with working through the code, the next function to examine is propagateHeadingClasses()

Improving the propagateHeadingClasses() function

    function propagateHeadingClasses(thead, tbody) {
        $("th", thead).each(function propagateHeadingClass(index, th) {
            $(`td:nth-child(${index + 1})`, tbody).addClass(th.className);
        });
    }

This code is quite complex because it’s both getting the information it needs at the same time that it applies those headings.

We can turn that function into the equivalent of some business logic, where it gets the information it needs, then passes it to a separate function.to do the work.

    function propagateHeadingClasses(thead, tbody) {
        var headingClasses = getHeadingClasses(thead);
        addClassesToColumns(tbody, headingClasses);
    }

Those functions are really easy to create:

    function getHeadingClasses(thead) {
        var headings = $(thead).find("th");
        return $.map(headings, (heading) => heading.className);
    }
    function addClassesToColumns(tbody, classes) {
        $(tbody).find("td").map(function addClassName(index, cell) {
            $(cell).addClass(classes[index]);
        });
    }

And just like that, we have three functions that have almost accidentally fallen into the layers of separation:

  • data layer: getHeadingClasses
  • business logic: propagateHeadingClasses
  • presentation layer: addClassesToColumns

I didn’t plan for that to happen, it’s just a natural outcome of simplifying how the code works, and seems to work well.

Improving the propagateRowClasses() function

Here’s how the function begins:

    function propagateRowClasses(tbody) {
        var firstCells = $("td:first-child", tbody);
        $.map(firstCells, function propagateRowClass(cell) {
            var removals = ["#", /[()]/g, /s/g, /\\|\//g];
            var className = removals.reduce(function sanitizeText(text, regex) {
                return text.replace(regex, "");
            }, cell.textContent);

            $(cell).addClass(className);
            $(cell).siblings("td").addClass(className);
            $(cell).parent("tr").addClass(className);
        });
    }

There’s a lot of complications going on in there, and it’s tricky to visually check if the classes are being added to the table. I think that I should add some temporary CSS classes so that I can get visual feedback about what’s happening. I’ll give each row class a different border color so that I can see that they are being applied.

/*.ranking, .rating {
    background-color:#558000;
}*/
table {
    border-collapse: collapse;
}
tr.Ranking {
    border: 5px solid red;
}
td.Ranking {
    background-color: magenta;
}
tr.Rating {
    border: 5px solid orange;
}
td.Rating {
    background-color: yellow;
}
tr.Row1Heading {
    border: 5px solid green;
}
td.Row1Heading {
    background-color: lime;
}
tr.Weather {
    border: 5px solid indigo;
}
td.Weather {
    background-color: blue;
}

image

It’s not pretty but it’s not supposed to be. The purpose is to show a TR classname (border color) as being different from a TD classname (background color).

When I finish updating the propagteRowClasses code, there should be no change in that image at all, after which I can return the CSS back to what it was before.

Back to the propagateRowClasses() function. The data that we need is the first cell text of each row.

    function getRows(tbody) {
        return $(tbody).find("tr");
    }
    function getFirstCellText(rows) {
        return $.map(rows, (row) => $(row).find("td").first().text());
    }
...
    function propagateRowClasses(tbody) {
        var rows = getRows(tbody);
        var firstCellTexts = getFirstCellText(rows);
        ...
    }

We then need to process that data by making them safe for classnames.

    function makeSafeClassnames(classnames) {
        var removals = ["#", /[()]/g, /s/g, /\\|\//g];
        return classnames.map(function makeSafeClassname(text) {
            return removals.reduce(function makeSafe(text, regex) {
                return text.replace(regex, "");
            }, text);
        });
    }
...
    function propagateRowClasses(tbody) {
        var rows = getRows(tbody);
        var firstCellTexts = getFirstCellText(rows);
        var rowClasses = makeSafeClassnames(firstCellTexts);
        ...
    }

And lastly, we want to add those rowClasses to the rows and the cells.

    function addClassesToRows(rows, classnames) {
        $.map(rows, (row, index) => $(row).addClass(classnames[index]));
    }
    function addClassesToCells(rows, classnames) {
        $.map(rows, (row, index) => $(row).find("td").addClass(classnames[index]));
    }
    function propagateRowClasses(tbody) {
        var rows = getRows(tbody);
        var firstCellTexts = getFirstCellText(rows);
        var rowClasses = makeSafeClassnames(firstCellTexts);
        addClassesToRows(rows, rowClasses);
        addClassesToCells(rows, rowClasses);
    }

I was tempted to inline the getRows function so that it all happens in the propagateRowClasses function, but then that’s breaching a rule where functions should have the same level of abstraction.

Bearing that in mind, I’ll take another look at the addHeadingAndRowClasses function next time.

We are collecting a lot of small functions that each do their own thing well, but they can be hard to manage.

To help keep them roughly wrangled, I’ll group them together into data functions, presentation, and business logic, for want of better terms to use right now.

jQuery(document).ready(function rankWeather($) {
    var StatJSON = {
        "Option1": {
            "ColHeading": "New York",
            "Ranking": "",
            "Rating": "4.5",
            "Row1Heading": "Hello",
            "Weather": "40d71n74d01/new-york"
        },
        "Option2": {
            "ColHeading": "San Francisco",
            "Ranking": "",
            "Rating": "3.2",
            "Row1Heading": "Whats",
            "Weather": "37d77n122d42/san-francisco"
        },
        "Option3": {
            "ColHeading": "Chicago",
            "Ranking": "",
            "Rating": "3.7",
            "Row1Heading": "Up",
            "Weather": "41d88n87d63/chicago"
        },
        "Option4": {
            "ColHeading": "Los Angeles",
            "Ranking": "",
            "Rating": "5.0",
            "Row1Heading": "With",
            "Weather": "34d05n118d24/los-angeles"
        }
    };

    /* DATA FUNCTIONS */
    function getSelectedOptions(select) {
        var selection = $(select).find(":selected");
        return $.map(selection, (option) => option.value);
    }
    function getFirstValue(obj) {
        var keyValuePair = Object.entries(obj)[0];
        return keyValuePair[1];
    }
    function getFirstCellText(rows) {
        return $.map(rows, (row) => $(row).find("td").first().text());
    }
    function removeSpacesFromText(el) {
        return $(el).text().replace(" ", "");
    }
    function getRows(tbody) {
        return $(tbody).find("tr");
    }
    function getHeadingClasses(thead) {
        var headings = $(thead).find("th");
        return $.map(headings, (heading) => heading.className);
    }

    /* PRESENTATION */
    function updateContainer(container, content) {
        $(container).empty().append(content);
    }
    function createTable(headRow, bodyRows) {
        var thead = `<thead>${headRow}</thead>`;
        var tbody = `<tbody>${bodyRows}</tbody>`;
        return `<table class="compTable">${thead}${tbody}</table>`;
    }
    function createHeadRow(headings) {
        var headRow = headings.map(function createHTMLHeading(heading) {
            return `<th>${heading}</th>`;
        }).join("");
        return `<tr>${headRow}</tr>`;
    }
    function addHeadingClasses(headings, classNames) {
        $(headings).map(function addHeadingClass(index, header) {
            $(header).addClass(classNames[index]);
        });
    }
    function createBodyRows(rowsData) {
        var bodyRows = rowsData.map(function addRow(rowData) {
            var row = rowData.map(function addData(data) {
                return `<td>${data}</td>`;
            }).join("");
            return `<tr>${row}</tr>`;
        }).join("");
        return bodyRows;
    }
    function addClassesToColumns(tbody, classes) {
        $(tbody).find("td").map(function addClassName(index, cell) {
            $(cell).addClass(classes[index]);
        });
    }
    function addClassesToRows(rows, classnames) {
        $.map(rows, (row, index) => $(row).addClass(classnames[index]));
    }
    function addClassesToCells(rows, classnames) {
        $.map(rows, (row, index) => $(row).find("td").addClass(classnames[index]));
    }
    function makeSafeClassnames(classnames) {
        var removals = ["#", /[()]/g, /\s/g, /\\|\//g];
        return classnames.map(function makeSafeClassname(text) {
            return removals.reduce(function makeSafe(text, regex) {
                return text.replace(regex, "");
            }, text);
        });
    }
    function propagateRowClasses(tbody) {
        var rows = getRows(tbody);
        var firstCellTexts = getFirstCellText(rows);
        var rowClasses = makeSafeClassnames(firstCellTexts);
        addClassesToRows(rows, rowClasses);
        addClassesToCells(rows, rowClasses);
    }

    /* BUSINESS LOGIC */
    function appendTitles(rowTitles, rowsData) {
        return rowsData.map(function appendTitle(rowData, rowIndex) {
            return [rowTitles[rowIndex], ...rowData];
        });
    }
    function createWeatherTable(cityData) {
        var headRows = [];
        var titledRows = [["No results found"]];
        if (cityData.headings.length) {
            headRows = ["", ...cityData.headings];
            titledRows = appendTitles(cityData.rowTitles, cityData.rowsData);
        }
        return createTable(
            createHeadRow(headRows),
            createBodyRows(titledRows)
        );
    }
    function propagateHeadingClasses(thead, tbody) {
        var headingClasses = getHeadingClasses(thead);
        addClassesToColumns(tbody, headingClasses);
    }
    function addHeadingAndRowClasses(thead, tbody) {
        var headings = $("th:not(:first)", thead);
        var headingClassNames = $.map(headings, removeSpacesFromText);
        addHeadingClasses(headings, headingClassNames);
        $("th:first-child", thead).removeAttr("name");
        propagateHeadingClasses(thead, tbody);
        propagateRowClasses(tbody);
    }
    function getCityData(StatJSON, selectedOptions) {
        var headings = selectedOptions.map(function getHeading(option) {
            return StatJSON[option].ColHeading;
        });
        var firstCityData = getFirstValue(StatJSON);
        var rowTitles = Object.keys(firstCityData).slice(1);
        var rowsData = rowTitles.map(function addRow(rowTitle) {
            return selectedOptions.map(function getRowData(option) {
                return StatJSON[option][rowTitle];
            });
        });
        return {
            headings,
            rowTitles: rowTitles,
            rowsData: rowsData
        };
    }

    function updateRatings(tbody) {
        function updateRating(cell) {
            var rating = parseFloat($(cell).text()).toFixed(2);
            $(cell).html(`<div><div class="rating">${rating}</div></div>`);
        }

        function updateRowRatings(ratingCell) {
            $.map($(ratingCell).nextAll("td"), updateRating);
        }
        $.map($(".Rating", tbody), updateRowRatings);
    }

    /* Still to be processed below */
    
    function updateRankings(tbody) {
        function getRatings() {
            var ratingCells = $("tr.Rating > td:not(:first)", tbody);
            var getText = (td) => $(td).text();
            return $.map(ratingCells, getText);
        }
        function numericSort(a, b) {
            return b - a;
        }
        function rankByRatings() {
            var ratings = getRatings();
            var sorted = ratings.slice().sort(numericSort);
            return ratings.map(function decideRank(ratingIndex) {
                return sorted.indexOf(ratingIndex) + 1;
            });
        }

        function updateCells(ranks, $td) {
            ranks.forEach(function updateCell(rank, i) {
                var html = $(`<div><div "ranking">#${rank}</div></div>`);
                $td.eq(i + 1).empty().append(html);
                if (rank === 1) {
                    $td.eq(i + 1).addClass("ranking1");
                }
            });
        }
        function showRankings(tbody) {
            var ranks = rankByRatings();
            var rows = $("tr", tbody);
            $.map(rows, function updateRow(tr) {
                var $td = $(tr).children();
                if ($td.eq(0).text() === "Ranking") {
                    updateCells(ranks, $td);
                }
            });
        }

        function toNumber(el) {
            var digits = $(el).text().replace(/[^0-9]/gi, "");
            return Number(digits);
        }
        function sortColumnsByRanking(tbody) {
            var rows = $("tr", tbody);
            var RowRanking = $(".Ranking", rows).find("td:not(:first)");
            var rankings = RowRanking.map(toNumber).sort(numericSort);
            rankings.each(function moveColumn(newIndex, cell) {
                var originalIndex = $(cell).index();
                if (originalIndex === newIndex) {
                    return;
                }
                $.map(rows, function swapColumns(row) {
                    var td = $(row).find("td, th");
                    td.eq(originalIndex).insertAfter(td.eq(newIndex));
                });
            });
        }

        showRankings(tbody);
        sortColumnsByRanking(tbody);
    }

    function addWeatherWidgets(tbody) {
        function showWeatherWidget(el) {
            var path = $(el).text();
            var linkHtml = `<a href="https://forecast7.com/en/${path}/"`;
            linkHtml += ` class="weatherwidget-io" data-label_2="WEATHER"`;
            linkHtml += ` data-days="3" data-theme="original">WEATHER</a>`;
            $(el).html(linkHtml);
        }
        function showWeatherWidgets(td) {
            if ($(td).text() === "Weather") {
                $.map($(td).nextAll("td"), showWeatherWidget);
            }
        }
        $.map($("tr td", tbody), showWeatherWidgets);
        __weatherwidget_init();
    }

    function updateTable(container, table) {
        var thead = $("thead", table);
        var tbody = $("tbody", table);
        updateContainer(container, table);
        addHeadingAndRowClasses(thead, tbody);
        updateRatings(tbody);
        updateRankings(tbody);
        addWeatherWidgets(tbody);
    }

    function updateWeatherTable(container, cityData) {
        var table = $(createWeatherTable(cityData));
        updateTable(container, table);
    }
    $("#btnSubmit").click(function citiesSubmitHandler() {
        var selectedOptions = getSelectedOptions("#selection");
        var cityData = getCityData(StatJSON, selectedOptions);
        updateWeatherTable("#divResult", cityData);
    });
});

Thanks to that implied structure, it’s easier to look through them and find where improvements are needed.

Improving removeSpacesFromText() function

The removeSpacesFromText function for example is given elements, but then gets the text from that and replaces the text.

    function removeSpacesFromText(el) {
        return $(el).text().replace(" ", "");
    }

A data function should not be making that kind of structural choice, so we’ll separate out the getting text part.

    function removeSpacesFromText(el) {
        // return $(el).text().replace(" ", "");
        var text = getText(el);
        return text.replace(" ", "");
    }

We can now push that getText up to a parent function, using text for the removeSpacesFromText function parameter instead.

    // function removeSpacesFromText(el) {
    function removeSpacesFromText(text) {
        // var text = getText(el);
        return text.replace(" ", "");
    }

...
    function addHeadingAndRowClasses(thead, tbody) {
        var headings = $("th:not(:first)", thead);
        // var headingClassNames = $.map(headings, removeSpacesFromText);
        var headingClassNames = $.map(headings, function removeSpaces(el) {
            var text = getText(el);
            removeSpacesFromText(text);
        });
        ...
    }

And that removeSpaces function can now be extracted from the function:

    function removeSpaces(el) {
        var text = getText(el);
        return removeSpacesFromText(text);
    }
    function addHeadingAndRowClasses(thead, tbody) {
        var headings = $("th:not(:first)", thead);
        var headingClassNames = $.map(headings, removeSpaces);
        ...
    }

There are other things that should happen to that addHeadingAndRowClasses function, such as separating it into separate addHeadingClasses and addRowClasses functions, but that will come later.

To try and remain on task, I’m working my way down from the top of the code, checking for any obvious issues.

Improve propagateRowClasses() function

This presentation function is doing too much work for itself. Here is how the function looks right now:

        return keyValuePair[1];
    }
    function getFirstCellText(rows) {
        return $.map(rows, (row) => $(row).find("td").first().text());
    }
    function getRows(tbody) {
        return $(tbody).find("tr");

    function propagateRowClasses(tbody) {
        var rows = getRows(tbody);
        var firstCellTexts = getFirstCellText(rows);
        var rowClasses = makeSafeClassnames(firstCellTexts);
        addClassesToRows(rows, rowClasses);
        addClassesToCells(rows, rowClasses);
    }

A good clue about the problem is that the function is called propagateRowClasses but it’s being given neither rows nor classes.

We can stat by passing in the rows to the function:

    // function propagateRowClasses(tbody) {
    function propagateRowClasses(rows) {
        // var rows = getRows(tbody);
        var firstCellTexts = getFirstCellText(rows);
        var rowClasses = makeSafeClassnames(firstCellTexts);
        addClassesToRows(rows, rowClasses);
        addClassesToCells(rows, rowClasses);
    }
...
    function addHeadingAndRowClasses(thead, tbody) {
        ...
        // propagateRowClasses(tbody);
        var rows = getRows(tbody);
        propagateRowClasses(rows);
    }

And then we can pass the rowClasses into the function too.

    // function propagateRowClasses(rows) {
    function propagateRowClasses(rows, rowClasses) {
        // var firstCellTexts = getFirstCellText(rows);
        // var rowClasses = makeSafeClassnames(firstCellTexts);
        addClassesToRows(rows, rowClasses);
        addClassesToCells(rows, rowClasses);
    }
...
    function addHeadingAndRowClasses(thead, tbody) {
        ...
        var rows = getRows(tbody);
        var firstCellTexts = getFirstCellText(rows);
        var rowClasses = makeSafeClassnames(firstCellTexts);
        propagateRowClasses(rows);
    }

The propagateRowClasses function can now be moved into th business-logic section.

Removing the need for getFirstCellText()

We are getting page data from StatJSON to add that data to the HTML table. And then we are getting that same data from the HTML table to do other things with it.

That is something of a circuitous path. Why can’t we just get the data we need from the StatJSON data that was used to make the table in the first place?

We have:

  • updateWeatherTable(container, cityData)
    • updateTable(container, table)
      • propagateRowClasses(thead, tbody)

I don’t think that we need the two separate updateWeatherTable and updateTable functions. We can combine them together:

    // function updateTable(container, table) {
    function updateWeatherTable(container, cityData) {
        var table = $(createWeatherTable(cityData));
        var thead = $("thead", table);
        var tbody = $("tbody", table);
        updateContainer(container, table);
        addHeadingAndRowClasses(thead, tbody);
        updateRatings(tbody);
        updateRankings(tbody);
        addWeatherWidgets(tbody);
    }

    // function updateWeatherTable(container, cityData) {
    //     var table = $(createWeatherTable(cityData));
    //     updateTable(container, table);
    // }

In the new updateWeatherTable function, I now want to separate apart the addHeadingAndRowClasses() function, so that I can give them a separate set of headingClasses and rowClasses.

Separate headingClasses()

The existing addHeadingClasses() function can be renamed to be updateHeadingClasses, to make way for a higher level of abstraction.

    function addHeadingClasses(headings, classNames) {
    function updateHeadingClasses(headings, classNames) {
        ...
    }
...
    function addHeadingAndRowClasses(thead, tbody) {
        ...
        // addHeadingClasses(headings, headingClasses);
        updateHeadingClasses(headings, headingClasses);
        ...
    }

We can now move out code from addHeadingAndRowClasses() into a separate addHeadingClasses() function.

    function addHeadingClasses(thead) {
        var headings = $("th:not(:first)", thead);
        var headingClasses = $.map(headings, removeSpaces);
        updateHeadingClasses(headings, headingClasses);
    }
...
    function addHeadingAndRowClasses(thead, tbody) {
        addHeadingClasses(thead);
        ...
    }

Now why is addHeadingClasses not getting the first element? I know that the first column has no heading, but does anything go wrong when we get all of the headings?

To help me find out, I’ve given all cells of the table that have a class attribute, even an empty attribute, a background color of red.

Other cells that I’ve given a background color will then take precedence over that red.

th[class], td[class] {
    background-color: red;
}
th.NewYork {
    background-color: orange;
}
th.LosAngeles {
    background-color: teal;
}

This way, I have immediate visual feedback about whether my changes have a negative impact on the classnames on the table.

I can now simplify the addHeadingClasses function so that it just gets all of the headings, which can be a simple function in the data area

    function getHeadingCells(thead) {
        return $(thead).find("th");
    }
...
    function addHeadingClasses(thead) {
        // var headings = $("th:not(:first)", thead);
        var headings = getHeadingCells(thead);
        var headingClasses = $.map(headings, removeSpaces);
        updateHeadingClasses(headings, headingClasses);
    }

We do have an empty string at the start of the headingClasses array, but all is good. Even if we were using classList to add the classes:
header.classList.add(classNames[index]);

We do get a useful error with classList that an empty classname can’t be added, which is good news. We aren’t using classList, and are using jQuery addClass instead, which also doesn’t allow empty classnames to be added and it doesn’t give any errors either. The top-left cell of the table remains class free as intended.

With the headingClass themself, we can reuse the makeSafeClassnames() function.

    function getHeadingTexts(headings) {
        return $.map(headings, getText);
    }
...
    function createHeadingClasses(headings) {
        return makeSafeClassnames(headings);
    }
...
    function addHeadingClasses(thead) {
        var headings = getHeadingCells(thead);
        // var headingClasses = $.map(headings, removeSpaces);
        var headingClasses = createHeadingClasses(headings);
        updateHeadingClasses(headings, headingClasses);
    }

The addHeadingClasses() function is now at about the same level of abstraction throughout it all, and we can move on to addRowClasses()

Now that removeSpaces isn’t being used, we can remove that function too.

    // function removeSpaces(el) {
    //     var text = getText(el);
    //     return removeSpacesFromText(text);
    // }

Move headingClasses to propagateHeadingClasses()

It now makes sense to move all things relating to headingClasses into a propagateHeadingClasses function.

    function propagateHeadingClasses(thead, tbody) {
        var headings = getHeadingCells(thead);
        var headingClasses = createHeadingClasses(headings);
        updateHeadingClasses(headings, headingClasses);
        addClassesToColumns(tbody, headingClasses);
    }
...
    function addHeadingAndRowClasses(thead, tbody) {
        propagateHeadingClasses(thead, tbody);
        var rows = getRows(tbody);
        propagateRowClasses(rows);
        $("th:first-child", thead).removeAttr("name");
    }

The whole point of this is so that the addHeadingAndRowClasses() function gets to be simplified to what is is now. That way everything in there is at about the same level of abstraction.

We also don’t need the removeSpacesFromText() function, or the getheadingClasses() function, so those can be removed.

    // function removeSpacesFromText(text) {
    //     return text.replace(" ", "");
    // }
...
    // function getHeadingClasses(thead) {
    //     var headings = getHeadingCells(thead);
    //     return $.map(headings, (heading) => heading.className);
    // }

It’s quite strongly beneficial to avoid retrieving info from the DOM, especially when we have just added that information from elsewhere.

Inlining the addHeadingAndRowsClasses() function

The addHeadingAndRowClasses function can now be removed, by moving those propagate functions into the updateWeatherTable function.

    // function addHeadingAndRowClasses(thead, tbody) {
    //     propagateHeadingClasses(thead, tbody);
    //     propagateRowClasses(tbody);
    //     $("th:first-child", thead).removeAttr("name");
    // }
...
    function updateWeatherTable(container, cityData) {
        ...
        // addHeadingAndRowClasses(thead, tbody);
        propagateHeadingClasses(thead, tbody);
        propagateRowClasses(tbody);
        ...
    }
```

That is significantly improved. The next thing I'll work on is to stop creatRowClasses() from accessing the table, and to give it the information from cityData that it needs instead.

Here’s the code that we currently have:

jQuery(document).ready(function rankWeather($) {
    var StatJSON = {
        "Option1": {
            "ColHeading": "New York",
            "Ranking": "",
            "Rating": "4.5",
            "Row1Heading": "Hello",
            "Weather": "40d71n74d01/new-york"
        },
        "Option2": {
            "ColHeading": "San Francisco",
            "Ranking": "",
            "Rating": "3.2",
            "Row1Heading": "Whats",
            "Weather": "37d77n122d42/san-francisco"
        },
        "Option3": {
            "ColHeading": "Chicago",
            "Ranking": "",
            "Rating": "3.7",
            "Row1Heading": "Up",
            "Weather": "41d88n87d63/chicago"
        },
        "Option4": {
            "ColHeading": "Los Angeles",
            "Ranking": "",
            "Rating": "5.0",
            "Row1Heading": "With",
            "Weather": "34d05n118d24/los-angeles"
        }
    };

    /* DATA FUNCTIONS */
    function getSelectedOptions(select) {
        var selection = $(select).find(":selected");
        return $.map(selection, (option) => option.value);
    }
    function getText(el) {
        return $(el).text();
    }
    function makeSafeClassnames(classnames) {
        var removals = ["#", /[()]/g, /\s/g, /\\|\//g];
        return classnames.map(function makeSafeClassname(text) {
            return removals.reduce(function makeSafe(text, regex) {
                return text.replace(regex, "");
            }, text);
        });
    }
    function getFirstValue(obj) {
        var keyValuePair = Object.entries(obj)[0];
        return keyValuePair[1];
    }

    /* PRESENTATION */
    function updateContainer(container, content) {
        $(container).empty().append(content);
    }
    function createTable(headRow, bodyRows) {
        var thead = `<thead>${headRow}</thead>`;
        var tbody = `<tbody>${bodyRows}</tbody>`;
        return `<table class="compTable">${thead}${tbody}</table>`;
    }
    function createHeadRow(headings) {
        var headRow = headings.map(function createHTMLHeading(heading) {
            return `<th>${heading}</th>`;
        }).join("");
        return `<tr>${headRow}</tr>`;
    }
    function getHeadingCells(thead) {
        return $(thead).find("th");
    }
    function getHeadingTexts(headings) {
        return $.map(headings, getText);
    }
    function createHeadingClasses(headings) {
        var headingTexts = getHeadingTexts(headings);
        return makeSafeClassnames(headingTexts);
    }
    function updateHeadingClasses(headings, classNames) {
        $(headings).map(function addHeadingClass(index, header) {
            $(header).addClass(classNames[index]);
        });
    }
    function createBodyRows(rowsData) {
        var bodyRows = rowsData.map(function addRow(rowData) {
            var row = rowData.map(function addData(data) {
                return `<td>${data}</td>`;
            }).join("");
            return `<tr>${row}</tr>`;
        }).join("");
        return bodyRows;
    }
    function getFirstCellText(rows) {
        return $.map(rows, (row) => $(row).find("td").first().text());
    }
    function getRows(tbody) {
        return $(tbody).find("tr");
    }
    function createRowClasses(rows) {
        var firstCellTexts = getFirstCellText(rows);
        return makeSafeClassnames(firstCellTexts);
    }
    function addClassesToColumns(tbody, classes) {
        $(tbody).find("td").map(function addClassName(index, cell) {
            $(cell).addClass(classes[index]);
        });
    }
    function addClassesToRows(rows, classnames) {
        $.map(rows, (row, index) => $(row).addClass(classnames[index]));
    }
    function addClassesToCells(rows, classnames) {
        $.map(rows, function addRowClass(row, index) {
            $(row).find("td").addClass(classnames[index]);
        });
    }

    /* BUSINESS LOGIC */
    function appendTitles(rowTitles, rowsData) {
        return rowsData.map(function appendTitle(rowData, rowIndex) {
            return [rowTitles[rowIndex], ...rowData];
        });
    }
    function createWeatherTable(cityData) {
        var headRows = [];
        var titledRows = [["No results found"]];
        if (cityData.headings.length) {
            headRows = ["", ...cityData.headings];
            titledRows = appendTitles(cityData.rowTitles, cityData.rowsData);
        }
        return createTable(
            createHeadRow(headRows),
            createBodyRows(titledRows)
        );
    }
    function propagateHeadingClasses(thead, tbody) {
        var headings = getHeadingCells(thead);
        var headingClasses = createHeadingClasses(headings);
        updateHeadingClasses(headings, headingClasses);
        addClassesToColumns(tbody, headingClasses);
    }
    function propagateRowClasses(tbody) {
        var rows = getRows(tbody);
        var rowClasses = createRowClasses(rows);
        addClassesToRows(rows, rowClasses);
        addClassesToCells(rows, rowClasses);
    }
    function getCityData(StatJSON, selectedOptions) {
        var headings = selectedOptions.map(function getHeading(option) {
            return StatJSON[option].ColHeading;
        });
        var firstCityData = getFirstValue(StatJSON);
        var rowTitles = Object.keys(firstCityData).slice(1);
        var rowsData = rowTitles.map(function addRow(rowTitle) {
            return selectedOptions.map(function getRowData(option) {
                return StatJSON[option][rowTitle];
            });
        });
        return {
            headings,
            rowTitles: rowTitles,
            rowsData: rowsData
        };
    }

    function updateRatings(tbody) {
        function updateRating(cell) {
            var rating = parseFloat($(cell).text()).toFixed(2);
            $(cell).html(`<div><div class="rating">${rating}</div></div>`);
        }

        function updateRowRatings(ratingCell) {
            $.map($(ratingCell).nextAll("td"), updateRating);
        }
        $.map($(".Rating", tbody), updateRowRatings);
    }

    /* Still to be processed below */

    function updateRankings(tbody) {
        function getRatings() {
            var ratingCells = $("tr.Rating > td:not(:first)", tbody);
            return $.map(ratingCells, getText);
        }
        function numericSort(a, b) {
            return b - a;
        }
        function rankByRatings() {
            var ratings = getRatings();
            var sorted = ratings.slice().sort(numericSort);
            return ratings.map(function decideRank(ratingIndex) {
                return sorted.indexOf(ratingIndex) + 1;
            });
        }

        function updateCells(ranks, $td) {
            ranks.forEach(function updateCell(rank, i) {
                var html = $(`<div><div "ranking">#${rank}</div></div>`);
                $td.eq(i + 1).empty().append(html);
                if (rank === 1) {
                    $td.eq(i + 1).addClass("ranking1");
                }
            });
        }
        function showRankings(tbody) {
            var ranks = rankByRatings();
            var rows = $("tr", tbody);
            $.map(rows, function updateRow(tr) {
                var $td = $(tr).children();
                if ($td.eq(0).text() === "Ranking") {
                    updateCells(ranks, $td);
                }
            });
        }

        function toNumber(el) {
            var digits = $(el).text().replace(/[^0-9]/gi, "");
            return Number(digits);
        }
        function sortColumnsByRanking(tbody) {
            var rows = $("tr", tbody);
            var RowRanking = $(".Ranking", rows).find("td:not(:first)");
            var rankings = RowRanking.map(toNumber).sort(numericSort);
            rankings.each(function moveColumn(newIndex, cell) {
                var originalIndex = $(cell).index();
                if (originalIndex === newIndex) {
                    return;
                }
                $.map(rows, function swapColumns(row) {
                    var td = $(row).find("td, th");
                    td.eq(originalIndex).insertAfter(td.eq(newIndex));
                });
            });
        }

        showRankings(tbody);
        sortColumnsByRanking(tbody);
    }

    function addWeatherWidgets(tbody) {
        function showWeatherWidget(el) {
            var path = $(el).text();
            var linkHtml = `<a href="https://forecast7.com/en/${path}/"`;
            linkHtml += ` class="weatherwidget-io" data-label_2="WEATHER"`;
            linkHtml += ` data-days="3" data-theme="original">WEATHER</a>`;
            $(el).html(linkHtml);
        }
        function showWeatherWidgets(td) {
            if ($(td).text() === "Weather") {
                $.map($(td).nextAll("td"), showWeatherWidget);
            }
        }
        $.map($("tr td", tbody), showWeatherWidgets);
        __weatherwidget_init();
    }

    function updateWeatherTable(container, cityData) {
        var table = $(createWeatherTable(cityData));
        var thead = $("thead", table);
        var tbody = $("tbody", table);
        updateContainer(container, table);
        propagateHeadingClasses(thead, tbody);
        propagateRowClasses(tbody);
        updateRatings(tbody);
        updateRankings(tbody);
        addWeatherWidgets(tbody);
    }

    $("#btnSubmit").click(function citiesSubmitHandler() {
        var selectedOptions = getSelectedOptions("#selection");
        var cityData = getCityData(StatJSON, selectedOptions);
        updateWeatherTable("#divResult", cityData);
    });
});

Looking down through the code again from the start, I find that we’'re getting information from the table that we’ve just created.

    function getHeadingCells(thead) {
        return $(thead).find("th");
    }
    function getHeadingTexts(headings) {
        return $.map(headings, getText);
    }

We really shouldn’t need those, as the heading titles should already be available.

Can we pass the headings from cityData through to propagateHeadingClasses() ?

    // function propagateHeadingClasses(thead, tbody) {
    function propagateHeadingClasses(thead, tbody, headings) {
        // var headings = getHeadingCells(thead);
        var headingClasses = createHeadingClasses(headings);
        updateHeadingClasses(headings, headingClasses);
        addClassesToColumns(tbody, headingClasses);
    }
...
    function updateWeatherTable(container, cityData) {
        ...
        // propagateHeadingClasses(thead, tbody);
        propagateHeadingClasses(thead, tbody, cityData.headings);
        propagateRowClasses(tbody);
        ...
    }

The headings from cityData, doesn’t have an empty first one for the left titles column. The createWeatherTable function adds that empty first heading to the headRows . . .

    function createWeatherTable(cityData) {
        ...
        if (cityData.headings.length) {
            headRows = ["", ...cityData.headings];
            titledRows = appendTitles(cityData.rowTitles, cityData.rowsData);
        }
        ...
    }

So let’s start by passing headings as a function parameter to the propagateHeadingClasses() function.

    // function propagateHeadingClasses(thead, tbody) {
    function propagateHeadingClasses(thead, tbody, headings) {
        // var headings = getHeadingCells(thead);
        var headingClasses = createHeadingClasses(headings);
        updateHeadingClasses(headings, headingClasses);
        addClassesToColumns(tbody, headingClasses);
    }
...
    function updateWeatherTable(container, cityData) {
        ...
        var headings = getHeadingCells(thead);
        ...
        // propagateHeadingClasses(thead);
        propagateHeadingClasses(thead, tbody, headings);
        ...
    }

We can now update the headings variable in updateWeatherTable(), so that it uses cityData instead.

I have to be careful with the updateWeatherTable function though, because headings refers both to heading cells and to heading texts. I had better clear that up now.

    function updateWeatherTable(container, cityData) {
        ...
        // var headingCells = getHeadingCells(thead);
        var headingTexts = ["", ...cityData.headings];
        ...
        propagateHeadingClasses(thead, tbody, headingCells);
        ...
    }

To connect things together, I need to update the propagateHeadingClasses() function so that it can receive headingTexts instead of headingCells. That means inlinding the createHeadingClasses() function:

    function propagateHeadingClasses(thead, tbody, headingCells) {
        // var headingClasses = createHeadingClasses(headings);
        var headingTexts = getHeadingTexts(headingCells);
        var headingClasses = makeSafeClassnames(headingTexts);
        updateHeadingClasses(headingCells, headingClasses);
        addClassesToColumns(tbody, headingClasses);
    }

And we can now move headingCells into the propagateHeadingClasses() function. replacing headingCells for headingTexts instead.

    // function propagateHeadingClasses(thead, tbody, headingCells) {
    function propagateHeadingClasses(thead, tbody, headingTexts) {
        var headingCells = getHeadingCells(thead);
        // var headingTexts = getHeadingTexts(headingCells);
        var headingClasses = makeSafeClassnames(headingTexts);
        updateHeadingClasses(headingCells, headingClasses);
        addClassesToColumns(tbody, headingClasses);
    }
...
    function updateWeatherTable(container, cityData) {
        ...
        // var headingsCells = getHeadingCells(thead);
        var headingTexts = ["", ...cityData.headings];
        ...
        propagateHeadingClasses(thead, tbody, headingsTexts);

That was a potentially complicated adjustment, but by taking things slowly one reliable step at a time and having good feedback to tell me immediately when I stray from the right path (using colourful table cells), it’s been a relatively easy adjustment.

The getHeadingCells() and getHeadingTexts() functions are no longer used, and can now be removed.

    // function getHeadingCells(thead) {
    //     return $(thead).find("th");
    // }
    // function getHeadingTexts(headings) {
    //     return $.map(headings, getText);
    // }

Remove the need for getText() function

We should be able to remove the need to use the getText() function too in the updateRankings function, by passing to the function what it needs, so that it doesn’t have to go and scour the table.

    function updateRankings(tbody, ratings) {
        // function getRatings() {
        //     var ratingCells = $("tr.Rating > td:not(:first)", tbody);
        //     return $.map(ratingCells, getText);
        // }
...
        function rankByRatings() {
            // var ratings = getRatings();
            var sorted = ratings.slice().sort(numericSort);
...
    function updateWeatherTable(container, cityData) {
        ...
        var ratings = cityData.rowsData[1];
        updateRatings(tbody);
        updateRankings(tbody, ratings);
        addWeatherWidgets(tbody);
    }

Along with removing the getRatings() function, we can now also remove the getText() function too.

Remove getFirstCellText() function

Here’s another function that examines the table for data that we already have.

    function getFirstCellText(rows) {
        return $.map(rows, (row) => $(row).find("td").first().text());
    }

That’s used to get the row titles, which is already in the cityData object. We can pass that information to the propagateRowClasses() function instead.

    // function propagateRowClasses(tbody) {
    function propagateRowClasses(tbody, rowTitles) {
        var rows = getRows(tbody);
        // var rowClasses = createRowClasses(rows);
        var rowClasses = makeSafeClassnames(rowTitles);
        addClassesToRows(rows, rowClasses);
        addClassesToCells(rows, rowClasses);
    }
...
    function updateWeatherTable(container, cityData) {
        ...
        propagateHeadingClasses(thead, tbody, headingTexts);
        // propagateRowClasses(tbody);
        propagateRowClasses(tbody, cityData.rowTitles);
        ...
    }

The getFirstCellText() and createRowClasses() functions are now no longer used, so can be removed.

    // function getFirstCellText(rows) {
    //     return $.map(rows, (row) => $(row).find("td").first().text());
    // }
...
    // function createRowClasses(rows) {
    //     var firstCellTexts = getFirstCellText(rows);
    //     return makeSafeClassnames(firstCellTexts);
    // }

A general trend that I’ve noticed with the updates here today is that the functions help to simplify the code allowing us to more easily understand what’s going on, which lets us restructure a few things so that some of those functions are no longer needed.

Now that I’ve removed some of te buildup, I also now feel better about completing my examination of the remaining functions, from updateRatings onwards in the next post.

Here is the JavaScript code as it stands today:

/*jslint browser */
/*global jQuery __weatherwidget_init */
jQuery(document).ready(function rankWeather($) {
    var StatJSON = {
        "Option1": {
            "ColHeading": "New York",
            "Ranking": "",
            "Rating": "4.5",
            "Row1Heading": "Hello",
            "Weather": "40d71n74d01/new-york"
        },
        "Option2": {
            "ColHeading": "San Francisco",
            "Ranking": "",
            "Rating": "3.2",
            "Row1Heading": "Whats",
            "Weather": "37d77n122d42/san-francisco"
        },
        "Option3": {
            "ColHeading": "Chicago",
            "Ranking": "",
            "Rating": "3.7",
            "Row1Heading": "Up",
            "Weather": "41d88n87d63/chicago"
        },
        "Option4": {
            "ColHeading": "Los Angeles",
            "Ranking": "",
            "Rating": "5.0",
            "Row1Heading": "With",
            "Weather": "34d05n118d24/los-angeles"
        }
    };

    /* DATA FUNCTIONS */
    function getSelectedOptions(select) {
        var selection = $(select).find(":selected");
        return $.map(selection, (option) => option.value);
    }
    function getHeadings(StatJSON, selectedOptions) {
        return selectedOptions.map(function getHeading(option) {
            return StatJSON[option].ColHeading;
        });
    }
    function makeSafeClassnames(classnames) {
        var removals = ["#", /[()]/g, /\s/g, /\\|\//g];
        return classnames.map(function makeSafeClassname(text) {
            return removals.reduce(function makeSafe(text, regex) {
                return text.replace(regex, "");
            }, text);
        });
    }
    function getFirstValue(obj) {
        var keyValuePair = Object.entries(obj)[0];
        return keyValuePair[1];
    }

    /* PRESENTATION */
    function updateContainer(container, content) {
        $(container).empty().append(content);
    }
    function createTable(headRow, bodyRows) {
        var thead = `<thead>${headRow}</thead>`;
        var tbody = `<tbody>${bodyRows}</tbody>`;
        return `<table class="compTable">${thead}${tbody}</table>`;
    }
    function createHeadRow(headings) {
        var headRow = headings.map(function createHTMLHeading(heading) {
            return `<th>${heading}</th>`;
        }).join("");
        return `<tr>${headRow}</tr>`;
    }
    function getHeadingCells(thead) {
        return $(thead).find("th");
    }
    function updateHeadingClasses(headings, classNames) {
        $(headings).map(function addHeadingClass(index, header) {
            $(header).addClass(classNames[index]);
        });
    }
    function createBodyRows(rowsData) {
        var bodyRows = rowsData.map(function addRow(rowData) {
            var row = rowData.map(function addData(data) {
                return `<td>${data}</td>`;
            }).join("");
            return `<tr>${row}</tr>`;
        }).join("");
        return bodyRows;
    }
    function getRows(tbody) {
        return $(tbody).find("tr");
    }
    function addClassesToColumns(tbody, classes) {
        $(tbody).find("td").map(function addClassName(index, cell) {
            $(cell).addClass(classes[index]);
        });
    }
    function addClassesToRows(rows, classnames) {
        $.map(rows, (row, index) => $(row).addClass(classnames[index]));
    }
    function addClassesToCells(rows, classnames) {
        $.map(rows, function addRowClass(row, index) {
            $(row).find("td").addClass(classnames[index]);
        });
    }

    /* BUSINESS LOGIC */
    function appendTitles(rowTitles, rowsData) {
        return rowsData.map(function appendTitle(rowData, rowIndex) {
            return [rowTitles[rowIndex], ...rowData];
        });
    }
    function createWeatherTable(cityData) {
        var headRows = [];
        var titledRows = [["No results found"]];
        if (cityData.headings.length) {
            headRows = ["", ...cityData.headings];
            titledRows = appendTitles(cityData.rowTitles, cityData.rowsData);
        }
        return createTable(
            createHeadRow(headRows),
            createBodyRows(titledRows)
        );
    }
    function propagateHeadingClasses(thead, tbody, headingTexts) {
        var headingCells = getHeadingCells(thead);
        var headingClasses = makeSafeClassnames(headingTexts);
        updateHeadingClasses(headingCells, headingClasses);
        addClassesToColumns(tbody, headingClasses);
    }
    function propagateRowClasses(tbody, rowTitles) {
        var rows = getRows(tbody);
        var rowClasses = makeSafeClassnames(rowTitles);
        addClassesToRows(rows, rowClasses);
        addClassesToCells(rows, rowClasses);
    }
    function getCityData(StatJSON, selectedOptions) {
        var headings = getHeadings(StatJSON, selectedOptions);
        var firstCityData = getFirstValue(StatJSON);
        var rowTitles = Object.keys(firstCityData).slice(1);
        var rowsData = rowTitles.map(function addRow(rowTitle) {
            return selectedOptions.map(function getRowData(option) {
                return StatJSON[option][rowTitle];
            });
        });
        return {
            headings,
            rowTitles,
            rowsData
        };
    }

    /* Still to be processed below */

    function updateRatings(tbody) {
        function updateRating(cell) {
            var rating = parseFloat($(cell).text()).toFixed(2);
            $(cell).html(`<div><div class="rating">${rating}</div></div>`);
        }

        function updateRowRatings(ratingCell) {
            $.map($(ratingCell).nextAll("td"), updateRating);
        }
        $.map($(".Rating", tbody), updateRowRatings);
    }

    function updateRankings(tbody, ratings) {
        function numericSort(a, b) {
            return b - a;
        }
        function rankByRatings() {
            var sorted = ratings.slice().sort(numericSort);
            return ratings.map(function decideRank(ratingIndex) {
                return sorted.indexOf(ratingIndex) + 1;
            });
        }

        function updateCells(ranks, $td) {
            ranks.forEach(function updateCell(rank, i) {
                var html = $(`<div><div "ranking">#${rank}</div></div>`);
                $td.eq(i + 1).empty().append(html);
                if (rank === 1) {
                    $td.eq(i + 1).addClass("ranking1");
                }
            });
        }
        function showRankings(tbody) {
            var ranks = rankByRatings();
            var rows = $("tr", tbody);
            $.map(rows, function updateRow(tr) {
                var $td = $(tr).children();
                if ($td.eq(0).text() === "Ranking") {
                    updateCells(ranks, $td);
                }
            });
        }

        function toNumber(el) {
            var digits = $(el).text().replace(/[^0-9]/gi, "");
            return Number(digits);
        }
        function sortColumnsByRanking(tbody) {
            var rows = $("tr", tbody);
            var RowRanking = $(".Ranking", rows).find("td:not(:first)");
            var rankings = RowRanking.map(toNumber).sort(numericSort);
            rankings.each(function moveColumn(newIndex, cell) {
                var originalIndex = $(cell).index();
                if (originalIndex === newIndex) {
                    return;
                }
                $.map(rows, function swapColumns(row) {
                    var td = $(row).find("td, th");
                    td.eq(originalIndex).insertAfter(td.eq(newIndex));
                });
            });
        }

        showRankings(tbody);
        sortColumnsByRanking(tbody);
    }

    function addWeatherWidgets(tbody) {
        function showWeatherWidget(el) {
            var path = $(el).text();
            var linkHtml = `<a href="https://forecast7.com/en/${path}/"`;
            linkHtml += ` class="weatherwidget-io" data-label_2="WEATHER"`;
            linkHtml += ` data-days="3" data-theme="original">WEATHER</a>`;
            $(el).html(linkHtml);
        }
        function showWeatherWidgets(td) {
            if ($(td).text() === "Weather") {
                $.map($(td).nextAll("td"), showWeatherWidget);
            }
        }
        $.map($("tr td", tbody), showWeatherWidgets);
        __weatherwidget_init();
    }

    function updateWeatherTable(container, cityData) {
        var table = $(createWeatherTable(cityData));
        var thead = $("thead", table);
        var tbody = $("tbody", table);
        var headingTexts = ["", ...cityData.headings];
        updateContainer(container, table);
        propagateHeadingClasses(thead, tbody, headingTexts);
        propagateRowClasses(tbody, cityData.rowTitles);
        var ratings = cityData.rowsData[1];
        updateRatings(tbody);
        updateRankings(tbody, ratings);
        addWeatherWidgets(tbody);
    }

    $("#btnSubmit").click(function citiesSubmitHandler() {
        var selectedOptions = getSelectedOptions("#selection");
        var cityData = getCityData(StatJSON, selectedOptions);
        updateWeatherTable("#divResult", cityData);
    });
});

Improving the updateRatings() function

I was delaying the update of this function until after the other code above it had settled into place. I now have some good techniques to deal with things.

On reflection, the updateRatings() function doesn’t even seem to be needed. What it does is to give the ratings 2 decimal points. Instead of doing that as an updatRatings() function, we can just update the ratings in the data object instead.

We can extract a fixToDecimalPlaces() function from the updateRatings() function:

    function fixedDecimalPlaces(number, decimalPlaces) {
        return parseFloat(number).toFixed(decimalPlaces);
    }
    function updateRatings(tbody) {
        function updateRating(cell) {
            // var rating = parseFloat($(cell).text().toFixed(2));
            var rating = fixedDecimalPlaces($(cell).text(), 2);
            $(cell).html(`<div><div class="rating">${rating}</div></div>`);
        }

We can now reliably use that fixedDecimalPlaces to update the cityData object.

    function updateWeatherTable(container, cityData) {
        var ratings = cityData.rowsData[1];
        cityData.rowsData[1] = ratings.map(function updateRatings(rating) {
            return fixedDecimalPlaces(rating, 2);
        });

The table should now look identical, when the call to updateRatings() is commented out.

    function updateWeatherTable(container, cityData) {
        ...
        // updateRatings(tbody);
        updateRankings(tbody, ratings);
        addWeatherWidgets(tbody);

The table still shows the updated ratings even after the updateRatings() function is commented out, so we can remove the whole thing now.

    // function updateRatings(tbody) {
    //     function updateRating(cell) {
    //         var rating = fixedDecimalPlaces($(cell).text(), 2);
    //         $(cell).html(`<div><div class="rating">${rating}</div></div>`);
    //     }

    //     function updateRowRatings(ratingCell) {
    //         $.map($(ratingCell).nextAll("td"), updateRating);
    //     }
    //     $.map($(".Rating", tbody), updateRowRatings);
    // }

Removing the need for sortColumnsByRank()

Currently the sortColumnsByRank() function is sorting the table. I recommend that we sort the information first before building the table. That way we don’t need to mess around with the table structure afterwards.

Here’s the updateRankings function before we do much with it. It’s a complicated beast.

    function updateRankings(tbody, ratings) {
        function numericSort(a, b) {
            return b - a;
        }
        function rankByRatings() {
            var sorted = ratings.slice().sort(numericSort);
            return ratings.map(function decideRank(ratingIndex) {
                return sorted.indexOf(ratingIndex) + 1;
            });
        }

        function updateCells(ranks, $td) {
            ranks.forEach(function updateCell(rank, i) {
                var html = $(`<div><div "ranking">#${rank}</div></div>`);
                $td.eq(i + 1).empty().append(html);
                if (rank === 1) {
                    $td.eq(i + 1).addClass("ranking1");
                }
            });
        }
        function showRankings(tbody) {
            var ranks = rankByRatings();
            var rows = $("tr", tbody);
            $.map(rows, function updateRow(tr) {
                var $td = $(tr).children();
                if ($td.eq(0).text() === "Ranking") {
                    updateCells(ranks, $td);
                }
            });
        }

        function toNumber(el) {
            var digits = $(el).text().replace(/[^0-9]/gi, "");
            return Number(digits);
        }
        function sortColumnsByRanking(tbody) {
            var rows = $(tbody).find("tr");
            var rankingRow = $(tbody).find("tr.Ranking");
            var RowRankings = $(rankingRow).find("td:not(:first)");
            var rankings = RowRankings.map(toNumber).sort(numericSort);
            rankings.each(function moveColumn(newIndex, cell) {
                var originalIndex = $(cell).index();
                if (originalIndex === newIndex) {
                    return;
                }
                $.map(rows, function swapColumns(row) {
                    var td = $(row).find("td, th");
                    td.eq(originalIndex).insertAfter(td.eq(newIndex));
                });
            });
        }

        showRankings(tbody);
        sortColumnsByRanking(tbody);
    }

My first task is to extract the existing functions out of the updateRankings function. This can be tricky because some of the functions rely on information that is not passed into the function.

We can start by updating the showRankings() function so that the rankings are passed into it. We can then easily pass ratings into the rankByRatings() function too.

    function updateRankings(tbody, ratings) {
        ...
        // function rankByRatings() {
        function rankByRatings(ratings) {
            ...
        }
        ...
        // function showRankings(tbody) {
        function showRankings(tbody, ranks) {
            // var ranks = rankByRatings();
            ...
        }
        ...
        var ranks = rankByRatings(ratings);
        // showRankings(tbody);
        showRankings(tbody, ranks);
        sortColumnsByRanking(tbody);
    }

The numericSort() and rankByRatings() functions can now be extracted from the updateRankings() function.

    function numericSort(a, b) {
        return b - a;
    }
    function rankByRatings(ratings) {
        ...
    }
    function updateRankings(tbody, ratings) {
        // function numericSort(a, b) {
        //     return b - a;
        // }
        // function rankByRatings(ratings) {
        ...
        // }

Looking at the cityData.rowsData array, it seems to have a place for us to put the ranking information.

0: (2) ['', '']
1: (2) ['4.50', '5.00']
2: (2) ['Hello', 'With']
3: (2) ['40d71n74d01/new-york', '34d05n118d24/los-angeles']

Can we update the rowsData with the rankings? I will temporarily stop updateRankings() from running for this experiment.

    function showWeatherTable(container, cityData) {
        ...
        propagateHeadingClasses(thead, tbody, cityData.headings);
        propagateRowClasses(tbody, rowTitles);
        // updateRankings(tbody, ratings);
        addWeatherWidgets(tbody);
    }
...
    function updateRowsData(rowsData) {
        var ratings = rowsData[1];
        var updatedRowsData = [...rowsData];
        updatedRowsData[0] = rankByRatings(ratings);
        updatedRowsData[1] = ratings.map(function updateRatings(rating) {
            return fixedDecimalPlaces(rating, 2);
        });
        return updatedRowsData;
    }
    function updateWeatherTable(container, cityData) {
        cityData.rowsData = updateRowsData(cityData.rowsData);
        showWeatherTable(container, cityData);
    }

The table now shows the correct rankings, and they appear on the table too.

0	2	1
1	'4.50'	'5.00'
2	'Hello'	'With'
3	'40d71n74d01/new-york'	'34d05n118d24/los-angeles'

Sort the rowsData array

All we need to do now is to sort those arrays by the order of the rankings, which means taking a copy of the rankings (we don’t want them changing in the middle of sorting), and using that to determine in which order the array items are sorted.

    function sortRowsData(rowsData) {
        var rankings = [...rowsData[0]];
        return rowsData.map(function sortRow(rowData, rowIndex) {
            return rowData.sort(function sortRowData(a, b) {
                var aIndex = rowsData[rowIndex].indexOf(a);
                var bIndex = rowsData[rowIndex].indexOf(b);
                return rankings[aIndex] - rankings[bIndex];
            });
        });
    }
    function updateRowsData(rowsData) {
        ...
        return sortRowsData(updatedRowsData);
    }

The old updateRankings() function can now be removed.

    function showWeatherTable(container, cityData) {
        ...
        // updateRankings(tbody, cityData.rowsData[1]);
        ...
    }

The last part of the code to examine is the weather widgets and code related to the rows data, which I’ll take care of in the next post.

Now that the updateRatings() and updateRanking() functions have been replaced by updating the data arrays instead, I can focus on other things.

Renaming rankByRatings()

    function rankByRatings(ratings) {
        var sorted = ratings.slice().sort(function numericSort(a, b) {
            return b - a;
        });
        return ratings.map(function decideRank(ratingIndex) {
            return sorted.indexOf(ratingIndex) + 1;
        });
    }

The rankByRatings() function is used for deciding the ranking based on given ratings, but it doesn’t need to be that exclusive. That same function works when given any kind of array so can just be called createRankings() instead, and have terminology relating to ratings removed from it.

    // function rankByRatings(ratings) {
    function createRankings(items) {
        // var sorted = ratings.slice().sort(function numericSort(a, b) {
        var sorted = items.slice().sort(function numericSort(a, b) {
            return b - a;
        });
        // return ratings.map(function decideRank(ratingIndex) {
        return items.map(function createRank(index) {
            // return sorted.indexOf(ratingIndex) + 1;
            return sorted.indexOf(index) + 1;
        });
    }

Improve createWeatherTable() function

In this function, the titles are being added to the start of the rows, but before that a more explicit form of that is used to add an empty section at the start of the heading row.

    function createWeatherTable(cityData) {
        ...
        if (cityData.headings.length) {
            headRow = ["", ...cityData.headings];
            titledRows = appendTitles(cityData.rowTitles, cityData.rowsData);
        }
        ...
    }

The same technique should be used to add the left column to both the headings and the rows, so that the same level of abstraction is maintained.

The appendTitles function can be renamed to something less specific about titles, so that it’s appropriate to use it both for the heading row and the body rows. This is when I notice that append isn’t the right term, it’s actually prepending instead.

The appendTitles function can have its rowTitles renamed to just be rows, and because there are a lot of row terms in there, rowIndex can be renamed to just index.

    // function appendTitles(rowTitles, rowsData) {
    function prependCells(cells, rows) {
        // return rowsData.map(function appendTitle(rowData, rowIndex) {
        return rows.map(function prependCell(row, index) {
            // return [rowTitles[rowIndex], ...rowData];
            return [cells[index], ...row];
        });
    }

We can also extract from there the prependCell() function so that we can use it on the header.

    function prependCells(cells, rows) {
        function prependCell(row, index) {
            return [cells[index], ...row];
        }
        return rows.map(prependCell);
    }

That prependCell function can’t be extracted any further yet, because it refers to cells that aren’t passed as a function parameter.

To help deal with that, we can split the function into two parts, one to prepare the parts to combine, and another to combine those parts.

    function prependCell(cell, row) {
        return [cell, ...row];
    }
    function prependCells(cells, rows) {
        var parts = cells.map(function prepareParts(cellToPrepend, index) {
            return [cellToPrepend, rows[index]];
        });
        return parts.map(function combineParts([cell, row]) {
            return prependCell(cell, row);
        });
    }

That prependCell function can now be reused with the headings, and we now have a similar level of abstraction in that function with the headRow and the titledRows.

    function createWeatherTable(cityData) {
        ...
        if (cityData.headings.length) {
            // headRow = ["", ...cityData.headings];
            headRow = prependCell("", cityData.headings);
            titledRows = prependCells(cityData.rowTitles, cityData.rowsData);
        }
        ...
    }

Checking the consistency of how table info is updated

The table consists of Headings, Ranking, Rating, Row1Heading, and Weather. Here are the functions being used to update them, and the location where that happens.

Row name   |  Updater    |   Location
========      =======        ========
Heading       prependCell    createWeatherTable
Ranking       createRankings updateRowsData
Rating        updateRatings  updateRowsData
Row1Heading   none           none
Weather       ...            ...

Move prepend functions up and out

That heading update seems like it should be done in an updateHeadingData function instead. The body rows should also be done in a similar place, so let’s move the prependCell up and out of the createWeatherTable function. To prepare for that, we need to separate apart the changing of the values from the retrieval of the values.

    function createWeatherTable(cityData) {
        var headRow = [];
        // var titledRows = [["No results found"]];
        var bodyRows = [["No results found"]];
        if (cityData.headings.length) {
            // headRow = prependCell("", cityData.headings);
            cityData.headings = prependCell("", cityData.headings);
            headRow = cityData.headings;
            // titledRows = prependCells(cityData.rowTitles, cityData.rowsData);
            cityData.rowsData = prependCells(cityData.rowTitles, cityData.rowsData);
            bodyRows = cityData.rowsData;
        }
        return createTable(
            createHeadRow(headRow),
            // createBodyRows(titledRows)
            createBodyRows(bodyRows)
        );

    }

We can now pull those prependCell() and prependCells() function calls up and out of the createWeatherTable function:

    function createWeatherTable(cityData) {
        var headRow = [];
        var bodyRows = [["No results found"]];
        if (cityData.headings.length) {
            // cityData.headings = prependCell("", cityData.headings);
            headRow = cityData.headings;
            // cityData.rowsData = prependCells(cityData.rowTitles, cityData.rowsData);
            bodyRows = cityData.rowsData;
        }
        ...
    }
    function showWeatherTable(container, cityData) {
        cityData.headings = prependCell("", cityData.headings);
        cityData.rowsData = prependCells(cityData.rowTitles, cityData.rowsData);
        var rowTitles = cityData.rowTitles;
        ...
        // cityData.headings = prependCell("", cityData.headings);
        ...
    }

That was nice that we could remove that other cityData.headings statement. We can now move the two prepend statements further up and out of the showWeatherTable function.

    function showWeatherTable(container, cityData) {
        // cityData.headings = prependCell("", cityData.headings);
        // cityData.rowsData = prependCells(cityData.rowTitles, cityData.rowsData);
        ...
    }
    function updateWeatherTable(container, cityData) {
        cityData.rowsData = updateRowsData(cityData.rowsData);
        cityData.headings = prependCell("", cityData.headings);
        cityData.rowsData = prependCells(cityData.rowTitles, cityData.rowsData);
        showWeatherTable(container, cityData);
    }

Add an addTitleRow() function

The plan to use a separate updateHeadings() function has now changed. It’s better to keep those prepend function calls together, so we’ll put them into an addTitleRow() function instead.

    function addTitleColumn(cityData) {
        cityData.headings = prependCell("", cityData.headings);
        cityData.rowsData = prependCells(cityData.rowTitles, cityData.rowsData);
        return cityData;
    }
....
    function updateWeatherTable(container, cityData) {
        cityData.rowsData = updateRowsData(cityData.rowsData);
        // cityData.headings = prependCell("", cityData.headings);
        // cityData.rowsData = prependCells(cityData.rowTitles, cityData.rowsData);
        cityData = addTitleColumn(cityData);
        showWeatherTable(container, cityData);
    }

Update weatherWidgets in updateRowsData()

It’s now a good time to adjust the updateRowsData() function so that it also updates the weather widgets.

    function updateRowsData(rowsData) {
        ...
        updatedRowsData[3] = rowsData[3].map(createWeatherWidget);
        return sortRowsData(updatedRowsData);
    }

The weather widgets now appear on the table, and we can completely delete the addWeatherWidgets() function.

Here’s the updated code as it is right now:

/*jslint browser */
/*global jQuery __weatherwidget_init */
jQuery(document).ready(function rankWeather($) {
    var StatJSON = {
        "Option1": {
            "ColHeading": "New York",
            "Ranking": "",
            "Rating": "4.5",
            "Row1Heading": "Hello",
            "Weather": "40d71n74d01/new-york"
        },
        "Option2": {
            "ColHeading": "San Francisco",
            "Ranking": "",
            "Rating": "3.2",
            "Row1Heading": "Whats",
            "Weather": "37d77n122d42/san-francisco"
        },
        "Option3": {
            "ColHeading": "Chicago",
            "Ranking": "",
            "Rating": "3.7",
            "Row1Heading": "Up",
            "Weather": "41d88n87d63/chicago"
        },
        "Option4": {
            "ColHeading": "Los Angeles",
            "Ranking": "",
            "Rating": "5.0",
            "Row1Heading": "With",
            "Weather": "34d05n118d24/los-angeles"
        }
    };

    /* DATA FUNCTIONS */
    function getSelectedOptions(select) {
        var selection = $(select).find(":selected");
        return $.map(selection, (option) => option.value);
    }
    function getHeadings(StatJSON, selectedOptions) {
        return selectedOptions.map(function getHeading(option) {
            return StatJSON[option].ColHeading;
        });
    }
    function makeSafeClassnames(classnames) {
        var removals = ["#", /[()]/g, /\s/g, /\\|\//g];
        return classnames.map(function makeSafeClassname(text) {
            return removals.reduce(function makeSafe(text, regex) {
                return text.replace(regex, "");
            }, text);
        });
    }
    function getFirstValue(obj) {
        var keyValuePair = Object.entries(obj)[0];
        return keyValuePair[1];
    }

    /* PRESENTATION */
    function fixedDecimalPlaces(number, decimalPlaces) {
        return parseFloat(number).toFixed(decimalPlaces);
    }
    function updateContainer(container, content) {
        $(container).empty().append(content);
    }
    function createTable(headRow, bodyRows) {
        var thead = `<thead>${headRow}</thead>`;
        var tbody = `<tbody>${bodyRows}</tbody>`;
        return `<table class="compTable">${thead}${tbody}</table>`;
    }
    function createHeadRow(headings) {
        var headRow = headings.map(function createHTMLHeading(heading) {
            return `<th>${heading}</th>`;
        }).join("");
        return `<tr>${headRow}</tr>`;
    }
    function getHeadingCells(thead) {
        return $(thead).find("th");
    }
    function updateHeadingClasses(headings, classNames) {
        $(headings).map(function addHeadingClass(index, header) {
            $(header).addClass(classNames[index]);
        });
    }
    function createBodyRows(rowsData) {
        var bodyRows = rowsData.map(function addRow(rowData) {
            var row = rowData.map(function addData(data) {
                return `<td>${data}</td>`;
            }).join("");
            return `<tr>${row}</tr>`;
        }).join("");
        return bodyRows;
    }
    function getRows(tbody) {
        return $(tbody).find("tr");
    }
    function addClassesToColumns(tbody, classes) {
        $(tbody).find("td").map(function addClassName(index, cell) {
            $(cell).addClass(classes[index]);
        });
    }
    function addClassesToRows(rows, classnames) {
        $.map(rows, (row, index) => $(row).addClass(classnames[index]));
    }
    function addClassesToCells(rows, classnames) {
        $.map(rows, function addRowClass(row, index) {
            $(row).find("td").addClass(classnames[index]);
        });
    }

    /* BUSINESS LOGIC */
    function prependCell(cell, row) {
        return [cell, ...row];
    }
    function prependCells(cells, rows) {
        var parts = cells.map(function prepareParts(cellToPrepend, index) {
            return [cellToPrepend, rows[index]];
        });
        return parts.map(function combineParts([cell, row]) {
            return prependCell(cell, row);
        });
    }
    function createRankings(items) {
        var sorted = items.slice().sort(function numericSort(a, b) {
            return b - a;
        });
        var rankings = items.map(function createRank(index) {
            return sorted.indexOf(index) + 1;
        });
        return rankings;
    }
    function createWeatherWidget(path) {
        var linkHtml = `<a href="https://forecast7.com/en/${path}/"`;
        linkHtml += ` class="weatherwidget-io" data-label_2="WEATHER"`;
        linkHtml += ` data-days="3" data-theme="original">WEATHER</a>`;
        return linkHtml;
    }
    function sortRowsData(rowsData) {
        var rankings = [...rowsData[0]];
        return rowsData.map(function sortRow(rowData, rowIndex) {
            return rowData.sort(function sortRowData(a, b) {
                var aIndex = rowsData[rowIndex].indexOf(a);
                var bIndex = rowsData[rowIndex].indexOf(b);
                return rankings[aIndex] - rankings[bIndex];
            });
        });
    }
    function addTitleColumn(cityData) {
        cityData.headings = prependCell("", cityData.headings);
        cityData.rowsData = prependCells(cityData.rowTitles, cityData.rowsData);
        return cityData;
    }
    function createWeatherTable(cityData) {
        var headRow = [];
        var bodyRows = [["No results found"]];
        if (cityData.headings.length) {
            headRow = cityData.headings;
            bodyRows = cityData.rowsData;
        }
        return createTable(
            createHeadRow(headRow),
            createBodyRows(bodyRows)
        );
    }
    function propagateHeadingClasses(thead, tbody, headingTexts) {
        var headingCells = getHeadingCells(thead);
        var headingClasses = makeSafeClassnames(headingTexts);
        updateHeadingClasses(headingCells, headingClasses);
        addClassesToColumns(tbody, headingClasses);
    }
    function propagateRowClasses(tbody, rowTitles) {
        var rows = getRows(tbody);
        var rowClasses = makeSafeClassnames(rowTitles);
        addClassesToRows(rows, rowClasses);
        addClassesToCells(rows, rowClasses);
    }
    function getCityData(StatJSON, selectedOptions) {
        var headings = getHeadings(StatJSON, selectedOptions);
        var firstCityData = getFirstValue(StatJSON);
        var rowTitles = Object.keys(firstCityData).slice(1);
        var rowsData = rowTitles.map(function addRow(rowTitle) {
            return selectedOptions.map(function getRowData(option) {
                return StatJSON[option][rowTitle];
            });
        });
        return {
            headings,
            rowTitles,
            rowsData
        };
    }

    /* Still to be processed below */

    function updateRowsData(rowsData) {
        var ratings = rowsData[1];
        var updatedRowsData = [...rowsData];
        updatedRowsData[0] = createRankings(ratings);
        updatedRowsData[1] = ratings.map(function updateRatings(rating) {
            return fixedDecimalPlaces(rating, 2);
        });
        updatedRowsData[3] = rowsData[3].map(createWeatherWidget);
        return sortRowsData(updatedRowsData);
    }
    function showWeatherTable(container, cityData) {
        var rowTitles = cityData.rowTitles;
        var table = $(createWeatherTable(cityData));
        var thead = $("thead", table);
        var tbody = $("tbody", table);
        updateContainer(container, table);
        propagateHeadingClasses(thead, tbody, cityData.headings);
        propagateRowClasses(tbody, rowTitles);
    }
    function updateWeatherTable(container, cityData) {
        cityData.rowsData = updateRowsData(cityData.rowsData);
        cityData = addTitleColumn(cityData);
        showWeatherTable(container, cityData);
    }

    $("#btnSubmit").click(function citiesSubmitHandler() {
        var selectedOptions = getSelectedOptions("#selection");
        var cityData = getCityData(StatJSON, selectedOptions);
        updateWeatherTable("#divResult", cityData);
    });
});

That’s some significant improvement there today. In my next post I’ll carry on with the improvements, starting from the updateRowsData() function.

Improve the sortRowsData() function

The next function to check is the sortRowsData function:

    function sortRows(rowsData) {
        var rankings = [...rowsData[0]];
        return rowsData.map(function sortRow(rowData, rowIndex) {
            return rowData.sort(function sortRowData(a, b) {
                var aIndex = rowsData[rowIndex].indexOf(a);
                var bIndex = rowsData[rowIndex].indexOf(b);
                return rankings[aIndex] - rankings[bIndex];
            });
        });
    }

Even though all of the information it needs is in the rowsData function, it makes better sense for the function to have rankings passed into it. That way the function parameters more directly tell us not just what gets sorted, but also what determines the sort order.

    function sortRows(rowsData, rankings) {
        // var rankings = [...rowsData[0]];
        return rowsData.map(function sortRow(rowData, rowIndex) {
            return rowData.sort(function sortRowData(a, b) {
                var aIndex = rowsData[rowIndex].indexOf(a);
                var bIndex = rowsData[rowIndex].indexOf(b);
                return rankings[aIndex] - rankings[bIndex];
            });
        });
    }
...
    function updateRows(rowsData) {
        var ratings = rowsData[1];
        var updatedRows = [...rowsData];
        updatedRows[0] = createRankings(ratings);
        updatedRows[1] = ratings.map(function updateRatings(rating) {
            return fixedDecimalPlaces(rating, 2);
        });
        updatedRows[3] = rowsData[3].map(createWeatherWidget);
        var rankings = [...rowsData[0]];
        // return sortRows(updatedRowsData);
        return sortRows(updatedRowsData, rankings);
    }

That updateRows() function could do with some TLC too. which I can take care of now.

Improving the updateRows() function

A common theme here is that to tidy up a problem, that problem needs to gets moved elsewhere. Hopefully though it’s not moved to be under the rug. Fortunately we have good ways to deal with such problems.

In the updateRows() function I want to make it quite clear what each of the indexed locations refers to, so I’ll give them separate names.

After a significant amount of separating new values from their assignments, we have a newly updated updateRows() function.

    function updateRows([rankings, ratings, row1Headings, weatherWidgets]) {
        var updatedRows = [
            createRankings(ratings),
            updateRatings(ratings),
            row1Headings,
            weatherWidgets.map(createWeatherWidget)
        ];
        return sortRowsData(updatedRows, rankings);
    }

It’s now much easier to see that all of the changes to the rows are happening in the one place, and that they are being sorted afterwards.

Restoring the ranking presentation

I do want the rankings to go back to being #1 instead of just 1. We can do that by updating the createRankings function.

    function createRankings(items) {
        var sorted = items.slice().sort(function numericSort(a, b) {
            return b - a;
        });
        var rankings = items.map(function createRank(index) {
            // return sorted.indexOf(index) + 1;
            return `#${sorted.indexOf(index) + 1}`;
        });
        return rankings;
    }

That does cause troubles with the sorting, so we need to ensure that the sorting only pays attention to the digits.

Fortunately we have a numericSort that we can press into action, by updating numericSort to only care about numbers, and to use rankingSort which reverse the things (1 is better than 5 for example).

    function numericSort(a, b) {
        var numberRegex = /[\d.]+/;
        a = numberRegex.exec(a)[0];
        b = numberRegex.exec(b)[0];
        return Number(b) - Number(a);
    }
    function rankingSort(a, b) {
        return -numericSort(a, b);
    }
...
    function sortRows(rowsData, sortOrder) {
        ...
                return rankingSort(sortOrder[aIndex], sortOrder[bIndex]);
        ...
    }

Conclusion

Here is the code that we’re left with:

jQuery(document).ready(function rankWeather($) {
    var StatJSON = {
        "Option1": {
            "ColHeading": "New York",
            "Ranking": "",
            "Rating": "4.5",
            "Row1Heading": "Hello",
            "Weather": "40d71n74d01/new-york"
        },
        "Option2": {
            "ColHeading": "San Francisco",
            "Ranking": "",
            "Rating": "3.2",
            "Row1Heading": "Whats",
            "Weather": "37d77n122d42/san-francisco"
        },
        "Option3": {
            "ColHeading": "Chicago",
            "Ranking": "",
            "Rating": "3.7",
            "Row1Heading": "Up",
            "Weather": "41d88n87d63/chicago"
        },
        "Option4": {
            "ColHeading": "Los Angeles",
            "Ranking": "",
            "Rating": "5.0",
            "Row1Heading": "With",
            "Weather": "34d05n118d24/los-angeles"
        }
    };

    /* DATA FUNCTIONS */
    function getSelectedOptions(select) {
        var selection = $(select).find(":selected");
        return $.map(selection, (option) => option.value);
    }
    function getHeadings(StatJSON, selectedOptions) {
        return selectedOptions.map(function getHeading(option) {
            return StatJSON[option].ColHeading;
        });
    }
    function makeSafeClassnames(classnames) {
        var removals = ["#", /[()]/g, /\s/g, /\\|\//g];
        return classnames.map(function makeSafeClassname(text) {
            return removals.reduce(function makeSafe(text, regex) {
                return text.replace(regex, "");
            }, text);
        });
    }
    function getFirstValue(obj) {
        var keyValuePair = Object.entries(obj)[0];
        return keyValuePair[1];
    }

    /* PRESENTATION */
    function updateContainer(container, table) {
        $(container).empty().append(table);
    }
    function createTable(headRow, bodyRows) {
        var thead = `<thead>${headRow}</thead>`;
        var tbody = `<tbody>${bodyRows}</tbody>`;
        return `<table class="compTable">${thead}${tbody}</table>`;
    }
    function createHeadRow(headings) {
        var headRow = headings.map(function createHTMLHeading(heading) {
            return `<th>${heading}</th>`;
        }).join("");
        return `<tr>${headRow}</tr>`;
    }
    function getHeadingCells(thead) {
        return $(thead).find("th");
    }
    function updateHeadingClasses(headings, classNames) {
        $(headings).map(function addHeadingClass(index, header) {
            $(header).addClass(classNames[index]);
        });
    }
    function createBodyRows(rowsData) {
        var bodyRows = rowsData.map(function addRow(rowData) {
            var row = rowData.map(function addData(data) {
                return `<td>${data}</td>`;
            }).join("");
            return `<tr>${row}</tr>`;
        }).join("");
        return bodyRows;
    }
    function getRows(tbody) {
        return $(tbody).find("tr");
    }
    function addClassesToColumns(tbody, classes) {
        $(tbody).find("td").map(function addClassName(index, cell) {
            $(cell).addClass(classes[index]);
        });
    }
    function addClassesToRows(rows, classnames) {
        $.map(rows, (row, index) => $(row).addClass(classnames[index]));
    }
    function addClassesToCells(rows, classnames) {
        $.map(rows, function addRowClass(row, index) {
            $(row).find("td").addClass(classnames[index]);
        });
    }

    /* BUSINESS LOGIC */
    function fixedDecimalPlaces(number, decimalPlaces) {
        return parseFloat(number).toFixed(decimalPlaces);
    }
    function getCityData(StatJSON, selectedOptions) {
        var headings = getHeadings(StatJSON, selectedOptions);
        var firstCityData = getFirstValue(StatJSON);
        var rowTitles = Object.keys(firstCityData).slice(1);
        var rowsData = rowTitles.map(function addRow(rowTitle) {
            return selectedOptions.map(function getRowData(option) {
                return StatJSON[option][rowTitle];
            });
        });
        return {
            headings,
            rowTitles,
            rowsData
        };
    }
    function prependCell(cell, row) {
        return [cell, ...row];
    }
    function prependCells(cells, rows) {
        var parts = cells.map(function prepareParts(cellToPrepend, index) {
            return [cellToPrepend, rows[index]];
        });
        return parts.map(function combineParts([cell, row]) {
            return prependCell(cell, row);
        });
    }
    function numericSort(a, b) {
        var numberRegex = /[\d.]+/;
        a = numberRegex.exec(a)[0];
        b = numberRegex.exec(b)[0];
        return Number(b) - Number(a);
    }
    function rankingSort(a, b) {
        return -numericSort(a, b);
    }
    function createRankings(items) {
        var sorted = items.slice().sort(numericSort);
        var rankings = items.map(function createRank(index) {
            return `#${sorted.indexOf(index) + 1}`;
        });
        return rankings;
    }
    function updateRatings(ratings) {
        return ratings.map(function updateRating(rating) {
            return fixedDecimalPlaces(rating, 2);
        });
    }
    function createWeatherWidget(path) {
        var linkHtml = `<a href="https://forecast7.com/en/${path}/"`;
        linkHtml += ` class="weatherwidget-io" data-label_2="WEATHER"`;
        linkHtml += ` data-days="3" data-theme="original">WEATHER</a>`;
        return linkHtml;
    }
    function addTitleColumn(cityData) {
        cityData.headings = prependCell("", cityData.headings);
        cityData.rowsData = prependCells(cityData.rowTitles, cityData.rowsData);
        return cityData;
    }
    function sortRows(rowsData, sortOrder) {
        return rowsData.map(function sortRow(rowData, rowIndex) {
            return rowData.sort(function sortRowData(a, b) {
                var aIndex = rowsData[rowIndex].indexOf(a);
                var bIndex = rowsData[rowIndex].indexOf(b);
                return rankingSort(sortOrder[aIndex], sortOrder[bIndex]);
            });
        });
    }
    function createWeatherTable(cityData) {
        var headRow = [];
        var bodyRows = [["No results found"]];
        if (cityData.headings.length) {
            headRow = cityData.headings;
            bodyRows = cityData.rowsData;
        }
        return createTable(
            createHeadRow(headRow),
            createBodyRows(bodyRows)
        );
    }
    function propagateHeadingClasses(thead, tbody, headingTexts) {
        var headingCells = getHeadingCells(thead);
        var headingClasses = makeSafeClassnames(headingTexts);
        updateHeadingClasses(headingCells, headingClasses);
        addClassesToColumns(tbody, headingClasses);
    }
    function propagateRowClasses(tbody, rowTitles) {
        var rows = getRows(tbody);
        var rowClasses = makeSafeClassnames(rowTitles);
        addClassesToRows(rows, rowClasses);
        addClassesToCells(rows, rowClasses);
    }
    function addClassesToTable(table, cityData) {
        var thead = $(table).find("thead");
        var tbody = $(table).find("tbody");
        var rowTitles = cityData.rowTitles;
        propagateHeadingClasses(thead, tbody, cityData.headings);
        propagateRowClasses(tbody, rowTitles);
    }

    function updateRows([rankings, ratings, row1Headings, weatherWidgets]) {
        rankings = createRankings(ratings);
        ratings = updateRatings(ratings);
        weatherWidgets = weatherWidgets.map(createWeatherWidget);
        var updatedRows = [
            rankings,
            ratings,
            row1Headings,
            weatherWidgets
        ];
        var sortOrder = [...rankings];
        return sortRows(updatedRows, sortOrder);
    }
    function showWeatherTable(container, cityData) {
        var table = $(createWeatherTable(cityData));
        updateContainer(container, table);
        addClassesToTable(table, cityData);
    }
    function updateWeatherTable(container, cityData) {
        cityData.rowsData = updateRows(cityData.rowsData, cityData.rowsData);
        cityData = addTitleColumn(cityData);
        showWeatherTable(container, cityData);
    }

    $("#btnSubmit").click(function citiesSubmitHandler() {
        var selectedOptions = getSelectedOptions("#selection");
        var cityData = getCityData(StatJSON, selectedOptions);
        updateWeatherTable("#divResult", cityData);
    });
});

That is my initial look at all of the functions now complete. Now that those functions have been tidied up, I do want to explore grouping them together in a possibly more suitable manner.

The separation of data / presentation / business logic has been handy to help simplify the working of the functions. We can now group those functions to help us make better sense of things.

Group together HTML table functions

To start with, I’ll group together all of the functions that are involved with HTML tables.

    function createTable(headRow, bodyRows, classname) {
        var thead = `<thead>${headRow}</thead>`;
        var tbody = `<tbody>${bodyRows}</tbody>`;
        var classattr = (
            (classname > "")
            ? ` class="compTable"`
            : ""
        );
        return `<table${classattr}>${thead}${tbody}</table>`;
    }
    function createHeadRow(headings) {
        var headRow = headings.map(function createHTMLHeading(heading) {
            return `<th>${heading}</th>`;
        }).join("");
        return `<tr>${headRow}</tr>`;
    }
    function createBodyRows(rowsData) {
        var bodyRows = rowsData.map(function addRow(rowData) {
            var row = rowData.map(function addData(data) {
                return `<td>${data}</td>`;
            }).join("");
            return `<tr>${row}</tr>`;
        }).join("");
        return bodyRows;
    }
    function getHeadingCells(thead) {
        return $(thead).find("th");
    }
    function getRows(tbody) {
        return $(tbody).find("tr");
    }

Create a module

We can turn those HTML tables into a module, by wrapping it with an IIFE (immeidately invoked function expression).

    var tables = (function htmlTablesModule() {
        function createHeadRow(headings) {
            ...
        }
        function createBodyRows(rowsData) {
            ...
        }
        function createTable(headRow, bodyRows, classname) {
            ...
        }
        function getHeadingCells(thead) {
            ...
        }
        function getRows(tbody) {
            ...
        }
    }());

We can now supply access to those functions by returning an object from the tables object.

    var htmlTable = (function htmlTablesModule() {
        ...
        return {
            create: createTable
            createHead: createHeadRow,
            createBody: createBodyRows,
            getHeadings: getHeadingCells,
            getRows: getRows
        }
    }());
...
        // return createTable(
        return htmlTable.create(
            // createHeadRow(headRow),
            htmlTable.createHead(headRow),
            // createBodyRows(bodyRows)
            htmlTable.createBody(bodyRows)
        );
...
        // var headingCells = getHeadingCells(thead);
        var headingCells = htmlTable.getHeadings(thead);
...
        // var rows = getRows(tbody);
        var rows = htmlTable.getRows(tbody);

Reduce module interactions

I don’t want to have to call tables.createHead and tabes.createBody. Instead I’ll pass the headRow and the bodyRows into the create table function.

        function createTable(headRow, bodyRows, classname) {
            // var thead = `<thead>${createHeadRow(headRow)}</thead>`;
            var thead = `<thead>${headRow}</thead>`;
            // var tbody = `<tbody>${createBodyRows(bodyRows)}</tbody>`;
            var tbody = `<tbody>${bodyRows}</tbody>`;
        }
...
        return {
            create: createTable,
            // createHead: createHeadRow,
            // createBody: createBodyRows,
            getHeadings: getHeadingCells,
            getRows: getRows
        };
...
        // return tables.create(
        //     htmlTable.createHead(headRow),
        //     htmlTable.createBody(bodyRows),
        //     "compTable"
        // );
        return htmlTable.create(headRow, bodyRows, "compTable");

Improving the addClassesToColumns() function

There is other code that interacts directly with the tables too. The addClassesToColumns function is one of them.

    function addClassesToColumns(tbody, classes) {
        $(tbody).find("td").map(function addClassName(index, cell) {
            $(cell).addClass(classes[index]);
        });
    }

That addClassesToColumns() function should instead have two function parameters of classes and columns. So, we’re going to want a getColumns() function.

        function getColumns(tbody) {
            var rows = $(tbody).find("tr");
            var rowCells = $(rows[0]).find("td");
            return $(rowCells).map(function getColumn(index) {
                return [$.map(rows, function getCell(tr) {
                    return $(tr).find("td").eq(index);
                })];
            });
        }
...
        return {
            ...
            getColumns,
            getRows
        };
...
    function addClassesToColumns(tbody, classes) {
        var columns = htmlTable.getColumns(tbody);
        $.map(columns, function addClass(column, colIndex) {
            $.map($(column), function addClass(cell) {
                $(cell).addClass(classes[colIndex]);
            });
        });
    }

We can now pass columns into the addClassesToColumns() function instead of tbody.

    function propagateHeadingClasses(thead, tbody, headingTexts) {
        ...
        var columns = htmlTable.getColumns(tbody);
        addClassesToColumns(headingClasses, columns);
    }

Improve the addClassesToCells() function

This is the other function that explicitly accesses td cells.

    function addClassesToCells(rows, classnames) {
        $.map(rows, function addRowClass(row, index) {
            $(row).find("td").addClass(classnames[index]);
        });
    }

We can move some of that out to a getCells function instead.

        function getCells(row) {
            return $(row).find("td");
        }
        return {
            ...
            getRows,
            getCells
        };
...
    function addClassesToCells(rows, classnames) {
        $.map(rows, function addRowClass(row, index) {
            // $(row).find("td").addClass(classnames[index]);
            htmlTable.getCells(row).addClass(classnames[index]);
        });
    }

Remove explicit from addClassesToTable() function

The last place where HTML-specific table references are used in the code is in the addClassesToTable() function.

     function addClassesToTable(table, cityData) {
        var thead = $(table).find("thead");
        var tbody = $(table).find("tbody");
        var rowTitles = cityData.rowTitles;
        propagateHeadingClasses(thead, tbody, cityData.headings);
        propagateRowClasses(tbody, rowTitles);
    }

The propagateHeadingClasses function uses thead to get the heading cells. If we just passed the table to the htmlTable module, that would make life a lot easier for us. That means changing the htmlTable functions so that the functions prefer to use table instead of thead or tbody. Let’s do that with the getheadingCells() function.

        function getHead(table) {
            var thead = $(table).find("thead");
        }
        // function getHeadingCells(thead) {
        function getHeadingCells(table) {
            return $(getHead(table)).find("th");
        }
...
    // function propagateHeadingClasses(thead, tbody, headingTexts) {
    function propagateHeadingClasses(table, tbody, headingTexts) {
        // var headingCells = htmlTable.getHeadings(thead);
        var headingCells = htmlTable.getHeadings(table);
...
        // propagateHeadingClasses(thead, tbody, cityData.headings);
        propagateHeadingClasses(table, tbody, cityData.headings);

That was quite successful. The other function in the propagateHeadingClasses() function that uses tbody is htmlTable.getColumns(), so let’s have that one use table too.

        function getBody(table) {
            return $(table).find("tbody");
        }
        // function getColumns(tbody) {
        function getColumns(table) {
            var tbody = getBody(table);
...
    // function propagateHeadingClasses(table, tbody, headingTexts) {
    function propagateHeadingClasses(table, headingTexts) {
        ...
        // var columns = htmlTable.getColumns(tbody);
        var columns = htmlTable.getColumns(table);
        ...
    }
...
    function addClassesToTable(table, cityData) {
        ...
        // propagateHeadingClasses(table, tbody, cityData.headings);
        propagateHeadingClasses(table, cityData.headings);
        ...
    }

Remove explicit from the addClassesToTable() function

The last part of HTML-specific table code is the tbody in the addClassesToTable() function.

    function addClassesToTable(table, cityData) {
        var tbody = $(table).find("tbody");
        var rowTitles = cityData.rowTitles;
        propagateHeadingClasses(table, cityData.headings);
        propagateRowClasses(tbody, rowTitles);
    }

The only reason why the propagateRowClasses() function uses tbody is to get the rows. We can pass the table to get the rows instead.

        // function getRows(tbody) {
        function getRows(table) {
            // return $(tbody).find("tr");
            return $(getBody(table)).find("tr");
        }
...
    // function propagateRowClasses(tbody, rowTitles) {
    function propagateRowClasses(table, rowTitles) {
        // var rows = htmlTable.getRows(tbody);
        var rows = htmlTable.getRows(table);
        ...
    }
...
    function addClassesToTable(table, cityData) {
        // var tbody = $(table).find("tbody");
        var rowTitles = cityData.rowTitles;
        propagateHeadingClasses(table, cityData.headings);
        // propagateRowClasses(tbody, rowTitles);
        propagateRowClasses(table, rowTitles);
    }

Cleaning up the propagate functions

The propagate functions say that they propagate classes to the headings, and classes to the rows, but we are not giving either of them those classes. Let’s take care of that now.

    // function propagateHeadingClasses(table, headingTextx) {
    function propagateHeadingClasses(table, headingClasses) {
        var headingCells = htmlTable.getHeadings(table);
        // var headingClasses = makeSafeClassnames(headingTexts);
        ...
    }
...
    // function propagateRowClasses(table, rowTitles) {
    function propagateRowClasses(table, rowClasses) {
        var rows = htmlTable.getRows(table);
        // var rowClasses = makeSafeClassnames(rowTitles);
        ...
    }
...
    function addClassesToTable(table, cityData) {
        var headingClasses = makeSafeClassnames(cityData.headings);
        var rowClasses = makeSafeClassnames(cityData.rowTitles);
        // propagateHeadingClasses(table, cityData.headings);
        propagateHeadingClasses(table, headingClasses);
        // propagateRowClasses(table, rowTitles);
        propagateRowClasses(table, rowTitles);
    }

Conclusion

The only place that thead, body, tr, td appear in the code now is in the htmlTable() module. Thanks to moving them all in the one place, the remaining code has become a lot clearer and easier to understand.

Next time I’ll try to figure out another module to create, that has just as much of a beneficial impact.

Here is a chart of which function call each other.

  • citiesSubmitHandler()
    • selectedOptions()
    • getCityData()
      • getHeadings()
      • getFirstValue()
    • updateWeatherTable()
      • updateRows()
        • createRankings()
          • numericSort()
        • updateRatings()
        • fixedDecimalPlaces()
        • createWeatherWidget()
      • sortRows()
        • rankingSort()
          • numericSort()
      • addTitleColumn()
        • prependCell()
        • prependCells()
          • prependCell()
      • showWeatherTable()
        • createWeatherTable()
          • createTable()
          • createHeadRow()
          • createBodyRows()
        • updateContainer()
        • addClassesToTable()
          • getRows()
          • makeSafeClassnames()
          • updateHeadingClasses()
          • addClassesToColumns()

There definitely seems to be some good value in putting together a weatherTable module, but I notice that it contains a significant amount of DOM interactions, so first I’ll extract out any code that interacts with the DOM.

While doing that I realized that some functions aren’t needed anymore. For example, addClassesToRows() can be removed in favor of a call to page.addClasses() instead.

    function propagateRowClasses(table, rowClasses) {
        var rows = htmlTable.getRows(table);
        // addClassesToRows(rows, rowClasses);
        page.addClasses(rows, rowClasses);
        addClassesToRowCells(rows, rowClasses);
    }

And, in the addClassesToRowCells() function, we can make the cells more apparent:

    function addClassesToRowCells(rows, classnames) {
        $.map(rows, function addRowClass(row, index) {
            // htmlTable.getCells(row).addClass(classnames[index]);
            var cells = htmlTable.getCells(row);
            page.addClass(cells, classnames[index]);
        });
    }

That way we can adjust the code to get all of the cells, where each item in the allCells array is one row of cells, then call page.addClasses() to add the classnames to those cells.

    function addClassesToRowCells(rows, classnames) {
        var allCells = $.map(rows, htmlTable.getCells);
        page.addClasses(allCells, classnames);
    }

The updateHeadingsClass() function also looks to be nicely handled by the page.addClasses() function.

    function updateHeadingClasses(headings, classNames) {
        // $(headings).map(function addHeadingClass(index, header) {
        //     page.addClass(header, classNames[index]);
        // });
        page.addClasses(headings, classNames);
    }

There doesn’t seem to be much benefit in keeping that updateHeadingClasses() function anymore, so we can inline the function.

    // function updateHeadingClasses(headings, classNames) {
    //     page.addClasses(headings, classNames);
    // }
    function propagateHeadingClasses(table, headingClasses) {
        ...
        // updateHeadingClasses(headingCells, headingClasses);
        page.addClasses(headingCells, headingClasses);
        ...
    }

Can we do something similar with the addClassesToColumns() function? First I’ll rename it to addColumnsClasses so that the function parameters can be columns then classes, similar to the other functions.

    // function addClassesToColumns(classes, columns) {
    function addColumnsClasses(columns, classes) {
        ...
    }
    function propagateHeadingClasses(table, headingClasses) {
        ...
        // addClassesToColumns(headingClasses, columns);
        addColumnsClasses(columns, headingClasses);
        ...
    }

The addColumnsClasses function can now be simplified quite a bit, thanks to the page.addClass() function.

    function addColumnsClasses(columns, classes) {
        $.map(columns, function addColumnClass(column, colIndex) {
            // $.map(column, function addCellClass(cell) {
            //     page.addClass(cell, classes[colIndex]);
            // });
            page.addClass(column, classes[colIndex]);
        });
    }

For the links, we can also add a createLink() function to the page class, that’s capable of dealing with different attributes that we want to add to it.

        function createLink(url, text, attrs) {
            var toAttr = (attr) => ` ${attr.name}="${attr.value}"`;
            var attrStr = $.map(attrs, toAttr).join("");
            return `<a href="${url}"${attrStr}>${text}</a>`;
        }
...
    function createWeatherWidget(path) {
        var path = `https://forecast7.com/en/${path}/"`;
        return page.createLink(path, "WEATHER", [
            {name: "class", value: "weatherwidget-io"},
            {name: "data-label_2", value: "WEATHER"},
            {name: "data-days", value: "3"},
            {name: "data-theme", value: "original"}
        ]);
    }

The code that we have at this stage is:

jQuery(document).ready(function rankWeather($) {
    var StatJSON = {
        "Option1": {
            "ColHeading": "New York",
            "Ranking": "",
            "Rating": "4.5",
            "Row1Heading": "Hello",
            "Weather": "40d71n74d01/new-york"
        },
        "Option2": {
            "ColHeading": "San Francisco",
            "Ranking": "",
            "Rating": "3.2",
            "Row1Heading": "Whats",
            "Weather": "37d77n122d42/san-francisco"
        },
        "Option3": {
            "ColHeading": "Chicago",
            "Ranking": "",
            "Rating": "3.7",
            "Row1Heading": "Up",
            "Weather": "41d88n87d63/chicago"
        },
        "Option4": {
            "ColHeading": "Los Angeles",
            "Ranking": "",
            "Rating": "5.0",
            "Row1Heading": "With",
            "Weather": "34d05n118d24/los-angeles"
        }
    };

    var htmlTable = (function htmlTablesModule() {
        function createHeadRow(headings) {
            var headRow = headings.map(function createHTMLHeading(heading) {
                return `<th>${heading}</th>`;
            }).join("");
            return `<tr>${headRow}</tr>`;
        }
        function createBodyRows(rowsData) {
            var bodyRows = rowsData.map(function addRow(rowData) {
                var row = rowData.map(function addData(data) {
                    return `<td>${data}</td>`;
                }).join("");
                return `<tr>${row}</tr>`;
            }).join("");
            return bodyRows;
        }
        function createTable(headRow, bodyRows, classname) {
            var thead = `<thead>${createHeadRow(headRow)}</thead>`;
            var tbody = `<tbody>${createBodyRows(bodyRows)}</tbody>`;
            var classattr = (
                (classname > "")
                ? ` class="compTable"`
                : ""
            );
            return `<table${classattr}>${thead}${tbody}</table>`;
        }
        function getHead(table) {
            return $(table).find("thead");
        }
        function getHeadingCells(table) {
            return $(getHead(table)).find("th");
        }
        function getBody(table) {
            return $(table).find("tbody");
        }
        function getColumns(table) {
            var tbody = getBody(table);
            var rows = $(tbody).find("tr");
            var firstRowCells = $(rows[0]).find("td");
            return $(firstRowCells).map(function getColumn(index) {
                return $(tbody).find("td:nth-child(" + (index + 1) + ")");
            });
        }
        function getRows(table) {
            return $(getBody(table)).find("tr");
        }
        function getCells(row) {
            return $(row).find("td");
        }
        return {
            create: createTable,
            getHeadings: getHeadingCells,
            getColumns,
            getRows,
            getCells
        };
    }());

    var page = (function pageModule() {
        function getSelectedOptions(select) {
            var selection = $(select).find(":selected");
            return $.map(selection, (option) => option.value);
        }
        function updateContainer(container, table) {
            $(container).empty().append(table);
        }
        function addClass(els, classname) {
            $(els).addClass(classname);
        }
        function addClasses(els, classnames) {
            $.map(els, (el, index) => addClass(el, classnames[index]));
        }
        function createLink(url, text, attrs) {
            var toAttr = (attr) => ` ${attr.name}="${attr.value}"`;
            var attrStr = $.map(attrs, toAttr).join("");
            return `<a href="${url}"${attrStr}>${text}</a>`;
        }
        return {
            getSelectedOptions,
            updateContainer,
            addClass,
            addClasses,
            createLink
        };
    }());

    function getHeadings(StatJSON, selectedOptions) {
        return selectedOptions.map(function getHeading(option) {
            return StatJSON[option].ColHeading;
        });
    }
    function makeSafeClassnames(classnames) {
        var removals = ["#", /[()]/g, /\s/g, /\\|\//g];
        return classnames.map(function makeSafeClassname(text) {
            return removals.reduce(function makeSafe(text, regex) {
                return text.replace(regex, "");
            }, text);
        });
    }
    function getFirstValue(obj) {
        var keyValuePair = Object.entries(obj)[0];
        return keyValuePair[1];
    }

    function addColumnsClasses(columns, classes) {
        $.map(columns, function addColumnClass(column, colIndex) {
            page.addClass(column, classes[colIndex]);
        });
    }
    function addClassesToRowCells(rows, classnames) {
        var allCells = $.map(rows, htmlTable.getCells);
        page.addClasses(allCells, classnames);
    }

    function fixedDecimalPlaces(number, decimalPlaces) {
        return parseFloat(number).toFixed(decimalPlaces);
    }
    function getCityData(StatJSON, selectedOptions) {
        var headings = getHeadings(StatJSON, selectedOptions);
        var firstCityData = getFirstValue(StatJSON);
        var rowTitles = Object.keys(firstCityData).slice(1);
        var rowsData = rowTitles.map(function addRow(rowTitle) {
            return selectedOptions.map(function getRowData(option) {
                return StatJSON[option][rowTitle];
            });
        });
        return {
            headings,
            rowTitles,
            rowsData
        };
    }
    function prependCell(cell, row) {
        return [cell, ...row];
    }
    function prependCells(cells, rows) {
        var parts = cells.map(function prepareParts(cellToPrepend, index) {
            return [cellToPrepend, rows[index]];
        });
        return parts.map(function combineParts([cell, row]) {
            return prependCell(cell, row);
        });
    }
    function numericSort(a, b) {
        var numberRegex = /[\d.]+/;
        a = numberRegex.exec(a)[0];
        b = numberRegex.exec(b)[0];
        return Number(b) - Number(a);
    }
    function rankingSort(a, b) {
        return -numericSort(a, b);
    }
    function createRankings(items) {
        var sorted = items.slice().sort(numericSort);
        var rankings = items.map(function createRank(index) {
            return `#${sorted.indexOf(index) + 1}`;
        });
        return rankings;
    }
    function updateRatings(ratings) {
        return ratings.map(function updateRating(rating) {
            return fixedDecimalPlaces(rating, 2);
        });
    }
    function createWeatherWidget(path) {
        var path = `https://forecast7.com/en/${path}/"`;
        return page.createLink(path, "WEATHER", [
            {name: "class", value: "weatherwidget-io"},
            {name: "data-label_2", value: "WEATHER"},
            {name: "data-days", value: "3"},
            {name: "data-theme", value: "original"}
        ]);
    }
    function addTitleColumn(cityData) {
        cityData.headings = prependCell("", cityData.headings);
        cityData.rowsData = prependCells(cityData.rowTitles, cityData.rowsData);
        return cityData;
    }
    function sortRows(rowsData, sortOrder) {
        return rowsData.map(function sortRow(rowData, rowIndex) {
            return rowData.sort(function sortRowData(a, b) {
                var aIndex = rowsData[rowIndex].indexOf(a);
                var bIndex = rowsData[rowIndex].indexOf(b);
                return rankingSort(sortOrder[aIndex], sortOrder[bIndex]);
            });
        });
    }
    function createWeatherTable(cityData) {
        var headRow = [];
        var bodyRows = [["No results found"]];
        if (cityData.headings.length) {
            headRow = cityData.headings;
            bodyRows = cityData.rowsData;
        }
        return htmlTable.create(headRow, bodyRows, "compTable");
    }
    function propagateHeadingClasses(table, headingClasses) {
        var headingCells = htmlTable.getHeadings(table);
        page.addClasses(headingCells, headingClasses);
        var columns = htmlTable.getColumns(table);
        addColumnsClasses(columns, headingClasses);
    }
    function propagateRowClasses(table, rowClasses) {
        var rows = htmlTable.getRows(table);
        page.addClasses(rows, rowClasses);
        addClassesToRowCells(rows, rowClasses);
    }
    function addClassesToTable(table, cityData) {
        var headingClasses = makeSafeClassnames(cityData.headings);
        var rowClasses = makeSafeClassnames(cityData.rowTitles);
        propagateHeadingClasses(table, headingClasses);
        propagateRowClasses(table, rowClasses);
    }

    function updateRows([rankings, ratings, row1Headings, weatherWidgets]) {
        rankings = createRankings(ratings);
        ratings = updateRatings(ratings);
        weatherWidgets = weatherWidgets.map(createWeatherWidget);
        var updatedRows = [
            rankings,
            ratings,
            row1Headings,
            weatherWidgets
        ];
        var sortOrder = [...rankings];
        return sortRows(updatedRows, sortOrder);
    }
    function showWeatherTable(container, cityData) {
        var table = $(createWeatherTable(cityData));
        page.updateContainer(container, table);
        addClassesToTable(table, cityData);
    }
    function updateWeatherTable(container, cityData) {
        cityData.rowsData = updateRows(cityData.rowsData, cityData.rowsData);
        cityData = addTitleColumn(cityData);
        showWeatherTable(container, cityData);
    }

    $("#btnSubmit").click(function citiesSubmitHandler() {
        var selectedOptions = page.getSelectedOptions("#selection");
        var cityData = getCityData(StatJSON, selectedOptions);
        updateWeatherTable("#divResult", cityData);
    });
});

I think that we’re ready to move the bulk of the remaining code into its own separate weather module, which I’ll take care of next time.

1 Like

Today I’ll be moving most of the remaining code into a weatherTable module.

With the citiesSubmitHandler() function, we can move the updateWeatherTable() functioninto its own separate weatherTable module.

    const weatherTable = (function weatherTableModule() {
        function updateWeatherTable(container, cityData) {
            cityData.rowsData = updateRows(cityData.rowsData);
            cityData = addTitleColumn(cityData);
            showWeatherTable(container, cityData);
        }
        return {
            update: updateWeatherTable
        };
    });
    $("#btnSubmit").click(function citiesSubmitHandler() {
        var selectedOptions = page.getSelectedOptions("#selection");
        var cityData = getCityData(StatJSON, selectedOptions);
        weatherTable.update("#divResult", cityData);
    });

We can now include the the other functions that are called from the updateWeatherTable() function.

    const weatherTable = (function weatherTableModule() {
        function updateRows([rankings, ratings, row1Headings, weatherWidgets]) {
            rankings = createRankings(ratings);
            ratings = updateRatings(ratings);
            weatherWidgets = weatherWidgets.map(createWeatherWidget);
            var updatedRows = [
                rankings,
                ratings,
                row1Headings,
                weatherWidgets
            ];
            var sortOrder = [...rankings];
            return sortRows(updatedRows, sortOrder);
        }
        function addTitleColumn({headings, rowTitles, rowsData}) {
            return {
                headings: prependCell("", headings),
                rowTitles,
                rowsData: prependCells(rowTitles, rowsData)
            };
        }
        function showWeatherTable(container, cityData) {
            var table = $(createWeatherTable(cityData));
            page.updateContainer(container, table);
            addClassesToTable(table, cityData);
        }
        function updateWeatherTable(container, cityData) {
            cityData.rowsData = updateRows(cityData.rowsData);
            cityData = addTitleColumn(cityData);
            showWeatherTable(container, cityData);
        }
        return {
            update: updateWeatherTable
        };
    }());

The functions that the updateWeatherTable() function needs are now all dealt with.

The same process then occurs with those other functions that we added in, until we find functions that are less suitable belonging in the weatherTable module, and are more suitable in the page or the table module instead.

  • with updateRows() we add createRankings(), updateRatings(), and createWeatherWidget()
  • with addTitleColumn() we add prependCell() and prependCells(). I’ll come back to those soon
  • with showWeatherTable() we add createWeatherTable() and addClassesToTable()

The showWeatherTable() function also uses the page module, so we should pass that page module in to the updateWeatherTable() function.

    // const weatherTable = (function weatherTableModule() {
    const weatherTable = (function weatherTableModule() {
        ...
    // }());
    }(page));

The prependCell() and prependCells() functions are symbolically prepending. It’s tempting to move those into the page module, but the code is instead actually working with array items, so those functions are fine being in the weatherTable module.

We now have a new range of functions to consider adding to the weatherTable module. It is expected that this will be a more and more difficult action to justify as the functions become more generic and less specific to the weather table module.

  • numericSort() is so widely useful that it goes in a separate utils module.
    const utils = (function utilsModule() {
        ...
        function numericSort(a, b) {
            var numRx = /[\d.]+/;
            a = numRx.exec(a)[0];
            b = numRx.exec(b)[0];
            return Number(b) - Number(a);
        }
        return {
            ...
            numericSort
        };
    }());

We can then add that utils module as one of the things that weatherTable needs. We should though try and keep this list to a minimum.

    // const weatherTable = (function weatherTableModule(page) {
    const weatherTable = (function weatherTableModule(page, utils) {
        ...
        function createRankings(items) {
            // var sorted = items.slice().sort(numericSort);
            var sorted = items.slice().sort(utils.numericSort);
            ...
        }
        ...
        function rankingSort(a, b) {
            return -utils.numericSort(a, b);
        }
        ...
    // }(page));
    }(page, utils));
  • with fixedDecimalPlaces(), that function doesn’t need to exist. It can be inlined into the updateRatings function instead.
        // function fixedDecimalPlaces(number, decimalPlaces) {
        //     return parseFloat(number).toFixed(decimalPlaces);
        // }
        ...
        function updateRatings(ratings) {
            return ratings.map(function updateRating(rating) {
                // return fixedDecimalPlaces(rating, 2);
                return parseFloat(rating).toFixed(2);
            });
        }

The addClassesToTable() function uses a makeSafeClassnames() function. That is another useful util to add to the utils module.

    const utils = (function utilsModule() {
        ...
        function makeSafeClassnames(classnames) {
            var removals = ["#", /[()]/g, /\s/g, /\\|\//g];
            return classnames.map(function makeSafeClassname(text) {
                return removals.reduce(function makeSafe(text, regex) {
                    return text.replace(regex, "");
                }, text);
            });
        }
        return {
            makeSafeClassnames,
            numericSort
        };
    }());
...
        function addClassesToTable(table, cityData) {
            // var headingClasses = makeSafeClassnames(cityData.headings);
            var headingClasses = utils.makeSafeClassnames(cityData.headings);
            // var rowClasses = makeSafeClassnames(cityData.rowTitles);
            var rowClasses = utils.makeSafeClassnames(cityData.rowTitles);

The next function of concern is the createWeatherTable() function. It has a reference to htmlTable.create() but I don’t want to add yet another reference to an external source. Instead of adding htmlTable, I can have the page let us through to that htmlTable code instead.

    var page = (function pageModule() {
        ...
        function createTable(headRow, bodyRows, classname) {
            return htmlTable.create(headRow, bodyRows, classname);
        }
        return {
            ...
            createTable,
            ...
        };
    }());
...
        function createWeatherTable(cityData) {
            ...
            // return htmlTable.create(headRow, bodyRows, "compTable");
            return page.createTable(headRow, bodyRows, "compTable");
        }

The rest of the weatherTable is pretty straight forward, by moving needed functions into there.

Here’s the code that we now have in full:

/*jslint browser */
/*global jQuery __weatherwidget_init */
jQuery(document).ready(function rankWeather($) {
    var StatJSON = {
        "Option1": {
            "ColHeading": "New York",
            "Ranking": "",
            "Rating": "4.5",
            "Row1Heading": "Hello",
            "Weather": "40d71n74d01/new-york"
        },
        "Option2": {
            "ColHeading": "San Francisco",
            "Ranking": "",
            "Rating": "3.2",
            "Row1Heading": "Whats",
            "Weather": "37d77n122d42/san-francisco"
        },
        "Option3": {
            "ColHeading": "Chicago",
            "Ranking": "",
            "Rating": "3.7",
            "Row1Heading": "Up",
            "Weather": "41d88n87d63/chicago"
        },
        "Option4": {
            "ColHeading": "Los Angeles",
            "Ranking": "",
            "Rating": "5.0",
            "Row1Heading": "With",
            "Weather": "34d05n118d24/los-angeles"
        }
    };

    const utils = (function utilsModule() {
        function numericSort(a, b) {
            var numRx = /[\d.]+/;
            a = numRx.exec(a)[0];
            b = numRx.exec(b)[0];
            return Number(b) - Number(a);
        }
        function makeSafeClassnames(classnames) {
            var removals = ["#", /[()]/g, /\s/g, /\\|\//g];
            return classnames.map(function makeSafeClassname(text) {
                return removals.reduce(function makeSafe(text, regex) {
                    return text.replace(regex, "");
                }, text);
            });
        }
        return {
            makeSafeClassnames,
            numericSort
        };
    }());
    var htmlTable = (function htmlTablesModule() {
        function createHeadRow(headings) {
            var headRow = headings.map(function createHTMLHeading(heading) {
                return `<th>${heading}</th>`;
            }).join("");
            return `<tr>${headRow}</tr>`;
        }
        function createBodyRows(rowsData) {
            var bodyRows = rowsData.map(function addRow(rowData) {
                var row = rowData.map(function addData(data) {
                    return `<td>${data}</td>`;
                }).join("");
                return `<tr>${row}</tr>`;
            }).join("");
            return bodyRows;
        }
        function createTable(headRow, bodyRows, classname) {
            var thead = `<thead>${createHeadRow(headRow)}</thead>`;
            var tbody = `<tbody>${createBodyRows(bodyRows)}</tbody>`;
            var classattr = (
                (classname > "")
                ? ` class="compTable"`
                : ""
            );
            return `<table${classattr}>${thead}${tbody}</table>`;
        }
        function getHead(table) {
            return $(table).find("thead");
        }
        function getHeadingCells(table) {
            return $(getHead(table)).find("th");
        }
        function getBody(table) {
            return $(table).find("tbody");
        }
        function getColumns(table) {
            var tbody = getBody(table);
            var rows = $(tbody).find("tr");
            var firstRowCells = $(rows[0]).find("td");
            return $(firstRowCells).map(function getColumn(index) {
                return $(tbody).find("td:nth-child(" + (index + 1) + ")");
            });
        }
        function getRows(table) {
            return $(getBody(table)).find("tr");
        }
        function getCells(row) {
            return $(row).find("td");
        }
        return {
            create: createTable,
            getCells,
            getColumns,
            getHeadings: getHeadingCells,
            getRows
        };
    }());

    var page = (function pageModule() {
        function getSelectedOptions(select) {
            var selection = $(select).find(":selected");
            return $.map(selection, (option) => option.value);
        }
        function updateContainer(container, table) {
            $(container).empty().append(table);
        }
        function addClass(els, classname) {
            $(els).addClass(classname);
        }
        function addClasses(els, classnames) {
            $.map(els, (el, index) => addClass(el, classnames[index]));
        }
        function createLink(url, text, attrs) {
            var toAttr = (attr) => ` ${attr.name}="${attr.value}"`;
            var attrStr = $.map(attrs, toAttr).join("");
            return `<a href="${url}"${attrStr}>${text}</a>`;
        }
        function createTable(headRow, bodyRows, classname) {
            return htmlTable.create(headRow, bodyRows, classname);
        }
        return {
            addClass,
            addClasses,
            createLink,
            createTable,
            getSelectedOptions,
            updateContainer
        };
    }());

    const weatherTable = (function weatherTableModule(page, utils) {
        function createRankings(items) {
            var sorted = items.slice().sort(utils.numericSort);
            var rankings = items.map(function createRank(index) {
                return `#${sorted.indexOf(index) + 1}`;
            });
            return rankings;
        }
        function updateRatings(ratings) {
            return ratings.map(function updateRating(rating) {
                return parseFloat(rating).toFixed(2);
            });
        }
        function createWeatherWidget(weatherPath) {
            var path = `https://forecast7.com/en/${weatherPath}/"`;
            return page.createLink(path, "WEATHER", [
                {name: "class", value: "weatherwidget-io"},
                {name: "data-label_2", value: "WEATHER"},
                {name: "data-days", value: "3"},
                {name: "data-theme", value: "original"}
            ]);
        }
        function rankingSort(a, b) {
            return -utils.numericSort(a, b);
        }
        function sortRows(rowsData, sortOrder) {
            return rowsData.map(function sortRow(rowData, rowIndex) {
                return rowData.sort(function sortRowData(a, b) {
                    var aIndex = rowsData[rowIndex].indexOf(a);
                    var bIndex = rowsData[rowIndex].indexOf(b);
                    return rankingSort(sortOrder[aIndex], sortOrder[bIndex]);
                });
            });
        }
        function updateRows([ignore, ratings, row1Headings, weatherWidgets]) {
            var updatedRows = [
                createRankings(ratings),
                updateRatings(ratings),
                row1Headings,
                weatherWidgets.map(createWeatherWidget)
            ];
            var sortOrder = [...updatedRows[0]];
            return sortRows(updatedRows, sortOrder);
        }
        function prependCell(cell, row) {
            return [cell, ...row];
        }
        function prependCells(cells, rows) {
            var parts = cells.map(function prepareParts(cellToPrepend, index) {
                return [cellToPrepend, rows[index]];
            });
            return parts.map(function combineParts([cell, row]) {
                return prependCell(cell, row);
            });
        }
        function addTitleColumn({headings, rowTitles, rowsData}) {
            return {
                headings: prependCell("", headings),
                rowTitles,
                rowsData: prependCells(rowTitles, rowsData)
            };
        }
        function addColumnsClasses(columns, classes) {
            $.map(columns, function addColumnClass(column, colIndex) {
                page.addClass(column, classes[colIndex]);
            });
        }
        function propagateHeadingClasses(table, headingClasses) {
            var headingCells = htmlTable.getHeadings(table);
            var columns = htmlTable.getColumns(table);
            page.addClasses(headingCells, headingClasses);
            addColumnsClasses(columns, headingClasses);
        }
        function addClassesToRowCells(rows, classnames) {
            var allCells = $.map(rows, htmlTable.getCells);
            page.addClasses(allCells, classnames);
        }
        function propagateRowClasses(table, rowClasses) {
            var rows = htmlTable.getRows(table);
            page.addClasses(rows, rowClasses);
            addClassesToRowCells(rows, rowClasses);
        }
        function addClassesToTable(table, cityData) {
            var headingClasses = utils.makeSafeClassnames(cityData.headings);
            var rowClasses = utils.makeSafeClassnames(cityData.rowTitles);
            propagateHeadingClasses(table, headingClasses);
            propagateRowClasses(table, rowClasses);
        }
        function createWeatherTable(cityData) {
            var headRow = [];
            var bodyRows = [["No results found"]];
            if (cityData.headings.length) {
                headRow = cityData.headings;
                bodyRows = cityData.rowsData;
            }
            return page.createTable(headRow, bodyRows, "compTable");
        }
        function showWeatherTable(container, cityData) {
            var table = $(createWeatherTable(cityData));
            page.updateContainer(container, table);
            addClassesToTable(table, cityData);
        }
        function updateWeatherTable(container, cityData) {
            cityData.rowsData = updateRows(cityData.rowsData);
            cityData = addTitleColumn(cityData);
            showWeatherTable(container, cityData);
        }
        return {
            update: updateWeatherTable
        };
    }(page, utils));
    function getHeadings(StatJSON, selectedOptions) {
        return selectedOptions.map(function getHeading(option) {
            return StatJSON[option].ColHeading;
        });
    }
    function getFirstValue(obj) {
        var keyValuePair = Object.entries(obj)[0];
        return keyValuePair[1];
    }
    function getCityData(StatJSON, selectedOptions) {
        var headings = getHeadings(StatJSON, selectedOptions);
        var firstCityData = getFirstValue(StatJSON);
        var rowTitles = Object.keys(firstCityData).slice(1);
        var rowsData = rowTitles.map(function addRow(rowTitle) {
            return selectedOptions.map(function getRowData(option) {
                return StatJSON[option][rowTitle];
            });
        });
        return {
            headings,
            rowTitles,
            rowsData
        };
    }
    $("#btnSubmit").click(function citiesSubmitHandler() {
        var selectedOptions = page.getSelectedOptions("#selection");
        var cityData = getCityData(StatJSON, selectedOptions);
        weatherTable.update("#divResult", cityData);
    });
});

There are a few other issues to deal with in there, but those can wait for my next post :slight_smile:

By moving code into the weatherTable module I ended up having several htmlTable references in there, which is undesired. We already have page and utils as module parameters. Adding a third for htmlTable which is similar to page is not a good thing.

Instead, we can expand the abilities of the page module to give us access to what we need from the htmlTable module.

    // var page = (function pageModule() {
    var page = (function pageModule(htmlTable) {
        ...
    // }());
    }(htmlTable));

The propagateHeadingClasses() function wants to use htmlTable to get the headers of the weather table element. It’s important that we don’t fall in to the trap of assuming that the weather table element will always be a table. It’a also tempting to add more information to that element, such as adding a headings array on to the element. That’s not a good idea because we want to change HTML elements as little as possible.

Instead, we can pass the table to the page module, asking to get the headings from that. That way, if we change things from being HTML table elements, it’s only in one place in the code in the page module that we need to update to account for that.

To help simplify things, I’ll split apart the propagateHeadingClasses() code. it’s doing two different things. One is to propagate the heading classes, and one is to propagate the column classes. I will split that function in two, so that it is easy to understand the two different things that are being done.

        function propagateHeadingClasses(table, headingClasses) {
            var headingCells = htmlTable.getHeadings(table);
            page.addClasses(headingCells, headingClasses);
        }
        ...
        function propagateColumnClasses(table, headingClasses) {
            var columns = htmlTable.getColumns(table);
            addColumnsClasses(columns, headingClasses);
        }

And then I can inline the addColumnsClasses() function:

        // function addColumnsClasses(columns, classes) {
        //     $.map(columns, function addColumnClass(column, colIndex) {
        //         page.addClass(column, classes[colIndex]);
        //     });
        // }
        function propagateColumnClasses(table, headingClasses) {
            var columns = htmlTable.getColumns(table);
            // addColumnsClasses(columns, headingClasses);
            $.map(columns, function addColumnClass(column, colIndex) {
                page.addClass(column, headingClasses[colIndex]);
            });
        }

I can now update propagateHeadingClasses() so that it’s not using table, but is given the headings instead.

        // function propagateHeadingClasses(table, headingClasses) {
        function propagateHeadingClasses(headings, headingClasses) {
            // var headingCells = htmlTable.getHeadings(table);
            // page.addClasses(headingCells, headingClasses);
            page.addClasses(headings, headingClasses);
        }
...
        function addClassesToTable(weatherTable, cityData) {
            var headings = htmlTable.getHeadings(weatherTable);
            ...
            // propagateHeadingClasses(weatherTable, headingClasses);
            propagateHeadingClasses(headings, headingClasses);

I can do the same for the propagateColumnClasses() and propagateRowClasses() functions too:

        // function propagateColumnClasses(table, headingClasses) {
        function propagateColumnClasses(columns, headingClasses) {
            // var columns = htmlTable.getColumns(table);
            addColumnsClasses(columns, headingClasses);
        }
        // function propagateRowClasses(table, rowClasses) {
        function propagateRowClasses(rows, rowClasses) {
            // var rows = htmlTable.getRows(table);
            page.addClasses(rows, rowClasses);
            addClassesToRowCells(rows, rowClasses);
        }
...
        function addClassesToTable(weatherTable, cityData) {
            ...
            var columns = htmlTable.getColumns(weatherTable);
            var rows = htmlTable.getRows(weatherTable);
            ...
            // propagateColumnClasses(weatherTable, headingClasses);
            propagateColumnClasses(columns, headingClasses);
            // propagateRowClasses(weatherTable, rowClasses);
            propagateRowClasses(rows, rowClasses);

Why is that important? It moves all of the headings, columns, and rows, into the one function in addClassesToTable(), so that I can gain them from the page module instead.

The addClassesToTable() function is now using the weatherTable to get all of the parts of a table.

        function addClassesToTable(weatherTable, cityData) {
            var headings = htmlTable.getHeadings(weatherTable);
            var columns = htmlTable.getColumns(weatherTable);
            var rows = htmlTable.getRows(weatherTable);

Instead of doing that there, we can ask the page module for the parts of a table instead.

    var page = (function pageModule(htmlTable) {
        ...
        return {
            ...
            table: htmlTable,
            ...
        };
...
    var weatherTable = (function weatherTableModule(page, utils) {
        ...
        function addClassesToTable(weatherTable, cityData) {
            // var headings = htmlTable.getHeadings(weatherTable);
            var headings = page.table.getHeadings(weatherTable);
            // var columns = htmlTable.getColumns(weatherTable);
            var columns = page.table.getColumns(weatherTable);
            // var rows = htmlTable.getRows(weatherTable);
            var rows = page.table.getRows(weatherTable);
            ...
        }

The same technique can be used to help with the addClassesToRowCells() function.

        function addClassesToRowCells(rows, classnames) {
            // var allCells = $.map(rows, htmlTable.getCells);
            var allCells = $.map(rows, page.table.getCells);
            page.addClasses(allCells, classnames);
        }

The weatherTable module now has no unexpected external references, and the code updates are at a pretty good place. Almost any kind of future update is going to be really easy to achieve now, and it’s a lot easier now to understand what happens with each part of the code.

Here’s the code that we have now.

jQuery(document).ready(function rankWeather($) {
    var StatJSON = {
        "Option1": {
            "ColHeading": "New York",
            "Ranking": "",
            "Rating": "4.5",
            "Row1Heading": "Hello",
            "Weather": "40d71n74d01/new-york"
        },
        "Option2": {
            "ColHeading": "San Francisco",
            "Ranking": "",
            "Rating": "3.2",
            "Row1Heading": "Whats",
            "Weather": "37d77n122d42/san-francisco"
        },
        "Option3": {
            "ColHeading": "Chicago",
            "Ranking": "",
            "Rating": "3.7",
            "Row1Heading": "Up",
            "Weather": "41d88n87d63/chicago"
        },
        "Option4": {
            "ColHeading": "Los Angeles",
            "Ranking": "",
            "Rating": "5.0",
            "Row1Heading": "With",
            "Weather": "34d05n118d24/los-angeles"
        }
    };

    var utils = (function utilsModule() {
        function numericSort(a, b) {
            var numRx = /[\d.]+/;
            a = numRx.exec(a)[0];
            b = numRx.exec(b)[0];
            return Number(b) - Number(a);
        }
        function makeSafeClassnames(classnames) {
            var removals = ["#", /[()]/g, /\s/g, /\\|\//g];
            return classnames.map(function makeSafeClassname(text) {
                return removals.reduce(function makeSafe(text, regex) {
                    return text.replace(regex, "");
                }, text);
            });
        }
        return {
            makeSafeClassnames,
            numericSort
        };
    }());

    var htmlTable = (function htmlTablesModule() {
        function createHeadRow(headings) {
            var headRow = headings.map(function createHTMLHeading(heading) {
                return `<th>${heading}</th>`;
            }).join("");            return `<tr>${headRow}</tr>`;
        }
        function createBodyRows(rowsData) {
            var bodyRows = rowsData.map(function addRow(rowData) {
                var row = rowData.map(function addData(data) {
                    return `<td>${data}</td>`;
                }).join("");
                return `<tr>${row}</tr>`;
            }).join("");
            return bodyRows;
        }
        function createTable(headRow, bodyRows, classname) {
            var thead = `<thead>${createHeadRow(headRow)}</thead>`;
            var tbody = `<tbody>${createBodyRows(bodyRows)}</tbody>`;
            var classattr = (
                (classname > "")
                ? ` class="compTable"`
                : ""
            );
            return `<table${classattr}>${thead}${tbody}</table>`;
        }
        function getHead(table) {
            return $(table).find("thead");
        }
        function getHeadingCells(table) {
            return $(getHead(table)).find("th");
        }
        function getBody(table) {
            return $(table).find("tbody");
        }
        function getColumns(table) {
            var tbody = getBody(table);
            var rows = $(tbody).find("tr");
            var firstRowCells = $(rows[0]).find("td");
            return $(firstRowCells).map(function getColumn(index) {
                return $(tbody).find("td:nth-child(" + (index + 1) + ")");
            });
        }
        function getRows(table) {
            return $(getBody(table)).find("tr");
        }
        function getCells(row) {
            return $(row).find("td");
        }
        return {
            create: createTable,
            getCells,
            getColumns,
            getHeadings: getHeadingCells,
            getRows
        };
    }());

    var page = (function pageModule(htmlTable) {
        function getSelectedOptions(select) {
            var selection = $(select).find(":selected");
            return $.map(selection, (option) => option.value);
        }
        function updateContainer(container, table) {
            $(container).empty().append(table);
        }
        function addClass(els, classname) {
            $(els).addClass(classname);
        }
        function addClasses(els, classnames) {
            $.map(els, (el, index) => addClass(el, classnames[index]));
        }
        function createLink(url, text, attrs) {
            var toAttr = (attr) => ` ${attr.name}="${attr.value}"`;
            var attrStr = $.map(attrs, toAttr).join("");
            return `<a href="${url}"${attrStr}>${text}</a>`;
        }
        function createTable(headRow, bodyRows, classname) {
            return htmlTable.create(headRow, bodyRows, classname);
        }
        return {
            addClass,
            addClasses,
            createLink,
            createTable,
            getSelectedOptions,
            table: htmlTable,
            updateContainer
        };
    }(htmlTable));

    var weatherTable = (function weatherTableModule(page, utils) {
        function createRankings(items) {
            var sorted = items.slice().sort(utils.numericSort);
            var rankings = items.map(function createRank(index) {
                return `#${sorted.indexOf(index) + 1}`;
            });
            return rankings;
        }
        function updateRatings(ratings) {
            return ratings.map(function updateRating(rating) {
                return parseFloat(rating).toFixed(2);
            });
        }
        function createWeatherWidget(weatherPath) {
            var path = `https://forecast7.com/en/${weatherPath}/"`;
            return page.createLink(path, "WEATHER", [
                {name: "class", value: "weatherwidget-io"},
                {name: "data-label_2", value: "WEATHER"},
                {name: "data-days", value: "3"},
                {name: "data-theme", value: "original"}
            ]);
        }
        function rankingSort(a, b) {
            return -utils.numericSort(a, b);
        }
        function sortRows(rowsData, sortOrder) {
            return rowsData.map(function sortRow(rowData, rowIndex) {
                return rowData.sort(function sortRowData(a, b) {
                    var aIndex = rowsData[rowIndex].indexOf(a);
                    var bIndex = rowsData[rowIndex].indexOf(b);
                    return rankingSort(sortOrder[aIndex], sortOrder[bIndex]);
                });
            });
        }
        function updateRows([ignore, ratings, row1Headings, weatherWidgets]) {
            var updatedRows = [
                createRankings(ratings),
                updateRatings(ratings),
                row1Headings,
                weatherWidgets.map(createWeatherWidget)
            ];
            var sortOrder = [...updatedRows[0]];
            return sortRows(updatedRows, sortOrder);
        }
        function prependCell(cell, row) {
            return [cell, ...row];
        }
        function prependCells(cells, rows) {
            var parts = cells.map(function prepareParts(cellToPrepend, index) {
                return [cellToPrepend, rows[index]];
            });
            return parts.map(function combineParts([cell, row]) {
                return prependCell(cell, row);
            });
        }
        function addTitleColumn({headings, rowTitles, rowsData}) {
            return {
                headings: prependCell("", headings),
                rowTitles,
                rowsData: prependCells(rowTitles, rowsData)
            };
        }
        function propagateHeadingClasses(headings, headingClasses) {
            page.addClasses(headings, headingClasses);
        }
        function addColumnsClasses(columns, classes) {
            $.map(columns, function addColumnClass(column, colIndex) {
                page.addClass(column, classes[colIndex]);
            });
        }
        function propagateColumnClasses(columns, headingClasses) {
            addColumnsClasses(columns, headingClasses);
        }
        function addClassesToRowCells(rows, classnames) {
            var allCells = $.map(rows, page.table.getCells);
            page.addClasses(allCells, classnames);
        }
        function propagateRowClasses(rows, rowClasses) {
            page.addClasses(rows, rowClasses);
            addClassesToRowCells(rows, rowClasses);
        }
        function addClassesToTable(weatherTable, cityData) {
            var headings = page.table.getHeadings(weatherTable);
            var columns = page.table.getColumns(weatherTable);
            var rows = page.table.getRows(weatherTable);
            var headingClasses = utils.makeSafeClassnames(cityData.headings);
            var rowClasses = utils.makeSafeClassnames(cityData.rowTitles);
            propagateHeadingClasses(headings, headingClasses);
            propagateColumnClasses(columns, headingClasses);
            propagateRowClasses(rows, rowClasses);
        }
        function createWeatherTable(cityData) {
            var headRow = [];
            var bodyRows = [["No results found"]];
            if (cityData.headings.length) {
                headRow = cityData.headings;
                bodyRows = cityData.rowsData;
            }
            return page.createTable(headRow, bodyRows, "compTable");
        }
        function showWeatherTable(container, cityData) {
            var weatherTbl = $(createWeatherTable(cityData));
            page.updateContainer(container, weatherTbl);
            addClassesToTable(weatherTbl, cityData);
        }
        function updateWeatherTable(container, cityData) {
            cityData.rowsData = updateRows(cityData.rowsData);
            cityData = addTitleColumn(cityData);
            showWeatherTable(container, cityData);
        }
        return {
            update: updateWeatherTable
        };
    }(page, utils));

    function getHeadings(StatJSON, selectedOptions) {
        return selectedOptions.map(function getHeading(option) {
            return StatJSON[option].ColHeading;
        });
    }
    function getFirstValue(obj) {
        var keyValuePair = Object.entries(obj)[0];
        return keyValuePair[1];
    }
    function getCityData(StatJSON, selectedOptions) {
        var headings = getHeadings(StatJSON, selectedOptions);
        var firstCityData = getFirstValue(StatJSON);
        var rowTitles = Object.keys(firstCityData).slice(1);
        var rowsData = rowTitles.map(function addRow(rowTitle) {
            return selectedOptions.map(function getRowData(option) {
                return StatJSON[option][rowTitle];
            });
        });
        return {
            headings,
            rowTitles,
            rowsData
        };
    }
    $("#btnSubmit").click(function citiesSubmitHandler() {
        var selectedOptions = page.getSelectedOptions("#selection");
        var cityData = getCityData(StatJSON, selectedOptions);
        weatherTable.update("#divResult", cityData);
    });
});

Typically the htmlTable, page, and weatherTable modules would be saved in separate files, as that helps to make it easier to manage the code.

I hope that some of this has been helpful for you.

1 Like

I know what I was going to do - demonstrate how the HTML table formatting can be fairly easily replaced with something else instead, such as a list item.

The page module currently uses htmlTable, so let’s make that a bit more generic by calling it pageTable instead.

    // var page = (function pageModule(htmlTable) {
    var page = (function pageModule(pageTable) {
        ...
        function createTable(headRow, bodyRows, classname) {
            // return htmlTable.create(headRow, bodyRows, classname);
            return pageTable.create(headRow, bodyRows, classname);
        }
        return {
            ...
            // getTable: htmlTable,
            getTable: pageTable,
            ...
        };
    }(htmlTable));

Now it’s only the htmlTable at the end of the page module that needs to be changed, to switch from one type to another.

    var page = (function pageModule(pageTable) {
        ...
    // }(htmlTable));
    }(listTable));

We still need a listTable module which in cooking-show terms, I have already prepared in the other oven.

    var listTable = (function listTableModule() {
        function createHeadRow(headings) {
            var headers = headings.map(function createHeading(heading) {
                return `<span>${heading}</span>`;
            });
            return `<li class="heading">${headers.join("")}</li>`;
        }
        function createRows(rowsData) {
            return rowsData.map(function createRow(rowData) {
                var cells = rowData.map(function createSpans(cell) {
                    return `<span>${cell}</span>`;
                });
                return `<li>${cells.join("")}</li>`;
            });
        }
        function createTable(headRow, bodyRows, classname) {
            var headings = createHeadRow(headRow);
            var rows = createRows(bodyRows).join("");
            var classattr = ` class="compTable"`;
            return `<ul${classattr}>${headings}${rows}</ul>`;
        }
        function getHead(listTable) {
            return $(listTable).find(".heading");
        }
        function getHeadingCells(listTable) {
            return getHead(listTable).find("span");
        }
        function getBody(listTable) {
            return $(listTable).find("li:not(.heading)");
        }
        function getColumns(listTable) {
            var body = getBody(listTable);
            var firstRowCells = $(body[0]).find("span");
            return $(firstRowCells).map(function getColumn(index) {
                return $(body).find("span:nth-child(" + (index + 1) + ")");
            });
        }
        function getRows(listTable) {
            return $(getBody(listTable));
        }
        function getCells(listItem) {
            return $(listItem).find("span");
        }
        return {
            create: createTable,
            getCells,
            getColumns,
            getHeadings: getHeadingCells,
            getRows
        };
    }());

And after a few CSS adjustments, things are looking pretty good.

ul.compTable {
    list-style-type: none;
}
.compTable li>span {
    display:inline-block;
    width:33%;
    vertical-align: middle;
}

That’s using a list-item for the table instead. Other techniques are just as easily available without changing anything else in the code, such as by using flexbox for improved layout, or the even more recent CSS-Grid.

1 Like