infinnie / infinnie.github.io

https://infinnie.github.io/
Apache License 2.0
0 stars 1 forks source link

An idea on the front-end implementation of rich internet applications #6

Open infinnie opened 7 years ago

infinnie commented 7 years ago

Update 3: Features & fixes

Screenshot:

image

Code:

HTML:

<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title>Todo list</title>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="/styles/todo.css" />
</head>
<body>
    <div class="app-container">
        <h1 class="app-title">Todo list</h1>
        <div class="app-area">
            <form class="todo-form" id="todoForm">
                <label class="todo-form__label">
                    <input type="checkbox" id="markAll" />
                </label>
                <input class="todo-form__input" placeholder="What needs to be done&hellip;" data-role="formInput" />
                <div class="todo-form__btnArea"><button type="submit" class="todo-form__btn">Add</button></div>
            </form>
            <ul class="todo-list" id="listArea"></ul>
        </div>
        <p class="todo-status">
            <a href="#" id="clearCompleted" class="todo-status__link todo-status__clearLink"></a>
            <span id="hashLinks">
                <a href="#!/" data-hash-link="all" class="todo-status__link">All</a>&ensp;&middot;&ensp;<a href="#!/completed" data-hash-link="completed" class="todo-status__link">Completed</a>&ensp;&middot;&ensp;<a href="#!/remaining" data-hash-link="remaining" class="todo-status__link">Remaining</a>
            </span>
        </p>
    </div>
    <script id="todoItemSource" type="text/plain">
        <li class="todo-item">
            <label class="todo-item__label">
                <input type="checkbox" data-role="doneCheck" />
            </label>
            <div class="todo-item__content">
                <button type="button" class="todo-item__clear" data-role="destroyTodo"></button>
                <div class="todo-item__text" data-role="content"></div>
                <label class="todo-item__inputArea">
                    <input class="todo-item__input" data-role="todoInput" />
                </label>
            </div>
        </li>
    </script>
    <script src="/Scripts/jquery-1.12.4.min.js"></script>
    <script src="/Scripts/todo.js"></script>
</body>
</html>

JavaScript:

