diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index 9633687ec..70fda9540 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -33,9 +33,9 @@ export const FORMATTING = { ], }, 'Channel::Api': { - marks: [], + marks: ['strong', 'em'], nodes: [], - menu: [], + menu: ['strong', 'em', 'undo', 'redo'], }, 'Channel::FacebookPage': { marks: ['strong', 'em', 'code', 'strike'], @@ -153,6 +153,80 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [ 'code', ]; +/** + * Markdown formatting patterns for stripping unsupported formatting. + * + * Maps camelCase type names to ProseMirror snake_case schema names. + * Order matters: codeBlock before code to avoid partial matches. + */ +export const MARKDOWN_PATTERNS = [ + // --- BLOCK NODES --- + { + type: 'codeBlock', // PM: code_block, eg: ```js\ncode\n``` + patterns: [ + { pattern: /`{3}(?:\w+)?\n?([\s\S]*?)`{3}/g, replacement: '$1' }, + ], + }, + { + type: 'blockquote', // PM: blockquote, eg: > quote + patterns: [{ pattern: /^> ?/gm, replacement: '' }], + }, + { + type: 'bulletList', // PM: bullet_list, eg: - item + patterns: [{ pattern: /^[\t ]*[-*+]\s+/gm, replacement: '' }], + }, + { + type: 'orderedList', // PM: ordered_list, eg: 1. item + patterns: [{ pattern: /^[\t ]*\d+\.\s+/gm, replacement: '' }], + }, + { + type: 'heading', // PM: heading, eg: ## Heading + patterns: [{ pattern: /^#{1,6}\s+/gm, replacement: '' }], + }, + { + type: 'horizontalRule', // PM: horizontal_rule, eg: --- + patterns: [{ pattern: /^(?:---|___|\*\*\*)\s*$/gm, replacement: '' }], + }, + { + type: 'image', // PM: image, eg: ![alt](url) + patterns: [{ pattern: /!\[([^\]]*)\]\([^)]+\)/g, replacement: '$1' }], + }, + { + type: 'hardBreak', // PM: hard_break, eg: line\\\n or line \n + patterns: [ + { pattern: /\\\n/g, replacement: '\n' }, + { pattern: / {2,}\n/g, replacement: '\n' }, + ], + }, + // --- INLINE MARKS --- + { + type: 'strong', // PM: strong, eg: **bold** or __bold__ + patterns: [ + { pattern: /\*\*(.+?)\*\*/g, replacement: '$1' }, + { pattern: /__(.+?)__/g, replacement: '$1' }, + ], + }, + { + type: 'em', // PM: em, eg: *italic* or _italic_ + patterns: [ + { pattern: /(? [k, true])); + const supportedNodes = Object.keys(camelcaseKeys(nodeKeysObj)); + + // Process each formatting type in order (codeBlock before code is important!) + MARKDOWN_PATTERNS.forEach(({ type, patterns }) => { + // Check if this format type is supported by the schema + const isMarkSupported = supportedMarks.includes(type); + const isNodeSupported = supportedNodes.includes(type); + + // If not supported, strip the formatting + if (!isMarkSupported && !isNodeSupported) { + patterns.forEach(({ pattern, replacement }) => { + sanitizedContent = sanitizedContent.replace(pattern, replacement); + }); + } + }); + + return sanitizedContent; +} + /** * Content Node Creation Helper Functions for * - mention @@ -314,8 +356,17 @@ const createNode = (editorView, nodeType, content) => { return mentionNode; } - case 'cannedResponse': - return new MessageMarkdownTransformer(state.schema).parse(content); + case 'cannedResponse': { + // Strip unsupported formatting before parsing to ensure content can be inserted + // into channels that don't support certain markdown features (e.g., API channels) + const sanitizedContent = stripUnsupportedFormatting( + content, + state.schema + ); + return new MessageMarkdownTransformer(state.schema).parse( + sanitizedContent + ); + } case 'variable': return state.schema.text(`{{${content}}}`); case 'emoji': diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index 664a1a42f..abdd07e6a 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -13,6 +13,7 @@ import { getSelectionCoords, getMenuAnchor, calculateMenuPosition, + stripUnsupportedFormatting, } from '../editorHelper'; import { FORMATTING } from 'dashboard/constants/editor'; import { EditorState } from '@chatwoot/prosemirror-schema'; @@ -745,6 +746,157 @@ describe('getFormattingForEditor', () => { }); }); +describe('stripUnsupportedFormatting', () => { + describe('when schema supports all formatting', () => { + const fullSchema = { + marks: { strong: {}, em: {}, code: {}, strike: {}, link: {} }, + nodes: { bulletList: {}, orderedList: {}, codeBlock: {}, blockquote: {} }, + }; + + it('preserves all formatting when schema supports it', () => { + const content = '**bold** and *italic* and `code`'; + expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content); + }); + + it('preserves links when schema supports them', () => { + const content = 'Check [this link](https://example.com)'; + expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content); + }); + + it('preserves lists when schema supports them', () => { + const content = '- item 1\n- item 2\n1. first\n2. second'; + expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content); + }); + }); + + describe('when schema has no formatting support (eg:SMS channel)', () => { + const emptySchema = { + marks: {}, + nodes: {}, + }; + + it('strips bold formatting', () => { + expect(stripUnsupportedFormatting('**bold text**', emptySchema)).toBe( + 'bold text' + ); + expect(stripUnsupportedFormatting('__bold text__', emptySchema)).toBe( + 'bold text' + ); + }); + + it('strips italic formatting', () => { + expect(stripUnsupportedFormatting('*italic text*', emptySchema)).toBe( + 'italic text' + ); + expect(stripUnsupportedFormatting('_italic text_', emptySchema)).toBe( + 'italic text' + ); + }); + + it('strips inline code formatting', () => { + expect(stripUnsupportedFormatting('`inline code`', emptySchema)).toBe( + 'inline code' + ); + }); + + it('strips strikethrough formatting', () => { + expect(stripUnsupportedFormatting('~~strikethrough~~', emptySchema)).toBe( + 'strikethrough' + ); + }); + + it('strips links but keeps text', () => { + expect( + stripUnsupportedFormatting( + 'Check [this link](https://example.com)', + emptySchema + ) + ).toBe('Check this link'); + }); + + it('strips bullet list markers', () => { + expect( + stripUnsupportedFormatting('- item 1\n- item 2', emptySchema) + ).toBe('item 1\nitem 2'); + expect( + stripUnsupportedFormatting('* item 1\n* item 2', emptySchema) + ).toBe('item 1\nitem 2'); + }); + + it('strips ordered list markers', () => { + expect( + stripUnsupportedFormatting('1. first\n2. second', emptySchema) + ).toBe('first\nsecond'); + }); + + it('strips code block markers', () => { + expect( + stripUnsupportedFormatting('```javascript\ncode here\n```', emptySchema) + ).toBe('code here\n'); + }); + + it('strips blockquote markers', () => { + expect(stripUnsupportedFormatting('> quoted text', emptySchema)).toBe( + 'quoted text' + ); + }); + + it('handles complex content with multiple formatting types', () => { + const content = + '**Bold** and *italic* with `code` and [link](url)\n- list item'; + const expected = 'Bold and italic with code and link\nlist item'; + expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected); + }); + }); + + describe('when schema has partial support', () => { + const partialSchema = { + marks: { strong: {}, em: {} }, + nodes: {}, + }; + + it('preserves supported marks and strips unsupported ones', () => { + const content = '**bold** and `code`'; + expect(stripUnsupportedFormatting(content, partialSchema)).toBe( + '**bold** and code' + ); + }); + + it('strips unsupported nodes but keeps supported marks', () => { + const content = '**bold** text\n- list item'; + expect(stripUnsupportedFormatting(content, partialSchema)).toBe( + '**bold** text\nlist item' + ); + }); + }); + + describe('edge cases', () => { + it('returns content unchanged if content is empty', () => { + expect(stripUnsupportedFormatting('', {})).toBe(''); + }); + + it('returns content unchanged if content is null', () => { + expect(stripUnsupportedFormatting(null, {})).toBe(null); + }); + + it('returns content unchanged if content is undefined', () => { + expect(stripUnsupportedFormatting(undefined, {})).toBe(undefined); + }); + + it('returns content unchanged if schema is null', () => { + expect(stripUnsupportedFormatting('**bold**', null)).toBe('**bold**'); + }); + + it('handles nested formatting correctly', () => { + const emptySchema = { marks: {}, nodes: {} }; + // After stripping bold (**), the remaining *and italic* becomes italic and is stripped too + expect( + stripUnsupportedFormatting('**bold *and italic***', emptySchema) + ).toBe('bold and italic'); + }); + }); +}); + describe('Menu positioning helpers', () => { const mockEditorView = { coordsAtPos: vi.fn((pos, bias) => { diff --git a/app/javascript/dashboard/routes/dashboard/settings/canned/AddCanned.vue b/app/javascript/dashboard/routes/dashboard/settings/canned/AddCanned.vue index 3ec03d5ef..56caa2558 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/canned/AddCanned.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/canned/AddCanned.vue @@ -110,6 +110,7 @@ export default { v-model="content" class="message-editor [&>div]:px-1" :class="{ editor_warning: v$.content.$error }" + channel-type="Context::Default" enable-variables :enable-canned-responses="false" :placeholder="$t('CANNED_MGMT.ADD.FORM.CONTENT.PLACEHOLDER')" diff --git a/app/javascript/dashboard/routes/dashboard/settings/canned/EditCanned.vue b/app/javascript/dashboard/routes/dashboard/settings/canned/EditCanned.vue index 26a590ed3..d2c906511 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/canned/EditCanned.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/canned/EditCanned.vue @@ -114,6 +114,7 @@ export default { v-model="content" class="message-editor [&>div]:px-1" :class="{ editor_warning: v$.content.$error }" + channel-type="Context::Default" enable-variables :enable-canned-responses="false" :placeholder="$t('CANNED_MGMT.EDIT.FORM.CONTENT.PLACEHOLDER')" diff --git a/package.json b/package.json index 111737624..84c266880 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "dependencies": { "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", - "@chatwoot/prosemirror-schema": "1.2.5", + "@chatwoot/prosemirror-schema": "1.2.6", "@chatwoot/utils": "^0.0.51", "@formkit/core": "^1.6.7", "@formkit/vue": "^1.6.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81032cf7f..602a3c5c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,8 +20,8 @@ importers: specifier: 1.2.3 version: 1.2.3 '@chatwoot/prosemirror-schema': - specifier: 1.2.5 - version: 1.2.5 + specifier: 1.2.6 + version: 1.2.6 '@chatwoot/utils': specifier: ^0.0.51 version: 0.0.51 @@ -421,8 +421,8 @@ packages: '@chatwoot/ninja-keys@1.2.3': resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==} - '@chatwoot/prosemirror-schema@1.2.5': - resolution: {integrity: sha512-nwi0G17jLiRwIzjjQXr9gTZRcDf5BQzo44XxO6CItfR02W5RExugurvHlpc88R4tUrDtIQHGM2Q2vijvFUNIkA==} + '@chatwoot/prosemirror-schema@1.2.6': + resolution: {integrity: sha512-ej60kU3m/tP0VoGkOJhj0X+Mxt7fEX5DSEE4IibCZnTM4kMewkMxSYyPu0AXqaA4nKX1hTMrwcbv1t7gVQttWQ==} '@chatwoot/utils@0.0.51': resolution: {integrity: sha512-WlEmWfOTzR7YZRUWzn5Wpm15/BRudpwqoNckph8TohyDbiim1CP4UZGa+qjajxTbNGLLhtKlm0Xl+X16+5Wceg==} @@ -4862,7 +4862,7 @@ snapshots: hotkeys-js: 3.8.7 lit: 2.2.6 - '@chatwoot/prosemirror-schema@1.2.5': + '@chatwoot/prosemirror-schema@1.2.6': dependencies: markdown-it-sup: 2.0.0 prosemirror-commands: 1.6.0