mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 20:18:08 +00:00
feat(v4): Add new conversation filters component (#10502)
Co-authored-by: Pranav <pranav@chatwoot.com> Co-authored-by: Pranav <pranavrajs@gmail.com>
This commit is contained in:
@@ -1,7 +1,33 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
const { strong } = defineProps({
|
||||||
|
// Use strong prop when this dropdown is stacked inside another dropdown
|
||||||
|
// Chrome has issues with stacked backdrop-blur, so we need an extra blur layer when stacked
|
||||||
|
// Also, stacked dropdowns should have a strong border
|
||||||
|
strong: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const borderClass = computed(() => {
|
||||||
|
return strong ? 'border-n-strong' : 'border-n-weak';
|
||||||
|
});
|
||||||
|
|
||||||
|
const beforeClass = computed(() => {
|
||||||
|
if (!strong) return '';
|
||||||
|
|
||||||
|
// Add extra blur layer only when strong prop is true, as a hack for Chrome's stacked backdrop-blur limitation
|
||||||
|
// https://issues.chromium.org/issues/40835530
|
||||||
|
return "before:content-['\x00A0'] before:absolute before:bottom-0 before:left-0 before:w-full before:h-full before:backdrop-contrast-70 before:backdrop-blur-sm before:z-0 [&>*]:relative";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="absolute">
|
<div class="absolute">
|
||||||
<ul
|
<ul
|
||||||
class="text-sm bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak rounded-xl shadow-sm py-2 n-dropdown-body gap-2 grid list-none px-2 reset-base"
|
class="text-sm bg-n-alpha-3 backdrop-blur-[100px] border rounded-xl shadow-sm py-2 n-dropdown-body gap-2 grid list-none px-2 reset-base relative"
|
||||||
|
:class="[borderClass, beforeClass]"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ const componentIs = computed(() => {
|
|||||||
const triggerClick = () => {
|
const triggerClick = () => {
|
||||||
if (props.click) {
|
if (props.click) {
|
||||||
props.click();
|
props.click();
|
||||||
if (!props.preserveOpen) closeMenu();
|
|
||||||
}
|
}
|
||||||
|
if (!props.preserveOpen) closeMenu();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref, useTemplateRef } from 'vue';
|
||||||
import ConditionRow from './ConditionRow.vue';
|
import ConditionRow from './ConditionRow.vue';
|
||||||
import Button from 'next/button/Button.vue';
|
import Button from 'next/button/Button.vue';
|
||||||
import { filterTypes } from './fixtures/filterTypes.js';
|
import { filterTypes } from './fixtures/filterTypes.js';
|
||||||
@@ -12,6 +12,7 @@ const DEFAULT_FILTER = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filters = ref([{ ...DEFAULT_FILTER }]);
|
const filters = ref([{ ...DEFAULT_FILTER }]);
|
||||||
|
const conditionsRef = useTemplateRef('conditionsRef');
|
||||||
|
|
||||||
const removeFilter = index => {
|
const removeFilter = index => {
|
||||||
filters.value.splice(index, 1);
|
filters.value.splice(index, 1);
|
||||||
@@ -22,6 +23,10 @@ const showQueryOperator = true;
|
|||||||
const addFilter = () => {
|
const addFilter = () => {
|
||||||
filters.value.push({ ...DEFAULT_FILTER });
|
filters.value.push({ ...DEFAULT_FILTER });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveFilter = () => {
|
||||||
|
console.log(conditionsRef.value.every(condition => condition.validate()));
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -33,6 +38,7 @@ const addFilter = () => {
|
|||||||
<template v-for="(filter, index) in filters" :key="`filter-${index}`">
|
<template v-for="(filter, index) in filters" :key="`filter-${index}`">
|
||||||
<ConditionRow
|
<ConditionRow
|
||||||
v-if="index === 0"
|
v-if="index === 0"
|
||||||
|
ref="conditionsRef"
|
||||||
v-model:attribute-key="filter.attributeKey"
|
v-model:attribute-key="filter.attributeKey"
|
||||||
v-model:filter-operator="filter.filterOperator"
|
v-model:filter-operator="filter.filterOperator"
|
||||||
v-model:values="filter.values"
|
v-model:values="filter.values"
|
||||||
@@ -43,6 +49,7 @@ const addFilter = () => {
|
|||||||
|
|
||||||
<ConditionRow
|
<ConditionRow
|
||||||
v-else
|
v-else
|
||||||
|
ref="conditionsRef"
|
||||||
v-model:attribute-key="filter.attributeKey"
|
v-model:attribute-key="filter.attributeKey"
|
||||||
v-model:filter-operator="filter.filterOperator"
|
v-model:filter-operator="filter.filterOperator"
|
||||||
v-model:values="filter.values"
|
v-model:values="filter.values"
|
||||||
@@ -52,7 +59,10 @@ const addFilter = () => {
|
|||||||
@remove="removeFilter(index)"
|
@remove="removeFilter(index)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<Button sm label="Add Filter" @click="addFilter" />
|
<div class="flex gap-3 mt-2">
|
||||||
|
<Button sm ghost label="Add Filter" @click="addFilter" />
|
||||||
|
<Button sm label="Save Filter" @click="saveFilter" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import FilterSelect from './inputs/FilterSelect.vue';
|
|||||||
import MultiSelect from './inputs/MultiSelect.vue';
|
import MultiSelect from './inputs/MultiSelect.vue';
|
||||||
import SingleSelect from './inputs/SingleSelect.vue';
|
import SingleSelect from './inputs/SingleSelect.vue';
|
||||||
|
|
||||||
|
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||||
import { validateSingleFilter } from 'dashboard/helper/validations.js';
|
import { validateSingleFilter } from 'dashboard/helper/validations.js';
|
||||||
|
|
||||||
// filterTypes: import('vue').ComputedRef<FilterType[]>
|
// filterTypes: import('vue').ComputedRef<FilterType[]>
|
||||||
@@ -91,11 +92,14 @@ const booleanOptions = computed(() => [
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const validationError = computed(() => {
|
const validationError = computed(() => {
|
||||||
return validateSingleFilter({
|
// TOOD: Migrate validateSingleFilter to use camelcase and then remove useSnakeCase here too
|
||||||
attributeKey: attributeKey.value,
|
return validateSingleFilter(
|
||||||
filter_operator: filterOperator.value,
|
useSnakeCase({
|
||||||
values: values.value,
|
attributeKey: attributeKey.value,
|
||||||
});
|
filterOperator: filterOperator.value,
|
||||||
|
values: values.value,
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetModelOnAttributeKeyChange = newAttributeKey => {
|
const resetModelOnAttributeKeyChange = newAttributeKey => {
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<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 { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
|
import { useConversationFilterContext } from './provider.js';
|
||||||
|
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||||
|
|
||||||
|
import Button from 'next/button/Button.vue';
|
||||||
|
import ConditionRow from './ConditionRow.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isFolderView: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
folderName: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['applyFilter', 'updateFolder', 'close']);
|
||||||
|
const { filterTypes } = useConversationFilterContext();
|
||||||
|
|
||||||
|
const filters = defineModel({
|
||||||
|
type: Array,
|
||||||
|
default: [],
|
||||||
|
});
|
||||||
|
const folderNameLocal = ref(props.folderName);
|
||||||
|
|
||||||
|
const DEFAULT_FILTER = {
|
||||||
|
attributeKey: 'status',
|
||||||
|
filterOperator: 'equal_to',
|
||||||
|
values: [],
|
||||||
|
queryOperator: 'and',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const resetFilter = () => {
|
||||||
|
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 updateSavedCustomViews = () => {
|
||||||
|
if (isConditionsValid()) {
|
||||||
|
emit('updateFolder', filters.value, folderNameLocal.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function validateAndSubmit() {
|
||||||
|
if (!isConditionsValid()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.dispatch(
|
||||||
|
'setConversationFilters',
|
||||||
|
useSnakeCase(JSON.parse(JSON.stringify(filters.value)))
|
||||||
|
);
|
||||||
|
emit('applyFilter', filters.value);
|
||||||
|
useTrack(CONVERSATION_EVENTS.APPLY_FILTER, {
|
||||||
|
appliedFilters: filters.value.map(filter => ({
|
||||||
|
key: filter.attributeKey,
|
||||||
|
operator: filter.filterOperator,
|
||||||
|
queryOperator: filter.queryOperator,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterModalHeaderTitle = computed(() => {
|
||||||
|
return !props.isFolderView
|
||||||
|
? t('FILTER.TITLE')
|
||||||
|
: t('FILTER.EDIT_CUSTOM_FILTER');
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => emit('close'));
|
||||||
|
const outsideClickHandler = [
|
||||||
|
() => emit('close'),
|
||||||
|
{ ignore: ['#toggleConversationFilterButton'] },
|
||||||
|
];
|
||||||
|
</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.isFolderView">
|
||||||
|
<label class="border-b border-n-weak pb-6">
|
||||||
|
<div class="text-n-slate-11 text-sm mb-2">
|
||||||
|
{{ t('FILTER.FOLDER_LABEL') }}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="folderNameLocal"
|
||||||
|
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
|
||||||
|
:placeholder="t('FILTER.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 gap-2 justify-between">
|
||||||
|
<Button sm ghost blue @click="addFilter">
|
||||||
|
{{ $t('FILTER.ADD_NEW_FILTER') }}
|
||||||
|
</Button>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button sm faded slate @click="resetFilter">
|
||||||
|
{{ t('FILTER.CLEAR_BUTTON_LABEL') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="isFolderView"
|
||||||
|
sm
|
||||||
|
solid
|
||||||
|
blue
|
||||||
|
:disabled="!folderNameLocal"
|
||||||
|
@click="updateSavedCustomViews"
|
||||||
|
>
|
||||||
|
{{ t('FILTER.UPDATE_BUTTON_LABEL') }}
|
||||||
|
</Button>
|
||||||
|
<Button v-else sm solid blue @click="validateAndSubmit">
|
||||||
|
{{ t('FILTER.SUBMIT_BUTTON_LABEL') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<script>
|
||||||
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
import { required, minLength } from '@vuelidate/validators';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
import { useTrack } from 'dashboard/composables';
|
||||||
|
import NextButton from 'next/button/Button.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
NextButton,
|
||||||
|
},
|
||||||
|
directives: {
|
||||||
|
onClickOutside: vOnClickOutside,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
filterType: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
customViewsQuery: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
|
openLastSavedItem: {
|
||||||
|
type: Function,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ['close'],
|
||||||
|
setup() {
|
||||||
|
return { v$: useVuelidate() };
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
name: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
isButtonDisabled() {
|
||||||
|
return this.v$.name.$invalid;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
validations: {
|
||||||
|
name: {
|
||||||
|
required,
|
||||||
|
minLength: minLength(1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
onClose() {
|
||||||
|
this.$emit('close');
|
||||||
|
},
|
||||||
|
async saveCustomViews() {
|
||||||
|
this.v$.$touch();
|
||||||
|
if (this.v$.$invalid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.$store.dispatch('customViews/create', {
|
||||||
|
name: this.name,
|
||||||
|
filter_type: this.filterType,
|
||||||
|
query: this.customViewsQuery,
|
||||||
|
});
|
||||||
|
this.alertMessage =
|
||||||
|
this.filterType === 0
|
||||||
|
? this.$t('FILTER.CUSTOM_VIEWS.ADD.API_FOLDERS.SUCCESS_MESSAGE')
|
||||||
|
: this.$t('FILTER.CUSTOM_VIEWS.ADD.API_SEGMENTS.SUCCESS_MESSAGE');
|
||||||
|
this.onClose();
|
||||||
|
|
||||||
|
useTrack(CONTACTS_EVENTS.SAVE_FILTER, {
|
||||||
|
type: this.filterType === 0 ? 'folder' : 'segment',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error?.message;
|
||||||
|
this.alertMessage =
|
||||||
|
errorMessage || this.filterType === 0
|
||||||
|
? errorMessage
|
||||||
|
: this.$t('FILTER.CUSTOM_VIEWS.ADD.API_SEGMENTS.ERROR_MESSAGE');
|
||||||
|
} finally {
|
||||||
|
useAlert(this.alertMessage);
|
||||||
|
}
|
||||||
|
this.openLastSavedItem();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-on-click-outside="[
|
||||||
|
() => $emit('close'),
|
||||||
|
{ ignore: ['#saveFilterTeleportTarget'] },
|
||||||
|
]"
|
||||||
|
class="z-40 max-w-3xl lg:w-[500px] 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">
|
||||||
|
{{ $t('FILTER.CUSTOM_VIEWS.ADD.TITLE') }}
|
||||||
|
</h3>
|
||||||
|
<form class="w-full grid gap-6" @submit.prevent="saveCustomViews">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
v-model="name"
|
||||||
|
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
|
||||||
|
:placeholder="$t('FILTER.CUSTOM_VIEWS.ADD.PLACEHOLDER')"
|
||||||
|
@blur="v$.name.$touch"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="v$.name.$error"
|
||||||
|
class="text-xs text-n-ruby-11 ml-1 rtl:mr-1"
|
||||||
|
>
|
||||||
|
{{ $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row justify-end w-full gap-2">
|
||||||
|
<NextButton sm solid blue :disabled="isButtonDisabled">
|
||||||
|
{{ $t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON') }}
|
||||||
|
</NextButton>
|
||||||
|
<NextButton faded slate sm @click.prevent="onClose">
|
||||||
|
{{ $t('FILTER.CUSTOM_VIEWS.ADD.CANCEL_BUTTON') }}
|
||||||
|
</NextButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -61,7 +61,7 @@ const updateSelected = newValue => {
|
|||||||
/>
|
/>
|
||||||
</slot>
|
</slot>
|
||||||
</template>
|
</template>
|
||||||
<DropdownBody class="top-0 min-w-48 z-[999]">
|
<DropdownBody class="top-0 min-w-48 z-50" strong>
|
||||||
<DropdownSection class="max-h-80 overflow-scroll">
|
<DropdownSection class="max-h-80 overflow-scroll">
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
v-for="option in options"
|
v-for="option in options"
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ const toggleOption = option => {
|
|||||||
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
|
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
<DropdownBody class="top-0 min-w-48 z-[999]">
|
<DropdownBody class="top-0 min-w-48 z-50" strong>
|
||||||
<DropdownSection class="max-h-80 overflow-scroll">
|
<DropdownSection class="max-h-80 overflow-scroll">
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
v-for="option in options"
|
v-for="option in options"
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ const toggleSelected = option => {
|
|||||||
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
|
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
<DropdownBody class="top-0 min-w-56 z-[999]">
|
<DropdownBody class="top-0 min-w-56 z-50" strong>
|
||||||
<div v-if="!disableSearch" class="relative">
|
<div v-if="!disableSearch" class="relative">
|
||||||
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
|
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
|
||||||
<input
|
<input
|
||||||
|
|||||||
164
app/javascript/dashboard/components-next/filter/operators.js
Normal file
164
app/javascript/dashboard/components-next/filter/operators.js
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { computed, h } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} FilterOperations
|
||||||
|
* @property {string} EQUAL_TO - Equals comparison
|
||||||
|
* @property {string} NOT_EQUAL_TO - Not equals comparison
|
||||||
|
* @property {string} IS_PRESENT - Present check
|
||||||
|
* @property {string} IS_NOT_PRESENT - Not present check
|
||||||
|
* @property {string} CONTAINS - Contains check
|
||||||
|
* @property {string} DOES_NOT_CONTAIN - Does not contain check
|
||||||
|
* @property {string} IS_GREATER_THAN - Greater than comparison
|
||||||
|
* @property {string} IS_LESS_THAN - Less than comparison
|
||||||
|
* @property {string} DAYS_BEFORE - Days before check
|
||||||
|
* @property {string} STARTS_WITH - Starts with check
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Operator
|
||||||
|
* @property {string} value - Operator value from FILTER_OPS
|
||||||
|
* @property {string} label - Translated display label
|
||||||
|
* @property {import('vue').VNode} icon - Vue icon component instance
|
||||||
|
* @property {string|null} inputOverride - Input field type override
|
||||||
|
* @property {boolean} hasInput - Whether operator requires an input value
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FILTER_OPS = {
|
||||||
|
EQUAL_TO: 'equal_to',
|
||||||
|
NOT_EQUAL_TO: 'not_equal_to',
|
||||||
|
IS_PRESENT: 'is_present',
|
||||||
|
IS_NOT_PRESENT: 'is_not_present',
|
||||||
|
CONTAINS: 'contains',
|
||||||
|
DOES_NOT_CONTAIN: 'does_not_contain',
|
||||||
|
IS_GREATER_THAN: 'is_greater_than',
|
||||||
|
IS_LESS_THAN: 'is_less_than',
|
||||||
|
DAYS_BEFORE: 'days_before',
|
||||||
|
STARTS_WITH: 'starts_with',
|
||||||
|
};
|
||||||
|
|
||||||
|
const NO_INPUT_OPTS = [FILTER_OPS.IS_PRESENT, FILTER_OPS.IS_NOT_PRESENT];
|
||||||
|
|
||||||
|
const OPS_INPUT_OVERRIDE = {
|
||||||
|
[FILTER_OPS.DAYS_BEFORE]: 'plainText',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Record<string, string>}
|
||||||
|
*/
|
||||||
|
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<Record<string, Operator>>} operators - All available operators
|
||||||
|
* @property {import('vue').ComputedRef<Operator[]>} equalityOperators - Equality comparison operators
|
||||||
|
* @property {import('vue').ComputedRef<Operator[]>} presenceOperators - Presence check operators
|
||||||
|
* @property {import('vue').ComputedRef<Operator[]>} containmentOperators - Containment check operators
|
||||||
|
* @property {import('vue').ComputedRef<Operator[]>} comparisonOperators - Numeric comparison operators
|
||||||
|
* @property {import('vue').ComputedRef<Operator[]>} 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<Record<string, Operator>>} */
|
||||||
|
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<Array<Operator>>} */
|
||||||
|
const equalityOperators = computed(() => [
|
||||||
|
operators.value[FILTER_OPS.EQUAL_TO],
|
||||||
|
operators.value[FILTER_OPS.NOT_EQUAL_TO],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @type {import('vue').ComputedRef<Array<Operator>>} */
|
||||||
|
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<Array<Operator>>} */
|
||||||
|
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<Array<Operator>>} */
|
||||||
|
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<Array<Operator>>} */
|
||||||
|
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<Operator>}
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
299
app/javascript/dashboard/components-next/filter/provider.js
Normal file
299
app/javascript/dashboard/components-next/filter/provider.js
Normal file
@@ -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<FilterType[]>, filterGroups: import('vue').ComputedRef<FilterGroup[]> }}
|
||||||
|
*/
|
||||||
|
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<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',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {import('vue').ComputedRef<FilterType[]>}
|
||||||
|
*/
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -22,10 +22,10 @@ import {
|
|||||||
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
|
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
|
||||||
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
|
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
|
||||||
import ChatListHeader from './ChatListHeader.vue';
|
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 ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
||||||
import ConversationItem from './ConversationItem.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 DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
||||||
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
||||||
import IntersectionObserver from './IntersectionObserver.vue';
|
import IntersectionObserver from './IntersectionObserver.vue';
|
||||||
@@ -37,9 +37,15 @@ import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
|
|||||||
import { useFilter } from 'shared/composables/useFilter';
|
import { useFilter } from 'shared/composables/useFilter';
|
||||||
import { useTrack } from 'dashboard/composables';
|
import { useTrack } from 'dashboard/composables';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import {
|
||||||
|
useCamelCase,
|
||||||
|
useSnakeCase,
|
||||||
|
} from 'dashboard/composables/useTransformKeys';
|
||||||
import { useEmitter } from 'dashboard/composables/emitter';
|
import { useEmitter } from 'dashboard/composables/emitter';
|
||||||
import { useEventListener } from '@vueuse/core';
|
import { useEventListener } from '@vueuse/core';
|
||||||
|
|
||||||
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
import advancedFilterOptions from './widgets/conversation/advancedFilterItems';
|
import advancedFilterOptions from './widgets/conversation/advancedFilterItems';
|
||||||
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
|
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
|
||||||
@@ -51,12 +57,11 @@ import {
|
|||||||
isOnMentionsView,
|
isOnMentionsView,
|
||||||
isOnUnattendedView,
|
isOnUnattendedView,
|
||||||
} from '../store/modules/conversations/helpers/actionHelpers';
|
} from '../store/modules/conversations/helpers/actionHelpers';
|
||||||
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
|
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
|
||||||
import {
|
import {
|
||||||
getUserPermissions,
|
getUserPermissions,
|
||||||
filterItemsByPermission,
|
filterItemsByPermission,
|
||||||
} from 'dashboard/helper/permissionsHelper.js';
|
} from 'dashboard/helper/permissionsHelper.js';
|
||||||
|
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
|
||||||
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
|
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
|
||||||
|
|
||||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||||
@@ -107,7 +112,7 @@ const unAssignedChatsList = useMapGetter('getUnAssignedChats');
|
|||||||
const chatListLoading = useMapGetter('getChatListLoadingStatus');
|
const chatListLoading = useMapGetter('getChatListLoadingStatus');
|
||||||
const activeInbox = useMapGetter('getSelectedInbox');
|
const activeInbox = useMapGetter('getSelectedInbox');
|
||||||
const conversationStats = useMapGetter('conversationStats/getStats');
|
const conversationStats = useMapGetter('conversationStats/getStats');
|
||||||
const appliedFilters = useMapGetter('getAppliedConversationFilters');
|
const appliedFilters = useMapGetter('getAppliedConversationFiltersV2');
|
||||||
const folders = useMapGetter('customViews/getConversationCustomViews');
|
const folders = useMapGetter('customViews/getConversationCustomViews');
|
||||||
const agentList = useMapGetter('agents/getAgents');
|
const agentList = useMapGetter('agents/getAgents');
|
||||||
const teamsList = useMapGetter('teams/getTeams');
|
const teamsList = useMapGetter('teams/getTeams');
|
||||||
@@ -371,6 +376,7 @@ function emitConversationLoaded() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fetchFilteredConversations(payload) {
|
function fetchFilteredConversations(payload) {
|
||||||
|
payload = useSnakeCase(payload);
|
||||||
let page = currentFiltersPage.value + 1;
|
let page = currentFiltersPage.value + 1;
|
||||||
store
|
store
|
||||||
.dispatch('fetchFilteredConversations', {
|
.dispatch('fetchFilteredConversations', {
|
||||||
@@ -383,6 +389,7 @@ function fetchFilteredConversations(payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fetchSavedFilteredConversations(payload) {
|
function fetchSavedFilteredConversations(payload) {
|
||||||
|
payload = useSnakeCase(payload);
|
||||||
let page = currentFiltersPage.value + 1;
|
let page = currentFiltersPage.value + 1;
|
||||||
store
|
store
|
||||||
.dispatch('fetchFilteredConversations', {
|
.dispatch('fetchFilteredConversations', {
|
||||||
@@ -393,6 +400,7 @@ function fetchSavedFilteredConversations(payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onApplyFilter(payload) {
|
function onApplyFilter(payload) {
|
||||||
|
payload = useSnakeCase(payload);
|
||||||
resetBulkActions();
|
resetBulkActions();
|
||||||
foldersQuery.value = filterQueryGenerator(payload);
|
foldersQuery.value = filterQueryGenerator(payload);
|
||||||
store.dispatch('conversationPage/reset');
|
store.dispatch('conversationPage/reset');
|
||||||
@@ -406,10 +414,11 @@ function closeAdvanceFiltersModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onUpdateSavedFilter(payload, folderName) {
|
function onUpdateSavedFilter(payload, folderName) {
|
||||||
|
const transformedPayload = useSnakeCase(payload);
|
||||||
const payloadData = {
|
const payloadData = {
|
||||||
...unref(activeFolder),
|
...unref(activeFolder),
|
||||||
name: unref(folderName),
|
name: unref(folderName),
|
||||||
query: filterQueryGenerator(payload),
|
query: filterQueryGenerator(transformedPayload),
|
||||||
};
|
};
|
||||||
store.dispatch('customViews/update', payloadData);
|
store.dispatch('customViews/update', payloadData);
|
||||||
closeAdvanceFiltersModal();
|
closeAdvanceFiltersModal();
|
||||||
@@ -461,17 +470,19 @@ function initializeExistingFilterToModal() {
|
|||||||
currentUserDetails.value,
|
currentUserDetails.value,
|
||||||
activeAssigneeTab.value
|
activeAssigneeTab.value
|
||||||
);
|
);
|
||||||
|
// TODO: Remove the usage of useCamelCase after migrating useFilter to camelcase
|
||||||
if (statusFilter) {
|
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(
|
const otherFilters = initializeInboxTeamAndLabelFilterToModal(
|
||||||
props.conversationInbox,
|
props.conversationInbox,
|
||||||
inbox.value,
|
inbox.value,
|
||||||
props.teamId,
|
props.teamId,
|
||||||
activeTeam.value,
|
activeTeam.value,
|
||||||
props.label
|
props.label
|
||||||
);
|
).map(useCamelCase);
|
||||||
|
|
||||||
appliedFilter.value = [...appliedFilter.value, ...otherFilters];
|
appliedFilter.value = [...appliedFilter.value, ...otherFilters];
|
||||||
}
|
}
|
||||||
@@ -486,27 +497,47 @@ function initializeFolderToFilterModal(newActiveFolder) {
|
|||||||
const query = unref(newActiveFolder)?.query?.payload;
|
const query = unref(newActiveFolder)?.query?.payload;
|
||||||
if (!Array.isArray(query)) return;
|
if (!Array.isArray(query)) return;
|
||||||
|
|
||||||
const newFilters = 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, setParamsForEditFolderModal())
|
setParamsForEditFolderModal()
|
||||||
: [],
|
)
|
||||||
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];
|
appliedFilter.value = [...appliedFilter.value, ...newFilters];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initalizeAppliedFiltersToModal() {
|
||||||
|
appliedFilter.value = [...appliedFilters.value];
|
||||||
|
}
|
||||||
|
|
||||||
function onToggleAdvanceFiltersModal() {
|
function onToggleAdvanceFiltersModal() {
|
||||||
|
if (showAdvancedFilters.value === true) {
|
||||||
|
closeAdvanceFiltersModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasAppliedFilters.value && !hasActiveFolders.value) {
|
if (!hasAppliedFilters.value && !hasActiveFolders.value) {
|
||||||
initializeExistingFilterToModal();
|
initializeExistingFilterToModal();
|
||||||
}
|
}
|
||||||
if (hasActiveFolders.value) {
|
if (hasActiveFolders.value) {
|
||||||
initializeFolderToFilterModal(activeFolder.value);
|
initializeFolderToFilterModal(activeFolder.value);
|
||||||
}
|
}
|
||||||
|
if (hasAppliedFilters.value) {
|
||||||
|
initalizeAppliedFiltersToModal();
|
||||||
|
}
|
||||||
|
|
||||||
showAdvancedFilters.value = true;
|
showAdvancedFilters.value = true;
|
||||||
}
|
}
|
||||||
@@ -751,7 +782,7 @@ watch(conversationFilters, (newVal, oldVal) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col flex-shrink-0 overflow-hidden border-r conversations-list-wrap rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
|
class="flex flex-col flex-shrink-0 border-r conversations-list-wrap rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
|
||||||
:class="[
|
:class="[
|
||||||
{ hidden: !showConversationList },
|
{ hidden: !showConversationList },
|
||||||
isOnExpandedLayout ? 'basis-full' : 'flex-basis-clamp',
|
isOnExpandedLayout ? 'basis-full' : 'flex-basis-clamp',
|
||||||
@@ -770,12 +801,14 @@ watch(conversationFilters, (newVal, oldVal) => {
|
|||||||
@basic-filter-change="onBasicFilterChange"
|
@basic-filter-change="onBasicFilterChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddCustomViews
|
<Teleport v-if="showAddFoldersModal" to="#saveFilterTeleportTarget">
|
||||||
v-if="showAddFoldersModal"
|
<SaveCustomView
|
||||||
:custom-views-query="foldersQuery"
|
v-model="appliedFilter"
|
||||||
:open-last-saved-item="openLastSavedItemInFolder"
|
:custom-views-query="foldersQuery"
|
||||||
@close="onCloseAddFoldersModal"
|
:open-last-saved-item="openLastSavedItemInFolder"
|
||||||
/>
|
@close="onCloseAddFoldersModal"
|
||||||
|
/>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<DeleteCustomViews
|
<DeleteCustomViews
|
||||||
v-if="showDeleteFoldersModal"
|
v-if="showDeleteFoldersModal"
|
||||||
@@ -871,22 +904,16 @@ watch(conversationFilters, (newVal, oldVal) => {
|
|||||||
</template>
|
</template>
|
||||||
</DynamicScroller>
|
</DynamicScroller>
|
||||||
</div>
|
</div>
|
||||||
<woot-modal
|
<Teleport v-if="showAdvancedFilters" to="#conversationFilterTeleportTarget">
|
||||||
v-model:show="showAdvancedFilters"
|
<ConversationFilter
|
||||||
:on-close="closeAdvanceFiltersModal"
|
v-model="appliedFilter"
|
||||||
size="medium"
|
:folder-name="activeFolderName"
|
||||||
>
|
|
||||||
<ConversationAdvancedFilter
|
|
||||||
v-if="showAdvancedFilters"
|
|
||||||
:initial-filter-types="advancedFilterTypes"
|
|
||||||
:initial-applied-filters="appliedFilter"
|
|
||||||
:active-folder-name="activeFolderName"
|
|
||||||
:on-close="closeAdvanceFiltersModal"
|
|
||||||
:is-folder-view="hasActiveFolders"
|
:is-folder-view="hasActiveFolders"
|
||||||
@apply-filter="onApplyFilter"
|
@apply-filter="onApplyFilter"
|
||||||
@update-folder="onUpdateSavedFilter"
|
@update-folder="onUpdateSavedFilter"
|
||||||
|
@close="closeAdvanceFiltersModal"
|
||||||
/>
|
/>
|
||||||
</woot-modal>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -62,14 +62,18 @@ const hasAppliedFiltersOrActiveFolders = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<template v-if="hasAppliedFilters && !hasActiveFolders">
|
<template v-if="hasAppliedFilters && !hasActiveFolders">
|
||||||
<woot-button
|
<div class="relative">
|
||||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
|
<woot-button
|
||||||
size="tiny"
|
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
|
||||||
variant="smooth"
|
size="tiny"
|
||||||
color-scheme="secondary"
|
variant="smooth"
|
||||||
icon="save"
|
color-scheme="secondary"
|
||||||
@click="emit('addFolders')"
|
icon="save"
|
||||||
/>
|
@click="emit('addFolders')"
|
||||||
|
/>
|
||||||
|
<div id="saveFilterTeleportTarget" class="absolute mt-2 z-40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<woot-button
|
<woot-button
|
||||||
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
|
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
|
||||||
size="tiny"
|
size="tiny"
|
||||||
@@ -80,14 +84,21 @@ const hasAppliedFiltersOrActiveFolders = computed(() => {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="hasActiveFolders">
|
<template v-if="hasActiveFolders">
|
||||||
<woot-button
|
<div class="relative">
|
||||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
|
<woot-button
|
||||||
size="tiny"
|
id="toggleConversationFilterButton"
|
||||||
variant="smooth"
|
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
|
||||||
color-scheme="secondary"
|
size="tiny"
|
||||||
icon="edit"
|
variant="smooth"
|
||||||
@click="emit('filtersModal')"
|
color-scheme="secondary"
|
||||||
/>
|
icon="edit"
|
||||||
|
@click="emit('filtersModal')"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
id="conversationFilterTeleportTarget"
|
||||||
|
class="absolute mt-2 z-40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<woot-button
|
<woot-button
|
||||||
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
|
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
|
||||||
size="tiny"
|
size="tiny"
|
||||||
@@ -97,15 +108,18 @@ const hasAppliedFiltersOrActiveFolders = computed(() => {
|
|||||||
@click="emit('deleteFolders')"
|
@click="emit('deleteFolders')"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<woot-button
|
<div v-else class="relative">
|
||||||
v-else
|
<woot-button
|
||||||
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
|
id="toggleConversationFilterButton"
|
||||||
variant="smooth"
|
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
|
||||||
color-scheme="secondary"
|
variant="smooth"
|
||||||
icon="filter"
|
color-scheme="secondary"
|
||||||
size="tiny"
|
icon="filter"
|
||||||
@click="emit('filtersModal')"
|
size="tiny"
|
||||||
/>
|
@click="emit('filtersModal')"
|
||||||
|
/>
|
||||||
|
<div id="conversationFilterTeleportTarget" class="absolute mt-2 z-40" />
|
||||||
|
</div>
|
||||||
<ConversationBasicFilter
|
<ConversationBasicFilter
|
||||||
v-if="!hasAppliedFiltersOrActiveFolders"
|
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||||
@change-filter="onBasicFilterChange"
|
@change-filter="onBasicFilterChange"
|
||||||
|
|||||||
25
app/javascript/dashboard/composables/useTransformKeys.js
Normal file
25
app/javascript/dashboard/composables/useTransformKeys.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// NOTE: In the future if performance becomes an issue, we can memoize the functions
|
||||||
|
|
||||||
|
import { unref } from 'vue';
|
||||||
|
import camelcaseKeys from 'camelcase-keys';
|
||||||
|
import snakecaseKeys from 'snakecase-keys';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue composable that converts object keys to camelCase
|
||||||
|
* @param {Object|Array|import('vue').Ref<Object|Array>} payload - Object or array to convert
|
||||||
|
* @returns {Object|Array} Converted payload with camelCase keys
|
||||||
|
*/
|
||||||
|
export function useCamelCase(payload) {
|
||||||
|
const unrefPayload = unref(payload);
|
||||||
|
return camelcaseKeys(unrefPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue composable that converts object keys to snake_case
|
||||||
|
* @param {Object|Array|import('vue').Ref<Object|Array>} payload - Object or array to convert
|
||||||
|
* @returns {Object|Array} Converted payload with snake_case keys
|
||||||
|
*/
|
||||||
|
export function useSnakeCase(payload) {
|
||||||
|
const unrefPayload = unref(payload);
|
||||||
|
return snakecaseKeys(unrefPayload);
|
||||||
|
}
|
||||||
@@ -6,6 +6,24 @@ export const VALUE_MUST_BE_BETWEEN_1_AND_998 =
|
|||||||
export const ACTION_PARAMETERS_REQUIRED = 'ACTION_PARAMETERS_REQUIRED';
|
export const ACTION_PARAMETERS_REQUIRED = 'ACTION_PARAMETERS_REQUIRED';
|
||||||
export const ATLEAST_ONE_CONDITION_REQUIRED = 'ATLEAST_ONE_CONDITION_REQUIRED';
|
export const ATLEAST_ONE_CONDITION_REQUIRED = 'ATLEAST_ONE_CONDITION_REQUIRED';
|
||||||
export const ATLEAST_ONE_ACTION_REQUIRED = 'ATLEAST_ONE_ACTION_REQUIRED';
|
export const ATLEAST_ONE_ACTION_REQUIRED = 'ATLEAST_ONE_ACTION_REQUIRED';
|
||||||
|
|
||||||
|
const isEmptyValue = value => {
|
||||||
|
if (!value) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return !value.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We can safely check the type here as both the null value
|
||||||
|
// and the array is ruled out earlier.
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
return !Object.keys(value).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// ------------------------ Filter Validation -----------------------
|
// ------------------------ Filter Validation -----------------------
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -29,12 +47,11 @@ export const validateSingleFilter = filter => {
|
|||||||
return FILTER_OPERATOR_REQUIRED;
|
return FILTER_OPERATOR_REQUIRED;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const operatorRequiresValue = !['is_present', 'is_not_present'].includes(
|
||||||
filter.filter_operator !== 'is_present' &&
|
filter.filter_operator
|
||||||
filter.filter_operator !== 'is_not_present' &&
|
);
|
||||||
(!filter.values ||
|
|
||||||
(Array.isArray(filter.values) && filter.values.length === 0))
|
if (operatorRequiresValue && isEmptyValue(filter.values)) {
|
||||||
) {
|
|
||||||
return VALUE_REQUIRED;
|
return VALUE_REQUIRED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,14 +22,23 @@
|
|||||||
"OPERATOR_LABELS": {
|
"OPERATOR_LABELS": {
|
||||||
"equal_to": "Equal to",
|
"equal_to": "Equal to",
|
||||||
"not_equal_to": "Not equal to",
|
"not_equal_to": "Not equal to",
|
||||||
"contains": "Contains",
|
|
||||||
"does_not_contain": "Does not contain",
|
"does_not_contain": "Does not contain",
|
||||||
"is_present": "Is present",
|
"is_present": "Is present",
|
||||||
"is_not_present": "Is not present",
|
"is_not_present": "Is not present",
|
||||||
"is_greater_than": "Is greater than",
|
"is_greater_than": "Is greater than",
|
||||||
"is_less_than": "Is lesser than",
|
"is_less_than": "Is lesser than",
|
||||||
"days_before": "Is x days before",
|
"days_before": "Is x days before",
|
||||||
"starts_with": "Starts with"
|
"starts_with": "Starts with",
|
||||||
|
"equalTo": "Equal to",
|
||||||
|
"notEqualTo": "Not equal to",
|
||||||
|
"contains": "Contains",
|
||||||
|
"doesNotContain": "Does not contain",
|
||||||
|
"isPresent": "Is present",
|
||||||
|
"isNotPresent": "Is not present",
|
||||||
|
"isGreaterThan": "Is greater than",
|
||||||
|
"isLessThan": "Is lesser than",
|
||||||
|
"daysBefore": "Is x days before",
|
||||||
|
"startsWith": "Starts with"
|
||||||
},
|
},
|
||||||
"ATTRIBUTE_LABELS": {
|
"ATTRIBUTE_LABELS": {
|
||||||
"TRUE": "True",
|
"TRUE": "True",
|
||||||
@@ -56,7 +65,10 @@
|
|||||||
"LAST_ACTIVITY": "Last activity"
|
"LAST_ACTIVITY": "Last activity"
|
||||||
},
|
},
|
||||||
"ERRORS": {
|
"ERRORS": {
|
||||||
"VALUE_REQUIRED": "Value is required"
|
"VALUE_REQUIRED": "Value is required",
|
||||||
|
"ATTRIBUTE_KEY_REQUIRED": "Attribute key is required",
|
||||||
|
"FILTER_OPERATOR_REQUIRED": "Filter operator is required",
|
||||||
|
"VALUE_MUST_BE_BETWEEN_1_AND_998": "Value must be between 1 and 998"
|
||||||
},
|
},
|
||||||
"GROUPS": {
|
"GROUPS": {
|
||||||
"STANDARD_FILTERS": "Standard filters",
|
"STANDARD_FILTERS": "Standard filters",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||||
import types from '../mutation-types';
|
import types from '../mutation-types';
|
||||||
import AttributeAPI from '../../api/attributes';
|
import AttributeAPI from '../../api/attributes';
|
||||||
|
import camelcaseKeys from 'camelcase-keys';
|
||||||
|
|
||||||
export const state = {
|
export const state = {
|
||||||
records: [],
|
records: [],
|
||||||
@@ -19,6 +20,16 @@ export const getters = {
|
|||||||
getAttributes: _state => {
|
getAttributes: _state => {
|
||||||
return _state.records;
|
return _state.records;
|
||||||
},
|
},
|
||||||
|
getConversationAttributes: _state => {
|
||||||
|
return _state.records
|
||||||
|
.filter(record => record.attribute_model === 'conversation_attribute')
|
||||||
|
.map(camelcaseKeys);
|
||||||
|
},
|
||||||
|
getContactAttributes: _state => {
|
||||||
|
return _state.records
|
||||||
|
.filter(record => record.attribute_model === 'contact_attribute')
|
||||||
|
.map(camelcaseKeys);
|
||||||
|
},
|
||||||
getAttributesByModel: _state => attributeModel => {
|
getAttributesByModel: _state => attributeModel => {
|
||||||
return _state.records.filter(
|
return _state.records.filter(
|
||||||
record => record.attribute_model === attributeModel
|
record => record.attribute_model === attributeModel
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
||||||
import { applyPageFilters, sortComparator } from './helpers';
|
import { applyPageFilters, sortComparator } from './helpers';
|
||||||
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
|
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
|
||||||
|
import camelcaseKeys from 'camelcase-keys';
|
||||||
|
|
||||||
export const getSelectedChatConversation = ({
|
export const getSelectedChatConversation = ({
|
||||||
allConversations,
|
allConversations,
|
||||||
@@ -54,6 +55,10 @@ const getters = {
|
|||||||
return isChatMine;
|
return isChatMine;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
getAppliedConversationFiltersV2: _state => {
|
||||||
|
// TODO: Replace existing one with V2 after migrating the filters to use camelcase
|
||||||
|
return _state.appliedFilters.map(camelcaseKeys);
|
||||||
|
},
|
||||||
getAppliedConversationFilters: _state => {
|
getAppliedConversationFilters: _state => {
|
||||||
return _state.appliedFilters;
|
return _state.appliedFilters;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"opus-recorder": "^8.0.5",
|
"opus-recorder": "^8.0.5",
|
||||||
"semver": "7.6.3",
|
"semver": "7.6.3",
|
||||||
|
"snakecase-keys": "^8.0.1",
|
||||||
"timezone-phone-codes": "^0.0.2",
|
"timezone-phone-codes": "^0.0.2",
|
||||||
"tinykeys": "^3.0.0",
|
"tinykeys": "^3.0.0",
|
||||||
"turbolinks": "^5.2.0",
|
"turbolinks": "^5.2.0",
|
||||||
|
|||||||
53
pnpm-lock.yaml
generated
53
pnpm-lock.yaml
generated
@@ -154,6 +154,9 @@ importers:
|
|||||||
semver:
|
semver:
|
||||||
specifier: 7.6.3
|
specifier: 7.6.3
|
||||||
version: 7.6.3
|
version: 7.6.3
|
||||||
|
snakecase-keys:
|
||||||
|
specifier: ^8.0.1
|
||||||
|
version: 8.0.1
|
||||||
timezone-phone-codes:
|
timezone-phone-codes:
|
||||||
specifier: ^0.0.2
|
specifier: ^0.0.2
|
||||||
version: 0.0.2
|
version: 0.0.2
|
||||||
@@ -3485,6 +3488,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
map-obj@4.3.0:
|
||||||
|
resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
map-obj@5.0.0:
|
map-obj@5.0.0:
|
||||||
resolution: {integrity: sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==}
|
resolution: {integrity: sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
@@ -4420,6 +4427,10 @@ packages:
|
|||||||
snake-case@3.0.4:
|
snake-case@3.0.4:
|
||||||
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
|
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
|
||||||
|
|
||||||
|
snakecase-keys@8.0.1:
|
||||||
|
resolution: {integrity: sha512-Sj51kE1zC7zh6TDlNNz0/Jn1n5HiHdoQErxO8jLtnyrkJW/M5PrI7x05uDgY3BO7OUQYKCvmeMurW6BPUdwEOw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
sortablejs@1.14.0:
|
sortablejs@1.14.0:
|
||||||
resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==}
|
resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==}
|
||||||
|
|
||||||
@@ -5847,7 +5858,7 @@ snapshots:
|
|||||||
'@material/mwc-icon@0.25.3':
|
'@material/mwc-icon@0.25.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
lit: 2.2.6
|
lit: 2.2.6
|
||||||
tslib: 2.6.2
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5932,7 +5943,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@lukeed/uuid': 2.0.0
|
'@lukeed/uuid': 2.0.0
|
||||||
dset: 3.1.4
|
dset: 3.1.4
|
||||||
tslib: 2.6.2
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@segment/analytics.js-video-plugins@0.2.1':
|
'@segment/analytics.js-video-plugins@0.2.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -7041,7 +7052,7 @@ snapshots:
|
|||||||
|
|
||||||
aria-hidden@1.2.4:
|
aria-hidden@1.2.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.7.0
|
tslib: 2.8.1
|
||||||
|
|
||||||
array-buffer-byte-length@1.0.0:
|
array-buffer-byte-length@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -7209,7 +7220,7 @@ snapshots:
|
|||||||
camel-case@4.1.2:
|
camel-case@4.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
pascal-case: 3.1.2
|
pascal-case: 3.1.2
|
||||||
tslib: 2.7.0
|
tslib: 2.8.1
|
||||||
|
|
||||||
camelcase-css@2.0.1: {}
|
camelcase-css@2.0.1: {}
|
||||||
|
|
||||||
@@ -7227,7 +7238,7 @@ snapshots:
|
|||||||
capital-case@1.0.4:
|
capital-case@1.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
no-case: 3.0.4
|
no-case: 3.0.4
|
||||||
tslib: 2.7.0
|
tslib: 2.8.1
|
||||||
upper-case-first: 2.0.2
|
upper-case-first: 2.0.2
|
||||||
|
|
||||||
chai@5.1.1:
|
chai@5.1.1:
|
||||||
@@ -7384,7 +7395,7 @@ snapshots:
|
|||||||
constant-case@3.0.4:
|
constant-case@3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
no-case: 3.0.4
|
no-case: 3.0.4
|
||||||
tslib: 2.7.0
|
tslib: 2.8.1
|
||||||
upper-case: 2.0.2
|
upper-case: 2.0.2
|
||||||
|
|
||||||
core-js@3.38.1: {}
|
core-js@3.38.1: {}
|
||||||
@@ -7578,7 +7589,7 @@ snapshots:
|
|||||||
dot-case@3.0.4:
|
dot-case@3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
no-case: 3.0.4
|
no-case: 3.0.4
|
||||||
tslib: 2.7.0
|
tslib: 2.8.1
|
||||||
|
|
||||||
dset@3.1.4: {}
|
dset@3.1.4: {}
|
||||||
|
|
||||||
@@ -8249,7 +8260,7 @@ snapshots:
|
|||||||
header-case@2.0.4:
|
header-case@2.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
capital-case: 1.0.4
|
capital-case: 1.0.4
|
||||||
tslib: 2.7.0
|
tslib: 2.8.1
|
||||||
|
|
||||||
highlight.js@11.10.0: {}
|
highlight.js@11.10.0: {}
|
||||||
|
|
||||||
@@ -8776,7 +8787,7 @@ snapshots:
|
|||||||
|
|
||||||
lower-case@2.0.2:
|
lower-case@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.7.0
|
tslib: 2.8.1
|
||||||
|
|
||||||
lru-cache@10.4.3: {}
|
lru-cache@10.4.3: {}
|
||||||
|
|
||||||
@@ -8814,6 +8825,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.6.3
|
semver: 7.6.3
|
||||||
|
|
||||||
|
map-obj@4.3.0: {}
|
||||||
|
|
||||||
map-obj@5.0.0: {}
|
map-obj@5.0.0: {}
|
||||||
|
|
||||||
markdown-it-anchor@8.6.7(@types/markdown-it@12.2.3)(markdown-it@12.3.2):
|
markdown-it-anchor@8.6.7(@types/markdown-it@12.2.3)(markdown-it@12.3.2):
|
||||||
@@ -8970,7 +8983,7 @@ snapshots:
|
|||||||
no-case@3.0.4:
|
no-case@3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
lower-case: 2.0.2
|
lower-case: 2.0.2
|
||||||
tslib: 2.7.0
|
tslib: 2.8.1
|
||||||
|
|
||||||
node-fetch@2.6.11:
|
node-fetch@2.6.11:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -9117,7 +9130,7 @@ snapshots:
|
|||||||
param-case@3.0.4:
|
param-case@3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
dot-case: 3.0.4
|
dot-case: 3.0.4
|
||||||
tslib: 2.7.0
|
tslib: 2.8.1
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -9132,12 +9145,12 @@ snapshots:
|
|||||||
pascal-case@3.1.2:
|
pascal-case@3.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
no-case: 3.0.4
|
no-case: 3.0.4
|
||||||
tslib: 2.7.0
|
tslib: 2.8.1
|
||||||
|
|
||||||
path-case@3.0.4:
|
path-case@3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
dot-case: 3.0.4
|
dot-case: 3.0.4
|
||||||
tslib: 2.7.0
|
tslib: 2.8.1
|
||||||
|
|
||||||
path-exists@4.0.0: {}
|
path-exists@4.0.0: {}
|
||||||
|
|
||||||
@@ -9748,7 +9761,7 @@ snapshots:
|
|||||||
sentence-case@3.0.4:
|
sentence-case@3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
no-case: 3.0.4
|
no-case: 3.0.4
|
||||||
tslib: 2.7.0
|
tslib: 2.8.1
|
||||||
upper-case-first: 2.0.2
|
upper-case-first: 2.0.2
|
||||||
|
|
||||||
set-function-length@1.2.2:
|
set-function-length@1.2.2:
|
||||||
@@ -9823,7 +9836,13 @@ snapshots:
|
|||||||
snake-case@3.0.4:
|
snake-case@3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
dot-case: 3.0.4
|
dot-case: 3.0.4
|
||||||
tslib: 2.7.0
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
snakecase-keys@8.0.1:
|
||||||
|
dependencies:
|
||||||
|
map-obj: 4.3.0
|
||||||
|
snake-case: 3.0.4
|
||||||
|
type-fest: 4.26.1
|
||||||
|
|
||||||
sortablejs@1.14.0: {}
|
sortablejs@1.14.0: {}
|
||||||
|
|
||||||
@@ -10196,11 +10215,11 @@ snapshots:
|
|||||||
|
|
||||||
upper-case-first@2.0.2:
|
upper-case-first@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.7.0
|
tslib: 2.8.1
|
||||||
|
|
||||||
upper-case@2.0.2:
|
upper-case@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.7.0
|
tslib: 2.8.1
|
||||||
|
|
||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user