feat: add pending conversation takeover UI (#14876)
Adds a dashboard-only takeover path for pending conversations currently handled by an assistant/bot. Agents see the warning by default, stay in private-note mode while the conversation is pending, and can use Take over to move the conversation back to human handling. ## Related - Original scope: https://linear.app/chatwoot/issue/CW-7450/block-replies-and-add-takeover-for-agent-bot-ownership - Backend follow-up: https://linear.app/chatwoot/issue/CW-7779/enforce-backend-reply-blocking-for-agent-bot-owned-conversations ## Why We want to prevent accidental parallel handling from the dashboard while an assistant is managing a pending conversation, without expanding this PR into API-level enforcement. Backend blocking is tracked separately in CW-7779. ## What changed - Locks the dashboard composer to private-note mode while the conversation status is `pending`. - Shows the takeover banner by default for pending conversations. - Uses the Agent Bot assignee name when the conversation payload exposes one, otherwise falls back to `a bot`. - Simplifies the banner action copy to `Take over`. - Reopens and self-assigns the conversation from the takeover action. - Clears stale local `AgentBot` assignee type when assigning the conversation back to a human in the store. ## How to test - Open a pending conversation assigned to an Agent Bot and verify the banner says it is handled by that bot name. - Verify the reply editor stays in private-note mode and public reply mode cannot be selected while the conversation is pending. - Click Take over and verify the conversation moves to open and is assigned to the current agent. - Open a pending Captain/Dialogflow-style conversation without an Agent Bot assignee payload and verify the banner falls back to `a bot`. --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
@@ -172,15 +172,32 @@ export default {
|
||||
showContentTemplates() {
|
||||
return this.isATwilioWhatsAppChannel && !this.isPrivate;
|
||||
},
|
||||
isPrivate() {
|
||||
if (
|
||||
this.currentChat.can_reply ||
|
||||
isWithinMessagingWindow() {
|
||||
return !!(
|
||||
this.currentChat?.can_reply ||
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isAPIInbox
|
||||
) {
|
||||
return this.isOnPrivateNote;
|
||||
}
|
||||
return true;
|
||||
);
|
||||
},
|
||||
canSendPublicReply() {
|
||||
return (
|
||||
this.isWithinMessagingWindow && !this.isBotOwnedPendingConversation
|
||||
);
|
||||
},
|
||||
isPrivate() {
|
||||
return (
|
||||
!this.canSendPublicReply || this.replyType === REPLY_EDITOR_MODES.NOTE
|
||||
);
|
||||
},
|
||||
isOnPrivateNote() {
|
||||
return this.isBotOwnedPendingConversation
|
||||
? this.isPrivate
|
||||
: this.replyType === REPLY_EDITOR_MODES.NOTE;
|
||||
},
|
||||
effectiveReplyMode() {
|
||||
return this.isOnPrivateNote
|
||||
? REPLY_EDITOR_MODES.NOTE
|
||||
: REPLY_EDITOR_MODES.REPLY;
|
||||
},
|
||||
hasMeaningfulEditorContent() {
|
||||
const body = this.message || '';
|
||||
@@ -197,10 +214,10 @@ export default {
|
||||
);
|
||||
return !!stripped.trim();
|
||||
},
|
||||
isReplyRestricted() {
|
||||
isBotOwnedPendingConversation() {
|
||||
return (
|
||||
!this.currentChat?.can_reply &&
|
||||
!(this.isAWhatsAppChannel || this.isAPIInbox)
|
||||
this.currentChat?.status === wootConstants.STATUS_TYPE.PENDING &&
|
||||
this.currentChat?.meta?.assignee_type === 'AgentBot'
|
||||
);
|
||||
},
|
||||
inboxId() {
|
||||
@@ -330,9 +347,6 @@ export default {
|
||||
showAudioRecorderEditor() {
|
||||
return this.showAudioRecorder && this.isRecordingAudio;
|
||||
},
|
||||
isOnPrivateNote() {
|
||||
return this.replyType === REPLY_EDITOR_MODES.NOTE;
|
||||
},
|
||||
isOnExpandedLayout() {
|
||||
const {
|
||||
LAYOUT_TYPES: { CONDENSED },
|
||||
@@ -375,7 +389,7 @@ export default {
|
||||
return this.conversationId;
|
||||
},
|
||||
editorStateId() {
|
||||
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
return `draft-${this.conversationIdByRoute}-${this.effectiveReplyMode}`;
|
||||
},
|
||||
audioRecordFormat() {
|
||||
if (this.isAWhatsAppCloudChannel) {
|
||||
@@ -456,7 +470,6 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
currentChat(conversation, oldConversation) {
|
||||
const { can_reply: canReply } = conversation;
|
||||
if (oldConversation && oldConversation.id !== conversation.id) {
|
||||
// Only update email fields when switching to a completely different conversation (by ID)
|
||||
// This prevents overwriting user input (e.g., CC/BCC fields) when performing actions
|
||||
@@ -470,11 +483,9 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) {
|
||||
this.replyType = REPLY_EDITOR_MODES.REPLY;
|
||||
} else {
|
||||
this.replyType = REPLY_EDITOR_MODES.NOTE;
|
||||
}
|
||||
this.replyType = this.isWithinMessagingWindow
|
||||
? REPLY_EDITOR_MODES.REPLY
|
||||
: REPLY_EDITOR_MODES.NOTE;
|
||||
|
||||
this.fetchAndSetReplyTo();
|
||||
},
|
||||
@@ -492,7 +503,7 @@ export default {
|
||||
},
|
||||
conversationIdByRoute(conversationId, oldConversationId) {
|
||||
if (conversationId !== oldConversationId) {
|
||||
this.setToDraft(oldConversationId, this.replyType);
|
||||
this.setToDraft(oldConversationId, this.effectiveReplyMode);
|
||||
this.getFromDraft();
|
||||
this.resetRecorderAndClearAttachments();
|
||||
}
|
||||
@@ -501,13 +512,25 @@ export default {
|
||||
// Autosave the current message draft.
|
||||
this.doAutoSaveDraft();
|
||||
},
|
||||
replyType(updatedReplyType, oldReplyType) {
|
||||
showWhatsappTemplates(isAvailable) {
|
||||
if (!isAvailable) this.hideWhatsappTemplatesModal();
|
||||
},
|
||||
showContentTemplates(isAvailable) {
|
||||
if (!isAvailable) this.hideContentTemplatesModal();
|
||||
},
|
||||
effectiveReplyMode(updatedReplyType, oldReplyType) {
|
||||
this.$store.dispatch('draftMessages/setReplyEditorMode', {
|
||||
mode: updatedReplyType,
|
||||
});
|
||||
this.setToDraft(this.conversationIdByRoute, oldReplyType);
|
||||
this.getFromDraft();
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$store.dispatch('draftMessages/setReplyEditorMode', {
|
||||
mode: this.effectiveReplyMode,
|
||||
});
|
||||
this.getFromDraft();
|
||||
// Don't use the keyboard listener mixin here as the events here are supposed to be
|
||||
// working even if the editor is focussed.
|
||||
@@ -516,7 +539,7 @@ export default {
|
||||
this.setCCAndToEmailsFromLastChat();
|
||||
this.doAutoSaveDraft = debounce(
|
||||
() => {
|
||||
this.saveDraft(this.conversationIdByRoute, this.replyType);
|
||||
this.saveDraft(this.conversationIdByRoute, this.effectiveReplyMode);
|
||||
},
|
||||
500,
|
||||
true
|
||||
@@ -549,22 +572,22 @@ export default {
|
||||
methods: {
|
||||
getDraftKey(
|
||||
conversationId = this.conversationIdByRoute,
|
||||
replyType = this.replyType
|
||||
replyType = this.effectiveReplyMode
|
||||
) {
|
||||
return `draft-${conversationId}-${replyType}`;
|
||||
},
|
||||
getCopilotAcceptedMessage(replyType = this.replyType) {
|
||||
getCopilotAcceptedMessage(replyType = this.effectiveReplyMode) {
|
||||
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||
return this.copilotAcceptedMessages[key] || '';
|
||||
},
|
||||
setCopilotAcceptedMessage(message, replyType = this.replyType) {
|
||||
setCopilotAcceptedMessage(message, replyType = this.effectiveReplyMode) {
|
||||
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||
this.copilotAcceptedMessages[key] = trimContent(
|
||||
message || '',
|
||||
this.maxLength
|
||||
);
|
||||
},
|
||||
clearCopilotAcceptedMessage(replyType = this.replyType) {
|
||||
clearCopilotAcceptedMessage(replyType = this.effectiveReplyMode) {
|
||||
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||
delete this.copilotAcceptedMessages[key];
|
||||
},
|
||||
@@ -933,12 +956,8 @@ export default {
|
||||
// This is to prevent from breaking the upload rules
|
||||
if (this.attachedFiles.length > 0) this.attachedFiles = [];
|
||||
|
||||
const { can_reply: canReply } = this.currentChat;
|
||||
this.$store.dispatch('draftMessages/setReplyEditorMode', {
|
||||
mode,
|
||||
});
|
||||
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
|
||||
this.replyType = mode;
|
||||
this.$store.dispatch('draftMessages/setReplyEditorMode', { mode });
|
||||
if (this.canSendPublicReply) this.replyType = mode;
|
||||
if (this.isRecordingAudio) {
|
||||
this.toggleAudioRecorder();
|
||||
}
|
||||
@@ -1009,7 +1028,7 @@ export default {
|
||||
},
|
||||
onBlur() {
|
||||
this.isFocused = false;
|
||||
this.saveDraft(this.conversationIdByRoute, this.replyType);
|
||||
this.saveDraft(this.conversationIdByRoute, this.effectiveReplyMode);
|
||||
},
|
||||
onFocus() {
|
||||
this.isFocused = true;
|
||||
@@ -1257,7 +1276,7 @@ export default {
|
||||
<ReplyTopPanel
|
||||
:mode="replyType"
|
||||
:conversation-id="conversationId"
|
||||
:is-reply-restricted="isReplyRestricted"
|
||||
:is-reply-restricted="!canSendPublicReply"
|
||||
:disabled="
|
||||
(copilot.isActive.value && copilot.isButtonDisabled.value) ||
|
||||
showAudioRecorderEditor
|
||||
|
||||
@@ -34,6 +34,7 @@ const assignedAgent = computed({
|
||||
store.dispatch('setCurrentChatAssignee', {
|
||||
conversationId: currentChat.value?.id,
|
||||
assignee: agent,
|
||||
assigneeType: agent ? 'User' : null,
|
||||
});
|
||||
store.dispatch('assignAgent', {
|
||||
conversationId: currentChat.value?.id,
|
||||
@@ -42,9 +43,8 @@ const assignedAgent = computed({
|
||||
},
|
||||
});
|
||||
|
||||
const isUserTyping = computed(
|
||||
() => props.message !== '' && !props.isOnPrivateNote
|
||||
);
|
||||
const hasMessage = computed(() => props.message !== '');
|
||||
const isUserTyping = computed(() => hasMessage.value && !props.isOnPrivateNote);
|
||||
const isUnassigned = computed(() => !assignedAgent.value);
|
||||
const isAssignedToOtherAgent = computed(
|
||||
() => assignedAgent.value?.id !== currentUser.value?.id
|
||||
@@ -56,16 +56,24 @@ const showSelfAssignBanner = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const showBotHandoffBanner = computed(
|
||||
() =>
|
||||
isUserTyping.value &&
|
||||
currentChat.value?.status === wootConstants.STATUS_TYPE.PENDING
|
||||
const isPendingConversation = computed(
|
||||
() => currentChat.value?.status === wootConstants.STATUS_TYPE.PENDING
|
||||
);
|
||||
|
||||
const botHandoffActionLabel = computed(() => {
|
||||
return assignedAgent.value?.id === currentUser.value?.id
|
||||
? t('CONVERSATION.BOT_HANDOFF_REOPEN_ACTION')
|
||||
: t('CONVERSATION.BOT_HANDOFF_ACTION');
|
||||
const isAgentBotOwned = computed(
|
||||
() => currentChat.value?.meta?.assignee_type === 'AgentBot'
|
||||
);
|
||||
|
||||
const showBotHandoffBanner = computed(() => {
|
||||
return isPendingConversation.value && isAgentBotOwned.value;
|
||||
});
|
||||
|
||||
const botAssigneeName = computed(() => {
|
||||
if (isAgentBotOwned.value && assignedAgent.value?.name) {
|
||||
return assignedAgent.value.name;
|
||||
}
|
||||
|
||||
return t('CONVERSATION.BOT_HANDOFF_FALLBACK_ASSIGNEE');
|
||||
});
|
||||
|
||||
const selfAssignConversation = async () => {
|
||||
@@ -89,15 +97,18 @@ const onClickSelfAssign = async () => {
|
||||
const reopenConversation = async () => {
|
||||
await store.dispatch('toggleStatus', {
|
||||
conversationId: currentChat.value?.id,
|
||||
status: wootConstants.STATUS_TYPE.OPEN,
|
||||
status: 'open',
|
||||
});
|
||||
};
|
||||
|
||||
const onClickBotHandoff = async () => {
|
||||
try {
|
||||
const shouldAssignToCurrentUser =
|
||||
isAgentBotOwned.value || needsAssignmentToCurrentUser.value;
|
||||
|
||||
await reopenConversation();
|
||||
|
||||
if (needsAssignmentToCurrentUser.value) {
|
||||
if (shouldAssignToCurrentUser) {
|
||||
await selfAssignConversation();
|
||||
}
|
||||
|
||||
@@ -124,9 +135,13 @@ const onClickBotHandoff = async () => {
|
||||
action-button-variant="ghost"
|
||||
color-scheme="secondary"
|
||||
class="mx-2 mb-2 rounded-lg !py-2"
|
||||
:banner-message="$t('CONVERSATION.BOT_HANDOFF_MESSAGE')"
|
||||
:banner-message="
|
||||
$t('CONVERSATION.BOT_HANDOFF_MESSAGE', {
|
||||
assigneeName: botAssigneeName,
|
||||
})
|
||||
"
|
||||
has-action-button
|
||||
:action-button-label="botHandoffActionLabel"
|
||||
:action-button-label="$t('CONVERSATION.BOT_HANDOFF_ACTION')"
|
||||
@primary-action="onClickBotHandoff"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
import { nextTick } from 'vue';
|
||||
import { createStore } from 'vuex';
|
||||
import ReplyBox from '../ReplyBox.vue';
|
||||
import WhatsappTemplates from '../WhatsappTemplates/Modal.vue';
|
||||
|
||||
const CHANNELS = [
|
||||
{ name: 'WhatsApp Cloud', inbox: { channel_type: 'Channel::Whatsapp' } },
|
||||
{
|
||||
name: 'Twilio WhatsApp',
|
||||
inbox: { channel_type: 'Channel::TwilioSms', medium: 'whatsapp' },
|
||||
},
|
||||
{ name: 'API', inbox: { channel_type: 'Channel::Api' } },
|
||||
{ name: 'Instagram', inbox: { channel_type: 'Channel::Instagram' } },
|
||||
{ name: 'TikTok', inbox: { channel_type: 'Channel::Tiktok' } },
|
||||
{ name: 'Facebook', inbox: { channel_type: 'Channel::FacebookPage' } },
|
||||
{ name: 'Line', inbox: { channel_type: 'Channel::Line' } },
|
||||
{ name: 'Telegram', inbox: { channel_type: 'Channel::Telegram' } },
|
||||
{ name: 'Email', inbox: { channel_type: 'Channel::Email' } },
|
||||
{ name: 'Web widget', inbox: { channel_type: 'Channel::WebWidget' } },
|
||||
];
|
||||
|
||||
const exemptFromMessagingWindow = name =>
|
||||
['WhatsApp Cloud', 'Twilio WhatsApp', 'API'].includes(name);
|
||||
|
||||
const REPLIABLE = {
|
||||
id: 1,
|
||||
inbox_id: 1,
|
||||
can_reply: true,
|
||||
status: 'open',
|
||||
meta: { sender: { id: 2 } },
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const buildStore = ({ inbox, chat, templates, drafts = {} }) =>
|
||||
createStore({
|
||||
state: {
|
||||
chat: { ...REPLIABLE, ...chat },
|
||||
replyEditorMode: REPLY_EDITOR_MODES.REPLY,
|
||||
drafts: { ...drafts },
|
||||
},
|
||||
mutations: {
|
||||
selectChat: (s, c) => {
|
||||
s.chat = c;
|
||||
},
|
||||
setReplyEditorMode: (s, mode) => {
|
||||
s.replyEditorMode = mode;
|
||||
},
|
||||
setDraft: (s, { key, message }) => {
|
||||
s.drafts = { ...s.drafts, [key]: message };
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
'draftMessages/setReplyEditorMode': ({ commit }, { mode }) =>
|
||||
commit('setReplyEditorMode', mode),
|
||||
'draftMessages/set': ({ commit }, payload) => commit('setDraft', payload),
|
||||
},
|
||||
getters: {
|
||||
getSelectedChat: s => s.chat,
|
||||
getCurrentUser: () => ({ id: 7, name: 'Agent', accounts: [] }),
|
||||
getCurrentAccountId: () => 1,
|
||||
getMessageSignature: () => '',
|
||||
getUISettings: () => ({}),
|
||||
getLastEmailInSelectedChat: () => null,
|
||||
'globalConfig/get': () => ({}),
|
||||
'inboxes/getInbox': () => () => ({ id: 1, ...inbox }),
|
||||
'inboxes/getWhatsAppTemplates': () => () => templates,
|
||||
'contacts/getContact': () => () => ({}),
|
||||
'draftMessages/get': s => key => s.drafts[key] || '',
|
||||
'draftMessages/getReplyEditorMode': s => s.replyEditorMode,
|
||||
'accounts/isFeatureEnabledonAccount': () => () => false,
|
||||
'accounts/getAccount': () => () => ({}),
|
||||
'portals/allPortals': () => [],
|
||||
'integrations/getUIFlags': () => ({ isFetching: false }),
|
||||
},
|
||||
});
|
||||
|
||||
const mountWith = ({
|
||||
inbox,
|
||||
chat,
|
||||
templates = [{ name: 'greeting' }],
|
||||
drafts,
|
||||
}) => {
|
||||
const store = buildStore({ inbox, chat, templates, drafts });
|
||||
const wrapper = shallowMount(ReplyBox, {
|
||||
global: {
|
||||
plugins: [store],
|
||||
mocks: { $t: key => key },
|
||||
// The bottom panel sits inside a <Transition>, which shallowMount stubs
|
||||
// without rendering its children.
|
||||
stubs: { transition: false },
|
||||
},
|
||||
});
|
||||
return { wrapper, store };
|
||||
};
|
||||
|
||||
const topPanel = wrapper =>
|
||||
wrapper.findComponent({ name: 'ReplyTopPanel' }).props();
|
||||
const bottomPanel = wrapper =>
|
||||
wrapper.findComponent({ name: 'ReplyBottomPanel' }).props();
|
||||
const editor = wrapper =>
|
||||
wrapper.findComponent({ name: 'WootMessageEditor' }).props();
|
||||
|
||||
describe('ReplyBox', () => {
|
||||
describe.each(CHANNELS)('$name', ({ name, inbox }) => {
|
||||
it('locks the composer and hides template sends when a bot owns a pending conversation', () => {
|
||||
const { wrapper } = mountWith({
|
||||
inbox,
|
||||
chat: {
|
||||
status: 'pending',
|
||||
meta: { sender: { id: 2 }, assignee_type: 'AgentBot' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(topPanel(wrapper).isReplyRestricted).toBe(true);
|
||||
expect(bottomPanel(wrapper).enableWhatsAppTemplates).toBe(false);
|
||||
expect(bottomPanel(wrapper).enableContentTemplates).toBe(false);
|
||||
// The note composer stays usable — this is a restriction, not a lockout.
|
||||
expect(topPanel(wrapper).isEditorDisabled).toBe(false);
|
||||
});
|
||||
|
||||
it('opens directly in note mode when a bot already owns the pending conversation', () => {
|
||||
const { wrapper, store } = mountWith({
|
||||
inbox,
|
||||
chat: {
|
||||
can_reply: false,
|
||||
status: 'pending',
|
||||
meta: { sender: { id: 2 }, assignee_type: 'AgentBot' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(bottomPanel(wrapper).isOnPrivateNote).toBe(true);
|
||||
expect(store.getters['draftMessages/getReplyEditorMode']).toBe(
|
||||
REPLY_EDITOR_MODES.NOTE
|
||||
);
|
||||
expect(topPanel(wrapper).isEditorDisabled).toBe(false);
|
||||
});
|
||||
|
||||
it('opens in reply mode for every other conversation', () => {
|
||||
const { wrapper, store } = mountWith({ inbox });
|
||||
|
||||
expect(bottomPanel(wrapper).isOnPrivateNote).toBe(false);
|
||||
expect(store.getters['draftMessages/getReplyEditorMode']).toBe(
|
||||
REPLY_EDITOR_MODES.REPLY
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['open', 'resolved', 'snoozed'])(
|
||||
'leaves the composer open when a bot owns a %s conversation',
|
||||
status => {
|
||||
const { wrapper } = mountWith({
|
||||
inbox,
|
||||
chat: {
|
||||
status,
|
||||
meta: { sender: { id: 2 }, assignee_type: 'AgentBot' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(topPanel(wrapper).isReplyRestricted).toBe(false);
|
||||
expect(bottomPanel(wrapper).enableWhatsAppTemplates).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('leaves the composer open when a human owns a pending conversation', () => {
|
||||
const { wrapper } = mountWith({
|
||||
inbox,
|
||||
chat: {
|
||||
status: 'pending',
|
||||
meta: { sender: { id: 2 }, assignee_type: 'User' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(topPanel(wrapper).isReplyRestricted).toBe(false);
|
||||
expect(bottomPanel(wrapper).enableWhatsAppTemplates).toBe(true);
|
||||
});
|
||||
|
||||
it('matches the existing messaging-window rule when no bot is involved', () => {
|
||||
const { wrapper } = mountWith({
|
||||
inbox,
|
||||
chat: { can_reply: false, status: 'resolved' },
|
||||
});
|
||||
|
||||
const stillRepliable = exemptFromMessagingWindow(name);
|
||||
expect(topPanel(wrapper).isReplyRestricted).toBe(!stillRepliable);
|
||||
expect(bottomPanel(wrapper).enableWhatsAppTemplates).toBe(stillRepliable);
|
||||
// WhatsApp/API disable the editor and steer to templates; everywhere
|
||||
// else the composer falls back to a usable private note.
|
||||
expect(topPanel(wrapper).isEditorDisabled).toBe(stillRepliable);
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the template action when the inbox has no templates synced', () => {
|
||||
const { wrapper } = mountWith({
|
||||
inbox: { channel_type: 'Channel::Whatsapp' },
|
||||
chat: { can_reply: false, status: 'open' },
|
||||
templates: [],
|
||||
});
|
||||
|
||||
expect(bottomPanel(wrapper).enableWhatsAppTemplates).toBe(false);
|
||||
expect(topPanel(wrapper).isReplyRestricted).toBe(false);
|
||||
});
|
||||
|
||||
describe('drafts', () => {
|
||||
const DRAFTS = {
|
||||
'draft-1-REPLY': 'half typed reply',
|
||||
'draft-1-NOTE': 'a note',
|
||||
};
|
||||
|
||||
it('loads the note draft while a bot owns the conversation', async () => {
|
||||
const { wrapper } = mountWith({
|
||||
inbox: { channel_type: 'Channel::WebWidget' },
|
||||
chat: {
|
||||
status: 'pending',
|
||||
meta: { sender: { id: 2 }, assignee_type: 'AgentBot' },
|
||||
},
|
||||
drafts: DRAFTS,
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(editor(wrapper)).toMatchObject({
|
||||
editorId: 'draft-1-NOTE',
|
||||
modelValue: 'a note',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the saved reply draft intact and restores it on takeover', async () => {
|
||||
const { wrapper, store } = mountWith({
|
||||
inbox: { channel_type: 'Channel::WebWidget' },
|
||||
chat: {
|
||||
status: 'pending',
|
||||
meta: { sender: { id: 2 }, assignee_type: 'AgentBot' },
|
||||
},
|
||||
drafts: DRAFTS,
|
||||
});
|
||||
await nextTick();
|
||||
expect(store.getters['draftMessages/get']('draft-1-REPLY')).toBe(
|
||||
'half typed reply'
|
||||
);
|
||||
|
||||
store.commit('selectChat', {
|
||||
...REPLIABLE,
|
||||
status: 'open',
|
||||
meta: { sender: { id: 2 }, assignee_type: 'User' },
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(editor(wrapper)).toMatchObject({
|
||||
editorId: 'draft-1-REPLY',
|
||||
modelValue: 'half typed reply',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('offers content templates on Twilio WhatsApp when no bot owns the conversation', () => {
|
||||
const { wrapper } = mountWith({
|
||||
inbox: { channel_type: 'Channel::TwilioSms', medium: 'whatsapp' },
|
||||
chat: { can_reply: true, status: 'open' },
|
||||
});
|
||||
|
||||
expect(bottomPanel(wrapper).enableContentTemplates).toBe(true);
|
||||
});
|
||||
|
||||
describe('on selecting a conversation', () => {
|
||||
const selectChat = async chat => {
|
||||
const { wrapper, store } = mountWith({
|
||||
inbox: { channel_type: 'Channel::WebWidget' },
|
||||
});
|
||||
store.commit('selectChat', { ...REPLIABLE, id: 99, ...chat });
|
||||
await nextTick();
|
||||
return { wrapper, store };
|
||||
};
|
||||
|
||||
it('switches to note mode when a bot owns a pending conversation', async () => {
|
||||
const { wrapper } = await selectChat({
|
||||
status: 'pending',
|
||||
meta: { sender: { id: 2 }, assignee_type: 'AgentBot' },
|
||||
});
|
||||
|
||||
expect(bottomPanel(wrapper).isOnPrivateNote).toBe(true);
|
||||
});
|
||||
|
||||
it('stays in reply mode when a human owns a pending conversation', async () => {
|
||||
const { wrapper } = await selectChat({
|
||||
status: 'pending',
|
||||
meta: { sender: { id: 2 }, assignee_type: 'User' },
|
||||
});
|
||||
|
||||
expect(bottomPanel(wrapper).isOnPrivateNote).toBe(false);
|
||||
});
|
||||
|
||||
it('closes an open template modal when a bot takes over the conversation', async () => {
|
||||
const { wrapper, store } = mountWith({
|
||||
inbox: { channel_type: 'Channel::Whatsapp' },
|
||||
});
|
||||
wrapper
|
||||
.findComponent({ name: 'ReplyBottomPanel' })
|
||||
.vm.$emit('selectWhatsappTemplate');
|
||||
await nextTick();
|
||||
expect(wrapper.findComponent(WhatsappTemplates).props('show')).toBe(true);
|
||||
|
||||
store.commit('selectChat', {
|
||||
...REPLIABLE,
|
||||
status: 'pending',
|
||||
meta: { sender: { id: 2 }, assignee_type: 'AgentBot' },
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.findComponent(WhatsappTemplates).props('show')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('returns to reply mode once the agent takes over', async () => {
|
||||
const { wrapper, store } = await selectChat({
|
||||
status: 'pending',
|
||||
meta: { sender: { id: 2 }, assignee_type: 'AgentBot' },
|
||||
});
|
||||
expect(bottomPanel(wrapper).isOnPrivateNote).toBe(true);
|
||||
|
||||
store.commit('selectChat', {
|
||||
...REPLIABLE,
|
||||
id: 99,
|
||||
status: 'open',
|
||||
meta: { sender: { id: 2 }, assignee_type: 'User' },
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(bottomPanel(wrapper).isOnPrivateNote).toBe(false);
|
||||
expect(topPanel(wrapper).isReplyRestricted).toBe(false);
|
||||
expect(store.getters['draftMessages/getReplyEditorMode']).toBe(
|
||||
REPLY_EDITOR_MODES.REPLY
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the agent in note mode after they chose it themselves', async () => {
|
||||
const { wrapper } = await selectChat({});
|
||||
wrapper
|
||||
.findComponent({ name: 'ReplyTopPanel' })
|
||||
.vm.$emit('setReplyMode', REPLY_EDITOR_MODES.NOTE);
|
||||
await nextTick();
|
||||
|
||||
expect(bottomPanel(wrapper).isOnPrivateNote).toBe(true);
|
||||
});
|
||||
|
||||
it('mirrors the forced note mode into the draftMessages store', async () => {
|
||||
const { store } = await selectChat({
|
||||
status: 'pending',
|
||||
meta: { sender: { id: 2 }, assignee_type: 'AgentBot' },
|
||||
});
|
||||
|
||||
expect(store.getters['draftMessages/getReplyEditorMode']).toBe(
|
||||
REPLY_EDITOR_MODES.NOTE
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,8 +36,9 @@
|
||||
"API_HOURS_WINDOW": "You can only reply to this conversation within {hours} hours",
|
||||
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
|
||||
"ASSIGN_TO_ME": "Assign to me",
|
||||
"BOT_HANDOFF_MESSAGE": "You are responding to a conversation which is currently handled by an assistant or a bot.",
|
||||
"BOT_HANDOFF_ACTION": "Mark open and assign to you",
|
||||
"BOT_HANDOFF_MESSAGE": "This conversation is currently handled by {assigneeName}.",
|
||||
"BOT_HANDOFF_FALLBACK_ASSIGNEE": "a bot",
|
||||
"BOT_HANDOFF_ACTION": "Take over",
|
||||
"BOT_HANDOFF_REOPEN_ACTION": "Mark conversation open",
|
||||
"BOT_HANDOFF_SUCCESS": "Conversation has been handed over to you",
|
||||
"BOT_HANDOFF_ERROR": "Failed to take over the conversation. Please try again.",
|
||||
|
||||
@@ -112,7 +112,9 @@ export const mutations = {
|
||||
const chat = getConversationById(_state)(conversationId);
|
||||
if (chat) {
|
||||
chat.meta.assignee = assignee;
|
||||
chat.meta.assignee_type = assigneeType;
|
||||
const inferredAssigneeType = assignee ? 'User' : null;
|
||||
chat.meta.assignee_type =
|
||||
assigneeType === undefined ? inferredAssigneeType : assigneeType;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -718,6 +718,48 @@ describe('#mutations', () => {
|
||||
expect(state.allConversations[0].meta.assignee_type).toEqual('AgentBot');
|
||||
expect(state.allConversations[1].meta.assignee).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should update assignee type when provided', () => {
|
||||
const assignee = { id: 1, name: 'Agent' };
|
||||
const state = {
|
||||
allConversations: [{ id: 1, meta: { assignee_type: 'AgentBot' } }],
|
||||
};
|
||||
|
||||
mutations[types.ASSIGN_AGENT](state, {
|
||||
conversationId: 1,
|
||||
assignee,
|
||||
assigneeType: 'User',
|
||||
});
|
||||
|
||||
expect(state.allConversations[0].meta.assignee_type).toEqual('User');
|
||||
});
|
||||
|
||||
it('should infer user assignee type when assignee type is omitted', () => {
|
||||
const assignee = { id: 1, name: 'Agent' };
|
||||
const state = {
|
||||
allConversations: [{ id: 1, meta: { assignee_type: 'AgentBot' } }],
|
||||
};
|
||||
|
||||
mutations[types.ASSIGN_AGENT](state, {
|
||||
conversationId: 1,
|
||||
assignee,
|
||||
});
|
||||
|
||||
expect(state.allConversations[0].meta.assignee_type).toEqual('User');
|
||||
});
|
||||
|
||||
it('should clear assignee type when assignee type and assignee are omitted', () => {
|
||||
const state = {
|
||||
allConversations: [{ id: 1, meta: { assignee_type: 'AgentBot' } }],
|
||||
};
|
||||
|
||||
mutations[types.ASSIGN_AGENT](state, {
|
||||
conversationId: 1,
|
||||
assignee: null,
|
||||
});
|
||||
|
||||
expect(state.allConversations[0].meta.assignee_type).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#ASSIGN_PRIORITY', () => {
|
||||
|
||||
Reference in New Issue
Block a user