Unverified Commit c6128ee0 authored by Hannah Stepanek's avatar Hannah Stepanek Committed by GitHub

Merge pull request #1036 from hypothesis/add-group-url

Add annotations:group:GROUP_ID fragment url
parents 30d967f5 ae25b323
...@@ -23,6 +23,7 @@ function configFrom(window_) { ...@@ -23,6 +23,7 @@ function configFrom(window_) {
enableExperimentalNewNoteButton: settings.hostPageSetting( enableExperimentalNewNoteButton: settings.hostPageSetting(
'enableExperimentalNewNoteButton' 'enableExperimentalNewNoteButton'
), ),
group: settings.group,
theme: settings.hostPageSetting('theme'), theme: settings.hostPageSetting('theme'),
usernameUrl: settings.hostPageSetting('usernameUrl'), usernameUrl: settings.hostPageSetting('usernameUrl'),
onLayoutChange: settings.hostPageSetting('onLayoutChange'), onLayoutChange: settings.hostPageSetting('onLayoutChange'),
......
...@@ -102,6 +102,28 @@ function settingsFrom(window_) { ...@@ -102,6 +102,28 @@ function settingsFrom(window_) {
return jsonConfigs.annotations || annotationsFromURL(); return jsonConfigs.annotations || annotationsFromURL();
} }
/**
* Return the `#annotations:group:*` ID from the given URL's fragment.
*
* If the URL contains a `#annotations:group:<GROUP_ID>` fragment then return
* the group ID extracted from the fragment. Otherwise return `null`.
*
* @return {string|null} - The extracted ID, or null.
*/
function group() {
function groupFromURL() {
const groupFragmentMatch = window_.location.href.match(
/#annotations:group:([A-Za-z0-9_-]+)$/
);
if (groupFragmentMatch) {
return groupFragmentMatch[1];
}
return null;
}
return jsonConfigs.group || groupFromURL();
}
function showHighlights() { function showHighlights() {
let showHighlights_ = hostPageSetting('showHighlights'); let showHighlights_ = hostPageSetting('showHighlights');
...@@ -179,6 +201,9 @@ function settingsFrom(window_) { ...@@ -179,6 +201,9 @@ function settingsFrom(window_) {
get clientUrl() { get clientUrl() {
return clientUrl(); return clientUrl();
}, },
get group() {
return group();
},
get showHighlights() { get showHighlights() {
return showHighlights(); return showHighlights();
}, },
......
...@@ -27,17 +27,17 @@ describe('annotator.config.index', function() { ...@@ -27,17 +27,17 @@ describe('annotator.config.index', function() {
assert.calledWithExactly(fakeSettingsFrom, 'WINDOW'); assert.calledWithExactly(fakeSettingsFrom, 'WINDOW');
}); });
['sidebarAppUrl', 'query', 'annotations', 'showHighlights'].forEach(function( ['sidebarAppUrl', 'query', 'annotations', 'group', 'showHighlights'].forEach(
settingName settingName => {
) { it('returns the ' + settingName + ' setting', () => {
it('returns the ' + settingName + ' setting', function() { fakeSettingsFrom()[settingName] = 'SETTING_VALUE';
fakeSettingsFrom()[settingName] = 'SETTING_VALUE';
const config = configFrom('WINDOW'); const config = configFrom('WINDOW');
assert.equal(config[settingName], 'SETTING_VALUE'); assert.equal(config[settingName], 'SETTING_VALUE');
}); });
}); }
);
context("when there's no application/annotator+html <link>", function() { context("when there's no application/annotator+html <link>", function() {
beforeEach('remove the application/annotator+html <link>', function() { beforeEach('remove the application/annotator+html <link>', function() {
......
...@@ -289,6 +289,29 @@ describe('annotator.config.settingsFrom', function() { ...@@ -289,6 +289,29 @@ describe('annotator.config.settingsFrom', function() {
}); });
}); });
[
{
description:
"returns an object with the group ID when there's a valid #annotations:group:<ID> fragment",
url: 'http://localhost:3000#annotations:group:alphanum3ric_-only',
returns: 'alphanum3ric_-only',
},
{
description: "returns null when there's a non-alphanumeric group ID",
url: 'http://localhost:3000#annotations:group:not%20alphanumeric',
returns: null,
},
{
description: "return null when there's an empty group ID",
url: 'http://localhost:3000#annotations:group:',
returns: null,
},
].forEach(test => {
it(test.description, () => {
assert.deepEqual(settingsFrom(fakeWindow(test.url)).group, test.returns);
});
});
describe('#query', function() { describe('#query', function() {
context( context(
'when the host page has a js-hypothesis-config with a query setting', 'when the host page has a js-hypothesis-config with a query setting',
......
...@@ -17,6 +17,9 @@ function hostPageConfig(window) { ...@@ -17,6 +17,9 @@ function hostPageConfig(window) {
// Direct-linked annotation ID // Direct-linked annotation ID
'annotations', 'annotations',
// Direct-linked group ID
'group',
// Default query passed by url // Default query passed by url
'query', 'query',
......
...@@ -232,6 +232,7 @@ function api(apiRoutes, auth, store) { ...@@ -232,6 +232,7 @@ function api(apiRoutes, auth, store) {
member: { member: {
delete: apiCall('group.member.delete'), delete: apiCall('group.member.delete'),
}, },
read: apiCall('group.read'),
}, },
groups: { groups: {
list: apiCall('groups.read'), list: apiCall('groups.read'),
......
...@@ -59,9 +59,15 @@ function groups( ...@@ -59,9 +59,15 @@ function groups(
* @param {Group[]} groups * @param {Group[]} groups
* @param {boolean} isLoggedIn * @param {boolean} isLoggedIn
* @param {string|null} directLinkedAnnotationId * @param {string|null} directLinkedAnnotationId
* @param {string|null} directLinkedGroupId
* @return {Promise<Group[]>} * @return {Promise<Group[]>}
*/ */
function filterGroups(groups, isLoggedIn, directLinkedAnnotationId) { function filterGroups(
groups,
isLoggedIn,
directLinkedAnnotationId,
directLinkedGroupId
) {
// If service groups are specified only return those. // If service groups are specified only return those.
// If a service group doesn't exist in the list of groups don't return it. // If a service group doesn't exist in the list of groups don't return it.
if (svc && svc.groups) { if (svc && svc.groups) {
...@@ -89,18 +95,26 @@ function groups( ...@@ -89,18 +95,26 @@ function groups(
// link to an annotation in that group. // link to an annotation in that group.
const nonWorldGroups = groups.filter(g => g.id !== '__world__'); const nonWorldGroups = groups.filter(g => g.id !== '__world__');
if (!directLinkedAnnotationId) { if (!directLinkedAnnotationId && !directLinkedGroupId) {
return Promise.resolve(nonWorldGroups); return Promise.resolve(nonWorldGroups);
} }
return api.annotation // If directLinkedGroup is the "Public" group, always return groups.
.get({ id: directLinkedAnnotationId }) if (directLinkedGroupId === '__world__') {
return groups;
}
// If the directLinkedAnnotationId's group is the "Public" group return groups,
// otherwise filter out the "Public" group.
// Force getAnnotation to enter the catch clause if there is no linked annotation.
const getAnnotation = directLinkedAnnotationId
? api.annotation.get({ id: directLinkedAnnotationId })
: Promise.reject();
return getAnnotation
.then(ann => { .then(ann => {
if (ann.group === '__world__') { return ann.group === '__world__' ? groups : nonWorldGroups;
return groups;
} else {
return nonWorldGroups;
}
}) })
.catch(() => { .catch(() => {
// Annotation does not exist or we do not have permission to read it. // Annotation does not exist or we do not have permission to read it.
...@@ -144,6 +158,7 @@ function groups( ...@@ -144,6 +158,7 @@ function groups(
if (isSidebar) { if (isSidebar) {
uri = getDocumentUriForGroupSearch(); uri = getDocumentUriForGroupSearch();
} }
const directLinkedGroup = settings.group;
return uri return uri
.then(uri => { .then(uri => {
const params = { const params = {
...@@ -161,20 +176,44 @@ function groups( ...@@ -161,20 +176,44 @@ function groups(
expand: params.expand, expand: params.expand,
}); });
const listGroupsApi = api.groups.list(params); const listGroupsApi = api.groups.list(params);
let groupApiRequests = [
return Promise.all([
profileGroupsApi, profileGroupsApi,
listGroupsApi, listGroupsApi,
auth.tokenGetter(), auth.tokenGetter(),
]).then(([myGroups, featuredGroups, token]) => [ ];
combineGroups(myGroups, featuredGroups, documentUri),
token, // If there is a directLinkedGroup, add an api request to get that
]); // particular group as well since it may not be in the results returned
// by group.list or profile.groups.
if (directLinkedGroup) {
const selectedGroupApi = api.group.read({
id: directLinkedGroup,
expand: params.expand,
});
groupApiRequests = groupApiRequests.concat(selectedGroupApi);
}
return Promise.all(groupApiRequests).then(
([myGroups, featuredGroups, token, selectedGroup]) => [
combineGroups(
myGroups,
selectedGroup !== undefined
? featuredGroups.concat([selectedGroup])
: featuredGroups,
documentUri
),
token,
]
);
}) })
.then(([groups, token]) => { .then(([groups, token]) => {
const isLoggedIn = token !== null; const isLoggedIn = token !== null;
const directLinkedAnnotation = settings.annotations; const directLinkedAnnotation = settings.annotations;
return filterGroups(groups, isLoggedIn, directLinkedAnnotation); return filterGroups(
groups,
isLoggedIn,
directLinkedAnnotation,
directLinkedGroup
);
}) })
.then(groups => { .then(groups => {
injectOrganizations(groups); injectOrganizations(groups);
...@@ -183,7 +222,9 @@ function groups( ...@@ -183,7 +222,9 @@ function groups(
const prevFocusedGroup = localStorage.getItem(STORAGE_KEY); const prevFocusedGroup = localStorage.getItem(STORAGE_KEY);
store.loadGroups(groups); store.loadGroups(groups);
if (isFirstLoad && groups.some(g => g.id === prevFocusedGroup)) { if (isFirstLoad && groups.some(g => g.id === directLinkedGroup)) {
store.focusGroup(directLinkedGroup);
} else if (isFirstLoad && groups.some(g => g.id === prevFocusedGroup)) {
store.focusGroup(prevFocusedGroup); store.focusGroup(prevFocusedGroup);
} }
......
...@@ -29,6 +29,11 @@ ...@@ -29,6 +29,11 @@
"method": "DELETE", "method": "DELETE",
"desc": "Remove the current user from a group." "desc": "Remove the current user from a group."
} }
},
"read": {
"url": "https://example.com/api/groups/:id",
"method": "GET",
"desc": "Fetch a group."
} }
}, },
"links": { "links": {
......
...@@ -124,6 +124,14 @@ describe('sidebar.services.api', function() { ...@@ -124,6 +124,14 @@ describe('sidebar.services.api', function() {
return api.group.member.delete({ pubid: 'an-id', userid: 'me' }); return api.group.member.delete({ pubid: 'an-id', userid: 'me' });
}); });
it('gets a group by provided group id', () => {
const group = { id: 'group-id', name: 'Group' };
expectCall('get', 'groups/group-id', 200, group);
return api.group.read({ id: 'group-id' }).then(group_ => {
assert.deepEqual(group_, group);
});
});
it('removes internal properties before sending data to the server', () => { it('removes internal properties before sending data to the server', () => {
const annotation = { const annotation = {
$highlight: true, $highlight: true,
......
...@@ -50,7 +50,6 @@ describe('groups', function() { ...@@ -50,7 +50,6 @@ describe('groups', function() {
let fakeLocalStorage; let fakeLocalStorage;
let fakeRootScope; let fakeRootScope;
let fakeServiceUrl; let fakeServiceUrl;
let sandbox;
beforeEach(function() { beforeEach(function() {
fakeAuth = { fakeAuth = {
...@@ -59,7 +58,6 @@ describe('groups', function() { ...@@ -59,7 +58,6 @@ describe('groups', function() {
fakeFeatures = { fakeFeatures = {
flagEnabled: sinon.stub().returns(false), flagEnabled: sinon.stub().returns(false),
}; };
sandbox = sinon.sandbox.create();
fakeStore = fakeReduxStore( fakeStore = fakeReduxStore(
{ {
...@@ -92,8 +90,8 @@ describe('groups', function() { ...@@ -92,8 +90,8 @@ describe('groups', function() {
fakeSession = sessionWithThreeGroups(); fakeSession = sessionWithThreeGroups();
fakeIsSidebar = true; fakeIsSidebar = true;
fakeLocalStorage = { fakeLocalStorage = {
getItem: sandbox.stub(), getItem: sinon.stub(),
setItem: sandbox.stub(), setItem: sinon.stub(),
}; };
fakeRootScope = { fakeRootScope = {
eventCallbacks: {}, eventCallbacks: {},
...@@ -108,7 +106,7 @@ describe('groups', function() { ...@@ -108,7 +106,7 @@ describe('groups', function() {
} }
}, },
$broadcast: sandbox.stub(), $broadcast: sinon.stub(),
}; };
fakeApi = { fakeApi = {
annotation: { annotation: {
...@@ -117,24 +115,21 @@ describe('groups', function() { ...@@ -117,24 +115,21 @@ describe('groups', function() {
group: { group: {
member: { member: {
delete: sandbox.stub().returns(Promise.resolve()), delete: sinon.stub().returns(Promise.resolve()),
}, },
read: sinon.stub().returns(Promise.resolve()),
}, },
groups: { groups: {
list: sandbox.stub().returns(dummyGroups), list: sinon.stub().returns(dummyGroups),
}, },
profile: { profile: {
groups: { groups: {
read: sandbox.stub().returns(Promise.resolve([dummyGroups[0]])), read: sinon.stub().returns(Promise.resolve([dummyGroups[0]])),
}, },
}, },
}; };
fakeServiceUrl = sandbox.stub(); fakeServiceUrl = sinon.stub();
fakeSettings = {}; fakeSettings = { group: null };
});
afterEach(function() {
sandbox.restore();
}); });
function service() { function service() {
...@@ -202,6 +197,45 @@ describe('groups', function() { ...@@ -202,6 +197,45 @@ describe('groups', function() {
}); });
}); });
it('combines groups from all 3 endpoints if there is a selectedGroup', () => {
const svc = service();
fakeSettings.group = 'selected-id';
const groups = [
{ id: 'groupa', name: 'GroupA' },
{ id: 'groupb', name: 'GroupB' },
{ id: fakeSettings.group, name: 'Selected Group' },
];
fakeApi.profile.groups.read.returns(Promise.resolve([groups[0]]));
fakeApi.groups.list.returns(Promise.resolve([groups[1]]));
fakeApi.group.read.returns(Promise.resolve(groups[2]));
return svc.load().then(() => {
assert.calledWith(fakeStore.loadGroups, groups);
});
});
it('passes the groupid from settings.group to the api.group.read call', () => {
const svc = service();
fakeSettings.group = 'selected-id';
const group = { id: fakeSettings.group, name: 'Selected Group' };
fakeApi.profile.groups.read.returns(Promise.resolve([]));
fakeApi.groups.list.returns(Promise.resolve([]));
fakeApi.group.read.returns(Promise.resolve(group));
return svc.load().then(() => {
assert.calledWith(
fakeApi.group.read,
sinon.match({
id: fakeSettings.group,
})
);
});
});
it('loads all available groups', function() { it('loads all available groups', function() {
const svc = service(); const svc = service();
...@@ -215,6 +249,7 @@ describe('groups', function() { ...@@ -215,6 +249,7 @@ describe('groups', function() {
fakeApi.groups.list.returns( fakeApi.groups.list.returns(
Promise.resolve([{ id: 'groupa', name: 'GroupA' }]) Promise.resolve([{ id: 'groupa', name: 'GroupA' }])
); );
fakeSettings.group = 'group-id';
return svc.load().then(() => { return svc.load().then(() => {
assert.calledWith( assert.calledWith(
...@@ -225,6 +260,10 @@ describe('groups', function() { ...@@ -225,6 +260,10 @@ describe('groups', function() {
fakeApi.groups.list, fakeApi.groups.list,
sinon.match({ expand: ['organization', 'scopes'] }) sinon.match({ expand: ['organization', 'scopes'] })
); );
assert.calledWith(
fakeApi.group.read,
sinon.match({ expand: ['organization', 'scopes'] })
);
}); });
}); });
...@@ -236,6 +275,25 @@ describe('groups', function() { ...@@ -236,6 +275,25 @@ describe('groups', function() {
}); });
}); });
it('sets the direct-linked group to take precedence over the group saved in local storage', () => {
const svc = service();
fakeSettings.group = dummyGroups[1].id;
fakeLocalStorage.getItem.returns(dummyGroups[0].id);
fakeApi.groups.list.returns(Promise.resolve(dummyGroups));
return svc.load().then(() => {
assert.calledWith(fakeStore.focusGroup, dummyGroups[1].id);
});
});
it('sets the focused group to the linked group', () => {
const svc = service();
fakeSettings.group = dummyGroups[1].id;
fakeApi.groups.list.returns(Promise.resolve(dummyGroups));
return svc.load().then(() => {
assert.calledWith(fakeStore.focusGroup, fakeSettings.group);
});
});
[null, 'some-group-id'].forEach(groupId => { [null, 'some-group-id'].forEach(groupId => {
it('does not set the focused group if not present in the groups list', () => { it('does not set the focused group if not present in the groups list', () => {
const svc = service(); const svc = service();
...@@ -300,6 +358,70 @@ describe('groups', function() { ...@@ -300,6 +358,70 @@ describe('groups', function() {
}); });
}); });
it('both groups are in the final groups list when an annotation and a group are linked to', () => {
// This can happen if the linked to annotation and group are configured by
// the frame embedding the client.
const svc = service();
fakeSettings.group = 'out-of-scope';
fakeSettings.annotations = 'ann-id';
fakeApi.profile.groups.read.returns(Promise.resolve([]));
fakeApi.groups.list.returns(
Promise.resolve([
{ name: 'BioPub', id: 'biopub' },
{ name: 'Public', id: '__world__' },
])
);
fakeApi.group.read.returns(
Promise.resolve({ name: 'Restricted', id: 'out-of-scope' })
);
fakeApi.annotation.get.returns(
Promise.resolve({
id: 'ann-id',
group: '__world__',
})
);
// The user is logged out.
fakeAuth.tokenGetter.returns(null);
return svc.load().then(groups => {
const linkedToGroupShown = groups.some(g => g.id === 'out-of-scope');
assert.isTrue(linkedToGroupShown);
const linkedToAnnGroupShown = groups.some(g => g.id === '__world__');
assert.isTrue(linkedToAnnGroupShown);
});
});
it('includes the "Public" group if the user links to it', () => {
// Set up the test under conditions that would otherwise
// not return the Public group. Aka: the user is logged
// out and there are associated groups.
const svc = service();
fakeSettings.group = '__world__';
fakeSettings.annotations = undefined;
fakeApi.profile.groups.read.returns(Promise.resolve([]));
fakeApi.groups.list.returns(
Promise.resolve([
{ name: 'BioPub', id: 'biopub' },
{ name: 'Public', id: '__world__' },
])
);
fakeApi.group.read.returns(
Promise.resolve({ name: 'Public', id: '__world__' })
);
fakeAuth.tokenGetter.returns(null);
return svc.load().then(groups => {
const publicGroupShown = groups.some(g => g.id === '__world__');
assert.isTrue(publicGroupShown);
});
});
truthTable(3).forEach( truthTable(3).forEach(
([loggedIn, pageHasAssociatedGroups, directLinkToPublicAnnotation]) => { ([loggedIn, pageHasAssociatedGroups, directLinkToPublicAnnotation]) => {
it('excludes the "Public" group if user logged out and page has associated groups', () => { it('excludes the "Public" group if user logged out and page has associated groups', () => {
...@@ -319,7 +441,7 @@ describe('groups', function() { ...@@ -319,7 +441,7 @@ describe('groups', function() {
); );
fakeSettings.annotations = 'direct-linked-ann'; fakeSettings.annotations = 'direct-linked-ann';
} else { } else {
fakeSettings.annotations = null; fakeSettings.annotations = undefined;
} }
// Create groups response from server. // Create groups response from server.
...@@ -330,7 +452,6 @@ describe('groups', function() { ...@@ -330,7 +452,6 @@ describe('groups', function() {
fakeAuth.tokenGetter.returns(loggedIn ? '1234' : null); fakeAuth.tokenGetter.returns(loggedIn ? '1234' : null);
fakeApi.groups.list.returns(Promise.resolve(groups)); fakeApi.groups.list.returns(Promise.resolve(groups));
fakeApi.profile.groups.read.returns(Promise.resolve([]));
return svc.load().then(groups => { return svc.load().then(groups => {
const publicGroupShown = groups.some(g => g.id === '__world__'); const publicGroupShown = groups.some(g => g.id === '__world__');
......
...@@ -14,6 +14,7 @@ describe('hostPageConfig', function() { ...@@ -14,6 +14,7 @@ describe('hostPageConfig', function() {
it('parses config from location string and returns whitelisted params', function() { it('parses config from location string and returns whitelisted params', function() {
const window_ = fakeWindow({ const window_ = fakeWindow({
annotations: '1234', annotations: '1234',
group: 'abc12',
appType: 'bookmarklet', appType: 'bookmarklet',
openSidebar: true, openSidebar: true,
requestConfigFromFrame: 'https://embedder.com', requestConfigFromFrame: 'https://embedder.com',
...@@ -27,6 +28,7 @@ describe('hostPageConfig', function() { ...@@ -27,6 +28,7 @@ describe('hostPageConfig', function() {
assert.deepEqual(hostPageConfig(window_), { assert.deepEqual(hostPageConfig(window_), {
annotations: '1234', annotations: '1234',
group: 'abc12',
appType: 'bookmarklet', appType: 'bookmarklet',
openSidebar: true, openSidebar: true,
requestConfigFromFrame: 'https://embedder.com', requestConfigFromFrame: 'https://embedder.com',
......
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