# Pull Request Template ## Description This PR includes, 1. **Channel-specific formatting and menu options** for the rich reply editor. 2. **Removal of the plain reply editor** and full **standardization** on the rich reply editor across all channels. 3. **Fix for multiple canned responses insertion:** * **Before:** The plain editor only allowed inserting canned responses at the beginning of a message, making it impossible to combine multiple canned responses in a single reply. This caused inconsistent behavior across the app. * **Solution:** Replaced the plain reply editor with the rich (ProseMirror) editor to ensure a unified experience. Agents can now insert multiple canned responses at any cursor position. 4. **Floating editor menu** for the reply box to improve accessibility and overall user experience. 5. **New Strikethrough formatting option** added to the editor menu. --- **Editor repo PR**: https://github.com/chatwoot/prosemirror-schema/pull/36 Fixes https://github.com/chatwoot/chatwoot/issues/12517, [CW-5924](https://linear.app/chatwoot/issue/CW-5924/standardize-the-editor), [CW-5679](https://linear.app/chatwoot/issue/CW-5679/allow-inserting-multiple-canned-responses-in-a-single-message) ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? ### Screenshot **Dark** <img width="850" height="345" alt="image" src="https://github.com/user-attachments/assets/47748e6c-380f-44a3-9e3b-c27e0c830bd0" /> **Light** <img width="850" height="345" alt="image" src="https://github.com/user-attachments/assets/6746cf32-bf63-4280-a5bd-bbd42c3cbe84" /> ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Pranav <pranav@chatwoot.com> Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
805 lines
23 KiB
JavaScript
805 lines
23 KiB
JavaScript
import {
|
|
findSignatureInBody,
|
|
appendSignature,
|
|
removeSignature,
|
|
replaceSignature,
|
|
cleanSignature,
|
|
extractTextFromMarkdown,
|
|
insertAtCursor,
|
|
findNodeToInsertImage,
|
|
setURLWithQueryAndSize,
|
|
getContentNode,
|
|
getFormattingForEditor,
|
|
getSelectionCoords,
|
|
getMenuAnchor,
|
|
calculateMenuPosition,
|
|
} from '../editorHelper';
|
|
import { FORMATTING } from 'dashboard/constants/editor';
|
|
import { EditorState } from '@chatwoot/prosemirror-schema';
|
|
import { EditorView } from '@chatwoot/prosemirror-schema';
|
|
import { Schema } from 'prosemirror-model';
|
|
|
|
// Define a basic ProseMirror schema
|
|
const schema = new Schema({
|
|
nodes: {
|
|
doc: { content: 'paragraph+' },
|
|
paragraph: {
|
|
content: 'inline*',
|
|
group: 'block',
|
|
toDOM: () => ['p', 0], // Represents a paragraph as a <p> tag in the DOM.
|
|
},
|
|
text: {
|
|
group: 'inline',
|
|
toDOM: node => node.text, // Represents text as its actual string value.
|
|
},
|
|
mention: {
|
|
attrs: {
|
|
userId: { default: '' },
|
|
userFullName: { default: '' },
|
|
mentionType: { default: 'user' },
|
|
},
|
|
inline: true,
|
|
group: 'inline',
|
|
toDOM: node => [
|
|
'span',
|
|
{ class: 'mention' },
|
|
`@${node.attrs.userFullName}`,
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
// Initialize a basic EditorState for testing
|
|
const createEditorState = (content = '') => {
|
|
if (!content) {
|
|
return EditorState.create({
|
|
schema,
|
|
doc: schema.node('doc', null, [schema.node('paragraph')]),
|
|
});
|
|
}
|
|
return EditorState.create({
|
|
schema,
|
|
doc: schema.node('doc', null, [
|
|
schema.node('paragraph', null, [schema.text(content)]),
|
|
]),
|
|
});
|
|
};
|
|
|
|
const NEW_SIGNATURE = 'This is a new signature';
|
|
|
|
const DOES_NOT_HAVE_SIGNATURE = {
|
|
'no signature': {
|
|
body: 'This is a test',
|
|
signature: 'This is a signature',
|
|
},
|
|
'text after signature': {
|
|
body: 'This is a test\n\n--\n\nThis is a signature\n\nThis is more text',
|
|
signature: 'This is a signature',
|
|
},
|
|
'signature has images': {
|
|
body: 'This is a test',
|
|
signature:
|
|
'Testing\n',
|
|
},
|
|
'signature has non commonmark syntax': {
|
|
body: 'This is a test',
|
|
signature: '- Signature',
|
|
},
|
|
'signature has trailing spaces': {
|
|
body: 'This is a test',
|
|
signature: '**hello** \n**world**',
|
|
},
|
|
};
|
|
|
|
const HAS_SIGNATURE = {
|
|
'signature at end': {
|
|
body: 'This is a test\n\n--\n\nThis is a signature',
|
|
signature: 'This is a signature',
|
|
},
|
|
'signature at end with spaces and new lines': {
|
|
body: 'This is a test\n\n--\n\nThis is a signature \n\n',
|
|
signature: 'This is a signature ',
|
|
},
|
|
'no text before signature': {
|
|
body: '\n\n--\n\nThis is a signature',
|
|
signature: 'This is a signature',
|
|
},
|
|
'signature has non-commonmark syntax': {
|
|
body: '\n\n--\n\n* Signature',
|
|
signature: '- Signature',
|
|
},
|
|
};
|
|
|
|
describe('findSignatureInBody', () => {
|
|
it('returns -1 if there is no signature', () => {
|
|
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
|
|
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
|
|
expect(findSignatureInBody(body, signature)).toBe(-1);
|
|
});
|
|
});
|
|
it('returns the index of the signature if there is one', () => {
|
|
Object.keys(HAS_SIGNATURE).forEach(key => {
|
|
const { body, signature } = HAS_SIGNATURE[key];
|
|
expect(findSignatureInBody(body, signature)).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('appendSignature', () => {
|
|
it('appends the signature if it is not present', () => {
|
|
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
|
|
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
|
|
const cleanedSignature = cleanSignature(signature);
|
|
expect(
|
|
appendSignature(body, signature).includes(cleanedSignature)
|
|
).toBeTruthy();
|
|
});
|
|
});
|
|
it('does not append signature if already present', () => {
|
|
Object.keys(HAS_SIGNATURE).forEach(key => {
|
|
const { body, signature } = HAS_SIGNATURE[key];
|
|
expect(appendSignature(body, signature)).toBe(body);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('cleanSignature', () => {
|
|
it('removes any instance of horizontal rule', () => {
|
|
const options = [
|
|
'---',
|
|
'***',
|
|
'___',
|
|
'- - -',
|
|
'* * *',
|
|
'_ _ _',
|
|
' ---',
|
|
'--- ',
|
|
' --- ',
|
|
'-----',
|
|
'*****',
|
|
'_____',
|
|
'- - - -',
|
|
'* * * * *',
|
|
'_ _ _ _ _ _',
|
|
' - - - - ',
|
|
' * * * * * ',
|
|
' _ _ _ _ _ _',
|
|
'- - - - -',
|
|
'* * * * * *',
|
|
'_ _ _ _ _ _ _',
|
|
];
|
|
options.forEach(option => {
|
|
expect(cleanSignature(option)).toBe('');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('removeSignature', () => {
|
|
it('does not remove signature if not present', () => {
|
|
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
|
|
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
|
|
expect(removeSignature(body, signature)).toBe(body);
|
|
});
|
|
});
|
|
it('removes signature if present at the end', () => {
|
|
const { body, signature } = HAS_SIGNATURE['signature at end'];
|
|
expect(removeSignature(body, signature)).toBe('This is a test\n\n');
|
|
});
|
|
it('removes signature if present with spaces and new lines', () => {
|
|
const { body, signature } =
|
|
HAS_SIGNATURE['signature at end with spaces and new lines'];
|
|
expect(removeSignature(body, signature)).toBe('This is a test\n\n');
|
|
});
|
|
it('removes signature if present without text before it', () => {
|
|
const { body, signature } = HAS_SIGNATURE['no text before signature'];
|
|
expect(removeSignature(body, signature)).toBe('\n\n');
|
|
});
|
|
it('removes just the delimiter if no signature is present', () => {
|
|
expect(removeSignature('This is a test\n\n--', 'This is a signature')).toBe(
|
|
'This is a test\n\n'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('replaceSignature', () => {
|
|
it('appends the new signature if not present', () => {
|
|
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
|
|
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
|
|
expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
|
|
`${body}\n\n--\n\n${NEW_SIGNATURE}`
|
|
);
|
|
});
|
|
});
|
|
it('removes signature if present at the end', () => {
|
|
const { body, signature } = HAS_SIGNATURE['signature at end'];
|
|
expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
|
|
`This is a test\n\n--\n\n${NEW_SIGNATURE}`
|
|
);
|
|
});
|
|
it('removes signature if present with spaces and new lines', () => {
|
|
const { body, signature } =
|
|
HAS_SIGNATURE['signature at end with spaces and new lines'];
|
|
expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
|
|
`This is a test\n\n--\n\n${NEW_SIGNATURE}`
|
|
);
|
|
});
|
|
it('removes signature if present without text before it', () => {
|
|
const { body, signature } = HAS_SIGNATURE['no text before signature'];
|
|
expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
|
|
`\n\n--\n\n${NEW_SIGNATURE}`
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('extractTextFromMarkdown', () => {
|
|
it('should extract text from markdown and remove all images, code blocks, links, headers, bold, italic, lists etc.', () => {
|
|
const markdown = `
|
|
# Hello World
|
|
|
|
This is a **bold** text with a [link](https://example.com).
|
|
|
|
\`\`\`javascript
|
|
const foo = 'bar';
|
|
console.log(foo);
|
|
\`\`\`
|
|
|
|
Here's an image: 
|
|
|
|
- List item 1
|
|
- List item 2
|
|
|
|
*Italic text*
|
|
`;
|
|
|
|
const expected =
|
|
"Hello World\nThis is a bold text with a link.\nHere's an image:\nList item 1\nList item 2\nItalic text";
|
|
expect(extractTextFromMarkdown(markdown)).toEqual(expected);
|
|
});
|
|
});
|
|
|
|
describe('insertAtCursor', () => {
|
|
it('should return undefined if editorView is not provided', () => {
|
|
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it('should insert text node at cursor position', () => {
|
|
const editorState = createEditorState();
|
|
const editorView = new EditorView(document.body, { state: editorState });
|
|
|
|
insertAtCursor(editorView, schema.text('Hello'), 0);
|
|
|
|
// Check if node was unwrapped and inserted correctly
|
|
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello');
|
|
});
|
|
|
|
it('should insert node without replacing any content if "to" is not provided', () => {
|
|
const editorState = createEditorState();
|
|
const editorView = new EditorView(document.body, { state: editorState });
|
|
|
|
insertAtCursor(editorView, schema.text('Hello'), 0);
|
|
|
|
// Check if node was inserted correctly
|
|
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello');
|
|
});
|
|
|
|
it('should replace content between "from" and "to" with the provided node', () => {
|
|
const editorState = createEditorState('ReplaceMe');
|
|
const editorView = new EditorView(document.body, { state: editorState });
|
|
|
|
insertAtCursor(editorView, schema.text('Hello'), 0, 8);
|
|
|
|
// Check if content was replaced correctly
|
|
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello Me');
|
|
});
|
|
});
|
|
|
|
describe('findNodeToInsertImage', () => {
|
|
let mockEditorState;
|
|
|
|
beforeEach(() => {
|
|
mockEditorState = {
|
|
selection: {
|
|
$from: {
|
|
node: vi.fn(() => ({})),
|
|
},
|
|
from: 0,
|
|
},
|
|
schema: {
|
|
nodes: {
|
|
image: {
|
|
create: vi.fn(attrs => ({ type: { name: 'image' }, attrs })),
|
|
},
|
|
paragraph: {
|
|
create: vi.fn((_, node) => ({
|
|
type: { name: 'paragraph' },
|
|
content: [node],
|
|
})),
|
|
},
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
it('should insert image directly into an empty paragraph', () => {
|
|
const mockNode = {
|
|
type: { name: 'paragraph' },
|
|
content: { size: 0, content: [] },
|
|
};
|
|
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
|
|
|
|
const result = findNodeToInsertImage(mockEditorState, 'image-url');
|
|
expect(result).toEqual({
|
|
node: { type: { name: 'image' }, attrs: { src: 'image-url' } },
|
|
pos: 0,
|
|
});
|
|
});
|
|
|
|
it('should insert image directly into a paragraph without an image but with other content', () => {
|
|
const mockNode = {
|
|
type: { name: 'paragraph' },
|
|
content: {
|
|
size: 1,
|
|
content: [
|
|
{
|
|
type: { name: 'text' },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
|
|
mockEditorState.selection.from = 1;
|
|
|
|
const result = findNodeToInsertImage(mockEditorState, 'image-url');
|
|
expect(result).toEqual({
|
|
node: { type: { name: 'image' }, attrs: { src: 'image-url' } },
|
|
pos: 2, // Because it should insert after the text, on a new line.
|
|
});
|
|
});
|
|
|
|
it("should wrap image in a new paragraph when the current node isn't a paragraph", () => {
|
|
const mockNode = {
|
|
type: { name: 'not-a-paragraph' },
|
|
content: { size: 0, content: [] },
|
|
};
|
|
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
|
|
|
|
const result = findNodeToInsertImage(mockEditorState, 'image-url');
|
|
expect(result.node.type.name).toBe('paragraph');
|
|
expect(result.node.content[0].type.name).toBe('image');
|
|
expect(result.node.content[0].attrs.src).toBe('image-url');
|
|
expect(result.pos).toBe(0);
|
|
});
|
|
|
|
it('should insert a new image directly into the paragraph that already contains an image', () => {
|
|
const mockNode = {
|
|
type: { name: 'paragraph' },
|
|
content: {
|
|
size: 1,
|
|
content: [
|
|
{
|
|
type: { name: 'image', attrs: { src: 'existing-image-url' } },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
|
|
mockEditorState.selection.from = 1;
|
|
|
|
const result = findNodeToInsertImage(mockEditorState, 'image-url');
|
|
expect(result.node.type.name).toBe('image');
|
|
expect(result.node.attrs.src).toBe('image-url');
|
|
expect(result.pos).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('setURLWithQueryAndSize', () => {
|
|
let selectedNode;
|
|
let editorView;
|
|
|
|
beforeEach(() => {
|
|
selectedNode = {
|
|
setAttribute: vi.fn(),
|
|
};
|
|
|
|
const tr = {
|
|
setNodeMarkup: vi.fn().mockReturnValue({
|
|
docChanged: true,
|
|
}),
|
|
};
|
|
|
|
const state = {
|
|
selection: { from: 0 },
|
|
tr,
|
|
};
|
|
|
|
editorView = {
|
|
state,
|
|
dispatch: vi.fn(),
|
|
};
|
|
});
|
|
|
|
it('updates the URL with the given size and updates the editor view', () => {
|
|
const size = { height: '20px' };
|
|
|
|
setURLWithQueryAndSize(selectedNode, size, editorView);
|
|
|
|
// Check if the editor view is updated
|
|
expect(editorView.dispatch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('updates the URL with the given size and updates the editor view with original size', () => {
|
|
const size = { height: 'auto' };
|
|
|
|
setURLWithQueryAndSize(selectedNode, size, editorView);
|
|
|
|
// Check if the editor view is updated
|
|
expect(editorView.dispatch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not update the editor view if the document has not changed', () => {
|
|
editorView.state.tr.setNodeMarkup = vi.fn().mockReturnValue({
|
|
docChanged: false,
|
|
});
|
|
|
|
const size = { height: '20px' };
|
|
|
|
setURLWithQueryAndSize(selectedNode, size, editorView);
|
|
|
|
// Check if the editor view dispatch was not called
|
|
expect(editorView.dispatch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not perform any operations if selectedNode is not provided', () => {
|
|
setURLWithQueryAndSize(null, { height: '20px' }, editorView);
|
|
|
|
// Ensure the dispatch method wasn't called
|
|
expect(editorView.dispatch).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('getContentNode', () => {
|
|
let mockEditorView;
|
|
|
|
beforeEach(() => {
|
|
mockEditorView = {
|
|
state: {
|
|
schema: {
|
|
nodes: {
|
|
mention: {
|
|
create: vi.fn(attrs => ({
|
|
type: { name: 'mention' },
|
|
attrs,
|
|
})),
|
|
},
|
|
},
|
|
text: vi.fn(content => ({ type: { name: 'text' }, text: content })),
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
describe('mention node creation', () => {
|
|
it('creates a user mention node with correct attributes', () => {
|
|
const userContent = {
|
|
id: '123',
|
|
name: 'John Doe',
|
|
type: 'user',
|
|
};
|
|
|
|
const result = getContentNode(mockEditorView, 'mention', userContent, {
|
|
from: 0,
|
|
to: 5,
|
|
});
|
|
|
|
expect(
|
|
mockEditorView.state.schema.nodes.mention.create
|
|
).toHaveBeenCalledWith({
|
|
userId: '123',
|
|
userFullName: 'John Doe',
|
|
mentionType: 'user',
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
node: {
|
|
type: { name: 'mention' },
|
|
attrs: {
|
|
userId: '123',
|
|
userFullName: 'John Doe',
|
|
mentionType: 'user',
|
|
},
|
|
},
|
|
from: 0,
|
|
to: 5,
|
|
});
|
|
});
|
|
|
|
it('creates a team mention node with correct attributes', () => {
|
|
const teamContent = {
|
|
id: '456',
|
|
name: 'Support Team',
|
|
type: 'team',
|
|
};
|
|
|
|
const result = getContentNode(mockEditorView, 'mention', teamContent, {
|
|
from: 0,
|
|
to: 5,
|
|
});
|
|
|
|
expect(
|
|
mockEditorView.state.schema.nodes.mention.create
|
|
).toHaveBeenCalledWith({
|
|
userId: '456',
|
|
userFullName: 'Support Team',
|
|
mentionType: 'team',
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
node: {
|
|
type: { name: 'mention' },
|
|
attrs: {
|
|
userId: '456',
|
|
userFullName: 'Support Team',
|
|
mentionType: 'team',
|
|
},
|
|
},
|
|
from: 0,
|
|
to: 5,
|
|
});
|
|
});
|
|
|
|
it('defaults to user mention type when type is not specified', () => {
|
|
const contentWithoutType = {
|
|
id: '789',
|
|
name: 'Jane Smith',
|
|
};
|
|
|
|
getContentNode(mockEditorView, 'mention', contentWithoutType, {
|
|
from: 0,
|
|
to: 5,
|
|
});
|
|
|
|
expect(
|
|
mockEditorView.state.schema.nodes.mention.create
|
|
).toHaveBeenCalledWith({
|
|
userId: '789',
|
|
userFullName: 'Jane Smith',
|
|
mentionType: 'user',
|
|
});
|
|
});
|
|
|
|
it('uses displayName over name when both are provided', () => {
|
|
const contentWithDisplayName = {
|
|
id: '101',
|
|
name: 'john_doe',
|
|
displayName: 'John Doe (Admin)',
|
|
type: 'user',
|
|
};
|
|
|
|
getContentNode(mockEditorView, 'mention', contentWithDisplayName, {
|
|
from: 0,
|
|
to: 5,
|
|
});
|
|
|
|
expect(
|
|
mockEditorView.state.schema.nodes.mention.create
|
|
).toHaveBeenCalledWith({
|
|
userId: '101',
|
|
userFullName: 'John Doe (Admin)',
|
|
mentionType: 'user',
|
|
});
|
|
});
|
|
|
|
it('handles missing displayName by falling back to name', () => {
|
|
const contentWithoutDisplayName = {
|
|
id: '102',
|
|
name: 'jane_smith',
|
|
type: 'user',
|
|
};
|
|
|
|
getContentNode(mockEditorView, 'mention', contentWithoutDisplayName, {
|
|
from: 0,
|
|
to: 5,
|
|
});
|
|
|
|
expect(
|
|
mockEditorView.state.schema.nodes.mention.create
|
|
).toHaveBeenCalledWith({
|
|
userId: '102',
|
|
userFullName: 'jane_smith',
|
|
mentionType: 'user',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('unsupported node types', () => {
|
|
it('returns null node for unsupported type', () => {
|
|
const result = getContentNode(mockEditorView, 'unsupported', 'content', {
|
|
from: 0,
|
|
to: 5,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
node: null,
|
|
from: 0,
|
|
to: 5,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getFormattingForEditor', () => {
|
|
describe('channel-specific formatting', () => {
|
|
it('returns full formatting for Email channel', () => {
|
|
const result = getFormattingForEditor('Channel::Email');
|
|
|
|
expect(result).toEqual(FORMATTING['Channel::Email']);
|
|
});
|
|
|
|
it('returns full formatting for WebWidget channel', () => {
|
|
const result = getFormattingForEditor('Channel::WebWidget');
|
|
|
|
expect(result).toEqual(FORMATTING['Channel::WebWidget']);
|
|
});
|
|
|
|
it('returns limited formatting for WhatsApp channel', () => {
|
|
const result = getFormattingForEditor('Channel::Whatsapp');
|
|
|
|
expect(result).toEqual(FORMATTING['Channel::Whatsapp']);
|
|
});
|
|
|
|
it('returns no formatting for API channel', () => {
|
|
const result = getFormattingForEditor('Channel::Api');
|
|
|
|
expect(result).toEqual(FORMATTING['Channel::Api']);
|
|
});
|
|
|
|
it('returns limited formatting for FacebookPage channel', () => {
|
|
const result = getFormattingForEditor('Channel::FacebookPage');
|
|
|
|
expect(result).toEqual(FORMATTING['Channel::FacebookPage']);
|
|
});
|
|
|
|
it('returns no formatting for TwitterProfile channel', () => {
|
|
const result = getFormattingForEditor('Channel::TwitterProfile');
|
|
|
|
expect(result).toEqual(FORMATTING['Channel::TwitterProfile']);
|
|
});
|
|
|
|
it('returns no formatting for SMS channel', () => {
|
|
const result = getFormattingForEditor('Channel::Sms');
|
|
|
|
expect(result).toEqual(FORMATTING['Channel::Sms']);
|
|
});
|
|
|
|
it('returns limited formatting for Telegram channel', () => {
|
|
const result = getFormattingForEditor('Channel::Telegram');
|
|
|
|
expect(result).toEqual(FORMATTING['Channel::Telegram']);
|
|
});
|
|
|
|
it('returns formatting for Instagram channel', () => {
|
|
const result = getFormattingForEditor('Channel::Instagram');
|
|
|
|
expect(result).toEqual(FORMATTING['Channel::Instagram']);
|
|
});
|
|
});
|
|
|
|
describe('context-specific formatting', () => {
|
|
it('returns default formatting for Context::Default', () => {
|
|
const result = getFormattingForEditor('Context::Default');
|
|
|
|
expect(result).toEqual(FORMATTING['Context::Default']);
|
|
});
|
|
|
|
it('returns signature formatting for Context::MessageSignature', () => {
|
|
const result = getFormattingForEditor('Context::MessageSignature');
|
|
|
|
expect(result).toEqual(FORMATTING['Context::MessageSignature']);
|
|
});
|
|
|
|
it('returns widget builder formatting for Context::InboxSettings', () => {
|
|
const result = getFormattingForEditor('Context::InboxSettings');
|
|
|
|
expect(result).toEqual(FORMATTING['Context::InboxSettings']);
|
|
});
|
|
});
|
|
|
|
describe('fallback behavior', () => {
|
|
it('returns default formatting for unknown channel type', () => {
|
|
const result = getFormattingForEditor('Channel::Unknown');
|
|
|
|
expect(result).toEqual(FORMATTING['Context::Default']);
|
|
});
|
|
|
|
it('returns default formatting for null channel type', () => {
|
|
const result = getFormattingForEditor(null);
|
|
|
|
expect(result).toEqual(FORMATTING['Context::Default']);
|
|
});
|
|
|
|
it('returns default formatting for undefined channel type', () => {
|
|
const result = getFormattingForEditor(undefined);
|
|
|
|
expect(result).toEqual(FORMATTING['Context::Default']);
|
|
});
|
|
|
|
it('returns default formatting for empty string', () => {
|
|
const result = getFormattingForEditor('');
|
|
|
|
expect(result).toEqual(FORMATTING['Context::Default']);
|
|
});
|
|
});
|
|
|
|
describe('return value structure', () => {
|
|
it('always returns an object with marks, nodes, and menu properties', () => {
|
|
const result = getFormattingForEditor('Channel::Email');
|
|
|
|
expect(result).toHaveProperty('marks');
|
|
expect(result).toHaveProperty('nodes');
|
|
expect(result).toHaveProperty('menu');
|
|
expect(Array.isArray(result.marks)).toBe(true);
|
|
expect(Array.isArray(result.nodes)).toBe(true);
|
|
expect(Array.isArray(result.menu)).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Menu positioning helpers', () => {
|
|
const mockEditorView = {
|
|
coordsAtPos: vi.fn((pos, bias) => {
|
|
// Return different coords based on position
|
|
if (bias === 1) return { top: 100, bottom: 120, left: 50, right: 100 };
|
|
return { top: 100, bottom: 120, left: 150, right: 200 };
|
|
}),
|
|
};
|
|
|
|
const wrapperRect = { top: 50, bottom: 300, left: 0, right: 400, width: 400 };
|
|
|
|
describe('getSelectionCoords', () => {
|
|
it('returns selection coordinates with onTop flag', () => {
|
|
const selection = { from: 0, to: 10 };
|
|
const result = getSelectionCoords(mockEditorView, selection, wrapperRect);
|
|
|
|
expect(result).toHaveProperty('start');
|
|
expect(result).toHaveProperty('end');
|
|
expect(result).toHaveProperty('selTop');
|
|
expect(result).toHaveProperty('onTop');
|
|
});
|
|
});
|
|
|
|
describe('getMenuAnchor', () => {
|
|
it('returns end.left when menu is below selection', () => {
|
|
const coords = { start: { left: 50 }, end: { left: 150 }, onTop: false };
|
|
expect(getMenuAnchor(coords, wrapperRect, false)).toBe(150);
|
|
});
|
|
|
|
it('returns start.left for LTR when menu is above and visible', () => {
|
|
const coords = { start: { top: 100, left: 50 }, end: {}, onTop: true };
|
|
expect(getMenuAnchor(coords, wrapperRect, false)).toBe(50);
|
|
});
|
|
|
|
it('returns start.right for RTL when menu is above and visible', () => {
|
|
const coords = { start: { top: 100, right: 100 }, end: {}, onTop: true };
|
|
expect(getMenuAnchor(coords, wrapperRect, true)).toBe(100);
|
|
});
|
|
});
|
|
|
|
describe('calculateMenuPosition', () => {
|
|
it('returns bounded left and top positions', () => {
|
|
const coords = {
|
|
start: { top: 100, bottom: 120, left: 50 },
|
|
end: { top: 100, bottom: 120, left: 150 },
|
|
selTop: 100,
|
|
onTop: false,
|
|
};
|
|
const result = calculateMenuPosition(coords, wrapperRect, false);
|
|
|
|
expect(result).toHaveProperty('left');
|
|
expect(result).toHaveProperty('top');
|
|
expect(result).toHaveProperty('width', 300);
|
|
expect(result.left).toBeGreaterThanOrEqual(0);
|
|
});
|
|
});
|
|
});
|