Commit e9b81457 authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Split `selection` store into `selection`, `filters`

The `selection` store module has become overlong and
its responsibilities aren't clear. We know we'll be
adding some more filtering capabilities to the app
in the next short while, and that would make
`selection` even more complex and heavy.

Split into two store modules: `selection` and
`filters`.

Temporarily re-implement `rootSelector`s that
are needed for generating thread and filter
state for components.
parent 899a2831
......@@ -9,7 +9,7 @@ import useRootThread from './hooks/use-root-thread';
import useStore from '../store/use-store';
/**
* @typedef {import('../store/modules/selection').FilterState} FilterState
* @typedef {import('../store/modules/filters').FilterState} FilterState
* @typedef {import('../util/build-thread').Thread} Thread
*/
......
......@@ -36,6 +36,7 @@ import annotations from './modules/annotations';
import defaults from './modules/defaults';
import directLinked from './modules/direct-linked';
import drafts from './modules/drafts';
import filters from './modules/filters';
import frames from './modules/frames';
import groups from './modules/groups';
import links from './modules/links';
......@@ -57,6 +58,7 @@ import viewer from './modules/viewer';
* @typedef {import("./modules/defaults").DefaultsStore} DefaultsStore
* @typedef {import("./modules/direct-linked").DirectLinkedStore} DirectLinkedStore
* @typedef {import("./modules/drafts").DraftsStore} DraftsStore
* @typedef {import("./modules/filters").FiltersStore} FiltersStore
* @typedef {import("./modules/frames").FramesStore} FramesStore
* @typedef {import("./modules/groups").GroupsStore} GroupsStore
* @typedef {import("./modules/links").LinksStore} LinksStore
......@@ -76,6 +78,7 @@ import viewer from './modules/viewer';
* DefaultsStore &
* DirectLinkedStore &
* DraftsStore &
* FiltersStore &
* FramesStore &
* GroupsStore &
* LinksStore &
......@@ -109,6 +112,7 @@ export default function store(settings) {
defaults,
directLinked,
drafts,
filters,
frames,
links,
groups,
......
import { createSelector } from 'reselect';
import { actionTypes } from '../util';
import { trueKeys } from '../../util/collections';
/**
* @typedef FocusConfig
* @prop {User} user
*/
/**
* @typedef FocusedUser
* @prop {string} filter - The identifier to use for filtering annotations
* derived from either a userId or a username. This may take the
* form of a username, e.g. 'oakbucket', or a userid
* @prop {string} displayName
*/
/**
* @typedef User
* @prop {string} [userid]
* @prop {string} [username]
* @prop {string} displayName - User's display name
*/
/**
* @typedef FocusState
* @prop {boolean} configured - Focus config contains valid `user` and
* is good to go
* @prop {boolean} active - Focus mode is currently applied
* @prop {FocusedUser} [user] - User to focus on (filter annotations for)
*/
/**
* TODO potentially split into `FilterState` and `FocusState`?
* @typedef FilterState
* @prop {string|null} filterQuery
* @prop {boolean} focusActive
* @prop {boolean} focusConfigured
* @prop {string|null} focusDisplayName
* @prop {number} forcedVisibleCount
* @prop {number} selectedCount
*/
/**
* Configure (user-)focused mode. User-focus mode may be set in one of two
* ways:
* - A `focus` object containing a valid `user` object is present in the
* application's `settings` object during initialization time
* - A `user` object is given to the `changeFocusedUser` action (this
* is implemented via an RPC method call)
* For focus mode to be considered configured, it must have a valid `user`.
* A successfully-configured focus mode will be set to `active` immediately
* and may be toggled via `toggleFocusMode`.
*
* @param {FocusConfig} focusConfig
* @return {FocusState}
*/
function setFocus(focusConfig) {
const focusDefaultState = {
configured: false,
active: false,
};
// To be able to apply a focused mode, a `user` object must be present,
// and that user object must have either a `username` or a `userid`
const focusUser = focusConfig.user || {};
const userFilter = focusUser.username || focusUser.userid;
// If that requirement is not met, we can't configure/activate focus mode
if (!userFilter) {
return focusDefaultState;
}
return {
configured: true,
active: true, // Activate valid focus mode immediately
user: {
filter: userFilter,
displayName: focusUser.displayName || userFilter || '',
},
};
}
function init(settings) {
return {
filters: {},
focus: setFocus(settings.focus || /** @type FocusConfig */ ({})),
query: settings.query || null,
};
}
const update = {
CHANGE_FOCUS_MODE_USER: function (state, action) {
return {
focus: setFocus({ user: action.user }),
};
},
SET_FILTER_QUERY: function (state, action) {
return { query: action.query };
},
SET_FOCUS_MODE: function (state, action) {
const active =
typeof action.active !== 'undefined'
? action.active
: !state.focus.active;
return {
focus: {
...state.focus,
active,
},
};
},
// Actions defined in other modules
CLEAR_SELECTION: function (state) {
return {
filters: {},
focus: {
...state.focus,
active: false,
},
query: null,
};
},
};
const actions = actionTypes(update);
// Action creators
/** Set the query used to filter displayed annotations. */
function setFilterQuery(query) {
return dispatch => {
dispatch({
type: actions.SET_FILTER_QUERY,
query: query,
});
};
}
/**
* Clears any applied filters, changes the focused user and sets
* focused enabled to `true`.
*
* @param {User} user - The user to focus on
*/
function changeFocusModeUser(user) {
return { type: actions.CHANGE_FOCUS_MODE_USER, user };
}
/**
* Toggle whether or not a (user-)focus mode is applied, either inverting the
* current active state or setting it to a target `active` state, if provided.
*
* @param {boolean} [active] - Optional `active` state for focus mode
*/
function toggleFocusMode(active) {
return {
type: actions.SET_FOCUS_MODE,
active,
};
}
// Selectors
function filterQuery(state) {
return state.query;
}
/**
* Returns the display name for a user or the userid
* if display name is not present. If both are missing
* then this returns an empty string.
*
* @return {string}
*/
function focusModeUserPrettyName(state) {
if (!state.focus.configured) {
return '';
}
return state.focus.user.displayName;
}
/**
* Summary of applied filters
*
* @type {(state: any) => FilterState}
*/
const filterState = createSelector(
rootState => rootState.selection,
rootState => rootState.filters,
(selection, filters) => {
// TODO FIXME
const forcedVisibleCount = trueKeys(selection.forcedVisible).length;
// TODO FIXME
const selectedCount = trueKeys(selection.selected).length;
return {
filterQuery: filters.query,
focusActive: filters.focus.active,
focusConfigured: filters.focus.configured,
focusDisplayName: focusModeUserPrettyName(filters),
forcedVisibleCount,
selectedCount,
};
}
);
/**
* @typedef FiltersStore
*
* // Actions
* @prop {typeof changeFocusModeUser} changeFocusModeUser
* @prop {typeof setFilterQuery} setFilterQuery
* @prop {typeof toggleFocusMode} toggleFocusMode
*
* // Selectors
* @prop {() => string|null} filterQuery
*
* // Root Selectors
* @prop {() => FilterState} filterState
*
*/
export default {
init,
namespace: 'filters',
update,
actions: {
changeFocusModeUser,
setFilterQuery,
toggleFocusMode,
},
selectors: {
filterQuery,
},
rootSelectors: {
filterState,
},
};
This diff is collapsed.
// import uiConstants from '../../../ui-constants';
import createStore from '../../create-store';
import filters from '../filters';
import selection from '../selection';
// import * as fixtures from '../../../test/annotation-fixtures';
describe('sidebar/store/modules/filters', () => {
let store;
let fakeSettings = [{}, {}];
const getFiltersState = () => {
return store.getState().filters;
};
beforeEach(() => {
store = createStore([filters, selection], fakeSettings);
});
describe('actions', () => {
describe('changeFocusModeUser', () => {
it('sets the focused user and activates focus', () => {
store.toggleFocusMode(false);
store.changeFocusModeUser({
username: 'testuser',
displayName: 'Test User',
});
const focusState = getFiltersState().focus;
assert.isTrue(focusState.active);
assert.isTrue(focusState.configured);
assert.equal(focusState.user.filter, 'testuser');
assert.equal(focusState.user.displayName, 'Test User');
});
// When the LMS app wants the client to disable focus mode it sends a
// changeFocusModeUser() RPC call with {username: undefined, displayName:
// undefined}:
//
// https://github.com/hypothesis/lms/blob/d6b88fd7e375a4b23899117556b3e39cfe18986b/lms/static/scripts/frontend_apps/components/LMSGrader.js#L46
//
// This is the LMS app's way of asking the client to disable focus mode.
it('deactivates and disables focus if username is undefined', () => {
store.toggleFocusMode(true);
store.changeFocusModeUser({
username: undefined,
displayName: undefined,
});
const focusState = getFiltersState().focus;
assert.isFalse(focusState.active);
assert.isFalse(focusState.configured);
});
});
describe('setFilterQuery', () => {
it('sets the filter query', () => {
store.setFilterQuery('a-query');
assert.equal(getFiltersState().query, 'a-query');
assert.equal(store.filterQuery(), 'a-query');
});
});
describe('toggleFocusMode', () => {
it('toggles the current active state if called without arguments', () => {
store.toggleFocusMode(false);
store.toggleFocusMode();
const focusState = getFiltersState().focus;
assert.isTrue(focusState.active);
});
it('toggles the current active state to designated state', () => {
store.toggleFocusMode(true);
store.toggleFocusMode(false);
const focusState = getFiltersState().focus;
assert.isFalse(focusState.active);
});
});
describe('CLEAR_SELECTION', () => {
it('responds to CLEAR_SELECTION by clearing filters and focus', () => {
store.changeFocusModeUser({
username: 'testuser',
displayName: 'Test User',
});
store.toggleFocusMode(true);
let focusState = getFiltersState().focus;
assert.isTrue(focusState.active);
store.clearSelection();
focusState = getFiltersState().focus;
assert.isFalse(focusState.active);
});
});
});
describe('selectors', () => {
describe('filterState', () => {
it('returns the current filter query', () => {
store.setFilterQuery('doodah, doodah');
assert.equal(store.filterState().filterQuery, 'doodah, doodah');
});
it('returns user focus information', () => {
store.changeFocusModeUser({
username: 'filbert',
displayName: 'Pantomime Nutball',
});
const filterState = store.filterState();
assert.isTrue(filterState.focusActive);
assert.isTrue(filterState.focusConfigured);
assert.equal(filterState.focusDisplayName, 'Pantomime Nutball');
});
it('returns a count of forced-visible annotations', () => {
store.setForcedVisible('kaboodle', true);
store.setForcedVisible('stampy', false);
assert.equal(store.filterState().forcedVisibleCount, 1);
});
it('returns a count of selected annotations', () => {
store.selectAnnotations(['tabulature', 'felonious']);
assert.equal(store.filterState().selectedCount, 2);
});
it('returns empty filter states when no filters active', () => {
const filterState = store.filterState();
assert.isFalse(filterState.focusActive);
assert.isFalse(filterState.focusConfigured);
assert.isEmpty(filterState.focusDisplayName);
assert.isNull(filterState.filterQuery);
assert.equal(filterState.forcedVisibleCount, 0);
assert.equal(filterState.selectedCount, 0);
});
});
});
});
......@@ -61,7 +61,7 @@ describe('store', function () {
it('sets `filterQuery` to null', () => {
store.clearSelection();
assert.isNull(store.getState().selection.filterQuery);
assert.isNull(store.getState().filters.query);
});
it('sets `directLinkedGroupFetchFailed` to false', () => {
......
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