From f52935f8c904598c4a339e076eba5e939271f770 Mon Sep 17 00:00:00 2001 From: Juanfran Date: Fri, 23 Oct 2015 16:46:46 +0200 Subject: [PATCH] Create like and watch buttons in project detail pages --- .../like-project-button.controller.coffee | 40 +++++ ...like-project-button.controller.spec.coffee | 115 ++++++++++++++ .../like-project-button.directive.coffee | 12 ++ .../like-project-button.jade | 29 ++++ .../like-project-button.service.coffee | 52 +++++++ .../like-project-button.service.spec.coffee | 132 ++++++++++++++++ .../watch-project-button.controller.coffee | 33 ++++ ...atch-project-button.controller.spec.coffee | 136 +++++++++++++++++ .../watch-project-button.directive.coffee | 12 ++ .../watch-project-button.jade | 56 +++++++ .../watch-project-button.service.coffee | 59 ++++++++ .../watch-project-button.service.spec.coffee | 142 ++++++++++++++++++ .../projects/listing/projects-listing.jade | 20 +-- .../projects/listing/projects-listing.scss | 80 ---------- .../listing/styles/profile-projects.scss | 9 +- .../projects/listing/styles/project-list.scss | 10 +- .../project/project.controller.coffee | 35 ++--- .../project/project.controller.spec.coffee | 73 +++------ app/modules/projects/project/project.jade | 65 ++++++-- app/modules/services/project.service.coffee | 33 ++-- .../services/project.service.spec.coffee | 75 +++++++-- 21 files changed, 1013 insertions(+), 205 deletions(-) create mode 100644 app/modules/projects/components/like-project-button/like-project-button.controller.coffee create mode 100644 app/modules/projects/components/like-project-button/like-project-button.controller.spec.coffee create mode 100644 app/modules/projects/components/like-project-button/like-project-button.directive.coffee create mode 100644 app/modules/projects/components/like-project-button/like-project-button.jade create mode 100644 app/modules/projects/components/like-project-button/like-project-button.service.coffee create mode 100644 app/modules/projects/components/like-project-button/like-project-button.service.spec.coffee create mode 100644 app/modules/projects/components/watch-project-button/watch-project-button.controller.coffee create mode 100644 app/modules/projects/components/watch-project-button/watch-project-button.controller.spec.coffee create mode 100644 app/modules/projects/components/watch-project-button/watch-project-button.directive.coffee create mode 100644 app/modules/projects/components/watch-project-button/watch-project-button.jade create mode 100644 app/modules/projects/components/watch-project-button/watch-project-button.service.coffee create mode 100644 app/modules/projects/components/watch-project-button/watch-project-button.service.spec.coffee delete mode 100644 app/modules/projects/listing/projects-listing.scss diff --git a/app/modules/projects/components/like-project-button/like-project-button.controller.coffee b/app/modules/projects/components/like-project-button/like-project-button.controller.coffee new file mode 100644 index 00000000..c41842f6 --- /dev/null +++ b/app/modules/projects/components/like-project-button/like-project-button.controller.coffee @@ -0,0 +1,40 @@ +class LikeProjectButtonController + @.$inject = [ + "$tgConfirm" + "tgLikeProjectButtonService" + ] + + constructor: (@confirm, @likeButtonService)-> + @.isMouseOver = false + @.loading = false + + showTextWhenMouseIsOver: -> + @.isMouseOver = true + + showTextWhenMouseIsLeave: -> + @.isMouseOver = false + + toggleLike: -> + @.loading = true + + if not @.project.get("is_fan") + promise = @._like() + else + promise = @._unlike() + + promise.finally () => @.loading = false + + return promise + + _like: -> + return @likeButtonService.like(@.project.get('id')) + .then => + @.showTextWhenMouseIsLeave() + .catch => + @confirm.notify("error") + + _unlike: -> + return @likeButtonService.unlike(@.project.get('id')).catch => + @confirm.notify("error") + +angular.module("taigaProjects").controller("LikeProjectButton", LikeProjectButtonController) diff --git a/app/modules/projects/components/like-project-button/like-project-button.controller.spec.coffee b/app/modules/projects/components/like-project-button/like-project-button.controller.spec.coffee new file mode 100644 index 00000000..0abb7f19 --- /dev/null +++ b/app/modules/projects/components/like-project-button/like-project-button.controller.spec.coffee @@ -0,0 +1,115 @@ +describe "LikeProjectButton", -> + $provide = null + $controller = null + mocks = {} + + _mockTgConfirm = -> + mocks.tgConfirm = { + notify: sinon.stub() + } + + $provide.value("$tgConfirm", mocks.tgConfirm) + + _mockTgLikeProjectButton = -> + mocks.tgLikeProjectButton = { + like: sinon.stub(), + unlike: sinon.stub() + } + + $provide.value("tgLikeProjectButtonService", mocks.tgLikeProjectButton) + + _mocks = -> + module (_$provide_) -> + $provide = _$provide_ + + _mockTgConfirm() + _mockTgLikeProjectButton() + + return null + + _inject = -> + inject (_$controller_) -> + $controller = _$controller_ + + _setup = -> + _mocks() + _inject() + + beforeEach -> + module "taigaProjects" + + _setup() + + it "toggleLike false -> true", (done) -> + project = Immutable.fromJS({ + id: 3, + is_fan: false + }) + + ctrl = $controller("LikeProjectButton") + ctrl.project = project + + mocks.tgLikeProjectButton.like = sinon.stub().promise() + + promise = ctrl.toggleLike() + + expect(ctrl.loading).to.be.true; + + mocks.tgLikeProjectButton.like.withArgs(project.get('id')).resolve() + + promise.finally () -> + expect(mocks.tgLikeProjectButton.like).to.be.calledOnce + expect(ctrl.loading).to.be.false; + + done() + + it "toggleLike false -> true, notify error", (done) -> + project = Immutable.fromJS({ + id: 3, + is_fan: false + }) + + ctrl = $controller("LikeProjectButton") + ctrl.project = project + + mocks.tgLikeProjectButton.like.withArgs(project.get('id')).promise().reject() + + ctrl.toggleLike().finally () -> + expect(mocks.tgConfirm.notify.withArgs("error")).to.be.calledOnce + done() + + it "toggleLike true -> false", (done) -> + project = Immutable.fromJS({ + is_fan: true + }) + + ctrl = $controller("LikeProjectButton") + ctrl.project = project + + mocks.tgLikeProjectButton.unlike = sinon.stub().promise() + + promise = ctrl.toggleLike() + + expect(ctrl.loading).to.be.true; + + mocks.tgLikeProjectButton.unlike.withArgs(project.get('id')).resolve() + + promise.finally () -> + expect(mocks.tgLikeProjectButton.unlike).to.be.calledOnce + expect(ctrl.loading).to.be.false; + + done() + + it "toggleLike true -> false, notify error", (done) -> + project = Immutable.fromJS({ + is_fan: true + }) + + ctrl = $controller("LikeProjectButton") + ctrl.project = project + + mocks.tgLikeProjectButton.unlike.withArgs(project.get('id')).promise().reject() + + ctrl.toggleLike().finally () -> + expect(mocks.tgConfirm.notify.withArgs("error")).to.be.calledOnce + done() diff --git a/app/modules/projects/components/like-project-button/like-project-button.directive.coffee b/app/modules/projects/components/like-project-button/like-project-button.directive.coffee new file mode 100644 index 00000000..b1b8d409 --- /dev/null +++ b/app/modules/projects/components/like-project-button/like-project-button.directive.coffee @@ -0,0 +1,12 @@ +LikeProjectButtonDirective = -> + return { + scope: {} + controller: "LikeProjectButton", + bindToController: { + project: '=' + } + controllerAs: "vm", + templateUrl: "projects/components/like-project-button/like-project-button.html", + } + +angular.module("taigaProjects").directive("tgLikeProjectButton", LikeProjectButtonDirective) diff --git a/app/modules/projects/components/like-project-button/like-project-button.jade b/app/modules/projects/components/like-project-button/like-project-button.jade new file mode 100644 index 00000000..7faad550 --- /dev/null +++ b/app/modules/projects/components/like-project-button/like-project-button.jade @@ -0,0 +1,29 @@ +a.track-button.like-button.like-container( + href="", + title="{{ 'PROJECT.LIKE_BUTTON.BUTTON_TITLE' | translate }}" + ng-click="vm.toggleLike()" + ng-class="{'active':vm.project.get('is_fan'), 'is-hover':vm.project.get('is_fan') && vm.isMouseOver}" + ng-mouseover="vm.showTextWhenMouseIsOver()" + ng-mouseleave="vm.showTextWhenMouseIsLeave()" +) + span.track-inner + span.track-icon + include ../../../../svg/like.svg + span( + ng-if="!vm.project.get('is_fan')" + translate="PROJECT.LIKE_BUTTON.LIKE" + ) + span( + ng-if="vm.project.get('is_fan') && !vm.isMouseOver" + translate="PROJECT.LIKE_BUTTON.LIKED" + ) + span( + ng-if="vm.project.get('is_fan') && vm.isMouseOver" + translate="PROJECT.LIKE_BUTTON.UNLIKE" + ) + + span.track-button-counter( + title="{{ 'PROJECT.LIKE_BUTTON.COUNTER_TITLE'|translate:{total:vm.project.get(\"total_fans\")||0}:'messageformat' }}", + tg-loading="vm.loading" + ) + | {{ vm.project.get('total_fans') }} diff --git a/app/modules/projects/components/like-project-button/like-project-button.service.coffee b/app/modules/projects/components/like-project-button/like-project-button.service.coffee new file mode 100644 index 00000000..dd6ce15c --- /dev/null +++ b/app/modules/projects/components/like-project-button/like-project-button.service.coffee @@ -0,0 +1,52 @@ +taiga = @.taiga + +class LikeProjectButtonService extends taiga.Service + @.$inject = ["tgResources", "tgCurrentUserService", "tgProjectService"] + + constructor: (@rs, @currentUserService, @projectService) -> + + _getProjectIndex: (projectId) -> + return @currentUserService.projects + .get('all') + .findIndex (project) -> project.get('id') == projectId + + _updateProjects: (projectId, isFan) -> + projectIndex = @._getProjectIndex(projectId) + projects = @currentUserService.projects + .get('all') + .update projectIndex, (project) -> + + totalFans = project.get("total_fans") + + if isFan then totalFans++ else totalFans-- + + return project.merge({ + is_fan: isFan, + total_fans: totalFans + }) + + @currentUserService.setProjects(projects) + + _updateCurrentProject: (isFan) -> + totalFans = @projectService.project.get("total_fans") + + if isFan then totalFans++ else totalFans-- + + project = @projectService.project.merge({ + is_fan: isFan, + total_fans: totalFans + }) + + @projectService.setProject(project) + + like: (projectId) -> + return @rs.projects.likeProject(projectId).then => + @._updateProjects(projectId, true) + @._updateCurrentProject(true) + + unlike: (projectId) -> + return @rs.projects.unlikeProject(projectId).then => + @._updateProjects(projectId, false) + @._updateCurrentProject(false) + +angular.module("taigaProjects").service("tgLikeProjectButtonService", LikeProjectButtonService) diff --git a/app/modules/projects/components/like-project-button/like-project-button.service.spec.coffee b/app/modules/projects/components/like-project-button/like-project-button.service.spec.coffee new file mode 100644 index 00000000..28c3a45f --- /dev/null +++ b/app/modules/projects/components/like-project-button/like-project-button.service.spec.coffee @@ -0,0 +1,132 @@ +describe "tgLikeProjectButtonService", -> + likeButtonService = null + provide = null + mocks = {} + + _mockTgResources = () -> + mocks.tgResources = { + projects: { + likeProject: sinon.stub(), + unlikeProject: sinon.stub() + } + } + + provide.value "tgResources", mocks.tgResources + + _mockTgCurrentUserService = () -> + mocks.tgCurrentUserService = { + setProjects: sinon.stub(), + projects: Immutable.fromJS({ + all: [ + { + id: 4, + total_fans: 2, + is_fan: false + }, + { + id: 5, + total_fans: 7, + is_fan: true + }, + { + id: 6, + total_fans: 4, + is_fan: true + } + ] + }) + } + + provide.value "tgCurrentUserService", mocks.tgCurrentUserService + + _mockTgProjectService = () -> + mocks.tgProjectService = { + setProject: sinon.stub() + } + + provide.value "tgProjectService", mocks.tgProjectService + + _inject = (callback) -> + inject (_tgLikeProjectButtonService_) -> + likeButtonService = _tgLikeProjectButtonService_ + callback() if callback + + _mocks = () -> + module ($provide) -> + provide = $provide + _mockTgResources() + _mockTgCurrentUserService() + _mockTgProjectService() + return null + + _setup = -> + _mocks() + + beforeEach -> + module "taigaProjects" + _setup() + _inject() + + it "like", (done) -> + projectId = 4 + + mocks.tgResources.projects.likeProject.withArgs(projectId).promise().resolve() + + newProject = { + id: 4, + total_fans: 3, + is_fan: true + } + + mocks.tgProjectService.project = mocks.tgCurrentUserService.projects.getIn(['all', 0]) + + userServiceCheckImmutable = sinon.match ((immutable) -> + immutable = immutable.toJS() + + return _.isEqual(immutable[0], newProject) + ), 'userServiceCheckImmutable' + + projectServiceCheckImmutable = sinon.match ((immutable) -> + immutable = immutable.toJS() + + return _.isEqual(immutable, newProject) + ), 'projectServiceCheckImmutable' + + + likeButtonService.like(projectId).finally () -> + expect(mocks.tgCurrentUserService.setProjects).to.have.been.calledWith(userServiceCheckImmutable) + expect(mocks.tgProjectService.setProject).to.have.been.calledWith(projectServiceCheckImmutable) + + done() + + it "unlike", (done) -> + projectId = 5 + + mocks.tgResources.projects.unlikeProject.withArgs(projectId).promise().resolve() + + newProject = { + id: 5, + total_fans: 6, + is_fan: false + } + + mocks.tgProjectService.project = mocks.tgCurrentUserService.projects.getIn(['all', 1]) + + userServiceCheckImmutable = sinon.match ((immutable) -> + immutable = immutable.toJS() + + return _.isEqual(immutable[1], newProject) + ), 'userServiceCheckImmutable' + + projectServiceCheckImmutable = sinon.match ((immutable) -> + immutable = immutable.toJS() + + return _.isEqual(immutable, newProject) + ), 'projectServiceCheckImmutable' + + + likeButtonService.unlike(projectId).finally () -> + expect(mocks.tgCurrentUserService.setProjects).to.have.been.calledWith(userServiceCheckImmutable) + expect(mocks.tgProjectService.setProject).to.have.been.calledWith(projectServiceCheckImmutable) + + done() diff --git a/app/modules/projects/components/watch-project-button/watch-project-button.controller.coffee b/app/modules/projects/components/watch-project-button/watch-project-button.controller.coffee new file mode 100644 index 00000000..037e38cd --- /dev/null +++ b/app/modules/projects/components/watch-project-button/watch-project-button.controller.coffee @@ -0,0 +1,33 @@ +class WatchProjectButtonController + @.$inject = [ + "$tgConfirm" + "tgWatchProjectButtonService" + ] + + constructor: (@confirm, @watchButtonService)-> + @.showWatchOptions = false + @.loading = false + + toggleWatcherOptions: () -> + @.showWatchOptions = !@.showWatchOptions + + closeWatcherOptions: () -> + @.showWatchOptions = false + + watch: (notifyLevel) -> + @.loading = true + @.closeWatcherOptions() + + return @watchButtonService.watch(@.project.get('id'), notifyLevel) + .catch () => @confirm.notify("error") + .finally () => @.loading = false + + unwatch: -> + @.loading = true + @.closeWatcherOptions() + + return @watchButtonService.unwatch(@.project.get('id')) + .catch () => @confirm.notify("error") + .finally () => @.loading = false + +angular.module("taigaProjects").controller("WatchProjectButton", WatchProjectButtonController) diff --git a/app/modules/projects/components/watch-project-button/watch-project-button.controller.spec.coffee b/app/modules/projects/components/watch-project-button/watch-project-button.controller.spec.coffee new file mode 100644 index 00000000..a11b4ac2 --- /dev/null +++ b/app/modules/projects/components/watch-project-button/watch-project-button.controller.spec.coffee @@ -0,0 +1,136 @@ +describe "WatchProjectButton", -> + $provide = null + $controller = null + mocks = {} + + _mockTgConfirm = -> + mocks.tgConfirm = { + notify: sinon.stub() + } + + $provide.value("$tgConfirm", mocks.tgConfirm) + + _mockTgWatchProjectButton = -> + mocks.tgWatchProjectButton = { + watch: sinon.stub(), + unwatch: sinon.stub() + } + + $provide.value("tgWatchProjectButtonService", mocks.tgWatchProjectButton) + + _mocks = -> + module (_$provide_) -> + $provide = _$provide_ + + _mockTgConfirm() + _mockTgWatchProjectButton() + + return null + + _inject = -> + inject (_$controller_) -> + $controller = _$controller_ + + _setup = -> + _mocks() + _inject() + + beforeEach -> + module "taigaProjects" + + _setup() + + it "toggleWatcherOption", () -> + ctrl = $controller("WatchProjectButton") + + ctrl.toggleWatcherOptions() + + expect(ctrl.showWatchOptions).to.be.true + + ctrl.toggleWatcherOptions() + + expect(ctrl.showWatchOptions).to.be.false + + it "watch", (done) -> + notifyLevel = 5 + project = Immutable.fromJS({ + id: 3 + }) + + ctrl = $controller("WatchProjectButton") + ctrl.project = project + ctrl.showWatchOptions = true + + mocks.tgWatchProjectButton.watch = sinon.stub().promise() + + promise = ctrl.watch(notifyLevel) + + expect(ctrl.loading).to.be.true + + mocks.tgWatchProjectButton.watch.withArgs(project.get('id'), notifyLevel).resolve() + + promise.finally () -> + expect(mocks.tgWatchProjectButton.watch).to.be.calledOnce + expect(ctrl.showWatchOptions).to.be.false + expect(ctrl.loading).to.be.false + + done() + + it "watch, notify error", (done) -> + notifyLevel = 5 + project = Immutable.fromJS({ + id: 3 + }) + + ctrl = $controller("WatchProjectButton") + ctrl.project = project + ctrl.showWatchOptions = true + + mocks.tgWatchProjectButton.watch.withArgs(project.get('id'), notifyLevel).promise().reject() + + ctrl.watch(notifyLevel).finally () -> + expect(mocks.tgConfirm.notify.withArgs("error")).to.be.calledOnce + expect(ctrl.showWatchOptions).to.be.false + expect(ctrl.loading).to.be.false + + done() + + it "unwatch", (done) -> + project = Immutable.fromJS({ + id: 3 + }) + + ctrl = $controller("WatchProjectButton") + ctrl.project = project + ctrl.showWatchOptions = true + + mocks.tgWatchProjectButton.unwatch = sinon.stub().promise() + + promise = ctrl.unwatch() + + expect(ctrl.loading).to.be.true + + mocks.tgWatchProjectButton.unwatch.withArgs(project.get('id')).resolve() + + promise.finally () -> + expect(mocks.tgWatchProjectButton.unwatch).to.be.calledOnce + expect(ctrl.showWatchOptions).to.be.false + + done() + + it "unwatch, notify error", (done) -> + project = Immutable.fromJS({ + id: 3 + }) + + ctrl = $controller("WatchProjectButton") + ctrl.project = project + ctrl.showWatchOptions = true + + mocks.tgWatchProjectButton.unwatch.withArgs(project.get('id')).promise().reject() + + ctrl.unwatch().finally () -> + expect(mocks.tgConfirm.notify.withArgs("error")).to.be.calledOnce + expect(ctrl.showWatchOptions).to.be.false + + done() diff --git a/app/modules/projects/components/watch-project-button/watch-project-button.directive.coffee b/app/modules/projects/components/watch-project-button/watch-project-button.directive.coffee new file mode 100644 index 00000000..d2746a7a --- /dev/null +++ b/app/modules/projects/components/watch-project-button/watch-project-button.directive.coffee @@ -0,0 +1,12 @@ +WatchProjectButtonDirective = -> + return { + scope: {} + controller: "WatchProjectButton", + bindToController: { + project: "=" + } + controllerAs: "vm", + templateUrl: "projects/components/watch-project-button/watch-project-button.html", + } + +angular.module("taigaProjects").directive("tgWatchProjectButton", WatchProjectButtonDirective) diff --git a/app/modules/projects/components/watch-project-button/watch-project-button.jade b/app/modules/projects/components/watch-project-button/watch-project-button.jade new file mode 100644 index 00000000..cd556a19 --- /dev/null +++ b/app/modules/projects/components/watch-project-button/watch-project-button.jade @@ -0,0 +1,56 @@ +a.track-button.watch-button.watch-container( + href="", + title="{{ 'PROJECT.WATCH_BUTTON.BUTTON_TITLE' | translate }}" + ng-click="vm.toggleWatcherOptions()" + ng-class="{'active': vm.project.get('is_watcher')}" +) + span.track-inner + span.track-icon + include ../../../../svg/watch.svg + span(ng-if="!vm.project.get('is_watcher')", translate="PROJECT.WATCH_BUTTON.WATCH") + span(ng-if="vm.project.get('is_watcher')", translate="PROJECT.WATCH_BUTTON.WATCHING") + span.icon.icon-arrow-up + + span.track-button-counter( + title="{{ 'PROJECT.WATCH_BUTTON.COUNTER_TITLE'|translate:{total:vm.project.get(\"total_watchers\")||0}:'messageformat' }}", + tg-loading="vm.loading" + ) + | {{ vm.project.get('total_watchers') }} + +ul.watch-options( + ng-class="{'hidden': !vm.showWatchOptions}" + ng-mouseleave="vm.closeWatcherOptions()" +) + //- NOTIFY LEVEL CHOICES: + //- 1 - Only involved + //- 2 - Receive all + //- 3 - No notifications + + li + a( + href="", + title="{{ 'PROJECT.WATCH_BUTTON.OPTIONS.NOTIFY_ALL_TITLE' | translate }}", + ng-click="vm.watch(2)", + ng-class="{'active': vm.project.get('is_watcher') && vm.project.get('notify_level') == 2}" + ) + span(translate="PROJECT.WATCH_BUTTON.OPTIONS.NOTIFY_ALL") + span.watch-check(ng-if="vm.project.get('is_watcher') && vm.project.get('notify_level') == 2") + include ../../../../svg/check.svg + li + a( + href="", + title="{{ 'PROJECT.WATCH_BUTTON.OPTIONS.NOTIFY_INVOLVED_TITLE' | translate }}", + ng-click="vm.watch(1)", + ng-class="{'active': vm.project.get('is_watcher') && vm.project.get('notify_level') == 1}" + ) + span(translate="PROJECT.WATCH_BUTTON.OPTIONS.NOTIFY_INVOLVED") + span.watch-check(ng-if="vm.project.get('is_watcher') && vm.project.get('notify_level') == 1") + include ../../../../svg/check.svg + + li(ng-if="vm.project.get('is_watcher')") + a( + href="", + title="{{ 'PROJECT.WATCH_BUTTON.OPTIONS.UNWATCH_TITLE' | translate }}", + ng-click="vm.unwatch()" + ) + span(translate="PROJECT.WATCH_BUTTON.OPTIONS.UNWATCH") diff --git a/app/modules/projects/components/watch-project-button/watch-project-button.service.coffee b/app/modules/projects/components/watch-project-button/watch-project-button.service.coffee new file mode 100644 index 00000000..424b86c4 --- /dev/null +++ b/app/modules/projects/components/watch-project-button/watch-project-button.service.coffee @@ -0,0 +1,59 @@ +taiga = @.taiga + +class WatchProjectButtonService extends taiga.Service + @.$inject = [ + "tgResources", + "tgCurrentUserService", + "tgProjectService" + ] + + constructor: (@rs, @currentUserService, @projectService) -> + + _getProjectIndex: (projectId) -> + return @currentUserService.projects + .get('all') + .findIndex (project) -> project.get('id') == projectId + + + _updateProjects: (projectId, notifyLevel, isWatcher) -> + projectIndex = @._getProjectIndex(projectId) + + projects = @currentUserService.projects + .get('all') + .update projectIndex, (project) => + totalWatchers = project.get('total_watchers') + + if isWatcher then totalWatchers++ else totalWatchers-- + + return project.merge({ + is_watcher: isWatcher, + total_watchers: totalWatchers + notify_level: notifyLevel + }) + + @currentUserService.setProjects(projects) + + _updateCurrentProject: (notifyLevel, isWatcher) -> + totalWatchers = @projectService.project.get("total_watchers") + + if isWatcher then totalWatchers++ else totalWatchers-- + + project = @projectService.project.merge({ + is_watcher: isWatcher, + total_watchers: totalWatchers + notify_level: notifyLevel + }) + + @projectService.setProject(project) + + watch: (projectId, notifyLevel) -> + return @rs.projects.watchProject(projectId, notifyLevel).then => + @._updateProjects(projectId, notifyLevel, true) + @._updateCurrentProject(notifyLevel, true) + + unwatch: (projectId) -> + return @rs.projects.unwatchProject(projectId).then => + @._updateProjects(projectId, null, false) + @._updateCurrentProject(null, false) + +angular.module("taigaProjects").service("tgWatchProjectButtonService", WatchProjectButtonService) diff --git a/app/modules/projects/components/watch-project-button/watch-project-button.service.spec.coffee b/app/modules/projects/components/watch-project-button/watch-project-button.service.spec.coffee new file mode 100644 index 00000000..2914d2e3 --- /dev/null +++ b/app/modules/projects/components/watch-project-button/watch-project-button.service.spec.coffee @@ -0,0 +1,142 @@ +describe "tgWatchProjectButtonService", -> + watchButtonService = null + provide = null + mocks = {} + + _mockTgResources = () -> + mocks.tgResources = { + projects: { + watchProject: sinon.stub(), + unwatchProject: sinon.stub() + } + } + + provide.value "tgResources", mocks.tgResources + + _mockTgCurrentUserService = () -> + mocks.tgCurrentUserService = { + setProjects: sinon.stub(), + getUser: () -> + return Immutable.fromJS({ + id: 89 + }) + projects: Immutable.fromJS({ + all: [ + { + id: 4, + total_watchers: 0, + is_watcher: false, + notify_level: null + }, + { + id: 5, + total_watchers: 1, + is_watcher: true, + notify_level: 3 + }, + { + id: 6, + total_watchers: 0, + is_watcher: true, + notify_level: null + } + ] + }) + } + + provide.value "tgCurrentUserService", mocks.tgCurrentUserService + + _mockTgProjectService = () -> + mocks.tgProjectService = { + setProject: sinon.stub() + } + + provide.value "tgProjectService", mocks.tgProjectService + + _inject = (callback) -> + inject (_tgWatchProjectButtonService_) -> + watchButtonService = _tgWatchProjectButtonService_ + callback() if callback + + _mocks = () -> + module ($provide) -> + provide = $provide + _mockTgResources() + _mockTgCurrentUserService() + _mockTgProjectService() + return null + + _setup = -> + _mocks() + + beforeEach -> + module "taigaProjects" + _setup() + _inject() + + it "watch", (done) -> + projectId = 4 + notifyLevel = 3 + + mocks.tgResources.projects.watchProject.withArgs(projectId, notifyLevel).promise().resolve() + + newProject = { + id: 4, + total_watchers: 1, + is_watcher: true, + notify_level: notifyLevel + } + + mocks.tgProjectService.project = mocks.tgCurrentUserService.projects.getIn(['all', 0]) + + userServiceCheckImmutable = sinon.match ((immutable) -> + immutable = immutable.toJS() + + return _.isEqual(immutable[0], newProject) + ), 'userServiceCheckImmutable' + + projectServiceCheckImmutable = sinon.match ((immutable) -> + immutable = immutable.toJS() + + return _.isEqual(immutable, newProject) + ), 'projectServiceCheckImmutable' + + + watchButtonService.watch(projectId, notifyLevel).finally () -> + expect(mocks.tgCurrentUserService.setProjects).to.have.been.calledWith(userServiceCheckImmutable) + expect(mocks.tgProjectService.setProject).to.have.been.calledWith(projectServiceCheckImmutable) + + done() + + it "unwatch", (done) -> + projectId = 5 + + mocks.tgResources.projects.unwatchProject.withArgs(projectId).promise().resolve() + + newProject = { + id: 5, + total_watchers: 0, + is_watcher: false, + notify_level: null + } + + mocks.tgProjectService.project = mocks.tgCurrentUserService.projects.getIn(['all', 1]) + + userServiceCheckImmutable = sinon.match ((immutable) -> + immutable = immutable.toJS() + + return _.isEqual(immutable[1], newProject) + ), 'userServiceCheckImmutable' + + projectServiceCheckImmutable = sinon.match ((immutable) -> + immutable = immutable.toJS() + + return _.isEqual(immutable, newProject) + ), 'projectServiceCheckImmutable' + + + watchButtonService.unwatch(projectId).finally () -> + expect(mocks.tgCurrentUserService.setProjects).to.have.been.calledWith(userServiceCheckImmutable) + expect(mocks.tgProjectService.setProject).to.have.been.calledWith(projectServiceCheckImmutable) + + done() diff --git a/app/modules/projects/listing/projects-listing.jade b/app/modules/projects/listing/projects-listing.jade index f4760ff1..60dab648 100644 --- a/app/modules/projects/listing/projects-listing.jade +++ b/app/modules/projects/listing/projects-listing.jade @@ -11,17 +11,17 @@ div.project-list-wrapper.centered section.project-list-section div.project-list ul(tg-sort-projects="vm.projects") - li.project-list-single(tg-bind-scope, tg-repeat="project in vm.projects track by project.get('id')") - div.project-list-single-left - div.project-title - h1.project-name + li.list-itemtype-project(tg-bind-scope, tg-repeat="project in vm.projects track by project.get('id')") + div.list-itemtype-project-left + div.list-itemtype-project-data + h2 a(href="#", tg-nav="project:project=project.get('slug')", title="{{ ::project.get('name') }}") {{project.get('name')}} - span.private(ng-if="project.get('is_private')", title="{{'PROJECT.PRIVATE' | translate}}") - include ../../../svg/lock.svg - p {{ ::project.get('description') | limitTo:300 }} - span(ng-if="::project.get('description').length > 300") ... - - div.project-list-single-tags.tags-container(ng-if="::project.get('tags').size") + span.private(ng-if="project.get('is_private')", title="{{'PROJECT.PRIVATE' | translate}}") + include ../../../svg/lock.svg + p {{ ::project.get('description') | limitTo:300 }} + span(ng-if="::project.get('description').length > 300") ... + + div.list-itemtype-project-tags.tag-container(ng-if="::project.get('tags').size") span.tag(style='border-left: 5px solid {{::tag.get("color")}};', tg-repeat="tag in ::project.get('colorized_tags')") span.tag-name {{::tag.get('name')}} diff --git a/app/modules/projects/listing/projects-listing.scss b/app/modules/projects/listing/projects-listing.scss deleted file mode 100644 index 6b0f99b8..00000000 --- a/app/modules/projects/listing/projects-listing.scss +++ /dev/null @@ -1,80 +0,0 @@ -.project-list-single { - border-bottom: 1px solid $whitish; - display: flex; - justify-content: space-between; - min-height: 9rem; - padding: 1rem; - position: relative; -} - -.project-list-single-left { - display: flex; - flex-direction: column; - padding-right: 1rem; - h1 { - @extend %text; - @extend %larger; - color: $gray; - display: inline-block; - margin-bottom: 0; - text-transform: none; - vertical-align: middle; - white-space: nowrap; - } - p { - @extend %text; - @extend %xsmall; - color: $gray; - margin-bottom: 0; - } - .project-list-single-tags { - align-self: flex-end; - display: flex; - flex: 3; - flex-wrap: wrap; - margin-top: .5rem; - } - .tag { - align-self: flex-end; - margin-right: .5rem; - padding: .5rem; - } -} - -.project-list-single-right { - flex-shrink: 0; - justify-content: space-between; - width: 200px; - .project-list-single-stats { - align-self: flex-end; - display: flex; - div { - color: $gray-light; - margin-right: .5rem; - .icon { - margin-right: .2rem; - vertical-align: center; - } - } - .active { - .icon { - color: $primary-light; - } - } - } - .project-list-single-members { - align-self: flex-end; - display: flex; - flex-direction: row-reverse; - flex-grow: 0; - flex-wrap: wrap-reverse; - margin-top: 1rem; - a { - display: block; } - img { - border-radius: .1rem; - margin-right: .3rem; - width: 34px; - } - } -} diff --git a/app/modules/projects/listing/styles/profile-projects.scss b/app/modules/projects/listing/styles/profile-projects.scss index 034c14c0..363ed7c1 100644 --- a/app/modules/projects/listing/styles/profile-projects.scss +++ b/app/modules/projects/listing/styles/profile-projects.scss @@ -1,10 +1,13 @@ .profile-projects { border-top: 1px solid $whitish; - .project-list-single { + .list-itemtype-project { display: flex; justify-content: space-between; min-height: 10rem; + .list-itemtype-project-right { + display: flex; + flex-direction: column; + width: 200px; + } } } - - diff --git a/app/modules/projects/listing/styles/project-list.scss b/app/modules/projects/listing/styles/project-list.scss index c4dc2dfd..daf97513 100644 --- a/app/modules/projects/listing/styles/project-list.scss +++ b/app/modules/projects/listing/styles/project-list.scss @@ -9,6 +9,7 @@ padding: .9rem 1rem; h1 { @extend %larger; + @extend %light; margin: 0; } } @@ -41,13 +42,12 @@ } .placeholder { background-color: lighten($whitish, 3%); - height: 7rem; - width: 100%; + height: 5rem; } - .project-list-single { - background: $white; + .list-itemtype-project { + background: rgba($white, .6); &:hover { - background: lighten($primary, 60%); + background: lighten($primary, 63%); cursor: move; transition: background .3s; .drag { diff --git a/app/modules/projects/project/project.controller.coffee b/app/modules/projects/project/project.controller.coffee index 673df954..367a2cf6 100644 --- a/app/modules/projects/project/project.controller.coffee +++ b/app/modules/projects/project/project.controller.coffee @@ -1,36 +1,31 @@ class ProjectController @.$inject = [ - "tgProjectsService", "$routeParams", "tgAppMetaService", "$tgAuth", - "tgXhrErrorService", - "$translate" + "$translate", + "tgProjectService" ] - constructor: (@projectsService, @routeParams, @appMetaService, @auth, @xhrError, @translate) -> + constructor: (@routeParams, @appMetaService, @auth, @translate, @projectService) -> projectSlug = @routeParams.pslug @.user = @auth.userData - @projectsService - .getProjectBySlug(projectSlug) - .then (project) => - @.project = project + taiga.defineImmutableProperty @, "project", () => return @projectService.project + taiga.defineImmutableProperty @, "members", () => return @projectService.activeMembers - members = @.project.get('members').filter (member) -> member.get('is_active') - - @.project = @.project.set('members', members) - - @._setMeta(@.project) - - .catch (xhr) => - @xhrError.response(xhr) + @appMetaService.setfn @._setMeta.bind(this) _setMeta: (project)-> - ctx = {projectName: project.get("name")} + metas = {} - title = @translate.instant("PROJECT.PAGE_TITLE", ctx) - description = project.get("description") - @appMetaService.setAll(title, description) + return metas if !@.project + + ctx = {projectName: @.project.get("name")} + + metas.title = @translate.instant("PROJECT.PAGE_TITLE", ctx) + metas.description = @.project.get("description") + + return metas angular.module("taigaProjects").controller("Project", ProjectController) diff --git a/app/modules/projects/project/project.controller.spec.coffee b/app/modules/projects/project/project.controller.spec.coffee index baa83fc6..3e8018cb 100644 --- a/app/modules/projects/project/project.controller.spec.coffee +++ b/app/modules/projects/project/project.controller.spec.coffee @@ -5,16 +5,14 @@ describe "ProjectController", -> $rootScope = null mocks = {} - _mockProjectsService = () -> - mocks.projectService = { - getProjectBySlug: sinon.stub() - } + _mockProjectService = () -> + mocks.projectService = {} - provide.value "tgProjectsService", mocks.projectService + provide.value "tgProjectService", mocks.projectService _mockAppMetaService = () -> mocks.appMetaService = { - setAll: sinon.stub() + setfn: sinon.stub() } provide.value "tgAppMetaService", mocks.appMetaService @@ -31,13 +29,6 @@ describe "ProjectController", -> pslug: "project-slug" } - _mockXhrErrorService = () -> - mocks.xhrErrorService = { - response: sinon.spy() - } - - provide.value "tgXhrErrorService", mocks.xhrErrorService - _mockTranslate = () -> mocks.translate = {} mocks.translate.instant = sinon.stub() @@ -47,11 +38,10 @@ describe "ProjectController", -> _mocks = () -> module ($provide) -> provide = $provide - _mockProjectsService() + _mockProjectService() _mockRouteParams() _mockAppMetaService() _mockAuth() - _mockXhrErrorService() _mockTranslate() return null @@ -72,14 +62,12 @@ describe "ProjectController", -> members: [] }) - mocks.projectService.getProjectBySlug.withArgs("project-slug").promise().resolve(project) - ctrl = $controller "Project", $scope: {} expect(ctrl.user).to.be.equal(mocks.auth.userData) - it "set page title", (done) -> + it "set page title", () -> $scope = $rootScope.$new() project = Immutable.fromJS({ name: "projectName" @@ -93,44 +81,31 @@ describe "ProjectController", -> }) .returns('projectTitle') - mocks.projectService.getProjectBySlug.withArgs("project-slug").promise().resolve(project) + mocks.projectService.project = project ctrl = $controller("Project") - setTimeout ( -> - expect(mocks.appMetaService.setAll.calledWithExactly("projectTitle", "projectDescription")).to.be.true - done() - ) + metas = ctrl._setMeta(project) - it "set local project variable with active members", (done) -> + expect(metas.title).to.be.equal('projectTitle') + expect(metas.description).to.be.equal('projectDescription') + expect(mocks.appMetaService.setfn).to.be.calledOnce + + it "set local project variable and members", () -> project = Immutable.fromJS({ - name: "projectName", - members: [ - {is_active: true}, - {is_active: true}, - {is_active: true}, - {is_active: false} - ] + name: "projectName" }) - mocks.projectService.getProjectBySlug.withArgs("project-slug").promise().resolve(project) + members = Immutable.fromJS([ + {is_active: true}, + {is_active: true}, + {is_active: true} + ]) + + mocks.projectService.project = project + mocks.projectService.activeMembers = members ctrl = $controller("Project") - setTimeout (() -> - expect(ctrl.project.get('members').size).to.be.equal(3) - - done() - ) - - it "handle project error", (done) -> - xhr = {code: 403} - - mocks.projectService.getProjectBySlug.withArgs("project-slug").promise().reject(xhr) - - ctrl = $controller("Project") - - setTimeout (() -> - expect(mocks.xhrErrorService.response.withArgs(xhr)).to.be.calledOnce - done() - ) + expect(ctrl.project).to.be.equal(project) + expect(ctrl.members).to.be.equal(members) diff --git a/app/modules/projects/project/project.jade b/app/modules/projects/project/project.jade index 64f69b1a..9f48bf12 100644 --- a/app/modules/projects/project/project.jade +++ b/app/modules/projects/project/project.jade @@ -2,29 +2,64 @@ div.wrapper tg-project-menu div.centered.single-project section.single-project-intro - h1 - span.green(class="project-name") {{::vm.project.get("name")}} - span.private(ng-if="::vm.project.get('is_private')", title="{{'PROJECT.PRIVATE' | translate}}") - include ../../../svg/lock.svg + div.intro-options + h1 + span.project-name {{::vm.project.get("name")}} + span.private( + ng-if="::vm.project.get('is_private')" + title="{{'PROJECT.PRIVATE' | translate}}" + ) + include ../../../svg/lock.svg + + //- Like and wacht buttons for authenticated users + div.track-buttons-container(ng-if="vm.user") + tg-like-project-button(project="vm.project") + tg-watch-project-button(project="vm.project") + + //- Like and wacht buttons for anonymous users + div.track-container(ng-if="!vm.user") + .list-itemtype-track + span.list-itemtype-track-likers( + title="{{ 'PROJECT.LIKE_BUTTON.COUNTER_TITLE'|translate:{total:vm.project.get(\"total_fans\")||0}:'messageformat' }}" + ) + span.icon + include ../../../svg/like.svg + span {{ ::vm.project.get('total_fans') }} + + span.list-itemtype-track-watchers( + title="{{ 'PROJECT.WATCH_BUTTON.COUNTER_TITLE'|translate:{total:vm.project.get(\"total_watchers\")||0}:'messageformat' }}" + ) + span.icon + include ../../../svg/watch.svg + span {{ ::vm.project.get('total_watchers') }} + p.description {{vm.project.get('description')}} div.single-project-tags.tags-container(ng-if="::vm.project.get('tags').size") - span.tag(style='border-left: 5px solid {{::tag.get("color")}};', - tg-repeat="tag in ::vm.project.get('colorized_tags')") + span.tag( + style='border-left: 5px solid {{::tag.get("color")}};', + tg-repeat="tag in ::vm.project.get('colorized_tags')" + ) span.tag-name {{::tag.get('name')}} div.project-data section.timeline(ng-if="vm.project") div(tg-user-timeline, projectId="vm.project.get('id')") + section.involved-data h2.title {{"PROJECT.SECTION.TEAM" | translate}} ul.involved-team - li(tg-repeat="member in ::vm.project.get('members')") - a(tg-nav="user-profile:username=member.get('username')", - title="{{::member.get('full_name')}}") - img(ng-src="{{::member.get('photo')}}", alt="{{::member.get('full_name')}}") - - // h2.title Organizations - // div.involved-organization - // a(href="", title="User Name") - // img(src="https://s3.amazonaws.com/uifaces/faces/twitter/dan_higham/48.jpg", alt="{{member.full_name}}") + li(tg-repeat="member in vm.members") + a( + tg-nav="user-profile:username=member.get('username')", + title="{{::member.get('full_name')}}" + ) + img(ng-src="{{::member.get('photo')}}", alt="{{::member.get('full_name')}}") + //- + h2.title Organizations + div.involved-organization + a(href="", title="User Name") + img( + src="https://s3.amazonaws.com/uifaces/faces/twitter/dan_higham/48.jpg" + alt="{{member.full_name}}" + ) diff --git a/app/modules/services/project.service.coffee b/app/modules/services/project.service.coffee index 9fe2902c..e764d20d 100644 --- a/app/modules/services/project.service.coffee +++ b/app/modules/services/project.service.coffee @@ -2,17 +2,20 @@ taiga = @.taiga class ProjectService @.$inject = [ - "tgProjectsService" + "tgProjectsService", + "tgXhrErrorService" ] - constructor: (@projectsService) -> + constructor: (@projectsService, @xhrError) -> @._project = null @._section = null @._sectionsBreadcrumb = Immutable.List() + @._activeMembers = Immutable.List() taiga.defineImmutableProperty @, "project", () => return @._project taiga.defineImmutableProperty @, "section", () => return @._section taiga.defineImmutableProperty @, "sectionsBreadcrumb", () => return @._sectionsBreadcrumb + taiga.defineImmutableProperty @, "activeMembers", () => return @._activeMembers setSection: (section) -> @._section = section @@ -22,20 +25,32 @@ class ProjectService else @._sectionsBreadcrumb = Immutable.List() - setProject: (pslug) -> - if @._pslug != pslug - @._pslug = pslug + setProjectBySlug: (pslug) -> + return new Promise (resolve, reject) => + if !@.project || @.project.get('slug') != pslug + @projectsService + .getProjectBySlug(pslug) + .then (project) => + @.setProject(project) + resolve() + .catch (xhr) => + @xhrError.response(xhr) - @.fetchProject() + else resolve() + + setProject: (project) -> + @._project = project + @._activeMembers = @._project.get('members').filter (member) -> member.get('is_active') cleanProject: () -> - @._pslug = null @._project = null + @._activeMembers = Immutable.List() @._section = null @._sectionsBreadcrumb = Immutable.List() fetchProject: () -> - return @projectsService.getProjectBySlug(@._pslug).then (project) => - @._project = project + pslug = @.project.get('slug') + + return @projectsService.getProjectBySlug(pslug).then (project) => @.setProject(project) angular.module("taigaCommon").service("tgProjectService", ProjectService) diff --git a/app/modules/services/project.service.spec.coffee b/app/modules/services/project.service.spec.coffee index 25016848..0b7d76f8 100644 --- a/app/modules/services/project.service.spec.coffee +++ b/app/modules/services/project.service.spec.coffee @@ -10,11 +10,19 @@ describe "tgProjectService", -> $provide.value "tgProjectsService", mocks.projectsService + _mockXhrErrorService = () -> + mocks.xhrErrorService = { + response: sinon.stub() + } + + $provide.value "tgXhrErrorService", mocks.xhrErrorService + _mocks = () -> module (_$provide_) -> $provide = _$provide_ _mockProjectsService() + _mockXhrErrorService() return null @@ -46,31 +54,70 @@ describe "tgProjectService", -> expect(projectService.sectionsBreadcrumb.toJS()).to.be.eql(breadcrumb) - it "set project if the project slug has changed", () -> - projectService.fetchProject = sinon.spy() + it "set project if the project slug has changed", (done) -> + projectService.setProject = sinon.spy() - pslug = "slug-1" + project = Immutable.Map({ + id: 1, + slug: 'slug-1', + members: [] + }) - projectService.setProject(pslug) + mocks.projectsService.getProjectBySlug.withArgs('slug-1').promise().resolve(project) + mocks.projectsService.getProjectBySlug.withArgs('slug-2').promise().resolve(project) - expect(projectService.fetchProject).to.be.calledOnce + projectService.setProjectBySlug('slug-1') + .then () -> projectService.setProjectBySlug('slug-1') + .then () -> projectService.setProjectBySlug('slug-2') + .finally () -> + expect(projectService.setProject).to.be.called.twice; + done() - projectService.setProject(pslug) + it "set project and set active members", () -> + project = Immutable.fromJS({ + name: 'test project', + members: [ + {is_active: true}, + {is_active: false}, + {is_active: true}, + {is_active: false}, + {is_active: false} + ] + }) - expect(projectService.fetchProject).to.be.calledOnce + projectService.setProject(project) - projectService.setProject("slug-2") - - expect(projectService.fetchProject).to.be.calledTwice + expect(projectService.project).to.be.equal(project) + expect(projectService.activeMembers.size).to.be.equal(2) it "fetch project", (done) -> - project = Immutable.Map({id: 1}) - pslug = "slug-1" + project = Immutable.Map({ + id: 1, + slug: 'slug', + members: [] + }) - projectService._pslug = pslug + projectService._project = project - mocks.projectsService.getProjectBySlug.withArgs(pslug).promise().resolve(project) + mocks.projectsService.getProjectBySlug.withArgs(project.get('slug')).promise().resolve(project) projectService.fetchProject().then () -> expect(projectService.project).to.be.equal(project) done() + + it "clean project", () -> + projectService._section = "fakeSection" + projectService._sectionsBreadcrumb = ["fakeSection"] + projectService._activeMembers = ["fakeMember"] + projectService._project = Immutable.Map({ + id: 1, + slug: 'slug', + members: [] + }) + + projectService.cleanProject() + + expect(projectService.project).to.be.null; + expect(projectService.activeMembers.size).to.be.equal(0); + expect(projectService.section).to.be.null; + expect(projectService.sectionsBreadcrumb.size).to.be.equal(0);