Skip to content

Commit

Permalink
feat(SDK): Enable Inline editing in text fields for React SDK (#29620)
Browse files Browse the repository at this point in the history
### Proposed changes
- Develop an inline editing component for our React SDK

### Video



https://github.com/user-attachments/assets/c045e382-e4ab-474f-bf52-5b4dd6c73553
  • Loading branch information
rjvelazco authored Aug 28, 2024
1 parent 8a0ce03 commit f325a94
Show file tree
Hide file tree
Showing 18 changed files with 574 additions and 22 deletions.
9 changes: 6 additions & 3 deletions core-web/libs/sdk/angular/package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
{
"name": "@dotcms/angular",
"version": "0.0.1-alpha.32",
"version": "0.0.1-alpha.33",
"dependencies": {
"@tinymce/tinymce-angular": "^8.0.0"
},
"peerDependencies": {
"@angular/common": "^17.1.0",
"@angular/core": "^17.1.0",
"@angular/router": "^17.1.0",
"@dotcms/client": "0.0.1-alpha.32",
"@tinymce/tinymce-angular": "^8.0.0",
"@dotcms/client": "0.0.1-alpha.33",
"rxjs": "^7.8.0"
},
"allowedNonPeerDependencies": ["@tinymce/tinymce-angular"],
"description": "Official Angular Components library to render a dotCMS page.",
"repository": {
"type": "git",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
postMessageToEditor
} from '@dotcms/client';

import { TINYMCE_CONFIG, TINYMCE_FORMAT, TINYMCE_MODE } from './utils';
import { TINYMCE_CONFIG, DOT_EDITABLE_TEXT_FORMAT, DOT_EDITABLE_TEXT_MODE } from './utils';

import { DotCMSContentlet } from '../../models';

Expand Down Expand Up @@ -56,17 +56,17 @@ export class DotEditableTextComponent implements OnInit, OnChanges {
/**
* Represents the mode of the editor which can be `plain`, `minimal`, or `full`
*
* @type {TINYMCE_MODE}
* @type {DOT_EDITABLE_TEXT_MODE}
* @memberof DotEditableTextComponent
*/
@Input() mode: TINYMCE_MODE = 'plain';
@Input() mode: DOT_EDITABLE_TEXT_MODE = 'plain';
/**
* Represents the format of the editor which can be `text` or `html`
*
* @type {TINYMCE_FORMAT}
* @type {DOT_EDITABLE_TEXT_FORMAT}
* @memberof DotEditableTextComponent
*/
@Input() format: TINYMCE_FORMAT = 'text';
@Input() format: DOT_EDITABLE_TEXT_FORMAT = 'text';
/**
* Represents the `contentlet` that can be inline edited
*
Expand Down Expand Up @@ -126,7 +126,7 @@ export class DotEditableTextComponent implements OnInit, OnChanges {
* @memberof DotEditableTextComponent
*/
get onNumberOfPages() {
return this.contentlet['onNumberOfPages'];
return this.contentlet['onNumberOfPages'] || 1;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { EditorComponent } from '@tinymce/tinymce-angular';

export type TINYMCE_MODE = 'minimal' | 'full' | 'plain';
export type DOT_EDITABLE_TEXT_MODE = 'minimal' | 'full' | 'plain';

export type TINYMCE_FORMAT = 'html' | 'text';
export type DOT_EDITABLE_TEXT_FORMAT = 'html' | 'text';

const DEFAULT_TINYMCE_CONFIG: EditorComponent['init'] = {
menubar: false,
Expand All @@ -17,7 +17,7 @@ const DEFAULT_TINYMCE_CONFIG: EditorComponent['init'] = {
};

export const TINYMCE_CONFIG: {
[key in TINYMCE_MODE]: EditorComponent['init'];
[key in DOT_EDITABLE_TEXT_MODE]: EditorComponent['init'];
} = {
minimal: {
...DEFAULT_TINYMCE_CONFIG,
Expand Down
2 changes: 1 addition & 1 deletion core-web/libs/sdk/client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@dotcms/client",
"version": "0.0.1-alpha.32",
"version": "0.0.1-alpha.33",
"description": "Official JavaScript library for interacting with DotCMS REST APIs.",
"repository": {
"type": "git",
Expand Down
4 changes: 2 additions & 2 deletions core-web/libs/sdk/experiments/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@dotcms/experiments",
"version": "0.0.1-alpha.32",
"version": "0.0.1-alpha.33",
"description": "Official JavaScript library to use Experiments with DotCMS.",
"repository": {
"type": "git",
Expand All @@ -25,6 +25,6 @@
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18",
"@dotcms/client": "0.0.1-alpha.32"
"@dotcms/client": "0.0.1-alpha.33"
}
}
7 changes: 5 additions & 2 deletions core-web/libs/sdk/react/package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
{
"name": "@dotcms/react",
"version": "0.0.1-alpha.32",
"version": "0.0.1-alpha.33",
"dependencies": {
"@tinymce/tinymce-react": "^5.1.1"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18",
"@dotcms/client": "0.0.1-alpha.32"
"@dotcms/client": "0.0.1-alpha.33"
},
"description": "Official React Components library to render a dotCMS page.",
"repository": {
Expand Down
1 change: 1 addition & 0 deletions core-web/libs/sdk/react/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './lib/components/DotcmsLayout/DotcmsLayout';
export * from './lib/components/DotEditableText/DotEditableText';
export * from './lib/components/PageProvider/PageProvider';
export * from './lib/components/Row/Row';
export * from './lib/hooks/useDotcmsPageContext';
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import { expect } from '@jest/globals';
import { fireEvent, render, screen } from '@testing-library/react';
import * as tinymceReact from '@tinymce/tinymce-react';

import * as dotcmsClient from '@dotcms/client';

import { DotEditableText } from './DotEditableText';

import { dotcmsContentletMock } from '../../mocks/mockPageContext';

const { CUSTOMER_ACTIONS, postMessageToEditor } = dotcmsClient;

// Define mockEditor before using it in jest.mock
const TINYMCE_EDITOR_MOCK = {
focus: () => {
/* */
},
getContent: (_data: string) => '',
isDirty: () => false,
hasFocus: () => false,
setContent: () => {
/* */
}
};

jest.mock('@tinymce/tinymce-react', () => ({
Editor: jest.fn(({ onInit, onMouseDown, onFocusOut }) => {
onInit({}, TINYMCE_EDITOR_MOCK);

return <div data-testid="tinymce-editor" onMouseDown={onMouseDown} onBlur={onFocusOut} />;
})
}));

// Mock @dotcms/client module
jest.mock('@dotcms/client', () => ({
...jest.requireActual('@dotcms/client'),
isInsideEditor: jest.fn().mockImplementation(() => true),
postMessageToEditor: jest.fn(),
DotCmsClient: {
dotcmsUrl: 'http://localhost:8080'
}
}));

const mockedDotcmsClient = dotcmsClient as jest.Mocked<typeof dotcmsClient>;
const { Editor } = tinymceReact as jest.Mocked<typeof tinymceReact>;

describe('DotEditableText', () => {
describe('Outside editor', () => {
beforeEach(() => {
mockedDotcmsClient.isInsideEditor.mockReturnValue(false);
render(<DotEditableText contentlet={dotcmsContentletMock} fieldName="title" />);
});

it('should render the content', () => {
const editor = screen.queryByTestId('tinymce-editor');
expect(editor).toBeNull();
expect(screen.getByText(dotcmsContentletMock['title'])).not.toBeNull();
});
});

describe('Inside editor', () => {
let rerenderFn: (ui: React.ReactNode) => void;

beforeEach(() => {
mockedDotcmsClient.isInsideEditor.mockReturnValue(true);
const { rerender } = render(
<DotEditableText contentlet={dotcmsContentletMock} fieldName="title" />
);
rerenderFn = rerender;
});

it('should pass the correct props to the Editor component', () => {
const editor = screen.getByTestId('tinymce-editor');
expect(editor).not.toBeNull();

expect(Editor).toHaveBeenCalledWith(
{
tinymceScriptSrc: 'http://localhost:8080/ext/tinymcev7/tinymce.min.js',
inline: true,
init: {
inline: true,
menubar: false,
plugins: '',
powerpaste_html_import: 'clean',
powerpaste_word_import: 'clean',
suffix: '.min',
toolbar: '',
valid_styles: {
'*': 'font-size,font-family,color,text-decoration,text-align'
}
},
initialValue: dotcmsContentletMock.title,
onMouseDown: expect.any(Function),
onFocusOut: expect.any(Function),
onInit: expect.any(Function)
},
{}
);
});

describe('DotEditableText events', () => {
let focusSpy: jest.SpyInstance;

describe('Window Message', () => {
beforeEach(() => {
focusSpy = jest.spyOn(TINYMCE_EDITOR_MOCK, 'focus');
});

it("should focus on the editor when the message is 'COPY_CONTENTLET_INLINE_EDITING_SUCCESS'", () => {
window.dispatchEvent(
new MessageEvent('message', {
data: {
name: 'COPY_CONTENTLET_INLINE_EDITING_SUCCESS',
payload: {
oldInode: dotcmsContentletMock['inode'],
inode: '456'
}
}
})
);
expect(focusSpy).toHaveBeenCalled();
});

it("should not focus on the editor when the message is not 'COPY_CONTENTLET_INLINE_EDITING_SUCCESS'", () => {
window.dispatchEvent(
new MessageEvent('message', {
data: { name: 'ANOTHER_EVENT' }
})
);
expect(focusSpy).not.toHaveBeenCalled();
});
});

describe('mousedown', () => {
const event = new MouseEvent('mousedown', {
bubbles: true
});
const mutiplePagesContentlet = {
...dotcmsContentletMock,
onNumberOfPages: 2
};

it('should postMessage the UVE if the content is in multiple pages', () => {
rerenderFn(
<DotEditableText contentlet={mutiplePagesContentlet} fieldName="title" />
);
const editorElem = screen.getByTestId('tinymce-editor');
fireEvent(editorElem, event);

const payload = {
dataset: {
fieldName: 'title',
inode: mutiplePagesContentlet.inode,
language: mutiplePagesContentlet.languageId
}
};
expect(postMessageToEditor).toHaveBeenCalledWith({
action: CUSTOMER_ACTIONS.COPY_CONTENTLET_INLINE_EDITING,
payload
});
});

it('should not postMessage the UVE if the content is in a single page', () => {
const editorElem = screen.getByTestId('tinymce-editor');
fireEvent(editorElem, event);
expect(postMessageToEditor).not.toHaveBeenCalled();
});
});

describe('onFocusOut', () => {
let isDirtySpy: jest.SpyInstance;
let getContentSpy: jest.SpyInstance;

const event = new FocusEvent('focusout', {
bubbles: true
});

beforeEach(() => {
isDirtySpy = jest.spyOn(TINYMCE_EDITOR_MOCK, 'isDirty');
getContentSpy = jest.spyOn(TINYMCE_EDITOR_MOCK, 'getContent');
});

it('should not postMessage the UVE if the editor is not dirty', () => {
mockedDotcmsClient.isInsideEditor.mockReturnValue(false);
const editorElem = screen.getByTestId('tinymce-editor');
fireEvent(editorElem, event);
expect(isDirtySpy).toHaveBeenCalled();
expect(getContentSpy).toHaveBeenCalledWith({ format: 'text' });
expect(postMessageToEditor).not.toHaveBeenCalled();
});

it('should not postMessage the UVE if the content did not change', () => {
isDirtySpy.mockReturnValue(true);
getContentSpy.mockReturnValue(dotcmsContentletMock.title);

const editorElem = screen.getByTestId('tinymce-editor');
fireEvent(editorElem, event);

expect(isDirtySpy).toHaveBeenCalled();
expect(getContentSpy).toHaveBeenCalledWith({ format: 'text' });
expect(postMessageToEditor).not.toHaveBeenCalled();
});

it('should postMessage the UVE if the content changed', () => {
isDirtySpy.mockReturnValue(true);
getContentSpy.mockReturnValue('New content');

const editorElem = screen.getByTestId('tinymce-editor');
fireEvent(editorElem, event);

const postMessageData = {
action: CUSTOMER_ACTIONS.UPDATE_CONTENTLET_INLINE_EDITING,
payload: {
content: 'New content',
dataset: {
inode: dotcmsContentletMock.inode,
langId: dotcmsContentletMock.languageId,
fieldName: 'title'
}
}
};

expect(isDirtySpy).toHaveBeenCalled();
expect(getContentSpy).toHaveBeenCalledWith({ format: 'text' });
expect(postMessageToEditor).toHaveBeenCalledWith(postMessageData);
});
});
});
});

afterEach(() => jest.clearAllMocks()); // Clear all mocks to avoid side effects from other tests
});
Loading

0 comments on commit f325a94

Please sign in to comment.