From 3a0b5f387d9d720a746866160e65b14623bde3fa Mon Sep 17 00:00:00 2001 From: Pranav Date: Tue, 27 May 2025 14:10:27 -0600 Subject: [PATCH 1/8] fix: Update specs, add background response job implementation for copilot threads (#11600) - Enable jobs by default when a copilot thread or a message is created. - Rename thread_id to copilot_thread_id to keep it consistent with the model name - Add a spec for search_linear_issues service --- .../captain/copilot_messages_controller.rb | 3 +- .../captain/copilot_threads_controller.rb | 9 +++- enterprise/app/helpers/captain/chat_helper.rb | 3 ++ .../app/jobs/captain/copilot/response_job.rb | 25 +++++++++++ enterprise/app/models/copilot_message.rb | 18 +++++--- .../services/captain/copilot/chat_service.rb | 2 +- .../copilot/search_linear_issues_service.rb | 2 +- .../copilot_messages_controller_spec.rb | 2 +- .../copilot_threads_controller_spec.rb | 5 ++- .../jobs/captain/copilot/response_job_spec.rb | 41 +++++++++++++++++++ .../enterprise/models/copilot_message_spec.rb | 11 +++-- .../captain/copilot/chat_service_spec.rb | 10 ++--- .../copilot/search_articles_service_spec.rb | 30 +++----------- .../search_linear_issues_service_spec.rb | 31 +++++++++++--- spec/factories/articles.rb | 9 ++-- spec/factories/categories.rb | 3 +- 16 files changed, 145 insertions(+), 59 deletions(-) create mode 100644 enterprise/app/jobs/captain/copilot/response_job.rb create mode 100644 spec/enterprise/jobs/captain/copilot/response_job_spec.rb diff --git a/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb index d7d79e9af..75abc0cdc 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/copilot_messages_controller.rb @@ -12,9 +12,10 @@ class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts: def create @copilot_message = @copilot_thread.copilot_messages.create!( - message: params[:message], + message: { content: params[:message] }, message_type: :user ) + @copilot_message.enqueue_response_job(params[:conversation_id], Current.user.id) end private diff --git a/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb index 482acd7e7..533859b95 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/copilot_threads_controller.rb @@ -18,7 +18,12 @@ class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts:: assistant: assistant ) - @copilot_thread.copilot_messages.create!(message_type: :user, message: copilot_thread_params[:message]) + copilot_message = @copilot_thread.copilot_messages.create!( + message_type: :user, + message: { content: copilot_thread_params[:message] } + ) + + copilot_message.enqueue_response_job(copilot_thread_params[:conversation_id], Current.user.id) end end @@ -33,7 +38,7 @@ class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts:: end def copilot_thread_params - params.permit(:message, :assistant_id) + params.permit(:message, :assistant_id, :conversation_id) end def permitted_params diff --git a/enterprise/app/helpers/captain/chat_helper.rb b/enterprise/app/helpers/captain/chat_helper.rb index e326f3094..924f11269 100644 --- a/enterprise/app/helpers/captain/chat_helper.rb +++ b/enterprise/app/helpers/captain/chat_helper.rb @@ -13,6 +13,9 @@ module Captain::ChatHelper ) handle_response(response) + rescue StandardError => e + Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error in chat completion: #{e}" + raise e end private diff --git a/enterprise/app/jobs/captain/copilot/response_job.rb b/enterprise/app/jobs/captain/copilot/response_job.rb new file mode 100644 index 000000000..fca5b0cce --- /dev/null +++ b/enterprise/app/jobs/captain/copilot/response_job.rb @@ -0,0 +1,25 @@ +class Captain::Copilot::ResponseJob < ApplicationJob + queue_as :default + + def perform(assistant:, conversation_id:, user_id:, copilot_thread_id:, message:) + Rails.logger.info("#{self.class.name} Copilot response job for assistant_id=#{assistant.id} user_id=#{user_id}") + generate_chat_response( + assistant: assistant, + conversation_id: conversation_id, + user_id: user_id, + copilot_thread_id: copilot_thread_id, + message: message + ) + end + + private + + def generate_chat_response(assistant:, conversation_id:, user_id:, copilot_thread_id:, message:) + Captain::Copilot::ChatService.new( + assistant, + user_id: user_id, + copilot_thread_id: copilot_thread_id, + conversation_id: conversation_id + ).generate_response(message) + end +end diff --git a/enterprise/app/models/copilot_message.rb b/enterprise/app/models/copilot_message.rb index f3816685c..5941656c3 100644 --- a/enterprise/app/models/copilot_message.rb +++ b/enterprise/app/models/copilot_message.rb @@ -19,14 +19,12 @@ class CopilotMessage < ApplicationRecord belongs_to :copilot_thread belongs_to :account - before_validation :ensure_account - enum message_type: { user: 0, assistant: 1, assistant_thinking: 2 } - validates :message_type, presence: true, inclusion: { in: message_types.keys } + validates :message_type, presence: true validates :message, presence: true + before_validation :ensure_account validate :validate_message_attributes - after_create_commit :broadcast_message def push_event_data @@ -39,10 +37,20 @@ class CopilotMessage < ApplicationRecord } end + def enqueue_response_job(conversation_id, user_id) + Captain::Copilot::ResponseJob.perform_later( + assistant: copilot_thread.assistant, + conversation_id: conversation_id, + user_id: user_id, + copilot_thread_id: copilot_thread.id, + message: message['content'] + ) + end + private def ensure_account - self.account = copilot_thread.account + self.account_id = copilot_thread&.account_id end def broadcast_message diff --git a/enterprise/app/services/captain/copilot/chat_service.rb b/enterprise/app/services/captain/copilot/chat_service.rb index 0c4811c7e..701837f55 100644 --- a/enterprise/app/services/captain/copilot/chat_service.rb +++ b/enterprise/app/services/captain/copilot/chat_service.rb @@ -51,7 +51,7 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService "#{self.class.name} Assistant: #{@assistant.id}, Previous History: #{config[:previous_history]&.length || 0}, Language: #{config[:language]}" ) - @copilot_thread = @account.copilot_threads.find_by(id: config[:thread_id]) if config[:thread_id].present? + @copilot_thread = @account.copilot_threads.find_by(id: config[:copilot_thread_id]) if config[:copilot_thread_id].present? @previous_history = if @copilot_thread.present? @copilot_thread.previous_history else diff --git a/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb b/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb index ac5116303..0d59e194d 100644 --- a/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb +++ b/enterprise/app/services/captain/tools/copilot/search_linear_issues_service.rb @@ -46,7 +46,7 @@ class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseS end def active? - @user.present? && @assistant.account.hooks.find_by(app_id: 'linear').present? + @user.present? && @assistant.account.hooks.exists?(app_id: 'linear') end private diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb index d435013cf..511f4ea73 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_messages_controller_spec.rb @@ -44,7 +44,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r end.to change(CopilotMessage, :count).by(1) expect(response).to have_http_status(:success) - expect(CopilotMessage.last.message).to eq(message_content) + expect(CopilotMessage.last.message).to eq({ 'content' => message_content }) expect(CopilotMessage.last.message_type).to eq('user') expect(CopilotMessage.last.copilot_thread_id).to eq(copilot_thread.id) end diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb index 748c3adf6..b8fc628d1 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/copilot_threads_controller_spec.rb @@ -4,6 +4,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do let(:account) { create(:account) } let(:admin) { create(:user, account: account, role: :administrator) } let(:agent) { create(:user, account: account, role: :agent) } + let(:conversation) { create(:conversation, account: account) } def json_response JSON.parse(response.body, symbolize_names: true) @@ -50,7 +51,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do describe 'POST /api/v1/accounts/{account.id}/captain/copilot_threads' do let(:assistant) { create(:captain_assistant, account: account) } - let(:valid_params) { { message: 'Hello, how can you help me?', assistant_id: assistant.id } } + let(:valid_params) { { message: 'Hello, how can you help me?', assistant_id: assistant.id, conversation_id: conversation.display_id } } context 'when it is an un-authenticated user' do it 'returns unauthorized' do @@ -103,7 +104,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do message = thread.copilot_messages.last expect(message.message_type).to eq('user') - expect(message.message).to eq(valid_params[:message]) + expect(message.message).to eq({ 'content' => valid_params[:message] }) end end end diff --git a/spec/enterprise/jobs/captain/copilot/response_job_spec.rb b/spec/enterprise/jobs/captain/copilot/response_job_spec.rb new file mode 100644 index 000000000..c8aacda5e --- /dev/null +++ b/spec/enterprise/jobs/captain/copilot/response_job_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe Captain::Copilot::ResponseJob, type: :job do + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) } + let(:conversation_id) { 123 } + let(:message) { { 'content' => 'Test message' } } + + describe '#perform' do + let(:chat_service) { instance_double(Captain::Copilot::ChatService) } + + before do + allow(Captain::Copilot::ChatService).to receive(:new).with( + assistant, + user_id: user.id, + copilot_thread_id: copilot_thread.id, + conversation_id: conversation_id + ).and_return(chat_service) + allow(chat_service).to receive(:generate_response).with(message) + end + + it 'initializes ChatService with correct parameters and calls generate_response' do + expect(Captain::Copilot::ChatService).to receive(:new).with( + assistant, + user_id: user.id, + copilot_thread_id: copilot_thread.id, + conversation_id: conversation_id + ) + expect(chat_service).to receive(:generate_response).with(message) + described_class.perform_now( + assistant: assistant, + conversation_id: conversation_id, + user_id: user.id, + copilot_thread_id: copilot_thread.id, + message: message + ) + end + end +end diff --git a/spec/enterprise/models/copilot_message_spec.rb b/spec/enterprise/models/copilot_message_spec.rb index e5c904c45..6c0e496f2 100644 --- a/spec/enterprise/models/copilot_message_spec.rb +++ b/spec/enterprise/models/copilot_message_spec.rb @@ -1,15 +1,14 @@ require 'rails_helper' RSpec.describe CopilotMessage, type: :model do - describe 'associations' do - it { is_expected.to belong_to(:copilot_thread) } - it { is_expected.to belong_to(:account) } - end + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) } describe 'validations' do it { is_expected.to validate_presence_of(:message_type) } it { is_expected.to validate_presence_of(:message) } - it { is_expected.to validate_inclusion_of(:message_type).in_array(described_class.message_types.keys) } end describe 'callbacks' do @@ -31,7 +30,7 @@ RSpec.describe CopilotMessage, type: :model do message = build(:captain_copilot_message, copilot_thread: copilot_thread) expect(Rails.configuration.dispatcher).to receive(:dispatch) - .with(COPILOT_MESSAGE_CREATED, anything, copilot_message: message) + .with('copilot.message.created', anything, copilot_message: message) message.save! end diff --git a/spec/enterprise/services/captain/copilot/chat_service_spec.rb b/spec/enterprise/services/captain/copilot/chat_service_spec.rb index e498fa83a..a4274ec4e 100644 --- a/spec/enterprise/services/captain/copilot/chat_service_spec.rb +++ b/spec/enterprise/services/captain/copilot/chat_service_spec.rb @@ -17,7 +17,7 @@ RSpec.describe Captain::Copilot::ChatService do let(:previous_history) { [{ role: copilot_message.message_type, content: copilot_message.message['content'] }] } let(:config) do - { user_id: user.id, thread_id: copilot_thread.id, conversation_id: conversation.display_id } + { user_id: user.id, copilot_thread_id: copilot_thread.id, conversation_id: conversation.display_id } end before do @@ -157,16 +157,16 @@ RSpec.describe Captain::Copilot::ChatService do end describe '#setup_message_history' do - context 'when thread_id is present' do + context 'when copilot_thread_id is present' do it 'finds the copilot thread and sets previous history from it' do - service = described_class.new(assistant, { thread_id: copilot_thread.id }) + service = described_class.new(assistant, { copilot_thread_id: copilot_thread.id }) expect(service.copilot_thread).to eq(copilot_thread) expect(service.previous_history).to eq previous_history end end - context 'when thread_id is not present' do + context 'when copilot_thread_id is not present' do it 'uses previous_history from config if present' do custom_history = [{ role: 'user', content: 'Custom message' }] service = described_class.new(assistant, { previous_history: custom_history }) @@ -222,7 +222,7 @@ RSpec.describe Captain::Copilot::ChatService do }.with_indifferent_access) expect do - described_class.new(assistant, { thread_id: copilot_thread.id }).generate_response('Hello') + described_class.new(assistant, { copilot_thread_id: copilot_thread.id }).generate_response('Hello') end.to change(CopilotMessage, :count).by(1) last_message = CopilotMessage.last diff --git a/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb index e4504d7bd..eb11e0d69 100644 --- a/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb +++ b/spec/enterprise/services/captain/tools/copilot/search_articles_service_spec.rb @@ -102,10 +102,6 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do end context 'when no articles are found' do - before do - allow(Article).to receive(:where).and_return(Article.none) - end - it 'returns no articles found message' do expect(service.execute({ 'query' => 'test' })).to eq('No articles found') end @@ -113,13 +109,8 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do context 'when articles are found' do let(:portal) { create(:portal, account: account) } - let(:article1) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 1', content: 'Content 1') } - let(:article2) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 2', content: 'Content 2') } - - before do - article1 - article2 - end + let!(:article1) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 1', content: 'Content 1') } + let!(:article2) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 2', content: 'Content 2') } it 'returns formatted articles with count' do result = service.execute({ 'query' => 'Test' }) @@ -130,11 +121,7 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do context 'when filtered by category' do let(:category) { create(:category, slug: 'test-category', portal: portal, account: account) } - let(:article3) { create(:article, account: account, portal: portal, author: user, category: category, title: 'Test Article 3') } - - before do - article3 - end + let!(:article3) { create(:article, account: account, portal: portal, author: user, category: category, title: 'Test Article 3') } it 'returns only articles from the specified category' do result = service.execute({ 'query' => 'Test', 'category_id' => category.id }) @@ -146,15 +133,8 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do end context 'when filtered by status' do - let(:article3) do - create(:article, account: account, portal: portal, author: user, title: 'Test Article 3', status: 'published') - end - let(:article4) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 4', status: 'draft') } - - before do - article3 - article4 - end + let!(:article3) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 3', status: 'published') } + let!(:article4) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 4', status: 'draft') } it 'returns only articles with the specified status' do result = service.execute({ 'query' => 'Test', 'status' => 'published' }) diff --git a/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb index f4a5225b1..f987b7a6a 100644 --- a/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb +++ b/spec/enterprise/services/captain/tools/copilot/search_linear_issues_service_spec.rb @@ -3,7 +3,8 @@ require 'rails_helper' RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do let(:account) { create(:account) } let(:assistant) { create(:captain_assistant, account: account) } - let(:service) { described_class.new(assistant) } + let(:user) { create(:user, account: account) } + let(:service) { described_class.new(assistant, user: user) } describe '#name' do it 'returns the correct service name' do @@ -40,14 +41,34 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do create(:integrations_hook, :linear, account: account) end - it 'returns true' do - expect(service.active?).to be true + context 'when user is present' do + it 'returns true' do + expect(service.active?).to be true + end + end + + context 'when user is not present' do + let(:service) { described_class.new(assistant) } + + it 'returns false' do + expect(service.active?).to be false + end end end context 'when Linear integration is not enabled' do - it 'returns false' do - expect(service.active?).to be false + context 'when user is present' do + it 'returns false' do + expect(service.active?).to be false + end + end + + context 'when user is not present' do + let(:service) { described_class.new(assistant) } + + it 'returns false' do + expect(service.active?).to be false + end end end end diff --git a/spec/factories/articles.rb b/spec/factories/articles.rb index e53549090..b86e422a9 100644 --- a/spec/factories/articles.rb +++ b/spec/factories/articles.rb @@ -1,13 +1,14 @@ FactoryBot.define do factory :article, class: 'Article' do - account_id { 1 } - category_id { 1 } + account + category { nil } + portal locale { 'en' } - author_id { 1 } + association :author, factory: :user title { "#{Faker::Movie.title} #{SecureRandom.hex}" } content { 'MyText' } description { 'MyDescrption' } - status { 1 } + status { :published } views { 0 } end end diff --git a/spec/factories/categories.rb b/spec/factories/categories.rb index f7becf8fc..f9685b8b0 100644 --- a/spec/factories/categories.rb +++ b/spec/factories/categories.rb @@ -1,9 +1,10 @@ FactoryBot.define do factory :category, class: 'Category' do - portal { portal } + portal name { 'MyString' } description { 'MyText' } position { 1 } + slug { name.parameterize } after(:build) do |category| category.account ||= category.portal.account From 03bde0a8aa0b6ac21a6145dde68f5b1a9326799b Mon Sep 17 00:00:00 2001 From: Petterson <58094725+hahuma@users.noreply.github.com> Date: Tue, 27 May 2025 17:24:43 -0300 Subject: [PATCH 2/8] fix: Truncate name in attachment bubble (#11540) # Pull Request ## Description This PR fixes a UI issue where very long attachment filenames (sometimes including parameters or hashes) would overflow or break the layout in the message bubble. The fix applies Tailwind's truncate utility class to ensure these filenames are properly truncated with ellipsis, preserving layout consistency and improving readability. Fixes #11514 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - Manually tested with attachment messages containing long filenames (e.g., with hashes, query params, or excessive length). - Verified that filenames are now truncated with ellipsis. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] New and existing unit tests pass locally with my changes --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- .../components-next/message/bubbles/BaseAttachment.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/dashboard/components-next/message/bubbles/BaseAttachment.vue b/app/javascript/dashboard/components-next/message/bubbles/BaseAttachment.vue index a4e4aa729..a573e7868 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/BaseAttachment.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/BaseAttachment.vue @@ -40,7 +40,7 @@ const senderName = computed(() => { -
+
{{ t(senderTranslationKey, { From 22b5e12a53a3183127a13f8552cfb748f9b7525d Mon Sep 17 00:00:00 2001 From: Vaishnavi Aswale <48441026+Vaishnavi-Aswale@users.noreply.github.com> Date: Tue, 27 May 2025 14:34:59 -0700 Subject: [PATCH 3/8] fix: use supported access method for schema_format in Rails 7 (#11576) Description In Rails 7.1+, accessing `schema_format` via `ActiveRecord::Base` is no longer supported, causing a method error. The correct approach is to use `ActiveRecord.schema_format`, which aligns with the public API. This issue occurs locally because the environment uses newer versions of Ruby and Rails than others. Fixes: https://github.com/chatwoot/chatwoot/issues/11594 Co-authored-by: Sojan Jose --- lib/tasks/db_enhancements.rake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/tasks/db_enhancements.rake b/lib/tasks/db_enhancements.rake index d9cc22334..5fddb6cde 100644 --- a/lib/tasks/db_enhancements.rake +++ b/lib/tasks/db_enhancements.rake @@ -18,7 +18,7 @@ db_namespace = namespace :db do ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db_config| ActiveRecord::Base.establish_connection(db_config.configuration_hash) unless ActiveRecord::Base.connection.table_exists? 'ar_internal_metadata' - db_namespace['load_config'].invoke if ActiveRecord::Base.schema_format == :ruby + db_namespace['load_config'].invoke if ActiveRecord.schema_format == :ruby ActiveRecord::Tasks::DatabaseTasks.load_schema_current(:ruby, ENV.fetch('SCHEMA', nil)) db_namespace['seed'].invoke end From f42fddd38e1d9563799619cfb3bd17e706bafc0c Mon Sep 17 00:00:00 2001 From: Pranav Date: Tue, 27 May 2025 18:36:32 -0600 Subject: [PATCH 4/8] feat: Add stores for copilotMessages and copilotThreads (#11603) - Set up stores for copilotThreads and copilotMessages. - Add support for upsert messages to the copilotMessages store on receiving ActionCable events. - Implement support for the upsert option. --- .../dashboard/api/captain/copilotMessages.js | 18 + .../dashboard/api/captain/copilotThreads.js | 9 + .../dashboard/helper/actionCable.js | 5 + .../helper/specs/actionCable.spec.js | 67 +++ .../store/captain/copilotMessages.js | 19 + .../dashboard/store/captain/copilotThreads.js | 7 + .../dashboard/store/captain/storeFactory.js | 90 +---- .../store/captain/storeFactory.spec.js | 380 ++++++++++++++++++ .../store/captain/storeFactoryHelper.js | 77 ++++ app/javascript/dashboard/store/index.js | 5 + 10 files changed, 607 insertions(+), 70 deletions(-) create mode 100644 app/javascript/dashboard/api/captain/copilotMessages.js create mode 100644 app/javascript/dashboard/api/captain/copilotThreads.js create mode 100644 app/javascript/dashboard/helper/specs/actionCable.spec.js create mode 100644 app/javascript/dashboard/store/captain/copilotMessages.js create mode 100644 app/javascript/dashboard/store/captain/copilotThreads.js create mode 100644 app/javascript/dashboard/store/captain/storeFactory.spec.js create mode 100644 app/javascript/dashboard/store/captain/storeFactoryHelper.js diff --git a/app/javascript/dashboard/api/captain/copilotMessages.js b/app/javascript/dashboard/api/captain/copilotMessages.js new file mode 100644 index 000000000..49e05398a --- /dev/null +++ b/app/javascript/dashboard/api/captain/copilotMessages.js @@ -0,0 +1,18 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CopilotMessages extends ApiClient { + constructor() { + super('captain/copilot_threads', { accountScoped: true }); + } + + get(threadId) { + return axios.get(`${this.url}/${threadId}/copilot_messages`); + } + + create({ threadId, ...rest }) { + return axios.post(`${this.url}/${threadId}/copilot_messages`, rest); + } +} + +export default new CopilotMessages(); diff --git a/app/javascript/dashboard/api/captain/copilotThreads.js b/app/javascript/dashboard/api/captain/copilotThreads.js new file mode 100644 index 000000000..7fdce3b91 --- /dev/null +++ b/app/javascript/dashboard/api/captain/copilotThreads.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class CopilotThreads extends ApiClient { + constructor() { + super('captain/copilot_threads', { accountScoped: true }); + } +} + +export default new CopilotThreads(); diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index 515806b31..991576e66 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -33,6 +33,7 @@ class ActionCableConnector extends BaseActionCableConnector { 'conversation.read': this.onConversationRead, 'conversation.updated': this.onConversationUpdated, 'account.cache_invalidated': this.onCacheInvalidate, + 'copilot.message.created': this.onCopilotMessageCreated, }; } @@ -189,6 +190,10 @@ class ActionCableConnector extends BaseActionCableConnector { this.app.$store.dispatch('notifications/updateNotification', data); }; + onCopilotMessageCreated = data => { + this.app.$store.dispatch('copilotMessages/upsert', data); + }; + onCacheInvalidate = data => { const keys = data.cache_keys; this.app.$store.dispatch('labels/revalidate', { newKey: keys.label }); diff --git a/app/javascript/dashboard/helper/specs/actionCable.spec.js b/app/javascript/dashboard/helper/specs/actionCable.spec.js new file mode 100644 index 000000000..4ad8a52c6 --- /dev/null +++ b/app/javascript/dashboard/helper/specs/actionCable.spec.js @@ -0,0 +1,67 @@ +import { describe, it, beforeEach, expect, vi } from 'vitest'; +import ActionCableConnector from '../actionCable'; + +vi.mock('shared/helpers/mitt', () => ({ + emitter: { + emit: vi.fn(), + }, +})); + +vi.mock('dashboard/composables/useImpersonation', () => ({ + useImpersonation: () => ({ + isImpersonating: { value: false }, + }), +})); + +global.chatwootConfig = { + websocketURL: 'wss://test.chatwoot.com', +}; + +describe('ActionCableConnector - Copilot Tests', () => { + let store; + let actionCable; + let mockDispatch; + + beforeEach(() => { + vi.clearAllMocks(); + mockDispatch = vi.fn(); + store = { + $store: { + dispatch: mockDispatch, + getters: { + getCurrentAccountId: 1, + }, + }, + }; + + actionCable = ActionCableConnector.init(store.$store, 'test-token'); + }); + describe('copilot event handlers', () => { + it('should register the copilot.message.created event handler', () => { + expect(Object.keys(actionCable.events)).toContain( + 'copilot.message.created' + ); + expect(actionCable.events['copilot.message.created']).toBe( + actionCable.onCopilotMessageCreated + ); + }); + + it('should handle the copilot.message.created event through the ActionCable system', () => { + const copilotData = { + id: 2, + content: 'This is a copilot message from ActionCable', + conversation_id: 456, + created_at: '2025-05-27T15:58:04-06:00', + account_id: 1, + }; + actionCable.onReceived({ + event: 'copilot.message.created', + data: copilotData, + }); + expect(mockDispatch).toHaveBeenCalledWith( + 'copilotMessages/upsert', + copilotData + ); + }); + }); +}); diff --git a/app/javascript/dashboard/store/captain/copilotMessages.js b/app/javascript/dashboard/store/captain/copilotMessages.js new file mode 100644 index 000000000..83b7fddce --- /dev/null +++ b/app/javascript/dashboard/store/captain/copilotMessages.js @@ -0,0 +1,19 @@ +import CopilotMessagesAPI from 'dashboard/api/captain/copilotMessages'; +import { createStore } from './storeFactory'; + +export default createStore({ + name: 'CopilotMessages', + API: CopilotMessagesAPI, + getters: { + getMessagesByThreadId: state => copilotThreadId => { + return state.records.filter( + record => record.copilot_thread?.id === Number(copilotThreadId) + ); + }, + }, + actions: mutationTypes => ({ + upsert({ commit }, data) { + commit(mutationTypes.UPSERT, data); + }, + }), +}); diff --git a/app/javascript/dashboard/store/captain/copilotThreads.js b/app/javascript/dashboard/store/captain/copilotThreads.js new file mode 100644 index 000000000..8d820f305 --- /dev/null +++ b/app/javascript/dashboard/store/captain/copilotThreads.js @@ -0,0 +1,7 @@ +import CopilotThreadsAPI from 'dashboard/api/captain/copilotThreads'; +import { createStore } from './storeFactory'; + +export default createStore({ + name: 'CopilotThreads', + API: CopilotThreadsAPI, +}); diff --git a/app/javascript/dashboard/store/captain/storeFactory.js b/app/javascript/dashboard/store/captain/storeFactory.js index a55522062..ad669f62b 100644 --- a/app/javascript/dashboard/store/captain/storeFactory.js +++ b/app/javascript/dashboard/store/captain/storeFactory.js @@ -1,5 +1,11 @@ -import { throwErrorMessage } from 'dashboard/store/utils/api'; import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers'; +import { + createRecord, + deleteRecord, + getRecords, + showRecord, + updateRecord, +} from './storeFactoryHelper'; export const generateMutationTypes = name => { const capitalizedName = name.toUpperCase(); @@ -10,6 +16,7 @@ export const generateMutationTypes = name => { EDIT: `EDIT_${capitalizedName}`, DELETE: `DELETE_${capitalizedName}`, SET_META: `SET_${capitalizedName}_META`, + UPSERT: `UPSERT_${capitalizedName}`, }; }; @@ -33,7 +40,6 @@ export const createGetters = () => ({ getMeta: state => state.meta, }); -// store/mutations.js export const createMutations = mutationTypes => ({ [mutationTypes.SET_UI_FLAG](state, data) { state.uiFlags = { @@ -51,78 +57,19 @@ export const createMutations = mutationTypes => ({ [mutationTypes.ADD]: MutationHelpers.create, [mutationTypes.EDIT]: MutationHelpers.update, [mutationTypes.DELETE]: MutationHelpers.destroy, + [mutationTypes.UPSERT]: MutationHelpers.setSingleRecord, }); -// store/actions/crud.js export const createCrudActions = (API, mutationTypes) => ({ - async get({ commit }, params = {}) { - commit(mutationTypes.SET_UI_FLAG, { fetchingList: true }); - try { - const response = await API.get(params); - commit(mutationTypes.SET, response.data.payload); - commit(mutationTypes.SET_META, response.data.meta); - return response.data.payload; - } catch (error) { - return throwErrorMessage(error); - } finally { - commit(mutationTypes.SET_UI_FLAG, { fetchingList: false }); - } - }, - - async show({ commit }, id) { - commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true }); - try { - const response = await API.show(id); - commit(mutationTypes.ADD, response.data); - return response.data; - } catch (error) { - return throwErrorMessage(error); - } finally { - commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false }); - } - }, - - async create({ commit }, dataObj) { - commit(mutationTypes.SET_UI_FLAG, { creatingItem: true }); - try { - const response = await API.create(dataObj); - commit(mutationTypes.ADD, response.data); - return response.data; - } catch (error) { - return throwErrorMessage(error); - } finally { - commit(mutationTypes.SET_UI_FLAG, { creatingItem: false }); - } - }, - - async update({ commit }, { id, ...updateObj }) { - commit(mutationTypes.SET_UI_FLAG, { updatingItem: true }); - try { - const response = await API.update(id, updateObj); - commit(mutationTypes.EDIT, response.data); - return response.data; - } catch (error) { - return throwErrorMessage(error); - } finally { - commit(mutationTypes.SET_UI_FLAG, { updatingItem: false }); - } - }, - - async delete({ commit }, id) { - commit(mutationTypes.SET_UI_FLAG, { deletingItem: true }); - try { - await API.delete(id); - commit(mutationTypes.DELETE, id); - return id; - } catch (error) { - return throwErrorMessage(error); - } finally { - commit(mutationTypes.SET_UI_FLAG, { deletingItem: false }); - } - }, + get: getRecords(mutationTypes, API), + show: showRecord(mutationTypes, API), + create: createRecord(mutationTypes, API), + update: updateRecord(mutationTypes, API), + delete: deleteRecord(mutationTypes, API), }); + export const createStore = options => { - const { name, API, actions } = options; + const { name, API, actions, getters } = options; const mutationTypes = generateMutationTypes(name); const customActions = actions ? actions(mutationTypes) : {}; @@ -130,7 +77,10 @@ export const createStore = options => { return { namespaced: true, state: createInitialState(), - getters: createGetters(), + getters: { + ...createGetters(), + ...(getters || {}), + }, mutations: createMutations(mutationTypes), actions: { ...createCrudActions(API, mutationTypes), diff --git a/app/javascript/dashboard/store/captain/storeFactory.spec.js b/app/javascript/dashboard/store/captain/storeFactory.spec.js new file mode 100644 index 000000000..0aec26e40 --- /dev/null +++ b/app/javascript/dashboard/store/captain/storeFactory.spec.js @@ -0,0 +1,380 @@ +import { throwErrorMessage } from 'dashboard/store/utils/api'; +import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers'; +import { + generateMutationTypes, + createInitialState, + createGetters, + createMutations, + createCrudActions, + createStore, +} from './storeFactory'; + +vi.mock('dashboard/store/utils/api', () => ({ + throwErrorMessage: vi.fn(), +})); + +vi.mock('shared/helpers/vuex/mutationHelpers', () => ({ + set: vi.fn(), + create: vi.fn(), + update: vi.fn(), + destroy: vi.fn(), + setSingleRecord: vi.fn(), +})); + +describe('storeFactory', () => { + describe('generateMutationTypes', () => { + it('generates correct mutation types with capitalized name', () => { + const result = generateMutationTypes('test'); + expect(result).toEqual({ + SET_UI_FLAG: 'SET_TEST_UI_FLAG', + SET: 'SET_TEST', + ADD: 'ADD_TEST', + EDIT: 'EDIT_TEST', + DELETE: 'DELETE_TEST', + SET_META: 'SET_TEST_META', + UPSERT: 'UPSERT_TEST', + }); + }); + }); + + describe('createInitialState', () => { + it('returns the correct initial state structure', () => { + const result = createInitialState(); + expect(result).toEqual({ + records: [], + meta: {}, + uiFlags: { + fetchingList: false, + fetchingItem: false, + creatingItem: false, + updatingItem: false, + deletingItem: false, + }, + }); + }); + }); + + describe('createGetters', () => { + it('returns getters with correct implementations', () => { + const getters = createGetters(); + + const state = { + records: [{ id: 2 }, { id: 1 }, { id: 3 }], + uiFlags: { fetchingList: true }, + meta: { totalCount: 10, page: 1 }, + }; + expect(getters.getRecords(state)).toEqual([ + { id: 3 }, + { id: 2 }, + { id: 1 }, + ]); + + expect(getters.getRecord(state)(2)).toEqual({ id: 2 }); + expect(getters.getRecord(state)(4)).toEqual({}); + + expect(getters.getUIFlags(state)).toEqual({ + fetchingList: true, + }); + + expect(getters.getMeta(state)).toEqual({ + totalCount: 10, + page: 1, + }); + }); + }); + + describe('createMutations', () => { + it('creates mutations with correct implementations', () => { + const mutationTypes = generateMutationTypes('test'); + const mutations = createMutations(mutationTypes); + + const state = { uiFlags: { fetchingList: false } }; + mutations[mutationTypes.SET_UI_FLAG](state, { fetchingList: true }); + expect(state.uiFlags).toEqual({ fetchingList: true }); + + const metaState = { meta: {} }; + mutations[mutationTypes.SET_META](metaState, { + total_count: '10', + page: '2', + }); + expect(metaState.meta).toEqual({ totalCount: 10, page: 2 }); + + expect(mutations[mutationTypes.SET]).toBe(MutationHelpers.set); + expect(mutations[mutationTypes.ADD]).toBe(MutationHelpers.create); + expect(mutations[mutationTypes.EDIT]).toBe(MutationHelpers.update); + expect(mutations[mutationTypes.DELETE]).toBe(MutationHelpers.destroy); + expect(mutations[mutationTypes.UPSERT]).toBe( + MutationHelpers.setSingleRecord + ); + }); + }); + + describe('createCrudActions', () => { + let API; + let commit; + let mutationTypes; + let actions; + + beforeEach(() => { + API = { + get: vi.fn(), + show: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + commit = vi.fn(); + mutationTypes = generateMutationTypes('test'); + actions = createCrudActions(API, mutationTypes); + }); + + describe('get action', () => { + it('handles successful API response', async () => { + const payload = [{ id: 1 }]; + const meta = { total_count: 10, page: 1 }; + API.get.mockResolvedValue({ data: { payload, meta } }); + + const result = await actions.get({ commit }); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingList: true, + }); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET, payload); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_META, meta); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingList: false, + }); + expect(result).toEqual(payload); + }); + + it('handles API error', async () => { + const error = new Error('API Error'); + API.get.mockRejectedValue(error); + throwErrorMessage.mockReturnValue('Error thrown'); + + const result = await actions.get({ commit }); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingList: true, + }); + expect(throwErrorMessage).toHaveBeenCalledWith(error); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingList: false, + }); + expect(result).toEqual('Error thrown'); + }); + }); + + describe('show action', () => { + it('handles successful API response', async () => { + const data = { id: 1, name: 'Test' }; + API.show.mockResolvedValue({ data }); + + const result = await actions.show({ commit }, 1); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingItem: true, + }); + expect(commit).toHaveBeenCalledWith(mutationTypes.ADD, data); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingItem: false, + }); + expect(result).toEqual(data); + }); + + it('handles API error', async () => { + const error = new Error('API Error'); + API.show.mockRejectedValue(error); + throwErrorMessage.mockReturnValue('Error thrown'); + + const result = await actions.show({ commit }, 1); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingItem: true, + }); + expect(throwErrorMessage).toHaveBeenCalledWith(error); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + fetchingItem: false, + }); + expect(result).toEqual('Error thrown'); + }); + }); + + describe('create action', () => { + it('handles successful API response', async () => { + const data = { id: 1, name: 'Test' }; + API.create.mockResolvedValue({ data }); + + const result = await actions.create({ commit }, { name: 'Test' }); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + creatingItem: true, + }); + expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + creatingItem: false, + }); + expect(result).toEqual(data); + }); + + it('handles API error', async () => { + const error = new Error('API Error'); + API.create.mockRejectedValue(error); + throwErrorMessage.mockReturnValue('Error thrown'); + + const result = await actions.create({ commit }, { name: 'Test' }); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + creatingItem: true, + }); + expect(throwErrorMessage).toHaveBeenCalledWith(error); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + creatingItem: false, + }); + expect(result).toEqual('Error thrown'); + }); + }); + + describe('update action', () => { + it('handles successful API response', async () => { + const data = { id: 1, name: 'Updated' }; + API.update.mockResolvedValue({ data }); + + const result = await actions.update( + { commit }, + { id: 1, name: 'Updated' } + ); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + updatingItem: true, + }); + expect(API.update).toHaveBeenCalledWith(1, { name: 'Updated' }); + expect(commit).toHaveBeenCalledWith(mutationTypes.EDIT, data); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + updatingItem: false, + }); + expect(result).toEqual(data); + }); + + it('handles API error', async () => { + const error = new Error('API Error'); + API.update.mockRejectedValue(error); + throwErrorMessage.mockReturnValue('Error thrown'); + + const result = await actions.update( + { commit }, + { id: 1, name: 'Updated' } + ); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + updatingItem: true, + }); + expect(throwErrorMessage).toHaveBeenCalledWith(error); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + updatingItem: false, + }); + expect(result).toEqual('Error thrown'); + }); + }); + + describe('delete action', () => { + it('handles successful API response', async () => { + API.delete.mockResolvedValue({}); + + const result = await actions.delete({ commit }, 1); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + deletingItem: true, + }); + expect(API.delete).toHaveBeenCalledWith(1); + expect(commit).toHaveBeenCalledWith(mutationTypes.DELETE, 1); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + deletingItem: false, + }); + expect(result).toEqual(1); + }); + + it('handles API error', async () => { + const error = new Error('API Error'); + API.delete.mockRejectedValue(error); + throwErrorMessage.mockReturnValue('Error thrown'); + + const result = await actions.delete({ commit }, 1); + + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + deletingItem: true, + }); + expect(throwErrorMessage).toHaveBeenCalledWith(error); + expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, { + deletingItem: false, + }); + expect(result).toEqual('Error thrown'); + }); + }); + }); + + describe('createStore', () => { + it('creates a complete store with default options', () => { + const API = {}; + const store = createStore({ name: 'test', API }); + + expect(store.namespaced).toBe(true); + expect(store.state).toEqual(createInitialState()); + expect(Object.keys(store.getters)).toEqual([ + 'getRecords', + 'getRecord', + 'getUIFlags', + 'getMeta', + ]); + expect(Object.keys(store.mutations)).toEqual([ + 'SET_TEST_UI_FLAG', + 'SET_TEST_META', + 'SET_TEST', + 'ADD_TEST', + 'EDIT_TEST', + 'DELETE_TEST', + 'UPSERT_TEST', + ]); + expect(Object.keys(store.actions)).toEqual([ + 'get', + 'show', + 'create', + 'update', + 'delete', + ]); + }); + + it('creates a store with custom actions and getters', () => { + const API = {}; + const customGetters = { customGetter: () => 'custom' }; + const customActions = () => ({ + customAction: () => 'custom', + }); + + const store = createStore({ + name: 'test', + API, + getters: customGetters, + actions: customActions, + }); + + expect(store.getters).toHaveProperty('customGetter'); + expect(store.actions).toHaveProperty('customAction'); + expect(Object.keys(store.getters)).toEqual([ + 'getRecords', + 'getRecord', + 'getUIFlags', + 'getMeta', + 'customGetter', + ]); + expect(Object.keys(store.actions)).toEqual([ + 'get', + 'show', + 'create', + 'update', + 'delete', + 'customAction', + ]); + }); + }); +}); diff --git a/app/javascript/dashboard/store/captain/storeFactoryHelper.js b/app/javascript/dashboard/store/captain/storeFactoryHelper.js new file mode 100644 index 000000000..7a04d2e81 --- /dev/null +++ b/app/javascript/dashboard/store/captain/storeFactoryHelper.js @@ -0,0 +1,77 @@ +import { throwErrorMessage } from 'dashboard/store/utils/api'; + +export const getRecords = + (mutationTypes, API) => + async ({ commit }, params = {}) => { + commit(mutationTypes.SET_UI_FLAG, { fetchingList: true }); + try { + const response = await API.get(params); + commit(mutationTypes.SET, response.data.payload); + commit(mutationTypes.SET_META, response.data.meta); + return response.data.payload; + } catch (error) { + return throwErrorMessage(error); + } finally { + commit(mutationTypes.SET_UI_FLAG, { fetchingList: false }); + } + }; + +export const showRecord = + (mutationTypes, API) => + async ({ commit }, id) => { + commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true }); + try { + const response = await API.show(id); + commit(mutationTypes.ADD, response.data); + return response.data; + } catch (error) { + return throwErrorMessage(error); + } finally { + commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false }); + } + }; + +export const createRecord = + (mutationTypes, API) => + async ({ commit }, dataObj) => { + commit(mutationTypes.SET_UI_FLAG, { creatingItem: true }); + try { + const response = await API.create(dataObj); + commit(mutationTypes.UPSERT, response.data); + return response.data; + } catch (error) { + return throwErrorMessage(error); + } finally { + commit(mutationTypes.SET_UI_FLAG, { creatingItem: false }); + } + }; + +export const updateRecord = + (mutationTypes, API) => + async ({ commit }, { id, ...updateObj }) => { + commit(mutationTypes.SET_UI_FLAG, { updatingItem: true }); + try { + const response = await API.update(id, updateObj); + commit(mutationTypes.EDIT, response.data); + return response.data; + } catch (error) { + return throwErrorMessage(error); + } finally { + commit(mutationTypes.SET_UI_FLAG, { updatingItem: false }); + } + }; + +export const deleteRecord = + (mutationTypes, API) => + async ({ commit }, id) => { + commit(mutationTypes.SET_UI_FLAG, { deletingItem: true }); + try { + await API.delete(id); + commit(mutationTypes.DELETE, id); + return id; + } catch (error) { + return throwErrorMessage(error); + } finally { + commit(mutationTypes.SET_UI_FLAG, { deletingItem: false }); + } + }; diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js index 5daf73ae1..960285ebf 100755 --- a/app/javascript/dashboard/store/index.js +++ b/app/javascript/dashboard/store/index.js @@ -51,6 +51,9 @@ import captainDocuments from './captain/document'; import captainResponses from './captain/response'; import captainInboxes from './captain/inboxes'; import captainBulkActions from './captain/bulkActions'; +import copilotThreads from './captain/copilotThreads'; +import copilotMessages from './captain/copilotMessages'; + const plugins = []; export default createStore({ @@ -106,6 +109,8 @@ export default createStore({ captainResponses, captainInboxes, captainBulkActions, + copilotThreads, + copilotMessages, }, plugins, }); From 3ce026e2bcb974258d8f5b36c88f38d1a0f84be6 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 28 May 2025 09:46:59 +0530 Subject: [PATCH 5/8] feat: save timezone from leadsquared API (#11583) --- .../mappers/conversation_mapper.rb | 22 ++-- .../crm/leadsquared/processor_service.rb | 4 +- app/services/crm/leadsquared/setup_service.rb | 3 +- config/integration/apps.yml | 1 + .../mappers/conversation_mapper_spec.rb | 100 +++++++++++++----- .../crm/leadsquared/processor_service_spec.rb | 4 +- .../crm/leadsquared/setup_service_spec.rb | 3 + 7 files changed, 93 insertions(+), 44 deletions(-) diff --git a/app/services/crm/leadsquared/mappers/conversation_mapper.rb b/app/services/crm/leadsquared/mappers/conversation_mapper.rb index 97a148435..c5c358cbf 100644 --- a/app/services/crm/leadsquared/mappers/conversation_mapper.rb +++ b/app/services/crm/leadsquared/mappers/conversation_mapper.rb @@ -6,17 +6,18 @@ class Crm::Leadsquared::Mappers::ConversationMapper # so this limits it ACTIVITY_NOTE_MAX_SIZE = 1800 - def self.map_conversation_activity(conversation) - new(conversation).conversation_activity + def self.map_conversation_activity(hook, conversation) + new(hook, conversation).conversation_activity end - def self.map_transcript_activity(conversation, messages = nil) - new(conversation, messages).transcript_activity + def self.map_transcript_activity(hook, conversation) + new(hook, conversation).transcript_activity end - def initialize(conversation, messages = nil) + def initialize(hook, conversation) + @hook = hook + @timezone = Time.find_zone(hook.settings['timezone']) || Time.zone @conversation = conversation - @messages = messages end def conversation_activity @@ -41,14 +42,14 @@ class Crm::Leadsquared::Mappers::ConversationMapper private - attr_reader :conversation, :messages + attr_reader :conversation def formatted_creation_time - conversation.created_at.strftime('%Y-%m-%d %H:%M:%S') + conversation.created_at.in_time_zone(@timezone).strftime('%Y-%m-%d %H:%M:%S') end def transcript_messages - @transcript_messages ||= messages || conversation.messages.chat.select(&:conversation_transcriptable?) + @transcript_messages ||= conversation.messages.chat.select(&:conversation_transcriptable?) end def format_messages @@ -77,8 +78,7 @@ class Crm::Leadsquared::Mappers::ConversationMapper end def message_time(message) - # TODO: Figure out what timezone to send the time in - message.created_at.strftime('%Y-%m-%d %H:%M') + message.created_at.in_time_zone(@timezone).strftime('%Y-%m-%d %H:%M') end def sender_name(message) diff --git a/app/services/crm/leadsquared/processor_service.rb b/app/services/crm/leadsquared/processor_service.rb index aedea51d7..ef33718f2 100644 --- a/app/services/crm/leadsquared/processor_service.rb +++ b/app/services/crm/leadsquared/processor_service.rb @@ -37,7 +37,7 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService activity_type: 'conversation', activity_code_key: 'conversation_activity_code', metadata_key: 'created_activity_id', - activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_conversation_activity(conversation) + activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_conversation_activity(@hook, conversation) ) end @@ -50,7 +50,7 @@ class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService activity_type: 'transcript', activity_code_key: 'transcript_activity_code', metadata_key: 'transcript_activity_id', - activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_transcript_activity(conversation) + activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_transcript_activity(@hook, conversation) ) end diff --git a/app/services/crm/leadsquared/setup_service.rb b/app/services/crm/leadsquared/setup_service.rb index 956ff1a10..0433f68fd 100644 --- a/app/services/crm/leadsquared/setup_service.rb +++ b/app/services/crm/leadsquared/setup_service.rb @@ -25,11 +25,12 @@ class Crm::Leadsquared::SetupService response = @client.get('Authentication.svc/UserByAccessKey.Get') endpoint_host = response['LSQCommonServiceURLs']['api'] app_host = response['LSQCommonServiceURLs']['app'] + timezone = response['TimeZone'] endpoint_url = "https://#{endpoint_host}/v2/" app_url = "https://#{app_host}/" - update_hook_settings({ :endpoint_url => endpoint_url, :app_url => app_url }) + update_hook_settings({ :endpoint_url => endpoint_url, :app_url => app_url, :timezone => timezone }) # replace the clients @client = Crm::Leadsquared::Api::BaseClient.new(@access_key, @secret_key, endpoint_url) diff --git a/config/integration/apps.yml b/config/integration/apps.yml index 10ba2e056..2921bf637 100644 --- a/config/integration/apps.yml +++ b/config/integration/apps.yml @@ -205,6 +205,7 @@ leadsquared: 'secret_key': { 'type': 'string' }, 'endpoint_url': { 'type': 'string' }, 'app_url': { 'type': 'string' }, + 'timezone': { 'type': 'string' }, 'enable_conversation_activity': { 'type': 'boolean' }, 'enable_transcript_activity': { 'type': 'boolean' }, 'conversation_activity_score': { 'type': 'string' }, diff --git a/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb b/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb index 85bb08d74..0ddd4ac9f 100644 --- a/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb +++ b/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb @@ -6,15 +6,39 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do let(:conversation) { create(:conversation, account: account, inbox: inbox) } let(:user) { create(:user, name: 'John Doe') } let(:contact) { create(:contact, name: 'Jane Smith') } + let(:hook) do + create(:integrations_hook, :leadsquared, account: account, settings: { + 'access_key' => 'test_access_key', + 'secret_key' => 'test_secret_key', + 'endpoint_url' => 'https://api.leadsquared.com/v2', + 'timezone' => 'UTC' + }) + end + let(:hook_with_pst) do + create(:integrations_hook, :leadsquared, account: account, settings: { + 'access_key' => 'test_access_key', + 'secret_key' => 'test_secret_key', + 'endpoint_url' => 'https://api.leadsquared.com/v2', + 'timezone' => 'America/Los_Angeles' + }) + end + let(:hook_without_timezone) do + create(:integrations_hook, :leadsquared, account: account, settings: { + 'access_key' => 'test_access_key', + 'secret_key' => 'test_secret_key', + 'endpoint_url' => 'https://api.leadsquared.com/v2' + }) + end before do + account.enable_features('crm_integration') allow(GlobalConfig).to receive(:get).with('BRAND_NAME').and_return({ 'BRAND_NAME' => 'TestBrand' }) end describe '.map_conversation_activity' do - it 'generates conversation activity note' do - travel_to(Time.zone.parse('2024-01-01 10:00:00')) do - result = described_class.map_conversation_activity(conversation) + it 'generates conversation activity note with UTC timezone' do + travel_to(Time.zone.parse('2024-01-01 10:00:00 UTC')) do + result = described_class.map_conversation_activity(hook, conversation) expect(result).to include('New conversation started on TestBrand') expect(result).to include('Channel: Test Inbox') @@ -23,12 +47,29 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do expect(result).to include('View in TestBrand: http://') end end + + it 'formats time according to hook timezone setting' do + travel_to(Time.zone.parse('2024-01-01 18:00:00 UTC')) do + result = described_class.map_conversation_activity(hook_with_pst, conversation) + + # PST is UTC-8, so 18:00 UTC becomes 10:00:00 PST + expect(result).to include('Created: 2024-01-01 10:00:00') + end + end + + it 'falls back to system timezone when hook has no timezone setting' do + travel_to(Time.zone.parse('2024-01-01 10:00:00')) do + result = described_class.map_conversation_activity(hook_without_timezone, conversation) + + expect(result).to include('Created: 2024-01-01 10:00:00') + end + end end describe '.map_transcript_activity' do context 'when conversation has no messages' do it 'returns no messages message' do - result = described_class.map_transcript_activity(conversation) + result = described_class.map_transcript_activity(hook, conversation) expect(result).to eq('No messages in conversation') end end @@ -68,7 +109,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do end it 'generates transcript with messages in reverse chronological order' do - result = described_class.map_transcript_activity(conversation) + result = described_class.map_transcript_activity(hook, conversation) expect(result).to include('Conversation Transcript from TestBrand') expect(result).to include('Channel: Test Inbox') @@ -83,6 +124,22 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do expect(message_positions['[2024-01-01 10:01] Jane Smith: Hi there']).to be < message_positions['[2024-01-01 10:00] John Doe: Hello'] end + it 'formats message times according to hook timezone setting' do + travel_to(Time.zone.parse('2024-01-01 18:00:00 UTC')) do + create(:message, + conversation: conversation, + sender: user, + content: 'Test message', + message_type: :outgoing, + created_at: Time.zone.parse('2024-01-01 18:00:00 UTC')) + + result = described_class.map_transcript_activity(hook_with_pst, conversation) + + # PST is UTC-8, so 18:00 UTC becomes 10:00 PST + expect(result).to include('[2024-01-01 10:00] John Doe: Test message') + end + end + context 'when message has attachments' do let(:message_with_attachment) do create(:message, :with_attachment, @@ -96,7 +153,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do before { message_with_attachment } it 'includes attachment information' do - result = described_class.map_transcript_activity(conversation) + result = described_class.map_transcript_activity(hook, conversation) expect(result).to include('See attachment') expect(result).to include('[Attachment: image]') @@ -116,7 +173,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do before { empty_message } it 'shows no content placeholder' do - result = described_class.map_transcript_activity(conversation) + result = described_class.map_transcript_activity(hook, conversation) expect(result).to include('[No content]') end end @@ -134,25 +191,12 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do before { unnamed_sender_message } it 'uses sender type and id' do - result = described_class.map_transcript_activity(conversation) + result = described_class.map_transcript_activity(hook, conversation) expect(result).to include("User #{unnamed_sender_message.sender_id}") end end end - context 'when specific messages are provided' do - let(:message1) { create(:message, conversation: conversation, content: 'Message 1', message_type: :outgoing) } - let(:message2) { create(:message, conversation: conversation, content: 'Message 2', message_type: :outgoing) } - let(:specific_messages) { [message1] } - - it 'only includes provided messages' do - result = described_class.map_transcript_activity(conversation, specific_messages) - - expect(result).to include('Message 1') - expect(result).not_to include('Message 2') - end - end - context 'when messages exceed the ACTIVITY_NOTE_MAX_SIZE' do it 'truncates messages to stay within the character limit' do # Create a large number of messages with reasonably sized content @@ -169,7 +213,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do created_at: Time.zone.parse("2024-01-01 #{10 + i}:00:00")) end - result = described_class.map_transcript_activity(conversation, messages) + result = described_class.map_transcript_activity(hook, conversation) # Verify latest message is included (message 14) expect(result).to include("[2024-01-02 00:00] John Doe: #{long_message_content} 14") @@ -189,13 +233,13 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do it 'respects the ACTIVITY_NOTE_MAX_SIZE constant' do # Create a single message that would exceed the limit by itself giant_content = 'A' * 2000 - message = create(:message, - conversation: conversation, - sender: user, - content: giant_content, - message_type: :outgoing) + create(:message, + conversation: conversation, + sender: user, + content: giant_content, + message_type: :outgoing) - result = described_class.map_transcript_activity(conversation, [message]) + result = described_class.map_transcript_activity(hook, conversation) # Extract just the formatted messages part id = conversation.display_id diff --git a/spec/services/crm/leadsquared/processor_service_spec.rb b/spec/services/crm/leadsquared/processor_service_spec.rb index efdead00b..7008eb064 100644 --- a/spec/services/crm/leadsquared/processor_service_spec.rb +++ b/spec/services/crm/leadsquared/processor_service_spec.rb @@ -116,7 +116,7 @@ RSpec.describe Crm::Leadsquared::ProcessorService do before do allow(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:map_conversation_activity) - .with(conversation) + .with(hook, conversation) .and_return(activity_note) end @@ -180,7 +180,7 @@ RSpec.describe Crm::Leadsquared::ProcessorService do before do allow(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:map_transcript_activity) - .with(conversation) + .with(hook, conversation) .and_return(activity_note) end diff --git a/spec/services/crm/leadsquared/setup_service_spec.rb b/spec/services/crm/leadsquared/setup_service_spec.rb index 1d907ecda..8ebdc9691 100644 --- a/spec/services/crm/leadsquared/setup_service_spec.rb +++ b/spec/services/crm/leadsquared/setup_service_spec.rb @@ -8,6 +8,7 @@ RSpec.describe Crm::Leadsquared::SetupService do let(:activity_client) { instance_double(Crm::Leadsquared::Api::ActivityClient) } let(:endpoint_response) do { + 'TimeZone' => 'Asia/Kolkata', 'LSQCommonServiceURLs' => { 'api' => 'api-in.leadsquared.com', 'app' => 'app.leadsquared.com' @@ -45,6 +46,7 @@ RSpec.describe Crm::Leadsquared::SetupService do updated_settings = hook.reload.settings expect(updated_settings['endpoint_url']).to eq('https://api-in.leadsquared.com/v2/') expect(updated_settings['app_url']).to eq('https://app.leadsquared.com/') + expect(updated_settings['timezone']).to eq('Asia/Kolkata') expect(updated_settings['conversation_activity_code']).to eq(1001) expect(updated_settings['transcript_activity_code']).to eq(1002) end @@ -71,6 +73,7 @@ RSpec.describe Crm::Leadsquared::SetupService do updated_settings = hook.reload.settings expect(updated_settings['endpoint_url']).to eq('https://api-in.leadsquared.com/v2/') expect(updated_settings['app_url']).to eq('https://app.leadsquared.com/') + expect(updated_settings['timezone']).to eq('Asia/Kolkata') expect(updated_settings['conversation_activity_code']).to eq(1001) expect(updated_settings['transcript_activity_code']).to eq(1002) end From 443214e9a0ef1556737409f4508a9809333e1a60 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 28 May 2025 13:50:43 +0530 Subject: [PATCH 6/8] feat: add support for bunny CDN videos (#11601) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- lib/custom_markdown_renderer.rb | 10 +++++++++- lib/embed_renderer.rb | 15 +++++++++++++++ spec/lib/custom_markdown_renderer_spec.rb | 17 +++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/lib/custom_markdown_renderer.rb b/lib/custom_markdown_renderer.rb index 75dc43700..902fc20a3 100644 --- a/lib/custom_markdown_renderer.rb +++ b/lib/custom_markdown_renderer.rb @@ -7,6 +7,7 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer MP4_REGEX = %r{https?://(?:www\.)?.+\.(mp4)} ARCADE_REGEX = %r{https?://(?:www\.)?app\.arcade\.software/share/([^&/]+)} WISTIA_REGEX = %r{https?://(?:www\.)?([^/]+)\.wistia\.com/medias/([^&/]+)} + BUNNY_REGEX = %r{https?://iframe\.mediadelivery\.net/play/(\d+)/([^&/?]+)} def text(node) content = node.string_content @@ -52,7 +53,8 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer MP4_REGEX => :make_video_embed, LOOM_REGEX => :make_loom_embed, ARCADE_REGEX => :make_arcade_embed, - WISTIA_REGEX => :make_wistia_embed + WISTIA_REGEX => :make_wistia_embed, + BUNNY_REGEX => :make_bunny_embed } embedding_methods.each do |regex, method| @@ -104,4 +106,10 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer video_id = arcade_match[1] EmbedRenderer.arcade(video_id) end + + def make_bunny_embed(bunny_match) + library_id = bunny_match[1] + video_id = bunny_match[2] + EmbedRenderer.bunny(library_id, video_id) + end end diff --git a/lib/embed_renderer.rb b/lib/embed_renderer.rb index 0a747bbb3..78f620376 100644 --- a/lib/embed_renderer.rb +++ b/lib/embed_renderer.rb @@ -84,4 +84,19 @@ module EmbedRenderer
) end + + def self.bunny(library_id, video_id) + %( +
+ +
+ ) + end end diff --git a/spec/lib/custom_markdown_renderer_spec.rb b/spec/lib/custom_markdown_renderer_spec.rb index 939965e91..23574e8c4 100644 --- a/spec/lib/custom_markdown_renderer_spec.rb +++ b/spec/lib/custom_markdown_renderer_spec.rb @@ -162,5 +162,22 @@ describe CustomMarkdownRenderer do expect(output).to include('src="https://www.youtube-nocookie.com/embed/VIDEO_ID"') end end + + context 'when link is a Bunny.net URL' do + let(:bunny_url) { 'https://iframe.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c' } + + it 'renders an iframe with Bunny embed code' do + output = render_markdown_link(bunny_url) + expect(output).to include('src="https://iframe.mediadelivery.net/embed/431789/1f105841-cad9-46fe-a70e-b7623c60797c?autoplay=false&loop=false&muted=false&preload=true&responsive=true"') + expect(output).to include('allowfullscreen') + expect(output).to include('allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"') + end + + it 'wraps iframe in responsive container' do + output = render_markdown_link(bunny_url) + expect(output).to include('position: relative; padding-top: 56.25%;') + expect(output).to include('position: absolute; top: 0; height: 100%; width: 100%;') + end + end end end From b1120ae7fbfb69bf3f1ce68e4c47d9ebac71f08d Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 28 May 2025 13:50:50 +0530 Subject: [PATCH 7/8] feat: allow searching articles in omnisearch (#11558) Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- .../api/v1/accounts/search_controller.rb | 4 ++ app/javascript/dashboard/api/search.js | 9 +++ .../dashboard/i18n/locale/en/search.json | 6 +- .../components/SearchResultArticleItem.vue | 69 +++++++++++++++++++ .../components/SearchResultArticlesList.vue | 53 ++++++++++++++ .../modules/search/components/SearchView.vue | 56 +++++++++++++-- .../dashboard/modules/search/search.routes.js | 8 ++- .../store/modules/conversationSearch.js | 24 +++++++ .../specs/conversationSearch/actions.spec.js | 25 +++++++ .../specs/conversationSearch/getters.spec.js | 11 +++ .../conversationSearch/mutations.spec.js | 22 ++++++ .../dashboard/store/mutation-types.js | 2 + app/services/search_service.rb | 12 +++- .../v1/accounts/search/_article.json.jbuilder | 8 +++ .../_conversation_search_result.json.jbuilder | 15 ++++ .../v1/accounts/search/articles.json.jbuilder | 7 ++ .../v1/accounts/search/index.json.jbuilder | 22 ++---- config/routes.rb | 1 + .../api/v1/accounts/search_controller_spec.rb | 64 ++++++++++++++++- spec/services/search_service_spec.rb | 57 ++++++++++++++- 20 files changed, 449 insertions(+), 26 deletions(-) create mode 100644 app/javascript/dashboard/modules/search/components/SearchResultArticleItem.vue create mode 100644 app/javascript/dashboard/modules/search/components/SearchResultArticlesList.vue create mode 100644 app/views/api/v1/accounts/search/_article.json.jbuilder create mode 100644 app/views/api/v1/accounts/search/_conversation_search_result.json.jbuilder create mode 100644 app/views/api/v1/accounts/search/articles.json.jbuilder diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb index 35979f70f..13e3a6a6c 100644 --- a/app/controllers/api/v1/accounts/search_controller.rb +++ b/app/controllers/api/v1/accounts/search_controller.rb @@ -15,6 +15,10 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController @result = search('Message') end + def articles + @result = search('Article') + end + private def search(search_type) diff --git a/app/javascript/dashboard/api/search.js b/app/javascript/dashboard/api/search.js index 7abb584c0..d533c2f28 100644 --- a/app/javascript/dashboard/api/search.js +++ b/app/javascript/dashboard/api/search.js @@ -40,6 +40,15 @@ class SearchAPI extends ApiClient { }, }); } + + articles({ q, page = 1 }) { + return axios.get(`${this.url}/articles`, { + params: { + q, + page: page, + }, + }); + } } export default new SearchAPI(); diff --git a/app/javascript/dashboard/i18n/locale/en/search.json b/app/javascript/dashboard/i18n/locale/en/search.json index 3cb566813..e8510ab97 100644 --- a/app/javascript/dashboard/i18n/locale/en/search.json +++ b/app/javascript/dashboard/i18n/locale/en/search.json @@ -4,12 +4,14 @@ "ALL": "All", "CONTACTS": "Contacts", "CONVERSATIONS": "Conversations", - "MESSAGES": "Messages" + "MESSAGES": "Messages", + "ARTICLES": "Articles" }, "SECTION": { "CONTACTS": "Contacts", "CONVERSATIONS": "Conversations", - "MESSAGES": "Messages" + "MESSAGES": "Messages", + "ARTICLES": "Articles" }, "VIEW_MORE": "View more", "LOAD_MORE": "Load more", diff --git a/app/javascript/dashboard/modules/search/components/SearchResultArticleItem.vue b/app/javascript/dashboard/modules/search/components/SearchResultArticleItem.vue new file mode 100644 index 000000000..7e2da950e --- /dev/null +++ b/app/javascript/dashboard/modules/search/components/SearchResultArticleItem.vue @@ -0,0 +1,69 @@ + + + diff --git a/app/javascript/dashboard/modules/search/components/SearchResultArticlesList.vue b/app/javascript/dashboard/modules/search/components/SearchResultArticlesList.vue new file mode 100644 index 000000000..679e411c2 --- /dev/null +++ b/app/javascript/dashboard/modules/search/components/SearchResultArticlesList.vue @@ -0,0 +1,53 @@ + + + diff --git a/app/javascript/dashboard/modules/search/components/SearchView.vue b/app/javascript/dashboard/modules/search/components/SearchView.vue index 1b0a9e4d7..bd48a3078 100644 --- a/app/javascript/dashboard/modules/search/components/SearchView.vue +++ b/app/javascript/dashboard/modules/search/components/SearchView.vue @@ -8,6 +8,7 @@ import { ROLES, CONVERSATION_PERMISSIONS, CONTACT_PERMISSIONS, + PORTAL_PERMISSIONS, } from 'dashboard/constants/permissions.js'; import { getUserPermissions, @@ -22,6 +23,7 @@ import SearchTabs from './SearchTabs.vue'; import SearchResultConversationsList from './SearchResultConversationsList.vue'; import SearchResultMessagesList from './SearchResultMessagesList.vue'; import SearchResultContactsList from './SearchResultContactsList.vue'; +import SearchResultArticlesList from './SearchResultArticlesList.vue'; const router = useRouter(); const store = useStore(); @@ -34,6 +36,7 @@ const pages = ref({ contacts: 1, conversations: 1, messages: 1, + articles: 1, }); const currentUser = useMapGetter('getCurrentUser'); @@ -43,6 +46,7 @@ const conversationRecords = useMapGetter( 'conversationSearch/getConversationRecords' ); const messageRecords = useMapGetter('conversationSearch/getMessageRecords'); +const articleRecords = useMapGetter('conversationSearch/getArticleRecords'); const uiFlags = useMapGetter('conversationSearch/getUIFlags'); const addTypeToRecords = (records, type) => @@ -57,6 +61,9 @@ const mappedConversations = computed(() => const mappedMessages = computed(() => addTypeToRecords(messageRecords, 'message') ); +const mappedArticles = computed(() => + addTypeToRecords(articleRecords, 'article') +); const isSelectedTabAll = computed(() => selectedTab.value === 'all'); @@ -66,6 +73,7 @@ const sliceRecordsIfAllTab = items => const contacts = computed(() => sliceRecordsIfAllTab(mappedContacts)); const conversations = computed(() => sliceRecordsIfAllTab(mappedConversations)); const messages = computed(() => sliceRecordsIfAllTab(mappedMessages)); +const articles = computed(() => sliceRecordsIfAllTab(mappedArticles)); const filterByTab = tab => computed(() => selectedTab.value === tab || isSelectedTabAll.value); @@ -73,6 +81,7 @@ const filterByTab = tab => const filterContacts = filterByTab('contacts'); const filterConversations = filterByTab('conversations'); const filterMessages = filterByTab('messages'); +const filterArticles = filterByTab('articles'); const userPermissions = computed(() => getUserPermissions(currentUser.value, currentAccountId.value) @@ -80,7 +89,12 @@ const userPermissions = computed(() => const TABS_CONFIG = { all: { - permissions: [CONTACT_PERMISSIONS, ...ROLES, ...CONVERSATION_PERMISSIONS], + permissions: [ + CONTACT_PERMISSIONS, + ...ROLES, + ...CONVERSATION_PERMISSIONS, + PORTAL_PERMISSIONS, + ], count: () => null, // No count for all tab }, contacts: { @@ -95,6 +109,10 @@ const TABS_CONFIG = { permissions: [...ROLES, ...CONVERSATION_PERMISSIONS], count: () => mappedMessages.value.length, }, + articles: { + permissions: [...ROLES, PORTAL_PERMISSIONS], + count: () => mappedArticles.value.length, + }, }; const tabs = computed(() => { @@ -123,6 +141,10 @@ const totalSearchResultsCount = computed(() => { permissions: [...ROLES, ...CONVERSATION_PERMISSIONS], count: () => conversations.value.length + messages.value.length, }, + articles: { + permissions: [...ROLES, PORTAL_PERMISSIONS], + count: () => articles.value.length, + }, }; return filterItemsByPermission( permissionCounts, @@ -138,12 +160,13 @@ const activeTabIndex = computed(() => { }); const isFetchingAny = computed(() => { - const { contact, message, conversation, isFetching } = uiFlags.value; + const { contact, message, conversation, article, isFetching } = uiFlags.value; return ( isFetching || contact.isFetching || message.isFetching || - conversation.isFetching + conversation.isFetching || + article.isFetching ); }); @@ -171,6 +194,7 @@ const showLoadMore = computed(() => { contacts: mappedContacts.value, conversations: mappedConversations.value, messages: mappedMessages.value, + articles: mappedArticles.value, }[selectedTab.value]; return ( @@ -185,10 +209,11 @@ const showViewMore = computed(() => ({ conversations: mappedConversations.value?.length > 5 && isSelectedTabAll.value, messages: mappedMessages.value?.length > 5 && isSelectedTabAll.value, + articles: mappedArticles.value?.length > 5 && isSelectedTabAll.value, })); const clearSearchResult = () => { - pages.value = { contacts: 1, conversations: 1, messages: 1 }; + pages.value = { contacts: 1, conversations: 1, messages: 1, articles: 1 }; store.dispatch('conversationSearch/clearSearchResults'); }; @@ -214,6 +239,7 @@ const loadMore = () => { contacts: 'conversationSearch/contactSearch', conversations: 'conversationSearch/conversationSearch', messages: 'conversationSearch/messageSearch', + articles: 'conversationSearch/articleSearch', }; if (uiFlags.value.isFetching || selectedTab.value === 'all') return; @@ -328,6 +354,28 @@ onUnmounted(() => { /> + + + + +
{ q: 'test', }); expect(dispatch).toHaveBeenCalledWith('messageSearch', { q: 'test' }); + expect(dispatch).toHaveBeenCalledWith('articleSearch', { q: 'test' }); }); }); @@ -150,6 +151,30 @@ describe('#actions', () => { }); }); + describe('#articleSearch', () => { + it('should handle successful article search', async () => { + axios.get.mockResolvedValue({ + data: { payload: { articles: [{ id: 1 }] } }, + }); + + await actions.articleSearch({ commit }, { q: 'test', page: 1 }); + expect(commit.mock.calls).toEqual([ + [types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true }], + [types.ARTICLE_SEARCH_SET, [{ id: 1 }]], + [types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false }], + ]); + }); + + it('should handle failed article search', async () => { + axios.get.mockRejectedValue({}); + await actions.articleSearch({ commit }, { q: 'test' }); + expect(commit.mock.calls).toEqual([ + [types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true }], + [types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false }], + ]); + }); + }); + describe('#clearSearchResults', () => { it('should commit clear search results mutation', () => { actions.clearSearchResults({ commit }); diff --git a/app/javascript/dashboard/store/modules/specs/conversationSearch/getters.spec.js b/app/javascript/dashboard/store/modules/specs/conversationSearch/getters.spec.js index ea3ca7048..efce6084a 100644 --- a/app/javascript/dashboard/store/modules/specs/conversationSearch/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversationSearch/getters.spec.js @@ -37,6 +37,15 @@ describe('#getters', () => { ]); }); + it('getArticleRecords', () => { + const state = { + articleRecords: [{ id: 1, title: 'Article 1' }], + }; + expect(getters.getArticleRecords(state)).toEqual([ + { id: 1, title: 'Article 1' }, + ]); + }); + it('getUIFlags', () => { const state = { uiFlags: { @@ -45,6 +54,7 @@ describe('#getters', () => { contact: { isFetching: true }, message: { isFetching: false }, conversation: { isFetching: false }, + article: { isFetching: false }, }, }; expect(getters.getUIFlags(state)).toEqual({ @@ -53,6 +63,7 @@ describe('#getters', () => { contact: { isFetching: true }, message: { isFetching: false }, conversation: { isFetching: false }, + article: { isFetching: false }, }); }); }); diff --git a/app/javascript/dashboard/store/modules/specs/conversationSearch/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversationSearch/mutations.spec.js index 7bef2e527..bf7e833d0 100644 --- a/app/javascript/dashboard/store/modules/specs/conversationSearch/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversationSearch/mutations.spec.js @@ -101,17 +101,39 @@ describe('#mutations', () => { }); }); + describe('#ARTICLE_SEARCH_SET', () => { + it('should append new article records to existing ones', () => { + const state = { articleRecords: [{ id: 1 }] }; + mutations[types.ARTICLE_SEARCH_SET](state, [{ id: 2 }]); + expect(state.articleRecords).toEqual([{ id: 1 }, { id: 2 }]); + }); + }); + + describe('#ARTICLE_SEARCH_SET_UI_FLAG', () => { + it('set article search UI flags correctly', () => { + const state = { + uiFlags: { + article: { isFetching: true }, + }, + }; + mutations[types.ARTICLE_SEARCH_SET_UI_FLAG](state, { isFetching: false }); + expect(state.uiFlags.article).toEqual({ isFetching: false }); + }); + }); + describe('#CLEAR_SEARCH_RESULTS', () => { it('should clear all search records', () => { const state = { contactRecords: [{ id: 1 }], conversationRecords: [{ id: 1 }], messageRecords: [{ id: 1 }], + articleRecords: [{ id: 1 }], }; mutations[types.CLEAR_SEARCH_RESULTS](state); expect(state.contactRecords).toEqual([]); expect(state.conversationRecords).toEqual([]); expect(state.messageRecords).toEqual([]); + expect(state.articleRecords).toEqual([]); }); }); }); diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index a74207e92..f3817c45a 100644 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -317,8 +317,10 @@ export default { CONVERSATION_SEARCH_SET: 'CONVERSATION_SEARCH_SET', CONVERSATION_SEARCH_SET_UI_FLAG: 'CONVERSATION_SEARCH_SET_UI_FLAG', MESSAGE_SEARCH_SET: 'MESSAGE_SEARCH_SET', + ARTICLE_SEARCH_SET: 'ARTICLE_SEARCH_SET', CLEAR_SEARCH_RESULTS: 'CLEAR_SEARCH_RESULTS', MESSAGE_SEARCH_SET_UI_FLAG: 'MESSAGE_SEARCH_SET_UI_FLAG', + ARTICLE_SEARCH_SET_UI_FLAG: 'ARTICLE_SEARCH_SET_UI_FLAG', FULL_SEARCH_SET_UI_FLAG: 'FULL_SEARCH_SET_UI_FLAG', SET_CONVERSATION_PARTICIPANTS_UI_FLAG: 'SET_CONVERSATION_PARTICIPANTS_UI_FLAG', diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 5999c88a6..40d862b19 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -9,8 +9,10 @@ class SearchService { conversations: filter_conversations } when 'Contact' { contacts: filter_contacts } + when 'Article' + { articles: filter_articles } else - { contacts: filter_contacts, messages: filter_messages, conversations: filter_conversations } + { contacts: filter_contacts, messages: filter_messages, conversations: filter_conversations, articles: filter_articles } end end @@ -90,4 +92,12 @@ class SearchService ILIKE :search OR identifier ILIKE :search", search: "%#{search_query}%" ).resolved_contacts.order_on_last_activity_at('desc').page(params[:page]).per(15) end + + def filter_articles + @articles = current_account.articles + .text_search(search_query) + .reorder('updated_at DESC') + .page(params[:page]) + .per(15) + end end diff --git a/app/views/api/v1/accounts/search/_article.json.jbuilder b/app/views/api/v1/accounts/search/_article.json.jbuilder new file mode 100644 index 000000000..a3cf94614 --- /dev/null +++ b/app/views/api/v1/accounts/search/_article.json.jbuilder @@ -0,0 +1,8 @@ +json.id article.id +json.title article.title +json.locale article.locale +json.content article.content +json.slug article.slug +json.portal_slug article.portal.slug +json.account_id article.account_id +json.category_name article.category&.name diff --git a/app/views/api/v1/accounts/search/_conversation_search_result.json.jbuilder b/app/views/api/v1/accounts/search/_conversation_search_result.json.jbuilder new file mode 100644 index 000000000..a0b7e0203 --- /dev/null +++ b/app/views/api/v1/accounts/search/_conversation_search_result.json.jbuilder @@ -0,0 +1,15 @@ +json.id conversation.display_id +json.account_id conversation.account_id +json.created_at conversation.created_at.to_i +json.message do + json.partial! 'message', formats: [:json], message: conversation.messages.try(:first) +end +json.contact do + json.partial! 'contact', formats: [:json], contact: conversation.contact if conversation.try(:contact).present? +end +json.inbox do + json.partial! 'inbox', formats: [:json], inbox: conversation.inbox if conversation.try(:inbox).present? +end +json.agent do + json.partial! 'agent', formats: [:json], agent: conversation.assignee if conversation.try(:assignee).present? +end diff --git a/app/views/api/v1/accounts/search/articles.json.jbuilder b/app/views/api/v1/accounts/search/articles.json.jbuilder new file mode 100644 index 000000000..7d4fe031c --- /dev/null +++ b/app/views/api/v1/accounts/search/articles.json.jbuilder @@ -0,0 +1,7 @@ +json.payload do + json.articles do + json.array! @result[:articles] do |article| + json.partial! 'article', formats: [:json], article: article + end + end +end \ No newline at end of file diff --git a/app/views/api/v1/accounts/search/index.json.jbuilder b/app/views/api/v1/accounts/search/index.json.jbuilder index 1c6e86284..a3d8f1858 100644 --- a/app/views/api/v1/accounts/search/index.json.jbuilder +++ b/app/views/api/v1/accounts/search/index.json.jbuilder @@ -1,21 +1,7 @@ json.payload do json.conversations do json.array! @result[:conversations] do |conversation| - json.id conversation.display_id - json.account_id conversation.account_id - json.created_at conversation.created_at.to_i - json.message do - json.partial! 'message', formats: [:json], message: conversation.messages.try(:first) - end - json.contact do - json.partial! 'contact', formats: [:json], contact: conversation.contact if conversation.try(:contact).present? - end - json.inbox do - json.partial! 'inbox', formats: [:json], inbox: conversation.inbox if conversation.try(:inbox).present? - end - json.agent do - json.partial! 'agent', formats: [:json], agent: conversation.assignee if conversation.try(:assignee).present? - end + json.partial! 'conversation_search_result', formats: [:json], conversation: conversation end end json.contacts do @@ -23,10 +9,14 @@ json.payload do json.partial! 'contact', formats: [:json], contact: contact end end - json.messages do json.array! @result[:messages] do |message| json.partial! 'message', formats: [:json], message: message end end + json.articles do + json.array! @result[:articles] do |article| + json.partial! 'article', formats: [:json], article: article + end + end end diff --git a/config/routes.rb b/config/routes.rb index 4b4db7b6d..d1705d605 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -137,6 +137,7 @@ Rails.application.routes.draw do get :conversations get :messages get :contacts + get :articles end end diff --git a/spec/controllers/api/v1/accounts/search_controller_spec.rb b/spec/controllers/api/v1/accounts/search_controller_spec.rb index b5644cebf..ea59bec9c 100644 --- a/spec/controllers/api/v1/accounts/search_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/search_controller_spec.rb @@ -11,6 +11,11 @@ RSpec.describe 'Search', type: :request do create(:message, conversation: conversation, account: account, content: 'test2') create(:contact_inbox, contact_id: contact.id, inbox_id: conversation.inbox.id) create(:inbox_member, user: agent, inbox: conversation.inbox) + + # Create articles for testing + portal = create(:portal, account: account) + create(:article, title: 'Test Article Guide', content: 'This is a test article content', + account: account, portal: portal, author: agent, status: 'published') end describe 'GET /api/v1/accounts/{account.id}/search' do @@ -33,10 +38,11 @@ RSpec.describe 'Search', type: :request do response_data = JSON.parse(response.body, symbolize_names: true) expect(response_data[:payload][:messages].first[:content]).to eq 'test2' - expect(response_data[:payload].keys).to contain_exactly(:contacts, :conversations, :messages) + expect(response_data[:payload].keys).to contain_exactly(:contacts, :conversations, :messages, :articles) expect(response_data[:payload][:messages].length).to eq 2 expect(response_data[:payload][:conversations].length).to eq 1 expect(response_data[:payload][:contacts].length).to eq 1 + expect(response_data[:payload][:articles].length).to eq 1 end end end @@ -115,4 +121,60 @@ RSpec.describe 'Search', type: :request do end end end + + describe 'GET /api/v1/accounts/{account.id}/search/articles' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/search/articles", params: { q: 'test' } + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + it 'returns all articles containing the search query' do + get "/api/v1/accounts/#{account.id}/search/articles", + headers: agent.create_new_auth_token, + params: { q: 'test' }, + as: :json + + expect(response).to have_http_status(:success) + response_data = JSON.parse(response.body, symbolize_names: true) + + expect(response_data[:payload].keys).to contain_exactly(:articles) + expect(response_data[:payload][:articles].length).to eq 1 + expect(response_data[:payload][:articles].first[:title]).to eq 'Test Article Guide' + end + + it 'returns empty results when no articles match the search query' do + get "/api/v1/accounts/#{account.id}/search/articles", + headers: agent.create_new_auth_token, + params: { q: 'nonexistent' }, + as: :json + + expect(response).to have_http_status(:success) + response_data = JSON.parse(response.body, symbolize_names: true) + + expect(response_data[:payload].keys).to contain_exactly(:articles) + expect(response_data[:payload][:articles].length).to eq 0 + end + + it 'supports pagination' do + portal = create(:portal, account: account) + 16.times do |i| + create(:article, title: "Test Article #{i}", account: account, portal: portal, author: agent, status: 'published') + end + + get "/api/v1/accounts/#{account.id}/search/articles", + headers: agent.create_new_auth_token, + params: { q: 'test', page: 1 }, + as: :json + + expect(response).to have_http_status(:success) + response_data = JSON.parse(response.body, symbolize_names: true) + + expect(response_data[:payload][:articles].length).to eq 15 # Default per_page is 15 + end + end + end end diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb index af097a2c9..22809d042 100644 --- a/spec/services/search_service_spec.rb +++ b/spec/services/search_service_spec.rb @@ -10,6 +10,11 @@ describe SearchService do let!(:harry) { create(:contact, name: 'Harry Potter', email: 'test@test.com', account_id: account.id) } let!(:conversation) { create(:conversation, contact: harry, inbox: inbox, account: account) } let!(:message) { create(:message, account: account, inbox: inbox, content: 'Harry Potter is a wizard') } + let!(:portal) { create(:portal, account: account) } + let(:article) do + create(:article, title: 'Harry Potter Magic Guide', content: 'Learn about wizardry', account: account, portal: portal, author: user, + status: 'published') + end before do create(:inbox_member, user: user, inbox: inbox) @@ -27,7 +32,7 @@ describe SearchService do it 'returns all for all' do search_type = 'all' search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type) - expect(search.perform.keys).to match_array(%i[contacts messages conversations]) + expect(search.perform.keys).to match_array(%i[contacts messages conversations articles]) end it 'returns contacts for contacts' do @@ -47,6 +52,12 @@ describe SearchService do search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type) expect(search.perform.keys).to match_array(%i[conversations]) end + + it 'returns articles for articles' do + search_type = 'Article' + search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type) + expect(search.perform.keys).to match_array(%i[articles]) + end end context 'when contact search' do @@ -143,6 +154,50 @@ describe SearchService do expect(search.perform[:conversations].map(&:id)).to include new_converstion.id end end + + context 'when article search' do + it 'orders results by updated_at desc' do + # Create articles with explicit timestamps + older_time = 2.days.ago + newer_time = 1.hour.ago + + article2 = create(:article, title: 'Spellcasting Guide', + account: account, portal: portal, author: user, status: 'published') + # rubocop:disable Rails/SkipsModelValidations + article2.update_column(:updated_at, older_time) + # rubocop:enable Rails/SkipsModelValidations + + article3 = create(:article, title: 'Spellcasting Manual', + account: account, portal: portal, author: user, status: 'published') + # rubocop:disable Rails/SkipsModelValidations + article3.update_column(:updated_at, newer_time) + # rubocop:enable Rails/SkipsModelValidations + + params = { q: 'Spellcasting' } + search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Article') + results = search.perform[:articles] + + # Check the timestamps to understand ordering + results.map { |a| [a.id, a.updated_at] } + + # Should be ordered by updated_at desc (newer first) + expect(results.length).to eq(2) + expect(results.first.updated_at).to be > results.second.updated_at + end + + it 'returns paginated results' do + # Create many articles to test pagination + 16.times do |i| + create(:article, title: "Magic Article #{i}", account: account, portal: portal, author: user, status: 'published') + end + + params = { q: 'Magic', page: 1 } + search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Article') + results = search.perform[:articles] + + expect(results.length).to eq(15) # Default per_page is 15 + end + end end describe '#use_gin_search' do From dc335e88c9dee4f6cfd60f41bb99f39bcf37c3a6 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 28 May 2025 15:15:05 +0530 Subject: [PATCH 8/8] fix: External links in widget not opening in new tab (#11608) --- app/javascript/portal/portalHelpers.js | 11 +-- app/javascript/portal/specs/portal.spec.js | 98 +++++++++++++++++++++- 2 files changed, 99 insertions(+), 10 deletions(-) diff --git a/app/javascript/portal/portalHelpers.js b/app/javascript/portal/portalHelpers.js index 5cced0fa4..9cb28e63a 100644 --- a/app/javascript/portal/portalHelpers.js +++ b/app/javascript/portal/portalHelpers.js @@ -38,16 +38,9 @@ export const openExternalLinksInNewTab = () => { document.addEventListener('click', event => { if (!isOnArticlePage) return; - // Some of the links come wrapped in strong tag through prosemirror - - const isTagAnchor = event.target.tagName === 'A'; - const isParentTagAnchor = - event.target.tagName === 'STRONG' && - event.target.parentNode.tagName === 'A'; - - if (isTagAnchor || isParentTagAnchor) { - const link = isTagAnchor ? event.target : event.target.parentNode; + const link = event.target.closest('a'); + if (link) { const isInternalLink = link.hostname === window.location.hostname || link.href.includes(customDomain) || diff --git a/app/javascript/portal/specs/portal.spec.js b/app/javascript/portal/specs/portal.spec.js index 13edd3718..861950a57 100644 --- a/app/javascript/portal/specs/portal.spec.js +++ b/app/javascript/portal/specs/portal.spec.js @@ -1,6 +1,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { JSDOM } from 'jsdom'; -import { InitializationHelpers } from '../portalHelpers'; +import { + InitializationHelpers, + openExternalLinksInNewTab, +} from '../portalHelpers'; describe('InitializationHelpers.navigateToLocalePage', () => { let dom; @@ -44,3 +47,96 @@ describe('InitializationHelpers.navigateToLocalePage', () => { ); }); }); + +describe('openExternalLinksInNewTab', () => { + let dom; + let document; + let window; + + beforeEach(() => { + dom = new JSDOM( + ` + + +
+ External + Internal + Custom + CodeBold + +
+ + `, + { url: 'https://app.chatwoot.com/hc/article' } + ); + + document = dom.window.document; + window = dom.window; + + window.portalConfig = { + customDomain: 'custom.domain.com', + hostURL: 'app.chatwoot.com', + }; + + global.document = document; + global.window = window; + }); + + afterEach(() => { + dom = null; + document = null; + window = null; + delete global.document; + delete global.window; + }); + + const simulateClick = selector => { + const element = document.querySelector(selector); + const event = new window.MouseEvent('click', { bubbles: true }); + element.dispatchEvent(event); + return element.closest('a') || element; + }; + + it('opens external links in new tab', () => { + openExternalLinksInNewTab(); + + const link = simulateClick('#external'); + + expect(link.target).toBe('_blank'); + expect(link.rel).toBe('noopener noreferrer'); + }); + + it('preserves internal links', () => { + openExternalLinksInNewTab(); + + const internal = simulateClick('#internal'); + const custom = simulateClick('#custom'); + + expect(internal.target).not.toBe('_blank'); + expect(custom.target).not.toBe('_blank'); + }); + + it('handles clicks on nested elements', () => { + openExternalLinksInNewTab(); + + simulateClick('#nested code'); + simulateClick('#nested strong'); + + const link = document.getElementById('nested'); + expect(link.target).toBe('_blank'); + expect(link.rel).toBe('noopener noreferrer'); + }); + + it('handles links inside list items with strong tags', () => { + openExternalLinksInNewTab(); + + // Click on the strong element inside the link in the list + simulateClick('#list-link strong'); + + const link = document.getElementById('list-link'); + expect(link.target).toBe('_blank'); + expect(link.rel).toBe('noopener noreferrer'); + }); +});