diff --git a/README.md b/README.md index c8fc68d..91569cd 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,20 @@ Or proceed manually as follow: ## Controls * UP & DOWN: navigate through tasks +* CNTRL+UP & CNTRL+DOWN: shuffle tasks * TAB: right-indent * SHIFT + TAB: left-indent +* BACKSPACE: Remove an empty task +* ENTER: New task +* Click on a bullet point to fold it +* Hover on a bullet point and click complete to complete it ## Technologies used * Node + Socket.io * Backbone +* Backbone.marionette +* Backbone.localforage * Foundation ## To-do diff --git a/bower.json b/bower.json index 4857b9e..21be4e3 100644 --- a/bower.json +++ b/bower.json @@ -15,13 +15,13 @@ "text": "requirejs-text#~2.0.14", "jquery": "~2.2.0", "backbone": "~1.2.3", - "backbone.localStorage": "1.1.16", "lodash": "~4.0.1", "requirejs": "~2.1.22", - "underscore": "~1.8.3", "modernizr": "~3.3.1", - "zepto": "~1.1.6", "localforage": "~1.3.3", - "localforage-backbone": "~0.6.2" + "localforage-backbone": "~0.6.2", + "normalize-css": "normalize.css#~3.0.3", + "backbone.marionette": "~2.4.4", + "underscore": "~1.8.3" } } diff --git a/index.html b/index.html index 116828e..9b0a856 100644 --- a/index.html +++ b/index.html @@ -53,7 +53,7 @@ var elem = document.getElementById("footer-qoute"); var newQoute = qoutes[Math.floor(Math.random() * qoutes.length)] + ' <3 Open Source.'; //if (newQoute.length>100){ - // TODO(wassname) truncate long qoutes with a more link + // TODO(wassname) truncate long qoutes with a more link //} elem.textContent = newQoute @@ -67,4 +67,5 @@ + diff --git a/javascripts/app.js b/javascripts/app.js index 150e057..c36f2e1 100644 --- a/javascripts/app.js +++ b/javascripts/app.js @@ -3,23 +3,18 @@ require.config({ //load lib files required paths: { jquery: '../bower_components/jquery/dist/jquery.min', - lodash: "../bower_components/lodash/dist/lodash.min", + underscore: "../bower_components/underscore/underscore.min", backbone: '../bower_components/backbone/backbone-min', localforage: '../bower_components/localforage/dist/localforage', localforagebackbone: '../bower_components/localforage-backbone/dist/localforage.backbone', modernizr: "vendor/custom.modernizr", socket: "../bower_components/socket.io-client/socket.io", text: '../bower_components/text/text', - }, - map: { - "*": { - // alias underscore to lodash for backbone - "underscore": "lodash" - } + marionette: '../bower_components/backbone.marionette/lib/backbone.marionette' }, shim: { backbone: { - deps: ["lodash", "jquery"], + deps: ["underscore", "jquery"], exports: "Backbone" } }, diff --git a/javascripts/collections/list.js b/javascripts/collections/list.js index 1fd7ba4..45b0330 100644 --- a/javascripts/collections/list.js +++ b/javascripts/collections/list.js @@ -13,22 +13,66 @@ localforage, localforageBackbone ) { - var List = Backbone.Collection.extend({ + var List = Backbone.Collection.extend({ + model: Task, + offlineSync: Backbone.localforage.sync("tasks"), - model: Task, - offlineSync: Backbone.localforage.sync("tasks"), - /** switches sync between server and local databases **/ - sync: function(){ + initialize: function(){ + // update order on add, remove. + // Ref: http://stackoverflow.com/a/11665085/221742 + this.on('add remove', this.updateModelPriority); + }, + + moveUp: function(model) { // I see move up as the -1 + var index = this.indexOf(model); + if (index > 0) { + this.remove(model, {silent: true}); // silence this to stop excess event triggers + this.add(model, {at: index-1}); + } + }, + + moveDown: function(model) { // I see move up as the -1 + var index = this.indexOf(model); + if (index < this.models.length) { + this.remove(model, {silent: true}); // silence this to stop excess event triggers + this.add(model, {at: index+1}); + } + }, + + /** Updated priority of each member of list **/ + updateModelPriority: function() { + this.each(function(model, index) { + if (model) + model.set('priority', index); + }, this); + }, + + /** switches sync between server and local databases **/ + sync: function(){ + //var self = this; + _localforageNamespace = this.offlineSync._localforageNamespace; + _localeForageKeyFn=this.offlineSync._localeForageKeyFn; + localforageKey = this.offlineSync._localeForageKeyFn; if (window.hackflowyOffline) return this.offlineSync.apply(this, arguments); else return Backbone.sync.apply(this, arguments); - }, + }, - url: '/tasks' + /** sort by priority then created date **/ + comporator: function(child){ + return [child.get('priority'), child.get('createdAt')]; + }, - }); + url: '/tasks' + + }); + + // a couple of vars backbone.localforage needs in the sync function + List.prototype.sync.localforage = List.prototype.offlineSync._localeForageKeyFn; + List.prototype.sync._localeForageKeyFn = List.prototype.offlineSync._localeForageKeyFn; + List.prototype.sync._localforageNamespace = List.prototype.offlineSync._localforageNamespace; return List; diff --git a/javascripts/models/task.js b/javascripts/models/task.js index 0f0d318..9e6bfa5 100644 --- a/javascripts/models/task.js +++ b/javascripts/models/task.js @@ -1,48 +1,63 @@ define( ['backbone', -'localforage', -'localforagebackbone' + 'localforage', + 'localforagebackbone' ], -function( -Backbone, -localforage, -localforageBackbone +function ( + Backbone, + localforage, + localforageBackbone ) { - var TaskModel = Backbone.Model.extend({ + var TaskModel = Backbone.Model.extend({ - offlineSync: Backbone.localforage.sync('TaskModel'), - /** switches sync between server and local databases **/ - sync: function(){ - if (window.hackflowyOffline) - return this.offlineSync.apply(this,arguments); - else - return Backbone.sync.apply(this, arguments); - }, + offlineSync: Backbone.localforage.sync('TaskModel'), - defaults: { - parentId: 0, - content: '', - isCompleted: 0, - priority: 0 - }, + /** switches sync between server and local databases **/ + sync: function () { - toggelCompletedStatus:function(isCompleted){ - var prev_isCompleted = isCompleted, - self = this; - this.save({'isCompleted':isCompleted}, - { - success:function(){}, - error:function(){ - //REVERT BACK ON ERROR - self.set({'isCompleted':prev_isCompleted}); - } - }); - } + if (window.hackflowyOffline) + return this.offlineSync.apply(this, arguments); + else + return Backbone.sync.apply(this, arguments); + }, - }); + defaults: { + parentId: 0, + content: '', + isCompleted: 0, + priority: 0, + id: '', + }, -return TaskModel; + toggelCompletedStatus: function (isCompleted) { + var prev_isCompleted = isCompleted, + self = this; + this.save({ + 'isCompleted': isCompleted + }, { + success: function () {}, + error: function () { + //REVERT BACK ON ERROR + self.set({ + 'isCompleted': prev_isCompleted + }); + } + }); + }, + + focusOnView: function(){ + return this.view.$('input:first').focus(); + } + + }); + + // a couple of vars backbone.localforage needs in the sync function + TaskModel.prototype.sync.localforage = TaskModel.prototype.offlineSync._localeForageKeyFn; + TaskModel.prototype.sync._localeForageKeyFn = TaskModel.prototype.offlineSync._localeForageKeyFn; + TaskModel.prototype.sync._localforageNamespace = TaskModel.prototype.offlineSync._localforageNamespace; + + return TaskModel; }); diff --git a/javascripts/util/constants.js b/javascripts/util/constants.js index f193243..0814ddc 100644 --- a/javascripts/util/constants.js +++ b/javascripts/util/constants.js @@ -2,13 +2,18 @@ define( [], function() { - + var constants = { - ENTER_KEY :13 + ENTER_KEY :13, + UP_ARROW: 38, + DOWN_ARROW: 40, + BACKSPACE: 8, + TAB: 9, + CNTRL: 17, }; return constants; -}); \ No newline at end of file +}); diff --git a/javascripts/views/list.js b/javascripts/views/list.js index 0f02a72..a6f3499 100644 --- a/javascripts/views/list.js +++ b/javascripts/views/list.js @@ -3,7 +3,9 @@ define( 'backbone', 'collections/list', 'views/task', - 'data/demo' + 'data/demo', + 'text!../../templates/task.html', + 'marionette' ], function ( @@ -11,23 +13,35 @@ define( Backbone, List, TaskView, - demoData + demoData, + listTemplate, + Marionette ) { - var ListView = Backbone.View.extend({ + // renders recursive tree structure for each item in collection + var ListView = Backbone.Marionette.CollectionView.extend({ el: $("#main .children"), + childView: TaskView, + viewComparator: List.prototype.comporator, + template: _.template(listTemplate), events: { 'click #add': 'addTask' }, initialize: function () { - Tasks = this.collection = new List(); + var self = this; - this.listenTo(this.collection, 'add', this.renderTask); + // this wholeCollection holds all items + this.wholeCollection = Tasks = new List(); + //this.collection = new List(); - /** Load demo data and warn users **/ + // custom events + this.listenTo(this, 'childview:rerender', this.render); + // this.listenTo(this.collection, 'add remove', this.render); + + /** Load demo data **/ function loadDemoData() { for (var i = 0; i < demoData.length; i++) { var task = Tasks.add(demoData[i]); @@ -35,53 +49,48 @@ define( } } - function success(data) { + function success(children, data, promise) { // load demo data if the server returns nothing - if (data.length === 0) + var directChildren = children.filter(this.filterDirectChildren); + if (directChildren.length === 0) loadDemoData(); + this.collection = new List(Tasks.filter(this.filterDirectChildren)); + this.render(); } - this.collection.fetch({ + Tasks.fetch({ success: success, error: function () { - // switch to localforage database if server isn't present - window.hackflowyOffline=true; + + // switch to localforage database if server isn't present and fetch again + // from there + window.hackflowyOffline = true; $('#header').append('
Running in offline mode, data may be lost
'); Tasks.fetch({ - success: success + success: success, + context: this }); - } + }, + context: this }); }, - render: function () { - this.collection.each(function (task) { - this.renderTask(task); - }, this); + // Only show direct children + filterDirectChildren: function (child, index, collection) { + return child.get('parentId') === 0; }, - renderTask: function (task) { - var taskView = new TaskView({ - model: task - }); - var a = taskView.render(); - if (a.model.get('parentId') === 0) { - this.$el.append(a.el); - } else { - var parent = $('*[data-id="' + a.model.get('parentId') + '"]'); - if (parent.length === 0) { - // TODO deal with loading order - console.error("Parent not rendered yet: ", { - selector: parent.selector, - task: task - }); - this.$el.append(a.el); - } else { - a.$el.insertBefore(parent.parents('li:first')); - } - } - } + /** This is the root view in the tree **/ + getParentView: function () { + return this; + }, + + /** Update parentId when added to collection **/ + onAddChild: function(childView){ + if (childView.model.get('parentId')!==0) + childView.model.save({parentId: 0}); + }, }); diff --git a/javascripts/views/page.js b/javascripts/views/page.js index 6868d93..b3b7c8e 100644 --- a/javascripts/views/page.js +++ b/javascripts/views/page.js @@ -22,8 +22,12 @@ Task }, initialize: function() { - this.listView = new ListView(); + listView = this.listView = new ListView(); this.input = $('#newTask'); + + // stop browser going back a page when jamming backspace + window.addEventListener('keydown',function(e){if(e.keyIdentifier=='U+0008'||e.keyIdentifier=='Backspace'){if(e.target==document.body){e.preventDefault();}}},true); + }, createNewTask: function(e) { diff --git a/javascripts/views/task.js b/javascripts/views/task.js index 84b0e20..68161bf 100644 --- a/javascripts/views/task.js +++ b/javascripts/views/task.js @@ -1,153 +1,249 @@ - - define( ['jquery', 'backbone', 'socket', + 'collections/list', 'util/constants', - 'text!../../templates/task.html' + 'text!../../templates/task.html', + 'marionette', ], function ( $, Backbone, io, + List, constants, - taskTemplate + taskTemplate, + Marionette ) { + // The recursive tree view. Ref:http://jsfiddle.net/wassname/zf61mLvh/2/ + var TaskView = Backbone.Marionette.CompositeView.extend({ - var TaskView = Backbone.View.extend({ + template: _.template(taskTemplate), //'#task-view-template', + tagName: 'ul', + className: "shift", + childView: TaskView, + childViewContainer: '.children', + childViewOptions: { + reorderOnSort: true + }, - tagName: 'li', - template: taskTemplate, + ui: { + input: '.task input:first', + options: '.task .options:first', + // ui elements are bound before child render + // so the below child hashes are only available because + // render:collection triggers this.bindUIElements + children: '.children>ul', + descendants: '.children ul', + }, events: { - 'click .task': 'edit', - 'blur .edit': 'close', - 'keyup .edit': 'handleKeyup', - 'keypress .edit': 'update', - 'mouseover .link': 'showOptions', - 'mouseout .link': 'hideOptions', - 'click .complete': 'markComplete', - 'click .uncomplete': 'unmarkComlete', - 'click .note': 'addNote' + 'click .task:first': 'edit', + 'blur .edit:first': 'close', + 'keyup .edit:first': 'handleKeyUp', + 'keydown .edit:first': 'handleKeyDown', + 'keypress .edit:first': 'handleKeyPress', + 'mouseover .link:first': 'showOptions', + 'mouseout .link:first': 'hideOptions', + 'click .complete:first': 'markComplete', + 'click .uncomplete:first': 'unmarkComlete', + 'click .note:first': 'addNote', + 'click .mouse-tip:first': 'foldChildren', + }, - initialize: function () { + collectionEvents: {}, + + childEvents: {}, + + initialize: function (options) { + var task = this; + + // options + if (!('reorderOnSort' in options)) { + options.reorderOnSort = true; + } + + // backlink + this.model.view = this; + + var children = Tasks.filter( + function (child, index, collection) { + return child.get('parentId') === this.model.id; + }, this); + this.collection = new List(children); + + // events this.listenTo(this.model, 'change', this.render); this.listenTo(this.model, 'destroy', this.remove); - this.socket = io.connect(); - var task = this; - this.socket.on('task', function (data) { - if (task.model.id == data.id) { - task.model.set({ - 'content': data.content, - 'isCompleted': data.isCompleted - }); - } - }); + // refresh ui hashes after children are rendered + this.listenTo(this, 'render:collection', this.bindUIElements); + // custom event + this.listenTo(this, 'childview:rerender', this.render); + this.listenTo(this, 'focus', this.model.focusOnView); - }, - - render: function () { - var tmpl = _.template(this.template); - var task = this; - this.$el.html(tmpl({ - model: this.model.toJSON() - })); - if (this.model.get('parentId') != 0) { - // add a shift[n] class for n-indents - this.$el.addClass('shift1'); - var className = $('*[data-id="' + this.model.get('parentId') + '"]').parents('li:first').attr('class'); - if (className != undefined && className != 0 && className.substring(0, 5) == 'shift') { - this.$el.removeClass(); - this.$el.addClass('shift' + (parseInt(className.charAt(5)) + 1)); - } + // updates from server + if (!window.hackflowyOffline) { + this.socket = io.connect(); + this.socket.on('task', function (data) { + if (task.model.id == data.id) { + task.model.set({ + 'content': data.content, + 'isCompleted': data.isCompleted + }); + } else { + console.error("task.model.id != data.id", task.model.id, data.id); + } + }); } - this.$input = this.$('.edit:first'); - return this; }, + // override marionette filter to filter displayed children + // filterDirectChildren: function (child, index, collection) { + // return child.get('parentId') === this.model.get('id'); + // }, + edit: function () { this.$el.addClass('editing'); - this.$input.focus(); + this.$('.edit:first').focus(); }, - handleKeyup: function (e) { - // down arrow - if (e.keyCode == 40) - this.$el.next('li').find('input').focus(); - // Up arrow - else if (e.keyCode == 38) - this.$el.prev('li').find('input').focus(); - - // shift and tab - if (e.shiftKey && e.keyCode == 9) { - var model = this.$el.next('li').find('input').data('id'); - model = Tasks.get(model); - var old_parent = model.get('parentId'); - old_parent = Tasks.get(old_parent); - var new_parent = old_parent.get('parentId'); - if (new_parent == null) new_parent = 0; - model.set('parentId', new_parent); - model.save({ - content: model.get('content'), - parentId: model.get('parentId') - }); - } - // tab - else if (e.keyCode == 9) { - var parent = this.$el.prev('li').prev('li').find('input').data('id'); - var current = this.$el.prev('li').find('input').data('id'); - var model = Tasks.get(current); - model.set('parentId', parent); - model.save({ - content: model.get('content'), - parentId: model.get('parentId') - }); - } - - this.socket.emit('task', { - id: this.model.id, - parentId: this.model.parentId, - content: this.$input.val().trim(), - isCompleted: this.model.toJSON().isCompleted - }); + /** Get the parent view or root view **/ + getParentView: function () { + var parent = Tasks.get(this.model.get('parentId')); + if (parent) return parent.view; + else return listView; }, - update: function (e) { - if (e.which === constants.ENTER_KEY) { + /** Update parentId when added to collection **/ + onAddChild: function(childView){ + if (childView.model.get('parentId')!==this.model.id) + childView.model.save({parentId: this.model.id}); + }, + + /** Focus on the next visible element down despite list level **/ + focusOnPrev: function () { + var all = listView.$el.find('ul:visible'); + var prev = $(all[all.index(this.$el) - 1]); + if (prev.length){ + prev.find('input:first').focus(); + } + return prev; + }, + + focusOnNext: function () { + var all = listView.$el.find('ul:visible'); + var next = $(all[all.index(this.$el) + 1]); + if (next) + next.find('input:first').focus(); + return next; + }, + + handleKeyUp: function(e){ + }, + + handleKeyPress: function(e){ + }, + + handleKeyDown: function (e) { + if (e.keyCode == constants.BACKSPACE) { + // remove if backspace pressed on empty/soon-to-be-empty item + if (this.ui.input.val().length < 2 && this.$('.children>ul').length===0) { + e.preventDefault(); + prev = this.focusOnPrev(); + this.destroy(); + this.model.destroy(); + // set the cursor to the end of the previous input + prev.find('input:first').focus(); + var input = prev.find('input:first')[0]; + input.selectionStart = input.selectionEnd = input.value.length; + return false; + } + } + if (e.keyCode === constants.ENTER_KEY) this.addNote(e.currentTarget); + else if (e.ctrlKey && e.keyCode == constants.DOWN_ARROW) { + // move down list by swapping priority with next sibling + e.preventDefault(); + this.getParentView().collection.moveDown(this.model); + this.trigger('rerender'); + this.model.focusOnView(); + } else if (e.ctrlKey && e.keyCode == constants.UP_ARROW) { + // move up the list + e.preventDefault(); + this.getParentView().collection.moveUp(this.model); + this.trigger('rerender'); + this.model.focusOnView(); + } else if (e.keyCode == constants.DOWN_ARROW) { + this.focusOnNext(); + } else if (e.keyCode == constants.UP_ARROW) { + this.focusOnPrev(); + } else if (e.shiftKey && e.keyCode == constants.TAB) { + // indent one less, by changing parent + e.preventDefault(); + var parentId = this.$el.parents('ul:first').find('input:first').data('id'); + if (parentId===0){ + + } else if (parentId){ + parentModel = Tasks.get(parentId); + this.getParentView().collection.remove(this.model); + var index = parentModel.view.getParentView().collection.indexOf(parentModel); + if (index<0) index=this.getParentView().collection.length-1; + parentModel.view.getParentView().collection.add(this.model, {at:index+1}); + this.model.focusOnView(); + } else { + console.warn("Can't untab any further"); + } + } else if (e.keyCode == constants.TAB) { + // indent one more, by changing parent + e.preventDefault(); + var prevSibling = this.$el.prev('ul'); + if (prevSibling.length) { + var prevSibId = prevSibling.find('input:first').data('id'); + var prevSibView =Tasks.get(prevSibId).view; + this.getParentView().collection.remove(this.model); + prevSibView.collection.add(this.model); + this.model.focusOnView(); + } else { + console.warn("Can't tab any further"); + } } + if (!window.hackflowyOffline) { + this.socket.emit('task', { + id: this.model.id, + parentId: this.model.parentId, + content: this.$('.edit:first').val().trim(), + isCompleted: this.model.toJSON().isCompleted + }); + } else { + + } }, + /** Finish editing an item **/ close: function () { - var value = this.$input.val().trim(); - if (value === '') { - this.model.destroy(); - // collection.at(this.model.get('id')).destroy(); - } else { + var value = this.$('.edit:first').val().trim(); this.model.save({ - content: value, - parentId: this.model.attributes.parentId + content: value }); - } this.$el.removeClass('editing'); }, showOptions: function () { - this.$el.find('.options').show(); + this.ui.options.show(); }, hideOptions: function () { - this.$el.find('.options').hide(); + this.ui.options.hide(); }, markComplete: function () { this.model.toggelCompletedStatus(true); - this.socket.emit('task', { + if (!window.hackflowyOffline) this.socket.emit('task', { id: this.model.id, parentId: this.model.parentId, content: this.model.toJSON().content, @@ -157,7 +253,7 @@ define( unmarkComlete: function () { this.model.toggelCompletedStatus(false); - this.socket.emit('task', { + if (!window.hackflowyOffline) this.socket.emit('task', { id: this.model.id, parentId: this.model.parentId, content: this.model.toJSON().content, @@ -165,23 +261,35 @@ define( }); }, - /** - * Add a new blank note - * @param {object} inputEle Input elelement from current item - */ - addNote: function (inputEle) { - var $inputEle = $(inputEle); - var currentId = $inputEle.data('id') || 0; - parentId = currentId !== 0 ? Tasks.get(currentId).get('parentId') : 0; + /** Add a new blank note below this **/ + addNote: function () { + var currentId = this.ui.input.data('id') || 0; + parentId = this.ui.input.data('parent-id'); - Tasks.add({ - content: '', - parentId: parentId - }); - $inputEle.blur(); - $inputEle.closest('li').next('li').find('input').focus(); - } + var index= this.getParentView().collection.indexOf(this.model); + + var task = Tasks.add({parentId: this.model.get('parentId')}); + task.save(); + if (index>=0) + this.getParentView().collection.add(task, {at:index+1}); + else + this.getParentView().collection.add(task); + console.log(index); + this.ui.input.blur(); + task.view.ui.input.focus(); + }, + + /** Fold children of the clicked element */ + foldChildren: function () { + if (this.ui.children.length > 0) { + this.ui.children.toggle(); + this.$el.find('li:first').toggleClass('folded'); + } else { + this.ui.children.show(); + this.$el.find('li:first').removeClass('folded'); + } + }, }); return TaskView; diff --git a/public/templates/list.html b/public/templates/list.html new file mode 100644 index 0000000..1080d6a --- /dev/null +++ b/public/templates/list.html @@ -0,0 +1 @@ +
  • <%= nodeName %>
  • diff --git a/stylesheets/app.css b/stylesheets/app.css index fdbdde4..5dc4700 100644 --- a/stylesheets/app.css +++ b/stylesheets/app.css @@ -5603,12 +5603,20 @@ body #hackflowy #main .children li .task .with-children > .link { body #hackflowy #main .children li .task .open > .link { background-color: white; } + +/* Show that this item has folded contents */ +body #hackflowy #main .children li.folded .task .link { + z-index: 7; + background-color: #ddd; +} + /* line 105, ../sass/app.scss */ body #hackflowy #main .children li .task .link:hover { cursor: pointer; z-index: 8; background-color: #aaa; } + /* line 110, ../sass/app.scss */ body #hackflowy #main .children li .task input.edit { box-shadow: none; @@ -5673,6 +5681,9 @@ body #hackflowy #main .children li.shift8 { body #hackflowy #main .children li.shift9 { margin-left: 270px; } +body #hackflowy #main .children ul.shift { + margin-bottom: 0px; +} /* line 167, ../sass/app.scss */ body #hackflowy #main > .children { margin-left: 0; @@ -5684,6 +5695,7 @@ body #hackflowy #footer { font-size: 12px; } + .options-div{ display: none; } @@ -5713,7 +5725,7 @@ cursor: pointer; background: #ddd; border: 1px solid #bbb; width: 80px; -height: 58px; +min-height: 58px; padding: 10px 0 5px 10px; margin-bottom: 10px; border-radius: 4px; @@ -5773,6 +5785,7 @@ border-left: 15px solid transparent; border-right: 15px solid transparent; } + .task-completed{ text-decoration: line-through; } diff --git a/templates/task.html b/templates/task.html index 8eb73bc..7ddece2 100644 --- a/templates/task.html +++ b/templates/task.html @@ -1,25 +1,28 @@ -
    -
    - +
  • +
    +
    + - <% if(model.isCompleted){%> - - <%}else {%> - - <%}%> -
    -
    + <% if(isCompleted) { %> + + <% }else { %> + + <% } %> +
  • +
    + +