Merge branch 'develop' into feat/openai-integration

This commit is contained in:
Sojan
2023-04-20 00:50:04 +05:30
12 changed files with 361 additions and 161 deletions

View File

@@ -21,7 +21,7 @@
.multiselect--active { .multiselect--active {
>.multiselect__tags { >.multiselect__tags {
border-color: $color-woot; border-color: var(--w-500);
} }
} }
@@ -75,16 +75,13 @@
} }
&.multiselect__option--selected { &.multiselect__option--selected {
background: var(--w-400); background: var(--w-75);
color: var(--white);
&.multiselect__option--highlight:hover { &.multiselect__option--highlight:hover {
background: var(--w-600); background: var(--w-75);
color: var(--white);
&::after { &::after {
background: transparent; background: transparent;
color: var(--white);
} }
&::after:hover { &::after:hover {
@@ -196,6 +193,7 @@
display: flex; display: flex;
font-size: var(--font-size-small); font-size: var(--font-size-small);
margin: 0; margin: 0;
max-height: 3.8rem;
padding: var(--space-smaller) var(--space-micro); padding: var(--space-smaller) var(--space-micro);
} }

View File

@@ -0,0 +1,37 @@
import InboxDropdownItem from './InboxDropdownItem';
export default {
title: 'Components/DropDowns/InboxDropdownItem',
component: InboxDropdownItem,
argTypes: {
name: {
defaultValue: 'My new inbox',
control: {
type: 'text',
},
},
inboxIdentifier: {
defaultValue: 'nithin@mail.com',
control: {
type: 'text',
},
},
channelType: {
defaultValue: 'email',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { InboxDropdownItem },
template: '<inbox-dropdown-item v-bind="$props" ></inbox-dropdown-item>',
});
export const Banner = Template.bind({});
Banner.args = {};

View File

@@ -0,0 +1,102 @@
<template>
<div class="option-item--inbox">
<span class="badge--icon">
<fluent-icon :icon="computedInboxIcon" size="14" />
</span>
<div class="option__user-data">
<h5 class="option__title">
{{ name }}
</h5>
<p class="option__body text-truncate" :title="inboxIdentifier">
{{ inboxIdentifier || computedInboxType }}
</p>
</div>
</div>
</template>
<script>
import {
getInboxClassByType,
getReadableInboxByType,
} from 'dashboard/helper/inbox';
export default {
components: {},
props: {
name: {
type: String,
default: '',
},
inboxIdentifier: {
type: String,
default: '',
},
channelType: {
type: String,
default: '',
},
},
computed: {
computedInboxIcon() {
if (!this.channelType) return 'chat';
const classByType = getInboxClassByType(
this.channelType,
this.inboxIdentifier
);
return classByType;
},
computedInboxType() {
if (!this.channelType) return 'chat';
const classByType = getReadableInboxByType(
this.channelType,
this.inboxIdentifier
);
return classByType;
},
},
};
</script>
<style lang="scss" scoped>
.option-item--inbox {
display: flex;
align-items: center;
height: 3.8rem;
min-width: 0;
padding: 0 var(--space-smaller);
}
.badge--icon {
display: inline-flex;
border-radius: var(--border-radius-small);
margin-right: var(--space-smaller);
background: var(--s-25);
padding: var(--space-micro);
align-items: center;
flex-shrink: 0;
justify-content: center;
width: var(--space-medium);
height: var(--space-medium);
}
.option__user-data {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
margin-left: var(--space-smaller);
margin-right: var(--space-smaller);
}
.option__body {
display: inline-block;
color: var(--s-600);
font-size: var(--font-size-small);
line-height: 1.3;
min-width: 0;
margin: 0;
}
.option__title {
line-height: 1.1;
font-size: var(--font-size-mini);
margin: 0;
}
</style>

View File

@@ -1,5 +1,55 @@
import { INBOX_TYPES } from 'shared/mixins/inboxMixin'; import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
export const getInboxSource = (type, phoneNumber, inbox) => {
switch (type) {
case INBOX_TYPES.WEB:
return inbox.website_url || '';
case INBOX_TYPES.TWILIO:
case INBOX_TYPES.WHATSAPP:
return phoneNumber || '';
case INBOX_TYPES.EMAIL:
return inbox.email || '';
default:
return '';
}
};
export const getReadableInboxByType = (type, phoneNumber) => {
switch (type) {
case INBOX_TYPES.WEB:
return 'livechat';
case INBOX_TYPES.FB:
return 'facebook';
case INBOX_TYPES.TWITTER:
return 'twitter';
case INBOX_TYPES.TWILIO:
return phoneNumber?.startsWith('whatsapp') ? 'whatsapp' : 'sms';
case INBOX_TYPES.WHATSAPP:
return 'whatsapp';
case INBOX_TYPES.API:
return 'api';
case INBOX_TYPES.EMAIL:
return 'email';
case INBOX_TYPES.TELEGRAM:
return 'telegram';
case INBOX_TYPES.LINE:
return 'line';
default:
return 'chat';
}
};
export const getInboxClassByType = (type, phoneNumber) => { export const getInboxClassByType = (type, phoneNumber) => {
switch (type) { switch (type) {
case INBOX_TYPES.WEB: case INBOX_TYPES.WEB:

View File

@@ -182,7 +182,8 @@
"LABEL": "To" "LABEL": "To"
}, },
"INBOX": { "INBOX": {
"LABEL": "Inbox", "LABEL": "Via Inbox",
"PLACEHOLDER": "Choose source inbox",
"ERROR": "Select an inbox" "ERROR": "Select an inbox"
}, },
"SUBJECT": { "SUBJECT": {

View File

@@ -8,17 +8,43 @@
<div v-else> <div v-else>
<div class="row gutter-small"> <div class="row gutter-small">
<div class="columns"> <div class="columns">
<label :class="{ error: $v.targetInbox.$error }"> <label>
{{ $t('NEW_CONVERSATION.FORM.INBOX.LABEL') }} {{ $t('NEW_CONVERSATION.FORM.INBOX.LABEL') }}
<select v-model="targetInbox"> </label>
<option <div class="multiselect-wrap--small">
v-for="contactableInbox in inboxes" <multiselect
:key="contactableInbox.inbox.id" v-model="targetInbox"
:value="contactableInbox" track-by="id"
> label="name"
{{ contactableInbox.inbox.name }} :placeholder="$t('FORMS.MULTISELECT.SELECT')"
</option> selected-label=""
</select> select-label=""
deselect-label=""
:max-height="160"
:close-on-select="true"
:options="[...inboxes]"
>
<template slot="singleLabel" slot-scope="{ option }">
<inbox-dropdown-item
v-if="option.name"
:name="option.name"
:inbox-identifier="computedInboxSource(option)"
:channel-type="option.channel_type"
/>
<span v-else>
{{ $t('NEW_CONVERSATION.FORM.INBOX.PLACEHOLDER') }}
</span>
</template>
<template slot="option" slot-scope="{ option }">
<inbox-dropdown-item
:name="option.name"
:inbox-identifier="computedInboxSource(option)"
:channel-type="option.channel_type"
/>
</template>
</multiselect>
</div>
<label :class="{ error: $v.targetInbox.$error }">
<span v-if="$v.targetInbox.$error" class="message"> <span v-if="$v.targetInbox.$error" class="message">
{{ $t('NEW_CONVERSATION.FORM.INBOX.ERROR') }} {{ $t('NEW_CONVERSATION.FORM.INBOX.ERROR') }}
</span> </span>
@@ -129,10 +155,12 @@ import Thumbnail from 'dashboard/components/widgets/Thumbnail';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor'; import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
import ReplyEmailHead from 'dashboard/components/widgets/conversation/ReplyEmailHead'; import ReplyEmailHead from 'dashboard/components/widgets/conversation/ReplyEmailHead';
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue'; import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
import InboxDropdownItem from 'dashboard/components/widgets/InboxDropdownItem';
import WhatsappTemplates from './WhatsappTemplates.vue'; import WhatsappTemplates from './WhatsappTemplates.vue';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import { INBOX_TYPES } from 'shared/mixins/inboxMixin'; import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors'; import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import { getInboxSource } from 'dashboard/helper/inbox';
import { required, requiredIf } from 'vuelidate/lib/validators'; import { required, requiredIf } from 'vuelidate/lib/validators';
export default { export default {
@@ -142,6 +170,7 @@ export default {
ReplyEmailHead, ReplyEmailHead,
CannedResponse, CannedResponse,
WhatsappTemplates, WhatsappTemplates,
InboxDropdownItem,
}, },
mixins: [alertMixin], mixins: [alertMixin],
props: { props: {
@@ -161,9 +190,9 @@ export default {
message: '', message: '',
showCannedResponseMenu: false, showCannedResponseMenu: false,
cannedResponseSearchKey: '', cannedResponseSearchKey: '',
selectedInbox: '',
bccEmails: '', bccEmails: '',
ccEmails: '', ccEmails: '',
targetInbox: {},
whatsappTemplateSelected: false, whatsappTemplateSelected: false,
}; };
}, },
@@ -186,8 +215,8 @@ export default {
}), }),
emailMessagePayload() { emailMessagePayload() {
const payload = { const payload = {
inboxId: this.targetInbox.inbox.id, inboxId: this.targetInbox.id,
sourceId: this.targetInbox.source_id, sourceId: this.targetInbox.sourceId,
contactId: this.contact.id, contactId: this.contact.id,
message: { content: this.message }, message: { content: this.message },
mailSubject: this.subject, mailSubject: this.subject,
@@ -202,12 +231,17 @@ export default {
} }
return payload; return payload;
}, },
targetInbox: { selectedInbox: {
get() { get() {
return this.selectedInbox || {}; const inboxList = this.contact.contactableInboxes || [];
return (
inboxList.find(inbox => inbox.inbox.id === this.targetInbox.id) || {
inbox: {},
}
);
}, },
set(value) { set(value) {
this.selectedInbox = value; this.targetInbox = value.inbox;
}, },
}, },
showNoInboxAlert() { showNoInboxAlert() {
@@ -217,7 +251,11 @@ export default {
return this.inboxes.length === 0 && !this.uiFlags.isFetchingInboxes; return this.inboxes.length === 0 && !this.uiFlags.isFetchingInboxes;
}, },
inboxes() { inboxes() {
return this.contact.contactableInboxes || []; const inboxList = this.contact.contactableInboxes || [];
return inboxList.map(inbox => ({
...inbox.inbox,
sourceId: inbox.source_id,
}));
}, },
isAnEmailInbox() { isAnEmailInbox() {
return ( return (
@@ -267,8 +305,8 @@ export default {
}, },
prepareWhatsAppMessagePayload({ message: content, templateParams }) { prepareWhatsAppMessagePayload({ message: content, templateParams }) {
const payload = { const payload = {
inboxId: this.targetInbox.inbox.id, inboxId: this.targetInbox.id,
sourceId: this.targetInbox.source_id, sourceId: this.targetInbox.sourceId,
contactId: this.contact.id, contactId: this.contact.id,
message: { content, template_params: templateParams }, message: { content, template_params: templateParams },
assigneeId: this.currentUser.id, assigneeId: this.currentUser.id,
@@ -311,6 +349,18 @@ export default {
const payload = this.prepareWhatsAppMessagePayload(messagePayload); const payload = this.prepareWhatsAppMessagePayload(messagePayload);
await this.createConversation(payload); await this.createConversation(payload);
}, },
inboxReadableIdentifier(inbox) {
return `${inbox.name} (${inbox.channel_type})`;
},
computedInboxSource(inbox) {
if (!inbox.channel_type) return '';
const classByType = getInboxSource(
inbox.channel_type,
inbox.phone_number,
inbox
);
return classByType;
},
}, },
}; };
</script> </script>
@@ -352,11 +402,23 @@ export default {
gap: var(--space-small); gap: var(--space-small);
} }
::v-deep .mention--box { ::v-deep {
left: 0; .mention--box {
margin: auto; left: 0;
right: 0; margin: auto;
top: unset; right: 0;
height: fit-content; top: unset;
height: fit-content;
}
/* TODO: Remove when have standardized a component out of multiselect */
.multiselect .multiselect__content .multiselect__option span {
display: inline-flex;
width: var(--space-medium);
color: var(--s-600);
}
.multiselect .multiselect__content .multiselect__option {
padding: var(--space-micro) var(--space-smaller);
}
} }
</style> </style>

View File

@@ -52,6 +52,8 @@ class ActionService
end end
def send_email_transcript(emails) def send_email_transcript(emails)
emails = emails[0].gsub(/\s+/, '').split(',')
emails.each do |email| emails.each do |email|
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, email)&.deliver_later ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, email)&.deliver_later
end end

View File

@@ -5,6 +5,9 @@ def fetch_git_sha
sha.strip sha.strip
elsif File.exist?('.git_sha') elsif File.exist?('.git_sha')
File.read('.git_sha').strip File.read('.git_sha').strip
# This is for Heroku. Ensure heroku labs:enable runtime-dyno-metadata is turned on.
elsif ENV.fetch('HEROKU_SLUG_COMMIT', nil).present?
ENV.fetch('HEROKU_SLUG_COMMIT', nil)
else else
'unknown' 'unknown'
end end

View File

@@ -121,18 +121,6 @@ describe AutomationRuleListener do
expect(conversation.assignee).to eq(user_1) expect(conversation.assignee).to eq(user_1)
end end
it 'triggers automation rule send email transcript to the mentioned email' do
mailer = double
expect(TeamNotifications::AutomationNotificationMailer).to receive(:conversation_creation)
listener.conversation_updated(event)
conversation.reload
allow(mailer).to receive(:conversation_transcript)
end
it 'triggers automation rule send message to the contacts' do it 'triggers automation rule send message to the contacts' do
expect(conversation.messages).to be_empty expect(conversation.messages).to be_empty
@@ -253,15 +241,6 @@ describe AutomationRuleListener do
expect(conversation.assignee).to eq(user_1) expect(conversation.assignee).to eq(user_1)
end end
it 'triggers automation rule send email transcript to the mentioned email' do
mailer = double
expect(TeamNotifications::AutomationNotificationMailer).to receive(:conversation_creation)
listener.conversation_updated(event)
conversation.reload
allow(mailer).to receive(:conversation_transcript)
end
it 'triggers automation rule send email to the team' do it 'triggers automation rule send email to the team' do
message_delivery = instance_double(ActionMailer::MessageDelivery) message_delivery = instance_double(ActionMailer::MessageDelivery)
@@ -457,15 +436,6 @@ describe AutomationRuleListener do
expect(conversation.assignee).to eq(user_1) expect(conversation.assignee).to eq(user_1)
end end
it 'triggers automation rule send email transcript to the mentioned email' do
mailer = double
expect(TeamNotifications::AutomationNotificationMailer).to receive(:conversation_creation)
listener.conversation_opened(event)
conversation.reload
allow(mailer).to receive(:conversation_transcript)
end
it 'triggers automation rule send email to the team' do it 'triggers automation rule send email to the team' do
message_delivery = instance_double(ActionMailer::MessageDelivery) message_delivery = instance_double(ActionMailer::MessageDelivery)
@@ -577,15 +547,6 @@ describe AutomationRuleListener do
expect(conversation.assignee).to eq(user_1) expect(conversation.assignee).to eq(user_1)
end end
it 'triggers automation rule send email transcript to the mentioned email' do
mailer = double
expect(TeamNotifications::AutomationNotificationMailer).to receive(:conversation_creation)
listener.message_created(event)
conversation.reload
allow(mailer).to receive(:conversation_transcript)
end
end end
end end
@@ -616,17 +577,6 @@ describe AutomationRuleListener do
end end
context 'when rule matches' do context 'when rule matches' do
it 'triggers automation rule send email transcript to the mentioned email' do
mailer = double
allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
allow(mailer).to receive(:conversation_transcript)
listener.message_created(event)
conversation.reload
expect(mailer).to have_received(:conversation_transcript).with(conversation, 'new_agent@example.com')
end
it 'triggers automation rule send message to the contacts' do it 'triggers automation rule send message to the contacts' do
expect(conversation.messages.count).to eq(1) expect(conversation.messages.count).to eq(1)
listener.message_created(event) listener.message_created(event)
@@ -710,18 +660,6 @@ describe AutomationRuleListener do
end end
context 'when rule matches' do context 'when rule matches' do
it 'triggers automation rule send email transcript to the mentioned email' do
mailer = double
allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
allow(mailer).to receive(:conversation_transcript)
listener.conversation_created(event)
conversation.reload
expect(mailer).to have_received(:conversation_transcript).with(conversation, 'new_agent@example.com')
end
it 'triggers automation rule send message to the contacts' do it 'triggers automation rule send message to the contacts' do
expect(conversation.messages.count).to eq(1) expect(conversation.messages.count).to eq(1)
@@ -781,15 +719,6 @@ describe AutomationRuleListener do
let(:event) { Events::Base.new('message_created', Time.zone.now, { conversation: tweet, message: message }) } let(:event) { Events::Base.new('message_created', Time.zone.now, { conversation: tweet, message: message }) }
let!(:message) { create(:message, account: account, conversation: tweet, message_type: 'incoming') } let!(:message) { create(:message, account: account, conversation: tweet, message_type: 'incoming') }
it 'triggers automation rule except send_message and send_attachment' do
mailer = double
allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
allow(mailer).to receive(:conversation_transcript)
listener.message_created(event)
expect(mailer).to have_received(:conversation_transcript).with(tweet, 'new_agent@example.com')
end
it 'does not triggers automation rule send message or send attachment' do it 'does not triggers automation rule send message or send attachment' do
expect(tweet.messages.count).to eq(1) expect(tweet.messages.count).to eq(1)

View File

@@ -87,5 +87,21 @@ RSpec.describe AutomationRules::ActionService do
described_class.new(rule, account, conversation).perform described_class.new(rule, account, conversation).perform
end end
end end
describe '#perform with send_email_transcript action' do
before do
rule.actions << { action_name: 'send_email_transcript', action_params: ['contact@example.com, agent@example.com,agent1@example.com'] }
end
it 'will send email to transcript to action params emails' do
mailer = double
allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
allow(mailer).to receive(:conversation_transcript).with(conversation, 'contact@example.com')
allow(mailer).to receive(:conversation_transcript).with(conversation, 'agent@example.com')
allow(mailer).to receive(:conversation_transcript).with(conversation, 'agent1@example.com')
described_class.new(rule, account, conversation).perform
end
end
end end
end end

View File

@@ -310,8 +310,6 @@
- $ref: '#/parameters/inbox_id' - $ref: '#/parameters/inbox_id'
get: get:
$ref: ./application/inboxes/inbox_members/show.yml $ref: ./application/inboxes/inbox_members/show.yml
delete:
$ref: ./application/inboxes/inbox_members/delete.yml
/api/v1/accounts/{account_id}/inbox_members: /api/v1/accounts/{account_id}/inbox_members:
parameters: parameters:
@@ -320,6 +318,8 @@
$ref: ./application/inboxes/inbox_members/create.yml $ref: ./application/inboxes/inbox_members/create.yml
patch: patch:
$ref: ./application/inboxes/inbox_members/update.yml $ref: ./application/inboxes/inbox_members/update.yml
delete:
$ref: ./application/inboxes/inbox_members/delete.yml

View File

@@ -3369,62 +3369,6 @@
"description": "Access denied" "description": "Access denied"
} }
} }
},
"delete": {
"tags": [
"Inboxes"
],
"operationId": "delete-agent-in-inbox",
"summary": "Remove an Agent from Inbox",
"description": "Remove an Agent from Inbox",
"security": [
{
"userApiKey": [
]
}
],
"parameters": [
{
"name": "data",
"in": "body",
"required": true,
"schema": {
"type": "object",
"required": [
"inbox_id",
"user_ids"
],
"properties": {
"inbox_id": {
"type": "string",
"description": "The ID of the inbox"
},
"user_ids": {
"type": "array",
"items": {
"type": "integer"
},
"description": "IDs of users to be deleted from the inbox"
}
}
}
}
],
"responses": {
"200": {
"description": "Success"
},
"404": {
"description": "Inbox not found"
},
"403": {
"description": "Access denied"
},
"422": {
"description": "User must exist"
}
}
} }
}, },
"/api/v1/accounts/{account_id}/inbox_members": { "/api/v1/accounts/{account_id}/inbox_members": {
@@ -3558,6 +3502,62 @@
"description": "User must exist" "description": "User must exist"
} }
} }
},
"delete": {
"tags": [
"Inboxes"
],
"operationId": "delete-agent-in-inbox",
"summary": "Remove an Agent from Inbox",
"description": "Remove an Agent from Inbox",
"security": [
{
"userApiKey": [
]
}
],
"parameters": [
{
"name": "data",
"in": "body",
"required": true,
"schema": {
"type": "object",
"required": [
"inbox_id",
"user_ids"
],
"properties": {
"inbox_id": {
"type": "string",
"description": "The ID of the inbox"
},
"user_ids": {
"type": "array",
"items": {
"type": "integer"
},
"description": "IDs of users to be deleted from the inbox"
}
}
}
}
],
"responses": {
"200": {
"description": "Success"
},
"404": {
"description": "Inbox not found"
},
"403": {
"description": "Access denied"
},
"422": {
"description": "User must exist"
}
}
} }
}, },
"/api/v1/accounts/{account_id}/conversations/{conversation_id}/messages": { "/api/v1/accounts/{account_id}/conversations/{conversation_id}/messages": {