feat: Contact filter preview (#10516)
# Pull Request Template ## Description **Screenshots** <img width="986" alt="image" src="https://github.com/user-attachments/assets/8df44237-ec51-45d3-aed3-518cded42f5d"> <img width="986" alt="image" src="https://github.com/user-attachments/assets/2213ce2e-2461-41f0-a05a-0f955a4d7e3a"> **Story** <img width="992" alt="image" src="https://github.com/user-attachments/assets/f8e25fe2-11e8-4b9b-8d0b-357f9b7b6e39">
This commit is contained in:
@@ -54,9 +54,9 @@ const emit = defineEmits([
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="sticky top-0 z-10 px-6 xl:px-0">
|
||||
<header class="sticky top-0 z-10">
|
||||
<div
|
||||
class="flex items-center justify-between w-full h-20 gap-2 mx-auto max-w-[960px]"
|
||||
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[960px]"
|
||||
>
|
||||
<span class="text-xl font-medium truncate text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
|
||||
@@ -176,7 +176,6 @@ const closeAdvanceFiltersModal = () => {
|
||||
};
|
||||
|
||||
const clearFilters = async () => {
|
||||
await store.dispatch('contacts/clearContactFilters');
|
||||
emit('clearFilters');
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import ActiveFilterPreview from 'dashboard/components-next/filter/ActiveFilterPreview.vue';
|
||||
|
||||
const emit = defineEmits(['clearFilters']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const appliedFilters = useMapGetter('contacts/getAppliedContactFiltersV4');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ActiveFilterPreview
|
||||
:applied-filters="appliedFilters"
|
||||
:max-visible-filters="2"
|
||||
:more-filters-label="
|
||||
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.MORE_FILTERS', {
|
||||
count: appliedFilters.length - 2,
|
||||
})
|
||||
"
|
||||
:clear-button-label="
|
||||
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
|
||||
"
|
||||
class="max-w-[960px] px-6"
|
||||
@clear-filters="emit('clearFilters')"
|
||||
/>
|
||||
</template>
|
||||
@@ -3,6 +3,7 @@ import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue';
|
||||
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
|
||||
defineProps({
|
||||
@@ -90,8 +91,12 @@ const updateCurrentPage = page => {
|
||||
@apply-filter="emit('applyFilter', $event)"
|
||||
@clear-filters="emit('clearFilters')"
|
||||
/>
|
||||
<main class="flex-1 px-6 overflow-y-auto xl:px-px">
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<div class="w-full mx-auto max-w-[960px]">
|
||||
<ContactsActiveFiltersPreview
|
||||
v-if="hasAppliedFilters && isNotSegmentView"
|
||||
@clear-filters="emit('clearFilters')"
|
||||
/>
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -58,7 +58,7 @@ const toggleExpanded = id => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-6">
|
||||
<div class="flex flex-col gap-4 px-6 pt-4 pb-6">
|
||||
<ContactsCard
|
||||
v-for="contact in contacts"
|
||||
:id="contact.id"
|
||||
|
||||
@@ -76,6 +76,7 @@ const togglePortalSwitcher = () => {
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3"
|
||||
@click="togglePortalSwitcher"
|
||||
|
||||
@@ -182,6 +182,7 @@ onMounted(() => {
|
||||
<OnClickOutside @trigger="openAgentsList = false">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
class="!px-0 font-normal hover:!bg-transparent"
|
||||
text-variant="info"
|
||||
@click="openAgentsList = !openAgentsList"
|
||||
@@ -214,6 +215,7 @@ onMounted(() => {
|
||||
"
|
||||
:icon="!selectedCategory?.icon ? 'i-lucide-shapes' : ''"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
class="!px-2 font-normal hover:!bg-transparent"
|
||||
@click="openCategoryList = !openCategoryList"
|
||||
>
|
||||
@@ -244,6 +246,7 @@ onMounted(() => {
|
||||
"
|
||||
icon="i-lucide-plus"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
:disabled="isNewArticle"
|
||||
class="!px-2 font-normal hover:!bg-transparent hover:!text-n-slate-11"
|
||||
@click="openProperties = !openProperties"
|
||||
|
||||
@@ -66,6 +66,7 @@ onMounted(() => {
|
||||
icon="i-lucide-x"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
class="hover:text-n-slate-11"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
|
||||
@@ -108,6 +108,7 @@ const redirectToPortalHomePage = () => {
|
||||
<Button
|
||||
icon="i-lucide-arrow-up-right"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
icon-lib="lucide"
|
||||
size="sm"
|
||||
class="!w-6 !h-6 hover:bg-n-slate-2 text-n-slate-11 !p-0.5 rounded-md"
|
||||
@@ -133,6 +134,7 @@ const redirectToPortalHomePage = () => {
|
||||
:key="index"
|
||||
:label="portal.name"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
trailing-icon
|
||||
:icon="isPortalActive(portal) ? 'i-lucide-check' : ''"
|
||||
class="!justify-end !px-2 !py-2 hover:!bg-n-alpha-2 [&>.i-lucide-check]:text-n-teal-10 h-9"
|
||||
|
||||
@@ -114,8 +114,13 @@ const SIZES = ['default', 'sm', 'lg'];
|
||||
<!-- Ghost & Link Variants -->
|
||||
<Variant title="Ghost & Link Variants">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<Button label="Ghost Button" variant="ghost" />
|
||||
<Button label="Ghost with Icon" variant="ghost" icon="i-lucide-plus" />
|
||||
<Button label="Ghost Button" variant="ghost" color="slate" />
|
||||
<Button
|
||||
label="Ghost with Icon"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
icon="i-lucide-plus"
|
||||
/>
|
||||
<Button label="Link Button" variant="link" />
|
||||
<Button label="Link with Icon" variant="link" icon="i-lucide-plus" />
|
||||
</div>
|
||||
|
||||
@@ -88,6 +88,7 @@ const STYLE_CONFIG = {
|
||||
faded:
|
||||
'bg-n-brand/10 text-n-blue-text hover:bg-n-brand/20 outline-transparent',
|
||||
outline: 'text-n-blue-text outline-n-blue-border',
|
||||
ghost: 'text-n-blue-text hover:bg-n-alpha-2 outline-transparent',
|
||||
link: 'text-n-blue-text hover:underline outline-transparent',
|
||||
},
|
||||
ruby: {
|
||||
@@ -95,6 +96,7 @@ const STYLE_CONFIG = {
|
||||
faded:
|
||||
'bg-n-ruby-9/10 text-n-ruby-11 hover:bg-n-ruby-9/20 outline-transparent',
|
||||
outline: 'text-n-ruby-11 hover:bg-n-ruby-9/10 outline-n-ruby-8',
|
||||
ghost: 'text-n-ruby-11 hover:bg-n-alpha-2 outline-transparent',
|
||||
link: 'text-n-ruby-9 hover:underline outline-transparent',
|
||||
},
|
||||
amber: {
|
||||
@@ -103,6 +105,7 @@ const STYLE_CONFIG = {
|
||||
'bg-n-amber-9/10 text-n-slate-12 hover:bg-n-amber-9/20 outline-transparent',
|
||||
outline: 'text-n-amber-11 hover:bg-n-amber-9/10 outline-n-amber-9',
|
||||
link: 'text-n-amber-9 hover:underline outline-transparent',
|
||||
ghost: 'text-n-amber-9 hover:bg-n-alpha-2 outline-transparent',
|
||||
},
|
||||
slate: {
|
||||
solid:
|
||||
@@ -111,6 +114,7 @@ const STYLE_CONFIG = {
|
||||
'bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20 outline-transparent',
|
||||
outline: 'text-n-slate-11 outline-n-strong hover:bg-n-slate-9/10',
|
||||
link: 'text-n-slate-11 hover:text-n-slate-12 hover:underline outline-transparent',
|
||||
ghost: 'text-n-slate-12 hover:bg-n-alpha-2 outline-transparent',
|
||||
},
|
||||
teal: {
|
||||
solid: 'bg-n-teal-9 text-white hover:bg-n-teal-10 outline-transparent',
|
||||
@@ -118,6 +122,7 @@ const STYLE_CONFIG = {
|
||||
'bg-n-teal-9/10 text-n-slate-12 hover:bg-n-teal-9/20 outline-transparent',
|
||||
outline: 'text-n-teal-11 hover:bg-n-teal-9/10 outline-n-teal-9',
|
||||
link: 'text-n-teal-9 hover:underline outline-transparent',
|
||||
ghost: 'text-n-teal-9 hover:bg-n-alpha-2 outline-transparent',
|
||||
},
|
||||
},
|
||||
sizes: {
|
||||
@@ -151,7 +156,7 @@ const STYLE_CONFIG = {
|
||||
|
||||
const variantClasses = computed(() => {
|
||||
const variantMap = {
|
||||
ghost: 'text-n-slate-12 hover:bg-n-alpha-2 outline-transparent',
|
||||
ghost: `${STYLE_CONFIG.colors[computedColor.value].ghost}`,
|
||||
link: `${STYLE_CONFIG.colors[computedColor.value].link} p-0 font-medium underline-offset-4`,
|
||||
outline: STYLE_CONFIG.colors[computedColor.value].outline,
|
||||
faded: STYLE_CONFIG.colors[computedColor.value].faded,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { sampleActiveFilters } from './fixtures/filterTypes';
|
||||
import ActiveFilterPreview from './ActiveFilterPreview.vue';
|
||||
|
||||
const appliedFilters = ref([...sampleActiveFilters]);
|
||||
const maxVisibleFilters = ref(2);
|
||||
|
||||
const moreFiltersLabel = computed(
|
||||
() => `${appliedFilters.value.length - maxVisibleFilters.value} more filters`
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Filters/ActiveFilterPreview"
|
||||
:layout="{ type: 'grid', width: '960px' }"
|
||||
>
|
||||
<Variant title="Default (2 visible filters)">
|
||||
<ActiveFilterPreview
|
||||
:applied-filters="appliedFilters.slice(0, 2)"
|
||||
:max-visible-filters="2"
|
||||
clear-button-label="Clear Filters"
|
||||
more-filters-label=""
|
||||
@clear-filters="() => {}"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Multiple Filters (with more indicator)">
|
||||
<ActiveFilterPreview
|
||||
:applied-filters="appliedFilters"
|
||||
:max-visible-filters="maxVisibleFilters"
|
||||
clear-button-label="Clear Filters"
|
||||
:more-filters-label="moreFiltersLabel"
|
||||
@clear-filters="() => {}"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Show All Filters">
|
||||
<ActiveFilterPreview
|
||||
:applied-filters="appliedFilters"
|
||||
:max-visible-filters="10"
|
||||
clear-button-label="Clear Filters"
|
||||
@clear-filters="() => {}"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Single Filter">
|
||||
<ActiveFilterPreview
|
||||
:applied-filters="[appliedFilters[0]]"
|
||||
:max-visible-filters="maxVisibleFilters"
|
||||
clear-button-label="Clear Filters"
|
||||
@clear-filters="() => {}"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With Object Values">
|
||||
<ActiveFilterPreview
|
||||
:applied-filters="[appliedFilters[6]]"
|
||||
:max-visible-filters="maxVisibleFilters"
|
||||
clear-button-label="Clear Filters"
|
||||
@clear-filters="() => {}"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup>
|
||||
import { replaceUnderscoreWithSpace } from './helper/filterHelper.js';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
appliedFilters: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
maxVisibleFilters: {
|
||||
type: Number,
|
||||
default: 2,
|
||||
},
|
||||
clearButtonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
moreFiltersLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['clearFilters']);
|
||||
|
||||
const shouldCapitalizeFirstLetter = key => {
|
||||
const lowercaseKeys = ['email'];
|
||||
return !lowercaseKeys.includes(key);
|
||||
};
|
||||
|
||||
const formatOperatorLabel = operator => {
|
||||
const operators = {
|
||||
equal_to: 'is',
|
||||
not_equal_to: 'is not',
|
||||
contains: 'contains',
|
||||
does_not_contain: 'does not contain',
|
||||
is_present: 'is present',
|
||||
is_not_present: 'is not present',
|
||||
is_greater_than: 'is greater than',
|
||||
is_less_than: 'is less than',
|
||||
days_before: 'days before',
|
||||
};
|
||||
return operators[operator] || replaceUnderscoreWithSpace(operator);
|
||||
};
|
||||
|
||||
const formatFilterValue = value => {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'object' && value.name) {
|
||||
return value.name;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center w-full gap-2 mx-auto">
|
||||
<template
|
||||
v-for="(filter, index) in appliedFilters"
|
||||
:key="filter.attributeKey"
|
||||
>
|
||||
<div
|
||||
v-if="index < maxVisibleFilters"
|
||||
class="inline-flex items-center gap-2 h-7"
|
||||
>
|
||||
<div
|
||||
class="flex items-center h-full min-w-0 gap-1 px-2 py-1 text-xs border rounded-lg max-w-72 border-n-weak"
|
||||
>
|
||||
<span
|
||||
class="lowercase whitespace-nowrap first-letter:capitalize text-n-slate-12"
|
||||
>
|
||||
{{ replaceUnderscoreWithSpace(filter.attributeKey) }}
|
||||
</span>
|
||||
<span class="px-1 text-xs text-n-slate-10 whitespace-nowrap">
|
||||
{{ formatOperatorLabel(filter.filterOperator) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="filter.values"
|
||||
class="lowercase truncate text-n-slate-12"
|
||||
:class="{
|
||||
'first-letter:capitalize': shouldCapitalizeFirstLetter(
|
||||
filter.attributeKey
|
||||
),
|
||||
}"
|
||||
>
|
||||
{{ formatFilterValue(filter.values) }}
|
||||
</span>
|
||||
</div>
|
||||
<template
|
||||
v-if="
|
||||
index < maxVisibleFilters - 1 && index < appliedFilters.length - 1
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="content-center h-full px-1 text-xs font-medium uppercase rounded-lg text-n-slate-10"
|
||||
>
|
||||
{{ filter.queryOperator }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="appliedFilters.length > maxVisibleFilters"
|
||||
class="inline-flex items-center content-center px-1 text-xs rounded-lg text-n-slate-10 h-7"
|
||||
>
|
||||
{{ moreFiltersLabel }}
|
||||
</div>
|
||||
<div class="w-px h-3 rounded-lg bg-n-strong" />
|
||||
<Button
|
||||
:label="clearButtonLabel"
|
||||
size="xs"
|
||||
class="!px-1"
|
||||
variant="ghost"
|
||||
@click="emit('clearFilters')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -556,3 +556,48 @@ export const filterTypes = [
|
||||
attributeModel: 'customAttributes',
|
||||
},
|
||||
];
|
||||
|
||||
export const sampleActiveFilters = [
|
||||
{
|
||||
attributeKey: 'name',
|
||||
filterOperator: 'contains',
|
||||
values: 'John',
|
||||
queryOperator: 'and',
|
||||
},
|
||||
{
|
||||
attributeKey: 'email',
|
||||
filterOperator: 'does_not_contain',
|
||||
values: 'test@chatwoot.com',
|
||||
queryOperator: 'or',
|
||||
},
|
||||
{
|
||||
attributeKey: 'phone_number',
|
||||
filterOperator: 'is_present',
|
||||
values: '+928383822',
|
||||
queryOperator: 'and',
|
||||
},
|
||||
{
|
||||
attributeKey: 'created_at',
|
||||
filterOperator: 'is_greater_than',
|
||||
values: '2024-01-01',
|
||||
queryOperator: 'and',
|
||||
},
|
||||
{
|
||||
attributeKey: 'last_activity',
|
||||
filterOperator: 'days_before',
|
||||
values: '30',
|
||||
queryOperator: 'and',
|
||||
},
|
||||
{
|
||||
attributeKey: 'date_of_birth',
|
||||
filterOperator: 'is_not_present',
|
||||
values: '',
|
||||
queryOperator: 'and',
|
||||
},
|
||||
{
|
||||
attributeKey: 'country',
|
||||
filterOperator: 'not_equal_to',
|
||||
values: { id: 1, name: 'India' },
|
||||
queryOperator: 'and',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -39,3 +39,11 @@ export const buildAttributesFilterTypes = (attributes, getOperatorTypes) => {
|
||||
attributeModel: 'customAttributes',
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces underscores with spaces in a string
|
||||
* @param {string} text - The input string
|
||||
* @returns {string} The string with underscores replaced by spaces
|
||||
*/
|
||||
export const replaceUnderscoreWithSpace = text =>
|
||||
text?.replaceAll('_', ' ') ?? '';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
getCustomAttributeInputType,
|
||||
buildAttributesFilterTypes,
|
||||
replaceUnderscoreWithSpace,
|
||||
} from './filterHelper';
|
||||
|
||||
describe('filterHelper', () => {
|
||||
@@ -123,4 +124,14 @@ describe('filterHelper', () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceUnderscoreWithSpace', () => {
|
||||
it('replaces underscores with spaces', () => {
|
||||
expect(replaceUnderscoreWithSpace('test_key')).toBe('test key');
|
||||
});
|
||||
|
||||
it('returns empty string if input is null', () => {
|
||||
expect(replaceUnderscoreWithSpace(null)).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,6 +75,7 @@ const pageInfo = computed(() => {
|
||||
icon="i-lucide-chevrons-left"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="slate"
|
||||
class="!w-8 !h-6"
|
||||
:disabled="isFirstPage"
|
||||
@click="changePage(1)"
|
||||
@@ -82,6 +83,7 @@ const pageInfo = computed(() => {
|
||||
<Button
|
||||
icon="i-lucide-chevron-left"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="!w-8 !h-6"
|
||||
:disabled="isFirstPage"
|
||||
@@ -96,6 +98,7 @@ const pageInfo = computed(() => {
|
||||
<Button
|
||||
icon="i-lucide-chevron-right"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="!w-8 !h-6"
|
||||
:disabled="isLastPage"
|
||||
@@ -104,6 +107,7 @@ const pageInfo = computed(() => {
|
||||
<Button
|
||||
icon="i-lucide-chevrons-right"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="!w-8 !h-6"
|
||||
:disabled="isLastPage"
|
||||
|
||||
@@ -491,10 +491,9 @@
|
||||
"LABEL": "Segment name",
|
||||
"INPUT_PLACEHOLDER": "Enter the name of the segment"
|
||||
},
|
||||
"GROUPS": {
|
||||
"STANDARD_FILTERS": "Standard filters",
|
||||
"ADDITIONAL_FILTERS": "Additional filters",
|
||||
"CUSTOM_ATTRIBUTES": "Custom attributes"
|
||||
"ACTIVE_FILTERS": {
|
||||
"MORE_FILTERS": "+ {count} more filters",
|
||||
"CLEAR_FILTERS": "Clear filters"
|
||||
}
|
||||
},
|
||||
"CARD": {
|
||||
|
||||
@@ -118,6 +118,7 @@ const getCommonFetchParams = (page = 1) => ({
|
||||
});
|
||||
|
||||
const fetchContacts = async (page = 1) => {
|
||||
await store.dispatch('contacts/clearContactFilters');
|
||||
await store.dispatch('contacts/get', getCommonFetchParams(page));
|
||||
updatePageParam(page);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user