feat: Standardize rich editor across all channels (#12600)
# 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>
This commit is contained in:
@@ -10,7 +10,7 @@ RSpec.describe ChatwootMarkdownRenderer do
|
||||
let(:html_content) { '<p>This is a <em>test</em> content with <sup>markdown</sup></p>' }
|
||||
|
||||
before do
|
||||
allow(CommonMarker).to receive(:render_doc).with(markdown_content, :DEFAULT).and_return(doc)
|
||||
allow(CommonMarker).to receive(:render_doc).with(markdown_content, :DEFAULT, [:strikethrough]).and_return(doc)
|
||||
allow(CustomMarkdownRenderer).to receive(:new).and_return(markdown_renderer)
|
||||
allow(markdown_renderer).to receive(:render).with(doc).and_return(html_content)
|
||||
end
|
||||
@@ -86,6 +86,7 @@ RSpec.describe ChatwootMarkdownRenderer do
|
||||
let(:rendered_content) { renderer.render_markdown_to_plain_text }
|
||||
|
||||
before do
|
||||
allow(CommonMarker).to receive(:render_doc).with(markdown_content, :DEFAULT).and_return(doc)
|
||||
allow(doc).to receive(:to_plaintext).and_return(plain_text_content)
|
||||
end
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ RSpec.describe MessageContentPresenter do
|
||||
let(:content_type) { 'text' }
|
||||
let(:content) { 'Regular message' }
|
||||
|
||||
it 'returns regular content' do
|
||||
expect(presenter.outgoing_content).to eq('Regular message')
|
||||
it 'returns content transformed for channel (HTML for WebWidget)' do
|
||||
expect(presenter.outgoing_content).to eq("<p>Regular message</p>\n")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,8 +23,8 @@ RSpec.describe MessageContentPresenter do
|
||||
allow(message.inbox).to receive(:web_widget?).and_return(true)
|
||||
end
|
||||
|
||||
it 'returns regular content without survey URL' do
|
||||
expect(presenter.outgoing_content).to eq('Rate your experience')
|
||||
it 'returns content without survey URL (HTML for WebWidget)' do
|
||||
expect(presenter.outgoing_content).to eq("<p>Rate your experience</p>\n")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -36,18 +36,20 @@ RSpec.describe MessageContentPresenter do
|
||||
allow(message.inbox).to receive(:web_widget?).and_return(false)
|
||||
end
|
||||
|
||||
it 'returns I18n default message when no CSAT config and dynamically generates survey URL' do
|
||||
it 'returns I18n default message when no CSAT config and dynamically generates survey URL (HTML format)' do
|
||||
with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
|
||||
expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
|
||||
expect(presenter.outgoing_content).to include(expected_url)
|
||||
expect(presenter.outgoing_content).to include('<p>')
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns CSAT config message when config exists and dynamically generates survey URL' do
|
||||
it 'returns CSAT config message when config exists and dynamically generates survey URL (HTML format)' do
|
||||
with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
|
||||
allow(message.inbox).to receive(:csat_config).and_return({ 'message' => 'Custom CSAT message' })
|
||||
expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
|
||||
expect(presenter.outgoing_content).to eq("Custom CSAT message #{expected_url}")
|
||||
expected_content = "<p>Custom CSAT message #{expected_url}</p>\n"
|
||||
expect(presenter.outgoing_content).to eq(expected_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
366
spec/services/messages/markdown_renderer_service_spec.rb
Normal file
366
spec/services/messages/markdown_renderer_service_spec.rb
Normal file
@@ -0,0 +1,366 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Messages::MarkdownRendererService, type: :service do
|
||||
describe '#render' do
|
||||
context 'when content is blank' do
|
||||
it 'returns the content as-is for nil' do
|
||||
result = described_class.new(nil, 'Channel::Whatsapp').render
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'returns the content as-is for empty string' do
|
||||
result = described_class.new('', 'Channel::Whatsapp').render
|
||||
expect(result).to eq('')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::Whatsapp' do
|
||||
let(:channel_type) { 'Channel::Whatsapp' }
|
||||
|
||||
it 'converts bold from double to single asterisk' do
|
||||
content = '**bold text**'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('*bold text*')
|
||||
end
|
||||
|
||||
it 'keeps italic with underscore' do
|
||||
content = '_italic text_'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('_italic text_')
|
||||
end
|
||||
|
||||
it 'keeps code with backticks' do
|
||||
content = '`code`'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('`code`')
|
||||
end
|
||||
|
||||
it 'converts links to URLs only' do
|
||||
content = '[link text](https://example.com)'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('https://example.com')
|
||||
end
|
||||
|
||||
it 'handles combined formatting' do
|
||||
content = '**bold** _italic_ `code` [link](https://example.com)'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('*bold* _italic_ `code` https://example.com')
|
||||
end
|
||||
|
||||
it 'handles nested formatting' do
|
||||
content = '**bold _italic_**'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('*bold _italic_*')
|
||||
end
|
||||
|
||||
it 'converts bullet lists' do
|
||||
content = "- item 1\n- item 2"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to include('- item 1')
|
||||
expect(result.strip).to include('- item 2')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::Instagram' do
|
||||
let(:channel_type) { 'Channel::Instagram' }
|
||||
|
||||
it 'converts bold from double to single asterisk' do
|
||||
content = '**bold text**'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('*bold text*')
|
||||
end
|
||||
|
||||
it 'keeps italic with underscore' do
|
||||
content = '_italic text_'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('_italic text_')
|
||||
end
|
||||
|
||||
it 'strips code backticks' do
|
||||
content = '`code`'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('code')
|
||||
end
|
||||
|
||||
it 'converts links to URLs only' do
|
||||
content = '[link text](https://example.com)'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('https://example.com')
|
||||
end
|
||||
|
||||
it 'preserves bullet list markers' do
|
||||
content = "- first item\n- second item"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('- first item')
|
||||
expect(result).to include('- second item')
|
||||
end
|
||||
|
||||
it 'preserves ordered list markers with numbering' do
|
||||
content = "1. first step\n2. second step"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('1. first step')
|
||||
expect(result).to include('2. second step')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::Line' do
|
||||
let(:channel_type) { 'Channel::Line' }
|
||||
|
||||
it 'adds spaces around bold markers' do
|
||||
content = '**bold**'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include(' *bold* ')
|
||||
end
|
||||
|
||||
it 'adds spaces around italic markers' do
|
||||
content = '_italic_'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include(' _italic_ ')
|
||||
end
|
||||
|
||||
it 'adds spaces around code markers' do
|
||||
content = '`code`'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include(' `code` ')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::Sms' do
|
||||
let(:channel_type) { 'Channel::Sms' }
|
||||
|
||||
it 'strips all markdown formatting' do
|
||||
content = '**bold** _italic_ `code`'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('bold italic code')
|
||||
end
|
||||
|
||||
it 'preserves URLs from links in plain text format' do
|
||||
content = '[link text](https://example.com)'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq('link text https://example.com')
|
||||
end
|
||||
|
||||
it 'preserves URLs in messages with multiple links' do
|
||||
content = 'Visit [our site](https://example.com) or [help center](https://help.example.com)'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq('Visit our site https://example.com or help center https://help.example.com')
|
||||
end
|
||||
|
||||
it 'preserves link text and URL when both are present' do
|
||||
content = '[Reset password](https://example.com/reset)'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq('Reset password https://example.com/reset')
|
||||
end
|
||||
|
||||
it 'handles complex markdown' do
|
||||
content = "# Heading\n\n**bold** _italic_ [link](https://example.com)"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('Heading')
|
||||
expect(result).to include('bold')
|
||||
expect(result).to include('italic')
|
||||
expect(result).to include('link https://example.com')
|
||||
expect(result).not_to include('**')
|
||||
expect(result).not_to include('_')
|
||||
expect(result).not_to include('[')
|
||||
end
|
||||
|
||||
it 'preserves bullet list markers' do
|
||||
content = "- first item\n- second item\n- third item"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('- first item')
|
||||
expect(result).to include('- second item')
|
||||
expect(result).to include('- third item')
|
||||
end
|
||||
|
||||
it 'preserves ordered list markers with numbering' do
|
||||
content = "1. first step\n2. second step\n3. third step"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('1. first step')
|
||||
expect(result).to include('2. second step')
|
||||
expect(result).to include('3. third step')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::Telegram' do
|
||||
let(:channel_type) { 'Channel::Telegram' }
|
||||
|
||||
it 'converts to HTML format' do
|
||||
content = '**bold** _italic_ `code`'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('<strong>bold</strong>')
|
||||
expect(result).to include('<em>italic</em>')
|
||||
expect(result).to include('<code>code</code>')
|
||||
end
|
||||
|
||||
it 'handles links' do
|
||||
content = '[link text](https://example.com)'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('<a href="https://example.com">link text</a>')
|
||||
end
|
||||
|
||||
it 'preserves newlines' do
|
||||
content = "line 1\nline 2"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include("\n")
|
||||
end
|
||||
|
||||
it 'converts strikethrough to HTML' do
|
||||
content = '~~strikethrough text~~'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('<del>strikethrough text</del>')
|
||||
end
|
||||
|
||||
it 'converts blockquotes to HTML' do
|
||||
content = '> quoted text'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('<blockquote>')
|
||||
expect(result).to include('quoted text')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::Email' do
|
||||
let(:channel_type) { 'Channel::Email' }
|
||||
|
||||
it 'renders full HTML' do
|
||||
content = '**bold** _italic_'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('<strong>bold</strong>')
|
||||
expect(result).to include('<em>italic</em>')
|
||||
end
|
||||
|
||||
it 'renders ordered lists as HTML' do
|
||||
content = "1. first\n2. second"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('<ol>')
|
||||
expect(result).to include('<li>first</li>')
|
||||
end
|
||||
|
||||
it 'converts strikethrough to HTML' do
|
||||
content = '~~strikethrough text~~'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('<del>strikethrough text</del>')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::WebWidget' do
|
||||
let(:channel_type) { 'Channel::WebWidget' }
|
||||
|
||||
it 'renders full HTML like Email' do
|
||||
content = '**bold** _italic_ `code`'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('<strong>bold</strong>')
|
||||
expect(result).to include('<em>italic</em>')
|
||||
expect(result).to include('<code>code</code>')
|
||||
end
|
||||
|
||||
it 'converts strikethrough to HTML' do
|
||||
content = '~~strikethrough text~~'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('<del>strikethrough text</del>')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::FacebookPage' do
|
||||
let(:channel_type) { 'Channel::FacebookPage' }
|
||||
|
||||
it 'converts bold to single asterisk like Instagram' do
|
||||
content = '**bold text**'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('*bold text*')
|
||||
end
|
||||
|
||||
it 'strips unsupported formatting' do
|
||||
content = '`code` ~~strike~~'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('code ~~strike~~')
|
||||
end
|
||||
|
||||
it 'preserves bullet list markers like Instagram' do
|
||||
content = "- first item\n- second item"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('- first item')
|
||||
expect(result).to include('- second item')
|
||||
end
|
||||
|
||||
it 'preserves ordered list markers with numbering like Instagram' do
|
||||
content = "1. first step\n2. second step"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('1. first step')
|
||||
expect(result).to include('2. second step')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::TwilioSms' do
|
||||
let(:channel_type) { 'Channel::TwilioSms' }
|
||||
|
||||
it 'strips all markdown like SMS' do
|
||||
content = '**bold** _italic_'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to eq('bold italic')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::Api' do
|
||||
let(:channel_type) { 'Channel::Api' }
|
||||
|
||||
it 'preserves markdown as-is' do
|
||||
content = '**bold** _italic_ `code`'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq('**bold** _italic_ `code`')
|
||||
end
|
||||
|
||||
it 'preserves links with markdown syntax' do
|
||||
content = '[Click here](https://example.com)'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq('[Click here](https://example.com)')
|
||||
end
|
||||
|
||||
it 'preserves lists with markdown syntax' do
|
||||
content = "- Item 1\n- Item 2"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq("- Item 1\n- Item 2")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is Channel::TwitterProfile' do
|
||||
let(:channel_type) { 'Channel::TwitterProfile' }
|
||||
|
||||
it 'strips all markdown like SMS' do
|
||||
content = '**bold** [link](https://example.com)'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('bold')
|
||||
expect(result).to include('link https://example.com')
|
||||
expect(result).not_to include('**')
|
||||
expect(result).not_to include('[')
|
||||
end
|
||||
|
||||
it 'preserves URLs from links' do
|
||||
content = '[Reset password](https://example.com/reset)'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq('Reset password https://example.com/reset')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when testing all formatting types' do
|
||||
let(:channel_type) { 'Channel::Whatsapp' }
|
||||
|
||||
it 'handles ordered lists' do
|
||||
content = "1. first\n2. second\n3. third"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('first')
|
||||
expect(result).to include('second')
|
||||
expect(result).to include('third')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is unknown' do
|
||||
let(:channel_type) { 'Channel::Unknown' }
|
||||
|
||||
it 'returns content as-is' do
|
||||
content = '**bold** _italic_'
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to eq(content)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user