From 38a6f73b9fe0990153c59e0e00053c46b7b4f0df Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 11 Sep 2014 14:05:55 +0200 Subject: [PATCH] Taiga-events integration (realtime taiga) --- app/coffee/app.coffee | 35 ++++- app/coffee/modules/auth.coffee | 7 +- app/coffee/modules/backlog/main.coffee | 18 ++- app/coffee/modules/common/components.coffee | 40 ++++-- app/coffee/modules/common/popovers.coffee | 51 ++++---- app/coffee/modules/events.coffee | 129 +++++++++++++++++++ app/coffee/modules/issues/list.coffee | 15 ++- app/coffee/modules/kanban/main.coffee | 11 +- app/coffee/modules/taskboard/main.coffee | 9 +- app/coffee/utils.coffee | 11 -- app/partials/views/modules/issues-table.jade | 2 +- 11 files changed, 266 insertions(+), 62 deletions(-) create mode 100644 app/coffee/modules/events.coffee 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")