Unverified Commit 2ae4096e authored by Hannah Stepanek's avatar Hannah Stepanek Committed by GitHub

Merge pull request #1064 from hypothesis/handle-illegal-group-link-states

Handle illegal group link states
parents 750b2017 8df71147
...@@ -15,7 +15,8 @@ module.exports = { ...@@ -15,7 +15,8 @@ module.exports = {
onClearSelection: '&', onClearSelection: '&',
searchQuery: '<', searchQuery: '<',
selectedTab: '<', selectedTab: '<',
selectionCount: '<', // Boolean indicating all annotations are visible (none are hidden).
areAllAnnotationsVisible: '<',
totalAnnotations: '<', totalAnnotations: '<',
totalNotes: '<', totalNotes: '<',
}, },
......
...@@ -49,6 +49,8 @@ function SidebarContentController( ...@@ -49,6 +49,8 @@ function SidebarContentController(
streamFilter streamFilter
) { ) {
const self = this; const self = this;
this.directLinkedGroupFetchFailed =
!!settings.group && settings.group !== store.focusedGroup().id;
function thread() { function thread() {
return rootThread.thread(store.getState()); return rootThread.thread(store.getState());
...@@ -161,7 +163,7 @@ function SidebarContentController( ...@@ -161,7 +163,7 @@ function SidebarContentController(
searchClient.get({ uri: uris, group: group }); searchClient.get({ uri: uris, group: group });
} }
function isLoading() { this.isLoading = function() {
if ( if (
!store.frames().some(function(frame) { !store.frames().some(function(frame) {
return frame.uri; return frame.uri;
...@@ -177,7 +179,7 @@ function SidebarContentController( ...@@ -177,7 +179,7 @@ function SidebarContentController(
} }
return false; return false;
} };
/** /**
* Load annotations for all URLs associated with `frames`. * Load annotations for all URLs associated with `frames`.
...@@ -272,7 +274,7 @@ function SidebarContentController( ...@@ -272,7 +274,7 @@ function SidebarContentController(
// of switching to the group containing a direct-linked annotation. // of switching to the group containing a direct-linked annotation.
// //
// In that case, we don't want to trigger reloading annotations again. // In that case, we don't want to trigger reloading annotations again.
if (isLoading()) { if (this.isLoading()) {
return; return;
} }
store.clearSelectedAnnotations(); store.clearSelectedAnnotations();
...@@ -283,6 +285,17 @@ function SidebarContentController( ...@@ -283,6 +285,17 @@ function SidebarContentController(
true true
); );
this.showSelectedTabs = function() {
if (
this.selectedAnnotationUnavailable() ||
this.selectedGroupUnavailable() ||
this.search.query()
) {
return false;
}
return true;
};
this.setCollapsed = function(id, collapsed) { this.setCollapsed = function(id, collapsed) {
store.setCollapsed(id, collapsed); store.setCollapsed(id, collapsed);
}; };
...@@ -297,17 +310,26 @@ function SidebarContentController( ...@@ -297,17 +310,26 @@ function SidebarContentController(
this.focus = focusAnnotation; this.focus = focusAnnotation;
this.scrollTo = scrollToAnnotation; this.scrollTo = scrollToAnnotation;
this.selectedAnnotationCount = function() { this.areAllAnnotationsVisible = function() {
if (this.directLinkedGroupFetchFailed) {
return true;
}
const selection = store.getState().selectedAnnotationMap; const selection = store.getState().selectedAnnotationMap;
if (!selection) { if (!selection) {
return 0; return false;
} }
return Object.keys(selection).length; return Object.keys(selection).length > 0;
};
this.selectedGroupUnavailable = function() {
return !this.isLoading() && this.directLinkedGroupFetchFailed;
}; };
this.selectedAnnotationUnavailable = function() { this.selectedAnnotationUnavailable = function() {
const selectedID = firstKey(store.getState().selectedAnnotationMap); const selectedID = firstKey(store.getState().selectedAnnotationMap);
return !isLoading() && !!selectedID && !store.annotationExists(selectedID); return (
!this.isLoading() && !!selectedID && !store.annotationExists(selectedID)
);
}; };
this.shouldShowLoggedOutMessage = function() { this.shouldShowLoggedOutMessage = function() {
...@@ -332,11 +354,11 @@ function SidebarContentController( ...@@ -332,11 +354,11 @@ function SidebarContentController(
// annotation. If there is an annotation selection and that // annotation. If there is an annotation selection and that
// selection is available to the user, show the CTA. // selection is available to the user, show the CTA.
const selectedID = firstKey(store.getState().selectedAnnotationMap); const selectedID = firstKey(store.getState().selectedAnnotationMap);
return !isLoading() && !!selectedID && store.annotationExists(selectedID); return (
!this.isLoading() && !!selectedID && store.annotationExists(selectedID)
);
}; };
this.isLoading = isLoading;
const visibleCount = memoize(function(thread) { const visibleCount = memoize(function(thread) {
return thread.children.reduce( return thread.children.reduce(
function(count, child) { function(count, child) {
...@@ -365,6 +387,8 @@ function SidebarContentController( ...@@ -365,6 +387,8 @@ function SidebarContentController(
store.clearSelectedAnnotations(); store.clearSelectedAnnotations();
store.selectTab(selectedTab); store.selectTab(selectedTab);
// Clear direct-linked group fetch failed state.
this.directLinkedGroupFetchFailed = false;
}; };
} }
......
...@@ -30,7 +30,7 @@ describe('searchStatusBar', function() { ...@@ -30,7 +30,7 @@ describe('searchStatusBar', function() {
const msg = 'Show all annotations'; const msg = 'Show all annotations';
const msgCount = '(2)'; const msgCount = '(2)';
const elem = util.createDirective(document, 'searchStatusBar', { const elem = util.createDirective(document, 'searchStatusBar', {
selectionCount: 1, areAllAnnotationsVisible: true,
totalAnnotations: 2, totalAnnotations: 2,
selectedTab: 'annotation', selectedTab: 'annotation',
}); });
...@@ -43,7 +43,7 @@ describe('searchStatusBar', function() { ...@@ -43,7 +43,7 @@ describe('searchStatusBar', function() {
const msg = 'Show all notes'; const msg = 'Show all notes';
const msgCount = '(3)'; const msgCount = '(3)';
const elem = util.createDirective(document, 'searchStatusBar', { const elem = util.createDirective(document, 'searchStatusBar', {
selectionCount: 1, areAllAnnotationsVisible: true,
totalNotes: 3, totalNotes: 3,
selectedTab: 'note', selectedTab: 'note',
}); });
......
...@@ -6,6 +6,7 @@ const EventEmitter = require('tiny-emitter'); ...@@ -6,6 +6,7 @@ const EventEmitter = require('tiny-emitter');
const events = require('../../events'); const events = require('../../events');
const noCallThru = require('../../../shared/test/util').noCallThru; const noCallThru = require('../../../shared/test/util').noCallThru;
const uiConstants = require('../../ui-constants');
let searchClients; let searchClients;
...@@ -150,7 +151,7 @@ describe('sidebar.components.sidebar-content', function() { ...@@ -150,7 +151,7 @@ describe('sidebar.components.sidebar-content', function() {
}); });
} }
beforeEach( const makeSidebarContentController = () => {
angular.mock.inject(function($componentController, _store_, _$rootScope_) { angular.mock.inject(function($componentController, _store_, _$rootScope_) {
$rootScope = _$rootScope_; $rootScope = _$rootScope_;
$scope = $rootScope.$new(); $scope = $rootScope.$new();
...@@ -163,13 +164,84 @@ describe('sidebar.components.sidebar-content', function() { ...@@ -163,13 +164,84 @@ describe('sidebar.components.sidebar-content', function() {
auth: { status: 'unknown' }, auth: { status: 'unknown' },
} }
); );
}) });
); };
beforeEach(() => {
makeSidebarContentController();
});
afterEach(function() { afterEach(function() {
return sandbox.restore(); return sandbox.restore();
}); });
describe('clearSelection', () => {
it('sets selectedTab to Annotations tab if selectedTab is null', () => {
store.selectTab(uiConstants.TAB_ORPHANS);
$scope.$digest();
ctrl.clearSelection();
assert.equal(store.getState().selectedTab, uiConstants.TAB_ANNOTATIONS);
});
it('sets selectedTab to Annotations tab if selectedTab is set to orphans', () => {
store.selectTab(uiConstants.TAB_ORPHANS);
$scope.$digest();
ctrl.clearSelection();
assert.equal(store.getState().selectedTab, uiConstants.TAB_ANNOTATIONS);
});
it('clears selected annotations', () => {
ctrl.clearSelection();
assert.equal(store.getState().selectedAnnotationMap, null);
assert.equal(store.getState().filterQuery, null);
});
it('clears the directLinkedGroupFetchFailed state', () => {
ctrl.directLinkedGroupFetchFailed = true;
ctrl.clearSelection();
assert.isFalse(ctrl.directLinkedGroupFetchFailed);
});
});
describe('showSelectedTabs', () => {
beforeEach(() => {
setFrames([{ uri: 'http://www.example.com' }]);
ctrl.search = { query: sinon.stub().returns(undefined) };
});
it('returns false if there is a search query', () => {
ctrl.search = { query: sinon.stub().returns('tag:foo') };
assert.isFalse(ctrl.showSelectedTabs());
});
it('returns false if selected group is unavailable', () => {
fakeSettings.group = 'group-id';
store.loadGroups([{ id: 'default-id' }]);
store.focusGroup('default-id');
fakeGroups.focused.returns({ id: 'default-id' });
$scope.$digest();
// Re-construct the controller after the environment setup.
makeSidebarContentController();
assert.isFalse(ctrl.showSelectedTabs());
});
it('returns false if selected annotation is unavailable', () => {
store.selectAnnotations(['missing']);
$scope.$digest();
assert.isFalse(ctrl.showSelectedTabs());
});
it('returns true in all other cases', () => {
assert.isTrue(ctrl.showSelectedTabs());
});
});
describe('#loadAnnotations', function() { describe('#loadAnnotations', function() {
it('unloads any existing annotations', function() { it('unloads any existing annotations', function() {
// When new clients connect, all existing annotations should be unloaded // When new clients connect, all existing annotations should be unloaded
...@@ -255,7 +327,62 @@ describe('sidebar.components.sidebar-content', function() { ...@@ -255,7 +327,62 @@ describe('sidebar.components.sidebar-content', function() {
assert.isTrue(updateSpy.calledWith(frameUris[1], true)); assert.isTrue(updateSpy.calledWith(frameUris[1], true));
}); });
context('when there is a selection', function() { context('when there is a direct-linked group error', () => {
beforeEach(() => {
setFrames([{ uri: 'http://www.example.com' }]);
fakeSettings.group = 'group-id';
store.loadGroups([{ id: 'default-id' }]);
store.focusGroup('default-id');
fakeGroups.focused.returns({ id: 'default-id' });
$scope.$digest();
// Re-construct the controller after the environment setup.
makeSidebarContentController();
});
it('sets directLinkedGroupFetchFailed to true', () => {
assert.isTrue(ctrl.directLinkedGroupFetchFailed);
});
it('areAllAnnotationsVisible returns true since there is an error message', () => {
assert.isTrue(ctrl.areAllAnnotationsVisible());
});
it('selectedGroupUnavailable returns true', () => {
assert.isTrue(ctrl.selectedGroupUnavailable());
});
});
context('when there is a direct-linked group selection', () => {
beforeEach(() => {
setFrames([{ uri: 'http://www.example.com' }]);
fakeSettings.group = 'group-id';
store.loadGroups([{ id: fakeSettings.group }]);
store.focusGroup(fakeSettings.group);
fakeGroups.focused.returns({ id: fakeSettings.group });
$scope.$digest();
});
it('sets directLinkedGroupFetchFailed to false', () => {
assert.isFalse(ctrl.directLinkedGroupFetchFailed);
});
it('areAllAnnotationsVisible returns false since group has no annotations', () => {
assert.isFalse(ctrl.areAllAnnotationsVisible());
});
it('selectedGroupUnavailable returns false', () => {
assert.isFalse(ctrl.selectedGroupUnavailable());
});
it('fetches annotations for the direct-linked group', () => {
assert.calledWith(searchClients[0].get, {
uri: ['http://www.example.com'],
group: 'group-id',
});
});
});
context('when there is a direct-linked annotation selection', function() {
const uri = 'http://example.com'; const uri = 'http://example.com';
const id = uri + '123'; const id = uri + '123';
...@@ -265,8 +392,8 @@ describe('sidebar.components.sidebar-content', function() { ...@@ -265,8 +392,8 @@ describe('sidebar.components.sidebar-content', function() {
$scope.$digest(); $scope.$digest();
}); });
it('selectedAnnotationCount is > 0', function() { it('areAllAnnotationsVisible is true', function() {
assert.equal(ctrl.selectedAnnotationCount(), 1); assert.isTrue(ctrl.areAllAnnotationsVisible());
}); });
it("switches to the selected annotation's group", function() { it("switches to the selected annotation's group", function() {
...@@ -295,8 +422,8 @@ describe('sidebar.components.sidebar-content', function() { ...@@ -295,8 +422,8 @@ describe('sidebar.components.sidebar-content', function() {
$scope.$digest(); $scope.$digest();
}); });
it('selectedAnnotationCount is 0', function() { it('areAllAnnotationsVisible is false', function() {
assert.equal(ctrl.selectedAnnotationCount(), 0); assert.isFalse(ctrl.areAllAnnotationsVisible());
}); });
it('fetches annotations for the current group', function() { it('fetches annotations for the current group', function() {
......
...@@ -70,6 +70,13 @@ class FakeVirtualThreadList extends EventEmitter { ...@@ -70,6 +70,13 @@ class FakeVirtualThreadList extends EventEmitter {
this.yOffsetOf = function() { this.yOffsetOf = function() {
return 42; return 42;
}; };
this.calculateVisibleThreads = () => {
return {
offscreenLowerHeight: 10,
offscreenUpperHeight: 20,
visibleThreads: thread.children,
};
};
} }
} }
......
...@@ -82,7 +82,21 @@ function ThreadListController($element, $scope, settings, VirtualThreadList) { ...@@ -82,7 +82,21 @@ function ThreadListController($element, $scope, settings, VirtualThreadList) {
this.thread, this.thread,
options options
); );
visibleThreads.on('changed', function(state) { // Calculate the visible threads.
onVisibleThreadsChanged(visibleThreads.calculateVisibleThreads());
// Subscribe onVisibleThreadsChanged to the visibleThreads 'changed' event
// after calculating visible threads, to avoid an undesired second call to
// onVisibleThreadsChanged that occurs from the emission of the 'changed'
// event during the visibleThreads calculation.
visibleThreads.on('changed', onVisibleThreadsChanged);
};
/**
* Update which threads are visible in the virtualThreadsList.
*
* @param {Object} state the new state of the virtualThreadsList
*/
function onVisibleThreadsChanged(state) {
self.virtualThreadList = { self.virtualThreadList = {
visibleThreads: state.visibleThreads, visibleThreads: state.visibleThreads,
invisibleThreads: state.invisibleThreads, invisibleThreads: state.invisibleThreads,
...@@ -103,8 +117,7 @@ function ThreadListController($element, $scope, settings, VirtualThreadList) { ...@@ -103,8 +117,7 @@ function ThreadListController($element, $scope, settings, VirtualThreadList) {
}, },
50 50
); );
}); }
};
/** /**
* Return the vertical scroll offset for the document in order to position the * Return the vertical scroll offset for the document in order to position the
......
...@@ -68,6 +68,19 @@ function groups( ...@@ -68,6 +68,19 @@ function groups(
directLinkedAnnotationId, directLinkedAnnotationId,
directLinkedGroupId directLinkedGroupId
) { ) {
// Filter the directLinkedGroup out if it is out of scope and scope is enforced.
if (directLinkedGroupId) {
const directLinkedGroup = groups.find(g => g.id === directLinkedGroupId);
if (
directLinkedGroup &&
!directLinkedGroup.isScopedToUri &&
directLinkedGroup.scopes.enforced
) {
groups = groups.filter(g => g.id !== directLinkedGroupId);
directLinkedGroupId = undefined;
}
}
// 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) {
...@@ -186,9 +199,15 @@ function groups( ...@@ -186,9 +199,15 @@ function groups(
// particular group as well since it may not be in the results returned // particular group as well since it may not be in the results returned
// by group.list or profile.groups. // by group.list or profile.groups.
if (directLinkedGroup) { if (directLinkedGroup) {
const selectedGroupApi = api.group.read({ const selectedGroupApi = api.group
.read({
id: directLinkedGroup, id: directLinkedGroup,
expand: params.expand, expand: params.expand,
})
.catch(() => {
// If the group does not exist or the user doesn't have permission,
// return undefined.
return undefined;
}); });
groupApiRequests = groupApiRequests.concat(selectedGroupApi); groupApiRequests = groupApiRequests.concat(selectedGroupApi);
} }
......
...@@ -181,6 +181,39 @@ describe('groups', function() { ...@@ -181,6 +181,39 @@ describe('groups', function() {
}); });
describe('#load', function() { describe('#load', function() {
it('filters out direct-linked groups that are out of scope and scope enforced', () => {
const svc = service();
fakeLocalStorage.getItem.returns(dummyGroups[0].id);
const outOfScopeEnforcedGroup = {
id: 'oos',
scopes: { enforced: true, uri_patterns: ['http://foo.com'] },
};
fakeSettings.group = outOfScopeEnforcedGroup.id;
fakeApi.group.read.returns(Promise.resolve(outOfScopeEnforcedGroup));
return svc.load().then(groups => {
// The focus group is not set to the direct-linked group.
assert.calledWith(fakeStore.focusGroup, dummyGroups[0].id);
// The direct-linked group is not in the list of groups.
assert.isFalse(groups.some(g => g.id === fakeSettings.group));
});
});
it('catches 404 error from api.group.read request', () => {
const svc = service();
fakeLocalStorage.getItem.returns(dummyGroups[0].id);
fakeSettings.group = 'does-not-exist';
fakeApi.group.read.returns(
Promise.reject(
"404 Not Found: Either the resource you requested doesn't exist, \
or you are not currently authorized to see it."
)
);
return svc.load().then(() => {
// The focus group is not set to the direct-linked group.
assert.calledWith(fakeStore.focusGroup, dummyGroups[0].id);
});
});
it('combines groups from both endpoints', function() { it('combines groups from both endpoints', function() {
const svc = service(); const svc = service();
......
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
'one': '1 search result', 'one': '1 search result',
'other': '{} search results'}"></span> 'other': '{} search results'}"></span>
</div> </div>
<div class="search-status-bar" ng-if="!vm.filterActive && vm.selectionCount > 0"> <div class="search-status-bar" ng-if="!vm.filterActive && vm.areAllAnnotationsVisible">
<button class="primary-action-btn primary-action-btn--short" <button class="primary-action-btn primary-action-btn--short"
ng-click="vm.onClearSelection()" ng-click="vm.onClearSelection()"
title="Clear the selection and show all annotations"> title="Clear the selection and show all annotations">
......
<selection-tabs <selection-tabs
ng-if="!vm.search.query() && vm.selectedAnnotationCount() === 0" ng-if="vm.showSelectedTabs()"
is-waiting-to-anchor-annotations="vm.waitingToAnchorAnnotations" is-waiting-to-anchor-annotations="vm.waitingToAnchorAnnotations"
is-loading="vm.isLoading" is-loading="vm.isLoading"
selected-tab="vm.selectedTab" selected-tab="vm.selectedTab"
...@@ -14,13 +14,14 @@ ...@@ -14,13 +14,14 @@
filter-match-count="vm.visibleCount()" filter-match-count="vm.visibleCount()"
on-clear-selection="vm.clearSelection()" on-clear-selection="vm.clearSelection()"
search-query="vm.search.query()" search-query="vm.search.query()"
selection-count="vm.selectedAnnotationCount()" are-all-annotations-visible="vm.areAllAnnotationsVisible()"
total-count="vm.topLevelThreadCount()" total-count="vm.topLevelThreadCount()"
selected-tab="vm.selectedTab" selected-tab="vm.selectedTab"
total-annotations="vm.totalAnnotations" total-annotations="vm.totalAnnotations"
total-notes="vm.totalNotes"> total-notes="vm.totalNotes">
</search-status-bar> </search-status-bar>
<!-- Display error message if direct-linked annotation fetch failed. -->
<sidebar-content-error <sidebar-content-error
class="sidebar-content-error" class="sidebar-content-error"
logged-out-error-message="'This annotation is not available.'" logged-out-error-message="'This annotation is not available.'"
...@@ -31,6 +32,17 @@ ...@@ -31,6 +32,17 @@
> >
</sidebar-content-error> </sidebar-content-error>
<!-- Display error message if direct-linked group fetch failed. -->
<sidebar-content-error
class="sidebar-content-error"
logged-out-error-message="'This group is not available.'"
logged-in-error-message="'You either do not have permission to view this group, the group does not exist, or the group is not visible at this URL.'"
on-login-request="vm.onLogin()"
is-logged-in="vm.auth.status === 'logged-in'"
ng-if="vm.selectedGroupUnavailable()"
>
</sidebar-content-error>
<thread-list <thread-list
on-change-collapsed="vm.setCollapsed(id, collapsed)" on-change-collapsed="vm.setCollapsed(id, collapsed)"
on-clear-selection="vm.clearSelection()" on-clear-selection="vm.clearSelection()"
...@@ -38,6 +50,7 @@ ...@@ -38,6 +50,7 @@
on-force-visible="vm.forceVisible(thread)" on-force-visible="vm.forceVisible(thread)"
on-select="vm.scrollTo(annotation)" on-select="vm.scrollTo(annotation)"
show-document-info="false" show-document-info="false"
ng-if="!vm.selectedGroupUnavailable()"
thread="vm.rootThread"> thread="vm.rootThread">
</thread-list> </thread-list>
......
...@@ -49,7 +49,7 @@ class VirtualThreadList extends EventEmitter { ...@@ -49,7 +49,7 @@ class VirtualThreadList extends EventEmitter {
this.scrollRoot = options.scrollRoot || document.body; this.scrollRoot = options.scrollRoot || document.body;
const debouncedUpdate = debounce(function() { const debouncedUpdate = debounce(function() {
self._updateVisibleThreads(); self.calculateVisibleThreads();
$scope.$digest(); $scope.$digest();
}, 20); }, 20);
this.scrollRoot.addEventListener('scroll', debouncedUpdate); this.scrollRoot.addEventListener('scroll', debouncedUpdate);
...@@ -83,7 +83,7 @@ class VirtualThreadList extends EventEmitter { ...@@ -83,7 +83,7 @@ class VirtualThreadList extends EventEmitter {
return; return;
} }
this._rootThread = thread; this._rootThread = thread;
this._updateVisibleThreads(); this.calculateVisibleThreads();
} }
/** /**
...@@ -131,7 +131,7 @@ class VirtualThreadList extends EventEmitter { ...@@ -131,7 +131,7 @@ class VirtualThreadList extends EventEmitter {
* *
* Emits a `changed` event with the recalculated set of visible threads. * Emits a `changed` event with the recalculated set of visible threads.
*/ */
_updateVisibleThreads() { calculateVisibleThreads() {
// Space above the viewport in pixels which should be considered 'on-screen' // Space above the viewport in pixels which should be considered 'on-screen'
// when calculating the set of visible threads // when calculating the set of visible threads
const MARGIN_ABOVE = 800; const MARGIN_ABOVE = 800;
...@@ -205,6 +205,12 @@ class VirtualThreadList extends EventEmitter { ...@@ -205,6 +205,12 @@ class VirtualThreadList extends EventEmitter {
visibleThreads: visibleThreads, visibleThreads: visibleThreads,
invisibleThreads: invisibleThreads, invisibleThreads: invisibleThreads,
}); });
return {
offscreenLowerHeight,
offscreenUpperHeight,
visibleThreads,
invisibleThreads,
};
} }
} }
......
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