feat: Create a new conversation from the contact panel (#2019)

* Chore: Improve button component styles
This commit is contained in:
Nithin David Thomas 2021-04-16 20:31:07 +05:30 committed by GitHub
parent c287ad08fb
commit 864471a21e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 469 additions and 9 deletions

View file

@ -14,6 +14,10 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/conversations`); return axios.get(`${this.url}/${contactId}/conversations`);
} }
getContactableInboxes(contactId) {
return axios.get(`${this.url}/${contactId}/contactable_inboxes`);
}
search(search = '', page = 1) { search(search = '', page = 1) {
return axios.get(`${this.url}/search?q=${search}&page=${page}`); return axios.get(`${this.url}/search?q=${search}&page=${page}`);
} }

View file

@ -13,7 +13,7 @@
> >
<spinner v-if="isLoading" size="small" /> <spinner v-if="isLoading" size="small" />
<i v-else-if="icon" class="icon" :class="icon"></i> <i v-else-if="icon" class="icon" :class="icon"></i>
<span v-if="$slots.default" class="content"><slot></slot></span> <span v-if="$slots.default" class="button__content"><slot></slot></span>
</button> </button>
</template> </template>
<script> <script>
@ -63,11 +63,16 @@ export default {
.button { .button {
display: flex; display: flex;
align-items: center; align-items: center;
&.link {
padding: 0;
margin: 0;
}
} }
.spinner { .spinner {
padding: 0 var(--space-small); padding: 0 var(--space-small);
} }
.icon + .content { .icon + .button__content {
padding-left: var(--space-small); padding-left: var(--space-small);
} }
</style> </style>

View file

@ -12,6 +12,7 @@
"INITIATED_FROM": "Initiated from", "INITIATED_FROM": "Initiated from",
"INITIATED_AT": "Initiated at", "INITIATED_AT": "Initiated at",
"IP_ADDRESS": "IP Address", "IP_ADDRESS": "IP Address",
"NEW_MESSAGE": "New message",
"CONVERSATIONS": { "CONVERSATIONS": {
"NO_RECORDS_FOUND": "There are no previous conversations associated to this contact.", "NO_RECORDS_FOUND": "There are no previous conversations associated to this contact.",
"TITLE": "Previous Conversations" "TITLE": "Previous Conversations"
@ -106,6 +107,30 @@
"CONTACT_ALREADY_EXIST": "This email address is in use for another contact.", "CONTACT_ALREADY_EXIST": "This email address is in use for another contact.",
"ERROR_MESSAGE": "There was an error, please try again" "ERROR_MESSAGE": "There was an error, please try again"
}, },
"NEW_CONVERSATION": {
"BUTTON_LABEL": "Start conversation",
"TITLE": "New conversation",
"DESC": "Start a new conversation by sending a new message.",
"NO_INBOX": "Couldn't find an inbox to initiate a new conversation with this contact.",
"FORM": {
"TO": {
"LABEL": "To"
},
"INBOX": {
"LABEL": "Inbox",
"ERROR": "Select an inbox"
},
"MESSAGE": {
"LABEL": "Message",
"PLACEHOLDER": "Write your message here",
"ERROR": "Message can't be empty"
},
"SUBMIT": "Send message",
"CANCEL": "Cancel",
"SUCCESS_MESSAGE": "Message sent!",
"ERROR_MESSAGE": "Couldn't send! try again"
}
},
"CONTACTS_PAGE": { "CONTACTS_PAGE": {
"HEADER": "Contacts", "HEADER": "Contacts",
"SEARCH_BUTTON": "Search", "SEARCH_BUTTON": "Search",

View file

@ -3,7 +3,7 @@
<span class="close-button" @click="onClose"> <span class="close-button" @click="onClose">
<i class="ion-android-close close-icon" /> <i class="ion-android-close close-icon" />
</span> </span>
<contact-info :contact="contact" /> <contact-info show-new-message :contact="contact" />
<contact-custom-attributes <contact-custom-attributes
v-if="hasContactAttributes" v-if="hasContactAttributes"
:custom-attributes="contact.custom_attributes" :custom-attributes="contact.custom_attributes"

View file

@ -50,19 +50,41 @@
</div> </div>
</div> </div>
<woot-button <woot-button
v-if="!showNewMessage"
class="edit-contact" class="edit-contact"
variant="clear" variant="clear link"
size="small" size="small"
@click="toggleEditModal" @click="toggleEditModal"
> >
{{ $t('EDIT_CONTACT.BUTTON_LABEL') }} {{ $t('EDIT_CONTACT.BUTTON_LABEL') }}
</woot-button> </woot-button>
<div v-else class="contact-actions">
<woot-button
class="new-message"
size="small expanded"
@click="toggleConversationModal"
>
{{ $t('CONTACT_PANEL.NEW_MESSAGE') }}
</woot-button>
<woot-button
variant="hollow"
size="small expanded"
@click="toggleEditModal"
>
{{ $t('EDIT_CONTACT.BUTTON_LABEL') }}
</woot-button>
</div>
<edit-contact <edit-contact
v-if="showEditModal" v-if="showEditModal"
:show="showEditModal" :show="showEditModal"
:contact="contact" :contact="contact"
@cancel="toggleEditModal" @cancel="toggleEditModal"
/> />
<new-conversation
:show="showConversationModal"
:contact="contact"
@cancel="toggleConversationModal"
/>
</div> </div>
</div> </div>
</template> </template>
@ -71,6 +93,7 @@ import ContactInfoRow from './ContactInfoRow';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import SocialIcons from './SocialIcons'; import SocialIcons from './SocialIcons';
import EditContact from './EditContact'; import EditContact from './EditContact';
import NewConversation from './NewConversation';
export default { export default {
components: { components: {
@ -78,6 +101,7 @@ export default {
EditContact, EditContact,
Thumbnail, Thumbnail,
SocialIcons, SocialIcons,
NewConversation,
}, },
props: { props: {
contact: { contact: {
@ -88,10 +112,15 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
showNewMessage: {
type: Boolean,
default: false,
},
}, },
data() { data() {
return { return {
showEditModal: false, showEditModal: false,
showConversationModal: false,
}; };
}, },
computed: { computed: {
@ -111,6 +140,9 @@ export default {
toggleEditModal() { toggleEditModal() {
this.showEditModal = !this.showEditModal; this.showEditModal = !this.showEditModal;
}, },
toggleConversationModal() {
this.showConversationModal = !this.showConversationModal;
},
}, },
}; };
</script> </script>
@ -120,7 +152,7 @@ export default {
@import '~dashboard/assets/scss/mixins'; @import '~dashboard/assets/scss/mixins';
.contact--profile { .contact--profile {
align-items: flex-start; align-items: flex-start;
padding: var(--space-normal) var(--space-normal) var(--space-large); padding: var(--space-normal) var(--space-normal);
.user-thumbnail-box { .user-thumbnail-box {
margin-right: $space-normal; margin-right: $space-normal;
@ -164,8 +196,21 @@ export default {
font-size: $font-weight-normal; font-size: $font-weight-normal;
} }
} }
.contact-actions {
margin: var(--space-small) 0;
}
.button.edit-contact {
margin-left: var(--space-two);
padding-left: var(--space-micro);
}
.edit-contact { .button.new-message {
margin-left: var(--space-slab); margin-right: var(--space-small);
}
.contact-actions {
display: flex;
align-items: center;
width: 100%;
} }
</style> </style>

View file

@ -0,0 +1,210 @@
<template>
<form class="conversation--form" @submit.prevent="handleSubmit">
<div v-if="showNoInboxAlert" class="callout warning">
<p>
{{ $t('NEW_CONVERSATION.NO_INBOX') }}
</p>
</div>
<div v-else>
<div class="row">
<div class="columns">
<label :class="{ error: $v.targetInbox.$error }">
{{ $t('NEW_CONVERSATION.FORM.INBOX.LABEL') }}
<select v-model="targetInbox">
<option
v-for="contactableInbox in inboxes"
:key="contactableInbox.inbox.id"
:value="contactableInbox"
>
{{ contactableInbox.inbox.name }}
</option>
</select>
<span v-if="$v.targetInbox.$error" class="message">
{{ $t('NEW_CONVERSATION.FORM.INBOX.ERROR') }}
</span>
</label>
</div>
<div class="columns">
<label>
{{ $t('NEW_CONVERSATION.FORM.TO.LABEL') }}
<div class="contact-input">
<thumbnail
:src="contact.thumbnail"
size="24px"
:username="contact.name"
:status="contact.availability_status"
/>
<h4 class="text-block-title contact-name">
{{ contact.name }}
</h4>
</div>
</label>
</div>
</div>
<div class="row">
<div class="columns">
<label :class="{ error: $v.message.$error }">
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.LABEL') }}
<textarea
v-model="message"
class="message-input"
type="text"
:placeholder="$t('NEW_CONVERSATION.FORM.MESSAGE.PLACEHOLDER')"
@input="$v.message.$touch"
/>
<span v-if="$v.message.$error" class="message">
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.ERROR') }}
</span>
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button class="button clear" @click.prevent="onCancel">
{{ $t('NEW_CONVERSATION.FORM.CANCEL') }}
</button>
<woot-button type="submit" :is-loading="conversationsUiFlags.isCreating">
{{ $t('NEW_CONVERSATION.FORM.SUBMIT') }}
</woot-button>
</div>
</form>
</template>
<script>
import { mapGetters } from 'vuex';
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
import alertMixin from 'shared/mixins/alertMixin';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import { required } from 'vuelidate/lib/validators';
export default {
components: {
Thumbnail,
},
mixins: [alertMixin],
props: {
contact: {
type: Object,
default: () => ({}),
},
onSubmit: {
type: Function,
default: () => {},
},
},
data() {
return {
name: '',
message: '',
selectedInbox: '',
};
},
validations: {
message: {
required,
},
targetInbox: {
required,
},
},
computed: {
...mapGetters({
uiFlags: 'contacts/getUIFlags',
conversationsUiFlags: 'contactConversations/getUIFlags',
}),
getNewConversation() {
return {
inboxId: this.targetInbox.inbox.id,
sourceId: this.targetInbox.source_id,
contactId: this.contact.id,
message: { content: this.message },
};
},
targetInbox: {
get() {
return this.selectedInbox || '';
},
set(value) {
this.selectedInbox = value;
},
},
showNoInboxAlert() {
if (!this.contact.contactableInboxes) {
return false;
}
return this.inboxes.length === 0 && !this.uiFlags.isFetchingInboxes;
},
inboxes() {
return this.contact.contactableInboxes || [];
},
},
methods: {
onCancel() {
this.$emit('cancel');
},
onSuccess() {
this.$emit('success');
},
async handleSubmit() {
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
try {
const payload = this.getNewConversation;
await this.onSubmit(payload);
this.onSuccess();
this.showAlert(this.$t('NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'));
} catch (error) {
if (error instanceof ExceptionWithMessage) {
this.showAlert(error.data);
} else {
this.showAlert(this.$t('NEW_CONVERSATION.FORM.ERROR_MESSAGE'));
}
}
},
},
};
</script>
<style scoped lang="scss">
.conversation--form {
padding: var(--space-normal) var(--space-large) var(--space-large);
.columns {
padding: 0 var(--space-smaller);
}
}
.input-group-label {
font-size: var(--font-size-small);
}
.contact-input {
display: flex;
align-items: center;
height: 3.9rem;
background: var(--color-background-light);
border: 1px solid var(--color-border);
padding: var(--space-smaller) var(--space-small);
border-radius: var(--border-radius-small);
.contact-name {
margin: 0;
margin-left: var(--space-small);
}
}
.message-input {
min-height: 8rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
}
</style>

View file

@ -0,0 +1,51 @@
<template>
<woot-modal :show.sync="show" :on-close="onCancel">
<div class="column content-box">
<woot-modal-header
:header-title="$t('NEW_CONVERSATION.TITLE')"
:header-content="$t('NEW_CONVERSATION.DESC')"
/>
<conversation-form
:contact="contact"
:on-submit="onSubmit"
@success="onSuccess"
@cancel="onCancel"
/>
</div>
</woot-modal>
</template>
<script>
import ConversationForm from './ConversationForm';
export default {
components: {
ConversationForm,
},
props: {
show: {
type: Boolean,
default: false,
},
contact: {
type: Object,
default: () => ({}),
},
},
mounted() {
const { id } = this.contact;
this.$store.dispatch('contacts/fetchContactableInbox', id);
},
methods: {
onCancel() {
this.$emit('cancel');
},
onSuccess() {
this.$emit('cancel');
},
async onSubmit(contactItem) {
await this.$store.dispatch('contactConversations/create', contactItem);
},
},
};
</script>

View file

@ -1,6 +1,7 @@
import Vue from 'vue'; import Vue from 'vue';
import * as types from '../mutation-types'; import * as types from '../mutation-types';
import ContactAPI from '../../api/contacts'; import ContactAPI from '../../api/contacts';
import ConversationApi from '../../api/conversations';
const state = { const state = {
records: {}, records: {},
@ -19,6 +20,30 @@ export const getters = {
}; };
export const actions = { export const actions = {
create: async ({ commit }, params) => {
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
isCreating: true,
});
const { inboxId, message, contactId, sourceId } = params;
try {
const { data } = await ConversationApi.create({
inbox_id: inboxId,
contact_id: contactId,
source_id: sourceId,
message,
});
commit(types.default.ADD_CONTACT_CONVERSATION, {
id: contactId,
data,
});
} catch (error) {
throw new Error(error);
} finally {
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
isCreating: false,
});
}
},
get: async ({ commit }, contactId) => { get: async ({ commit }, contactId) => {
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
isFetching: true, isFetching: true,
@ -53,6 +78,10 @@ export const mutations = {
[types.default.SET_CONTACT_CONVERSATIONS]: ($state, { id, data }) => { [types.default.SET_CONTACT_CONVERSATIONS]: ($state, { id, data }) => {
Vue.set($state.records, id, data); Vue.set($state.records, id, data);
}, },
[types.default.ADD_CONTACT_CONVERSATION]: ($state, { id, data }) => {
const conversations = $state.records[id] || [];
Vue.set($state.records, id, [...conversations, data]);
},
}; };
export default { export default {

View file

@ -83,6 +83,26 @@ export const actions = {
} }
}, },
fetchContactableInbox: async ({ commit }, id) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetchingInboxes: true });
try {
const response = await ContactAPI.getContactableInboxes(id);
const contact = {
id,
contactableInboxes: response.data.payload,
};
commit(types.SET_CONTACT_ITEM, contact);
} catch (error) {
if (error.response?.data?.message) {
throw new ExceptionWithMessage(error.response.data.message);
} else {
throw new Error(error);
}
} finally {
commit(types.SET_CONTACT_UI_FLAG, { isFetchingInboxes: false });
}
},
updatePresence: ({ commit }, data) => { updatePresence: ({ commit }, data) => {
commit(types.UPDATE_CONTACTS_PRESENCE, data); commit(types.UPDATE_CONTACTS_PRESENCE, data);
}, },

View file

@ -11,6 +11,7 @@ const state = {
uiFlags: { uiFlags: {
isFetching: false, isFetching: false,
isFetchingItem: false, isFetchingItem: false,
isFetchingInboxes: false,
isUpdating: false, isUpdating: false,
}, },
}; };

View file

@ -1,5 +1,6 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers'; import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import * as types from '../mutation-types'; import * as types from '../mutation-types';
import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
import InboxesAPI from '../../api/inboxes'; import InboxesAPI from '../../api/inboxes';
import WebChannel from '../../api/channel/webChannel'; import WebChannel from '../../api/channel/webChannel';
import FBChannel from '../../api/channel/fbChannel'; import FBChannel from '../../api/channel/fbChannel';
@ -41,6 +42,20 @@ export const getters = {
getInboxes($state) { getInboxes($state) {
return $state.records; return $state.records;
}, },
getNewConversationInboxes($state) {
return $state.records.filter(inbox => {
const {
channel_type: channelType,
phone_number: phoneNumber = '',
} = inbox;
const isEmailChannel = channelType === INBOX_TYPES.EMAIL;
const isSmsChannel =
channelType === INBOX_TYPES.TWILIO &&
phoneNumber.startsWith('whatsapp');
return isEmailChannel || isSmsChannel;
});
},
getInbox: $state => inboxId => { getInbox: $state => inboxId => {
const [inbox] = $state.records.filter( const [inbox] = $state.records.filter(
record => record.id === Number(inboxId) record => record.id === Number(inboxId)

View file

@ -38,4 +38,43 @@ describe('#actions', () => {
]); ]);
}); });
}); });
describe('#create', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: conversationList[0] });
await actions.create(
{ commit },
{ inboxId: 1, message: { content: 'hi' }, contactId: 4, sourceId: 5 }
);
expect(commit.mock.calls).toEqual([
[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isCreating: true }],
[
types.default.ADD_CONTACT_CONVERSATION,
{ id: 4, data: conversationList[0] },
],
[
types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG,
{ isCreating: false },
],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.create(
{ commit },
{ inboxId: 1, message: { content: 'hi' }, contactId: 4, sourceId: 5 }
)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, { isCreating: true }],
[
types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG,
{ isCreating: false },
],
]);
});
});
}); });

View file

@ -26,4 +26,17 @@ describe('#mutations', () => {
}); });
}); });
}); });
describe('#ADD_CONTACT_CONVERSATION', () => {
it('Adds new contact conversation to records', () => {
const state = { records: {} };
mutations[types.default.ADD_CONTACT_CONVERSATION](state, {
id: 1,
data: { id: 1, contact_id: 1, message: 'hello' },
});
expect(state.records).toEqual({
1: [{ id: 1, contact_id: 1, message: 'hello' }],
});
});
});
}); });

View file

@ -114,6 +114,7 @@ export default {
// Contact Conversation // Contact Conversation
SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG', SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG',
SET_CONTACT_CONVERSATIONS: 'SET_CONTACT_CONVERSATIONS', SET_CONTACT_CONVERSATIONS: 'SET_CONTACT_CONVERSATIONS',
ADD_CONTACT_CONVERSATION: 'ADD_CONTACT_CONVERSATION',
// Conversation Label // Conversation Label
SET_CONVERSATION_LABELS_UI_FLAG: 'SET_CONVERSATION_LABELS_UI_FLAG', SET_CONVERSATION_LABELS_UI_FLAG: 'SET_CONVERSATION_LABELS_UI_FLAG',

View file

@ -40,7 +40,7 @@ class Contacts::ContactableInboxesService
end end
def twilio_contactable_inbox(inbox) def twilio_contactable_inbox(inbox)
return unless @contact.phone_number return if @contact.phone_number.blank?
case inbox.channel.medium case inbox.channel.medium
when 'sms' when 'sms'

View file

@ -1,2 +1,4 @@
json.source_id resource.source_id json.source_id resource.source_id
json.inbox resource.inbox json.inbox do
json.partial! 'api/v1/models/inbox.json.jbuilder', resource: resource.inbox
end