patched-undo-peasy
Advanced tools
Comparing version 0.1.5 to 0.1.6
{ | ||
"name": "patched-undo-peasy", | ||
"version": "0.1.5", | ||
"version": "0.1.6", | ||
"main": "src/UndoRedoApi.ts", | ||
@@ -5,0 +5,0 @@ "dependencies": { |
import "chai/register-should"; | ||
import _ from "lodash"; | ||
import produce from "immer"; | ||
import { clonePlain, filterCopy, findGetters, removeDeep } from "../Utils"; | ||
import { assert } from "chai"; | ||
import { findGetters, removeDeep } from "../Utils"; | ||
test("clonePlain", () => { | ||
const src = { | ||
foo: "bar", | ||
n: 2, | ||
a: [1, 2, 3, () => 1], | ||
zoo: { | ||
get bar() { | ||
return 3; | ||
}, | ||
zap: "zo", | ||
}, | ||
zin: { | ||
get fum() { | ||
return 4; | ||
}, | ||
}, | ||
}; | ||
const expected = { | ||
foo: "bar", | ||
n: 2, | ||
a: [1, 2, 3], | ||
zoo: { | ||
zap: "zo", | ||
}, | ||
zin: {}, | ||
}; | ||
const copy = clonePlain(src); | ||
copy.should.deep.equal(expected); | ||
("bar" in copy.zoo).should.equal(false); | ||
}); | ||
function filterCopyData() { | ||
const src = { | ||
foo: "ignored", | ||
pickMeToo: "zip", | ||
bar: { | ||
baz: undefined, | ||
yo: { | ||
bah: undefined, | ||
pickMe: "zap", | ||
}, | ||
}, | ||
}; | ||
const dest = { | ||
bar: { | ||
}, | ||
}; | ||
return {src, dest}; | ||
} | ||
test("filterCopy", () => { | ||
const {src, dest} = filterCopyData(); | ||
filterCopy(src, dest, (key:string) => key.startsWith("pick")); | ||
(dest as any).pickMeToo.should.equal("zip"); | ||
(dest as any).bar.yo.pickMe.should.equal("zap"); | ||
}); | ||
test("immer filterCopy", () => { | ||
const {src, dest} = filterCopyData(); | ||
const result = produce(dest, draft => { | ||
filterCopy(src, draft, (key:string) => key.startsWith("pick")); | ||
}); | ||
(result as any).pickMeToo.should.equal("zip"); | ||
(result as any).bar.yo.pickMe.should.equal("zap"); | ||
assert((dest as any).pickMeToo === undefined); | ||
}); | ||
test("findGetters", () => { | ||
@@ -79,0 +7,0 @@ const src = { |
@@ -6,4 +6,2 @@ import { AnyAction, Dispatch, Middleware, MiddlewareAPI } from "redux"; | ||
TODO | ||
* separate undoredo into its own project | ||
* clean up for review | ||
* add option for max number undo elements | ||
@@ -13,3 +11,6 @@ */ | ||
export interface UndoRedoConfig { | ||
/** function called to identify actions that should not be saved in undo history */ | ||
noSaveActions?: ActionFilter; | ||
/** function called to identify keys that should not be saved in undo history */ | ||
noSaveKeys?: KeyPathFilter; | ||
@@ -26,2 +27,16 @@ } | ||
/** @returns redux middleware to support undo/redo actions. | ||
* | ||
* The middleware does two things: | ||
* | ||
* 1) for undo/redo actions, the middlware attaches some information to the action. | ||
* It attaches the 'raw' state object. easy peasy normally sends only an immer | ||
* proxy of the raw state, and the proxy obscures the difference between computed | ||
* and regular properties. | ||
* At it attaches any user provided noSaveKeys filter. | ||
* | ||
* 2) for normal actions, the middeware dispatches an additional undoSave action to | ||
* follow the original action. The reducer for the undoSave action will save the state | ||
* in undo history. | ||
*/ | ||
export function undoRedo(config: UndoRedoConfig = {}): Middleware { | ||
@@ -28,0 +43,0 @@ const { noSaveActions, noSaveKeys } = replaceUndefined(config, undoDefaults); |
@@ -28,11 +28,2 @@ import _ from "lodash"; | ||
interface UndoParams { | ||
noSaveKeys: KeyPathFilter; | ||
state: WithUndo; | ||
} | ||
interface WithUndoHistory { | ||
undoHistory: UndoHistory; | ||
} | ||
export type ModelWithUndo<T> = { | ||
@@ -50,2 +41,17 @@ [P in keyof T]: T[P]; | ||
const undoModel: UndoHistory = { undo: [], redo: [] }; | ||
/** Used internally, to pass params and raw state from middleware config to action reducers. | ||
* Users of the actions do _not_ need to pass these parameters, they are attached by the middleware. | ||
*/ | ||
interface UndoParams { | ||
noSaveKeys: KeyPathFilter; | ||
state: WithUndo; | ||
} | ||
interface WithUndoHistory { | ||
undoHistory: UndoHistory; | ||
} | ||
const undoSave = action<WithUndo, UndoParams>((draftState, params) => { | ||
@@ -63,2 +69,4 @@ const history = draftState.undoHistory; | ||
if (!history.computeds) { | ||
// consider this initialization only happens once, is there an init hook we could use instead? | ||
// LATER consider, what if the model is hot-reloaded? | ||
history.computeds = findGetters(params.state); | ||
@@ -68,2 +76,3 @@ } | ||
// remove keys that shouldn't be saved in undo history (computeds, user filtered, and history state) | ||
const filteredCopy: AnyObject = removeDeep(draftState, (_value, key, path) => { | ||
@@ -76,4 +85,4 @@ const fullPath = path.concat([key]); | ||
}); | ||
delete filteredCopy["undoHistory"]; | ||
draftState.undoHistory.current = filteredCopy; | ||
@@ -115,2 +124,1 @@ } | ||
export const undoModel: UndoHistory = { undo: [], redo: [] }; |
@@ -49,26 +49,2 @@ import _ from "lodash"; | ||
/** | ||
* @return a deep copy of an object or value, preserving only 'plain' serializable | ||
* properties and values. | ||
* | ||
* Properties whose values are numbers, strings, booleans, Dates, undefined, null, | ||
* arrays or objects are copied. | ||
* | ||
* Getter properties and properties with function values are dropped. */ | ||
export function clonePlain(src: any): any { | ||
if (isPrimitive(src)) { | ||
return src; | ||
} else if (_.isArray(src)) { | ||
return src.filter(isPlain).map(clonePlain); | ||
} else if (_.isObject(src) && !_.isFunction(src)) { | ||
const plain = Object.entries(src).filter( | ||
([key, value]) => !isGetter(src, key) && isPlain(value) | ||
); | ||
const copy = plain.map(([key, value]) => [key, clonePlain(value)]); | ||
return Object.fromEntries(copy); | ||
} else { | ||
return undefined; | ||
} | ||
} | ||
export interface AnyObject { | ||
@@ -93,66 +69,3 @@ [key: string]: any; | ||
/** Copy target properties from a src to a dest object, mutating the destination object. | ||
* Target properties in child objects of the src object. | ||
* Parent objects of target properties are created in the dest object as necessary. | ||
* | ||
* Property getters are not copied. | ||
* | ||
* @param keyFilter function to identify property keys to copy. | ||
*/ | ||
export function filterCopy( | ||
src: AnyObject, | ||
dest: AnyObject, | ||
keyFilter: (key: string, path: string[]) => boolean | ||
): void { | ||
copyRecurse(src, []); | ||
function copyRecurse(from: AnyObject, path: string[]): void { | ||
const srcKeys = Object.keys(from); | ||
const copyKeys = srcKeys.filter( | ||
(key) => | ||
keyFilter(key, path) && | ||
Object.getOwnPropertyDescriptor(from, key)?.get === undefined | ||
); | ||
copyKeys.forEach((key) => pathSet(path.concat([key]), dest, from[key])); | ||
const recurseKeys = Object.keys(from).filter( | ||
(key) => !keyFilter(key, path) && _.isPlainObject(from[key]) | ||
); | ||
recurseKeys.forEach((key) => copyRecurse(from[key], path.concat([key]))); | ||
} | ||
} | ||
function pathSet(fullPath: string[], destRoot: AnyObject, value: any): void { | ||
const path = fullPath.slice(0, fullPath.length - 1); | ||
const key = _.last(fullPath)!; | ||
let destChild = destRoot; | ||
for (const pathElem of path) { | ||
if (destChild[pathElem] === undefined) { | ||
destChild[pathElem] = {}; | ||
} | ||
destChild = destChild[pathElem]; | ||
} | ||
destChild[key] = value; | ||
} | ||
function isPrimitive(value: any): boolean { | ||
return ( | ||
value === undefined || | ||
value === null || | ||
_.isNumber(value) || | ||
_.isString(value) || | ||
_.isBoolean(value) || | ||
_.isDate(value) | ||
); | ||
} | ||
function isPlain(value: any): boolean { | ||
return ( | ||
isPrimitive(value) || _.isArray(value) || (_.isObject(value) && !_.isFunction(value)) | ||
); | ||
} | ||
function isGetter(src: {}, key: string): boolean { | ||
@@ -159,0 +72,0 @@ const desc = Object.getOwnPropertyDescriptor(src, key); |
18585
469