Files
chatwoot/app/javascript/dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue
Sivin Varghese 8606aa1310 feat: Ability to filter contact based on labels (#12343)
# Pull Request Template

## Description

This PR add the ability to filter contact based on labels.

Fixes
https://linear.app/chatwoot/issue/CW-4001/feat-ability-to-filter-contact-based-on-labels

## Type of change

- [x] New feature (non-breaking change which adds functionality

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/f3d58d0fcee844b7817325a9a19929d3?sid=075b9448-7e6d-4180-af3c-9466fbf2138b


## 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
- [ ] 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
2025-09-02 12:35:18 +05:30

319 lines
10 KiB
Vue

<script setup>
import { ref, computed, unref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRouter } from 'vue-router';
import { useAlert, useTrack } from 'dashboard/composables';
import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
import contactFilterItems from 'dashboard/routes/dashboard/contacts/contactFilterItems';
import {
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
import countries from 'shared/constants/countries';
import {
useCamelCase,
useSnakeCase,
} from 'dashboard/composables/useTransformKeys';
import ContactsHeader from 'dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue';
import CreateNewContactDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue';
import ContactExportDialog from 'dashboard/components-next/Contacts/ContactsForm/ContactExportDialog.vue';
import ContactImportDialog from 'dashboard/components-next/Contacts/ContactsForm/ContactImportDialog.vue';
import CreateSegmentDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateSegmentDialog.vue';
import DeleteSegmentDialog from 'dashboard/components-next/Contacts/ContactsForm/DeleteSegmentDialog.vue';
import ContactsFilter from 'dashboard/components-next/filter/ContactsFilter.vue';
const props = defineProps({
showSearch: { type: Boolean, default: true },
searchValue: { type: String, default: '' },
activeSort: { type: String, default: 'last_activity_at' },
activeOrdering: { type: String, default: '' },
headerTitle: { type: String, default: '' },
segmentsId: { type: [String, Number], default: 0 },
activeSegment: { type: Object, default: null },
hasAppliedFilters: { type: Boolean, default: false },
isLabelView: { type: Boolean, default: false },
isActiveView: { type: Boolean, default: false },
});
const emit = defineEmits([
'update:sort',
'search',
'applyFilter',
'clearFilters',
]);
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const createNewContactDialogRef = ref(null);
const contactExportDialogRef = ref(null);
const contactImportDialogRef = ref(null);
const createSegmentDialogRef = ref(null);
const deleteSegmentDialogRef = ref(null);
const showFiltersModal = ref(false);
const appliedFilter = ref([]);
const segmentsQuery = ref({});
const appliedFilters = useMapGetter('contacts/getAppliedContactFiltersV4');
const contactAttributes = useMapGetter('attributes/getContactAttributes');
const labels = useMapGetter('labels/getLabels');
const hasActiveSegments = computed(
() => props.activeSegment && props.segmentsId !== 0
);
const activeSegmentName = computed(() => props.activeSegment?.name);
const openCreateNewContactDialog = async () => {
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
createNewContactDialogRef.value?.dialogRef.open();
};
const openContactImportDialog = () =>
contactImportDialogRef.value?.dialogRef.open();
const openContactExportDialog = () =>
contactExportDialogRef.value?.dialogRef.open();
const openCreateSegmentDialog = () =>
createSegmentDialogRef.value?.dialogRef.open();
const openDeleteSegmentDialog = () =>
deleteSegmentDialogRef.value?.dialogRef.open();
const onCreate = async contact => {
try {
await store.dispatch('contacts/create', contact);
createNewContactDialogRef.value?.onSuccess();
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SUCCESS_MESSAGE')
);
} catch (error) {
const i18nPrefix = 'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION';
if (error instanceof DuplicateContactException) {
if (error.data.includes('email')) {
useAlert(t(`${i18nPrefix}.EMAIL_ADDRESS_DUPLICATE`));
} else if (error.data.includes('phone_number')) {
useAlert(t(`${i18nPrefix}.PHONE_NUMBER_DUPLICATE`));
}
} else if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(t(`${i18nPrefix}.ERROR_MESSAGE`));
}
}
};
const onImport = async file => {
try {
await store.dispatch('contacts/import', file);
contactImportDialogRef.value?.dialogRef.close();
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.SUCCESS_MESSAGE')
);
useTrack(CONTACTS_EVENTS.IMPORT_SUCCESS);
} catch (error) {
useAlert(
error.message ??
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.ERROR_MESSAGE')
);
useTrack(CONTACTS_EVENTS.IMPORT_FAILURE);
}
};
const onExport = async query => {
try {
await store.dispatch('contacts/export', query);
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
error.message ||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.EXPORT_CONTACT.ERROR_MESSAGE')
);
}
};
const onCreateSegment = async payload => {
try {
const payloadData = {
...payload,
query: segmentsQuery.value,
};
const response = await store.dispatch('customViews/create', payloadData);
createSegmentDialogRef.value?.dialogRef.close();
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.SUCCESS_MESSAGE')
);
const segmentId = response?.data?.id;
if (!segmentId) return;
// Navigate to the created segment
router.push({
name: 'contacts_dashboard_segments_index',
params: { segmentId },
query: { page: 1 },
});
} catch {
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.CREATE_SEGMENT.ERROR_MESSAGE')
);
}
};
const onDeleteSegment = async payload => {
try {
await store.dispatch('customViews/delete', {
id: Number(props.segmentsId),
...payload,
});
router.push({
name: 'contacts_dashboard_index',
query: {
page: 1,
},
});
deleteSegmentDialogRef.value?.dialogRef.close();
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.FILTERS.DELETE_SEGMENT.ERROR_MESSAGE')
);
}
};
const closeAdvanceFiltersModal = () => {
showFiltersModal.value = false;
appliedFilter.value = [];
};
const clearFilters = async () => {
emit('clearFilters');
};
const onApplyFilter = async payload => {
payload = useSnakeCase(payload);
segmentsQuery.value = filterQueryGenerator(payload);
emit('applyFilter', filterQueryGenerator(payload));
showFiltersModal.value = false;
};
const onUpdateSegment = async (payload, segmentName) => {
payload = useSnakeCase(payload);
const payloadData = {
...props.activeSegment,
name: segmentName,
query: filterQueryGenerator(payload),
};
await store.dispatch('customViews/update', payloadData);
closeAdvanceFiltersModal();
};
const setParamsForEditSegmentModal = () => {
return {
countries,
filterTypes: contactFilterItems,
allCustomAttributes: useSnakeCase(contactAttributes.value),
labels: labels.value || [],
};
};
const initializeSegmentToFilterModal = segment => {
const query = unref(segment)?.query?.payload;
if (!Array.isArray(query)) return;
const newFilters = query.map(filter => {
const transformed = useCamelCase(filter);
const values = Array.isArray(transformed.values)
? generateValuesForEditCustomViews(
useSnakeCase(filter),
setParamsForEditSegmentModal()
)
: [];
return {
attributeKey: transformed.attributeKey,
attributeModel: transformed.attributeModel,
customAttributeType: transformed.customAttributeType,
filterOperator: transformed.filterOperator,
queryOperator: transformed.queryOperator ?? 'and',
values,
};
});
appliedFilter.value = [...appliedFilter.value, ...newFilters];
};
const onToggleFilters = () => {
appliedFilter.value = [];
if (hasActiveSegments.value) {
initializeSegmentToFilterModal(props.activeSegment);
} else {
appliedFilter.value = props.hasAppliedFilters
? [...appliedFilters.value]
: [
{
attributeKey: 'name',
filterOperator: 'equal_to',
values: '',
queryOperator: 'and',
attributeModel: 'standard',
},
];
}
showFiltersModal.value = true;
};
defineExpose({
onToggleFilters,
});
</script>
<template>
<ContactsHeader
:show-search="showSearch"
:search-value="searchValue"
:active-sort="activeSort"
:active-ordering="activeOrdering"
:header-title="headerTitle"
:is-segments-view="hasActiveSegments"
:is-label-view="isLabelView"
:is-active-view="isActiveView"
:has-active-filters="hasAppliedFilters"
:button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
@search="emit('search', $event)"
@update:sort="emit('update:sort', $event)"
@add="openCreateNewContactDialog"
@import="openContactImportDialog"
@export="openContactExportDialog"
@filter="onToggleFilters"
@create-segment="openCreateSegmentDialog"
@delete-segment="openDeleteSegmentDialog"
>
<template #filter>
<div
class="absolute mt-1 ltr:-right-52 rtl:-left-52 sm:ltr:right-0 sm:rtl:left-0 top-full"
>
<ContactsFilter
v-if="showFiltersModal"
v-model="appliedFilter"
:segment-name="activeSegmentName"
:is-segment-view="hasActiveSegments"
@apply-filter="onApplyFilter"
@update-segment="onUpdateSegment"
@close="closeAdvanceFiltersModal"
@clear-filters="clearFilters"
/>
</div>
</template>
</ContactsHeader>
<CreateNewContactDialog ref="createNewContactDialogRef" @create="onCreate" />
<ContactExportDialog ref="contactExportDialogRef" @export="onExport" />
<ContactImportDialog ref="contactImportDialogRef" @import="onImport" />
<CreateSegmentDialog ref="createSegmentDialogRef" @create="onCreateSegment" />
<DeleteSegmentDialog ref="deleteSegmentDialogRef" @delete="onDeleteSegment" />
</template>