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:
Sivin Varghese
2024-12-02 16:22:05 +05:30
committed by GitHub
parent e95680e800
commit 0ab7accd3f
19 changed files with 313 additions and 12 deletions

View File

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

View File

@@ -176,7 +176,6 @@ const closeAdvanceFiltersModal = () => {
};
const clearFilters = async () => {
await store.dispatch('contacts/clearContactFilters');
emit('clearFilters');
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -66,6 +66,7 @@ onMounted(() => {
icon="i-lucide-x"
size="sm"
variant="ghost"
color="slate"
class="hover:text-n-slate-11"
@click="emit('close')"
/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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('_', ' ') ?? '';

View File

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

View File

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

View File

@@ -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": {

View File

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