Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add ability to pause history #7

Merged
merged 6 commits into from
Apr 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,25 @@ console.log(tryUndoState.present); // [{ id: 1, title: "Dune" }]

Just like undoable functions, these methods will act mutably when passed an immer draft and immutably otherwise.

### Pausing history

If you need to make changes to the state without affecting the history, you can use the `pause` and `resume` methods.

```ts
const pausedState = booksHistoryAdapter.pause(resetState);

const withBook = addBook(pausedState, { id: 2, title: "Foundation" });

const resumedState = booksHistoryAdapter.resume(withBook);

const undoneState = booksHistoryAdapter.undo(resumedState);

// changes while paused cannot be undone
console.log(undoneState.present); // [{ id: 1, title: "Dune" }, { id: 2, title: "Foundation" }]
```

Changes will still be made to the data while paused (including `undo` and `redo`), but they won't be recorded in the history.

## Redux helper methods

If imported from `"history-adapter/redux"`, the history adapter will have additional methods to assist use with Redux, specifically with Redux Toolkit.
Expand Down Expand Up @@ -247,7 +266,7 @@ dispatch(addBook(book, true)); // action.meta.undoable === true
dispatch(addBook(book, false)); // action.meta.undoable === false
```

As a tip, `undo`, `redo` and `clearHistory` are all valid reducers due to not needing an argument. The version of `jump` on a Redux history adapter allows for either a number or payload action, making it also valid.
As a tip, `undo`, `redo`, `pause`, `resume` and `clearHistory` are all valid reducers due to not needing an argument. The version of `jump` on a Redux history adapter allows for either a number or payload action, making it also valid.

```ts
const booksSlice = createSlice({
Expand All @@ -257,6 +276,8 @@ const booksSlice = createSlice({
undo: booksHistoryAdapter.undo,
redo: booksHistoryAdapter.redo,
jump: booksHistoryAdapter.jump,
pause: booksHistoryAdapter.pause,
resume: booksHistoryAdapter.resume,
clearHistory: booksHistoryAdapter.clearHistory,
addBook: {
prepare: booksHistoryAdapter.withPayload<Book>(),
Expand All @@ -275,20 +296,24 @@ const booksSlice = createSlice({
A method which returns some useful selectors.

```ts
const { selectCanUndo, selectCanRedo, selectPresent } =
const { selectCanUndo, selectCanRedo, selectPresent, selectPaused } =
booksHistoryAdapter.getSelectors();

console.log(
selectPresent(initialState), // []
selectCanUndo(initialState), // false
selectCanRedo(initialState), // false
selectPaused(initialState), // false
);

console.log(
selectPresent(nextState), // [{ id: 1, title: "Dune" }]
selectCanUndo(nextState), // true
selectCanRedo(nextState), // false
selectPaused(nextState), // false
);

console.log(selectPaused(pausedState)); // true
```

If an input selector is provided, the selectors will be combined using [reselect](https://github.com/reduxjs/reselect).
Expand All @@ -301,7 +326,7 @@ const { selectPresent } = booksHistoryAdapter.getSelectors(
console.log(selectPresent({ books: initialState })); // []
```

The instance of `createSelector` used can be customised:
The instance of `createSelector` used can be customised, and defaults to RTK's [`createDraftSafeSelector`](https://redux-toolkit.js.org/api/createSelector#createdraftsafeselector):

```ts
import { createSelectorCreator, lruMemoize } from "reselect";
Expand Down
22 changes: 22 additions & 0 deletions src/creator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ describe("Slice creators", () => {
undo,
redo,
jump,
pause,
resume,
clearHistory,
reset,
addBook,
Expand Down Expand Up @@ -109,6 +111,16 @@ describe("Slice creators", () => {
store.dispatch(reset());

expect(selectLastBook(store.getState())).toBeUndefined();

store.dispatch(pause());

store.dispatch(addBook(book1));

store.dispatch(resume());

store.dispatch(undo());

expect(selectLastBook(store.getState())).toEqual(book1);
});
it("works with nested state", () => {
const bookSlice = createAppSlice({
Expand Down Expand Up @@ -144,6 +156,8 @@ describe("Slice creators", () => {
undo,
redo,
jump,
pause,
resume,
clearHistory,
reset,
addBook,
Expand Down Expand Up @@ -195,6 +209,14 @@ describe("Slice creators", () => {
store.dispatch(reset());

expect(selectLastBook(store.getState())).toBeUndefined();

store.dispatch(pause());

store.dispatch(addBook(book1));

store.dispatch(resume());

expect(selectLastBook(store.getState())).toEqual(book1);
});
it("can be destructured", () => {
const bookSlice = createAppSlice({
Expand Down
6 changes: 5 additions & 1 deletion src/creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const undoableCreatorsCreatorType = Symbol("undoableCreatorsCreator");
interface HistoryReducers<State> {
undo: CaseReducerDefinition<State, PayloadAction>;
redo: CaseReducerDefinition<State, PayloadAction>;
pause: CaseReducerDefinition<State, PayloadAction>;
resume: CaseReducerDefinition<State, PayloadAction>;
jump: CaseReducerDefinition<State, PayloadAction<number>>;
clearHistory: CaseReducerDefinition<State, PayloadAction>;
reset: ReducerDefinition<typeof historyMethodsCreatorType> & {
Expand Down Expand Up @@ -153,12 +155,14 @@ export const historyMethodsCreator: ReducerCreator<
{
selectHistoryState = (state) => state as HistoryState<Data>,
}: HistoryMethodsCreatorConfig<State, Data> = {},
) {
): HistoryReducers<State> {
const createReducer = makeScopedReducerCreator(selectHistoryState);
return {
undo: createReducer(adapter.undo),
redo: createReducer(adapter.redo),
jump: createReducer(adapter.jump),
pause: createReducer(adapter.pause),
resume: createReducer(adapter.resume),
clearHistory: createReducer(adapter.clearHistory),
reset: {
_reducerDefinitionType: historyMethodsCreatorType,
Expand Down
101 changes: 101 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe("createHistoryAdapter", () => {
past: [],
present: [],
future: [],
paused: false,
});
});
});
Expand All @@ -52,6 +53,7 @@ describe("createHistoryAdapter", () => {
past: [aPatchState],
present: [book1],
future: [],
paused: false,
});
});
it("handles nothing value to return undefined", () => {
Expand All @@ -63,6 +65,7 @@ describe("createHistoryAdapter", () => {
past: [aPatchState],
present: undefined,
future: [],
paused: false,
});
});
it("allows deriving from arguments whether update should be undoable", () => {
Expand All @@ -78,6 +81,7 @@ describe("createHistoryAdapter", () => {
past: [],
present: [book1],
future: [],
paused: false,
});
});
it("can be used as a mutator if already working with drafts", () => {
Expand All @@ -92,6 +96,7 @@ describe("createHistoryAdapter", () => {
past: [aPatchState],
present: [book1],
future: [],
paused: false,
});
});
it("can be provided with a selector if working with nested state", () => {
Expand All @@ -113,6 +118,7 @@ describe("createHistoryAdapter", () => {
past: [aPatchState],
present: [book1],
future: [],
paused: false,
},
});
});
Expand All @@ -130,6 +136,7 @@ describe("createHistoryAdapter", () => {
past: [],
present: [],
future: [aPatchState],
paused: false,
});
});
it("can be used as a mutator if already working with drafts", () => {
Expand All @@ -141,6 +148,7 @@ describe("createHistoryAdapter", () => {
past: [],
present: [],
future: [aPatchState],
paused: false,
});
});
});
Expand All @@ -159,6 +167,7 @@ describe("createHistoryAdapter", () => {
past: [aPatchState],
present: [book1],
future: [],
paused: false,
});
});
it("can be used as a mutator if already working with drafts", () => {
Expand All @@ -170,6 +179,7 @@ describe("createHistoryAdapter", () => {
past: [aPatchState],
present: [book1],
future: [],
paused: false,
});
});
});
Expand All @@ -187,6 +197,7 @@ describe("createHistoryAdapter", () => {
past: [],
present: [],
future: [aPatchState, aPatchState],
paused: false,
});
const jumpedForwardState = booksHistoryAdapter.jump(jumpedState, 2);
expect(jumpedForwardState).toEqual(secondState);
Expand All @@ -200,6 +211,96 @@ describe("createHistoryAdapter", () => {
past: [],
present: [],
future: [aPatchState, aPatchState],
paused: false,
});
});
});
describe("pause", () => {
it("can be used to pause history tracking", () => {
const initialState = booksHistoryAdapter.getInitialState([]);
const pausedState = booksHistoryAdapter.pause(initialState);
expect(pausedState).toEqual<HistoryState<Array<Book>>>({
past: [],
present: [],
future: [],
paused: true,
});
});
it("can be used as a mutator if already working with drafts", () => {
const initialState = booksHistoryAdapter.getInitialState([]);
expect(
produce(initialState, (draft) => {
booksHistoryAdapter.pause(draft);
}),
).toEqual<HistoryState<Array<Book>>>({
past: [],
present: [],
future: [],
paused: true,
});
});
it("is respected by undoable functions", () => {
const addBook = booksHistoryAdapter.undoable((books, book: Book) => {
books.push(book);
});
const initialState = booksHistoryAdapter.getInitialState([]);
const pausedState = booksHistoryAdapter.pause(initialState);
const nextState = addBook(pausedState, book1);
expect(nextState).toEqual<HistoryState<Array<Book>>>({
past: [],
present: [book1],
future: [],
paused: true,
});
});
});
describe("resume", () => {
it("can be used to resume history tracking", () => {
const initialState = booksHistoryAdapter.getInitialState([]);
const pausedState = booksHistoryAdapter.pause(initialState);
const resumedState = booksHistoryAdapter.resume(pausedState);
expect(resumedState).toEqual<HistoryState<Array<Book>>>({
past: [],
present: [],
future: [],
paused: false,
});
});
it("can be used as a mutator if already working with drafts", () => {
const initialState = booksHistoryAdapter.getInitialState([]);
const pausedState = booksHistoryAdapter.pause(initialState);
expect(
produce(pausedState, (draft) => {
booksHistoryAdapter.resume(draft);
}),
).toEqual<HistoryState<Array<Book>>>({
past: [],
present: [],
future: [],
paused: false,
});
});
it("is respected by undoable functions", () => {
const addBook = booksHistoryAdapter.undoable((books, book: Book) => {
books.push(book);
});
const initialState = booksHistoryAdapter.getInitialState([]);
const pausedState = booksHistoryAdapter.pause(initialState);
const withBook = addBook(pausedState, book1);
expect(withBook).toEqual<HistoryState<Array<Book>>>({
past: [],
present: [book1],
future: [],
paused: true,
});

const resumedState = booksHistoryAdapter.resume(withBook);
const nextState = addBook(resumedState, book2);
expect(nextState).toEqual<HistoryState<Array<Book>>>({
past: [aPatchState],
present: [book1, book2],
future: [],
paused: false,
});
});
});
Expand Down
22 changes: 22 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface HistoryState<Data> {
past: Array<PatchState>;
present: Data;
future: Array<PatchState>;
paused: boolean;
}

export type MaybeDraftHistoryState<Data> =
Expand Down Expand Up @@ -130,6 +131,17 @@ export interface HistoryAdapter<Data> {
state: State,
...args: Args
) => State;

/**
* Pauses the history, preventing any new patches from being added.
* @param state History state shape, with patches
*/
pause<State extends MaybeDraftHistoryState<Data>>(state: State): State;
/**
* Resumes the history, allowing new patches to be added.
* @param state History state shape, with patches
*/
resume<State extends MaybeDraftHistoryState<Data>>(state: State): State;
}

/**
Expand All @@ -142,6 +154,7 @@ export function getInitialState<Data>(initialData: Data): HistoryState<Data> {
past: [],
present: initialData,
future: [],
paused: false,
};
}

Expand Down Expand Up @@ -210,6 +223,9 @@ export function createHistoryAdapter<Data>({
});
state.present = present;

// if paused, don't add to history
if (state.paused) return;

const undoable = isUndoable?.(...args) ?? true;
if (undoable) {
const lengthWithoutFuture = state.past.length + 1;
Expand All @@ -221,5 +237,11 @@ export function createHistoryAdapter<Data>({
}
});
},
pause: makeStateOperator<HistoryState<Data>>((state) => {
state.paused = true;
}),
resume: makeStateOperator<HistoryState<Data>>((state) => {
state.paused = false;
}),
};
}
Loading