[Из песочницы] 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 После этой операции мы получим следующий каркас приложения: b2f8d6e619794755a19232f7ced2c7a7.png

Приложение сгенерируется не пустым. Оно будет иметь кнопку, по нажатию на которую будет выводиться 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 файл и видим вот такую картинку: 59af816bbc89433090603c56d41d408b.png

На кнопку можно нажимать, а можно не нажимать. Можно грабить корованы. На этом возможности приложения заканчиваются. Чуда не случилось, дальше придется писать код, чем мы, собственно, и займемся.

Для нетерпеливых сразу даю ссылку на 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 (); } } }); После сборки мы увидим вот такую красоту: 10ad2e39bc534dd1ab3256063ce9327c.png

Пора наполнить ее смыслом. Нам будут необходимы следующие элементы: тулбар, запись 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); } } }); На этом этапе мы получили вполне себе функционально законченное приложение. Есть только один нюанс, оно страшно, как атомная война: 4e53377de5b545db95be0dad4285146e.png

Попробуем привести его к пристойному виду. Оговорюсь сразу, дизайнер из меня, как из козла балерина, поэтому задача максимум для меня добиться, чтобы наш 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 лист будет выглядеть так: 61fbb5f90fc44d03b0e6141d1ff6bbd3.png

На этом пока можно закончить. Я не затронул огромное количество вопросов, но это просто невозмозможно в рамках одной статьи. Хотелось познакомить с фреймворком на примере небольшой задачи, как можно меньше углубляясь в детали. Подробнее можно почитать по приведенным ссылкам. Обо всех ошибках и опечатках прошу писать в личку. Спасибо за внимание.

Полезные ссылки: Домашняя страница 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

© Habrahabr.ru