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) {
window.hypothesisConfig = function () {
return {
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
openSidebar: true,
......
......@@ -24,6 +24,7 @@ function configFrom(window_) {
'enableExperimentalNewNoteButton'
),
group: settings.group,
focus: settings.hostPageSetting('focus'),
theme: settings.hostPageSetting('theme'),
usernameUrl: settings.hostPageSetting('usernameUrl'),
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(
true
);
this.showFocusedHeader = () => {
return store.focusModeEnabled();
};
this.showSelectedTabs = function() {
if (
this.selectedAnnotationUnavailable() ||
......@@ -132,8 +136,11 @@ function SidebarContentController(
store.getState().filterQuery
) {
return false;
}
} else if (store.focusModeFocused()) {
return false;
} else {
return true;
}
};
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() {
});
});
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() {
setFrames([{ uri: 'https://a-page.com' }]);
$scope.$digest();
......
......@@ -38,6 +38,9 @@ function hostPageConfig(window) {
// This should be removed once new note button is enabled for everybody.
'enableExperimentalNewNoteButton',
// Forces the sidebar to filter annotations to a single user.
'focus',
// Fetch config from a parent frame.
'requestConfigFromFrame',
......
......@@ -180,6 +180,10 @@ function startAngularApp(config) {
'searchStatusBar',
wrapReactComponent(require('./components/search-status-bar'))
)
.component(
'focusedModeHeader',
wrapReactComponent(require('./components/focused-mode-header'))
)
.component(
'selectionTabs',
wrapReactComponent(require('./components/selection-tabs'))
......
......@@ -48,17 +48,31 @@ function RootThread($rootScope, store, searchFilter, viewFilter) {
*/
function buildRootThread(state) {
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;
if (state.filterQuery) {
const filters = searchFilter.generateFacetedFilter(state.filterQuery);
if (shouldFilterThread()) {
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) {
return viewFilter.filter([annot], filters).length > 0;
};
}
let threadFilterFn;
if (state.isSidebar && !state.filterQuery) {
if (state.isSidebar && !shouldFilterThread()) {
threadFilterFn = function(thread) {
if (!thread.annotation) {
return false;
......
......@@ -127,9 +127,12 @@ function toObject(searchtext) {
* facet.
*
* @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}
*/
function generateFacetedFilter(searchtext) {
function generateFacetedFilter(searchtext, focusFilters = {}) {
let terms;
const any = [];
const quote = [];
......@@ -138,8 +141,7 @@ function generateFacetedFilter(searchtext) {
const tag = [];
const text = [];
const uri = [];
const user = [];
const user = focusFilters.user ? [focusFilters.user] : [];
if (searchtext) {
terms = tokenize(searchtext);
for (const term of terms) {
......
......@@ -28,6 +28,7 @@ describe('rootThread', function() {
let fakeStore;
let fakeBuildThread;
let fakeSearchFilter;
let fakeSettings;
let fakeViewFilter;
let $rootScope;
......@@ -65,6 +66,8 @@ describe('rootThread', function() {
getDraftIfNotEmpty: sinon.stub().returns(null),
removeDraft: sinon.stub(),
createAnnotation: sinon.stub(),
focusModeFocused: sinon.stub().returns(false),
focusModeUsername: sinon.stub().returns({}),
};
fakeBuildThread = sinon.stub().returns(fixtures.emptyThread);
......@@ -73,6 +76,8 @@ describe('rootThread', function() {
generateFacetedFilter: sinon.stub(),
};
fakeSettings = {};
fakeViewFilter = {
filter: sinon.stub(),
};
......@@ -81,6 +86,7 @@ describe('rootThread', function() {
.module('app', [])
.value('store', fakeStore)
.value('searchFilter', fakeSearchFilter)
.value('settings', fakeSettings)
.value('viewFilter', fakeViewFilter)
.service('rootThread', rootThreadFactory);
......@@ -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() {
const annot = annotationFixtures.defaultAnnotation();
......
......@@ -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) {
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.
sortKey: TAB_SORTKEY_DEFAULT[TAB_DEFAULT],
// Keys by which annotations can be sorted.
......@@ -146,6 +153,15 @@ const update = {
return { focusedAnnotationMap: action.focused };
},
SET_FOCUS_MODE_FOCUSED: function(state, action) {
return {
focusMode: {
...state.focusMode,
focused: action.focused,
},
};
},
SET_FORCE_VISIBLE: function(state, action) {
return { forceVisible: action.forceVisible };
},
......@@ -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. */
function setSortKey(key) {
return {
......@@ -353,6 +379,55 @@ const getFirstSelectedAnnotationId = createSelector(
function filterQuery(state) {
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 = {
init: init,
......@@ -367,6 +442,7 @@ module.exports = {
selectTab: selectTab,
setCollapsed: setCollapsed,
setFilterQuery: setFilterQuery,
setFocusModeFocused: setFocusModeFocused,
setForceVisible: setForceVisible,
setSortKey: setSortKey,
toggleSelectedAnnotations: toggleSelectedAnnotations,
......@@ -375,6 +451,10 @@ module.exports = {
selectors: {
hasSelectedAnnotations,
filterQuery,
focusModeFocused,
focusModeEnabled,
focusModeUsername,
focusModeUserPrettyName,
isAnnotationSelected,
getFirstSelectedAnnotationId,
},
......
......@@ -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() {
it('sets the highlighted annotations', function() {
store.highlightAnnotations(['id1', 'id2']);
......
<focused-mode-header
ng-if="vm.showFocusedHeader()">
</focused-mode-header>
<selection-tabs
ng-if="vm.showSelectedTabs()"
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;
@import './components/annotation-thread';
@import './components/annotation-user';
@import './components/excerpt';
@import './components/focused-mode-header';
@import './components/group-list';
@import './components/group-list-item';
@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