From 2bd8e76886e23f90ee67e36be8ce8ba746c7c37c Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 11 Dec 2025 16:36:37 +0530 Subject: [PATCH 1/4] feat: Add backend changes for whatsapp csat template (#12984) This PR add the backend changes for the feature [sending CSAT surveys via WhatsApp message templates ](https://github.com/chatwoot/chatwoot/pull/12787) --------- Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com> --- .../inbox_csat_templates_controller.rb | 160 ++++++++ .../api/v1/accounts/inboxes_controller.rb | 38 +- .../whatsapp/csat_template_name_service.rb | 49 +++ .../whatsapp/csat_template_service.rb | 139 +++++++ .../providers/whatsapp_cloud_service.rb | 19 + config/routes.rb | 2 + .../inbox_csat_templates_controller_spec.rb | 383 ++++++++++++++++++ .../v1/accounts/inboxes_controller_spec.rb | 95 +++++ .../whatsapp/csat_template_service_spec.rb | 376 +++++++++++++++++ .../providers/whatsapp_cloud_service_spec.rb | 88 ++++ 10 files changed, 1331 insertions(+), 18 deletions(-) create mode 100644 app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb create mode 100644 app/services/whatsapp/csat_template_name_service.rb create mode 100644 app/services/whatsapp/csat_template_service.rb create mode 100644 spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb create mode 100644 spec/services/whatsapp/csat_template_service_spec.rb diff --git a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb new file mode 100644 index 000000000..d17fe35fb --- /dev/null +++ b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb @@ -0,0 +1,160 @@ +class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController + DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze + DEFAULT_LANGUAGE = 'en'.freeze + + before_action :fetch_inbox + before_action :validate_whatsapp_channel + + def show + template = @inbox.csat_config&.dig('template') + return render json: { template_exists: false } unless template + + template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id) + status_result = @inbox.channel.provider_service.get_template_status(template_name) + + render_template_status_response(status_result, template_name) + rescue StandardError => e + Rails.logger.error "Error fetching CSAT template status: #{e.message}" + render json: { error: e.message }, status: :internal_server_error + end + + def create + template_params = extract_template_params + return render_missing_message_error if template_params[:message].blank? + + # Delete existing template even though we are using a new one. + # We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails. + delete_existing_template_if_needed + + result = create_template_via_provider(template_params) + render_template_creation_result(result) + rescue ActionController::ParameterMissing + render json: { error: 'Template parameters are required' }, status: :unprocessable_entity + rescue StandardError => e + Rails.logger.error "Error creating CSAT template: #{e.message}" + render json: { error: 'Template creation failed' }, status: :internal_server_error + end + + private + + def fetch_inbox + @inbox = Current.account.inboxes.find(params[:inbox_id]) + authorize @inbox, :show? + end + + def validate_whatsapp_channel + return if @inbox.whatsapp? + + render json: { error: 'CSAT template operations only available for WhatsApp channels' }, + status: :bad_request + end + + def extract_template_params + params.require(:template).permit(:message, :button_text, :language) + end + + def render_missing_message_error + render json: { error: 'Message is required' }, status: :unprocessable_entity + end + + def create_template_via_provider(template_params) + template_config = { + message: template_params[:message], + button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT, + base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'), + language: template_params[:language] || DEFAULT_LANGUAGE, + template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id) + } + + @inbox.channel.provider_service.create_csat_template(template_config) + end + + def render_template_creation_result(result) + if result[:success] + render_successful_template_creation(result) + else + render_failed_template_creation(result) + end + end + + def render_successful_template_creation(result) + render json: { + template: { + name: result[:template_name], + template_id: result[:template_id], + status: 'PENDING', + language: result[:language] || DEFAULT_LANGUAGE + } + }, status: :created + end + + def render_failed_template_creation(result) + whatsapp_error = parse_whatsapp_error(result[:response_body]) + error_message = whatsapp_error[:user_message] || result[:error] + + render json: { + error: error_message, + details: whatsapp_error[:technical_details] + }, status: :unprocessable_entity + end + + def delete_existing_template_if_needed + template = @inbox.csat_config&.dig('template') + return true if template.blank? + + template_name = template['name'] + return true if template_name.blank? + + template_status = @inbox.channel.provider_service.get_template_status(template_name) + return true unless template_status[:success] + + deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name) + if deletion_result[:success] + Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}" + true + else + Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}" + false + end + rescue StandardError => e + Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}" + false + end + + def render_template_status_response(status_result, template_name) + if status_result[:success] + render json: { + template_exists: true, + template_name: template_name, + status: status_result[:template][:status], + template_id: status_result[:template][:id] + } + else + render json: { + template_exists: false, + error: 'Template not found' + } + end + end + + def parse_whatsapp_error(response_body) + return { user_message: nil, technical_details: nil } if response_body.blank? + + begin + error_data = JSON.parse(response_body) + whatsapp_error = error_data['error'] || {} + + user_message = whatsapp_error['error_user_msg'] || whatsapp_error['message'] + technical_details = { + code: whatsapp_error['code'], + subcode: whatsapp_error['error_subcode'], + type: whatsapp_error['type'], + title: whatsapp_error['error_user_title'] + }.compact + + { user_message: user_message, technical_details: technical_details } + rescue JSON::ParserError + { user_message: nil, technical_details: response_body } + end + end +end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index ae1d4369a..1c8845c04 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -152,31 +152,37 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def format_csat_config(config) - { - display_type: config['display_type'] || 'emoji', - message: config['message'] || '', - survey_rules: { - operator: config.dig('survey_rules', 'operator') || 'contains', - values: config.dig('survey_rules', 'values') || [] - } + formatted = { + 'display_type' => config['display_type'] || 'emoji', + 'message' => config['message'] || '', + :survey_rules => { + 'operator' => config.dig('survey_rules', 'operator') || 'contains', + 'values' => config.dig('survey_rules', 'values') || [] + }, + 'button_text' => config['button_text'] || 'Please rate us', + 'language' => config['language'] || 'en' } + format_template_config(config, formatted) + formatted + end + + def format_template_config(config, formatted) + formatted['template'] = config['template'] if config['template'].present? end def inbox_attributes [:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved, :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name, - { csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }] + { csat_config: [:display_type, :message, :button_text, :language, + { survey_rules: [:operator, { values: [] }], + template: [:name, :template_id, :created_at, :language] }] }] end def permitted_params(channel_attributes = []) # We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend params.each { |k, v| params[k] = params[k] == 'null' ? nil : v } - - params.permit( - *inbox_attributes, - channel: [:type, *channel_attributes] - ) + params.permit(*inbox_attributes, channel: [:type, *channel_attributes]) end def channel_type_from_params @@ -192,11 +198,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def get_channel_attributes(channel_type) - if channel_type.constantize.const_defined?(:EDITABLE_ATTRS) - channel_type.constantize::EDITABLE_ATTRS.presence - else - [] - end + channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : [] end def whatsapp_channel? diff --git a/app/services/whatsapp/csat_template_name_service.rb b/app/services/whatsapp/csat_template_name_service.rb new file mode 100644 index 000000000..223efda04 --- /dev/null +++ b/app/services/whatsapp/csat_template_name_service.rb @@ -0,0 +1,49 @@ +class Whatsapp::CsatTemplateNameService + CSAT_BASE_NAME = 'customer_satisfaction_survey'.freeze + + # Generates template names like: customer_satisfaction_survey_{inbox_id}_{version_number} + + def self.csat_template_name(inbox_id, version = nil) + base_name = csat_base_name_for_inbox(inbox_id) + version ? "#{base_name}_#{version}" : base_name + end + + def self.extract_version(template_name, inbox_id) + return nil if template_name.blank? + + pattern = versioned_pattern_for_inbox(inbox_id) + match = template_name.match(pattern) + match ? match[1].to_i : nil + end + + def self.generate_next_template_name(base_name, inbox_id, current_template_name) + return base_name if current_template_name.blank? + + current_version = extract_version(current_template_name, inbox_id) + next_version = current_version ? current_version + 1 : 1 + csat_template_name(inbox_id, next_version) + end + + def self.matches_csat_pattern?(template_name, inbox_id) + return false if template_name.blank? + + base_pattern = base_pattern_for_inbox(inbox_id) + versioned_pattern = versioned_pattern_for_inbox(inbox_id) + + template_name.match?(base_pattern) || template_name.match?(versioned_pattern) + end + + def self.csat_base_name_for_inbox(inbox_id) + "#{CSAT_BASE_NAME}_#{inbox_id}" + end + + def self.base_pattern_for_inbox(inbox_id) + /^#{CSAT_BASE_NAME}_#{inbox_id}$/ + end + + def self.versioned_pattern_for_inbox(inbox_id) + /^#{CSAT_BASE_NAME}_#{inbox_id}_(\d+)$/ + end + + private_class_method :csat_base_name_for_inbox, :base_pattern_for_inbox, :versioned_pattern_for_inbox +end diff --git a/app/services/whatsapp/csat_template_service.rb b/app/services/whatsapp/csat_template_service.rb new file mode 100644 index 000000000..43009b484 --- /dev/null +++ b/app/services/whatsapp/csat_template_service.rb @@ -0,0 +1,139 @@ +class Whatsapp::CsatTemplateService + DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze + DEFAULT_LANGUAGE = 'en'.freeze + WHATSAPP_API_VERSION = 'v14.0'.freeze + TEMPLATE_CATEGORY = 'MARKETING'.freeze + TEMPLATE_STATUS_PENDING = 'PENDING'.freeze + + def initialize(whatsapp_channel) + @whatsapp_channel = whatsapp_channel + end + + def create_template(template_config) + base_name = template_config[:template_name] + template_name = generate_template_name(base_name) + template_config_with_name = template_config.merge(template_name: template_name) + request_body = build_template_request_body(template_config_with_name) + response = send_template_creation_request(request_body) + process_template_creation_response(response, template_config_with_name) + end + + def delete_template(template_name = nil) + template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(@whatsapp_channel.inbox.id) + response = HTTParty.delete( + "#{business_account_path}/message_templates?name=#{template_name}", + headers: api_headers + ) + { success: response.success?, response_body: response.body } + end + + def get_template_status(template_name) + response = HTTParty.get("#{business_account_path}/message_templates?name=#{template_name}", headers: api_headers) + + if response.success? && response['data']&.any? + template_data = response['data'].first + { + success: true, + template: { + id: template_data['id'], name: template_data['name'], + status: template_data['status'], language: template_data['language'] + } + } + else + { success: false, error: 'Template not found' } + end + rescue StandardError => e + Rails.logger.error "Error fetching template status: #{e.message}" + { success: false, error: e.message } + end + + private + + def generate_template_name(base_name) + current_template_name = current_template_name_from_config + Whatsapp::CsatTemplateNameService.generate_next_template_name(base_name, @whatsapp_channel.inbox.id, current_template_name) + end + + def current_template_name_from_config + @whatsapp_channel.inbox.csat_config&.dig('template', 'name') + end + + def build_template_request_body(template_config) + { + name: template_config[:template_name], + language: template_config[:language] || DEFAULT_LANGUAGE, + category: TEMPLATE_CATEGORY, + components: build_template_components(template_config) + } + end + + def build_template_components(template_config) + [ + build_body_component(template_config[:message]), + build_buttons_component(template_config) + ] + end + + def build_body_component(message) + { + type: 'BODY', + text: message + } + end + + def build_buttons_component(template_config) + { + type: 'BUTTONS', + buttons: [ + { + type: 'URL', + text: template_config[:button_text] || DEFAULT_BUTTON_TEXT, + url: "#{template_config[:base_url]}/survey/responses/{{1}}", + example: ['12345'] + } + ] + } + end + + def send_template_creation_request(request_body) + HTTParty.post( + "#{business_account_path}/message_templates", + headers: api_headers, + body: request_body.to_json + ) + end + + def process_template_creation_response(response, template_config = {}) + if response.success? + { + success: true, + template_id: response['id'], + template_name: response['name'] || template_config[:template_name], + language: template_config[:language] || DEFAULT_LANGUAGE, + status: TEMPLATE_STATUS_PENDING + } + else + Rails.logger.error "WhatsApp template creation failed: #{response.code} - #{response.body}" + { + success: false, + error: 'Template creation failed', + response_body: response.body + } + end + end + + def business_account_path + "#{api_base_path}/#{WHATSAPP_API_VERSION}/#{@whatsapp_channel.provider_config['business_account_id']}" + end + + def api_headers + { + 'Authorization' => "Bearer #{@whatsapp_channel.provider_config['api_key']}", + 'Content-Type' => 'application/json' + } + end + + def api_base_path + ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com') + end +end diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index 1968693ff..e13ee8979 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -62,12 +62,31 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi { 'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}", 'Content-Type' => 'application/json' } end + def create_csat_template(template_config) + csat_template_service.create_template(template_config) + end + + def delete_csat_template(template_name = nil) + template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(whatsapp_channel.inbox.id) + csat_template_service.delete_template(template_name) + end + + def get_template_status(template_name) + csat_template_service.get_template_status(template_name) + end + def media_url(media_id, phone_number_id = nil) url = "#{api_base_path}/v13.0/#{media_id}" url += "?phone_number_id=#{phone_number_id}" if phone_number_id url end + private + + def csat_template_service + @csat_template_service ||= Whatsapp::CsatTemplateService.new(whatsapp_channel) + end + def api_base_path ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com') end diff --git a/config/routes.rb b/config/routes.rb index 9b22cb333..0ac001612 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -203,6 +203,8 @@ Rails.application.routes.draw do delete :avatar, on: :member post :sync_templates, on: :member get :health, on: :member + + resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates' end resources :inbox_members, only: [:create, :show], param: :inbox_id do collection do diff --git a/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb b/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb new file mode 100644 index 000000000..7ca9083c0 --- /dev/null +++ b/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb @@ -0,0 +1,383 @@ +require 'rails_helper' + +RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + let(:whatsapp_channel) do + create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) + end + let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account) } + let(:web_widget_inbox) { create(:inbox, account: account) } + let(:mock_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) } + + before do + create(:inbox_member, user: agent, inbox: whatsapp_inbox) + allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(mock_service) + end + + describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is not a WhatsApp channel' do + it 'returns bad request' do + get "/api/v1/accounts/#{account.id}/inboxes/#{web_widget_inbox.id}/csat_template", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:bad_request) + expect(response.parsed_body['error']).to eq('CSAT template operations only available for WhatsApp channels') + end + end + + context 'when it is a WhatsApp channel' do + it 'returns template not found when no configuration exists' do + get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body['template_exists']).to be false + end + + it 'returns template status when template exists on WhatsApp' do + template_config = { + 'template' => { + 'name' => 'custom_survey_template', + 'template_id' => '123456789', + 'language' => 'en' + } + } + whatsapp_inbox.update!(csat_config: template_config) + + allow(mock_service).to receive(:get_template_status) + .with('custom_survey_template') + .and_return({ + success: true, + template: { id: '123456789', status: 'APPROVED' } + }) + + get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + response_data = response.parsed_body + expect(response_data['template_exists']).to be true + expect(response_data['template_name']).to eq('custom_survey_template') + expect(response_data['status']).to eq('APPROVED') + expect(response_data['template_id']).to eq('123456789') + end + + it 'returns template not found when template does not exist on WhatsApp' do + template_config = { 'template' => { 'name' => 'custom_survey_template' } } + whatsapp_inbox.update!(csat_config: template_config) + + allow(mock_service).to receive(:get_template_status) + .with('custom_survey_template') + .and_return({ success: false, error: 'Template not found' }) + + get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + response_data = response.parsed_body + expect(response_data['template_exists']).to be false + expect(response_data['error']).to eq('Template not found') + end + + it 'handles service errors gracefully' do + template_config = { 'template' => { 'name' => 'custom_survey_template' } } + whatsapp_inbox.update!(csat_config: template_config) + + allow(mock_service).to receive(:get_template_status) + .and_raise(StandardError, 'API connection failed') + + get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:internal_server_error) + expect(response.parsed_body['error']).to eq('API connection failed') + end + + it 'returns unauthorized when agent is not assigned to inbox' do + other_agent = create(:user, account: account, role: :agent) + + get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", + headers: other_agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'allows access when agent is assigned to inbox' do + whatsapp_inbox.update!(csat_config: { 'template' => { 'name' => 'test' } }) + allow(mock_service).to receive(:get_template_status) + .and_return({ success: true, template: { id: '123', status: 'APPROVED' } }) + + get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + end + end + end + + describe 'POST /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template' do + let(:valid_template_params) do + { + template: { + message: 'How would you rate your experience?', + button_text: 'Rate Us', + language: 'en' + } + } + end + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", + params: valid_template_params, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is not a WhatsApp channel' do + it 'returns bad request' do + post "/api/v1/accounts/#{account.id}/inboxes/#{web_widget_inbox.id}/csat_template", + headers: admin.create_new_auth_token, + params: valid_template_params, + as: :json + + expect(response).to have_http_status(:bad_request) + expect(response.parsed_body['error']).to eq('CSAT template operations only available for WhatsApp channels') + end + end + + context 'when it is a WhatsApp channel' do + it 'returns error when message is missing' do + invalid_params = { + template: { + button_text: 'Rate Us', + language: 'en' + } + } + + post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", + headers: admin.create_new_auth_token, + params: invalid_params, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['error']).to eq('Message is required') + end + + it 'returns error when template parameters are completely missing' do + post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", + headers: admin.create_new_auth_token, + params: {}, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['error']).to eq('Template parameters are required') + end + + it 'creates template successfully' do + allow(mock_service).to receive(:get_template_status).and_return({ success: false }) + allow(mock_service).to receive(:create_csat_template).and_return({ + success: true, + template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}", + template_id: '987654321' + }) + + post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", + headers: admin.create_new_auth_token, + params: valid_template_params, + as: :json + + expect(response).to have_http_status(:created) + response_data = response.parsed_body + expect(response_data['template']['name']).to eq("customer_satisfaction_survey_#{whatsapp_inbox.id}") + expect(response_data['template']['template_id']).to eq('987654321') + expect(response_data['template']['status']).to eq('PENDING') + expect(response_data['template']['language']).to eq('en') + end + + it 'uses default values for optional parameters' do + minimal_params = { + template: { + message: 'How would you rate your experience?' + } + } + + allow(mock_service).to receive(:get_template_status).and_return({ success: false }) + expect(mock_service).to receive(:create_csat_template) do |config| + expect(config[:button_text]).to eq('Please rate us') + expect(config[:language]).to eq('en') + expect(config[:template_name]).to eq("customer_satisfaction_survey_#{whatsapp_inbox.id}") + { success: true, template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}", template_id: '123' } + end + + post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", + headers: admin.create_new_auth_token, + params: minimal_params, + as: :json + + expect(response).to have_http_status(:created) + end + + it 'handles WhatsApp API errors with user-friendly messages' do + whatsapp_error_response = { + 'error' => { + 'code' => 100, + 'error_subcode' => 2_388_092, + 'message' => 'Invalid parameter', + 'error_user_title' => 'Template Creation Failed', + 'error_user_msg' => 'The template message contains invalid content. Please review your message and try again.' + } + } + + allow(mock_service).to receive(:get_template_status).and_return({ success: false }) + allow(mock_service).to receive(:create_csat_template).and_return({ + success: false, + error: 'Template creation failed', + response_body: whatsapp_error_response.to_json + }) + + post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", + headers: admin.create_new_auth_token, + params: valid_template_params, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + response_data = response.parsed_body + expect(response_data['error']).to eq('The template message contains invalid content. Please review your message and try again.') + expect(response_data['details']).to include({ + 'code' => 100, + 'subcode' => 2_388_092, + 'title' => 'Template Creation Failed' + }) + end + + it 'handles generic API errors' do + allow(mock_service).to receive(:get_template_status).and_return({ success: false }) + allow(mock_service).to receive(:create_csat_template).and_return({ + success: false, + error: 'Network timeout', + response_body: nil + }) + + post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", + headers: admin.create_new_auth_token, + params: valid_template_params, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['error']).to eq('Network timeout') + end + + it 'handles unexpected service errors' do + allow(mock_service).to receive(:get_template_status).and_return({ success: false }) + allow(mock_service).to receive(:create_csat_template) + .and_raise(StandardError, 'Unexpected error') + + post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", + headers: admin.create_new_auth_token, + params: valid_template_params, + as: :json + + expect(response).to have_http_status(:internal_server_error) + expect(response.parsed_body['error']).to eq('Template creation failed') + end + + it 'deletes existing template before creating new one' do + whatsapp_inbox.update!(csat_config: { + 'template' => { + 'name' => 'existing_template', + 'template_id' => '111111111' + } + }) + + allow(mock_service).to receive(:get_template_status) + .with('existing_template') + .and_return({ success: true, template: { id: '111111111' } }) + expect(mock_service).to receive(:delete_csat_template) + .with('existing_template') + .and_return({ success: true }) + expect(mock_service).to receive(:create_csat_template) + .and_return({ + success: true, + template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}", + template_id: '222222222' + }) + + post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", + headers: admin.create_new_auth_token, + params: valid_template_params, + as: :json + + expect(response).to have_http_status(:created) + end + + it 'continues with creation even if deletion fails' do + whatsapp_inbox.update!(csat_config: { + 'template' => { 'name' => 'existing_template' } + }) + + allow(mock_service).to receive(:get_template_status).and_return({ success: true }) + allow(mock_service).to receive(:delete_csat_template) + .and_return({ success: false, response_body: 'Delete failed' }) + allow(mock_service).to receive(:create_csat_template).and_return({ + success: true, + template_name: "customer_satisfaction_survey_#{whatsapp_inbox.id}", + template_id: '333333333' + }) + + post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", + headers: admin.create_new_auth_token, + params: valid_template_params, + as: :json + + expect(response).to have_http_status(:created) + end + + it 'returns unauthorized when agent is not assigned to inbox' do + other_agent = create(:user, account: account, role: :agent) + + post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", + headers: other_agent.create_new_auth_token, + params: valid_template_params, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'allows access when agent is assigned to inbox' do + allow(mock_service).to receive(:get_template_status).and_return({ success: false }) + allow(mock_service).to receive(:create_csat_template).and_return({ + success: true, + template_name: 'customer_satisfaction_survey', + template_id: '444444444' + }) + + post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template", + headers: agent.create_new_auth_token, + params: valid_template_params, + as: :json + + expect(response).to have_http_status(:created) + end + end + end +end diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb index a44f60391..fafd6788e 100644 --- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb @@ -804,6 +804,101 @@ RSpec.describe 'Inboxes API', type: :request do expect(found_inbox['csat_config']['display_type']).to eq('emoji') end end + + it 'successfully updates inbox with template configuration' do + csat_config_with_template = csat_config.merge({ + 'template' => { + 'name' => 'custom_survey_template', + 'template_id' => '123456789', + 'language' => 'en', + 'created_at' => Time.current.iso8601 + } + }) + + patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}", + params: { + csat_survey_enabled: true, + csat_config: csat_config_with_template + }, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + + inbox.reload + template_config = inbox.csat_config['template'] + expect(template_config).to be_present + expect(template_config['name']).to eq('custom_survey_template') + expect(template_config['template_id']).to eq('123456789') + expect(template_config['language']).to eq('en') + end + + it 'returns template configuration in inbox details' do + csat_config_with_template = csat_config.merge({ + 'template' => { + 'name' => 'custom_survey_template', + 'template_id' => '123456789', + 'language' => 'en', + 'created_at' => Time.current.iso8601 + } + }) + + patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}", + params: { + csat_survey_enabled: true, + csat_config: csat_config_with_template + }, + headers: admin.create_new_auth_token, + as: :json + + get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + json_response = response.parsed_body + template_config = json_response['csat_config']['template'] + + expect(template_config).to be_present + expect(template_config['name']).to eq('custom_survey_template') + expect(template_config['template_id']).to eq('123456789') + expect(template_config['language']).to eq('en') + expect(template_config['created_at']).to be_present + end + + it 'removes template configuration when not provided in update' do + # First set up template configuration + csat_config_with_template = csat_config.merge({ + 'template' => { + 'name' => 'custom_survey_template', + 'template_id' => '123456789' + } + }) + + patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}", + params: { + csat_survey_enabled: true, + csat_config: csat_config_with_template + }, + headers: admin.create_new_auth_token, + as: :json + + # Then update without template + patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}", + params: { + csat_survey_enabled: true, + csat_config: csat_config.merge({ 'message' => 'Updated message' }) + }, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + + inbox.reload + config = inbox.csat_config + expect(config['message']).to eq('Updated message') + expect(config['template']).to be_nil # Template should be removed when not provided + end end end diff --git a/spec/services/whatsapp/csat_template_service_spec.rb b/spec/services/whatsapp/csat_template_service_spec.rb new file mode 100644 index 000000000..f21d87227 --- /dev/null +++ b/spec/services/whatsapp/csat_template_service_spec.rb @@ -0,0 +1,376 @@ +require 'rails_helper' + +RSpec.describe Whatsapp::CsatTemplateService do + let(:account) { create(:account) } + let(:whatsapp_channel) do + create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) + end + let(:inbox) { create(:inbox, channel: whatsapp_channel, account: account) } + let(:service) { described_class.new(whatsapp_channel) } + + let(:expected_template_name) { "customer_satisfaction_survey_#{whatsapp_channel.inbox.id}" } + let(:template_config) do + { + message: 'How would you rate your experience?', + button_text: 'Rate Us', + language: 'en', + base_url: 'https://example.com', + template_name: expected_template_name + } + end + + before do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('WHATSAPP_CLOUD_BASE_URL', anything).and_return('https://graph.facebook.com') + end + + describe '#generate_template_name' do + context 'when no existing template' do + it 'returns base name as-is' do + whatsapp_channel.inbox.update!(csat_config: {}) + result = service.send(:generate_template_name, 'new_template_name') + expect(result).to eq('new_template_name') + end + + it 'returns base name when template key is missing' do + whatsapp_channel.inbox.update!(csat_config: { 'other_config' => 'value' }) + result = service.send(:generate_template_name, 'new_template_name') + expect(result).to eq('new_template_name') + end + end + + context 'when existing template has no versioned name' do + it 'starts versioning from 1' do + whatsapp_channel.inbox.update!(csat_config: { + 'template' => { 'name' => expected_template_name } + }) + result = service.send(:generate_template_name, 'new_template_name') + expect(result).to eq("#{expected_template_name}_1") + end + + it 'starts versioning from 1 for custom name' do + whatsapp_channel.inbox.update!(csat_config: { + 'template' => { 'name' => 'custom_survey' } + }) + result = service.send(:generate_template_name, 'new_template_name') + expect(result).to eq("#{expected_template_name}_1") + end + end + + context 'when existing template has versioned name' do + it 'increments version number' do + whatsapp_channel.inbox.update!(csat_config: { + 'template' => { 'name' => "#{expected_template_name}_1" } + }) + result = service.send(:generate_template_name, 'new_template_name') + expect(result).to eq("#{expected_template_name}_2") + end + + it 'increments higher version numbers' do + whatsapp_channel.inbox.update!(csat_config: { + 'template' => { 'name' => "#{expected_template_name}_5" } + }) + result = service.send(:generate_template_name, 'new_template_name') + expect(result).to eq("#{expected_template_name}_6") + end + + it 'handles double digit version numbers' do + whatsapp_channel.inbox.update!(csat_config: { + 'template' => { 'name' => "#{expected_template_name}_12" } + }) + result = service.send(:generate_template_name, 'new_template_name') + expect(result).to eq("#{expected_template_name}_13") + end + end + + context 'when existing template has non-matching versioned name' do + it 'starts versioning from 1' do + whatsapp_channel.inbox.update!(csat_config: { + 'template' => { 'name' => 'different_survey_3' } + }) + result = service.send(:generate_template_name, 'new_template_name') + expect(result).to eq("#{expected_template_name}_1") + end + end + + context 'when template name is blank' do + it 'returns base name' do + whatsapp_channel.inbox.update!(csat_config: { + 'template' => { 'name' => '' } + }) + result = service.send(:generate_template_name, 'new_template_name') + expect(result).to eq('new_template_name') + end + + it 'returns base name when template name is nil' do + whatsapp_channel.inbox.update!(csat_config: { + 'template' => { 'name' => nil } + }) + result = service.send(:generate_template_name, 'new_template_name') + expect(result).to eq('new_template_name') + end + end + end + + describe '#build_template_request_body' do + it 'builds correct request structure' do + result = service.send(:build_template_request_body, template_config) + + expect(result).to eq({ + name: expected_template_name, + language: 'en', + category: 'MARKETING', + components: [ + { + type: 'BODY', + text: 'How would you rate your experience?' + }, + { + type: 'BUTTONS', + buttons: [ + { + type: 'URL', + text: 'Rate Us', + url: 'https://example.com/survey/responses/{{1}}', + example: ['12345'] + } + ] + } + ] + }) + end + + it 'uses default language when not provided' do + config_without_language = template_config.except(:language) + result = service.send(:build_template_request_body, config_without_language) + expect(result[:language]).to eq('en') + end + + it 'uses default button text when not provided' do + config_without_button = template_config.except(:button_text) + result = service.send(:build_template_request_body, config_without_button) + expect(result[:components][1][:buttons][0][:text]).to eq('Please rate us') + end + end + + describe '#create_template' do + let(:mock_response) do + # rubocop:disable RSpec/VerifiedDoubles + double('response', :success? => true, :body => '{}', '[]' => { 'id' => '123', 'name' => 'template_name' }) + # rubocop:enable RSpec/VerifiedDoubles + end + + before do + allow(HTTParty).to receive(:post).and_return(mock_response) + inbox.update!(csat_config: {}) + end + + it 'creates template with generated name' do + expected_body = { + name: expected_template_name, + language: 'en', + category: 'MARKETING', + components: [ + { + type: 'BODY', + text: 'How would you rate your experience?' + }, + { + type: 'BUTTONS', + buttons: [ + { + type: 'URL', + text: 'Rate Us', + url: 'https://example.com/survey/responses/{{1}}', + example: ['12345'] + } + ] + } + ] + } + + expect(HTTParty).to receive(:post).with( + "https://graph.facebook.com/v14.0/#{whatsapp_channel.provider_config['business_account_id']}/message_templates", + headers: { + 'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}", + 'Content-Type' => 'application/json' + }, + body: expected_body.to_json + ) + + service.create_template(template_config) + end + + it 'returns success response on successful creation' do + allow(mock_response).to receive(:[]).with('id').and_return('template_123') + allow(mock_response).to receive(:[]).with('name').and_return(expected_template_name) + + result = service.create_template(template_config) + + expect(result).to eq({ + success: true, + template_id: 'template_123', + template_name: expected_template_name, + language: 'en', + status: 'PENDING' + }) + end + + context 'when API call fails' do + let(:error_response) do + # rubocop:disable RSpec/VerifiedDoubles + double('response', success?: false, code: 400, body: '{"error": "Invalid template"}') + # rubocop:enable RSpec/VerifiedDoubles + end + + before do + allow(HTTParty).to receive(:post).and_return(error_response) + allow(Rails.logger).to receive(:error) + end + + it 'returns error response' do + result = service.create_template(template_config) + + expect(result).to eq({ + success: false, + error: 'Template creation failed', + response_body: '{"error": "Invalid template"}' + }) + end + + it 'logs the error' do + expect(Rails.logger).to receive(:error).with('WhatsApp template creation failed: 400 - {"error": "Invalid template"}') + service.create_template(template_config) + end + end + end + + describe '#delete_template' do + it 'makes DELETE request to correct endpoint' do + # rubocop:disable RSpec/VerifiedDoubles + mock_response = double('response', success?: true, body: '{}') + # rubocop:enable RSpec/VerifiedDoubles + + expect(HTTParty).to receive(:delete).with( + "https://graph.facebook.com/v14.0/#{whatsapp_channel.provider_config['business_account_id']}/message_templates?name=test_template", + headers: { + 'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}", + 'Content-Type' => 'application/json' + } + ).and_return(mock_response) + + result = service.delete_template('test_template') + expect(result).to eq({ success: true, response_body: '{}' }) + end + + it 'uses default template name when none provided' do + # rubocop:disable RSpec/VerifiedDoubles + mock_response = double('response', success?: true, body: '{}') + # rubocop:enable RSpec/VerifiedDoubles + + expect(HTTParty).to receive(:delete).with( + "https://graph.facebook.com/v14.0/#{whatsapp_channel.provider_config['business_account_id']}/message_templates?name=#{expected_template_name}", + anything + ).and_return(mock_response) + + service.delete_template + end + + it 'returns failure response when API call fails' do + # rubocop:disable RSpec/VerifiedDoubles + mock_response = double('response', success?: false, body: '{"error": "Template not found"}') + # rubocop:enable RSpec/VerifiedDoubles + allow(HTTParty).to receive(:delete).and_return(mock_response) + + result = service.delete_template('test_template') + expect(result).to eq({ success: false, response_body: '{"error": "Template not found"}' }) + end + end + + describe '#get_template_status' do + it 'makes GET request to correct endpoint' do + # rubocop:disable RSpec/VerifiedDoubles + mock_response = double('response', success?: true, body: '{}') + # rubocop:enable RSpec/VerifiedDoubles + allow(mock_response).to receive(:[]).with('data').and_return([{ + 'id' => '123', + 'name' => 'test_template', + 'status' => 'APPROVED', + 'language' => 'en' + }]) + + expect(HTTParty).to receive(:get).with( + "https://graph.facebook.com/v14.0/#{whatsapp_channel.provider_config['business_account_id']}/message_templates?name=test_template", + headers: { + 'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}", + 'Content-Type' => 'application/json' + } + ).and_return(mock_response) + + service.get_template_status('test_template') + end + + it 'returns success response when template exists' do + # rubocop:disable RSpec/VerifiedDoubles + mock_response = double('response', success?: true, body: '{}') + # rubocop:enable RSpec/VerifiedDoubles + allow(mock_response).to receive(:[]).with('data').and_return([{ + 'id' => '123', + 'name' => 'test_template', + 'status' => 'APPROVED', + 'language' => 'en' + }]) + allow(HTTParty).to receive(:get).and_return(mock_response) + + result = service.get_template_status('test_template') + + expect(result).to eq({ + success: true, + template: { + id: '123', + name: 'test_template', + status: 'APPROVED', + language: 'en' + } + }) + end + + it 'returns failure response when template not found' do + # rubocop:disable RSpec/VerifiedDoubles + mock_response = double('response', success?: true, body: '{}') + # rubocop:enable RSpec/VerifiedDoubles + allow(mock_response).to receive(:[]).with('data').and_return([]) + allow(HTTParty).to receive(:get).and_return(mock_response) + + result = service.get_template_status('test_template') + expect(result).to eq({ success: false, error: 'Template not found' }) + end + + it 'returns failure response when API call fails' do + # rubocop:disable RSpec/VerifiedDoubles + mock_response = double('response', success?: false, body: '{}') + # rubocop:enable RSpec/VerifiedDoubles + allow(HTTParty).to receive(:get).and_return(mock_response) + + result = service.get_template_status('test_template') + expect(result).to eq({ success: false, error: 'Template not found' }) + end + + context 'when API raises an exception' do + before do + allow(HTTParty).to receive(:get).and_raise(StandardError, 'Network error') + allow(Rails.logger).to receive(:error) + end + + it 'handles exceptions gracefully' do + result = service.get_template_status('test_template') + expect(result).to eq({ success: false, error: 'Network error' }) + end + + it 'logs the error' do + expect(Rails.logger).to receive(:error).with('Error fetching template status: Network error') + service.get_template_status('test_template') + end + end + end +end diff --git a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb index 69ba69379..156e46349 100644 --- a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb +++ b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb @@ -312,4 +312,92 @@ describe Whatsapp::Providers::WhatsappCloudService do end end end + + describe 'CSAT template methods' do + let(:mock_csat_template_service) { instance_double(Whatsapp::CsatTemplateService) } + let(:expected_template_name) { "customer_satisfaction_survey_#{whatsapp_channel.inbox.id}" } + let(:template_config) do + { + name: expected_template_name, + language: 'en', + category: 'UTILITY' + } + end + + before do + allow(Whatsapp::CsatTemplateService).to receive(:new) + .with(whatsapp_channel) + .and_return(mock_csat_template_service) + end + + describe '#create_csat_template' do + it 'delegates to csat_template_service with correct config' do + allow(mock_csat_template_service).to receive(:create_template) + .with(template_config) + .and_return({ success: true, template_id: '123' }) + + result = service.create_csat_template(template_config) + + expect(mock_csat_template_service).to have_received(:create_template).with(template_config) + expect(result).to eq({ success: true, template_id: '123' }) + end + end + + describe '#delete_csat_template' do + it 'delegates to csat_template_service with default template name' do + allow(mock_csat_template_service).to receive(:delete_template) + .with(expected_template_name) + .and_return({ success: true }) + + result = service.delete_csat_template + + expect(mock_csat_template_service).to have_received(:delete_template).with(expected_template_name) + expect(result).to eq({ success: true }) + end + + it 'delegates to csat_template_service with custom template name' do + custom_template_name = 'custom_csat_template' + allow(mock_csat_template_service).to receive(:delete_template) + .with(custom_template_name) + .and_return({ success: true }) + + result = service.delete_csat_template(custom_template_name) + + expect(mock_csat_template_service).to have_received(:delete_template).with(custom_template_name) + expect(result).to eq({ success: true }) + end + end + + describe '#get_template_status' do + it 'delegates to csat_template_service with template name' do + template_name = 'customer_survey_template' + expected_response = { success: true, template: { status: 'APPROVED' } } + allow(mock_csat_template_service).to receive(:get_template_status) + .with(template_name) + .and_return(expected_response) + + result = service.get_template_status(template_name) + + expect(mock_csat_template_service).to have_received(:get_template_status).with(template_name) + expect(result).to eq(expected_response) + end + end + + describe 'csat_template_service memoization' do + it 'creates and memoizes the csat_template_service instance' do + allow(Whatsapp::CsatTemplateService).to receive(:new) + .with(whatsapp_channel) + .and_return(mock_csat_template_service) + allow(mock_csat_template_service).to receive(:get_template_status) + .and_return({ success: true }) + + # Call multiple methods that use the service + service.get_template_status('test1') + service.get_template_status('test2') + + # Verify the service was only instantiated once + expect(Whatsapp::CsatTemplateService).to have_received(:new).once + end + end + end end From df4c8cf58b7b593e0eb0e0206dd8ef048ddaff77 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 11 Dec 2025 19:58:59 +0530 Subject: [PATCH 2/4] chore: Strip unsupported signature formatting by channel (#13046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Pull Request Template ## Description 1. This PR is an enhancement to https://github.com/chatwoot/chatwoot/pull/13045 It strips unsupported formatting from **message signatures** based on each channel’s formatting capabilities defined in the `FORMATTING` config 2. Remove usage of plain editor in Compose new conversation modal Only the following signature elements are considered: bold (strong), italic (em), links (link), images (image).

Any formatting not supported by the target channel is automatically removed before the signature is appended.

Channel-wise Signature Formatting Support

Channel | Keeps in Signature | Strips from Signature -- | -- | -- Email | bold, italic, links, images | — WebWidget | bold, italic, links, images | — API | bold, italic | links, images WhatsApp | bold, italic | links, images Telegram | bold, italic, links | images Facebook | bold, italic | links, images Instagram | bold, italic | links, images Line | bold, italic | links, images SMS | — | everything Twilio SMS | — | everything Twitter/X | — | everything

📝 Note

Message signatures only support bold, italic, links, and images.
Other formatting options available in the editor (lists, code blocks, strike-through, etc.) do not apply to signatures and are ignored.

## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ### Loom video https://www.loom.com/share/d325ab86ca514c6d8f90dfe72a8928dd ## 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 --- .../components-next/Editor/Editor.vue | 2 + .../components/ComposeNewConversationForm.vue | 25 +++- .../components/MessageEditor.vue | 119 ++++-------------- .../components/widgets/WootWriter/Editor.vue | 31 ++++- .../widgets/conversation/ReplyBox.vue | 22 +++- app/javascript/dashboard/constants/editor.js | 12 +- .../dashboard/helper/editorHelper.js | 111 ++++++++++++---- app/javascript/dashboard/helper/inbox.js | 5 + .../helper/specs/editorHelper.spec.js | 110 ++++++++++++---- 9 files changed, 270 insertions(+), 167 deletions(-) diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue index ff1c616de..90b7a0c31 100644 --- a/app/javascript/dashboard/components-next/Editor/Editor.vue +++ b/app/javascript/dashboard/components-next/Editor/Editor.vue @@ -24,6 +24,7 @@ const props = defineProps({ allowSignature: { type: Boolean, default: false }, sendWithSignature: { type: Boolean, default: false }, channelType: { type: String, default: '' }, + medium: { type: String, default: '' }, }); const emit = defineEmits(['update:modelValue']); @@ -106,6 +107,7 @@ watch( :allow-signature="allowSignature" :send-with-signature="sendWithSignature" :channel-type="channelType" + :medium="medium" @input="handleInput" @focus="handleFocus" @blur="handleBlur" diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue index 52ca95a07..77267707b 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue @@ -6,6 +6,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { appendSignature, removeSignature, + getEffectiveChannelType, } from 'dashboard/helper/editorHelper'; import { buildContactableInboxesList, @@ -86,6 +87,12 @@ const whatsappMessageTemplates = computed(() => const inboxChannelType = computed(() => props.targetInbox?.channelType || ''); +const inboxMedium = computed(() => props.targetInbox?.medium || ''); + +const effectiveChannelType = computed(() => + getEffectiveChannelType(inboxChannelType.value, inboxMedium.value) +); + const validationRules = computed(() => ({ selectedContact: { required }, targetInbox: { required }, @@ -202,7 +209,11 @@ const removeSignatureFromMessage = () => { // Always remove the signature from message content when inbox/contact is removed // to ensure no leftover signature content remains if (props.messageSignature) { - state.message = removeSignature(state.message, props.messageSignature); + state.message = removeSignature( + state.message, + props.messageSignature, + effectiveChannelType.value + ); } }; @@ -214,9 +225,9 @@ const removeTargetInbox = value => { }; const clearSelectedContact = () => { + removeSignatureFromMessage(); emit('clearSelectedContact'); state.attachedFiles = []; - removeSignatureFromMessage(); }; const onClickInsertEmoji = emoji => { @@ -227,12 +238,16 @@ const handleAddSignature = signature => { state.message = appendSignature( state.message, signature, - inboxChannelType.value + effectiveChannelType.value ); }; const handleRemoveSignature = signature => { - state.message = removeSignature(state.message, signature); + state.message = removeSignature( + state.message, + signature, + effectiveChannelType.value + ); }; const handleAttachFile = files => { @@ -356,10 +371,10 @@ const shouldShowMessageEditor = computed(() => { v-model="state.message" :message-signature="messageSignature" :send-with-signature="sendWithSignature" - :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" :has-errors="validationStates.isMessageInvalid" :has-attachments="state.attachedFiles.length > 0" :channel-type="inboxChannelType" + :medium="targetInbox?.medium || ''" /> -import { ref, watch, nextTick } from 'vue'; import { useI18n } from 'vue-i18n'; -import { - appendSignature, - removeSignature, -} from 'dashboard/helper/editorHelper'; import Editor from 'dashboard/components-next/Editor/Editor.vue'; -import TextArea from 'dashboard/components-next/textarea/TextArea.vue'; -import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue'; -const props = defineProps({ - isEmailOrWebWidgetInbox: { type: Boolean, required: true }, +defineProps({ hasErrors: { type: Boolean, default: false }, hasAttachments: { type: Boolean, default: false }, sendWithSignature: { type: Boolean, default: false }, messageSignature: { type: String, default: '' }, channelType: { type: String, default: '' }, + medium: { type: String, default: '' }, }); const { t } = useI18n(); @@ -25,98 +18,28 @@ const modelValue = defineModel({ type: String, default: '', }); - -const state = ref({ - hasSlashCommand: false, - showMentions: false, - mentionSearchKey: '', -}); - -watch( - modelValue, - newValue => { - if (props.isEmailOrWebWidgetInbox) return; - - const bodyWithoutSignature = newValue - ? removeSignature(newValue, props.messageSignature) - : ''; - - // Check if message starts with slash - const startsWithSlash = bodyWithoutSignature.startsWith('/'); - - // Update slash command and mentions state - state.value = { - ...state.value, - hasSlashCommand: startsWithSlash, - showMentions: startsWithSlash, - mentionSearchKey: startsWithSlash ? bodyWithoutSignature.slice(1) : '', - }; - }, - { immediate: true } -); - -const hideMention = () => { - state.value.showMentions = false; -}; - -const replaceText = async message => { - // Only append signature on replace if sendWithSignature is true - const finalMessage = props.sendWithSignature - ? appendSignature(message, props.messageSignature, props.channelType) - : message; - - await nextTick(); - modelValue.value = finalMessage; -}; diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 318727acc..7a2a489ac 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -54,6 +54,7 @@ import { getFormattingForEditor, getSelectionCoords, calculateMenuPosition, + getEffectiveChannelType, } from 'dashboard/helper/editorHelper'; import { hasPressedEnterAndNotCmdOrShift, @@ -81,6 +82,7 @@ const props = defineProps({ // are triggered except when this flag is true allowSignature: { type: Boolean, default: false }, channelType: { type: String, default: '' }, + medium: { type: String, default: '' }, showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar focusOnMount: { type: Boolean, default: true }, }); @@ -105,10 +107,16 @@ const TYPING_INDICATOR_IDLE_TIME = 4000; const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB const DEFAULT_FORMATTING = 'Context::Default'; +const effectiveChannelType = computed(() => + getEffectiveChannelType(props.channelType, props.medium) +); + const editorSchema = computed(() => { if (!props.channelType) return messageSchema; - const formatType = props.isPrivate ? DEFAULT_FORMATTING : props.channelType; + const formatType = props.isPrivate + ? DEFAULT_FORMATTING + : effectiveChannelType.value; const formatting = getFormattingForEditor(formatType); return buildMessageSchema(formatting.marks, formatting.nodes); }); @@ -116,7 +124,7 @@ const editorSchema = computed(() => { const editorMenuOptions = computed(() => { const formatType = props.isPrivate ? DEFAULT_FORMATTING - : props.channelType || DEFAULT_FORMATTING; + : effectiveChannelType.value || DEFAULT_FORMATTING; const formatting = getFormattingForEditor(formatType); return formatting.menu; }); @@ -301,8 +309,13 @@ function isBodyEmpty(content) { // if the signature is present, we need to remove it before checking // note that we don't update the editorView, so this is safe + // Use effective channel type to match how signature was appended const bodyWithoutSignature = props.signature - ? removeSignatureHelper(content, props.signature) + ? removeSignatureHelper( + content, + props.signature, + effectiveChannelType.value + ) : content; // trimming should remove all the whitespaces, so we can check the length @@ -370,7 +383,11 @@ function addSignature() { // see if the content is empty, if it is before appending the signature // we need to add a paragraph node and move the cursor at the start of the editor const contentWasEmpty = isBodyEmpty(content); - content = appendSignature(content, props.signature, props.channelType); + content = appendSignature( + content, + props.signature, + effectiveChannelType.value + ); // need to reload first, ensuring that the editorView is updated reloadState(content); @@ -382,7 +399,11 @@ function addSignature() { function removeSignature() { if (!props.signature) return; let content = props.modelValue; - content = removeSignatureHelper(content, props.signature); + content = removeSignatureHelper( + content, + props.signature, + effectiveChannelType.value + ); // reload the state, ensuring that the editorView is updated reloadState(content); } diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 0e698e0c6..6f955ffd5 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -43,6 +43,7 @@ import fileUploadMixin from 'dashboard/mixins/fileUploadMixin'; import { appendSignature, removeSignature, + getEffectiveChannelType, } from 'dashboard/helper/editorHelper'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; @@ -564,9 +565,13 @@ export default { return message; } + const effectiveChannelType = getEffectiveChannelType( + this.channelType, + this.inbox?.medium || '' + ); return this.sendWithSignature - ? appendSignature(message, this.messageSignature, this.channelType) - : removeSignature(message, this.messageSignature); + ? appendSignature(message, this.messageSignature, effectiveChannelType) + : removeSignature(message, this.messageSignature, effectiveChannelType); }, removeFromDraft() { if (this.conversationIdByRoute) { @@ -757,10 +762,14 @@ export default { // if signature is enabled, append it to the message // appendSignature ensures that the signature is not duplicated // so we don't need to check if the signature is already present + const effectiveChannelType = getEffectiveChannelType( + this.channelType, + this.inbox?.medium || '' + ); message = appendSignature( message, this.messageSignature, - this.channelType + effectiveChannelType ); } @@ -800,10 +809,14 @@ export default { this.message = ''; if (this.sendWithSignature && !this.isPrivate) { // if signature is enabled, append it to the message + const effectiveChannelType = getEffectiveChannelType( + this.channelType, + this.inbox?.medium || '' + ); this.message = appendSignature( this.message, this.messageSignature, - this.channelType + effectiveChannelType ); } this.attachedFiles = []; @@ -1121,6 +1134,7 @@ export default { :signature="messageSignature" allow-signature :channel-type="channelType" + :medium="inbox.medium" @typing-off="onTypingOff" @typing-on="onTypingOn" @focus="onFocus" diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index eaec36371..087caa99f 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -210,7 +210,12 @@ export const MARKDOWN_PATTERNS = [ type: 'em', // PM: em, eg: *italic* or _italic_ patterns: [ { pattern: /(? arr.includes(key); + + // Define stripping rules: [condition, pattern, replacement] + const rules = [ + [!has(nodes, 'image'), /!\[.*?\]\(.*?\)/g, ''], + [!has(marks, 'link'), /\[([^\]]+)\]\([^)]+\)/g, '$1'], + [!has(nodes, 'codeBlock'), /```[\s\S]*?```/g, ''], + [!has(marks, 'code'), /`([^`]+)`/g, '$1'], + [!has(marks, 'strong'), /\*\*([^*]+)\*\*/g, '$1'], + [!has(marks, 'strong'), /__([^_]+)__/g, '$1'], + [!has(marks, 'em'), /\*([^*]+)\*/g, '$1'], + // Match _text_ only at word boundaries (whitespace/string start/end) + // Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names + [ + !has(marks, 'em'), + /(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g, + '$1', + ], + [!has(marks, 'strike'), /~~([^~]+)~~/g, '$1'], + [!has(nodes, 'blockquote'), /^>\s?/gm, ''], + [!has(nodes, 'bulletList'), /^[-*+]\s+/gm, ''], + [!has(nodes, 'orderedList'), /^\d+\.\s+/gm, ''], + ]; + + const result = rules.reduce( + (text, [shouldStrip, pattern, replacement]) => + shouldStrip ? text.replace(pattern, replacement) : text, + markdown + ); + + return result + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .join('\n') + .replace(/\n{2,}/g, '\n') + .trim(); +} + /** * The delimiter used to separate the signature from the rest of the body. * @type {string} @@ -97,29 +144,36 @@ export function findSignatureInBody(body, signature) { } /** - * Checks if the channel supports image signatures. + * Gets the effective channel type for formatting purposes. + * For Twilio channels, returns WhatsApp or Twilio based on medium. * - * @param {string} channelType - The channel type. - * @returns {boolean} - True if the channel supports image signatures. + * @param {string} channelType - The channel type + * @param {string} medium - Optional. The medium for Twilio channels (sms/whatsapp) + * @returns {string} - The effective channel type for formatting */ -export function supportsImageSignature(channelType) { - return CHANNEL_WITH_RICH_SIGNATURE.includes(channelType); +export function getEffectiveChannelType(channelType, medium) { + if (channelType === INBOX_TYPES.TWILIO) { + return medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP + ? INBOX_TYPES.WHATSAPP + : INBOX_TYPES.TWILIO; + } + return channelType; } /** * Appends the signature to the body, separated by the signature delimiter. - * Automatically strips images for channels that don't support image signatures. + * Automatically strips unsupported formatting based on channel capabilities. * * @param {string} body - The body to append the signature to. * @param {string} signature - The signature to append. - * @param {string} channelType - Optional. The channel type to determine if images should be stripped. + * @param {string} channelType - Optional. The effective channel type to determine supported formatting. + * For Twilio channels, pass the result of getEffectiveChannelType(). * @returns {string} - The body with the signature appended. */ export function appendSignature(body, signature, channelType) { - // For channels that don't support images, strip markdown formatting - const shouldStripImages = channelType && !supportsImageSignature(channelType); - const preparedSignature = shouldStripImages - ? extractTextFromMarkdown(signature) + // Strip only unsupported formatting based on channel capabilities + const preparedSignature = channelType + ? stripUnsupportedSignatureMarkdown(signature, channelType) : signature; const cleanedSignature = cleanSignature(preparedSignature); // if signature is already present, return body @@ -132,21 +186,28 @@ export function appendSignature(body, signature, channelType) { /** * Removes the signature from the body, along with the signature delimiter. - * Tries to find both the original signature and the stripped version (for non-image channels). + * Tries to find both the original signature and the stripped version. * * @param {string} body - The body to remove the signature from. * @param {string} signature - The signature to remove. + * @param {string} channelType - Optional. The effective channel type for channel-specific stripping. + * For Twilio channels, pass the result of getEffectiveChannelType(). * @returns {string} - The body with the signature removed. */ -export function removeSignature(body, signature) { - // Build list of signatures to try: original first, then stripped version - // Always try both to handle cases where channelType is unknown or inbox is being removed +export function removeSignature(body, signature, channelType) { + // Build list of signatures to try: original, channel-stripped, and fully stripped const cleanedSignature = cleanSignature(signature); - const strippedSignature = cleanSignature(extractTextFromMarkdown(signature)); - const signaturesToTry = - cleanedSignature === strippedSignature - ? [cleanedSignature] - : [cleanedSignature, strippedSignature]; + const channelStripped = channelType + ? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType)) + : null; + const fullyStripped = cleanSignature(extractTextFromMarkdown(signature)); + + // Try signatures in order: original → channel-specific → fully stripped + const signaturesToTry = [ + cleanedSignature, + channelStripped, + fullyStripped, + ].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates // Find the first matching signature const signatureIndex = signaturesToTry.reduce( diff --git a/app/javascript/dashboard/helper/inbox.js b/app/javascript/dashboard/helper/inbox.js index 501375ab7..05823e8a7 100644 --- a/app/javascript/dashboard/helper/inbox.js +++ b/app/javascript/dashboard/helper/inbox.js @@ -13,6 +13,11 @@ export const INBOX_TYPES = { VOICE: 'Channel::Voice', }; +export const TWILIO_CHANNEL_MEDIUM = { + WHATSAPP: 'whatsapp', + SMS: 'sms', +}; + const INBOX_ICON_MAP_FILL = { [INBOX_TYPES.WEB]: 'i-ri-global-fill', [INBOX_TYPES.FB]: 'i-ri-messenger-fill', diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index 07dfbf1e5..1ae765ca7 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -5,7 +5,7 @@ import { replaceSignature, cleanSignature, extractTextFromMarkdown, - supportsImageSignature, + stripUnsupportedSignatureMarkdown, insertAtCursor, findNodeToInsertImage, setURLWithQueryAndSize, @@ -145,10 +145,63 @@ describe('appendSignature', () => { }); }); +describe('stripUnsupportedSignatureMarkdown', () => { + const richSignature = + '**Bold** _italic_ [link](http://example.com) ![](http://localhost:3000/image.png)'; + + it('keeps all formatting for Email channel (supports image, link, strong, em)', () => { + const result = stripUnsupportedSignatureMarkdown( + richSignature, + 'Channel::Email' + ); + expect(result).toContain('**Bold**'); + expect(result).toContain('_italic_'); + expect(result).toContain('[link](http://example.com)'); + expect(result).toContain('![](http://localhost:3000/image.png)'); + }); + it('strips images but keeps bold/italic for Api channel', () => { + const result = stripUnsupportedSignatureMarkdown( + richSignature, + 'Channel::Api' + ); + expect(result).toContain('**Bold**'); + expect(result).toContain('_italic_'); + expect(result).toContain('link'); // link text kept + expect(result).not.toContain('[link]('); // link syntax removed + expect(result).not.toContain('![]('); // image removed + }); + it('strips images but keeps bold/italic/link for Telegram channel', () => { + const result = stripUnsupportedSignatureMarkdown( + richSignature, + 'Channel::Telegram' + ); + expect(result).toContain('**Bold**'); + expect(result).toContain('_italic_'); + expect(result).toContain('[link](http://example.com)'); + expect(result).not.toContain('![]('); + }); + it('strips all formatting for SMS channel', () => { + const result = stripUnsupportedSignatureMarkdown( + richSignature, + 'Channel::Sms' + ); + expect(result).toContain('Bold'); + expect(result).toContain('italic'); + expect(result).toContain('link'); + expect(result).not.toContain('**'); + expect(result).not.toContain('_'); + expect(result).not.toContain('['); + expect(result).not.toContain('![]('); + }); + it('returns empty string for empty input', () => { + expect(stripUnsupportedSignatureMarkdown('', 'Channel::Api')).toBe(''); + expect(stripUnsupportedSignatureMarkdown(null, 'Channel::Api')).toBe(''); + }); +}); + describe('appendSignature with channelType', () => { const signatureWithImage = 'Thanks\n![](http://localhost:3000/image.png?cw_image_height=24px)'; - const strippedSignature = 'Thanks'; it('keeps images for Email channel', () => { const result = appendSignature( @@ -166,24 +219,31 @@ describe('appendSignature with channelType', () => { ); expect(result).toContain('![](http://localhost:3000/image.png'); }); - it('strips images for Api channel', () => { + it('strips images but keeps text for Api channel', () => { const result = appendSignature('Hello', signatureWithImage, 'Channel::Api'); expect(result).not.toContain('![]('); - expect(result).toContain(strippedSignature); + expect(result).toContain('Thanks'); }); - it('strips images for WhatsApp channel', () => { + it('strips images but keeps text for WhatsApp channel', () => { const result = appendSignature( 'Hello', signatureWithImage, 'Channel::Whatsapp' ); expect(result).not.toContain('![]('); - expect(result).toContain(strippedSignature); + expect(result).toContain('Thanks'); }); it('keeps images when channelType is not provided', () => { const result = appendSignature('Hello', signatureWithImage); expect(result).toContain('![](http://localhost:3000/image.png'); }); + it('keeps bold/italic for channels that support them', () => { + const boldSignature = '**Bold** *italic* Thanks'; + const result = appendSignature('Hello', boldSignature, 'Channel::Api'); + // Api supports strong and em + expect(result).toContain('**Bold**'); + expect(result).toContain('*italic*'); + }); }); describe('cleanSignature', () => { @@ -331,24 +391,6 @@ describe('extractTextFromMarkdown', () => { }); }); -describe('supportsImageSignature', () => { - it('returns true for Email channel', () => { - expect(supportsImageSignature('Channel::Email')).toBe(true); - }); - it('returns true for WebWidget channel', () => { - expect(supportsImageSignature('Channel::WebWidget')).toBe(true); - }); - it('returns false for Api channel', () => { - expect(supportsImageSignature('Channel::Api')).toBe(false); - }); - it('returns false for WhatsApp channel', () => { - expect(supportsImageSignature('Channel::Whatsapp')).toBe(false); - }); - it('returns false for Telegram channel', () => { - expect(supportsImageSignature('Channel::Telegram')).toBe(false); - }); -}); - describe('insertAtCursor', () => { it('should return undefined if editorView is not provided', () => { const result = insertAtCursor(undefined, schema.text('Hello'), 0); @@ -884,6 +926,26 @@ describe('stripUnsupportedFormatting', () => { ); }); + it('preserves underscores in URLs and mid-word positions', () => { + // Underscores in URLs should not be stripped as italic formatting + expect( + stripUnsupportedFormatting( + 'https://www.chatwoot.com/new_first_second-third/ssd', + emptySchema + ) + ).toBe('https://www.chatwoot.com/new_first_second-third/ssd'); + + // Underscores in variable names should not be stripped + expect( + stripUnsupportedFormatting('some_variable_name', emptySchema) + ).toBe('some_variable_name'); + + // But actual italic formatting with spaces should still be stripped + expect( + stripUnsupportedFormatting('hello _world_ there', emptySchema) + ).toBe('hello world there'); + }); + it('strips inline code formatting', () => { expect(stripUnsupportedFormatting('`inline code`', emptySchema)).toBe( 'inline code' From 696564863cecc1e74fb5eec9966a104ce760522f Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Fri, 12 Dec 2025 10:14:22 +0530 Subject: [PATCH 3/4] feat: Add plain-text editor for non-rich content channels (#13058) # Pull Request Template ## Description This PR restores the plain text editor for all channels except Website, Email, and API. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] 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 - [ ] 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 --- .../components/ComposeNewConversationForm.vue | 3 + .../components/MessageEditor.vue | 6 +- .../widgets/conversation/ReplyBox.vue | 213 ++++++++++++++---- .../shared/components/ResizableTextArea.vue | 7 +- 4 files changed, 186 insertions(+), 43 deletions(-) diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue index 77267707b..74836219b 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue @@ -200,6 +200,7 @@ const setSelectedContact = async ({ value, action, ...rest }) => { const handleInboxAction = ({ value, action, ...rest }) => { v$.value.$reset(); + state.message = ''; emit('updateTargetInbox', { ...rest }); showInboxesDropdown.value = false; state.attachedFiles = []; @@ -220,6 +221,7 @@ const removeSignatureFromMessage = () => { const removeTargetInbox = value => { v$.value.$reset(); removeSignatureFromMessage(); + state.message = ''; emit('updateTargetInbox', value); state.attachedFiles = []; }; @@ -227,6 +229,7 @@ const removeTargetInbox = value => { const clearSelectedContact = () => { removeSignatureFromMessage(); emit('clearSelectedContact'); + state.message = ''; state.attachedFiles = []; }; diff --git a/app/javascript/dashboard/components-next/NewConversation/components/MessageEditor.vue b/app/javascript/dashboard/components-next/NewConversation/components/MessageEditor.vue index 2cda75e53..ef523a288 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/MessageEditor.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/MessageEditor.vue @@ -1,9 +1,10 @@