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 > +
+ + +
-