diff --git a/packages/history-utility/README.md b/packages/history-utility/README.md index 30bb98e..fb4e586 100644 --- a/packages/history-utility/README.md +++ b/packages/history-utility/README.md @@ -36,7 +36,9 @@ export default function App() { canUndo, canRedo, getCurrentChangeDate, + getNode, remove, + replace, } = useSnapshot(state); ... @@ -47,4 +49,4 @@ export default function App() { - the `history` object has changes - `history.snapshots` is renamed to `history.nodes` - - a `HistoryNode` has the structure `{ createdAt: Date; snapshot: Snapshot }` + - a `HistoryNode` has the structure `{ snapshot: Snapshot; createdAt: Date; updatedAt?: Date; }` diff --git a/packages/history-utility/src/__tests__/history-utility.vanilla.spec.ts b/packages/history-utility/src/__tests__/history-utility.vanilla.spec.ts index 3631313..6e0debf 100644 --- a/packages/history-utility/src/__tests__/history-utility.vanilla.spec.ts +++ b/packages/history-utility/src/__tests__/history-utility.vanilla.spec.ts @@ -256,4 +256,134 @@ describe('proxyWithHistory: vanilla', () => { expect(state.history.nodes.map(mapNumbers)).toEqual([0, 1, 2, 3, 4]); }); }); + + describe('replace', () => { + it('should replace no items in history when invalid index is provided', async () => { + const state = proxyWithHistory({ count: 0 }); + + state.value.count += 1; + await Promise.resolve(); + state.value.count += 1; + await Promise.resolve(); + state.value.count += 1; + await Promise.resolve(); + state.value.count += 1; + await Promise.resolve(); + state.value.count += 1; + await Promise.resolve(); + + expect(state.value.count).toEqual(5); + expect(state.history.nodes.length).toEqual(6); + expect(state.history.index).toEqual(5); + expect(state.history.nodes.map(mapNumbers)).toEqual([0, 1, 2, 3, 4, 5]); + + state.replace(100, { count: 100 }); + await Promise.resolve(); + + expect(state.value.count).toEqual(5); + expect(state.history.nodes.length).toEqual(6); + expect(state.history.index).toEqual(5); + expect(state.history.nodes.map(mapNumbers)).toEqual([0, 1, 2, 3, 4, 5]); + }); + + it('should replace current index as last index in history without increasing history length', async () => { + const state = proxyWithHistory({ count: 0 }); + + state.value.count += 1; + await Promise.resolve(); + state.value.count += 1; + await Promise.resolve(); + state.value.count += 1; + await Promise.resolve(); + state.value.count += 1; + await Promise.resolve(); + state.value.count += 1; + await Promise.resolve(); + + expect(state.value.count).toEqual(5); + expect(state.history.nodes.length).toEqual(6); + expect(state.history.index).toEqual(5); + expect(state.history.nodes.map(mapNumbers)).toEqual([0, 1, 2, 3, 4, 5]); + + state.replace(5, { count: 100 }); + await Promise.resolve(); + + expect(state.value.count).toEqual(100); + expect(state.history.nodes.length).toEqual(6); + expect(state.history.index).toEqual(5); + expect(state.history.nodes.map(mapNumbers)).toEqual([0, 1, 2, 3, 4, 100]); + }); + + it('should replace current index when not last index in history without increasing history', async () => { + const state = proxyWithHistory({ count: 0 }); + + state.value.count += 1; + await Promise.resolve(); + state.value.count += 1; + await Promise.resolve(); + state.value.count += 1; + await Promise.resolve(); + state.value.count += 1; + await Promise.resolve(); + state.value.count += 1; + await Promise.resolve(); + state.undo(); + await Promise.resolve(); + state.undo(); + await Promise.resolve(); + state.undo(); + await Promise.resolve(); + + expect(state.value.count).toEqual(2); + expect(state.history.nodes.length).toEqual(6); + expect(state.history.index).toEqual(2); + expect(state.history.nodes.map(mapNumbers)).toEqual([0, 1, 2, 3, 4, 5]); + + state.replace(2, { count: 100 }); + await Promise.resolve(); + + expect(state.value.count).toEqual(100); + expect(state.history.nodes.length).toEqual(6); + expect(state.history.index).toEqual(2); + expect(state.history.nodes.map(mapNumbers)).toEqual([0, 1, 100, 3, 4, 5]); + }); + + it('should replace item in history without increasing history', async () => { + const state = proxyWithHistory({ count: 0 }); + + state.value.count += 1; + await Promise.resolve(); + state.value.count += 1; + await Promise.resolve(); + state.value.count += 1; + await Promise.resolve(); + state.value.count += 1; + await Promise.resolve(); + state.value.count += 1; + await Promise.resolve(); + state.undo(); + await Promise.resolve(); + state.undo(); + await Promise.resolve(); + state.undo(); + await Promise.resolve(); + + expect(state.value.count).toEqual(2); + expect(state.history.nodes.length).toEqual(6); + expect(state.history.index).toEqual(2); + expect(state.history.nodes.map(mapNumbers)).toEqual([0, 1, 2, 3, 4, 5]); + + state.replace(3, { count: 100 }); + await Promise.resolve(); + state.replace(4, { count: 200 }); + await Promise.resolve(); + + expect(state.value.count).toEqual(2); + expect(state.history.nodes.length).toEqual(6); + expect(state.history.index).toEqual(2); + expect(state.history.nodes.map(mapNumbers)).toEqual([ + 0, 1, 2, 100, 200, 5, + ]); + }); + }); }); diff --git a/packages/history-utility/src/history-utility.ts b/packages/history-utility/src/history-utility.ts index eef51d1..c674f5f 100644 --- a/packages/history-utility/src/history-utility.ts +++ b/packages/history-utility/src/history-utility.ts @@ -8,8 +8,9 @@ import { import type { INTERNAL_Snapshot as Snapshot } from 'valtio/vanilla'; export type HistoryNode = { - createdAt: Date; snapshot: Snapshot; + createdAt: Date; + updatedAt?: Date; }; export type History = { @@ -56,6 +57,8 @@ const deepClone = (value: T): T => { * - saveHistory: a function to save history * - getCurrentChangeDate: gets the date of the current change * - remove: a function to remove a specified history index + * - replace: a function to replace a snapshot at a specified history index + * - getNode: a function to get the node at a specified history index * * [Notes] * - Suspense/promise is not supported. @@ -78,10 +81,33 @@ export function proxyWithHistory(initialValue: V, skipSubscribe = false) { nodes: [], index: -1, }), + /** + * getCurrentChangeDate + * + * get the date when a node was entered into history. + * + * @returns date + */ getCurrentChangeDate: () => { const node = proxyObject.history.nodes[proxyObject.history.index]; return node?.createdAt; }, + /** + * getNode + * + * utility method to get a history node. + * The snapshot within this node is already cloned and + * will not affect the original value if updated. + * + * @param index + * @returns historyNode + */ + getNode: (index: number) => { + const node = proxyObject.history.nodes[index]; + return node + ? { ...node, snapshot: proxyObject.clone(node.snapshot) } + : undefined; + }, clone: deepClone, canUndo: () => proxyObject.history.index > 0, undo: () => { @@ -163,6 +189,41 @@ export function proxyWithHistory(initialValue: V, skipSubscribe = false) { return node; }, + + /** + * replace + * + * utility to replace a value in history. The history + * changes with not be affected, only the value to be replaced. + * If a base value is needed to operate on, + * the `getNode` utility can be used to retrieve + * a cloned historyNode. + * + * Notes: + * - No operations are done on the value provided to this utility. + * - This is an advanced method, please ensure the value provided + * is a snapshot of the same type of the value being tracked. + * + * @param index - index to replace value for + * @param value - the updated snapshot to be stored at the index + */ + replace: (index: number, value: Snapshot) => { + const node = proxyObject.history.nodes[index]; + const isCurrentIndex = proxyObject.history.index === index; + + if (!node) return; + + proxyObject.history.nodes[index] = { + ...node, + snapshot: value, + updatedAt: new Date(), + }; + + if (isCurrentIndex) { + proxyObject.history.wip = value; + proxyObject.value = proxyObject.history.wip as V; + } + }, }); proxyObject.saveHistory();