Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abfd335f57 |
@@ -15,6 +15,7 @@ import { validateSingleFilter } from 'dashboard/helper/validations.js';
|
||||
const { filterTypes } = defineProps({
|
||||
showQueryOperator: { type: Boolean, default: false },
|
||||
filterTypes: { type: Array, required: true },
|
||||
searchableAttributes: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['remove']);
|
||||
@@ -199,6 +200,7 @@ defineExpose({ validate, resetValidation });
|
||||
v-model="attributeKey"
|
||||
variant="faded"
|
||||
:options="filterTypes"
|
||||
:searchable="searchableAttributes"
|
||||
@update:model-value="resetModelOnAttributeKeyChange"
|
||||
/>
|
||||
<FilterSelect
|
||||
|
||||
@@ -23,7 +23,7 @@ const emit = defineEmits([
|
||||
'close',
|
||||
'clearFilters',
|
||||
]);
|
||||
const { filterTypes } = useContactFilterContext();
|
||||
const { attributeFilterTypes } = useContactFilterContext();
|
||||
|
||||
const filters = defineModel({
|
||||
type: Array,
|
||||
@@ -127,8 +127,9 @@ const outsideClickHandler = [
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:values="filter.values"
|
||||
:filter-types="filterTypes"
|
||||
:filter-types="attributeFilterTypes"
|
||||
:show-query-operator="false"
|
||||
searchable-attributes
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
<ConditionRow
|
||||
@@ -140,7 +141,8 @@ const outsideClickHandler = [
|
||||
v-model:query-operator="filters[index - 1].queryOperator"
|
||||
v-model:values="filter.values"
|
||||
show-query-operator
|
||||
:filter-types="filterTypes"
|
||||
:filter-types="attributeFilterTypes"
|
||||
searchable-attributes
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -24,7 +24,7 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const emit = defineEmits(['applyFilter', 'updateFolder', 'close']);
|
||||
const { filterTypes } = useConversationFilterContext();
|
||||
const { attributeFilterTypes } = useConversationFilterContext();
|
||||
|
||||
const filters = defineModel({
|
||||
type: Array,
|
||||
@@ -128,8 +128,9 @@ const outsideClickHandler = [
|
||||
v-model:attribute-key="filter.attributeKey"
|
||||
v-model:filter-operator="filter.filterOperator"
|
||||
v-model:values="filter.values"
|
||||
:filter-types="filterTypes"
|
||||
:filter-types="attributeFilterTypes"
|
||||
:show-query-operator="false"
|
||||
searchable-attributes
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
<ConditionRow
|
||||
@@ -141,7 +142,8 @@ const outsideClickHandler = [
|
||||
v-model:query-operator="filters[index - 1].queryOperator"
|
||||
v-model:values="filter.values"
|
||||
show-query-operator
|
||||
:filter-types="filterTypes"
|
||||
:filter-types="attributeFilterTypes"
|
||||
searchable-attributes
|
||||
@remove="removeFilter(index)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
buildAttributesFilterTypes,
|
||||
CONTACT_ATTRIBUTES,
|
||||
} from './helper/filterHelper.js';
|
||||
import { groupFilterTypes } from './helper/filterAttributeIcons.js';
|
||||
import countries from 'shared/constants/countries.js';
|
||||
|
||||
/**
|
||||
@@ -212,5 +213,10 @@ export function useContactFilterContext() {
|
||||
...customFilterTypes.value,
|
||||
]);
|
||||
|
||||
return { filterTypes };
|
||||
// Same attributes, but grouped into sections with leading icons for the attribute picker.
|
||||
const attributeFilterTypes = computed(() =>
|
||||
groupFilterTypes(filterTypes.value, t)
|
||||
);
|
||||
|
||||
return { filterTypes, attributeFilterTypes };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Shared helpers to make the attribute picker in FilterSelect richer: a leading icon per
|
||||
* attribute and grouped, non-clickable section headers. Used by the conversation, contact and
|
||||
* automation filter builders so they stay visually consistent.
|
||||
*/
|
||||
|
||||
// Icon per known standard / additional attribute key (conversation + contact filters).
|
||||
const ATTRIBUTE_ICONS = {
|
||||
// Contact attributes
|
||||
name: 'i-lucide-user',
|
||||
email: 'i-lucide-mail',
|
||||
phone_number: 'i-lucide-phone',
|
||||
identifier: 'i-lucide-fingerprint',
|
||||
country_code: 'i-lucide-flag',
|
||||
city: 'i-lucide-map-pin',
|
||||
company_name: 'i-lucide-building-2',
|
||||
blocked: 'i-lucide-ban',
|
||||
// Conversation attributes
|
||||
status: 'i-lucide-circle-dot',
|
||||
priority: 'i-lucide-signal-high',
|
||||
assignee_id: 'i-lucide-user-round',
|
||||
inbox_id: 'i-lucide-inbox',
|
||||
team_id: 'i-lucide-users-round',
|
||||
contact_id: 'i-lucide-contact',
|
||||
display_id: 'i-lucide-hash',
|
||||
campaign_id: 'i-lucide-megaphone',
|
||||
browser_language: 'i-lucide-globe',
|
||||
conversation_language: 'i-lucide-languages',
|
||||
referer: 'i-lucide-link',
|
||||
// Shared
|
||||
labels: 'i-lucide-tags',
|
||||
created_at: 'i-lucide-calendar',
|
||||
last_activity_at: 'i-lucide-activity',
|
||||
};
|
||||
|
||||
// Icon per custom-attribute display type.
|
||||
const CUSTOM_TYPE_ICONS = {
|
||||
text: 'i-lucide-type',
|
||||
number: 'i-lucide-hash',
|
||||
currency: 'i-lucide-banknote',
|
||||
percent: 'i-lucide-percent',
|
||||
link: 'i-lucide-link',
|
||||
date: 'i-lucide-calendar',
|
||||
list: 'i-lucide-list',
|
||||
checkbox: 'i-lucide-square-check',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON = 'i-lucide-tag';
|
||||
|
||||
/**
|
||||
* Resolve the leading icon for a single filter type.
|
||||
* @param {Object} type - A FilterType entry.
|
||||
* @returns {string} The icon class to render.
|
||||
*/
|
||||
export const getAttributeIcon = type => {
|
||||
if (type.attributeModel === 'customAttributes') {
|
||||
return CUSTOM_TYPE_ICONS[type.attributeDisplayType] || DEFAULT_ICON;
|
||||
}
|
||||
return ATTRIBUTE_ICONS[type.attributeKey] || DEFAULT_ICON;
|
||||
};
|
||||
|
||||
// Order the groups appear in, keyed by attributeModel, with their i18n label keys.
|
||||
const GROUPS = [
|
||||
{ model: 'standard', label: 'FILTER.GROUPS.STANDARD_FILTERS' },
|
||||
{ model: 'additional', label: 'FILTER.GROUPS.ADDITIONAL_FILTERS' },
|
||||
{ model: 'customAttributes', label: 'FILTER.GROUPS.CUSTOM_ATTRIBUTES' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Attach a leading icon to each filter type and split them into grouped sections separated by
|
||||
* disabled header entries (which FilterSelect renders as non-clickable section titles).
|
||||
* @param {Object[]} filterTypes - Flat list of FilterType entries.
|
||||
* @param {Function} t - vue-i18n translate function.
|
||||
* @returns {Object[]} Grouped list with header + icon-enriched entries.
|
||||
*/
|
||||
export const groupFilterTypes = (filterTypes, t) => {
|
||||
const withIcon = type => ({
|
||||
...type,
|
||||
icon: type.icon || getAttributeIcon(type),
|
||||
});
|
||||
const knownModels = GROUPS.map(group => group.model);
|
||||
|
||||
const grouped = GROUPS.flatMap(({ model, label }) => {
|
||||
const group = filterTypes.filter(
|
||||
type => (type.attributeModel || 'standard') === model
|
||||
);
|
||||
if (!group.length) return [];
|
||||
return [
|
||||
{ value: `__group_${model}`, label: t(label), disabled: true },
|
||||
...group.map(withIcon),
|
||||
];
|
||||
});
|
||||
|
||||
// Append any attribute with an unexpected model rather than dropping it silently.
|
||||
const ungrouped = filterTypes.filter(
|
||||
type => !knownModels.includes(type.attributeModel || 'standard')
|
||||
);
|
||||
|
||||
return [...grouped, ...ungrouped.map(withIcon)];
|
||||
};
|
||||
@@ -78,6 +78,7 @@ export const buildAttributesFilterTypes = (
|
||||
attributeName: attr.attributeDisplayName,
|
||||
label: attr.attributeDisplayName,
|
||||
inputType: getCustomAttributeInputType(attr.attributeDisplayType),
|
||||
attributeDisplayType: attr.attributeDisplayType,
|
||||
filterOperators: getOperatorTypes(attr.attributeDisplayType),
|
||||
options:
|
||||
attr.attributeDisplayType === 'list'
|
||||
|
||||
@@ -82,6 +82,7 @@ describe('filterHelper', () => {
|
||||
attributeName: 'Test Name',
|
||||
label: 'Test Name',
|
||||
inputType: 'plainText',
|
||||
attributeDisplayType: 'text',
|
||||
filterOperators: ['contains', 'not_contains'],
|
||||
options: [],
|
||||
attributeModel: 'customAttributes',
|
||||
@@ -111,6 +112,7 @@ describe('filterHelper', () => {
|
||||
attributeName: 'List Name',
|
||||
label: 'List Name',
|
||||
inputType: 'searchSelect',
|
||||
attributeDisplayType: 'list',
|
||||
filterOperators: ['is', 'is_not'],
|
||||
options: [
|
||||
{ id: 'option1', name: 'option1' },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useElementBounding, useWindowSize } from '@vueuse/core';
|
||||
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
|
||||
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
|
||||
@@ -7,6 +8,7 @@ import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||
|
||||
import Button from 'next/button/Button.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
// [{label, icon, value}]
|
||||
const props = defineProps({
|
||||
@@ -30,6 +32,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
searchable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const selected = defineModel({
|
||||
@@ -37,9 +43,21 @@ const selected = defineModel({
|
||||
required: true,
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const searchQuery = ref('');
|
||||
const triggerRef = ref(null);
|
||||
const dropdownRef = ref(null);
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase();
|
||||
if (!props.searchable || !query) return props.options;
|
||||
return props.options.filter(
|
||||
option =>
|
||||
!option.disabled && (option.label || '').toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const { top } = useElementBounding(triggerRef);
|
||||
const { height } = useWindowSize();
|
||||
const { height: dropdownHeight } = useElementBounding(dropdownRef);
|
||||
@@ -65,6 +83,12 @@ const dropdownPosition = computed(() => {
|
||||
|
||||
const updateSelected = newValue => {
|
||||
selected.value = newValue;
|
||||
searchQuery.value = '';
|
||||
};
|
||||
|
||||
const handleTriggerClick = toggle => {
|
||||
searchQuery.value = '';
|
||||
toggle();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -81,7 +105,7 @@ const updateSelected = newValue => {
|
||||
:icon="iconToRender"
|
||||
:trailing-icon="selectedOption.icon ? false : true"
|
||||
:label="label || (hideLabel ? null : selectedOption.label)"
|
||||
@click="toggle"
|
||||
@click="handleTriggerClick(toggle)"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
@@ -91,8 +115,17 @@ const updateSelected = newValue => {
|
||||
:class="dropdownPosition"
|
||||
strong
|
||||
>
|
||||
<div v-if="searchable" class="relative">
|
||||
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
v-focus
|
||||
class="w-full p-1.5 pl-8 rounded-lg text-n-slate-11 bg-n-alpha-1"
|
||||
:placeholder="t('FILTER.SEARCH_PLACEHOLDER')"
|
||||
/>
|
||||
</div>
|
||||
<DropdownSection class="[&>ul]:max-h-72">
|
||||
<template v-for="option in options" :key="option.value">
|
||||
<template v-for="option in filteredOptions" :key="option.value">
|
||||
<li
|
||||
v-if="option.disabled"
|
||||
class="px-2 py-1.5 text-xs font-medium text-n-slate-10 select-none"
|
||||
@@ -106,6 +139,12 @@ const updateSelected = newValue => {
|
||||
@click="updateSelected(option.value)"
|
||||
/>
|
||||
</template>
|
||||
<li
|
||||
v-if="searchable && !filteredOptions.length"
|
||||
class="px-2 py-1.5 text-sm text-n-slate-10 select-none"
|
||||
>
|
||||
{{ t('FILTER.NO_RESULTS') }}
|
||||
</li>
|
||||
</DropdownSection>
|
||||
</DropdownBody>
|
||||
</DropdownContainer>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildAttributesFilterTypes,
|
||||
CONVERSATION_ATTRIBUTES,
|
||||
} from './helper/filterHelper';
|
||||
import { groupFilterTypes } from './helper/filterAttributeIcons';
|
||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
||||
|
||||
/**
|
||||
@@ -287,5 +288,10 @@ export function useConversationFilterContext() {
|
||||
...customFilterTypes.value,
|
||||
]);
|
||||
|
||||
return { filterTypes };
|
||||
// Same attributes, but grouped into sections with leading icons for the attribute picker.
|
||||
const attributeFilterTypes = computed(() =>
|
||||
groupFilterTypes(filterTypes.value, t)
|
||||
);
|
||||
|
||||
return { filterTypes, attributeFilterTypes };
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"OR": "OR"
|
||||
},
|
||||
"INPUT_PLACEHOLDER": "Enter value",
|
||||
"SEARCH_PLACEHOLDER": "Search…",
|
||||
"NO_RESULTS": "No matches",
|
||||
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
|
||||
"CONTACT_FALLBACK": "Contact #{id}",
|
||||
"OPERATOR_LABELS": {
|
||||
|
||||
+6
-1
@@ -3,6 +3,7 @@ import { ref, computed, h, useTemplateRef, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useOperators } from 'dashboard/components-next/filter/operators';
|
||||
import { getAttributeIcon } from 'dashboard/components-next/filter/helper/filterAttributeIcons';
|
||||
import ConditionRow from 'dashboard/components-next/filter/ConditionRow.vue';
|
||||
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -137,6 +138,7 @@ const filterTypes = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const attributeModel = attr.customAttributeType || 'standard';
|
||||
return {
|
||||
attributeKey: attr.key,
|
||||
value: attr.key,
|
||||
@@ -146,7 +148,8 @@ const filterTypes = computed(() => {
|
||||
options,
|
||||
filterOperators,
|
||||
dataType: 'text',
|
||||
attributeModel: attr.customAttributeType || 'standard',
|
||||
attributeModel,
|
||||
icon: getAttributeIcon({ attributeKey: attr.key, attributeModel }),
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -315,6 +318,7 @@ defineExpose({ open, close });
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
searchable-attributes
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
<ConditionRow
|
||||
@@ -328,6 +332,7 @@ defineExpose({ open, close });
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
show-query-operator
|
||||
searchable-attributes
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user