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
@@ -182,7 +182,10 @@ const MIN_SEARCH_LENGTH = 2;
export const createContactSearcher = () => {
let controller = null;
return async (query, { skipMinLength = false } = {}) => {
return async (
query,
{ skipMinLength = false, reachableOnly = true } = {}
) => {
const trimmed = typeof query === 'string' ? query.trim() : '';
controller?.abort();
@@ -199,6 +202,8 @@ export const createContactSearcher = () => {
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
if (!reachableOnly) return camelCasedPayload || [];
// Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email
@@ -1,6 +1,7 @@
<script setup>
import { computed, h, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { debounce } from '@chatwoot/utils';
import Button from 'next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import FilterSelect from './inputs/FilterSelect.vue';
@@ -109,6 +110,34 @@ const inputFieldType = computed(() => {
return 'text';
});
const asyncOptions = ref([]);
const isSearching = ref(false);
const lastSearchQuery = ref('');
const performAsyncSearch = async query => {
let results;
try {
results = await currentFilter.value.searchOptions(query);
} catch {
results = [];
}
// skip stale responses — a newer search in this row owns the UI
if (query !== lastSearchQuery.value) return;
// null means another row's search aborted ours, reset instead of staying stuck on the searching state
if (results !== null) asyncOptions.value = results;
isSearching.value = false;
};
const debouncedAsyncSearch = debounce(performAsyncSearch, 300);
const onAsyncSearch = query => {
const hasQuery = !!query.trim();
lastSearchQuery.value = query;
if (!hasQuery) asyncOptions.value = [];
isSearching.value = hasQuery;
debouncedAsyncSearch(query);
};
const resetModelOnAttributeKeyChange = newAttributeKey => {
/**
* Resets the filter values and operator when the attribute key changes. This ensures that
@@ -121,11 +150,16 @@ const resetModelOnAttributeKeyChange = newAttributeKey => {
const newInputType = getInputType(newOperator, filter);
if (newInputType === 'multiSelect') {
values.value = [];
} else if (['searchSelect', 'booleanSelect'].includes(newInputType)) {
} else if (
['searchSelect', 'asyncSearchSelect', 'booleanSelect'].includes(
newInputType
)
) {
values.value = {};
} else {
values.value = '';
}
asyncOptions.value = [];
filterOperator.value = newOperator.value;
};
@@ -185,6 +219,16 @@ defineExpose({ validate, resetValidation });
:options="currentFilter.options"
dropdown-max-height="max-h-64"
/>
<SingleSelect
v-else-if="inputType === 'asyncSearchSelect'"
v-model="values"
async-search
:options="asyncOptions"
:is-searching="isSearching"
:search-placeholder="currentFilter.searchPlaceholder"
dropdown-max-height="max-h-64"
@search="onAsyncSearch"
/>
<SingleSelect
v-else-if="inputType === 'booleanSelect'"
v-model="values"
@@ -7,6 +7,7 @@ export const CONVERSATION_ATTRIBUTES = {
ASSIGNEE_ID: 'assignee_id',
INBOX_ID: 'inbox_id',
TEAM_ID: 'team_id',
CONTACT_ID: 'contact_id',
DISPLAY_ID: 'display_id',
CAMPAIGN_ID: 'campaign_id',
LABELS: 'labels',
@@ -1,9 +1,37 @@
import { ref } from 'vue';
import ContactAPI from 'dashboard/api/contacts';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConversationFilterContext } from '../provider';
import {
CONVERSATION_ATTRIBUTES,
getCustomAttributeInputType,
buildAttributesFilterTypes,
replaceUnderscoreWithSpace,
} from './filterHelper';
vi.mock('dashboard/api/contacts', () => ({
default: {
search: vi.fn(),
},
}));
vi.mock('dashboard/composables/store.js', () => ({
useMapGetter: vi.fn(),
}));
vi.mock('next/icon/provider', () => ({
useChannelIcon: () => ref('i-test-channel'),
}));
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, params = {}) => {
if (key === 'FILTER.CONTACT_FALLBACK') return `Contact #${params.id}`;
return key;
},
}),
}));
describe('filterHelper', () => {
describe('getCustomAttributeInputType', () => {
it('returns date for date type', () => {
@@ -135,3 +163,64 @@ describe('filterHelper', () => {
});
});
});
const storeValues = {
'attributes/getConversationAttributes': ref([]),
'labels/getLabels': ref([]),
'agents/getAgents': ref([]),
'inboxes/getInboxes': ref([]),
'teams/getTeams': ref([]),
'campaigns/getAllCampaigns': ref([]),
};
describe('useConversationFilterContext', () => {
beforeEach(() => {
vi.clearAllMocks();
useMapGetter.mockImplementation(key => storeValues[key] || ref([]));
});
it('exposes contact as an async searchable conversation filter', () => {
const { filterTypes } = useConversationFilterContext();
const contactFilter = filterTypes.value.find(
filter => filter.attributeKey === CONVERSATION_ATTRIBUTES.CONTACT_ID
);
expect(contactFilter).toMatchObject({
attributeKey: 'contact_id',
label: 'FILTER.ATTRIBUTES.CONTACT',
inputType: 'asyncSearchSelect',
dataType: 'number',
attributeModel: 'standard',
});
expect(
contactFilter.filterOperators.map(operator => operator.value)
).toEqual(['equal_to', 'not_equal_to']);
});
it('uses the existing contact search API for contact filter options', async () => {
ContactAPI.search.mockResolvedValue({
data: {
payload: [
{ id: 1, name: 'Jane Doe' },
{ id: 2, email: 'alex@example.com' },
{ id: 3 },
],
},
});
const { filterTypes } = useConversationFilterContext();
const contactFilter = filterTypes.value.find(
filter => filter.attributeKey === CONVERSATION_ATTRIBUTES.CONTACT_ID
);
const options = await contactFilter.searchOptions('jane');
expect(ContactAPI.search).toHaveBeenCalledWith('jane', 1, 'name', '', {
signal: expect.any(AbortSignal),
});
expect(options).toEqual([
{ id: 1, name: 'Jane Doe' },
{ id: 2, name: 'alex@example.com' },
{ id: 3, name: 'Contact #3' },
]);
});
});
@@ -11,6 +11,8 @@ import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const {
options,
asyncSearch,
isSearching,
disableSearch,
disableDeselect,
placeholderIcon,
@@ -23,6 +25,14 @@ const {
type: Array,
required: true,
},
asyncSearch: {
type: Boolean,
default: false,
},
isSearching: {
type: Boolean,
default: false,
},
disableSearch: {
type: Boolean,
default: false,
@@ -53,6 +63,12 @@ const {
},
});
const emit = defineEmits(['search']);
// the input is re-inserted on every dropdown open (v-if),
// where the native autofocus attribute is ignored so focus it via a directive instead
const vFocus = { mounted: el => el.focus() };
const { t } = useI18n();
const selected = defineModel({
type: Object,
@@ -60,7 +76,9 @@ const selected = defineModel({
});
const searchTerm = ref('');
const searchResults = computed(() => {
if (asyncSearch) return options;
if (!options) return [];
return picoSearch(options, searchTerm.value, ['name']);
});
@@ -77,7 +95,11 @@ const selectedItem = computed(() => {
if (!optionToSearch) return null;
// extract the selected item from the options array
// this ensures that options like icon is also included
return options.find(option => option.id === optionToSearch.id);
return (
options.find(option => option.id === optionToSearch.id) ||
// async options may not include the selected option, fall back to it
(asyncSearch && optionToSearch.id !== undefined ? optionToSearch : null)
);
});
const toggleSelected = option => {
@@ -131,13 +153,19 @@ const toggleSelected = option => {
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
<input
v-model="searchTerm"
autofocus
v-focus
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
@input="emit('search', $event.target.value)"
/>
</div>
<DropdownSection :height="dropdownMaxHeight">
<template v-if="searchResults.length">
<template v-if="isSearching">
<DropdownItem disabled>
{{ t('DROPDOWN_MENU.SEARCHING') }}
</DropdownItem>
</template>
<template v-else-if="searchResults.length">
<DropdownItem
v-for="option in searchResults"
:key="option.id"
@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n';
import { useOperators } from './operators';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useChannelIcon } from 'next/icon/provider';
import { createContactSearcher } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import {
buildAttributesFilterTypes,
CONVERSATION_ATTRIBUTES,
@@ -30,7 +31,7 @@ import languages from 'dashboard/components/widgets/conversation/advancedFilterI
* @property {string} value - This is a proxy for the attribute key used in FilterSelect
* @property {string} attributeName - The attribute name used to display on the UI
* @property {string} label - This is a proxy for the attribute name used in FilterSelect
* @property {'multiSelect'|'searchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute
* @property {'multiSelect'|'searchSelect'|'asyncSearchSelect'|'plainText'|'date'|'booleanSelect'} inputType - The input type for the attribute
* @property {FilterOption[]} [options] - The options available for the attribute if it is a multiSelect or singleSelect type
* @property {'text'|'number'} dataType
* @property {FilterOperator[]} filterOperators - The operators available for the attribute
@@ -68,6 +69,30 @@ export function useConversationFilterContext() {
getOperatorTypes,
} = useOperators();
const searchContacts = createContactSearcher();
const contactOptionName = contact =>
contact.name ||
contact.email ||
contact.phoneNumber ||
contact.identifier ||
t('FILTER.CONTACT_FALLBACK', { id: contact.id });
const searchContactOptions = async query => {
const contacts = await searchContacts(query, {
skipMinLength: true,
reachableOnly: false,
});
// null means the request was aborted (a newer search is in-flight)
if (contacts === null) return null;
return contacts.map(contact => ({
id: contact.id,
name: contactOptionName(contact),
}));
};
/**
* @type {import('vue').ComputedRef<FilterType[]>}
*/
@@ -158,6 +183,18 @@ export function useConversationFilterContext() {
filterOperators: presenceOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.CONTACT_ID,
value: CONVERSATION_ATTRIBUTES.CONTACT_ID,
attributeName: t('FILTER.ATTRIBUTES.CONTACT'),
label: t('FILTER.ATTRIBUTES.CONTACT'),
inputType: 'asyncSearchSelect',
searchOptions: searchContactOptions,
searchPlaceholder: t('FILTER.CONTACT_SEARCH_PLACEHOLDER'),
dataType: 'number',
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
value: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
@@ -151,6 +151,9 @@ const activeFolder = computed(() => {
return undefined;
});
const getContact = useMapGetter('contacts/getContact');
const folderContactId = useMapGetter('customViews/getActiveFolderContactId');
const activeFolderName = computed(() => {
return activeFolder.value?.name;
});
@@ -456,6 +459,7 @@ function setParamsForEditFolderModal() {
inboxes: inboxesList.value,
labels: labels.value,
campaigns: campaigns.value,
contacts: [getContact.value(folderContactId.value)],
languages: languages,
countries: countries,
priority: [
@@ -46,6 +46,14 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
{
attributeKey: 'contact_id',
attributeI18nKey: 'CONTACT',
inputType: 'search_select',
dataType: 'number',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'standard',
},
{
attributeKey: 'display_id',
attributeI18nKey: 'CONVERSATION_IDENTIFIER',
@@ -133,6 +141,10 @@ export const filterAttributeGroups = [
key: 'team_id',
i18nKey: 'TEAM_NAME',
},
{
key: 'contact_id',
i18nKey: 'CONTACT',
},
{
key: 'display_id',
i18nKey: 'CONVERSATION_IDENTIFIER',
@@ -37,6 +37,13 @@ export const getValuesName = (values, list, idKey, nameKey) => {
};
};
const getValuesForContact = (values, contacts) => ({
id: values[0],
name:
contacts?.find(contact => contact.id === values[0])?.name ||
`Contact #${values[0]}`,
});
export const getValuesForStatus = values => {
return values.map(value => ({ id: value, name: value }));
};
@@ -84,6 +91,7 @@ export const getValuesForFilter = (filter, params) => {
campaigns,
labels,
priority,
contacts,
} = params;
switch (attribute_key) {
case 'status':
@@ -94,6 +102,8 @@ export const getValuesForFilter = (filter, params) => {
return getValuesName(values, inboxes, 'id', 'name');
case 'team_id':
return getValuesName(values, teams, 'id', 'name');
case 'contact_id':
return getValuesForContact(values, contacts);
case 'campaign_id':
return getValuesName(values, campaigns, 'id', 'title');
case 'labels':
@@ -273,6 +273,41 @@ describe('customViewsHelper', () => {
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual('1');
});
it('returns contact name for contact filters when contact is available', () => {
const filter = {
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: [123],
};
const params = {
contacts: [{ id: 123, name: 'John Doe' }],
filterTypes: advancedFilterTypes,
allCustomAttributes: [],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 123,
name: 'John Doe',
});
});
it('returns fallback contact display value when contact is not available', () => {
const filter = {
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: [123],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 123,
name: 'Contact #123',
});
});
});
describe('#generateCustomAttributesInputType', () => {
@@ -64,4 +64,25 @@ describe('#filterQueryGenerator', () => {
filterQueryGenerator(testData).payload.every(i => Array.isArray(i.values))
).toBe(true);
});
it('serializes a selected contact object to contact id', () => {
const result = filterQueryGenerator([
{
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: { id: 123, name: 'Jane Doe' },
query_operator: 'and',
},
]);
expect(result).toMatchObject({
payload: [
{
attribute_key: 'contact_id',
filter_operator: 'equal_to',
values: [123],
},
],
});
});
});
@@ -19,6 +19,8 @@
"OR": "OR"
},
"INPUT_PLACEHOLDER": "Enter value",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {
"equal_to": "Equal to",
"not_equal_to": "Not equal to",
@@ -49,6 +51,7 @@
"ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Inbox name",
"TEAM_NAME": "Team name",
"CONTACT": "Contact",
"CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name",
"LABELS": "Labels",
@@ -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();
});
});