diff --git a/app/coffee/app.coffee b/app/coffee/app.coffee
index 3385474a..4316337a 100644
--- a/app/coffee/app.coffee
+++ b/app/coffee/app.coffee
@@ -21,7 +21,21 @@
@taiga = taiga = {}
-configure = ($routeProvider, $locationProvider, $httpProvider, $provide, tgLoaderProvider) ->
+# Generic function for generate hash from a arbitrary length
+# collection of parameters.
+taiga.generateHash = (components=[]) ->
+ components = _.map(components, (x) -> JSON.stringify(x))
+ return hex_sha1(components.join(":"))
+
+taiga.generateUniqueSessionIdentifier = ->
+ date = (new Date()).getTime()
+ randomNumber = Math.floor(Math.random() * 0x9000000)
+ return taiga.generateHash([date, randomNumber])
+
+taiga.sessionId = taiga.generateUniqueSessionIdentifier()
+
+
+configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEventsProvider, tgLoaderProvider) ->
$routeProvider.when("/",
{templateUrl: "/partials/projects.html", resolve: {loader: tgLoaderProvider.add()}})
$routeProvider.when("/project/:pslug/",
@@ -127,13 +141,18 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, tgLoade
defaultHeaders = {
"Content-Type": "application/json"
"Accept-Language": "en"
+ "X-Session-Id": taiga.sessionId
}
$httpProvider.defaults.headers.delete = defaultHeaders
$httpProvider.defaults.headers.patch = defaultHeaders
$httpProvider.defaults.headers.post = defaultHeaders
$httpProvider.defaults.headers.put = defaultHeaders
- $httpProvider.defaults.headers.get = {}
+ $httpProvider.defaults.headers.get = {
+ "X-Session-Id": taiga.sessionId
+ }
+
+ $tgEventsProvider.setSessionId(taiga.sessionId)
# Add next param when user try to access to a secction need auth permissions.
authHttpIntercept = ($q, $location, $confirm, $navUrls, $lightboxService) ->
@@ -148,7 +167,8 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, tgLoade
$location.url($navUrls.resolve("login")).search("next=#{nextPath}")
return $q.reject(response)
- $provide.factory("authHttpIntercept", ["$q", "$location", "$tgConfirm", "$tgNavUrls", "lightboxService", authHttpIntercept])
+ $provide.factory("authHttpIntercept", ["$q", "$location", "$tgConfirm", "$tgNavUrls",
+ "lightboxService", authHttpIntercept])
$httpProvider.responseInterceptors.push('authHttpIntercept')
$httpProvider.interceptors.push('loaderInterceptor')
@@ -166,10 +186,13 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, tgLoade
linewidth: "The subject must have a maximum size of %s"
})
-init = ($log, $i18n, $config, $rootscope) ->
+init = ($log, $i18n, $config, $rootscope, $auth, $events) ->
$i18n.initialize($config.get("defaultLanguage"))
$log.debug("Initialize application")
+ if $auth.isAuthenticated()
+ $events.setupConnection()
+
# Default Value for taiga local config module.
angular.module("taigaLocalConfig", []).value("localconfig", {})
@@ -181,6 +204,7 @@ modules = [
"taigaResources",
"taigaLocales",
"taigaAuth",
+ "taigaEvents",
# Specific Modules
"taigaRelatedTasks",
@@ -211,6 +235,7 @@ module.config([
"$locationProvider",
"$httpProvider",
"$provide",
+ "$tgEventsProvider",
"tgLoaderProvider",
configure
])
@@ -220,5 +245,7 @@ module.run([
"$tgI18n",
"$tgConfig",
"$rootScope",
+ "$tgAuth",
+ "$tgEvents",
init
])
diff --git a/app/coffee/modules/auth.coffee b/app/coffee/modules/auth.coffee
index 75375819..752a9e5d 100644
--- a/app/coffee/modules/auth.coffee
+++ b/app/coffee/modules/auth.coffee
@@ -171,7 +171,7 @@ PublicRegisterMessageDirective = ($config, $navUrls) ->
module.directive("tgPublicRegisterMessage", ["$tgConfig", "$tgNavUrls", PublicRegisterMessageDirective])
-LoginDirective = ($auth, $confirm, $location, $routeParams, $navUrls) ->
+LoginDirective = ($auth, $confirm, $location, $config, $routeParams, $navUrls, $events) ->
link = ($scope, $el, $attrs) ->
$scope.data = {}
@@ -181,6 +181,7 @@ LoginDirective = ($auth, $confirm, $location, $routeParams, $navUrls) ->
else
nextUrl = $navUrls.resolve("home")
+ $events.setupConnection()
$location.path(nextUrl)
onErrorSubmit = (response) ->
@@ -204,8 +205,8 @@ LoginDirective = ($auth, $confirm, $location, $routeParams, $navUrls) ->
return {link:link}
-module.directive("tgLogin", ["$tgAuth", "$tgConfirm", "$tgLocation", "$routeParams", "$tgNavUrls",
- LoginDirective])
+module.directive("tgLogin", ["$tgAuth", "$tgConfirm", "$tgLocation", "$tgConfig", "$routeParams",
+ "$tgNavUrls", "$tgEvents", LoginDirective])
#############################################################################
## Register Directive
diff --git a/app/coffee/modules/backlog/main.coffee b/app/coffee/modules/backlog/main.coffee
index 0d4c3c11..36719ed7 100644
--- a/app/coffee/modules/backlog/main.coffee
+++ b/app/coffee/modules/backlog/main.coffee
@@ -46,11 +46,12 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F
"$tgLocation",
"$appTitle",
"$tgNavUrls",
+ "$tgEvents",
"tgLoader"
]
- constructor: (@scope, @rootscope, @repo, @confirm, @rs, @params, @q, @location, @appTitle, @navUrls,
- tgLoader) ->
+ constructor: (@scope, @rootscope, @repo, @confirm, @rs, @params, @q,
+ @location, @appTitle, @navUrls, @events, tgLoader) ->
_.bindAll(@)
@scope.sectionName = "Backlog"
@@ -94,8 +95,18 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F
@scope.$on("sprint:us:moved", @.loadSprints)
@scope.$on("sprint:us:moved", @.loadProjectStats)
+ initializeSubscription: ->
+ routingKey1 = "changes.project.#{@scope.projectId}.userstories"
+ @events.subscribe @scope, routingKey1, (message) =>
+ @.loadUserstories()
+ @.loadSprints()
+
+ routingKey2 = "changes.project.#{@scope.projectId}.milestones"
+ @events.subscribe @scope, routingKey2, (message) =>
+ @.loadSprints()
+
toggleShowTags: ->
- @scope.$apply () =>
+ @scope.$apply =>
@showTags = !@showTags
@rs.userstories.storeShowTags(@scope.projectId, @showTags)
@@ -186,6 +197,7 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F
# Resolve project slug
promise = @repo.resolve({pslug: @params.pslug}).then (data) =>
@scope.projectId = data.project
+ @.initializeSubscription()
return data
return promise.then(=> @.loadProject())
diff --git a/app/coffee/modules/common/components.coffee b/app/coffee/modules/common/components.coffee
index 6c38651d..525eb199 100644
--- a/app/coffee/modules/common/components.coffee
+++ b/app/coffee/modules/common/components.coffee
@@ -333,15 +333,20 @@ ListItemPriorityDirective = ->
"""
link = ($scope, $el, $attrs) ->
- issue = $scope.$eval($attrs.tgListitemPriority)
- bindOnce $scope, "priorityById", (priorityById) ->
+ render = (priorityById, issue) ->
priority = priorityById[issue.priority]
-
- domNode = $el.find("div.level")
+ domNode = $el.find(".level")
domNode.css("background-color", priority.color)
domNode.addClass(priority.name.toLowerCase())
domNode.attr("title", priority.name)
+ bindOnce $scope, "priorityById", (priorityById) ->
+ issue = $scope.$eval($attrs.tgListitemPriority)
+ render(priorityById, issue)
+
+ $scope.$watch $attrs.tgListitemPriority, (issue) ->
+ render($scope.priorityById, issue)
+
return {
link: link
template: template
@@ -354,15 +359,20 @@ ListItemSeverityDirective = ->
"""
link = ($scope, $el, $attrs) ->
- issue = $scope.$eval($attrs.tgListitemSeverity)
- bindOnce $scope, "severityById", (severityById) ->
+ render = (severityById, issue) ->
severity = severityById[issue.severity]
-
- domNode = $el.find("div.level")
+ domNode = $el.find(".level")
domNode.css("background-color", severity.color)
domNode.addClass(severity.name.toLowerCase())
domNode.attr("title", severity.name)
+ bindOnce $scope, "severityById", (severityById) ->
+ issue = $scope.$eval($attrs.tgListitemSeverity)
+ render(severityById, issue)
+
+ $scope.$watch $attrs.tgListitemSeverity, (issue) ->
+ render($scope.severityById, issue)
+
return {
link: link
template: template
@@ -374,16 +384,20 @@ ListItemTypeDirective = ->
"""
link = ($scope, $el, $attrs) ->
- issue = $scope.$eval($attrs.tgListitemType)
-
- bindOnce $scope, "issueTypeById", (issueTypeById) ->
+ render = (issueTypeById, issue) ->
type = issueTypeById[issue.type]
-
- domNode = $el.find("div.level")
+ domNode = $el.find(".level")
domNode.css("background-color", type.color)
domNode.addClass(type.name.toLowerCase())
domNode.attr("title", type.name)
+ bindOnce $scope, "issueTypeById", (issueTypeById) ->
+ issue = $scope.$eval($attrs.tgListitemType)
+ render(issueTypeById, issue)
+
+ $scope.$watch $attrs.tgListitemType, (issue) ->
+ render($scope.issueTypeById, issue)
+
return {
link: link
template: template
diff --git a/app/coffee/modules/common/popovers.coffee b/app/coffee/modules/common/popovers.coffee
index 69f34810..39e6a954 100644
--- a/app/coffee/modules/common/popovers.coffee
+++ b/app/coffee/modules/common/popovers.coffee
@@ -42,9 +42,9 @@ UsStatusDirective = ($repo, popoverService) ->
NOTE: This directive need 'usStatusById' and 'project'.
###
- selectionTemplate = _.template("""
+ template = _.template("""
""")
- updateUsStatus = ($el, us, usStatusById) ->
- usStatusDomParent = $el.find(".us-status")
- usStatusDom = $el.find(".us-status .us-status-bind")
-
- if usStatusById[us.status]
- usStatusDom.text(usStatusById[us.status].name)
- usStatusDomParent.prop("title", usStatusById[us.status].name)
- usStatusDomParent.css('color', usStatusById[us.status].color)
-
link = ($scope, $el, $attrs) ->
$ctrl = $el.controller()
- us = $scope.$eval($attrs.tgUsStatus)
+
+ render = (us) ->
+ usStatusDomParent = $el.find(".us-status")
+ usStatusDom = $el.find(".us-status .us-status-bind")
+ usStatusById = $scope.usStatusById
+
+ if usStatusById[us.status]
+ usStatusDom.text(usStatusById[us.status].name)
+ usStatusDomParent.css("color", usStatusById[us.status].color)
$el.on "click", ".us-status", (event) ->
event.preventDefault()
event.stopPropagation()
-
$el.find(".pop-status").popover().open()
- # pop = $el.find(".pop-status")
- # popoverService.open(pop)
-
$el.on "click", ".status", debounce 2000, (event) ->
event.preventDefault()
event.stopPropagation()
+
target = angular.element(event.currentTarget)
+
+ us = $scope.$eval($attrs.tgUsStatus)
us.status = target.data("status-id")
+ render(us)
+
$el.find(".pop-status").popover().close()
- updateUsStatus($el, us, $scope.usStatusById)
$scope.$apply () ->
$repo.save(us).then ->
$scope.$eval($attrs.onUpdate)
- taiga.bindOnce $scope, "project", (project) ->
- $el.append(selectionTemplate({ 'statuses': project.us_statuses }))
- updateUsStatus($el, us, $scope.usStatusById)
+
+ $scope.$on("userstories:loaded", -> render($scope.$eval($attrs.tgUsStatus)))
+ $scope.$on("$destroy", -> $el.off())
+
+ # Bootstrap
+ us = $scope.$eval($attrs.tgUsStatus)
+ render(us)
+
+ bindOnce $scope, "project", (project) ->
+ html = template({"statuses": project.us_statuses})
+ $el.append(html)
# If the user has not enough permissions the click events are unbinded
- if project.my_permissions.indexOf("modify_us") == -1
+ if $scope.project.my_permissions.indexOf("modify_us") == -1
$el.unbind("click")
$el.find("a").addClass("not-clickable")
- $scope.$on "$destroy", ->
- $el.off()
return {link: link}
diff --git a/app/coffee/modules/events.coffee b/app/coffee/modules/events.coffee
new file mode 100644
index 00000000..7b6ac3d9
--- /dev/null
+++ b/app/coffee/modules/events.coffee
@@ -0,0 +1,129 @@
+###
+# Copyright (C) 2014 Andrey Antukh
+# Copyright (C) 2014 Jesús Espino Garcia
+# Copyright (C) 2014 David Barragán Merino
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+# File: modules/events.coffee
+###
+
+taiga = @.taiga
+
+module = angular.module("taigaEvents", [])
+
+
+class EventsService
+ constructor: (@win, @log, @config, @auth) ->
+ _.bindAll(@)
+
+ initialize: (sessionId) ->
+ @.sessionId = sessionId
+ @.subscriptions = {}
+
+ if @win.WebSocket is undefined
+ @log.debug "WebSockets not supported on your browser"
+
+ setupConnection: ->
+ @.stopExistingConnection()
+
+ wshost = @config.get("eventsHost", "localhost:8888")
+ wsscheme = @config.get("eventsScheme", "ws")
+ url = "#{wsscheme}://#{wshost}/events"
+
+ @.ws = new @win.WebSocket(url)
+ @.ws.addEventListener("open", @.onOpen)
+ @.ws.addEventListener("message", @.onMessage)
+ @.ws.addEventListener("error", @.onError)
+ @.ws.addEventListener("close", @.onClose)
+
+ stopExistingConnection: ->
+ if @.ws is undefined
+ return
+
+ @.ws.close()
+ @.ws.removeEventListener("open", @.onOpen)
+ @.ws.removeEventListener("close", @.onClose)
+ @.ws.removeEventListener("error", @.onError)
+ @.ws.removeEventListener("message", @.onMessage)
+
+ delete @.ws
+
+ onOpen: ->
+ @log.debug("WebSocket connection opened")
+ token = @auth.getToken()
+
+ message = {
+ cmd: "auth"
+ data: {token: token, sessionId: @.sessionId}
+ }
+
+ @.ws.send(JSON.stringify(message))
+
+ onMessage: (event) ->
+ @.log.debug "WebSocket message received: #{event.data}"
+
+ data = JSON.parse(event.data)
+ routingKey = data.routing_key
+
+ if not @.subscriptions[routingKey]?
+ return
+
+ subscription = @.subscriptions[routingKey]
+ subscription.scope.$apply ->
+ subscription.callback(data.data)
+
+ onError: (error) ->
+ @log.error("WebSocket error: #{error}")
+
+ onClose: ->
+ @log.debug("WebSocket closed.")
+
+ subscribe: (scope, routingKey, callback) ->
+ subscription = {
+ scope: scope,
+ routingKey: routingKey,
+ callback: callback
+ }
+
+ message = {
+ "cmd": "subscribe",
+ "routing_key": routingKey
+ }
+
+ @.subscriptions[routingKey] = subscription
+ @.ws.send(JSON.stringify(message))
+ scope.$on("$destroy", => @.unsubscribe(routingKey))
+
+ unsubscribe: (routingKey) ->
+ message = {
+ "cmd": "unsubscribe",
+ "routing_key": routingKey
+ }
+
+ @.ws.send(JSON.stringify(message))
+
+
+class EventsProvider
+ setSessionId: (sessionId) ->
+ @.sessionId = sessionId
+
+ $get: ($win, $log, $conf, $auth) ->
+ service = new EventsService($win, $log, $conf, $auth)
+ service.initialize(@.sessionId)
+ return service
+
+ @.prototype.$get.$inject = ["$window", "$log", "$tgConfig", "$tgAuth"]
+
+module.provider("$tgEvents", EventsProvider)
diff --git a/app/coffee/modules/issues/list.coffee b/app/coffee/modules/issues/list.coffee
index 28115e51..fd06841a 100644
--- a/app/coffee/modules/issues/list.coffee
+++ b/app/coffee/modules/issues/list.coffee
@@ -49,11 +49,12 @@ class IssuesController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi
"$tgLocation",
"$appTitle",
"$tgNavUrls",
+ "$tgEvents",
"tgLoader"
]
constructor: (@scope, @rootscope, @repo, @confirm, @rs, @urls, @params, @q, @location, @appTitle,
- @navUrls, tgLoader) ->
+ @navUrls, @events, tgLoader) ->
@scope.sectionName = "Issues"
@scope.filters = {}
@@ -82,6 +83,11 @@ class IssuesController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi
@.loadIssues()
@.loadFilters()
+ initializeSubscription: ->
+ routingKey = "changes.project.#{@scope.projectId}.issues"
+ @events.subscribe @scope, routingKey, (message) =>
+ @.loadIssues()
+
storeFilters: ->
@rs.issues.storeFilters(@params.pslug, @location.search())
@@ -256,6 +262,7 @@ class IssuesController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi
loadInitialData: ->
promise = @repo.resolve({pslug: @params.pslug}).then (data) =>
@scope.projectId = data.project
+ @.initializeSubscription()
return data
return promise.then(=> @.loadProject())
@@ -755,6 +762,9 @@ IssueStatusInlineEditionDirective = ($repo, popoverService) ->
$el.unbind("click")
$el.find("a").addClass("not-clickable")
+ $scope.$watch $attrs.tgIssueStatusInlineEdition, (val) =>
+ updateIssueStatus($el, val, $scope.issueStatusById)
+
$scope.$on "$destroy", ->
$el.off()
@@ -803,6 +813,9 @@ IssueAssignedToInlineEditionDirective = ($repo, $rootscope, popoverService) ->
$repo.save(updatedIssue)
updateIssue(updatedIssue)
+ $scope.$watch $attrs.tgIssueAssignedToInlineEdition, (val) =>
+ updateIssue(val)
+
$scope.$on "$destroy", ->
$el.off()
diff --git a/app/coffee/modules/kanban/main.coffee b/app/coffee/modules/kanban/main.coffee
index d0e112b2..ae487649 100644
--- a/app/coffee/modules/kanban/main.coffee
+++ b/app/coffee/modules/kanban/main.coffee
@@ -59,11 +59,12 @@ class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi
"$tgLocation",
"$appTitle",
"$tgNavUrls",
+ "$tgEvents",
"tgLoader"
]
- constructor: (@scope, @rootscope, @repo, @confirm, @rs, @params, @q, @location, @appTitle, @navUrls,
- tgLoader) ->
+ constructor: (@scope, @rootscope, @repo, @confirm, @rs, @params, @q, @location,
+ @appTitle, @navUrls, @events, tgLoader) ->
_.bindAll(@)
@scope.sectionName = "Kanban"
@scope.statusViewModes = {}
@@ -162,10 +163,16 @@ class KanbanController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.Fi
@scope.$emit("project:loaded", project)
return project
+ initializeSubscription: ->
+ routingKey1 = "changes.project.#{@scope.projectId}.userstories"
+ @events.subscribe @scope, routingKey1, (message) =>
+ @.loadUserstories()
+
loadInitialData: ->
# Resolve project slug
promise = @repo.resolve({pslug: @params.pslug}).then (data) =>
@scope.projectId = data.project
+ @.initializeSubscription()
return data
return promise.then(=> @.loadProject())
diff --git a/app/coffee/modules/taskboard/main.coffee b/app/coffee/modules/taskboard/main.coffee
index a95dc73b..ebe51465 100644
--- a/app/coffee/modules/taskboard/main.coffee
+++ b/app/coffee/modules/taskboard/main.coffee
@@ -46,11 +46,12 @@ class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin)
"$appTitle",
"$tgLocation",
"$tgNavUrls"
+ "$tgEvents"
"tgLoader"
]
constructor: (@scope, @rootscope, @repo, @confirm, @rs, @params, @q, @appTitle, @location, @navUrls,
- tgLoader) ->
+ @events, tgLoader) ->
_.bindAll(@)
@scope.sectionName = "Taskboard"
@@ -82,6 +83,11 @@ class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin)
promise.then null, ->
console.log "FAIL" # TODO
+ initializeSubscription: ->
+ routingKey = "changes.project.#{@scope.projectId}.tasks"
+ @events.subscribe @scope, routingKey, (message) =>
+ @.loadTaskboard()
+
loadProject: ->
return @rs.projects.get(@scope.projectId).then (project) =>
@scope.project = project
@@ -157,6 +163,7 @@ class TaskboardController extends mixOf(taiga.Controller, taiga.PageMixin)
promise = @repo.resolve(params).then (data) =>
@scope.projectId = data.project
@scope.sprintId = data.milestone
+ @.initializeSubscription()
return data
return promise.then(=> @.loadProject())
diff --git a/app/coffee/utils.coffee b/app/coffee/utils.coffee
index 59c47d11..f5dc5e11 100644
--- a/app/coffee/utils.coffee
+++ b/app/coffee/utils.coffee
@@ -125,15 +125,6 @@ sizeFormat = (input, precision=1) ->
return "#{size} #{units[number]}"
-typeIsArray = Array.isArray || ( value ) -> return {}.toString.call( value ) is '[object Array]'
-
-
-# Generic method for generate hash from a arbitrary length
-# collection of parameters.
-generateHash = (components=[]) ->
- components = _.map(components, (x) -> JSON.stringify(x))
- return hex_sha1(components.join(":"))
-
taiga = @.taiga
taiga.nl2br = nl2br
taiga.bindOnce = bindOnce
@@ -151,5 +142,3 @@ taiga.joinStr = joinStr
taiga.debounce = debounce
taiga.startswith = startswith
taiga.sizeFormat = sizeFormat
-taiga.typeIsArray = typeIsArray
-taiga.generateHash = generateHash
diff --git a/app/partials/views/modules/issues-table.jade b/app/partials/views/modules/issues-table.jade
index 490636db..4a59f2f0 100644
--- a/app/partials/views/modules/issues-table.jade
+++ b/app/partials/views/modules/issues-table.jade
@@ -14,7 +14,7 @@ section.issues-table.basic-table(ng-class="{empty: !issues.length}")
div.subject
a(href="", tg-nav="project-issues-detail:project=project.slug,ref=issue.ref")
span(tg-bo-ref="issue.ref")
- span(tg-bo-bind="issue.subject")
+ span(ng-bind="issue.subject")
div.issue-field(tg-issue-status-inline-edition="issue")
a.issue-status(href="", title="Change status")