feat: add contact filter for conversations (#14629)

# Pull Request Template

## Description

Adds a Contact condition to the conversation advanced filter so agents
can search for an existing contact and filter conversations by
`conversations.contact_id`.

Fixes
[CW-7239](https://linear.app/chatwoot/issue/CW-7239/contact-filter-for-conversations)

## 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)
- [ ] This change requires a documentation update

## How Has This Been Tested?

- `eval "$(rbenv init -)" && bundle exec rspec
spec/services/conversations/filter_service_spec.rb`
- `pnpm exec vitest --no-watch --no-cache --no-coverage --logHeapUsage
app/javascript/dashboard/components-next/filter/helper/filterHelper.spec.js
app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js
app/javascript/dashboard/helper/specs/customViewsHelper.spec.js
app/javascript/dashboard/helper/specs/filterQueryGenerator.spec.js`
- `pnpm eslint`
- `eval "$(rbenv init -)" && bundle exec rubocop
spec/services/conversations/filter_service_spec.rb`

## Checklist:

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

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Sony Mathew
2026-06-08 16:00:17 +05:30
committed by GitHub
co-authored by iamsivin Sivin Varghese
parent 7718f2a62c
commit e919a2cef5
20 changed files with 496 additions and 28 deletions
@@ -73,6 +73,12 @@ const getValueFromConversation = (conversation, attributeKey) => {
return conversation.display_id || conversation.id;
case 'assignee_id':
return conversation.meta?.assignee?.id;
case 'contact_id':
return (
conversation.meta?.sender?.id ||
conversation.contact?.id ||
conversation.contact_id
);
case 'inbox_id':
return conversation.inbox_id;
case 'team_id':
@@ -244,6 +244,32 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should match conversation with equal_to operator for contact_id', () => {
const conversation = { meta: { sender: { id: 42 } } };
const filters = [
{
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: { id: 42, name: 'Jane Doe' },
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should match conversation with saved contact_id filter values', () => {
const conversation = { meta: { sender: { id: 42 } } };
const filters = [
{
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: [42],
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Standard attribute tests - priority
it('should match conversation with equal_to operator for priority', () => {
const conversation = { priority: 'urgent' };
@@ -15,6 +15,12 @@ const FILTER_KEYS = {
[VIEW_TYPES.CONTACT]: VIEW_TYPES.CONTACT,
};
// a folder's contact_id filter stores only the id, extract it so the
// contact can be fetched and its name shown in the edit folder modal
const getFolderContactId = folder =>
folder?.query?.payload?.find(filter => filter.attribute_key === 'contact_id')
?.values?.[0];
export const state = {
[VIEW_TYPES.CONVERSATION]: {
records: [],
@@ -47,6 +53,9 @@ export const getters = {
getActiveConversationFolder(_state) {
return _state.activeConversationFolder;
},
getActiveFolderContactId(_state) {
return getFolderContactId(_state.activeConversationFolder);
},
};
export const actions = {
@@ -104,8 +113,11 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: false });
}
},
setActiveConversationFolder({ commit }, data) {
setActiveConversationFolder({ commit, dispatch }, data) {
commit(types.SET_ACTIVE_CONVERSATION_FOLDER, data);
// prefetch the contact of a contact filter so the UI can show its name
const contactId = getFolderContactId(data);
if (contactId) dispatch('contacts/show', { id: contactId }, { root: true });
},
};
@@ -1,7 +1,11 @@
import axios from 'axios';
import { actions } from '../../customViews';
import * as types from '../../../mutation-types';
import { customViewList, updateCustomViewList } from './fixtures';
import { actions } from '../../customViews';
import {
contactFilterView,
customViewList,
updateCustomViewList,
} from './fixtures';
const commit = vi.fn();
global.axios = axios;
@@ -106,5 +110,27 @@ describe('#actions', () => {
[types.default.SET_ACTIVE_CONVERSATION_FOLDER, customViewList[0]],
]);
});
it('prefetches the contact of a contact filter', async () => {
const dispatch = vi.fn();
await actions.setActiveConversationFolder(
{ commit, dispatch },
contactFilterView
);
expect(dispatch).toHaveBeenCalledWith(
'contacts/show',
{ id: 42 },
{ root: true }
);
});
it('does not prefetch without a contact filter', async () => {
const dispatch = vi.fn();
await actions.setActiveConversationFolder(
{ commit, dispatch },
customViewList[0]
);
expect(dispatch).not.toHaveBeenCalled();
});
});
});
@@ -15,6 +15,21 @@ export const contactViewList = [
},
];
export const contactFilterView = {
name: 'Contact view',
filter_type: 0,
query: {
payload: [
{
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: [42],
query_operator: null,
},
],
},
};
export const customViewList = [
{
name: 'Custom view',
@@ -1,5 +1,5 @@
import { getters } from '../../customViews';
import { contactViewList, customViewList } from './fixtures';
import { contactFilterView, contactViewList, customViewList } from './fixtures';
describe('#getters', () => {
it('getCustomViewsByFilterType', () => {
@@ -43,4 +43,20 @@ describe('#getters', () => {
customViewList[0]
);
});
it('getActiveFolderContactId', () => {
expect(
getters.getActiveFolderContactId({
activeConversationFolder: contactFilterView,
})
).toEqual(42);
});
it('getActiveFolderContactId returns undefined without a contact filter', () => {
expect(
getters.getActiveFolderContactId({
activeConversationFolder: customViewList[0],
})
).toBeUndefined();
});
});