mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 19:48:08 +00:00
feat: New contacts advanced filter (#10514)
This commit is contained in:
@@ -81,7 +81,9 @@ const emit = defineEmits([
|
||||
</Input>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative">
|
||||
<Button
|
||||
id="toggleContactsFilterButton"
|
||||
:icon="
|
||||
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
|
||||
"
|
||||
@@ -96,6 +98,8 @@ const emit = defineEmits([
|
||||
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
|
||||
/>
|
||||
</Button>
|
||||
<slot name="filter" />
|
||||
</div>
|
||||
<Button
|
||||
v-if="hasActiveFilters && !isSegmentsView"
|
||||
icon="i-lucide-save"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, unref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
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';
|
||||
@@ -9,6 +9,10 @@ import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
|
||||
import contactFilterItems from 'dashboard/routes/dashboard/contacts/contactFilterItems';
|
||||
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';
|
||||
@@ -16,7 +20,7 @@ import ContactExportDialog from 'dashboard/components-next/Contacts/ContactsForm
|
||||
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 ContactsAdvancedFilters from 'dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue';
|
||||
import ContactsFilter from 'dashboard/components-next/filter/ContactsFilter.vue';
|
||||
|
||||
const props = defineProps({
|
||||
showSearch: {
|
||||
@@ -74,18 +78,13 @@ const showFiltersModal = ref(false);
|
||||
const appliedFilter = ref([]);
|
||||
const segmentsQuery = ref({});
|
||||
|
||||
const appliedFilters = useMapGetter('contacts/getAppliedContactFiltersV4');
|
||||
const contactAttributes = useMapGetter('attributes/getContactAttributes');
|
||||
const hasActiveSegments = computed(
|
||||
() => props.activeSegment && props.segmentsId !== 0
|
||||
);
|
||||
const activeSegmentName = computed(() => props.activeSegment?.name);
|
||||
|
||||
const contactFilterItemsList = computed(() =>
|
||||
contactFilterItems.map(filter => ({
|
||||
...filter,
|
||||
attributeName: t(`CONTACTS_FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
|
||||
}))
|
||||
);
|
||||
|
||||
const openCreateNewContactDialog = async () => {
|
||||
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
|
||||
createNewContactDialogRef.value?.dialogRef.open();
|
||||
@@ -182,12 +181,14 @@ const clearFilters = async () => {
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -201,31 +202,52 @@ const setParamsForEditSegmentModal = () => {
|
||||
return {
|
||||
countries,
|
||||
filterTypes: contactFilterItems,
|
||||
allCustomAttributes:
|
||||
store.getters['attributes/getAttributesByModel']('contact_attribute'),
|
||||
allCustomAttributes: useSnakeCase(contactAttributes.value),
|
||||
};
|
||||
};
|
||||
|
||||
const initializeSegmentToFilterModal = segment => {
|
||||
const query = segment?.query?.payload;
|
||||
const query = unref(segment)?.query?.payload;
|
||||
if (!Array.isArray(query)) return;
|
||||
|
||||
appliedFilter.value = 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, setParamsForEditSegmentModal())
|
||||
: [],
|
||||
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),
|
||||
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;
|
||||
};
|
||||
@@ -249,28 +271,25 @@ const onToggleFilters = () => {
|
||||
@filter="onToggleFilters"
|
||||
@create-segment="openCreateSegmentDialog"
|
||||
@delete-segment="openDeleteSegmentDialog"
|
||||
>
|
||||
<template #filter>
|
||||
<ContactsFilter
|
||||
v-if="showFiltersModal"
|
||||
v-model="appliedFilter"
|
||||
:segment-name="activeSegmentName"
|
||||
:is-segment-view="hasActiveSegments"
|
||||
class="absolute mt-1 ltr:right-0 rtl:left-0 top-full"
|
||||
@apply-filter="onApplyFilter"
|
||||
@update-segment="onUpdateSegment"
|
||||
@close="closeAdvanceFiltersModal"
|
||||
@clear-filters="clearFilters"
|
||||
/>
|
||||
</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" />
|
||||
<woot-modal
|
||||
v-model:show="showFiltersModal"
|
||||
:on-close="closeAdvanceFiltersModal"
|
||||
size="medium"
|
||||
>
|
||||
<ContactsAdvancedFilters
|
||||
v-if="showFiltersModal"
|
||||
:on-close="closeAdvanceFiltersModal"
|
||||
:initial-filter-types="contactFilterItemsList"
|
||||
:initial-applied-filters="appliedFilter"
|
||||
:active-segment-name="activeSegmentName"
|
||||
:is-segments-view="hasActiveSegments"
|
||||
@apply-filter="onApplyFilter"
|
||||
@update-segment="onUpdateSegment"
|
||||
@clear-filters="clearFilters"
|
||||
/>
|
||||
</woot-modal>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
<script setup>
|
||||
import { useTemplateRef, onBeforeUnmount, computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { useContactFilterContext } from './contactProvider.js';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
import Button from 'next/button/Button.vue';
|
||||
import ConditionRow from './ConditionRow.vue';
|
||||
|
||||
const props = defineProps({
|
||||
isSegmentView: { type: Boolean, default: false },
|
||||
segmentName: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'applyFilter',
|
||||
'updateSegment',
|
||||
'close',
|
||||
'clearFilters',
|
||||
]);
|
||||
const { filterTypes } = useContactFilterContext();
|
||||
|
||||
const filters = defineModel({
|
||||
type: Array,
|
||||
default: [],
|
||||
});
|
||||
const segmentNameLocal = ref(props.segmentName);
|
||||
|
||||
const DEFAULT_FILTER = {
|
||||
attributeKey: 'name',
|
||||
filterOperator: 'equal_to',
|
||||
values: '',
|
||||
queryOperator: 'and',
|
||||
attributeModel: 'standard',
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const resetFilter = () => {
|
||||
emit('clearFilters');
|
||||
filters.value = [{ ...DEFAULT_FILTER }];
|
||||
};
|
||||
|
||||
const removeFilter = index => {
|
||||
if (filters.value.length === 1) {
|
||||
resetFilter();
|
||||
} else {
|
||||
filters.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const addFilter = () => {
|
||||
filters.value.push({ ...DEFAULT_FILTER });
|
||||
};
|
||||
|
||||
const conditionsRef = useTemplateRef('conditionsRef');
|
||||
|
||||
const isConditionsValid = () => {
|
||||
return conditionsRef.value.every(condition => condition.validate());
|
||||
};
|
||||
|
||||
const updateSavedSegment = () => {
|
||||
if (isConditionsValid()) {
|
||||
emit('updateSegment', filters.value, segmentNameLocal.value);
|
||||
}
|
||||
};
|
||||
|
||||
function validateAndSubmit() {
|
||||
if (!isConditionsValid()) return;
|
||||
|
||||
store.dispatch(
|
||||
'contacts/setContactFilters',
|
||||
useSnakeCase(JSON.parse(JSON.stringify(filters.value)))
|
||||
);
|
||||
emit('applyFilter', filters.value);
|
||||
useTrack(CONTACTS_EVENTS.APPLY_FILTER, {
|
||||
appliedFilters: filters.value.map(filter => ({
|
||||
key: filter.attributeKey,
|
||||
operator: filter.filterOperator,
|
||||
queryOperator: filter.queryOperator,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const filterModalHeaderTitle = computed(() => {
|
||||
return !props.isSegmentView
|
||||
? t('CONTACTS_LAYOUT.FILTER.TITLE')
|
||||
: t('CONTACTS_LAYOUT.FILTER.EDIT_SEGMENT');
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => emit('close'));
|
||||
const outsideClickHandler = [
|
||||
() => emit('close'),
|
||||
{ ignore: ['#toggleContactsFilterButton'] },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="outsideClickHandler"
|
||||
class="z-40 max-w-3xl lg:w-[750px] overflow-visible w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
|
||||
>
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ filterModalHeaderTitle }}
|
||||
</h3>
|
||||
<div v-if="props.isSegmentView">
|
||||
<label class="pb-6 border-b border-n-weak">
|
||||
<div class="mb-2 text-sm text-n-slate-11">
|
||||
{{ $t('CONTACTS_LAYOUT.FILTER.SEGMENT.LABEL') }}
|
||||
</div>
|
||||
<input
|
||||
v-model="segmentNameLocal"
|
||||
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
|
||||
:placeholder="t('CONTACTS_LAYOUT.FILTER.SEGMENT.INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<ul class="grid gap-4 list-none">
|
||||
<template v-for="(filter, index) in filters" :key="filter.id">
|
||||
<ConditionRow
|
||||
v-if="index === 0"
|
||||
ref="conditionsRef"
|
||||
:key="`filter-${filter.attributeKey}-0`"
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:values="filter.values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
<ConditionRow
|
||||
v-else
|
||||
:key="`filter-${filter.attributeKey}-${index}`"
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:query-operator="filters[index - 1].queryOperator"
|
||||
v-model:values="filter.values"
|
||||
show-query-operator
|
||||
:filter-types="filterTypes"
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
</template>
|
||||
</ul>
|
||||
<div class="flex justify-between gap-2">
|
||||
<Button sm ghost blue @click="addFilter">
|
||||
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.ADD_FILTER') }}
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button sm faded slate @click="resetFilter">
|
||||
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.CLEAR_FILTERS') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isSegmentView"
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
:disabled="!segmentNameLocal"
|
||||
@click="updateSavedSegment"
|
||||
>
|
||||
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.UPDATE_SEGMENT') }}
|
||||
</Button>
|
||||
<Button v-else sm solid blue @click="validateAndSubmit">
|
||||
{{ $t('CONTACTS_LAYOUT.FILTER.BUTTONS.APPLY_FILTERS') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,184 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useOperators } from './operators';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { buildAttributesFilterTypes } from './helper/filterHelper.js';
|
||||
import countries from 'shared/constants/countries.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
|
||||
*/
|
||||
|
||||
/**
|
||||
* Composable that provides conversation filtering context
|
||||
* @returns {{ filterTypes: import('vue').ComputedRef<FilterType[]>, filterGroups: import('vue').ComputedRef<FilterGroup[]> }}
|
||||
*/
|
||||
export function useContactFilterContext() {
|
||||
const { t } = useI18n();
|
||||
|
||||
const contactAttributes = useMapGetter('attributes/getContactAttributes');
|
||||
|
||||
const {
|
||||
equalityOperators,
|
||||
containmentOperators,
|
||||
dateOperators,
|
||||
getOperatorTypes,
|
||||
} = useOperators();
|
||||
|
||||
/**
|
||||
* @type {import('vue').ComputedRef<FilterType[]>}
|
||||
*/
|
||||
const customFilterTypes = computed(() =>
|
||||
buildAttributesFilterTypes(contactAttributes.value, getOperatorTypes)
|
||||
);
|
||||
|
||||
/**
|
||||
* @type {import('vue').ComputedRef<FilterType[]>}
|
||||
*/
|
||||
const filterTypes = computed(() => [
|
||||
{
|
||||
attributeKey: 'name',
|
||||
value: 'name',
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.NAME'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.NAME'),
|
||||
inputType: 'plainText',
|
||||
dataType: 'text',
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'email',
|
||||
value: 'email',
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
|
||||
inputType: 'plainText',
|
||||
dataType: 'text',
|
||||
filterOperators: containmentOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'phone_number',
|
||||
value: 'phone_number',
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
|
||||
inputType: 'plainText',
|
||||
dataType: 'text',
|
||||
filterOperators: containmentOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'identifier',
|
||||
value: 'identifier',
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
|
||||
inputType: 'plainText',
|
||||
dataType: 'number',
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'country_code',
|
||||
value: 'country_code',
|
||||
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||
inputType: 'searchSelect',
|
||||
options: countries,
|
||||
dataType: 'number',
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: 'city',
|
||||
value: 'city',
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.CITY'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.CITY'),
|
||||
inputType: 'plainText',
|
||||
dataType: 'text',
|
||||
filterOperators: containmentOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'created_at',
|
||||
value: 'created_at',
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
|
||||
inputType: 'date',
|
||||
dataType: 'text',
|
||||
filterOperators: dateOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'last_activity_at',
|
||||
value: 'last_activity_at',
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
|
||||
inputType: 'date',
|
||||
dataType: 'text',
|
||||
filterOperators: dateOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'referer',
|
||||
value: 'referer',
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
|
||||
inputType: 'plainText',
|
||||
dataType: 'text',
|
||||
filterOperators: containmentOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'blocked',
|
||||
value: 'blocked',
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
|
||||
inputType: 'searchSelect',
|
||||
options: [
|
||||
{
|
||||
id: 'true',
|
||||
name: t('CONTACTS_LAYOUT.FILTER.BLOCKED_TRUE'),
|
||||
},
|
||||
{
|
||||
id: 'false',
|
||||
name: t('CONTACTS_LAYOUT.FILTER.BLOCKED_FALSE'),
|
||||
},
|
||||
],
|
||||
dataType: 'text',
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
...customFilterTypes.value,
|
||||
]);
|
||||
|
||||
return { filterTypes };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export const getCustomAttributeInputType = key => {
|
||||
switch (key) {
|
||||
case 'date':
|
||||
return 'date';
|
||||
case 'text':
|
||||
return 'plainText';
|
||||
case 'list':
|
||||
return 'searchSelect';
|
||||
case 'checkbox':
|
||||
return 'booleanSelect';
|
||||
default:
|
||||
return 'plainText';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds filter types for custom attributes
|
||||
* @param {Array} attributes - The attributes array
|
||||
* @param {Function} getOperatorTypes - Function to get operator types
|
||||
* @returns {Array} Array of filter types
|
||||
*/
|
||||
export const buildAttributesFilterTypes = (attributes, getOperatorTypes) => {
|
||||
return attributes.map(attr => ({
|
||||
attributeKey: attr.attributeKey,
|
||||
value: attr.attributeKey,
|
||||
attributeName: attr.attributeDisplayName,
|
||||
label: attr.attributeDisplayName,
|
||||
inputType: getCustomAttributeInputType(attr.attributeDisplayType),
|
||||
filterOperators: getOperatorTypes(attr.attributeDisplayType),
|
||||
options:
|
||||
attr.attributeDisplayType === 'list'
|
||||
? attr.attributeValues.map(item => ({ id: item, name: item }))
|
||||
: [],
|
||||
attributeModel: 'customAttributes',
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
getCustomAttributeInputType,
|
||||
buildAttributesFilterTypes,
|
||||
} from './filterHelper';
|
||||
|
||||
describe('filterHelper', () => {
|
||||
describe('getCustomAttributeInputType', () => {
|
||||
it('returns date for date type', () => {
|
||||
expect(getCustomAttributeInputType('date')).toBe('date');
|
||||
});
|
||||
|
||||
it('returns plainText for text type', () => {
|
||||
expect(getCustomAttributeInputType('text')).toBe('plainText');
|
||||
});
|
||||
|
||||
it('returns searchSelect for list type', () => {
|
||||
expect(getCustomAttributeInputType('list')).toBe('searchSelect');
|
||||
});
|
||||
|
||||
it('returns booleanSelect for checkbox type', () => {
|
||||
expect(getCustomAttributeInputType('checkbox')).toBe('booleanSelect');
|
||||
});
|
||||
|
||||
it('returns plainText for unknown type', () => {
|
||||
expect(getCustomAttributeInputType('unknown')).toBe('plainText');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAttributesFilterTypes', () => {
|
||||
const mockGetOperatorTypes = type => {
|
||||
return type === 'list' ? ['is', 'is_not'] : ['contains', 'not_contains'];
|
||||
};
|
||||
|
||||
it('builds filter types for text attributes', () => {
|
||||
const attributes = [
|
||||
{
|
||||
attributeKey: 'test_key',
|
||||
attributeDisplayName: 'Test Name',
|
||||
attributeDisplayType: 'text',
|
||||
attributeValues: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildAttributesFilterTypes(
|
||||
attributes,
|
||||
mockGetOperatorTypes
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
attributeKey: 'test_key',
|
||||
value: 'test_key',
|
||||
attributeName: 'Test Name',
|
||||
label: 'Test Name',
|
||||
inputType: 'plainText',
|
||||
filterOperators: ['contains', 'not_contains'],
|
||||
options: [],
|
||||
attributeModel: 'customAttributes',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds filter types for list attributes with options', () => {
|
||||
const attributes = [
|
||||
{
|
||||
attributeKey: 'list_key',
|
||||
attributeDisplayName: 'List Name',
|
||||
attributeDisplayType: 'list',
|
||||
attributeValues: ['option1', 'option2'],
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildAttributesFilterTypes(
|
||||
attributes,
|
||||
mockGetOperatorTypes
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
attributeKey: 'list_key',
|
||||
value: 'list_key',
|
||||
attributeName: 'List Name',
|
||||
label: 'List Name',
|
||||
inputType: 'searchSelect',
|
||||
filterOperators: ['is', 'is_not'],
|
||||
options: [
|
||||
{ id: 'option1', name: 'option1' },
|
||||
{ id: 'option2', name: 'option2' },
|
||||
],
|
||||
attributeModel: 'customAttributes',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles multiple attributes', () => {
|
||||
const attributes = [
|
||||
{
|
||||
attributeKey: 'date_key',
|
||||
attributeDisplayName: 'Date Name',
|
||||
attributeDisplayType: 'date',
|
||||
attributeValues: [],
|
||||
},
|
||||
{
|
||||
attributeKey: 'checkbox_key',
|
||||
attributeDisplayName: 'Checkbox Name',
|
||||
attributeDisplayType: 'checkbox',
|
||||
attributeValues: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildAttributesFilterTypes(
|
||||
attributes,
|
||||
mockGetOperatorTypes
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].inputType).toBe('date');
|
||||
expect(result[1].inputType).toBe('booleanSelect');
|
||||
});
|
||||
|
||||
it('handles empty attributes array', () => {
|
||||
const result = buildAttributesFilterTypes([], mockGetOperatorTypes);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 { buildAttributesFilterTypes } from './helper/filterHelper';
|
||||
import countries from 'shared/constants/countries.js';
|
||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
||||
|
||||
@@ -40,26 +41,6 @@ import languages from 'dashboard/components/widgets/conversation/advancedFilterI
|
||||
* @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<FilterType[]>, filterGroups: import('vue').ComputedRef<FilterGroup[]> }}
|
||||
@@ -88,23 +69,9 @@ export function useConversationFilterContext() {
|
||||
/**
|
||||
* @type {import('vue').ComputedRef<FilterType[]>}
|
||||
*/
|
||||
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',
|
||||
};
|
||||
});
|
||||
});
|
||||
const customFilterTypes = computed(() =>
|
||||
buildAttributesFilterTypes(conversationAttributes.value, getOperatorTypes)
|
||||
);
|
||||
|
||||
/**
|
||||
* @type {import('vue').ComputedRef<FilterType[]>}
|
||||
@@ -272,28 +239,5 @@ export function useConversationFilterContext() {
|
||||
...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 };
|
||||
return { filterTypes };
|
||||
}
|
||||
|
||||
@@ -466,6 +466,37 @@
|
||||
"PAGINATION_FOOTER": {
|
||||
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} contacts"
|
||||
},
|
||||
"FILTER": {
|
||||
"NAME": "Name",
|
||||
"EMAIL": "Email",
|
||||
"PHONE_NUMBER": "Phone number",
|
||||
"IDENTIFIER": "Identifier",
|
||||
"COUNTRY": "Country",
|
||||
"CITY": "City",
|
||||
"CREATED_AT": "Created at",
|
||||
"LAST_ACTIVITY": "Last activity",
|
||||
"REFERER_LINK": "Referer link",
|
||||
"BLOCKED": "Blocked",
|
||||
"BLOCKED_TRUE": "True",
|
||||
"BLOCKED_FALSE": "False",
|
||||
"BUTTONS": {
|
||||
"CLEAR_FILTERS": "Clear filters",
|
||||
"UPDATE_SEGMENT": "Update segment",
|
||||
"APPLY_FILTERS": "Apply filters",
|
||||
"ADD_FILTER": "Add filter"
|
||||
},
|
||||
"TITLE": "Filter contacts",
|
||||
"EDIT_SEGMENT": "Edit segment",
|
||||
"SEGMENT": {
|
||||
"LABEL": "Segment name",
|
||||
"INPUT_PLACEHOLDER": "Enter the name of the segment"
|
||||
},
|
||||
"GROUPS": {
|
||||
"STANDARD_FILTERS": "Standard filters",
|
||||
"ADDITIONAL_FILTERS": "Additional filters",
|
||||
"CUSTOM_ATTRIBUTES": "Custom attributes"
|
||||
}
|
||||
},
|
||||
"CARD": {
|
||||
"OF": "of",
|
||||
"VIEW_DETAILS": "View details",
|
||||
|
||||
@@ -23,4 +23,7 @@ export const getters = {
|
||||
getAppliedContactFilters: _state => {
|
||||
return _state.appliedFilters;
|
||||
},
|
||||
getAppliedContactFiltersV4: _state => {
|
||||
return _state.appliedFilters.map(camelcaseKeys);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user