[Из песочницы] Qooxdoo. Разрабатываем TODO List
На сегодняшний день существует великое множество javascript фреймворков, по многим из них написаны горы документации. Я хотел бы остановиться на фреймворке, который, по неизвестной мне причине, не пользуется особой популярностью у российских разработчиков.Фреймворк называется qooxdoo. Произносится «куксду» (кому удобнее английская транстрипция: ['kuksdu:]).
На Хабре было несколько попыток написать про этот фреймворк, но все они свелись к новостям о выходе новой версии или к парам абзацев в статьях типа «смотрите каких фреймворков понаписали». Я несколько лет работаю с qooxdoo и мне хотелось бы восполнить этот пробел.Вкратце о том, что это за зверь и с чем его едят. Больше всего фреймворк «похож» на ExtJS. Слово «похож» не совсем корректное, в данном случае, но я затрудняюсь подобрать более подходящее. Разработка проекта началась в недрах компании 1&1 Internet AG. Первая публичная версия 0.1 вышла в 2005 году. Текущая актуальная версия 4.1, про нее и будем вести речь. Некоторые моменты позволяют мне сказать, что разработчики вдохновлялись Qt при создании своего детища. Основная изначальная задумка разработчиков дать возможность разрабатывать веб приложения людям без знания HTML, CSS и DOM модели. С помощью qooxdoo это возможно. Новичок, которому требуется написать, например, админку в виде single page application (далее SPA) и который не знает ни одного HTML тега, а про CSS вообще никогда не слышал, действительно, сможет это сделать. Это не означает, что знания HTML, CSS и DOM модели вдруг резко стали не нужны. Просто, поначалу, можно обойтись без них. Что будет особенно интересно, например, разработчикам десктопных приложений, которым потребовалось что-то сделать в вебе.
В конце статьи вы можете найти немного полезных ссылок. В частности, там есть ссылки на разнообразные демо и примеры реального использования фреймворка в продакшене.
Просто так рассказывать о фреймворке скушно и неинтересно. К тому же, разработчики это уже и так сделали. Поэтому я решил сделать какой-нибудь простенький пример для демонстрации возможностей фреймворка. Многие знают о проекте http://todomvc.com/. Вот и мы с вами сделаем что-то максимально похожее с использованием qooxdoo. Справедливости ради, разработчики уже сделали демо todo листа, но это не совсем то, что нам нужно.
Итак, приступим.
Следует оговориться, что рассматриваться будет именно SPA (Desktop в терминологии qooxdoo). Для начала необходимо загрузить qooxdoo sdk. Сделать это можно по этой ссылке. SDK содержит ряд утилит, которые позволяют сгенерировать шаблон приложения и собрать отладочную и релизную версию, собрать автоматическую докуентацию, туты и т.д. Ознакомиться с документацией по тулчейну можно тут.
Для создания шаблона приложения мы запустим:
create-application.py --name=todos После этой операции мы получим следующий каркас приложения:
Приложение сгенерируется не пустым. Оно будет иметь кнопку, по нажатию на которую будет выводиться alert.Основной файл Application.js будет содержать следующий код:
/** * This is the main application class of your custom application «todos» * * @asset (todos/*) */ qx.Class.define («todos.Application», { extend: qx.application.Standalone, members: { /** * This method contains the initial application code and gets called * during startup of the application * * @lint ignoreDeprecated (alert) */ main: function () { // Call super class this.base (arguments);
// Enable logging in debug variant if (qx.core.Environment.get («qx.debug»)) { // support native logging capabilities, e.g. Firebug for Firefox qx.log.appender.Native; // support additional cross-browser console. Press F7 to toggle visibility qx.log.appender.Console; }
/* ------------------------------------------------------------------------- Below is your actual application code… ------------------------------------------------------------------------- */
// Create a button var button1 = new qx.ui.form.Button («First Button», «todos/test.png»);
// Document is the application root var doc = this.getRoot ();
// Add button to document at fixed coordinates doc.add (button1, {left: 100, top: 50});
// Add an event listener button1.addListener («execute», function (e) { alert («Hello World!»); }); } } }); Для того, чтобы увидеть задумку авторов, нам нужно будет собрать дебажную или продакшн версию приложения.Первый вариант получится, если перейти в папку проекта и запустить: ./generate.py source второй можно получить после запуска: ./generate.py build После этого грузим в браузере соответствующий index.html файл и видим вот такую картинку:
На кнопку можно нажимать, а можно не нажимать. Можно грабить корованы. На этом возможности приложения заканчиваются. Чуда не случилось, дальше придется писать код, чем мы, собственно, и займемся.
Для нетерпеливых сразу даю ссылку на github с готовым вариантом, с которым можно играться. Для того, чтобы получилось, кроме исходников с гитхаба необходимо скачать SDK и прописать в файле config.json корректный путь «QOOXDOO_PATH». После чего необходимо собрать требуемую версию, как описано выше.
Ну, а мы рассмотрим процесс создания приложения последовательно, в его естественном виде.Для начала мы создадим заготовку для виджета окна для нашего todo листа и безжалостно удалим из Application.js все что там нам нагенерировал генератор. Получится у нас следущее.
Window.js
qx.Class.define («todos.Window», { extend: qx.ui.window.Window,
construct: function (){ this.base (arguments);
this.set ({ caption: «todos», width: 480, height: 640, allowMinimize: false, allowMaximize: false, allowClose: false });
this.addListenerOnce («appear», function (){ this.center (); }, this); } }); Application.js /** * @asset (todos/*) */ qx.Class.define («todos.Application», { extend: qx.application.Standalone, members: { main: function () { // Call super class this.base (arguments);
var wnd = new todos.Window; wnd.show (); } } }); После сборки мы увидим вот такую красоту:
Пора наполнить ее смыслом. Нам будут необходимы следующие элементы: тулбар, запись todo листа и элемент добавления записи в лист. Запись todo листа является повторяющимся элементом, оформим его в виде отдельного виджета. Тулбар и элемент добавления записи в лист можно сделать как отдельными виджетами, что позволит их использовать повторно, так и частью Window. Тулбар сделаем отдельным виджетом, а элемент добавления записи оставим частью Window, чтобы показать, что можно и так и так. Сделаем все вышеописанное и наполним виджеты жизнью.
ToDo.js
qx.Class.define («todos.ToDo», { extend: qx.ui.core.Widget,
events: { remove: «qx.event.type.Event» },
properties: { completed: { init: false, check: «Boolean», event: «completedChanged» },
appearance: { refine: true, init: «todo» } },
construct: function (text){ this.base (arguments);
var grid = new qx.ui.layout.Grid; grid.setColumnWidth (0, 20); grid.setColumnFlex (1, 1); grid.setColumnWidth (2, 20); grid.setColumnAlign (0, «center», «middle»); grid.setColumnAlign (1, «left», «middle»); grid.setColumnAlign (2, «center», «middle»);
this._setLayout (grid); this._add (this.getChildControl («checkbox»), {row: 0, column: 0}); this._add (this.getChildControl («text-container»), {row: 0, column: 1}); this._add (this.getChildControl («icon»), {row: 0, column: 2});
this.getChildControl («label»).setValue (text);
this.addListener («mouseover», function (){this.getChildControl («icon»).show ();}, this); this.addListener («mouseout», function (){this.getChildControl («icon»).hide ();}, this); this.getChildControl («icon»).hide ();
this.getChildControl («text-container»).addListener («dblclick», this.__editToDo, this); },
members: {
// overridden _createChildControlImpl: function (id) { var control;
switch (id) { case «checkbox»: control = new qx.ui.form.CheckBox; this.bind («completed», control, «value»); control.bind («value», this, «completed»); break; case «text-container»: control = new qx.ui.container.Composite (new qx.ui.layout.HBox); control.add (this.getChildControl («label»), {flex: 1}); break; case «label»: control = new qx.ui.basic.Label; control.bind («value», control, «toolTipText»); break; case «textfield»: control = new qx.ui.form.TextField; control.addListener («keypress», function (event){ var key = event.getKeyIdentifier (); switch (key) { case «Enter»: this.__editComplete (); break; case «Escape»: this.__editCancel (); break; } }, this); control.addListener («blur», this.__editComplete, this); break; case «icon»: control = new qx.ui.basic.Image («todos/icon-remove-circle.png»); control.addListener («click», function (){ this.fireEvent («remove»); }, this); break; } return control || this.base (arguments, id); },
__editToDo: function () { var tc = this.getChildControl («text-container»); var tf = this.getChildControl («textfield»); tc.removeAll (); tc.add (tf, {flex: 1}); tf.setValue (this.getChildControl («label»).getValue ()); tf.focus (); tf.activate (); },
__editComplete: function () { this.getChildControl («label»).setValue (this.getChildControl («textfield»).getValue ()); this.__editCancel (); },
__editCancel: function () { var tc = this.getChildControl («text-container»); tc.removeAll (); tc.add (this.getChildControl («label»), {flex: 1}); } } }); StatusBar.js qx.Class.define («todos.StatusBar», { extend: qx.ui.core.Widget,
events: { removeCompleted: «qx.event.type.Event» },
properties: { todos: { init: [], check: «Array» },
filter: { init: «all», check: [«all», «active», «completed»], event: «filterChanged» } },
construct: function () { this.base (arguments);
var grid = new qx.ui.layout.Grid; grid.setColumnWidth (0, 100); grid.setColumnFlex (1, 1); grid.setColumnWidth (2, 130); grid.setColumnAlign (0, «left», «middle»); grid.setColumnAlign (1, «center», «middle»); grid.setColumnAlign (2, «right», «middle»); grid.setRowHeight (0, 26);
this._setLayout (grid); this._add (this.getChildControl («info»), {row: 0, column: 0}); this._add (this.getChildControl («filter»), {row: 0, column: 1}); this._add (this.getChildControl («remove-completed-button»), {row: 0, column: 2}); this.update (); },
destruct: function () { this.__rgFilter.dispose (); },
members: { __rgFilter: null,
update: function () { var todosCount = this.getTodos ().length; var itemsLeft = this.getTodos ().filter (function (item){return! item.getCompleted ();}).length; this.getChildControl («info»).setValue (»»+itemsLeft+» items left»); if (itemsLeft === todosCount) { this.getChildControl («remove-completed-button»).exclude (); } else { this.getChildControl («remove-completed-button»).setLabel («Clear completed (»+(todosCount-itemsLeft)+»)»); this.getChildControl («remove-completed-button»).show (); } },
// overridden _createChildControlImpl: function (id) { var control;
switch (id) { case «info»: control = new qx.ui.basic.Label; control.setRich (true); break; case «filter»: control = new qx.ui.container.Composite (new qx.ui.layout.HBox); control.add (this.getChildControl («rb-filter-all»)); control.add (this.getChildControl («rb-filter-active»)); control.add (this.getChildControl («rb-filter-completed»)); this.__rgFilter = new qx.ui.form.RadioGroup ( this.getChildControl («rb-filter-all»), this.getChildControl («rb-filter-active»), this.getChildControl («rb-filter-completed») ); this.__rgFilter.addListener («changeSelection», this.__onFilterChanged, this); break; case «rb-filter-all»: control = new qx.ui.form.RadioButton («All»); control.setUserData («value», «all»); break; case «rb-filter-active»: control = new qx.ui.form.RadioButton («Active»); control.setUserData («value», «active»); break; case «rb-filter-completed»: control = new qx.ui.form.RadioButton («Completed»); control.setUserData («value», «completed»); break; case «remove-completed-button»: control = new qx.ui.form.Button; control.addListener («execute», function (){ this.fireEvent («removeCompleted»); }, this); break; } return control || this.base (arguments, id); },
__onFilterChanged: function (event) { this.setFilter (event.getData ()[0].getUserData («value»)); } } }); Window.js qx.Class.define («todos.Window», { extend: qx.ui.window.Window,
properties: { appearance: { refine: true, init: «todo-window» },
todos: { init: [], check: «Array», event: «todosChanged» },
filter: { init: «all», check: [«all», «active», «completed»], apply:»__applyFilter» } },
construct: function (){ this.base (arguments);
this.set ({ caption: «todos», width: 480, height: 640, allowMinimize: false, allowMaximize: false, allowClose: false });
this.setLayout (new qx.ui.layout.VBox (2)); this.add (this.getChildControl («todo-writer»)); this.add (this.getChildControl («todos-scroll»), {flex: 1}); this.add (this.getChildControl («statusbar»));
this.addListenerOnce («appear», function (){ this.center (); }, this); },
destruct: function () {
var todoItems = this.getTodos ();
for (var i= 0, l=todoItems.length; i members: {
// overridden
_createChildControlImpl: function (id) {
var control; switch (id) {
case «todo-writer»:
var grid = new qx.ui.layout.Grid;
grid.setColumnWidth (0, 20);
grid.setColumnFlex (1, 1);
grid.setColumnAlign (0, «center», «middle»);
grid.setColumnAlign (1, «left», «middle»);
control = new qx.ui.container.Composite (grid);
control.add (this.getChildControl («checkbox»), {row: 0, column: 0});
control.add (this.getChildControl («textfield»), {row: 0, column: 1});
break;
case «checkbox»:
control = new qx.ui.form.CheckBox;
control.addListener («changeValue», this.__onCheckAllChanged, this);
break;
case «textfield»:
control = new qx.ui.form.TextField;
control.setPlaceholder («What needs to be done?»);
control.addListener («keydown», this.__onWriterTextFieldKeydown, this);
break;
case «todos-scroll»:
control = new qx.ui.container.Scroll;
control.add (this.getChildControl («todos-container»));
break;
case «todos-container»:
control = new qx.ui.container.Composite (new qx.ui.layout.VBox (1));
break;
case «statusbar»:
control = new todos.StatusBar;
control.bind («filter», this, «filter»);
this.bind («todos», control, «todos»);
control.addListener («removeCompleted», this.__onRemoveCompleted, this);
break;
}
return control || this.base (arguments, id);
}, __onWriterTextFieldKeydown: function (event) {
var key = event.getKeyIdentifier ();
switch (key) {
case «Enter»:
var value = event.getTarget ().getValue ();
if (value) {
event.getTarget ().setValue (»);
var todo = new todos.ToDo (value);
this.getTodos ().push (todo);
todo.addListenerOnce («remove», this.__onTodoRemove, this);
todo.addListener («completedChanged», this.__onTodoCompletedChanged, this); this.__updateTodoList ();
this.getChildControl («statusbar»).update (); var cbAll = this.getChildControl («checkbox»);
cbAll.removeListener («changeValue», this.__onCheckAllChanged, this);
cbAll.setValue (false);
cbAll.addListener («changeValue», this.__onCheckAllChanged, this);
}
break;
case «Escape»:
event.getTarget ().setValue (»);
break;
}
}, __updateTodoList: function () {
var toList;
switch (this.getFilter ()) {
case «all»:
toList = this.getTodos ();
break;
case «active»:
toList = this.getTodos ().filter (function (item){return! item.getCompleted ();});
break;
case «completed»:
toList = this.getTodos ().filter (function (item){return item.getCompleted ();});
break;
}
var container = this.getChildControl («todos-container»);
container.removeAll ();
toList.forEach (function (item){
container.add (item);
});
}, __applyFilter: function () {
this.__updateTodoList ();
}, __onTodoRemove: function (event) {
var todo = event.getTarget ();
this.setTodos (this.getTodos ().filter (function (item){return item!== todo;}));
this.getChildControl («todos-container»).remove (todo);
todo.dispose ();
this.getChildControl («statusbar»).update ();
}, __onTodoCompletedChanged: function () {
var cbAll = this.getChildControl («checkbox»);
cbAll.removeListener («changeValue», this.__onCheckAllChanged, this);
cbAll.setValue (this.getTodos ().length === this.getTodos ().filter (function (item){return item.getCompleted ();}).length);
cbAll.addListener («changeValue», this.__onCheckAllChanged, this);
this.__updateTodoList ();
this.getChildControl («statusbar»).update ();
}, __onCheckAllChanged: function (event) {
var value = event.getData ();
this.getTodos ().forEach (function (todo){
todo.removeListener («completedChanged», this.__onTodoCompletedChanged, this);
todo.setCompleted (value);
todo.addListener («completedChanged», this.__onTodoCompletedChanged, this);
}, this);
this.__updateTodoList ();
this.getChildControl («statusbar»).update ();
}, __onRemoveCompleted: function () {
var completed = this.getTodos ().filter (function (item){return item.getCompleted ();});
this.setTodos (this.getTodos ().filter (function (item){return! item.getCompleted ();}));
completed.forEach (function (todo){
this.getChildControl («todos-container»).remove (todo);
todo.dispose ();
}, this);
this.getChildControl («statusbar»).update ();
this.getChildControl («checkbox»).setValue (false);
}
}
});
На этом этапе мы получили вполне себе функционально законченное приложение. Есть только один нюанс, оно страшно, как атомная война: Попробуем привести его к пристойному виду. Оговорюсь сразу, дизайнер из меня, как из козла балерина, поэтому задача максимум для меня добиться, чтобы наш todo лист выглядел просто аккуратно, без изысков. За внешний вид приложения в qooxdoo отвечают темы. Фреймворк поставляется с 4 темами. Темы можно расширять, переписывать и т.д. Тема в qooxdoo имеет 5 составляющих и определяется таким образом:
qx.Theme.define («todos.theme.Theme», {
meta: {
color: todos.theme.Color,
decoration: todos.theme.Decoration,
font: todos.theme.Font,
icon: qx.theme.icon.Tango,
appearance: todos.theme.Appearance
}
});
Подробнее про темы можно почитать тут.Итак, сделаем следующие изменения: Appearance.js
/**
* * @asset (qx/icon/Tango/*
*/
qx.Theme.define («todos.theme.Appearance», {
extend: qx.theme.simple.Appearance,
appearances: {
«todo-window» : {
include: «window»,
alias: «window»,
style: function (){
return {
contentPadding: 0
};
}
},
«checkbox»: {
alias: «atom»,
style: function (states) {
var icon;
if (states.checked) {
icon = «todos/checked.png»;
} else if (states.undetermined) {
icon = qx.theme.simple.Image.URLS[«todos/undetermined.png»];
} else {
icon = qx.theme.simple.Image.URLS[«blank»];
} return {
icon: icon,
gap: 8,
cursor: «pointer»
}
}
},
«radiobutton»: {
style: function (states) {
return {
icon: null,
font: states.checked? «bold» : «default»,
textColor: states.checked? «green» : «black»,
cursor: «pointer»
}
}
},
«checkbox/icon» : {
style: function (states) {
return {
decorator: «checkbox»,
width: 16,
height: 16,
backgroundColor: «white»
}
}
},
«todo-window/checkbox» : «checkbox»,
«todo-window/textfield» : «textfield»,
«todo-window/todos-scroll» : «scrollarea»,
«todo-window/todo-writer» : {
style: function () {
return {
padding: [2, 2, 0, 0]
};
}
},
«todo-window/statusbar» : {
style: function () {
return {
padding: [ 2, 6],
decorator: «statusbar»,
minHeight: 32,
height: 32
};
}
},
«todo-window/statusbar/info» : «label»,
«todo-window/statusbar/rb-filter-all» : «radiobutton»,
«todo-window/statusbar/rb-filter-active» : «radiobutton»,
«todo-window/statusbar/rb-filter-completed» : «radiobutton»,
«todo-window/statusbar/remove-completed-button» : {
include: «button»,
alias: «button»,
style: function () {
return {
width: 150,
allowGrowX: false
};
}
},
«todo/label» : {
include: «label»,
alias: «label»,
style: function (states) {
return {
font: (states.completed? «line-through» : «default»),
textColor: (states.completed? «light-gray» : «black»),
cursor: «text»
};
}
},
«todo/icon» : {
style: function () {
return {
cursor: «pointer»
};
}
},
«todo/text-container» : {
style: function () {
return {
allowGrowY: false
};
}
},
«todo/checkbox» : «checkbox»
}
});
Color.js
qx.Theme.define («todos.theme.Color»,
{
extend: qx.theme.simple.Color, colors:
{
«light-gray» :»#BBBBBB»,
«border-checkbox»:»#B6B6B6»
}
});
Decoration.js
qx.Theme.define («todos.theme.Decoration», {
extend: qx.theme.simple.Decoration, decorations: {
«statusbar» : {
style: {
backgroundColor: «background»,
width: [2, 0, 0, 0],
color: «window-border-inner»
}
}, «checkbox» : {
decorator: [
qx.ui.decoration.MBorderRadius,
qx.ui.decoration.MSingleBorder
], style: {
radius: 3,
width: 1,
color: «border-checkbox»
}
}
}
});
Font.js
qx.Theme.define («todos.theme.Font»,
{
extend: qx.theme.simple.Font, fonts:
{
«line-through» :
{
size: 13,
family: [«arial», «sans-serif»],
decoration: «line-through»
}
}
});
После этого наш TODO лист будет выглядеть так: На этом пока можно закончить. Я не затронул огромное количество вопросов, но это просто невозмозможно в рамках одной статьи. Хотелось познакомить с фреймворком на примере небольшой задачи, как можно меньше углубляясь в детали. Подробнее можно почитать по приведенным ссылкам. Обо всех ошибках и опечатках прошу писать в личку. Спасибо за внимание. Полезные ссылки: Домашняя страница qooxdoo: http://qooxdoo.org/Страница загрузки SDK: http://qooxdoo.org/downloadsРазнообразные демо: http://qooxdoo.org/demosПримеры использования: http://qooxdoo.org/community/real_life_examplesSPA туториал: http://manual.qooxdoo.org/current/pages/desktop/tutorials/tutorial-part-1.htmlКод примера на гитхабе: https://github.com/VasisualyLokhankin/todolist_qooxdoo