From 39e7f4053add98bb69f0717cc9ec1170852b5213 Mon Sep 17 00:00:00 2001 From: Matt Kilpatrick Date: Tue, 23 Jul 2024 13:15:11 -0400 Subject: [PATCH] feat: add APIs for setting undo/redo history This addresses https://github.com/measuredco/puck/issues/519. This change exposes the history store's setHistories and setHistoryIndex functions. When they are called the data is dispatched so that Puck rerenders. The use case this supports is when a Puck consumer wants to change or reset the history state manually without requiring the page to be refreshed. --------- Co-authored-by: Chris Villa --- .../docs/api-reference/functions/use-puck.mdx | 32 ++++++-- .../lib/__tests__/use-history-store.spec.tsx | 32 ++++++++ .../lib/__tests__/use-puck-history.spec.tsx | 82 +++++++++++++++++++ packages/core/lib/use-history-store.ts | 18 ++-- packages/core/lib/use-puck-history.ts | 27 +++++- packages/core/lib/use-puck.ts | 2 + 6 files changed, 179 insertions(+), 14 deletions(-) diff --git a/apps/docs/pages/docs/api-reference/functions/use-puck.mdx b/apps/docs/pages/docs/api-reference/functions/use-puck.mdx index 62dc317c5..31bf945e4 100644 --- a/apps/docs/pages/docs/api-reference/functions/use-puck.mdx +++ b/apps/docs/pages/docs/api-reference/functions/use-puck.mdx @@ -28,12 +28,14 @@ export function Editor() { ## Returns -| Param | Example | Type | -| ------------------------------- | ------------------------------------------------ | --------------------------------------------------- | -| [`appState`](#appstate) | `{ data: {}, ui: {} }` | [AppState](/docs/api-reference/app-state) | -| [`dispatch`](#dispatch) | `(action: PuckAction) => void` | Function | -| [`history`](#history) | `{}` | Object | -| [`selectedItem`](#selecteditem) | `{ type: "Heading", props: {id: "my-heading"} }` | [ComponentData](/docs/api-reference/data#content-1) | +| Param | Example | Type | +| ------------------------------------- | ------------------------------------------------ | --------------------------------------------------- | +| [`appState`](#appstate) | `{ data: {}, ui: {} }` | [AppState](/docs/api-reference/app-state) | +| [`dispatch`](#dispatch) | `(action: PuckAction) => void` | Function | +| [`history`](#history) | `{}` | Object | +| [`selectedItem`](#selecteditem) | `{ type: "Heading", props: {id: "my-heading"} }` | [ComponentData](/docs/api-reference/data#content-1) | +| [`setHistories`](#sethistories) | `setHistories: (histories) => {}` | Function | +| [`setHistoryIndex`](#sethistoryindex) | `setHistoryIndex: (index) => {}` | Function | ### `appState` @@ -117,3 +119,21 @@ The currently selected item, as defined by `appState.ui.itemSelector`. console.log(selectedItem); // { type: "Heading", props: {id: "my-heading"} } ``` + +### `setHistories` + +A function to set the history state. + +```tsx +const { setHistories } = usePuck(); +setHistories([]); // clears all history +``` + +### `setHistoryIndex` + +A function to set current history index. + +```tsx +const { setHistoryIndex } = usePuck(); +setHistoryIndex(2); +``` diff --git a/packages/core/lib/__tests__/use-history-store.spec.tsx b/packages/core/lib/__tests__/use-history-store.spec.tsx index 7f07624e8..4813b1e22 100644 --- a/packages/core/lib/__tests__/use-history-store.spec.tsx +++ b/packages/core/lib/__tests__/use-history-store.spec.tsx @@ -89,6 +89,38 @@ describe("use-history-store", () => { expect(renderedHook.result.current.histories[1].data).toBe("Banana"); expect(renderedHook.result.current.currentHistory.data).toBe("Banana"); }); + + test("should reset histories and index on setHistories", () => { + act(() => renderedHook.result.current.record("Apples")); + act(() => renderedHook.result.current.record("Oranges")); + act(() => + renderedHook.result.current.setHistories([ + { + id: "1", + data: "Oreo", + }, + ]) + ); + + expect(renderedHook.result.current.hasPast).toBe(true); + expect(renderedHook.result.current.hasFuture).toBe(false); + expect(renderedHook.result.current.histories.length).toBe(1); + expect(renderedHook.result.current.histories[0].data).toBe("Oreo"); + expect(renderedHook.result.current.currentHistory.data).toBe("Oreo"); + expect(renderedHook.result.current.index).toBe(0); + }); + + test("should update index on setHistoryIndex", () => { + act(() => renderedHook.result.current.record("Apples")); + act(() => renderedHook.result.current.record("Oranges")); + act(() => renderedHook.result.current.setHistoryIndex(0)); + + expect(renderedHook.result.current.hasPast).toBe(true); + expect(renderedHook.result.current.hasFuture).toBe(true); + expect(renderedHook.result.current.histories.length).toBe(2); + expect(renderedHook.result.current.currentHistory.data).toBe("Apples"); + expect(renderedHook.result.current.index).toBe(0); + }); }); describe("use-history-store-prefilled", () => { diff --git a/packages/core/lib/__tests__/use-puck-history.spec.tsx b/packages/core/lib/__tests__/use-puck-history.spec.tsx index e3615ba39..1b8901c3f 100644 --- a/packages/core/lib/__tests__/use-puck-history.spec.tsx +++ b/packages/core/lib/__tests__/use-puck-history.spec.tsx @@ -12,6 +12,8 @@ const historyStore = { nextHistory: { data: null }, back: jest.fn(), forward: jest.fn(), + setHistories: jest.fn(), + setHistoryIndex: jest.fn(), } as unknown as HistoryStore; const initialAppState = defaultAppState; @@ -99,4 +101,84 @@ describe("use-puck-history", () => { state: historyStore.nextHistory?.data, }); }); + + test("setHistories calls dispatch to last history item", () => { + const { result } = renderHook(() => + usePuckHistory({ dispatch, initialAppState, historyStore }) + ); + + const updatedHistories = [ + { + id: "1", + data: { + one: "foo 1", + two: "bar 1", + }, + }, + { + id: "2", + data: { + one: "foo 2", + two: "bar 2", + }, + }, + ]; + + act(() => { + result.current.setHistories(updatedHistories); + }); + + expect(historyStore.setHistories).toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledWith({ + type: "set", + state: updatedHistories[1].data, + }); + }); + + test("setHistoryIndex calls dispatch on the history at that index", () => { + const updatedHistories = [ + { + id: "1", + data: { + one: "foo 1", + two: "bar 1", + }, + }, + { + id: "2", + data: { + one: "foo 2", + two: "bar 2", + }, + }, + ]; + historyStore.histories = updatedHistories; + + const { result } = renderHook(() => + usePuckHistory({ dispatch, initialAppState, historyStore }) + ); + + act(() => { + result.current.setHistoryIndex(0); + }); + + expect(historyStore.setHistoryIndex).toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledWith({ + type: "set", + state: updatedHistories[0].data, + }); + }); + + test("setHistoryIndex does not call dispatch when index out of bounds", () => { + const { result } = renderHook(() => + usePuckHistory({ dispatch, initialAppState, historyStore }) + ); + + act(() => { + result.current.setHistoryIndex(5); + }); + + expect(historyStore.setHistoryIndex).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); }); diff --git a/packages/core/lib/use-history-store.ts b/packages/core/lib/use-history-store.ts index 72774e2ba..2188c320e 100644 --- a/packages/core/lib/use-history-store.ts +++ b/packages/core/lib/use-history-store.ts @@ -21,6 +21,8 @@ export type HistoryStore = { currentHistory: History; nextHistory: History | null; prevHistory: History | null; + setHistories: (histories: History[]) => void; + setHistoryIndex: (index: number) => void; }; const EMPTY_HISTORY_INDEX = -1; @@ -33,6 +35,12 @@ export function useHistoryStore(initialHistory?: { initialHistory?.histories ?? [] ); + // Exported as setHistories so that the index gets automatically updated. + const updateHistories = (histories: History[]) => { + setHistories(histories); + setIndex(histories.length - 1); + }; + const [index, setIndex] = useState( initialHistory?.index ?? EMPTY_HISTORY_INDEX ); @@ -50,13 +58,7 @@ export function useHistoryStore(initialHistory?: { id: generateId("history"), }; - setHistories((prev) => { - const newVal = [...prev.slice(0, index + 1), history]; - - setIndex(newVal.length - 1); - - return newVal; - }); + updateHistories([...histories.slice(0, index + 1), history]); }, 250); const back = () => { @@ -78,5 +80,7 @@ export function useHistoryStore(initialHistory?: { nextHistory, prevHistory, histories, + setHistories: updateHistories, + setHistoryIndex: setIndex, }; } diff --git a/packages/core/lib/use-puck-history.ts b/packages/core/lib/use-puck-history.ts index 7b15d9826..e711a4e4a 100644 --- a/packages/core/lib/use-puck-history.ts +++ b/packages/core/lib/use-puck-history.ts @@ -1,11 +1,13 @@ import type { AppState } from "../types/Config"; import { PuckAction } from "../reducer"; import { useHotkeys } from "react-hotkeys-hook"; -import { HistoryStore } from "./use-history-store"; +import { History, HistoryStore } from "./use-history-store"; export type PuckHistory = { back: VoidFunction; forward: VoidFunction; + setHistories: (histories: History[]) => void; + setHistoryIndex: (index: number) => void; historyStore: HistoryStore; }; @@ -37,6 +39,27 @@ export function usePuckHistory({ } }; + const setHistories = (histories: History[]) => { + // dispatch the last history index or initial state + dispatch({ + type: "set", + state: histories[histories.length - 1]?.data || initialAppState, + }); + + historyStore.setHistories(histories); + }; + + const setHistoryIndex = (index: number) => { + if (historyStore.histories.length > index) { + dispatch({ + type: "set", + state: historyStore.histories[index]?.data || initialAppState, + }); + + historyStore.setHistoryIndex(index); + } + }; + useHotkeys("meta+z", back, { preventDefault: true }); useHotkeys("meta+shift+z", forward, { preventDefault: true }); useHotkeys("meta+y", forward, { preventDefault: true }); @@ -45,5 +68,7 @@ export function usePuckHistory({ back, forward, historyStore, + setHistories, + setHistoryIndex, }; } diff --git a/packages/core/lib/use-puck.ts b/packages/core/lib/use-puck.ts index d508b4ea0..f443c70e4 100644 --- a/packages/core/lib/use-puck.ts +++ b/packages/core/lib/use-puck.ts @@ -16,6 +16,8 @@ export const usePuck = () => { history: { back: history.back!, forward: history.forward!, + setHistories: history.setHistories!, + setHistoryIndex: history.setHistoryIndex!, hasPast: history.historyStore!.hasPast, hasFuture: history.historyStore!.hasFuture, histories: history.historyStore!.histories,