Skip to content

Commit 12f0a21

Browse files
FedericoBonelchrisvxd
authored andcommitted
feat: add resolveDataById utility to Puck API
1 parent 7c2f928 commit 12f0a21

4 files changed

Lines changed: 241 additions & 16 deletions

File tree

apps/docs/pages/docs/api-reference/puck-api.mdx

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,18 @@ import { Callout } from "nextra/components";
1717

1818
## Params
1919

20-
| Param | Example | Type |
21-
| ------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------------------- |
22-
| [`appState`](#appstate) | `{ data: {}, ui: {} }` | [AppState](/docs/api-reference/data-model/app-state) |
23-
| [`dispatch`](#dispatchaction) | `(action: PuckAction) => void` | Function |
24-
| [`getItemBySelector`](#getitembyselectorselector) | `() => ({ type: "Heading", props: {} })` | Function |
25-
| [`getItemById`](#getitembyidid) | `() => ({ type: "Heading", props: {} })` | Function |
26-
| [`getSelectorForId`](#getselectorforidid) | `() => ({ index: 0, zone: 'Flex-123:children' })` | Function |
27-
| [`getPermissions`](#getpermissionsparams) | `() => ({ delete: true })` | Function |
28-
| [`history`](#history) | `{}` | Object |
29-
| [`refreshPermissions`](#refreshpermissionsparams) | `() => void` | Function |
30-
| [`selectedItem`](#selecteditem) | `{ type: "Heading", props: {id: "my-heading"} }` | [ComponentData](/docs/api-reference/data-model/data#content-1) |
20+
| Param | Example | Type |
21+
| --------------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------------------- |
22+
| [`appState`](#appstate) | `{ data: {}, ui: {} }` | [AppState](/docs/api-reference/data-model/app-state) |
23+
| [`dispatch`](#dispatchaction) | `(action: PuckAction) => void` | Function |
24+
| [`getItemBySelector`](#getitembyselectorselector) | `() => ({ type: "Heading", props: {} })` | Function |
25+
| [`getItemById`](#getitembyidid) | `() => ({ type: "Heading", props: {} })` | Function |
26+
| [`getSelectorForId`](#getselectorforidid) | `() => ({ index: 0, zone: 'Flex-123:children' })` | Function |
27+
| [`getPermissions`](#getpermissionsparams) | `() => ({ delete: true })` | Function |
28+
| [`history`](#history) | `{}` | Object |
29+
| [`refreshPermissions`](#refreshpermissionsparams) | `() => void` | Function |
30+
| [`resolveComponentDataById`](#resolvecomponentdatabyidid) | `() => void` | Function |
31+
| [`selectedItem`](#selecteditem) | `{ type: "Heading", props: {id: "my-heading"} }` | [ComponentData](/docs/api-reference/data-model/data#content-1) |
3132

3233
### `appState`
3334

@@ -253,6 +254,14 @@ Specify `type` to refresh the permissions for all components of a given componen
253254
refreshPermissions({ type: "HeadingBlock" });
254255
```
255256

257+
### `resolveComponentDataById(id)`
258+
259+
Force a component to [resolve its data](/docs/api-reference/configuration/component-config#resolvedatadata-params) by id.
260+
261+
```tsx
262+
resolveComponentDataById("Heading-1234");
263+
```
264+
256265
### `selectedItem`
257266

258267
The currently selected item, as defined by `appState.ui.itemSelector`.
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { act, waitFor } from "@testing-library/react";
2+
import { createAppStore, defaultAppState } from "../../../store";
3+
import { Config } from "../../../types";
4+
import { cache } from "../../resolve-component-data";
5+
import { resolveDataById } from "../resolve-data-by-id";
6+
import { walkAppState } from "../walk-app-state";
7+
8+
const appStore = createAppStore();
9+
10+
const childResolveData = jest.fn(async (data, params) => {
11+
if (params.trigger === "force") {
12+
return {
13+
...data,
14+
props: {
15+
resolvedProp: "Forced",
16+
},
17+
};
18+
}
19+
20+
return data;
21+
});
22+
23+
const config: Config = {
24+
components: {
25+
Parent: {
26+
fields: { items: { type: "slot" } },
27+
render: () => <div />,
28+
},
29+
Child: {
30+
fields: {},
31+
resolveData: childResolveData,
32+
render: () => <div />,
33+
},
34+
},
35+
};
36+
37+
function resetStores() {
38+
appStore.setState(
39+
{
40+
...appStore.getInitialState(),
41+
config,
42+
state: walkAppState(
43+
{
44+
...defaultAppState,
45+
data: {
46+
...defaultAppState.data,
47+
content: [
48+
{
49+
type: "Parent",
50+
props: {
51+
id: "Parent-1",
52+
items: [
53+
{
54+
type: "Child",
55+
props: {
56+
id: "Child-1",
57+
},
58+
},
59+
{
60+
type: "Child",
61+
props: {
62+
id: "Child-2",
63+
},
64+
},
65+
{
66+
type: "Child",
67+
props: {
68+
id: "Child-3",
69+
},
70+
},
71+
],
72+
},
73+
},
74+
{
75+
type: "Parent",
76+
props: {
77+
id: "Parent-2",
78+
items: [],
79+
},
80+
},
81+
],
82+
},
83+
},
84+
config
85+
),
86+
},
87+
true
88+
);
89+
}
90+
91+
describe("useResolveDataOnMoved", () => {
92+
beforeEach(async () => {
93+
resetStores();
94+
jest.clearAllMocks();
95+
cache.lastChange = {};
96+
});
97+
98+
it("resolves when called", async () => {
99+
// When: ---------------
100+
await act(() => resolveDataById("Child-1", appStore.getState));
101+
102+
// Then: ---------------
103+
expect(childResolveData).toHaveBeenCalledTimes(1);
104+
const mockedReturn = await childResolveData.mock.results[0].value;
105+
expect(mockedReturn.props.resolvedProp).toBe("Forced");
106+
});
107+
108+
it("resolves even if data hasn't changed", async () => {
109+
// TODO: Change this when we solve race conditions over caches, it should be 3
110+
const resolveAndCommitDataCalls = 6;
111+
112+
// When: ---------------
113+
await act(async () => {
114+
// This executes resolveData twice because of race conditions in cache
115+
appStore.getState().resolveAndCommitData();
116+
});
117+
118+
// TODO: Change this when we solve race conditions over caches, we shouldn't need to wait
119+
await waitFor(() => Object.keys(cache.lastChange).length > 1);
120+
121+
await act(() => resolveDataById("Child-1", appStore.getState));
122+
123+
// Then: ---------------
124+
const expectedCalls = resolveAndCommitDataCalls + 1;
125+
expect(childResolveData).toHaveBeenCalledTimes(expectedCalls);
126+
const mockedReturn = await childResolveData.mock.results[expectedCalls - 1]
127+
.value;
128+
expect(mockedReturn.props.resolvedProp).toBe("Forced");
129+
});
130+
131+
it("shows a warning if the id doesn't exist", async () => {
132+
// Given: --------------
133+
const consoleWarnMock = jest.spyOn(console, "warn").mockImplementation();
134+
135+
// When: ---------------
136+
await act(() => resolveDataById("Doesn't exist", appStore.getState));
137+
138+
// Then: ---------------
139+
expect(consoleWarnMock).toHaveBeenCalledTimes(1);
140+
consoleWarnMock.mockRestore();
141+
});
142+
143+
it("shows a warning and doesn't resolve if the component was deleted", async () => {
144+
// Given: --------------
145+
const dispatch = appStore.getState().dispatch;
146+
const consoleWarnMock = jest.spyOn(console, "warn").mockImplementation();
147+
148+
// When: ---------------
149+
await act(async () => {
150+
dispatch({ type: "remove", index: 0, zone: "Parent-1:items" });
151+
resolveDataById("Child-1", appStore.getState);
152+
});
153+
154+
// Then: ---------------
155+
expect(childResolveData).toHaveBeenCalledTimes(0);
156+
expect(consoleWarnMock).toHaveBeenCalledTimes(1);
157+
consoleWarnMock.mockRestore();
158+
});
159+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { useAppStoreApi } from "../../store";
2+
import { ResolveDataTrigger } from "../../types";
3+
import { PuckNodeData } from "../../types/Internal";
4+
import { getSelectorForId } from "../get-selector-for-id";
5+
import { toComponent } from "./to-component";
6+
7+
export async function resolveDataById(
8+
id: string,
9+
getState: ReturnType<typeof useAppStoreApi>["getState"],
10+
trigger: ResolveDataTrigger = "force"
11+
) {
12+
const node: PuckNodeData | undefined = getState().state.indexes.nodes[id];
13+
14+
const notFoundMsg = `Warning: Could not find component with id "${id}" to resolve its data. Component may have been removed or the id is invalid.`;
15+
if (!node) {
16+
console.warn(notFoundMsg);
17+
return;
18+
}
19+
20+
const resolvedResult = await getState().resolveComponentData(
21+
node.data,
22+
trigger
23+
);
24+
if (!resolvedResult.didChange) return;
25+
26+
const itemSelector = getSelectorForId(
27+
getState().state,
28+
resolvedResult.node.props.id
29+
);
30+
if (!itemSelector) {
31+
console.warn(notFoundMsg);
32+
return;
33+
}
34+
35+
getState().dispatch({
36+
type: "replace",
37+
data: toComponent(resolvedResult.node),
38+
destinationIndex: itemSelector.index,
39+
destinationZone: itemSelector.zone,
40+
});
41+
}

packages/core/lib/use-puck.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Config, UserGenerics, AppState, ComponentData } from "../types";
1+
import { Config, UserGenerics, ResolveDataTrigger } from "../types";
22
import { createContext, useContext, useEffect, useState } from "react";
33
import { AppStore, useAppStoreApi } from "../store";
44
import {
@@ -9,6 +9,7 @@ import { HistorySlice } from "../store/slices/history";
99
import { createStore, StoreApi, useStore } from "zustand";
1010
import { makeStatePublic } from "./data/make-state-public";
1111
import { getItem, ItemSelector } from "./data/get-item";
12+
import { resolveDataById } from "./data/resolve-data-by-id";
1213
import { getSelectorForId } from "./get-selector-for-id";
1314
import { PuckNodeData } from "../types/Internal";
1415

@@ -21,6 +22,7 @@ export type UsePuckData<
2122
dispatch: AppStore["dispatch"];
2223
getPermissions: GetPermissions<UserConfig>;
2324
refreshPermissions: RefreshPermissions<UserConfig>;
25+
resolveDataById: (id: string, trigger?: ResolveDataTrigger) => void;
2426
selectedItem: G["UserComponentData"] | null;
2527
getItemBySelector: (
2628
selector: ItemSelector
@@ -47,10 +49,19 @@ type UsePuckStore<UserConfig extends Config = Config> = PuckApi<UserConfig>;
4749

4850
type PickedStore = Pick<
4951
AppStore,
50-
"config" | "dispatch" | "selectedItem" | "permissions" | "history" | "state"
52+
| "config"
53+
| "dispatch"
54+
| "selectedItem"
55+
| "permissions"
56+
| "history"
57+
| "state"
58+
| "resolveComponentData"
5159
>;
5260

53-
export const generateUsePuck = (store: PickedStore): UsePuckStore => {
61+
export const generateUsePuck = (
62+
store: PickedStore,
63+
getState: ReturnType<typeof useAppStoreApi>["getState"]
64+
): UsePuckStore => {
5465
const history: UsePuckStore["history"] = {
5566
back: store.history.back,
5667
forward: store.history.forward,
@@ -68,6 +79,7 @@ export const generateUsePuck = (store: PickedStore): UsePuckStore => {
6879
dispatch: store.dispatch,
6980
getPermissions: store.permissions.getPermissions,
7081
refreshPermissions: store.permissions.refreshPermissions,
82+
resolveDataById: (id, trigger) => resolveDataById(id, getState, trigger),
7183
history,
7284
selectedItem: store.selectedItem || null,
7385
getItemBySelector: (selector) => getItem(selector, store.state),
@@ -100,6 +112,7 @@ const convertToPickedStore = (store: AppStore): PickedStore => {
100112
config: store.config,
101113
dispatch: store.dispatch,
102114
permissions: store.permissions,
115+
resolveComponentData: store.resolveComponentData,
103116
history: store.history,
104117
selectedItem: store.selectedItem,
105118
};
@@ -113,7 +126,10 @@ export const useRegisterUsePuckStore = (
113126
) => {
114127
const [usePuckStore] = useState(() =>
115128
createStore(() =>
116-
generateUsePuck(convertToPickedStore(appStore.getState()))
129+
generateUsePuck(
130+
convertToPickedStore(appStore.getState()),
131+
appStore.getState
132+
)
117133
)
118134
);
119135

@@ -122,7 +138,7 @@ export const useRegisterUsePuckStore = (
122138
return appStore.subscribe(
123139
(store) => convertToPickedStore(store),
124140
(pickedStore) => {
125-
usePuckStore.setState(generateUsePuck(pickedStore));
141+
usePuckStore.setState(generateUsePuck(pickedStore, appStore.getState));
126142
}
127143
);
128144
}, []);

0 commit comments

Comments
 (0)