feat: add search to filter dropdowns and group attributes (#15348)
This commit is contained in:
@@ -123,7 +123,6 @@ defineExpose({ validate });
|
||||
v-model:values="child.values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
searchable-attributes
|
||||
@remove="removeChild(index)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)];
|
||||
};
|
||||
@@ -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'
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user