feat: Contact sort
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user