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,21 +81,25 @@ const emit = defineEmits([
|
|||||||
</Input>
|
</Input>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Button
|
<div class="relative">
|
||||||
:icon="
|
<Button
|
||||||
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
|
id="toggleContactsFilterButton"
|
||||||
"
|
:icon="
|
||||||
color="slate"
|
isSegmentsView ? 'i-lucide-pen-line' : 'i-lucide-list-filter'
|
||||||
size="sm"
|
"
|
||||||
class="relative"
|
color="slate"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
@click="emit('filter')"
|
class="relative"
|
||||||
>
|
variant="ghost"
|
||||||
<div
|
@click="emit('filter')"
|
||||||
v-if="hasActiveFilters && !isSegmentsView"
|
>
|
||||||
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
|
<div
|
||||||
/>
|
v-if="hasActiveFilters && !isSegmentsView"
|
||||||
</Button>
|
class="absolute top-0 right-0 w-2 h-2 rounded-full bg-n-brand"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<slot name="filter" />
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
v-if="hasActiveFilters && !isSegmentsView"
|
v-if="hasActiveFilters && !isSegmentsView"
|
||||||
icon="i-lucide-save"
|
icon="i-lucide-save"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed, unref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useStore } from 'dashboard/composables/store';
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useAlert, useTrack } from 'dashboard/composables';
|
import { useAlert, useTrack } from 'dashboard/composables';
|
||||||
import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
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 contactFilterItems from 'dashboard/routes/dashboard/contacts/contactFilterItems';
|
||||||
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
|
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
|
||||||
import countries from 'shared/constants/countries';
|
import countries from 'shared/constants/countries';
|
||||||
|
import {
|
||||||
|
useCamelCase,
|
||||||
|
useSnakeCase,
|
||||||
|
} from 'dashboard/composables/useTransformKeys';
|
||||||
|
|
||||||
import ContactsHeader from 'dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue';
|
import ContactsHeader from 'dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue';
|
||||||
import CreateNewContactDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.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 ContactImportDialog from 'dashboard/components-next/Contacts/ContactsForm/ContactImportDialog.vue';
|
||||||
import CreateSegmentDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateSegmentDialog.vue';
|
import CreateSegmentDialog from 'dashboard/components-next/Contacts/ContactsForm/CreateSegmentDialog.vue';
|
||||||
import DeleteSegmentDialog from 'dashboard/components-next/Contacts/ContactsForm/DeleteSegmentDialog.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({
|
const props = defineProps({
|
||||||
showSearch: {
|
showSearch: {
|
||||||
@@ -74,18 +78,13 @@ const showFiltersModal = ref(false);
|
|||||||
const appliedFilter = ref([]);
|
const appliedFilter = ref([]);
|
||||||
const segmentsQuery = ref({});
|
const segmentsQuery = ref({});
|
||||||
|
|
||||||
|
const appliedFilters = useMapGetter('contacts/getAppliedContactFiltersV4');
|
||||||
|
const contactAttributes = useMapGetter('attributes/getContactAttributes');
|
||||||
const hasActiveSegments = computed(
|
const hasActiveSegments = computed(
|
||||||
() => props.activeSegment && props.segmentsId !== 0
|
() => props.activeSegment && props.segmentsId !== 0
|
||||||
);
|
);
|
||||||
const activeSegmentName = computed(() => props.activeSegment?.name);
|
const activeSegmentName = computed(() => props.activeSegment?.name);
|
||||||
|
|
||||||
const contactFilterItemsList = computed(() =>
|
|
||||||
contactFilterItems.map(filter => ({
|
|
||||||
...filter,
|
|
||||||
attributeName: t(`CONTACTS_FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreateNewContactDialog = async () => {
|
const openCreateNewContactDialog = async () => {
|
||||||
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
|
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
|
||||||
createNewContactDialogRef.value?.dialogRef.open();
|
createNewContactDialogRef.value?.dialogRef.open();
|
||||||
@@ -182,12 +181,14 @@ const clearFilters = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onApplyFilter = async payload => {
|
const onApplyFilter = async payload => {
|
||||||
|
payload = useSnakeCase(payload);
|
||||||
segmentsQuery.value = filterQueryGenerator(payload);
|
segmentsQuery.value = filterQueryGenerator(payload);
|
||||||
emit('applyFilter', filterQueryGenerator(payload));
|
emit('applyFilter', filterQueryGenerator(payload));
|
||||||
showFiltersModal.value = false;
|
showFiltersModal.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onUpdateSegment = async (payload, segmentName) => {
|
const onUpdateSegment = async (payload, segmentName) => {
|
||||||
|
payload = useSnakeCase(payload);
|
||||||
const payloadData = {
|
const payloadData = {
|
||||||
...props.activeSegment,
|
...props.activeSegment,
|
||||||
name: segmentName,
|
name: segmentName,
|
||||||
@@ -201,31 +202,52 @@ const setParamsForEditSegmentModal = () => {
|
|||||||
return {
|
return {
|
||||||
countries,
|
countries,
|
||||||
filterTypes: contactFilterItems,
|
filterTypes: contactFilterItems,
|
||||||
allCustomAttributes:
|
allCustomAttributes: useSnakeCase(contactAttributes.value),
|
||||||
store.getters['attributes/getAttributesByModel']('contact_attribute'),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const initializeSegmentToFilterModal = segment => {
|
const initializeSegmentToFilterModal = segment => {
|
||||||
const query = segment?.query?.payload;
|
const query = unref(segment)?.query?.payload;
|
||||||
if (!Array.isArray(query)) return;
|
if (!Array.isArray(query)) return;
|
||||||
|
|
||||||
appliedFilter.value = query.map(filter => ({
|
const newFilters = query.map(filter => {
|
||||||
attribute_key: filter.attribute_key,
|
const transformed = useCamelCase(filter);
|
||||||
attribute_model: filter.attribute_model,
|
const values = Array.isArray(transformed.values)
|
||||||
filter_operator: filter.filter_operator,
|
? generateValuesForEditCustomViews(
|
||||||
values: Array.isArray(filter.values)
|
useSnakeCase(filter),
|
||||||
? generateValuesForEditCustomViews(filter, setParamsForEditSegmentModal())
|
setParamsForEditSegmentModal()
|
||||||
: [],
|
)
|
||||||
query_operator: filter.query_operator,
|
: [];
|
||||||
custom_attribute_type: filter.custom_attribute_type,
|
|
||||||
}));
|
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 = () => {
|
const onToggleFilters = () => {
|
||||||
appliedFilter.value = [];
|
appliedFilter.value = [];
|
||||||
if (hasActiveSegments.value) {
|
if (hasActiveSegments.value) {
|
||||||
initializeSegmentToFilterModal(props.activeSegment);
|
initializeSegmentToFilterModal(props.activeSegment);
|
||||||
|
} else {
|
||||||
|
appliedFilter.value = props.hasAppliedFilters
|
||||||
|
? [...appliedFilters.value]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
attributeKey: 'name',
|
||||||
|
filterOperator: 'equal_to',
|
||||||
|
values: '',
|
||||||
|
queryOperator: 'and',
|
||||||
|
attributeModel: 'standard',
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
showFiltersModal.value = true;
|
showFiltersModal.value = true;
|
||||||
};
|
};
|
||||||
@@ -249,28 +271,25 @@ const onToggleFilters = () => {
|
|||||||
@filter="onToggleFilters"
|
@filter="onToggleFilters"
|
||||||
@create-segment="openCreateSegmentDialog"
|
@create-segment="openCreateSegmentDialog"
|
||||||
@delete-segment="openDeleteSegmentDialog"
|
@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" />
|
<CreateNewContactDialog ref="createNewContactDialogRef" @create="onCreate" />
|
||||||
<ContactExportDialog ref="contactExportDialogRef" @export="onExport" />
|
<ContactExportDialog ref="contactExportDialogRef" @export="onExport" />
|
||||||
<ContactImportDialog ref="contactImportDialogRef" @import="onImport" />
|
<ContactImportDialog ref="contactImportDialogRef" @import="onImport" />
|
||||||
<CreateSegmentDialog ref="createSegmentDialogRef" @create="onCreateSegment" />
|
<CreateSegmentDialog ref="createSegmentDialogRef" @create="onCreateSegment" />
|
||||||
<DeleteSegmentDialog ref="deleteSegmentDialogRef" @delete="onDeleteSegment" />
|
<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>
|
</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 { useOperators } from './operators';
|
||||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
import { useChannelIcon } from 'next/icon/provider';
|
import { useChannelIcon } from 'next/icon/provider';
|
||||||
|
import { buildAttributesFilterTypes } from './helper/filterHelper';
|
||||||
import countries from 'shared/constants/countries.js';
|
import countries from 'shared/constants/countries.js';
|
||||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.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
|
* @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
|
* Composable that provides conversation filtering context
|
||||||
* @returns {{ filterTypes: import('vue').ComputedRef<FilterType[]>, filterGroups: import('vue').ComputedRef<FilterGroup[]> }}
|
* @returns {{ filterTypes: import('vue').ComputedRef<FilterType[]>, filterGroups: import('vue').ComputedRef<FilterGroup[]> }}
|
||||||
@@ -88,23 +69,9 @@ export function useConversationFilterContext() {
|
|||||||
/**
|
/**
|
||||||
* @type {import('vue').ComputedRef<FilterType[]>}
|
* @type {import('vue').ComputedRef<FilterType[]>}
|
||||||
*/
|
*/
|
||||||
const customFilterTypes = computed(() => {
|
const customFilterTypes = computed(() =>
|
||||||
return conversationAttributes.value.map(attr => {
|
buildAttributesFilterTypes(conversationAttributes.value, getOperatorTypes)
|
||||||
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<FilterType[]>}
|
* @type {import('vue').ComputedRef<FilterType[]>}
|
||||||
@@ -272,28 +239,5 @@ export function useConversationFilterContext() {
|
|||||||
...customFilterTypes.value,
|
...customFilterTypes.value,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const filterGroups = computed(() => {
|
return { filterTypes };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -466,6 +466,37 @@
|
|||||||
"PAGINATION_FOOTER": {
|
"PAGINATION_FOOTER": {
|
||||||
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} contacts"
|
"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": {
|
"CARD": {
|
||||||
"OF": "of",
|
"OF": "of",
|
||||||
"VIEW_DETAILS": "View details",
|
"VIEW_DETAILS": "View details",
|
||||||
|
|||||||
@@ -23,4 +23,7 @@ export const getters = {
|
|||||||
getAppliedContactFilters: _state => {
|
getAppliedContactFilters: _state => {
|
||||||
return _state.appliedFilters;
|
return _state.appliedFilters;
|
||||||
},
|
},
|
||||||
|
getAppliedContactFiltersV4: _state => {
|
||||||
|
return _state.appliedFilters.map(camelcaseKeys);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user