Pull to refresh

Qooxdoo. Разрабатываем TODO List

Reading time 15 min
Views 18K
На сегодняшний день существует великое множество 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
Tags:
Hubs:
+13
Comments 9
Comments Comments 9

Articles