Статьи

Создание одностраничных веб-приложений с помощью Sinatra: часть 2

В первой части этого мини-сериала мы создали базовую структуру приложения, использующего интерфейс Sinatra JSON для базы данных SQLite, и интерфейс на основе Knockout, который позволяет нам добавлять задачи в нашу базу данных. В этой заключительной части мы рассмотрим некоторые более продвинутые функциональные возможности Knockout, включая сортировку, поиск, обновление и удаление.

Давайте начнем с того, где мы остановились; Вот соответствующая часть нашего файла index.erb .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<div id=»container»>
           <section id=»taskforms» class=»clearfix»>
               <div id=»newtaskform» class=»floatleft fifty»>
                   <h2>Create a New Task</h2>
                   <form id=»addtask» data-bind=»submit: addTask»>
                       <input data-bind=»value: newTaskDesc»>
                       <input type=»submit»>
                   </form>
               </div>
               <div id=»tasksearchform» class=»floatright fifty»>
                   <h2>Search Tasks</h2>
                   <form id=»searchtask»>
                       <input>
                   </form>
               </div>
           </section>
           <section id=»tasktable»>
               <h2>Incomplete Tasks remaining: <span>
               <a>Delete All Complete Tasks</a>
               <table>
                   <tbody><tr>
                       <th>DB ID</th>
                       <th>Description</th>
                       <th>Date Added</th>
                       <th>Date Modified</th>
                       <th>Complete?</th>
                       <th>Delete</th>
                   </tr>
                   <!— ko foreach: tasks —>
                   <tr>
                       <td data-bind=»text: id»></td>
                       <td data-bind=»text: description»></td>
                       <td data-bind=»text: created_at»></td>
                       <td data-bind=»text: updated_at»></td>
                       <td><input type=»checkbox» data-bind=»checked: complete, click: $parent.markAsComplete»> </td>
                       <td data-bind=»click: $parent.destroyTask» class=»destroytask»><a>X</a></td>
                   </tr>
                   <!— /ko —>
               </tbody></table>
           </section>
       </div>

