Unverified Commit 0a44e65b authored by Lyza Gardner's avatar Lyza Gardner Committed by GitHub

Merge pull request #1884 from hypothesis/fix-change-group-mutation-violation

Fix mutation violation when changing focused group
parents 7314300e 576e810a
...@@ -33,14 +33,13 @@ function GroupListItem({ ...@@ -33,14 +33,13 @@ function GroupListItem({
const actions = useStore(store => ({ const actions = useStore(store => ({
clearDirectLinkedGroupFetchFailed: store.clearDirectLinkedGroupFetchFailed, clearDirectLinkedGroupFetchFailed: store.clearDirectLinkedGroupFetchFailed,
clearDirectLinkedIds: store.clearDirectLinkedIds, clearDirectLinkedIds: store.clearDirectLinkedIds,
focusGroup: store.focusGroup,
})); }));
const focusGroup = () => { const focusGroup = () => {
analytics.track(analytics.events.GROUP_SWITCH); analytics.track(analytics.events.GROUP_SWITCH);
actions.clearDirectLinkedGroupFetchFailed(); actions.clearDirectLinkedGroupFetchFailed();
actions.clearDirectLinkedIds(); actions.clearDirectLinkedIds();
actions.focusGroup(group.id); groupsService.focus(group.id);
}; };
const leaveGroup = () => { const leaveGroup = () => {
......
...@@ -29,7 +29,6 @@ describe('GroupListItem', () => { ...@@ -29,7 +29,6 @@ describe('GroupListItem', () => {
}; };
fakeStore = { fakeStore = {
focusGroup: sinon.stub(),
focusedGroupId: sinon.stub().returns('groupid'), focusedGroupId: sinon.stub().returns('groupid'),
clearDirectLinkedIds: sinon.stub(), clearDirectLinkedIds: sinon.stub(),
clearDirectLinkedGroupFetchFailed: sinon.stub(), clearDirectLinkedGroupFetchFailed: sinon.stub(),
...@@ -50,6 +49,7 @@ describe('GroupListItem', () => { ...@@ -50,6 +49,7 @@ describe('GroupListItem', () => {
}; };
fakeGroupsService = { fakeGroupsService = {
focus: sinon.stub(),
leave: sinon.stub(), leave: sinon.stub(),
}; };
...@@ -111,7 +111,7 @@ describe('GroupListItem', () => { ...@@ -111,7 +111,7 @@ describe('GroupListItem', () => {
.props() .props()
.onClick(); .onClick();
assert.calledWith(fakeStore.focusGroup, fakeGroup.id); assert.calledWith(fakeGroupsService.focus, fakeGroup.id);
assert.calledWith(fakeAnalytics.track, fakeAnalytics.events.GROUP_SWITCH); assert.calledWith(fakeAnalytics.track, fakeAnalytics.events.GROUP_SWITCH);
}); });
......
...@@ -18,9 +18,6 @@ export default { ...@@ -18,9 +18,6 @@ export default {
// UI state changes // UI state changes
/** The currently selected group changed */
GROUP_FOCUSED: 'groupFocused',
// Annotation events // Annotation events
/** A new annotation has been created locally. */ /** A new annotation has been created locally. */
......
const STORAGE_KEY = 'hypothesis.groups.focus'; import events from '../events';
import serviceConfig from '../service-config';
import { isReply } from '../util/annotation-metadata';
import { combineGroups } from '../util/groups';
import { awaitStateChange } from '../util/state';
const DEFAULT_ORG_ID = '__default__'; const DEFAULT_ORG_ID = '__default__';
/** /**
...@@ -11,18 +16,12 @@ const DEFAULT_ORGANIZATION = { ...@@ -11,18 +16,12 @@ const DEFAULT_ORGANIZATION = {
encodeURIComponent(require('../../images/icons/logo.svg')), encodeURIComponent(require('../../images/icons/logo.svg')),
}; };
import events from '../events';
import serviceConfig from '../service-config';
import { combineGroups } from '../util/groups';
import { awaitStateChange } from '../util/state';
// @ngInject // @ngInject
export default function groups( export default function groups(
$rootScope, $rootScope,
store, store,
api, api,
isSidebar, isSidebar,
localStorage,
serviceUrl, serviceUrl,
session, session,
settings, settings,
...@@ -42,6 +41,40 @@ export default function groups( ...@@ -42,6 +41,40 @@ export default function groups(
return awaitStateChange(store, mainUri); return awaitStateChange(store, mainUri);
} }
/**
* Update the focused group. Update the store, then check to see if
* there are any new (unsaved) annotations—if so, update those annotations
* such that they are associated with the newly-focused group.
*/
function focus(groupId) {
const prevGroupId = store.focusedGroupId();
store.focusGroup(groupId);
const newGroupId = store.focusedGroupId();
const groupHasChanged = prevGroupId !== newGroupId && prevGroupId !== null;
if (groupHasChanged) {
// Move any top-level new annotations to the newly-focused group.
// Leave replies where they are.
const updatedAnnotations = [];
store.newAnnotations().forEach(annot => {
if (!isReply(annot)) {
updatedAnnotations.push(
Object.assign({}, annot, { group: newGroupId })
);
}
});
if (updatedAnnotations.length) {
store.addAnnotations(updatedAnnotations);
}
// Persist this group as the last focused group default
store.setDefault('focusedGroup', newGroupId);
}
}
/** /**
* Filter the returned list of groups from the API. * Filter the returned list of groups from the API.
* *
...@@ -276,17 +309,17 @@ export default function groups( ...@@ -276,17 +309,17 @@ export default function groups(
// Step 5. Load the groups into the store and focus the appropriate // Step 5. Load the groups into the store and focus the appropriate
// group. // group.
const isFirstLoad = store.allGroups().length === 0; const isFirstLoad = store.allGroups().length === 0;
const prevFocusedGroup = localStorage.getItem(STORAGE_KEY); const prevFocusedGroup = store.getDefault('focusedGroup');
store.loadGroups(groups); store.loadGroups(groups);
if (isFirstLoad) { if (isFirstLoad) {
if (groups.some(g => g.id === directLinkedAnnotationGroupId)) { if (groups.some(g => g.id === directLinkedAnnotationGroupId)) {
store.focusGroup(directLinkedAnnotationGroupId); focus(directLinkedAnnotationGroupId);
} else if (groups.some(g => g.id === directLinkedGroupId)) { } else if (groups.some(g => g.id === directLinkedGroupId)) {
store.focusGroup(directLinkedGroupId); focus(directLinkedGroupId);
} else if (groups.some(g => g.id === prevFocusedGroup)) { } else if (groups.some(g => g.id === prevFocusedGroup)) {
store.focusGroup(prevFocusedGroup); focus(prevFocusedGroup);
} }
} }
...@@ -316,34 +349,6 @@ export default function groups( ...@@ -316,34 +349,6 @@ export default function groups(
}); });
} }
/** 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.
*/
function focused() {
return store.focusedGroup();
}
/** Set the group with the passed id as the currently focused group. */
function focus(id) {
store.focusGroup(id);
}
// Persist the focused group to storage when it changes.
let prevFocusedId = store.focusedGroupId();
store.subscribe(() => {
const focusedId = store.focusedGroupId();
// The focused group may be null when the user login state changes.
if (focusedId !== null && focusedId !== prevFocusedId) {
prevFocusedId = focusedId;
localStorage.setItem(STORAGE_KEY, focusedId);
// Emit the `GROUP_FOCUSED` event for code that still relies on it.
$rootScope.$broadcast(events.GROUP_FOCUSED, focusedId);
}
});
// refetch the list of groups when user changes // refetch the list of groups when user changes
$rootScope.$on(events.USER_CHANGED, () => { $rootScope.$on(events.USER_CHANGED, () => {
// FIXME Makes a second api call on page load. better way? // FIXME Makes a second api call on page load. better way?
...@@ -368,7 +373,6 @@ export default function groups( ...@@ -368,7 +373,6 @@ export default function groups(
leave: leave, leave: leave,
load: load, load: load,
focused: focused,
focus: focus, focus: focus,
}; };
} }
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
const DEFAULT_KEYS = { const DEFAULT_KEYS = {
annotationPrivacy: 'hypothesis.privacy', annotationPrivacy: 'hypothesis.privacy',
focusedGroup: 'hypothesis.groups.focus',
}; };
// @ngInject // @ngInject
......
...@@ -123,24 +123,6 @@ export default function RootThread( ...@@ -123,24 +123,6 @@ export default function RootThread(
store.removeAnnotations([annotation]); store.removeAnnotations([annotation]);
}); });
// Once the focused group state is moved to the app state store, then the
// logic in this event handler can be moved to the annotations reducer.
$rootScope.$on(events.GROUP_FOCUSED, function(event, focusedGroupId) {
const updatedAnnots = store
.getState()
.annotations.annotations.filter(function(ann) {
return metadata.isNew(ann) && !metadata.isReply(ann);
})
.map(function(ann) {
return Object.assign(ann, {
group: focusedGroupId,
});
});
if (updatedAnnots.length > 0) {
store.addAnnotations(updatedAnnots);
}
});
/** /**
* Build the root conversation thread from the given UI state. * Build the root conversation thread from the given UI state.
* @return {Thread} * @return {Thread}
......
import events from '../../events'; import events from '../../events';
import fakeReduxStore from '../../test/fake-redux-store'; import fakeReduxStore from '../../test/fake-redux-store';
import groups from '../groups'; import groups, { $imports } from '../groups';
/** /**
* Generate a truth table containing every possible combination of a set of * Generate a truth table containing every possible combination of a set of
...@@ -44,15 +44,19 @@ describe('groups', function() { ...@@ -44,15 +44,19 @@ describe('groups', function() {
let fakeSession; let fakeSession;
let fakeSettings; let fakeSettings;
let fakeApi; let fakeApi;
let fakeLocalStorage;
let fakeRootScope; let fakeRootScope;
let fakeServiceUrl; let fakeServiceUrl;
let fakeMetadata;
beforeEach(function() { beforeEach(function() {
fakeAuth = { fakeAuth = {
tokenGetter: sinon.stub().returns('1234'), tokenGetter: sinon.stub().returns('1234'),
}; };
fakeMetadata = {
isReply: sinon.stub(),
};
fakeStore = fakeReduxStore( fakeStore = fakeReduxStore(
{ {
frames: [{ uri: 'http://example.org' }], frames: [{ uri: 'http://example.org' }],
...@@ -66,9 +70,13 @@ describe('groups', function() { ...@@ -66,9 +70,13 @@ describe('groups', function() {
}, },
}, },
{ {
addAnnotations: sinon.stub(),
focusGroup: sinon.stub(), focusGroup: sinon.stub(),
focusedGroupId: sinon.stub(),
getDefault: sinon.stub(),
getGroup: sinon.stub(), getGroup: sinon.stub(),
loadGroups: sinon.stub(), loadGroups: sinon.stub(),
newAnnotations: sinon.stub().returns([]),
allGroups() { allGroups() {
return this.getState().groups.groups; return this.getState().groups.groups;
}, },
...@@ -78,20 +86,13 @@ describe('groups', function() { ...@@ -78,20 +86,13 @@ describe('groups', function() {
mainFrame() { mainFrame() {
return this.getState().frames[0]; return this.getState().frames[0];
}, },
focusedGroupId() { setDefault: sinon.stub(),
const group = this.getState().groups.focusedGroup;
return group ? group.id : null;
},
setDirectLinkedGroupFetchFailed: sinon.stub(), setDirectLinkedGroupFetchFailed: sinon.stub(),
clearDirectLinkedGroupFetchFailed: sinon.stub(), clearDirectLinkedGroupFetchFailed: sinon.stub(),
} }
); );
fakeSession = sessionWithThreeGroups(); fakeSession = sessionWithThreeGroups();
fakeIsSidebar = true; fakeIsSidebar = true;
fakeLocalStorage = {
getItem: sinon.stub(),
setItem: sinon.stub(),
};
fakeRootScope = { fakeRootScope = {
eventCallbacks: {}, eventCallbacks: {},
...@@ -129,6 +130,14 @@ describe('groups', function() { ...@@ -129,6 +130,14 @@ describe('groups', function() {
}; };
fakeServiceUrl = sinon.stub(); fakeServiceUrl = sinon.stub();
fakeSettings = { group: null }; fakeSettings = { group: null };
$imports.$mock({
'../util/annotation-metadata': fakeMetadata,
});
});
afterEach(() => {
$imports.$restore();
}); });
function service() { function service() {
...@@ -137,7 +146,6 @@ describe('groups', function() { ...@@ -137,7 +146,6 @@ describe('groups', function() {
fakeStore, fakeStore,
fakeApi, fakeApi,
fakeIsSidebar, fakeIsSidebar,
fakeLocalStorage,
fakeServiceUrl, fakeServiceUrl,
fakeSession, fakeSession,
fakeSettings, fakeSettings,
...@@ -145,6 +153,81 @@ describe('groups', function() { ...@@ -145,6 +153,81 @@ describe('groups', function() {
); );
} }
describe('#focus', () => {
it('updates the focused group in the store', () => {
const svc = service();
fakeStore.focusedGroupId.returns('whatever');
svc.focus('whatnot');
assert.calledOnce(fakeStore.focusGroup);
assert.calledWith(fakeStore.focusGroup, 'whatnot');
});
context('focusing to a different group than before', () => {
beforeEach(() => {
fakeStore.focusedGroupId.returns('newgroup');
fakeStore.focusedGroupId.onFirstCall().returns('whatnot');
});
it('moves top-level annotations to the newly-focused group', () => {
const fakeAnnotations = [
{ $tag: '1', group: 'groupA' },
{ $tag: '2', group: 'groupB' },
];
fakeMetadata.isReply.returns(false);
fakeStore.newAnnotations.returns(fakeAnnotations);
const svc = service();
svc.focus('newgroup');
assert.calledWith(
fakeStore.addAnnotations,
sinon.match([
{ $tag: '1', group: 'newgroup' },
{ $tag: '2', group: 'newgroup' },
])
);
const updatedAnnotations = fakeStore.addAnnotations.getCall(0).args[0];
updatedAnnotations.forEach(annot => {
assert.equal(annot.group, 'newgroup');
});
});
it('does not move replies to the newly-focused group', () => {
fakeMetadata.isReply.returns(true);
fakeStore.newAnnotations.returns([
{ $tag: '1', group: 'groupA' },
{ $tag: '2', group: 'groupB' },
]);
const svc = service();
svc.focus('newgroup');
assert.calledTwice(fakeMetadata.isReply);
assert.notCalled(fakeStore.addAnnotations);
});
it('updates the focused-group default', () => {
const svc = service();
svc.focus('newgroup');
assert.calledOnce(fakeStore.setDefault);
assert.calledWith(fakeStore.setDefault, 'focusedGroup', 'newgroup');
});
});
it('does not update the focused-group default if the group has not changed', () => {
fakeStore.focusedGroupId.returns('samegroup');
const svc = service();
svc.focus('samegroup');
assert.notCalled(fakeStore.setDefault);
});
});
describe('#all', function() { describe('#all', function() {
it('returns all groups from store.allGroups', () => { it('returns all groups from store.allGroups', () => {
const svc = service(); const svc = service();
...@@ -157,7 +240,7 @@ describe('groups', function() { ...@@ -157,7 +240,7 @@ describe('groups', function() {
describe('#load', function() { describe('#load', function() {
it('filters out direct-linked groups that are out of scope and scope enforced', () => { it('filters out direct-linked groups that are out of scope and scope enforced', () => {
const svc = service(); const svc = service();
fakeLocalStorage.getItem.returns(dummyGroups[0].id); fakeStore.getDefault.returns(dummyGroups[0].id);
const outOfScopeEnforcedGroup = { const outOfScopeEnforcedGroup = {
id: 'oos', id: 'oos',
scopes: { enforced: true, uri_patterns: ['http://foo.com'] }, scopes: { enforced: true, uri_patterns: ['http://foo.com'] },
...@@ -180,7 +263,7 @@ describe('groups', function() { ...@@ -180,7 +263,7 @@ describe('groups', function() {
it('catches error from api.group.read request', () => { it('catches error from api.group.read request', () => {
const svc = service(); const svc = service();
fakeLocalStorage.getItem.returns(dummyGroups[0].id); fakeStore.getDefault.returns(dummyGroups[0].id);
fakeStore.setState({ fakeStore.setState({
directLinked: { directLinked: {
directLinkedGroupId: 'does-not-exist', directLinkedGroupId: 'does-not-exist',
...@@ -325,7 +408,7 @@ describe('groups', function() { ...@@ -325,7 +408,7 @@ describe('groups', function() {
it('sets the focused group from the value saved in local storage', () => { it('sets the focused group from the value saved in local storage', () => {
const svc = service(); const svc = service();
fakeLocalStorage.getItem.returns(dummyGroups[1].id); fakeStore.getDefault.returns(dummyGroups[1].id);
return svc.load().then(() => { return svc.load().then(() => {
assert.calledWith(fakeStore.focusGroup, dummyGroups[1].id); assert.calledWith(fakeStore.focusGroup, dummyGroups[1].id);
}); });
...@@ -339,7 +422,7 @@ describe('groups', function() { ...@@ -339,7 +422,7 @@ describe('groups', function() {
directLinkedGroupId: dummyGroups[1].id, directLinkedGroupId: dummyGroups[1].id,
}, },
}); });
fakeLocalStorage.getItem.returns(dummyGroups[0].id); fakeStore.getDefault.returns(dummyGroups[0].id);
fakeApi.groups.list.returns(Promise.resolve(dummyGroups)); fakeApi.groups.list.returns(Promise.resolve(dummyGroups));
fakeApi.annotation.get.returns( fakeApi.annotation.get.returns(
Promise.resolve({ Promise.resolve({
...@@ -360,7 +443,7 @@ describe('groups', function() { ...@@ -360,7 +443,7 @@ describe('groups', function() {
}, },
}); });
fakeApi.groups.list.returns(Promise.resolve(dummyGroups)); fakeApi.groups.list.returns(Promise.resolve(dummyGroups));
fakeLocalStorage.getItem.returns(dummyGroups[0].id); fakeStore.getDefault.returns(dummyGroups[0].id);
fakeApi.annotation.get.returns( fakeApi.annotation.get.returns(
Promise.resolve({ Promise.resolve({
id: 'ann-id', id: 'ann-id',
...@@ -379,7 +462,7 @@ describe('groups', function() { ...@@ -379,7 +462,7 @@ describe('groups', function() {
directLinkedGroupId: dummyGroups[1].id, directLinkedGroupId: dummyGroups[1].id,
}, },
}); });
fakeLocalStorage.getItem.returns(dummyGroups[0].id); fakeStore.getDefault.returns(dummyGroups[0].id);
fakeApi.groups.list.returns(Promise.resolve(dummyGroups)); fakeApi.groups.list.returns(Promise.resolve(dummyGroups));
return svc.load().then(() => { return svc.load().then(() => {
assert.calledWith(fakeStore.focusGroup, dummyGroups[1].id); assert.calledWith(fakeStore.focusGroup, dummyGroups[1].id);
...@@ -416,7 +499,7 @@ describe('groups', function() { ...@@ -416,7 +499,7 @@ describe('groups', function() {
[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();
fakeLocalStorage.getItem.returns(groupId); fakeStore.getDefault.returns(groupId);
return svc.load().then(() => { return svc.load().then(() => {
assert.notCalled(fakeStore.focusGroup); assert.notCalled(fakeStore.focusGroup);
}); });
...@@ -745,69 +828,6 @@ describe('groups', function() { ...@@ -745,69 +828,6 @@ describe('groups', function() {
}); });
}); });
describe('#focused', function() {
it('returns the focused group', function() {
const svc = service();
fakeStore.setState({
groups: { groups: dummyGroups, focusedGroup: dummyGroups[2] },
});
assert.equal(svc.focused(), dummyGroups[2]);
});
});
describe('#focus', function() {
it('sets the focused group to the named group', function() {
const svc = service();
svc.focus('foo');
assert.calledWith(fakeStore.focusGroup, 'foo');
});
});
context('when the focused group changes', () => {
it('stores the focused group id in localStorage', function() {
service();
fakeStore.setState({
groups: { groups: dummyGroups, focusedGroup: dummyGroups[1] },
});
assert.calledWithMatch(
fakeLocalStorage.setItem,
sinon.match.any,
dummyGroups[1].id
);
});
it('emits the GROUP_FOCUSED event if the focused group changed', function() {
service();
fakeStore.setState({
groups: { groups: dummyGroups, focusedGroup: dummyGroups[1] },
});
assert.calledWith(
fakeRootScope.$broadcast,
events.GROUP_FOCUSED,
dummyGroups[1].id
);
});
it('does not emit GROUP_FOCUSED if the focused group did not change', () => {
service();
fakeStore.setState({
groups: { groups: dummyGroups, focusedGroup: dummyGroups[1] },
});
fakeRootScope.$broadcast.reset();
fakeStore.setState({
groups: { groups: dummyGroups, focusedGroup: dummyGroups[1] },
});
assert.notCalled(fakeRootScope.$broadcast);
});
});
describe('#leave', function() { describe('#leave', function() {
it('should call the group leave API', function() { it('should call the group leave API', function() {
const s = service(); const s = service();
......
...@@ -3,6 +3,7 @@ import persistedDefaults from '../persisted-defaults'; ...@@ -3,6 +3,7 @@ import persistedDefaults from '../persisted-defaults';
const DEFAULT_KEYS = { const DEFAULT_KEYS = {
annotationPrivacy: 'hypothesis.privacy', annotationPrivacy: 'hypothesis.privacy',
focusedGroup: 'hypothesis.groups.focus',
}; };
describe('sidebar/services/persisted-defaults', function() { describe('sidebar/services/persisted-defaults', function() {
...@@ -41,14 +42,15 @@ describe('sidebar/services/persisted-defaults', function() { ...@@ -41,14 +42,15 @@ describe('sidebar/services/persisted-defaults', function() {
svc.init(); svc.init();
// Retrieving the one default from localStorage // Retrieving each known default from localStorage...
assert.calledOnce(fakeLocalStorage.getItem); assert.equal(
assert.calledWith( fakeLocalStorage.getItem.callCount,
fakeLocalStorage.getItem, Object.keys(DEFAULT_KEYS).length
DEFAULT_KEYS.annotationPrivacy
); );
// Setting the one default in the store
assert.calledOnce(fakeStore.setDefault); Object.keys(DEFAULT_KEYS).forEach(defaultKey => {
assert.calledWith(fakeLocalStorage.getItem, DEFAULT_KEYS[defaultKey]);
});
}); });
it('should set defaults on the store with the values returned by `localStorage`', () => { it('should set defaults on the store with the values returned by `localStorage`', () => {
...@@ -57,7 +59,9 @@ describe('sidebar/services/persisted-defaults', function() { ...@@ -57,7 +59,9 @@ describe('sidebar/services/persisted-defaults', function() {
svc.init(); svc.init();
assert.calledWith(fakeStore.setDefault, 'annotationPrivacy', 'bananas'); Object.keys(DEFAULT_KEYS).forEach(defaultKey => {
assert.calledWith(fakeStore.setDefault, defaultKey, 'bananas');
});
}); });
it('should set default to `null` if key non-existent in storage', () => { it('should set default to `null` if key non-existent in storage', () => {
...@@ -66,7 +70,9 @@ describe('sidebar/services/persisted-defaults', function() { ...@@ -66,7 +70,9 @@ describe('sidebar/services/persisted-defaults', function() {
svc.init(); svc.init();
assert.calledWith(fakeStore.setDefault, 'annotationPrivacy', null); Object.keys(DEFAULT_KEYS).forEach(defaultKey => {
assert.calledWith(fakeStore.setDefault, defaultKey, null);
});
}); });
context('when defaults change in the store', () => { context('when defaults change in the store', () => {
...@@ -113,7 +119,7 @@ describe('sidebar/services/persisted-defaults', function() { ...@@ -113,7 +119,7 @@ describe('sidebar/services/persisted-defaults', function() {
}); });
it('should not update local storage if default has not changed', () => { it('should not update local storage if default has not changed', () => {
const defaults = { annotationPrivacy: 'carrots' }; const defaults = { focusedGroup: 'carrots' };
fakeLocalStorage.getItem.returns('carrots'); fakeLocalStorage.getItem.returns('carrots');
fakeStore.getDefaults.returns(defaults); fakeStore.getDefaults.returns(defaults);
fakeStore.setState({ defaults: defaults }); fakeStore.setState({ defaults: defaults });
......
...@@ -416,40 +416,4 @@ describe('rootThread', function() { ...@@ -416,40 +416,4 @@ describe('rootThread', function() {
}); });
}); });
}); });
describe('when the focused group changes', function() {
it('moves new annotations to the focused group', function() {
fakeStore.state.annotations.annotations = [{ $tag: 'a-tag' }];
$rootScope.$broadcast(events.GROUP_FOCUSED, 'private-group');
assert.calledWith(
fakeStore.addAnnotations,
sinon.match([
{
$tag: 'a-tag',
group: 'private-group',
},
])
);
});
it('does not move replies to the new group', function() {
fakeStore.state.annotations.annotations = [annotationFixtures.newReply()];
$rootScope.$broadcast(events.GROUP_FOCUSED, 'private-group');
assert.notCalled(fakeStore.addAnnotations);
});
it('does not move saved annotations to the new group', function() {
fakeStore.state.annotations.annotations = [
annotationFixtures.defaultAnnotation(),
];
$rootScope.$broadcast(events.GROUP_FOCUSED, 'private-group');
assert.notCalled(fakeStore.addAnnotations);
});
});
}); });
...@@ -383,6 +383,16 @@ function findAnnotationByID(state, id) { ...@@ -383,6 +383,16 @@ function findAnnotationByID(state, id) {
return findByID(state.annotations.annotations, id); return findByID(state.annotations.annotations, id);
} }
/**
* Return all loaded annotations that are not highlights and have not been saved
* to the server.
*/
const newAnnotations = createSelector(
state => state.annotations.annotations,
annotations =>
annotations.filter(ann => metadata.isNew(ann) && !metadata.isHighlight(ann))
);
/** /**
* Return all loaded annotations that are highlights and have not been saved * Return all loaded annotations that are highlights and have not been saved
* to the server. * to the server.
...@@ -445,6 +455,7 @@ export default { ...@@ -445,6 +455,7 @@ export default {
findAnnotationByID, findAnnotationByID,
findIDsForTags, findIDsForTags,
isWaitingToAnchorAnnotations, isWaitingToAnchorAnnotations,
newAnnotations,
newHighlights, newHighlights,
noteCount, noteCount,
orphanCount, orphanCount,
......
...@@ -23,6 +23,7 @@ function init() { ...@@ -23,6 +23,7 @@ function init() {
*/ */
return { return {
annotationPrivacy: null, annotationPrivacy: null,
focusedGroup: null,
}; };
} }
......
...@@ -174,6 +174,41 @@ describe('sidebar/store/modules/annotations', function() { ...@@ -174,6 +174,41 @@ describe('sidebar/store/modules/annotations', function() {
}); });
}); });
describe('newAnnotations', () => {
[
{
annotations: [
fixtures.oldAnnotation(), // no
fixtures.newAnnotation(), // yes
fixtures.newAnnotation(), // yes
fixtures.newReply(), // yes
],
expectedLength: 3,
},
{
annotations: [fixtures.oldAnnotation(), fixtures.newHighlight()],
expectedLength: 0,
},
{
annotations: [
fixtures.newHighlight(), // no
fixtures.newReply(), // yes
fixtures.oldAnnotation(), // no
fixtures.newPageNote(), // yes
],
expectedLength: 2,
},
].forEach(testCase => {
it('returns number of unsaved, new annotations', () => {
const state = { annotations: { annotations: testCase.annotations } };
assert.lengthOf(
selectors.newAnnotations(state),
testCase.expectedLength
);
});
});
});
describe('newHighlights', () => { describe('newHighlights', () => {
[ [
{ {
......
...@@ -50,7 +50,11 @@ describe('store/modules/defaults', function() { ...@@ -50,7 +50,11 @@ describe('store/modules/defaults', function() {
const latestDefaults = store.getDefaults(); const latestDefaults = store.getDefaults();
assert.hasAllKeys(latestDefaults, ['foo', 'annotationPrivacy']); assert.hasAllKeys(latestDefaults, [
'foo',
'annotationPrivacy',
'focusedGroup',
]);
}); });
}); });
}); });
......
...@@ -104,6 +104,18 @@ export function newHighlight() { ...@@ -104,6 +104,18 @@ export function newHighlight() {
}; };
} }
/** Return an annotation domain model object for a new page note.
*/
export function newPageNote() {
return {
$highlight: undefined,
target: [{ source: 'http://example.org' }],
references: [],
text: '',
tags: [],
};
}
/** Return an annotation domain model object for an existing annotation /** Return an annotation domain model object for an existing annotation
* received from the server. * received from the server.
*/ */
...@@ -137,7 +149,8 @@ export function oldHighlight() { ...@@ -137,7 +149,8 @@ export function oldHighlight() {
*/ */
export function oldPageNote() { export function oldPageNote() {
return { return {
highlight: undefined, id: 'note_id',
$highlight: undefined,
target: [{ source: 'http://example.org' }], target: [{ source: 'http://example.org' }],
references: [], references: [],
text: '', text: '',
......
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