Как стать автором
Поиск
Написать публикацию
Обновить

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

Время на прочтение15 мин
Количество просмотров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
Теги:
Хабы:
Всего голосов 23: ↑18 и ↓5+13
Комментарии9

Публикации

Ближайшие события