# Pull Request Template ## Description This is the third and final PR in a series of PRs for Introducing unread counts in the sidebar for inboxes and labels. In this PR: * Added frontend changes to show the badges for unread counts for Inboxes and Labels * Added specs for the changes Issue: https://linear.app/chatwoot/issue/CW-6851/support-unread-conversation-counts ## 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. Cases to test: * Send a message from the widget and see if the count changes * Mark a conversation as unread and see the count change for inbox * Open an unread conversation as agent and see the count go down * Add a label to an unread conversation from sidebar right click action without opening the conversation and see the count of un-reads on the label change Added the screenshot of how it will look like <img width="614" height="990" alt="Screenshot 2026-05-05 at 7 00 11 PM" src="https://github.com/user-attachments/assets/99fbaa9f-bcf2-4d8d-86e2-5727f652a9dd" /> ## 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: Sojan Jose <sojan@pepalo.com>
202 lines
7.1 KiB
Vue
202 lines
7.1 KiB
Vue
<script setup>
|
|
import { computed, ref, onMounted, nextTick } from 'vue';
|
|
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';
|
|
|
|
const props = defineProps({
|
|
label: { type: String, required: true },
|
|
children: { type: Array, default: () => [] },
|
|
activeChild: { type: Object, default: undefined },
|
|
triggerRect: { type: Object, default: () => ({ top: 0, left: 0 }) },
|
|
});
|
|
|
|
const emit = defineEmits(['close', 'mouseenter', 'mouseleave']);
|
|
|
|
const router = useRouter();
|
|
const { isAllowed, sidebarWidth } = useSidebarContext();
|
|
|
|
const expandedSubGroup = ref(null);
|
|
const popoverRef = ref(null);
|
|
const topPosition = ref(0);
|
|
const isRTL = useMapGetter('accounts/isRTL');
|
|
const skipTransition = ref(true);
|
|
|
|
const toggleSubGroup = name => {
|
|
expandedSubGroup.value = expandedSubGroup.value === name ? null : name;
|
|
};
|
|
|
|
const navigateAndClose = to => {
|
|
router.push(to);
|
|
emit('close');
|
|
};
|
|
|
|
const isActive = child => props.activeChild?.name === child.name;
|
|
|
|
const getAccessibleSubChildren = children =>
|
|
children.filter(c => isAllowed(c.to));
|
|
|
|
const renderIcon = icon => ({
|
|
component: typeof icon === 'object' ? icon : Icon,
|
|
props: typeof icon === 'string' ? { icon } : null,
|
|
});
|
|
|
|
const transition = computed(() =>
|
|
skipTransition.value
|
|
? {}
|
|
: {
|
|
enterActiveClass: 'transition-all duration-200 ease-out',
|
|
enterFromClass: 'opacity-0 -translate-y-2 max-h-0',
|
|
enterToClass: 'opacity-100 translate-y-0 max-h-96',
|
|
leaveActiveClass: 'transition-all duration-150 ease-in',
|
|
leaveFromClass: 'opacity-100 translate-y-0 max-h-96',
|
|
leaveToClass: 'opacity-0 -translate-y-2 max-h-0',
|
|
}
|
|
);
|
|
|
|
const accessibleChildren = computed(() => {
|
|
return props.children.filter(child => {
|
|
if (child.children) {
|
|
return child.children.some(subChild => isAllowed(subChild.to));
|
|
}
|
|
return child.to && isAllowed(child.to);
|
|
});
|
|
});
|
|
|
|
onMounted(async () => {
|
|
await nextTick();
|
|
|
|
// Auto-expand subgroup if active child is inside it
|
|
if (props.activeChild) {
|
|
const parentGroup = props.children.find(child =>
|
|
child.children?.some(subChild => subChild.name === props.activeChild.name)
|
|
);
|
|
if (parentGroup) {
|
|
expandedSubGroup.value = parentGroup.name;
|
|
// Wait for the subgroup expansion to render before measuring height
|
|
await nextTick();
|
|
}
|
|
}
|
|
|
|
if (!props.triggerRect) return;
|
|
|
|
const viewportHeight = window.innerHeight;
|
|
const popoverHeight = popoverRef.value?.offsetHeight || 300;
|
|
const { top: triggerTop } = props.triggerRect;
|
|
|
|
// Adjust position if popover would overflow viewport
|
|
topPosition.value =
|
|
triggerTop + popoverHeight > viewportHeight - 20
|
|
? Math.max(20, viewportHeight - popoverHeight - 20)
|
|
: triggerTop;
|
|
|
|
await nextTick();
|
|
skipTransition.value = false;
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<TeleportWithDirection>
|
|
<div
|
|
ref="popoverRef"
|
|
class="fixed z-[100] min-w-[200px] max-w-[280px]"
|
|
:style="{
|
|
[isRTL ? 'right' : 'left']: `${sidebarWidth + 8}px`,
|
|
top: `${topPosition}px`,
|
|
}"
|
|
@mouseenter="emit('mouseenter')"
|
|
@mouseleave="emit('mouseleave')"
|
|
>
|
|
<div
|
|
class="bg-n-alpha-3 backdrop-blur-[100px] outline outline-1 -outline-offset-1 w-56 outline-n-weak rounded-xl shadow-lg py-2 px-2"
|
|
>
|
|
<div
|
|
class="px-2 py-1.5 text-xs font-medium text-n-slate-11 uppercase tracking-wider border-b border-n-weak mb-1"
|
|
>
|
|
{{ label }}
|
|
</div>
|
|
<ul
|
|
class="m-0 p-0 list-none max-h-[400px] overflow-y-auto no-scrollbar"
|
|
>
|
|
<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)"
|
|
>
|
|
<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>
|
|
<Transition v-bind="transition">
|
|
<ul
|
|
v-if="expandedSubGroup === child.name"
|
|
class="m-0 p-0 list-none ltr:pl-4 rtl:pr-4 mt-1 overflow-hidden"
|
|
>
|
|
<li
|
|
v-for="subChild in getAccessibleSubChildren(child.children)"
|
|
:key="subChild.name"
|
|
class="py-0.5"
|
|
>
|
|
<button
|
|
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-sm text-left rtl:text-right transition-colors duration-150 ease-out"
|
|
:class="{
|
|
'text-n-slate-12 bg-n-alpha-2': isActive(subChild),
|
|
'text-n-slate-11 hover:bg-n-alpha-2':
|
|
!isActive(subChild),
|
|
}"
|
|
@click="navigateAndClose(subChild.to)"
|
|
>
|
|
<component
|
|
:is="renderIcon(subChild.icon).component"
|
|
v-if="subChild.icon"
|
|
v-bind="renderIcon(subChild.icon).props"
|
|
class="size-4 flex-shrink-0"
|
|
/>
|
|
<span class="flex-1 truncate">{{ subChild.label }}</span>
|
|
<SidebarUnreadBadge :count="subChild.badgeCount" />
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</Transition>
|
|
</li>
|
|
<!-- Direct child item -->
|
|
<li v-else class="py-0.5">
|
|
<button
|
|
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-sm text-left rtl:text-right transition-colors duration-150 ease-out"
|
|
:class="{
|
|
'text-n-slate-12 bg-n-alpha-2': isActive(child),
|
|
'text-n-slate-11 hover:bg-n-alpha-2': !isActive(child),
|
|
}"
|
|
@click="navigateAndClose(child.to)"
|
|
>
|
|
<component
|
|
:is="renderIcon(child.icon).component"
|
|
v-if="child.icon"
|
|
v-bind="renderIcon(child.icon).props"
|
|
class="size-4 flex-shrink-0"
|
|
/>
|
|
<span class="flex-1 truncate">{{ child.label }}</span>
|
|
<SidebarUnreadBadge :count="child.badgeCount" />
|
|
</button>
|
|
</li>
|
|
</template>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</TeleportWithDirection>
|
|
</template>
|