diff --git a/app/directives/challenge-tile/challenge-tile.directive.jade b/app/directives/challenge-tile/challenge-tile.directive.jade index 278be3ff2..3ec2e022d 100644 --- a/app/directives/challenge-tile/challenge-tile.directive.jade +++ b/app/directives/challenge-tile/challenge-tile.directive.jade @@ -32,7 +32,8 @@ // Only show if not data science track p.roles span(ng-hide="challenge.track === 'DATA_SCIENCE'") - #[span Role: ] #[span {{challenge.userDetails.roles | listRoles}}] + span Role:   + span {{challenge.userDetails.roles | listRoles}} .completed-challenge( ng-show="challenge.status === 'COMPLETED' || challenge.status === 'PAST'", @@ -66,7 +67,8 @@ // Only show if not data science track p.roles span(ng-hide="challenge.track === 'DATA_SCIENCE'") - #[span Role: ] #[span {{challenge.userDetails.roles | listRoles}}] + span Role:   + span {{challenge.userDetails.roles | listRoles}} .challenge.list-view(ng-show="view=='list'", ng-class="challenge.track") .active-challenge(ng-show="challenge.status === 'ACTIVE'") diff --git a/app/directives/external-account/external-account.directive.js b/app/directives/external-account/external-account.directive.js index 832a100d3..162f232a9 100644 --- a/app/directives/external-account/external-account.directive.js +++ b/app/directives/external-account/external-account.directive.js @@ -5,11 +5,10 @@ { provider: "linkedin", className: "fa-linkedin", displayName: "LinkedIn", disabled: true, order: 5, colorClass: 'el-linkedin', featured: true}, { provider: "stackoverflow", className: "fa-stack-overflow", displayName: "Stack Overflow", disabled: false, order: 3, colorClass: 'el-stackoverflow'}, { provider: "behance", className: "fa-behance", displayName: "Behance", disabled: true, order: 2, colorClass: 'el-behance'}, - // { provider: "google-oauth2", className: "fa-google-plus", displayName: "Google+", disabled: true, order: }, colorClass: 'el-dribble', { provider: "github", className: "fa-github", displayName: "Github", disabled: false, order: 1, colorClass: 'el-github', featured: true}, { provider: "bitbucket", className: "fa-bitbucket", displayName: "Bitbucket", disabled: false, order: 7, colorClass: 'el-bitbucket'}, { provider: "twitter", className: "fa-twitter", displayName: "Twitter", disabled: true, order: 4, colorClass: 'el-twitter'}, - { provider: "weblinks", className: "fa-globe", displayName: "Web Links", disabled: true, order: 8, colorClass: 'el-weblinks'} + { provider: "weblink", className: "fa-globe", displayName: "Web Links", disabled: true, order: -1, colorClass: 'el-weblinks'} // TODO add more ]; @@ -20,28 +19,36 @@ templateUrl: 'directives/external-account/external-account.directive.html', scope: { linkedAccounts: '=', - linksData: '=', readOnly: '=' }, controller: ['$log', '$scope', 'ExternalAccountService', 'toaster', function($log, $scope, ExternalAccountService, toaster) { + $log = $log.getInstance("ExtAccountDirectiveCtrl") - $scope.accountList = _.clone(_supportedAccounts, true); + + var _accountList = _.clone(_supportedAccounts, true); + _.remove(_accountList, function(al) { return al.order < 0}); $scope.$watchCollection('linkedAccounts', function(newValue, oldValue) { - for (var i=0;i<$scope.accountList.length;i++) { - var _idx = _.findIndex(newValue, function(a) { - return $scope.accountList[i].provider === a.providerType; - }); - if (_idx == -1) { - $scope.accountList[i].status = 'unlinked'; - } else { - // check if data - if ($scope.linksData[$scope.accountList[i].provider]) { - $scope.accountList[i].status = 'linked'; + if (newValue) { + angular.forEach(_accountList, function(account) { + var _linkedAccount = _.find(newValue, function(p) { + return p.provider === account.provider + }); + var accountStatus = _.get(_linkedAccount, 'data.status', null); + if (!_linkedAccount) { + account.status = 'unlinked'; + } else if(accountStatus && accountStatus.toLowerCase() === 'pending') { + account.status = 'pending'; } else { - $scope.accountList[i].status = 'pending'; + account.status = 'linked'; } - } + }); + $scope.accountList = _accountList; + } else { + // reset the status for all accounts + angular.forEach(_accountList, function(account) { + delete account.status; + }); } }); @@ -62,7 +69,7 @@ ExternalAccountService.linkExternalAccount(provider.provider, null) .then(function(resp) { $log.debug("Social account linked: " + JSON.stringify(resp)); - $scope.linkedAccounts.push(resp.profile); + $scope.linkedAccounts.push(resp.linkedAccount); toaster.pop('success', "Success", String.supplant( "Your {provider} account has been linked. Data from your linked account will be visible on your profile shortly.", @@ -92,11 +99,12 @@ .then(function(resp) { $log.debug("Social account unlinked: " + JSON.stringify(resp)); var toRemove = _.findIndex($scope.linkedAccounts, function(la) { - return la.providerType === provider.provider; + return la.provider === provider.provider; }); - // remove from both links array and links data array - $scope.linkedAccounts.splice(toRemove, 1); - delete $scope.linksData[provider.provider]; + if (toRemove > -1) { + // remove from the linkedAccounts array + $scope.linkedAccounts.splice(toRemove, 1); + } toaster.pop('success', "Success", String.supplant( "Your {provider} account has been unlinked.", @@ -120,60 +128,6 @@ ] }; }) - .directive('externalLinksData', function() { - return { - restrict: 'E', - templateUrl: 'directives/external-account/external-link-data.directive.html', - scope: { - linkedAccountsData: '=', - externalLinks: '=' - }, - controller: ['$log', '$scope', 'ExternalAccountService', - function($log, $scope, ExternalAccountService) { - $log = $log.getInstance('ExternalLinksDataDirective'); - var validProviders = _.pluck(_supportedAccounts, 'provider'); - function reCalcData(links, data) { - $scope.linkedAccounts = []; - angular.forEach(links, function(link) { - - var provider = link.providerType; - var isValidProviderIdx = _.findIndex(validProviders, function(p) { - return p === provider; - }); - // skip if we dont care about this provider - if (isValidProviderIdx == -1) - return; - - if (!data[provider]) { - $scope.linkedAccounts.push({ - provider: provider, - data: { - handle: link.name, - status: 'PENDING' - } - }); - } else { - // add data - $scope.linkedAccounts.push({ - provider: provider, - data: data[provider] - }); - } - }); - } - - - $scope.$watch('linkedAccountsData', function(newValue, oldValue) { - reCalcData($scope.externalLinks, newValue); - }); - - $scope.$watchCollection('externalLinks', function(newValue, oldValue) { - reCalcData(newValue, $scope.linkedAccountsData); - }); - } - ] - } - }) .filter('providerData', function() { return function(input, field) { return _.result(_.find(_supportedAccounts, function(s) { diff --git a/app/directives/external-account/external-account.directive.spec.js b/app/directives/external-account/external-account.directive.spec.js index 0698616a5..4d512f131 100644 --- a/app/directives/external-account/external-account.directive.spec.js +++ b/app/directives/external-account/external-account.directive.spec.js @@ -2,138 +2,266 @@ describe('External Accounts Directive', function() { var scope; var element; + var toasterSvc; + var extAccountSvc; + var mockLinkedAccounts = [ + { + provider: 'linkedin', + data: { + // don't care about other details + } + }, + { + provider: 'github', + data: { + // don't care about other details + } + } + ]; beforeEach(function() { bard.appModule('topcoder'); - bard.inject(this, '$compile', '$rootScope'); + bard.inject(this, '$compile', '$rootScope', 'toaster', 'ExternalAccountService', '$q'); + + extAccountSvc = ExternalAccountService; + // mock external account service + sinon.stub(extAccountSvc, 'linkExternalAccount', function(provider) { + var $deferred = $q.defer(); + if (provider === 'twitter') { + $deferred.reject({ + status: 'SOCIAL_PROFILE_ALREADY_EXISTS', + msg: 'profile already exists' + }); + } else if(provider === 'weblink') { + $deferred.reject({ + status: 'FATAL_ERROR', + msg: 'fatal error' + }); + } else { + $deferred.resolve({ + status: 'SUCCESS', + linkedAccount : { + data: { + status: 'PENDING' + }, + provider: provider + } + }); + } + return $deferred.promise; + }); + sinon.stub(extAccountSvc, 'unlinkExternalAccount', function(provider) { + var $deferred = $q.defer(); + if (provider === 'twitter') { + $deferred.reject({ + status: 'SOCIAL_PROFILE_NOT_EXIST', + msg: 'profile not exists' + }); + } else if(provider === 'weblink') { + $deferred.reject({ + status: 'FATAL_ERROR', + msg: 'fatal error' + }); + } else { + $deferred.resolve({ + status: 'SUCCESS' + }); + } + return $deferred.promise; + }); + + toasterSvc = toaster; + bard.mockService(toaster, { + pop: $q.when(true), + default: $q.when(true) + }); + scope = $rootScope.$new(); }); bard.verifyNoOutstandingHttpRequests(); describe('Linked external accounts', function() { - var linkedAccounts = [ - { - providerType: 'linkedin', - // don't care about other details - }, - { - providerType: 'github' - } - ]; - var linksData = { - 'linkedin' : {provider: 'linkedin', name: 'name-linkedin'}, - 'github' : {provider: 'github', name: 'name-github'} - }; - var externalAccounts; + var linkedAccounts = angular.copy(mockLinkedAccounts); + var externalAccounts, controller; beforeEach(function() { scope.linkedAccounts = linkedAccounts; - scope.linksData = linksData; - element = angular.element(')'); + element = angular.element(')'); externalAccounts = $compile(element)(scope); scope.$digest(); - // scope.$apply(); + + controller = element.controller('externalAccounts'); + }); + + afterEach(function() { + linkedAccounts = angular.copy(mockLinkedAccounts); + scope.linkedAccounts = linkedAccounts; }); it('should have added account list to scope', function() { expect(element.isolateScope().accountList).to.exist; - }); it('should have "linked" property set for github & linkedin', function() { - var githubAccount = _.find(element.isolateScope().accountList, function(a) { return a.provider === 'github'}); - expect(githubAccount).to.have.property('status') - .that.equals('linked'); + var githubAccount = _.find(element.isolateScope().accountList, function(a) { + return a.provider === 'github' + }); + expect(githubAccount).to.have.property('status').that.equals('linked'); + }); - // var linkeindAccount = _.find(element.isolateScope().accountList, function(a) { return a.provider === 'linkedin'}); - // expect(linkeindAccount).to.have.property('linked') - // .that.equals(true); + it('should have pending status for stackoverflow ', function() { + scope.linkedAccounts.push({provider: 'stackoverflow', data: {status: 'PENDING'}}); + scope.$digest(); + var soAccount = _.find(element.isolateScope().accountList, function(a) { + return a.provider === 'stackoverflow' + }); + expect(soAccount).to.have.property('status').that.equals('pending'); }); - }); -}); -describe('External Links Data Directive', function() { - var scope; - var element; + it('should reset accountList when linkedAccounts set to null ', function() { + scope.linkedAccounts = null; + scope.$digest(); + expect(element.isolateScope().accountList).to.have.length(7); + expect(_.all(_.pluck(element.isolateScope().accountList, 'status'))).to.be.false; + }); - beforeEach(function() { - bard.appModule('topcoder'); - bard.inject(this, '$compile', '$rootScope'); - scope = $rootScope.$new(); - }); + it('should link external account ', function() { + element.isolateScope().handleClick('stackoverflow', 'unlinked'); + scope.$digest(); + expect(toasterSvc.pop).to.have.been.calledWith('success').calledOnce; + expect(element.isolateScope().linkedAccounts).to.have.length(3); + expect(element.isolateScope().accountList).to.have.length(7); + element.isolateScope().accountList.forEach(function(account) { + expect(account.status).to.exist; + expect(account.provider).to.exist; + if (['github', 'linkedin'].indexOf(account.provider) != -1) { + expect(account.status).to.equal('linked'); + } else if (['stackoverflow'].indexOf(account.provider) != -1) { + expect(account.status).to.equal('pending'); + } else { + expect(account.status).to.equal('unlinked'); + } + }); + }); - bard.verifyNoOutstandingHttpRequests(); + it('should NOT link external account with fatal error ', function() { + element.isolateScope().handleClick('weblink', 'unlinked'); + scope.$digest(); + expect(toasterSvc.pop).to.have.been.calledWith('error').calledOnce; + expect(element.isolateScope().linkedAccounts).to.have.length(2); + expect(element.isolateScope().accountList).to.have.length(7); + element.isolateScope().accountList.forEach(function(account) { + expect(account.status).to.exist; + expect(account.provider).to.exist; + if (['github', 'linkedin'].indexOf(account.provider) != -1) { + expect(account.status).to.equal('linked'); + } else { + expect(account.status).to.equal('unlinked'); + } + }); + }); - describe('Linked external accounts', function() { - var externalLinks = [ - { - providerType: 'linkedin', - // don't care about other details - }, - { - providerType: 'github' - }, - { - providerType: 'behance' - }, - { - providerType: 'dribbble' - }, - { - providerType: 'bitbucket' - } - ]; - var linkedAccounts = { - github: { - handle: "github-handle", - followers: 1, - publicRepos: 1 - }, - stackoverflow: { - handle: 'so-handle', - reputation: 2, - answers: 2 - }, - behance: { - name: 'behance name', - projectViews: 3, - projectAppreciations: 3 - }, - dribbble: { - handle: 'dribble-handle', - followers: 4, - likes: 4 - }, - bitbucket: { - username: 'bitbucket-username', - followers: 5, - repositories: 5 - }, - twitter: { - handle: 'twitter-handle', - noOfTweets: 6, - followers: 6 - }, - linkedin: { - name: 'linkedin name', - title: 'linkedin title' - } - }; - var externalLinksData; + it('should NOT link external account with already existing account ', function() { + element.isolateScope().handleClick('twitter', 'unlinked'); + scope.$digest(); + expect(toasterSvc.pop).to.have.been.calledWith('error').calledOnce; + expect(element.isolateScope().linkedAccounts).to.have.length(2); + expect(element.isolateScope().accountList).to.have.length(7); + element.isolateScope().accountList.forEach(function(account) { + expect(account.status).to.exist; + expect(account.provider).to.exist; + if (['github', 'linkedin'].indexOf(account.provider) != -1) { + expect(account.status).to.equal('linked'); + } else { + expect(account.status).to.equal('unlinked'); + } + }); + }); - beforeEach(function() { - scope.linkedAccounts = linkedAccounts; - scope.externalLinks = externalLinks; - element = angular.element(')'); - externalLinksData = $compile(element)(scope); + it('should unlink external account ', function() { + element.isolateScope().handleClick('github', 'linked'); scope.$digest(); + expect(toasterSvc.pop).to.have.been.calledWith('success').calledOnce; + expect(element.isolateScope().linkedAccounts).to.have.length(1); + expect(element.isolateScope().accountList).to.have.length(7); + element.isolateScope().accountList.forEach(function(account) { + expect(account.status).to.exist; + expect(account.provider).to.exist; + if (['linkedin'].indexOf(account.provider) != -1) { + expect(account.status).to.equal('linked'); + } else { + expect(account.status).to.equal('unlinked'); + } + }); }); - it('should have added linkedAccounts to scope', function() { - expect(element.isolateScope().linkedAccounts).to.exist; - // linkedAccounts should have 5 entries because externalLinks contains only 5 records - expect(element.isolateScope().linkedAccounts).to.have.length(5); + it('should unlink if controller doesn\'t have account linked but API returns success ', function() { + element.isolateScope().handleClick('stackoverflow', 'linked'); + scope.$digest(); + expect(toasterSvc.pop).to.have.been.calledWith('success').calledOnce; + expect(element.isolateScope().linkedAccounts).to.have.length(2); + expect(element.isolateScope().accountList).to.have.length(7); + element.isolateScope().accountList.forEach(function(account) { + expect(account.status).to.exist; + expect(account.provider).to.exist; + if (['github', 'linkedin'].indexOf(account.provider) != -1) { + expect(account.status).to.equal('linked'); + } else { + expect(account.status).to.equal('unlinked'); + } + }); + }); + + it('should NOT ulink external account with fatal error ', function() { + element.isolateScope().handleClick('weblink', 'linked'); + scope.$digest(); + expect(toasterSvc.pop).to.have.been.calledWith('error').calledOnce; + expect(element.isolateScope().linkedAccounts).to.have.length(2); + expect(element.isolateScope().accountList).to.have.length(7); + element.isolateScope().accountList.forEach(function(account) { + expect(account.status).to.exist; + expect(account.provider).to.exist; + if (['github', 'linkedin'].indexOf(account.provider) != -1) { + expect(account.status).to.equal('linked'); + } else { + expect(account.status).to.equal('unlinked'); + } + }); + }); + + it('should NOT unlink external account with already unlinked account ', function() { + element.isolateScope().handleClick('twitter', 'linked'); + scope.$digest(); + expect(toasterSvc.pop).to.have.been.calledWith('error').calledOnce; + expect(element.isolateScope().linkedAccounts).to.have.length(2); + expect(element.isolateScope().accountList).to.have.length(7); + element.isolateScope().accountList.forEach(function(account) { + expect(account.status).to.exist; + expect(account.provider).to.exist; + if (['github', 'linkedin'].indexOf(account.provider) != -1) { + expect(account.status).to.equal('linked'); + } else { + expect(account.status).to.equal('unlinked'); + } + }); + }); + + it('should not do anything ', function() { + element.isolateScope().handleClick('github', 'pending'); + scope.$digest(); + expect(toasterSvc.pop).to.have.callCount(0); + expect(element.isolateScope().linkedAccounts).to.have.length(2); + expect(element.isolateScope().accountList).to.have.length(7); + element.isolateScope().accountList.forEach(function(account) { + expect(account.status).to.exist; + expect(account.provider).to.exist; + if (['github', 'linkedin'].indexOf(account.provider) != -1) { + expect(account.status).to.equal('linked'); + } else { + expect(account.status).to.equal('unlinked'); + } + }); }); }); diff --git a/app/directives/external-account/external-link-data.directive.jade b/app/directives/external-account/external-link-data.directive.jade index 789a7a1f9..77a486ffe 100644 --- a/app/directives/external-account/external-link-data.directive.jade +++ b/app/directives/external-account/external-link-data.directive.jade @@ -1,5 +1,5 @@ .external-link-list - div.external-link-tile(ng-repeat="account in linkedAccounts") + div.external-link-tile(ng-repeat="account in linkedAccountsData") .top div.logo i.fa(ng-class="(account|providerData:'className') || 'fa-globe'") @@ -97,3 +97,9 @@ .handle {{account.data.name}} .title {{account.data.title}} + + div(ng-switch-when="weblink") + p.link-title(data-ellipsis, ng-bind="account.title", ng-hide="account.status === 'PENDING'") + p.link-title(ng-show="account.status === 'PENDING'") Loading data. This will take a few minutes. + + a.link-url(ng-href="{{account.URL}}", ng-bind="account.URL") diff --git a/app/directives/external-account/external-links-data.directive.js b/app/directives/external-account/external-links-data.directive.js new file mode 100644 index 000000000..fdb77f690 --- /dev/null +++ b/app/directives/external-account/external-links-data.directive.js @@ -0,0 +1,22 @@ +(function() { + 'use strict'; + + /** + * @desc links data card directive + * @example + */ + angular + .module('tcUIComponents') + .directive('externalLinksData', externalLinksData); + + function externalLinksData() { + var directive = { + restrict: 'E', + templateUrl: 'directives/external-account/external-link-data.directive.html', + scope: { + linkedAccountsData: '=' + } + }; + return directive; + } +})(); diff --git a/app/directives/external-account/external-links-data.directive.spec.js b/app/directives/external-account/external-links-data.directive.spec.js new file mode 100644 index 000000000..81f085ef9 --- /dev/null +++ b/app/directives/external-account/external-links-data.directive.spec.js @@ -0,0 +1,85 @@ +/* jshint -W117, -W030 */ +describe('External Links Data Directive', function() { + var scope; + var element; + + beforeEach(function() { + bard.appModule('topcoder'); + bard.inject(this, '$compile', '$rootScope'); + scope = $rootScope.$new(); + }); + + bard.verifyNoOutstandingHttpRequests(); + + describe('Linked external accounts', function() { + var linkedAccounts = [ + { + provider: 'github', + data: { + handle: "github-handle", + followers: 1, + publicRepos: 1 + } + }, + { provider: 'stackoverflow', + data: { + handle: 'so-handle', + reputation: 2, + answers: 2 + } + }, + { + provider: 'behance', + data: { + name: 'behance name', + projectViews: 3, + projectAppreciations: 3 + } + }, + { + provider: 'dribbble', + data: { + handle: 'dribbble-handle', + followers: 4, + likes: 4 + } + }, + { + provider: 'bitbucket', + data: { + username: 'bitbucket-username', + followers: 5, + repositories: 5 + } + }, + { + provider: 'twitter', + data: { + handle: 'twitter-handle', + noOfTweets: 6, + followers: 6 + } + }, + { + provider: 'linkedin', + data: { + status: 'pending' + } + } + ]; + var externalLinksData; + + beforeEach(function() { + scope.linkedAccounts = linkedAccounts; + element = angular.element(')'); + externalLinksData = $compile(element)(scope); + scope.$digest(); + }); + + it('should have added linkedAccounts to scope', function() { + expect(element.isolateScope().linkedAccountsData).to.exist; + expect(element.isolateScope().linkedAccountsData).to.have.length(7); + }); + + }); +}); diff --git a/app/directives/external-account/external-web-link.directive.jade b/app/directives/external-account/external-web-link.directive.jade new file mode 100644 index 000000000..0f99e4cec --- /dev/null +++ b/app/directives/external-account/external-web-link.directive.jade @@ -0,0 +1,16 @@ +.web-link + form(name="addWebLinkFrm", ng-submit="addWebLinkFrm.$valid && addWebLink()", autocomplete="off") + .validation-bar.url(ng-class="{ 'error-bar': (addWebLinkFrm.url.$dirty && addWebLinkFrm.url.$invalid) }") + input.form-field.url(name="url", type="url", ng-model="url", placeholder="http://www.yourlink.com", required) + + .form-input-error(ng-show="addWebLinkFrm.url.$dirty && addWebLinkFrm.url.$invalid") + p(ng-show="addWebLinkFrm.url.$error.required") This is a required field. + + p(ng-show="addWebLinkFrm.url.$error.url") Please enter a valid URL + + button.tc-btn.tc-btn-m(type="submit", + tc-busy-button, tc-busy-when="addingWebLink", tc-busy-message="Adding", + ng-disabled="addWebLinkFrm.$invalid || addWebLinkFrm.$pristine") Add + + .form-errors(ng-show="errorMessage") + p.form-error {{errorMessage}} diff --git a/app/directives/external-account/external-web-links.directive.js b/app/directives/external-account/external-web-links.directive.js new file mode 100644 index 000000000..411d1f22f --- /dev/null +++ b/app/directives/external-account/external-web-links.directive.js @@ -0,0 +1,61 @@ +(function() { + 'use strict'; + + /** + * @desc links data card directive + * @example + */ + angular + .module('tcUIComponents') + .directive('externalWebLink', ExternalWebLink); + ExternalWebLink.$inject = ['$log', 'ExternalWebLinksService', 'toaster']; + + function ExternalWebLink($log, ExternalWebLinksService, toaster) { + var directive = { + restrict: 'E', + templateUrl: 'directives/external-account/external-web-link.directive.html', + scope: { + linkedAccounts: '=', + userHandle: '@' + }, + controller: ['$scope', '$log', ExternalWebLinkCtrl] + }; + + + function ExternalWebLinkCtrl($scope, $log) { + $log = $log.getInstance('ExternalWebLinkCtrl'); + $scope.addingWebLink = false; + $scope.errorMessage = null; + + $scope.addWebLink = function() { + $log.debug("URL: " + $scope.url); + $scope.addingWebLink = true; + $scope.errorMessage = null; + ExternalWebLinksService.addLink($scope.userHandle, $scope.url) + .then(function(data) { + $scope.addingWebLink = false; + $log.debug("Web link added: " + JSON.stringify(data)); + data.data.provider = data.provider; + $scope.linkedAccounts.push(data.data); + toaster.pop('success', "Success", "Your link has been added. Data from your link will be visible on your profile shortly."); + }) + .catch(function(resp) { + $scope.addingWebLink = false; + + if (resp.status === 'WEBLINK_ALREADY_EXISTS') { + $log.info("Social profile already linked to another account"); + toaster.pop('error', "Whoops!", + "This weblink is already added to your account. \ + If you think this is an error please contact support@topcoder.com." + ); + } else { + $log.error("Fatal Error: addWebLink: " + resp.msg); + toaster.pop('error', "Sorry, we are unable add web link. If problem persist please contact support@topcoder.com."); + } + }); + }; + } + + return directive; + } +})(); diff --git a/app/directives/external-account/external-web-links.directive.spec.js b/app/directives/external-account/external-web-links.directive.spec.js new file mode 100644 index 000000000..6e8997e8b --- /dev/null +++ b/app/directives/external-account/external-web-links.directive.spec.js @@ -0,0 +1,115 @@ +/* jshint -W117, -W030 */ +describe('ExternalWebLinks Directive', function() { + var scope = {}; + var element; + var extWebLinkSvc; + var toasterSvc; + var mockLinkedAccounts = [ + { + provider: 'github', + data: { + handle: "github-handle", + followers: 1, + publicRepos: 1 + } + } + ]; + + beforeEach(function() { + bard.appModule('topcoder'); + bard.inject(this, '$compile', '$rootScope', 'ExternalWebLinksService', '$q', 'toaster'); + + extWebLinkSvc = ExternalWebLinksService; + + // mock external weblink service + sinon.stub(extWebLinkSvc, 'addLink', function(handle, url) { + var $deferred = $q.defer(); + if (handle === 'throwError') { + $deferred.reject({ + status: 'FATAL_ERROR', + msg: 'fatal error' + }); + } else if(handle === 'alreadyExistsError') { + $deferred.reject({ + status: 'WEBLINK_ALREADY_EXISTS', + msg: 'link already added to your account' + }); + } else { + $deferred.resolve({ + data: { + status: 'PENDING' + }, + provider: 'weblink' + }); + } + return $deferred.promise; + }); + + toasterSvc = toaster; + bard.mockService(toaster, { + pop: $q.when(true), + default: $q.when(true) + }); + + scope = $rootScope.$new(); + }); + + bard.verifyNoOutstandingHttpRequests(); + + describe('Linked external accounts', function() { + var linkedAccounts = angular.copy(mockLinkedAccounts); + var template, element, controller; + + beforeEach(function() { + scope.linkedAccounts = linkedAccounts; + element = angular.element(')'); + template = $compile(element)(scope); + scope.$digest(); + + controller = element.controller('externalWebLink'); + }); + + afterEach(function() { + linkedAccounts = angular.copy(mockLinkedAccounts); + scope.linkedAccounts = linkedAccounts; + }); + + it('should have added linkedAccounts to scope', function() { + expect(scope.linkedAccounts).to.exist; + expect(scope.linkedAccounts).to.have.length(1); + }); + + it('should have added new weblink to linkedAccounts', function() { + scope.userHandle = 'test'; + scope.url = 'https://www.topcoder.com'; + element.isolateScope().addWebLink(); + scope.$digest(); + expect(scope.linkedAccounts).to.have.length(2); + var topcoderLink = _.find(scope.linkedAccounts, function(a) { + return a.provider === 'weblink' + }); + expect(topcoderLink).to.exist; + expect(topcoderLink.status).to.exist.to.equal('PENDING'); + expect(toasterSvc.pop).to.have.been.calledWith('success').calledOnce; + }); + + it('should NOT add new weblink to linkedAccounts', function() { + element.isolateScope().userHandle = 'throwError'; + element.isolateScope().url = 'https://www.topcoder.com'; + element.isolateScope().addWebLink(); + scope.$digest(); + expect(scope.linkedAccounts).to.have.length(1); + expect(toasterSvc.pop).to.have.been.calledWith('error').calledOnce; + }); + + it('should NOT add new weblink to linkedAccounts', function() { + element.isolateScope().userHandle = 'alreadyExistsError'; + element.isolateScope().url = 'https://www.topcoder.com'; + element.isolateScope().addWebLink(); + scope.$digest(); + expect(scope.linkedAccounts).to.have.length(1); + expect(toasterSvc.pop).to.have.been.calledWith('error').calledOnce; + }); + + }); +}); diff --git a/app/filters/external-link-color.filter.js b/app/filters/external-link-color.filter.js index 9a81f4033..5dc7c9578 100644 --- a/app/filters/external-link-color.filter.js +++ b/app/filters/external-link-color.filter.js @@ -10,7 +10,7 @@ var providerColors = { 'el-weblinks': '#82A0AA', 'el-bitbucket': '#205081', - 'el-dribble': '#EA4C89', + 'el-dribbble': '#EA4C89', 'el-linkedin': '#127CB5', 'el-twitter': '#62AADC', 'el-stackoverflow': '#E5712A', diff --git a/app/index.jade b/app/index.jade index 30d2b4c69..65b8274b9 100644 --- a/app/index.jade +++ b/app/index.jade @@ -73,6 +73,7 @@ html link(rel="stylesheet", href="assets/css/directives/page-state-header.directive.css") link(rel="stylesheet", href="assets/css/directives/ios-card.css") link(rel="stylesheet", href="assets/css/directives/history-graph.css") + link(rel="stylesheet", href="assets/css/directives/external-web-link.css") link(rel="stylesheet", href="assets/css/directives/external-link-data.css") link(rel="stylesheet", href="assets/css/directives/external-account.css") link(rel="stylesheet", href="assets/css/directives/empty-state-placeholder.css") @@ -128,6 +129,7 @@ html script(src='../bower_components/angucomplete-alt/angucomplete-alt.js') script(src='../bower_components/angular-cookies/angular-cookies.js') script(src='../bower_components/angular-dropdowns/dist/angular-dropdowns.js') + script(src='../bower_components/angular-ellipsis/src/angular-ellipsis.js') script(src='../bower_components/angular-filter/dist/angular-filter.min.js') script(src='../bower_components/angular-img-fallback/angular.dcb-img-fallback.js') script(src='../bower_components/intro.js/intro.js') @@ -192,6 +194,8 @@ html script(src="directives/distribution-graph/distribution-graph.directive.js") script(src="directives/empty-state-placeholder/empty-state-placeholder.directive.js") script(src="directives/external-account/external-account.directive.js") + script(src="directives/external-account/external-links-data.directive.js") + script(src="directives/external-account/external-web-links.directive.js") script(src="directives/focus-on.directive.js") script(src="directives/header/header-menu-item.directive.js") script(src="directives/history-graph/history-graph.directive.js") @@ -270,6 +274,7 @@ html script(src="services/communityData.service.js") script(src="services/emptyState.service.js") script(src="services/externalAccounts.service.js") + script(src="services/externalLinks.service.js") script(src="services/helpers.service.js") script(src="services/image.service.js") script(src="services/introduction.service.js") diff --git a/app/profile/about/about.controller.js b/app/profile/about/about.controller.js index 564986821..cfd21c43d 100644 --- a/app/profile/about/about.controller.js +++ b/app/profile/about/about.controller.js @@ -3,9 +3,9 @@ angular.module('tc.profile').controller('ProfileAboutController', ProfileAboutController); - ProfileAboutController.$inject = ['$log', '$scope', 'ProfileService', 'ExternalAccountService', 'UserService', 'CONSTANTS']; + ProfileAboutController.$inject = ['$log', '$scope', '$q', 'ProfileService', 'ExternalAccountService', 'ExternalWebLinksService', 'UserService', 'CONSTANTS']; - function ProfileAboutController($log, $scope, ProfileService, ExternalAccountService, UserService, CONSTANTS) { + function ProfileAboutController($log, $scope, $q, ProfileService, ExternalAccountService, ExternalWebLinksService, UserService, CONSTANTS) { var vm = this; $log = $log.getInstance("ProfileAboutController"); var profileVm = $scope.$parent.profileVm; @@ -22,38 +22,18 @@ function activate() { - ExternalAccountService.getLinkedExternalLinksData(profileVm.userHandle).then(function(data) { - vm.linkedExternalAccountsData = data.plain(); - - // show section if user is viewing his/her own profile OR if we have data - //vm.hasLinks = profileVm.linkedExternalAccounts.length; - vm.hasLinks = _.any(_.valuesIn(_.omit(vm.linkedExternalAccountsData, ['userId', 'updatedAt','createdAt','createdBy','updatedBy','handle']))); - vm.displaySection.externalLinks = profileVm.showEditProfileLink || vm.hasLinks; - - // if user is authenticated, call for profiles end point - if (profileVm.isUser) { - var userId = UserService.getUserIdentity().userId; - ExternalAccountService.getLinkedExternalAccounts(userId).then(function(data) { - vm.linkedExternalAccounts = data; - profileVm.status.externalLinks = CONSTANTS.STATE_READY; - }).catch(function(err) { - profileVm.status.externalLinks = CONSTANTS.STATE_ERROR; - }); - } else { - vm.linkedExternalAccounts = []; - // remove all keys except the provider keys - var accounts = _.omit(vm.linkedExternalAccountsData, ['userId', 'updatedAt','createdAt','createdBy','updatedBy','handle']); - // populate the externalLinks for external-account-data directive with info from ext accounts data - for(var provider in accounts) { - if (accounts[provider]) { - vm.linkedExternalAccounts.push({ - providerType: provider - }); - } - } - profileVm.status.externalLinks = CONSTANTS.STATE_READY; - } - }).catch(function(err) { + var _userId = profileVm.isUser ? UserService.getUserIdentity().userId : false; + // retrieve web links & external accounts + var _linksPromises = [ + ExternalAccountService.getAllExternalLinks(profileVm.userHandle, _userId, !!_userId), + ExternalWebLinksService.getLinks(profileVm.userHandle, !!_userId) + ]; + $q.all(_linksPromises).then(function(data) { + vm.linkedExternalAccounts = data[0].concat(data[1]); + vm.displaySection.externalLinks = profileVm.showEditProfileLink || !!vm.linkedExternalAccounts.length; + + profileVm.status.externalLinks = CONSTANTS.STATE_READY; + }).catch(function(resp) { profileVm.status.externalLinks = CONSTANTS.STATE_ERROR; }); diff --git a/app/profile/about/about.jade b/app/profile/about/about.jade index 712d49d6a..2c7826e1e 100644 --- a/app/profile/about/about.jade +++ b/app/profile/about/about.jade @@ -12,7 +12,7 @@ .empty-profile .empty-state - empty-state-placeholder(state-name="profile-empty", show="profileVm.status.skills === 'ready' && profileVm.status.stats === 'ready' && profileVm.status.externalLinks === 'ready' && !profileVm.showEditProfileLink && !profileVm.showTCActivity && (!profileVm.skills || (profileVm.skills && profileVm.skills.length == 0)) && !vm.hasLinks") + empty-state-placeholder(state-name="profile-empty", show="profileVm.status.skills === 'ready' && profileVm.status.stats === 'ready' && profileVm.status.externalLinks === 'ready' && !profileVm.showEditProfileLink && !profileVm.showTCActivity && (!profileVm.skills || (profileVm.skills && profileVm.skills.length == 0)) && !vm.linkedExternalAccounts.length") .sample-image img(ng-src="/images/robot.svg") @@ -93,10 +93,14 @@ #externalLinks tc-section(ng-show="vm.displaySection.externalLinks", state="profileVm.status.externalLinks") .external-links - h3.activity(ng-if="vm.hasLinks") on the web - external-links-data(ng-show="vm.hasLinks", external-links="vm.linkedExternalAccounts", linked-accounts-data="vm.linkedExternalAccountsData") + + h3.activity(ng-if="vm.linkedExternalAccounts.length") on the web + + external-links-data(ng-show="vm.linkedExternalAccounts.length", linked-accounts-data="vm.linkedExternalAccounts") .empty-state - empty-state-placeholder(state-name="profile-external-links", show="!vm.hasLinks") + empty-state-placeholder(state-name="profile-external-links", show="!vm.linkedExternalAccounts.length") external-accounts.external-account-container(linked-accounts="[]", links-data="{}", read-only="true") + + diff --git a/app/services/externalAccounts.service.js b/app/services/externalAccounts.service.js index e99f71a76..b6e47b6c2 100644 --- a/app/services/externalAccounts.service.js +++ b/app/services/externalAccounts.service.js @@ -9,12 +9,13 @@ var auth0 = auth; $log = $log.getInstance('ExternalAccountService'); - var api = ApiService.restangularV3; + var memberApi = ApiService.getApiServiceProvider('MEMBER'); var userApi = ApiService.getApiServiceProvider('USER'); var service = { - getLinkedExternalAccounts: getLinkedExternalAccounts, - getLinkedExternalLinksData: getLinkedExternalLinksData, + getAllExternalLinks: getAllExternalLinks, + getLinkedAccounts: getLinkedAccounts, + getAccountsData: getAccountsData, linkExternalAccount: linkExternalAccount, unlinkExternalAccount: unlinkExternalAccount }; @@ -28,22 +29,23 @@ * @param userId * @return list of linked Accounts */ - function getLinkedExternalAccounts(userId) { + function getLinkedAccounts(userId) { return userApi.one('users', userId).get({fields:"profiles"}) .then(function(result) { - return result.profiles || []; + angular.forEach(result.profiles, function(p) { + p.provider = p.providerType; + }); + return result.profiles; }); } - function getLinkedExternalLinksData(userHandle) { - return api.one('members', userHandle).withHttpConfig({skipAuthorization: true}).customGET('externalAccounts') - .then(function(data) { - // TODO workaround for dribbble spelling mistake, remove once API is fixed - if (data.dribble) { - data.dribbble = data.dribble; - } - return data; - }) + function getAccountsData(userHandle) { + return memberApi.one('members', userHandle) + .withHttpConfig({skipAuthorization: true}) + .customGET('externalAccounts') + .then(function(data) { + return data; + }); } function unlinkExternalAccount(account) { @@ -71,6 +73,53 @@ }); } + + function _convertAccountsIntoCards(links, data, includePending) { + var _cards = []; + if (!links.length) { + var providers = _.omit(data, ['userId', 'updatedAt', 'createdAt', 'createdBy', 'updatedBy', 'handle']); + // populate the externalLinks for external-account-data directive with info from ext accounts data + + angular.forEach(_.keys(providers), function(p) { + if (providers[p]) + links.push({provider: p}); + }); + } + // handling external accounts first + angular.forEach(links, function(link) { + var provider = link.provider; + if (data[provider]) { + // add data + _cards.push({provider: provider, data: data[provider]}); + } else if (includePending) { + // add pending card + _cards.push({provider: provider, data: {handle: link.name, status: 'PENDING'}}); + } + }); + $log.debug("Processed Accounts Cards: " + JSON.stringify(_cards)); + return _cards; + } + + + function getAllExternalLinks(userHandle, userId, includePending) { + return $q(function(resolve, reject) { + var _promises = [getAccountsData(userHandle)]; + if (includePending) + _promises.push(getLinkedAccounts(userId)); + + $q.all(_promises).then(function(data) { + var links = includePending ? data[1]: []; + var _cards = _convertAccountsIntoCards(links, data[0].plain(), includePending); + // TODO add weblinks + resolve(_cards); + }).catch(function(resp) { + $log.error(resp); + reject(resp); + }) + }) + } + + function linkExternalAccount(provider, callbackUrl) { return $q(function(resolve, reject) { // supported backends @@ -81,7 +130,6 @@ connection: provider, scope: "openid profile offline_access", state: callbackUrl, - // callbackURL: CONSTANTS.auth0Callback }, function(profile, idToken, accessToken, state, refreshToken) { $log.debug("onSocialLoginSuccess"); @@ -105,10 +153,16 @@ userApi.one('users', user.userId).customPOST(postData, "profiles", {}, {}) .then(function(resp) { $log.debug("Succesfully linked account: " + JSON.stringify(resp)); - resolve({ + // construct "card" object and resolve it + var _data = { status: "SUCCESS", - profile: postData - }); + linkedAccount: { + provider: provider, + data: postData + } + }; + _data.linkedAccount.data.status = 'PENDING'; + resolve(_data); }) .catch(function(resp) { var errorStatus = "FATAL_ERROR"; diff --git a/app/services/externalAccounts.service.spec.js b/app/services/externalAccounts.service.spec.js new file mode 100644 index 000000000..39a8af578 --- /dev/null +++ b/app/services/externalAccounts.service.spec.js @@ -0,0 +1,236 @@ +/* jshint -W117, -W030 */ +describe('ExternalAccount Service', function() { + var service; + var mockAccountsData = mockData.getMockLinkedExternalAccountsData(); + var mockUserLinksData = mockData.getMockLinkedExternalAccounts(); + var mockAuth0Profile = mockData.getMockAuth0Profile(); + var mockProfile = mockData.getMockProfile(); + var apiUrl; + var auth0, userService; + var profileGet, profilePost, profileDelete; + + + beforeEach(function() { + bard.appModule('topcoder'); + bard.inject(this, 'ExternalAccountService', '$httpBackend', '$q', 'CONSTANTS', 'JwtInterceptorService', 'auth', 'UserService'); + bard.mockService(JwtInterceptorService, { + getToken: $q.when('token'), + _default: $q.when([]) + }); + + apiUrl = CONSTANTS.API_URL; + service = ExternalAccountService; + auth0 = auth; + userService = UserService; + + // mock user api + sinon.stub(auth0, 'signin', function(params, successCallback, failureCallback) { + if (params && params.state == 'failure') { + failureCallback.call(failureCallback, "MOCK_ERROR"); + } + successCallback.call( + successCallback, + mockAuth0Profile, + "mockAuth0IdToken", + "mockAuth0AccessToken", + params.state, + null + ); + }); + + // mock user service + sinon.stub(userService, 'getUserIdentity', function() { + return {userId: 111, handle: mockProfile.handle }; + }); + + $httpBackend + .when('GET', apiUrl + '/members/test1/externalAccounts/') + .respond(200, {result: {content: mockAccountsData}}); + + profileGet = $httpBackend.when('GET', apiUrl + '/users/111/?fields=profiles'); + profileGet.respond(200, {result: {content: mockUserLinksData}}); + + profilePost = $httpBackend.when('POST', apiUrl + '/users/111/profiles/'); + profilePost.respond(200, {result: {content: mockProfile}}); + + profileDelete = $httpBackend.when('DELETE', apiUrl + '/users/111/profiles/stackoverflow/'); + profileDelete.respond(200, {result: {content: mockProfile}}); + + }); + + afterEach(function() { + $httpBackend.verifyNoOutstandingExpectation(); + $httpBackend.verifyNoOutstandingRequest(); + }); + + it('service should be defined', function() { + expect(service).to.be.defined; + }); + + it('should return linked external accounts', function() { + service.getLinkedAccounts(111).then(function(data) { + expect(data).to.have.length(5); + }); + $httpBackend.flush(); + }); + + it('should return linked external accounts data', function() { + service.getAccountsData('test1').then(function(data) { + data = data.plain(); + expect(data).to.be.defined; + expect(_.keys(data)).to.include.members(['dribbble', 'github', 'behance', 'bitbucket', 'linkedin', 'stackoverflow', 'twitter']); + }); + $httpBackend.flush(); + }); + + it('should return all non-pending external links', function() { + // spy + service.getAllExternalLinks('test1', 111, false).then(function(data) { + expect(data).to.be.defined; + expect(_.pluck(data, 'provider')).to.include.members(['dribbble', 'github','bitbucket', 'stackoverflow']); + expect(_.all(_.pluck(data, 'data'))).to.be.truthy; + }); + $httpBackend.flush(); + }); + + it('should return all external links including pending', function() { + // spy + service.getAllExternalLinks('test1', 111, true).then(function(data) { + expect(data).to.be.defined; + expect(_.pluck(data, 'provider')).to.include.members(['dribbble', 'github', 'behance', 'bitbucket','stackoverflow']); + expect(data).to.have.length(5); + var nullAccounts = _.remove(data, function(n) {return n.data.status === 'PENDING'}); + expect(nullAccounts).to.have.length(1); + }); + $httpBackend.flush(); + }); + + it('should not return unsupported links even if they are returned by the API', function() { + var profiles = JSON.parse(JSON.stringify(mockUserLinksData)); + profiles.profiles.push({providerType: 'unsupported'}); + profileGet.respond(200, {result: {content: profiles}}); + // spy + service.getAllExternalLinks('test1', 111, true).then(function(data) { + expect(data).to.be.defined; + expect(_.pluck(data, 'provider')).to.include.members(['dribbble', 'github','bitbucket', 'stackoverflow']); + expect(_.all(_.pluck(data, 'data'))).to.be.truthy; + }); + $httpBackend.flush(); + }); + + it('should fail in returning links', function() { + var errorMessage = "bad request"; + // mocks the GET call to respond with 400 bad request + profileGet.respond(400, {result: { status: 400, content: errorMessage } }); + // calls getAllExternalLinks method with valid params + service.getAllExternalLinks('test1', 111, true).then(function(data) { + sinon.assert.fail('should not be called'); + }).catch(function(resp) { + expect(resp).to.exist; + expect(resp.status).to.exist.to.equal(400); + }); + $httpBackend.flush(); + }); + + it('should link external account', function() { + // call linkExternalAccount method with supporte network, should succeed + service.linkExternalAccount('stackoverflow', "callback").then(function(data) { + expect(data).to.be.defined; + // console.log(data); + expect(data.status).to.exist.to.equal('SUCCESS'); + expect(data.linkedAccount).to.exist; + expect(data.linkedAccount.provider).to.exist.to.equal('stackoverflow'); + expect(data.linkedAccount.data).to.exist; + expect(data.linkedAccount.data.status).to.exist.to.equal('PENDING'); + }); + $httpBackend.flush(); + }); + + it('should fail with unsupported network', function() { + // call linkExternalAccount method with unsupported network, should fail + service.linkExternalAccount('unsupported', "callback").then(function(data) { + expect(data).to.be.defined; + expect(data.status).to.exist.to.equal('failed'); + expect(data.error.to.contain('unsupported')); + }); + }); + + it('should fail with already existing profile', function() { + var errorMessage = "social profile exists"; + profilePost.respond(400, {result: { status: 400, content: errorMessage } }); + // call linkExternalAccount method, having user service throw already exist + service.linkExternalAccount('stackoverflow', "callback").then(function(data) { + sinon.assert.fail('should not be called'); + }, function(error) { + expect(error).to.be.defined; + expect(error.status).to.exist.to.equal('SOCIAL_PROFILE_ALREADY_EXISTS'); + expect(error.msg).to.exist.to.equal(errorMessage); + }); + $httpBackend.flush(); + }); + + it('should fail with auth0 error', function() { + // call linkExternalAccount method with auth0 throwing error + service.linkExternalAccount('stackoverflow', "failure").then(function(data) { + sinon.assert.fail('should not be called'); + }, function(error) { + expect(error).to.be.exist.to.equal('MOCK_ERROR'); + }); + $httpBackend.flush(); + }); + + it('should fail, with fatal error, in linking external account', function() { + var errorMessage = "endpoint not found"; + profilePost.respond(404, {result: { status: 404, content: errorMessage } }); + // call unlinkExternalAccount method with supporte network, should succeed + service.linkExternalAccount('stackoverflow', "callback").then(function(data) { + sinon.assert.fail('should not be called'); + }).catch(function(error) { + expect(error).to.be.defined; + expect(error.status).to.exist.to.equal('FATAL_ERROR'); + expect(error.msg).to.exist.to.equal(errorMessage); + }); + $httpBackend.flush(); + }); + + it('should unlink external account', function() { + var errorMessage = "social profile exists"; + profilePost.respond(400, {result: { status: 400, content: errorMessage } }); + // call unlinkExternalAccount method with supporte network, should succeed + service.unlinkExternalAccount('stackoverflow').then(function(data) { + expect(data).to.be.defined; + // console.log(data); + expect(data.status).to.exist.to.equal('SUCCESS'); + }); + $httpBackend.flush(); + }); + + it('should fail, with profile does not exist, in unlinking external account', function() { + var errorMessage = "social profile does not exists"; + profileDelete.respond(404, {result: { status: 404, content: errorMessage } }); + // call unlinkExternalAccount method with supporte network, should succeed + service.unlinkExternalAccount('stackoverflow').then(function(data) { + sinon.assert.fail('should not be called'); + }).catch(function(error) { + expect(error).to.be.defined; + expect(error.status).to.exist.to.equal('SOCIAL_PROFILE_NOT_EXIST'); + expect(error.msg).to.exist.to.equal(errorMessage); + }); + $httpBackend.flush(); + }); + + it('should fail, with fatal error, in unlinking external account', function() { + var errorMessage = "bad request"; + profileDelete.respond(400, {result: { status: 400, content: errorMessage } }); + // call unlinkExternalAccount method with supporte network, should succeed + service.unlinkExternalAccount('stackoverflow').then(function(data) { + sinon.assert.fail('should not be called'); + }).catch(function(error) { + expect(error).to.be.defined; + expect(error.status).to.exist.to.equal('FATAL_ERROR'); + expect(error.msg).to.exist.to.equal(errorMessage); + }); + $httpBackend.flush(); + }); + +}); diff --git a/app/services/externalLinks.service.js b/app/services/externalLinks.service.js new file mode 100644 index 000000000..8c8437401 --- /dev/null +++ b/app/services/externalLinks.service.js @@ -0,0 +1,74 @@ +(function() { + 'use strict'; + + angular.module('tc.services').factory('ExternalWebLinksService', ExternalWebLinksService); + + ExternalWebLinksService.$inject = ['$log', 'CONSTANTS', 'ApiService', '$q']; + + function ExternalWebLinksService($log, CONSTANTS, ApiService, $q) { + $log = $log.getInstance("ExternalWebLinksService"); + + var memberApi = ApiService.getApiServiceProvider('MEMBER'); + + var service = { + getLinks: getLinks, + addLink: addLink, + removeLink: removeLink + }; + return service; + + ///////////////////////// + + function getLinks(userHandle, includePending) { + return memberApi.one('members', userHandle) + .withHttpConfig({skipAuthorization: true}) + .customGET('externalLinks') + .then(function(links) { + links = links.plain(); + if (!includePending) { + _.remove(links, function(l) { + return _.get(l, 'synchronizedAt') === 0; + }); + } + // add provider type as weblink + links = _(links).forEach(function(l) { + l.provider = 'weblink'; + if (l.synchronizedAt === 0) { + l.status = 'PENDING'; + } + }).value(); + return links; + }); + } + + function addLink(userHandle, url) { + return $q(function(resolve, reject) { + memberApi.one('members', userHandle).customPOST({'url': url}, 'externalLinks') + .then(function(resp) { + var _newLink = { + provider: 'weblink', + data: resp + }; + _newLink.data.status = 'PENDING'; + resolve(_newLink); + }) + .catch(function(resp) { + var errorStatus = "FATAL_ERROR"; + $log.error("Error adding weblink: " + resp.data.result.content); + if (resp.data.result && resp.data.result.status === 400) { + errorStatus = "WEBLINK_ALREADY_EXISTS"; + } + reject({ + status: errorStatus, + msg: resp.data.result.content + }); + }); + }); + } + + function removeLink(userHandle, key) { + return memberApi.one('members', userHandle).one('externalLinks', key).remove(); + } + + } +})(); diff --git a/app/services/externalLinks.service.spec.js b/app/services/externalLinks.service.spec.js new file mode 100644 index 000000000..0b228456a --- /dev/null +++ b/app/services/externalLinks.service.spec.js @@ -0,0 +1,90 @@ +/* jshint -W117, -W030 */ +describe('ExternalWebLinks service', function() { + var service; + var mockExternalLinks = mockData.getMockExternalWebLinksData(); + var apiUrl; + var linksGet, linksPost, linksDelete; + + + beforeEach(function() { + bard.appModule('topcoder'); + bard.inject(this, 'ExternalWebLinksService', 'JwtInterceptorService', '$httpBackend', 'CONSTANTS', '$q'); + bard.mockService(JwtInterceptorService, { + getToken: $q.when('token'), + _default: $q.when([]) + }); + + apiUrl = CONSTANTS.API_URL; + service = ExternalWebLinksService; + + // mock profile api + linksGet = $httpBackend.when('GET', apiUrl + '/members/test1/externalLinks/'); + linksGet.respond(200, {result: {content: mockExternalLinks}}); + + // mock profile api [POST] + linksPost = $httpBackend.when('POST', apiUrl + '/members/test1/externalLinks/'); + linksPost.respond(200, {result: {content: mockExternalLinks[0]}}); + + // mock profile api [DELETE] + linksDelete = $httpBackend.when('DELETE', apiUrl + '/members/test1/externalLinks/testkey/'); + linksDelete.respond(200, {result: {}}); + }); + + afterEach(function() { + $httpBackend.verifyNoOutstandingExpectation(); + $httpBackend.verifyNoOutstandingRequest(); + }); + + it('should be defined', function() { + expect(service).to.be.defined; + }); + + it('should return linked external web links including pending', function() { + service.getLinks('test1', true).then(function(data) { + expect(data).to.have.length(3); + }); + $httpBackend.flush(); + }); + + it('should return linked external non-pending web links ', function() { + service.getLinks('test1', false).then(function(data) { + expect(data).to.have.length(2); + }); + $httpBackend.flush(); + }); + + it('should add external link', function() { + // call addLink method with valid params, should succeed + service.addLink('test1', "http://google.com").then(function(newLink) { + expect(newLink).to.be.exist; + expect(newLink.provider).to.exist.to.equal('weblink'); + expect(newLink.data).to.exist; + expect(newLink.data.status).to.exist.to.equal('PENDING'); + }); + $httpBackend.flush(); + }); + + it('should fail with already existing link', function() { + var errorMessage = "web link exists"; + linksPost.respond(400, {result: { status: 400, content: errorMessage } }); + // call linkExternalAccount method, having user service throw already exist + service.addLink('test1', "http://google.com").then(function(data) { + sinon.assert.fail('should not be called'); + }, function(error) { + expect(error).to.be.defined; + expect(error.status).to.exist.to.equal('WEBLINK_ALREADY_EXISTS'); + expect(error.msg).to.exist.to.equal(errorMessage); + }); + $httpBackend.flush(); + }); + + it('should remove external link', function() { + // call removeLink method with valid params, should succeed + service.removeLink('test1', "testkey").then(function(newLink) { + }).catch(function(error) { + sinon.assert.fail('should not be called'); + }); + $httpBackend.flush(); + }); + +}); diff --git a/app/services/user.service.js b/app/services/user.service.js index de598d536..b1006636f 100644 --- a/app/services/user.service.js +++ b/app/services/user.service.js @@ -25,6 +25,7 @@ updatePassword: updatePassword, getUserProfile: getUserProfile, getV2UserProfile: getV2UserProfile, + addSocialProfile: addSocialProfile, removeSocialProfile: removeSocialProfile, getPreference: getPreference, setPreference: setPreference @@ -102,6 +103,10 @@ return api.one('users', userId).get(queryParams); } + function addSocialProfile(userId, profileData) { + return api.one('users', userId).customPOST(profileData, "profiles", {}, {}); + } + function removeSocialProfile (userId, account) { return api.one("users", userId).one("profiles", account).remove(); } diff --git a/app/settings/edit-profile/edit-profile.controller.js b/app/settings/edit-profile/edit-profile.controller.js index 7c908d47b..dc182e84b 100644 --- a/app/settings/edit-profile/edit-profile.controller.js +++ b/app/settings/edit-profile/edit-profile.controller.js @@ -3,10 +3,9 @@ angular.module('tc.settings').controller('EditProfileController', EditProfileController); + EditProfileController.$inject = ['$rootScope', 'userData', 'userHandle', 'ProfileService', 'ExternalAccountService', 'ExternalWebLinksService', '$log', 'ISO3166', 'ImageService', 'CONSTANTS', 'TagsService', 'toaster', '$q', '$scope']; - EditProfileController.$inject = ['$rootScope', 'userData', 'userHandle', 'ProfileService', 'ExternalAccountService', '$log', 'ISO3166', 'ImageService', 'CONSTANTS', 'TagsService', 'toaster', '$scope']; - - function EditProfileController($rootScope, userData, userHandle, ProfileService, ExternalAccountService, $log, ISO3166, ImageService, CONSTANTS, TagsService, toaster, $scope) { + function EditProfileController($rootScope, userData, userHandle, ProfileService, ExternalAccountService, ExternalWebLinksService, $log, ISO3166, ImageService, CONSTANTS, TagsService, toaster, $q, $scope) { $log = $log.getInstance("EditProfileCtrl"); var vm = this; vm.toggleTrack = toggleTrack; @@ -35,25 +34,19 @@ processData(vm.userData); - // commenting out since this might come back -// $scope.tracks = vm.tracks; -// $scope.$watch('tracks', function watcher() { -// if (!tracksValid()) { -// toaster.pop('error', "Error", "Please select at least one track."); -// } -// }, true); - - ExternalAccountService.getLinkedExternalAccounts(vm.userData.userId).then(function(data) { - vm.linkedExternalAccounts = data; - }); - - ExternalAccountService.getLinkedExternalLinksData(userHandle).then(function(data) { - vm.linkedExternalAccountsData = data.plain(); - vm.hasLinks = _.any(_.valuesIn(_.omit(vm.linkedExternalAccountsData, ['userId', 'updatedAt','createdAt','createdBy','updatedBy','handle']))); - }) - .catch(function(err) { - $log.error(JSON.stringify(err)); + var userId = vm.userData.userId; + var userHandle = vm.userData.handle; + var _linksPromises = [ + ExternalAccountService.getAllExternalLinks(userHandle, userId, true), + ExternalWebLinksService.getLinks(userHandle, true) + ]; + $q.all(_linksPromises).then(function(data) { + vm.linkedExternalAccountsData = data[0].concat(data[1]); }); + ExternalAccountService.getLinkedAccounts(userId) + .then(function(data) { + vm.linkedExternalAccounts = data; + }) TagsService.getApprovedSkillTags() .then(function(tags) { diff --git a/app/settings/edit-profile/edit-profile.jade b/app/settings/edit-profile/edit-profile.jade index 132e6f603..4b977b46f 100644 --- a/app/settings/edit-profile/edit-profile.jade +++ b/app/settings/edit-profile/edit-profile.jade @@ -95,12 +95,17 @@ .description Show off your work and experience outside of Topcoder. Connect accounts from popular services and networks or add a link to any site. .section-fields + .field-label Add a web link + + .web-links + external-web-link(linked-accounts="vm.linkedExternalAccountsData", user-handle="{{vm.userData.handle}}") + .field-label Link Your Accounts .external-links - external-accounts(linked-accounts="vm.linkedExternalAccounts", links-data="vm.linkedExternalAccountsData", read-only="false") + external-accounts(linked-accounts="vm.linkedExternalAccountsData", read-only="false") .field-label Linked Accounts .existing-links - external-links-data(external-links="vm.linkedExternalAccounts", linked-accounts-data="vm.linkedExternalAccountsData") + external-links-data(linked-accounts-data="vm.linkedExternalAccountsData") diff --git a/app/specs.html b/app/specs.html index bc986d6ac..d70c81ed1 100644 --- a/app/specs.html +++ b/app/specs.html @@ -122,6 +122,7 @@

