mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-30 02:32:29 +00:00
# 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
319 lines
10 KiB
Vue
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>
|