Skip to content

Commit

Permalink
feat: add undo/redo history
Browse files Browse the repository at this point in the history
  • Loading branch information
Yuddomack committed Oct 4, 2023
1 parent 182fcfe commit 222697e
Show file tree
Hide file tree
Showing 11 changed files with 2,307 additions and 36 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ jobs:
- name: Install dependencies
run: yarn

- name: Run tests
run: yarn test

- name: Check linting and formatting
run: |
if yarn lint && yarn format:check; then
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"release:prepare": "git fetch --tags && conventional-recommended-bump -p angular | xargs yarn version:auto $1",
"release:canary": "yarn release:prepare && node scripts/get-unstable-version canary | xargs yarn version:auto $1",
"release-commit": "git add -u && git commit -m \"release: v${npm_package_version}\"",
"test": "turbo run test",
"version": "lerna version --force-publish -y --no-push --no-changelog --no-git-tag-version $npm_package_version",
"version:auto": "yarn version --no-git-tag-version --new-version $1",
"changelog": "node scripts/create-changelog"
Expand Down
38 changes: 37 additions & 1 deletion packages/core/components/Puck/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { Plugin } from "../../types/Plugin";
import { usePlaceholderStyle } from "../../lib/use-placeholder-style";

import { SidebarSection } from "../SidebarSection";
import { Globe, Sidebar } from "react-feather";
import { Globe, Sidebar, ChevronLeft, ChevronRight } from "react-feather";
import { Heading } from "../Heading";
import { IconButton } from "../IconButton/IconButton";
import { DropZone, DropZoneProvider, dropZoneContext } from "../DropZone";
Expand All @@ -30,6 +30,7 @@ import { LayerTree } from "../LayerTree";
import { findZonesForArea } from "../../lib/find-zones-for-area";
import { areaContainsZones } from "../../lib/area-contains-zones";
import { flushZones } from "../../lib/flush-zones";
import { usePuckHistory } from "../../lib/use-puck-history";

const Field = () => {};

Expand Down Expand Up @@ -91,6 +92,11 @@ export function Puck({
flushZones(initialData)
);

const { canForward, canRewind, rewind, forward } = usePuckHistory({
data,
dispatch,
});

const [itemSelector, setItemSelector] = useState<ItemSelector | null>(null);

const selectedItem = itemSelector ? getItem(itemSelector, data) : null;
Expand Down Expand Up @@ -337,6 +343,36 @@ export function Puck({
justifyContent: "flex-end",
}}
>
<div style={{ display: "flex" }}>
<IconButton
title="undo"
disabled={!canRewind}
onClick={rewind}
>
<ChevronLeft
size={21}
stroke={
canRewind
? "var(--puck-color-black)"
: "var(--puck-color-grey-7)"
}
/>
</IconButton>
<IconButton
title="redo"
disabled={!canForward}
onClick={forward}
>
<ChevronRight
size={21}
stroke={
canForward
? "var(--puck-color-black)"
: "var(--puck-color-grey-7)"
}
/>
</IconButton>
</div>
{renderHeaderActions &&
renderHeaderActions({ data, dispatch })}
<Button
Expand Down
8 changes: 8 additions & 0 deletions packages/core/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { Config } from "jest";

const config: Config = {
preset: "ts-jest",
testEnvironment: "jsdom",
};

export default config;
192 changes: 192 additions & 0 deletions packages/core/lib/__tests__/use-action-history.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { renderHook, act, type RenderHookResult } from "@testing-library/react";
import { useActionHistory } from "../use-action-history";

describe("use-action-history", () => {
let renderedHook: RenderHookResult<ReturnType<typeof useActionHistory>, any>;

beforeEach(() => {
renderedHook = renderHook(() => useActionHistory());
});

test("at first, canRewind should be false", () => {
expect(renderedHook.result.current.canRewind).toBe(false);
});

test("at first, canForward should be false", () => {
expect(renderedHook.result.current.canForward).toBe(false);
});

describe("when a single action history is recorded", () => {
let rewind: jest.Mock;
let forward: jest.Mock;

beforeEach(() => {
rewind = jest.fn();
forward = jest.fn();

act(() =>
renderedHook.result.current.record({
rewind,
forward,
})
);
});

test("canRewind should be true", () => {
expect(renderedHook.result.current.canRewind).toBe(true);
});

test("canForward still should be false", () => {
expect(renderedHook.result.current.canForward).toBe(false);
});

describe("and when history is rewound once", () => {
beforeEach(() => {
act(() => renderedHook.result.current.rewind());
});

test("the rewind function should be executed", () => {
expect(rewind).toBeCalledTimes(1);
});

test("canRewind should be false - there is no rewind history remaining", () => {
expect(renderedHook.result.current.canRewind).toBe(false);
});

test("canForward should be true", () => {
expect(renderedHook.result.current.canForward).toBe(true);
});

describe("and then rewound again", () => {
beforeEach(() => {
act(() => renderedHook.result.current.rewind());
});

test("the rewind function is not executed again - there is no rewind history remaining", () => {
expect(rewind).toBeCalledTimes(1);
});
});

describe("and then fast-forwarded once", () => {
beforeEach(() => {
act(() => renderedHook.result.current.forward());
});

test("the forward function should be executed", () => {
expect(forward).toBeCalledTimes(1);
});

test("canForward should be false - there is no forward history remaining", () => {
expect(renderedHook.result.current.canForward).toBe(false);
});

test("canRewind should be true", () => {
expect(renderedHook.result.current.canRewind).toBe(true);
});
});
});
});

describe("when multiple action histories are recorded and rewound", () => {
const actions = [
{ rewind: jest.fn(), forward: jest.fn() },
{ rewind: jest.fn(), forward: jest.fn() },
{ rewind: jest.fn(), forward: jest.fn() },
];

beforeEach(() => {
actions.forEach(({ rewind, forward }) => {
act(() =>
renderedHook.result.current.record({
rewind,
forward,
})
);
});
});

test("the rewind function should be executed each time an action is rewound", () => {
actions.reverse().forEach(({ rewind }) => {
act(() => renderedHook.result.current.rewind());
expect(rewind).toBeCalled();
});
});

test("canRewind should be true until the start of the history is reached", () => {
actions.reverse().forEach((_, i) => {
act(() => renderedHook.result.current.rewind());
expect(renderedHook.result.current.canRewind).toBe(
i !== actions.length - 1
);
});
});
});

describe("when multiple action histories are recorded, rewound and then fast-forwarded", () => {
const actions = [
{ rewind: jest.fn(), forward: jest.fn() },
{ rewind: jest.fn(), forward: jest.fn() },
{ rewind: jest.fn(), forward: jest.fn() },
];

beforeEach(() => {
actions.forEach(({ rewind, forward }) => {
act(() =>
renderedHook.result.current.record({
rewind,
forward,
})
);
});
});

beforeEach(() => {
actions.forEach(() => {
act(() => renderedHook.result.current.rewind());
});
});

test("the forward function should be executed each time an action is fast-forwarded", () => {
actions.forEach(({ forward }) => {
act(() => renderedHook.result.current.forward());
expect(forward).toBeCalled();
});
});

test("canForward should be true until the end of the history is reached", () => {
actions.forEach((_, i) => {
act(() => renderedHook.result.current.forward());
expect(renderedHook.result.current.canForward).toBe(
i !== actions.length - 1
);
});
});
});

describe("in the middle of history", () => {
const actions = [
{ rewind: jest.fn(), forward: jest.fn() },
{ rewind: jest.fn(), forward: jest.fn() },
{ rewind: jest.fn(), forward: jest.fn() },
];

beforeEach(() => {
actions.forEach(({ rewind, forward }) => {
act(() =>
renderedHook.result.current.record({
rewind,
forward,
})
);
});
act(() => renderedHook.result.current.rewind());
});

test("the forward history is overridden when recording a new action ", () => {
const newAction = { rewind: jest.fn(), forward: jest.fn() };
act(() => renderedHook.result.current.record(newAction));

expect(renderedHook.result.current.canForward).toBe(false);
});
});
});
20 changes: 16 additions & 4 deletions packages/core/lib/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
removeRelatedZones,
} from "./reduce-related-zones";
import { generateId } from "./generate-id";
import { recordDiff } from "./use-puck-history";

