From 9bd658137a639bf6fd65b65b5c8dd38875b937fa Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Fri, 23 May 2025 16:12:18 +0530
Subject: [PATCH 01/11] feat: Scroll lock on message context menu (#11454)
This PR uses `useScrollLock` from `VueUse` to lock scrolling on the
conversation panel when the message context menu is open.
---
.../components-next/message/Message.vue | 29 ++++++-----
.../dashboard/components/ChatList.vue | 16 ++-----
.../dashboard/components/ui/ContextMenu.vue | 48 ++++++++++++++-----
.../widgets/conversation/Message.vue | 15 ++++--
.../widgets/conversation/MessagesView.vue | 9 +++-
.../components/MessageContextMenu.vue | 6 ++-
6 files changed, 80 insertions(+), 43 deletions(-)
diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue
index cf6cc0881..98323f79a 100644
--- a/app/javascript/dashboard/components-next/message/Message.vue
+++ b/app/javascript/dashboard/components-next/message/Message.vue
@@ -315,11 +315,7 @@ const componentToRender = computed(() => {
});
const shouldShowContextMenu = computed(() => {
- return !(
- props.status === MESSAGE_STATUS.FAILED ||
- props.status === MESSAGE_STATUS.PROGRESS ||
- props.contentAttributes?.isUnsupported
- );
+ return !props.contentAttributes?.isUnsupported;
});
const isBubble = computed(() => {
@@ -344,12 +340,23 @@ const contextMenuEnabledOptions = computed(() => {
const hasAttachments = !!(props.attachments && props.attachments.length > 0);
const isOutgoing = props.messageType === MESSAGE_TYPES.OUTGOING;
+ const isFailedOrProcessing =
+ props.status === MESSAGE_STATUS.FAILED ||
+ props.status === MESSAGE_STATUS.PROGRESS;
return {
copy: hasText,
- delete: hasText || hasAttachments,
- cannedResponse: isOutgoing && hasText,
- replyTo: !props.private && props.inboxSupportsReplyTo.outgoing,
+ delete:
+ (hasText || hasAttachments) &&
+ !isFailedOrProcessing &&
+ !isMessageDeleted.value,
+ cannedResponse: isOutgoing && hasText && !isMessageDeleted.value,
+ copyLink: !isFailedOrProcessing,
+ translate: !isFailedOrProcessing && !isMessageDeleted.value && hasText,
+ replyTo:
+ !props.private &&
+ props.inboxSupportsReplyTo.outgoing &&
+ !isFailedOrProcessing,
};
});
@@ -499,8 +506,8 @@ provideMessageContext({
diff --git a/app/javascript/dashboard/components/widgets/conversation/Message.vue b/app/javascript/dashboard/components/widgets/conversation/Message.vue
index 253eaeaa7..bed90fd05 100644
--- a/app/javascript/dashboard/components/widgets/conversation/Message.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/Message.vue
@@ -185,8 +185,17 @@ export default {
contextMenuEnabledOptions() {
return {
copy: this.hasText,
- delete: this.hasText || this.hasAttachments,
- cannedResponse: this.isOutgoing && this.hasText,
+ delete:
+ (this.hasText || this.hasAttachments) &&
+ !this.isMessageDeleted &&
+ !this.isFailed,
+ cannedResponse:
+ this.isOutgoing && this.hasText && !this.isMessageDeleted,
+ copyLink: !this.isFailed || !this.isProcessing,
+ translate:
+ (!this.isFailed || !this.isProcessing) &&
+ !this.isMessageDeleted &&
+ this.hasText,
replyTo: !this.data.private && this.inboxSupportsReplyTo.outgoing,
};
},
@@ -328,7 +337,7 @@ export default {
return !this.sender.type || this.sender.type === 'agent_bot';
},
shouldShowContextMenu() {
- return !(this.isFailed || this.isPending || this.isUnsupported);
+ return !this.isUnsupported;
},
showAvatar() {
if (this.isOutgoing || this.isTemplate) {
diff --git a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue
index 8d0c77f1b..e5e5da385 100644
--- a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue
@@ -1,5 +1,5 @@
+
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+ {{ category }}
+
+
+
+ {{ truncatedContent }}
+
+
+
+
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 09/11] 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(
+ `
+
+
+
+
+ `,
+ { 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');
+ });
+});
From f916fb2924e525b879ac060526bd3ccfb80ef47e Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Wed, 28 May 2025 17:56:32 +0530
Subject: [PATCH 10/11] fix: handle empty customDomain when checking for
`isInternalLink` (#11609)
This PR improves the portal's internal link detection logic to be more
robust when handling empty or undefined configuration values.
Previously, the code could fail when `customDomain` was empty, causing
external links to incorrectly behave as internal links. The fix
introduces a new `isSameOrigin` helper function that safely compares
URLs using proper URL parsing and origin comparison, gracefully handling
edge cases like missing domains, relative paths, and malformed URLs.
This ensures external links consistently open in new tabs regardless of
portal configuration completeness.
---
app/javascript/portal/portalHelpers.js | 63 ++++++++++++++++++----
app/javascript/portal/specs/portal.spec.js | 45 +++++++++++++++-
2 files changed, 98 insertions(+), 10 deletions(-)
diff --git a/app/javascript/portal/portalHelpers.js b/app/javascript/portal/portalHelpers.js
index 9cb28e63a..68e2cb6a8 100644
--- a/app/javascript/portal/portalHelpers.js
+++ b/app/javascript/portal/portalHelpers.js
@@ -25,15 +25,56 @@ export const getHeadingsfromTheArticle = () => {
return rows;
};
+/**
+ * Converts various input formats to URL objects.
+ * Handles URL objects, domain strings, relative paths, and full URLs.
+ * @param {string|URL} input - Input to convert to URL object
+ * @returns {URL|null} URL object or null if input is invalid
+ */
+const toURL = input => {
+ if (!input) return null;
+ if (input instanceof URL) return input;
+
+ if (
+ typeof input === 'string' &&
+ !input.includes('://') &&
+ !input.startsWith('/')
+ ) {
+ return new URL(`https://${input}`);
+ }
+
+ if (typeof input === 'string' && input.startsWith('/')) {
+ return new URL(input, window.location.origin);
+ }
+
+ return new URL(input);
+};
+
+/**
+ * Determines if two URLs belong to the same host by comparing their normalized URL objects.
+ * Handles various input formats including URL objects, domain strings, relative paths, and full URLs.
+ * Returns false if either URL cannot be parsed or normalized.
+ * @param {string|URL} url1 - First URL to compare
+ * @param {string|URL} url2 - Second URL to compare
+ * @returns {boolean} True if both URLs have the same host, false otherwise
+ */
+const isSameHost = (url1, url2) => {
+ try {
+ const urlObj1 = toURL(url1);
+ const urlObj2 = toURL(url2);
+
+ if (!urlObj1 || !urlObj2) return false;
+
+ return urlObj1.hostname === urlObj2.hostname;
+ } catch (error) {
+ return false;
+ }
+};
+
export const openExternalLinksInNewTab = () => {
const { customDomain, hostURL } = window.portalConfig;
- const isSameHost =
- window.location.href.includes(customDomain) ||
- window.location.href.includes(hostURL);
-
- // Modify external links only on articles page
const isOnArticlePage =
- isSameHost && document.querySelector('#cw-article-content') !== null;
+ document.querySelector('#cw-article-content') !== null;
document.addEventListener('click', event => {
if (!isOnArticlePage) return;
@@ -41,10 +82,14 @@ export const openExternalLinksInNewTab = () => {
const link = event.target.closest('a');
if (link) {
+ const currentLocation = window.location.href;
+ const linkHref = link.href;
+
+ // Check against current location and custom domains
const isInternalLink =
- link.hostname === window.location.hostname ||
- link.href.includes(customDomain) ||
- link.href.includes(hostURL);
+ isSameHost(linkHref, currentLocation) ||
+ (customDomain && isSameHost(linkHref, customDomain)) ||
+ (hostURL && isSameHost(linkHref, hostURL));
if (!isInternalLink) {
link.target = '_blank';
diff --git a/app/javascript/portal/specs/portal.spec.js b/app/javascript/portal/specs/portal.spec.js
index 861950a57..5205c5d45 100644
--- a/app/javascript/portal/specs/portal.spec.js
+++ b/app/javascript/portal/specs/portal.spec.js
@@ -103,7 +103,6 @@ describe('openExternalLinksInNewTab', () => {
openExternalLinksInNewTab();
const link = simulateClick('#external');
-
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener noreferrer');
});
@@ -139,4 +138,48 @@ describe('openExternalLinksInNewTab', () => {
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener noreferrer');
});
+
+ it('opens external links in a new tab even if customDomain is empty', () => {
+ window = dom.window;
+ window.portalConfig = {
+ hostURL: 'app.chatwoot.com',
+ };
+
+ global.window = window;
+
+ openExternalLinksInNewTab();
+
+ const link = simulateClick('#external');
+ const internal = simulateClick('#internal');
+ const custom = simulateClick('#custom');
+
+ expect(link.target).toBe('_blank');
+ expect(link.rel).toBe('noopener noreferrer');
+
+ expect(internal.target).not.toBe('_blank');
+ // this will be blank since the configs customDomain is empty
+ // which is a fair expectation
+ expect(custom.target).toBe('_blank');
+ });
+
+ it('opens external links in a new tab even if hostURL is empty', () => {
+ window = dom.window;
+ window.portalConfig = {
+ customDomain: 'custom.domain.com',
+ };
+
+ global.window = window;
+
+ openExternalLinksInNewTab();
+
+ const link = simulateClick('#external');
+ const internal = simulateClick('#internal');
+ const custom = simulateClick('#custom');
+
+ expect(link.target).toBe('_blank');
+ expect(link.rel).toBe('noopener noreferrer');
+
+ expect(internal.target).not.toBe('_blank');
+ expect(custom.target).not.toBe('_blank');
+ });
});
From b5ebc4763723e15ad3a04ab0b0ecb1e01087a2b1 Mon Sep 17 00:00:00 2001
From: Muhsin Keloth
Date: Wed, 28 May 2025 19:34:11 +0530
Subject: [PATCH 11/11] fix: Send CSAT survey only when agent can reply in
conversation (#11584)
Fixes https://github.com/chatwoot/chatwoot/issues/11569
## Problem
On platforms like WhatsApp and Facebook Messenger, customers cannot
reply to messages after 24 hours (or other channel-specific messaging
windows). Despite this limitation, the system continued sending CSAT
surveys to customers outside their messaging window, making it
impossible for them to respond.
## Solution
Added a check for `conversation.can_reply?` in the
`should_send_csat_survey?` method. This leverages the existing
`MessageWindowService` which already handles all channel-specific
messaging window logic.
---
.../concerns/activity_message_handler.rb | 1 +
.../concerns/csat_activity_message_handler.rb | 8 +++++
.../hook_execution_service.rb | 23 +++++++++++----
config/locales/en.yml | 2 ++
spec/models/conversation_spec.rb | 14 +++++++++
.../hook_execution_service_spec.rb | 29 ++++++++++++++++++-
6 files changed, 71 insertions(+), 6 deletions(-)
create mode 100644 app/models/concerns/csat_activity_message_handler.rb
diff --git a/app/models/concerns/activity_message_handler.rb b/app/models/concerns/activity_message_handler.rb
index 54e58b4d9..0d6741c7a 100644
--- a/app/models/concerns/activity_message_handler.rb
+++ b/app/models/concerns/activity_message_handler.rb
@@ -5,6 +5,7 @@ module ActivityMessageHandler
include LabelActivityMessageHandler
include SlaActivityMessageHandler
include TeamActivityMessageHandler
+ include CsatActivityMessageHandler
private
diff --git a/app/models/concerns/csat_activity_message_handler.rb b/app/models/concerns/csat_activity_message_handler.rb
new file mode 100644
index 000000000..7a2488c4d
--- /dev/null
+++ b/app/models/concerns/csat_activity_message_handler.rb
@@ -0,0 +1,8 @@
+module CsatActivityMessageHandler
+ extend ActiveSupport::Concern
+
+ def create_csat_not_sent_activity_message
+ content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
+ ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
+ end
+end
diff --git a/app/services/message_templates/hook_execution_service.rb b/app/services/message_templates/hook_execution_service.rb
index a8a4c4318..8291c8a9c 100644
--- a/app/services/message_templates/hook_execution_service.rb
+++ b/app/services/message_templates/hook_execution_service.rb
@@ -17,7 +17,7 @@ class MessageTemplates::HookExecutionService
::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message?
::MessageTemplates::Template::Greeting.new(conversation: conversation).perform if should_send_greeting?
::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if inbox.enable_email_collect && should_send_email_collect?
- ::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform if should_send_csat_survey?
+ handle_csat_survey
end
def should_send_out_of_office_message?
@@ -65,13 +65,26 @@ class MessageTemplates::HookExecutionService
true
end
- def should_send_csat_survey?
+ def handle_csat_survey
return unless csat_enabled_conversation?
-
# only send CSAT once in a conversation
- return if conversation.messages.where(content_type: :input_csat).present?
+ return if csat_already_sent?
- true
+ # Only send CSAT if agent can still reply by checking the messaging window restriction
+ # https://www.chatwoot.com/docs/self-hosted/supported-features#outgoing-message-restriction
+ if within_messaging_window?
+ ::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
+ else
+ conversation.create_csat_not_sent_activity_message
+ end
+ end
+
+ def csat_already_sent?
+ conversation.messages.where(content_type: :input_csat).present?
+ end
+
+ def within_messaging_window?
+ conversation.can_reply?
end
end
MessageTemplates::HookExecutionService.prepend_mod_with('MessageTemplates::HookExecutionService')
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 9a35197ad..b309e718c 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -185,6 +185,8 @@ en:
removed: '%{user_name} removed %{labels}'
sla:
added: '%{user_name} added SLA policy %{sla_name}'
+ csat:
+ not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
removed: '%{user_name} removed SLA policy %{sla_name}'
muted: '%{user_name} has muted the conversation'
unmuted: '%{user_name} has unmuted the conversation'
diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb
index aef91603d..ad3446a13 100644
--- a/spec/models/conversation_spec.rb
+++ b/spec/models/conversation_spec.rb
@@ -435,6 +435,20 @@ RSpec.describe Conversation do
end
end
+ describe '#create_csat_not_sent_activity_message' do
+ subject(:create_csat_not_sent_activity_message) { conversation.create_csat_not_sent_activity_message }
+
+ let(:conversation) { create(:conversation) }
+
+ it 'creates CSAT not sent activity message' do
+ create_csat_not_sent_activity_message
+ expect(Conversations::ActivityMessageJob)
+ .to(have_been_enqueued.at_least(:once).with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id,
+ message_type: :activity,
+ content: 'CSAT survey not sent due to outgoing message restrictions' }))
+ end
+ end
+
describe 'unread_messages' do
subject(:unread_messages) { conversation.unread_messages }
diff --git a/spec/services/message_templates/hook_execution_service_spec.rb b/spec/services/message_templates/hook_execution_service_spec.rb
index 24e40ea8d..6e97aac42 100644
--- a/spec/services/message_templates/hook_execution_service_spec.rb
+++ b/spec/services/message_templates/hook_execution_service_spec.rb
@@ -121,8 +121,9 @@ describe MessageTemplates::HookExecutionService do
create(:message, conversation: conversation, message_type: 'incoming')
end
- it 'calls ::MessageTemplates::Template::CsatSurvey when a conversation is resolved in an inbox with survey enabled' do
+ it 'calls ::MessageTemplates::Template::CsatSurvey when a conversation is resolved in an inbox with survey enabled and can reply' do
conversation.inbox.update(csat_survey_enabled: true)
+ allow(conversation).to receive(:can_reply?).and_return(true)
conversation.resolved!
Conversations::ActivityMessageJob.perform_now(conversation,
@@ -172,6 +173,32 @@ describe MessageTemplates::HookExecutionService do
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new).with(conversation: conversation)
expect(csat_survey).not_to have_received(:perform)
end
+
+ it 'will not call ::MessageTemplates::Template::CsatSurvey if cannot reply' do
+ conversation.inbox.update(csat_survey_enabled: true)
+ allow(conversation).to receive(:can_reply?).and_return(false)
+
+ conversation.resolved!
+ Conversations::ActivityMessageJob.perform_now(conversation,
+ { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
+ content: 'Conversation marked resolved!!' })
+
+ expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new).with(conversation: conversation)
+ expect(csat_survey).not_to have_received(:perform)
+ end
+
+ it 'creates activity message when CSAT not sent due to messaging window restriction' do
+ conversation.inbox.update(csat_survey_enabled: true)
+ allow(conversation).to receive(:can_reply?).and_return(false)
+ allow(conversation).to receive(:create_csat_not_sent_activity_message)
+
+ conversation.resolved!
+ Conversations::ActivityMessageJob.perform_now(conversation,
+ { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
+ content: 'Conversation marked resolved!!' })
+
+ expect(conversation).to have_received(:create_csat_not_sent_activity_message)
+ end
end
context 'when it is after working hours' do