From e02bfc43d9281a36775f2ff7b7dbc3c493183842 Mon Sep 17 00:00:00 2001 From: EskiMojo14 Date: Tue, 12 Mar 2024 00:16:08 +0000 Subject: [PATCH 1/3] better support nested history state by passing a config to undoable --- package.json | 1 + src/creator.test.ts | 97 +++++++++++++++++++++++++++++++++++++++++++++ src/index.test.ts | 30 +++++++++++++- src/index.ts | 60 +++++++++++++++++++++++----- src/redux.test.ts | 45 +++++++++++++++++++++ src/redux.ts | 16 ++++++-- test-setup.ts | 1 + vitest.config.ts | 7 ++++ yarn.lock | 40 +++++++++++++++++++ 9 files changed, 280 insertions(+), 17 deletions(-) create mode 100644 test-setup.ts create mode 100644 vitest.config.ts diff --git a/package.json b/package.json index 60d2d00..5945ebf 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "eslint-plugin-vitest": "^0.3.20", "husky": "^8.0.3", "lint-staged": "^15.2.0", + "mix-n-matchers": "^1.5.0", "prettier": "^3.1.1", "publint": "^0.2.7", "reselect": "^5.1.0", diff --git a/src/creator.test.ts b/src/creator.test.ts index 7d1fd58..844a620 100644 --- a/src/creator.test.ts +++ b/src/creator.test.ts @@ -5,6 +5,7 @@ import { } from "@reduxjs/toolkit"; import { describe, expect, it } from "vitest"; import { historyMethodsCreator } from "./creator"; +import type { HistoryState } from "./redux"; import { createHistoryAdapter } from "./redux"; interface Book { @@ -106,6 +107,102 @@ describe("Slice creators", () => { store.dispatch(reset()); + expect(selectLastBook(store.getState())).toBeUndefined(); + }); + it("works with nested state", () => { + const selectHistoryState = (state: { books: HistoryState> }) => + state.books; + const bookAdapter = createHistoryAdapter>(); + const bookSlice = createAppSlice({ + name: "book", + initialState: { books: bookAdapter.getInitialState([]) }, + reducers: (create) => ({ + ...create.historyMethods(bookAdapter, { + selectHistoryState, + }), + addBook: create.preparedReducer( + bookAdapter.withPayload(), + bookAdapter.undoableReducer( + (state, action) => { + state.push(action.payload); + }, + { + selectHistoryState, + }, + ), + ), + removeLastBook: create.preparedReducer( + bookAdapter.withoutPayload(), + bookAdapter.undoableReducer( + (state) => { + state.pop(); + }, + { + selectHistoryState, + }, + ), + ), + }), + selectors: { + selectLastBook: (state) => state.books.present.at(-1), + }, + }); + + const { + actions: { + undo, + redo, + jump, + clearHistory, + reset, + addBook, + removeLastBook, + }, + selectors: { selectLastBook }, + } = bookSlice; + + const store = configureStore({ reducer: combineSlices(bookSlice) }); + + expect(selectLastBook(store.getState())).toBeUndefined(); + + store.dispatch(addBook(book1)); + + expect(selectLastBook(store.getState())).toStrictEqual(book1); + + store.dispatch(removeLastBook()); + + expect(selectLastBook(store.getState())).toBeUndefined(); + + store.dispatch(undo()); + + expect(selectLastBook(store.getState())).toStrictEqual(book1); + + store.dispatch(redo()); + + expect(selectLastBook(store.getState())).toBeUndefined(); + + store.dispatch(addBook(book2, false)); + + expect(selectLastBook(store.getState())).toStrictEqual(book2); + + store.dispatch(undo()); + + expect(selectLastBook(store.getState())).toBe(book2); + + store.dispatch(jump(-1)); + + expect(selectLastBook(store.getState())).toStrictEqual(book2); + + store.dispatch(clearHistory()); + + store.dispatch(undo()); + + expect(selectLastBook(store.getState())).toStrictEqual(book2); + + store.dispatch(addBook(book1)); + + store.dispatch(reset()); + expect(selectLastBook(store.getState())).toBeUndefined(); }); }); diff --git a/src/index.test.ts b/src/index.test.ts index f5137fe..d21d149 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -32,9 +32,13 @@ describe("createHistoryAdapter", () => { }); }); + const aPatch = expect.objectContaining({ + op: expect.oneOf(["add", "replace", "remove"]), + }); + const aPatchState = { - redo: expect.any(Array), - undo: expect.any(Array), + redo: expect.iterableOf(aPatch), + undo: expect.iterableOf(aPatch), }; describe("undoable", () => { @@ -90,6 +94,28 @@ describe("createHistoryAdapter", () => { future: [], }); }); + it("can be provided with a selector if working with nested state", () => { + const addBook = booksHistoryAdapter.undoable( + (books, book: Book) => { + books.push(book); + }, + { + selectHistoryState: (state: { books: HistoryState> }) => + state.books, + }, + ); + const initialState = { books: booksHistoryAdapter.getInitialState([]) }; + + const nextState = addBook(initialState, book1); + + expect(nextState).toEqual({ + books: { + past: [aPatchState], + present: [book1], + future: [], + }, + }); + }); }); describe("undo", () => { const initialState = booksHistoryAdapter.getInitialState([]); diff --git a/src/index.ts b/src/index.ts index 9e21d8f..a4e933a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,11 +32,10 @@ enablePatches(); const isDraftTyped = isDraft as (value: T | Draft) => value is Draft; -function makeStateOperator< - State extends HistoryState, - Args extends Array = [], ->(mutator: (state: Draft, ...args: Args) => void) { - return function operator(state: S, ...args: Args) { +function makeStateOperator = []>( + mutator: (state: Draft, ...args: Args) => void, +) { + return function operator(state: S, ...args: Args): S { if (isDraftTyped(state)) { mutator(state, ...args); return state; @@ -52,6 +51,19 @@ export interface HistoryAdapterConfig { limit?: number; } +export interface UndoableConfig, RootState> { + /** + * A function to extract from the arguments whether the action was undoable or not. + * If not provided (or if function returns undefined), defaults to true. + * Non-undoable actions will not be included in state history. + */ + isUndoable?: (...args: Args) => boolean | undefined; + /** + * A function to select the history state from the root state. + */ + selectHistoryState?: (state: Draft) => HistoryState; +} + export interface HistoryAdapter { /** * Construct an initial state with no history. @@ -82,6 +94,16 @@ export interface HistoryAdapter { * @param state History state shape, with patches */ clearHistory>(state: State): State; + + /** + * Wraps a function to automatically update patch history according to changes + * @param recipe An immer-style recipe, which can mutate the draft or return new state + * @param config Configuration for undoable action + */ + undoable, RootState = HistoryState>( + recipe: (draft: Draft, ...args: Args) => ValidRecipeReturnType, + config?: UndoableConfig, + ): (state: State, ...args: Args) => State; /** * Wraps a function to automatically update patch history according to changes * @param recipe An immer-style recipe, which can mutate the draft or return new state @@ -129,7 +151,7 @@ export function createHistoryAdapter({ getInitialState, undo: makeStateOperator(undoMutably), redo: makeStateOperator(redoMutably), - jump: makeStateOperator((state, n) => { + jump: makeStateOperator, [number]>((state, n) => { if (n < 0) { for (let i = 0; i < -n; i++) { undoMutably(state); @@ -140,18 +162,34 @@ export function createHistoryAdapter({ } } }), - clearHistory: makeStateOperator((state) => { + clearHistory: makeStateOperator>((state) => { state.past = []; state.future = []; }), - undoable(recipe, isUndoable) { - return makeStateOperator((state, ...args) => { + undoable, RootState>( + recipe: ( + draft: Draft, + ...args: Args + ) => ValidRecipeReturnType, + configOrIsUndoable?: + | UndoableConfig + | ((...args: Args) => boolean | undefined), + ) { + const { + isUndoable, + selectHistoryState = (s: Draft) => s as HistoryState, + } = + typeof configOrIsUndoable === "function" + ? { isUndoable: configOrIsUndoable } + : configOrIsUndoable ?? {}; + return makeStateOperator((rootState, ...args) => { + const state = selectHistoryState(rootState); const [{ present }, redo, undo] = produceWithPatches(state, (draft) => { const result = recipe(draft.present as Draft, ...args); if (result === nothing) { - draft.present = undefined as Draft>; + draft.present = undefined as Draft; } else if (typeof result !== "undefined") { - draft.present = result as Draft>; + draft.present = result as Draft; } }); state.present = present; diff --git a/src/redux.test.ts b/src/redux.test.ts index f19bab9..5b3b67e 100644 --- a/src/redux.test.ts +++ b/src/redux.test.ts @@ -6,6 +6,7 @@ import { createSlice, lruMemoize, } from "@reduxjs/toolkit"; +import type { HistoryState } from "./redux"; import { createHistoryAdapter } from "./redux"; import { describe, expect, it, beforeEach, vi } from "vitest"; @@ -128,6 +129,50 @@ describe("createReduxHistoryAdapter", () => { expect(selectLastBook(store.getState())).toStrictEqual(book1); }); + it("can work with nested state", () => { + const nestedSlice = createSlice({ + name: "nested", + initialState: { + books: booksHistoryAdapter.getInitialState([]), + }, + reducers: (create) => ({ + undo: create.reducer((state) => { + booksHistoryAdapter.undo(state.books); + }), + addBook: create.preparedReducer( + booksHistoryAdapter.withPayload(), + booksHistoryAdapter.undoableReducer( + (state, action) => { + state.push(action.payload); + }, + { + selectHistoryState: (s: { books: HistoryState> }) => + s.books, + }, + ), + ), + }), + selectors: { + selectLastBook: (state: RootState) => state.books.present.at(-1), + }, + }); + + const { addBook, undo } = nestedSlice.actions; + const { selectLastBook } = nestedSlice.selectors; + + const reducer = combineSlices(nestedSlice); + + const store = configureStore({ reducer }); + + store.dispatch(addBook(book1)); + + expect(selectLastBook(store.getState())).toStrictEqual(book1); + + store.dispatch(undo()); + + expect(selectLastBook(store.getState())).toBeUndefined(); + }); + describe("getSelectors", () => { it("can be used with an input selector", () => { const { selectPresent } = booksHistoryAdapter.getSelectors( diff --git a/src/redux.ts b/src/redux.ts index fdd5566..063e87c 100644 --- a/src/redux.ts +++ b/src/redux.ts @@ -7,6 +7,7 @@ import type { HistoryAdapter as Adapter, HistoryAdapterConfig, HistoryState, + UndoableConfig, } from "."; import { createHistoryAdapter as createAdapter } from "."; import type { IfMaybeUndefined } from "./utils"; @@ -106,9 +107,13 @@ export interface HistoryAdapter extends Adapter { > ) => { payload: P; meta: UndoableMeta }; /** Wraps a reducer in logic which automatically updates the state history, and extracts whether an action is undoable from its meta (`action.meta.undoable`) */ - undoableReducer( + undoableReducer< + A extends Action & { meta?: UndoableMeta }, + RootState = HistoryState, + >( reducer: CaseReducer, - ): >(state: State, action: A) => State; + config?: Omit, "isUndoable">, + ): (state: State, action: A) => State; getSelectors(): HistorySelectors; getSelectors( @@ -155,8 +160,11 @@ export function createHistoryAdapter( > ) => ({ payload: payload as P, meta: { undoable } }); }, - undoableReducer(reducer) { - return adapter.undoable(reducer, getUndoableMeta); + undoableReducer(reducer, config) { + return adapter.undoable(reducer, { + ...config, + isUndoable: getUndoableMeta, + }); }, getSelectors: makeSelectorFactory(), }; diff --git a/test-setup.ts b/test-setup.ts new file mode 100644 index 0000000..e99992d --- /dev/null +++ b/test-setup.ts @@ -0,0 +1 @@ +import "mix-n-matchers/vitest"; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..fbb0c4c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + setupFiles: ["./test-setup"], + }, +}); diff --git a/yarn.lock b/yarn.lock index 63ead42..37eafa2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -335,6 +335,13 @@ wrap-ansi "^8.1.0" wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" +"@jest/expect-utils@>=28.0.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.7.0.tgz#023efe5d26a8a70f21677d0a1afc0f0a44e3a1c6" + integrity sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA== + dependencies: + jest-get-type "^29.6.3" + "@jest/schemas@^29.6.3": version "29.6.3" resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" @@ -2022,6 +2029,31 @@ jackspeak@^2.3.5: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" +jest-diff@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" + integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== + dependencies: + chalk "^4.0.0" + diff-sequences "^29.6.3" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + +jest-get-type@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.6.3.tgz#36f499fdcea197c1045a127319c0481723908fd1" + integrity sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw== + +jest-matcher-utils@>=28.0.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz#ae8fec79ff249fd592ce80e3ee474e83a6c44f12" + integrity sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g== + dependencies: + chalk "^4.0.0" + jest-diff "^29.7.0" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + joycon@^3.0.1: version "3.1.1" resolved "https://registry.yarnpkg.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03" @@ -2257,6 +2289,14 @@ minimist@^1.2.0, minimist@^1.2.6: resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c" integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ== +mix-n-matchers@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/mix-n-matchers/-/mix-n-matchers-1.5.0.tgz#56d6a49d001e53d6b715049d33e1684f89823ab3" + integrity sha512-cYS7uUyoURQlFG1VKElefFli8hn4ghujt7cR7SCJsVfyjxe7YtPK3JmMytd27YLJkNn6ef1391nRZX7oLD9hnw== + dependencies: + "@jest/expect-utils" ">=28.0.0" + jest-matcher-utils ">=28.0.0" + mlly@^1.2.0, mlly@^1.4.2: version "1.4.2" resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.4.2.tgz#7cf406aa319ff6563d25da6b36610a93f2a8007e" From 1dfbf8b260780c39988089d23f79607310813361 Mon Sep 17 00:00:00 2001 From: EskiMojo14 Date: Tue, 12 Mar 2024 00:22:03 +0000 Subject: [PATCH 2/3] tweak destructure --- src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index a4e933a..0ede087 100644 --- a/src/index.ts +++ b/src/index.ts @@ -177,8 +177,8 @@ export function createHistoryAdapter({ ) { const { isUndoable, - selectHistoryState = (s: Draft) => s as HistoryState, - } = + selectHistoryState = (s) => s as HistoryState, + }: UndoableConfig = typeof configOrIsUndoable === "function" ? { isUndoable: configOrIsUndoable } : configOrIsUndoable ?? {}; From 72f33dbebd6e888aae6c23377eec62e975f5e823 Mon Sep 17 00:00:00 2001 From: EskiMojo14 Date: Tue, 12 Mar 2024 00:28:00 +0000 Subject: [PATCH 3/3] make sure args are labelled --- src/redux.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/redux.ts b/src/redux.ts index 063e87c..ffcfb8c 100644 --- a/src/redux.ts +++ b/src/redux.ts @@ -112,7 +112,7 @@ export interface HistoryAdapter extends Adapter { RootState = HistoryState, >( reducer: CaseReducer, - config?: Omit, "isUndoable">, + config?: Omit, "isUndoable">, ): (state: State, action: A) => State; getSelectors(): HistorySelectors;