diff --git a/app/coffee/app.coffee b/app/coffee/app.coffee index 29c5ed73..0ae11c6a 100644 --- a/app/coffee/app.coffee +++ b/app/coffee/app.coffee @@ -68,7 +68,9 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven }, loader: true, title: "HOME.PAGE_TITLE", + loader: true, description: "HOME.PAGE_DESCRIPTION", + joyride: "dashboard" } ) @@ -109,7 +111,8 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven { templateUrl: "backlog/backlog.html", loader: true, - section: "backlog" + section: "backlog", + joyride: "backlog" } ) @@ -117,7 +120,8 @@ configure = ($routeProvider, $locationProvider, $httpProvider, $provide, $tgEven { templateUrl: "kanban/kanban.html", loader: true, - section: "kanban" + section: "kanban", + joyride: "kanban" } ) @@ -703,6 +707,7 @@ module.run([ "tgAppMetaService", "tgProjectService", "tgLoader", - "tgNavigationBarService" + "tgNavigationBarService", + "$route", init ]) diff --git a/app/index.jade b/app/index.jade index e50edda1..a9994cef 100644 --- a/app/index.jade +++ b/app/index.jade @@ -37,6 +37,8 @@ html(lang="en") include partials/includes/components/notification-message + div(tg-joy-ride) + script(src="/js/libs.js?v=#{v}") script(src="/js/templates.js?v=#{v}") script(src="/js/app-loader.js?v=#{v}") diff --git a/app/locales/locale-en.json b/app/locales/locale-en.json index c1a97761..7c51abee 100644 --- a/app/locales/locale-en.json +++ b/app/locales/locale-en.json @@ -1284,5 +1284,61 @@ "LOGIN_WITH_ANOTHER_USER": "Login with another user", "AUTHORIZE_APP": "Authorize app", "CANCEL": "Cancel" + }, + "JOYRIDE": { + "DASHBOARD": { + "STEP1": { + "TITLE": "Your project", + "TEXT": "Welcome! Here you will find the projects you are involved on. We have left you sample project templates to help you discover the power of Taiga." + }, + "STEP2": { + "TITLE": "Working on", + "TEXT": "Here you will find the User Stories, Tasks and Issues in which you are working on." + }, + "STEP3": { + "TITLE": "Watching", + "TEXT1": "And right here you will find the ones that you want to know about.", + "TEXT2": "You are already working with Taiga ;)" + }, + "STEP4": { + "TITLE": "Let’s start", + "TEXT1": "You can start by creating your first Taiga project or taking a look at the sample templates that we have left for you.", + "TEXT2": "Good luck!" + } + }, + "BACKLOG": { + "STEP1": { + "TITLE": "Project summary", + "TEXT1": "Here you will see the state of your project.", + "TEXT2": "You can change every kind of project settings through the admin." + }, + "STEP2": { + "TITLE": "Product backlog", + "TEXT": "The backlog is the list of requirements (User Stories) for the project. Here is where you will plan your sprints." + }, + "STEP3": { + "TITLE": "Sprints", + "TEXT": "Sprints are short periods of time (usually 2 weeks) during which specific work has to be completed and delivered." + }, + "STEP4": { + "TITLE": "User Stories", + "TEXT": "Those are the requirements at high level. You can add them to the backlog and drag them to the sprint in which it should be delivered." + } + }, + "KANBAN": { + "STEP1": { + "TITLE": "Customize your workflow", + "TEXT": "Set up the columns you need to map your workflow statuses through the admin." + }, + "STEP2": { + "TITLE": "User Stories & Tasks", + "TEXT": "User Stories are the requirements at high level. You can drag them to different columns." + }, + "STEP3": { + "TITLE": "Adding User Stories", + "TEXT1": "You may want to add a single User Story (add US icon) or a group of them (bulk icon)", + "TEXT2": "Good luck!" + } + } } } diff --git a/app/modules/components/joy-ride/joy-ride.directive.coffee b/app/modules/components/joy-ride/joy-ride.directive.coffee new file mode 100644 index 00000000..f2d54a8c --- /dev/null +++ b/app/modules/components/joy-ride/joy-ride.directive.coffee @@ -0,0 +1,59 @@ +taiga = @.taiga + +JoyRideDirective = ($rootScope, currentUserService, joyRideService) -> + link = (scope, el, attrs, ctrl) -> + intro = introJs() + + #Todo: translate + intro.setOptions({ + exitOnEsc: false, + exitOnOverlayClick: false, + showStepNumbers: false, + nextLabel: 'Next →', + prevLabel: '← Back', + skipLabel: 'Skip', + doneLabel: 'Done', + disableInteraction: true + }) + + intro.oncomplete () -> + $('html,body').scrollTop(0) + + intro.onexit () -> + currentUserService.disableJoyRide() + + initJoyrRide = (next, config) -> + if !config[next.joyride] + return + + intro.setOption('steps', joyRideService.get(next.joyride)) + intro.start() + + $rootScope.$on '$routeChangeSuccess', (event, next) -> + return if !next.joyride || !currentUserService.isAuthenticated() + + intro.oncomplete () -> + currentUserService.disableJoyRide(next.joyride) + + if next.loader + un = $rootScope.$on 'loader:end', () -> + currentUserService.loadJoyRideConfig() + .then (config) -> initJoyrRide(next, config) + + un() + else + currentUserService.loadJoyRideConfig() + .then (config) -> initJoyrRide(next, config) + + return { + scope: {}, + link: link + } + +JoyRideDirective.$inject = [ + "$rootScope", + "tgCurrentUserService", + "tgJoyRideService" +] + +angular.module("taigaComponents").directive("tgJoyRide", JoyRideDirective) diff --git a/app/modules/components/joy-ride/joy-ride.service.coffee b/app/modules/components/joy-ride/joy-ride.service.coffee new file mode 100644 index 00000000..30c69a56 --- /dev/null +++ b/app/modules/components/joy-ride/joy-ride.service.coffee @@ -0,0 +1,152 @@ +class JoyRideService extends taiga.Service + @.$inject = [ + '$translate', + 'tgCheckPermissionsService' + ] + + constructor: (@translate, @checkPermissionsService) -> + + getConfig: () -> + return { + dashboard: () => + return [ + { + element: '.project-list > section:not(.ng-hide)', + position: 'left', + joyride: { + title: @translate.instant('JOYRIDE.DASHBOARD.STEP1.TITLE'), + text: @translate.instant('JOYRIDE.DASHBOARD.STEP1.TEXT') + } + }, + { + element: '.working-on-container', + position: 'right', + joyride: { + title: @translate.instant('JOYRIDE.DASHBOARD.STEP2.TITLE'), + text: @translate.instant('JOYRIDE.DASHBOARD.STEP2.TEXT') + } + }, + { + element: '.watching-container', + position: 'right', + joyride: { + title: @translate.instant('JOYRIDE.DASHBOARD.STEP3.TITLE') + text: [ + @translate.instant('JOYRIDE.DASHBOARD.STEP3.TEXT1'), + @translate.instant('JOYRIDE.DASHBOARD.STEP3.TEXT2') + ] + } + }, + { + element: '.project-list .see-more-projects-btn', + position: 'bottom', + joyride: { + title: @translate.instant('JOYRIDE.DASHBOARD.STEP4.TITLE') + text: [ + @translate.instant('JOYRIDE.DASHBOARD.STEP4.TEXT1'), + @translate.instant('JOYRIDE.DASHBOARD.STEP4.TEXT2') + ] + } + } + ] + + backlog: () => + steps = [ + { + element: '.summary', + position: 'bottom', + joyride: { + title: @translate.instant('JOYRIDE.BACKLOG.STEP1.TITLE') + text: [ + @translate.instant('JOYRIDE.BACKLOG.STEP1.TEXT1'), + @translate.instant('JOYRIDE.BACKLOG.STEP1.TEXT2') + ] + } + }, + { + element: '.backlog-table-empty', + position: 'bottom', + joyride: { + title: @translate.instant('JOYRIDE.BACKLOG.STEP2.TITLE') + text: @translate.instant('JOYRIDE.BACKLOG.STEP2.TEXT') + } + }, + { + element: '.sprints', + position: 'left', + joyride: { + title: @translate.instant('JOYRIDE.BACKLOG.STEP3.TITLE') + text: @translate.instant('JOYRIDE.BACKLOG.STEP3.TEXT') + } + } + ] + + if @checkPermissionsService.check('add_us') + steps.push({ + element: '.new-us', + position: 'rigth', + joyride: { + title: @translate.instant('JOYRIDE.BACKLOG.STEP4.TITLE') + text: @translate.instant('JOYRIDE.BACKLOG.STEP4.TEXT') + } + }) + + return steps + + kanban: () => + steps = [ + { + element: '.kanban-table-inner', + position: 'bottom', + joyride: { + title: @translate.instant('JOYRIDE.KANBAN.STEP1.TITLE') + text: @translate.instant('JOYRIDE.KANBAN.STEP1.TEXT') + } + }, + { + element: '.card-placeholder', + position: 'right', + joyride: { + title: @translate.instant('JOYRIDE.KANBAN.STEP2.TITLE') + text: @translate.instant('JOYRIDE.KANBAN.STEP2.TEXT') + } + } + ] + + if @checkPermissionsService.check('add_us') + steps.push({ + element: '.icon-plus', + position: 'bottom', + joyride: { + title: @translate.instant('JOYRIDE.KANBAN.STEP3.TITLE') + text: [ + @translate.instant('JOYRIDE.KANBAN.STEP3.TEXT1'), + @translate.instant('JOYRIDE.KANBAN.STEP3.TEXT2'), + ] + } + }) + + return steps + } + + get: (name) -> + joyRides = @.getConfig() + joyRide = joyRides[name].call(this) + + return _.map joyRide, (item) -> + html = "" + + if item.joyride.title + html += "
#{text}
" + else + html += "#{item.joyride.text}
" + + item.intro = html + + return item + +angular.module("taigaComponents").service("tgJoyRideService", JoyRideService) diff --git a/app/modules/components/joy-ride/joy-ride.service.spec.coffee b/app/modules/components/joy-ride/joy-ride.service.spec.coffee new file mode 100644 index 00000000..75bca72a --- /dev/null +++ b/app/modules/components/joy-ride/joy-ride.service.spec.coffee @@ -0,0 +1,57 @@ +describe "tgJoyRideService", -> + joyRideService = provide = null + mocks = {} + + _mockTranslate = () -> + mocks.translate = { + instant: sinon.stub() + } + + provide.value "$translate", mocks.translate + + _mockCheckPermissionsService = () -> + mocks.checkPermissionsService = { + check: sinon.stub() + } + + mocks.checkPermissionsService.check.returns(true) + + provide.value "tgCheckPermissionsService", mocks.checkPermissionsService + + _inject = (callback) -> + inject (_tgJoyRideService_) -> + joyRideService = _tgJoyRideService_ + callback() if callback + + _mocks = () -> + module ($provide) -> + provide = $provide + _mockTranslate() + _mockCheckPermissionsService() + return null + + _setup = -> + _mocks() + + beforeEach -> + module "taigaComponents" + _setup() + _inject() + + it "get joyride by category", () -> + example = { + element: '.project-list > section:not(.ng-hide)', + position: 'left', + joyride: { + title: 'test', + text: 'test' + }, + intro: 'test
' + } + + mocks.translate.instant.returns('test') + + joyRide = joyRideService.get('dashboard') + + expect(joyRide).to.have.length(4) + expect(joyRide[0]).to.be.eql(example) diff --git a/app/modules/external-apps/external-app.controller.coffee b/app/modules/external-apps/external-app.controller.coffee index 21561f9a..6c963753 100644 --- a/app/modules/external-apps/external-app.controller.coffee +++ b/app/modules/external-apps/external-app.controller.coffee @@ -12,8 +12,8 @@ class ExternalAppController extends taiga.Controller "tgLoader" ] - constructor: (@routeParams, @externalAppsService, @window, @currentUserService, @location, @navUrls, - @xhrError, @loader) -> + constructor: (@routeParams, @externalAppsService, @window, @currentUserService, @location, + @navUrls, @xhrError, @loader) -> @loader.start(false) @._applicationId = @routeParams.application @._state = @routeParams.state diff --git a/app/modules/home/working-on/working-on.jade b/app/modules/home/working-on/working-on.jade index 4777e130..f12edae1 100644 --- a/app/modules/home/working-on/working-on.jade +++ b/app/modules/home/working-on/working-on.jade @@ -1,17 +1,19 @@ -div.title-bar.working-on-title(translate="HOME.WORKING_ON_SECTION") +section.working-on-container + .title-bar.working-on-title(translate="HOME.WORKING_ON_SECTION") -section.working-on(ng-show="vm.assignedTo.size") - div.duty-single(tg-duty="duty", tg-repeat="duty in vm.assignedTo", ng-class="{blocked: duty.is_blocked}") + .working-on(ng-show="vm.assignedTo.size") + .duty-single(tg-duty="duty", tg-repeat="duty in vm.assignedTo", ng-class="{blocked: duty.is_blocked}") -section.working-on-empty(ng-show="!vm.assignedTo.size") - p(translate="HOME.EMPTY_WORKING_ON") - include empty.jade + .working-on-empty(ng-show="!vm.assignedTo.size") + p(translate="HOME.EMPTY_WORKING_ON") + include empty.jade -div.title-bar.watching-title(translate="HOME.WATCHING_SECTION") +section.watching-container + .title-bar.watching-title(translate="HOME.WATCHING_SECTION") -section.watching(ng-show="vm.watching.size") - div.duty-single(tg-duty="duty", tg-repeat="duty in vm.watching", ng-class="{blocked: duty.is_blocked}") + .watching(ng-show="vm.watching.size") + .duty-single(tg-duty="duty", tg-repeat="duty in vm.watching", ng-class="{blocked: duty.is_blocked}") -section.watching-empty(ng-show="!vm.watching.size") - p(translate="HOME.EMPTY_WATCHING") - include empty.jade + .watching-empty(ng-show="!vm.watching.size") + p(translate="HOME.EMPTY_WATCHING") + include empty.jade diff --git a/app/modules/resources/resources.coffee b/app/modules/resources/resources.coffee index 8d73ce4d..077cb726 100644 --- a/app/modules/resources/resources.coffee +++ b/app/modules/resources/resources.coffee @@ -1,5 +1,6 @@ services = [ "tgProjectsResources", + "tgUserResources", "tgUsersResources", "tgUserstoriesResource", "tgTasksResource", diff --git a/app/modules/resources/user-resource.service.coffee b/app/modules/resources/user-resource.service.coffee new file mode 100644 index 00000000..d51d3498 --- /dev/null +++ b/app/modules/resources/user-resource.service.coffee @@ -0,0 +1,41 @@ +Resource = (urlsService, http, paginateResponseService) -> + service = {} + + service.getUserStorage = (key) -> + url = urlsService.resolve("user-storage") + + if key + url += '/' + key + + httpOptions = {} + + return http.get(url, {}).then (response) -> + return response.data.value + + service.setUserStorage = (key, value) -> + url = urlsService.resolve("user-storage") + '/' + key + + params = { + key: key, + value: value + } + + return http.put(url, params) + + service.createUserStorage = (key, value) -> + url = urlsService.resolve("user-storage") + + params = { + key: key, + value: value + } + + return http.post(url, params) + + return () -> + return {"user": service} + +Resource.$inject = ["$tgUrls", "$tgHttp"] + +module = angular.module("taigaResources2") +module.factory("tgUserResources", Resource) diff --git a/app/modules/services/check-permissions.service.coffee b/app/modules/services/check-permissions.service.coffee new file mode 100644 index 00000000..0b255196 --- /dev/null +++ b/app/modules/services/check-permissions.service.coffee @@ -0,0 +1,13 @@ +taiga = @.taiga + +class ChekcPermissionsService + @.$inject = [ + "tgProjectService" + ] + + constructor: (@projectService) -> + + check: (permission) -> + return @projectService.project.get('my_permissions').indexOf(permission) != -1 + +angular.module("taigaCommon").service("tgCheckPermissionsService", ChekcPermissionsService) diff --git a/app/modules/services/check-permissions.service.spec.coffee b/app/modules/services/check-permissions.service.spec.coffee new file mode 100644 index 00000000..0e862c1e --- /dev/null +++ b/app/modules/services/check-permissions.service.spec.coffee @@ -0,0 +1,44 @@ +describe "tgCheckPermissionsService", -> + checkPermissionsService = provide = null + mocks = {} + + _mockProjectService = () -> + mocks.projectService = { + project: sinon.stub() + } + + provide.value "tgProjectService", mocks.projectService + + _inject = () -> + inject (_tgCheckPermissionsService_) -> + checkPermissionsService = _tgCheckPermissionsService_ + + _mocks = () -> + module ($provide) -> + provide = $provide + _mockProjectService() + + return null + + beforeEach -> + module "taigaCommon" + _mocks() + _inject() + + it "the user has perms", () -> + mocks.projectService.project = Immutable.fromJS({ + my_permissions: ['add_us'] + }) + + perm = checkPermissionsService.check('add_us') + + expect(perm).to.be.true + + it "the user hasn't perms", () -> + mocks.projectService.project = Immutable.fromJS({ + my_permissions: [] + }) + + perm = checkPermissionsService.check('add_us') + + expect(perm).to.be.false diff --git a/app/modules/services/current-user.service.coffee b/app/modules/services/current-user.service.coffee index a4301dae..21d7c217 100644 --- a/app/modules/services/current-user.service.coffee +++ b/app/modules/services/current-user.service.coffee @@ -5,13 +5,15 @@ groupBy = @.taiga.groupBy class CurrentUserService @.$inject = [ "tgProjectsService", - "$tgStorage" + "$tgStorage", + "tgResources" ] - constructor: (@projectsService, @storageService) -> + constructor: (@projectsService, @storageService, @rs) -> @._user = null @._projects = Immutable.Map() @._projectsById = Immutable.Map() + @._joyride = null taiga.defineImmutableProperty @, "projects", () => return @._projects taiga.defineImmutableProperty @, "projectsById", () => return @._projectsById @@ -55,7 +57,43 @@ class CurrentUserService return @.projects + disableJoyRide: (section) -> + if section + @._joyride[section] = false + else + @._joyride = { + backlog: false, + kanban: false, + dashboard: false + } + + @rs.user.setUserStorage('joyride', @._joyride) + + loadJoyRideConfig: () -> + return new Promise (resolve) => + if @._joyride != null + resolve(@._joyride) + return + + @rs.user.getUserStorage('joyride') + .then (config) => + @._joyride = config + resolve(@._joyride) + .catch () => + #joyride not defined + @._joyride = { + backlog: true, + kanban: true, + dashboard: true + } + + @rs.user.createUserStorage('joyride', @._joyride) + + resolve(@._joyride) + _loadUserInfo: () -> - return @.loadProjects() + return Promise.all([ + @.loadProjects() + ]) angular.module("taigaCommon").service("tgCurrentUserService", CurrentUserService) diff --git a/app/modules/services/current-user.service.spec.coffee b/app/modules/services/current-user.service.spec.coffee index e17e44e9..7a418ff0 100644 --- a/app/modules/services/current-user.service.spec.coffee +++ b/app/modules/services/current-user.service.spec.coffee @@ -17,6 +17,17 @@ describe "tgCurrentUserService", -> provide.value "tgProjectsService", mocks.projectsService + _mockResources = () -> + mocks.resources = { + user: { + setUserStorage: sinon.stub(), + getUserStorage: sinon.stub(), + createUserStorage: sinon.stub() + } + } + + provide.value "tgResources", mocks.resources + _inject = (callback) -> inject (_tgCurrentUserService_) -> currentUserService = _tgCurrentUserService_ @@ -27,6 +38,7 @@ describe "tgCurrentUserService", -> provide = $provide _mockTgStorage() _mockProjectsService() + _mockResources() return null @@ -105,3 +117,35 @@ describe "tgCurrentUserService", -> currentUserService.removeUser() expect(currentUserService._user).to.be.null + + it "disable joyride", () -> + currentUserService.disableJoyRide() + + expect(mocks.resources.user.setUserStorage).to.have.been.calledWith('joyride', { + backlog: false, + kanban: false, + dashboard: false + }); + + it "load joyride config", (done) -> + mocks.resources.user.getUserStorage.withArgs('joyride').promise().resolve(true) + + currentUserService.loadJoyRideConfig().then (config) -> + expect(config).to.be.true + + done() + + it "create default joyride config", (done) -> + mocks.resources.user.getUserStorage.withArgs('joyride').promise().reject() + + currentUserService.loadJoyRideConfig().then (config) -> + joyride = { + backlog: true, + kanban: true, + dashboard: true + } + + expect(mocks.resources.user.createUserStorage).to.have.been.calledWith('joyride', joyride) + expect(config).to.be.eql(joyride) + + done() diff --git a/app/styles/modules/help/joyride.scss b/app/styles/modules/help/joyride.scss new file mode 100644 index 00000000..c9938f01 --- /dev/null +++ b/app/styles/modules/help/joyride.scss @@ -0,0 +1,58 @@ +// scss-lint:disable SelectorFormat, QualifyingElement + +.introjs-overlay { + background: radial-gradient(center, ellipse cover, rgba($white, .2) 0, rgba($whitish, .2) 100%); + background-color: $whitish; +} +.introjs-helperLayer { + border: 1px solid rgba($primary-light, .8); +} + +.introjs-helperLayer, +.introjs-tooltip { + box-shadow: 0 1px 8px rgba($grayer, .2); +} + +.introjs-tooltip { + h3 { + @extend %large; + margin-bottom: .5rem; + } + p { + @extend %light; + line-height: 1.4; + margin-bottom: 0; + } +} + +.introjs-bullets { + ul { + li { + a.active { + background: $primary-light; + } + } + } +} + +.introjs-button { + background-color: $primary; + background-image: none; + border: 0; + border-radius: 0; + color: $white; + margin-top: 10px; + padding: .3rem .8rem; + text-shadow: none; + &:focus, + &:hover { + background: none; + background-color: $primary-light; + color: $white; + } + &.introjs-disabled { + background: $whitish; + background-color: none; + color: $white; + } +} diff --git a/bower.json b/bower.json index 50937eb7..3bbd02bf 100644 --- a/bower.json +++ b/bower.json @@ -81,7 +81,8 @@ "ngInfiniteScroll": "1.2.1", "eventemitter2": "~0.4.14", "immutable": "~3.7.2", - "bluebird": "~2.10.2" + "bluebird": "~2.10.2", + "intro.js": "~1.1.1" }, "resolutions": { "lodash": "~2.4.2", diff --git a/gulpfile.js b/gulpfile.js index 321bf478..dbd0e4d3 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -58,7 +58,10 @@ paths.htmlPartials = [ paths.images = paths.app + "images/**/*"; paths.svg = paths.app + "svg/**/*"; -paths.css_vendor = paths.app + "styles/vendor/*.css"; +paths.css_vendor = [ + paths.app + "styles/vendor/*.css", + paths.vendor + "intro.js/introjs.css" +]; paths.locales = paths.app + "locales/**/*.json"; paths.sass = [ @@ -164,6 +167,7 @@ paths.libs = [ paths.vendor + "ngInfiniteScroll/build/ng-infinite-scroll.js", paths.vendor + "eventemitter2/lib/eventemitter2.js", paths.vendor + "immutable/dist/immutable.js", + paths.vendor + "intro.js/intro.js", paths.app + "js/jquery.ui.git-custom.js", paths.app + "js/jquery-ui.drag-multiple-custom.js", paths.app + "js/jquery.ui.touch-punch.min.js", @@ -244,7 +248,7 @@ gulp.task("scss-lint", [], function() { }; } }))) - .pipe(gulpif(fail, scsslint.failReporter())) + .pipe(gulpif(fail, scsslint.failReporter())); }); gulp.task("clear-sass-cache", function() { @@ -311,7 +315,7 @@ gulp.task("main-css", function() { return gulp.src(_paths) .pipe(concat("theme-" + themes.current.name + ".css")) .pipe(gulpif(isDeploy, minifyCSS({noAdvanced: true}))) - .pipe(gulp.dest(paths.dist + "styles/")) + .pipe(gulp.dest(paths.dist + "styles/")); }); var compileThemes = function (cb) { @@ -322,7 +326,7 @@ var compileThemes = function (cb) { ["app-css", "vendor-css"], "main-css", function() { - themes.next() + themes.next(); if (themes.current) { compileThemes(cb);