суббота, 6 февраля 2010 г.

AJAX с cl-closure-template. Часть 1.

В результате работы над текущим проектом графического редактора у меня зародилась идея, которая может быть применима к широкому кругу AJAX-приложений. Поскольку эта идея демонстрирует преимущества использования cl-closure-template, то решил рассказать об этом здесь. Ну и надеюсь, что будет целая серия постов, поэтому это "Часть 1". И да, в данный момент я не обладаю полным решением, поэтому оно будет изменяться от поста к посту.

Мне нравится как сделан github, поэтому я решил показать, как можно реализовать элементы управления в его стиле. Самый простой элемент (он встречается там достаточно часто) можно найти на странице проекта (если у вас есть проекты на github, то вы им безусловно пользовались), например строка с описанием проекта. По ней можно кликнуть мышкой, в результате чего появится форма, в которой можно редактировать это описание. Несколько обобщив концепцию данного элемента можно получить следующую схему:
  • Элемент модели данных (хранящийся на сервере) описывается с помощью фрагмента разметки
  • Пользователь может активировать процесс редактирования этого элемента с помощью клика мышкой или иным образом
  • В результате изначальная разметка скрывается и вместо неё появляется форма для ввода данных
  • Пользователь может либо сохранить внесённые изменения, либо отказаться от них, в результате чего форма для ввода данных скрывается и снова появляется изначальная разметка, но модифицированная на основе введённых данных
Реализовать подобное поведение можно полностью на уровне JavaScript (как это видимо и сделано на github), но в этом случае JavaScript код должен иметь достаточно точные знания о разметке. Во-первых, это приводит к появлению сильных связей между серверным кодом, генерирующим страницу, и JavaScript-кодом. Во-вторых, написание JavaScript кода даже в достаточно простых случаях может оказаться довольно нудным занятием. Я хочу, что бы JavaScript код, решающий подобную проблему был абсолютно минимальным и обладал бы некоторой степенью общности, что позволило бы использовать его для различных элементов, работающих по подобной схеме.

Используемое мной решение заключается в том, что бы не заниматься "тонким редактированием" разметки, а производить полное её замещение на основе шаблонов, реализованных с помощью cl-closure-template: изменилась модель - создали новую разметку и подставили её вместо старой.

Для начала потребуются следующие шаблоны (которые в основном повторяют соответствующую разметку, используемую в github на момент написания данного поста):
{namespace example.githubway.view}

// Представление текстового элемента
{template editable-text}
<div class="editable-text" json="{$json}">
<p>
{$value}
<em class="edit-text">click to edit</em>
</p>
</div>
{/template}

// Форма для редактирование элемента
{template edit-text}
<form method="post" action="{$saveLink}" json="{$json}">
<input type="text" value="{$value}" name="value"></input>

<div class="form-actions">
<input type="submit" class="minibutton save" value="Save"></input>
<span class="fakelink cancel">cancel</span>
</div>
</form>
{/template}
Эти шаблоны принимают следующие аргументы:
  • value - значение элемента
  • saveLink - URL, который может использоваться для обновления значения элемента с помощью POST-запроса
  • json - а вот это любопытно, это предыдущие аргументы в формате JSON. Зачем это надо? Напомню, что cl-closure-template позволяет создавать шаблоны, доступные как на сервеной, так и на клиентской стороне. Я хочу, что бы JavaScript-код получил полную информацию об элементе модели, которую он сможет в последующем использовать для передачи в эти же шаблоны для генерации новой разметки. Для этого я сохраняю эти данные в атрибуте json корневого элемента генерируемой разметки, что существенно упрощает последующую обработку.
Теперь, JavaScript код, я выделил базовый класс, который может быть использован для различных подобных элементов:
function EObject (node) {
// node - узел DOM-дерева, представляющего элемент данных
if (node) {
this.node = node;
}
}

// Возвращает описание элемента модели в виде
// пригодном для генерации разметки с помощью шаблонов
EObject.prototype.modelData = function () {
var data = $.evalJSON(this.node.attr("json"))
data.json = $.toJSON(data);
return data;
};

// Метод для генерации основной разметки
EObject.prototype.toHTML = function (data) {
throw "Method toHTML not implemented";
};

