Compare commits

...
4 changed files with 283 additions and 90 deletions
@@ -3,17 +3,17 @@
<tag-agents
v-if="showUserMentions && isPrivate"
:search-key="mentionSearchKey"
@click="insertMentionNode"
@click="content => insertSpecialContent('mention', content)"
/>
<canned-response
v-if="shouldShowCannedResponses"
:search-key="cannedSearchTerm"
@click="insertCannedResponse"
@click="content => insertSpecialContent('cannedResponse', content)"
/>
<variable-list
v-if="shouldShowVariables"
:search-key="variableSearchTerm"
@click="insertVariable"
@click="content => insertSpecialContent('variable', content)"
/>
<input
ref="imageUpload"
@@ -47,11 +47,9 @@
<script>
import {
messageSchema,
buildEditor,
EditorView,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
EditorState,
Selection,
} from '@chatwoot/prosemirror-schema';
import {
@@ -70,6 +68,7 @@ import {
scrollCursorIntoView,
findNodeToInsertImage,
setURLWithQueryAndSize,
createState,
} from 'dashboard/helper/editorHelper';
const TYPING_INDICATOR_IDLE_TIME = 4000;
@@ -82,10 +81,7 @@ import {
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import {
replaceVariablesInMessage,
createTypingIndicator,
} from '@chatwoot/utils';
import { createTypingIndicator } from '@chatwoot/utils';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
@@ -94,27 +90,7 @@ import {
MESSAGE_EDITOR_MENU_OPTIONS,
MESSAGE_EDITOR_IMAGE_RESIZES,
} from 'dashboard/constants/editor';
const createState = (
content,
placeholder,
// eslint-disable-next-line default-param-last
plugins = [],
// eslint-disable-next-line default-param-last
methods = {},
enabledMenuOptions
) => {
return EditorState.create({
doc: new MessageMarkdownTransformer(messageSchema).parse(content),
plugins: buildEditor({
schema: messageSchema,
placeholder,
methods,
plugins,
enabledMenuOptions,
}),
});
};
import useSpecialContent from 'dashboard/composables/editor/useSpecialContent';
export default {
name: 'WootMessageEditor',
@@ -139,6 +115,11 @@ export default {
channelType: { type: String, default: '' },
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
},
setup() {
const { getContentNode } = useSpecialContent();
return { getContentNode };
},
data() {
return {
typingIndicator: createTypingIndicator(
@@ -452,22 +433,12 @@ export default {
this.emitOnChange();
},
handleDOMEvents: {
keyup: () => {
this.onKeyup();
},
keydown: (view, event) => {
this.onKeydown(event);
},
focus: () => {
this.onFocus();
},
click: () => {
this.isEditorMouseFocusedOnAnImage();
},
blur: () => {
this.onBlur();
},
paste: (view, event) => {
keyup: this.onKeyup,
keydown: (_view, event) => this.onKeydown(event),
focus: this.onFocus,
click: this.isEditorMouseFocusedOnAnImage,
blur: this.onBlur,
paste: (_view, event) => {
const data = event.clipboardData.files;
if (data.length > 0) {
event.preventDefault();
@@ -551,57 +522,39 @@ export default {
this.editorView.dispatch(tr.setSelection(selection));
this.editorView.focus();
},
insertMentionNode(mentionItem) {
/**
* Inserts special content (mention, canned response, or variable) into the editor.
*
* @param {string} type - The type of special content to insert. Possible values: 'mention', 'canned_response', 'variable'.
* @param {Object|string} content - The content to insert, depending on the type.
* - For 'mention' type: An object with 'id' and 'name' properties representing the user mention.
* - For 'canned_response' type: A string representing the canned response.
* - For 'variable' type: A string representing the variable name.
*/
insertSpecialContent(type, content) {
if (!this.editorView) {
return null;
}
const node = this.editorView.state.schema.nodes.mention.create({
userId: mentionItem.id,
userFullName: mentionItem.name,
});
this.insertNodeIntoEditor(node, this.range.from, this.range.to);
this.$track(CONVERSATION_EVENTS.USED_MENTIONS);
return false;
},
insertCannedResponse(cannedItem) {
const updatedMessage = replaceVariablesInMessage({
message: cannedItem,
variables: this.variables,
});
if (!this.editorView) {
return null;
return;
}
let node = new MessageMarkdownTransformer(messageSchema).parse(
updatedMessage
let { node, from, to } = this.getContentNode(
this.editorView,
type,
content,
this.range,
this.variables
);
const from =
node.textContent === updatedMessage
? this.range.from
: this.range.from - 1;
this.insertNodeIntoEditor(node, from, this.range.to);
this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
return false;
},
insertVariable(variable) {
if (!this.editorView) {
return null;
}
const content = `{{${variable}}}`;
let node = this.editorView.state.schema.text(content);
const { from, to } = this.range;
if (!node) return;
this.insertNodeIntoEditor(node, from, to);
this.showVariables = false;
this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE);
return false;
const event_map = {
mention: CONVERSATION_EVENTS.USED_MENTIONS,
cannedResponse: CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE,
variable: CONVERSATION_EVENTS.INSERTED_A_VARIABLE,
};
this.$track(event_map[type]);
},
openFileBrowser() {
this.$refs.imageUpload.click();
@@ -0,0 +1,101 @@
import { replaceVariablesInMessage } from '@chatwoot/utils';
import {
MessageMarkdownTransformer,
messageSchema,
} from '@chatwoot/prosemirror-schema';
/**
* Provides utility functions for creating special content nodes (mentions, canned responses, variables) in the editor state.
*
* @module useSpecialContent
* @returns {Object} - An object containing the `getContentNode` function.
*/
export default function useSpecialContent() {
/**
* Creates a mention node for the editor state
* @param {Object} editorView - The editor view instance
* @param {Object} content - The content object containing user id and name
* @param {number} from - The start position of the mention in the document
* @param {number} to - The end position of the mention in the document
* @returns {Object} - The created mention node and the updated from and to positions
*/
const getMentionNode = (editorView, content, from, to) => {
const node = editorView.state.schema.nodes.mention.create({
userId: content.id,
userFullName: content.name,
});
return { node, from, to };
};
/**
* Creates a canned response node for the editor state
* @param {Object} editorView - The editor view instance
* @param {string} content - The canned response content
* @param {number} from - The start position of the canned response in the document
* @param {number} to - The end position of the canned response in the document
* @param {Object} variables - The variables to replace in the canned response
* @returns {Object} - The created canned response node and the updated from and to positions
*/
const getCannedResponseNode = (editorView, content, from, to, variables) => {
const updatedMessage = replaceVariablesInMessage({
message: content,
variables,
});
const node = new MessageMarkdownTransformer(messageSchema).parse(
updatedMessage
);
from = node.textContent === updatedMessage ? from : from - 1;
return { node, from, to };
};
/**
* Creates a variable node for the editor state
* @param {Object} editorView - The editor view instance
* @param {string} content - The variable content
* @param {number} from - The start position of the variable in the document
* @param {number} to - The end position of the variable in the document
* @returns {Object} - The created variable node and the updated from and to positions
*/
const getVariableNode = (editorView, content, from, to) => {
const node = editorView.state.schema.text(`{{${content}}}`);
return { node, from, to };
};
/**
* Creates a content node based on the type (mention, canned response, or variable)
* @param {Object} editorView - The editor view instance
* @param {string} type - The type of content (mention, cannedResponse, or variable)
* @param {string|Object} content - The content to create the node from
* @param {Object} range - The range object containing the from and to positions
* @param {Object} variables - The variables to replace in the canned response (optional)
* @returns {Object} - The created content node and the updated from and to positions
*/
const getContentNode = (editorView, type, content, range, variables) => {
const methodMap = {
mention: getMentionNode,
cannedResponse: getCannedResponseNode,
variable: getVariableNode,
};
if (!methodMap[type]) {
return { node: null, from: range.from, to: range.to };
}
let { node, from, to } = methodMap[type](
editorView,
content,
range.from,
range.to,
variables
);
return { node, from, to };
};
return {
getContentNode,
};
}
@@ -0,0 +1,115 @@
import useSpecialContent from 'dashboard/composables/editor/useSpecialContent';
import {
MessageMarkdownTransformer,
messageSchema,
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
jest.mock('@chatwoot/prosemirror-schema', () => ({
MessageMarkdownTransformer: jest.fn(),
messageSchema: {},
}));
jest.mock('@chatwoot/utils', () => ({
replaceVariablesInMessage: jest.fn(),
}));
describe('useSpecialContent', () => {
let editorView;
let specialContent;
beforeEach(() => {
editorView = {
state: {
schema: {
nodes: {
mention: {
create: jest.fn(),
},
},
text: jest.fn(),
},
},
};
specialContent = useSpecialContent();
});
describe('getMentionNode', () => {
it('should create a mention node', () => {
const content = { id: 1, name: 'John Doe' };
const from = 0;
const to = 10;
specialContent.getContentNode(editorView, 'mention', content, {
from,
to,
});
expect(editorView.state.schema.nodes.mention.create).toHaveBeenCalledWith(
{
userId: content.id,
userFullName: content.name,
}
);
});
});
describe('getCannedResponseNode', () => {
it('should create a canned response node', () => {
const content = 'Hello {{name}}';
const variables = { name: 'John' };
const from = 0;
const to = 10;
const updatedMessage = 'Hello John';
replaceVariablesInMessage.mockReturnValue(updatedMessage);
MessageMarkdownTransformer.mockImplementation(() => ({
parse: jest.fn().mockReturnValue({ textContent: updatedMessage }),
}));
const { node } = specialContent.getContentNode(
editorView,
'cannedResponse',
content,
{ from, to },
variables
);
expect(replaceVariablesInMessage).toHaveBeenCalledWith({
message: content,
variables,
});
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema);
expect(node.textContent).toBe(updatedMessage);
});
});
describe('getVariableNode', () => {
it('should create a variable node', () => {
const content = 'name';
const from = 0;
const to = 10;
specialContent.getContentNode(editorView, 'variable', content, {
from,
to,
});
expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}');
});
});
describe('getContentNode', () => {
it('should return null for invalid type', () => {
const content = 'invalid';
const from = 0;
const to = 10;
const { node } = specialContent.getContentNode(
editorView,
'invalid',
content,
{ from, to }
);
expect(node).toBeNull();
});
});
});
@@ -2,7 +2,10 @@ import {
messageSchema,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
buildEditor,
EditorState,
} from '@chatwoot/prosemirror-schema';
import * as Sentry from '@sentry/browser';
/**
@@ -281,3 +284,24 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
}
}
}
export const createState = (
content,
placeholder,
// eslint-disable-next-line default-param-last
plugins = [],
// eslint-disable-next-line default-param-last
methods = {},
enabledMenuOptions
) => {
return EditorState.create({
doc: new MessageMarkdownTransformer(messageSchema).parse(content),
plugins: buildEditor({
schema: messageSchema,
placeholder,
methods,
plugins,
enabledMenuOptions,
}),
});
};