Сортировка является распространенной задачей, используемой во многих приложениях. В нашем случае мы хотим отсортировать список задач по любому полю заголовка в нашей таблице списка задач. Мы начнем с добавления следующего кода в TaskViewModel :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
t.sortedBy = [];
t.sort = function(field){
    if (t.sortedBy.length && t.sortedBy[0] == field && t.sortedBy[1]==1){
            t.sortedBy[1]=0;
            t.tasks.sort(function(first,next){
                if (!next[field].call()){ return 1;
                return (next[field].call() < first[field].call()) ?
            });
    } else {
        t.sortedBy[0] = field;
        t.sortedBy[1] = 1;
        t.tasks.sort(function(first,next){
            if (!first[field].call()){ return 1;
            return (first[field].call() < next[field].call()) ?
        });
    }
}

Knockout предоставляет функцию сортировки для наблюдаемых массивов

Сначала мы определяем массив sortedBy как свойство нашей модели представления. Это позволяет нам хранить, если и как коллекция отсортирована.

Далее идет функция sort() . Он принимает аргумент field (поле, которое мы хотим отсортировать) и проверяет, отсортированы ли задачи по текущей схеме сортировки. Мы хотим отсортировать, используя тип переключения. Например, сортируйте по описанию один раз, и задачи располагаются в алфавитном порядке. Сортировка по описанию снова, и задачи расположены в обратном алфавитном порядке. Эта функция sort() поддерживает это поведение, проверяя самую последнюю схему сортировки и сравнивая ее с тем, что пользователь хочет отсортировать.

Knockout предоставляет функцию сортировки для наблюдаемых массивов. Он принимает функцию в качестве аргумента, который управляет тем, как должен сортироваться массив. Эта функция сравнивает два элемента из массива и возвращает 1 , 0 или -1 в результате этого сравнения. Все одинаковые значения сгруппированы вместе (что будет полезно для группировки завершенных и незавершенных задач).

Примечание: свойства элементов массива должны вызываться, а не просто вызываться; эти свойства на самом деле являются функциями, которые возвращают значение свойства, если оно вызывается без каких-либо аргументов.

Далее мы определяем привязки к заголовкам таблицы в нашем представлении.

1
2
3
4
5
6
<th data-bind=»click: function(){ sort(‘id’) }»>DB ID</th>
<th data-bind=»click: function(){ sort(‘description’) }»>Description</th>
<th data-bind=»click: function(){ sort(‘created_at’) }»>Date Added</th>
<th data-bind=»click: function(){ sort(‘updated_at’) }»>Date Modified</th>
<th data-bind=»click: function(){ sort(‘complete’) }»>Complete?</th>
<th>Delete</th>

Эти привязки позволяют каждому из заголовков инициировать сортировку на основе переданного строкового значения; каждый из них напрямую отображается на модель Task .


Далее мы хотим иметь возможность пометить задачу как завершенную, и мы выполним это, просто установив флажок, связанный с конкретной задачей. Давайте начнем с определения метода в TaskViewModel :

01
02
03
04
05
06
07
08
09
10
t.markAsComplete = function(task) {
    if (task.complete() == true){
        task.complete(true);
    } else {
        task.complete(false);
    }
    task._method = «put»;
    t.saveTask(task);
    return true;
}

Метод markAsComplete() принимает задачу в качестве аргумента, который автоматически передается Knockout при итерации по коллекции элементов. Затем мы переключаем свойство complete и добавляем ._method="put" к задаче. Это позволяет DataMapper использовать глагол HTTP PUT а не POST . Затем мы используем наш удобный t.saveTask() для сохранения изменений в базе данных. Наконец, мы возвращаем true потому что возвращение false предотвращает изменение состояния флажка.

Затем мы изменим представление, заменив код флажка внутри цикла задачи следующим:

1
<input type=»checkbox» data-bind=»checked: complete, click: $parent.markAsComplete»>

Это говорит нам о двух вещах:

  1. Если флажок « complete установлен на «Истина»
  2. При щелчке запустите markAsComplete() из родительского TaskViewModel (в данном случае TaskViewModel ). Это автоматически передает текущую задачу в цикл.

Чтобы удалить задачу, мы просто используем несколько удобных методов и вызываем saveTask() . В нашей TaskViewModel добавьте следующее:

1
2
3
4
5
t.destroyTask = function(task) {
    task._method = «delete»;
    t.tasks.destroy(task);
    t.saveTask(task);
};

Эта функция добавляет свойство, аналогичное методу «put», для выполнения задачи. Встроенный метод destroy() удаляет переданную задачу из наблюдаемого массива. Наконец, вызов saveTask() уничтожает задачу; то есть, если для ._method установлено значение «удалить».

Теперь нам нужно изменить наш взгляд; добавить следующее:

1
<td data-bind=»click: $parent.destroyTask» class=»destroytask»><a>X</a></td>

По функциональности это очень похоже на полный флажок. Обратите внимание, что class="destroytask" предназначен исключительно для стилизации.


Далее мы хотим добавить функцию «удалить все выполненные задачи». Сначала добавьте следующий код в TaskViewModel :

1
2
3
4
5
6
7
t.removeAllComplete = function() {
    ko.utils.arrayForEach(t.tasks(), function(task){
        if (task.complete()){
            t.destroyTask(task);
        }
    });
}

Эта функция просто перебирает задачи, чтобы определить, какие из них завершены, и мы вызываем метод destroyTask() для каждой завершенной задачи. На наш взгляд, добавьте следующее для ссылки «удалить все завершено».

1
<a data-bind=»click: removeAllComplete, visible: completeTasks().length > 0 «>Delete All Complete Tasks</a>

Наша привязка кликов будет работать правильно, но нам нужно определить completeTasks() . Добавьте следующее в нашу TaskViewModel :

1
2
3
t.completeTasks = ko.computed(function() {
    return ko.utils.arrayFilter(t.tasks(), function(task) { return (task.complete() && task._method != «delete») });
});

Этот метод является вычисляемым свойством. Эти свойства возвращают значение, которое вычисляется «на лету» при обновлении модели. В этом случае мы возвращаем отфильтрованный массив, который содержит только завершенные задачи, которые не помечены для удаления. Затем мы просто используем свойство length этого массива, чтобы скрыть или показать ссылку «Удалить все выполненные задачи».


Наш интерфейс также должен отображать количество незавершенных задач. Подобно нашей функции completeTasks() описанной выше, мы определяем функцию incompleteTasks() в TaskViewModel :

1
2
3
t.incompleteTasks = ko.computed(function() {
    return ko.utils.arrayFilter(t.tasks(), function(task) { return (!task.complete() && task._method != «delete») });
});

Затем мы получаем доступ к этому вычисленному отфильтрованному массиву в нашем представлении, например так:

1
<h2>Incomplete Tasks remaining: <span data-bind=»text: incompleteTasks().length»>

Мы хотим стилизовать выполненные элементы не так, как задачи в списке, и мы можем сделать это с помощью привязки css Knockout. Измените открывающий тег tr в нашем arrayForEach() задачи следующим образом.

1
<tr data-bind=»css: { ‘complete’: complete }, visible: isvisible»>

Это добавляет complete класс CSS в строку таблицы для каждой задачи, если его complete свойство имеет значение true .


Давайте избавимся от этих уродливых строк дат Ruby. Начнем с определения функции dateFormat в нашей TaskViewModel :

1
2
3
4
5
6
t.MONTHS = [«Jan», «Feb», «Mar», «Apr», «May», «Jun», «Jul», «Aug», «Sep», «Oct», «Nov», «Dec»];
t.dateFormat = function(date){
    if (!date) { return «refresh to see server date»;
    var d = new Date(date);
    return d.getHours() + «:» + d.getMinutes() + «, » + d.getDate() + » » + t.MONTHS[d.getMonth()] + «, » + d.getFullYear();
}

Эта функция довольно проста. Если по какой-либо причине дата не определена, нам просто нужно обновить браузер, чтобы получить дату в начальной функции извлечения Task . В противном случае мы создаем удобочитаемую дату с простым объектом JavaScript Date с помощью массива MONTHS . (Примечание: разумеется, нет необходимости использовать заглавные буквы для имени MONTHS ; это просто способ узнать, что это постоянное значение, которое не следует изменять.)

Затем мы добавляем следующие изменения в наше представление для свойств created_at и updated_at :

1
2
<td data-bind=»text: $root.dateFormat(created_at())»></td>
<td data-bind=»text: $root.dateFormat(updated_at())»></td>

Это передает свойства dateFormat() и updated_at в dateFormat() . Еще раз, важно помнить, что свойства каждой задачи не являются обычными свойствами; они функции. Чтобы получить их значение, вы должны вызвать функцию (как показано в примере выше). Примечание: $root — это ключевое слово, определенное Knockout, которое ссылается на ViewModel. dateFormat() метод dateFormat() определяется как метод корневого ViewModel ( TaskViewModel ).


Мы можем искать наши задачи различными способами, но мы сделаем все проще и выполним предварительный поиск. Имейте в виду, однако, что вполне вероятно, что эти результаты поиска будут основаны на базе данных, так как данные растут ради нумерации страниц. Но пока давайте определим наш метод search() в TaskViewModel :

01
02
03
04
05
06
07
08
09
10
11
12
13
t.query = ko.observable(»);
t.search = function(task){
    ko.utils.arrayForEach(t.tasks(), function(task){
        if (task.description() && t.query() != «»){
            task.isvisible(task.description().toLowerCase().indexOf(t.query().toLowerCase()) >= 0);
        } else if (t.query() == «») {
            task.isvisible(true);
        } else {
            task.isvisible(false);
        }
    })
    return true;
}

Мы видим, что это перебирает массив задач и проверяет, присутствует ли t.query() (обычное наблюдаемое значение) в описании задачи. Обратите внимание, что эта проверка фактически выполняется внутри функции сеттера для свойства task.isvisible . Если оценка равна false , задача не найдена, а для свойства isvisible установлено значение false . Если запрос равен пустой строке, все задачи должны быть видимыми. Если у задачи нет описания и запрос является непустым значением, задача не является частью возвращенного набора данных и является скрытой.

В нашем файле index.erb мы настроили наш интерфейс поиска следующим кодом:

1
2
3
<form id=»searchtask»>
    <input data-bind=»value: query, valueUpdate: ‘keyup’, event : { keyup : search}»>
</form>

В качестве входного значения ko.observable query . Далее мы видим, что событие valueUpdate определенно идентифицируется как событие valueUpdate . Наконец, мы устанавливаем ручную привязку событий к keyup для выполнения функции search ( t.search() ). Заполнение формы не требуется; список подходящих элементов будет отображаться и все еще может быть сортируемым, удаляемым и т. д. Поэтому все взаимодействия работают всегда.


01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
<!DOCTYPE html >
<html>
<!—[if lt IE 7]> <html class=»no-js lt-ie9 lt-ie8 lt-ie7″> <![endif]—>
<!—[if IE 7]> <html class=»no-js lt-ie9 lt-ie8″> <![endif]—>
<!—[if IE 8]> <html class=»no-js lt-ie9″> <![endif]—>
<!—[if gt IE 8]><!—> <!—<![endif]—>
    <body>
        <meta charset=»utf-8″>
        <meta http-equiv=»X-UA-Compatible» content=»IE=edge,chrome=1″>
        <title>ToDo</title>
        <meta name=»description» content=»»>
        <meta name=»viewport» content=»width=device-width»>
 
        <!— Place favicon.ico and apple-touch-icon.png in the root directory —>
        <link rel=»stylesheet» href=»styles/styles.css»>
        <script src=»scripts/modernizr-2.6.2.min.js»></script>
     
     
        <!—[if lt IE 7]>
            <p class=»chromeframe»>You are using an outdated browser.
        <![endif]—>
        <!— Add your site or application content here —>
        <div id=»container»>
            <section id=»taskforms» class=»clearfix»>
                <div id=»newtaskform» class=»floatleft fifty»>
                    <h2>Create a New Task</h2>
                    <form id=»addtask» data-bind=»submit: addTask»>
                        <input data-bind=»value: newTaskDesc»>
                        <input type=»submit»>
                    </form>
                </div>
                <div id=»tasksearchform» class=»floatright fifty»>
                    <h2>Search Tasks</h2>
                    <form id=»searchtask»>
                        <input data-bind=»value: query, valueUpdate: ‘keyup’, event : { keyup : search}»>
                    </form>
                </div>
            </section>
            <section id=»tasktable»>
                <h2>Incomplete Tasks remaining: <span data-bind=»text: incompleteTasks().length»>
                <a data-bind=»click: removeAllComplete, visible: completeTasks().length > 0 «>Delete All Complete Tasks</a>
                <table>
                    <tbody><tr>
                        <th data-bind=»click: function(){ sort(‘id’) }»>DB ID</th>
                        <th data-bind=»click: function(){ sort(‘description’) }»>Description</th>
                        <th data-bind=»click: function(){ sort(‘created_at’) }»>Date Added</th>
                        <th data-bind=»click: function(){ sort(‘updated_at’) }»>Date Modified</th>
                        <th data-bind=»click: function(){ sort(‘complete’) }»>Complete?</th>
                        <th>Delete</th>
                    </tr>
                    <!— ko foreach: tasks —>
                    <tr data-bind=»css: { ‘complete’: complete }, visible: isvisible»>
                        <td data-bind=»text: id»></td>
                        <td data-bind=»text: description»></td>
                        <td data-bind=»text: $root.dateFormat(created_at())»></td>
                        <td data-bind=»text: $root.dateFormat(updated_at())»></td>
                        <td><input type=»checkbox» data-bind=»checked: complete, click: $parent.markAsComplete»> </td>
                        <td data-bind=»click: $parent.destroyTask» class=»destroytask»><a>X</a></td>
                    </tr>
                    <!— /ko —>
                </tbody></table>
            </section>
        </div>
 
        <script src=»http://ajax.googleapis.com/ajax/libs/jquery/1.8.1/jquery.min.js»></script>
        <script>window.jQuery ||
        <script src=»scripts/knockout.js»></script>
        <script src=»scripts/app.js»></script>
 
        <!— Google Analytics: change UA-XXXXX-X to be your site’s ID.
        <script>
            var _gaq=[[‘_setAccount’,’UA-XXXXX-X’],[‘_trackPageview’]];
            (function(d,t){var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
            g.src=(‘https:’==location.protocol?’//ssl’:’//www’)+’.google-analytics.com/ga.js’;
            s.parentNode.insertBefore(g,s)}(document,’script’));
        </script>
    </body>
</html>
001
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
028
029
030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
function Task(data) {
    this.description = ko.observable(data.description);
    this.complete = ko.observable(data.complete);
    this.created_at = ko.observable(data.created_at);
    this.updated_at = ko.observable(data.updated_at);
    this.id = ko.observable(data.id);
    this.isvisible = ko.observable(true);
}
 
function TaskViewModel() {
    var t = this;
    t.tasks = ko.observableArray([]);
    t.newTaskDesc = ko.observable();
    t.sortedBy = [];
    t.query = ko.observable(»);
    t.MONTHS = [«Jan», «Feb», «Mar», «Apr», «May», «Jun», «Jul», «Aug», «Sep», «Oct», «Nov», «Dec»];
 
 
    $.getJSON(«http://localhost:9393/tasks», function(raw) {
        var tasks = $.map(raw, function(item) { return new Task(item) });
        t.tasks(tasks);
    });
 
    t.incompleteTasks = ko.computed(function() {
        return ko.utils.arrayFilter(t.tasks(), function(task) { return (!task.complete() && task._method != «delete») });
    });
    t.completeTasks = ko.computed(function() {
        return ko.utils.arrayFilter(t.tasks(), function(task) { return (task.complete() && task._method != «delete») });
    });
 
    // Operations
    t.dateFormat = function(date){
        if (!date) { return «refresh to see server date»;
        var d = new Date(date);
        return d.getHours() + «:» + d.getMinutes() + «, » + d.getDate() + » » + t.MONTHS[d.getMonth()] + «, » + d.getFullYear();
    }
    t.addTask = function() {
        var newtask = new Task({ description: this.newTaskDesc() });
        $.getJSON(«/getdate», function(data){
            newtask.created_at(data.date);
            newtask.updated_at(data.date);
            t.tasks.push(newtask);
            t.saveTask(newtask);
            t.newTaskDesc(«»);
        })
    };
    t.search = function(task){
        ko.utils.arrayForEach(t.tasks(), function(task){
            if (task.description() && t.query() != «»){
                task.isvisible(task.description().toLowerCase().indexOf(t.query().toLowerCase()) >= 0);
            } else if (t.query() == «») {
                task.isvisible(true);
            } else {
                task.isvisible(false);
            }
        })
        return true;
    }
    t.sort = function(field){
        if (t.sortedBy.length && t.sortedBy[0] == field && t.sortedBy[1]==1){
                t.sortedBy[1]=0;
                t.tasks.sort(function(first,next){
                    if (!next[field].call()){ return 1;
                    return (next[field].call() < first[field].call()) ?
                });
        } else {
            t.sortedBy[0] = field;
            t.sortedBy[1] = 1;
            t.tasks.sort(function(first,next){
                if (!first[field].call()){ return 1;
                return (first[field].call() < next[field].call()) ?
            });
        }
    }
    t.markAsComplete = function(task) {
        if (task.complete() == true){
            task.complete(true);
        } else {
            task.complete(false);
        }
        task._method = «put»;
        t.saveTask(task);
        return true;
    }
    t.destroyTask = function(task) {
        task._method = «delete»;
        t.tasks.destroy(task);
        t.saveTask(task);
    };
    t.removeAllComplete = function() {
        ko.utils.arrayForEach(t.tasks(), function(task){
            if (task.complete()){
                t.destroyTask(task);
            }
        });
    }
    t.saveTask = function(task) {
        var t = ko.toJS(task);
        $.ajax({
             url: «http://localhost:9393/tasks»,
             type: «POST»,
             data: t
        }).done(function(data){
            task.id(data.task.id);
        });
    }
}
ko.applyBindings(new TaskViewModel());

Обратите внимание на перестановку объявлений свойств в TaskViewModel .


Теперь у вас есть методы для создания более сложных приложений!

Эти два руководства ознакомили вас с процессом создания одностраничного приложения с помощью Knockout.js и Sinatra. Приложение может записывать и извлекать данные через простой интерфейс JSON, и оно имеет функции, выходящие за рамки простых действий CRUD, таких как массовое удаление, сортировка и поиск. С этими инструментами и примерами у вас теперь есть методы для создания гораздо более сложных приложений!