Merge branch 'develop' into feat/google-play-reviews

This commit is contained in:
Muhsin Keloth
2026-05-22 09:03:26 +04:00
committed by GitHub
108 changed files with 5051 additions and 107 deletions
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import ChannelIcon from 'next/icon/ChannelIcon.vue';
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
const props = defineProps({
label: {
@@ -17,6 +18,10 @@ const props = defineProps({
type: Object,
required: true,
},
badgeCount: {
type: [Number, String],
default: 0,
},
});
const reauthorizationRequired = computed(() => {
@@ -29,6 +34,7 @@ const reauthorizationRequired = computed(() => {
<ChannelIcon :inbox="inbox" class="size-4" />
</span>
<div class="flex-1 truncate min-w-0">{{ label }}</div>
<SidebarUnreadBadge :count="badgeCount" />
<div
v-if="reauthorizationRequired"
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
@@ -1,5 +1,5 @@
<script setup>
import { h, ref, computed, onMounted } from 'vue';
import { h, ref, computed, onMounted, watch } from 'vue';
import { provideSidebarContext, useSidebarResize } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd';
@@ -61,6 +61,24 @@ const hasAdvancedAssignment = computed(() => {
);
});
const hasConversationUnreadCounts = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
});
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
if (!currentAccountId) return;
if (!isEnabled) {
store.dispatch('conversationUnreadCounts/clear');
return;
}
store.dispatch('conversationUnreadCounts/get');
};
const toggleShortcutModalFn = show => {
if (show) {
emit('openKeyShortcutModal');
@@ -157,6 +175,15 @@ useEventListener(document, 'touchend', onResizeEnd);
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar');
const getInboxUnreadCount = useMapGetter(
'conversationUnreadCounts/getInboxUnreadCount'
);
const getLabelUnreadCount = useMapGetter(
'conversationUnreadCounts/getLabelUnreadCount'
);
const getTeamUnreadCount = useMapGetter(
'conversationUnreadCounts/getTeamUnreadCount'
);
const teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
@@ -173,6 +200,10 @@ onMounted(() => {
store.dispatch('customViews/get', 'contact');
});
watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
immediate: true,
});
const sortedInboxes = computed(() =>
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
);
@@ -270,6 +301,7 @@ const menuItems = computed(() => {
children: teams.value.map(team => ({
name: `${team.name}-${team.id}`,
label: team.name,
badgeCount: getTeamUnreadCount.value(team.id),
to: accountScopedRoute('team_conversations', { teamId: team.id }),
})),
},
@@ -281,6 +313,7 @@ const menuItems = computed(() => {
children: sortedInboxes.value.map(inbox => ({
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
badgeCount: getInboxUnreadCount.value(inbox.id),
icon: h(ChannelIcon, { inbox, class: 'size-[16px]' }),
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps =>
@@ -288,6 +321,7 @@ const menuItems = computed(() => {
label: leafProps.label,
active: leafProps.active,
inbox,
badgeCount: leafProps.badgeCount,
}),
})),
},
@@ -299,6 +333,7 @@ const menuItems = computed(() => {
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
badgeCount: getLabelUnreadCount.value(label.id),
icon: h('span', {
class: `size-[8px] rounded-sm`,
style: { backgroundColor: label.color },
@@ -5,6 +5,7 @@ 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 },
@@ -166,6 +167,7 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ subChild.label }}</span>
<SidebarUnreadBadge :count="subChild.badgeCount" />
</button>
</li>
</ul>
@@ -188,6 +190,7 @@ onMounted(async () => {
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate">{{ child.label }}</span>
<SidebarUnreadBadge :count="child.badgeCount" />
</button>
</li>
</template>
@@ -3,6 +3,7 @@ import { isVNode, computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import Policy from 'dashboard/components/policy.vue';
import { useSidebarContext } from './provider';
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
const props = defineProps({
label: { type: String, required: true },
@@ -10,6 +11,7 @@ const props = defineProps({
icon: { type: [String, Object], default: null },
active: { type: Boolean, default: false },
component: { type: Function, default: null },
badgeCount: { type: [Number, String], default: 0 },
});
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
@@ -39,15 +41,14 @@ const shouldRenderComponent = computed(() => {
<component
:is="component"
v-if="shouldRenderComponent"
:label
:icon
:active
v-bind="{ label, icon, active, badgeCount }"
/>
<template v-else>
<span v-if="icon" class="size-4 grid place-content-center rounded-full">
<Icon :icon="icon" class="size-4 inline-block" />
</span>
<div class="flex-1 truncate min-w-0 text-sm">{{ label }}</div>
<SidebarUnreadBadge :count="badgeCount" />
</template>
</component>
</Policy>
@@ -0,0 +1,27 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
count: { type: [Number, String], default: 0 },
});
const normalizedCount = computed(() => {
const count = Number(props.count);
return Number.isFinite(count) && count > 0 ? count : 0;
});
const displayCount = computed(() =>
normalizedCount.value > 99 ? '99+' : String(normalizedCount.value)
);
</script>
<template>
<span
v-if="normalizedCount > 0"
data-test-id="sidebar-unread-badge"
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-brand px-1 text-xxs font-medium leading-3 text-white flex-shrink-0"
>
{{ displayCount }}
</span>
<span v-else class="hidden" />
</template>
@@ -0,0 +1,38 @@
import { mount } from '@vue/test-utils';
import ChannelLeaf from '../ChannelLeaf.vue';
const mountChannelLeaf = props =>
mount(ChannelLeaf, {
props: {
label: 'Website',
inbox: { reauthorization_required: false },
...props,
},
global: {
mocks: {
$t: key => key,
},
stubs: {
ChannelIcon: true,
Icon: true,
},
},
});
describe('ChannelLeaf', () => {
it('renders unread badge when count is present', () => {
const wrapper = mountChannelLeaf({ badgeCount: 3 });
const badge = wrapper.find('[data-test-id="sidebar-unread-badge"]');
expect(badge.exists()).toBe(true);
expect(badge.text()).toBe('3');
});
it('does not render unread badge when count is zero', () => {
const wrapper = mountChannelLeaf({ badgeCount: 0 });
expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').exists()).toBe(
false
);
});
});
@@ -0,0 +1,76 @@
import { mount } from '@vue/test-utils';
import { h } from 'vue';
import SidebarGroupLeaf from '../SidebarGroupLeaf.vue';
vi.mock('../provider', () => ({
useSidebarContext: () => ({
resolvePermissions: () => [],
resolveFeatureFlag: () => '',
}),
}));
const PolicyStub = {
props: ['as', 'permissions', 'featureFlag'],
template: '<li><slot /></li>',
};
const RouterLinkStub = {
props: ['to'],
template: '<a><slot /></a>',
};
const mountLeaf = props =>
mount(SidebarGroupLeaf, {
props: {
label: 'Support',
to: '/support',
...props,
},
global: {
stubs: {
Icon: true,
Policy: PolicyStub,
RouterLink: RouterLinkStub,
},
},
});
describe('SidebarGroupLeaf', () => {
it('renders unread badge when count is present', () => {
const wrapper = mountLeaf({ badgeCount: 7 });
const badge = wrapper.find('[data-test-id="sidebar-unread-badge"]');
expect(badge.exists()).toBe(true);
expect(badge.text()).toBe('7');
});
it('does not render unread badge when count is zero', () => {
const wrapper = mountLeaf({ badgeCount: 0 });
expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').exists()).toBe(
false
);
});
it('caps large unread counts', () => {
const wrapper = mountLeaf({ badgeCount: 120 });
expect(wrapper.find('[data-test-id="sidebar-unread-badge"]').text()).toBe(
'99+'
);
});
it('passes unread count to custom leaf components', () => {
const wrapper = mountLeaf({
badgeCount: 4,
component: leafProps =>
h(
'span',
{ 'data-test-id': 'custom-leaf-count' },
leafProps.badgeCount
),
});
expect(wrapper.find('[data-test-id="custom-leaf-count"]').text()).toBe('4');
});
});