/// <reference path="/Scripts/jquery-1.12.4.js"/>
jQuery(function ($) {
    "use strict";
    var AppStorage = {
        read: function () {
            /// <returns type="Array"/>
            return JSON.parse(localStorage.getItem("todoList") || "null") || [];
        },
        write: function (newList) {
            localStorage.setItem("todoList", JSON.stringify(newList));
        }
    }, AppModel = {
        todoList: null,
        count: null,
        doneCount: 0,
        allDone: false
    }, combineTransformations = (function () {
        var promiseJoin = function (f, g) {
            /// <param name="f" type="Function"/>
            /// <param name="g" type="Function"/>
            return function (x) {
                /// <returns type="Promise"/>
                return f(x).then(g);
            };
        }, idTransformation = function (x) {
            var d = $.Deferred();
            d.resolve(x);
            return d.promise();
        };
        return function (transformations) {
            /// <param name="transformations" type="Array"/>
            var ret = idTransformation;
            $.each(transformations || [], function (i, val) {
                ret = promiseJoin(ret, val);
            });
            return ret;
        };
    })(), storageActionMap = {
        precreate: function (list, item) {
            /// <summary>returns the transformed item and list.</summary>
            if (item.type !== "todo") {
                return { list: list, item: item };
            }
            var val = item.value,
                obj = { id: +new Date() };
            $.extend(obj, val);
            return {
                list: [obj].concat(list),
                item: {
                    action: "create",
                    value: obj,
                    id: obj.id,
                    type: "todo"
                }
            };
        }, preupdate: function (list, item) {
            if (item.type !== "todo") {
                return { list: list, item: item };
            }
            var id = item.id,
                retList = $.map(list, function (element, index) {
                    if (id !== element.id) { return element; }
                    return $.extend({}, element, item.value);
                });
            return {
                list: retList,
                item: {
                    action: "update",
                    value: item.value,
                    id: id,
                    type: "todo"
                }
            };
        }, predestroy: function (list, item) {
            /// <summary>returns the transformed item and list.</summary>
            if (item.type !== "todo") {
                return { list: list, item: item };
            }
            var id = item.id,
                retList = $.grep(list, function (element) {
                    return id !== element.id;
                });
            return {
                list: retList,
                item: {
                    action: "destroy",
                    type: "todo",
                    id: id
                }
            };
        }, initiate: function (list, item) {
            /// <param name="list" type="Array">The todo list. Initiated from storage.</param>
            if (item.type !== "todoList") {
                return { list: list, item: item };
            }
            return {
                list: list,
                item: {
                    action: "fill",
                    type: "todoList",
                    value: list
                }
            }
        }, clearcompleted: function (list, item) {
            if (item.type !== "todoList") {
                return { list: list, item: item };
            }
            var ret = [], retList = $.grep(list, function (element) {
                if (element.done) {
                    ret.push({
                        action: "destroy",
                        type: "todo",
                        id: element.id
                    });
                    return false;
                }
                return true;
            });
            return {
                list: retList,
                item: ret
            };
        }, markall: function (list, item) {
            if (item.type !== "todoList") {
                return { list: list, item: item };
            }
            var ret = [], retList = $.map(list, function (element) {
                var doneObj = { done: item.value };
                if (element.done !== item.value) {
                    ret.push({
                        action: "update",
                        type: "todo",
                        id: element.id,
                        value: doneObj
                    });
                    return $.extend({}, element, doneObj);
                }
                return element;
            });
            return {
                list: retList,
                item: ret
            };
        }
    }, storageTransformation = function (updates) {
        /// <summary>Not implemented.</summary>
        /// <param name="updates" type="Array"/>
        var list = null,
            should = false,
            transformed = [].concat.apply([], $.map(updates, function (item, i) {
                /// <summary>Updates the list and returns the item per iteration.</summary>
                /// <param name="val" type="Object"/>
                // Do something
                var temp;
                if (!item) {
                    return [];
                }
                if (item.action in storageActionMap) {
                    if (!should) {
                        should = true;
                        list = AppStorage.read();
                    }
                    temp = storageActionMap[item.action](list, item);
                    list = temp.list || list;
                    return temp.item || [];
                }
                return item;
            })), d = $.Deferred();
        if (should) {
            AppStorage.write(list);
        }
        setTimeout(function () {
            d.resolve(should ? transformed.concat({
                type: "todoList",
                action: "update",
                value: list
            }) : transformed);
        }, 1);
        return d.promise();
    }, getHashSub = function (hash) {
        return hash.replace(/^#(?:!\/)?/, "");
    }, hashTest = function (done) {
        /// <param name="done" type="Boolean"/>
        switch (getHashSub(location.hash)) {
            case "completed":
                return done;
            case "remaining":
                return !done;
        }
        return true;
    }, modelTransformation = (function () {
        var list = [];
        return function (updates) {
            /// <summary>Not implemented. Update model and computed properties.</summary>
            /// <param name="updates" type="Array"/>
            var count, doneCount, ret = {
                action: "update",
                type: "counter"
            }, should = false, prevList = list, retUpdates;
            $.each(updates, function (index, item) {
                if (/^(?:fill|update)$/.test(item.action) && item.type === "todoList") {
                    list = item.value;
                    return false;
                }
            });
            retUpdates = [].concat.apply([], $.map(updates, function (update) {
                var todoItem, prevItem;
                if (update.type === "todo") {
                    if (update.id) {
                        todoItem = $.grep(list, function (todo, i) {
                            return todo.id === update.id;
                        })[0];
                    }
                    if (update.action === "cancel") {
                        return {
                            type: "todo",
                            action: "update",
                            id: update.id,
                            value: {
                                editing: false,
                                content: todoItem && todoItem.content
                            }
                        };
                    }
                    if (update.action === "create") {
                        if (!hashTest(update.value.done)) {
                            return [];
                        }
                    }
                    if (update.action === "update") {
                        if (todoItem && !hashTest(todoItem.done)) {
                            return {
                                type: "todo",
                                action: "destroy",
                                id: update.id
                            };
                        }
                        if (("done" in update.value) && hashTest(update.value.done)) {
                            prevItem = $.grep(prevList, function (todo, i) {
                                return todo.id === update.id;
                            })[0];
                            if (!hashTest(prevItem.done)) {
                                return {
                                    type: "todo",
                                    action: "viewinsert",
                                    value: todoItem,
                                    id: update.id
                                };
                            }
                        }
                    }
                }

                if (update.action === "update" && update.type === "hash") {
                    return [update, {
                        type: "todoList",
                        action: "viewfill",
                        value: $.grep(list, function (item, index) {
                            return hashTest(item.done);
                        })
                    }];
                }

                return update;
            }));
            AppModel.todoList = list;
            count = list.length;
            doneCount = $.grep(list, function (item) {
                return item.done;
            }).length;
            retUpdates.push({
                type: "allDone",
                action: "update",
                value: count > 0 && (doneCount === count)
            });
            if (count !== AppModel.count) {
                AppModel.count = count;
                should = true;
                ret.count = count;
            }
            if (doneCount !== AppModel.doneCount) {
                AppModel.doneCount = doneCount;
                should = true;
                ret.doneCount = doneCount;
            }
            return should ? retUpdates.concat(ret) : retUpdates;
        };
    })(), viewElements = {
        todoForm: $("#todoForm"),
        todoItemTemplate: $($("#todoItemSource").html()),
        listArea: $("#listArea"),
        clearCompleted: $("#clearCompleted"),
        linkArea: $("#hashLinks"),
        allMarker: $("#markAll")
    }, viewActionMaps = (function () {
        var makeTodoElement = function (itemValue) {
            var item = viewElements.todoItemTemplate.clone();
            item.attr("data-todo-id", itemValue.id);
            if (itemValue.content) {
                item.find("[data-role=content]").text(itemValue.content);
                item.find("[data-role=todoInput]").val(itemValue.content);
            }
            if (itemValue.done) {
                item.addClass("todo-item--done");
                item.find("[data-role=doneCheck]").prop("checked", true);
            }
            return item.get(0);
        };
        return {
            form: {
                update: function (update) {
                    var formInput = viewElements.todoForm.find("[data-role=formInput]");
                    // alert(formInput.val());
                    formInput.val(update.value);
                    return update;
                }
            }, todo: {
                create: function (update) {
                    viewElements.listArea.prepend($(makeTodoElement(update.value)).addClass("todo-item--animate").get(0));
                }, viewinsert: function (update) {
                    var el = makeTodoElement(update.value), justOlder = viewElements.listArea.find("[data-todo-id]").filter(function () {
                        return update.id >= +$(this).attr("data-todo-id");
                    }).get(0);
                    if (justOlder) {
                        $(justOlder).before(el);
                    } else {
                        viewElements.listArea.prepend(el);
                    }
                }, destroy: function (update) {
                    viewElements.listArea.find("[data-todo-id=" + update.id + "]").remove();
                }, update: function (update) {
                    var updateValue = update.value,
                        elementUpdated = viewElements.listArea.find("[data-todo-id=" + update.id + "]");
                    if ("done" in updateValue) {
                        elementUpdated[updateValue.done ? "addClass" : "removeClass"]("todo-item--done");
                        elementUpdated.find("[data-role=doneCheck]").prop("checked", updateValue.done);
                    }
                    if ("editing" in updateValue) {
                        elementUpdated[updateValue.editing ? "addClass" : "removeClass"]("todo-item--editing");
                    }
                    if ("content" in updateValue) {
                        elementUpdated.find("[data-role=content]").text(updateValue.content);
                        elementUpdated.find("[data-role=todoInput]").val(updateValue.content);
                    }
                }
            }, todoList: {
                viewfill: function (update) {
                    var items = update.value.slice(0);
                    items.sort(function (x, y) {
                        return y.id - x.id;
                    });
                    $($.map(items, function (todo, index) {
                        return makeTodoElement(todo);
                    })).prependTo(viewElements.listArea.empty());
                }
            }, counter: {
                update: function (update) {
                    if ("doneCount" in update) {
                        viewElements.clearCompleted.text("Clear " + update.doneCount + " completed")[update.doneCount ? "show" : "hide"]();
                    }
                    if ("count" in update) {
                        viewElements.allMarker.parent("label")[update.count ? "show" : "hide"]();
                    }
                }
            }, hash: {
                update: function () {
                    var match = getHashSub(location.hash) || "all";
                    viewElements.linkArea.find("[data-hash-link]").each(function (i, el) {
                        $(this)[$(this).attr("data-hash-link") === match ? "addClass" : "removeClass"]("todo-status__link--current");
                    });
                }
            }, allDone: {
                update: function (update) {
                    viewElements.allMarker.prop("checked", update.value);
                }
            }
        };
    })(), viewTransformation = function (updates) {
        /// <summary>Not implemented.</summary>
        /// <param name="updates" type="Array"/>
        return $.map(updates, function (update, index) {
            var subMap = viewActionMaps[update.type], action = subMap && subMap[update.action];
            if (action) {
                return action(update);
            }
            return update;
        });
    }, performTransformations = combineTransformations([
        storageTransformation,
        modelTransformation,
        viewTransformation
    ]);

    viewElements.todoForm.on("submit", function () {
        // alert(1);
        var inputValue = $(this).find("[data-role=formInput]").val();
        if (inputValue) {
            performTransformations([
                {
                    action: "update",
                    type: "form",
                    value: ""
                }, {
                    action: "precreate",
                    type: "todo",
                    value: {
                        content: inputValue,
                        done: false
                    }
                }
            ]);
        }
        return false;
    });

    viewElements.allMarker.on("change", function () {
        performTransformations([
            {
                type: "todoList",
                action: "markall",
                value: $(this).prop("checked")
            }
        ]);
    });

    viewElements.clearCompleted.on("click", function () {
        performTransformations([
            {
                action: "clearcompleted",
                type: "todoList"
            }
        ]);
        return false;
    });

    viewElements.listArea.on("click", "[data-role=destroyTodo]", function () {
        var todoId = +$(this).parents("[data-todo-id]").attr("data-todo-id");
        performTransformations([
            {
                action: "predestroy",
                type: "todo",
                id: todoId
            }
        ]);
    }).on("change", "[data-role=doneCheck]", function () {
        var todoId = +$(this).parents("[data-todo-id]").attr("data-todo-id");
        performTransformations([
            {
                action: "preupdate",
                type: "todo",
                id: todoId,
                value: {
                    done: $(this).prop("checked")
                }
            }
        ]);
    }).on("dblclick", "[data-role=content]", function () {
        var todoId = +$(this).parents("[data-todo-id]").attr("data-todo-id");
        performTransformations([
            {
                action: "update", // Update without syncing to storage
                type: "todo",
                id: todoId,
                value: {
                    editing: true
                }
            }
        ]);
    }).on("keyup", "[data-role=todoInput]", function (e) {
        /// <param name="e" type="KeyboardEvent"/>
        var todoId = +$(this).parents("[data-todo-id]").attr("data-todo-id"), that = this;
        if (e.keyCode === 13) {
            this.blur();
        } if (e.keyCode === 27) {
            $(this).data("canceled", true);
            performTransformations([
                {
                    action: "cancel",
                    type: "todo",
                    id: todoId
                }
            ]).then(function () {
                that.blur();
                setTimeout(function () {
                    $(that).data("canceled", false);
                }, 0);
            });
        }
    }).on("blur", "[data-role=todoInput]", function (e) {
        var todoId = +$(this).parents("[data-todo-id]").attr("data-todo-id"), inputValue = $(this).val();
        if ($(this).data("canceled")) {
            return;
        }
        performTransformations(inputValue ? [
             {
                 action: "preupdate",
                 type: "todo",
                 id: todoId,
                 value: {
                     content: inputValue
                 }
             }, {
                 action: "update",
                 type: "todo",
                 id: todoId,
                 value: {
                     editing: false
                 }
             }
        ] : [
                {
                    action: "cancel",
                    type: "todo",
                    id: todoId
                }
        ]);
    });

    performTransformations([
        {
            type: "todoList",
            action: "initiate"
        }
    ]);

    $(window).on("hashchange", function () {
        performTransformations([
            {
                type: "hash",
                action: "update"
            }
        ]);
    }).trigger("hashchange");
});

Update 2: Mark all as done added

Screenshot: [The same as above]

Code:

<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title>Todo list</title>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="/styles/todo.css" />
</head>
<body>
    <div class="app-container">
        <h1 class="app-title">Todo list</h1>
        <div class="app-area">
            <form class="todo-form" id="todoForm">
                <label class="todo-form__label">
                    <input type="checkbox" id="markAll" />
                </label>
                <input class="todo-form__input" placeholder="What needs to be done&hellip;" data-role="formInput" />
                <div class="todo-form__btnArea"><button type="submit" class="todo-form__btn">Add</button></div>
            </form>
            <ul class="todo-list" id="listArea"></ul>
        </div>
        <p class="todo-status">
            <a href="#" id="clearCompleted" class="todo-status__link todo-status__clearLink"></a>
            <span id="hashLinks">
                <a href="#!/" data-hash-link="all" class="todo-status__link">All</a>&ensp;&middot;&ensp;<a href="#!/completed" data-hash-link="completed" class="todo-status__link">Completed</a>&ensp;&middot;&ensp;<a href="#!/remaining" data-hash-link="remaining" class="todo-status__link">Remaining</a>
            </span>
        </p>
    </div>
    <script id="todoItemSource" type="text/plain">
        <li class="todo-item">
            <label class="todo-item__label">
                <input type="checkbox" data-role="doneCheck" />
            </label>
            <div class="todo-item__content">
                <button type="button" class="todo-item__clear" data-role="destroyTodo"></button>
                <div class="todo-item__text" data-role="content"></div>
                <label class="todo-item__inputArea">
                    <input class="todo-item__input" data-role="todoInput" />
                </label>
            </div>
        </li>
    </script>
    <script src="/Scripts/jquery-1.12.4.min.js"></script>
    <script>
        jQuery(function ($) {
            "use strict";
            var AppStorage = {
                read: function () {
                    /// <returns type="Array"/>
                    return JSON.parse(localStorage.getItem("todoList") || "null") || [];
                },
                write: function (newList) {
                    localStorage.setItem("todoList", JSON.stringify(newList));
                }
            }, AppModel = {
                todoList: null,
                count: 0,
                doneCount: 0,
                allDone: false
            }, combineTransformations = (function () {
                var promiseJoin = function (f, g) {
                    /// <param name="f" type="Function"/>
                    /// <param name="g" type="Function"/>
                    return function (x) {
                        /// <returns type="Promise"/>
                        return f(x).then(g);
                    };
                }, idTransformation = function (x) {
                    var d = $.Deferred();
                    d.resolve(x);
                    return d.promise();
                };
                return function (transformations) {
                    /// <param name="transformations" type="Array"/>
                    var ret = idTransformation;
                    $.each(transformations || [], function (i, val) {
                        ret = promiseJoin(ret, val);
                    });
                    return ret;
                };
            })(), storageActionMap = {
                precreate: function (list, item) {
                    /// <summary>returns the transformed item and list.</summary>
                    if (item.type !== "todo") {
                        return { list: list, item: item };
                    }
                    var val = item.value,
                        obj = { id: +new Date() };
                    $.extend(obj, val);
                    return {
                        list: [obj].concat(list),
                        item: {
                            action: "create",
                            value: obj,
                            id: obj.id,
                            type: "todo"
                        }
                    };
                }, preupdate: function (list, item) {
                    if (item.type !== "todo") {
                        return { list: list, item: item };
                    }
                    var id = item.id,
                        retList = $.map(list, function (element, index) {
                            if (id !== element.id) { return element; }
                            return $.extend({}, element, item.value);
                        });
                    return {
                        list: retList,
                        item: {
                            action: "update",
                            value: item.value,
                            id: id,
                            type: "todo"
                        }
                    };
                }, predestroy: function (list, item) {
                    /// <summary>returns the transformed item and list.</summary>
                    if (item.type !== "todo") {
                        return { list: list, item: item };
                    }
                    var id = item.id,
                        retList = $.grep(list, function (element) {
                            return id !== element.id;
                        });
                    return {
                        list: retList,
                        item: {
                            action: "destroy",
                            type: "todo",
                            id: id
                        }
                    };
                }, initiate: function (list, item) {
                    /// <param name="list" type="Array">The todo list. Initiated from storage.</param>
                    if (item.type !== "todoList") {
                        return { list: list, item: item };
                    }
                    return {
                        list: list,
                        item: {
                            action: "fill",
                            type: "todoList",
                            value: list
                        }
                    }
                }, clearcompleted: function (list, item) {
                    if (item.type !== "todoList") {
                        return { list: list, item: item };
                    }
                    var ret = [], retList = $.grep(list, function (element) {
                        if (element.done) {
                            ret.push({
                                action: "destroy",
                                type: "todo",
                                id: element.id
                            });
                            return false;
                        }
                        return true;
                    });
                    return {
                        list: retList,
                        item: ret
                    };
                }, markall: function (list, item) {
                    if (item.type !== "todoList") {
                        return { list: list, item: item };
                    }
                    var ret = [], retList = $.map(list, function (element) {
                        var doneObj = { done: item.value };
                        if (element.done !== item.value) {
                            ret.push({
                                action: "update",
                                type: "todo",
                                id: element.id,
                                value: doneObj
                            });
                            return $.extend({}, element, doneObj);
                        }
                        return element;
                    });
                    return {
                        list: retList,
                        item: ret
                    };
                }
            }, storageTransformation = function (updates) {
                /// <summary>Not implemented.</summary>
                /// <param name="updates" type="Array"/>
                var list = null,
                    should = false,
                    transformed = [].concat.apply([], $.map(updates, function (item, i) {
                        /// <summary>Updates the list and returns the item per iteration.</summary>
                        /// <param name="val" type="Object"/>
                        // Do something
                        var temp;
                        if (!item) {
                            return [];
                        }
                        if (item.action in storageActionMap) {
                            if (!should) {
                                should = true;
                                list = AppStorage.read();
                            }
                            temp = storageActionMap[item.action](list, item);
                            list = temp.list || list;
                            return temp.item || [];
                        }
                        return item;
                    })), d = $.Deferred();
                if (should) {
                    AppStorage.write(list);
                }
                setTimeout(function () {
                    d.resolve(should ? transformed.concat({
                        type: "todoList",
                        action: "update",
                        value: list
                    }) : transformed);
                }, 1);
                return d.promise();
            }, getHashSub = function (hash) {
                return hash.replace(/^#(?:!\/)?/, "");
            }, hashTest = function (done) {
                /// <param name="done" type="Boolean"/>
                switch (getHashSub(location.hash)) {
                    case "completed":
                        return done;
                    case "remaining":
                        return !done;
                }
                return true;
            }, modelTransformation = (function () {
                var list = [];
                return function (updates) {
                    /// <summary>Not implemented. Update model and computed properties.</summary>
                    /// <param name="updates" type="Array"/>
                    var count, doneCount, ret = {
                        action: "update",
                        type: "counter"
                    }, should = false, prevList = list, retUpdates;
                    $.each(updates, function (index, item) {
                        if (/^(?:fill|update)$/.test(item.action) && item.type === "todoList") {
                            list = item.value;
                            return false;
                        }
                    });
                    retUpdates = [].concat.apply([], $.map(updates, function (update) {
                        var todoItem, prevItem;
                        if (update.type === "todo") {
                            if (update.id) {
                                todoItem = $.grep(list, function (todo, i) {
                                    return todo.id === update.id;
                                })[0];
                            }
                            if (update.action === "cancel") {
                                return {
                                    type: "todo",
                                    action: "update",
                                    id: update.id,
                                    value: {
                                        editing: false,
                                        content: todoItem && todoItem.content
                                    }
                                };
                            }
                            if (update.action === "create") {
                                if (!hashTest(update.value.done)) {
                                    return [];
                                }
                            }
                            if (update.action === "update") {
                                if (todoItem && !hashTest(todoItem.done)) {
                                    return {
                                        type: "todo",
                                        action: "destroy",
                                        id: update.id
                                    };
                                }
                                if (("done" in update.value) && hashTest(update.value.done)) {
                                    prevItem = $.grep(prevList, function (todo, i) {
                                        return todo.id === update.id;
                                    })[0];
                                    if (!hashTest(prevItem.done)) {
                                        return {
                                            type: "todo",
                                            action: "create",
                                            value: todoItem
                                        };
                                    }
                                }
                            }
                        }

                        if (update.action === "update" && update.type === "hash") {
                            return [update, {
                                type: "todoList",
                                action: "viewfill",
                                value: $.grep(list, function (item, index) {
                                    return hashTest(item.done);
                                })
                            }];
                        }

                        return update;
                    }));
                    AppModel.todoList = list;
                    count = list.length;
                    doneCount = $.grep(list, function (item) {
                        return item.done;
                    }).length;
                    retUpdates.push({
                        type: "allDone",
                        action: "update",
                        value: count > 0 && (doneCount === count)
                    });
                    if (count !== AppModel.count) {
                        AppModel.count = count;
                        should = true;
                        ret.count = count;
                    }
                    if (doneCount !== AppModel.doneCount) {
                        AppModel.doneCount = doneCount;
                        should = true;
                        ret.doneCount = doneCount;
                    }
                    return should ? retUpdates.concat(ret) : retUpdates;
                };
            })(), viewElements = {
                todoForm: $("#todoForm"),
                todoItemTemplate: $($("#todoItemSource").html()),
                listArea: $("#listArea"),
                clearCompleted: $("#clearCompleted"),
                linkArea: $("#hashLinks"),
                allMarker: $("#markAll")
            }, viewActionMaps = (function () {
                var makeTodoElement = function (itemValue) {
                    var item = viewElements.todoItemTemplate.clone();
                    item.attr("data-todo-id", itemValue.id);
                    if (itemValue.content) {
                        item.find("[data-role=content]").text(itemValue.content);
                        item.find("[data-role=todoInput]").val(itemValue.content);
                    }
                    if (itemValue.done) {
                        item.addClass("todo-item--done");
                        item.find("[data-role=doneCheck]").prop("checked", true);
                    }
                    return item.get(0);
                };
                return {
                    form: {
                        update: function (update) {
                            var formInput = viewElements.todoForm.find("[data-role=formInput]");
                            // alert(formInput.val());
                            formInput.val(update.value);
                            return update;
                        }
                    }, todo: {
                        create: function (update) {
                            if (hashTest(update.value.done)) {
                                viewElements.listArea.prepend($(makeTodoElement(update.value)).addClass("todo-item--animate").get(0));
                            }
                        }, destroy: function (update) {
                            viewElements.listArea.find("[data-todo-id=" + update.id + "]").remove();
                        }, update: function (update) {
                            var updateValue = update.value,
                                elementUpdated = viewElements.listArea.find("[data-todo-id=" + update.id + "]");
                            if ("done" in updateValue) {
                                elementUpdated[updateValue.done ? "addClass" : "removeClass"]("todo-item--done");
                                elementUpdated.find("[data-role=doneCheck]").prop("checked", updateValue.done);
                            }
                            if ("editing" in updateValue) {
                                elementUpdated[updateValue.editing ? "addClass" : "removeClass"]("todo-item--editing");
                            }
                            if ("content" in updateValue) {
                                elementUpdated.find("[data-role=content]").text(updateValue.content);
                                elementUpdated.find("[data-role=todoInput]").val(updateValue.content);
                            }
                        }
                    }, todoList: {
                        viewfill: function (update) {
                            var items = update.value.slice(0);
                            items.sort(function (x, y) {
                                return y.id - x.id;
                            });
                            $($.map(items, function (todo, index) {
                                return makeTodoElement(todo);
                            })).prependTo(viewElements.listArea.empty());
                        }
                    }, counter: {
                        update: function (update) {
                            if ("doneCount" in update) {
                                viewElements.clearCompleted.text("Clear " + update.doneCount + " completed")[update.doneCount ? "show" : "hide"]();
                            }
                        }
                    }, hash: {
                        update: function () {
                            var match = getHashSub(location.hash) || "all";
                            viewElements.linkArea.find("[data-hash-link]").each(function (i, el) {
                                $(this)[$(this).attr("data-hash-link") === match ? "addClass" : "removeClass"]("todo-status__link--current");
                            });
                        }
                    }, allDone: {
                        update: function (update) {
                            viewElements.allMarker.prop("checked", update.value);
                        }
                    }
                };
            })(), viewTransformation = function (updates) {
                /// <summary>Not implemented.</summary>
                /// <param name="updates" type="Array"/>
                return $.map(updates, function (update, index) {
                    var subMap = viewActionMaps[update.type], action = subMap && subMap[update.action];
                    if (action) {
                        return action(update);
                    }
                    return update;
                });
            }, performTransformations = combineTransformations([
                storageTransformation,
                modelTransformation,
                viewTransformation
            ]);

            viewElements.todoForm.on("submit", function () {
                // alert(1);
                var inputValue = $(this).find("[data-role=formInput]").val();
                if (inputValue) {
                    performTransformations([
                        {
                            action: "update",
                            type: "form",
                            value: ""
                        }, {
                            action: "precreate",
                            type: "todo",
                            value: {
                                content: inputValue,
                                done: false
                            }
                        }
                    ]);
                }
                return false;
            });

            viewElements.allMarker.on("change", function () {
                performTransformations([
                    {
                        type: "todoList",
                        action: "markall",
                        value: $(this).prop("checked")
                    }
                ]);
            });

            viewElements.clearCompleted.on("click", function () {
                performTransformations([
                    {
                        action: "clearcompleted",
                        type: "todoList"
                    }
                ]);
                return false;
            });

            viewElements.listArea.on("click", "[data-role=destroyTodo]", function () {
                var todoId = +$(this).parents("[data-todo-id]").attr("data-todo-id");
                performTransformations([
                    {
                        action: "predestroy",
                        type: "todo",
                        id: todoId
                    }
                ]);
            }).on("change", "[data-role=doneCheck]", function () {
                var todoId = +$(this).parents("[data-todo-id]").attr("data-todo-id");
                performTransformations([
                    {
                        action: "preupdate",
                        type: "todo",
                        id: todoId,
                        value: {
                            done: $(this).prop("checked")
                        }
                    }
                ]);
            }).on("dblclick", "[data-role=content]", function () {
                var todoId = +$(this).parents("[data-todo-id]").attr("data-todo-id");
                performTransformations([
                    {
                        action: "update", // Update without syncing to storage
                        type: "todo",
                        id: todoId,
                        value: {
                            editing: true
                        }
                    }
                ]);
            }).on("keyup", "[data-role=todoInput]", function (e) {
                /// <param name="e" type="KeyboardEvent"/>
                var todoId = +$(this).parents("[data-todo-id]").attr("data-todo-id"), that = this;
                if (e.keyCode === 13) {
                    this.blur();
                } if (e.keyCode === 27) {
                    $(this).data("canceled", true);
                    performTransformations([
                        {
                            action: "cancel",
                            type: "todo",
                            id: todoId
                        }
                    ]).then(function () {
                        that.blur();
                        setTimeout(function () {
                            $(that).data("canceled", false);
                        }, 0);
                    });
                }
            }).on("blur", "[data-role=todoInput]", function (e) {
                var todoId = +$(this).parents("[data-todo-id]").attr("data-todo-id"), inputValue = $(this).val();
                if ($(this).data("canceled")) {
                    return;
                }
                performTransformations(inputValue ? [
                     {
                         action: "preupdate",
                         type: "todo",
                         id: todoId,
                         value: {
                             content: inputValue
                         }
                     }, {
                         action: "update",
                         type: "todo",
                         id: todoId,
                         value: {
                             editing: false
                         }
                     }
                ] : [
                        {
                            action: "cancel",
                            type: "todo",
                            id: todoId
                        }
                ]);
            });

            performTransformations([
                {
                    type: "todoList",
                    action: "initiate"
                }
            ]);

            $(window).on("hashchange", function () {
                performTransformations([
                    {
                        type: "hash",
                        action: "update"
                    }
                ]);
            }).trigger("hashchange");
        });
    </script>
</body>
</html>

Update 1: Without “mark all as done”

Screenshot: image

Code:

<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title>Todo list</title>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="/styles/todo.css" />
</head>
<body>
    <div class="app-container" id="appArea">
        <h1 class="app-title">Todo list</h1>
        <div class="app-area">
            <form class="todo-form" id="todoForm">
                <input class="todo-form__input" placeholder="What needs to be done&hellip;" data-role="formInput" />
                <div class="todo-form__btnArea"><button type="submit" class="todo-form__btn">Add</button></div>
            </form>
            <ul class="todo-list" id="listArea"></ul>
        </div>
        <p class="todo-status">
            <a href="#" id="clearCompleted" class="todo-status__link todo-status__clearLink"></a>
            <span id="hashLinks">
                <a href="#!/" data-hash-link="all" class="todo-status__link">All</a>&ensp;&middot;&ensp;<a href="#!/completed" data-hash-link="completed" class="todo-status__link">Completed</a>&ensp;&middot;&ensp;<a href="#!/remaining" data-hash-link="remaining" class="todo-status__link">Remaining</a>
            </span>
        </p>
    </div>
    <script id="todoItemSource" type="text/plain">
        <li class="todo-item">
            <label class="todo-item__label">
                <input type="checkbox" data-role="doneCheck" />
            </label>
            <div class="todo-item__content">
                <button type="button" class="todo-item__clear" data-role="destroyTodo"></button>
                <div class="todo-item__text" data-role="content"></div>
                <label class="todo-item__inputArea">
                    <input class="todo-item__input" data-role="todoInput" />
                </label>
            </div>
        </li>
    </script>
    <script src="/Scripts/jquery-1.12.4.min.js"></script>
    <script>
        jQuery(function ($) {
            "use strict";
            var AppStorage = {
                read: function () {
                    /// <returns type="Array"/>
                    return JSON.parse(localStorage.getItem("todoList") || "null") || [];
                },
                write: function (newList) {
                    localStorage.setItem("todoList", JSON.stringify(newList));
                }
            }, AppModel = {
                todoList: null,
                count: 0,
                doneCount: 0
            }, combineTransformations = (function () {
                var promiseJoin = function (f, g) {
                    /// <param name="f" type="Function"/>
                    /// <param name="g" type="Function"/>
                    return function (x) {
                        /// <returns type="Promise"/>
                        return f(x).then(g);
                    };
                }, idTransformation = function (x) {
                    var d = $.Deferred();
                    d.resolve(x);
                    return d.promise();
                };
                return function (transformations) {
                    /// <param name="transformations" type="Array"/>
                    var ret = idTransformation;
                    $.each(transformations || [], function (i, val) {
                        ret = promiseJoin(ret, val);
                    });
                    return ret;
                };
            })(), storageActionMap = {
                precreate: function (list, item) {
                    /// <summary>returns the transformed item and list.</summary>
                    if (item.type !== "todo") {
                        return { list: list, item: item };
                    }
                    var val = item.value,
                        obj = { id: +new Date() };
                    $.extend(obj, val);
                    return {
                        list: [obj].concat(list),
                        item: {
                            action: "create",
                            value: obj,
                            id: obj.id,
                            type: "todo"
                        }
                    };
                }, preupdate: function (list, item) {
                    if (item.type !== "todo") {
                        return { list: list, item: item };
                    }
                    var id = item.id,
                        retList = $.map(list, function (element, index) {
                            if (id !== element.id) { return element; }
                            return $.extend({}, element, item.value);
                        });
                    return {
                        list: retList,
                        item: {
                            action: "update",
                            value: item.value,
                            id: id,
                            type: "todo"
                        }
                    };
                }, predestroy: function (list, item) {
                    /// <summary>returns the transformed item and list.</summary>
                    if (item.type !== "todo") {
                        return { list: list, item: item };
                    }
                    var id = item.id,
                        retList = $.grep(list, function (element) {
                            return id !== element.id;
                        });
                    return {
                        list: retList,
                        item: {
                            action: "destroy",
                            type: "todo",
                            id: id
                        }
                    };
                }, initiate: function (list, item) {
                    /// <param name="list" type="Array">The todo list. Initiated from storage.</param>
                    if (item.type !== "todoList") {
                        return { list: list, item: item };
                    }
                    return {
                        list: list,
                        item: {
                            action: "fill",
                            type: "todoList",
                            value: list
                        }
                    }
                }, clearcompleted: function (list, item) {
                    if (item.type !== "todoList") {
                        return { list: list, item: item };
                    }
                    var ret = [], retList = $.grep(list, function (element) {
                        if (element.done) {
                            ret.push({
                                action: "destroy",
                                type: "todo",
                                id: element.id
                            });
                            return false;
                        }
                        return true;
                    });
                    return {
                        list: retList,
                        item: ret
                    };
                }
            }, storageTransformation = function (updates) {
                /// <summary>Not implemented.</summary>
                /// <param name="updates" type="Array"/>
                var list = null,
                    should = false,
                    transformed = [].concat.apply([], $.map(updates, function (item, i) {
                        /// <summary>Updates the list and returns the item per iteration.</summary>
                        /// <param name="val" type="Object"/>
                        // Do something
                        var temp;
                        if (!item) {
                            return [];
                        }
                        if (item.action in storageActionMap) {
                            if (!should) {
                                should = true;
                                list = AppStorage.read();
                            }
                            temp = storageActionMap[item.action](list, item);
                            list = temp.list || list;
                            return temp.item || [];
                        }
                        return item;
                    })), d = $.Deferred();
                if (should) {
                    AppStorage.write(list);
                }
                setTimeout(function () {
                    d.resolve(should ? transformed.concat({
                        type: "todoList",
                        action: "update",
                        value: list
                    }) : transformed);
                }, 1);
                return d.promise();
            }, getHashSub = function (hash) {
                return hash.replace(/^#(?:!\/)?/, "");
            }, hashTest = function (done) {
                /// <param name="done" type="Boolean"/>
                switch (getHashSub(location.hash)) {
                    case "completed":
                        return done;
                    case "remaining":
                        return !done;
                }
                return true;
            }, modelTransformation = (function () {
                var list = [];
                return function (updates) {
                    /// <summary>Not implemented. Update model and computed properties.</summary>
                    /// <param name="updates" type="Array"/>
                    var count, doneCount, ret = {
                        action: "update",
                        type: "counter"
                    }, should = false, prevList = list, retUpdates;
                    $.each(updates, function (index, item) {
                        if (/^(?:fill|update)$/.test(item.action) && item.type === "todoList") {
                            list = item.value;
                            return false;
                        }
                    });
                    retUpdates = [].concat.apply([], $.map(updates, function (update) {
                        var todoItem, prevItem;
                        if (update.type === "todo") {
                            if (update.id) {
                                todoItem = $.grep(list, function (todo, i) {
                                    return todo.id === update.id;
                                })[0];
                            }
                            if (update.action === "cancel") {
                                return {
                                    type: "todo",
                                    action: "update",
                                    id: update.id,
                                    value: {
                                        editing: false,
                                        content: todoItem && todoItem.content
                                    }
                                };
                            }
                            if (update.action === "create") {
                                if (!hashTest(update.value.done)) {
                                    return [];
                                }
                            }
                            if (update.action === "update") {
                                if (todoItem && !hashTest(todoItem.done)) {
                                    return {
                                        type: "todo",
                                        action: "destroy",
                                        id: update.id
                                    };
                                }
                                if (("done" in update.value) && hashTest(update.value.done)) {
                                    prevItem = $.grep(prevList, function (todo, i) {
                                        return todo.id === update.id;
                                    })[0];
                                    if (!hashTest(prevItem.done)) {
                                        return {
                                            type: "todo",
                                            action: "create",
                                            value: todoItem
                                        };
                                    }
                                }
                            }
                        }

                        if (update.action === "update" && update.type === "hash") {
                            return [update, {
                                type: "todoList",
                                action: "viewfill",
                                value: $.grep(list, function (item, index) {
                                    return hashTest(item.done);
                                })
                            }];
                        }

                        return update;
                    }));
                    AppModel.todoList = list;
                    count = list.length;
                    doneCount = $.grep(list, function (item) {
                        return item.done;
                    }).length;
                    if (count !== AppModel.count) {
                        AppModel.count = count;
                        should = true;
                        ret.count = count;
                    }
                    if (doneCount !== AppModel.doneCount) {
                        AppModel.doneCount = doneCount;
                        should = true;
                        ret.doneCount = doneCount;
                    }
                    return should ? retUpdates.concat(ret) : retUpdates;
                };
            })(), viewElements = {
                todoForm: $("#todoForm"),
                todoItemTemplate: $($("#todoItemSource").html()),
                listArea: $("#listArea"),
                clearCompleted: $("#clearCompleted"),
                linkArea: $("#hashLinks")
            }, viewActionMaps = (function () {
                var makeTodoElement = function (itemValue) {
                    var item = viewElements.todoItemTemplate.clone();
                    item.attr("data-todo-id", itemValue.id);
                    if (itemValue.content) {
                        item.find("[data-role=content]").text(itemValue.content);
                        item.find("[data-role=todoInput]").val(itemValue.content);
                    }
                    if (itemValue.done) {
                        item.addClass("todo-item--done");
                        item.find("[data-role=doneCheck]").prop("checked", true);
                    }
                    return item.get(0);
                };
                return {
                    form: {
                        update: function (update) {
                            var formInput = viewElements.todoForm.find("[data-role=formInput]");
                            // alert(formInput.val());
                            formInput.val(update.value);
                            return update;
                        }
                    }, todo: {
                        create: function (update) {
                            if (hashTest(update.value.done)) {
                                viewElements.listArea.prepend($(makeTodoElement(update.value)).addClass("todo-item--animate").get(0));
                            }
                        }, destroy: function (update) {
                            viewElements.listArea.find("[data-todo-id=" + update.id + "]").remove();
                        }, update: function (update) {
                            var updateValue = update.value,
                                elementUpdated = viewElements.listArea.find("[data-todo-id=" + update.id + "]");
                            if ("done" in updateValue) {
                                elementUpdated[updateValue.done ? "addClass" : "removeClass"]("todo-item--done");
                            }
                            if ("editing" in updateValue) {
                                elementUpdated[updateValue.editing ? "addClass" : "removeClass"]("todo-item--editing");
                            }
                            if ("content" in updateValue) {
                                elementUpdated.find("[data-role=content]").text(updateValue.content);
                                elementUpdated.find("[data-role=todoInput]").val(updateValue.content);
                            }
                        }
                    }, todoList: {
                        viewfill: function (update) {
                            var items = update.value.slice(0);
                            items.sort(function (x, y) {
                                return y.id - x.id;
                            });
                            $($.map(items, function (todo, index) {
                                return makeTodoElement(todo);
                            })).prependTo(viewElements.listArea.empty());
                        }
                    }, counter: {
                        update: function (update) {
                            if ("doneCount" in update) {
                                viewElements.clearCompleted.text("Clear " + update.doneCount + " completed")[update.doneCount ? "show" : "hide"]();
                            }
                        }
                    }, hash: {
                        update: function () {
                            var match = getHashSub(location.hash)||"all";
                            viewElements.linkArea.find("[data-hash-link]").each(function (i, el) {
                                $(this)[$(this).attr("data-hash-link") === match ? "addClass" : "removeClass"]("todo-status__link--current");
                            });
                        }
                    }
                };
            })(), viewTransformation = function (updates) {
                /// <summary>Not implemented.</summary>
                /// <param name="updates" type="Array"/>
                return $.map(updates, function (update, index) {
                    var subMap = viewActionMaps[update.type], action = subMap && subMap[update.action];
                    if (action) {
                        return action(update);
                    }
                    return update;
                });
            }, performTransformations = combineTransformations([
                storageTransformation,
                modelTransformation,
                viewTransformation
            ]);

            viewElements.todoForm.on("submit", function () {
                // alert(1);
                var inputValue = $(this).find("[data-role=formInput]").val();
                if (inputValue) {
                    performTransformations([
                        {
                            action: "update",
                            type: "form",
                            value: ""
                        }, {
                            action: "precreate",
                            type: "todo",
                            value: {
                                content: inputValue,
                                done: false
                            }
                        }
                    ]);
                }
                return false;
            });

            viewElements.clearCompleted.on("click", function () {
                performTransformations([
                    {
                        action: "clearcompleted",
                        type: "todoList"
                    }
                ]);
                return false;
            });

            viewElements.listArea.on("click", "[data-role=destroyTodo]", function () {
                var todoId = +$(this).parents("[data-todo-id]").attr("data-todo-id");
                performTransformations([
                    {
                        action: "predestroy",
                        type: "todo",
                        id: todoId
                    }
                ]);
            }).on("change", "[data-role=doneCheck]", function () {
                var todoId = +$(this).parents("[data-todo-id]").attr("data-todo-id");
                performTransformations([
                    {
                        action: "preupdate",
                        type: "todo",
                        id: todoId,
                        value: {
                            done: $(this).prop("checked")
                        }
                    }
                ]);
            }).on("dblclick", "[data-role=content]", function () {
                var todoId = +$(this).parents("[data-todo-id]").attr("data-todo-id");
                performTransformations([
                    {
                        action: "update", // Update without syncing to storage
                        type: "todo",
                        id: todoId,
                        value: {
                            editing: true
                        }
                    }
                ]);
            }).on("keyup", "[data-role=todoInput]", function (e) {
                /// <param name="e" type="KeyboardEvent"/>
                var todoId = +$(this).parents("[data-todo-id]").attr("data-todo-id"), that = this;
                if (e.keyCode === 13) {
                    this.blur();
                } if (e.keyCode === 27) {
                    $(this).data("canceled", true);
                    performTransformations([
                        {
                            action: "cancel",
                            type: "todo",
                            id: todoId
                        }
                    ]).then(function () {
                        that.blur();
                        setTimeout(function () {
                            $(that).data("canceled", false);
                        }, 0);
                    });
                }
            }).on("blur", "[data-role=todoInput]", function (e) {
                var todoId = +$(this).parents("[data-todo-id]").attr("data-todo-id"), inputValue = $(this).val();
                if ($(this).data("canceled")) {
                    return;
                }
                performTransformations([
                    inputValue ? {
                        action: "preupdate",
                        type: "todo",
                        id: todoId,
                        value: {
                            content: inputValue
                        }
                    } : null, {
                        action: "update",
                        type: "todo",
                        id: todoId,
                        value: {
                            editing: false
                        }
                    }
                ]);
            });

            performTransformations([
                {
                    type: "todoList",
                    action: "initiate"
                }
            ]);

            $(window).on("hashchange", function () {
                performTransformations([
                    {
                        type: "hash",
                        action: "update"
                    }
                ]);
            }).trigger("hashchange");
        });
    </script>
</body>
</html>

Original idea

var combineTransformations = (function () {
    var promiseJoin = function (f, g) {
        /// <param name="f" type="Function"/>
        /// <param name="g" type="Function"/>
        return function (x) {
            /// <returns type="Promise"/>
            return f(x).then(g);
        };
    }, idTransformation = function (x) {
        var d = $.Deferred();
        d.resolve(x);
        return d.promise();
    };
    return function (transformations) {
        /// <param name="transformations" type="Array"/>
        var ret = idTransformation;
        $.each(transformations || [], function (i, val) {
            ret = promiseJoin(ret, val);
        });
        return ret;
    };
})(), storageTransformation = function (updates) {
    /// <summary>Not implemented.</summary>
    /// <param name="updates" type="Array"/>
    return $.when.apply($, $.map(updates, function (val, i) {
        /// <param name="val" type="Object"/>
        // Do something
    })).then(function () {
        return [].concat.apply([], arguments);
    });
}, modelTransformation = function (updates) {
    /// <summary>Not implemented.</summary>
    /// <param name="updates" type="Array"/>
}, viewTransformation = function (updates) {
    /// <summary>Not implemented.</summary>
    /// <param name="updates" type="Array"/>
}, appTransformations = combineTransformations([
    storageTransformation,
    modelTransformation,
    viewTransformation
]);

// Assume implemented
// When any data has any update, just call
appTransformations([{
    type: "Todo",
    action: "Preupdate",
    id: "2333",
    value: {
        content: "Learn jQuery."
    }
}]);

So that the appTransformations() function would automatically pop whatever is updated to the storage, the model, and the view and let the transformers correctly transform the updates to the next level.

infinnie commented 7 years ago

Submitted elsewhere: https://github.com/tastejs/todomvc/issues/1762