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("<b>"+itemsLeft+"</b> 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<l; i++) {       todoItems[i].dispose();     }   },    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_examples
SPA туториал: http://manual.qooxdoo.org/current/pages/desktop/tutorials/tutorial-part-1.html
Код примера на гитхабе: https://github.com/VasisualyLokhankin/todolist_qooxdoo

ссылка на оригинал статьи http://habrahabr.ru/post/254767/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *