Skip to content

feat(editor): support editing of emails with very long body text #890

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 24 commits into from
May 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
489cee9
feat: uncontrolled mode implementation
nubsthead Apr 24, 2025
3f73929
refactor(wip): try direct usage of TinyMCE
gnekoz Apr 24, 2025
ff58d9f
feat(wip): remove composer from shell
nubsthead Apr 28, 2025
94e4a67
feat: debounce save on key down
nubsthead Apr 30, 2025
a39643a
refactor: change tinymce listened event
gnekoz May 5, 2025
90afdf3
feat: update tinymce from external components
gnekoz May 5, 2025
aebf28b
fix: keep focus on other editor components after save
gnekoz May 5, 2025
b65762a
refactor(wip): handle tinymce remove event
gnekoz May 6, 2025
4ddb277
feat: save draft when the composer is closed
nubsthead May 6, 2025
8f14596
refactor: memoize setTextProvider
nubsthead May 6, 2025
36eb57d
test: refactor text-editor-container tests
nubsthead May 6, 2025
3e5dc07
refactor: textarea in uncontrolled mode
nubsthead May 6, 2025
eaa654b
fix: debounce on composer change
nubsthead May 7, 2025
38c2e93
fix: remove reactive value from useEditorText
nubsthead May 7, 2025
c341ace
refactor: split text editor components
nubsthead May 7, 2025
4fea20a
test: add tests for useEditorTextProvider
gnekoz May 8, 2025
2966d92
test: complete useEditorTextProvider tests
gnekoz May 8, 2025
fded5dd
test: move and extend useEditorText tests
gnekoz May 8, 2025
28f6ab8
refactor: avoid useless text save
gnekoz May 8, 2025
1651232
refactor: split text editor components
nubsthead May 9, 2025
64396f9
test: update tests
nubsthead May 9, 2025
50e027e
refactor: clean up editor text component props
nubsthead May 9, 2025
7f7317b
refactor: clean up editor test component props
nubsthead May 9, 2025
d3eeab4
fix: explicit draft save will retrieve the current unsaved text
nubsthead May 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/store/editor/editor-transformations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@
export const getMP = (editor: MailsEditorV2): SoapEmailMessagePartObj[] => {
const { prefs } = getUserSettings();

// The stored text could be out of sync with the current text of the composer.
// TODO: This logic should be encapsulated in the editor or the store

Check notice on line 166 in src/store/editor/editor-transformations.ts

View check run for this annotation

Sonarqube Zextras / SonarQube Code Analysis

src/store/editor/editor-transformations.ts#L166

Complete the task associated to this "TODO" comment.
const text = editor.textProvider?.getCurrentText() ?? editor.text;

const style = {
font: prefs?.zimbraPrefHtmlEditorDefaultFontFamily as string,
fontSize: prefs?.zimbraPrefHtmlEditorDefaultFontSize as string,
Expand All @@ -172,8 +176,8 @@
const savedInlineAttachment = filterSavedInlineAttachment(editor.savedAttachments);

const contentWithCidUrl = {
plainText: editor.text.plainText,
richText: replaceServiceUrlWithCidUrl(editor.text.richText)
plainText: text?.plainText,
richText: replaceServiceUrlWithCidUrl(text?.richText)
};

if (editor.isRichText) {
Expand Down Expand Up @@ -241,7 +245,9 @@
{
ct: 'text/plain',
body: true,
content: { _content: editor.text.plainText ?? '' }
content: {
_content: text?.plainText ?? ''
}
}
];
};
Expand Down
69 changes: 52 additions & 17 deletions src/store/editor/hooks/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,40 +48,75 @@ export const useEditorSubject = (
);
};

export const useEditorTextProvider = (
id: MailsEditorV2['id']
): {
textProvider: MailsEditorV2['textProvider'];
setTextProvider: (textProvider: MailsEditorV2['textProvider']) => void;
} => {
const value = useEditorsStore((state) => state.editors[id].textProvider);
const setter = useEditorsStore((state) => state.setTextProvider);

const setTextProvider = useCallback(
(val: MailsEditorV2['textProvider']): void => {
setter(id, val);
},
[id, setter]
);

return useMemo(
() => ({
textProvider: value,
setTextProvider
}),
[setTextProvider, value]
);
};

type EditorSetTextOptions = {
syncTextProvider?: boolean;
};

