mirror of
https://github.com/chanzuckerberg/cellxgene.git
synced 2026-10-05 08:38:12 +08:00
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:
@@ -10,6 +10,8 @@ import {
|
||||
makeContinuousDimensionName
|
||||
} from "../util/nameCreators";
|
||||
|
||||
const XYDimName = layoutDimensionName("XY");
|
||||
|
||||
const CrossfilterReducer = (
|
||||
state = null,
|
||||
action,
|
||||
@@ -109,40 +111,37 @@ const CrossfilterReducer = (
|
||||
return crossfilter;
|
||||
}
|
||||
|
||||
case "graph brush selection change": {
|
||||
const name = layoutDimensionName("XY");
|
||||
const [x0, y0] = action.brushCoords.northwest;
|
||||
const [x1, y1] = action.brushCoords.southeast;
|
||||
return state.select(name, {
|
||||
case "graph brush end":
|
||||
case "graph brush change": {
|
||||
const [minX, maxY] = action.brushCoords.northwest;
|
||||
const [maxX, minY] = action.brushCoords.southeast;
|
||||
return state.select(XYDimName, {
|
||||
mode: "within-rect",
|
||||
x0,
|
||||
y0,
|
||||
x1,
|
||||
y1
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY
|
||||
});
|
||||
}
|
||||
|
||||
case "lasso deselect":
|
||||
case "graph brush deselect": {
|
||||
const name = layoutDimensionName("XY");
|
||||
return state.select(name, { mode: "all" });
|
||||
}
|
||||
|
||||
case "lasso selection": {
|
||||
case "graph lasso end": {
|
||||
const { polygon } = action;
|
||||
const name = layoutDimensionName("XY");
|
||||
if (polygon.length < 3) {
|
||||
// single point or a line is not a polygon, and is therefore a deselect
|
||||
return state.select(name, { mode: "all" });
|
||||
}
|
||||
return state.select(name, {
|
||||
return state.select(XYDimName, {
|
||||
mode: "within-polygon",
|
||||
polygon
|
||||
});
|
||||
}
|
||||
|
||||
case "graph lasso cancel":
|
||||
case "graph brush cancel":
|
||||
case "graph lasso deselect":
|
||||
case "graph brush deselect": {
|
||||
return state.select(XYDimName, { mode: "all" });
|
||||
}
|
||||
|
||||
case "continuous metadata histogram start":
|
||||
case "continuous metadata histogram brush":
|
||||
case "continuous metadata histogram cancel":
|
||||
case "continuous metadata histogram end": {
|
||||
const name = makeContinuousDimensionName(
|
||||
action.continuousNamespace,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
const GraphSelection = (
|
||||
state = {
|
||||
tool: "lasso", // what selection tool mode (lasso, brush, ...)
|
||||
selection: { mode: "all" } // current selection, which is tool specific
|
||||
},
|
||||
action
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "reset World to eq Universe": {
|
||||
return {
|
||||
...state,
|
||||
selection: {
|
||||
mode: "all"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case "graph brush end":
|
||||
case "graph brush change": {
|
||||
const { brushCoords } = action;
|
||||
return {
|
||||
...state,
|
||||
selection: {
|
||||
mode: "within-rect",
|
||||
brushCoords
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case "graph lasso end": {
|
||||
const { polygon } = action;
|
||||
return {
|
||||
...state,
|
||||
selection: {
|
||||
mode: "within-polygon",
|
||||
polygon
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
case "graph lasso cancel":
|
||||
case "graph brush cancel":
|
||||
case "graph lasso deselect":
|
||||
case "graph brush deselect": {
|
||||
return {
|
||||
...state,
|
||||
selection: {
|
||||
mode: "all"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default GraphSelection;
|
||||
@@ -8,6 +8,7 @@ import universe from "./universe";
|
||||
import world from "./world";
|
||||
import categoricalSelection from "./categoricalSelection";
|
||||
import continuousSelection from "./continuousSelection";
|
||||
import graphSelection from "./graphSelection";
|
||||
import crossfilter from "./crossfilter";
|
||||
import colors from "./colors";
|
||||
import differential from "./differential";
|
||||
@@ -15,48 +16,7 @@ import responsive from "./responsive";
|
||||
import controls from "./controls";
|
||||
import resetCache from "./resetCache";
|
||||
|
||||
const ignoredActions = new Set([
|
||||
// these actions will not affect history, ie, we will
|
||||
// not snapshot history upon these actions. These take
|
||||
// precedent over `clearHistoryUponActions`
|
||||
"url changed",
|
||||
"interface reset started",
|
||||
"initial data load start",
|
||||
"configuration load complete",
|
||||
"increment graph render counter",
|
||||
"window resize",
|
||||
|
||||
"lasso started",
|
||||
|
||||
"request differential expression success",
|
||||
|
||||
"expression load start",
|
||||
"expression load success",
|
||||
"expression load error",
|
||||
|
||||
"continuous metadata histogram brush",
|
||||
"continuous metadata histogram end",
|
||||
|
||||
"request user defined gene started",
|
||||
"request user defined gene success",
|
||||
"request user defined gene error",
|
||||
"bulk user defined gene complete",
|
||||
"single user defined gene complete"
|
||||
]);
|
||||
|
||||
const clearOnActions = new Set([
|
||||
// history will be cleared when these actions occur
|
||||
"initial data load complete (universe exists)",
|
||||
"reset World to eq Universe",
|
||||
"initial data load error"
|
||||
]);
|
||||
|
||||
/* configuration for the undoable meta reducer */
|
||||
const undoableConfig = {
|
||||
historyLimit: 50, // maximum history size
|
||||
skipActionFilter: (state, action) => ignoredActions.has(action.type),
|
||||
clearOnActionFilter: (state, action) => clearOnActions.has(action.type)
|
||||
};
|
||||
import undoableConfig from "./undoableConfig";
|
||||
|
||||
const Reducer = undoable(
|
||||
cascadeReducers([
|
||||
@@ -65,6 +25,7 @@ const Reducer = undoable(
|
||||
["world", world],
|
||||
["categoricalSelection", categoricalSelection],
|
||||
["continuousSelection", continuousSelection],
|
||||
["graphSelection", graphSelection],
|
||||
["crossfilter", crossfilter],
|
||||
["colors", colors],
|
||||
["controls", controls],
|
||||
@@ -76,6 +37,7 @@ const Reducer = undoable(
|
||||
"world",
|
||||
"categoricalSelection",
|
||||
"continuousSelection",
|
||||
"graphSelection",
|
||||
"crossfilter",
|
||||
"colors",
|
||||
"controls",
|
||||
|
||||
+155
-22
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import StateMachine from "../util/statemachine";
|
||||
import createFsmTransitions from "./undoableFsm";
|
||||
|
||||
const actionKey = "@@undoable/filterAction";
|
||||
const stateKey = "@@undoable/filterState";
|
||||
|
||||
/*
|
||||
these actions will not affect history
|
||||
*/
|
||||
const skipOnActions = new Set([
|
||||
"url changed",
|
||||
"interface reset started",
|
||||
"initial data load start",
|
||||
"configuration load complete",
|
||||
"increment graph render counter",
|
||||
"window resize",
|
||||
"user reset start",
|
||||
"reset colorscale",
|
||||
|
||||
"graph brush change",
|
||||
"continuous metadata histogram brush",
|
||||
|
||||
"expression load start",
|
||||
"expression load success",
|
||||
"expression load error",
|
||||
|
||||
"request user defined gene started",
|
||||
"request user defined gene success",
|
||||
"clear all user defined genes",
|
||||
|
||||
"get single gene expression for coloring started",
|
||||
"get single gene expression for coloring error"
|
||||
]);
|
||||
|
||||
/*
|
||||
identical, repeated occurances of these action types will be debounced.
|
||||
Entire action must be identical (all keys).
|
||||
*/
|
||||
const debounceOnActions = new Set([
|
||||
"color by categorical metadata",
|
||||
"color by continuous metadata",
|
||||
"color by expression"
|
||||
]);
|
||||
|
||||
/*
|
||||
history will be cleared when these actions occur
|
||||
*/
|
||||
const clearOnActions = new Set([
|
||||
"initial data load complete (universe exists)",
|
||||
"reset World to eq Universe",
|
||||
"initial data load error",
|
||||
"user reset end"
|
||||
]);
|
||||
|
||||
/*
|
||||
An immediate history save will be done for these
|
||||
*/
|
||||
const saveOnActions = new Set([
|
||||
"categorical metadata filter select",
|
||||
"categorical metadata filter deselect",
|
||||
"categorical metadata filter all of these",
|
||||
"categorical metadata none of these",
|
||||
|
||||
"color by categorical metadata",
|
||||
"color by continuous metadata",
|
||||
"color by expression",
|
||||
|
||||
"set scatterplot x",
|
||||
"set scatterplot y",
|
||||
|
||||
"store current cell selection as differential set 1",
|
||||
"store current cell selection as differential set 2",
|
||||
|
||||
"set World to current selection"
|
||||
]);
|
||||
|
||||
/**
|
||||
StateMachine - processing complex action handling - see FSM graph for
|
||||
actual structure, in undoableFsm.js
|
||||
**/
|
||||
|
||||
/*
|
||||
Default FSM actions. Used to side-effect transitions in the graph.
|
||||
See graph definition for the transitions that use each.
|
||||
|
||||
Signature: (fsm, transition, reducerState, reducerAction) => undoableAction
|
||||
*/
|
||||
const stashPending = fsm => ({
|
||||
[actionKey]: "stashPending",
|
||||
[stateKey]: { fsm }
|
||||
});
|
||||
const cancelPending = () => ({
|
||||
[actionKey]: "cancelPending",
|
||||
[stateKey]: { fsm: null }
|
||||
});
|
||||
const applyPending = () => ({
|
||||
[actionKey]: "applyPending",
|
||||
[stateKey]: { fsm: null }
|
||||
});
|
||||
const skip = fsm => ({ [actionKey]: "skip", [stateKey]: { fsm } });
|
||||
const clear = () => ({ [actionKey]: "clear", [stateKey]: { fsm: null } });
|
||||
const save = fsm => ({ [actionKey]: "save", [stateKey]: { fsm } });
|
||||
|
||||
/*
|
||||
Error handler for state transitions that are unexpected. Called by
|
||||
StateMachine when it doesn't know what to do.
|
||||
|
||||
Signature: (fsm, event, from) => undoableAction
|
||||
*/
|
||||
const onFsmError = (fsm, name, from) => {
|
||||
console.error("FSM error - unexpected history state", fsm, name, from);
|
||||
// In production, try to recover gracefully if we have unexpected state
|
||||
return clear(fsm);
|
||||
};
|
||||
|
||||
/*
|
||||
Definition of the transition graph mapping action types to history side effects.
|
||||
*/
|
||||
const fsmTransitions = createFsmTransitions(
|
||||
stashPending,
|
||||
cancelPending,
|
||||
applyPending,
|
||||
skip,
|
||||
clear,
|
||||
save
|
||||
);
|
||||
/* State machine we clone whenever we need to run it */
|
||||
const seedFsm = new StateMachine("init", fsmTransitions, onFsmError);
|
||||
|
||||
/*
|
||||
See undoable.js for description action filter interface description.
|
||||
|
||||
Basic approach:
|
||||
* trivial handlers for skip, clear & save cases to keep config simple.
|
||||
* only implement complex state machines where absolutely required (eg,
|
||||
multi-event seleciton and the like)
|
||||
*/
|
||||
const actionFilter = debug => (state, action, prevFilterState) => {
|
||||
const actionType = action.type;
|
||||
const filterState = {
|
||||
...prevFilterState,
|
||||
prevAction: action
|
||||
};
|
||||
if (skipOnActions.has(actionType)) {
|
||||
return { [actionKey]: "skip", [stateKey]: filterState };
|
||||
}
|
||||
if (
|
||||
debounceOnActions.has(actionType) &&
|
||||
shallowObjectEq(action, prevFilterState.prevAction)
|
||||
) {
|
||||
return { [actionKey]: "skip", [stateKey]: filterState };
|
||||
}
|
||||
if (clearOnActions.has(actionType)) {
|
||||
return { [actionKey]: "clear", [stateKey]: filterState };
|
||||
}
|
||||
if (saveOnActions.has(actionType)) {
|
||||
return { [actionKey]: "save", [stateKey]: filterState };
|
||||
}
|
||||
|
||||
/*
|
||||
Else, something more complex OR unknown to us....
|
||||
*/
|
||||
if (seedFsm.events.has(actionType)) {
|
||||
let { fsm } = filterState;
|
||||
if (!fsm) {
|
||||
/* no active FSM, so create one in init state */
|
||||
fsm = seedFsm.clone("init");
|
||||
}
|
||||
return fsm.next(action.type, { state, action });
|
||||
}
|
||||
|
||||
/* else, we have no idea what this is - skip it */
|
||||
if (debug) console.log("**** ACTION FILTER EVENT HANDLER MISS", actionType);
|
||||
return { [actionKey]: "skip", [stateKey]: filterState };
|
||||
};
|
||||
|
||||
/*
|
||||
return true if objA and objB are ===, OR if:
|
||||
- are both objects and not null
|
||||
- have same own properties
|
||||
- all values are strict equal (===)
|
||||
*/
|
||||
function shallowObjectEq(objA, objB) {
|
||||
if (objA === objB) return true;
|
||||
if (!objA || !objB) return false;
|
||||
if (!shallowArrayEq(Object.keys(objA), Object.keys(objB))) return false;
|
||||
if (!shallowArrayEq(Object.values(objA), Object.values(objB))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
return true if arrA and arrB contain the same strict-equal values,
|
||||
in the same order.
|
||||
*/
|
||||
function shallowArrayEq(arrA, arrB) {
|
||||
if (arrA.length !== arrB.length) return false;
|
||||
for (let i = 0, l = arrA.length; i < l; i += 1) {
|
||||
if (arrA[i] !== arrB[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* configuration for the undoable meta reducer */
|
||||
const debug = false; // set truish for undoble debugging
|
||||
const undoableConfig = {
|
||||
debug,
|
||||
historyLimit: 50, // maximum history size
|
||||
actionFilter: actionFilter(debug)
|
||||
};
|
||||
|
||||
/*
|
||||
this code is strictly for sanity checking configuration, and is only
|
||||
enabled when we are debugging the undoable configuration (ie, debug === true).
|
||||
*/
|
||||
if (debug) {
|
||||
/*
|
||||
Confirm no intersection between the various trivial rejection action filters
|
||||
*/
|
||||
if (
|
||||
new Set([...skipOnActions].filter(x => clearOnActions.has(x))).size > 0 ||
|
||||
new Set([...skipOnActions].filter(x => saveOnActions.has(x))).size > 0 ||
|
||||
new Set([...clearOnActions].filter(x => saveOnActions.has(x))).size > 0
|
||||
) {
|
||||
console.error(
|
||||
"Undoable misconfiguration - action filters have redundant events"
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
Confirm that no FSM events are blocked by a trivial rejection filter.
|
||||
If this occurs, the FSM can't ever see the events needed to process
|
||||
state transitions.
|
||||
*/
|
||||
const trivialFilters = new Set([
|
||||
...skipOnActions,
|
||||
...clearOnActions,
|
||||
...saveOnActions
|
||||
]);
|
||||
const trivialOverlapWithFsm = new Set(
|
||||
[...trivialFilters].filter(x => seedFsm.events.has(x))
|
||||
);
|
||||
if (trivialOverlapWithFsm.size > 0) {
|
||||
console.error(
|
||||
"Undoable misconfiguration - trivival action filter blocking FSM filter",
|
||||
[...trivialOverlapWithFsm]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default undoableConfig;
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
State transition graph for complex action/history interactions.
|
||||
|
||||
Assumed configuration from undoableConfig:
|
||||
* By convention, "init" is used as the start state for all, and "done"
|
||||
as the final state.
|
||||
* Unexpected states will result in an error, plus a clear and cancelPending
|
||||
side-effect.
|
||||
|
||||
TODO: is is possible there is a more concise format for this, as it is
|
||||
a fairly repetitive pattern.
|
||||
|
||||
These events are largely one of two types:
|
||||
a) async operations or multi-event options that should only be committed
|
||||
upon some success criteria, otherwise cancelled.
|
||||
|
||||
b) compound actions that should be collapsed into a single history change.
|
||||
|
||||
*/
|
||||
|
||||
const createFsmTransitions = (
|
||||
stashPending,
|
||||
cancelPending,
|
||||
applyPending,
|
||||
skip,
|
||||
clear,
|
||||
save
|
||||
) => {
|
||||
return [
|
||||
/* graph selection brushing */
|
||||
{
|
||||
event: "graph brush start",
|
||||
from: "init",
|
||||
to: "graph brush in progress",
|
||||
action: stashPending
|
||||
},
|
||||
{
|
||||
event: "graph brush cancel",
|
||||
from: "graph brush in progress",
|
||||
to: "done",
|
||||
action: applyPending
|
||||
},
|
||||
{
|
||||
event: "graph brush deselect",
|
||||
from: "graph brush in progress",
|
||||
to: "done",
|
||||
/* if current selection is all, cancelPending. Else, applyPending */
|
||||
action: (fsm, transition, data) =>
|
||||
data.state.graphSelection.selection.mode === "all"
|
||||
? cancelPending()
|
||||
: applyPending()
|
||||
},
|
||||
{
|
||||
event: "graph brush end",
|
||||
from: "graph brush in progress",
|
||||
to: "done",
|
||||
action: applyPending
|
||||
},
|
||||
|
||||
/* graph selection lasso */
|
||||
{
|
||||
event: "graph lasso start",
|
||||
from: "init",
|
||||
to: "graph lasso in progress",
|
||||
action: stashPending
|
||||
},
|
||||
{
|
||||
event: "graph lasso cancel",
|
||||
from: "graph lasso in progress",
|
||||
to: "done",
|
||||
action: applyPending
|
||||
},
|
||||
{
|
||||
event: "graph lasso deselect",
|
||||
from: "graph lasso in progress",
|
||||
to: "done",
|
||||
/* if current selection is all, cancelPending. Else, applyPending */
|
||||
action: (fsm, transition, data) =>
|
||||
data.state.graphSelection.selection.mode === "all"
|
||||
? cancelPending()
|
||||
: applyPending()
|
||||
},
|
||||
{
|
||||
event: "graph lasso end",
|
||||
from: "graph lasso in progress",
|
||||
to: "done",
|
||||
action: applyPending
|
||||
},
|
||||
|
||||
/* Continuous metadata histogram brush selection */
|
||||
{
|
||||
event: "continuous metadata histogram start",
|
||||
from: "init",
|
||||
to: "continuous histo select in progress",
|
||||
action: stashPending
|
||||
},
|
||||
{
|
||||
event: "continuous metadata histogram cancel",
|
||||
from: "continuous histo select in progress",
|
||||
to: "done",
|
||||
action: cancelPending
|
||||
},
|
||||
{
|
||||
event: "continuous metadata histogram end",
|
||||
from: "continuous histo select in progress",
|
||||
to: "done",
|
||||
action: applyPending
|
||||
},
|
||||
|
||||
/* Single gene request by user */
|
||||
{
|
||||
event: "single user defined gene start",
|
||||
from: "init",
|
||||
to: "single user gene request in progress",
|
||||
action: stashPending
|
||||
},
|
||||
{
|
||||
event: "request user defined gene error",
|
||||
from: "single user gene request in progress",
|
||||
to: "single user gene error in progress",
|
||||
action: skip
|
||||
},
|
||||
{
|
||||
event: "single user defined gene error",
|
||||
from: "single user gene error in progress",
|
||||
to: "done",
|
||||
action: cancelPending
|
||||
},
|
||||
{
|
||||
event: "single user defined gene complete",
|
||||
from: "single user gene request in progress",
|
||||
to: "done",
|
||||
action: applyPending
|
||||
},
|
||||
|
||||
/* Bulk gene request by user */
|
||||
{
|
||||
event: "bulk user defined gene start",
|
||||
from: "init",
|
||||
to: "bulk user gene request in progress",
|
||||
action: stashPending
|
||||
},
|
||||
{
|
||||
event: "request user defined gene error",
|
||||
from: "bulk user gene request in progress",
|
||||
to: "bulk user gene request error in progress",
|
||||
action: skip
|
||||
},
|
||||
{
|
||||
event: "bulk user defined gene error",
|
||||
from: "bulk user gene request error in progress",
|
||||
to: "done",
|
||||
action: cancelPending
|
||||
},
|
||||
{
|
||||
event: "bulk user defined gene complete",
|
||||
from: "bulk user gene request in progress",
|
||||
to: "done",
|
||||
action: applyPending
|
||||
},
|
||||
|
||||
/* Compute Differential Expression button user action */
|
||||
{
|
||||
event: "request differential expression started",
|
||||
from: "init",
|
||||
to: "diffexp in progress",
|
||||
action: stashPending
|
||||
},
|
||||
{
|
||||
event: "request user defined gene error",
|
||||
from: "diffexp in progress",
|
||||
to: "done",
|
||||
action: cancelPending
|
||||
},
|
||||
{
|
||||
event: "request differential expression success",
|
||||
from: "diffexp in progress",
|
||||
to: "done",
|
||||
action: applyPending
|
||||
},
|
||||
|
||||
/* Clear Differential Expression button user action */
|
||||
{
|
||||
event: "clear differential expression",
|
||||
from: "init",
|
||||
to: "CDE Button in progress",
|
||||
action: stashPending
|
||||
},
|
||||
{
|
||||
event: "clear scatterplot",
|
||||
from: "CDE Button in progress",
|
||||
to: "done",
|
||||
action: applyPending
|
||||
},
|
||||
|
||||
/* clear scatter plot button (eg, on scatterplot view) */
|
||||
{
|
||||
event: "clear scatterplot",
|
||||
from: "init",
|
||||
to: "done",
|
||||
action: save
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
export default createFsmTransitions;
|
||||
Reference in New Issue
Block a user