Spec Runner

+ @@ -199,6 +200,8 @@

Spec Runner

+ + @@ -242,6 +245,8 @@

Spec Runner

+ + @@ -263,15 +268,18 @@

Spec Runner

- + + + - + + - + diff --git a/app/topcoder.module.js b/app/topcoder.module.js index 381202abb..fcb692b88 100644 --- a/app/topcoder.module.js +++ b/app/topcoder.module.js @@ -33,7 +33,8 @@ 'angular-intro', 'ngMessages', 'angular-carousel', - 'sticky' + 'sticky', + 'dibari.angular-ellipsis' ]; angular.module('topcoder', dependencies).run(appRun); diff --git a/assets/css/directives/external-link-data.scss b/assets/css/directives/external-link-data.scss index 3c068ce80..e71c29e20 100644 --- a/assets/css/directives/external-link-data.scss +++ b/assets/css/directives/external-link-data.scss @@ -67,7 +67,7 @@ external-accounts { } .bottom { width: 220px; - margin-top: 15px; + margin-top: 10px; .handle { @include sofia-pro-light; font-size: 18px; @@ -75,7 +75,7 @@ external-accounts { } .title { // placeholder - margin-top: 20px; + margin-top: 10px; color: #9e9e9e; font-size: 12px; line-height: 14px; @@ -117,6 +117,29 @@ external-accounts { padding: 10px; font-size: 50px; } + + .link-title { + @include sofia-pro-medium; + font-size: 12px; + line-height: 20px; + color: $gray-darkest; + margin-top: 10px; + height: 40px; + overflow: hidden; + padding: 0px 20px; + text-transform: uppercase; + } + + .link-url { + font-size: 12px; + line-height: 14px; + word-wrap: break-word; + display: block; + overflow: hidden; + max-height: 14px; + padding: 0px 20px; + text-transform: uppercase; + } } } @@ -175,6 +198,7 @@ external-accounts { } .bottom { + margin-top: 15px; width: auto; .handle { margin-top: 20px; @@ -183,6 +207,8 @@ external-accounts { .title { // placeholder height: 40px; + margin-top: 15px; + margin-bottom: 30px; } ul { @@ -213,6 +239,16 @@ external-accounts { .logo { padding: 10px; font-size: 50px; + } + + .link-title { + margin-top: 15px; + margin-bottom: 20px; + height: 60px; + } + + .link-url { + max-height: 28px; } } } diff --git a/assets/css/directives/external-web-link.scss b/assets/css/directives/external-web-link.scss new file mode 100644 index 000000000..6c8811045 --- /dev/null +++ b/assets/css/directives/external-web-link.scss @@ -0,0 +1,55 @@ +@import 'tc-includes'; + +external-web-link { + .web-link { + margin: 6px 0 31px 0; + + .form-errors { + position: initial; + width: 100%; + } + + form { + display: flex; + flex-flow: row wrap; + + .form-label { + @include sofia-pro-regular; + font-size: 12px; + color: black; + text-transform: uppercase; + margin-bottom: 5px; + margin-top: 5px; + } + .form-field { + @include form-field; + @include ui-form-placeholder; + &:disabled { + color: #B7B7B7; + } + } + .form-field-focused { + @include form-field-focused; + } + + .validation-bar.url { + flex: 1; + width: auto; + + &:before { + height: 40px; + } + } + + input.url { + width: 100%; + margin: 0px; + } + + button { + width: auto; + margin-left: 10px; + } + } + } +} diff --git a/bower.json b/bower.json index 0035233e4..a46e8baca 100644 --- a/bower.json +++ b/bower.json @@ -28,6 +28,7 @@ "angular": "1.4.x", "angular-cookies": "1.4.x", "angular-dropdowns": "1.1.0", + "angular-ellipsis": "~0.1.6", "angular-filter": "~0.5.4", "angular-img-fallback": "~0.1.3", "angular-intro.js": "~1.3.0", diff --git a/tests/test-helpers/mock-data.js b/tests/test-helpers/mock-data.js index a9807a3b9..e9f8a4f70 100644 --- a/tests/test-helpers/mock-data.js +++ b/tests/test-helpers/mock-data.js @@ -22,7 +22,9 @@ var mockData = (function() { getMockBadge: getMockBadge, getMockUserFinancials: getMockUserFinancials, getMockLinkedExternalAccounts: getMockLinkedExternalAccounts, - getMockLinkedExternalAccountsData: getMockLinkedExternalAccountsData + getMockLinkedExternalAccountsData: getMockLinkedExternalAccountsData, + getMockExternalWebLinksData: getMockExternalWebLinksData, + getMockAuth0Profile: getMockAuth0Profile }; function getMockStates() { @@ -1627,99 +1629,85 @@ var mockData = (function() { "DESIGN": { "challenges": 664, "wins": 271, - "subTracks": [ - { - "id": 34, - "name": "STUDIO_OTHER", - "challenges": 21, - "wins": 4, - "mostRecentEventDate": "2011-04-20T09:00:00.000Z" - }, - { - "id": 30, - "name": "WIDGET_OR_MOBILE_SCREEN_DESIGN", - "challenges": 82, - "wins": 30, - "mostRecentEventDate": "2015-02-01T22:00:53.000Z" - }, - { - "id": 22, - "name": "IDEA_GENERATION", - "challenges": 3, - "wins": 0, - "mostRecentEventDate": "2013-05-27T10:00:07.000Z" - }, - { - "id": 17, - "name": "WEB_DESIGNS", - "challenges": 418, - "wins": 190, - "mostRecentEventDate": "2015-01-26T19:00:03.000Z" - }, - { - "id": 29, - "name": "COPILOT_POSTING", - "challenges": 1, - "wins": 0, - "mostRecentEventDate": null - }, - { - "id": 18, - "name": "WIREFRAMES", - "challenges": 2, - "wins": 1, - "mostRecentEventDate": "2010-11-17T09:00:00.000Z" - }, - { - "id": 14, - "name": "ASSEMBLY_COMPETITION", - "challenges": 1, - "wins": 0, - "mostRecentEventDate": null - }, - { - "id": 32, - "name": "APPLICATION_FRONT_END_DESIGN", - "challenges": 54, - "wins": 23, - "mostRecentEventDate": "2014-08-07T01:03:11.000Z" - }, - { - "id": 21, - "name": "PRINT_OR_PRESENTATION", - "challenges": 24, - "wins": 8, - "mostRecentEventDate": "2014-10-08T17:48:09.000Z" - }, - { - "id": 16, - "name": "BANNERS_OR_ICONS", - "challenges": 24, - "wins": 10, - "mostRecentEventDate": "2014-01-11T17:30:27.000Z" - }, - { - "id": 20, - "name": "LOGO_DESIGN", - "challenges": 31, - "wins": 4, - "mostRecentEventDate": "2014-02-21T19:00:09.000Z" - }, - { - "id": 31, - "name": "FRONT_END_FLASH", - "challenges": 2, - "wins": 1, - "mostRecentEventDate": "2009-06-19T23:00:00.000Z" - }, - { - "id": 13, - "name": "TEST_SUITES", - "challenges": 1, - "wins": 0, - "mostRecentEventDate": null - } - ], + "subTracks": [{ + "id": 34, + "name": "STUDIO_OTHER", + "challenges": 21, + "wins": 4, + "mostRecentEventDate": "2011-04-20T09:00:00.000Z" + }, { + "id": 30, + "name": "WIDGET_OR_MOBILE_SCREEN_DESIGN", + "challenges": 82, + "wins": 30, + "mostRecentEventDate": "2015-02-01T22:00:53.000Z" + }, { + "id": 22, + "name": "IDEA_GENERATION", + "challenges": 3, + "wins": 0, + "mostRecentEventDate": "2013-05-27T10:00:07.000Z" + }, { + "id": 17, + "name": "WEB_DESIGNS", + "challenges": 418, + "wins": 190, + "mostRecentEventDate": "2015-01-26T19:00:03.000Z" + }, { + "id": 29, + "name": "COPILOT_POSTING", + "challenges": 1, + "wins": 0, + "mostRecentEventDate": null + }, { + "id": 18, + "name": "WIREFRAMES", + "challenges": 2, + "wins": 1, + "mostRecentEventDate": "2010-11-17T09:00:00.000Z" + }, { + "id": 14, + "name": "ASSEMBLY_COMPETITION", + "challenges": 1, + "wins": 0, + "mostRecentEventDate": null + }, { + "id": 32, + "name": "APPLICATION_FRONT_END_DESIGN", + "challenges": 54, + "wins": 23, + "mostRecentEventDate": "2014-08-07T01:03:11.000Z" + }, { + "id": 21, + "name": "PRINT_OR_PRESENTATION", + "challenges": 24, + "wins": 8, + "mostRecentEventDate": "2014-10-08T17:48:09.000Z" + }, { + "id": 16, + "name": "BANNERS_OR_ICONS", + "challenges": 24, + "wins": 10, + "mostRecentEventDate": "2014-01-11T17:30:27.000Z" + }, { + "id": 20, + "name": "LOGO_DESIGN", + "challenges": 31, + "wins": 4, + "mostRecentEventDate": "2014-02-21T19:00:09.000Z" + }, { + "id": 31, + "name": "FRONT_END_FLASH", + "challenges": 2, + "wins": 1, + "mostRecentEventDate": "2009-06-19T23:00:00.000Z" + }, { + "id": 13, + "name": "TEST_SUITES", + "challenges": 1, + "wins": 0, + "mostRecentEventDate": null + }], "mostRecentEventDate": "2015-02-01T22:00:53.000Z" }, "DATA_SCIENCE": { @@ -1977,38 +1965,116 @@ var mockData = (function() { function getMockLinkedExternalAccountsData() { return { - github: null, - stackoverflow: null, - dribble: null, - behance: null, - bitbucket: null, - linkedin: null, - twitter: null, - userId: 123, + "updatedAt": null, + "createdAt": null, + "createdBy": null, + "updatedBy": null, + "userId": 22688955, + "handle": "test", + "behance": null, + "bitbucket": { + "handle": "test1", + "followers": 0, + "languages": "html/css", + "repos": 1 + }, + "dribbble": { + "handle": "test2", + "socialId": "944202", + "name": "Vikas Agarwal", + "summary": "Principal Engineer @Appirio", + "followers": 0, + "likes": 0, + "tags": null + }, + "github": { + "handle": "test3", + "socialId": "2417632", + "publicRepos": 11, + "followers": 2, + "languages": "Java,JavaScript,HTML,CSS,Ruby" + }, + "linkedin": null, + "stackoverflow": { + "name": "test", + "socialId": "365172", + "answers": 42, + "questions": 9, + "reputation": 928, + "topTags": "java,jsp,jstl,hashmap,quartz-scheduler,eclipse,ant,tomcat,warnings,hadoop,mysql,amazon-ec2,amazon-ebs,java-ee,amazon-web-services,amazon-rds,hibernate,scala,maven,apache-spark,apache-spark-sql,hbase,scheduling,javascript,gmail,junit,byte,persistence,hql,gdata" + }, + "twitter": null, plain: function() {} }; } function getMockLinkedExternalAccounts() { - return [ - { - providerType: 'linkedin', - // don't care about other details - }, - { + return { + profiles: [{ providerType: 'github' - }, - { + }, { + providerType: 'stackoverflow' + }, { providerType: 'behance' - }, - { + }, { providerType: 'dribbble' - }, - { + }, { providerType: 'bitbucket' + }] + } + } + + function getMockExternalWebLinksData() { + return [ + { + "userId": 111, + "key": "c69a1246c135b16069395010e91f5c64", + "handle": "test1", + "description": "description 1.", + "entities": "Activiti,Data Science,Reference Implementation for Angular Reference", + "keywords": "topcoder-app,merged,oct,dashboard,15appirio-tech,20appirio-tech,polish,21appirio-tech,sup-1889,19appirio-tech", + "title": "Test's profile", + "images": "https://avatars3.githubusercontent.com/u/2417632?v=3&s=400,https://avatars1.githubusercontent.com/u/2417632?v=3&s=460,https://assets-cdn.github.com/images/spinners/octocat-spinner-128.gif", + "source": "embed.ly", + "synchronizedAt": 123112 + }, { + "userId": 111, + "key": "c69a1246c135b16069395010e91f5c65", + "handle": "test1", + "description": "description 1.", + "entities": "Activiti,Data Science,Reference Implementation for Angular Reference", + "keywords": "topcoder-app,merged,oct,dashboard,15appirio-tech,20appirio-tech,polish,21appirio-tech,sup-1889,19appirio-tech", + "title": "Test's profile", + "images": "https://avatars3.githubusercontent.com/u/2417632?v=3&s=400,https://avatars1.githubusercontent.com/u/2417632?v=3&s=460,https://assets-cdn.github.com/images/spinners/octocat-spinner-128.gif", + "source": "embed.ly", + "synchronizedAt": 123123 + }, { + "userId": 111, + "key": "c69a1246c135b16069395010e91f5c66", + "handle": "test1", + "synchronizedAt": 0 } - ]; + ] } + function getMockAuth0Profile() { + return { + "user_id": "mockSocialNetwork|123456", + "given_name": "mock", + "family_name": "user", + "first_name": "mock", + "last_name": "user", + "nickname": "mocky", + "name": "mock user", + "email": "mock@topcoder.com", + "username": "mockuser", + "identities": [ + { + "access_token": "abcdefghi", + "access_token_secret": "abcdefghijklmnopqrstuvwxyz" + } + ] + }; + } })();