feat: add search to filter dropdowns and group attributes (#15348)

This commit is contained in:
Sivin Varghese
2026-08-11 18:48:55 +05:30
committed by GitHub
parent e6f21f7c99
commit 7cba8a01bd
13 changed files with 257 additions and 67 deletions

View File

@@ -123,7 +123,6 @@ defineExpose({ validate });
v-model:values="child.values"
:filter-types="filterTypes"
:show-query-operator="false"
searchable-attributes
@remove="removeChild(index)"
/>
</template>

View File

@@ -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"
/>
<FilterSelect

View File

@@ -23,7 +23,7 @@ const emit = defineEmits([
'close',
'clearFilters',
]);
const { filterTypes } = useContactFilterContext();
const { attributeFilterTypes } = useContactFilterContext();
const filters = defineModel({
type: Array,
@@ -127,7 +127,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)"
/>
@@ -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)"
/>
</template>

View File

@@ -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)"
/>
</template>

View File

@@ -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 };
}

View File

@@ -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)];
};

View File

@@ -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'

View File

@@ -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' },

View File

@@ -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();
};
</script>
@@ -97,7 +98,7 @@ const handleTriggerClick = toggle => {
<template>
<DropdownContainer>
<template #trigger="{ toggle }">
<slot name="trigger" :toggle="toggle">
<slot name="trigger" :toggle="() => toggleDropdown(toggle)">
<Button
ref="triggerRef"
type="button"
@@ -107,7 +108,7 @@ const handleTriggerClick = toggle => {
:icon="iconToRender"
:trailing-icon="selectedOption.icon ? false : true"
:label="label || (hideLabel ? null : selectedOption.label)"
@click="handleTriggerClick(toggle)"
@click="toggleDropdown(toggle)"
/>
</slot>
</template>
@@ -117,17 +118,17 @@ const handleTriggerClick = toggle => {
:class="dropdownPosition"
strong
>
<div v-if="searchable" class="relative">
<div v-if="showSearch" class="relative">
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
<input
v-model="searchQuery"
v-model="searchTerm"
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')"
:placeholder="t('COMBOBOX.SEARCH_PLACEHOLDER')"
/>
</div>
<DropdownSection class="[&>ul]:max-h-72">
<template v-for="option in filteredOptions" :key="option.value">
<template v-for="option in searchResults" :key="option.value">
<li
v-if="option.disabled"
class="px-2 py-1.5 text-xs font-medium text-n-slate-10 select-none"
@@ -141,12 +142,13 @@ const handleTriggerClick = toggle => {
@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>
<DropdownItem v-if="!searchResults.length" disabled>
{{
searchTerm
? t('COMBOBOX.EMPTY_SEARCH_RESULTS', { searchTerm })
: t('COMBOBOX.EMPTY_STATE')
}}
</DropdownItem>
</DropdownSection>
</DropdownBody>
</DropdownContainer>

View File

@@ -11,7 +11,23 @@ const options = [
{ name: 'All', id: 'all' },
];
const labelOptions = [
'billing',
'bug',
'churn-risk',
'documentation',
'enterprise',
'feature-request',
'follow-up',
'onboarding',
'refund',
'security',
'spam',
'vip',
].map(id => ({ id, name: id }));
const selected = ref([]);
const selectedLabels = ref([]);
</script>
<template>
@@ -19,8 +35,15 @@ const selected = ref([]);
title="Components/Filters/Multiselect Input"
:layout="{ type: 'grid', width: '600px' }"
>
<div class="min-h-[400px]">
<MultiSelect v-model="selected" :options="options" />
</div>
<Variant title="Short List">
<div class="min-h-[400px]">
<MultiSelect v-model="selected" :options="options" />
</div>
</Variant>
<Variant title="Long List (with search)">
<div class="min-h-[400px]">
<MultiSelect v-model="selectedLabels" :options="labelOptions" />
</div>
</Variant>
</Story>
</template>

View File

@@ -1,6 +1,8 @@
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { picoSearch } from '@scmmishra/pico-search';
import { DROPDOWN_SEARCH_THRESHOLD } from '../helper/filterHelper';
import Icon from 'next/icon/Icon.vue';
import Button from 'next/button/Button.vue';
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
@@ -23,12 +25,25 @@ const { options, maxChips, dropdownMaxHeight } = defineProps({
},
});
const vFocus = { mounted: el => el.focus() };
const { t } = useI18n();
const selected = defineModel({
type: [Array, String],
required: true,
});
const searchTerm = ref('');
const showSearch = computed(() => 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 options;
return picoSearch(options, query, ['name']);
});
const hasItems = computed(() => {
if (!selected.value) return false;
if (!Array.isArray(selected.value)) return false;
@@ -67,6 +82,11 @@ const remainingTooltip = computed(() => {
return remainingItems.value.map(item => item.name).join(', ');
});
const toggleDropdown = toggle => {
searchTerm.value = '';
toggle();
};
const toggleOption = option => {
// Ensure that the `icon` prop is not included, icon is a VNode which has circular references
// This causes an error when creating a clone using JSON.parse(JSON.stringify())
@@ -96,7 +116,7 @@ const toggleOption = option => {
<button
v-if="hasItems"
class="bg-n-alpha-2 py-2 rounded-lg h-8 flex items-center px-0"
@click="toggle"
@click="toggleDropdown(toggle)"
>
<div
v-for="item in selectedVisibleItems"
@@ -119,7 +139,7 @@ const toggleOption = option => {
<Icon icon="i-lucide-plus" />
</div>
</button>
<Button v-else sm slate faded @click="toggle">
<Button v-else sm slate faded @click="toggleDropdown(toggle)">
<template #icon>
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
</template>
@@ -127,23 +147,44 @@ const toggleOption = option => {
</Button>
</template>
<DropdownBody class="top-0 min-w-48 z-50" strong>
<div v-if="showSearch" class="relative">
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
<input
v-model="searchTerm"
v-focus
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
:placeholder="t('COMBOBOX.SEARCH_PLACEHOLDER')"
/>
</div>
<DropdownSection :height="dropdownMaxHeight">
<DropdownItem
v-for="option in options"
:key="option.id"
:icon="option.icon"
preserve-open
@click="toggleOption(option)"
>
<template #label>
{{ option.name }}
<Icon
v-if="selectedIds.includes(option.id)"
icon="i-lucide-check"
class="bg-n-blue-text pointer-events-none"
/>
</template>
</DropdownItem>
<template v-if="searchResults.length">
<DropdownItem
v-for="option in searchResults"
:key="option.id"
:icon="option.icon"
preserve-open
@click="toggleOption(option)"
>
<template #label>
{{ option.name }}
<Icon
v-if="selectedIds.includes(option.id)"
icon="i-lucide-check"
class="bg-n-blue-text pointer-events-none"
/>
</template>
</DropdownItem>
</template>
<template v-else-if="searchTerm">
<DropdownItem disabled>
{{ t('COMBOBOX.EMPTY_SEARCH_RESULTS', { searchTerm }) }}
</DropdownItem>
</template>
<template v-else>
<DropdownItem disabled>
{{ t('COMBOBOX.EMPTY_STATE') }}
</DropdownItem>
</template>
</DropdownSection>
</DropdownBody>
</DropdownContainer>

View File

@@ -4,10 +4,12 @@ import { useOperators } from './operators';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useChannelIcon } from 'next/icon/provider';
import { createContactSearcher } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import {
buildAttributesFilterTypes,
CONVERSATION_ATTRIBUTES,
} from './helper/filterHelper';
import { groupFilterTypes } from './helper/filterAttributeIcons';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
/**
@@ -178,7 +180,17 @@ export function useConversationFilterContext() {
attributeName: t('FILTER.ATTRIBUTES.TEAM_NAME'),
label: t('FILTER.ATTRIBUTES.TEAM_NAME'),
inputType: 'searchSelect',
options: teams.value,
options: teams.value.map(team => ({
id: team.id,
name: team.name,
icon: team.icon
? h(EmojiIcon, {
value: team.icon,
color: team.icon_color,
class: 'size-4',
})
: undefined,
})),
dataType: 'number',
filterOperators: presenceOperators.value,
attributeModel: 'standard',
@@ -287,5 +299,10 @@ export function useConversationFilterContext() {
...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)
);
return { filterTypes, attributeFilterTypes };
}

View File

@@ -19,8 +19,6 @@
"OR": "OR"
},
"INPUT_PLACEHOLDER": "Enter value",
"SEARCH_PLACEHOLDER": "Search…",
"NO_RESULTS": "No matches",
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
"CONTACT_FALLBACK": "Contact #{id}",
"OPERATOR_LABELS": {