// Метод для генерации формы редактирования
EObject.prototype.editForm = function (data) {
throw "Method editForm not implemented";
};

// Вспомогательный метод, позволяющий заменить разметку элемента
// таким образом, что бы в объекте осталась ссылка на актуальный
// элемент DOM-дерева
EObject.prototype.replaceHTML = function (html) {
this.node.after(html);
this.node = this.node.next();
this.node.prev().remove();
};

// Заменяет основное представление на форму редактирования
EObject.prototype.startEdit = function () {
this.replaceHTML(this.editForm(this.modelData()));

var obj = this;

$('.cancel:first', this.node).click(function (evt) { obj.endEdit(); });

this.node.ajaxForm({
dataType: 'json',
success: function (data) { obj.endEdit(data)},
error: function () { alert("Не удалось сохранить данные"); obj.endEdit() }
});
};

// Заменяет форму редактирования на основной вариант разметки
EObject.prototype.endEdit = function (data) {
this.replaceHTML(this.toHTML(data || this.modelData()));

// Похоже на хак, но мне кажется нормальным решением.
// Фактически, просто ре-инициализация объекта, которая,
// например, приведёт к активизации нужных обработчиков событий
this.constructor(this.node);
};
Данный код использует библиотеке jquery и плагины jquery.form и jquery.json. Теперь, создать код для редактирования текстового элемента очень просто:
function EditableText (node) {
if (node) {
// вызываем конструктор базового класса
EObject.prototype.constructor.call(this, node);

// Инициируем вызов процедуру редактирования по клику мышкой
var obj = this;
this.node.click(function (evt) { obj.startEdit(); });
}
}

// Инициализируем прототип
EditableText.prototype = new EObject;

// Необходимо, что бы иметь возможность создать новый класс, наследующий от EditableText
EditableText.prototype.constructor = EditableText;

// Генерируем разметку с помощью ранее описанного шаблона editable-text
EditableText.prototype.toHTML = example.githubway.view.editableText;

// Генерируем форму редактирования с помощью ранее описанного шаблона edit-text
EditableText.prototype.editForm = example.githubway.view.editText;
И наконец инициализация при загрузке документа:
$(document).ready(function () {
// Тоже довольно любопытно. Просто создаём новые объекты "в никуда".
// Но благодаря привязке этих объектов к DOM-дереву через обработчики
// событий они останутся "жить" и будут выполнять свою работу
$(".editable-text").each( function (i, node) { new EditableText($(node)); })
});
Теперь серверная часть. Для демонстрации я использую очень простое приложение с одной страницей, на которой показывается имя и email некоего человека. Пользователь приложения может отредактировать их в стиле github. Код данного приложения зависит от:Сначала определим очень простую модель
(defparameter *name* "Ivan Petrov")

(defparameter *email* "Ivan.Petrov@example.com")


(defun with-json (&rest args)
(list* :json (json:encode-json-plist-to-string args)
args))

(defun name-to-json ()
(with-json :value *name*
:save-link (genurl 'save-name)))

(defun email-to-json ()
(with-json :value *email*
:save-link (genurl 'save-email)))
Здесь функции name-to-json и email-to-json генерируют plist, подходящий для использования с шаблонами, описанными в начале.

Описываем маршрут для основной страницы:
(define-route main ("")
(example.githubway.view:page (list :name (name-to-json)
:email (email-to-json))))
Ради экономии места здесь я не буду приводить текст шаблона example.githubway.view:page, укажу лишь, что в нём есть следующие строчки:
{call editable-text data="$name" /}
{call editable-text data="$email" /}
Теперь определяем обработчики для обновления данных об имени и email, ради упрощения какая-либо обработка ошибок опущена:
(define-route save-name ("api/name" :method :post :content-type "application/json")
(setf *name*
(hunchentoot:post-parameter "value"))
(json:encode-json-plist-to-string (name-to-json)))

(define-route save-email ("api/email" :method :post :content-type "application/json")
(setf *email*
(hunchentoot:post-parameter "value"))
(json:encode-json-plist-to-string (email-to-json)))
Вот и всё. Полный код данного приложения я включил в состав closure-template и посмотреть его можно здесь.

Продолжение следует...

Комментариев нет:

Отправить комментарий