/**
* Returns reactive references to the text values and to their setter
* @param id
*/
export const useEditorText = (
id: MailsEditorV2['id']
): {
text: MailsEditorV2['text'];
setText: (text: MailsEditorV2['text']) => void;
resetText: () => void;
getText: () => MailsEditorV2['text'];
setText: (text: MailsEditorV2['text'], options?: EditorSetTextOptions) => void;
} => {
const { debouncedSaveDraft } = useSaveDraftFromEditor();
const value = useEditorsStore((state) => state.editors[id].text);
const { immediateSaveDraft } = useSaveDraftFromEditor();
const setter = useEditorsStore((state) => state.setText);
const { textProvider } = useEditorTextProvider(id);

const getText = useCallback(
(): MailsEditorV2['text'] =>
textProvider?.getCurrentText() ?? useEditorsStore.getState().editors[id].text,
[id, textProvider]
);

const setText = useCallback(
(val: MailsEditorV2['text']): void => {
(
val: MailsEditorV2['text'],
options: EditorSetTextOptions = { syncTextProvider: true }
): void => {
if (textProvider && options.syncTextProvider) {
textProvider.setCurrentText(val);
}
setter(id, val);
debouncedSaveDraft(id);
immediateSaveDraft(id);
},
[id, debouncedSaveDraft, setter]
[id, immediateSaveDraft, setter, textProvider]
);

const resetText = useCallback((): void => {
setter(id, { plainText: '', richText: '' });
debouncedSaveDraft(id);
}, [id, debouncedSaveDraft, setter]);

return useMemo(
() => ({
text: value,
setText,
resetText
getText,
setText
}),
[resetText, setText, value]
[getText, setText]
);
};

Expand Down
52 changes: 52 additions & 0 deletions src/store/editor/hooks/tests/use-editor-text-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: 2025 Zextras <https://www.zextras.com>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/

import { act } from '@testing-library/react';

import { setupHook } from '../../../../carbonio-ui-commons/test/test-setup';
import { setupEditorStore } from '../../../../tests/generators/editor-store';
import { generateNewMessageEditor } from '../../editor-generators';
import { useEditorsStore } from '../../store';
import { useEditorTextProvider } from '../editor';

describe('useEditorTextProvider', () => {
it('should return an object with the current textProvider and its setter', () => {
const textProvider = {
setCurrentText: jest.fn(),
getCurrentText: jest.fn()
};
const editor = generateNewMessageEditor();
editor.textProvider = textProvider;
setupEditorStore({ editors: [editor] });

const {
result: { current: hookResult }
} = setupHook(useEditorTextProvider, { initialProps: [editor.id] });

expect(hookResult).toEqual({
setTextProvider: expect.any(Function),
textProvider: editor.textProvider
});
});

it('should set the textProvider when the setter is called', () => {
const textProvider = {
setCurrentText: jest.fn(),
getCurrentText: jest.fn()
};
const editor = generateNewMessageEditor();
setupEditorStore({ editors: [editor] });

const { result } = setupHook(useEditorTextProvider, { initialProps: [editor.id] });

act(() => {
result.current.setTextProvider(textProvider);
});

expect(useEditorsStore.getState().editors[editor.id].textProvider).toBe(textProvider);
expect(result.current.textProvider).toBe(textProvider);
});
});
124 changes: 124 additions & 0 deletions src/store/editor/hooks/tests/use-editor-text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* SPDX-FileCopyrightText: 2025 Zextras <https://www.zextras.com>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { faker } from '@faker-js/faker';
import { act } from '@testing-library/react';

import { createSoapAPIInterceptor } from '../../../../carbonio-ui-commons/test/mocks/network/msw/create-api-interceptor';
import { setupHook } from '../../../../carbonio-ui-commons/test/test-setup';
import { setupEditorStore } from '../../../../tests/generators/editor-store';
import { generateEditorV2Case } from '../../../../tests/generators/editors';
import { generateNewMessageEditor } from '../../editor-generators';
import { useEditorText } from '../editor';
import { addEditor, getEditor } from '../editors';

describe('useEditorText', () => {
test('get the editor text', async () => {
const initialPlainText = 'initial plain text';
const initialRichText = 'initial <b>rich</b> text';
setupEditorStore({ editors: [] });
const editor = await generateEditorV2Case(1);
editor.text = {
plainText: initialPlainText,
richText: initialRichText
};
addEditor({ id: editor.id, editor });

const { result: hookResult } = setupHook(useEditorText, { initialProps: [editor.id] });
const { getText } = hookResult.current;
expect(getText().plainText).toEqual(initialPlainText);
expect(getText().richText).toEqual(initialRichText);
});

test('set the editor text', async () => {
const initialPlainText = 'initial plain text';
const initialRichText = 'initial <b>rich</b> text';
const newPlainText = 'new plain text';
const newRichText = 'new <b>rich</b> text';

createSoapAPIInterceptor('SaveDraft');

setupEditorStore({ editors: [] });
const editor = await generateEditorV2Case(1);
editor.text = {
plainText: initialPlainText,
richText: initialRichText
};
addEditor({ id: editor.id, editor });

const { result: hookResult } = setupHook(useEditorText, { initialProps: [editor.id] });
const { setText } = hookResult.current;

await act(async () => {
setText({ plainText: newPlainText, richText: newRichText });
});

const editorFromStore = getEditor({ id: editor.id });
expect(editorFromStore?.text.plainText).toEqual(newPlainText);
expect(editorFromStore?.text.richText).toEqual(newRichText);
});

describe('Text provider', () => {
it('should return the text from the test provider when the provider is set', () => {
const providerTextValue = {
richText: faker.lorem.paragraph(),
plainText: faker.lorem.paragraph()
};
const textProvider = {
setCurrentText: jest.fn(),
getCurrentText: jest.fn().mockReturnValue(providerTextValue)
};
const editor = generateNewMessageEditor();
editor.textProvider = textProvider;
setupEditorStore({ editors: [editor] });

const { result: hookResult } = setupHook(useEditorText, { initialProps: [editor.id] });
const { getText } = hookResult.current;

expect(getText()).toEqual(providerTextValue);
expect(textProvider.getCurrentText).toHaveBeenCalled();
});

it('should return the text from the store when the provider is not set', () => {
const text = {
richText: faker.lorem.paragraph(),
plainText: faker.lorem.paragraph()
};
const editor = generateNewMessageEditor();
editor.text = text;
editor.textProvider = undefined;
setupEditorStore({ editors: [editor] });

const { result: hookResult } = setupHook(useEditorText, { initialProps: [editor.id] });
const { getText } = hookResult.current;

expect(getText()).toEqual(text);
});

it('should invoke the provider function when the setText is invoked and the provider is set', async () => {
createSoapAPIInterceptor('SaveDraft');

const text = {
richText: faker.lorem.paragraph(),
plainText: faker.lorem.paragraph()
};
const editor = generateNewMessageEditor();
editor.textProvider = {
setCurrentText: jest.fn(),
getCurrentText: jest.fn()
};
setupEditorStore({ editors: [editor] });

const { result: hookResult } = setupHook(useEditorText, { initialProps: [editor.id] });
const { setText } = hookResult.current;

await act(async () => {
setText(text);
});

expect(editor.textProvider.setCurrentText).toHaveBeenCalledWith(text);
});
});
});
10 changes: 10 additions & 0 deletions src/store/editor/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getUnsavedAttachmentIndex } from './store-utils';
import {
AttachmentUploadProcessStatus,
EditorsStateTypeV2,
EditorTextProvider,
MailsEditorV2,
SavedAttachment,
UnsavedAttachment
Expand Down Expand Up @@ -362,5 +363,14 @@ export const useEditorsStore = create<EditorsStateTypeV2>()((set) => ({
}
})
);
},
setTextProvider: (id: MailsEditorV2['id'], provider: EditorTextProvider): void => {
set(
produce((state: EditorsStateTypeV2) => {
if (state?.editors?.[id]) {
state.editors[id].textProvider = provider;
}
})
);
}
}));
44 changes: 0 additions & 44 deletions src/store/editor/tests/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
getEditor,
deleteEditor,
useEditorSubject,
useEditorText,
useEditorAutoSendTime
// useEditorIsUrgent,
// useEditorRequestReadReceipt,
Expand Down Expand Up @@ -84,49 +83,6 @@ describe('all editor hooks', () => {
});
});

