Skip to content

Commit cf3fc26

Browse files
authored
feat(sdk-react): Add useEditableDotCMSPage custom hook to handle changes from UVE (dotCMS#31955)
This pull request introduces a new custom hook, `useEditableDotCMSPage`, for managing the editable state of DotCMS pages in React applications. It also includes significant updates to the Universal Visual Editor (UVE) to support GraphQL and REST API configurations, along with enhancements to the types and interfaces used across the SDK. Below is a summary of the most important changes grouped by theme. ### New Feature: React Hook for Editable Pages * Added `useEditableDotCMSPage` hook in `core-web/libs/sdk/react/src/lib/next/hooks/useEditableDotCMSPage.ts`, which initializes the UVE, subscribes to content changes, and updates the editable page state dynamically. This ensures React components reflect the latest content during editing. * Exported the new hook in `core-web/libs/sdk/react/src/next.ts` for external usage. ### UVE Enhancements * Updated `initUVE` in `core-web/libs/sdk/uve/src/lib/editor/public.ts` to accept a `DotCMSUVEConfig` parameter, enabling initialization with GraphQL or REST API configurations. * Enhanced `setClientIsReady` in `core-web/libs/sdk/uve/src/script/utils.ts` to handle GraphQL and REST API configurations when notifying the editor that the client is ready. ### Type and Interface Additions * Introduced `DotCMSGraphQLPageResponse`, `DotCMSGraphQLError`, and `DotCMSUVEConfig` interfaces in `core-web/libs/sdk/uve/src/lib/types/editor/public.ts` to support GraphQL responses and UVE configuration. * Added `DotCMSBasicGraphQLPage` interface in `core-web/libs/sdk/uve/src/lib/types/page/public.ts` to represent the structure of pages retrieved via GraphQL. ### Integration Updates * Imported `DotCMSUVEConfig` in various files to integrate the new configuration type across the SDK. [[1]](diffhunk://#diff-dafb1a0d66a8cdf6b6bef1edaace11856185883ccc9040b1185ce0a143bc11eeL9-R9) [[2]](diffhunk://#diff-0aeba835f7122ce16900ea7fbfc3c7da0cc82ea6e00d138110e88427cd9f4ef3L6-R6) ### Refactored UVE Script * Refactored the `dot-uve.js` script to include support for initializing the UVE with GraphQL or REST API configurations and to streamline event handling.
1 parent 83e72f0 commit cf3fc26

File tree

13 files changed

+414
-20
lines changed

13 files changed

+414
-20
lines changed

core-web/libs/sdk/angular/src/lib/deprecated/layout/dotcms-layout/dotcms-layout.component.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export class DotcmsLayoutComponent implements OnInit {
142142
return;
143143
}
144144

145-
this.pageContextService.setPageAsset(data as DotCMSPageAsset);
145+
this.pageContextService.setPageAsset(data as unknown as DotCMSPageAsset);
146146
});
147147

148148
postMessageToEditor({ action: CLIENT_ACTIONS.CLIENT_READY, payload: this.editor });

core-web/libs/sdk/client/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,21 +95,21 @@ The @dotcms/client package is compatible with the following browsers:
9595
### ES Modules
9696

9797
```javascript
98-
import { dotCMSCreateClient } from '@dotcms/client';
98+
import { createDotCMSClient } from '@dotcms/client/next';
9999
```
100100
101101
### CommonJS
102102
103103
```javascript
104-
const { dotCMSCreateClient } = require('@dotcms/client');
104+
const { createDotCMSClient } = require('@dotcms/client/next');
105105
```
106106
107107
### Initialization
108108
109109
First, initialize the client with your dotCMS instance details.
110110
111111
```javascript
112-
const client = dotCMSCreateClient({
112+
const client = createDotCMSClient({
113113
dotcmsUrl: 'https://your-dotcms-instance.com',
114114
authToken: 'your-auth-token',
115115
siteId: 'your-site-id'

core-web/libs/sdk/react/README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
`@dotcms/react` is the official set of React components and hooks designed to work seamlessly with dotCMS, making it easy to render dotCMS pages and use the page builder.
44

55
> **Note:** This SDK is currently in **beta** (v0.0.1-beta.13 or newest).
6-
>
6+
>
77
> For comprehensive documentation, visit our [developer portal](https://dev.dotcms.com/docs/javascript-sdk-react-library).
88
99
> **⚠️ IMPORTANT:** Versions published under the `next` tag (`npm install @dotcms/react@next`) are experimental, in beta, and not code complete. For the current stable and functional version, please use `latest` (`npm install @dotcms/react@latest`). Once we release the stable version, we will provide a migration guide from the alpha to stable version. The current alpha version (under `latest`) will continue to work, allowing you to migrate progressively at your own pace.
@@ -108,7 +108,7 @@ The `DotCMSLayoutBody` component renders the layout body for a DotCMS page.
108108
#### Usage
109109

110110
```javascript
111-
import { DotCMSLayoutBody } from '@dotcms/react';
111+
import { DotCMSLayoutBody } from '@dotcms/react/next';
112112

