chore: Enable Participating tab for conversations (#11714)

## Summary

This PR enables the **Participating** conversation view in the main
sidebar and keeps the behavior aligned with existing conversation views.

## What changed

- Added **Participating** under Conversations in the new sidebar.
- Added a guard in conversation realtime `addConversation` flow so
generic `conversation.created` events are not injected while the user is
on Participating view.
- Added participating route mapping in conversation-list redirect helper
so list redirects resolve correctly to `/participating/conversations`.

## Scope notes

- Kept changes minimal and consistent with current `develop` behavior.
- No additional update-event filtering was added beyond what existing
views already do.

---------


Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
Sojan Jose
2026-04-15 17:03:39 +05:30
committed by GitHub
parent 3f9f054c43
commit b96bf41234
13 changed files with 216 additions and 9 deletions

View File

@@ -250,6 +250,12 @@ const menuItems = computed(() => {
activeOn: ['conversation_through_mentions'],
to: accountScopedRoute('conversation_mentions'),
},
{
name: 'Participating',
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
activeOn: ['conversation_through_participating'],
to: accountScopedRoute('conversation_participating'),
},
{
name: 'Unattended',
activeOn: ['conversation_through_unattended'],

View File

@@ -56,6 +56,7 @@ import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHe
import { conversationListPageURL } from '../helper/URLHelper';
import {
isOnMentionsView,
isOnParticipatingView,
isOnUnattendedView,
} from '../store/modules/conversations/helpers/actionHelpers';
import {
@@ -113,6 +114,7 @@ const chatLists = useMapGetter('getFilteredConversations');
const mineChatsList = useMapGetter('getMineChats');
const allChatList = useMapGetter('getAllStatusChats');
const unAssignedChatsList = useMapGetter('getUnAssignedChats');
const participatingChatsList = useMapGetter('getParticipatingChats');
const chatListLoading = useMapGetter('getChatListLoadingStatus');
const activeInbox = useMapGetter('getSelectedInbox');
const conversationStats = useMapGetter('conversationStats/getStats');
@@ -296,13 +298,15 @@ const pageTitle = computed(() => {
if (props.label) {
return `#${props.label}`;
}
if (props.conversationType === 'mention') {
if (props.conversationType === wootConstants.CONVERSATION_TYPE.MENTION) {
return t('CHAT_LIST.MENTION_HEADING');
}
if (props.conversationType === 'participating') {
if (
props.conversationType === wootConstants.CONVERSATION_TYPE.PARTICIPATING
) {
return t('CONVERSATION_PARTICIPANTS.SIDEBAR_MENU_TITLE');
}
if (props.conversationType === 'unattended') {
if (props.conversationType === wootConstants.CONVERSATION_TYPE.UNATTENDED) {
return t('CHAT_LIST.UNATTENDED_HEADING');
}
if (hasActiveFolders.value) {
@@ -311,12 +315,30 @@ const pageTitle = computed(() => {
return t('CHAT_LIST.TAB_HEADING');
});
function filterByAssigneeTab(conversations) {
if (activeAssigneeTab.value === wootConstants.ASSIGNEE_TYPE.ME) {
return conversations.filter(
c => c.meta?.assignee?.id === currentUser.value?.id
);
}
if (activeAssigneeTab.value === wootConstants.ASSIGNEE_TYPE.UNASSIGNED) {
return conversations.filter(c => !c.meta?.assignee);
}
return [...conversations];
}
const conversationList = computed(() => {
let localConversationList = [];
if (!hasAppliedFiltersOrActiveFolders.value) {
const filters = conversationFilters.value;
if (activeAssigneeTab.value === 'me') {
if (
props.conversationType === wootConstants.CONVERSATION_TYPE.PARTICIPATING
) {
localConversationList = filterByAssigneeTab(
participatingChatsList.value(filters)
);
} else if (activeAssigneeTab.value === 'me') {
localConversationList = [...mineChatsList.value(filters)];
} else if (activeAssigneeTab.value === 'unassigned') {
localConversationList = [...unAssignedChatsList.value(filters)];
@@ -637,9 +659,11 @@ function redirectToConversationList() {
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
conversationType = wootConstants.CONVERSATION_TYPE.MENTION;
} else if (isOnParticipatingView({ route: { name } })) {
conversationType = wootConstants.CONVERSATION_TYPE.PARTICIPATING;
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
conversationType = wootConstants.CONVERSATION_TYPE.UNATTENDED;
}
router.push(
conversationListPageURL({

View File

@@ -45,6 +45,7 @@ const backButtonUrl = computed(() => {
const conversationTypeMap = {
conversation_through_mentions: 'mention',
conversation_through_participating: 'participating',
conversation_through_unattended: 'unattended',
};
return conversationListPageURL({

View File

@@ -12,6 +12,11 @@ export default {
SNOOZED: 'snoozed',
ALL: 'all',
},
CONVERSATION_TYPE: {
MENTION: 'mention',
PARTICIPATING: 'participating',
UNATTENDED: 'unattended',
},
SORT_BY_TYPE: {
LAST_ACTIVITY_AT_ASC: 'last_activity_at_asc',
LAST_ACTIVITY_AT_DESC: 'last_activity_at_desc',

View File

@@ -51,6 +51,7 @@ export const conversationListPageURL = ({
} else if (conversationType) {
const urlMap = {
mention: 'mentions/conversations',
participating: 'participating/conversations',
unattended: 'unattended/conversations',
};
url = `accounts/${accountId}/${urlMap[conversationType]}`;

View File

@@ -40,6 +40,15 @@ describe('#URL Helpers', () => {
'/app/accounts/1/custom_view/1'
);
});
it('should return url to participating conversations', () => {
expect(
conversationListPageURL({
accountId: 1,
conversationType: 'participating',
})
).toBe('/app/accounts/1/participating/conversations');
});
});
describe('conversationUrl', () => {
it('should return direct conversation URL if activeInbox is nil', () => {

View File

@@ -6,6 +6,7 @@ import { createPendingMessage } from 'dashboard/helper/commons';
import {
buildConversationList,
isOnMentionsView,
isOnParticipatingView,
isOnUnattendedView,
isOnFoldersView,
} from './helpers/actionHelpers';
@@ -371,6 +372,7 @@ const actions = {
!hasAppliedFilters &&
!isOnFoldersView(rootState) &&
!isOnMentionsView(rootState) &&
!isOnParticipatingView(rootState) &&
!isOnUnattendedView(rootState) &&
isMatchingInboxFilter
) {
@@ -395,6 +397,7 @@ const actions = {
const {
meta: { sender },
} = conversation;
commit(types.UPDATE_CONVERSATION, conversation);
dispatch('conversationLabels/setConversationLabel', {

View File

@@ -102,6 +102,19 @@ const getters = {
return isUnAssigned && shouldFilter;
});
},
getParticipatingChats: (_state, _, __, rootGetters) => activeFilters => {
const currentUserId = rootGetters.getCurrentUser?.id;
const getWatchers = rootGetters['conversationWatchers/getByConversationId'];
return _state.allConversations.filter(conversation => {
const watchers = getWatchers(conversation.id);
// Watchers are only loaded for the conversation open in the detail
// panel. If loaded and current user is not in them, filter it out.
if (watchers && !watchers.some(w => w.id === currentUserId)) {
return false;
}
return applyPageFilters(conversation, activeFilters);
});
},
getAllStatusChats: (_state, _, __, rootGetters) => activeFilters => {
const currentUser = rootGetters.getCurrentUser;
const currentUserId = rootGetters.getCurrentUser.id;

View File

@@ -30,6 +30,14 @@ export const isOnUnattendedView = ({ route: { name: routeName } }) => {
return UNATTENDED_ROUTES.includes(routeName);
};
export const isOnParticipatingView = ({ route: { name: routeName } }) => {
const PARTICIPATING_ROUTES = [
'conversation_participating',
'conversation_through_participating',
];
return PARTICIPATING_ROUTES.includes(routeName);
};
export const isOnFoldersView = ({ route: { name: routeName } }) => {
const FOLDER_ROUTES = [
'folder_conversations',

View File

@@ -1,4 +1,8 @@
import { isOnMentionsView, isOnFoldersView } from '../actionHelpers';
import {
isOnMentionsView,
isOnFoldersView,
isOnParticipatingView,
} from '../actionHelpers';
describe('#isOnMentionsView', () => {
it('return valid responses when passing the state', () => {
@@ -24,3 +28,19 @@ describe('#isOnFoldersView', () => {
);
});
});
describe('#isOnParticipatingView', () => {
it('return valid responses when passing the state', () => {
expect(
isOnParticipatingView({ route: { name: 'conversation_participating' } })
).toBe(true);
expect(
isOnParticipatingView({
route: { name: 'conversation_through_participating' },
})
).toBe(true);
expect(
isOnParticipatingView({ route: { name: 'conversation_messages' } })
).toBe(false);
});
});

View File

@@ -258,7 +258,11 @@ export const mutations = {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
}
} else {
_state.allConversations.push(conversation);
const { conversationType } = _state.conversationFilters || {};
const { MENTION, PARTICIPATING } = wootConstants.CONVERSATION_TYPE;
if (![MENTION, PARTICIPATING].includes(conversationType)) {
_state.allConversations.push(conversation);
}
}
},

View File

@@ -183,6 +183,73 @@ describe('#getters', () => {
]);
});
});
describe('#getParticipatingChats', () => {
const conversationList = [
{ id: 1, inbox_id: 2, status: 1, meta: { assignee: { id: 1 } } },
{ id: 2, inbox_id: 2, status: 1, meta: {} },
{ id: 3, inbox_id: 3, status: 1, meta: { assignee: { id: 2 } } },
];
it('returns all conversations when watchers are not loaded', () => {
const state = {
allConversations: conversationList,
participatingConversationIds: {},
};
const rootGetters = {
getCurrentUser: { id: 1 },
'conversationWatchers/getByConversationId': () => undefined,
};
const result = getters.getParticipatingChats(
state,
{},
{},
rootGetters
)({ status: 1 });
expect(result).toEqual(conversationList);
});
it('filters out conversation when watchers loaded and user not participating', () => {
const state = {
allConversations: conversationList,
participatingConversationIds: {},
};
const rootGetters = {
getCurrentUser: { id: 1 },
'conversationWatchers/getByConversationId': id => {
if (id === 2) return [{ id: 3 }];
return undefined;
},
};
const result = getters.getParticipatingChats(
state,
{},
{},
rootGetters
)({ status: 1 });
expect(result).toEqual([conversationList[0], conversationList[2]]);
});
it('keeps conversation when watchers loaded and user is participating', () => {
const state = {
allConversations: conversationList,
participatingConversationIds: {},
};
const rootGetters = {
getCurrentUser: { id: 1 },
'conversationWatchers/getByConversationId': id => {
if (id === 1) return [{ id: 1 }, { id: 2 }];
return undefined;
},
};
const result = getters.getParticipatingChats(
state,
{},
{},
rootGetters
)({ status: 1 });
expect(result).toEqual(conversationList);
});
});
describe('#getConversationById', () => {
it('get conversations based on id', () => {
const state = {

View File

@@ -786,9 +786,55 @@ describe('#mutations', () => {
});
});
it('should add conversation if not found', () => {
it('should add conversation if not found on normal view', () => {
const state = {
allConversations: [],
conversationFilters: {},
};
const conversation = {
id: 1,
status: 'open',
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations).toEqual([conversation]);
});
it('should not add conversation if not found on participating view', () => {
const state = {
allConversations: [],
conversationFilters: { conversationType: 'participating' },
};
const conversation = {
id: 1,
status: 'open',
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations).toEqual([]);
});
it('should not add conversation if not found on mention view', () => {
const state = {
allConversations: [],
conversationFilters: { conversationType: 'mention' },
};
const conversation = {
id: 1,
status: 'open',
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations).toEqual([]);
});
it('should add conversation if not found on unattended view', () => {
const state = {
allConversations: [],
conversationFilters: { conversationType: 'unattended' },
};
const conversation = {