Controller tests

Controller tests

--- test/spec/controller.coffee
+++ test/spec/controller.coffee
@@ -1,4 +1,177 @@
 describe 'Controller', ->

-  it 'should pass this test', ->
-    assert.ok true+  el = null
+
+  beforeEach ->
+
+    el = $(
+      '''
+

+      '''
+    )
+
+  it 'not create scope when scope is false', ->
+    c = new Neck.Controller
+      scope: false
+
+    assert.isUndefined c.scope
+
+  it 'create child scope when inhertScope is true', ->
+    parentScope = new Neck.Scope {}
+    c = new Neck.Controller
+      scope: true
+      parentScope: parentScope
+
+    assert.equal c.scope.parent, parentScope
+
+  it 'not create child scope when inhertScope is false', ->
+    parentScope = new Neck.Scope {}
+    c = new Neck.Controller
+      scope: true
+      inheritScope: false
+      parentScope: parentScope
+
+    assert.notEqual c.scope.parent, parentScope
+
+  it 'take dataset of DOM element and inject it to scope', ->
+    c = new Neck.Controller
+      el: el
+      scope:
+        test: "="
+        number: "="
+        property: "@"
+
+    assert.equal c.scope.test, 'test text'
+    assert.isString c.scope.test
+
+    assert.equal c.scope.number, 2
+    assert.isNumber c.scope.number
+
+    assert.equal c.scope.property, 'test'
+    assert.isString c.scope.property
+
+    c2 = new Neck.Controller
+      el: el
+      parentScope: new Neck.Scope {}
+      scope:
+        property: '='
+
+    assert.ok c2.scope.hasOwnProperty 'property'
+    assert.isUndefined c2.scope.property
+
+  it 'move scope properties when not in dataset', ->
+    c = new Neck.Controller
+      el: el
+      parentScope: new Neck.Scope {}
+      scope:
+        asdf: '='
+        test: 'This is test text'
+        myProperty: 'this is moved to scope'
+
+    assert.isUndefined c.scope.asdf
+    assert.equal c.scope.test, 'This is test text'
+    assert.equal c.scope.myProperty, 'this is moved to scope'
+
+  it 'take all dataset values are evaluated when scope is "="', ->
+    c = new Neck.Controller
+      el: el
+      parentScope: new Neck.Scope {}
+      scope: '='
+
+    assert.equal c.scope.test, 'test text'
+    assert.equal c.scope.number, 2
+    assert.isUndefined c.scope.property
+
+  it 'take all dataset values are copied when scope is "@"', ->
+    c = new Neck.Controller
+      el: el
+      parentScope: new Neck.Scope {}
+      scope: '@'
+
+    assert.equal c.scope.test, "'test text'"
+    assert.equal c.scope.number, '2'
+    assert.equal c.scope.property, 'test'
+
+  it 'append to self(el) when no yield element', ->
+    c = new Neck.Controller
+      el: $('

')
+      scope: false
+
+    c.append $('

')
+
+    assert.lengthOf c.el.children('p'), 1
+
+  it 'append to yield element when it is in el', ->
+    c = new Neck.Controller
+      el: $('

')
+      scope: false
+
+    c.append $('

')
+    assert.lengthOf c.el.find('[ui-yield]').children('p'), 1
+
+  it 'render by require view path', ->
+    viewCallback = -> '

'
+    window.require = sinon.stub().returns viewCallback
+
+    c = new Neck.Controller
+      el: $('

')
+      scope: false
+      view: 'exampleView'
+
+    c.render()
+    assert.ok c.el[0].outerHTML is '

'
+
+    window.require = undefined
+
+  it 'render by template string property', ->
+    c = new Neck.Controller
+      el: $('

')
+      scope: false
+      template: '

'
+
+    c.render()
+    assert.ok c.el[0].outerHTML is '

'
+
+  it 'render by template function property', ->
+    template = ->
+      '

'
+
+    c = new Neck.Controller
+      el: $('

')
+      scope: false
+      template: template
+
+    c.render()
+    assert.ok c.el[0].outerHTML is '

'
+
+  it 'render template over view when both are given', ->
+    viewCallback = -> '

'
+    window.require = sinon.stub().returns viewCallback
+
+    c = new Neck.Controller
+      el: $('

')
+      scope: false
+      view: 'exampleView'
+      template: '

'
+
+    c.render()
+    assert.ok c.el[0].outerHTML is '

'
+
+  it 'release scope when release self', ->
+    c = new Neck.Controller
+      scope: true
+      inhertScope: false
+
+    assert.isObject c.scope
+
+    spy = c.scope.release = sinon.spy()
+
+    c.release()
+    assert.ok spy.calledOnce
+    assert.isUndefined c.scope
+
+
+
+
+
+


Confirmation for delete button, also fix addProduct method name.

--- client/app/Applications/Groups.kdapplication/Views/productsettingsview.coffee
+++ client/app/Applications/Groups.kdapplication/Views/productsettingsview.coffee
@@ -17,7 +17,7 @@
       cssClass   : "product-button"
       title      : "+"
       callback   : =>
-        group.addPlan
+        group.addProduct
           name  : __utils.slugify @name.getValue()
           price : @price.getValue()
           title : @name.getValue()
@@ -131,9 +131,23 @@

     @deleteButton = new KDButtonView
       title    : "-"
-      callback : ->
-        new KDNotificationView
-          title: "Coming soon!"
+      callback : =>
+        @confirmDelete =>
+
+  confirmDelete:(callback) ->
+
+    @deleteModal = new KDModalView
+      title        : "Warning"
+      content      : "
Are you sure you want to delete this item?
"
+      height       : "auto"
+      overlay      : yes
+      buttons      :
+        Yes        :
+          loader   :
+            color  : "#ffffff"
+            diameter : 16
+          style    : "modal-clean-gray"
+          callback : callback

   viewAppended: JView::viewAppended


Remaining renaming :|

Remaining renaming :|

--- client/landing/app/Applications/Chat.kdapplication/Views/mainchatpanel.coffee
+++ client/landing/app/Applications/Chat.kdapplication/Views/mainchatpanel.coffee
@@ -6,13 +6,13 @@

     @registerSingleton "chatPanel", @, yes

-    @contactList = new ChatContactListView
-    @contactListController = new ChatContactListController
-      view : @contactList
+    @conversationList = new ChatConversationListView
+    @conversationListController = new ChatConversationListController
+      view : @conversationList

   viewAppended:->
-    @addSubView @contactList
-    @contactListController.loadItems()
+    @addSubView @conversationList
+    @conversationListController.loadItems()

   show:->
     @setClass 'visible'


Let specs cleanup after themselves

Let specs cleanup after themselves

--- src/index.feature.spec.coffee
+++ src/index.feature.spec.coffee
@@ -21,6 +21,7 @@
     listenProcess.on 'close', ->
       setTimeout ->
         watch.end()
+        del.sync './tmp', force: true
         done()
       , 250
     listenProcess.kill 'SIGTERM'


delete working within gui

delete working within gui

--- coffee/main.coffee
+++ coffee/main.coffee
@@ -633,9 +633,18 @@
     if idx > -1
       @elements.splice(idx, 1)

-    idx = @baseRect.obj3d.children.indexOf(e.shp.obj3d)
+    obj3d = null
+    obj3d = e.shp.obj3d if e.shp?
+    obj3d = e.ln.obj3d if e.ln?
+    idx = @baseRect.obj3d.children.indexOf(obj3d)
     if idx > -1
       @baseRect.obj3d.children.splice(idx, 1)
+
+    if e.glowBubble?
+      idx = @selectGroup.children.indexOf(e.glowBubble)
+      if idx > -1
+        @selectGroup.children.splice(idx, 1)
+      delete e.glowBubble

     @ve.render()

@@ -722,8 +731,6 @@
       @updateLink(ln) for ln in o.lines
     true

-
-  moveSelection: -> #TODO

   glowMaterial: () ->
     #cam = @ve.sview.camera
@@ -858,6 +865,22 @@
   clearSelector: ->
     @selectorGroup.children = []
     @ve.render()
+
+  deleteSelection: () =>
+    console.log("deleting selection")
+    deletes = []
+    for x in @selectGroup.children
+      deletes.push(x.userData)
+      if x.userData.links?
+        deletes.push.apply(deletes, x.userData.links)
+    @ve.addie.delete(deletes)
+
+    for d in deletes
+      @removeElement(d)
+
+    @ve.propsEditor.hide()
+    @ve.equationEditor.hide()
+    true

 class NameManager
   constructor: () ->
@@ -1300,6 +1323,11 @@
     #above update structure
     doSettingsUpdate()

+  delete: (xs) =>
+
+    console.log("addie deleting objects")
+    console.log(xs)
+
   load: () =>
     ($.get "/addie/"+dsg+"/design/read", (data, status, jqXHR) =>
       console.log("design read success")
@@ -2159,6 +2187,9 @@
       @ve.hsplit()
     else if(keycode == 86)
       @ve.vsplit()
+    else if(keycode == 8 || keycode == 46)
+      @ve.surface.deleteSelection()
+      event.preventDefault()


client: minify kd.js

client: minify kd.js

--- Builder.coffee
+++ Builder.coffee
@@ -79,7 +79,7 @@
   buildFramework:->

     @config ?= require('koding-config-manager').load("main.#{options.configFile}")
-    cmd = "cd client/Framework && npm i && gulp compile --outputDir=../../website/a/"
+    cmd = "cd client/Framework && npm i && gulp compile --uglify --outputDir=../../website/a/"
     exec cmd, (err, stdout, stderr)->
       console.log """\n\n
       ################################### FRAMEWORK COMPILED #################################


color stat bonuses with red/green based on pos/neg values.

--- src/adventure/characterRollView.coffee
+++ src/adventure/characterRollView.coffee
@@ -26,6 +26,9 @@
     @setButton(7, "BACK")
     @roll()

+  getBonusColor: (value) ->
+    if value < 0 then "#FF8080" else "#80FF80"
+
   roll : =>
     @player = new Player(@character)
     @clear()
@@ -42,32 +45,32 @@
       @addLabel("HP: #{@player.hitPoints}/#{@player.maxHitPoints}, SP: #{@player.spellPoints}/#{@player.maxSpellPoints}", "#FFF", 20, y)
     else
       @addLabel("Hit Points: #{@player.hitPoints}/#{@player.maxHitPoints}", "#FFF", 20, y)
-    y += 8
-    @addLabel("Armor Class: #{@player.getArmorClass()}", "#FFF", 20, y)
+#    y += 8
+#    @addLabel("Armor Class: #{@player.getArmorClass()}", "#FFF", 20, y)
     y += 16
     @addLabel("Strength: #{@player.getStrength()}", "#FFF", 20, y)
     y += 8
     strengthBonus = @player.getAttributeBonus(@player.getStrength())
     if (strengthBonus != 0)
-      @addLabel("Damage Bonus: #{strengthBonus}", "#A0A0A0", 28, y)
+      @addLabel("Damage Bonus: #{strengthBonus}", @getBonusColor(strengthBonus), 28, y)
     y += 10
     @addLabel("Accuracy: #{@player.getAccuracy()}", "#FFF", 20, y)
     y += 8
     accuracyBonus = @player.getAttributeBonus(@player.getAccuracy())
     if (accuracyBonus != 0)
-      @addLabel("To-Hit Bonus: #{accuracyBonus}", "#A0A0A0", 28, y)
+      @addLabel("To-Hit Bonus: #{accuracyBonus}", @getBonusColor(accuracyBonus), 28, y)
     y += 10
     @addLabel("Awareness: #{@player.getAwareness()}", "#FFF", 20, y)
     y += 8
     awarenessBonus = @player.getAttributeBonus(@player.getAwareness())
     if (awarenessBonus != 0)
-      @addLabel("Armor Class Bonus: #{awarenessBonus}", "#A0A0A0", 28, y)
+      @addLabel("Armor Class Bonus: #{awarenessBonus}", @getBonusColor(awarenessBonus), 28, y)
     y += 10
     @addLabel("Toughness: #{@player.getConstitution()}", "#FFF", 20, y)
     y += 8
     constitutionBonus = @player.getAttributeBonus(@player.getConstitution())
     if (constitutionBonus != 0)
-      @addLabel("HP Bonus/Level: #{constitutionBonus}", "#A0A0A0", 28, y)
+      @addLabel("HP Bonus/Level: #{constitutionBonus}", @getBonusColor(constitutionBonus), 28, y)

   command : (text) =>
     switch (text)


Rework up.feedback docs and document [up-alias]

--- lib/assets/javascripts/unpoly/feedback.coffee
+++ lib/assets/javascripts/unpoly/feedback.coffee
@@ -9,34 +9,37 @@

 \#\#\# Example

-Let's say we have an navigation bar with two links, pointing to `/foo` and `/bar` respectively:
-
-

+Let's say we have an `<nav>` element with two links, pointing to `/foo` and `/bar` respectively:
+
+
       Foo
       Bar
-

+
+
+By giving the navigation bar the `[up-nav]` attribute, links pointing to the current browser address are highlighted
+as we navigate through the site.

 If the current URL is `/foo`, the first link is automatically marked with an [`.up-current`](/a.up-current) class:

-

+
       Foo
       Bar
-

+

 When the user clicks on the `/bar` link, the link will receive the [`up-active`](/a.up-active) class while it is waiting
 for the server to respond:

-

+
       Foo
       Bar

 Once the response is received the URL will change to `/bar` and the `up-active` class is removed:

-

+
       Foo
       Bar
-

+

 @module up.feedback
@@ -242,7 +245,7 @@
   ###**
   Marks this element as a navigation component, such as a menu or navigation bar.

-  When a link within an `[up-nav]` element points to the current location, it is assigned the `.up-current` class. When the browser navigates to another location, the class is removed automatically.
+  When a link within an `[up-nav]` element points to the current location, it is assigned the [`.up-current`](/a.up-current) class. When the browser navigates to another location, the class is removed automatically.

   You may also assign `[up-nav]` to an individual link instead of an navigational container.

@@ -285,29 +288,59 @@

   A link matches the current location (and is marked as `.up-current`) if it matches either:

-  - the link's `href` attribute
-  - the link's `up-href` attribute
-  - a space-separated list of URLs in the link's `up-alias` attribute
-
-  \#\#\# Matching URL by pattern
-
-  You can mark a link as `.up-current` whenever the current URL matches a prefix or suffix.
-  To do so, include an asterisk (`*`) in the `up-alias` attribute.
-
-  For instance, the following `[up-nav]` link is highlighted for both `/reports` and `/reports/123`:
-
-      Reports
+  - the link's `[href]` attribute
+  - the link's `[up-href]` attribute
+  - the URL pattern in the link's [`[up-alias]`](/a-up-alias) attribute

   @selector [up-nav]
   @stable
   ###

   ###**
+  Links within `[up-nav]` may use the `[up-alias]` attribute to pass an URL pattern for which they
+  should also be highlighted as [`.up-current`](a.up-current).
+
+  \#\#\# Examples
+
+  The link below will be highlighted with `.up-current` at both `/profile` and `/profile/edit` locations:
+
+
+        Profile
+
+
+  To pass more than one alternative URLs, separate them by a space character:
+
+
+        Profile
+
+
+  Often you would like to mark a link as `.up-current` whenever the current URL matches a prefix or suffix.
+  To do so, include an asterisk (`*`) in the `[up-alias]` attribute. For instance, the first link in the
+  below will be highlighted for both `/users` and `/users/123`:
+
+
+        Users
+        Reports
+

+
+  You may pass multiple patterns separated by a space character:
+
+
+        Users
+
+
+  @selector a[up-alias]
+  @param {String} up-alias
+    A space-separated list of alternative URLs or URL patterns.
+  @stable
+  ###
+
+  ###**
   When a link within an `[up-nav]` element points to the current location, it is assigned the `.up-current` class.

   See [`[up-nav]`](/up-nav) for more documentation and examples.

-  @selector [up-nav] a.up-current
+  @selector a.up-current
   @stable
   ###


Fix add-edge bug.

Fix add-edge bug.

--- src/app/skill/graph/skill.graph.coffee
+++ src/app/skill/graph/skill.graph.coffee
@@ -209,7 +209,7 @@

   $scope.nodeClick = (slug) ->
     if $scope.addingEdge?
-      addEdgeSelect()
+      addEdgeSelect(slug)
     else if $scope.deletingNode
       skillSet.delete(slug)
       buildGraph()
@@ -229,7 +229,7 @@
   # addingEdge == {} or {prereq:___} --> in the process of adding an edge.
   # addingEdge == {prereq:___, skill: ___} --> ready to create the new edge.
   $scope.addingEdge = null
-  addEdgeSelect = () ->
+  addEdgeSelect = (slug) ->
     e = $scope.addingEdge
     if e?.prereq?
       e.skill = slug


Forbid ack = 0

Forbid ack = 0

--- admin/static/coffee/namespaces/replicas.coffee
+++ admin/static/coffee/namespaces/replicas.coffee
@@ -197,6 +197,10 @@
                 msg_error.push('The number of acks must be an integer.')
                 nreplicas_input = formdata.num_replicas
                 nacks_input = formdata.num_acks
+            if formdata.num_acks is "0"
+                msg_error.push('The number of acks must be greater than 0')
+                nreplicas_input = formdata.num_replicas
+                nacks_input = formdata.num_acks
             if msg_error.length isnt 0
                 @render_inner msg_error, nreplicas_input, nacks_input
                 return


text corrections

text corrections

--- src/client/personal_stat_render.coffee
+++ src/client/personal_stat_render.coffee
@@ -101,10 +101,10 @@
   })

   rolesWinrateData = [
-    {role: 'Мирным', winrate: if data.gamesCitizen then Math.floor(data.winsCitizen / data.gamesCitizen * 100) else 0}
-    {role: 'Шерифом', winrate: if data.gamesSheriff then Math.floor(data.winsSheriff / data.gamesSheriff * 100) else 0}
-    {role: 'Мафией', winrate: if data.gamesMafia then Math.floor(data.winsMafia / data.gamesMafia * 100) else 0}
-    {role: 'Доном', winrate: if data.gamesDon then Math.floor(data.winsDon / data.gamesDon * 100) else 0}
+    {role: 'Мирный', winrate: if data.gamesCitizen then Math.floor(data.winsCitizen / data.gamesCitizen * 100) else 0}
+    {role: 'Шериф', winrate: if data.gamesSheriff then Math.floor(data.winsSheriff / data.gamesSheriff * 100) else 0}
+    {role: 'Мафия', winrate: if data.gamesMafia then Math.floor(data.winsMafia / data.gamesMafia * 100) else 0}
+    {role: 'Дон', winrate: if data.gamesDon then Math.floor(data.winsDon / data.gamesDon * 100) else 0}
   ]

   new Morris.Bar({
@@ -112,7 +112,7 @@
     data: rolesWinrateData,
     xkey: 'role',
     ykeys: ['winrate'],
-    labels: ['% побед'],
+    labels: ['процент побед'],
     hideHover:'auto'
   })


Changed respond to hear

Changed respond to hear

--- scripts/snomedct.coffee
+++ scripts/snomedct.coffee
@@ -20,7 +20,7 @@

 module.exports = (robot) ->

-  robot.respond /sctid (.*)/i, (res) ->
+  robot.hear /sctid (.*)/i, (res) ->
     sctid = res.match[1]
     robot.http("https://sct-rest.ihtsdotools.org/api/snomed/en-edition/v20160731/query/concepts/#{sctid}")
     .header('Accept', 'application/json')
@@ -31,7 +31,7 @@

-  robot.respond /concept (.*)/i, (res) ->
+  robot.hear /concept (.*)/i, (res) ->
     concept = res.match[1]
     robot.http("http://browser.ihtsdotools.org/api/snomed/en-edition/v20160731/descriptions?query=#{concept}&statusFilter=activeOnly")
     .header('Accept', 'application/json')


Rewording some triggers to be friendlier and more welcoming

--- scripts/triggers.coffee
+++ scripts/triggers.coffee
@@ -13,41 +13,39 @@
 triggers =
   # Question Assistance
   '!dataja'       : "Don't ask to ask, Just ask!"
-  '!tryit'        : "Try it and see™. You learn much more by experimentation than by asking without having even tried."
+  '!nopros'       : "Please don't ask around for pros on something, just present your question to the room according to our guidelines and someone will help you if they can or send you on the right path."
+  '!tryit'        : "Please make sure you've attempted something before asking. You learn much more by experimentation than by asking without having even tried."
   '!explain'      : "Explain your business goals, such as what website feature you're making, instead of the technical problem you're facing. We can give you much better answers this way."
-  '!help'         : "Before asking for help, use #{pasteUrl} to provide us the information we need to help you - Laravel version, expected/actual behavior, and all relevant code. Paste the link here when done. Thanks!"
-  '!helpme'       : "Before asking for help, use #{pasteUrl} to provide us the information we need to help you - Laravel version, expected/actual behavior, and all relevant code. Paste the link here when done. Thanks!"
-  '!paste'        : "You may paste your code at #{pasteUrl}.  Please avoid using pastebin.com"
+  '!help'         : "Before asking for help, please use #{pasteUrl} to provide us the information we need to help you - Laravel version, expected/actual behavior, full error messages, and all relevant code. Paste the link here when done. Thanks!"
+  '!paste'        : "You may paste your code at #{pasteUrl}.  Please avoid using pastebin.com - we hate slow load times and ads :)"
   '!dontspam'     : "Please don't paste your code here. Instead, use #{pasteUrl}"
+  '!failpaste'    : "Please don't paste your code directly to the room. Instead, use #{pasteUrl}"
   '!debug'        : "Open your <project>/app/config/app.php and set the debug key to true"
   '!pb'           : "Please avoid using pastebin.com as it is slow and forces others to look at ads.  Please use #{pasteUrl}.  Thanks!"
   '!rules'        : "You may review our room rules at http://goo.gl/Tl77U2"
   '!docs'         : "The !docs syntax is deprecated, please use `Liona show <user> docs for <topic>`"
-  '!usedev'       : "Only use the dev version if you don't mind potentially needing to change your app on updates if the API or concepts change. Stability is not guaranteed."
+  '!usedev'       : "Only use the dev version if you don't mind potentially needing to change your app on updates if the API or concepts change. Stability is not guaranteed nor likely."
+  '!43'           : "Laravel 4.3 has been bumped to 5.0 and is expected to be released in January."
+  '!5'            : "Laravel 5.0 is expected to be released in January."
+  '!xy'           : "It's difficult to discuss a solution without first understanding the problem. Please, explain the problem itself and not the solution that you have in mind. For more info on presenting your problem see !help. Thanks! Also see http://mywiki.wooledge.org/XyProblem"

   # Helpers
   '!ugt'          : "It is always morning when someone comes into a channel. We call that Universal Greeting Time http://www.total-knowledge.com/~ilya/mips/ugt.html"
   '!nick'         : "Hello! You're currently using a nick that's difficult to distinguish. Please type in \"/nick your_name\" so we can easily identify you"
-  '!wiki'         : "Check out our wiki at http://wiki.laravel.io!"
   '!welcome'      : "Hello, I'm #{process.env.HUBOT_IRC_NICK}, the Laravel IRC Bot!  Welcome to Laravel :).  If you have any questions, type !help to see how to best ask for assistance.  If you need to paste code, check !paste for more info.  Thanks!"
   '!wysiwyg'      : "What you see is what you get"
   '!relsched'     : "Here is our release schedule: http://wiki.laravel.io/Laravel_4#Release_Schedule"
-  '!massassign'   : "Getting a MassAssignmentException? Find out how to protect your input at: http://wiki.laravel.io/FAQ_(Laravel_4)#MassAssignmentException"
-  '!docscontrib'  : "Want to contribute to the documentation? Awesome! Fork and submit a pull request at http://github.com/laravel/docs"
+  '!massassign'   : "Getting a MassAssignmentException? Learn what it's all about at http://laravel.com/docs/4.2/eloquent#mass-assignment"
+  '!contrib'      : "Want to contribute to Laravel? Thanks for being awesome! Fork and submit a pull request at http://github.com/laravel/framework."
+  '!docscontrib'  : "Want to contribute to the documentation? Thanks for being awesome! Fork and submit a pull request at http://github.com/laravel/docs"
+  '!liferaft'     : "Liferaft is a Laravel CLI application to help users send pull requests to report bugs, and help developers efficiently retrieve and resolve bugs. Read more at http://laravel.com/docs/contributions#creating-liferaft-applications"

   # Fun
   '!no'           : "NOOOOOOOOO! http://www.youtube.com/watch?v=umDr0mPuyQc"
-  '!xy'           : "It's difficult to discuss a solution without first understanding the problem. Please, explain the problem itself and not the solution that you have in mind. For more info on presenting your problem see !help. Thanks! Also see http://mywiki.wooledge.org/XyProblem"
   '!drama'        : "I just can't take it anymore...."
-  '!41update'     : "If you're updating from 4.0.x to Laravel 4.1, make sure you follow the steps in http://laravel.com/docs/upgrade to avoid common issues. This is due to necessary changes to the Laravel core to make things more awesome."
-  '!43'           : "Laravel 4.3 has been bumped to 5.0 and is expected to be released in January."
-  '!5'            : "Laravel 5.0 is expected to be released in January."
-  '!ghissues'     : "Laravel's GitHub issues are closed. Please see #laravel-dev or check out ways you can report a bug at http://laravel.com/docs/contributions#bugs"
-  '!liferaft'     : "Liferaft is a Laravel CLI application to help users send pull requests to report bugs, and help developers efficiently retrieve and resolve bugs. Read more at http://laravel.com/docs/contributions#creating-liferaft-applications"
   '!tableflip'    : "(╯°□°)╯︵ ┻━┻"
   '!dunno'        : " ¯\\_(ツ)_/¯"
   '!tableflippin' : "http://i.imgur.com/xBQOzc4.gif"
-  '!testing'      : "lolnope"
   '!goal'         : "GOOOOOOOOOOOOOOOOOOOOOOAAAAAAAAAAAAAAAAALLLLLLLLLL!!!!!!!"
   '!unstoppable'  : "I am unstoppable!!! http://i.imgur.com/ALHS4Za.png"

@@ -70,4 +68,4 @@
       else
         msg.send triggerPhrase
     else
-      msg.reply "....ya I got nothing."
+      msg.reply "....ya I got nothing for #{trigger}."


Added another check

Added another check

--- server/lib/crawler/staticpages/profile.coffee
+++ server/lib/crawler/staticpages/profile.coffee
@@ -19,7 +19,7 @@

   linkToStatusUpdate = createLinkToStatusUpdate createdAt, slug
   statusUpdateContent = ""
-  if statusUpdate.body
+  if statusUpdate?.body?
     statusUpdateContent =
     """
     <li itemtype="http://schema.org/Comment" itemscope itemprop="comment">


teams: minor: code reuse prep

teams: minor: code reuse prep

--- servers/lib/server/handlers/createteam.coffee
+++ servers/lib/server/handlers/createteam.coffee
@@ -110,6 +110,8 @@
       username
     } = body

+    token = result.newToken or result.replacementToken
+
     { JUser, JGroup, JInvitation } = koding.models

     # don't set the cookie we don't want that
@@ -122,7 +124,7 @@
     # set session token for later usage down the line
     owner                      = result.account
     redirect                  ?= '/'
-    client.sessionToken        = result.newToken or result.replacementToken
+    client.sessionToken        = token
     client.connection.delegate = result.account

     if validationError = validateGroupDataAndReturnError body


Server: create invitations and update marketing setting

--- servers/lib/server/handlers/createteam.coffee
+++ servers/lib/server/handlers/createteam.coffee
@@ -1,11 +1,40 @@
+Bongo                                   = require "bongo"
 koding                                  = require './../bongo'
 { getClientId, handleClientIdNotFound } = require './../helpers'
+{ dash }                                = Bongo
+{ uniq }                                = require 'underscore'
+
+createInvitations = (client, invitees, callback)->
+  return callback null  if invitees is ""
+
+  inviteEmails = invitees.split(",") or []
+  return callback null  if inviteEmails.length is 0 # return early
+
+  # data = { invitations:[ {email:"cihangir+test26@koding.com"} ] }
+  invitations = uniq inviteEmails           # remove duplicates
+    .filter (email) -> email isnt ""        # clear emtpty ones
+    .map (email) -> { email: email.trim() } # clear empty spaces
+
+  koding.models.JInvitation.create client, { invitations }, (err)->
+    console.error "Err while creating invitations", err  if err
+    callback()

 module.exports = (req, res, next) ->

-  { body }                        = req
-  { JUser, JGroup }               = koding.models
-  { companyName, slug, redirect } = body
+  { body }              = req
+  { JUser, JGroup, JInvitation }  = koding.models
+  { # companyName, team name, basically a title, can be changed
+    companyName
+    # slug is team slug, unique name. Can not be changed
+    slug
+    redirect
+    # invitees are comma separated emails which will be invited to that team
+    invitees
+    # domains holds allowed domains for signinup without invitation, comma separated
+    domains
+    # newsletter holds announcements config.
+    newsletter
+  } = body

   redirect ?= '/'
   context   = { group: slug }
@@ -27,7 +56,11 @@

     client.clientIP = (clientIPAddress.split ',')[0]

-    JUser.convert client, req.body, (err, result) ->
+    body.emailFrequency or= {}
+    # subscribe to koding marketing mailings or not
+    body.emailFrequency.marketing = !!newsletter
+
+    JUser.convert client, body, (err, result) ->

       if err?

@@ -46,8 +79,15 @@
       # res.cookie 'clientId', result.newToken, path : '/'

       # set session token for later usage down the line
-      client.sessionToken = result.newToken
-      owner               = result.account
+      client.sessionToken         = result.newToken
+      owner                       = result.account
+      client.connection.delegate  = result.account
+
+      # clear domains
+      allowedDomains = domains?.split(",") or []
+      allowedDomains = uniq allowedDomains # remove duplicates
+        .filter (domain) -> domain isnt "" # clear emtpty ones
+        .map (domain) -> domain.trim()     # clear empty spaces

       JGroup.create client,
         title           : companyName
@@ -55,13 +95,22 @@
         visibility      : 'hidden'
         defaultChannels : []
         initialData     : body
+        allowedDomains  : allowedDomains
       , owner, (err, group) ->

         console.log err, group

         return res.status(500).send "Couldn't create the group."  if err or not group

-        # handle the request as an XHR response:
-        return res.status(200).end() if req.xhr
-        # handle the request with an HTTP redirect:
-        res.redirect 301, redirect
+        queue = [
+          # add other parallel operations here
+          -> createInvitations client, invitees, queue.fin
+        ]
+        dash queue, (err)->
+          # do not block group creation
+          console.error "Error while creating group artifacts", body, err if err
+
+          # handle the request as an XHR response:
+          return res.status(200).end() if req.xhr
+          # handle the request with an HTTP redirect:
+          res.redirect 301, redirect


add probability to change autocomplete result count

--- app/assets/javascripts/app/select2/view.coffee
+++ app/assets/javascripts/app/select2/view.coffee
@@ -1,6 +1,6 @@
 class @Select2Input.View extends Backbone.View

-  RESULT_LIMIT = 10
+  resultLimin = 10

   initItem : () ->
     @$item = @$el.find('[data-rel-multiple]')
@@ -72,7 +72,7 @@
     result = []
     index = 0
     chosen = @$item.select2('val')
-    while result.length isnt RESULT_LIMIT
+    while result.length isnt resultLimin
       item = data[index++]
       break if item is undefined
       result.push(item) if chosen.indexOf(item.id) is -1
@@ -130,5 +130,7 @@
   @initPlugin : () =>
     @initAll()

+  @setResultLimit : (limit) -> resultLimin = limit
+
 $ ->
-  $(document).on 'ready page:load init-plugins', Select2Input.View.initPlugin+  $(document).on 'ready page:load init-plugins', Select2Input.View.initPlugin


show popover in .graf--mixtapeEmbed

show popover in .graf--mixtapeEmbed

--- app/assets/javascripts/dante/popover.js.coffee
+++ app/assets/javascripts/dante/popover.js.coffee
@@ -25,7 +25,7 @@
   #display & copy original link
   positionAt: (ev)->
     target           = $(ev.currentTarget)
-    target_positions = target.position()
+    target_positions = @resolveTargetPosition(target)
     target_offset    = target.offset()
     target_width     = target.outerWidth()
     target_height    = target.outerHeight()
@@ -37,6 +37,8 @@
       .css("top", top_value)
       .css("left",  left_value )
       .show()
+
+    @handleDirection(target)

   displayAt: (ev)->
     @cancelHide()
@@ -57,5 +59,18 @@
       $(@el).find(".popover").hide()
     , @settings.timeout

+  resolveTargetPosition: (target)->
+    if target.parents(".graf--mixtapeEmbed").exists()
+      target.parents(".graf--mixtapeEmbed").position()
+    else
+      target.position()
+
+
+  handleDirection: (target)->
+    if target.parents(".graf--mixtapeEmbed").exists()
+      $(@el).find(".popover").removeClass("popover--bottom").addClass("popover--top")
+    else
+      $(@el).find(".popover").removeClass("popover--top").addClass("popover--bottom")
+
   render: ()->
     $(@template()).insertAfter(@editor.$el)


Fix selected bar unselect legend element

--- app/assets/javascripts/chart/vue-chart.js.coffee
+++ app/assets/javascripts/chart/vue-chart.js.coffee
@@ -33,6 +33,9 @@
         dataset = chart.data.datasets[datasetIndex]
         data = dataset.data[barIndex]
         meta = chart.controller.getDatasetMeta(datasetIndex)
+
+        return if meta.hidden
+
         bar = meta.data[barIndex]

         this._drawText(chart, bar, data, textColor)


Update light-box.cjsx

Update light-box.cjsx

remove unused code

--- app/assets/javascripts/components/light-box.cjsx
+++ app/assets/javascripts/components/light-box.cjsx
@@ -18,8 +18,6 @@
   getInitialState:->
     first: @props.subject_set.subjects[0]
     folded: false
-
-  componentWillReceiveProps: (new_props) ->

   handleFoldClick: (e)->


Inform patient about result being sent to doctor

--- app/assets/javascripts/components/outcome.coffee
+++ app/assets/javascripts/components/outcome.coffee
@@ -27,7 +27,7 @@
                 key: 'check'
               span
                 key: 'thanks'
-                'Bedankt voor het invullen'
+                'Bedankt voor het invullen. De uitslag van de test is verstuurd naar uw behandelaar.'
             ]
           p
             key: 'estimate-variance'


F didCommit event fired

F didCommit event fired

--- app/assets/javascripts/mixins/model_ajax_mixin.js.coffee
+++ app/assets/javascripts/mixins/model_ajax_mixin.js.coffee
@@ -9,7 +9,7 @@

     success = (response) =>
       store.pushPayload(type.typeKey,response)
-      console.info 'inserted'
+      @adapterDidCommit()

     error = (response) =>
       # we have to manually add the errors


Format assignee options correctly.

Format assignee options correctly.

--- app/assets/javascripts/overlays/components.js.jsx.coffee
+++ app/assets/javascripts/overlays/components.js.jsx.coffee
@@ -80,10 +80,14 @@

 Tahi.overlays.components.AssigneeDropDown = React.createClass
   displayName: "AssigneeDropDown"
+
+  assigneeOptions: ->
+    _.map @props.assignees, (a) ->
+      [a.id, a.full_name]
   render: ->
     {div, label, select, option} = React.DOM

-    assignees = [[null, 'Please select assignee']].concat @props.assignees
+    assignees = [[null, 'Please select assignee']].concat @assigneeOptions()
     (Tahi.overlays.components.RailsForm {action: @props.action, ref: 'form'}, [
       (label {htmlFor: "task_assignee_id"}, 'This card is owned by'),
       (Chosen {


on save reset model

on save reset model

--- app/assets/javascripts/type_station/lib/models.js.coffee
+++ app/assets/javascripts/type_station/lib/models.js.coffee
@@ -18,9 +18,12 @@
     super key, value
     @DIRTY[key] = 1

-
   changedKeys: -> Object.keys(@DIRTY)
   isChanged: -> @changedKeys().length > 0
+
+  _reset: ->
+    @STORE = {}
+    @DIRTY = {}

   save: (callback = ->) ->
     self = @
@@ -35,4 +38,6 @@
       dataType: 'json'
       contentType: 'application/json'
       data: JSON.stringify({contents: data})
-      success: (data) -> callback(data)+      success: (data) ->
+        self._reset() #reset model to a clean state
+        callback(data)


Set widget__progress as last class

Set widget__progress as last class

--- app/assets/javascripts/uploadcare/widget/template.coffee
+++ app/assets/javascripts/uploadcare/widget/template.coffee
@@ -13,7 +13,14 @@
     constructor: (@settings, @element)->
       @content = $(tpl('widget'))
       @element.after(@content)
-      @circle = new progress.Circle(@content.find('.uploadcare--widget__progress'))
+      @circle = new progress.Circle(
+        @content
+          .find('.uploadcare--widget__progress')
+          .removeClass('uploadcare--widget__progress')
+      )
+      @content
+        .find('.uploadcare--progress')
+        .addClass('uploadcare--widget__progress')
       @statusText = @content.find('.uploadcare--widget__text')
       @content.toggleClass('uploadcare--widget_option_clearable',
                            @settings.clearable)


move the show and hides outside of the if block

--- app/assets/javascripts/me-add-to-playlist.coffee
+++ app/assets/javascripts/me-add-to-playlist.coffee
@@ -18,11 +18,10 @@
         if $('#add_playlist_item_playlists_not_empty')[0].innerHTML == 'false'
           $('#playlist_item_description')[0].value = ''
           $('#playlist_item_title').val(player.options.playlistItemDefaultTitle)
-          $('#add_to_playlist').show(500)
           $('#playlist_item_start').val(mejs.Utility.secondsToTimeCode(player.getCurrentTime(), true))
           $('#playlist_item_end').val(mejs.Utility.secondsToTimeCode(player.media.duration, true))
-          $('#add_to_playlist_alert').hide(500)
-
+        $('#add_to_playlist').show(500)
+        $('#add_to_playlist_alert').hide(500)

       $('.add_playlist_item_submit').click (event) ->
         p = $('#post_playlist_id').val()


data-table heading

data-table heading

--- app/assets/javascripts/monster/directives.coffee
+++ app/assets/javascripts/monster/directives.coffee
@@ -110,4 +110,17 @@
   '''
 app.directive 'pagination', [directive]

-
+$directive 'tableHeading', '$timeout','$window',
+  ($timeout,$window)->
+    (scope,element,attrs)->
+      resize = ->
+        fun = ->
+          parent  = element.parent('.data_table')
+          columns = parent.find('.top_level:first .cell')
+          _.each columns, (e,i)=>
+            width = angular.element(e).outerWidth()
+            element.find(".cell:nth-child(#{i+1})").css width: "#{width}px"
+        $timeout fun, 0
+      angular.element($window).bind 'resize', resize
+      scope.$watch attrs.tableHeading       , resize
+      scope.$on 'data_heading'              , resize


unstick scroll to bottom for chat so load more doesn't scroll down

--- app/assets/javascripts/views/chat_view.js.coffee
+++ app/assets/javascripts/views/chat_view.js.coffee
@@ -14,6 +14,8 @@
     @scrollContainer = options.scrollContainer
     @scrollPadding = options.scrollPadding

+    @stuckToBottom = true
+
     @scrollContainer.css(
       'height':'100px'
       'overflow-x':'hidden'
@@ -28,12 +30,14 @@

   render: =>
     @$('.js-chat-load-more').toggle(@collection.length >= 25)
-    @scrollToLatestActivity()
+    @scrollToLatestActivity() if @stuckToBottom

   scrollToLatestActivity: ->
     $(@scrollContainer).scrollTop(999999)

   optimisticallyCreateActivity: (comment) ->
+    @stuckToBottom = true
+
     activity = new Activity(
       type: 'activities/chat'
       created: (new Date()).toISOString()
@@ -54,6 +58,7 @@

   onLoadMore: (e) =>
     e.preventDefault()
+    @stuckToBottom = false
     originalText = $('.js-chat-load-more').text()
     @$('.js-chat-load-more').attr('disabled', true).text('Loading…')
     $.ajax(


highlight nodes when hovering on pertaining edge.

--- app/assets/javascripts/plugins/graph.js.coffee
+++ app/assets/javascripts/plugins/graph.js.coffee
@@ -186,6 +186,17 @@
     addClass($(this), 'selected')
     embedEdgeInfo(path_data)

+  $('path').on('mouseenter', (e) ->
+    removeClass('g.node', 'highlight')
+    path_data = d3.select(this).data()[0]
+    source_index = path_data.source.index
+    target_index = path_data.target.index
+    addClass($(node[0][source_index]), 'highlight')
+    addClass($(node[0][target_index]), 'highlight')
+  )
+  $('path').on('mouseleave', (e) ->
+    removeClass('g.node', 'highlight'))
+
   node.append("circle").
     attr("r", (d) ->
       return 10 if d.is_center


had to add a fake oid mapping to the new 5.0 data critera.

--- app/assets/javascripts/utils/oid_map.js.coffee
+++ app/assets/javascripts/utils/oid_map.js.coffee
@@ -36,6 +36,8 @@
       classname = classname.replace /_([a-z])/g, (g) -> g[1].toUpperCase()
       classname.charAt(0).toUpperCase() + classname.slice(1)

+  # TODO: these oid maps should not be included directly. They should be passed
+  # in as an argument.
   @hqmf_oid_map =
     "2.16.840.1.113883.3.560.1.1001":
         "definition":"patient_characteristic"
@@ -819,3 +821,9 @@
     "2.16.840.1.113883.10.20.28.3.85":
         "definition":"transfer_to"
         "status":""
+    "OID_NOT_YET_AVAILABLE_ADVERSE_EVENT":
+        "definition":"adverse_event"
+        "status":""
+    "OID_NOT_YET_AVAILABLE_ALLERGY_INTOLERANCE":
+        "definition":"allergy_intolerance"
+        "status":""


Minor coffeescript syntax tweak

Minor coffeescript syntax tweak

--- app/js/jail_iframe/classes/control_iframe.coffee
+++ app/js/jail_iframe/classes/control_iframe.coffee
@@ -1,7 +1,7 @@

 class ControlIframe
   constructor: ->
-    @el = document.createElement('iframe');
+    @el = document.createElement('iframe')
     #need to append to outer document before we can access frame document.
     FactlinkJailRoot.$factlinkCoreContainer.append(@el)
     @$el = $(@el)


Extracted IconButton

Extracted IconButton

--- app/js/jail_iframe/classes/icon_buttons.coffee
+++ app/js/jail_iframe/classes/icon_buttons.coffee
@@ -1,41 +1,81 @@
-class FactlinkJailRoot.ShowButton
-  constructor: (highlightElements, factId) ->
-    @$el = $('<factlink-icon-button><factlink-bubble-icon><factlink-bubble-icon-triangle></factlink-bubble-icon-triangle></factlink-bubble-icon></factlink-icon-button>')
+class IconButton
+  constructor: (options) ->
+    $targetElement = $(options.targetElement)
+
+    @$el = $ """
+      <factlink-icon-button>
+        <factlink-bubble-icon>
+          #{options.content || ''}
+          <factlink-bubble-icon-triangle></factlink-bubble-icon-triangle>
+        </factlink-bubble-icon>
+      </factlink-icon-button>
+    """
     FactlinkJailRoot.$factlinkCoreContainer.append(@$el)

-    @$highlightElements = $(highlightElements)
-    @_factId = factId
+    @_tether = new Tether
+      element: @$el[0]
+      target: $targetElement[0]
+      attachment: 'top left'
+      targetAttachment: 'top right'
+      classPrefix: 'factlink-tether'
+      targetOffset: options.targetOffset || '0 0'

     @_robustHover = new FactlinkJailRoot.RobustHover
       $el: @$el
-      mouseenter: @_onHover
-      mouseleave: @_onUnhover
-    @$el.on 'click', @_onClick
+      mouseenter: options.mouseenter
+      mouseleave: options.mouseleave
+    @$el.on 'click', options.mouseclick

+    targetColor = $targetElement.css('color')
+    targetRGB = targetColor.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/)
+
+    # See https://gamedev.stackexchange.com/questions/38536/given-a-rgb-color-x-how-to-find-the-most-contrasting-color-y/38561#38561
+    targetBrightness = 0.2126*targetRGB[1]*targetRGB[1] + 0.7152*targetRGB[2]*targetRGB[2] + 0.0722*targetRGB[3]*targetRGB[3]
+
+    @$el.css
+      'line-height': $targetElement.css('line-height')
+      'font-size': Math.max 15, Math.min 20, parseInt $targetElement.css('font-size')
+      'color': if targetBrightness > 0.5 then 'black' else 'white'
+
+    @$el.find('factlink-bubble-icon').css
+      'background-color': targetColor
+
+    @$el.find('factlink-bubble-icon-triangle').css
+      'border-top-color': targetColor
+
+  destroy: ->
+    @_tether.destroy()
+    @_robustHover.destroy()
+    @$el.remove()
+
+  fadeIn: ->
     @$el.addClass 'factlink-control-visible'
+
+  fadeOut: ->
+    @$el.removeClass 'factlink-control-visible'
+
+class FactlinkJailRoot.ShowButton
+  constructor: (highlightElements, factId) ->
+    @$highlightElements = $(highlightElements)

     # TODO: really do grouping, so we don't have to do hacks like this!
     textContainer = @_textContainer(@$highlightElements[0])
     verticalOffset = @$highlightElements.offset().top - $(textContainer).offset().top
     verticalOffsetPercentage = verticalOffset*100 / $(textContainer).height()

-    @_tether = new Tether
-      element: @$el[0]
-      target: textContainer
-      attachment: 'top left'
-      targetAttachment: 'top right'
-      classPrefix: 'factlink-tether'
+    @_iconButton = new IconButton
+      targetElement: textContainer
       targetOffset: "#{verticalOffsetPercentage}% 0"
+      mouseenter: @_onHover
+      mouseleave: @_onUnhover
+      click: @_onClick

-    @$el.css
-      # 'color': $(textContainer).css('color')
-      'line-height': $(textContainer).css('line-height')
-      'font-size': Math.max 15, Math.min 20, parseInt $(textContainer).css('font-size')
+    @_factId = factId
+
+    @_iconButton.fadeIn()

   destroy: ->
-    @_tether.destroy()
-    @_robustHover.destroy()
-    @$el.remove()
+    @_iconButton.destroy()

   _onHover: =>
     @$highlightElements.addClass 'fl-active'
@@ -57,40 +97,26 @@
     @$paragraph = $(paragraphElement)
     return unless @_valid()

-    @$el = $(@$el = $('<factlink-icon-button><factlink-bubble-icon>+<factlink-bubble-icon-triangle></factlink-bubble-icon-triangle></factlink-bubble-icon></factlink-icon-button>'))
-    FactlinkJailRoot.$factlinkCoreContainer.append(@$el)
-
-    @$el.css
-      # 'color': @$paragraph.css('color')
-      'line-height': @$paragraph.css('line-height')
-      'font-size': Math.max 15, Math.min 20, parseInt @$paragraph.css('font-size')
+    @_iconButton = new IconButton
+      content: '+'
+      targetElement: @$paragraph[0]
+      mouseenter: => @_attentionSpan.gainAttention()
+      mouseleave: => @_attentionSpan.loseAttention()
+      click: @_onClick

     @_attentionSpan = new FactlinkJailRoot.AttentionSpan
       wait_for_neglection: 500
-      onAttentionGained: => @$el.addClass 'factlink-control-visible'
-      onAttentionLost: => @$el.removeClass 'factlink-control-visible'
-
-    @_robustHover = new FactlinkJailRoot.RobustHover
-      $el: @$el
-      mouseenter: => @_attentionSpan.gainAttention()
-      mouseleave: => @_attentionSpan.loseAttention()
-    @$el.on 'click', @_onClick
-
-    @_tether = new Tether
-      element: @$el[0]
-      target: @$paragraph[0]
-      attachment: 'top left'
-      targetAttachment: 'top right'
-      classPrefix: 'factlink-tether'
+      onAttentionGained: => @_iconButton.fadeIn()
+      onAttentionLost: => @_iconButton.fadeOut()

     FactlinkJailRoot.on 'factlink.factsLoaded factlinkAdded', @_destroyUnlessValid

     if FactlinkJailRoot.isTouchDevice()
-      @$el.addClass 'factlink-control-visible'
+      @_iconButton.fadeIn()
     else
       @_robustParagraphHover = new FactlinkJailRoot.RobustHover
         $el: @$paragraph
-        mouseenter: => @_showOnlyThisParagraphButton()
+        mouseenter: @_showOnlyThisParagraphButton
         mouseleave: => @_attentionSpan.loseAttention()
       FactlinkJailRoot.on 'hideAllParagraphButtons', @_onHideAllParagraphButtons

@@ -102,13 +128,10 @@
     @_attentionSpan.loseAttentionNow()

   destroy: ->
-    @_tether.destroy()
-    @_robustHover.destroy()
+    @_iconButton.destroy()
     @_attentionSpan.destroy()
     @_robustParagraphHover?.destroy()
-    @$el.remove()
     FactlinkJailRoot.off 'factlink.factsLoaded factlinkAdded', @_destroyUnlessValid
-    @$paragraph.off 'mousemove', @_showOnlyThisParagraphButton
     FactlinkJailRoot.off 'hideAllParagraphButtons', @_onHideAllParagraphButtons

   _destroyUnlessValid: =>


Extracted _setStylesFromElement

Extracted _setStylesFromElement

--- app/js/jail_iframe/classes/icon_buttons.coffee
+++ app/js/jail_iframe/classes/icon_buttons.coffee
@@ -1,7 +1,5 @@
 class IconButton
   constructor: (options) ->
-    $targetElement = $(options.targetElement)
-
     @$el = $ """
       <factlink-icon-button>
         <factlink-icon-button-bubble>
@@ -12,13 +10,7 @@
     """
     FactlinkJailRoot.$factlinkCoreContainer.append(@$el)

-    @_tether = new Tether
-      element: @$el[0]
-      target: $targetElement[0]
-      attachment: 'top left'
-      targetAttachment: 'top right'
-      classPrefix: 'factlink-tether'
-      targetOffset: options.targetOffset || '0 0'
+    @_setStylesFromElement(options.targetElement)

     @_robustHover = new FactlinkJailRoot.RobustHover
       $el: @$el
@@ -26,23 +18,13 @@
       mouseleave: options.onmouseleave
     @$el.on 'click', options.onclick

-    targetColor = $targetElement.css('color')
-
-    # See https://gamedev.stackexchange.com/questions/38536/given-a-rgb-color-x-how-to-find-the-most-contrasting-color-y/38561#38561
-    targetRGB = targetColor.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/)
-    r = targetRGB[1]/255; g = targetRGB[2]/255; b = targetRGB[3]/255;
-    targetBrightness = 0.2126*r*r + 0.7152*g*g + 0.0722*b*b
-
-    @$el.css
-      'line-height': $targetElement.css('line-height')
-      'font-size': Math.max 15, Math.min 20, parseInt $targetElement.css('font-size')
-      'color': if targetBrightness > 0.5 then 'black' else 'white'
-
-    @$el.find('factlink-icon-button-bubble').css
-      'background-color': targetColor
-
-    @$el.find('factlink-icon-button-bubble-triangle').css
-      'border-top-color': targetColor
+    @_tether = new Tether
+      element: @$el[0]
+      target: options.targetElement
+      attachment: 'top left'
+      targetAttachment: 'top right'
+      classPrefix: 'factlink-tether'
+      targetOffset: options.targetOffset || '0 0'

   destroy: ->
     @_tether.destroy()
@@ -54,6 +36,27 @@

   fadeOut: ->
     @$el.removeClass 'factlink-control-visible'
+
+  _setStylesFromElement: (element) ->
+    $element = $(element)
+    targetColor = $element.css('color')
+
+    # See https://gamedev.stackexchange.com/questions/38536/given-a-rgb-color-x-how-to-find-the-most-contrasting-color-y/38561#38561
+    targetRGB = targetColor.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/)
+    r = targetRGB[1]/255; g = targetRGB[2]/255; b = targetRGB[3]/255;
+    targetBrightness = 0.2126*r*r + 0.7152*g*g + 0.0722*b*b
+
+    @$el.css
+      'line-height': $element.css('line-height')
+      'font-size': Math.max 15, Math.min 20, parseInt $element.css('font-size')
+      'color': if targetBrightness > 0.5 then 'black' else 'white'
+
+    @$el.find('factlink-icon-button-bubble').css
+      'background-color': targetColor
+
+    @$el.find('factlink-icon-button-bubble-triangle').css
+      'border-top-color': targetColor
+

 class FactlinkJailRoot.ShowButton
   constructor: (highlightElements, factId) ->


Dom-text-mapper library:

Dom-text-mapper library:

- New feature: getDefaultPath() returns the default path for selection
- New feature: the selectNode() and selectPath() functions instruct
    the browser to select a given sub-tree of a dom, and optionally
    to scroll to it.
- API change: the getAllPaths() function now returns a map,
    instead of a list, and also gives more data. (References the
    actual DOM node, and gives content, too)
- Internal change: the getAllPaths() function now caches the result
    to @allPaths
- API change: the mappings variable
    - is a map now, instead of a list
    - references data from @allPaths, instead of copying it
- New feature: the getMappingsForPath() function returns the mappings
    for a given DOM element.
- API change: the getMappingsFor() function
     - has been renamed to getMappingsForRange()
     - returns the match range information in a slightly different
       (and more verbose) format

- Improved error handling (reloated to iframe problems,
    missing/invalid input parameters, etc)

- Internal change: removed last traces of innerText support
- Internal change in preprocessing:
    acceleration #1: never do any XPath-based lookups, use cached data
       from getAllPaths() instead
    acceleration #2: skip some useless internal state changes in phase
    acceleration #3: reuse more data from phase1 in phase2

- Small bugfixes for weird corner cases

--- app/lib/dom_text_mapper/dom_text_mapper.coffee
+++ app/lib/dom_text_mapper/dom_text_mapper.coffee
@@ -20,8 +20,10 @@
   #
   # Call this when mapping content in an iframe.
   setRootIframe: (iframeId) ->
-    iframe = document.getElementById iframeId
+    iframe = window.document.getElementById iframeId
+    unless iframe? then throw new Error "Can't find iframe with specified ID!"
     @rootWin = iframe.contentWindow
+    unless @rootWin? then throw new Error "Can't access contents of the spefified iframe!"
     @rootNode = @rootWin.document
     @pathStartNode = @getBody()

@@ -36,10 +38,22 @@

   # The available paths which can be scanned
   #
-  # An array is returned, with each entry containing a "path" and a "length" field.
-  # The value of the "path" field is the valid path value, "length" contains information
-  # about the length of content belonging to the given path.
-  getAllPaths: -> @collectPathsForNode @pathStartNode
+  # An map is returned, where the keys are the paths, and the values are objects with the following fields:
+  #   path: the valid path value
+  #   node: reference to the DOM node
+  #   content: the text content of the node, as rendered by the browser
+  #   length: the length of the next content
+  getAllPaths: ->
+    @saveSelection()
+    @allPaths = @collectPathsForNode @pathStartNode
+    @restoreSelection()
+    @allPaths
+
+  # Return the default path
+  getDefaultPath: -> @getPathTo @pathStartNode
+
+  # Select the given path (for visual identification), and optionally scroll to it
+  selectPath: (path, scroll = false) -> @selectNode @allPaths[path].node, scroll

   # Scan the given part of the document.
   #
@@ -63,21 +77,29 @@
   #  corpus will contain the text content of the selected path#
   #  scannedPath will be set to the path
   scan: (path = null, rescan = false) ->
-    if not path? then path = @getAllPaths()[0]
+    path ?= @getDefaultPath()
     if path is @scannedPath and not rescan then return
-    node = @lookUpNode path
-    @mappings = []
+#    console.log "Scanning path: " + path
+    node = @allPaths[path].node
+    @mappings = {}
+    @saveSelection()
     @collectStrings node, path, null, 0, 0
+    @restoreSelection()
     @scannedPath = path
-    @corpus = @mappings[0].content
+    @corpus = @mappings[path].pathInfo.content
+#    console.log "Corpus is: " + @corpus
     null
+
+  getRangeForPath: (path) -> @mappings[path]

   # Get the matching DOM elements for a given text range
   #
   # If the "path" argument is supplied, scan is called automatically.
   # (Except if the supplied path is the same as the last scanned path,
   # and rescan is false.)
-  getMappingsFor: (start, end, path = null, rescan = false) ->
+  getMappingsForRange: (start, end, path = null, rescan = false) ->
+    unless (start? and end?) then throw new Error "start and end is required!"
+#    console.log "Collecting matches for [" + start + ":" + end + "]"
     if path?
       @scan(path, rescan)
     else
@@ -86,7 +108,7 @@

     # Collect the matching mappings
     matches = []
-    for mapping in @mappings when mapping.atomic and @regions_overlap mapping.start, mapping.end, start, end
+    for p, mapping of @mappings when mapping.atomic and @regions_overlap mapping.start, mapping.end, start, end
       do (mapping) =>
         match =
           element: mapping
@@ -97,44 +119,56 @@
         else
          if start <= mapping.start
             match.end = end - mapping.start
-            match.wanted = mapping.content.substr 0, match.end
+            match.wanted = mapping.pathInfo.content.substr 0, match.end
           else if mapping.end <= end
             match.start = start - mapping.start
-            match.wanted = mapping.content.substr match.start
+            match.wanted = mapping.pathInfo.content.substr match.start
           else
             match.start = start - mapping.start
             match.end = end - mapping.start
-            match.wanted = mapping.content.substr match.start, match.end - match.start
+            match.wanted = mapping.pathInfo.content.substr match.start, match.end - match.start
         @computeSourcePositions match
-        match.yields = mapping.node.data.substr match.startCorrected, match.endCorrected - match.startCorrected
+        match.yields = mapping.pathInfo.node.data.substr match.startCorrected, match.endCorrected - match.startCorrected
         matches.push match
+
+    if matches.length is 0
+      throw new Error "No matches found!"
+

     # Create a DOM range object
     r = @rootWin.document.createRange()
     startMatch = matches[0]
-    startNode = startMatch.element.node
-    startInfo = startMatch.element.path
+    startNode = startMatch.element.pathInfo.node
+    startPath = startMatch.element.pathInfo.path
+    startOffset = startMatch.startCorrected
     if startMatch.full
       r.setStartBefore startNode
+      startInfo = startPath
     else
-      r.setStart startNode, startMatch.startCorrected
-      startInfo += ":" + startMatch.startCorrected
+      r.setStart startNode, startOffset
+      startInfo = startPath + ":" + startOffset

     endMatch = matches[matches.length - 1]
-    endNode = endMatch.element.node
-    endInfo = endMatch.element.path
+    endNode = endMatch.element.pathInfo.node
+    endPath = endMatch.element.pathInfo.path
+    endOffset = endMatch.endCorrected
     if endMatch.full
       r.setEndAfter endNode
+      endInfo = endPath
     else
-      r.setEnd endNode, endMatch.endCorrected
-      endInfo += ":" + endMatch.endCorrected
+      r.setEnd endNode, endOffset
+      endInfo = endPath + ":" + endOffset

     result = {
       nodes: matches
       range: r
       rangeInfo:
-        start: startInfo
-        end: endInfo
+        startPath: startPath
+        startOffset: startOffset
+        startInfo: startInfo
+        endPath: endPath
+        endOffset: endOffset
+        endInfo: endInfo
     }
     result

@@ -164,13 +198,14 @@
     xpath = xpath.replace /\/$/, ''
     xpath

-  collectPathsForNode: (node, results = []) ->
-    if @careAboutNodeType and node.nodeType in @ignoredNodeTypes and results.length > 0 then return
-
-    len = (@getNodeContent node).length
-    if len then results.push
-      path: @getPathTo node
-      length: len
+  collectPathsForNode: (node, results = {}) ->
+    path = @getPathTo node
+    cont = @getNodeContent node, false
+    if cont.length then results[path] =
+      path: path
+      node: node
+      content: cont
+      length: cont.length

     if node.hasChildNodes
       children = node.childNodes
@@ -184,28 +219,30 @@

   regions_overlap: (start1, end1, start2, end2) -> start1 < end2 and start2 < end1

-  lookUpNode: (path) ->
-    doc = @rootNode.ownerDocument ? @rootNode
-    results = doc.evaluate path, @rootNode, null, 0, null
-    node = results.iterateNext()
+#  lookUpNode: (path) ->
+#    doc = @rootNode.ownerDocument ? @rootNode
+#    results = doc.evaluate path, @rootNode, null, 0, null
+#    node = results.iterateNext()

   # save the original selection
   saveSelection: ->
     sel = @rootWin.getSelection()
+#    console.log "Saving selection: " + sel.rangeCount + " ranges."
     @oldRanges = (sel.getRangeAt i) for i in [0 ... sel.rangeCount]
-    @oldRanges ?= []
+    switch sel.rangeCount
+      when 0 then @oldRanges ?= []
+      when 1 then @oldRanges = [ @oldRanges ]

   # restore selection
   restoreSelection: ->
+#    console.log "Restoring selection: " + @oldRanges.length + " ranges."
     sel = @rootWin.getSelection()
     sel.removeAllRanges()
     sel.addRange range for range in @oldRanges

-  # Read the "text content" of a sub-tree of the DOM by creating a selection from it
-  getNodeSelectionText: (node, shouldRestoreSelection = true) ->
-    sel = @rootWin.getSelection()
-
-    if shouldRestoreSelection then @saveSelection()
+  # Select the given node (for visual identification), and optionally scroll to it
+  selectNode: (node, scroll = false) ->
+    sel = @rootWin.getSelection()

     # clear the selection
     sel.removeAllRanges()
@@ -215,6 +252,19 @@
     range.setStartBefore node
     range.setEndAfter node
     sel.addRange range
+    if scroll
+      sn = node
+      while not sn.scrollIntoViewIfNeeded?
+        sn = sn.parentNode
+      sn.scrollIntoViewIfNeeded()
+    sel
+
+  # Read the "text content" of a sub-tree of the DOM by creating a selection from it
+  getNodeSelectionText: (node, shouldRestoreSelection = true) ->
+    if shouldRestoreSelection then @saveSelection()
+
+    # select the node
+    sel = @selectNode node

     # read (and convert) the content of the selection
     text = sel.toString().trim().replace(/\n/g, " ").replace /[ ][ ]+/g, " "
@@ -225,10 +275,10 @@

   # Convert "display" text indices to "source" text indices.
   computeSourcePositions: (match) ->
-    sourceText = match.element.node.data.replace /\n/g, " "
+    sourceText = match.element.pathInfo.node.data.replace /\n/g, " "
     # the HTML source of the text inside a text element.

-    displayText = match.element.content
+    displayText = match.element.pathInfo.content
     # what gets displayed, when the node is processed by the browser.

     displayStart = if match.start? then match.start else 0
@@ -253,11 +303,14 @@
     match.endCorrected = sourceEnd
     null

-  getNodeContent: (node) -> @getNodeSelectionText node
+  getNodeContent: (node, shouldRestoreSelection = true) -> @getNodeSelectionText node, shouldRestoreSelection

   collectStrings: (node, parentPath, parentContent = null, parentIndex = 0, index = 0) ->
 #    console.log "Scanning path " + parentPath
-    content = @getNodeContent node
+#    content = @getNodeContent node, false
+
+    pathInfo = @allPaths[parentPath]
+    content = pathInfo?.content

     if not content? or content is ""
       # node has no content
@@ -273,13 +326,11 @@

     endIndex = startIndex + content.length
     atomic = not node.hasChildNodes()
-    @mappings.push
-      "path": parentPath
-      "node": node
-      "content": content
-      "start": parentIndex + startIndex
-      "end": parentIndex + endIndex
-      "atomic" : atomic
+    @mappings[parentPath] =
+      pathInfo: pathInfo
+      start: parentIndex + startIndex
+      end: parentIndex + endIndex
+      atomic: atomic

     if not atomic
       children = node.childNodes


* Do not trigger error upon intentional abort (releaseAll for example)

--- app/assets/javascripts/backbone.rails_store.js.coffee
+++ app/assets/javascripts/backbone.rails_store.js.coffee
@@ -55,6 +55,7 @@
   _manyToManyCreate: {}
   _manyToManyDestroy: {}
   _requests: []
+  _abortingXhrs: false

   ###
     Singleton Pattern
@@ -158,8 +159,10 @@
             hasElements = true
             @release(model, {silent:true})

+    @_abortingXhrs = true;
     _.each @_requests, (xhr) =>
       xhr.abort()
+    @_abortingXhrs = false;
     @_requests.length = 0
     @_changedModels = {}
     @_deletedModels = []
@@ -223,6 +226,7 @@
           @trigger('authenticate:failed')
           throw e
       error: =>
+        return if (@_abortingXhrs)
         options.error() if options.error
         @trigger('authenticate:failed')
     @_doProgress(xhr)
@@ -326,6 +330,7 @@
           @trigger('find:failed', @)
           throw e
       error: =>
+        return if (@_abortingXhrs)
         if options.error
           options.error.apply(@,arguments)
         @trigger('find:failed', @)
@@ -425,6 +430,7 @@
           throw e
       error: =>
         # TODO: i18n
+        return if (@_abortingXhrs)
         errors = {errors: "Communication error!"}
         options.error(errors) if options.error
         @trigger('commit:failed', errors)
@@ -524,6 +530,7 @@
           @trigger('refresh:failed')
           throw e
       error: =>
+        return if (@_abortingXhrs)
         @trigger('refresh:failed')

     @trigger('refresh:start')
@@ -591,6 +598,7 @@
           params.error(resp) if _.isFunction(params.error)
           throw e
       error: (resp) =>
+        return if (@_abortingXhrs)
         @trigger('service:failed', resp)
         params.error(resp) if _.isFunction(params.error)
     @trigger('service:start', @)


type in date validator logic

type in date validator logic

--- app/assets/javascripts/components/todo-item.js.coffee
+++ app/assets/javascripts/components/todo-item.js.coffee
@@ -8,7 +8,7 @@

   dueDateChange: Em.observer ->
     due_date = new Date(@get("dueDate"))
-    @set("task.due_at", due_date) if not isNaN(due_date) and Em.isEmpty(@get("dueDate"))
+    @set("task.due_at", due_date) if not isNaN(due_date) and not Em.isEmpty(@get("dueDate"))
   .observes("dueDate").on("didInsertElement")

   validDueDate: Em.computed ->


remove stale comment

remove stale comment

--- app/assets/javascripts/store/gestpay/iframe.js.coffee
+++ app/assets/javascripts/store/gestpay/iframe.js.coffee
@@ -2,7 +2,6 @@
   constructor: (@merchant, @tokenPath, @transaction, @amount) ->

   generate: ->
-    # Probably not on the correct page
     if @browserSupported()
       @log("iframe initialized")
       @getToken()


use other as default

use other as default

--- app/assets/javascripts/uploadcare/locale/ar.js.coffee
+++ app/assets/javascripts/uploadcare/locale/ar.js.coffee
@@ -15,7 +15,11 @@
       deleted: 'تم حذف ملف'
     draghere: 'إفلت الملف هنا'
     file:
+      zero: '%1 ملفات'
       one: '%1 ملف'
+      two: '%1 ملفات'
+      few: '%1 ملفات'
+      many: '%1 ملفات'
       other: '%1 ملفات'
     buttons:
       cancel: 'إلغاء'


return focus to "Set default grade" button

fixes CNVS-18702

test plan:
1. On the Individual View Gradebook screen, navigate to the
   "Set default grade" button using only the keyboard.
2. Enter a score and tab to the "Set Default Grade" button
   at the bottom of the modal.
3. Press Enter. The focus should return to the "Set default
   grade" button.

Change-Id: I4fa3de8721f339a5e2bb5e63cbc2feed7ea8b4cf
Reviewed-on: https://gerrit.instructure.com/51486
Tested-by: Jenkins
QA-Review: Nathan Rogowski <2e8aa918660411855c6d44d5bb2da677aa033255@instructure.com>
Reviewed-by: Clay Diffrient <9dff2e5c98626d20d2848250c411e8009465bb87@instructure.com>
Product-Review: Strand McCutchen <3926e7f2370f1e7814576250688185c598b36b91@instructure.com>

--- app/coffeescripts/gradebook2/CurveGradesDialog.coffee
+++ app/coffeescripts/gradebook2/CurveGradesDialog.coffee
@@ -51,6 +51,7 @@
             submissions = (datum.submission for datum in data)
             $.publish 'submissions_updated', [submissions]
             alert I18n.t("alerts.scores_updated", { one: "1 Student score updated", other: "%{count} Student scores updated"}, count: data.length)
+            $("#set_default_grade").focus()
         .dialog
           width: 350
           modal: true


Select nodes tab after Network Analysis calculation

--- app/frontend/javascripts/visualization-edit.js.coffee
+++ app/frontend/javascripts/visualization-edit.js.coffee
@@ -370,6 +370,7 @@

   onNetworkAnalysisSuccess: (e) ->
     @tableNodes.addNetworkAnalysisColumns e.visualization, e.nodes
+    $('#visualization-table-selector > li > a[href=#nodes]').trigger 'click'

   # Auxiliar Info Node method


Convert to coffee class

Convert to coffee class

--- app/javascript/src/legacy/similar-thumbnailing.coffee
+++ app/javascript/src/legacy/similar-thumbnailing.coffee
@@ -17,205 +17,204 @@
 #
 ###

-window.ThumbnailUserImage = (file, onComplete) ->
-
-  ### Create the shared image pool, if we havn't yet. ###
-
-  if !ThumbnailUserImage.image_pool?
-    ThumbnailUserImage.image_pool = new ImgPoolHandler
-  @file = file
-  @canvas = create_canvas_2d()
-  @image = ThumbnailUserImage.image_pool.get()
-  @onComplete = onComplete
-  @url = URL.createObjectURL(@file)
-  @image.on 'load', @image_load_event.bindAsEventListener(this)
-  @image.on 'abort', @image_abort_event.bindAsEventListener(this)
-  @image.on 'error', @image_error_event.bindAsEventListener(this)
-  document.documentElement.addClassName 'progress'
-  @image.src = @url
-  return
-
-### This is a shared pool; for clarity, don't put it in the prototype. ###
-
-ThumbnailUserImage.image_pool = null
-
-### Cancel any running request.  The onComplete callback will not be called.
-# The object must not be reused.
-###
-
-ThumbnailUserImage::destroy = ->
-  document.documentElement.removeClassName 'progress'
-  @onComplete = null
-  @image.stopObserving()
-  ThumbnailUserImage.image_pool.release @image
-  @image = null
-  if @url?
-    URL.revokeObjectURL @url
-    @url = null
-  return
-
-ThumbnailUserImage::completed = (result) ->
-  if @onComplete
-    @onComplete result
-  @destroy()
-  return
-
-### When the image finishes loading after form_submit_event sets it, update the canvas
-# thumbnail from it.
-###
-
-ThumbnailUserImage::image_load_event = (e) ->
-
-  ### Reduce the image size to thumbnail resolution. ###
-
-  width = @image.width
-  height = @image.height
-  max_width = 128
-  max_height = 128
-  ratio = undefined
-  if width > max_width
-    ratio = max_width / width
-    height *= ratio
-    width *= ratio
-  if height > max_height
-    ratio = max_height / height
-    height *= ratio
-    width *= ratio
-  width = Math.round(width)
-  height = Math.round(height)
-
-  ### Set the canvas to the image size we want. ###
-
-  canvas = @canvas
-  canvas.width = width
-  canvas.height = height
-
-  ### Blit the image onto the canvas. ###
-
-  ctx = canvas.getContext('2d')
-
-  ### Clear the canvas, so check_image_contents can check that the data was correctly loaded. ###
-
-  ctx.clearRect 0, 0, canvas.width, canvas.height
-  ctx.drawImage @image, 0, 0, canvas.width, canvas.height
-  if !@check_image_contents()
+# This is a shared pool to be used by all instances
+image_pool = null
+
+class window.ThumbnailUserImage
+  constructor: (file, onComplete) ->
+
+    # Create the shared image pool, if we havn't yet.
+    image_pool ?= new ImgPoolHandler
+    @file = file
+    @canvas = create_canvas_2d()
+    @image = image_pool.get()
+    @onComplete = onComplete
+    @url = URL.createObjectURL(@file)
+    @image.on 'load', @image_load_event.bindAsEventListener(this)
+    @image.on 'abort', @image_abort_event.bindAsEventListener(this)
+    @image.on 'error', @image_error_event.bindAsEventListener(this)
+    document.documentElement.addClassName 'progress'
+    @image.src = @url
+    return
+
+  ### Cancel any running request.  The onComplete callback will not be called.
+  # The object must not be reused.
+  ###
+
+  destroy: ->
+    document.documentElement.removeClassName 'progress'
+    @onComplete = null
+    @image.stopObserving()
+    image_pool.release @image
+    @image = null
+    if @url?
+      URL.revokeObjectURL @url
+      @url = null
+    return
+
+  completed: (result) ->
+    if @onComplete
+      @onComplete result
+    @destroy()
+    return
+
+  ### When the image finishes loading after form_submit_event sets it, update the canvas
+  # thumbnail from it.
+  ###
+
+  image_load_event: (e) ->
+
+    ### Reduce the image size to thumbnail resolution. ###
+
+    width = @image.width
+    height = @image.height
+    max_width = 128
+    max_height = 128
+    ratio = undefined
+    if width > max_width
+      ratio = max_width / width
+      height *= ratio
+      width *= ratio
+    if height > max_height
+      ratio = max_height / height
+      height *= ratio
+      width *= ratio
+    width = Math.round(width)
+    height = Math.round(height)
+
+    ### Set the canvas to the image size we want. ###
+
+    canvas = @canvas
+    canvas.width = width
+    canvas.height = height
+
+    ### Blit the image onto the canvas. ###
+
+    ctx = canvas.getContext('2d')
+
+    ### Clear the canvas, so check_image_contents can check that the data was correctly loaded. ###
+
+    ctx.clearRect 0, 0, canvas.width, canvas.height
+    ctx.drawImage @image, 0, 0, canvas.width, canvas.height
+    if !@check_image_contents()
+      @completed
+        success: false
+        chromeFailure: true
+      return
+    @completed
+      success: true
+      canvas: @canvas
+    return
+
+  ###
+  # Work around a Chrome bug.  When very large images fail to load, we still get
+  # onload and the image acts like a loaded, completely transparent image, instead
+  # of firing onerror.  This makes it difficult to tell if the image actually loaded
+  # or not.  Check that the image loaded by looking at the results; reject the image
+  # if it's completely transparent.
+  ###
+
+  check_image_contents: ->
+    ctx = @canvas.getContext('2d')
+    image = ctx.getImageData(0, 0, @canvas.width, @canvas.height)
+    data = image.data
+
+    ### Iterate through the alpha components, and search for any nonzero value. ###
+
+    idx = 3
+    max_idx = image.width * image.height * 4
+    while idx < max_idx
+      if data[idx] != 0
+        return true
+      idx += 4
+    false
+
+  image_abort_event: (e) ->
     @completed
       success: false
-      chromeFailure: true
-    return
-  @completed
-    success: true
-    canvas: @canvas
-  return
-
-###
-# Work around a Chrome bug.  When very large images fail to load, we still get
-# onload and the image acts like a loaded, completely transparent image, instead
-# of firing onerror.  This makes it difficult to tell if the image actually loaded
-# or not.  Check that the image loaded by looking at the results; reject the image
-# if it's completely transparent.
-###
-
-ThumbnailUserImage::check_image_contents = ->
-  ctx = @canvas.getContext('2d')
-  image = ctx.getImageData(0, 0, @canvas.width, @canvas.height)
-  data = image.data
-
-  ### Iterate through the alpha components, and search for any nonzero value. ###
-
-  idx = 3
-  max_idx = image.width * image.height * 4
-  while idx < max_idx
-    if data[idx] != 0
-      return true
-    idx += 4
-  false
-
-ThumbnailUserImage::image_abort_event = (e) ->
-  @completed
-    success: false
-    aborted: true
-  return
-
-### This happens on normal errors, usually because the file isn't a supported image. ###
-
-ThumbnailUserImage::image_error_event = (e) ->
-  @completed success: false
-  return
-
-window.SimilarWithThumbnailing = (form) ->
-  @similar = null
-  @form = form
-  @force_file = null
-  form.on 'submit', @form_submit_event.bindAsEventListener(this)
-  return
-
-SimilarWithThumbnailing::form_submit_event = (e) ->
-  post_file = @form.down('#file')
-
-  ### If the files attribute isn't supported, or we have no file (source upload), use regular
-  # form submission.
-  ###
-
-  if !post_file.files? or post_file.files.length == 0
-    return
-
-  ### If we failed to load the image last time due to a silent Chrome error, continue with
-  # the submission normally this time.
-  ###
-
-  file = post_file.files[0]
-  if @force_file and @force_file == file
+      aborted: true
+    return
+
+  ### This happens on normal errors, usually because the file isn't a supported image. ###
+
+  image_error_event: (e) ->
+    @completed success: false
+    return
+
+class window.SimilarWithThumbnailing
+  constructor: (form) ->
+    @similar = null
+    @form = form
     @force_file = null
-    return
-  e.stop()
-  if @similar
-    @similar.destroy()
-  @similar = new ThumbnailUserImage(file, @complete.bind(this))
-  return
-
-### Submit a post/similar request using the image currently in the canvas. ###
-
-SimilarWithThumbnailing::complete = (result) ->
-  if result.chromeFailure
-    notice 'The image failed to load; submitting normally...'
-    @force_file = @file
-
-    ### Resend the submit event.  Defer it, so the notice can take effect before we
-    # navigate off the page.
+    form.on 'submit', @form_submit_event.bindAsEventListener(this)
+    return
+
+  form_submit_event: (e) ->
+    post_file = @form.down('#file')
+
+    ### If the files attribute isn't supported, or we have no file (source upload), use regular
+    # form submission.
     ###

-    (->
-      @form.simulate_submit()
-      return
-    ).bind(this).defer()
-    return
-  if !result.success
-    if !result.aborted
-      alert 'The file couldn\'t be loaded.'
-    return
-
-  ### Grab a data URL from the canvas; this is what we'll send to the server. ###
-
-  data_url = result.canvas.toDataURL()
-
-  ### Create the FormData containing the thumbnail image we're sending. ###
-
-  form_data = new FormData
-  form_data.append 'url', data_url
-  req = new (Ajax.Request)('/post/similar.json',
-    method: 'post'
-    postBody: form_data
-    contentType: null
-    onComplete: (resp) ->
-      json = resp.responseJSON
-      if !json.success
-        notice json.reason
+    if !post_file.files? or post_file.files.length == 0
+      return
+
+    ### If we failed to load the image last time due to a silent Chrome error, continue with
+    # the submission normally this time.
+    ###
+
+    file = post_file.files[0]
+    if @force_file and @force_file == file
+      @force_file = null
+      return
+    e.stop()
+    if @similar
+      @similar.destroy()
+    @similar = new ThumbnailUserImage(file, @complete.bind(this))
+    return
+
+  ### Submit a post/similar request using the image currently in the canvas. ###
+
+  complete: (result) ->
+    if result.chromeFailure
+      notice 'The image failed to load; submitting normally...'
+      @force_file = @file
+
+      ### Resend the submit event.  Defer it, so the notice can take effect before we
+      # navigate off the page.
+      ###
+
+      (->
+        @form.simulate_submit()
         return
-
-      ### Redirect to the search results. ###
-
-      window.location.href = '/post/similar?search_id=' + json.search_id
-      return
-)
-  return
+      ).bind(this).defer()
+      return
+    if !result.success
+      if !result.aborted
+        alert 'The file couldn\'t be loaded.'
+      return
+
+    ### Grab a data URL from the canvas; this is what we'll send to the server. ###
+
+    data_url = result.canvas.toDataURL()
+
+    ### Create the FormData containing the thumbnail image we're sending. ###
+
+    form_data = new FormData
+    form_data.append 'url', data_url
+    req = new (Ajax.Request)('/post/similar.json',
+      method: 'post'
+      postBody: form_data
+      contentType: null
+      onComplete: (resp) ->
+        json = resp.responseJSON
+        if !json.success
+          notice json.reason
+          return
+
+        ### Redirect to the search results. ###
+
+        window.location.href = '/post/similar?search_id=' + json.search_id
+        return
+  )
+    return


Make the border-snap distance a little tighter

--- src/scripts/controllers/CreaturePicker.coffee
+++ src/scripts/controllers/CreaturePicker.coffee
@@ -159,8 +159,8 @@
       @movementCircle ||= @createStrayCircle()

       fauxPoint =
-        x: Marker::limit (e.pageX - left) / width, 0.02
-        y: Marker::limit (e.pageY - top) / height, 0.04
+        x: Marker::limit (e.pageX - left) / width, 0.01
+        y: Marker::limit (e.pageY - top) / height, 0.01

       @movementCircle.attr
         cx: fauxPoint.x * width


Comment out analytics.js code

Comment out analytics.js code

--- src/scripts/helpers/handlers/analytics.coffee
+++ src/scripts/helpers/handlers/analytics.coffee
@@ -8,25 +8,25 @@
   return new class AnalyticsHandler
     constructor: () ->
       # Setup temporary analytics.js objects
-      window.GoogleAnalyticsObject = 'ga'
-      window.ga = () -> (window.ga.q ?= []).push(arguments)
-      window.ga.l = 1 * new Date()
+      # window.GoogleAnalyticsObject = 'ga'
+      # window.ga = () -> (window.ga.q ?= []).push(arguments)
+      # window.ga.l = 1 * new Date()

       # Initialize analytics.js account
-      window.ga('create', settings.analyticsID, 'auto')
+      # window.ga('create', settings.analyticsID, 'auto')

       # ## Setup ga.js
       window._gaq ?= []
       window._gaq.push(['_setAccount', settings.analyticsID])

       # Asynchronously load analytics.js.
-      require(['https://www.google-analytics.com/analytics.js'])
+      # require(['https://www.google-analytics.com/analytics.js'])

       # Asynchronously load ga.js
       require(['https://www.google-analytics.com/ga.js'])

     # Wrapper functions to add analytics events
-    ga: () -> window.ga?.apply(@, arguments) # analytics.js
+    # ga: () -> window.ga?.apply(@, arguments) # analytics.js
     gaq: () -> window._gaq?.push(arguments) # ga.js

     # Send the current page to every analytics service
@@ -34,5 +34,5 @@
       fragment = Backbone.history.fragment
       if not /^\//.test(fragment) then fragment = '/' + fragment

-      @ga('send', 'pageview')
+      # @ga('send', 'pageview')
       @gaq(['_trackPageview', fragment])


Doesn't this look nicer?

Doesn't this look nicer?

--- src/server/controllers/mixins/resource.coffee
+++ src/server/controllers/mixins/resource.coffee
@@ -209,9 +209,8 @@
       else
         conditions.where[key] = value

-      for key, val of conditions
-        if _.isEmpty val
-          delete conditions[key]
+      for key, val of conditions when _.isEmpty val
+        delete conditions[key]

       conditions


Added update settings fields

Added update settings fields

--- snippets/php_fields.cson
+++ snippets/php_fields.cson
@@ -324,6 +324,20 @@
     \treturn \\$fields;
     }
     """
+  'updateSettingsFields':
+    'prefix': 'updatesettingsfields'
+    'body': """
+    /**
+     * Update Fields
+     * @return FieldList
+     */
+    public function updateSettingsFields(FieldList \\$fields)
+    {
+    \t\\$owner = $this->owner;
+    \t\\$fields->${0}
+    \treturn \\$fields;
+    }
+    """
   'getEditForm':
     'prefix': 'geteditform'
     'body': """


Fix room switch  bug

Fix room switch  bug

--- frontend/app/scripts/Router.coffee
+++ frontend/app/scripts/Router.coffee
@@ -34,8 +34,10 @@
       view = showPage Page.IndexPage

     rooms: middleware.wrap (id)->
-      id = common.user.get 'active_room_id' unless id?
-      @navigate "!/rooms/#{id}"
+      auto_id = common.user.get 'active_room_id' unless id?
+      if auto_id?
+        @navigate "!/rooms/#{auto_id}", {trigger: true}
+        return
       view = showPage Page.MainPage
       view.setRoom id if id?


Fixed assignment issue

Fixed assignment issue

--- source/Downloader.coffee
+++ source/Downloader.coffee
@@ -64,7 +64,7 @@
     $ = cheerio.load(html)
     winston.info('Parsed page with $.')
     links = $('.item').find('.play')
-    title = []
+    titles = []
     downloads = []
     winston.info('Fetched links.')
     $('.item').find('.desc').each (i, element) ->


fixes user reference in key retrieval

fixes user reference in key retrieval

--- api/v0/models/keys.coffee
+++ api/v0/models/keys.coffee
@@ -13,7 +13,7 @@

 keysSchema.static "getUserKeyWithIdentifier", (user, ident, cb) ->
 	this.find
-		user: mongoose.Schema.ObjectId(user.id)
+		user: user.id
 		identifier: ident
 	, (err, doc) ->
 			if err
@@ -28,7 +28,7 @@

 keysSchema.static "createUserKeyWithIdentifier", (user, ident, key, cb) ->
 	newKey = new Keys
-		user: user
+		user: user._id
 		identifier: ident
 		key: key


add some configs

add some configs

--- app/assets/javascripts/application.js.coffee
+++ app/assets/javascripts/application.js.coffee
@@ -296,6 +296,46 @@

   checkInitialSidebarSize()
   new Aside()
-
+
   if gon.sentry_dsn?
-    Raven.config(gon.sentry_dsn).install()+    Raven.config(gon.sentry_dsn, {
+      ignoreErrors: [
+        # Random plugins/extensions
+        'top.GLOBALS',
+        # See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error. html
+        'originalCreateNotification',
+        'canvas.contentDocument',
+        'MyApp_RemoveAllHighlights',
+        'http://tt.epicplay.com',
+        'Can\'t find variable: ZiteReader',
+        'jigsaw is not defined',
+        'ComboSearch is not defined',
+        'http://loading.retry.widdit.com/',
+        'atomicFindClose',
+        # ISP "optimizing" proxy - `Cache-Control: no-transform` seems to
+        # reduce this. (thanks @acdha)
+        # See http://stackoverflow.com/questions/4113268
+        'bmi_SafeAddOnload',
+        'EBCallBackMessageReceived',
+        # See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
+        'conduitPage'
+      ],
+      ignoreUrls: [
+        # Woopra flakiness
+        /eatdifferent\.com\.woopra-ns\.com/i,
+        /static\.woopra\.com\/js\/woopra\.js/i,
+        # Chrome extensions
+        /extensions\//i,
+        /^chrome:\/\//i,
+        # Other plugins
+        /127\.0\.0\.1:4001\/isrunning/i,  # Cacaoweb
+        /webappstoolbarba\.texthelp\.com\//i,
+        /metrics\.itunes\.apple\.com\.edgesuite\.net\//i
+      ]
+    }).install()
+
+  if gon.current_user_id
+    Raven.setUserContext({
+      api_token: gon.api_token,
+      id: gon.current_user_id
+    })


Update default bio

Update default bio

--- app/assets/javascripts/application.js.coffee
+++ app/assets/javascripts/application.js.coffee
@@ -34,11 +34,17 @@
     @_currentUser

   defaultBio: ->
-    "Hi everyone! I love coding in VB6 across the stack; I'm currently living in
-     Westeros. I'll ping-pong a lot between concept and design too. Hoping to
-     get involved with IE6 compatibility issues, kickstarting ideas, and
-     providing tangible feedback. I'd like to free up to 8-16 hours a week to
-     work with everyone on this. Great to see so many people!"
+    """
+    Hi Everyone, I'm Maeby! I love coding in VB6 across the stack.
+     I also ping-pong a lot between concept and design. Hoping to get involved
+     with UX, to help kickstart ideas, and to provide tangible feedback.
+
+    I'm here partly because I think the app idea is great, but also because I
+     think your team is outstanding and well-rounded – so I'm excited to learn
+     alongside you. I'd like to free up to 8-16 hours a week (mostly evenings –
+     I'm on CST here in Madison) to help ship this product. Great to see so many
+     people working on this!
+    """

   setCurrentAnalyticsProduct: (product) ->
     @_currentAnalyticsProduct = new Product(product)


Only update title on posts list

Only update title on posts list

--- app/assets/javascripts/channels/posts.coffee
+++ app/assets/javascripts/channels/posts.coffee
@@ -22,6 +22,7 @@
   is_page_visible = false

 $(window).on 'focus', ->
-  is_page_visible = true
-  num_unseen_posts = 0
-  document.title = "Recent posts - metasmoke"
+  if location.pathname == '/posts'
+    is_page_visible = true
+    num_unseen_posts = 0
+    document.title = "Recent posts - metasmoke"


Restore fluent transition of bar charts. Fixes #1620 and #1631

--- app/assets/javascripts/d3/stacked_bar.coffee
+++ app/assets/javascripts/d3/stacked_bar.coffee
@@ -143,12 +143,12 @@
       stacked_data = _.flatten @stack_method(@prepare_data())
       @svg.selectAll('rect.serie')
         .data(stacked_data, (s) -> s.id)
+        .transition()
         .attr('y', (d) => @series_height - @y(d.y0 + d.y))
         .attr('height', (d) => @y(d.y))
         .attr("data-tooltip-text", (d) =>
           Metric.autoscale_value d.y, @model.get('unit')
         )
-        .transition()

       # move the target lines
       @svg.selectAll('rect.target_line')


Delete UserCtrl.coffee

Delete UserCtrl.coffee

--- app/assets/javascripts/users/UserCtrl.coffee
+++ app/assets/javascripts/users/UserCtrl.coffee
@@ -1,22 +0,0 @@
-
-class UserCtrl
-
-    constructor: (@$log, @UserService) ->
-        @$log.debug "constructing UserController"
-        @users = []
-        @getAllUsers()
-
-    getAllUsers: () ->
-        @$log.debug "getAllUsers()"
-
-        @UserService.listUsers()
-        .then(
-            (data) =>
-                @$log.debug "Promise returned #{data.length} Users"
-                @users = data
-            ,
-            (error) =>
-                @$log.error "Unable to get Users: #{error}"
-            )
-
-controllersModule.controller('UserCtrl', ['$log', 'UserService', UserCtrl])


Adding loading to milestone add

Adding loading to milestone add

--- app/coffee/modules/backlog/lightboxes.coffee
+++ app/coffee/modules/backlog/lightboxes.coffee
@@ -28,7 +28,7 @@
 ## Creare/Edit Sprint Lightbox Directive
 #############################################################################

-CreateEditSprint = ($repo, $confirm, $rs, $rootscope, lightboxService) ->
+CreateEditSprint = ($repo, $confirm, $rs, $rootscope, lightboxService, $loading) ->
     link = ($scope, $el, attrs) ->
         createSprint = true
         $scope.sprint = {
@@ -38,7 +38,7 @@
             estimated_finish: null
         }

-        submit = ->
+        submit = (event) ->
             form = $el.find("form").checksley()
             if not form.validate()
                 return
@@ -55,12 +55,17 @@
                 newSprint.setAttr("estimated_finish", moment(newSprint.estimated_finish).format("YYYY-MM-DD"))
                 promise = $repo.save(newSprint)

+            target = angular.element(event.currentTarget)
+            $loading.start(target)
+
             promise.then (data) ->
+                $loading.finish(target)
                 $scope.sprintsCounter += 1 if createSprint
                 lightboxService.close($el)
                 $rootscope.$broadcast("sprintform:create:success", data)

             promise.then null, (data) ->
+                $loading.finish(target)
                 form.setErrors(data)
                 if data._error_message
                     $confirm.notify("error", data._error_message)
@@ -111,7 +116,7 @@

         $el.on "click", ".button-green", (event) ->
             event.preventDefault()
-            submit()
+            submit(event)

         $el.on "click", ".delete-sprint .icon-delete", (event) ->
             event.preventDefault()
@@ -129,5 +134,6 @@
     "$tgResources",
     "$rootScope",
     "lightboxService"
+    "$tgLoading"
     CreateEditSprint
 ])


starting implementing the discussion model

JDiscussion -> JDiscussionReply -> JComment

--- server/app/bongo/models/messages/discussion.coffee
+++ server/app/bongo/models/messages/discussion.coffee
@@ -1,3 +1,28 @@
+# JDiscussion
+#  |_ JDiscussionReply*
+#      |_ JComment*
+
+class JDiscussionReply extends JComment
+	relationships     :
+	  comment         : JComment
+	  participant     :
+	    targetType    : JAccount
+	    as            : ['author','commenter']
+	  likedBy         :
+	    targetType    : JAccount
+	    as            : 'like'
+	  repliesActivity :
+	    targetType    : CRepliesActivity
+	    as            : 'repliesActivity'
+	  tag             :
+	    targetType    : JTag
+	    as            : 'tag'
+	  follower        :
+	    as            : 'follower'
+	    targetType    : JAccount
+# give it the relationships of a post while being a comment
+# dont forget about garbage collection when a reply gets deleted
+
 class JDiscussion extends JPost

   {secure} = require 'bongo'
@@ -9,11 +34,29 @@
   @set
     sharedMethods : JPost.sharedMethods
     schema        : JPost.schema
-    # TODO: copying and pasting this for now...  We need an abstract interface "commentable" or something like that)
-    relationships : JPost.relationships
+    relationships     :
+      comment         : JDiscussionReply
+      participant     :
+        targetType    : JAccount
+        as            : ['author','commenter']
+      likedBy         :
+        targetType    : JAccount
+        as            : 'like'
+      repliesActivity :
+        targetType    : CRepliesActivity
+        as            : 'repliesActivity'
+      tag             :
+        targetType    : JTag
+        as            : 'tag'
+      follower        :
+        as            : 'follower'
+        targetType    : JAccount

   @create = secure (client, data, callback)->
     discussion =
       title 	: data.title
       body		: data.body
       meta		: data.meta
+    log discussion
+
+


Add a few callback tests

Add a few callback tests

Signed-off-by: Thodoris Greasidis <5791bdc4541e79bd43d586e3c3eff1e39c16665f@balena.io>

--- tests/integration/models/application.spec.coffee
+++ tests/integration/models/application.spec.coffee
@@ -23,15 +23,30 @@

 		describe 'balena.models.application.getAll()', ->

-			it 'should eventually become an empty array', ->
+			it 'should eventually become an empty array [Promise]', ->
 				promise = balena.models.application.getAll()
 				m.chai.expect(promise).to.become([])

+			it 'should eventually become an empty array [callback]', (done) ->
+				balena.models.application.getAll (err, applications) ->
+					m.chai.expect(err).to.be.null
+					m.chai.expect(applications).to.deep.equal([])
+					done()
+				return
+
 		describe 'balena.models.application.getAppByOwner()', ->

-			it 'should eventually reject', ->
+			it 'should eventually reject [Promise]', ->
 				promise = balena.models.application.getAppByOwner('testapp', 'FooBar')
 				m.chai.expect(promise).to.be.rejected
+
+			it 'should eventually reject [callback]', (done) ->
+				balena.models.application.getAppByOwner('testapp', 'FooBar',
+					(err) ->
+						m.chai.expect(err).to.not.be.undefined
+						done()
+				)
+				return

 		describe 'balena.models.application.hasAny()', ->

@@ -183,10 +198,20 @@
 					balena.models.application.getAll().then (applications) =>
 						m.chai.expect(applications[0].id).to.equal(@application.id)

-				it 'should support arbitrary pinejs options', ->
+				it 'should support arbitrary pinejs options [Promise]', ->
 					balena.models.application.getAll($expand: 'user')
 					.then (applications) ->
 						m.chai.expect(applications[0].user[0].username).to.equal(credentials.username)
+
+				it 'should support arbitrary pinejs options [callback]', (done) ->
+					balena.models.application.getAll(
+						$expand: 'user',
+						(err, applications) ->
+							m.chai.expect(err).to.be.null
+							m.chai.expect(applications[0].user[0].username).to.equal(credentials.username)
+							done()
+					)
+					return

 			describe 'balena.models.application.get()', ->


Use bookId if it's in params, otherwise look it up

--- src/components/book-content-mixin.cjsx
+++ src/components/book-content-mixin.cjsx
@@ -1,3 +1,4 @@
+React = require 'react'
 _ = require 'underscore'
 S = require '../helpers/string'
 dom = require '../helpers/dom'
@@ -19,10 +20,10 @@
     @detectImgAspectRatio()
     @processLinks()

-  insertOverlays: ->
   contextTypes:
     router: React.PropTypes.func

+  insertOverlays: ->
     title = @getSplashTitle()
     return unless title
     root = @getDOMNode()
@@ -54,14 +55,12 @@
     S.capitalize(tag)

   buildReferenceBookLink: (cnxId) ->
-    {courseId} = @context.router.getCurrentParams()
-    course = CourseStore.get(courseId)
-    referenceBookParams =
-      bookId: course.book_id
-      cnxId: cnxId or @getCnxId()
-    pageUrl = @context.router.makeHref('viewReferenceBookPage', referenceBookParams)
-
-    pageUrl
+    {courseId, bookId} = @context.router.getCurrentParams()
+    if courseId and not bookId
+      bookId = CourseStore.get(courseId)?.book_id
+    @context.router.makeHref( 'viewReferenceBookPage',
+      { bookId, cnxId: cnxId or @getCnxId() }
+    )

   isMediaLink: (link) ->
     link.hash.length > 0 and link.hash.search('/') is -1


Improve performance of standard deviation calculation on TeacherStudentView (#5253)

--- app/views/teachers/TeacherStudentView.coffee
+++ app/views/teachers/TeacherStudentView.coffee
@@ -161,9 +161,18 @@
   calculateStandardDev: ->
     return unless @courses.loaded and @levels.loaded and @sessions?.loaded and @levelData

+    levelSessionsByStudentByLevel = {}
+    for session in @sessions.models
+      userSessions = levelSessionsByStudentByLevel[session.get('creator')] or {}
+      userSessionsForLevel = userSessions[session.get('level').original] or []
+      userSessionsForLevel.push session
+      userSessions[session.get('level').original] = userSessionsForLevel
+      levelSessionsByStudentByLevel[session.get('creator')] = userSessions
+    levelDataByLevel = {}
+    for levelDatum in @levelData
+      levelDataByLevel[levelDatum.levelID] = levelDatum
     @courseComparisonMap = []
     for versionedCourse in @classroom.getSortedCourses() or []
-      # course = _.find @courses.models, (c) => c.id is versionedCourse._id
       course = @courses.get(versionedCourse._id)
       numbers = []
       studentCourseTotal = 0
@@ -172,14 +181,14 @@
         number = 0
         memberPlayed = 0 # number of levels a member has played that this student has also played
         for versionedLevel in versionedCourse.levels
-          for session in @sessions.models
-            if session.get('level').original is versionedLevel.original and session.get('creator') is member
-              playedLevel = _.findWhere(@levelData, {levelID: session.get('level').original})
-              if playedLevel.levelProgress is 'complete' or playedLevel.levelProgress is 'started'
-                number += session.get('playtime') or 0
-                memberPlayed += 1
-              if session.get('creator') is @studentID
-                studentCourseTotal += session.get('playtime') or 0
+          sessions = (levelSessionsByStudentByLevel[member] ? {})[versionedLevel.original] ? []
+          for session in sessions
+            playedLevel = levelDataByLevel[session.get('level').original]
+            if playedLevel.levelProgress is 'complete' or playedLevel.levelProgress is 'started'
+              number += session.get('playtime') or 0
+              memberPlayed += 1
+            if session.get('creator') is @studentID
+              studentCourseTotal += session.get('playtime') or 0
         if memberPlayed > 0 then members += 1
         numbers.push number


Test for Android's peculiar backspacing behavior

--- test/src/system/composition_input_test.coffee
+++ test/src/system/composition_input_test.coffee
@@ -51,3 +51,25 @@
     updateComposition "xi", ->
       endComposition "喜", ->
         expectDocument "喜\n"
+
+# Simulates the sequence of events when pressing backspace through a word on Android
+editorTest "backspacing through a composition", (expectDocument) ->
+  element = getEditorElement()
+  element.editor.insertString("a cat")
+
+  triggerEvent(element, "keydown", charCode: 0, keyCode: 229, which: 229)
+  triggerEvent(element, "compositionupdate", data: "ca")
+  removeCharacters -1, ->
+    triggerEvent(element, "keydown", charCode: 0, keyCode: 229, which: 229)
+    triggerEvent(element, "compositionupdate", data: "c")
+    triggerEvent(element, "compositionend", data: "c")
+    removeCharacters -1, ->
+      pressKey "backspace", ->
+        expectDocument "a \n"
+
+removeCharacters = (direction, callback) ->
+  selection = rangy.getSelection()
+  range = selection.getRangeAt(0)
+  range.moveStart("character", direction)
+  range.deleteContents()
+  defer(callback)