Unverified Commit b104d018 authored by Kyle Keating's avatar Kyle Keating Committed by GitHub

Merge pull request #1282 from hypothesis/focused-user-mode

Add focused user mode for speed grader
parents 59dbd146 56bfa23b
...@@ -97,6 +97,16 @@ function LiveReloadServer(port, config) { ...@@ -97,6 +97,16 @@ function LiveReloadServer(port, config) {
window.hypothesisConfig = function () { window.hypothesisConfig = function () {
return { return {
liveReloadServer: 'ws://' + appHost + ':${port}', liveReloadServer: 'ws://' + appHost + ':${port}',
// Force into focused user mode
// Example focused user mode
// focus: {
// user: {
// username: 'foo',
// authority: 'lms',
// displayName: 'Foo Bar',
// }
// },
// Open the sidebar when the page loads // Open the sidebar when the page loads
openSidebar: true, openSidebar: true,
......
...@@ -24,6 +24,7 @@ function configFrom(window_) { ...@@ -24,6 +24,7 @@ function configFrom(window_) {
'enableExperimentalNewNoteButton' 'enableExperimentalNewNoteButton'
), ),
group: settings.group, group: settings.group,
focus: settings.hostPageSetting('focus'),
theme: settings.hostPageSetting('theme'), theme: settings.hostPageSetting('theme'),
usernameUrl: settings.hostPageSetting('usernameUrl'), usernameUrl: settings.hostPageSetting('usernameUrl'),
onLayoutChange: settings.hostPageSetting('onLayoutChange'), onLayoutChange: settings.hostPageSetting('onLayoutChange'),
......
'use strict';
const { createElement } = require('preact');
const useStore = require('../store/use-store');
function FocusedModeHeader() {
const store = useStore(store => ({
actions: {
setFocusModeFocused: store.setFocusModeFocused,
},
selectors: {
focusModeFocused: store.focusModeFocused,
focusModeUserPrettyName: store.focusModeUserPrettyName,
},
}));
const toggleFocusedMode = () => {
store.actions.setFocusModeFocused(!store.selectors.focusModeFocused());
};
const buttonText = () => {
if (store.selectors.focusModeFocused()) {
return `Annotations by ${store.selectors.focusModeUserPrettyName()}`;
} else {
return 'All annotations';
}
};
return (
<div className="focused-mode-header">
<button
onClick={toggleFocusedMode}
className="primary-action-btn primary-action-btn--short"
title={`Toggle to show annotations only by ${store.selectors.focusModeUserPrettyName()}`}
>
{buttonText()}
</button>
</div>
);
}
FocusedModeHeader.propTypes = {};
module.exports = FocusedModeHeader;
...@@ -125,6 +125,10 @@ function SidebarContentController( ...@@ -125,6 +125,10 @@ function SidebarContentController(
true true
); );
this.showFocusedHeader = () => {
return store.focusModeEnabled();
};
this.showSelectedTabs = function() { this.showSelectedTabs = function() {
if ( if (
this.selectedAnnotationUnavailable() || this.selectedAnnotationUnavailable() ||
...@@ -132,8 +136,11 @@ function SidebarContentController( ...@@ -132,8 +136,11 @@ function SidebarContentController(
store.getState().filterQuery store.getState().filterQuery
) { ) {
return false; return false;
} else if (store.focusModeFocused()) {
return false;
} else {
return true;
} }
return true;
}; };
this.setCollapsed = function(id, collapsed) { this.setCollapsed = function(id, collapsed) {
......
'use strict';
const { shallow } = require('enzyme');
const { createElement } = require('preact');
const FocusedModeHeader = require('../focused-mode-header');
describe('FocusedModeHeader', function() {
let fakeStore;
function createComponent() {
return shallow(<FocusedModeHeader />);
}
beforeEach(function() {
fakeStore = {
selection: {
focusMode: {
enabled: true,
focused: true,
},
},
focusModeFocused: sinon.stub().returns(false),
focusModeUserPrettyName: sinon.stub().returns('Fake User'),
setFocusModeFocused: sinon.stub(),
};
FocusedModeHeader.$imports.$mock({
'../store/use-store': callback => callback(fakeStore),
});
});
afterEach(() => {
FocusedModeHeader.$imports.$restore();
});
it('creates the component', () => {
const wrapper = createComponent();
assert.include(wrapper.text(), 'All annotations');
});
it("sets the button's text to the user's name when focused", () => {
fakeStore.focusModeFocused = sinon.stub().returns(true);
const wrapper = createComponent();
assert.include(wrapper.text(), 'Annotations by Fake User');
});
describe('clicking the button shall toggle the focused mode', function() {
it('when focused is false, toggle to true', () => {
const wrapper = createComponent();
wrapper.find('button').simulate('click');
assert.calledWith(fakeStore.setFocusModeFocused, true);
});
it('when focused is true, toggle to false', () => {
fakeStore.focusModeFocused = sinon.stub().returns(true);
const wrapper = createComponent();
wrapper.find('button').simulate('click');
assert.calledWith(fakeStore.setFocusModeFocused, false);
});
});
});
...@@ -158,6 +158,17 @@ describe('sidebar.components.sidebar-content', function() { ...@@ -158,6 +158,17 @@ describe('sidebar.components.sidebar-content', function() {
}); });
}); });
describe('showFocusedHeader', () => {
it('returns true if focus mode is enabled', () => {
store.focusModeEnabled = sinon.stub().returns(true);
assert.isTrue(ctrl.showFocusedHeader());
});
it('returns false if focus mode is not enabled', () => {
store.focusModeEnabled = sinon.stub().returns(false);
assert.isFalse(ctrl.showFocusedHeader());
});
});
function connectFrameAndPerformInitialFetch() { function connectFrameAndPerformInitialFetch() {
setFrames([{ uri: 'https://a-page.com' }]); setFrames([{ uri: 'https://a-page.com' }]);
$scope.$digest(); $scope.$digest();
......
...@@ -38,6 +38,9 @@ function hostPageConfig(window) { ...@@ -38,6 +38,9 @@ function hostPageConfig(window) {
// This should be removed once new note button is enabled for everybody. // This should be removed once new note button is enabled for everybody.
'enableExperimentalNewNoteButton', 'enableExperimentalNewNoteButton',
// Forces the sidebar to filter annotations to a single user.
'focus',
// Fetch config from a parent frame. // Fetch config from a parent frame.
'requestConfigFromFrame', 'requestConfigFromFrame',
......
...@@ -180,6 +180,10 @@ function startAngularApp(config) { ...@@ -180,6 +180,10 @@ function startAngularApp(config) {
'searchStatusBar', 'searchStatusBar',
wrapReactComponent(require('./components/search-status-bar')) wrapReactComponent(require('./components/search-status-bar'))
) )
.component(
'focusedModeHeader',
wrapReactComponent(require('./components/focused-mode-header'))
)
.component( .component(
'selectionTabs', 'selectionTabs',
wrapReactComponent(require('./components/selection-tabs')) wrapReactComponent(require('./components/selection-tabs'))
......
...@@ -48,17 +48,31 @@ function RootThread($rootScope, store, searchFilter, viewFilter) { ...@@ -48,17 +48,31 @@ function RootThread($rootScope, store, searchFilter, viewFilter) {
*/ */
function buildRootThread(state) { function buildRootThread(state) {
const sortFn = sortFns[state.sortKey]; const sortFn = sortFns[state.sortKey];
const shouldFilterThread = () => {
// is there a query or focused truthy value from the config?
return state.filterQuery || store.focusModeFocused();
};
let filterFn; let filterFn;
if (state.filterQuery) { if (shouldFilterThread()) {
const filters = searchFilter.generateFacetedFilter(state.filterQuery); const userFilter = {}; // optional user filter object for focused mode
// look for a unique username, if present, add it to the user filter
const focusedUsername = store.focusModeUsername(); // may be null if no focused user
if (focusedUsername) {
// focused user found, add it to the filter object
userFilter.user = focusedUsername;
}
const filters = searchFilter.generateFacetedFilter(
state.filterQuery,
userFilter
);
filterFn = function(annot) { filterFn = function(annot) {
return viewFilter.filter([annot], filters).length > 0; return viewFilter.filter([annot], filters).length > 0;
}; };
} }
let threadFilterFn; let threadFilterFn;
if (state.isSidebar && !state.filterQuery) { if (state.isSidebar && !shouldFilterThread()) {
threadFilterFn = function(thread) { threadFilterFn = function(thread) {
if (!thread.annotation) { if (!thread.annotation) {
return false; return false;
......
...@@ -127,9 +127,12 @@ function toObject(searchtext) { ...@@ -127,9 +127,12 @@ function toObject(searchtext) {
* facet. * facet.
* *
* @param {string} searchtext * @param {string} searchtext
* @param {object} focusFilters - Map of the filter objects keyed to array values.
* Currently, only the `user` filter key is supported.
*
* @return {Object} * @return {Object}
*/ */
function generateFacetedFilter(searchtext) { function generateFacetedFilter(searchtext, focusFilters = {}) {
let terms; let terms;
const any = []; const any = [];
const quote = []; const quote = [];
...@@ -138,8 +141,7 @@ function generateFacetedFilter(searchtext) { ...@@ -138,8 +141,7 @@ function generateFacetedFilter(searchtext) {
const tag = []; const tag = [];
const text = []; const text = [];
const uri = []; const uri = [];
const user = []; const user = focusFilters.user ? [focusFilters.user] : [];
if (searchtext) { if (searchtext) {
terms = tokenize(searchtext); terms = tokenize(searchtext);
for (const term of terms) { for (const term of terms) {
......
...@@ -28,6 +28,7 @@ describe('rootThread', function() { ...@@ -28,6 +28,7 @@ describe('rootThread', function() {
let fakeStore; let fakeStore;
let fakeBuildThread; let fakeBuildThread;
let fakeSearchFilter; let fakeSearchFilter;
let fakeSettings;
let fakeViewFilter; let fakeViewFilter;
let $rootScope; let $rootScope;
...@@ -65,6 +66,8 @@ describe('rootThread', function() { ...@@ -65,6 +66,8 @@ describe('rootThread', function() {
getDraftIfNotEmpty: sinon.stub().returns(null), getDraftIfNotEmpty: sinon.stub().returns(null),
removeDraft: sinon.stub(), removeDraft: sinon.stub(),
createAnnotation: sinon.stub(), createAnnotation: sinon.stub(),
focusModeFocused: sinon.stub().returns(false),
focusModeUsername: sinon.stub().returns({}),
}; };
fakeBuildThread = sinon.stub().returns(fixtures.emptyThread); fakeBuildThread = sinon.stub().returns(fixtures.emptyThread);
...@@ -73,6 +76,8 @@ describe('rootThread', function() { ...@@ -73,6 +76,8 @@ describe('rootThread', function() {
generateFacetedFilter: sinon.stub(), generateFacetedFilter: sinon.stub(),
}; };
fakeSettings = {};
fakeViewFilter = { fakeViewFilter = {
filter: sinon.stub(), filter: sinon.stub(),
}; };
...@@ -81,6 +86,7 @@ describe('rootThread', function() { ...@@ -81,6 +86,7 @@ describe('rootThread', function() {
.module('app', []) .module('app', [])
.value('store', fakeStore) .value('store', fakeStore)
.value('searchFilter', fakeSearchFilter) .value('searchFilter', fakeSearchFilter)
.value('settings', fakeSettings)
.value('viewFilter', fakeViewFilter) .value('viewFilter', fakeViewFilter)
.service('rootThread', rootThreadFactory); .service('rootThread', rootThreadFactory);
...@@ -340,6 +346,26 @@ describe('rootThread', function() { ...@@ -340,6 +346,26 @@ describe('rootThread', function() {
}); });
}); });
describe('when the focus user is present', () => {
it("generates a thread filter focused on the user's annotations", () => {
fakeBuildThread.reset();
const filters = [{ user: { terms: ['acct:bill@localhost'] } }];
const annotation = annotationFixtures.defaultAnnotation();
fakeSearchFilter.generateFacetedFilter.returns(filters);
fakeStore.focusModeFocused = sinon.stub().returns(true);
rootThread.thread(fakeStore.state);
const filterFn = fakeBuildThread.args[0][1].filterFn;
fakeViewFilter.filter.returns([annotation]);
assert.isTrue(filterFn(annotation));
assert.calledWith(
fakeViewFilter.filter,
sinon.match([annotation]),
filters
);
});
});
context('when annotation events occur', function() { context('when annotation events occur', function() {
const annot = annotationFixtures.defaultAnnotation(); const annot = annotationFixtures.defaultAnnotation();
......
...@@ -185,5 +185,23 @@ describe('sidebar.search-filter', () => { ...@@ -185,5 +185,23 @@ describe('sidebar.search-filter', () => {
} }
}); });
}); });
it('filters to a focused user', () => {
const filter = searchFilter.generateFacetedFilter(null, {
user: 'fakeusername',
});
// Remove empty facets.
Object.keys(filter).forEach(k => {
if (filter[k].terms.length === 0) {
delete filter[k];
}
});
assert.deepEqual(filter, {
user: {
operator: 'or',
terms: ['fakeusername'],
},
});
});
}); });
}); });
...@@ -113,6 +113,13 @@ function init(settings) { ...@@ -113,6 +113,13 @@ function init(settings) {
selectedTab: TAB_DEFAULT, selectedTab: TAB_DEFAULT,
focusMode: {
enabled: settings.hasOwnProperty('focus'), // readonly
focused: true,
// Copy over the focus confg from settings object
config: { ...(settings.focus ? settings.focus : {}) },
},
// Key by which annotations are currently sorted. // Key by which annotations are currently sorted.
sortKey: TAB_SORTKEY_DEFAULT[TAB_DEFAULT], sortKey: TAB_SORTKEY_DEFAULT[TAB_DEFAULT],
// Keys by which annotations can be sorted. // Keys by which annotations can be sorted.
...@@ -146,6 +153,15 @@ const update = { ...@@ -146,6 +153,15 @@ const update = {
return { focusedAnnotationMap: action.focused }; return { focusedAnnotationMap: action.focused };
}, },
SET_FOCUS_MODE_FOCUSED: function(state, action) {
return {
focusMode: {
...state.focusMode,
focused: action.focused,
},
};
},
SET_FORCE_VISIBLE: function(state, action) { SET_FORCE_VISIBLE: function(state, action) {
return { forceVisible: action.forceVisible }; return { forceVisible: action.forceVisible };
}, },
...@@ -307,6 +323,16 @@ function setFilterQuery(query) { ...@@ -307,6 +323,16 @@ function setFilterQuery(query) {
}; };
} }
/**
* Set the focused to only show annotations by the focused user.
*/
function setFocusModeFocused(focused) {
return {
type: actions.SET_FOCUS_MODE_FOCUSED,
focused,
};
}
/** Sets the sort key for the annotation list. */ /** Sets the sort key for the annotation list. */
function setSortKey(key) { function setSortKey(key) {
return { return {
...@@ -353,6 +379,55 @@ const getFirstSelectedAnnotationId = createSelector( ...@@ -353,6 +379,55 @@ const getFirstSelectedAnnotationId = createSelector(
function filterQuery(state) { function filterQuery(state) {
return state.filterQuery; return state.filterQuery;
} }
/**
* Returns the on/off state of the focus mode. This can be toggled on or off to
* filter to the focused user.
*
* @return {boolean}
*/
function focusModeFocused(state) {
return state.focusMode.enabled && state.focusMode.focused;
}
/**
* Returns the value of the focus mode from the config.
*
* @return {boolean}
*/
function focusModeEnabled(state) {
return state.focusMode.enabled;
}
/**
* Returns the username of the focused mode or null if none is found.
*
* @return {object}
*/
function focusModeUsername(state) {
if (state.focusMode.config.user && state.focusMode.config.user.username) {
return state.focusMode.config.user.username;
}
return null;
}
/**
* Returns the display name for a user or the username
* if display name is not present. If both are missing
* then this returns an empty string.
*
* @return {string}
*/
function focusModeUserPrettyName(state) {
const user = state.focusMode.config.user;
if (!user) {
return '';
} else if (user.displayName) {
return user.displayName;
} else if (user.username) {
return user.username;
} else {
return '';
}
}
module.exports = { module.exports = {
init: init, init: init,
...@@ -367,6 +442,7 @@ module.exports = { ...@@ -367,6 +442,7 @@ module.exports = {
selectTab: selectTab, selectTab: selectTab,
setCollapsed: setCollapsed, setCollapsed: setCollapsed,
setFilterQuery: setFilterQuery, setFilterQuery: setFilterQuery,
setFocusModeFocused: setFocusModeFocused,
setForceVisible: setForceVisible, setForceVisible: setForceVisible,
setSortKey: setSortKey, setSortKey: setSortKey,
toggleSelectedAnnotations: toggleSelectedAnnotations, toggleSelectedAnnotations: toggleSelectedAnnotations,
...@@ -375,6 +451,10 @@ module.exports = { ...@@ -375,6 +451,10 @@ module.exports = {
selectors: { selectors: {
hasSelectedAnnotations, hasSelectedAnnotations,
filterQuery, filterQuery,
focusModeFocused,
focusModeEnabled,
focusModeUsername,
focusModeUserPrettyName,
isAnnotationSelected, isAnnotationSelected,
getFirstSelectedAnnotationId, getFirstSelectedAnnotationId,
}, },
......
...@@ -186,6 +186,84 @@ describe('store/modules/selection', () => { ...@@ -186,6 +186,84 @@ describe('store/modules/selection', () => {
}); });
}); });
describe('setFocusModeFocused()', function() {
it('sets the focus mode to enabled', function() {
store.setFocusModeFocused(true);
assert.equal(store.getState().focusMode.focused, true);
});
it('sets the focus mode to not enabled', function() {
store = createStore([selection], [{ focus: { user: {} } }]);
store.setFocusModeFocused(false);
assert.equal(store.getState().focusMode.focused, false);
});
});
describe('focusModeEnabled()', function() {
it('should be true when the focus setting is present', function() {
store = createStore([selection], [{ focus: { user: {} } }]);
assert.equal(store.focusModeEnabled(), true);
});
it('should be false when the focus setting is not present', function() {
assert.equal(store.focusModeEnabled(), false);
});
});
describe('focusModeFocused()', function() {
it('should return true by default when focus mode is enabled', function() {
store = createStore([selection], [{ focus: { user: {} } }]);
assert.equal(store.getState().focusMode.enabled, true);
assert.equal(store.getState().focusMode.focused, true);
assert.equal(store.focusModeFocused(), true);
});
it('should return false by default when focus mode is not enabled', function() {
assert.equal(store.getState().focusMode.enabled, false);
assert.equal(store.getState().focusMode.focused, true);
assert.equal(store.focusModeFocused(), false);
});
});
describe('focusModeUserPrettyName()', function() {
it('should return false by default when focus mode is not enabled', function() {
store = createStore(
[selection],
[{ focus: { user: { displayName: 'FakeDisplayName' } } }]
);
assert.equal(store.focusModeUserPrettyName(), 'FakeDisplayName');
});
it('should the username when displayName is missing', function() {
store = createStore(
[selection],
[{ focus: { user: { username: 'FakeUserName' } } }]
);
assert.equal(store.focusModeUserPrettyName(), 'FakeUserName');
});
it('should an return empty string when user object has no names', function() {
store = createStore([selection], [{ focus: { user: {} } }]);
assert.equal(store.focusModeUserPrettyName(), '');
});
it('should an return empty string when there is no focus object', function() {
assert.equal(store.focusModeUserPrettyName(), '');
});
});
describe('focusModeUsername()', function() {
it('should return the user name when present', function() {
store = createStore(
[selection],
[{ focus: { user: { username: 'FakeUserName' } } }]
);
assert.equal(store.focusModeUsername(), 'FakeUserName');
});
it('should return null when the username is not present', function() {
store = createStore([selection], [{ focus: { user: {} } }]);
assert.isNull(store.focusModeUsername());
});
it('should return null when the user object is not present', function() {
assert.isNull(store.focusModeUsername());
});
});
describe('highlightAnnotations()', function() { describe('highlightAnnotations()', function() {
it('sets the highlighted annotations', function() { it('sets the highlighted annotations', function() {
store.highlightAnnotations(['id1', 'id2']); store.highlightAnnotations(['id1', 'id2']);
......
<focused-mode-header
ng-if="vm.showFocusedHeader()">
</focused-mode-header>
<selection-tabs <selection-tabs
ng-if="vm.showSelectedTabs()" ng-if="vm.showSelectedTabs()"
is-loading="vm.isLoading()"> is-loading="vm.isLoading()">
......
.focused-mode-header {
margin-bottom: 10px;
button {
width: 100%;
height: 24px;
margin-right: 10px;
&:focus {
outline: none;
}
}
}
...@@ -27,6 +27,7 @@ $base-line-height: 20px; ...@@ -27,6 +27,7 @@ $base-line-height: 20px;
@import './components/annotation-thread'; @import './components/annotation-thread';
@import './components/annotation-user'; @import './components/annotation-user';
@import './components/excerpt'; @import './components/excerpt';
@import './components/focused-mode-header';
@import './components/group-list'; @import './components/group-list';
@import './components/group-list-item'; @import './components/group-list-item';
@import './components/help-panel'; @import './components/help-panel';
......
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