113113
const MyPage = ({ page }) => {
114114
return <DotCMSLayoutBody page={page} components={components} />;
@@ -129,7 +129,7 @@ The `DotCMSShow` component conditionally renders content based on dotCMS conditi
129129
#### Usage
130130

131131
```javascript
132-
import { DotCMSShow } from '@dotcms/react';
132+
import { DotCMSShow } from '@dotcms/react/next';
133133
import { UVE_MODE } from '@dotcms/uve';
134134

135135
const MyComponent = () => {
@@ -171,12 +171,12 @@ A custom hook that provides the same functionality as the `DotCMSShow` component
171171
#### Usage
172172

173173
```javascript
174-
import { useDotCMSShowWhen } from '@dotcms/react';
174+
import { useDotCMSShowWhen } from '@dotcms/react/next';
175175
import { UVE_MODE } from '@dotcms/uve';
176176

177177
const MyComponent = () => {
178178
const isVisible = useDotCMSShowWhen(UVE_MODE.EDIT);
179-
179+
180180
return isVisible ? <div>Visible content</div> : null;
181181
};
182182
```
@@ -211,7 +211,7 @@ export const usePageAsset = (currentPageAsset) => {
211211
}
212212

213213
// Note: If using plain JavaScript instead of TypeScript, you can use the string literals directly
214-
sendMessageToEditor({ action: DotCMSUVEAction.CLIENT_READY || "client-ready" });
214+
sendMessageToEditor({ action: DotCMSUVEAction.CLIENT_READY || "client-ready" });
215215
const subscription = createUVESubscription(UVEEventType.CONTENT_CHANGES || "changes", (pageAsset) => setPageAsset(pageAsset));
216216

217217
return () => {
@@ -231,7 +231,7 @@ import { usePageAsset } from './hooks/usePageAsset';
231231

232232
const MyPage = ({ initialPageAsset }) => {
233233
const pageAsset = usePageAsset(initialPageAsset);
234-
234+
235235
return <DotCMSLayoutBody page={pageAsset} components={components} />;
236236
};
237237
```

core-web/libs/sdk/react/src/lib/deprecated/hooks/useDotcmsEditor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export const useDotcmsEditor = ({ pageContext, config }: DotcmsPageProps) => {
8383
}
8484

8585
const { unsubscribe } = createUVESubscription(UVEEventType.CONTENT_CHANGES, (data) => {
86-
const pageAsset = data as DotCMSPageContext['pageAsset'];
86+
const pageAsset = data as unknown as DotCMSPageContext['pageAsset'];
8787
setState((state) => ({ ...state, pageAsset }));
8888
});
8989

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { renderHook, act } from '@testing-library/react-hooks';
2+
3+
import { updateNavigation } from '@dotcms/client';
4+
import { getUVEState, initUVE, createUVESubscription } from '@dotcms/uve';
5+
import { DotCMSEditablePage, UVEEventType } from '@dotcms/uve/types';
6+
7+
import { useEditableDotCMSPage } from '../../hooks/useEditableDotCMSPage';
8+
9+
jest.mock('@dotcms/client', () => ({
10+
updateNavigation: jest.fn()
11+
}));
12+
13+
jest.mock('@dotcms/uve', () => ({
14+
getUVEState: jest.fn(),
15+
initUVE: jest.fn(),
16+
createUVESubscription: jest.fn()
17+
}));
18+
19+
describe('useEditableDotCMSPage', () => {
20+
const getUVEStateMock = getUVEState as jest.Mock;
21+
const initUVEMock = initUVE as jest.Mock;
22+
const createUVESubscriptionMock = createUVESubscription as jest.Mock;
23+
const updateNavigationMock = updateNavigation as jest.Mock;
24+
25+
const mockUnsubscribe = jest.fn();
26+
const mockDestroyUVESubscriptions = jest.fn();
27+
28+
// Use unknown as intermediate type to avoid type checking issues
29+
const mockEditablePage = {
30+
page: {
31+
pageURI: '/test-page',
32+
title: 'Test Page',
33+
metadata: {},
34+
template: 'test-template',
35+
modDate: '2023-01-01',
36+
cachettl: 0
37+
},
38+
content: {
39+
testContent: [{ title: 'Test Item' }]
40+
},
41+
graphql: {} // Required for DotCMSGraphQLPageResponse
42+
} as unknown as DotCMSEditablePage;
43+
44+
beforeEach(() => {
45+
jest.clearAllMocks();
46+
initUVEMock.mockReturnValue({ destroyUVESubscriptions: mockDestroyUVESubscriptions });
47+
createUVESubscriptionMock.mockReturnValue({ unsubscribe: mockUnsubscribe });
48+
});
49+
50+
test('should initialize with the provided editable page', () => {
51+
getUVEStateMock.mockReturnValue({ mode: 'EDIT' });
52+
53+
const { result } = renderHook(() => useEditableDotCMSPage(mockEditablePage));
54+
55+
expect(result.current).toEqual(mockEditablePage);
56+
});
57+
58+
test('should initialize UVE and update navigation when UVE state exists', () => {
59+
getUVEStateMock.mockReturnValue({ mode: 'EDIT' });
60+
61+
renderHook(() => useEditableDotCMSPage(mockEditablePage));
62+
63+
expect(initUVEMock).toHaveBeenCalledWith(mockEditablePage);
64+
expect(updateNavigationMock).toHaveBeenCalledWith('/test-page');
65+
});
66+
67+
test('should not initialize UVE when UVE state does not exist', () => {
68+
getUVEStateMock.mockReturnValue(undefined);
69+
70+
renderHook(() => useEditableDotCMSPage(mockEditablePage));
71+
72+
expect(initUVEMock).not.toHaveBeenCalled();
73+
expect(updateNavigationMock).not.toHaveBeenCalled();
74+
});
75+
76+
test('should cleanup subscriptions on unmount', () => {
77+
getUVEStateMock.mockReturnValue({ mode: 'EDIT' });
78+
79+
const { unmount } = renderHook(() => useEditableDotCMSPage(mockEditablePage));
80+
81+
unmount();
82+
83+
expect(mockDestroyUVESubscriptions).toHaveBeenCalled();
84+
expect(mockUnsubscribe).toHaveBeenCalled();
85+
});
86+
87+
test('should update editable page when content changes are received', () => {
88+
getUVEStateMock.mockReturnValue({ mode: 'EDIT' });
89+
90+
let contentChangesCallback: (payload: DotCMSEditablePage) => void;
91+
92+
createUVESubscriptionMock.mockImplementation((eventType, callback) => {
93+
if (eventType === UVEEventType.CONTENT_CHANGES) {
94+
contentChangesCallback = callback;
95+
}
96+
97+
return { unsubscribe: mockUnsubscribe };
98+
});
99+
100+
const { result } = renderHook(() => useEditableDotCMSPage(mockEditablePage));
101+
102+
const updatedPage = {
103+
page: {
104+
pageURI: '/test-page',
105+
title: 'Updated Title',
106+
metadata: {},
107+
template: 'test-template',
108+
modDate: '2023-01-01',
109+
cachettl: 0
110+
},
111+
content: {
112+
testContent: [{ title: 'Updated Item' }]
113+
},
114+
graphql: {} // Required for DotCMSGraphQLPageResponse
115+
} as unknown as DotCMSEditablePage;
116+
117+
act(() => {
118+
contentChangesCallback(updatedPage);
119+
});
120+
121+
expect(result.current).toEqual(updatedPage);
122+
});
123+
});
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { useState, useEffect } from 'react';
2+
3+
import { updateNavigation } from '@dotcms/client';
4+
import { getUVEState, initUVE, createUVESubscription } from '@dotcms/uve';
5+
import { DotCMSEditablePage, UVEEventType } from '@dotcms/uve/types';
6+
7+
/**
8+
* Custom hook to manage the editable state of a DotCMS page.
9+
*
10+
* This hook initializes the Universal Visual Editor (UVE) and subscribes to content changes.
11+
* It updates the editable page state when content changes are detected in the UVE,
12+
* ensuring your React components always display the latest content when editing in DotCMS.
13+
*
14+
* @example
15+
* ```ts
16+
* // Import the hook and the client
17+
* import { useEditableDotCMSPage } from '@dotcms/react';
18+
* import { createDotCMSClient } from '@dotcms/client';
19+
*
20+
* // Create the client
21+
* const client = createDotCMSClient({
22+
* dotcmsURL: 'https://your-dotcms-instance.com',
23+
* authToken: 'your-auth-token'
24+
* });
25+
*
26+
* // Get the page
27+
* const page = await client.page.get('/', {
28+
* languageId: '1',
29+
* });
30+
*
31+
* // Use the hook to get an editable version of the page
32+
* const editablePage = useEditableDotCMSPage(page);
33+
*
34+
* // Then use the page data in your component
35+
* return (
36+
* <div>
37+
* <h1>{editablePage.page.title}</h1>
38+
* <div dangerouslySetInnerHTML={{ __html: editablePage.page.body }} />
39+
* </div>
40+
* );
41+
* ```
42+
*
43+
* @example
44+
* ```ts
45+
* // Import the hook and the client
46+
* import { useEditableDotCMSPage } from '@dotcms/react';
47+
* import { createDotCMSClient } from '@dotcms/client';
48+
*
49+
* // Create the client
50+
* const client = createDotCMSClient({
51+
* dotcmsURL: 'https://your-dotcms-instance.com',
52+
* authToken: 'your-auth-token'
53+
* });
54+
*
55+
* // Get the page with GraphQL content
56+
* const page = await client.page.get('/', {
57+
* languageId: '1',
58+
* graphql: {
59+
* content: {
60+
* products: `ProductCollection(query: "+title:snow", limit: 10, offset: 0, sortBy: "score") {
61+
* title
62+
* urlMap
63+
* category {
64+
* name
65+
* inode
66+
* }
67+
* retailPrice
68+
* image {
69+
* versionPath
70+
* }
71+
* }`
72+
* }
73+
* }
74+
* });
75+
*
76+
* // Use the hook to get an editable version of the page and its content
77+
* const editablePage = useEditableDotCMSPage(page);
78+
*
79+
* // Access both page data and GraphQL content
80+
* const { page: pageData, content } = editablePage;
81+
*
82+
* // Use the products from GraphQL content
83+
* return (
84+
* <div>
85+
* <h1>{pageData.title}</h1>
86+
* <ProductList products={content.products} />
87+
* </div>
88+
* );
89+
* ```
90+
* @param {DotCMSEditablePage} editablePage - The initial editable page data from client.page.get().
91+
*
92+
* @returns {DotCMSEditablePage} The updated editable page state that reflects any changes made in the UVE.
93+
* The structure includes page data and any GraphQL content that was requested.
94+
*/
95+
export const useEditableDotCMSPage = (editablePage: DotCMSEditablePage): DotCMSEditablePage => {
96+
const [updatedEditablePage, setUpdatedEditablePage] =
97+
useState<DotCMSEditablePage>(editablePage);
98+
99+
useEffect(() => {
100+
if (!getUVEState()) {
101+
return;
102+
}
103+
104+
const pageURI = editablePage?.page?.pageURI ?? '/';
105+
106+
const { destroyUVESubscriptions } = initUVE(editablePage);
107+
108+
// Update the navigation to the pageURI
109+
updateNavigation(pageURI);
110+
111+
return () => {
112+
destroyUVESubscriptions();
113+
};
114+
}, [editablePage]);
115+
116+
useEffect(() => {
117+
const { unsubscribe } = createUVESubscription(UVEEventType.CONTENT_CHANGES, (payload) => {
118+
setUpdatedEditablePage(payload);
119+
});
120+
121+
return () => {
122+
unsubscribe();
123+
};
124+
}, []);
125+
126+
return updatedEditablePage;
127+
};

core-web/libs/sdk/react/src/next.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ export { DotCMSLayoutBody } from './lib/next/components/DotCMSLayoutBody/DotCMSL
33
export { DotCMSShow } from './lib/next/components/DotCMSShow/DotCMSShow';
44

55
export { useDotCMSShowWhen } from './lib/next/hooks/useDotCMSShowWhen';
6+
7+
export { useEditableDotCMSPage } from './lib/next/hooks/useEditableDotCMSPage';

core-web/libs/sdk/uve/src/lib/editor/public.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,26 @@ describe('UVE Public Functions', () => {
116116
expect(utils.registerUVEEvents).toHaveBeenCalled();
117117
});
118118

119+
it('should call setClientIsReady with empty config when no config is provided', () => {
120+
const setClientIsReadySpy = jest.spyOn(utils, 'setClientIsReady');
121+
initUVE();
122+
expect(setClientIsReadySpy).toHaveBeenCalledWith({});
123+
});
124+
125+
it('should call setClientIsReady with graphql config when provided', () => {
126+
const setClientIsReadySpy = jest.spyOn(utils, 'setClientIsReady');
127+
const config = { graphql: { query: '{ test }', variables: {} } };
128+
initUVE(config);
129+
expect(setClientIsReadySpy).toHaveBeenCalledWith(config);
130+
});
131+
132+
it('should call setClientIsReady with params config when provided', () => {
133+
const setClientIsReadySpy = jest.spyOn(utils, 'setClientIsReady');
134+
const config = { params: { depth: '1' } };
135+
initUVE(config);
136+
expect(setClientIsReadySpy).toHaveBeenCalledWith(config);
137+
});
138+
119139
it('should return destroy function that unsubscribes all subscriptions', () => {
120140
// Create spy functions for unsubscribe
121141
const unsubscribeSpy1 = jest.fn();

0 commit comments

Comments
 (0)