describe('useEditorText', () => {
test('get the editor text', async () => {
const initialPlainText = 'initial plain text';
const initialRichText = 'initial <b>rich</b> text';
setupEditorStore({ editors: [] });
const editor = await generateEditorV2Case(1);
editor.text = {
plainText: initialPlainText,
richText: initialRichText
};
addEditor({ id: editor.id, editor });

const { result: hookResult } = setupHook(useEditorText, { initialProps: [editor.id] });
const { text } = hookResult.current;
expect(text.plainText).toEqual(initialPlainText);
expect(text.richText).toEqual(initialRichText);
});

test('set the editor text', async () => {
const initialPlainText = 'initial plain text';
const initialRichText = 'initial <b>rich</b> text';
const newPlainText = 'new plain text';
const newRichText = 'new <b>rich</b> text';

setupEditorStore({ editors: [] });
const editor = await generateEditorV2Case(1);
editor.text = {
plainText: initialPlainText,
richText: initialRichText
};
addEditor({ id: editor.id, editor });

const { result: hookResult } = setupHook(useEditorText, { initialProps: [editor.id] });
const { setText } = hookResult.current;
act(() => {
setText({ plainText: newPlainText, richText: newRichText });
});
const editorFromStore = getEditor({ id: editor.id });
expect(editorFromStore?.text.plainText).toEqual(newPlainText);
expect(editorFromStore?.text.richText).toEqual(newRichText);
});
});

describe('useEditorAutoSendTime', () => {
test('get the editor scheduled send time', async () => {
const initialAutoSendTime = 123456789;
Expand Down
Loading