diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/AudienceGroup.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/AudienceGroup.vue index 9b6cf1f28..4f1f9fb0e 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/AudienceGroup.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/AudienceGroup.vue @@ -123,7 +123,6 @@ defineExpose({ validate }); v-model:values="child.values" :filter-types="filterTypes" :show-query-operator="false" - searchable-attributes @remove="removeChild(index)" /> diff --git a/app/javascript/dashboard/components-next/filter/ConditionRow.vue b/app/javascript/dashboard/components-next/filter/ConditionRow.vue index e28c744e0..30709babd 100644 --- a/app/javascript/dashboard/components-next/filter/ConditionRow.vue +++ b/app/javascript/dashboard/components-next/filter/ConditionRow.vue @@ -15,7 +15,6 @@ 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']); @@ -200,7 +199,6 @@ defineExpose({ validate, resetValidation }); v-model="attributeKey" variant="faded" :options="filterTypes" - :searchable="searchableAttributes" @update:model-value="resetModelOnAttributeKeyChange" /> @@ -140,7 +140,7 @@ const outsideClickHandler = [ v-model:query-operator="filters[index - 1].queryOperator" v-model:values="filter.values" show-query-operator - :filter-types="filterTypes" + :filter-types="attributeFilterTypes" @remove="removeFilter(index)" /> diff --git a/app/javascript/dashboard/components-next/filter/ConversationFilter.vue b/app/javascript/dashboard/components-next/filter/ConversationFilter.vue index 79897d944..44a4f5d04 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,7 +128,7 @@ 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" @remove="removeFilter(index)" /> @@ -141,7 +141,7 @@ const outsideClickHandler = [ v-model:query-operator="filters[index - 1].queryOperator" v-model:values="filter.values" show-query-operator - :filter-types="filterTypes" + :filter-types="attributeFilterTypes" @remove="removeFilter(index)" /> diff --git a/app/javascript/dashboard/components-next/filter/contactProvider.js b/app/javascript/dashboard/components-next/filter/contactProvider.js index 21b5a9b14..95a303fa6 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'; /** @@ -202,5 +203,10 @@ export function useContactFilterContext() { ...customFilterTypes.value, ]); - return { filterTypes }; + // The same attributes, grouped into sections with a leading icon, for the attribute picker. + const attributeFilterTypes = computed(() => + groupFilterTypes(filterTypes.value, t, 'CONTACTS_FILTER') + ); + + 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..87ba25680 --- /dev/null +++ b/app/javascript/dashboard/components-next/filter/helper/filterAttributeIcons.js @@ -0,0 +1,97 @@ +/** + * Leading icons and grouped section headers for the attribute picker rendered by FilterSelect, + * so the conversation and contact filters read like the Captain audience picker. + */ + +// Icon per known attribute key, across the conversation and 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', + 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'; + +const getAttributeIcon = type => + (type.attributeModel === 'customAttributes' + ? CUSTOM_TYPE_ICONS[type.attributeDisplayType] + : ATTRIBUTE_ICONS[type.attributeKey]) || DEFAULT_ICON; + +// The order groups appear in, keyed by attributeModel. Labels resolve against the caller's i18n +// namespace so the conversation and contact filters can name their own sections. +const GROUPS = [ + { model: 'standard', labelKey: 'STANDARD_FILTERS' }, + { model: 'additional', labelKey: 'ADDITIONAL_FILTERS' }, + { model: 'customAttributes', labelKey: 'CUSTOM_ATTRIBUTES' }, +]; + +const KNOWN_MODELS = GROUPS.map(({ model }) => model); + +/** + * Attach a leading icon to each filter type and split them into 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. + * @param {string} [i18nKey] - Namespace holding the GROUPS labels. + * @returns {Object[]} Grouped list of header and icon-enriched entries. + */ +export const groupFilterTypes = (filterTypes, t, i18nKey = 'FILTER') => { + const modelOf = type => type.attributeModel || 'standard'; + const withIcon = type => ({ + ...type, + icon: type.icon || getAttributeIcon(type), + }); + + const grouped = GROUPS.flatMap(({ model, labelKey }) => { + const group = filterTypes.filter(type => modelOf(type) === model); + if (!group.length) return []; + return [ + { + value: `__group_${model}`, + label: t(`${i18nKey}.GROUPS.${labelKey}`), + disabled: true, + }, + ...group.map(withIcon), + ]; + }); + + // Append attributes with an unexpected model rather than dropping them silently. + const ungrouped = filterTypes.filter( + type => !KNOWN_MODELS.includes(modelOf(type)) + ); + + 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..e2f4db53c 100644 --- a/app/javascript/dashboard/components-next/filter/helper/filterHelper.js +++ b/app/javascript/dashboard/components-next/filter/helper/filterHelper.js @@ -1,3 +1,9 @@ +/** + * Number of options a filter dropdown can hold before it renders a search field. + * Shared so the attribute and value dropdowns never disagree on when to show one. + */ +export const DROPDOWN_SEARCH_THRESHOLD = 8; + /** * Standard attributes of the conversation model */ @@ -78,6 +84,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 411ccd1b2..000edf4e7 100644 --- a/app/javascript/dashboard/components-next/filter/inputs/FilterSelect.vue +++ b/app/javascript/dashboard/components-next/filter/inputs/FilterSelect.vue @@ -2,6 +2,8 @@ import { computed, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { useElementBounding, useWindowSize } from '@vueuse/core'; +import { picoSearch } from '@scmmishra/pico-search'; +import { DROPDOWN_SEARCH_THRESHOLD } from '../helper/filterHelper'; import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue'; import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue'; import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue'; @@ -12,9 +14,10 @@ import Icon from 'next/icon/Icon.vue'; // [{label, icon, value}] const props = defineProps({ + // Empty while an attribute the saved filter refers to no longer exists. options: { type: Array, - required: true, + default: () => [], }, hideLabel: { type: Boolean, @@ -32,12 +35,9 @@ const props = defineProps({ type: String, default: null, }, - searchable: { - type: Boolean, - default: false, - }, }); +const { t } = useI18n(); const selected = defineModel({ type: [String, Number], required: true, @@ -45,19 +45,21 @@ const selected = defineModel({ const vFocus = { mounted: el => el.focus() }; -const { t } = useI18n(); - -const searchQuery = ref(''); const triggerRef = ref(null); const dropdownRef = ref(null); +const searchTerm = ref(''); -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 showSearch = computed( + () => props.options.length > DROPDOWN_SEARCH_THRESHOLD +); + +const searchResults = computed(() => { + // picoSearch throws on a whitespace-only query, which trims down to no search terms. + const query = searchTerm.value.trim(); + if (!query) return props.options; + // Section headers are not selectable, so they are dropped once a query narrows the list. + const selectableOptions = props.options.filter(option => !option.disabled); + return picoSearch(selectableOptions, query, ['label']); }); const { top } = useElementBounding(triggerRef); @@ -65,7 +67,7 @@ const { height } = useWindowSize(); const { height: dropdownHeight } = useElementBounding(dropdownRef); const selectedOption = computed(() => { - return props.options?.find(o => o.value === selected.value) || {}; + return props.options.find(o => o.value === selected.value) || {}; }); const iconToRender = computed(() => { @@ -85,11 +87,10 @@ const dropdownPosition = computed(() => { const updateSelected = newValue => { selected.value = newValue; - searchQuery.value = ''; }; -const handleTriggerClick = toggle => { - searchQuery.value = ''; +const toggleDropdown = toggle => { + searchTerm.value = ''; toggle(); }; @@ -97,7 +98,7 @@ const handleTriggerClick = toggle => {