mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 03:27:52 +00:00
This PR allows agents to mention entire teams in private messages using `@team_name` syntax. When a team is mentioned, all team members with inbox access are automatically notified. The scheme changes can be found [here](https://github.com/chatwoot/prosemirror-schema/pull/34). --------- Co-authored-by: iamsivin <iamsivin@gmail.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Sojan Jose <sojan@pepalo.com>
629 lines
17 KiB
JavaScript
629 lines
17 KiB
JavaScript
import {
|
|
findSignatureInBody,
|
|
appendSignature,
|
|
removeSignature,
|
|
replaceSignature,
|
|
cleanSignature,
|
|
extractTextFromMarkdown,
|
|
insertAtCursor,
|
|
findNodeToInsertImage,
|
|
setURLWithQueryAndSize,
|
|
getContentNode,
|
|
} from '../editorHelper';
|
|
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 unwrap doc nodes that are wrapped in a paragraph', () => {
|
|
const docNode = schema.node('doc', null, [
|
|
schema.node('paragraph', null, [schema.text('Hello')]),
|
|
]);
|
|
|
|
const editorState = createEditorState();
|
|
const editorView = new EditorView(document.body, { state: editorState });
|
|
|
|
insertAtCursor(editorView, docNode, 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,
|
|
});
|
|
});
|
|
});
|
|
});
|