fix: added ordering capability for sidebar sections folders, teams, channels and labels (CW-7193) (#14609)
## Description * Added the ability to sort for 4 sub-sections under conversations folders, teams, channels and labels. * the sort options are basically created at, alphabetical and unread counts along with both directions. * for folders we don't have an unread count, so we sort it by only created and alphabetical. * all the sort preferences are stored on the frontend - easiest implementation for now. Fixes # CW-7193 ## Type of change Please delete options that are not relevant. - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? Tested this locally by visually verifying the changes. Also ran the newly added tests for component level changes. Here is the screenshot of the changes: Added the sort option to sub sections in the conversation sidebar: <img width="282" height="848" alt="Screenshot 2026-06-02 at 1 58 12 AM" src="https://github.com/user-attachments/assets/4a7c6061-86e3-438a-92ae-ee643a0128b6" /> The sort options looks like this: <img width="783" height="698" alt="Screenshot 2026-06-02 at 1 58 49 AM" src="https://github.com/user-attachments/assets/a15bb0a7-b810-4423-a88c-fbd84d0476c0" /> ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
co-authored by
Sivin Varghese
iamsivin
parent
4816e923b6
commit
d8a16278b9
@@ -162,7 +162,7 @@ onMounted(() => {
|
||||
>
|
||||
<p
|
||||
v-if="section.title"
|
||||
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky top-0 z-10 bg-n-alpha-3 backdrop-blur-sm"
|
||||
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky top-0 z-10 bg-n-alpha-3 backdrop-blur-sm truncate min-w-0"
|
||||
>
|
||||
{{ section.title }}
|
||||
</p>
|
||||
|
||||
@@ -21,6 +21,12 @@ import ChannelIcon from 'next/icon/ChannelIcon.vue';
|
||||
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
||||
import Logo from 'next/icon/Logo.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
import {
|
||||
SIDEBAR_SORT_SECTIONS,
|
||||
getSidebarSortOptions,
|
||||
resolveSidebarSort,
|
||||
sortSidebarItems,
|
||||
} from 'dashboard/helper/sidebarSort';
|
||||
|
||||
const props = defineProps({
|
||||
isMobileSidebarOpen: {
|
||||
@@ -50,6 +56,7 @@ const { width: windowWidth } = useWindowSize();
|
||||
const isMobile = computed(() => windowWidth.value < 768);
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const currentUserId = useMapGetter('getCurrentUserID');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
@@ -79,6 +86,11 @@ const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
|
||||
store.dispatch('conversationUnreadCounts/get');
|
||||
};
|
||||
|
||||
const fetchSidebarSortPreferences = ([currentAccountId, userId]) => {
|
||||
if (!currentAccountId || !userId) return;
|
||||
store.dispatch('sidebarSortPreferences/initialize');
|
||||
};
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
emit('openKeyShortcutModal');
|
||||
@@ -192,6 +204,9 @@ const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
|
||||
const conversationCustomViews = useMapGetter(
|
||||
'customViews/getConversationCustomViews'
|
||||
);
|
||||
const getSidebarSectionSort = useMapGetter(
|
||||
'sidebarSortPreferences/getSectionSort'
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('labels/get');
|
||||
@@ -207,44 +222,62 @@ watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const normalizeUnreadCount = count => {
|
||||
const unreadCount = Number(count);
|
||||
return Number.isFinite(unreadCount) && unreadCount > 0 ? unreadCount : 0;
|
||||
};
|
||||
watch([accountId, currentUserId], fetchSidebarSortPreferences, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const sortByUnreadCount = (items, labelKey, unreadCountKey) =>
|
||||
items.slice().sort((a, b) => {
|
||||
const unreadCountDiff =
|
||||
normalizeUnreadCount(unreadCountKey(b)) -
|
||||
normalizeUnreadCount(unreadCountKey(a));
|
||||
|
||||
if (unreadCountDiff !== 0) return unreadCountDiff;
|
||||
|
||||
return labelKey(a).localeCompare(labelKey(b));
|
||||
const getSortOptionsForSection = section =>
|
||||
getSidebarSortOptions(section, {
|
||||
hasUnreadCounts: hasConversationUnreadCounts.value,
|
||||
});
|
||||
|
||||
const getSortForSection = section =>
|
||||
resolveSidebarSort(section, getSidebarSectionSort.value(section), {
|
||||
hasUnreadCounts: hasConversationUnreadCounts.value,
|
||||
});
|
||||
|
||||
const updateSortPreference = (section, sortBy) => {
|
||||
store.dispatch('sidebarSortPreferences/setSectionSort', {
|
||||
section,
|
||||
sortBy,
|
||||
});
|
||||
};
|
||||
|
||||
const buildSortConfig = section => ({
|
||||
sortOptions: getSortOptionsForSection(section),
|
||||
activeSort: getSortForSection(section),
|
||||
onSortChange: sortBy => updateSortPreference(section, sortBy),
|
||||
});
|
||||
|
||||
const sortedFolders = computed(() =>
|
||||
sortSidebarItems(conversationCustomViews.value, {
|
||||
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.FOLDERS),
|
||||
labelKey: view => view.name,
|
||||
})
|
||||
);
|
||||
|
||||
const sortedTeams = computed(() =>
|
||||
sortByUnreadCount(
|
||||
teams.value,
|
||||
team => team.name,
|
||||
team => getTeamUnreadCount.value(team.id)
|
||||
)
|
||||
sortSidebarItems(teams.value, {
|
||||
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.TEAMS),
|
||||
labelKey: team => team.name,
|
||||
unreadCountKey: team => getTeamUnreadCount.value(team.id),
|
||||
})
|
||||
);
|
||||
|
||||
const sortedInboxes = computed(() =>
|
||||
sortByUnreadCount(
|
||||
inboxes.value,
|
||||
inbox => inbox.name,
|
||||
inbox => getInboxUnreadCount.value(inbox.id)
|
||||
)
|
||||
sortSidebarItems(inboxes.value, {
|
||||
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.CHANNELS),
|
||||
labelKey: inbox => inbox.name,
|
||||
unreadCountKey: inbox => getInboxUnreadCount.value(inbox.id),
|
||||
})
|
||||
);
|
||||
|
||||
const sortedLabels = computed(() =>
|
||||
sortByUnreadCount(
|
||||
labels.value,
|
||||
label => label.title,
|
||||
label => getLabelUnreadCount.value(label.id)
|
||||
)
|
||||
sortSidebarItems(labels.value, {
|
||||
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.LABELS),
|
||||
labelKey: label => label.title,
|
||||
unreadCountKey: label => getLabelUnreadCount.value(label.id),
|
||||
})
|
||||
);
|
||||
|
||||
const closeMobileSidebar = () => {
|
||||
@@ -331,9 +364,10 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.CUSTOM_VIEWS_FOLDER'),
|
||||
icon: 'i-lucide-folder',
|
||||
activeOn: ['conversations_through_folders'],
|
||||
...buildSortConfig(SIDEBAR_SORT_SECTIONS.FOLDERS),
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: conversationCustomViews.value.map(view => ({
|
||||
children: sortedFolders.value.map(view => ({
|
||||
name: `${view.name}-${view.id}`,
|
||||
label: view.name,
|
||||
to: accountScopedRoute('folder_conversations', { id: view.id }),
|
||||
@@ -344,6 +378,7 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.TEAMS'),
|
||||
icon: 'i-lucide-users',
|
||||
activeOn: ['conversations_through_team'],
|
||||
...buildSortConfig(SIDEBAR_SORT_SECTIONS.TEAMS),
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: sortedTeams.value.map(team => ({
|
||||
@@ -358,6 +393,7 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.CHANNELS'),
|
||||
icon: 'i-lucide-mailbox',
|
||||
activeOn: ['conversation_through_inbox'],
|
||||
...buildSortConfig(SIDEBAR_SORT_SECTIONS.CHANNELS),
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: sortedInboxes.value.map(inbox => ({
|
||||
@@ -380,6 +416,7 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.LABELS'),
|
||||
icon: 'i-lucide-tag',
|
||||
activeOn: ['conversations_through_label'],
|
||||
...buildSortConfig(SIDEBAR_SORT_SECTIONS.LABELS),
|
||||
collapsible: true,
|
||||
showTreeLine: true,
|
||||
children: sortedLabels.value.map(label => ({
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, nextTick } from 'vue';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useSidebarContext } from './provider';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
|
||||
import SidebarSortMenu from './SidebarSortMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
@@ -14,7 +16,7 @@ const props = defineProps({
|
||||
triggerRect: { type: Object, default: () => ({ top: 0, left: 0 }) },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'mouseenter', 'mouseleave']);
|
||||
const emit = defineEmits(['close', 'mouseenter', 'mouseleave', 'sortToggle']);
|
||||
|
||||
const router = useRouter();
|
||||
const { isAllowed, sidebarWidth } = useSidebarContext();
|
||||
@@ -29,6 +31,14 @@ const toggleSubGroup = name => {
|
||||
expandedSubGroup.value = expandedSubGroup.value === name ? null : name;
|
||||
};
|
||||
|
||||
const handleSortToggle = isSortOpen => {
|
||||
emit('sortToggle', isSortOpen);
|
||||
};
|
||||
|
||||
onClickOutside(popoverRef, () => emit('close'), {
|
||||
ignore: ['[data-popover-content]'],
|
||||
});
|
||||
|
||||
const navigateAndClose = to => {
|
||||
router.push(to);
|
||||
emit('close');
|
||||
@@ -123,24 +133,44 @@ onMounted(async () => {
|
||||
>
|
||||
<template v-for="child in accessibleChildren" :key="child.name">
|
||||
<!-- SubGroup with children -->
|
||||
<li v-if="child.children" class="py-0.5">
|
||||
<button
|
||||
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-n-slate-11 hover:bg-n-alpha-2 transition-colors duration-150 ease-out text-left rtl:text-right"
|
||||
@click="toggleSubGroup(child.name)"
|
||||
<li v-if="child.children" class="group/sidebar-section py-0.5">
|
||||
<div
|
||||
class="flex items-center rounded-lg text-n-slate-11 hover:bg-n-alpha-2 transition-colors duration-150 ease-out"
|
||||
>
|
||||
<Icon
|
||||
v-if="child.icon"
|
||||
:icon="child.icon"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate text-sm">{{ child.label }}</span>
|
||||
<span
|
||||
class="size-3 transition-transform i-lucide-chevron-down"
|
||||
:class="{
|
||||
'rotate-180': expandedSubGroup === child.name,
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="flex flex-1 min-w-0 items-center gap-2 ps-2 py-1.5 text-left rtl:text-right"
|
||||
@click="toggleSubGroup(child.name)"
|
||||
>
|
||||
<Icon
|
||||
v-if="child.icon"
|
||||
:icon="child.icon"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate text-sm">{{ child.label }}</span>
|
||||
</button>
|
||||
<div class="flex flex-shrink-0 items-center gap-1 pe-2">
|
||||
<SidebarSortMenu
|
||||
v-if="child.sortOptions?.length"
|
||||
:active-sort="child.activeSort"
|
||||
:options="child.sortOptions"
|
||||
:open-on-hover="false"
|
||||
@sort="child.onSortChange"
|
||||
@toggle="handleSortToggle"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-6 flex-shrink-0 items-center justify-center rounded-md text-n-slate-11 hover:bg-n-alpha-2 focus-visible:bg-n-alpha-2 focus-visible:outline-none"
|
||||
@click.stop="toggleSubGroup(child.name)"
|
||||
>
|
||||
<span
|
||||
class="size-4 flex-shrink-0 transition-transform i-lucide-chevron-down"
|
||||
:class="{
|
||||
'rotate-180': expandedSubGroup === child.name,
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Transition v-bind="transition">
|
||||
<ul
|
||||
v-if="expandedSubGroup === child.name"
|
||||
|
||||
@@ -55,6 +55,9 @@ const hasChildren = computed(
|
||||
const isPopoverOpen = computed(() => activePopover.value === props.name);
|
||||
const triggerRef = ref(null);
|
||||
const triggerRect = ref({ top: 0, left: 0, bottom: 0, right: 0 });
|
||||
// The sort dropdown teleports outside the popover; keep the popover open while
|
||||
// it is showing so moving the cursor onto it does not close everything.
|
||||
const isSortMenuOpen = ref(false);
|
||||
|
||||
const openPopover = () => {
|
||||
if (triggerRef.value) {
|
||||
@@ -82,7 +85,7 @@ const handleMouseEnter = () => {
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (!hasChildren.value) return;
|
||||
if (!hasChildren.value || isSortMenuOpen.value) return;
|
||||
scheduleClose(200);
|
||||
};
|
||||
|
||||
@@ -91,9 +94,15 @@ const handlePopoverMouseEnter = () => {
|
||||
};
|
||||
|
||||
const handlePopoverMouseLeave = () => {
|
||||
if (isSortMenuOpen.value) return;
|
||||
scheduleClose(100);
|
||||
};
|
||||
|
||||
const handleSortToggle = isOpen => {
|
||||
isSortMenuOpen.value = isOpen;
|
||||
cancelClose();
|
||||
};
|
||||
|
||||
// Close popover when mouse leaves the window
|
||||
const handleWindowBlur = () => {
|
||||
closeActivePopover();
|
||||
@@ -273,6 +282,7 @@ watch(
|
||||
@close="closePopover"
|
||||
@mouseenter="handlePopoverMouseEnter"
|
||||
@mouseleave="handlePopoverMouseLeave"
|
||||
@sort-toggle="handleSortToggle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -307,6 +317,9 @@ watch(
|
||||
:end-tree-line="child.showTreeLine && isLastVisibleChild(child)"
|
||||
:is-expanded="isExpanded"
|
||||
:active-child="activeChild"
|
||||
:sort-options="child.sortOptions"
|
||||
:active-sort="child.activeSort"
|
||||
@update-sort="child.onSortChange"
|
||||
/>
|
||||
<SidebarGroupLeaf
|
||||
v-else-if="isAllowed(child.to)"
|
||||
|
||||
@@ -45,7 +45,9 @@ const count = computed(() =>
|
||||
class="size-2 -top-px ltr:-right-px rtl:-left-px bg-n-brand absolute rounded-full border border-n-solid-2"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 flex-grow min-w-0 flex-1">
|
||||
<div
|
||||
class="flex items-center gap-1.5 flex-grow justify-between min-w-0 flex-1"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
:class="{
|
||||
@@ -57,11 +59,7 @@ const count = computed(() =>
|
||||
</span>
|
||||
<span
|
||||
v-if="dynamicCount && !expandable"
|
||||
class="rounded-md capitalize text-xs leading-5 font-medium text-center outline outline-1 px-1 flex-shrink-0"
|
||||
:class="{
|
||||
'text-n-slate-12 outline-n-slate-6': isActive,
|
||||
'text-n-slate-11 outline-n-strong': !isActive,
|
||||
}"
|
||||
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-slate-4 px-1 text-xxs font-medium leading-3 text-n-slate-12 dark:bg-n-slate-5 flex-shrink-0"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import SidebarSortMenu from './SidebarSortMenu.vue';
|
||||
|
||||
defineProps({
|
||||
collapsible: {
|
||||
@@ -18,6 +19,14 @@ defineProps({
|
||||
type: [Object, String],
|
||||
default: '',
|
||||
},
|
||||
sortOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
activeSort: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showTreeLine: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -28,7 +37,7 @@ defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle']);
|
||||
const emit = defineEmits(['toggle', 'update-sort']);
|
||||
|
||||
const TREE_VERTICAL_LINE =
|
||||
"before:content-[''] before:absolute before:-top-1 before:w-0.5 before:bg-n-slate-4 before:start-[-0.5rem]";
|
||||
@@ -37,34 +46,59 @@ const TREE_ELBOW =
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="collapsible ? 'button' : 'div'"
|
||||
:type="collapsible ? 'button' : undefined"
|
||||
:aria-expanded="collapsible ? isExpanded : undefined"
|
||||
:title="label"
|
||||
class="relative justify-between flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 text-n-slate-10 select-none min-w-0"
|
||||
:class="[
|
||||
showTreeLine && TREE_VERTICAL_LINE,
|
||||
showTreeLine &&
|
||||
(endTreeLine ? `before:h-3 ${TREE_ELBOW}` : 'before:-bottom-1'),
|
||||
{
|
||||
'w-full': !collapsible,
|
||||
'pointer-events-none': !collapsible,
|
||||
'ms-5 cursor-pointer hover:bg-n-alpha-2': collapsible,
|
||||
},
|
||||
]"
|
||||
@click.stop="emit('toggle')"
|
||||
>
|
||||
<div class="min-w-0 inline-flex gap-2 items-center">
|
||||
<Icon v-if="icon" :icon="icon" class="size-4 flex-shrink-0" />
|
||||
<span class="text-sm font-medium leading-5 flex-grow truncate text-start">
|
||||
{{ label }}
|
||||
</span>
|
||||
<div class="relative min-w-0" :class="{ 'ms-5': collapsible }">
|
||||
<component
|
||||
:is="collapsible ? 'button' : 'div'"
|
||||
:type="collapsible ? 'button' : undefined"
|
||||
:aria-expanded="collapsible ? isExpanded : undefined"
|
||||
:title="label"
|
||||
class="relative flex h-8 w-full min-w-0 items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-n-slate-10 select-none"
|
||||
:class="[
|
||||
showTreeLine && TREE_VERTICAL_LINE,
|
||||
showTreeLine &&
|
||||
(endTreeLine ? `before:h-3 ${TREE_ELBOW}` : 'before:-bottom-1'),
|
||||
{
|
||||
'pointer-events-none': !collapsible,
|
||||
'cursor-pointer hover:bg-n-alpha-2': collapsible,
|
||||
'pe-14': collapsible && sortOptions.length,
|
||||
'pe-8': collapsible && !sortOptions.length,
|
||||
'pe-10': !collapsible && sortOptions.length,
|
||||
},
|
||||
]"
|
||||
@click.stop="collapsible ? emit('toggle') : undefined"
|
||||
>
|
||||
<div class="inline-flex min-w-0 items-center gap-2">
|
||||
<Icon v-if="icon" :icon="icon" class="size-4 flex-shrink-0" />
|
||||
<span
|
||||
class="flex-grow truncate text-start text-sm font-medium leading-5"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</component>
|
||||
<div
|
||||
v-if="collapsible || sortOptions.length"
|
||||
class="absolute end-2 top-1/2 flex -translate-y-1/2 items-center gap-1"
|
||||
>
|
||||
<SidebarSortMenu
|
||||
v-if="sortOptions.length"
|
||||
:active-sort="activeSort"
|
||||
:options="sortOptions"
|
||||
@sort="sortBy => emit('update-sort', sortBy)"
|
||||
/>
|
||||
<button
|
||||
v-if="collapsible"
|
||||
type="button"
|
||||
class="flex size-6 flex-shrink-0 items-center justify-center rounded-md text-n-slate-10 hover:bg-n-alpha-2 focus-visible:bg-n-alpha-2 focus-visible:outline-none"
|
||||
:aria-expanded="isExpanded"
|
||||
:aria-label="label"
|
||||
@click.stop="emit('toggle')"
|
||||
>
|
||||
<span
|
||||
class="size-3 flex-shrink-0"
|
||||
:class="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
v-if="collapsible"
|
||||
class="size-3 flex-shrink-0 text-n-slate-10 me-0.5"
|
||||
:class="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
/>
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import { SIDEBAR_SORT_KEYS } from 'dashboard/helper/sidebarSort';
|
||||
|
||||
const props = defineProps({
|
||||
activeSort: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
openOnHover: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sort', 'toggle']);
|
||||
|
||||
const SORT_OPTION_GROUPS = [
|
||||
{
|
||||
key: 'created',
|
||||
options: [SIDEBAR_SORT_KEYS.CREATED_DESC, SIDEBAR_SORT_KEYS.CREATED_ASC],
|
||||
},
|
||||
{
|
||||
key: 'alphabetical',
|
||||
options: [
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'unread_count',
|
||||
options: [
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const { t } = useI18n();
|
||||
const isOpen = ref(false);
|
||||
const triggerRef = ref(null);
|
||||
const popoverRef = ref(null);
|
||||
let closeTimer;
|
||||
|
||||
const { fixedPosition, updatePosition } = useDropdownPosition(
|
||||
triggerRef,
|
||||
popoverRef,
|
||||
isOpen,
|
||||
{ align: 'start' }
|
||||
);
|
||||
|
||||
const getSortOptionLabel = option => {
|
||||
if (option === SIDEBAR_SORT_KEYS.CREATED_DESC) {
|
||||
return t('SIDEBAR.SORT_OPTIONS.CREATED_DESC');
|
||||
}
|
||||
|
||||
if (option === SIDEBAR_SORT_KEYS.CREATED_ASC) {
|
||||
return t('SIDEBAR.SORT_OPTIONS.CREATED_ASC');
|
||||
}
|
||||
|
||||
if (option === SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC) {
|
||||
return t('SIDEBAR.SORT_OPTIONS.ALPHABETICAL_ASC');
|
||||
}
|
||||
|
||||
if (option === SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC) {
|
||||
return t('SIDEBAR.SORT_OPTIONS.ALPHABETICAL_DESC');
|
||||
}
|
||||
|
||||
if (option === SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC) {
|
||||
return t('SIDEBAR.SORT_OPTIONS.UNREAD_COUNT_DESC');
|
||||
}
|
||||
|
||||
if (option === SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC) {
|
||||
return t('SIDEBAR.SORT_OPTIONS.UNREAD_COUNT_ASC');
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const getSortGroupLabel = groupKey => {
|
||||
if (groupKey === 'created') {
|
||||
return t('SIDEBAR.SORT_GROUPS.CREATED');
|
||||
}
|
||||
|
||||
if (groupKey === 'alphabetical') {
|
||||
return t('SIDEBAR.SORT_GROUPS.ALPHABETICAL');
|
||||
}
|
||||
|
||||
if (groupKey === 'unread_count') {
|
||||
return t('SIDEBAR.SORT_GROUPS.UNREAD_COUNT');
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const sortMenuSections = computed(() =>
|
||||
SORT_OPTION_GROUPS.map(group => ({
|
||||
title: getSortGroupLabel(group.key),
|
||||
items: group.options
|
||||
.filter(option => props.options.includes(option))
|
||||
.map(option => ({
|
||||
label: getSortOptionLabel(option),
|
||||
value: option,
|
||||
action: 'sort',
|
||||
isActive: option === props.activeSort,
|
||||
})),
|
||||
})).filter(section => section.items.length)
|
||||
);
|
||||
|
||||
const clearCloseTimer = () => {
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const openMenu = async () => {
|
||||
clearCloseTimer();
|
||||
isOpen.value = true;
|
||||
emit('toggle', true);
|
||||
|
||||
await nextTick();
|
||||
updatePosition();
|
||||
};
|
||||
|
||||
const closeMenu = () => {
|
||||
clearCloseTimer();
|
||||
isOpen.value = false;
|
||||
emit('toggle', false);
|
||||
};
|
||||
|
||||
const scheduleClose = () => {
|
||||
clearCloseTimer();
|
||||
closeTimer = setTimeout(closeMenu, 150);
|
||||
};
|
||||
|
||||
const handleTriggerEnter = () => {
|
||||
if (props.openOnHover) openMenu();
|
||||
};
|
||||
|
||||
const handleTriggerLeave = () => {
|
||||
if (props.openOnHover) scheduleClose();
|
||||
};
|
||||
|
||||
const handleClickOutside = event => {
|
||||
if (triggerRef.value?.contains(event.target)) return;
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
const handleSortChange = ({ value }) => {
|
||||
emit('sort', value);
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
onBeforeUnmount(clearCloseTimer);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="triggerRef"
|
||||
class="relative invisible flex-shrink-0 opacity-0 pointer-events-none transition-opacity duration-150 group-hover/sidebar-section:visible group-hover/sidebar-section:opacity-100 group-hover/sidebar-section:pointer-events-auto"
|
||||
:class="{ '!visible !opacity-100 !pointer-events-auto': isOpen }"
|
||||
@mouseenter="handleTriggerEnter"
|
||||
@mouseleave="handleTriggerLeave"
|
||||
>
|
||||
<Button
|
||||
:title="t('SIDEBAR.SORT_TOOLTIP')"
|
||||
icon="i-lucide-arrow-up-down"
|
||||
ghost
|
||||
slate
|
||||
xs
|
||||
class="!size-6 !text-n-slate-11 hover:!text-n-slate-12"
|
||||
:class="{ '!bg-n-alpha-2': isOpen }"
|
||||
@click.stop="openMenu"
|
||||
/>
|
||||
<TeleportWithDirection>
|
||||
<DropdownMenu
|
||||
v-if="isOpen"
|
||||
ref="popoverRef"
|
||||
v-on-click-outside="handleClickOutside"
|
||||
data-popover-content
|
||||
:menu-sections="sortMenuSections"
|
||||
:class="fixedPosition.class"
|
||||
:style="fixedPosition.style"
|
||||
class="w-60 !fixed"
|
||||
@action="handleSortChange"
|
||||
@mouseenter="clearCloseTimer"
|
||||
@mouseleave="handleTriggerLeave"
|
||||
>
|
||||
<template #trailing-icon="{ item }">
|
||||
<span
|
||||
v-if="item.isActive"
|
||||
class="i-lucide-check ms-auto size-4 flex-shrink-0 text-n-slate-11"
|
||||
/>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</TeleportWithDirection>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
@@ -8,7 +9,6 @@ import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
|
||||
import SidebarGroupSeparator from './SidebarGroupSeparator.vue';
|
||||
|
||||
import { useSidebarContext } from './provider';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
@@ -17,11 +17,15 @@ const props = defineProps({
|
||||
icon: { type: [Object, String], required: true },
|
||||
children: { type: Array, default: undefined },
|
||||
activeChild: { type: Object, default: undefined },
|
||||
sortOptions: { type: Array, default: () => [] },
|
||||
activeSort: { type: String, default: '' },
|
||||
collapsible: { type: Boolean, default: false },
|
||||
showTreeLine: { type: Boolean, default: false },
|
||||
endTreeLine: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update-sort']);
|
||||
|
||||
const { isAllowed } = useSidebarContext();
|
||||
const scrollableContainer = ref(null);
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
@@ -121,7 +125,7 @@ watch([hasActiveChild, storageKey], expandSubGroupOnActiveChild, {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="relative flex flex-col list-none min-w-0">
|
||||
<li class="group/sidebar-section relative flex flex-col list-none min-w-0">
|
||||
<template v-if="hasAccessibleItems">
|
||||
<SidebarGroupSeparator
|
||||
v-show="isExpanded"
|
||||
@@ -131,8 +135,11 @@ watch([hasActiveChild, storageKey], expandSubGroupOnActiveChild, {
|
||||
:is-expanded="isSubGroupExpanded"
|
||||
:show-tree-line="showTreeLine"
|
||||
:end-tree-line="endTreeLine"
|
||||
:sort-options="sortOptions"
|
||||
:active-sort="activeSort"
|
||||
class="my-1"
|
||||
@toggle="toggleSubGroup"
|
||||
@update-sort="sortBy => emit('update-sort', sortBy)"
|
||||
/>
|
||||
<ul
|
||||
v-if="children.length"
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
export const SIDEBAR_SORT_KEYS = Object.freeze({
|
||||
CREATED_DESC: 'created_at_desc',
|
||||
CREATED_ASC: 'created_at_asc',
|
||||
ALPHABETICAL_ASC: 'alphabetical_asc',
|
||||
ALPHABETICAL_DESC: 'alphabetical_desc',
|
||||
UNREAD_COUNT_DESC: 'unread_count_desc',
|
||||
UNREAD_COUNT_ASC: 'unread_count_asc',
|
||||
});
|
||||
|
||||
export const SIDEBAR_SORT_SECTIONS = Object.freeze({
|
||||
FOLDERS: 'folders',
|
||||
TEAMS: 'teams',
|
||||
CHANNELS: 'channels',
|
||||
LABELS: 'labels',
|
||||
});
|
||||
|
||||
export const SIDEBAR_SORT_OPTIONS_BY_SECTION = Object.freeze({
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: [
|
||||
SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
],
|
||||
[SIDEBAR_SORT_SECTIONS.TEAMS]: [
|
||||
SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
|
||||
],
|
||||
[SIDEBAR_SORT_SECTIONS.CHANNELS]: [
|
||||
SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
|
||||
],
|
||||
[SIDEBAR_SORT_SECTIONS.LABELS]: [
|
||||
SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
|
||||
],
|
||||
});
|
||||
|
||||
const UNREAD_COUNT_SORT_OPTIONS = [
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
|
||||
];
|
||||
|
||||
export const DEFAULT_SIDEBAR_SORT_PREFERENCES = Object.freeze({
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
[SIDEBAR_SORT_SECTIONS.CHANNELS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
[SIDEBAR_SORT_SECTIONS.LABELS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
});
|
||||
|
||||
export const isValidSidebarSort = (section, sortBy) => {
|
||||
return SIDEBAR_SORT_OPTIONS_BY_SECTION[section]?.includes(sortBy);
|
||||
};
|
||||
|
||||
const isUnreadCountSort = sortBy => UNREAD_COUNT_SORT_OPTIONS.includes(sortBy);
|
||||
|
||||
export const getSidebarSortOptions = (
|
||||
section,
|
||||
{ hasUnreadCounts = true } = {}
|
||||
) => {
|
||||
const options = SIDEBAR_SORT_OPTIONS_BY_SECTION[section] || [];
|
||||
|
||||
if (hasUnreadCounts) return options;
|
||||
|
||||
return options.filter(option => !isUnreadCountSort(option));
|
||||
};
|
||||
|
||||
export const resolveSidebarSort = (
|
||||
section,
|
||||
sortBy,
|
||||
{ hasUnreadCounts = true } = {}
|
||||
) => {
|
||||
const options = getSidebarSortOptions(section, { hasUnreadCounts });
|
||||
|
||||
if (options.includes(sortBy)) return sortBy;
|
||||
if (!hasUnreadCounts && isUnreadCountSort(sortBy)) {
|
||||
return SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC;
|
||||
}
|
||||
|
||||
return options[0] || SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC;
|
||||
};
|
||||
|
||||
export const normalizeSidebarSortPreferences = (preferences = {}) => {
|
||||
const savedPreferences = preferences || {};
|
||||
|
||||
return Object.keys(DEFAULT_SIDEBAR_SORT_PREFERENCES).reduce(
|
||||
(result, section) => {
|
||||
const sortBy = savedPreferences[section];
|
||||
result[section] = isValidSidebarSort(section, sortBy)
|
||||
? sortBy
|
||||
: DEFAULT_SIDEBAR_SORT_PREFERENCES[section];
|
||||
return result;
|
||||
},
|
||||
{}
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeUnreadCount = count => {
|
||||
const unreadCount = Number(count);
|
||||
return Number.isFinite(unreadCount) && unreadCount > 0 ? unreadCount : 0;
|
||||
};
|
||||
|
||||
const getCreatedValue = item => {
|
||||
const createdAt = item.created_at || item.createdAt;
|
||||
|
||||
if (typeof createdAt === 'number') return createdAt;
|
||||
|
||||
if (createdAt) {
|
||||
const timestamp = Date.parse(createdAt);
|
||||
if (Number.isFinite(timestamp)) return timestamp;
|
||||
}
|
||||
|
||||
const id = Number(item.id);
|
||||
return Number.isFinite(id) ? id : 0;
|
||||
};
|
||||
|
||||
const getLabelValue = (item, labelKey) => {
|
||||
return String(labelKey(item) || '');
|
||||
};
|
||||
|
||||
const compareAlphabetically = (a, b, labelKey) => {
|
||||
return getLabelValue(a, labelKey).localeCompare(
|
||||
getLabelValue(b, labelKey),
|
||||
undefined,
|
||||
{
|
||||
sensitivity: 'base',
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const sortSidebarItems = (
|
||||
items,
|
||||
{ sortBy, labelKey, unreadCountKey = () => 0 }
|
||||
) => {
|
||||
return (items || []).slice().sort((a, b) => {
|
||||
if (sortBy === SIDEBAR_SORT_KEYS.CREATED_DESC) {
|
||||
const createdDiff = getCreatedValue(b) - getCreatedValue(a);
|
||||
if (createdDiff !== 0) return createdDiff;
|
||||
return compareAlphabetically(a, b, labelKey);
|
||||
}
|
||||
|
||||
if (sortBy === SIDEBAR_SORT_KEYS.CREATED_ASC) {
|
||||
const createdDiff = getCreatedValue(a) - getCreatedValue(b);
|
||||
if (createdDiff !== 0) return createdDiff;
|
||||
return compareAlphabetically(a, b, labelKey);
|
||||
}
|
||||
|
||||
if (sortBy === SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC) {
|
||||
return compareAlphabetically(b, a, labelKey);
|
||||
}
|
||||
|
||||
if (sortBy === SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC) {
|
||||
const unreadCountDiff =
|
||||
normalizeUnreadCount(unreadCountKey(b)) -
|
||||
normalizeUnreadCount(unreadCountKey(a));
|
||||
|
||||
if (unreadCountDiff !== 0) return unreadCountDiff;
|
||||
}
|
||||
|
||||
if (sortBy === SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC) {
|
||||
const unreadCountDiff =
|
||||
normalizeUnreadCount(unreadCountKey(a)) -
|
||||
normalizeUnreadCount(unreadCountKey(b));
|
||||
|
||||
if (unreadCountDiff !== 0) return unreadCountDiff;
|
||||
}
|
||||
|
||||
return compareAlphabetically(a, b, labelKey);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
SIDEBAR_SORT_KEYS,
|
||||
SIDEBAR_SORT_SECTIONS,
|
||||
getSidebarSortOptions,
|
||||
normalizeSidebarSortPreferences,
|
||||
resolveSidebarSort,
|
||||
sortSidebarItems,
|
||||
} from '../sidebarSort';
|
||||
|
||||
const items = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Billing',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Accounts',
|
||||
created_at: '2024-03-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Support',
|
||||
created_at: '2024-02-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
describe('#sortSidebarItems', () => {
|
||||
it('sorts by created date descending', () => {
|
||||
const sortedItems = sortSidebarItems(items, {
|
||||
sortBy: SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
labelKey: item => item.name,
|
||||
});
|
||||
|
||||
expect(sortedItems.map(item => item.name)).toEqual([
|
||||
'Accounts',
|
||||
'Support',
|
||||
'Billing',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts by created date ascending', () => {
|
||||
const sortedItems = sortSidebarItems(items, {
|
||||
sortBy: SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
labelKey: item => item.name,
|
||||
});
|
||||
|
||||
expect(sortedItems.map(item => item.name)).toEqual([
|
||||
'Billing',
|
||||
'Support',
|
||||
'Accounts',
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to id when created date is not present', () => {
|
||||
const sortedItems = sortSidebarItems(
|
||||
items.map(({ created_at: _createdAt, ...item }) => item),
|
||||
{
|
||||
sortBy: SIDEBAR_SORT_KEYS.CREATED_DESC,
|
||||
labelKey: item => item.name,
|
||||
}
|
||||
);
|
||||
|
||||
expect(sortedItems.map(item => item.id)).toEqual([3, 2, 1]);
|
||||
});
|
||||
|
||||
it('sorts alphabetically from A to Z', () => {
|
||||
const sortedItems = sortSidebarItems(items, {
|
||||
sortBy: SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
labelKey: item => item.name,
|
||||
});
|
||||
|
||||
expect(sortedItems.map(item => item.name)).toEqual([
|
||||
'Accounts',
|
||||
'Billing',
|
||||
'Support',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts alphabetically from Z to A', () => {
|
||||
const sortedItems = sortSidebarItems(items, {
|
||||
sortBy: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
labelKey: item => item.name,
|
||||
});
|
||||
|
||||
expect(sortedItems.map(item => item.name)).toEqual([
|
||||
'Support',
|
||||
'Billing',
|
||||
'Accounts',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts by unread count descending and falls back to alphabetical order', () => {
|
||||
const unreadCounts = {
|
||||
1: 3,
|
||||
2: 3,
|
||||
3: 7,
|
||||
};
|
||||
|
||||
const sortedItems = sortSidebarItems(items, {
|
||||
sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
labelKey: item => item.name,
|
||||
unreadCountKey: item => unreadCounts[item.id],
|
||||
});
|
||||
|
||||
expect(sortedItems.map(item => item.name)).toEqual([
|
||||
'Accounts',
|
||||
'Billing',
|
||||
'Support',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts by unread count ascending and falls back to alphabetical order', () => {
|
||||
const unreadCounts = {
|
||||
1: 3,
|
||||
2: 3,
|
||||
3: 7,
|
||||
};
|
||||
|
||||
const sortedItems = sortSidebarItems(items, {
|
||||
sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
|
||||
labelKey: item => item.name,
|
||||
unreadCountKey: item => unreadCounts[item.id],
|
||||
});
|
||||
|
||||
expect(sortedItems.map(item => item.name)).toEqual([
|
||||
'Billing',
|
||||
'Support',
|
||||
'Accounts',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#normalizeSidebarSortPreferences', () => {
|
||||
it('keeps valid preferences', () => {
|
||||
const preferences = normalizeSidebarSortPreferences({
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
});
|
||||
|
||||
expect(preferences).toEqual({
|
||||
...DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to defaults for unsupported preferences', () => {
|
||||
const preferences = normalizeSidebarSortPreferences({
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
});
|
||||
|
||||
expect(preferences).toEqual(DEFAULT_SIDEBAR_SORT_PREFERENCES);
|
||||
});
|
||||
|
||||
it('falls back to defaults when stored preferences are null', () => {
|
||||
expect(normalizeSidebarSortPreferences(null)).toEqual(
|
||||
DEFAULT_SIDEBAR_SORT_PREFERENCES
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getSidebarSortOptions', () => {
|
||||
it('keeps unread count options when unread counts are enabled', () => {
|
||||
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.TEAMS, {
|
||||
hasUnreadCounts: true,
|
||||
});
|
||||
|
||||
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
|
||||
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
|
||||
});
|
||||
|
||||
it('removes unread count options when unread counts are disabled', () => {
|
||||
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.TEAMS, {
|
||||
hasUnreadCounts: false,
|
||||
});
|
||||
|
||||
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
|
||||
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
|
||||
expect(options).toContain(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#resolveSidebarSort', () => {
|
||||
it('keeps unread count sort when unread counts are enabled', () => {
|
||||
const sortBy = resolveSidebarSort(
|
||||
SIDEBAR_SORT_SECTIONS.TEAMS,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
{ hasUnreadCounts: true }
|
||||
);
|
||||
|
||||
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
|
||||
});
|
||||
|
||||
it('falls back to alphabetical sort when unread counts are disabled', () => {
|
||||
const sortBy = resolveSidebarSort(
|
||||
SIDEBAR_SORT_SECTIONS.TEAMS,
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
{ hasUnreadCounts: false }
|
||||
);
|
||||
|
||||
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
|
||||
});
|
||||
});
|
||||
@@ -390,6 +390,21 @@
|
||||
"INFO_TEXT": "Let the system automatically mark you offline when you aren't using the app or dashboard.",
|
||||
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
|
||||
},
|
||||
"SORT_TOOLTIP": "Sort",
|
||||
"SORT_BY": "Sort by",
|
||||
"SORT_GROUPS": {
|
||||
"CREATED": "Sort by date of creation",
|
||||
"ALPHABETICAL": "Sort in alphabetical order",
|
||||
"UNREAD_COUNT": "Sort by unread count"
|
||||
},
|
||||
"SORT_OPTIONS": {
|
||||
"CREATED_DESC": "Newest first",
|
||||
"CREATED_ASC": "Oldest first",
|
||||
"ALPHABETICAL_ASC": "Ascending (A - Z)",
|
||||
"ALPHABETICAL_DESC": "Descending (Z - A)",
|
||||
"UNREAD_COUNT_DESC": "Highest first",
|
||||
"UNREAD_COUNT_ASC": "Lowest first"
|
||||
},
|
||||
"DOCS": "Read docs",
|
||||
"SECURITY": "Security",
|
||||
"CAPTAIN_AI": "Captain",
|
||||
|
||||
@@ -44,6 +44,7 @@ import portals from './modules/helpCenterPortals';
|
||||
import reports from './modules/reports';
|
||||
import sla from './modules/sla';
|
||||
import slaReports from './modules/SLAReports';
|
||||
import sidebarSortPreferences from './modules/sidebarSortPreferences';
|
||||
import summaryReports from './modules/summaryReports';
|
||||
import teamMembers from './modules/teamMembers';
|
||||
import teams from './modules/teams';
|
||||
@@ -108,6 +109,7 @@ export default createStore({
|
||||
reports,
|
||||
sla,
|
||||
slaReports,
|
||||
sidebarSortPreferences,
|
||||
summaryReports,
|
||||
teamMembers,
|
||||
teams,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import {
|
||||
DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
isValidSidebarSort,
|
||||
normalizeSidebarSortPreferences,
|
||||
} from 'dashboard/helper/sidebarSort';
|
||||
|
||||
const STORAGE_NAME = 'chatwoot_sidebar_sort_preferences';
|
||||
export const SET_SIDEBAR_SORT_PREFERENCES = 'SET_SIDEBAR_SORT_PREFERENCES';
|
||||
|
||||
const getPreferenceScope = rootGetters => {
|
||||
const currentUserId = rootGetters.getCurrentUserID;
|
||||
const currentAccountId = rootGetters.getCurrentAccountId;
|
||||
|
||||
if (!currentUserId || !currentAccountId) return null;
|
||||
|
||||
return `${currentUserId}:${currentAccountId}`;
|
||||
};
|
||||
|
||||
export const state = {
|
||||
preferences: { ...DEFAULT_SIDEBAR_SORT_PREFERENCES },
|
||||
storageKey: null,
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
getSectionSort: $state => section => {
|
||||
return (
|
||||
$state.preferences[section] || DEFAULT_SIDEBAR_SORT_PREFERENCES[section]
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
initialize({ commit, rootGetters }) {
|
||||
const storageKey = getPreferenceScope(rootGetters);
|
||||
const storedPreferences = storageKey
|
||||
? LocalStorage.getFromJsonStore(STORAGE_NAME, storageKey)
|
||||
: {};
|
||||
|
||||
commit(SET_SIDEBAR_SORT_PREFERENCES, {
|
||||
preferences: normalizeSidebarSortPreferences(storedPreferences),
|
||||
storageKey,
|
||||
});
|
||||
},
|
||||
setSectionSort({ commit, rootGetters, state: currentState }, payload = {}) {
|
||||
const { section, sortBy } = payload;
|
||||
|
||||
if (!isValidSidebarSort(section, sortBy)) return;
|
||||
|
||||
const storageKey =
|
||||
currentState.storageKey || getPreferenceScope(rootGetters);
|
||||
const preferences = {
|
||||
...currentState.preferences,
|
||||
[section]: sortBy,
|
||||
};
|
||||
|
||||
commit(SET_SIDEBAR_SORT_PREFERENCES, {
|
||||
preferences,
|
||||
storageKey,
|
||||
});
|
||||
|
||||
if (storageKey) {
|
||||
LocalStorage.updateJsonStore(STORAGE_NAME, storageKey, preferences);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
[SET_SIDEBAR_SORT_PREFERENCES]($state, payload = {}) {
|
||||
const { preferences = {}, storageKey = null } = payload;
|
||||
$state.preferences = normalizeSidebarSortPreferences(preferences);
|
||||
$state.storageKey = storageKey;
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import {
|
||||
DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
SIDEBAR_SORT_KEYS,
|
||||
SIDEBAR_SORT_SECTIONS,
|
||||
} from 'dashboard/helper/sidebarSort';
|
||||
import {
|
||||
SET_SIDEBAR_SORT_PREFERENCES,
|
||||
actions,
|
||||
} from '../../sidebarSortPreferences';
|
||||
|
||||
vi.mock('shared/helpers/localStorage', () => ({
|
||||
LocalStorage: {
|
||||
getFromJsonStore: vi.fn(),
|
||||
updateJsonStore: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const rootGetters = {
|
||||
getCurrentUserID: 1,
|
||||
getCurrentAccountId: 2,
|
||||
};
|
||||
|
||||
describe('#actions', () => {
|
||||
const commit = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
commit.mockClear();
|
||||
LocalStorage.getFromJsonStore.mockReset();
|
||||
LocalStorage.updateJsonStore.mockReset();
|
||||
});
|
||||
|
||||
describe('#initialize', () => {
|
||||
it('loads scoped preferences from local storage', () => {
|
||||
LocalStorage.getFromJsonStore.mockReturnValue({
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
});
|
||||
|
||||
actions.initialize({ commit, rootGetters });
|
||||
|
||||
expect(LocalStorage.getFromJsonStore).toHaveBeenCalledWith(
|
||||
'chatwoot_sidebar_sort_preferences',
|
||||
'1:2'
|
||||
);
|
||||
expect(commit).toHaveBeenCalledWith(SET_SIDEBAR_SORT_PREFERENCES, {
|
||||
preferences: {
|
||||
...DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
|
||||
},
|
||||
storageKey: '1:2',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setSectionSort', () => {
|
||||
it('persists valid preferences to local storage', () => {
|
||||
const state = {
|
||||
preferences: DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
storageKey: '1:2',
|
||||
};
|
||||
|
||||
actions.setSectionSort(
|
||||
{ commit, rootGetters, state },
|
||||
{
|
||||
section: SIDEBAR_SORT_SECTIONS.LABELS,
|
||||
sortBy: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
}
|
||||
);
|
||||
|
||||
const preferences = {
|
||||
...DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
[SIDEBAR_SORT_SECTIONS.LABELS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
};
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(SET_SIDEBAR_SORT_PREFERENCES, {
|
||||
preferences,
|
||||
storageKey: '1:2',
|
||||
});
|
||||
expect(LocalStorage.updateJsonStore).toHaveBeenCalledWith(
|
||||
'chatwoot_sidebar_sort_preferences',
|
||||
'1:2',
|
||||
preferences
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores invalid preferences', () => {
|
||||
actions.setSectionSort(
|
||||
{
|
||||
commit,
|
||||
rootGetters,
|
||||
state: {
|
||||
preferences: DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
storageKey: '1:2',
|
||||
},
|
||||
},
|
||||
{
|
||||
section: SIDEBAR_SORT_SECTIONS.FOLDERS,
|
||||
sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
|
||||
}
|
||||
);
|
||||
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
expect(LocalStorage.updateJsonStore).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
SIDEBAR_SORT_KEYS,
|
||||
SIDEBAR_SORT_SECTIONS,
|
||||
} from 'dashboard/helper/sidebarSort';
|
||||
import { getters } from '../../sidebarSortPreferences';
|
||||
|
||||
describe('#getters', () => {
|
||||
it('returns section sort preference', () => {
|
||||
const state = {
|
||||
preferences: {
|
||||
...DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.CREATED_ASC,
|
||||
},
|
||||
};
|
||||
|
||||
expect(getters.getSectionSort(state)(SIDEBAR_SORT_SECTIONS.TEAMS)).toBe(
|
||||
SIDEBAR_SORT_KEYS.CREATED_ASC
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to default section sort preference', () => {
|
||||
const state = {
|
||||
preferences: {},
|
||||
};
|
||||
|
||||
expect(getters.getSectionSort(state)(SIDEBAR_SORT_SECTIONS.LABELS)).toBe(
|
||||
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
SIDEBAR_SORT_KEYS,
|
||||
SIDEBAR_SORT_SECTIONS,
|
||||
} from 'dashboard/helper/sidebarSort';
|
||||
import {
|
||||
SET_SIDEBAR_SORT_PREFERENCES,
|
||||
mutations,
|
||||
} from '../../sidebarSortPreferences';
|
||||
|
||||
describe('#mutations', () => {
|
||||
it('sets normalized preferences', () => {
|
||||
const state = {
|
||||
preferences: {},
|
||||
storageKey: null,
|
||||
};
|
||||
|
||||
mutations[SET_SIDEBAR_SORT_PREFERENCES](state, {
|
||||
preferences: {
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
[SIDEBAR_SORT_SECTIONS.LABELS]: 'invalid',
|
||||
},
|
||||
storageKey: '1:2',
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
preferences: {
|
||||
...DEFAULT_SIDEBAR_SORT_PREFERENCES,
|
||||
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
|
||||
},
|
||||
storageKey: '1:2',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user