diff --git a/app/javascript/dashboard/components-next/filter/ConditionRow.vue b/app/javascript/dashboard/components-next/filter/ConditionRow.vue
index 30709babd..e28c744e0 100644
--- a/app/javascript/dashboard/components-next/filter/ConditionRow.vue
+++ b/app/javascript/dashboard/components-next/filter/ConditionRow.vue
@@ -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"
/>
diff --git a/app/javascript/dashboard/components-next/filter/ConversationFilter.vue b/app/javascript/dashboard/components-next/filter/ConversationFilter.vue
index 79897d944..303171025 100644
--- a/app/javascript/dashboard/components-next/filter/ConversationFilter.vue
+++ b/app/javascript/dashboard/components-next/filter/ConversationFilter.vue
@@ -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)"
/>
diff --git a/app/javascript/dashboard/components-next/filter/contactProvider.js b/app/javascript/dashboard/components-next/filter/contactProvider.js
index 79933c138..a5cbbad79 100644
--- a/app/javascript/dashboard/components-next/filter/contactProvider.js
+++ b/app/javascript/dashboard/components-next/filter/contactProvider.js
@@ -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 };
}
diff --git a/app/javascript/dashboard/components-next/filter/helper/filterAttributeIcons.js b/app/javascript/dashboard/components-next/filter/helper/filterAttributeIcons.js
new file mode 100644
index 000000000..87421e57f
--- /dev/null
+++ b/app/javascript/dashboard/components-next/filter/helper/filterAttributeIcons.js
@@ -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)];
+};
diff --git a/app/javascript/dashboard/components-next/filter/helper/filterHelper.js b/app/javascript/dashboard/components-next/filter/helper/filterHelper.js
index 5e3e9b74a..8ddb6a09a 100644
--- a/app/javascript/dashboard/components-next/filter/helper/filterHelper.js
+++ b/app/javascript/dashboard/components-next/filter/helper/filterHelper.js
@@ -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'
diff --git a/app/javascript/dashboard/components-next/filter/helper/filterHelper.spec.js b/app/javascript/dashboard/components-next/filter/helper/filterHelper.spec.js
index 6e7a58354..ab527c88f 100644
--- a/app/javascript/dashboard/components-next/filter/helper/filterHelper.spec.js
+++ b/app/javascript/dashboard/components-next/filter/helper/filterHelper.spec.js
@@ -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' },
diff --git a/app/javascript/dashboard/components-next/filter/inputs/FilterSelect.vue b/app/javascript/dashboard/components-next/filter/inputs/FilterSelect.vue
index 58db5ecef..b64400e4f 100644
--- a/app/javascript/dashboard/components-next/filter/inputs/FilterSelect.vue
+++ b/app/javascript/dashboard/components-next/filter/inputs/FilterSelect.vue
@@ -1,5 +1,6 @@
@@ -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)"
/>
@@ -91,8 +115,17 @@ const updateSelected = newValue => {
:class="dropdownPosition"
strong
>
+
+
+
+
-
+
{
@click="updateSelected(option.value)"
/>
+
+ {{ t('FILTER.NO_RESULTS') }}
+
diff --git a/app/javascript/dashboard/components-next/filter/provider.js b/app/javascript/dashboard/components-next/filter/provider.js
index ede25c505..ccac2015f 100644
--- a/app/javascript/dashboard/components-next/filter/provider.js
+++ b/app/javascript/dashboard/components-next/filter/provider.js
@@ -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 };
}
diff --git a/app/javascript/dashboard/i18n/locale/en/advancedFilters.json b/app/javascript/dashboard/i18n/locale/en/advancedFilters.json
index 89143c62a..ce72a1b13 100644
--- a/app/javascript/dashboard/i18n/locale/en/advancedFilters.json
+++ b/app/javascript/dashboard/i18n/locale/en/advancedFilters.json
@@ -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": {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue b/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue
index d78c40749..4b0d4a915 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue
@@ -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)"
/>