feat: add sorting to filtered conversations (#15753)

## Description

Agents can now use the existing conversation sort menu while viewing
conversations narrowed by advanced filters or saved folders. Changing
the order refetches the filtered result set before pagination, so
oldest/newest, activity, unread, priority, and waiting-time ordering
remain consistent across loaded pages.

## Closes

-
[CW-7862](https://linear.app/chatwoot/issue/CW-7862/lack-of-sorting-options-when-using-filters-in-conversations-tab)

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [x] This change requires a documentation update

## What changed

- Keep the existing order menu available for ad-hoc filtered and
saved-folder conversation lists while hiding the conflicting status
selector.
- Share the server-side sort whitelist between standard and filtered
conversation queries, preserving existing defaults and legacy aliases.
- Send the selected sort through filtered fetches, pagination, and
reconnect refreshes, with client-side ordering aligned to the server.
- Document the optional `sort_by` parameter for the conversation filter
API.

## How to test

1. Open Conversations and apply an advanced filter.
2. Open the sort menu and confirm the existing order options are
available while the status selector is hidden.
3. Change between oldest/newest, unread, priority, and waiting-time
options and confirm the filtered list refetches in the selected order.
4. Open a saved conversation folder and confirm the same sorting
behavior, including after loading another page.

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Sony Mathew
2026-09-15 18:21:04 -07:00
committed by GitHub
parent fc5c734b97
commit 3effb7e824
18 changed files with 263 additions and 51 deletions

View File

@@ -2,24 +2,6 @@ class ConversationFinder
attr_reader :current_user, :current_account, :params
DEFAULT_STATUS = 'open'.freeze
SORT_OPTIONS = {
'last_activity_at_asc' => %w[sort_on_last_activity_at asc],
'last_activity_at_desc' => %w[sort_on_last_activity_at desc],
'created_at_asc' => %w[sort_on_created_at asc],
'created_at_desc' => %w[sort_on_created_at desc],
'priority_asc' => %w[sort_on_priority asc],
'priority_desc' => %w[sort_on_priority desc],
'waiting_since_asc' => %w[sort_on_waiting_since asc],
'waiting_since_desc' => %w[sort_on_waiting_since desc],
'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc],
'unread' => %w[sort_on_unread desc],
# To be removed in v3.5.0
'latest' => %w[sort_on_last_activity_at desc],
'sort_on_created_at' => %w[sort_on_created_at asc],
'sort_on_priority' => %w[sort_on_priority desc],
'sort_on_waiting_since' => %w[sort_on_waiting_since asc]
}.with_indifferent_access
# assumptions
# inbox_id if not given, take from all conversations, else specific to inbox
# assignee_type if not given, take 'all'
@@ -216,9 +198,7 @@ class ConversationFinder
def conversations
@conversations = conversations_base_query
sort_by, sort_order = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['last_activity_at_desc']
@conversations = @conversations.send(sort_by, sort_order)
@conversations = Conversations::SortService.apply(@conversations, params[:sort_by])
if params[:updated_within].present?
@conversations.where('conversations.updated_at > ?', Time.zone.now - params[:updated_within].to_i.seconds)

View File

@@ -41,6 +41,7 @@ class ConversationApi extends ApiClient {
signal: options.signal,
params: {
page: payload.page,
sort_by: payload.sortBy,
},
});
}

View File

