}
+ */
+const filterOperatorIcon = {
+ [FILTER_OPS.EQUAL_TO]: 'i-ph-equals-bold',
+ [FILTER_OPS.NOT_EQUAL_TO]: 'i-ph-not-equals-bold',
+ [FILTER_OPS.IS_PRESENT]: 'i-ph-member-of-bold',
+ [FILTER_OPS.IS_NOT_PRESENT]: 'i-ph-not-member-of-bold',
+ [FILTER_OPS.CONTAINS]: 'i-ph-superset-of-bold',
+ [FILTER_OPS.DOES_NOT_CONTAIN]: 'i-ph-not-superset-of-bold',
+ [FILTER_OPS.IS_GREATER_THAN]: 'i-ph-greater-than-bold',
+ [FILTER_OPS.IS_LESS_THAN]: 'i-ph-less-than-bold',
+ [FILTER_OPS.DAYS_BEFORE]: 'i-ph-calendar-minus-bold',
+ [FILTER_OPS.STARTS_WITH]: 'i-ph-caret-line-right-bold',
+};
+
+/**
+ * Vue composable providing access to filter operators and related functionality
+ * @returns {Object} Collection of operators and utility functions
+ * @property {import('vue').ComputedRef
>} operators - All available operators
+ * @property {import('vue').ComputedRef} equalityOperators - Equality comparison operators
+ * @property {import('vue').ComputedRef} presenceOperators - Presence check operators
+ * @property {import('vue').ComputedRef} containmentOperators - Containment check operators
+ * @property {import('vue').ComputedRef} comparisonOperators - Numeric comparison operators
+ * @property {import('vue').ComputedRef} dateOperators - Date-specific operators
+ * @property {(key: 'list'|'text'|'number'|'link'|'date'|'checkbox') => Operator[]} getOperatorTypes - Get operators for a field type
+ */
+export function useOperators() {
+ const { t } = useI18n();
+
+ /** @type {import('vue').ComputedRef>} */
+ const operators = computed(() => {
+ return Object.values(FILTER_OPS).reduce((acc, value) => {
+ acc[value] = {
+ value,
+ label: t(`FILTER.OPERATOR_LABELS.${value}`),
+ hasInput: !NO_INPUT_OPTS.includes(value),
+ inputOverride: OPS_INPUT_OVERRIDE[value] || null,
+ icon: h('span', {
+ class: `${filterOperatorIcon[value]} !text-n-blue-text`,
+ }),
+ };
+ return acc;
+ }, {});
+ });
+
+ /** @type {import('vue').ComputedRef>} */
+ const equalityOperators = computed(() => [
+ operators.value[FILTER_OPS.EQUAL_TO],
+ operators.value[FILTER_OPS.NOT_EQUAL_TO],
+ ]);
+
+ /** @type {import('vue').ComputedRef>} */
+ const presenceOperators = computed(() => [
+ operators.value[FILTER_OPS.EQUAL_TO],
+ operators.value[FILTER_OPS.NOT_EQUAL_TO],
+ operators.value[FILTER_OPS.IS_PRESENT],
+ operators.value[FILTER_OPS.IS_NOT_PRESENT],
+ ]);
+
+ /** @type {import('vue').ComputedRef>} */
+ const containmentOperators = computed(() => [
+ operators.value[FILTER_OPS.EQUAL_TO],
+ operators.value[FILTER_OPS.NOT_EQUAL_TO],
+ operators.value[FILTER_OPS.CONTAINS],
+ operators.value[FILTER_OPS.DOES_NOT_CONTAIN],
+ ]);
+
+ /** @type {import('vue').ComputedRef>} */
+ const comparisonOperators = computed(() => [
+ operators.value[FILTER_OPS.EQUAL_TO],
+ operators.value[FILTER_OPS.NOT_EQUAL_TO],
+ operators.value[FILTER_OPS.IS_PRESENT],
+ operators.value[FILTER_OPS.IS_NOT_PRESENT],
+ operators.value[FILTER_OPS.IS_GREATER_THAN],
+ operators.value[FILTER_OPS.IS_LESS_THAN],
+ ]);
+
+ /** @type {import('vue').ComputedRef>} */
+ const dateOperators = computed(() => [
+ operators.value[FILTER_OPS.IS_GREATER_THAN],
+ operators.value[FILTER_OPS.IS_LESS_THAN],
+ operators.value[FILTER_OPS.DAYS_BEFORE],
+ ]);
+
+ /**
+ * Get operator types based on key
+ * @param {string} key - Type of operator to get
+ * @returns {Array}
+ */
+ const getOperatorTypes = key => {
+ switch (key) {
+ case 'list':
+ return equalityOperators.value;
+ case 'text':
+ return containmentOperators.value;
+ case 'number':
+ return equalityOperators.value;
+ case 'link':
+ return equalityOperators.value;
+ case 'date':
+ return comparisonOperators.value;
+ case 'checkbox':
+ return equalityOperators.value;
+ default:
+ return equalityOperators.value;
+ }
+ };
+
+ return {
+ operators,
+ equalityOperators,
+ presenceOperators,
+ containmentOperators,
+ comparisonOperators,
+ dateOperators,
+ getOperatorTypes,
+ };
+}
diff --git a/app/javascript/dashboard/components-next/filter/provider.js b/app/javascript/dashboard/components-next/filter/provider.js
new file mode 100644
index 000000000..267732f76
--- /dev/null
+++ b/app/javascript/dashboard/components-next/filter/provider.js
@@ -0,0 +1,299 @@
+import { computed, h } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useOperators } from './operators';
+import { useMapGetter } from 'dashboard/composables/store.js';
+import { useChannelIcon } from 'next/icon/provider';
+import countries from 'shared/constants/countries.js';
+import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
+
+/**
+ * @typedef {Object} FilterOption
+ * @property {string|number} id
+ * @property {string} name
+ * @property {import('vue').VNode} [icon]
+ */
+
+/**
+ * @typedef {Object} FilterOperator
+ * @property {string} value
+ * @property {string} label
+ * @property {string} icon
+ * @property {boolean} hasInput
+ */
+
+/**
+ * @typedef {Object} FilterType
+ * @property {string} attributeKey - The attribute key
+ * @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 {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
+ * @property {'standard'|'additional'|'customAttributes'} attributeModel
+ */
+
+/**
+ * @typedef {Object} FilterGroup
+ * @property {string} name
+ * @property {FilterType[]} attributes
+ */
+
+/**
+ * Determines the input type for a custom attribute based on its key
+ * @param {string} key - The attribute display type key
+ * @returns {'date'|'plainText'|'searchSelect'|'booleanSelect'} The corresponding input type
+ */
+const customAttributeInputType = key => {
+ switch (key) {
+ case 'date':
+ return 'date';
+ case 'text':
+ return 'plainText';
+ case 'list':
+ return 'searchSelect';
+ case 'checkbox':
+ return 'booleanSelect';
+ default:
+ return 'plainText';
+ }
+};
+
+/**
+ * Composable that provides conversation filtering context
+ * @returns {{ filterTypes: import('vue').ComputedRef, filterGroups: import('vue').ComputedRef }}
+ */
+export function useConversationFilterContext() {
+ const { t } = useI18n();
+
+ const conversationAttributes = useMapGetter(
+ 'attributes/getConversationAttributes'
+ );
+
+ const labels = useMapGetter('labels/getLabels');
+ const agents = useMapGetter('agents/getAgents');
+ const inboxes = useMapGetter('inboxes/getInboxes');
+ const teams = useMapGetter('teams/getTeams');
+ const campaigns = useMapGetter('campaigns/getAllCampaigns');
+
+ const {
+ equalityOperators,
+ presenceOperators,
+ containmentOperators,
+ dateOperators,
+ getOperatorTypes,
+ } = useOperators();
+
+ /**
+ * @type {import('vue').ComputedRef}
+ */
+ const customFilterTypes = computed(() => {
+ return conversationAttributes.value.map(attr => {
+ return {
+ attributeKey: attr.attributeKey,
+ value: attr.attributeKey,
+ attributeName: attr.attributeDisplayName,
+ label: attr.attributeDisplayName,
+ inputType: customAttributeInputType(attr.attributeDisplayType),
+ filterOperators: getOperatorTypes(attr.attributeDisplayType),
+ options:
+ attr.attributeDisplayType === 'list'
+ ? attr.attributeValues.map(item => ({ id: item, name: item }))
+ : [],
+ attributeModel: 'customAttributes',
+ };
+ });
+ });
+
+ /**
+ * @type {import('vue').ComputedRef}
+ */
+ const filterTypes = computed(() => [
+ {
+ attributeKey: 'status',
+ value: 'status',
+ attributeName: t('FILTER.ATTRIBUTES.STATUS'),
+ label: t('FILTER.ATTRIBUTES.STATUS'),
+ inputType: 'multiSelect',
+ options: ['open', 'resolved', 'pending', 'snoozed', 'all'].map(id => {
+ return {
+ id,
+ name: t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${id}.TEXT`),
+ };
+ }),
+ dataType: 'text',
+ filterOperators: equalityOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'assignee_id',
+ value: 'assignee_id',
+ attributeName: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'),
+ label: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'),
+ inputType: 'searchSelect',
+ options: agents.value.map(agent => {
+ return {
+ id: agent.id,
+ name: agent.name,
+ };
+ }),
+ dataType: 'text',
+ filterOperators: presenceOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'inbox_id',
+ value: 'inbox_id',
+ attributeName: t('FILTER.ATTRIBUTES.INBOX_NAME'),
+ label: t('FILTER.ATTRIBUTES.INBOX_NAME'),
+ inputType: 'searchSelect',
+ options: inboxes.value.map(inbox => {
+ return {
+ ...inbox,
+ icon: useChannelIcon(inbox).value,
+ };
+ }),
+ dataType: 'text',
+ filterOperators: presenceOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'team_id',
+ value: 'team_id',
+ attributeName: t('FILTER.ATTRIBUTES.TEAM_NAME'),
+ label: t('FILTER.ATTRIBUTES.TEAM_NAME'),
+ inputType: 'searchSelect',
+ options: teams.value,
+ dataType: 'number',
+ filterOperators: presenceOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'display_id',
+ value: 'display_id',
+ attributeName: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
+ label: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
+ inputType: 'plainText',
+ datatype: 'number',
+ filterOperators: containmentOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'campaign_id',
+ value: 'campaign_id',
+ attributeName: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'),
+ label: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'),
+ inputType: 'searchSelect',
+ options: campaigns.value.map(campaign => ({
+ id: campaign.id,
+ name: campaign.title,
+ })),
+ datatype: 'number',
+ filterOperators: presenceOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'labels',
+ value: 'labels',
+ attributeName: t('FILTER.ATTRIBUTES.LABELS'),
+ label: t('FILTER.ATTRIBUTES.LABELS'),
+ inputType: 'multiSelect',
+ options: labels.value.map(label => {
+ return {
+ id: label.title,
+ name: label.title,
+ icon: h('span', {
+ class: `rounded-full`,
+ style: {
+ backgroundColor: label.color,
+ height: '6px',
+ width: '6px',
+ },
+ }),
+ };
+ }),
+ dataType: 'text',
+ filterOperators: presenceOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'browser_language',
+ value: 'browser_language',
+ attributeName: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'),
+ label: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'),
+ inputType: 'searchSelect',
+ options: languages,
+ dataType: 'text',
+ filterOperators: equalityOperators.value,
+ attributeModel: 'additional',
+ },
+ {
+ attributeKey: 'country_code',
+ value: 'country_code',
+ attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
+ label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
+ inputType: 'searchSelect',
+ options: countries,
+ dataType: 'text',
+ filterOperators: equalityOperators.value,
+ attributeModel: 'additional',
+ },
+ {
+ attributeKey: 'referer',
+ value: 'referer',
+ attributeName: t('FILTER.ATTRIBUTES.REFERER_LINK'),
+ label: t('FILTER.ATTRIBUTES.REFERER_LINK'),
+ inputType: 'plainText',
+ dataType: 'text',
+ filterOperators: containmentOperators.value,
+ attributeModel: 'additional',
+ },
+ {
+ attributeKey: 'created_at',
+ value: 'created_at',
+ attributeName: t('FILTER.ATTRIBUTES.CREATED_AT'),
+ label: t('FILTER.ATTRIBUTES.CREATED_AT'),
+ inputType: 'date',
+ dataType: 'text',
+ filterOperators: dateOperators.value,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'last_activity_at',
+ value: 'last_activity_at',
+ attributeName: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'),
+ label: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'),
+ inputType: 'date',
+ dataType: 'text',
+ filterOperators: dateOperators.value,
+ attributeModel: 'standard',
+ },
+ ...customFilterTypes.value,
+ ]);
+
+ const filterGroups = computed(() => {
+ return [
+ {
+ name: t(`FILTER.GROUPS.STANDARD_FILTERS`),
+ attributes: filterTypes.value.filter(
+ filter => filter.attributeModel === 'standard'
+ ),
+ },
+ {
+ name: t(`FILTER.GROUPS.ADDITIONAL_FILTERS`),
+ attributes: filterTypes.value.filter(
+ filter => filter.attributeModel === 'additional'
+ ),
+ },
+ {
+ name: t(`FILTER.GROUPS.CUSTOM_ATTRIBUTES`),
+ attributes: filterTypes.value.filter(
+ filter => filter.attributeModel === 'customAttributes'
+ ),
+ },
+ ];
+ });
+
+ return { filterTypes, filterGroups };
+}
diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue
index c4c81cbfb..0108e02df 100644
--- a/app/javascript/dashboard/components/ChatList.vue
+++ b/app/javascript/dashboard/components/ChatList.vue
@@ -22,10 +22,10 @@ import {
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import ChatListHeader from './ChatListHeader.vue';
-import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
+import ConversationFilter from 'next/filter/ConversationFilter.vue';
+import SaveCustomView from 'next/filter/SaveCustomView.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue';
-import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews.vue';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import IntersectionObserver from './IntersectionObserver.vue';
@@ -37,9 +37,15 @@ import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
import { useFilter } from 'shared/composables/useFilter';
import { useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
+import {
+ useCamelCase,
+ useSnakeCase,
+} from 'dashboard/composables/useTransformKeys';
import { useEmitter } from 'dashboard/composables/emitter';
import { useEventListener } from '@vueuse/core';
+import { emitter } from 'shared/helpers/mitt';
+
import wootConstants from 'dashboard/constants/globals';
import advancedFilterOptions from './widgets/conversation/advancedFilterItems';
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
@@ -51,12 +57,11 @@ import {
isOnMentionsView,
isOnUnattendedView,
} from '../store/modules/conversations/helpers/actionHelpers';
-import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
-import { emitter } from 'shared/helpers/mitt';
import {
getUserPermissions,
filterItemsByPermission,
} from 'dashboard/helper/permissionsHelper.js';
+import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
@@ -107,7 +112,7 @@ const unAssignedChatsList = useMapGetter('getUnAssignedChats');
const chatListLoading = useMapGetter('getChatListLoadingStatus');
const activeInbox = useMapGetter('getSelectedInbox');
const conversationStats = useMapGetter('conversationStats/getStats');
-const appliedFilters = useMapGetter('getAppliedConversationFilters');
+const appliedFilters = useMapGetter('getAppliedConversationFiltersV2');
const folders = useMapGetter('customViews/getConversationCustomViews');
const agentList = useMapGetter('agents/getAgents');
const teamsList = useMapGetter('teams/getTeams');
@@ -371,6 +376,7 @@ function emitConversationLoaded() {
}
function fetchFilteredConversations(payload) {
+ payload = useSnakeCase(payload);
let page = currentFiltersPage.value + 1;
store
.dispatch('fetchFilteredConversations', {
@@ -383,6 +389,7 @@ function fetchFilteredConversations(payload) {
}
function fetchSavedFilteredConversations(payload) {
+ payload = useSnakeCase(payload);
let page = currentFiltersPage.value + 1;
store
.dispatch('fetchFilteredConversations', {
@@ -393,6 +400,7 @@ function fetchSavedFilteredConversations(payload) {
}
function onApplyFilter(payload) {
+ payload = useSnakeCase(payload);
resetBulkActions();
foldersQuery.value = filterQueryGenerator(payload);
store.dispatch('conversationPage/reset');
@@ -406,10 +414,11 @@ function closeAdvanceFiltersModal() {
}
function onUpdateSavedFilter(payload, folderName) {
+ const transformedPayload = useSnakeCase(payload);
const payloadData = {
...unref(activeFolder),
name: unref(folderName),
- query: filterQueryGenerator(payload),
+ query: filterQueryGenerator(transformedPayload),
};
store.dispatch('customViews/update', payloadData);
closeAdvanceFiltersModal();
@@ -461,17 +470,19 @@ function initializeExistingFilterToModal() {
currentUserDetails.value,
activeAssigneeTab.value
);
+ // TODO: Remove the usage of useCamelCase after migrating useFilter to camelcase
if (statusFilter) {
- appliedFilter.value = [...appliedFilter.value, statusFilter];
+ appliedFilter.value = [...appliedFilter.value, useCamelCase(statusFilter)];
}
+ // TODO: Remove the usage of useCamelCase after migrating useFilter to camelcase
const otherFilters = initializeInboxTeamAndLabelFilterToModal(
props.conversationInbox,
inbox.value,
props.teamId,
activeTeam.value,
props.label
- );
+ ).map(useCamelCase);
appliedFilter.value = [...appliedFilter.value, ...otherFilters];
}
@@ -486,27 +497,47 @@ function initializeFolderToFilterModal(newActiveFolder) {
const query = unref(newActiveFolder)?.query?.payload;
if (!Array.isArray(query)) return;
- const newFilters = query.map(filter => ({
- attribute_key: filter.attribute_key,
- attribute_model: filter.attribute_model,
- filter_operator: filter.filter_operator,
- values: Array.isArray(filter.values)
- ? generateValuesForEditCustomViews(filter, setParamsForEditFolderModal())
- : [],
- query_operator: filter.query_operator,
- custom_attribute_type: filter.custom_attribute_type,
- }));
+ const newFilters = query.map(filter => {
+ const transformed = useCamelCase(filter);
+ const values = Array.isArray(transformed.values)
+ ? generateValuesForEditCustomViews(
+ useSnakeCase(filter),
+ setParamsForEditFolderModal()
+ )
+ : [];
+
+ return {
+ attributeKey: transformed.attributeKey,
+ attributeModel: transformed.attributeModel,
+ customAttributeType: transformed.customAttributeType,
+ filterOperator: transformed.filterOperator,
+ queryOperator: transformed.queryOperator ?? 'and',
+ values,
+ };
+ });
appliedFilter.value = [...appliedFilter.value, ...newFilters];
}
+function initalizeAppliedFiltersToModal() {
+ appliedFilter.value = [...appliedFilters.value];
+}
+
function onToggleAdvanceFiltersModal() {
+ if (showAdvancedFilters.value === true) {
+ closeAdvanceFiltersModal();
+ return;
+ }
+
if (!hasAppliedFilters.value && !hasActiveFolders.value) {
initializeExistingFilterToModal();
}
if (hasActiveFolders.value) {
initializeFolderToFilterModal(activeFolder.value);
}
+ if (hasAppliedFilters.value) {
+ initalizeAppliedFiltersToModal();
+ }
showAdvancedFilters.value = true;
}
@@ -751,7 +782,7 @@ watch(conversationFilters, (newVal, oldVal) => {
-
+
+
+
{
-