feat: Contact sort

This commit is contained in:
iamsivin
2024-11-08 01:27:51 +05:30
parent c5974f97cd
commit ecd8b265bc
9 changed files with 419 additions and 29 deletions
@@ -0,0 +1,51 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import ContactSortMenu from './ContactSortMenu.vue';
defineProps({
buttonLabel: {
type: String,
default: '',
},
isDetailView: {
type: Boolean,
default: false,
},
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
});
const emit = defineEmits(['filter', 'more', 'message', 'update:sort']);
</script>
<template>
<div class="flex items-center gap-2">
<Button
v-if="!isDetailView"
icon="i-lucide-list-filter"
color="slate"
variant="ghost"
@click="emit('filter')"
/>
<ContactSortMenu
v-if="!isDetailView"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
<Button
v-if="!isDetailView"
icon="i-lucide-ellipsis-vertical"
color="slate"
variant="ghost"
@click="emit('more')"
/>
<Button :label="buttonLabel" size="sm" @click="emit('message')" />
</div>
</template>
@@ -0,0 +1,128 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import SelectMenu from 'dashboard/components-next/selectmenu/SelectMenu.vue';
const props = defineProps({
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:sort']);
const { t } = useI18n();
const isMenuOpen = ref(false);
const sortMenus = [
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.NAME'),
value: 'name',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.EMAIL'),
value: 'email',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.PHONE_NUMBER'),
value: 'phone_number',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COMPANY'),
value: 'company_name',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COUNTRY'),
value: 'country',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.CITY'),
value: 'city',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.LAST_ACTIVITY'),
value: 'last_activity_at',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.CREATED_AT'),
value: 'created_at',
},
];
const orderingMenus = [
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.OPTIONS.ASCENDING'),
value: '',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.OPTIONS.DESCENDING'),
value: '-',
},
];
const activeSortLabel = computed(() => {
return (
sortMenus.find(menu => menu.value === props.activeSort)?.label || 'Sort by'
);
});
const activeOrderingLabel = computed(() => {
return (
orderingMenus.find(menu => menu.value === props.activeOrdering)?.label ||
'Order'
);
});
const handleSortChange = value => {
emit('update:sort', { sort: value, order: props.activeOrdering });
};
const handleOrderChange = value => {
emit('update:sort', { sort: props.activeSort, order: value });
};
</script>
<template>
<div class="relative">
<Button
icon="i-lucide-arrow-down-up"
color="slate"
variant="ghost"
:class="isMenuOpen ? 'bg-n-alpha-2' : ''"
@click="isMenuOpen = !isMenuOpen"
/>
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 right-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.LABEL') }}
</span>
<SelectMenu
:model-value="activeSort"
:options="sortMenus"
:label="activeSortLabel"
@update:model-value="handleSortChange"
/>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.HEADER.ACTIONS.ORDER.LABEL') }}
</span>
<SelectMenu
:model-value="activeOrdering"
:options="orderingMenus"
:label="activeOrderingLabel"
@update:model-value="handleOrderChange"
/>
</div>
</div>
</div>
</template>
@@ -2,11 +2,11 @@
import { computed, useSlots } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import ContactActions from 'dashboard/components-next/Contacts/ContactHeader/ContactActions.vue';
const props = defineProps({
headerTitle: {
@@ -41,6 +41,14 @@ const props = defineProps({
type: Object,
default: () => ({}),
},
activeSort: {
type: String,
default: '',
},
activeOrdering: {
type: String,
default: '',
},
});
const emit = defineEmits([
@@ -50,6 +58,7 @@ const emit = defineEmits([
'update:currentPage',
'goToContactsList',
'search',
'sort',
]);
const { t } = useI18n();
@@ -116,7 +125,9 @@ const updateCurrentPage = page => {
<div v-if="!isDetailView" class="flex items-center gap-2">
<Input
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
:custom-input-class="['h-8 ltr:!pl-8 rtl:!pr-8']"
:custom-input-class="[
'h-10 [&:not(.focus)]:!border-transparent bg-n-solid-1 ltr:!pl-8 rtl:!pr-8',
]"
@input="emit('search', $event.target.value)"
>
<template #prefix>
@@ -127,21 +138,16 @@ const updateCurrentPage = page => {
</template>
</Input>
</div>
<Button
v-if="!isDetailView"
icon="i-lucide-list-filter"
size="sm"
color="slate"
@click="emit('filter')"
<ContactActions
:button-label="buttonLabel"
:is-detail-view="isDetailView"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@filter="emit('filter')"
@update:sort="emit('sort', $event)"
@more="emit('more')"
@message="emit('message')"
/>
<Button
v-if="!isDetailView"
icon="i-lucide-ellipsis-vertical"
size="sm"
color="slate"
@click="emit('more')"
/>
<Button :label="buttonLabel" size="sm" @click="emit('message')" />
</div>
</div>
</div>
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, ref } from 'vue';
const props = defineProps({
modelValue: {
type: [String, Number],
@@ -44,7 +44,9 @@ const props = defineProps({
},
});
const emit = defineEmits(['update:modelValue', 'blur', 'input']);
const emit = defineEmits(['update:modelValue', 'blur', 'input', 'focus']);
const isFocused = ref(false);
const messageClass = computed(() => {
switch (props.messageType) {
@@ -70,6 +72,16 @@ const handleInput = event => {
emit('update:modelValue', event.target.value);
emit('input', event);
};
const handleFocus = event => {
emit('focus', event);
isFocused.value = true;
};
const handleBlur = event => {
emit('blur', event);
isFocused.value = false;
};
</script>
<template>
@@ -89,7 +101,10 @@ const handleInput = event => {
:class="[
customInputClass,
inputBorderClass,
{ error: messageType === 'error' },
{
error: messageType === 'error',
focus: isFocused,
},
]"
:type="type"
:placeholder="placeholder"
@@ -97,7 +112,8 @@ const handleInput = event => {
:min="['date', 'datetime-local', 'time'].includes(type) ? min : undefined"
class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 border rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out"
@input="handleInput"
@blur="emit('blur')"
@focus="handleFocus"
@blur="handleBlur"
/>
<p
v-if="message"
@@ -0,0 +1,46 @@
<script setup>
import { ref } from 'vue';
import SelectMenu from './SelectMenu.vue';
const sampleOptions = [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
{ label: 'Option 3', value: 'option3' },
{ label: 'Option 4', value: 'option4' },
];
const selectedValue = ref('option1');
</script>
<template>
<Story
title="Components/SelectMenu"
:layout="{ type: 'grid', width: '400px' }"
>
<Variant title="Default">
<div class="flex flex-col gap-4 p-4 h-[400px]">
<SelectMenu
v-model="selectedValue"
:options="sampleOptions"
:label="sampleOptions.find(opt => opt.value === selectedValue)?.label"
/>
<div class="text-sm">Selected value: {{ selectedValue }}</div>
</div>
</Variant>
<Variant title="With Many Options">
<div class="flex flex-col gap-4 p-4 h-[400px]">
<SelectMenu
v-model="selectedValue"
:options="
Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i + 1}`,
value: `value${i + 1}`,
}))
"
label="Select from many"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,69 @@
<script setup>
import { ref } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
options: {
type: Array,
required: true,
},
modelValue: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
});
const emit = defineEmits(['update:modelValue']);
const isOpen = ref(false);
const toggleMenu = () => {
isOpen.value = !isOpen.value;
};
const handleSelect = value => {
emit('update:modelValue', value);
isOpen.value = false;
};
</script>
<template>
<div
v-on-clickaway="() => (isOpen = false)"
class="relative flex flex-col gap-1 w-fit"
>
<Button
icon="i-lucide-chevron-down"
size="sm"
trailing-icon
color="slate"
variant="faded"
class="!w-fit"
:class="{ 'dark:!bg-n-alpha-2 !bg-n-slate-9/20': isOpen }"
:label="label"
@click="toggleMenu"
/>
<div
v-if="isOpen"
class="absolute ltr:left-full rtl:right-full select-none max-w-48 ltr:ml-1 rtl:mr-1 flex flex-col gap-1 bg-n-alpha-3 backdrop-blur-[100px] p-1 top-0 shadow-lg rounded-lg border border-n-weak"
>
<Button
v-for="option in options"
:key="option.value"
:label="option.label"
:icon="option.value === modelValue ? 'i-lucide-check' : ''"
size="sm"
variant="ghost"
color="slate"
trailing-icon
class="!justify-end !px-2.5 !h-7"
:class="{ '!bg-n-alpha-2': option.value === modelValue }"
@click="handleSelect(option.value)"
/>
</div>
</div>
</template>
@@ -394,6 +394,28 @@
"MESSAGE_BUTTON": "Message",
"BREADCRUMB": {
"CONTACTS": "Contacts"
},
"ACTIONS": {
"SORT_BY": {
"LABEL": "Sort by",
"OPTIONS": {
"NAME": "Name",
"EMAIL": "Email",
"PHONE_NUMBER": "Phone number",
"COMPANY": "Company",
"COUNTRY": "Country",
"CITY": "City",
"LAST_ACTIVITY": "Last activity",
"CREATED_AT": "Created at"
}
},
"ORDER": {
"LABEL": "Ordering",
"OPTIONS": {
"ASCENDING": "Ascending",
"DESCENDING": "Descending"
}
}
}
},
"CARD": {
@@ -1,13 +1,18 @@
<script setup>
import { onMounted, computed, ref } from 'vue';
import { onMounted, computed, ref, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { debounce } from '@chatwoot/utils';
import { useUISettings } from 'dashboard/composables/useUISettings';
import ContactsLayout from 'dashboard/components-next/Contacts/ContactsLayout.vue';
import ContactsList from 'dashboard/components-next/Contacts/Pages/ContactsList.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const DEFAULT_SORT_FIELD = 'last_activity_at';
const DEBOUNCE_DELAY = 300;
const store = useStore();
const { updateUISettings, uiSettings } = useUISettings();
const contacts = useMapGetter('contacts/getContactsList');
const uiFlags = useMapGetter('contacts/getUIFlags');
@@ -15,32 +20,52 @@ const meta = useMapGetter('contacts/getMeta');
const searchValue = ref('');
const parseSortSettings = (sortString = '') => {
const hasDescending = sortString.startsWith('-');
const sortField = hasDescending ? sortString.slice(1) : sortString;
return {
sort: sortField || DEFAULT_SORT_FIELD,
order: hasDescending ? '-' : '',
};
};
const { contacts_sort_by: contactSortBy = '' } = uiSettings.value ?? {};
const { sort: initialSort, order: initialOrder } =
parseSortSettings(contactSortBy);
const activeSort = ref(initialSort);
const activeOrdering = ref(initialOrder);
const isFetchingList = computed(() => uiFlags.value.isFetching);
const currentPage = computed(() => Number(meta.value?.currentPage));
const totalItems = computed(() => meta.value?.count);
const buildSortAttr = (order, sort) => `${order}${sort}`;
const fetchContacts = page => {
const sortAttr = buildSortAttr(activeOrdering.value, activeSort.value);
store.dispatch('contacts/get', {
page,
sortAttr: '-last_activity_at',
sortAttr,
});
};
const debouncedSearch = debounce(
value => {
searchValue.value = value;
const sortAttr = buildSortAttr(activeOrdering.value, activeSort.value);
if (!value) {
fetchContacts(1);
} else {
store.dispatch('contacts/search', {
search: encodeURIComponent(value),
page: 1,
sortAttr: '-last_activity_at',
sortAttr,
});
}
},
300,
DEBOUNCE_DELAY,
false
);
@@ -48,13 +73,35 @@ const searchContacts = value => {
debouncedSearch(value);
};
onMounted(() => {
fetchContacts(1);
const handleSort = async ({ sort, order }) => {
activeSort.value = sort;
activeOrdering.value = order;
const sortString = buildSortAttr(order, sort);
await updateUISettings({
contacts_sort_by: sortString,
});
await fetchContacts(1);
};
watch(
() => uiSettings.value?.contacts_sort_by,
newSortBy => {
if (newSortBy) {
const { sort, order } = parseSortSettings(newSortBy);
activeSort.value = sort;
activeOrdering.value = order;
}
},
{ immediate: true }
);
onMounted(async () => {
await fetchContacts(1);
});
const updateCurrentPage = page => {
fetchContacts(page);
};
const updateCurrentPage = page => fetchContacts(page);
</script>
<template>
@@ -67,8 +114,11 @@ const updateCurrentPage = page => {
:current-page="currentPage"
:total-items="totalItems"
:show-pagination-footer="!isFetchingList && searchValue === ''"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:current-page="updateCurrentPage"
@search="searchContacts"
@sort="handleSort"
>
<div
v-if="isFetchingList"
+2
View File
@@ -3,6 +3,7 @@ import { defineSetupVue3 } from '@histoire/plugin-vue';
import i18nMessages from 'dashboard/i18n';
import { createI18n } from 'vue-i18n';
import { vResizeObserver } from '@vueuse/components';
import { directive as onClickaway } from 'vue3-click-away';
import store from 'dashboard/store';
const i18n = createI18n({
@@ -15,4 +16,5 @@ export const setupVue3 = defineSetupVue3(({ app }) => {
app.use(store);
app.use(i18n);
app.directive('resize', vResizeObserver);
app.directive('onClickaway', onClickaway);
});