Skip to content

Commit

Permalink
Merge pull request #2 from EskiMojo14/select-history-state
Browse files Browse the repository at this point in the history
better support nested history state by passing a config to undoable
  • Loading branch information
EskiMojo14 authored Mar 12, 2024
2 parents 9a9c5b9 + 72f33db commit 5a14924
Show file tree
Hide file tree
Showing 9 changed files with 280 additions and 17 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
97 changes: 97 additions & 0 deletions src/creator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Array<Book>> }) =>
state.books;
const bookAdapter = createHistoryAdapter<Array<Book>>();
const bookSlice = createAppSlice({
name: "book",
initialState: { books: bookAdapter.getInitialState([]) },
reducers: (create) => ({
...create.historyMethods(bookAdapter, {
selectHistoryState,
}),
addBook: create.preparedReducer(
bookAdapter.withPayload<Book>(),
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();
});
});
30 changes: 28 additions & 2 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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<Array<Book>> }) =>
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([]);
Expand Down
60 changes: 49 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,10 @@ enablePatches();

const isDraftTyped = isDraft as <T>(value: T | Draft<T>) => value is Draft<T>;

function makeStateOperator<
State extends HistoryState<unknown>,
Args extends Array<any> = [],
>(mutator: (state: Draft<State>, ...args: Args) => void) {
return function operator<S extends State>(state: S, ...args: Args) {
function makeStateOperator<State, Args extends Array<any> = []>(
mutator: (state: Draft<State>, ...args: Args) => void,
) {
return function operator<S extends State>(state: S, ...args: Args): S {
if (isDraftTyped(state)) {
mutator(state, ...args);
return state;
Expand All @@ -52,6 +51,19 @@ export interface HistoryAdapterConfig {
limit?: number;
}

export interface UndoableConfig<Data, Args extends Array<any>, 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<RootState>) => HistoryState<Data>;
}

export interface HistoryAdapter<Data> {
/**
* Construct an initial state with no history.
Expand Down Expand Up @@ -82,6 +94,16 @@ export interface HistoryAdapter<Data> {
* @param state History state shape, with patches
*/
clearHistory<State extends HistoryState<Data>>(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<Args extends Array<any>, RootState = HistoryState<Data>>(
recipe: (draft: Draft<Data>, ...args: Args) => ValidRecipeReturnType<Data>,
config?: UndoableConfig<Data, Args, RootState>,
): <State extends RootState>(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
Expand Down Expand Up @@ -129,7 +151,7 @@ export function createHistoryAdapter<Data>({
getInitialState,
undo: makeStateOperator(undoMutably),
redo: makeStateOperator(redoMutably),
jump: makeStateOperator((state, n) => {
jump: makeStateOperator<HistoryState<Data>, [number]>((state, n) => {
if (n < 0) {
for (let i = 0; i < -n; i++) {
undoMutably(state);
Expand All @@ -140,18 +162,34 @@ export function createHistoryAdapter<Data>({
}
}
}),
clearHistory: makeStateOperator((state) => {
clearHistory: makeStateOperator<HistoryState<Data>>((state) => {
state.past = [];
state.future = [];
}),
undoable(recipe, isUndoable) {
return makeStateOperator((state, ...args) => {
undoable<Args extends Array<any>, RootState>(
recipe: (
draft: Draft<Data>,
...args: Args
) => ValidRecipeReturnType<Data>,
configOrIsUndoable?:
| UndoableConfig<Data, Args, RootState>
| ((...args: Args) => boolean | undefined),
) {
const {
isUndoable,
selectHistoryState = (s) => s as HistoryState<Data>,
}: UndoableConfig<Data, Args, RootState> =
typeof configOrIsUndoable === "function"
? { isUndoable: configOrIsUndoable }
: configOrIsUndoable ?? {};
return makeStateOperator<RootState, Args>((rootState, ...args) => {
const state = selectHistoryState(rootState);
const [{ present }, redo, undo] = produceWithPatches(state, (draft) => {
const result = recipe(draft.present as Draft<Data>, ...args);
if (result === nothing) {
draft.present = undefined as Draft<Draft<Data>>;
draft.present = undefined as Draft<Data>;
} else if (typeof result !== "undefined") {
draft.present = result as Draft<Draft<Data>>;
draft.present = result as Draft<Data>;
}
});
state.present = present;
Expand Down
45 changes: 45 additions & 0 deletions src/redux.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<Book>(),
booksHistoryAdapter.undoableReducer(
(state, action) => {
state.push(action.payload);
},
{
selectHistoryState: (s: { books: HistoryState<Array<Book>> }) =>
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(
Expand Down
16 changes: 12 additions & 4 deletions src/redux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
HistoryAdapter as Adapter,
HistoryAdapterConfig,
HistoryState,
UndoableConfig,
} from ".";
import { createHistoryAdapter as createAdapter } from ".";
import type { IfMaybeUndefined } from "./utils";
Expand Down Expand Up @@ -106,9 +107,13 @@ export interface HistoryAdapter<Data> extends Adapter<Data> {
>
) => { 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<A extends Action & { meta?: UndoableMeta }>(
undoableReducer<
A extends Action & { meta?: UndoableMeta },
RootState = HistoryState<Data>,
>(
reducer: CaseReducer<Data, A>,
): <State extends HistoryState<Data>>(state: State, action: A) => State;
config?: Omit<UndoableConfig<Data, [action: A], RootState>, "isUndoable">,
): <State extends RootState>(state: State, action: A) => State;

getSelectors(): HistorySelectors<Data>;
getSelectors<RootState>(
Expand Down Expand Up @@ -155,8 +160,11 @@ export function createHistoryAdapter<Data>(
>
) => ({ 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<Data>(),
};
Expand Down
1 change: 1 addition & 0 deletions test-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import "mix-n-matchers/vitest";
7 changes: 7 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
setupFiles: ["./test-setup"],
},
});
Loading

0 comments on commit 5a14924

Please sign in to comment.