export type ActionType = "insert" | "reorder";

Expand Down Expand Up @@ -91,9 +92,20 @@ export type PuckAction =

export type StateReducer = Reducer<Data, PuckAction>;

export const createReducer =
({ config }: { config: Config }): StateReducer =>
(data, action) => {
const storeInterceptor = (reducer: StateReducer) => {
return (data, action) => {
const newData = reducer(data, action);

if (!["registerZone", "unregisterZone", "set"].includes(action.type)) {
recordDiff(newData);
}

return newData;
};
};

export const createReducer = ({ config }: { config: Config }): StateReducer =>
storeInterceptor((data, action) => {
if (action.type === "insert") {
const emptyComponentData = {
type: action.componentType,
Expand Down Expand Up @@ -336,4 +348,4 @@ export const createReducer =
}

return data;
};
});
57 changes: 57 additions & 0 deletions packages/core/lib/use-action-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useState } from "react";

export type History = {
id: string;
forward: VoidFunction;
rewind: VoidFunction;
};

const EMPTY_HISTORY_INDEX = -1;

export function useActionHistory() {
const [histories, setHistories] = useState<History[]>([]);
const [currentHistoryIndex, setCurrentHistoryIndex] =
useState(EMPTY_HISTORY_INDEX);

const currentHistory = histories[currentHistoryIndex];
const canRewind = currentHistoryIndex > EMPTY_HISTORY_INDEX;
const canForward = currentHistoryIndex < histories.length - 1;

const record = (params: Pick<History, "forward" | "rewind">) => {
const history: History = {
id: Math.random().toString(),
...params,
};

setHistories((prev) => [
...prev.slice(0, currentHistoryIndex + 1),
history,
]);
setCurrentHistoryIndex((prev) => prev + 1);
};

const rewind = () => {
if (canRewind) {
currentHistory.rewind();
setCurrentHistoryIndex((prev) => prev - 1);
}
};

const forward = () => {
const forwardHistory = histories[currentHistoryIndex + 1];

if (canForward && forwardHistory) {
forwardHistory.forward();
setCurrentHistoryIndex((prev) => prev + 1);
}
};

return {
currentHistory,
canRewind,
canForward,
record,
rewind,
forward,
};
}
Loading

0 comments on commit 222697e

Please sign in to comment.