@@ -196,6 +196,7 @@ describe('#ConversationAPI', () => {
it('#filter', () => {
const payload = {
page: 1,
sortBy: 'last_activity_at_asc',
queryData: {
payload: [
{
@@ -223,7 +224,12 @@ describe('#ConversationAPI', () => {
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/conversations/filter',
payload.queryData,
{ params: { page: payload.page } }
{
params: {
page: payload.page,
sort_by: payload.sortBy,
},
}
);
});

View File

@@ -50,6 +50,7 @@ import {
filterItemsByPermission,
} from 'dashboard/helper/permissionsHelper.js';
import { matchesFilters } from '../store/modules/conversations/helpers/filterHelpers';
import { sortComparator } from '../store/modules/conversations/helpers';
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
@@ -309,12 +310,9 @@ function filterByAssigneeTab(conversations) {
}
function sortByUnreadStatus(conversations) {
return [...conversations].sort((a, b) => {
const unreadCountDiff = (b.unread_count || 0) - (a.unread_count || 0);
if (unreadCountDiff !== 0) return unreadCountDiff;
return (b.last_activity_at || 0) - (a.last_activity_at || 0);
});
return [...conversations].sort((a, b) =>
sortComparator(a, b, wootConstants.SORT_BY_TYPE.UNREAD)
);
}
const conversationList = computed(() => {
@@ -400,6 +398,7 @@ function fetchFilteredConversations(payload) {
.dispatch('fetchFilteredConversations', {
queryData: filterQueryGenerator(payload),
page,
sortBy: activeSortBy.value,
})
.catch(() => useAlert(t('CHAT_LIST.FETCH_ERROR')))
// emit even on failure so a deep-linked conversation still loads via
@@ -416,6 +415,7 @@ function fetchSavedFilteredConversations(payload) {
.dispatch('fetchFilteredConversations', {
queryData: payload,
page,
sortBy: activeSortBy.value,
})
.catch(() => useAlert(t('CHAT_LIST.FETCH_ERROR')))
.finally(emitConversationLoaded);
@@ -627,6 +627,20 @@ function onBasicFilterChange(value, type) {
} else {
activeSortBy.value = value;
}
if (type === 'sort' && hasAppliedFiltersOrActiveFolders.value) {
resetBulkActions();
store.dispatch('conversationPage/reset');
store.dispatch('emptyAllConversations');
if (hasActiveFolders.value) {
fetchSavedFilteredConversations(activeFolder.value.query);
} else {
fetchFilteredConversations(appliedFilters.value);
}
return;
}
resetAndFetchData();
}

View File

@@ -155,8 +155,8 @@ const toggleConversationLayout = () => {
/>
</div>
<ConversationBasicFilter
v-if="!hasAppliedFiltersOrActiveFolders"
:is-on-expanded-layout="isOnExpandedLayout"
:show-status-filter="!hasAppliedFiltersOrActiveFolders"
@change-filter="onBasicFilterChange"
/>
<SwitchLayout

View File

@@ -14,6 +14,10 @@ defineProps({
type: Boolean,
required: true,
},
showStatusFilter: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['changeFilter']);
@@ -157,7 +161,10 @@ const handleSortChange = value => {
'ltr:right-0 rtl:left-0': isOnExpandedLayout,
}"
>
<div class="flex items-center justify-between last:mt-4 gap-2">
<div
v-if="showStatusFilter"
class="flex items-center justify-between gap-2"
>
<span class="text-sm truncate text-n-slate-12">
{{ $t('CHAT_LIST.CHAT_SORT.STATUS') }}
</span>
@@ -169,7 +176,10 @@ const handleSortChange = value => {
@update:model-value="handleStatusChange"
/>
</div>
<div class="flex items-center justify-between last:mt-4 gap-2">
<div
class="flex items-center justify-between gap-2"
:class="{ 'mt-4': showStatusFilter }"
>
<span class="text-sm truncate text-n-slate-12">
{{ $t('CHAT_LIST.CHAT_SORT.ORDER_BY') }}
</span>

View File

@@ -114,17 +114,22 @@ const actions = {
});
},
fetchFilteredConversations: async ({ commit, dispatch }, params) => {
fetchFilteredConversations: async ({ commit, dispatch, state }, params) => {
return conversationListRequest.run(async signal => {
const { replaceExisting = false, ...requestParams } = params;
const {
replaceExisting = false,
sortBy = state.chatSortFilter,
...requestParams
} = params;
const filterRequestParams = { ...requestParams, sortBy };
const countRequest = await dispatch(
'conversationStats/onListRequestStarted',
requestParams
filterRequestParams
);
if (signal.aborted) return;
commit(types.SET_LIST_LOADING_STATUS);
try {
const { data } = await ConversationApi.filter(requestParams, {
const { data } = await ConversationApi.filter(filterRequestParams, {
signal,
});
@@ -132,7 +137,7 @@ const actions = {
buildConversationList(
{ commit, dispatch },
requestParams,
filterRequestParams,
data,
'appliedFilters',
{ replaceExisting, countRequest }

View File

@@ -117,6 +117,7 @@ const SORT_OPTIONS = {
waiting_since_asc: ['sortOnWaitingSince', 'asc'],
waiting_since_desc: ['sortOnWaitingSince', 'desc'],
priority_desc_created_at_asc: ['sortOnPriorityCreatedAt', 'desc'],
unread: ['sortOnUnread', 'desc'],
};
const sortAscending = (valueA, valueB) => valueA - valueB;
const sortDescending = (valueA, valueB) => valueB - valueA;
@@ -136,8 +137,10 @@ const sortConfig = {
const p1 = CONVERSATION_PRIORITY_ORDER[a.priority] || DEFAULT_FOR_NULL;
const p2 = CONVERSATION_PRIORITY_ORDER[b.priority] || DEFAULT_FOR_NULL;
const priorityDiff = getSortOrderFunction(sortDirection)(p1, p2);
if (priorityDiff !== 0) return priorityDiff;
return getSortOrderFunction(sortDirection)(p1, p2);
return sortDescending(a.last_activity_at, b.last_activity_at);
},
sortOnPriorityCreatedAt: (a, b) => {
@@ -152,13 +155,20 @@ const sortConfig = {
const sortFunc = getSortOrderFunction(sortDirection);
if (!a.waiting_since || !b.waiting_since) {
if (!a.waiting_since && !b.waiting_since) {
return sortFunc(a.created_at, b.created_at);
return sortAscending(a.created_at, b.created_at);
}
return sortFunc(a.waiting_since ? 0 : 1, b.waiting_since ? 0 : 1);
return a.waiting_since ? -1 : 1;
}
return sortFunc(a.waiting_since, b.waiting_since);
},
sortOnUnread: (a, b) => {
const unreadCountDiff = (b.unread_count || 0) - (a.unread_count || 0);
if (unreadCountDiff !== 0) return unreadCountDiff;
return (b.last_activity_at || 0) - (a.last_activity_at || 0);
},
};
export const sortComparator = (a, b, sortKey) => {

View File

@@ -7,14 +7,17 @@ import actions, {
} from '../../conversations/actions';
import types from '../../../mutation-types';
const dataToSend = {
payload: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
query_operator: null,
},
],
page: 1,
queryData: {
payload: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
query_operator: null,
},
],
},
};
import { dataReceived } from './testConversationResponse';
@@ -634,7 +637,11 @@ describe('#actions', () => {
axios.post.mockResolvedValue({ data: filteredResponse });
await actions.fetchFilteredConversations(
{ commit, dispatch },
{
commit,
dispatch,
state: { chatSortFilter: 'last_activity_at_desc' },
},
{
queryData: {
payload: [
@@ -670,7 +677,11 @@ describe('#actions', () => {
data: dataReceived,
});
await actions.fetchFilteredConversations(
{ commit, dispatch },
{
commit,
dispatch,
state: { chatSortFilter: 'last_activity_at_desc' },
},
dataToSend
);
expect(commit).toHaveBeenCalledTimes(4);
@@ -683,12 +694,29 @@ describe('#actions', () => {
dataReceived.payload.map(chat => chat.meta.sender),
],
]);
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/conversations/filter',
dataToSend.queryData,
expect.objectContaining({
params: {
page: dataToSend.page,
sort_by: 'last_activity_at_desc',
},
})
);
});
it('clears the loading state and rethrows if the request fails', async () => {
axios.post.mockRejectedValue(new Error('Request failed'));
await expect(
actions.fetchFilteredConversations({ commit, dispatch }, dataToSend)
actions.fetchFilteredConversations(
{
commit,
dispatch,
state: { chatSortFilter: 'last_activity_at_desc' },
},
dataToSend
)
).rejects.toThrow('Request failed');
expect(commit.mock.calls).toEqual([
['SET_LIST_LOADING_STATUS'],

View File

@@ -102,6 +102,18 @@ describe('#getters', () => {
]);
});
it('uses latest activity to order conversations with the same priority', () => {
const state = {
allConversations: [conversations[2], conversations[1]],
chatSortFilter: 'priority_desc',
};
expect(getters.getAllConversations(state)).toEqual([
conversations[1],
conversations[2],
]);
});
it('returns conversations ordered by priority in ascending order if chatStatusFilter = priority_asc', () => {
const state = {
allConversations: [...conversations],
@@ -127,6 +139,19 @@ describe('#getters', () => {
conversations[0],
]);
});
it('returns waiting conversations before non-waiting conversations when sorting by shortest wait', () => {
const state = {
allConversations: [...conversations],
chatSortFilter: 'waiting_since_desc',
};
expect(getters.getAllConversations(state)).toEqual([
conversations[3],
conversations[1],
conversations[2],
conversations[0],
]);
});
});
describe('#getUnAssignedChats', () => {
it('order returns only chats assigned to user', () => {
@@ -720,5 +745,26 @@ describe('#getters', () => {
mockConversations[2],
]);
});
it('sorts filtered conversations by unread count and then latest activity', () => {
const state = {
allConversations: [
{ ...mockConversations[0], unread_count: 1 },
{ ...mockConversations[1], unread_count: 2 },
{ ...mockConversations[2], unread_count: 2 },
],
chatSortFilter: 'unread',
appliedFilters: [],
};
const result = getters.getFilteredConversations(
state,
{},
{},
mockRootGetters
);
expect(result.map(conversation => conversation.id)).toEqual([3, 2, 1]);
});
});
});

View File

@@ -48,6 +48,7 @@ class WorkingHour < ApplicationRecord
def open_at?(time)
return false if closed_all_day?
return true if open_all_day?
open_time = Time.zone.now.in_time_zone(inbox.timezone).change({ hour: open_hour, min: open_minutes })
close_time = Time.zone.now.in_time_zone(inbox.timezone).change({ hour: close_hour, min: close_minutes })

View File

@@ -54,7 +54,7 @@ class Conversations::FilterService < FilterService
end
def conversations
@conversations.sort_on_last_activity_at.page(current_page)
Conversations::SortService.apply(@conversations, @params[:sort_by]).page(current_page)
end
private

View File

@@ -0,0 +1,26 @@
class Conversations::SortService
DEFAULT_SORT = 'last_activity_at_desc'.freeze
SORT_OPTIONS = {
'last_activity_at_asc' => %w[sort_on_last_activity_at asc],
'last_activity_at_desc' => %w[sort_on_last_activity_at desc],
'created_at_asc' => %w[sort_on_created_at asc],
'created_at_desc' => %w[sort_on_created_at desc],
'priority_asc' => %w[sort_on_priority asc],
'priority_desc' => %w[sort_on_priority desc],
'waiting_since_asc' => %w[sort_on_waiting_since asc],
'waiting_since_desc' => %w[sort_on_waiting_since desc],
'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc],
'unread' => %w[sort_on_unread desc],
# To be removed in v3.5.0
'latest' => %w[sort_on_last_activity_at desc],
'sort_on_created_at' => %w[sort_on_created_at asc],
'sort_on_priority' => %w[sort_on_priority desc],
'sort_on_waiting_since' => %w[sort_on_waiting_since asc]
}.with_indifferent_access.freeze
def self.apply(scope, sort_by)
sort_method, sort_order = SORT_OPTIONS[sort_by] || SORT_OPTIONS[DEFAULT_SORT]
scope.public_send(sort_method, sort_order)
end
end

View File

@@ -67,6 +67,12 @@ RSpec.describe WorkingHour do
expect(described_class.today.close_hour).to be 23
expect(described_class.today.close_minutes).to be 59
end
it 'remains open through the end of the day' do
travel_to '18.02.2022 23:59:59'.to_datetime do
expect(described_class.today.open_now?).to be true
end
end
end
context 'when open_all_day and closed_all_day true at the same time' do

View File

@@ -213,6 +213,26 @@ describe Conversations::FilterService do
expect(result[:count][:all_count]).to eq conversations.count
end
it 'sorts filtered conversations using the requested sort order' do
older_conversation = create(:conversation, account: account, inbox: inbox, last_activity_at: 2.days.ago)
newer_conversation = create(:conversation, account: account, inbox: inbox, last_activity_at: 1.day.ago)
params[:sort_by] = 'last_activity_at_asc'
conversation_ids = filter_service.new(params, user_1, account).perform[:conversations].pluck(:id)
expect(conversation_ids.index(older_conversation.id)).to be < conversation_ids.index(newer_conversation.id)
end
it 'defaults to newest activity first when the requested sort order is invalid' do
older_conversation = create(:conversation, account: account, inbox: inbox, last_activity_at: 2.days.ago)
newer_conversation = create(:conversation, account: account, inbox: inbox, last_activity_at: 1.day.ago)
params[:sort_by] = 'invalid_sort'
conversation_ids = filter_service.new(params, user_1, account).perform[:conversations].pluck(:id)
expect(conversation_ids.index(newer_conversation.id)).to be < conversation_ids.index(older_conversation.id)
end
it 'filters items with contains filter_operator with values being an array' do
params[:payload] = [{
attribute_key: 'browser_language',

View File

@@ -10,6 +10,23 @@ parameters:
in: query
schema:
type: number
- name: sort_by
in: query
description: Sort filtered conversations using the same options as the conversation list.
schema:
type: string
default: last_activity_at_desc
enum:
- last_activity_at_asc
- last_activity_at_desc
- created_at_asc
- created_at_desc
- priority_asc
- priority_desc
- waiting_since_asc
- waiting_since_desc
- priority_desc_created_at_asc
- unread
requestBody:
required: true
content:

View File

@@ -4584,6 +4584,27 @@
"schema": {
"type": "number"
}
},
{
"name": "sort_by",
"in": "query",
"description": "Sort filtered conversations using the same options as the conversation list.",
"schema": {
"type": "string",
"default": "last_activity_at_desc",
"enum": [
"last_activity_at_asc",
"last_activity_at_desc",
"created_at_asc",
"created_at_desc",
"priority_asc",
"priority_desc",
"waiting_since_asc",
"waiting_since_desc",
"priority_desc_created_at_asc",
"unread"
]
}
}
],
"requestBody": {

View File

@@ -3127,6 +3127,27 @@
"schema": {
"type": "number"
}
},
{
"name": "sort_by",
"in": "query",
"description": "Sort filtered conversations using the same options as the conversation list.",
"schema": {
"type": "string",
"default": "last_activity_at_desc",
"enum": [
"last_activity_at_asc",
"last_activity_at_desc",
"created_at_asc",
"created_at_desc",
"priority_asc",
"priority_desc",
"waiting_since_asc",
"waiting_since_desc",
"priority_desc_created_at_asc",
"unread"
]
}
}
],
"requestBody": {