Graph selection state management and history bug fixes (#679)

* save graph selection in redux state

* fix old graph brush select regressions

* refactor graph brush selection to work with undo/redo

* update tests to match new crossfilter spatial select API

* graph selection state now in redux

* remove dead code

* sync graph selection with redux state; improvements to undoable machinery

* fix regression in undoable

* differentiate graph selection cancel from deselect action

* simplify calculation

* remove debugging code

* fix responsive repaint bug in graph selection tool

* undoable debugging and code cleanliness

* undoable action filter state now merges, rather than replaces

* improve comments

* add debounce to undoable action filter; improve comments and debug sanity check code

* comments

* fix undoable bug with clear scatterplot actions

* disable undoable debug flag

* cleanup API and comments around statemachine

* add test id attribute to lasso

* add better error handling for gene fetch requests
This commit is contained in:
Bruce Martin
2019-04-08 15:53:12 -07:00
committed by GitHub
parent c9a8e3ea42
commit 7275d9d4dc
16 changed files with 1150 additions and 237 deletions
+155 -22
View File
@@ -8,35 +8,73 @@ Requires three parameters:
state to be made "undoable".
* options - an optional object, which may contain the following parameters:
* historyLimit: max number of historical states to remember (aka max undo depth)
* skipActionFilter: filter function, (state, action) => bool. If it returns
truthy, the current state will not be pushed onto the history stack.
* clearOnActionFilter: filter function, (state, action) => bool. If it returns
truthy, the history state will be cleared as part of handling this action.
skipActionFilter has precedence over clearOnActionFilter.
* actionFilter: filter function, (state, action, filterState) => value.
See below for details.
* debug: if truish, will print helpful log messages about history manipulation
This meta reducer accepts three actions types:
* @@undoable/undo - move back in history
* @@undoable/redo - move forward in history
* @@undoable/clear - clear history
---
Action filter - controls the undoable reducer side-effects. If not
specified, the action filter defaults to "save", ie, pushes a redo
point upon each action.
The action filter callback has access to the current action, the entire
undoable reducer state, and any state it wants to manage ("filterState").
This filter state will be passed to each action filter call, and any
value returned (via @@undoable/filterState field described below) will be
MERGED into the current filter state.
An object must be returned (the "undoable action"), indicating desired
history state processing. The undoable action object contents, by key:
@@undoable/filterAction: required. Can be one of:
"skip" - reduce the current action, but no other side effects.
Same as returning false.
"clear" - reduce the current action, and clear history state.
"save" - push the previous state onto the history stack (ie,
before reducing the action)
"stashPending" - reduce action, save state as pending. Does not
not commit it to history. Along with cancelPending and applyPending,
can be used to delay commit of history (eg, for multi-action
groupings, asynch operations, etc).
"cancelPending" - reduce action, cancel any pending state save.
"applyPending" - commit any pending state to the history stack,
then reduce action.
@@undoable/filterState: optional. If this value is set, it will be
MERGED into the current filter state. The value and semantics of any
filter state are entirely at the discretion of the action filter.
*/
const historyKeyPrefix = "@@undoable/";
const pastKey = `${historyKeyPrefix}past`;
const futureKey = `${historyKeyPrefix}future`;
const filterStateKey = `${historyKeyPrefix}filterState`;
const filterActionKey = `${historyKeyPrefix}filterAction`;
const pendingKey = `${historyKeyPrefix}pending`;
const defaultHistoryLimit = -100;
const Undoable = (reducer, undoableKeys, options = {}) => {
const { debug } = options;
let { historyLimit } = options;
if (!historyLimit) historyLimit = defaultHistoryLimit;
if (historyLimit > 0) historyLimit = -historyLimit;
const skipActionFilter = options.skipActionFilter || (() => false);
const clearOnActionFilter = options.clearOnActionFilter || (() => false);
const actionFilter =
options.actionFilter || (() => ({ [filterActionKey]: "save" }));
if (!Array.isArray(undoableKeys) || undoableKeys.length === 0)
throw new Error("undoable keys array must be specified");
const undoableKeysSet = new Set(undoableKeys);
/*
Undo the current to previous history
*/
function undo(currentState) {
const past = currentState[pastKey];
const future = currentState[futureKey];
@@ -51,11 +89,15 @@ const Undoable = (reducer, undoableKeys, options = {}) => {
...currentState,
...fromEntries(newState),
[pastKey]: newPast,
[futureKey]: newFuture
[futureKey]: newFuture,
[pendingKey]: null
};
return nextState;
}
/*
Replay future, previously undone.
*/
function redo(currentState) {
const past = currentState[pastKey] || [];
const future = currentState[futureKey] || [];
@@ -70,30 +112,45 @@ const Undoable = (reducer, undoableKeys, options = {}) => {
...currentState,
...fromEntries(newState),
[pastKey]: newPast,
[futureKey]: newFuture
[futureKey]: newFuture,
[pendingKey]: null
};
return nextState;
}
/*
Clear the history state. No side-effects on current state.
*/
function clear(currentState) {
return {
...currentState,
[pastKey]: [],
[futureKey]: []
[futureKey]: [],
[filterStateKey]: {},
[pendingKey]: null
};
}
function skip(currentState, action) {
/*
Reduce current action, with no history side-effects
*/
function skip(currentState, action, filterState) {
const past = currentState[pastKey] || [];
const pending = currentState[pendingKey];
const res = reducer(currentState, action);
return {
...res,
[pastKey]: past,
[futureKey]: []
[futureKey]: [],
[filterStateKey]: filterState,
[pendingKey]: pending
};
}
function save(currentState, action) {
/*
Save current state in the history, then reduce action.
*/
function save(currentState, action, filterState) {
const past = currentState[pastKey] || [];
const currentUndoableState = Object.entries(currentState).filter(kv =>
undoableKeysSet.has(kv[0])
@@ -103,7 +160,48 @@ const Undoable = (reducer, undoableKeys, options = {}) => {
const nextState = {
...res,
[pastKey]: newPast,
[futureKey]: []
[futureKey]: [],
[filterStateKey]: filterState,
[pendingKey]: null
};
return nextState;
}
/*
Save current state as pending history change. No other side effects.
*/
function stashPending(currentState) {
const currentUndoableState = Object.entries(currentState).filter(kv =>
undoableKeysSet.has(kv[0])
);
return {
...currentState,
[pendingKey]: currentUndoableState
};
}
/*
Cancel pending history state change. No other side effects.
*/
function cancelPending(currentState) {
return {
...currentState,
[pendingKey]: null
};
}
/*
Push pending state onto the history stack
*/
function applyPending(currentState) {
const past = currentState[pastKey] || [];
const pendingState = currentState[pendingKey];
const newPast = push(past, pendingState, historyLimit);
const nextState = {
...currentState,
[pastKey]: newPast,
[futureKey]: [],
[pendingKey]: null
};
return nextState;
}
@@ -111,7 +209,9 @@ const Undoable = (reducer, undoableKeys, options = {}) => {
return (
currentState = {
[pastKey]: [],
[futureKey]: []
[futureKey]: [],
[filterStateKey]: {},
[pendingKey]: null
},
action
) => {
@@ -120,20 +220,53 @@ const Undoable = (reducer, undoableKeys, options = {}) => {
case "@@undoable/undo": {
return undo(currentState, action);
}
case "@@undoable/redo": {
return redo(currentState, action);
}
case "@@undoable/clear": {
return clear(currentState, action);
}
default: {
if (skipActionFilter(currentState, action)) {
return skip(currentState, action);
const currentFilterState = currentState[filterStateKey];
const actionFilterResp = actionFilter(
currentState,
action,
currentFilterState
);
const {
[filterActionKey]: filterAction,
[filterStateKey]: filterStateUpdate
} = actionFilterResp;
const nextFilterState = { ...currentFilterState, ...filterStateUpdate };
switch (filterAction) {
case "clear":
if (debug) console.log("---- CLEAR HISTO", action.type);
return clear(skip(currentState, action, nextFilterState));
case "save":
if (debug) console.log("---- SAVE HISTO", action.type);
return save(currentState, action, nextFilterState);
case "stashPending":
if (debug) console.log("---- STASH PENDING", action.type);
return skip(stashPending(currentState), action, nextFilterState);
case "cancelPending":
if (debug) console.log("---- CANCEL PENDING", action.type);
return skip(cancelPending(currentState), action, nextFilterState);
case "applyPending":
if (debug) console.log("---- APPLY PENDING", action.type);
return skip(applyPending(currentState), action, nextFilterState);
case "skip":
default:
return skip(currentState, action, nextFilterState);
}
if (clearOnActionFilter(currentState, action)) {
return clear(skip(currentState, action));
}
return save(currentState, action);
}
}
};