diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..9bbc3632 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ + +# 1.0.0 taiga-front (2014-10-07) + +### Misc +- Lots of small and not so small bugfixes + +### Features +- Redesign for taskboard and backlog summaries +- Allow feedback for users from the platform +- Real time changes for backlog, taskboard, kanban and issues diff --git a/README.md b/README.md index 24209044..51ff54aa 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ -Taiga Front -=============== +# Taiga Front # ![Kaleidos Project](http://kaleidos.net/static/img/badge.png "Kaleidos Project") -Setup initial environment -------------------------- +## Setup initial environment ## Install requirements: @@ -13,21 +11,29 @@ Install requirements: You can install Ruby through the apt package manager, rbenv, or rvm. Install Sass through your **Terminal or Command Prompt**. -```bash - gem install sass - sass -v // should return Sass 3.3.8 (Maptastic Maple) +``` +$ gem install sass scss-lint +$ export PATH="~/.gem/ruby/2.1.0/bin:$PATH" +$ sass -v // should return Sass 3.3.8 (Maptastic Maple) ``` -> Complete process for all OS at: http://sass-lang.com/install +Complete process for all OS at: http://sass-lang.com/install **Node + Bower + Gulp** -```bash - sudo npm install -g gulp - npm install - sudo npm install -g bower - bower install - gulp +``` +$ sudo npm install -g gulp +$ sudo npm install -g bower +$ npm install +$ bower install +$ gulp ``` And go in your browser to: http://localhost:9001/ + + +## Community ## + +[Taiga has a mailing list](http://groups.google.com/d/forum/taigaio). Feel free to join it and ask any questions you may have. + +To subscribe for announcements of releases, important changes and so on, please follow [@taigaio](https://twitter.com/taigaio) on Twitter. diff --git a/app/coffee/app.coffee b/app/coffee/app.coffee index 3385474a..bfdec816 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", @@ -196,6 +220,7 @@ modules = [ "taigaNavMenu", "taigaProject", "taigaUserSettings", + "taigaFeedback", "taigaPlugins", # Vendor modules @@ -211,6 +236,7 @@ module.config([ "$locationProvider", "$httpProvider", "$provide", + "$tgEventsProvider", "tgLoaderProvider", configure ]) @@ -220,5 +246,7 @@ module.run([ "$tgI18n", "$tgConfig", "$rootScope", + "$tgAuth", + "$tgEvents", init ]) diff --git a/app/coffee/config.coffee b/app/coffee/config.coffee index 14320253..43ea197b 100644 --- a/app/coffee/config.coffee +++ b/app/coffee/config.coffee @@ -38,6 +38,8 @@ class ConfigService extends taiga.Service termsOfServiceUrl: null privacyPolicyUrl: null + + feedbackEnabled: true } initialize: (localconfig) -> diff --git a/app/coffee/modules/admin/lightboxes.coffee b/app/coffee/modules/admin/lightboxes.coffee index db8a57a2..2cf83e74 100644 --- a/app/coffee/modules/admin/lightboxes.coffee +++ b/app/coffee/modules/admin/lightboxes.coffee @@ -34,10 +34,10 @@ CreateMembersDirective = ($rs, $rootScope, $confirm, lightboxService) -> template = _.template("""
- + data-required="true" <% } %> data-type="email" />
- data-required="true" <% } %> data-required="true"> <% _.each(roleList, function(role) { %> <% }); %> @@ -48,8 +48,8 @@ CreateMembersDirective = ($rs, $rootScope, $confirm, lightboxService) -> """) # i18n link = ($scope, $el, $attrs) -> - createFieldSet = -> - ctx = {roleList: $scope.roles} + createFieldSet = (required = true)-> + ctx = {roleList: $scope.roles, required: required} return template(ctx) resetForm = -> @@ -86,10 +86,11 @@ CreateMembersDirective = ($rs, $rootScope, $confirm, lightboxService) -> target.removeClass("icon-plus add-fieldset") .addClass("icon-delete delete-fieldset") - newFieldSet = createFieldSet() + newFieldSet = createFieldSet(false) + fieldSet.after(newFieldSet) - if $el.find("fieldset").length == MAX_MEMBERSHIP_FIELDSETS + if $el.find(".add-member-wrapper").length == MAX_MEMBERSHIP_FIELDSETS $el.find("fieldset:last > a").removeClass("icon-plus add-fieldset") .addClass("icon-delete delete-fieldset") @@ -107,19 +108,31 @@ CreateMembersDirective = ($rs, $rootScope, $confirm, lightboxService) -> $rootScope.$broadcast("membersform:new:error") form = $el.find("form").checksley() + + #checksley find new fields + form.destroy() + form.initialize() + if not form.validate() return memberWrappers = $el.find("form > .add-member-wrapper") + memberWrappers = _.filter memberWrappers, (mw) -> + angular.element(mw).find("input").hasClass('checksley-ok') + invitations = _.map memberWrappers, (mw) -> memberWrapper = angular.element(mw) + email = memberWrapper.find("input") + role = memberWrapper.find("select") + return { - email: memberWrapper.find("input").val() - role_id: memberWrapper.find("select").val() + email: email.val() + role_id: role.val() } - $rs.memberships.bulkCreateMemberships($scope.project.id, invitations).then(onSuccess, onError) + if invitations.length + $rs.memberships.bulkCreateMemberships($scope.project.id, invitations).then(onSuccess, onError) return {link: link} diff --git a/app/coffee/modules/admin/roles.coffee b/app/coffee/modules/admin/roles.coffee index 5cdb0e91..49bf2913 100644 --- a/app/coffee/modules/admin/roles.coffee +++ b/app/coffee/modules/admin/roles.coffee @@ -241,12 +241,12 @@ RolePermissionsDirective = ($rootscope, $repo, $confirm) -> categories = [] milestonePermissions = [ - { key: "view_milestones", description: "View milestones" } - { key: "add_milestone", description: "Add milestone" } - { key: "modify_milestone", description: "Modify milestone" } - { key: "delete_milestone", description: "Delete milestone" } + { key: "view_milestones", description: "View sprints" } + { key: "add_milestone", description: "Add sprint" } + { key: "modify_milestone", description: "Modify sprint" } + { key: "delete_milestone", description: "Delete sprint" } ] - categories.push({ name: "Milestones", permissions: setActivePermissions(milestonePermissions) }) + categories.push({ name: "Sprints", permissions: setActivePermissions(milestonePermissions) }) userStoryPermissions = [ { key: "view_us", description: "View user story" } diff --git a/app/coffee/modules/auth.coffee b/app/coffee/modules/auth.coffee index 5fb7e29a..9f65a2d3 100644 --- a/app/coffee/modules/auth.coffee +++ b/app/coffee/modules/auth.coffee @@ -170,7 +170,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 = {} @@ -180,6 +180,7 @@ LoginDirective = ($auth, $confirm, $location, $routeParams, $navUrls) -> else nextUrl = $navUrls.resolve("home") + $events.setupConnection() $location.path(nextUrl) onErrorSubmit = (response) -> @@ -203,8 +204,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 @@ -220,7 +221,7 @@ RegisterDirective = ($auth, $confirm, $location, $navUrls, $config) -> form = $el.find("form").checksley() onSuccessSubmit = (response) -> - $confirm.notify("success", "Our Oompa Loompas are happy, wellcome to Taiga.") #TODO: i18n + $confirm.notify("success", "Our Oompa Loompas are happy, welcome to Taiga.") #TODO: i18n $location.path($navUrls.resolve("home")) onErrorSubmit = (response) -> @@ -307,7 +308,7 @@ ChangePasswordFromRecoveryDirective = ($auth, $confirm, $location, $params, $nav onSuccessSubmit = (response) -> $location.path($navUrls.resolve("login")) - $confirm.success("Our Oompa Loompas save your new password.
+ $confirm.success("Our Oompa Loompas saved your new password.
Try to sign in with it.") #TODO: i18n onErrorSubmit = (response) -> @@ -348,8 +349,8 @@ InvitationDirective = ($auth, $confirm, $location, $params, $navUrls) -> promise.then null, (response) -> $location.path($navUrls.resolve("login")) - $confirm.success("Ooops, we have a problems
- Our Oompa Loompas can't find your invitations.") #TODO: i18n + $confirm.success("Ooops, we have a problem
+ Our Oompa Loompas can't find your invitation.") #TODO: i18n # Login form $scope.dataLogin = {token: token} @@ -357,12 +358,12 @@ InvitationDirective = ($auth, $confirm, $location, $params, $navUrls) -> onSuccessSubmitLogin = (response) -> $location.path($navUrls.resolve("project", {project: $scope.invitation.project_slug})) - $confirm.notify("success", "You've successfully joined to this project", - "Wellcome to #{$scope.invitation.project_name}") + $confirm.notify("success", "You've successfully joined this project", + "Welcome to #{$scope.invitation.project_name}") onErrorSubmitLogin = (response) -> $confirm.notify("light-error", "According to our Oompa Loompas, your are not registered yet or - type an invalid password.") #TODO: i18n + typed an invalid password.") #TODO: i18n submitLogin = -> if not loginForm.validate() @@ -385,11 +386,11 @@ InvitationDirective = ($auth, $confirm, $location, $params, $navUrls) -> onSuccessSubmitRegister = (response) -> $location.path($navUrls.resolve("project", {project: $scope.invitation.project_slug})) - $confirm.notify("success", "You've successfully joined to this project", - "Wellcome to #{$scope.invitation.project_name}") + $confirm.notify("success", "You've successfully joined this project", + "Welcome to #{$scope.invitation.project_name}") onErrorSubmitRegister = (response) -> - $confirm.notify("light-error", "According to our Oompa Loompas, the + $confirm.notify("light-error", "According to our Oompa Loompas, that username or email is already in use.") #TODO: i18n submitRegister = -> diff --git a/app/coffee/modules/backlog/main.coffee b/app/coffee/modules/backlog/main.coffee index 0d4c3c11..69beb617 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" @@ -90,12 +91,24 @@ class BacklogController extends mixOf(taiga.Controller, taiga.PageMixin, taiga.F @scope.$on("sprintform:remove:success", @.loadUserstories) @scope.$on("usform:new:success", @.loadUserstories) @scope.$on("usform:edit:success", @.loadUserstories) + @scope.$on("usform:new:success", @.loadProjectStats) + @scope.$on("usform:bulk:success", @.loadProjectStats) @scope.$on("sprint:us:move", @.moveUs) @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 +199,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()) @@ -956,3 +970,52 @@ tgBacklogGraphDirective = -> module.directive("tgGmBacklogGraph", tgBacklogGraphDirective) + + +############################################################################# +## Backlog progress bar directive +############################################################################# + +TgBacklogProgressBarDirective = -> + template = _.template(""" +
+
+
+ """) + + render = (el, projectPointsPercentaje, closedPointsPercentaje) -> + el.html(template({ + projectPointsPercentaje: projectPointsPercentaje, + closedPointsPercentaje:closedPointsPercentaje + })) + + adjustPercentaje = (percentage) -> + adjusted = _.max([0 , percentage]) + adjusted = _.min([100, adjusted]) + return Math.round(adjusted) + + link = ($scope, $el, $attrs) -> + element = angular.element($el) + + $scope.$watch $attrs.tgBacklogProgressBar, (stats) -> + if stats? + totalPoints = stats.total_points + definedPoints = stats.defined_points + closedPoints = stats.closed_points + if definedPoints > totalPoints + projectPointsPercentaje = totalPoints * 100 / definedPoints + closedPointsPercentaje = closedPoints * 100 / definedPoints + else + projectPointsPercentaje = 100 + closedPointsPercentaje = closedPoints * 100 / totalPoints + + projectPointsPercentaje = adjustPercentaje(projectPointsPercentaje - 3) + closedPointsPercentaje = adjustPercentaje(closedPointsPercentaje - 3) + render($el, projectPointsPercentaje, closedPointsPercentaje) + + $scope.$on "$destroy", -> + $el.off() + + return {link: link} + +module.directive("tgBacklogProgressBar", TgBacklogProgressBarDirective) diff --git a/app/coffee/modules/common/components.coffee b/app/coffee/modules/common/components.coffee index 6c38651d..df6e6254 100644 --- a/app/coffee/modules/common/components.coffee +++ b/app/coffee/modules/common/components.coffee @@ -333,15 +333,19 @@ 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 +358,19 @@ 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,15 +382,18 @@ ListItemTypeDirective = -> """ link = ($scope, $el, $attrs) -> - issue = $scope.$eval($attrs.tgListitemType) + render = (issueTypeById, issue) -> + type = issueTypeById[issue.type] + domNode = $el.find(".level") + domNode.css("background-color", type.color) + domNode.attr("title", type.name) bindOnce $scope, "issueTypeById", (issueTypeById) -> - type = issueTypeById[issue.type] + issue = $scope.$eval($attrs.tgListitemType) + render(issueTypeById, issue) - domNode = $el.find("div.level") - domNode.css("background-color", type.color) - domNode.addClass(type.name.toLowerCase()) - domNode.attr("title", type.name) + $scope.$watch $attrs.tgListitemType, (issue) -> + render($scope.issueTypeById, issue) return { link: link diff --git a/app/coffee/modules/common/history.coffee b/app/coffee/modules/common/history.coffee index ead7a6a8..98ba3356 100644 --- a/app/coffee/modules/common/history.coffee +++ b/app/coffee/modules/common/history.coffee @@ -269,7 +269,7 @@ HistoryDirective = ($log, $loading) -> team_requirement: "team requirement" # Task - milestone: "spreint" + milestone: "sprint" user_story: "user story" is_iocaine: "is iocaine" diff --git a/app/coffee/modules/common/lightboxes.coffee b/app/coffee/modules/common/lightboxes.coffee index 48138e03..4da59d18 100644 --- a/app/coffee/modules/common/lightboxes.coffee +++ b/app/coffee/modules/common/lightboxes.coffee @@ -32,6 +32,7 @@ debounce = @.taiga.debounce class LightboxService extends taiga.Service open: ($el) -> $el.css('display', 'flex') + $el.find('input,textarea').first().focus() timeout(70, -> $el.addClass("open")) docEl = angular.element(document) @@ -45,9 +46,10 @@ class LightboxService extends taiga.Service docEl.off(".keyboard-navigation") # Hack: to fix problems in the WYSIWYG textareas when press ENTER $el.one "transitionend", => - $el.css('display', 'none') + $el.removeAttr('style') + $el.removeClass("open").removeClass('close') - $el.removeClass("open") + $el.addClass('close') closeAll: -> docEl = angular.element(document) 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/common/tags.coffee b/app/coffee/modules/common/tags.coffee index 738fea8e..87a6fef9 100644 --- a/app/coffee/modules/common/tags.coffee +++ b/app/coffee/modules/common/tags.coffee @@ -96,6 +96,7 @@ TagLineDirective = ($log, $rs) -> template = """
+
""" # Tags template (rendered manually using lodash) @@ -137,6 +138,14 @@ TagLineDirective = ($log, $rs) -> $scope.$apply -> $model.$setViewValue(normalizeTags(tags)) + saveInputTag = () -> + input = $el.find('input') + + addValue(input.val()) + input.val("") + input.autocomplete("close") + $el.find('.save').hide() + $scope.$watch $attrs.ngModel, (val) -> tags_colors = if $scope.project?.tags_colors? then $scope.project.tags_colors else [] renderTags($el, val, editable, tags_colors) @@ -171,13 +180,16 @@ TagLineDirective = ($log, $rs) -> event.preventDefault() $el.on "keyup", "input", (event) -> - return if event.keyCode != 13 - event.preventDefault() - target = angular.element(event.currentTarget) - addValue(target.val()) - target.val("") - $el.find("input").autocomplete("close") + + if event.keyCode == 13 + saveInputTag() + else if target.val().length + $el.find('.save').show() + else + $el.find('.save').hide() + + $el.on "click", ".save", saveInputTag $el.on "click", ".icon-delete", (event) -> event.preventDefault() diff --git a/app/coffee/modules/events.coffee b/app/coffee/modules/events.coffee new file mode 100644 index 00000000..08445607 --- /dev/null +++ b/app/coffee/modules/events.coffee @@ -0,0 +1,160 @@ +### +# 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 = {} + @.connected = false + @.error = false + @.pendingMessages = [] + + if @win.WebSocket is undefined + @log.info "WebSockets not supported on your browser" + + setupConnection: -> + @.stopExistingConnection() + + url = @config.get("eventsUrl", "ws://localhost:8888/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.removeEventListener("open", @.onOpen) + @.ws.removeEventListener("close", @.onClose) + @.ws.removeEventListener("error", @.onError) + @.ws.removeEventListener("message", @.onMessage) + @.ws.close() + + delete @.ws + + serialize: (message) -> + if _.isObject(message) + return JSON.stringify(message) + return message + + sendMessage: (message) -> + @.pendingMessages.push(message) + + if not @.connected + return + + messages = _.map(@.pendingMessages, @.serialize) + @.pendingMessages = [] + + for msg in messages + @.ws.send(msg) + + subscribe: (scope, routingKey, callback) -> + if @.error + return + + @log.debug("Subscribe to: #{routingKey}") + subscription = { + scope: scope, + routingKey: routingKey, + callback: _.debounce(callback, 500, {"leading": true, "trailing": false}) + } + + message = { + "cmd": "subscribe", + "routing_key": routingKey + } + + @.subscriptions[routingKey] = subscription + @.sendMessage(message) + scope.$on("$destroy", => @.unsubscribe(routingKey)) + + unsubscribe: (routingKey) -> + if @.error + return + + @log.debug("Unsubscribe from: #{routingKey}") + + message = { + "cmd": "unsubscribe", + "routing_key": routingKey + } + + @.sendMessage(message) + + onOpen: -> + @.connected = true + + @log.debug("WebSocket connection opened") + token = @auth.getToken() + + message = { + cmd: "auth" + data: {token: token, sessionId: @.sessionId} + } + + @.sendMessage(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}") + @.error = true + + onClose: -> + @log.debug("WebSocket closed.") + @.connected = false + + +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/feedback.coffee b/app/coffee/modules/feedback.coffee new file mode 100644 index 00000000..17add8e7 --- /dev/null +++ b/app/coffee/modules/feedback.coffee @@ -0,0 +1,68 @@ +### +# 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/feedback.coffee +### + +taiga = @.taiga + +groupBy = @.taiga.groupBy +bindOnce = @.taiga.bindOnce +mixOf = @.taiga.mixOf +debounce = @.taiga.debounce +trim = @.taiga.trim + +module = angular.module("taigaFeedback", []) + +FeedbackDirective = ($lightboxService, $repo, $confirm)-> + link = ($scope, $el, $attrs) -> + form = $el.find("form").checksley() + + submit = debounce 2000, -> + if not form.validate() + return + + promise = $repo.create("feedback", $scope.feedback) + + promise.then (data) -> + $lightboxService.close($el) + $confirm.notify("success", "\\o/ we'll be happy to read your") + + promise.then null, -> + $confirm.notify("error") + + $el.on "submit", (event) -> + submit() + + $el.on "click", ".button-green", (event) -> + event.preventDefault() + submit() + + $scope.$on "feedback:show", -> + $scope.$apply -> + $scope.feedback = {} + + $lightboxService.open($el) + $el.find("textarea").focus() + + $scope.$on "$destroy", -> + $el.off() + + return {link:link} + +module.directive("tgLbFeedback", ["lightboxService", "$tgRepo", "$tgConfirm", FeedbackDirective]) 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..5bb01b02 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()) @@ -250,13 +257,16 @@ module.controller("KanbanController", KanbanController) KanbanDirective = ($repo, $rootscope) -> link = ($scope, $el, $attrs) -> - tableBodyDom = $el.find(".kanban-table-body") + tableBodyDom.on "scroll", (event) -> target = angular.element(event.currentTarget) tableHeaderDom = $el.find(".kanban-table-header .kanban-table-inner") tableHeaderDom.css("left", -1 * target.scrollLeft()) + $scope.$on "$destroy", -> + $el.off() + return {link: link} module.directive("tgKanban", ["$tgRepo", "$rootScope", KanbanDirective]) @@ -273,10 +283,14 @@ KanbanRowWidthFixerDirective = -> size = (statuses.length * itemSize) - 10 $el.css("width", "#{size}px") + $scope.$on "$destroy", -> + $el.off() + return {link: link} module.directive("tgKanbanRowWidthFixer", KanbanRowWidthFixerDirective) + ############################################################################# ## Kanban Column Height Fixer Directive ############################################################################# @@ -294,6 +308,9 @@ KanbanColumnHeightFixerDirective = -> link = ($scope, $el, $attrs) -> timeout(500, -> renderSize($el)) + $scope.$on "$destroy", -> + $el.off() + return {link:link} @@ -305,14 +322,23 @@ module.directive("tgKanbanColumnHeightFixer", KanbanColumnHeightFixerDirective) KanbanUserstoryDirective = ($rootscope) -> link = ($scope, $el, $attrs, $model) -> + $el.disableSelection() + + $scope.$watch "us", (us) -> + if us.is_blocked and not $el.hasClass("blocked") + $el.addClass("blocked") + else if not us.is_blocked and $el.hasClass("blocked") + $el.removeClass("blocked") + $el.find(".icon-edit").on "click", (event) -> if $el.find(".icon-edit").hasClass("noclick") return + $scope.$apply -> $rootscope.$broadcast("usform:edit", $model.$modelValue) - if $scope.us.is_blocked - $el.addClass("blocked") - $el.disableSelection() + + $scope.$on "$destroy", -> + $el.off() return { templateUrl: "/partials/views/components/kanban-task.html" @@ -320,7 +346,6 @@ KanbanUserstoryDirective = ($rootscope) -> require: "ngModel" } - module.directive("tgKanbanUserstory", ["$rootScope", KanbanUserstoryDirective]) @@ -344,6 +369,9 @@ KanbanWipLimitDirective = -> $scope.$on "usform:new:success", redrawWipLimit $scope.$on "usform:bulk:success", redrawWipLimit + $scope.$on "$destroy", -> + $el.off() + return {link: link} module.directive("tgKanbanWipLimit", KanbanWipLimitDirective) @@ -405,7 +433,9 @@ KanbanUserDirective = ($log) -> $ctrl = $el.controller() $ctrl.changeUsAssignedTo(us) + $scope.$on "$destroy", -> + $el.off() + return {link: link, require:"ngModel"} - module.directive("tgKanbanUserAvatar", ["$log", KanbanUserDirective]) diff --git a/app/coffee/modules/kanban/sortable.coffee b/app/coffee/modules/kanban/sortable.coffee index cd495efe..bb371980 100644 --- a/app/coffee/modules/kanban/sortable.coffee +++ b/app/coffee/modules/kanban/sortable.coffee @@ -37,45 +37,49 @@ module = angular.module("taigaKanban") KanbanSortableDirective = ($repo, $rs, $rootscope) -> link = ($scope, $el, $attrs) -> - oldParentScope = null - newParentScope = null - itemEl = null - tdom = $el + bindOnce $scope, "project", (project) -> + if not (project.my_permissions.indexOf("modify_us") > -1) + return - deleteElement = (itemEl) -> - # Completelly remove item and its scope from dom - itemEl.scope().$destroy() - itemEl.off() - itemEl.remove() + oldParentScope = null + newParentScope = null + itemEl = null + tdom = $el - tdom.sortable({ - handle: ".kanban-task-inner" - dropOnEmpty: true - connectWith: ".kanban-uses-box" - revert: 400 - }) + deleteElement = (itemEl) -> + # Completelly remove item and its scope from dom + itemEl.scope().$destroy() + itemEl.off() + itemEl.remove() - tdom.on "sortstop", (event, ui) -> - parentEl = ui.item.parent() - itemEl = ui.item - itemUs = itemEl.scope().us - itemIndex = itemEl.index() - newParentScope = parentEl.scope() + tdom.sortable({ + handle: ".kanban-task-inner" + dropOnEmpty: true + connectWith: ".kanban-uses-box" + revert: 400 + }) - newStatusId = newParentScope.status.id - oldStatusId = oldParentScope.status.id + tdom.on "sortstop", (event, ui) -> + parentEl = ui.item.parent() + itemEl = ui.item + itemUs = itemEl.scope().us + itemIndex = itemEl.index() + newParentScope = parentEl.scope() - if newStatusId != oldStatusId - deleteElement(itemEl) + newStatusId = newParentScope.status.id + oldStatusId = oldParentScope.status.id - $scope.$apply -> - $rootscope.$broadcast("kanban:us:move", itemUs, newStatusId, itemIndex) + if newStatusId != oldStatusId + deleteElement(itemEl) - ui.item.find('a').removeClass('noclick') + $scope.$apply -> + $rootscope.$broadcast("kanban:us:move", itemUs, newStatusId, itemIndex) - tdom.on "sortstart", (event, ui) -> - oldParentScope = ui.item.parent().scope() - ui.item.find('a').addClass('noclick') + ui.item.find('a').removeClass('noclick') + + tdom.on "sortstart", (event, ui) -> + oldParentScope = ui.item.parent().scope() + ui.item.find('a').addClass('noclick') $scope.$on "$destroy", -> $el.off() diff --git a/app/coffee/modules/nav.coffee b/app/coffee/modules/nav.coffee index 44c913c6..4fb57c09 100644 --- a/app/coffee/modules/nav.coffee +++ b/app/coffee/modules/nav.coffee @@ -59,7 +59,8 @@ class ProjectsNavigationController extends taiga.Controller return projects newProject: -> - @rootscope.$broadcast("projects:create") + @scope.$apply () => + @rootscope.$broadcast("projects:create") filterProjects: (text) -> @scope.filteredProjects = _.filter @scope.projects, (project) -> @@ -200,7 +201,7 @@ module.directive("tgProjectsNav", ["$rootScope", "animationFrame", "$timeout", " ## Project ############################################################################# -ProjectMenuDirective = ($log, $compile, $auth, $rootscope, $tgAuth, $location, $navUrls) -> +ProjectMenuDirective = ($log, $compile, $auth, $rootscope, $tgAuth, $location, $navUrls, $config) -> menuEntriesTemplate = _.template("""