+import { computed, ref, watch } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { LocalStorage } from 'shared/helpers/localStorage';
+
+const props = defineProps({
+ knowledge: {
+ type: Object,
+ default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
+ },
+});
+
+const route = useRoute();
+const router = useRouter();
+
+// Dismissal is remembered per assistant for 24 hours (setFlag's default expiry).
+const DISMISS_STORE = 'captain_overview_coverage_banner';
+
+const accountId = computed(() => route.params.accountId);
+const assistantId = computed(() => route.params.assistantId);
+
+// Re-read the stored flag whenever the assistant changes, otherwise the banner
+// would keep the first assistant's dismissed state after switching.
+const dismissed = ref(false);
+
+watch(
+ [accountId, assistantId],
+ ([account, assistant]) => {
+ dismissed.value = LocalStorage.getFlag(DISMISS_STORE, account, assistant);
+ },
+ { immediate: true }
+);
+
+// Thin coverage paired with a large review backlog: approving the pending FAQs
+// is the quickest lever to lift auto-resolution, so nudge the team to act.
+const COVERAGE_THRESHOLD = 85;
+const PENDING_THRESHOLD = 100;
+
+const showBanner = computed(
+ () =>
+ !dismissed.value &&
+ (props.knowledge?.coverage ?? 0) < COVERAGE_THRESHOLD &&
+ (props.knowledge?.pending ?? 0) > PENDING_THRESHOLD
+);
+
+const dismiss = () => {
+ LocalStorage.setFlag(DISMISS_STORE, accountId.value, assistantId.value);
+ dismissed.value = true;
+};
+
+const goToPending = () => {
+ router.push({
+ name: 'captain_assistants_responses_pending',
+ params: {
+ accountId: route.params.accountId,
+ assistantId: route.params.assistantId,
+ },
+ });
+};
+
+
+
+
+
+
+
+ {{
+ $t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.TEXT', {
+ count: knowledge.pending,
+ coverage: knowledge.coverage,
+ })
+ }}
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue
new file mode 100644
index 000000000..45952fa11
--- /dev/null
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+ {{ $t('CAPTAIN.OVERVIEW.INBOX_BANNER.TEXT') }}
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue
new file mode 100644
index 000000000..80c7ae69d
--- /dev/null
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+ {{ $t('CAPTAIN.OVERVIEW.KNOWLEDGE.TITLE') }}
+
+
+ {{ $t('CAPTAIN.OVERVIEW.KNOWLEDGE.COVERAGE', { pct: approvedPct }) }}
+
+
+
+
+
+
+ {{ stat.value }}
+
+
+ {{ stat.label }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue
new file mode 100644
index 000000000..9f8a76f43
--- /dev/null
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+ {{ label }}
+
+
+
+
+ {{ value }}
+
+
+ {{ trend }}
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/QuickLinks.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/QuickLinks.vue
new file mode 100644
index 000000000..e9fb45a07
--- /dev/null
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/QuickLinks.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+ {{ link.title }}
+
+
+ {{ link.description }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/RangeSelector.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/RangeSelector.vue
new file mode 100644
index 000000000..d003480dd
--- /dev/null
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/RangeSelector.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue
new file mode 100644
index 000000000..df336a7e4
--- /dev/null
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+ {{ $t('CAPTAIN.OVERVIEW.WELCOME.LABEL') }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/switcher/AssistantSwitcher.vue b/app/javascript/dashboard/components-next/captain/pageComponents/switcher/AssistantSwitcher.vue
index a67d685f5..c78842241 100644
--- a/app/javascript/dashboard/components-next/captain/pageComponents/switcher/AssistantSwitcher.vue
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/switcher/AssistantSwitcher.vue
@@ -65,7 +65,7 @@ const handleAssistantChange = async assistant => {
const currentRouteName = route.name;
const targetRouteName =
- currentRouteName || 'captain_assistants_responses_index';
+ currentRouteName || 'captain_assistants_overview_index';
await fetchDataForRoute(targetRouteName, assistant.id);
diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
index 15a007a74..a78460ca2 100644
--- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
+++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
@@ -75,6 +75,16 @@ const hasConversationUnreadCounts = computed(() => {
);
});
+const hasFilteredUnreadCounts = computed(() => {
+ return (
+ hasConversationUnreadCounts.value &&
+ isFeatureEnabledonAccount.value(
+ accountId.value,
+ FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS
+ )
+ );
+});
+
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
if (!currentAccountId) return;
@@ -199,6 +209,18 @@ const getLabelUnreadCount = useMapGetter(
const getTeamUnreadCount = useMapGetter(
'conversationUnreadCounts/getTeamUnreadCount'
);
+const mentionsUnreadCount = useMapGetter(
+ 'conversationUnreadCounts/getMentionsUnreadCount'
+);
+const participatingUnreadCount = useMapGetter(
+ 'conversationUnreadCounts/getParticipatingUnreadCount'
+);
+const unattendedUnreadCount = useMapGetter(
+ 'conversationUnreadCounts/getUnattendedUnreadCount'
+);
+const getFolderUnreadCount = useMapGetter(
+ 'conversationUnreadCounts/getFolderUnreadCount'
+);
const teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
@@ -226,14 +248,22 @@ watch([accountId, currentUserId], fetchSidebarSortPreferences, {
immediate: true,
});
+const hasUnreadCountsForSection = section => {
+ if (section === SIDEBAR_SORT_SECTIONS.FOLDERS) {
+ return hasFilteredUnreadCounts.value;
+ }
+
+ return hasConversationUnreadCounts.value;
+};
+
const getSortOptionsForSection = section =>
getSidebarSortOptions(section, {
- hasUnreadCounts: hasConversationUnreadCounts.value,
+ hasUnreadCounts: hasUnreadCountsForSection(section),
});
const getSortForSection = section =>
resolveSidebarSort(section, getSidebarSectionSort.value(section), {
- hasUnreadCounts: hasConversationUnreadCounts.value,
+ hasUnreadCounts: hasUnreadCountsForSection(section),
});
const updateSortPreference = (section, sortBy) => {
@@ -253,6 +283,7 @@ const sortedFolders = computed(() =>
sortSidebarItems(conversationCustomViews.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.FOLDERS),
labelKey: view => view.name,
+ unreadCountKey: view => getFolderUnreadCount.value(view.id),
})
);
@@ -342,6 +373,9 @@ const menuItems = computed(() => {
name: 'Mentions',
label: t('SIDEBAR.MENTIONED_CONVERSATIONS'),
icon: 'i-lucide-at-sign',
+ badgeCount: hasFilteredUnreadCounts.value
+ ? mentionsUnreadCount.value
+ : 0,
activeOn: ['conversation_through_mentions'],
to: accountScopedRoute('conversation_mentions'),
},
@@ -349,6 +383,9 @@ const menuItems = computed(() => {
name: 'Participating',
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
icon: 'i-lucide-user-round-check',
+ badgeCount: hasFilteredUnreadCounts.value
+ ? participatingUnreadCount.value
+ : 0,
activeOn: ['conversation_through_participating'],
to: accountScopedRoute('conversation_participating'),
},
@@ -357,6 +394,9 @@ const menuItems = computed(() => {
activeOn: ['conversation_through_unattended'],
label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'),
icon: 'i-lucide-clock-alert',
+ badgeCount: hasFilteredUnreadCounts.value
+ ? unattendedUnreadCount.value
+ : 0,
to: accountScopedRoute('conversation_unattended'),
},
{
@@ -370,6 +410,9 @@ const menuItems = computed(() => {
children: sortedFolders.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
+ badgeCount: hasFilteredUnreadCounts.value
+ ? getFolderUnreadCount.value(view.id)
+ : 0,
to: accountScopedRoute('folder_conversations', { id: view.id }),
})),
},
@@ -440,6 +483,14 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CAPTAIN'),
activeOn: ['captain_assistants_create_index'],
children: [
+ {
+ name: 'Overview',
+ label: t('SIDEBAR.CAPTAIN_OVERVIEW'),
+ activeOn: ['captain_assistants_overview_index'],
+ to: accountScopedRoute('captain_assistants_index', {
+ navigationPath: 'captain_assistants_overview_index',
+ }),
+ },
{
name: 'FAQs',
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue
index 2429ebe7b..2652b582b 100644
--- a/app/javascript/dashboard/components/widgets/ChannelItem.vue
+++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue
@@ -1,6 +1,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js
index 1ab4fa501..8448f32cf 100644
--- a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js
@@ -6,6 +6,7 @@ import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
import AssistantsIndexPage from './pages/AssistantsIndexPage.vue';
import AssistantEmptyStateIndex from './assistants/Index.vue';
+import AssistantOverviewIndex from './assistants/overview/Index.vue';
import AssistantSettingsIndex from './assistants/settings/Settings.vue';
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
import AssistantPlaygroundIndex from './assistants/playground/Index.vue';
@@ -36,6 +37,12 @@ const metaV2 = {
};
const assistantRoutes = [
+ {
+ path: frontendURL('accounts/:accountId/captain/:assistantId/overview'),
+ component: AssistantOverviewIndex,
+ name: 'captain_assistants_overview_index',
+ meta,
+ },
{
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'),
component: ResponsesIndex,
@@ -129,7 +136,7 @@ export const routes = [
return {
name: 'captain_assistants_index',
params: {
- navigationPath: 'captain_assistants_responses_index',
+ navigationPath: 'captain_assistants_overview_index',
...to.params,
},
};
diff --git a/app/javascript/dashboard/routes/dashboard/captain/pages/AssistantsIndexPage.vue b/app/javascript/dashboard/routes/dashboard/captain/pages/AssistantsIndexPage.vue
index 01ec64618..d366d4254 100644
--- a/app/javascript/dashboard/routes/dashboard/captain/pages/AssistantsIndexPage.vue
+++ b/app/javascript/dashboard/routes/dashboard/captain/pages/AssistantsIndexPage.vue
@@ -53,6 +53,7 @@ const routeToLastActiveAssistant = () => {
const { navigationPath } = route.params;
const isAValidRoute = [
+ 'captain_assistants_overview_index', // Overview page
'captain_assistants_responses_index', // Faq page
'captain_assistants_documents_index', // Document page
'captain_assistants_scenarios_index', // Scenario page
@@ -64,7 +65,7 @@ const routeToLastActiveAssistant = () => {
const navigateTo = isAValidRoute
? navigationPath
- : 'captain_assistants_responses_index';
+ : 'captain_assistants_overview_index';
return routeToView(navigateTo, {
accountId: route.params.accountId,
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue b/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue
index fede6e3cb..1e33a192b 100644
--- a/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue
+++ b/app/javascript/dashboard/routes/dashboard/conversation/ConversationAction.vue
@@ -25,7 +25,7 @@ export default {
},
},
setup() {
- const { agentsList } = useAgentsList();
+ const { agentsList } = useAgentsList(true, { includeAgentBots: true });
return {
agentsList,
};
@@ -81,18 +81,27 @@ export default {
},
assignedAgent: {
get() {
- return this.currentChat.meta.assignee;
+ const assignee = this.currentChat.meta.assignee;
+ return (
+ assignee && {
+ ...assignee,
+ assignee_type: this.currentChat.meta.assignee_type || 'User',
+ }
+ );
},
set(agent) {
const agentId = agent ? agent.id : null;
+ const assigneeType = agent ? agent.assignee_type || 'User' : null;
this.$store.dispatch('setCurrentChatAssignee', {
conversationId: this.currentChat.id,
assignee: agent,
+ assigneeType,
});
this.$store
.dispatch('assignAgent', {
conversationId: this.currentChat.id,
agentId,
+ assigneeType,
})
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
@@ -152,7 +161,10 @@ export default {
if (!this.assignedAgent) {
return true;
}
- if (this.assignedAgent.id !== this.currentUser.id) {
+ if (
+ this.assignedAgent.id !== this.currentUser.id ||
+ (this.assignedAgent.assignee_type || 'User') !== 'User'
+ ) {
return true;
}
return false;
@@ -183,7 +195,11 @@ export default {
this.assignedAgent = selfAssign;
},
onClickAssignAgent(selectedItem) {
- if (this.assignedAgent && this.assignedAgent.id === selectedItem.id) {
+ if (
+ this.assignedAgent?.id === selectedItem.id &&
+ (this.assignedAgent?.assignee_type || 'User') ===
+ (selectedItem.assignee_type || 'User')
+ ) {
this.assignedAgent = null;
} else {
this.assignedAgent = selectedItem;
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js
index ba2d6f0dc..9618c5e44 100644
--- a/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/inbox-setup/useChannelConfig.js
@@ -1,4 +1,5 @@
import { useMapGetter } from 'dashboard/composables/store';
+import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
// OAuth/SDK channels need installation-level app credentials to be usable. When
// the credential is missing the channel is "not configured" and is hidden from
@@ -13,11 +14,14 @@ export function useChannelConfig() {
// WhatsApp is onboarded only via Meta embedded signup, which needs both the
// app id (not the 'none' sentinel) and the signup configuration id.
whatsapp: () =>
+ !IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId),
facebook: () => Boolean(installationConfig.fbAppId),
- instagram: () => Boolean(installationConfig.instagramAppId),
+ instagram: () =>
+ !IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
+ Boolean(installationConfig.instagramAppId),
tiktok: () => Boolean(installationConfig.tiktokAppId),
gmail: () => Boolean(installationConfig.googleOAuthClientId),
outlook: () => Boolean(globalConfig.value.azureAppId),
diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js
index 1134d4494..875bfaf0d 100644
--- a/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js
+++ b/app/javascript/dashboard/routes/dashboard/onboarding/specs/inbox-setup/useDetectedChannels.spec.js
@@ -6,6 +6,13 @@ import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels';
vi.mock('vue-router');
+// Neutralize the temporary Instagram/WhatsApp kill switch so these specs keep
+// covering the credential-based gating it currently short-circuits.
+vi.mock('dashboard/constants/globals', async importOriginal => ({
+ ...(await importOriginal()),
+ IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED: false,
+}));
+
// Mounts the composable against a real store and the real useAccount (only
// useRoute and the underlying getters are faked), so a change to how useAccount
// resolves the current account is exercised here too. The real ./constants are
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue
index b8b8126c7..98cc90fee 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue
@@ -7,6 +7,7 @@ import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
+import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
const route = useRoute();
const router = useRouter();
@@ -23,6 +24,7 @@ const PROVIDER_TYPES = {
const hasWhatsappAppId = computed(() => {
return (
+ !IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
diff --git a/app/javascript/dashboard/store/modules/conversationUnreadCounts.js b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js
index c03249311..03bbb31eb 100644
--- a/app/javascript/dashboard/store/modules/conversationUnreadCounts.js
+++ b/app/javascript/dashboard/store/modules/conversationUnreadCounts.js
@@ -6,6 +6,10 @@ export const state = {
inboxes: {},
labels: {},
teams: {},
+ mentionsCount: 0,
+ participatingCount: 0,
+ unattendedCount: 0,
+ folders: {},
};
const normalizeCount = count => {
@@ -37,6 +41,18 @@ export const getters = {
getTeamUnreadCount: $state => teamId => {
return $state.teams[String(teamId)] || 0;
},
+ getMentionsUnreadCount($state) {
+ return $state.mentionsCount;
+ },
+ getParticipatingUnreadCount($state) {
+ return $state.participatingCount;
+ },
+ getUnattendedUnreadCount($state) {
+ return $state.unattendedCount;
+ },
+ getFolderUnreadCount: $state => folderId => {
+ return $state.folders[String(folderId)] || 0;
+ },
getInboxUnreadCounts($state) {
return $state.inboxes;
},
@@ -46,6 +62,9 @@ export const getters = {
getTeamUnreadCounts($state) {
return $state.teams;
},
+ getFolderUnreadCounts($state) {
+ return $state.folders;
+ },
};
export const actions = {
@@ -68,6 +87,10 @@ export const mutations = {
$state.inboxes = normalizeCounts(payload.inboxes);
$state.labels = normalizeCounts(payload.labels);
$state.teams = normalizeCounts(payload.teams);
+ $state.mentionsCount = normalizeCount(payload.mentions_count);
+ $state.participatingCount = normalizeCount(payload.participating_count);
+ $state.unattendedCount = normalizeCount(payload.unattended_count);
+ $state.folders = normalizeCounts(payload.folders);
},
};
diff --git a/app/javascript/dashboard/store/modules/conversationWatchers.js b/app/javascript/dashboard/store/modules/conversationWatchers.js
index c690da21f..da29acaf6 100644
--- a/app/javascript/dashboard/store/modules/conversationWatchers.js
+++ b/app/javascript/dashboard/store/modules/conversationWatchers.js
@@ -1,8 +1,15 @@
import types from '../mutation-types';
import { throwErrorMessage } from 'dashboard/store/utils/api';
+import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import ConversationInboxApi from '../../api/inbox/conversation';
+const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS = 30000;
+const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS = 15000;
+const getFilteredUnreadCountsRefreshRetryDelay = () =>
+ FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS +
+ Math.random() * FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS;
+
const state = {
records: {},
uiFlags: {
@@ -20,6 +27,43 @@ export const getters = {
},
};
+const hasFeatureEnabled = (rootGetters, featureFlag) => {
+ const accountId = rootGetters?.getCurrentAccountId;
+ const isFeatureEnabled = rootGetters?.['accounts/isFeatureEnabledonAccount'];
+
+ return Boolean(accountId && isFeatureEnabled?.(accountId, featureFlag));
+};
+
+const hasCurrentUser = (participants, currentUserId) =>
+ (Array.isArray(participants) ? participants : []).some(
+ participant => participant.id === currentUserId
+ );
+
+const refreshConversationUnreadCounts = dispatch => {
+ dispatch('conversationUnreadCounts/get', {}, { root: true });
+ setTimeout(
+ () => dispatch('conversationUnreadCounts/get', {}, { root: true }),
+ getFilteredUnreadCountsRefreshRetryDelay()
+ );
+};
+
+const shouldRefreshConversationUnreadCounts = (
+ { rootGetters, state: moduleState },
+ conversationId,
+ participants
+) => {
+ const currentUserId =
+ rootGetters?.getCurrentUserID || rootGetters?.getCurrentUser?.id;
+
+ return (
+ currentUserId &&
+ hasFeatureEnabled(rootGetters, FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS) &&
+ hasFeatureEnabled(rootGetters, FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS) &&
+ hasCurrentUser(moduleState.records[conversationId], currentUserId) !==
+ hasCurrentUser(participants, currentUserId)
+ );
+};
+
export const actions = {
show: async ({ commit }, { conversationId }) => {
commit(types.SET_CONVERSATION_PARTICIPANTS_UI_FLAG, {
@@ -42,7 +86,10 @@ export const actions = {
}
},
- update: async ({ commit }, { conversationId, userIds }) => {
+ update: async (
+ { commit, dispatch, rootGetters, state: moduleState },
+ { conversationId, userIds }
+ ) => {
commit(types.SET_CONVERSATION_PARTICIPANTS_UI_FLAG, {
isUpdating: true,
});
@@ -52,10 +99,18 @@ export const actions = {
conversationId,
userIds,
});
+ const shouldRefreshUnreadCounts = shouldRefreshConversationUnreadCounts(
+ { rootGetters, state: moduleState },
+ conversationId,
+ response.data
+ );
commit(types.SET_CONVERSATION_PARTICIPANTS, {
conversationId,
data: response.data,
});
+ if (shouldRefreshUnreadCounts) {
+ refreshConversationUnreadCounts(dispatch);
+ }
} catch (error) {
throwErrorMessage(error);
} finally {
diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js
index 72ab8fa5e..f8fdecc36 100644
--- a/app/javascript/dashboard/store/modules/conversations/actions.js
+++ b/app/javascript/dashboard/store/modules/conversations/actions.js
@@ -208,23 +208,31 @@ const actions = {
}
},
- assignAgent: async ({ dispatch }, { conversationId, agentId }) => {
+ assignAgent: async (
+ { dispatch },
+ { conversationId, agentId, assigneeType }
+ ) => {
try {
const response = await ConversationApi.assignAgent({
conversationId,
agentId,
+ assigneeType,
});
dispatch('setCurrentChatAssignee', {
conversationId,
assignee: response.data,
+ assigneeType,
});
} catch (error) {
// Handle error
}
},
- setCurrentChatAssignee({ commit }, { conversationId, assignee }) {
- commit(types.ASSIGN_AGENT, { conversationId, assignee });
+ setCurrentChatAssignee(
+ { commit },
+ { conversationId, assignee, assigneeType }
+ ) {
+ commit(types.ASSIGN_AGENT, { conversationId, assignee, assigneeType });
},
assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js
index 8a13940c0..4f539e22d 100644
--- a/app/javascript/dashboard/store/modules/conversations/index.js
+++ b/app/javascript/dashboard/store/modules/conversations/index.js
@@ -108,10 +108,11 @@ export const mutations = {
}
},
- [types.ASSIGN_AGENT](_state, { conversationId, assignee }) {
+ [types.ASSIGN_AGENT](_state, { conversationId, assignee, assigneeType }) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.meta.assignee = assignee;
+ chat.meta.assignee_type = assigneeType;
}
},
diff --git a/app/javascript/dashboard/store/modules/customViews.js b/app/javascript/dashboard/store/modules/customViews.js
index 388388e0f..b4dff3610 100644
--- a/app/javascript/dashboard/store/modules/customViews.js
+++ b/app/javascript/dashboard/store/modules/customViews.js
@@ -1,11 +1,17 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import CustomViewsAPI from '../../api/customViews';
+import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const VIEW_TYPES = {
CONVERSATION: 'conversation',
CONTACT: 'contact',
};
+const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS = 30000;
+const FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS = 15000;
+const getFilteredUnreadCountsRefreshRetryDelay = () =>
+ FILTERED_UNREAD_COUNTS_REFRESH_RETRY_MS +
+ Math.random() * FILTERED_UNREAD_COUNTS_REFRESH_RETRY_JITTER_MS;
// use to normalize the filter type
const FILTER_KEYS = {
@@ -21,6 +27,38 @@ const getFolderContactId = folder =>
folder?.query?.payload?.find(filter => filter.attribute_key === 'contact_id')
?.values?.[0];
+const hasFeatureEnabled = (rootGetters, featureFlag) => {
+ const accountId = rootGetters?.getCurrentAccountId;
+ const isFeatureEnabled = rootGetters?.['accounts/isFeatureEnabledonAccount'];
+
+ return Boolean(accountId && isFeatureEnabled?.(accountId, featureFlag));
+};
+
+const shouldRefreshConversationUnreadCounts = (filterType, rootGetters) => {
+ return (
+ FILTER_KEYS[filterType] === VIEW_TYPES.CONVERSATION &&
+ hasFeatureEnabled(rootGetters, FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS) &&
+ hasFeatureEnabled(rootGetters, FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS)
+ );
+};
+
+const dispatchConversationUnreadCounts = dispatch => {
+ dispatch('conversationUnreadCounts/get', {}, { root: true });
+};
+
+const refreshConversationUnreadCounts = (
+ { dispatch, rootGetters },
+ filterType
+) => {
+ if (!shouldRefreshConversationUnreadCounts(filterType, rootGetters)) return;
+
+ dispatchConversationUnreadCounts(dispatch);
+ setTimeout(
+ () => dispatchConversationUnreadCounts(dispatch),
+ getFilteredUnreadCountsRefreshRetryDelay()
+ );
+};
+
export const state = {
[VIEW_TYPES.CONVERSATION]: {
records: [],
@@ -71,14 +109,19 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isFetching: false });
}
},
- create: async function createCustomViews({ commit }, obj) {
+ create: async function createCustomViews(
+ { commit, dispatch, rootGetters },
+ obj
+ ) {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true });
try {
const response = await CustomViewsAPI.create(obj);
+ const filterType = FILTER_KEYS[obj.filter_type];
commit(types.ADD_CUSTOM_VIEW, {
data: response.data,
- filterType: FILTER_KEYS[obj.filter_type],
+ filterType,
});
+ refreshConversationUnreadCounts({ dispatch, rootGetters }, filterType);
return response;
} catch (error) {
const errorMessage = error?.response?.data?.message;
@@ -87,14 +130,19 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false });
}
},
- update: async function updateCustomViews({ commit }, obj) {
+ update: async function updateCustomViews(
+ { commit, dispatch, rootGetters },
+ obj
+ ) {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true });
try {
const response = await CustomViewsAPI.update(obj.id, obj);
+ const filterType = FILTER_KEYS[obj.filter_type];
commit(types.UPDATE_CUSTOM_VIEW, {
data: response.data,
- filterType: FILTER_KEYS[obj.filter_type],
+ filterType,
});
+ refreshConversationUnreadCounts({ dispatch, rootGetters }, filterType);
} catch (error) {
const errorMessage = error?.response?.data?.message;
throw new Error(errorMessage);
@@ -102,11 +150,12 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false });
}
},
- delete: async ({ commit }, { id, filterType }) => {
+ delete: async ({ commit, dispatch, rootGetters }, { id, filterType }) => {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: true });
try {
await CustomViewsAPI.deleteCustomViews(id, filterType);
commit(types.DELETE_CUSTOM_VIEW, { data: id, filterType });
+ refreshConversationUnreadCounts({ dispatch, rootGetters }, filterType);
} catch (error) {
throw new Error(error);
} finally {
diff --git a/app/javascript/dashboard/store/modules/inboxAssignableAgents.js b/app/javascript/dashboard/store/modules/inboxAssignableAgents.js
index 1b129e3ef..6dbee9ea8 100644
--- a/app/javascript/dashboard/store/modules/inboxAssignableAgents.js
+++ b/app/javascript/dashboard/store/modules/inboxAssignableAgents.js
@@ -7,31 +7,52 @@ const state = {
},
};
+const recordKey = (inboxId, { includeAgentBots = false } = {}) =>
+ includeAgentBots ? `${inboxId}:with_agent_bots` : inboxId;
+
export const types = {
SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG: 'SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG',
SET_INBOX_ASSIGNABLE_AGENTS: 'SET_INBOX_ASSIGNABLE_AGENTS',
};
export const getters = {
- getAssignableAgents: $state => inboxId => {
- const allAgents = $state.records[inboxId] || [];
- const verifiedAgents = allAgents.filter(record => record.confirmed);
- return verifiedAgents;
- },
+ getAssignableAgents:
+ $state =>
+ (inboxId, options = {}) => {
+ const includeAgentBots = options.includeAgentBots || false;
+ const allAgents = $state.records[recordKey(inboxId, options)] || [];
+ const verifiedAgents = allAgents.filter(
+ record =>
+ record.confirmed ||
+ (includeAgentBots && record.assignee_type === 'AgentBot')
+ );
+ return verifiedAgents;
+ },
getUIFlags($state) {
return $state.uiFlags;
},
};
export const actions = {
- async fetch({ commit }, inboxIds) {
+ async fetch({ commit }, actionPayload) {
+ const inboxIds = Array.isArray(actionPayload)
+ ? actionPayload
+ : actionPayload.inboxIds;
+ const includeAgentBots =
+ !Array.isArray(actionPayload) && actionPayload.includeAgentBots;
commit(types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true });
try {
const {
data: { payload },
- } = await AssignableAgentsAPI.get(inboxIds);
+ } = await AssignableAgentsAPI.get(inboxIds, { includeAgentBots });
+ if (includeAgentBots) {
+ commit(types.SET_INBOX_ASSIGNABLE_AGENTS, {
+ inboxId: inboxIds.join(','),
+ members: payload,
+ });
+ }
commit(types.SET_INBOX_ASSIGNABLE_AGENTS, {
- inboxId: inboxIds.join(','),
+ inboxId: recordKey(inboxIds.join(','), { includeAgentBots }),
members: payload,
});
} catch (error) {
diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js
index 29fadc897..c3d040d84 100644
--- a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/actions.spec.js
@@ -19,6 +19,10 @@ describe('#actions', () => {
inboxes: { 1: '2' },
labels: { 3: 4 },
teams: { 5: 6 },
+ mentions_count: 7,
+ participating_count: 8,
+ unattended_count: 9,
+ folders: { 10: 11 },
};
axios.get.mockResolvedValue({ data: { payload } });
diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
index 9fe19e22d..9b7467ae1 100644
--- a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/getters.spec.js
@@ -7,6 +7,7 @@ describe('#getters', () => {
inboxes: { 1: 2 },
labels: {},
teams: {},
+ folders: {},
};
expect(getters.getInboxUnreadCount(state)(1)).toBe(2);
@@ -20,6 +21,7 @@ describe('#getters', () => {
inboxes: {},
labels: { 3: 4 },
teams: {},
+ folders: {},
};
expect(getters.getLabelUnreadCount(state)(3)).toBe(4);
@@ -33,6 +35,7 @@ describe('#getters', () => {
inboxes: {},
labels: {},
teams: { 5: 6 },
+ folders: {},
};
expect(getters.getTeamUnreadCount(state)(5)).toBe(6);
@@ -46,21 +49,44 @@ describe('#getters', () => {
inboxes: {},
labels: {},
teams: {},
+ folders: {},
};
expect(getters.getAllUnreadCount(state)).toBe(7);
});
+ it('returns filtered unread counts', () => {
+ const state = {
+ allCount: 0,
+ inboxes: {},
+ labels: {},
+ teams: {},
+ mentionsCount: 1,
+ participatingCount: 2,
+ unattendedCount: 3,
+ folders: { 8: 4 },
+ };
+
+ expect(getters.getMentionsUnreadCount(state)).toBe(1);
+ expect(getters.getParticipatingUnreadCount(state)).toBe(2);
+ expect(getters.getUnattendedUnreadCount(state)).toBe(3);
+ expect(getters.getFolderUnreadCount(state)(8)).toBe(4);
+ expect(getters.getFolderUnreadCount(state)('8')).toBe(4);
+ expect(getters.getFolderUnreadCount(state)(9)).toBe(0);
+ });
+
it('returns unread count maps', () => {
const state = {
allCount: 0,
inboxes: { 1: 2 },
labels: { 3: 4 },
teams: { 5: 6 },
+ folders: { 7: 8 },
};
expect(getters.getInboxUnreadCounts(state)).toEqual({ 1: 2 });
expect(getters.getLabelUnreadCounts(state)).toEqual({ 3: 4 });
expect(getters.getTeamUnreadCounts(state)).toEqual({ 5: 6 });
+ expect(getters.getFolderUnreadCounts(state)).toEqual({ 7: 8 });
});
});
diff --git a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js
index 8941d7430..60785593c 100644
--- a/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversationUnreadCounts/mutations.spec.js
@@ -4,7 +4,16 @@ import { mutations } from '../../conversationUnreadCounts';
describe('#mutations', () => {
describe('#SET_CONVERSATION_UNREAD_COUNTS', () => {
it('normalizes unread count payload', () => {
- const state = { allCount: 0, inboxes: {}, labels: {}, teams: {} };
+ const state = {
+ allCount: 0,
+ inboxes: {},
+ labels: {},
+ teams: {},
+ mentionsCount: 0,
+ participatingCount: 0,
+ unattendedCount: 0,
+ folders: {},
+ };
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
all_count: '3',
@@ -21,6 +30,13 @@ describe('#mutations', () => {
6: '7',
7: 0,
},
+ mentions_count: '8',
+ participating_count: 9,
+ unattended_count: 0,
+ folders: {
+ 10: '11',
+ 12: -1,
+ },
});
expect(state).toEqual({
@@ -28,6 +44,10 @@ describe('#mutations', () => {
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
+ mentionsCount: 8,
+ participatingCount: 9,
+ unattendedCount: 0,
+ folders: { 10: 11 },
});
});
@@ -37,6 +57,10 @@ describe('#mutations', () => {
inboxes: { 1: 2 },
labels: { 4: 5 },
teams: { 6: 7 },
+ mentionsCount: 8,
+ participatingCount: 9,
+ unattendedCount: 10,
+ folders: { 11: 12 },
};
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {});
@@ -46,17 +70,36 @@ describe('#mutations', () => {
inboxes: {},
labels: {},
teams: {},
+ mentionsCount: 0,
+ participatingCount: 0,
+ unattendedCount: 0,
+ folders: {},
});
});
it('normalizes invalid aggregate counts to zero', () => {
- const state = { allCount: 2, inboxes: {}, labels: {}, teams: {} };
+ const state = {
+ allCount: 2,
+ inboxes: {},
+ labels: {},
+ teams: {},
+ mentionsCount: 2,
+ participatingCount: 3,
+ unattendedCount: 4,
+ folders: {},
+ };
mutations[types.SET_CONVERSATION_UNREAD_COUNTS](state, {
all_count: 'invalid',
+ mentions_count: 'invalid',
+ participating_count: -1,
+ unattended_count: 0,
});
expect(state.allCount).toBe(0);
+ expect(state.mentionsCount).toBe(0);
+ expect(state.participatingCount).toBe(0);
+ expect(state.unattendedCount).toBe(0);
});
});
});
diff --git a/app/javascript/dashboard/store/modules/specs/conversationWatchers/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversationWatchers/actions.spec.js
index b3cfabeba..defa9a3db 100644
--- a/app/javascript/dashboard/store/modules/specs/conversationWatchers/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversationWatchers/actions.spec.js
@@ -1,11 +1,32 @@
import axios from 'axios';
import { actions } from '../../conversationWatchers';
import types from '../../../mutation-types';
+import { FEATURE_FLAGS } from '../../../../featureFlags';
const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
+const mockRetryJitter = value =>
+ vi.spyOn(Math, 'random').mockReturnValue(value);
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.clearAllTimers();
+ vi.useRealTimers();
+});
+
+const conversationUnreadCountsEnabledRootGetters = {
+ getCurrentAccountId: 1,
+ getCurrentUserID: 1,
+ 'accounts/isFeatureEnabledonAccount': vi.fn((_, featureFlag) =>
+ [
+ FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS,
+ FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS,
+ ].includes(featureFlag)
+ ),
+};
+
describe('#actions', () => {
describe('#get', () => {
it('sends correct actions if API is success', async () => {
@@ -48,6 +69,56 @@ describe('#actions', () => {
[types.SET_CONVERSATION_PARTICIPANTS_UI_FLAG, { isUpdating: false }],
]);
});
+ it('refetches unread counts when the current user starts watching', async () => {
+ vi.useFakeTimers();
+ mockRetryJitter(0.5);
+ const dispatch = vi.fn();
+ const moduleState = { records: { 2: [] } };
+ const mutatingCommit = vi.fn((mutation, payload) => {
+ if (mutation === types.SET_CONVERSATION_PARTICIPANTS) {
+ moduleState.records[payload.conversationId] = payload.data;
+ }
+ });
+ axios.patch.mockResolvedValue({ data: [{ id: 1 }] });
+
+ await actions.update(
+ {
+ commit: mutatingCommit,
+ dispatch,
+ rootGetters: conversationUnreadCountsEnabledRootGetters,
+ state: moduleState,
+ },
+ { conversationId: 2, userIds: [1] }
+ );
+
+ expect(dispatch).toHaveBeenCalledWith(
+ 'conversationUnreadCounts/get',
+ {},
+ { root: true }
+ );
+
+ vi.advanceTimersByTime(37499);
+ expect(dispatch).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(1);
+ expect(dispatch).toHaveBeenCalledTimes(2);
+ });
+ it('does not refetch unread counts when another watcher changes', async () => {
+ const dispatch = vi.fn();
+ axios.patch.mockResolvedValue({ data: [{ id: 1 }, { id: 2 }] });
+
+ await actions.update(
+ {
+ commit,
+ dispatch,
+ rootGetters: conversationUnreadCountsEnabledRootGetters,
+ state: { records: { 2: [{ id: 1 }] } },
+ },
+ { conversationId: 2, userIds: [1, 2] }
+ );
+
+ expect(dispatch).not.toHaveBeenCalled();
+ });
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(
diff --git a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js
index fa052ec1b..5014b63ca 100644
--- a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js
@@ -357,11 +357,12 @@ describe('#actions', () => {
});
await actions.assignAgent(
{ dispatch },
- { conversationId: 1, agentId: 1 }
+ { conversationId: 1, agentId: 1, assigneeType: 'AgentBot' }
);
expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', {
conversationId: 1,
assignee: { id: 1, name: 'User' },
+ assigneeType: 'AgentBot',
});
});
});
@@ -371,6 +372,7 @@ describe('#actions', () => {
const payload = {
conversationId: 1,
assignee: { id: 1, name: 'User' },
+ assigneeType: 'AgentBot',
};
await actions.setCurrentChatAssignee({ commit }, payload);
expect(commit).toHaveBeenCalledTimes(1);
diff --git a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js
index fc1c61b35..a97bdad53 100644
--- a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js
@@ -712,8 +712,10 @@ describe('#mutations', () => {
mutations[types.ASSIGN_AGENT](state, {
conversationId: 1,
assignee,
+ assigneeType: 'AgentBot',
});
expect(state.allConversations[0].meta.assignee).toEqual(assignee);
+ expect(state.allConversations[0].meta.assignee_type).toEqual('AgentBot');
expect(state.allConversations[1].meta.assignee).toBeUndefined();
});
});
diff --git a/app/javascript/dashboard/store/modules/specs/customViews/actions.spec.js b/app/javascript/dashboard/store/modules/specs/customViews/actions.spec.js
index a09dda17f..a1ea0638d 100644
--- a/app/javascript/dashboard/store/modules/specs/customViews/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/customViews/actions.spec.js
@@ -1,6 +1,7 @@
import axios from 'axios';
import * as types from '../../../mutation-types';
import { actions } from '../../customViews';
+import { FEATURE_FLAGS } from '../../../../featureFlags';
import {
contactFilterView,
customViewList,
@@ -11,6 +12,25 @@ const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
+const mockRetryJitter = value =>
+ vi.spyOn(Math, 'random').mockReturnValue(value);
+
+const conversationUnreadCountsEnabledRootGetters = {
+ getCurrentAccountId: 1,
+ 'accounts/isFeatureEnabledonAccount': vi.fn((_, featureFlag) =>
+ [
+ FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS,
+ FEATURE_FLAGS.UNREAD_COUNT_FOR_FILTERS,
+ ].includes(featureFlag)
+ ),
+};
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.clearAllTimers();
+ vi.useRealTimers();
+});
+
describe('#actions', () => {
describe('#get', () => {
it('sends correct actions if API is success', async () => {
@@ -49,6 +69,36 @@ describe('#actions', () => {
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }],
]);
});
+
+ it('refetches unread counts after creating a conversation folder', async () => {
+ vi.useFakeTimers();
+ mockRetryJitter(0.5);
+ const dispatch = vi.fn();
+ const firstItem = customViewList[0];
+ axios.post.mockResolvedValue({ data: firstItem });
+
+ await actions.create(
+ {
+ commit,
+ dispatch,
+ rootGetters: conversationUnreadCountsEnabledRootGetters,
+ },
+ firstItem
+ );
+
+ expect(dispatch).toHaveBeenCalledWith(
+ 'conversationUnreadCounts/get',
+ {},
+ { root: true }
+ );
+
+ vi.advanceTimersByTime(37499);
+ expect(dispatch).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(1);
+ expect(dispatch).toHaveBeenCalledTimes(2);
+ });
+
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.create({ commit })).rejects.toThrow(Error);
@@ -69,6 +119,44 @@ describe('#actions', () => {
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: false }],
]);
});
+
+ it('refetches unread counts after deleting a conversation folder', async () => {
+ vi.useFakeTimers();
+ const dispatch = vi.fn();
+ axios.delete.mockResolvedValue({ data: customViewList[0] });
+
+ await actions.delete(
+ {
+ commit,
+ dispatch,
+ rootGetters: conversationUnreadCountsEnabledRootGetters,
+ },
+ { id: 1, filterType: 'conversation' }
+ );
+
+ expect(dispatch).toHaveBeenCalledWith(
+ 'conversationUnreadCounts/get',
+ {},
+ { root: true }
+ );
+ });
+
+ it('does not refetch unread counts after deleting a contact segment', async () => {
+ const dispatch = vi.fn();
+ axios.delete.mockResolvedValue({ data: contactFilterView });
+
+ await actions.delete(
+ {
+ commit,
+ dispatch,
+ rootGetters: conversationUnreadCountsEnabledRootGetters,
+ },
+ { id: 1, filterType: 'contact' }
+ );
+
+ expect(dispatch).not.toHaveBeenCalled();
+ });
+
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.delete({ commit }, 1)).rejects.toThrow(Error);
@@ -93,6 +181,29 @@ describe('#actions', () => {
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }],
]);
});
+
+ it('refetches unread counts after updating a conversation folder', async () => {
+ vi.useFakeTimers();
+ const dispatch = vi.fn();
+ const item = updateCustomViewList[0];
+ axios.patch.mockResolvedValue({ data: item });
+
+ await actions.update(
+ {
+ commit,
+ dispatch,
+ rootGetters: conversationUnreadCountsEnabledRootGetters,
+ },
+ item
+ );
+
+ expect(dispatch).toHaveBeenCalledWith(
+ 'conversationUnreadCounts/get',
+ {},
+ { root: true }
+ );
+ });
+
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.update({ commit }, 1)).rejects.toThrow(Error);
diff --git a/app/javascript/dashboard/store/modules/specs/inboxAssignableMembers/actions.spec.js b/app/javascript/dashboard/store/modules/specs/inboxAssignableMembers/actions.spec.js
index eac8e7d08..bda8f1c6e 100644
--- a/app/javascript/dashboard/store/modules/specs/inboxAssignableMembers/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/inboxAssignableMembers/actions.spec.js
@@ -7,12 +7,21 @@ global.axios = axios;
vi.mock('axios');
describe('#actions', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
describe('#fetch', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
data: { payload: agentsData },
});
await actions.fetch({ commit }, [1]);
+ expect(axios.get).toHaveBeenCalledWith('/api/v1/assignable_agents', {
+ params: {
+ inbox_ids: [1],
+ },
+ });
expect(commit.mock.calls).toEqual([
[types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true }],
[
@@ -24,13 +33,37 @@ describe('#actions', () => {
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
- await expect(actions.fetch({ commit }, { inboxId: 1 })).rejects.toThrow(
- Error
- );
+ await expect(actions.fetch({ commit }, [1])).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true }],
[types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: false }],
]);
});
+
+ it('requests agent bots only when opted in', async () => {
+ axios.get.mockResolvedValue({
+ data: { payload: agentsData },
+ });
+
+ await actions.fetch(
+ { commit },
+ { inboxIds: [1], includeAgentBots: true }
+ );
+
+ expect(axios.get).toHaveBeenCalledWith('/api/v1/assignable_agents', {
+ params: {
+ inbox_ids: [1],
+ include_agent_bots: true,
+ },
+ });
+ expect(commit).toHaveBeenCalledWith(types.SET_INBOX_ASSIGNABLE_AGENTS, {
+ inboxId: '1',
+ members: agentsData,
+ });
+ expect(commit).toHaveBeenCalledWith(types.SET_INBOX_ASSIGNABLE_AGENTS, {
+ inboxId: '1:with_agent_bots',
+ members: agentsData,
+ });
+ });
});
});
diff --git a/app/javascript/dashboard/store/modules/specs/inboxAssignableMembers/getters.spec.js b/app/javascript/dashboard/store/modules/specs/inboxAssignableMembers/getters.spec.js
index ac287e2b2..744bcbccf 100644
--- a/app/javascript/dashboard/store/modules/specs/inboxAssignableMembers/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/inboxAssignableMembers/getters.spec.js
@@ -1,4 +1,4 @@
-import { getters } from '../../teamMembers';
+import { getters } from '../../inboxAssignableAgents';
import agentsData from './fixtures';
describe('#getters', () => {
@@ -8,7 +8,26 @@ describe('#getters', () => {
1: [agentsData[0]],
},
};
- expect(getters.getTeamMembers(state)(1)).toEqual([agentsData[0]]);
+ expect(getters.getAssignableAgents(state)(1)).toEqual([agentsData[0]]);
+ });
+
+ it('keeps agent bots scoped to bot-inclusive lists', () => {
+ const agentBot = {
+ id: 1,
+ name: 'Captain',
+ assignee_type: 'AgentBot',
+ };
+ const state = {
+ records: {
+ 1: [agentBot, agentsData[0]],
+ '1:with_agent_bots': [agentBot, agentsData[0]],
+ },
+ };
+
+ expect(getters.getAssignableAgents(state)(1)).toEqual([agentsData[0]]);
+ expect(
+ getters.getAssignableAgents(state)(1, { includeAgentBots: true })
+ ).toEqual([agentBot, agentsData[0]]);
});
it('getUIFlags', () => {
diff --git a/app/javascript/dashboard/store/modules/specs/sidebarSortPreferences/actions.spec.js b/app/javascript/dashboard/store/modules/specs/sidebarSortPreferences/actions.spec.js
index 7fee1d018..5242f2125 100644
--- a/app/javascript/dashboard/store/modules/specs/sidebarSortPreferences/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/sidebarSortPreferences/actions.spec.js
@@ -83,7 +83,7 @@ describe('#actions', () => {
);
});
- it('ignores invalid preferences', () => {
+ it('ignores invalid sort values', () => {
actions.setSectionSort(
{
commit,
@@ -95,7 +95,7 @@ describe('#actions', () => {
},
{
section: SIDEBAR_SORT_SECTIONS.FOLDERS,
- sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
+ sortBy: 'invalid_sort',
}
);
diff --git a/app/javascript/shared/components/ui/MultiselectDropdown.vue b/app/javascript/shared/components/ui/MultiselectDropdown.vue
index 898f89db4..0f775c679 100644
--- a/app/javascript/shared/components/ui/MultiselectDropdown.vue
+++ b/app/javascript/shared/components/ui/MultiselectDropdown.vue
@@ -63,6 +63,14 @@ const hasValue = computed(() => {
const hasIcon = computed(() => {
return props.selectedItem?.icon || false;
});
+
+const isAgentBot = computed(
+ () => props.selectedItem?.assignee_type === 'AgentBot'
+);
+
+const selectedThumbnail = computed(
+ () => props.selectedItem?.thumbnail || props.selectedItem?.avatar_url
+);
@@ -93,16 +101,25 @@ const hasIcon = computed(() => {