Commit dcc41497 authored by Robert Knight's avatar Robert Knight

Convert AppController to JS

 - Extract the logic computing a numeric key for
   sorting annotations by location into a separate
   module to make testing easier and add tests.
parent 330e9b9e
'use strict';
/**
* Utility functions for querying annotation metadata.
*/
......@@ -68,8 +70,30 @@ function isNew(annotation) {
return !annotation.id;
}
/** Return a numeric key that can be used to sort annotations by location.
*
* @return {number} - A key representing the location of the annotation in
* the document, where lower numbers mean closer to the
* start.
*/
function location(annotation) {
if (annotation) {
var targets = annotation.target || [];
for (var i=0; i < targets.length; i++) {
var selectors = targets[i].selector || [];
for (var k=0; k < selectors.length; k++) {
if (selectors[k].type === 'TextPositionSelector') {
return selectors[k].start;
}
}
}
}
return Number.POSITIVE_INFINITY;
}
module.exports = {
extractDocumentMetadata: extractDocumentMetadata,
isReply: isReply,
isNew: isNew,
location: location,
};
angular = require('angular')
events = require('./events')
parseAccountID = require('./filter/persona').parseAccountID
module.exports = class AppController
this.$inject = [
'$controller', '$document', '$location', '$rootScope', '$route', '$scope',
'$window', 'annotationUI', 'auth', 'drafts', 'features', 'groups',
'identity', 'session'
]
constructor: (
$controller, $document, $location, $rootScope, $route, $scope,
$window, annotationUI, auth, drafts, features, groups,
identity, session
) ->
$controller('AnnotationUIController', {$scope})
# This stores information about the current user's authentication status.
# When the controller instantiates we do not yet know if the user is
# logged-in or not, so it has an initial status of 'unknown'. This can be
# used by templates to show an intermediate or loading state.
$scope.auth = {status: 'unknown'}
# Allow all child scopes to look up feature flags as:
#
# if ($scope.feature('foo')) { ... }
$scope.feature = features.flagEnabled
# Allow all child scopes access to the session
$scope.session = session
isFirstRun = $location.search().hasOwnProperty('firstrun')
# App dialogs
$scope.accountDialog = visible: false
$scope.shareDialog = visible: false
# Check to see if we're in the sidebar, or on a standalone page such as
# the stream page or an individual annotation page.
$scope.isSidebar = $window.top isnt $window
# Default sort
$scope.sort = {
name: 'Location'
options: ['Newest', 'Oldest', 'Location']
}
# Reload the view when the user switches accounts
$scope.$on(events.USER_CHANGED, (event, data) ->
if !data || !data.initialLoad
$route.reload()
);
identity.watch({
onlogin: (identity) ->
# Hide the account dialog
$scope.accountDialog.visible = false
# Update the current logged-in user information
userid = auth.userid(identity)
parsed = parseAccountID(userid)
angular.copy({
status: 'signed-in',
userid: userid,
username: parsed.username,
provider: parsed.provider,
}, $scope.auth)
onlogout: ->
angular.copy({status: 'signed-out'}, $scope.auth)
onready: ->
# If their status is still 'unknown', then `onlogin` wasn't called and
# we know the current user isn't signed in.
if $scope.auth.status == 'unknown'
angular.copy({status: 'signed-out'}, $scope.auth)
if isFirstRun
$scope.login()
})
$scope.$watch 'sort.name', (name) ->
return unless name
predicate = switch name
when 'Newest' then ['-!!message', '-message.updated']
when 'Oldest' then ['-!!message', 'message.updated']
when 'Location' then (thread) ->
if thread.message?
for target in thread.message.target ? []
for selector in target.selector ? []
if selector.type is 'TextPositionSelector'
return selector.start
return Number.POSITIVE_INFINITY
$scope.sort = {
name,
predicate,
options: $scope.sort.options,
}
# Start the login flow. This will present the user with the login dialog.
$scope.login = ->
$scope.accountDialog.visible = true
identity.request({
oncancel: -> $scope.accountDialog.visible = false
})
# Prompt to discard any unsaved drafts.
promptToLogout = ->
# TODO - Replace this with a UI which doesn't look terrible.
if drafts.count() == 1
text = 'You have an unsaved annotation.\n
Do you really want to discard this draft?'
else if drafts.count() > 1
text = 'You have ' + drafts.count() + ' unsaved annotations.\n
Do you really want to discard these drafts?'
return (drafts.count() == 0 or $window.confirm(text))
# Log the user out.
$scope.logout = ->
if promptToLogout()
for draft in drafts.unsaved()
$rootScope.$emit("annotationDeleted", draft)
drafts.discard()
$scope.accountDialog.visible = false
identity.logout()
$scope.clearSelection = ->
$scope.search.query = ''
annotationUI.clearSelectedAnnotations()
$scope.search =
query: $location.search()['q']
clear: ->
$location.search('q', null)
update: (query) ->
unless angular.equals $location.search()['q'], query
$location.search('q', query or null)
annotationUI.clearSelectedAnnotations()
'use strict';
var angular = require('angular');
var annotationMetadata = require('./annotation-metadata');
var events = require('./events');
var parseAccountID = require('./filter/persona').parseAccountID;
// @ngInject
module.exports = function AppController(
$controller, $document, $location, $rootScope, $route, $scope,
$window, annotationUI, auth, drafts, features, groups,
identity, session
) {
$controller('AnnotationUIController', {$scope: $scope});
// This stores information about the current user's authentication status.
// When the controller instantiates we do not yet know if the user is
// logged-in or not, so it has an initial status of 'unknown'. This can be
// used by templates to show an intermediate or loading state.
$scope.auth = {status: 'unknown'};
// Allow all child scopes to look up feature flags as:
//
// if ($scope.feature('foo')) { ... }
$scope.feature = features.flagEnabled;
// Allow all child scopes access to the session
$scope.session = session;
var isFirstRun = $location.search().hasOwnProperty('firstrun');
// App dialogs
$scope.accountDialog = {visible: false};
$scope.shareDialog = {visible: false};
// Check to see if we're in the sidebar, or on a standalone page such as
// the stream page or an individual annotation page.
$scope.isSidebar = $window.top !== $window;
// Default sort
$scope.sort = {
name: 'Location',
options: ['Newest', 'Oldest', 'Location']
};
// Reload the view when the user switches accounts
$scope.$on(events.USER_CHANGED, function (event, data) {
if (!data || !data.initialLoad) {
$route.reload();
}
});
identity.watch({
onlogin: function (identity) {
// Hide the account dialog
$scope.accountDialog.visible = false;
// Update the current logged-in user information
var userid = auth.userid(identity);
var parsed = parseAccountID(userid);
angular.copy({
status: 'signed-in',
userid: userid,
username: parsed.username,
provider: parsed.provider,
}, $scope.auth);
},
onlogout: function () {
angular.copy({status: 'signed-out'}, $scope.auth);
},
onready: function () {
// If their status is still 'unknown', then `onlogin` wasn't called and
// we know the current user isn't signed in.
if ($scope.auth.status === 'unknown') {
angular.copy({status: 'signed-out'}, $scope.auth);
if (isFirstRun) {
$scope.login();
}
}
}
});
$scope.$watch('sort.name', function (name) {
if (!name) {
return;
}
var predicateFn;
switch (name) {
case 'Newest':
predicateFn = ['-!!message', '-message.updated'];
break;
case 'Oldest':
predicateFn = ['-!!message', 'message.updated'];
break;
case 'Location':
predicateFn = annotationMetadata.location;
break;
}
$scope.sort = {
name: name,
predicate: predicateFn,
options: $scope.sort.options,
};
});
// Start the login flow. This will present the user with the login dialog.
$scope.login = function () {
$scope.accountDialog.visible = true;
return identity.request({
oncancel: function () { $scope.accountDialog.visible = false; }
});
};
// Prompt to discard any unsaved drafts.
var promptToLogout = function () {
// TODO - Replace this with a UI which doesn't look terrible.
var text = '';
if (drafts.count() === 1) {
text = 'You have an unsaved annotation.\n' +
'Do you really want to discard this draft?';
} else if (drafts.count() > 1) {
text = 'You have ' + drafts.count() + ' unsaved annotations.\n' +
'Do you really want to discard these drafts?';
}
return (drafts.count() === 0 || $window.confirm(text));
};
// Log the user out.
$scope.logout = function () {
if (promptToLogout()) {
var iterable = drafts.unsaved();
for (var i = 0, draft; i < iterable.length; i++) {
draft = iterable[i];
$rootScope.$emit("annotationDeleted", draft);
}
drafts.discard();
$scope.accountDialog.visible = false;
return identity.logout();
}
};
$scope.clearSelection = function () {
$scope.search.query = '';
annotationUI.clearSelectedAnnotations();
};
$scope.search = {
query: $location.search().q,
clear: function () {
$location.search('q', null);
},
update: function (query) {
if (!angular.equals($location.search().q, query)) {
$location.search('q', query || null);
annotationUI.clearSelectedAnnotations();
}
}
};
};
......@@ -4,7 +4,8 @@ var annotationMetadata = require('../annotation-metadata');
var extractDocumentMetadata = annotationMetadata.extractDocumentMetadata;
describe('extractDocumentMetadata()', function() {
describe('annotation-metadata', function () {
describe('.extractDocumentMetadata', function() {
context('when the model has a document property', function() {
it('returns the hostname from model.uri as the domain', function() {
......@@ -123,4 +124,26 @@ describe('extractDocumentMetadata()', function() {
);
});
});
});
describe('.location', function () {
it('returns the position for annotations with a text position', function () {
assert.equal(annotationMetadata.location({
target: [{
selector: [{
type: 'TextPositionSelector',
start: 100,
}]
}]
}), 100);
});
it('returns +ve infinity for annotations without a text position', function () {
assert.equal(annotationMetadata.location({
target: [{
selector: undefined,
}]
}), Number.POSITIVE_INFINITY);
});
});
});
{module, inject} = angular.mock
events = require('../events')
describe 'AppController', ->
$controller = null
$scope = null
$rootScope = null
fakeAnnotationUI = null
fakeAuth = null
fakeDrafts = null
fakeFeatures = null
fakeIdentity = null
fakeLocation = null
fakeParams = null
fakeSession = null
fakeGroups = null
fakeRoute = null
fakeWindow = null
sandbox = null
createController = (locals={}) ->
locals.$scope = $scope
$controller('AppController', locals)
before ->
angular.module('h', [])
.controller('AppController', require('../app-controller'))
.controller('AnnotationUIController', angular.noop)
beforeEach module('h')
beforeEach module ($provide) ->
sandbox = sinon.sandbox.create()
fakeAnnotationUI = {
tool: 'comment'
clearSelectedAnnotations: sandbox.spy()
}
fakeAuth = {
userid: sandbox.stub()
}
fakeDrafts = {
contains: sandbox.stub()
remove: sandbox.spy()
all: sandbox.stub().returns([])
discard: sandbox.spy()
count: sandbox.stub().returns(0)
unsaved: sandbox.stub().returns([])
}
fakeFeatures = {
fetch: sandbox.spy()
flagEnabled: sandbox.stub().returns(false)
}
fakeIdentity = {
watch: sandbox.spy()
request: sandbox.spy()
logout: sandbox.stub()
}
fakeLocation = {
search: sandbox.stub().returns({})
}
fakeParams = {id: 'test'}
fakeSession = {}
fakeGroups = {focus: sandbox.spy()}
fakeRoute = {reload: sandbox.spy()}
fakeWindow = {
top: {}
confirm: sandbox.stub()
}
$provide.value 'annotationUI', fakeAnnotationUI
$provide.value 'auth', fakeAuth
$provide.value 'drafts', fakeDrafts
$provide.value 'features', fakeFeatures
$provide.value 'identity', fakeIdentity
$provide.value 'session', fakeSession
$provide.value 'groups', fakeGroups
$provide.value '$route', fakeRoute
$provide.value '$location', fakeLocation
$provide.value '$routeParams', fakeParams
$provide.value '$window', fakeWindow
return
beforeEach inject (_$controller_, _$rootScope_) ->
$controller = _$controller_
$rootScope = _$rootScope_
$scope = $rootScope.$new()
afterEach ->
sandbox.restore()
describe 'isSidebar property', ->
it 'is false if the window is the top window', ->
fakeWindow.top = fakeWindow
createController()
assert.isFalse($scope.isSidebar)
it 'is true if the window is not the top window', ->
fakeWindow.top = {}
createController()
assert.isTrue($scope.isSidebar)
it 'watches the identity service for identity change events', ->
createController()
assert.calledOnce(fakeIdentity.watch)
it 'auth.status is "unknown" on startup', ->
createController()
assert.equal($scope.auth.status, 'unknown')
it 'sets auth.status to "signed-out" when the identity has been checked but the user is not authenticated', ->
createController()
{onready} = fakeIdentity.watch.args[0][0]
onready()
assert.equal($scope.auth.status, 'signed-out')
it 'sets auth.status to "signed-in" when the identity has been checked and the user is authenticated', ->
createController()
fakeAuth.userid.withArgs('test-assertion').returns('acct:hey@joe')
{onlogin} = fakeIdentity.watch.args[0][0]
onlogin('test-assertion')
assert.equal($scope.auth.status, 'signed-in')
it 'sets userid, username, and provider properties at login', ->
createController()
fakeAuth.userid.withArgs('test-assertion').returns('acct:hey@joe')
{onlogin} = fakeIdentity.watch.args[0][0]
onlogin('test-assertion')
assert.equal($scope.auth.userid, 'acct:hey@joe')
assert.equal($scope.auth.username, 'hey')
assert.equal($scope.auth.provider, 'joe')
it 'sets auth.status to "signed-out" at logout', ->
createController()
{onlogout} = fakeIdentity.watch.args[0][0]
onlogout()
assert.equal($scope.auth.status, "signed-out")
it 'does not show login form for logged in users', ->
createController()
assert.isFalse($scope.accountDialog.visible)
it 'does not show the share dialog at start', ->
createController()
assert.isFalse($scope.shareDialog.visible)
it 'does not reload the view when the logged-in user changes on first load', ->
createController()
fakeRoute.reload = sinon.spy()
$scope.$broadcast(events.USER_CHANGED, {initialLoad: true})
assert.notCalled(fakeRoute.reload)
it 'reloads the view when the logged-in user changes after first load', ->
createController()
fakeRoute.reload = sinon.spy()
$scope.$broadcast(events.USER_CHANGED, {initialLoad: false})
assert.calledOnce(fakeRoute.reload)
describe 'logout()', ->
it 'prompts the user if there are drafts', ->
fakeDrafts.count.returns(1)
createController()
$scope.logout()
assert.equal(fakeWindow.confirm.callCount, 1)
it 'emits "annotationDeleted" for each unsaved draft annotation', ->
fakeDrafts.unsaved = sandbox.stub().returns(
["draftOne", "draftTwo", "draftThree"]
)
createController()
$rootScope.$emit = sandbox.stub()
$scope.logout()
assert($rootScope.$emit.calledThrice)
assert.deepEqual(
$rootScope.$emit.firstCall.args, ["annotationDeleted", "draftOne"])
assert.deepEqual(
$rootScope.$emit.secondCall.args, ["annotationDeleted", "draftTwo"])
assert.deepEqual(
$rootScope.$emit.thirdCall.args, ["annotationDeleted", "draftThree"])
it 'discards draft annotations', ->
createController()
$scope.logout()
assert(fakeDrafts.discard.calledOnce)
it 'does not emit "annotationDeleted" if the user cancels the prompt', ->
createController()
fakeDrafts.count.returns(1)
$rootScope.$emit = sandbox.stub()
fakeWindow.confirm.returns(false)
$scope.logout()
assert($rootScope.$emit.notCalled)
it 'does not discard drafts if the user cancels the prompt', ->
createController()
fakeDrafts.count.returns(1)
fakeWindow.confirm.returns(false)
$scope.logout()
assert(fakeDrafts.discard.notCalled)
it 'does not prompt if there are no drafts', ->
createController()
fakeDrafts.count.returns(0)
$scope.logout()
assert.equal(fakeWindow.confirm.callCount, 0)
'use strict';
var angular = require('angular');
var events = require('../events');
describe('AppController', function () {
var $controller = null;
var $scope = null;
var $rootScope = null;
var fakeAnnotationUI = null;
var fakeAuth = null;
var fakeDrafts = null;
var fakeFeatures = null;
var fakeIdentity = null;
var fakeLocation = null;
var fakeParams = null;
var fakeSession = null;
var fakeGroups = null;
var fakeRoute = null;
var fakeWindow = null;
var sandbox = null;
var createController = function (locals) {
locals = locals || {};
locals.$scope = $scope;
return $controller('AppController', locals);
};
before(function () {
return angular.module('h', [])
.controller('AppController', require('../app-controller'))
.controller('AnnotationUIController', angular.noop);
});
beforeEach(angular.mock.module('h'));
beforeEach(angular.mock.module(function ($provide) {
sandbox = sinon.sandbox.create();
fakeAnnotationUI = {
tool: 'comment',
clearSelectedAnnotations: sandbox.spy()
};
fakeAuth = {
userid: sandbox.stub()
};
fakeDrafts = {
contains: sandbox.stub(),
remove: sandbox.spy(),
all: sandbox.stub().returns([]),
discard: sandbox.spy(),
count: sandbox.stub().returns(0),
unsaved: sandbox.stub().returns([])
};
fakeFeatures = {
fetch: sandbox.spy(),
flagEnabled: sandbox.stub().returns(false)
};
fakeIdentity = {
watch: sandbox.spy(),
request: sandbox.spy(),
logout: sandbox.stub()
};
fakeLocation = {
search: sandbox.stub().returns({})
};
fakeParams = {id: 'test'};
fakeSession = {};
fakeGroups = {focus: sandbox.spy()};
fakeRoute = {reload: sandbox.spy()};
fakeWindow = {
top: {},
confirm: sandbox.stub()
};
$provide.value('annotationUI', fakeAnnotationUI);
$provide.value('auth', fakeAuth);
$provide.value('drafts', fakeDrafts);
$provide.value('features', fakeFeatures);
$provide.value('identity', fakeIdentity);
$provide.value('session', fakeSession);
$provide.value('groups', fakeGroups);
$provide.value('$route', fakeRoute);
$provide.value('$location', fakeLocation);
$provide.value('$routeParams', fakeParams);
$provide.value('$window', fakeWindow);
}));
beforeEach(angular.mock.inject(function (_$controller_, _$rootScope_) {
$controller = _$controller_;
$rootScope = _$rootScope_;
$scope = $rootScope.$new();
}));
afterEach(function () {
sandbox.restore();
});
describe('isSidebar property', function () {
it('is false if the window is the top window', function () {
fakeWindow.top = fakeWindow;
createController();
assert.isFalse($scope.isSidebar);
});
it('is true if the window is not the top window', function () {
fakeWindow.top = {};
createController();
assert.isTrue($scope.isSidebar);
});
});
it('watches the identity service for identity change events', function () {
createController();
assert.calledOnce(fakeIdentity.watch);
});
it('auth.status is "unknown" on startup', function () {
createController();
assert.equal($scope.auth.status, 'unknown');
});
it('sets auth.status to "signed-out" when the identity has been checked but the user is not authenticated', function () {
createController();
var identityCallbackArgs = fakeIdentity.watch.args[0][0];
identityCallbackArgs.onready();
assert.equal($scope.auth.status, 'signed-out');
});
it('sets auth.status to "signed-in" when the identity has been checked and the user is authenticated', function () {
createController();
fakeAuth.userid.withArgs('test-assertion').returns('acct:hey@joe');
var identityCallbackArgs = fakeIdentity.watch.args[0][0];
identityCallbackArgs.onlogin('test-assertion');
assert.equal($scope.auth.status, 'signed-in');
});
it('sets userid, username, and provider properties at login', function () {
createController();
fakeAuth.userid.withArgs('test-assertion').returns('acct:hey@joe');
var identityCallbackArgs = fakeIdentity.watch.args[0][0];
identityCallbackArgs.onlogin('test-assertion');
assert.equal($scope.auth.userid, 'acct:hey@joe');
assert.equal($scope.auth.username, 'hey');
assert.equal($scope.auth.provider, 'joe');
});
it('sets auth.status to "signed-out" at logout', function () {
createController();
var identityCallbackArgs = fakeIdentity.watch.args[0][0];
identityCallbackArgs.onlogout();
assert.equal($scope.auth.status, "signed-out");
});
it('does not show login form for logged in users', function () {
createController();
assert.isFalse($scope.accountDialog.visible);
});
it('does not show the share dialog at start', function () {
createController();
assert.isFalse($scope.shareDialog.visible);
});
it('does not reload the view when the logged-in user changes on first load', function () {
createController();
fakeRoute.reload = sinon.spy();
$scope.$broadcast(events.USER_CHANGED, {initialLoad: true});
assert.notCalled(fakeRoute.reload);
});
it('reloads the view when the logged-in user changes after first load', function () {
createController();
fakeRoute.reload = sinon.spy();
$scope.$broadcast(events.USER_CHANGED, {initialLoad: false});
assert.calledOnce(fakeRoute.reload);
});
describe('logout()', function () {
it('prompts the user if there are drafts', function () {
fakeDrafts.count.returns(1);
createController();
$scope.logout();
assert.equal(fakeWindow.confirm.callCount, 1);
});
it('emits "annotationDeleted" for each unsaved draft annotation', function () {
fakeDrafts.unsaved = sandbox.stub().returns(
["draftOne", "draftTwo", "draftThree"]
);
createController();
$rootScope.$emit = sandbox.stub();
$scope.logout();
assert($rootScope.$emit.calledThrice);
assert.deepEqual(
$rootScope.$emit.firstCall.args, ["annotationDeleted", "draftOne"]);
assert.deepEqual(
$rootScope.$emit.secondCall.args, ["annotationDeleted", "draftTwo"]);
assert.deepEqual(
$rootScope.$emit.thirdCall.args, ["annotationDeleted", "draftThree"]);
});
it('discards draft annotations', function () {
createController();
$scope.logout();
assert(fakeDrafts.discard.calledOnce);
});
it('does not emit "annotationDeleted" if the user cancels the prompt', function () {
createController();
fakeDrafts.count.returns(1);
$rootScope.$emit = sandbox.stub();
fakeWindow.confirm.returns(false);
$scope.logout();
assert($rootScope.$emit.notCalled);
});
it('does not discard drafts if the user cancels the prompt', function () {
createController();
fakeDrafts.count.returns(1);
fakeWindow.confirm.returns(false);
$scope.logout();
assert(fakeDrafts.discard.notCalled);
});
it('does not prompt if there are no drafts', function () {
createController();
fakeDrafts.count.returns(0);
$scope.logout();
assert.equal(fakeWindow.confirm.callCount, 0);
});
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment