Compare commits

...
Author SHA1 Message Date
PranavandClaude Opus 4.8 abfd335f57 feat(filter): richer attribute picker with icons, groups and search
Add a leading icon and grouped, searchable sections to the attribute
picker used by conversation filters, contact filters/segments and
automation rule conditions. FilterSelect gains a `searchable` option
(type-ahead + empty state) and a shared helper supplies per-attribute
icons and section headers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:25:01 -07:00
11 changed files with 178 additions and 11 deletions
@@ -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": {
@@ -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>