From f33e469e9a2fc4dc417031c4598c7e0463efd721 Mon Sep 17 00:00:00 2001 From: Sony Mathew Date: Wed, 20 May 2026 19:21:25 +0530 Subject: [PATCH] feat: Unread Count: Frontend changes for showing unread count badges (3/3)[CW-6851] (#14372) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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 Screenshot 2026-05-05 at 7 00 11 PM ## 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 --- app/javascript/dashboard/api/conversations.js | 4 + .../dashboard/api/specs/conversations.spec.js | 8 ++ .../components-next/sidebar/ChannelLeaf.vue | 6 ++ .../components-next/sidebar/Sidebar.vue | 37 ++++++- .../sidebar/SidebarCollapsedPopover.vue | 3 + .../sidebar/SidebarGroupLeaf.vue | 7 +- .../sidebar/SidebarUnreadBadge.vue | 27 +++++ .../sidebar/specs/ChannelLeaf.spec.js | 38 +++++++ .../sidebar/specs/SidebarGroupLeaf.spec.js | 76 ++++++++++++++ app/javascript/dashboard/featureFlags.js | 1 + .../dashboard/helper/actionCable.js | 56 +++++++++++ .../helper/specs/actionCable.spec.js | 98 ++++++++++++++++++- app/javascript/dashboard/store/index.js | 2 + .../store/modules/conversationUnreadCounts.js | 70 +++++++++++++ .../conversationUnreadCounts/actions.spec.js | 53 ++++++++++ .../conversationUnreadCounts/getters.spec.js | 51 ++++++++++ .../mutations.spec.js | 48 +++++++++ .../dashboard/store/mutation-types.js | 3 + app/models/account.rb | 5 + .../conversations/unread_counts/refresher.rb | 2 +- .../unread_counts/refresher_spec.rb | 37 +++++++ 21 files changed, 626 insertions(+), 6 deletions(-) create mode 100644 app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue create mode 100644 app/javascript/dashboard/components-next/sidebar/specs/ChannelLeaf.spec.js create mode 100644 app/javascript/dashboard/components-next/sidebar/specs/SidebarGroupLeaf.spec.js create mode 100644 app/javascript/dashboard/store/modules/conversationUnreadCounts.js create mode 100644 app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js create mode 100644 app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js create mode 100644 app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js diff --git a/app/javascript/dashboard/api/conversations.js b/app/javascript/dashboard/api/conversations.js index 876103694..1de9aee29 100644 --- a/app/javascript/dashboard/api/conversations.js +++ b/app/javascript/dashboard/api/conversations.js @@ -13,6 +13,10 @@ class ConversationApi extends ApiClient { updateLabels(conversationID, labels) { return axios.post(`${this.url}/${conversationID}/labels`, { labels }); } + + getUnreadCounts() { + return axios.get(`${this.url}/unread_counts`); + } } export default new ConversationApi(); diff --git a/app/javascript/dashboard/api/specs/conversations.spec.js b/app/javascript/dashboard/api/specs/conversations.spec.js index 7ae4eb774..686db4098 100644 --- a/app/javascript/dashboard/api/specs/conversations.spec.js +++ b/app/javascript/dashboard/api/specs/conversations.spec.js @@ -11,6 +11,7 @@ describe('#ConversationApi', () => { expect(conversationsAPI).toHaveProperty('delete'); expect(conversationsAPI).toHaveProperty('getLabels'); expect(conversationsAPI).toHaveProperty('updateLabels'); + expect(conversationsAPI).toHaveProperty('getUnreadCounts'); }); describe('API calls', () => { @@ -47,5 +48,12 @@ describe('#ConversationApi', () => { } ); }); + + it('#getUnreadCounts', () => { + conversationsAPI.getUnreadCounts(); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/conversations/unread_counts' + ); + }); }); }); diff --git a/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue b/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue index 83d1d4e20..a995cf510 100644 --- a/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue +++ b/app/javascript/dashboard/components-next/sidebar/ChannelLeaf.vue @@ -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(() => {
{{ label }}
+
-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 }, diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue b/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue index 1beea47df..18e1aea23 100644 --- a/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue +++ b/app/javascript/dashboard/components-next/sidebar/SidebarCollapsedPopover.vue @@ -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" /> {{ subChild.label }} + @@ -188,6 +190,7 @@ onMounted(async () => { class="size-4 flex-shrink-0" /> {{ child.label }} + diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue b/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue index b1b8dd740..31e41a6c2 100644 --- a/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue +++ b/app/javascript/dashboard/components-next/sidebar/SidebarGroupLeaf.vue @@ -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(() => { diff --git a/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue b/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue new file mode 100644 index 000000000..d9329fdaf --- /dev/null +++ b/app/javascript/dashboard/components-next/sidebar/SidebarUnreadBadge.vue @@ -0,0 +1,27 @@ + + + diff --git a/app/javascript/dashboard/components-next/sidebar/specs/ChannelLeaf.spec.js b/app/javascript/dashboard/components-next/sidebar/specs/ChannelLeaf.spec.js new file mode 100644 index 000000000..03d963134 --- /dev/null +++ b/app/javascript/dashboard/components-next/sidebar/specs/ChannelLeaf.spec.js @@ -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 + ); + }); +}); diff --git a/app/javascript/dashboard/components-next/sidebar/specs/SidebarGroupLeaf.spec.js b/app/javascript/dashboard/components-next/sidebar/specs/SidebarGroupLeaf.spec.js new file mode 100644 index 000000000..79b62d191 --- /dev/null +++ b/app/javascript/dashboard/components-next/sidebar/specs/SidebarGroupLeaf.spec.js @@ -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: '
  • ', +}; + +const RouterLinkStub = { + props: ['to'], + template: '', +}; + +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'); + }); +}); diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index b97e30984..00a79763b 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -46,6 +46,7 @@ export const FEATURE_FLAGS = { COMPANIES: 'companies', ADVANCED_SEARCH: 'advanced_search', CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes', + CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts', }; export const PREMIUM_FEATURES = [ diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index 6feebb35d..74b82105c 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -4,14 +4,18 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi import { BUS_EVENTS } from 'shared/constants/busEvents'; import { emitter } from 'shared/helpers/mitt'; import { useImpersonation } from 'dashboard/composables/useImpersonation'; +import { FEATURE_FLAGS } from 'dashboard/featureFlags'; const { isImpersonating } = useImpersonation(); +const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000; class ActionCableConnector extends BaseActionCableConnector { constructor(app, pubsubToken) { const { websocketURL = '' } = window.chatwootConfig || {}; super(app, pubsubToken, websocketURL); this.CancelTyping = []; + this.lastUnreadCountsFetchAt = null; + this.unreadCountsFetchTimer = null; this.events = { 'message.created': this.onMessageCreated, 'message.updated': this.onMessageUpdated, @@ -32,6 +36,8 @@ class ActionCableConnector extends BaseActionCableConnector { 'notification.updated': this.onNotificationUpdated, 'conversation.read': this.onConversationRead, 'conversation.updated': this.onConversationUpdated, + 'conversation.unread_count_changed': + this.onConversationUnreadCountChanged, 'account.cache_invalidated': this.onCacheInvalidate, 'account.enrichment_completed': this.onEnrichmentCompleted, 'copilot.message.created': this.onCopilotMessageCreated, @@ -120,6 +126,56 @@ class ActionCableConnector extends BaseActionCableConnector { this.fetchConversationStats(); }; + onConversationUnreadCountChanged = () => { + this.throttledFetchConversationUnreadCounts(); + }; + + throttledFetchConversationUnreadCounts = () => { + const now = Date.now(); + const elapsedTime = now - this.lastUnreadCountsFetchAt; + + if ( + this.lastUnreadCountsFetchAt === null || + elapsedTime >= UNREAD_COUNTS_REFETCH_THROTTLE_MS + ) { + this.clearUnreadCountsFetchTimer(); + this.fetchConversationUnreadCounts(); + return; + } + + if (this.unreadCountsFetchTimer) return; + + this.unreadCountsFetchTimer = setTimeout(() => { + this.unreadCountsFetchTimer = null; + this.fetchConversationUnreadCounts(); + }, UNREAD_COUNTS_REFETCH_THROTTLE_MS - elapsedTime); + }; + + clearUnreadCountsFetchTimer = () => { + if (!this.unreadCountsFetchTimer) return; + + clearTimeout(this.unreadCountsFetchTimer); + this.unreadCountsFetchTimer = null; + }; + + fetchConversationUnreadCounts = () => { + if (!this.isConversationUnreadCountsEnabled()) return; + + this.lastUnreadCountsFetchAt = Date.now(); + this.app.$store.dispatch('conversationUnreadCounts/get'); + }; + + isConversationUnreadCountsEnabled = () => { + const accountId = this.app.$store.getters.getCurrentAccountId; + const isFeatureEnabled = + this.app.$store.getters['accounts/isFeatureEnabledonAccount']; + + return isFeatureEnabled?.( + accountId, + FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS + ); + }; + onTypingOn = ({ conversation, user }) => { const conversationId = conversation.id; diff --git a/app/javascript/dashboard/helper/specs/actionCable.spec.js b/app/javascript/dashboard/helper/specs/actionCable.spec.js index 4ad8a52c6..8ba411a5f 100644 --- a/app/javascript/dashboard/helper/specs/actionCable.spec.js +++ b/app/javascript/dashboard/helper/specs/actionCable.spec.js @@ -1,4 +1,4 @@ -import { describe, it, beforeEach, expect, vi } from 'vitest'; +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; import ActionCableConnector from '../actionCable'; vi.mock('shared/helpers/mitt', () => ({ @@ -30,12 +30,17 @@ describe('ActionCableConnector - Copilot Tests', () => { dispatch: mockDispatch, getters: { getCurrentAccountId: 1, + 'accounts/isFeatureEnabledonAccount': vi.fn(() => true), }, }, }; actionCable = ActionCableConnector.init(store.$store, 'test-token'); }); + + afterEach(() => { + vi.useRealTimers(); + }); describe('copilot event handlers', () => { it('should register the copilot.message.created event handler', () => { expect(Object.keys(actionCable.events)).toContain( @@ -64,4 +69,95 @@ describe('ActionCableConnector - Copilot Tests', () => { ); }); }); + + describe('conversation unread count event handlers', () => { + it('should register the conversation.unread_count_changed event handler', () => { + expect(Object.keys(actionCable.events)).toContain( + 'conversation.unread_count_changed' + ); + expect(actionCable.events['conversation.unread_count_changed']).toBe( + actionCable.onConversationUnreadCountChanged + ); + }); + + it('should refetch unread counts when unread count changes', () => { + actionCable.onReceived({ + event: 'conversation.unread_count_changed', + data: { account_id: 1 }, + }); + + expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get'); + }); + + it('does not refetch unread counts when unread count feature is disabled', () => { + store.$store.getters[ + 'accounts/isFeatureEnabledonAccount' + ].mockReturnValue(false); + + actionCable.onReceived({ + event: 'conversation.unread_count_changed', + data: { account_id: 1 }, + }); + + expect(mockDispatch).not.toHaveBeenCalledWith( + 'conversationUnreadCounts/get' + ); + }); + + it('should throttle unread count refetches for repeated events', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + + actionCable.onReceived({ + event: 'conversation.unread_count_changed', + data: { account_id: 1 }, + }); + actionCable.onReceived({ + event: 'conversation.unread_count_changed', + data: { account_id: 1 }, + }); + actionCable.onReceived({ + event: 'conversation.unread_count_changed', + data: { account_id: 1 }, + }); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(4999); + expect(mockDispatch).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1); + expect(mockDispatch).toHaveBeenCalledTimes(2); + expect(mockDispatch).toHaveBeenLastCalledWith( + 'conversationUnreadCounts/get' + ); + }); + + it('clears pending unread count refetch before immediate refetch', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + + actionCable.onReceived({ + event: 'conversation.unread_count_changed', + data: { account_id: 1 }, + }); + + vi.advanceTimersByTime(1000); + actionCable.onReceived({ + event: 'conversation.unread_count_changed', + data: { account_id: 1 }, + }); + + vi.setSystemTime(new Date('2026-01-01T00:00:06Z')); + actionCable.onReceived({ + event: 'conversation.unread_count_changed', + data: { account_id: 1 }, + }); + + expect(mockDispatch).toHaveBeenCalledTimes(2); + + vi.advanceTimersByTime(4000); + expect(mockDispatch).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js index d56958eb5..054a823c6 100755 --- a/app/javascript/dashboard/store/index.js +++ b/app/javascript/dashboard/store/index.js @@ -25,6 +25,7 @@ import conversations from './modules/conversations'; import conversationSearch from './modules/conversationSearch'; import conversationStats from './modules/conversationStats'; import conversationTypingStatus from './modules/conversationTypingStatus'; +import conversationUnreadCounts from './modules/conversationUnreadCounts'; import conversationWatchers from './modules/conversationWatchers'; import csat from './modules/csat'; import customRole from './modules/customRole'; @@ -88,6 +89,7 @@ export default createStore({ conversationSearch, conversationStats, conversationTypingStatus, + conversationUnreadCounts, conversationWatchers, csat, customRole, diff --git a/app/javascript/dashboard/store/modules/conversationUnreadCounts.js b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js new file mode 100644 index 000000000..0503c0806 --- /dev/null +++ b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js @@ -0,0 +1,70 @@ +import ConversationAPI from '../../api/conversations'; +import types from '../mutation-types'; + +export const state = { + inboxes: {}, + labels: {}, + teams: {}, +}; + +const normalizeCounts = counts => { + return Object.entries(counts || {}).reduce((result, [id, count]) => { + const parsedCount = Number(count); + if (Number.isFinite(parsedCount) && parsedCount > 0) { + result[String(id)] = parsedCount; + } + + return result; + }, {}); +}; + +export const getters = { + getInboxUnreadCount: $state => inboxId => { + return $state.inboxes[String(inboxId)] || 0; + }, + getLabelUnreadCount: $state => labelId => { + return $state.labels[String(labelId)] || 0; + }, + getTeamUnreadCount: $state => teamId => { + return $state.teams[String(teamId)] || 0; + }, + getInboxUnreadCounts($state) { + return $state.inboxes; + }, + getLabelUnreadCounts($state) { + return $state.labels; + }, + getTeamUnreadCounts($state) { + return $state.teams; + }, +}; + +export const actions = { + get: async function getUnreadCounts({ commit }) { + try { + const response = await ConversationAPI.getUnreadCounts(); + commit(types.SET_CONVERSATION_UNREAD_COUNTS, response.data.payload); + } catch (error) { + // Ignore errors so the sidebar can continue rendering without badges. + } + }, + clear({ commit }) { + commit(types.SET_CONVERSATION_UNREAD_COUNTS, {}); + }, +}; + +export const mutations = { + [types.SET_CONVERSATION_UNREAD_COUNTS]($state, payload = {}) { + $state.inboxes = normalizeCounts(payload.inboxes); + $state.labels = normalizeCounts(payload.labels); + $state.teams = normalizeCounts(payload.teams); + }, +}; + +export default { + namespaced: true, + state, + getters, + actions, + mutations, +}; diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js new file mode 100644 index 000000000..3100cdd10 --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js @@ -0,0 +1,53 @@ +import axios from 'axios'; +import { actions } from '../../conversationUnreadCounts'; +import types from '../../../mutation-types'; + +const commit = vi.fn(); +global.axios = axios; +vi.mock('axios'); + +describe('#actions', () => { + beforeEach(() => { + commit.mockClear(); + axios.get.mockReset(); + }); + + describe('#get', () => { + it('commits unread counts when API is successful', async () => { + const payload = { + inboxes: { 1: '2' }, + labels: { 3: 4 }, + teams: { 5: 6 }, + }; + axios.get.mockResolvedValue({ data: { payload } }); + + await actions.get({ commit }); + + expect(axios.get).toHaveBeenCalledWith( + '/api/v1/conversations/unread_counts' + ); + expect(commit.mock.calls).toEqual([ + [types.SET_CONVERSATION_UNREAD_COUNTS, payload], + ]); + }); + + it('does not commit when API fails', async () => { + axios.get.mockRejectedValue({ message: 'Incorrect header' }); + + await actions.get({ commit }); + + expect(commit).not.toHaveBeenCalled(); + }); + }); + + describe('#clear', () => { + it('clears unread counts', () => { + actions.clear({ commit }); + + expect(commit).toHaveBeenCalledWith( + types.SET_CONVERSATION_UNREAD_COUNTS, + {} + ); + }); + }); +}); diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js new file mode 100644 index 000000000..a3e74fc37 --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js @@ -0,0 +1,51 @@ +import { getters } from '../../conversationUnreadCounts'; + +describe('#getters', () => { + it('returns inbox unread count by id', () => { + const state = { + inboxes: { 1: 2 }, + labels: {}, + teams: {}, + }; + + expect(getters.getInboxUnreadCount(state)(1)).toBe(2); + expect(getters.getInboxUnreadCount(state)('1')).toBe(2); + expect(getters.getInboxUnreadCount(state)(2)).toBe(0); + }); + + it('returns label unread count by id', () => { + const state = { + inboxes: {}, + labels: { 3: 4 }, + teams: {}, + }; + + expect(getters.getLabelUnreadCount(state)(3)).toBe(4); + expect(getters.getLabelUnreadCount(state)('3')).toBe(4); + expect(getters.getLabelUnreadCount(state)(4)).toBe(0); + }); + + it('returns team unread count by id', () => { + const state = { + inboxes: {}, + labels: {}, + teams: { 5: 6 }, + }; + + expect(getters.getTeamUnreadCount(state)(5)).toBe(6); + expect(getters.getTeamUnreadCount(state)('5')).toBe(6); + expect(getters.getTeamUnreadCount(state)(6)).toBe(0); + }); + + it('returns unread count maps', () => { + const state = { + inboxes: { 1: 2 }, + labels: { 3: 4 }, + teams: { 5: 6 }, + }; + + expect(getters.getInboxUnreadCounts(state)).toEqual({ 1: 2 }); + expect(getters.getLabelUnreadCounts(state)).toEqual({ 3: 4 }); + expect(getters.getTeamUnreadCounts(state)).toEqual({ 5: 6 }); + }); +}); diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js new file mode 100644 index 000000000..3f7e2b1ec --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js @@ -0,0 +1,48 @@ +import types from '../../../mutation-types'; +import { mutations } from '../../conversationUnreadCounts'; + +describe('#mutations', () => { + describe('#SET_CONVERSATION_UNREAD_COUNTS', () => { + it('normalizes unread count payload', () => { + const state = { inboxes: {}, labels: {}, teams: {} }; + + mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, { + inboxes: { + 1: '2', + 2: 0, + 3: 'invalid', + }, + labels: { + 4: 5, + 5: -1, + }, + teams: { + 6: '7', + 7: 0, + }, + }); + + expect(state).toEqual({ + inboxes: { 1: 2 }, + labels: { 4: 5 }, + teams: { 6: 7 }, + }); + }); + + it('clears counts when payload is empty', () => { + const state = { + inboxes: { 1: 2 }, + labels: { 4: 5 }, + teams: { 6: 7 }, + }; + + mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {}); + + expect(state).toEqual({ + inboxes: {}, + labels: {}, + teams: {}, + }); + }); + }); +}); diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index 82858b6b6..1597b7ea6 100644 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -187,6 +187,9 @@ export default { CLEAR_SELECTED_CONVERSATION_IDS: 'CLEAR_SELECTED_CONVERSATION_IDS', REMOVE_SELECTED_CONVERSATION_IDS: 'REMOVE_SELECTED_CONVERSATION_IDS', + // Conversation Unread Counts + SET_CONVERSATION_UNREAD_COUNTS: 'SET_CONVERSATION_UNREAD_COUNTS', + // Reports SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS', SET_HEATMAP_DATA: 'SET_HEATMAP_DATA', diff --git a/app/models/account.rb b/app/models/account.rb index efaca8850..667058a2f 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -169,6 +169,11 @@ class Account < ApplicationRecord Redis::Alfred.exists?(enrichment_key) ? 'enrichment' : step end + def reset_cache_keys + super + clear_unread_conversation_counts_cache + end + private def notify_creation diff --git a/app/services/conversations/unread_counts/refresher.rb b/app/services/conversations/unread_counts/refresher.rb index 9456cab55..9e49ecd28 100644 --- a/app/services/conversations/unread_counts/refresher.rb +++ b/app/services/conversations/unread_counts/refresher.rb @@ -114,7 +114,7 @@ class Conversations::UnreadCounts::Refresher end def affected_assignee_ids - return [conversation.assignee_id].compact unless changed_attribute?(:assignee_id) + return [conversation.assignee_id] unless changed_attribute?(:assignee_id) [previous_value_for(:assignee_id), conversation.assignee_id].uniq end diff --git a/spec/services/conversations/unread_counts/refresher_spec.rb b/spec/services/conversations/unread_counts/refresher_spec.rb index 9b22c2186..5f361e7aa 100644 --- a/spec/services/conversations/unread_counts/refresher_spec.rb +++ b/spec/services/conversations/unread_counts/refresher_spec.rb @@ -119,6 +119,43 @@ RSpec.describe Conversations::UnreadCounts::Refresher do expect(store).to have_received(:remove_assignment_membership).with(hash_including(assignee_ids: [assignee.id])) end + it 'moves assignment-aware unassigned label membership when labels change' do + conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title]) + Conversations::UnreadCounts::Builder.new(account).build_assignment! + + conversation.update_labels([new_label.title]) + result = described_class.new( + conversation.reload, + changed_attributes: { label_list: [[label.title], [new_label.title]] } + ).perform + + expect(result).to be(true) + expect(store.counts_for_keys([ + store.label_inbox_unassigned_key(account.id, label.id, inbox.id), + store.label_inbox_unassigned_key(account.id, new_label.id, inbox.id) + ])).to eq( + store.label_inbox_unassigned_key(account.id, label.id, inbox.id) => 0, + store.label_inbox_unassigned_key(account.id, new_label.id, inbox.id) => 1 + ) + end + + it 'removes assignment-aware unassigned membership when conversation is resolved' do + conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title]) + Conversations::UnreadCounts::Builder.new(account).build_assignment! + + conversation.update!(status: :resolved) + result = described_class.new(conversation.reload, changed_attributes: { status: %w[open resolved] }).perform + + expect(result).to be(true) + expect(store.counts_for_keys([ + store.inbox_unassigned_key(account.id, inbox.id), + store.label_inbox_unassigned_key(account.id, label.id, inbox.id) + ])).to eq( + store.inbox_unassigned_key(account.id, inbox.id) => 0, + store.label_inbox_unassigned_key(account.id, label.id, inbox.id) => 0 + ) + end + it 'moves assignment-aware team membership when team changes' do create(:team_member, user: assignee, team: new_team) conversation = create_unread_conversation(account: account, inbox: inbox, assignee: assignee, team: team)