Commit 95512b68 authored by Sean Hammond's avatar Sean Hammond Committed by Nick Stenning

Enable publishing annotations to a group

Features
========

1. If you have a group selected in the groups dropdown when you create
   an annotation, the annotation will be created in that group.

2. Only users who are members of the group can see the group's
   annotations.

3. An annotation can be separately set to public or private, whether it
   is in a group or not, using the separate visibility dropdown on the
   annotation card when creating or editing an annotation.

   Private means viewable by the user only, public means viewable by the
   group or by everyone if the annotation has no group. When in a group
   the group's name appears instead of "Public" in the user interface
   (and with a group icon instead of the public icon). So for example if
   I make an annotation in group Foobar I can set its visibility to Only
   Me or to Foobar.

4. New icons and styles for all the group-related parts of the UI,
   should look the same as in Jake's mockups now.

Implementation Notes
====================

Auth
----

- We add `"group:<hashid>"` principals to `request.effective_principals`
  for each group the user is a member of.

- The existing `"group:admin"` and `"group:staff"` principals are
  renamed to `"group:__admin__"` and `"group:__staff__"` so that they
  never clash with group hashids (hashids never contain _'s).

- A user may only create an annotation in group "x" if they are
  themselves a member of group "x". This is enforced by an ACL
  in `h.api.resources`.

- Users may only view annotations in groups of which they are members.
  This is enforced by a query filter in search: `h.api.groups.search`.

- In `h.api.groups.logic`: A reply's group is always set to that of its
  parent, server-side. It doesn't matter what the client sends.

Search Filtering
----------------

- Whether an annotation belongs to a group is recorded in a top-level
  `group` field in the annotation. The value of this field is the
  group's hashid.

  For example, it is currently possible to have a private annotation
  (visible only to the user who created it) within a group. This allows
  users to use the privacy controls for "drafting" group annotations and
  replies, only sharing them with the group when they're ready. It also
  allows users to keep private replies to group annotations.

  When the sidebar is focused on a group (not yet implemented), the
  search will filter the annotations to those that have the group's
  hashid in the group field, regardless of whether the annotation's
  permissions are private to the user or shared with the group.

- The groups search filtering (`h.api.groups.search`) uses the top-level
  groups field to filter out annotations that belong to groups that the
  user isn't a member of.

- Annotations that are public simply have a group field containing
  `__world__`.

Client-Side
-----------

- `annotation.coffee` sets the group field on the annotation before
  saving it.

- There's a new group-list-controller to handle the now more interactive
  groups dropdown.

- And a group-service to share state about which group is currently
  focused with different parts of the code.

Misc
----

- `h.streamer`: Infinite scroll and live updates filter out annotations
  from groups the user isn't a member of, as well.

- `h.api.search.transform`: the API adds group: '__world__' to any
  annotations without a group field before returning them to the
  client.
parent 0762ef02
......@@ -89,7 +89,7 @@ module.exports = angular.module('h', [
.directive('formValidate', require('./directive/form-validate'))
.directive('groupList', require('./directive/group-list'))
.directive('markdown', require('./directive/markdown'))
.directive('privacy', require('./directive/privacy'))
.directive('privacy', require('./directive/privacy').directive)
.directive('simpleSearch', require('./directive/simple-search'))
.directive('statusButton', require('./directive/status-button'))
.directive('thread', require('./directive/thread'))
......@@ -117,6 +117,7 @@ module.exports = angular.module('h', [
.service('features', require('./features'))
.service('flash', require('./flash'))
.service('formRespond', require('./form-respond'))
.service('groups', require('./groups'))
.service('host', require('./host'))
.service('localStorage', require('./local-storage'))
.service('permissions', require('./permissions'))
......
......@@ -43,10 +43,10 @@ errorMessage = (reason) ->
AnnotationController = [
'$scope', '$timeout', '$q', '$rootScope', '$document',
'drafts', 'flash', 'permissions', 'tags', 'time',
'annotationUI', 'annotationMapper', 'session'
'annotationUI', 'annotationMapper', 'session', 'groups'
($scope, $timeout, $q, $rootScope, $document,
drafts, flash, permissions, tags, time,
annotationUI, annotationMapper, session) ->
annotationUI, annotationMapper, session, groups) ->
@annotation = {}
@action = 'view'
......@@ -56,14 +56,24 @@ AnnotationController = [
@embedded = false
@hasDiff = false
@showDiff = undefined
@privacyLevel = null
@timestamp = null
model = $scope.annotationGet()
if not model.group
model.group = groups.focused().hashid
highlight = model.$highlight
original = null
vm = this
###*
# @ngdoc method
# @name annotation.AnnotationController#group.
# @returns {Object} The full group object associated with the annotation.
###
this.group = ->
groups.get(model.group)
###*
# @ngdoc method
# @name annotation.AnnotationController#tagsAutoComplete.
......@@ -98,6 +108,33 @@ AnnotationController = [
this.isPrivate = ->
permissions.isPrivate model.permissions, model.user
###*
# @ngdoc method
# @name annotation.AnnotationController#setPrivate
#
# Set permissions on this annotation to private.
###
this.setPrivate = ->
model.permissions = permissions.private()
###*
# @ngdoc method
# @name annotation.AnnotationController#isShared
# @returns {boolean} True if the annotation is shared (either with the
# current group or with everyone).
###
this.isShared = ->
permissions.isPublic model.permissions, model.group
###*
# @ngdoc method
# @name annotation.AnnotationController#setShared
#
# Set permissions on this annotation to share with the current group.
###
this.setShared = ->
model.permissions = permissions.public(model.group)
###*
# @ngdoc method
# @name annotation.AnnotationController#authorize
......
......@@ -6,12 +6,15 @@
* @restrict AE
* @description Displays a list of groups of which the user is a member.
*/
module.exports = function () {
// @ngInject
module.exports = function (groups) {
return {
restrict: 'AE',
scope: {
groups: '='
link: function (scope, elem, attrs) {
scope.groups = groups;
},
restrict: 'AE',
scope: {},
templateUrl: 'group_list.html'
};
};
module.exports = ['localStorage', 'permissions', (localStorage, permissions) ->
VISIBILITY_KEY ='hypothesis.visibility'
VISIBILITY_PUBLIC = 'public'
VISIBILITY_PRIVATE = 'private'
levels = [
{name: VISIBILITY_PUBLIC, text: 'Public'}
{name: VISIBILITY_PRIVATE, text: 'Only Me'}
]
getLevel = (name) ->
for level in levels
if level.name == name
return level
undefined
isPublic = (level) -> level == VISIBILITY_PUBLIC
link: (scope, elem, attrs, controller) ->
return unless controller?
controller.$formatters.push (selectedPermissions) ->
return unless selectedPermissions?
if permissions.isPublic(selectedPermissions)
getLevel(VISIBILITY_PUBLIC)
else
getLevel(VISIBILITY_PRIVATE)
controller.$parsers.push (privacy) ->
return unless privacy?
if isPublic(privacy.name)
newPermissions = permissions.public()
else
newPermissions = permissions.private()
# Cannot change the $modelValue into a new object
# Just update its properties
for key,val of newPermissions
controller.$modelValue[key] = val
controller.$modelValue
controller.$render = ->
unless controller.$modelValue.read?.length
name = localStorage.getItem VISIBILITY_KEY
name ?= VISIBILITY_PUBLIC
level = getLevel(name)
controller.$setViewValue level
scope.level = controller.$viewValue
scope.levels = levels
scope.setLevel = (level) ->
localStorage.setItem VISIBILITY_KEY, level.name
controller.$setViewValue level
controller.$render()
scope.isPublic = isPublic
require: '?ngModel'
restrict: 'E'
scope:
level: '='
templateUrl: 'privacy.html'
]
'use strict';
var STORAGE_KEY = 'hypothesis.privacy';
var SHARED = 'shared';
var PRIVATE = 'private';
// Return a descriptor object for the passed group.
function describeGroup(group) {
var type;
if (group.public) {
type = 'public';
} else {
type = 'group';
}
return {
name: group.name,
type: type
};
}
// @ngInject
function PrivacyController($scope, localStorage) {
this._level = null;
/**
* @ngdoc method
* @name PrivacyController#level
*
* Returns the current privacy level descriptor.
*/
this.level = function () {
// If the privacy level isn't set yet, we first try and set it from the
// annotation model
if (this._level === null) {
if ($scope.annotation.isPrivate()) {
this._level = PRIVATE;
} else if ($scope.annotation.isShared()) {
this._level = SHARED;
}
// If the annotation is neither (i.e. it's new) we fall through.
}
// If the privacy level still isn't set, try and retrieve it from
// localStorage, falling back to shared.
if (this._level === null) {
var fromStorage = localStorage.getItem(STORAGE_KEY);
if ([SHARED, PRIVATE].indexOf(fromStorage) !== -1) {
this._level = fromStorage;
} else {
this._level = SHARED;
}
// Since we loaded from localStorage, we need to explicitly set this so
// that the annotation model updates.
this.setLevel(this._level);
}
if (this._level === SHARED) {
return this.shared();
}
return this.private();
};
/**
* @ngdoc method
* @name PrivacyController#setLevel
*
* @param {String} level
*
* Sets the current privacy level. `level` may be either 'private' or
* 'shared'.
*/
this.setLevel = function (level) {
if (level === SHARED) {
this._level = SHARED;
$scope.annotation.setShared();
localStorage.setItem(STORAGE_KEY, SHARED);
} else if (level === PRIVATE) {
this._level = PRIVATE;
$scope.annotation.setPrivate();
localStorage.setItem(STORAGE_KEY, PRIVATE);
}
};
/**
* @ngdoc method
* @name PrivacyController#shared
*
* Returns a descriptor object for the current 'shared' privacy level.
*/
this.shared = function () {
return describeGroup($scope.annotation.group());
};
/**
* @ngdoc method
* @name PrivacyController#private
*
* Returns a descriptor object for the current 'private' privacy level.
*/
this.private = function () {
return {
name: 'Only Me',
type: 'private'
};
};
return this;
}
var directive = function () {
return {
controller: PrivacyController,
controllerAs: 'vm',
link: function (scope, elem, attrs, annotation) {
scope.annotation = annotation;
},
require: '^annotation',
restrict: 'E',
scope: {},
templateUrl: 'privacy.html'
};
};
exports.PrivacyController = PrivacyController;
exports.directive = directive;
......@@ -14,6 +14,7 @@ describe 'annotation', ->
fakeAnnotationUI = null
fakeDrafts = null
fakeFlash = null
fakeGroups = null
fakeMomentFilter = null
fakePermissions = null
fakePersonaFilter = null
......@@ -79,6 +80,11 @@ describe 'annotation', ->
}
fakeUrlEncodeFilter = (v) -> encodeURIComponent(v)
fakeGroups = {
focused: -> {}
get: ->
}
$provide.value 'annotationMapper', fakeAnnotationMapper
$provide.value 'annotationUI', fakeAnnotationUI
$provide.value 'drafts', fakeDrafts
......@@ -91,6 +97,7 @@ describe 'annotation', ->
$provide.value 'tags', fakeTags
$provide.value 'time', fakeTime
$provide.value 'urlencodeFilter', fakeUrlEncodeFilter
$provide.value 'groups', fakeGroups
return
beforeEach inject (_$compile_, _$document_, _$rootScope_, _$timeout_) ->
......@@ -557,24 +564,36 @@ describe("AnnotationController", ->
createAnnotationDirective = ({annotation, personaFilter, momentFilter,
urlencodeFilter, drafts, flash,
permissions, session, tags, time, annotationUI,
annotationMapper}) ->
annotationMapper, groups}) ->
locals = {
personaFilter: personaFilter or {}
personaFilter: personaFilter or ->
momentFilter: momentFilter or {}
urlencodeFilter: urlencodeFilter or {}
drafts: drafts or {
add: ->
remove: ->
}
flash: flash or {
info: ->
error: ->
}
permissions: permissions or {
isPublic: -> false
isPrivate: -> false
permits: -> true
}
flash: flash or {}
permissions: permissions or {}
session: session or {state: {}}
tags: tags or {}
tags: tags or {store: ->}
time: time or {
toFuzzyString: ->
nextFuzzyUpdate: ->
}
annotationUI: annotationUI or {}
annotationMapper: annotationMapper or {}
groups: groups or {
get: ->
focused: -> {}
}
}
module(($provide) ->
$provide.value("personaFilter", locals.personaFilter)
......@@ -588,6 +607,7 @@ describe("AnnotationController", ->
$provide.value("time", locals.time)
$provide.value("annotationUI", locals.annotationUI)
$provide.value("annotationMapper", locals.annotationMapper)
$provide.value("groups", locals.groups)
return
)
......@@ -611,6 +631,37 @@ describe("AnnotationController", ->
)
)
describe("save", ->
it("Passes group:<hashid> to the server when saving a new annotation", ->
annotation = {
# The annotation needs to have a user or the controller will refuse to
# save it.
user: 'acct:fred@hypothes.is'
# The annotation needs to have some text or it won't validate.
text: 'foo'
}
# Stub $create so we can spy on what gets sent to the server.
annotation.$create = sinon.stub().returns(Promise.resolve())
group = {hashid: "test-hashid"}
{controller} = createAnnotationDirective({
annotation: annotation
# Mock the groups service, pretend that there's a group with hashid
# "test-group" focused.
groups: {
focused: -> group
get: ->
}
})
controller.action = 'create'
controller.save().then(->
assert annotation.$create.lastCall.thisValue.group == "test-hashid"
)
)
)
###
Simulate what happens when the user edits an annotation, clicks Save,
gets an error because the server fails to save the annotation, then clicks
......@@ -634,22 +685,6 @@ describe("AnnotationController", ->
data: {}
})
}
flash: {
info: ->
error: ->
}
personaFilter: ->
permissions: {
isPrivate: -> false
permits: -> true
}
tags: {
store: ->
}
drafts: {
add: ->
remove: ->
}
)
original_text = controller.annotation.text
......
{module, inject} = angular.mock
VISIBILITY_KEY ='hypothesis.visibility'
VISIBILITY_PUBLIC = 'public'
VISIBILITY_PRIVATE = 'private'
describe 'privacy', ->
$compile = null
$scope = null
$window = null
fakeAuth = null
fakePermissions = null
fakeLocalStorage = null
sandbox = null
before ->
angular.module('h', [])
.directive('privacy', require('../privacy'))
beforeEach module('h')
beforeEach module('h.templates')
beforeEach module ($provide) ->
sandbox = sinon.sandbox.create()
fakeAuth = {
user: 'acct:angry.joe@texas.com'
}
storage = {}
fakeLocalStorage = {
getItem: sandbox.spy (key) -> storage[key]
setItem: sandbox.spy (key, value) -> storage[key] = value
removeItem: sandbox.spy (key) -> delete storage[key]
}
fakePermissions = {
isPublic: sandbox.stub().returns(true)
isPrivate: sandbox.stub().returns(false)
permits: sandbox.stub().returns(true)
public: sandbox.stub().returns({read: ['everybody']})
private: sandbox.stub().returns({read: ['justme']})
}
$provide.value 'auth', fakeAuth
$provide.value 'localStorage', fakeLocalStorage
$provide.value 'permissions', fakePermissions
return
beforeEach inject (_$compile_, _$rootScope_, _$window_) ->
$compile = _$compile_
$scope = _$rootScope_.$new()
$window = _$window_
afterEach ->
sandbox.restore()
describe 'saves visibility level', ->
it 'stores the default visibility level when it changes', ->
$scope.permissions = {read: ['acct:user@example.com']}
$element = $compile('<privacy ng-model="permissions" level="vm.privacylevel">')($scope)
$scope.$digest()
$isolateScope = $element.isolateScope()
$isolateScope.setLevel(name: VISIBILITY_PUBLIC)
expected = VISIBILITY_PUBLIC
stored = fakeLocalStorage.getItem VISIBILITY_KEY
assert.equal stored, expected
describe 'setting permissions', ->
$element = null
describe 'when no setting is stored', ->
beforeEach ->
fakeLocalStorage.removeItem VISIBILITY_KEY
it 'defaults to public', ->
$scope.permissions = {read: []}
$element = $compile('<privacy ng-model="permissions" level="vm.privacylevel">')($scope)
$scope.$digest()
$isolateScope = $element.isolateScope()
assert.equal $isolateScope.level.name, VISIBILITY_PUBLIC
describe 'when permissions.read is empty', ->
beforeEach ->
fakeLocalStorage.setItem VISIBILITY_KEY, VISIBILITY_PUBLIC
$scope.permissions = {read: []}
$element = $compile('<privacy ng-model="permissions" level="vm.privacylevel">')($scope)
$scope.$digest()
it 'sets the initial permissions based on the stored privacy level', ->
assert.equal $element.isolateScope().level.name, VISIBILITY_PUBLIC
it 'does not alter the level on subsequent renderings', ->
fakeLocalStorage.setItem VISIBILITY_KEY, VISIBILITY_PRIVATE
$scope.permissions.read = ['acct:user@example.com']
$scope.$digest()
assert.equal $element.isolateScope().level.name, VISIBILITY_PUBLIC
describe 'when permissions.read is filled', ->
it 'does not alter the level', ->
fakeLocalStorage.setItem VISIBILITY_KEY, VISIBILITY_PRIVATE
$scope.permissions = {read: ['group:__world__']}
$element = $compile('<privacy ng-model="permissions" level="vm.privacylevel">')($scope)
$scope.$digest()
$isolateScope = $element.isolateScope()
assert.equal($isolateScope.level.name, VISIBILITY_PUBLIC)
describe 'user attribute', ->
beforeEach ->
$scope.permissions = {read: []}
it 'fills the permissions fields with the auth.user name', ->
fakeLocalStorage.setItem VISIBILITY_KEY, VISIBILITY_PRIVATE
$element = $compile('<privacy ng-model="permissions" level="vm.privacylevel">')($scope)
$scope.$digest()
assert.deepEqual $scope.permissions, fakePermissions.private()
it 'puts group_world into the read permissions for public visibility', ->
fakeLocalStorage.setItem VISIBILITY_KEY, VISIBILITY_PUBLIC
$element = $compile('<privacy ng-model="permissions" level="vm.privacylevel">')($scope)
$scope.$digest()
assert.deepEqual $scope.permissions, fakePermissions.public()
'use strict';
var PrivacyController = require('../privacy').PrivacyController;
describe('PrivacyController', function () {
var fakeScope;
var fakeLocalStorage;
var sandbox;
beforeEach(function () {
sandbox = sinon.sandbox.create();
fakeScope = {
annotation: {
group: sandbox.stub().returns({name: 'Everyone', public: true}),
isPrivate: sandbox.stub().returns(false),
isShared: sandbox.stub().returns(false),
setPrivate: sandbox.spy(),
setShared: sandbox.spy()
}
};
fakeLocalStorage = {
setItem: sandbox.stub(),
getItem: sandbox.stub()
};
});
afterEach(function () {
sandbox.restore();
});
function controller() {
return new PrivacyController(fakeScope, fakeLocalStorage);
}
describe('.shared()', function () {
it('returns a correct descriptor for the public group', function () {
var c = controller();
var result = c.shared();
assert.deepEqual(result, {name: 'Everyone', type: 'public'});
});
it('returns a correct descriptor for non-public groups', function () {
var c = controller();
fakeScope.annotation.group.returns({name: 'Foo'})
var result = c.shared();
assert.deepEqual(result, {name: 'Foo', type: 'group'});
});
});
describe('.level()', function () {
it('returns the public level if the annotation is shared', function () {
var c = controller();
fakeScope.annotation.isShared.returns(true);
var result = c.level();
assert.equal(result.type, 'public');
});
it('returns the private level if the annotation is private', function () {
var c = controller();
fakeScope.annotation.isPrivate.returns(true);
var result = c.level();
assert.equal(result.type, 'private');
});
it('falls back to localStorage if the annotation is new (shared)', function () {
var c = controller();
fakeLocalStorage.getItem.returns('shared');
var result = c.level();
assert.equal(result.type, 'public');
});
it('falls back to localStorage if the annotation is new (private)', function () {
var c = controller();
fakeLocalStorage.getItem.returns('private');
var result = c.level();
assert.equal(result.type, 'private');
});
it('calls setLevel if the annotation is new to update the model', function () {
var c = controller();
sandbox.spy(c, 'setLevel');
c.level();
assert.calledWith(c.setLevel, 'shared');
});
it('ignores junk data in localStorage', function () {
var c = controller();
fakeLocalStorage.getItem.returns('aslkdhasdug');
var result = c.level();
assert.equal(result.type, 'public');
});
it('falls back to the public level by default', function () {
var c = controller();
var result = c.level();
assert.equal(result.type, 'public');
});
});
describe('.setLevel()', function () {
it('sets the controller state', function () {
var c = controller();
c.setLevel('shared');
assert.equal(c.level().type, 'public');
c.setLevel('private');
assert.equal(c.level().type, 'private');
});
it('calls setShared on the annotation when setting level to shared', function () {
var c = controller();
c.setLevel('shared');
assert.calledOnce(fakeScope.annotation.setShared);
});
it('calls setPrivate on the annotation when setting level to private', function () {
var c = controller();
c.setLevel('private');
assert.calledOnce(fakeScope.annotation.setPrivate);
});
it('stores the last permissions state in localStorage', function () {
var c = controller();
c.setLevel('shared');
assert.calledWithMatch(fakeLocalStorage.setItem,
sinon.match.any,
'shared');
c.setLevel('private');
assert.calledWithMatch(fakeLocalStorage.setItem,
sinon.match.any,
'private');
});
});
});
/**
* @ngdoc service
* @name groups
*
* @description
* Get and set the UI's currently focused group.
*/
'use strict';
var STORAGE_KEY = 'hypothesis.groups.focus';
// @ngInject
function groups(localStorage, session) {
// The currently focused group. This is the group that's shown as selected in
// the groups dropdown, the annotations displayed are filtered to only ones
// that belong to this group, and any new annotations that the user creates
// will be created in this group.
var focused;
var all = function all() {
return session.state.groups || [];
};
// Return the full object for the group with the given hashid.
var get = function get(hashid) {
var gs = all();
for (var i = 0, max = gs.length; i < max; i++) {
if (gs[i].hashid === hashid) {
return gs[i];
}
}
};
return {
all: all,
get: get,
// Return the currently focused group. If no group is explicitly focused we
// will check localStorage to see if we have persisted a focused group from
// a previous session. Lastly, we fall back to the first group available.
focused: function() {
if (focused) {
return focused;
}
var fromStorage = get(localStorage.getItem(STORAGE_KEY));
if (typeof fromStorage !== 'undefined') {
focused = fromStorage;
return focused;
}
return all()[0];
},
// Set the group with the passed hashid as the currently focused group.
focus: function(hashid) {
var g = get(hashid);
if (typeof g !== 'undefined') {
focused = g;
localStorage.setItem(STORAGE_KEY, g.hashid);
}
}
};
}
module.exports = groups;
......@@ -44,27 +44,40 @@ module.exports = ['session', (session) ->
###*
# @ngdoc method
# @name permissions#private
# @name permissions#public
#
# @param {String} [group] Group to make annotation public in.
#
# Sets permissions for a public annotation
# Typical use: annotation.permissions = permissions.public()
###
public: ->
read: [GROUP_WORLD]
update: [session.state.userid]
delete: [session.state.userid]
admin: [session.state.userid]
public: (group) ->
if group?
group = 'group:' + group
else
group = GROUP_WORLD
return {
read: [group]
update: [session.state.userid]
delete: [session.state.userid]
admin: [session.state.userid]
}
###*
# @ngdoc method
# @name permissions#isPublic
#
# @param {Object} permissions
# @param {String} [group]
#
# This function determines whether the permissions allow public visibility
###
isPublic: (permissions) ->
GROUP_WORLD in (permissions?.read or [])
isPublic: (permissions, group) ->
if group?
group = 'group:' + group
else
group = GROUP_WORLD
group in (permissions?.read or [])
###*
# @ngdoc method
......
'use strict';
var groups = require('../groups');
// Return a mock session service containing three groups.
var sessionWithThreeGroups = function() {
return {
state: {
groups: [
{name: 'Group 1', hashid: 'id1'},
{name: 'Group 2', hashid: 'id2'},
{name: 'Group 3', hashid: 'id3'},
]
}
};
};
describe('groups', function() {
var fakeSession;
var fakeLocalStorage;
var sandbox;
beforeEach(function () {
sandbox = sinon.sandbox.create();
fakeSession = sessionWithThreeGroups();
fakeLocalStorage = {
getItem: sandbox.stub(),
setItem: sandbox.stub()
};
});
afterEach(function () {
sandbox.restore();
});
function service() {
return groups(fakeLocalStorage, fakeSession);
}
describe('.all()', function() {
it('returns no groups if there are none in the session', function() {
fakeSession = {state: {groups: []}};
var groups = service().all();
assert.equal(groups.length, 0);
});
it('returns the groups from the session when there are some', function() {
var groups = service().all();
assert.equal(groups.length, 3);
assert.deepEqual(groups, [
{name: 'Group 1', hashid: 'id1'},
{name: 'Group 2', hashid: 'id2'},
{name: 'Group 3', hashid: 'id3'}
]);
});
});
describe('.get() method', function() {
it('returns the requested group', function() {
var group = service().get('id2');
assert.equal(group.hashid, 'id2');
});
it("returns undefined if the group doesn't exist", function() {
var group = service().get('foobar');
assert.isUndefined(group);
});
});
describe('.focused() method', function() {
it('returns the focused group', function() {
var s = service();
s.focus('id2');
assert.equal(s.focused().hashid, 'id2');
});
it('returns the first group initially', function() {
var s = service();
assert.equal(s.focused().hashid, 'id1');
});
it('returns the group selected in localStorage if available', function() {
fakeLocalStorage.getItem.returns('id3');
var s = service();
assert.equal(s.focused().hashid, 'id3');
});
});
describe('.focus() method', function() {
it('sets the focused group to the named group', function() {
var s = service();
s.focus('id2');
assert.equal(s.focused().hashid, 'id2');
});
it("does nothing if the named group isn't recognised", function() {
var s = service();
s.focus('foobar');
assert.equal(s.focused().hashid, 'id1');
});
it("stores the focused group hashid in localStorage", function() {
var s = service();
s.focus('id3');
assert.calledWithMatch(fakeLocalStorage.setItem, sinon.match.any, 'id3');
});
});
});
......@@ -45,6 +45,13 @@ describe 'h:permissions', ->
assert.equal(perms.delete[0], 'acct:flash@gordon')
assert.equal(perms.admin[0], 'acct:flash@gordon')
it 'public call fills the read property with group:foo if passed "foo"', ->
perms = permissions.public("foo")
assert.equal(perms.read[0], 'group:foo')
assert.equal(perms.update[0], 'acct:flash@gordon')
assert.equal(perms.delete[0], 'acct:flash@gordon')
assert.equal(perms.admin[0], 'acct:flash@gordon')
describe 'isPublic', ->
it 'isPublic() true if the read permission has group:__world__ in it', ->
permission = {
......
......@@ -82,20 +82,25 @@ ol {
/* The groups dropdown list. */
.group-list {
margin-right: 0.5em;
}
$group-list-width: 225px;
.group-list .dropdown {
white-space: nowrap;
}
.group-list .dropdown-menu {
width: $group-list-width;
}
.group-list .dropdown-menu li {
@include pie-clearfix;
}
.group-list .dropdown-menu .group-name {
max-width: $group-list-width - 45px;
float: left;
overflow: hidden;
text-overflow: ellipsis;
width: $group-list-width - 30px;
}
.user-picker {
......
......@@ -35,6 +35,11 @@
@include flex-grow(1);
-ms-flex: 1; /* IE10 support */
margin-right: .75em;
overflow: hidden;
}
.topbar .inner .group-list {
margin-right: .75em;
}
.topbar .btn {
......
This diff is collapsed.
......@@ -13,6 +13,13 @@
target="_blank"
ng-href="{{vm.baseURI}}u/{{vm.annotation.user}}"
>{{vm.annotation.user | persona}}</a>
<span class="small" ng-if="vm.group() && vm.group().url">
to
<a target="_blank" href="{{vm.group().url}}">
<i class="h-icon-group" title="{{vm.group.name}}"></i>
{{vm.group().name}}
</a>
</span>
<i class="h-icon-border-color" ng-show="vm.isHighlight() && !vm.editing" title="This is a highlight. Click 'edit' to add a note or tag."></i>
<span ng-show="vm.isPrivate() && !vm.editing"
title="This annotation is visible only to you.">
......@@ -31,11 +38,7 @@
<aside class="pull-right" ng-if="vm.editing">
<privacy ng-click="$event.stopPropagation()"
ng-if="vm.annotation.permissions && vm.editing && action != 'delete'"
ng-model="vm.annotation.permissions"
level="vm.privacyLevel"
user="{{vm.annotation.user}}"
class="dropdown privacy pull-right"
name="privacy" />
model="annotation">
</aside>
<!-- / Editing controls -->
</span>
......@@ -115,10 +118,19 @@
<footer class="annotation-footer">
<div class="small" ng-if="vm.editing">
<p ng-show="vm.privacyLevel.text == 'Only Me'">
<i class="h-icon-lock"></i> This annotation is visible only to you.</p>
<p ng-show="vm.privacyLevel.text == 'Public'">
<i class="h-icon-public"></i> This annotation is visible to everyone.</p>
<p ng-show="vm.isPrivate()">
<i class="h-icon-lock"></i>
This annotation will be visible only to you.
</p>
<p ng-show="vm.isShared() && vm.group().public">
<i class="h-icon-public"></i>
This annotation will be visible to everyone.
</p>
<p ng-show="vm.isShared() && !vm.group().public">
<i class="h-icon-group"></i>
This annotation will be visible to everyone in the
<strong ng-bind="vm.group().name"></strong> group.
</p>
</div>
<div class="form-actions" ng-if="vm.editing" ng-switch="vm.action">
......@@ -139,7 +151,7 @@
</div>
<div class="annotation-section annotation-license"
ng-show="vm.privacyLevel.name != 'private' && vm.editing">
ng-show="vm.isShared() && vm.editing">
<a href="http://creativecommons.org/publicdomain/zero/1.0/"
title="View more information about the Creative Commons Public Domain license"
target="_blank">
......
<span role="button" class="dropdown-toggle" data-toggle="dropdown">
Groups
<i class="h-icon-arrow-drop-down"></i>
</span>
<ul class="dropdown-menu pull-right" role="menu">
<li ng-repeat="group in groups">
<a ng-href="{{group.url}}" ng-bind="group.name" target="_blank"
class="group-name pull-left"></a>
<a ng-href="{{group.url}}" target="_blank" class="h-icon-link pull-right"
title="Share this group"></a>
<div style="clear:both;"></div>
</li>
<li>
<a href="/groups/new" target="_blank"><i class="h-icon-add"></i> New Group</a>
</li>
</ul>
<div class="pull-right dropdown">
<span class="dropdown-toggle"
data-toggle="dropdown"
role="button"
ng-switch on="groups.focused().public">
<i class="h-icon-public" ng-switch-when="true"></i>
<i class="h-icon-group" ng-switch-default></i>
{{groups.focused().name}}
<i class="h-icon-arrow-drop-down"></i>
</span>
<ul class="dropdown-menu pull-right" role="menu">
<li ng-repeat="group in groups.all()"
ng-class="group.hashid == groups.focused().hashid? 'selected' : ''">
<a class="group-name"
href=""
ng-click="groups.focus(group.hashid)"
ng-switch on="group.public">
<i class="h-icon-public" ng-switch-when="true"></i>
<i class="h-icon-group" ng-switch-default></i>
{{group.name}}
</a>
<a ng-href="{{group.url}}" ng-if="group.url"
target="_blank" class="h-icon-link" title="Share this group"></a>
</li>
<li>
<a href="/groups/new" target="_blank"><i class="h-icon-add"></i>
New Group</a>
</li>
</ul>
</div>
......@@ -3,17 +3,26 @@
role="button"
class="dropdown-toggle"
data-toggle="dropdown">
<i class="small" ng-class="{'h-icon-public': isPublic(level.name),
'h-icon-lock': !isPublic(level.name)}"></i>
<span ng-bind="level.text"></span>
<i class="small"
ng-class="{'h-icon-public': vm.level().type === 'public',
'h-icon-group': vm.level().type === 'group',
'h-icon-lock': vm.level().type === 'private'}"></i>
<span ng-bind="vm.level().name"></span>
<i class="h-icon-arrow-drop-down"></i>
</span>
<ul class="dropdown-menu" role="menu">
<li ng-repeat="level in levels" ng-click="setLevel(level)">
<li ng-click="vm.setLevel('shared')">
<a href="">
<i class="small" ng-class="{'h-icon-public': isPublic(level.name),
'h-icon-lock': !isPublic(level.name)}"></i>
<span ng-bind="level.text"></span>
<i class="small"
ng-class="{'h-icon-public': vm.shared().type === 'public',
'h-icon-group': vm.shared().type === 'group'}"></i>
<span ng-bind="vm.shared().name"></span>
</a>
</li>
<li ng-click="vm.setLevel('private')">
<a href="">
<i class="small h-icon-lock"></i>
<span ng-bind="vm.private().name"></span>
</a>
</li>
</ul>
......
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