feat: Add Contacts page (#1335)
Co-authored-by: Sojan <sojan@pepalo.com> Co-authored-by: Pranav Raj Sreepuram <pranavrajs@gmail.com>
This commit is contained in:
parent
2babfd6148
commit
f214c9c47c
41 changed files with 1163 additions and 179 deletions
|
@ -64,6 +64,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
|||
end
|
||||
|
||||
def agents
|
||||
@agents ||= Current.account.users.order_by_full_name
|
||||
@agents ||= Current.account.users.order_by_full_name.includes({ avatar_attachment: [:blob] })
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,17 +1,32 @@
|
|||
class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
RESULTS_PER_PAGE = 15
|
||||
protect_from_forgery with: :null_session
|
||||
|
||||
before_action :check_authorization
|
||||
before_action :set_current_page, only: [:index, :active, :search]
|
||||
before_action :fetch_contact, only: [:show, :update]
|
||||
|
||||
def index
|
||||
@contacts = Current.account.contacts
|
||||
contacts = Current.account.contacts.where.not(email: [nil, '']).or(Current.account.contacts.where.not(phone_number: [nil, '']))
|
||||
@contacts_count = contacts.count
|
||||
@contacts = fetch_contact_last_seen_at(contacts)
|
||||
end
|
||||
|
||||
def search
|
||||
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
|
||||
|
||||
contacts = Current.account.contacts.where.not(email: [nil, '']).or(Current.account.contacts.where.not(phone_number: [nil, '']))
|
||||
.where('name LIKE :search OR email LIKE :search', search: "%#{params[:q]}%")
|
||||
@contacts_count = contacts.count
|
||||
@contacts = fetch_contact_last_seen_at(contacts)
|
||||
end
|
||||
|
||||
# returns online contacts
|
||||
def active
|
||||
@contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
|
||||
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
|
||||
.get_available_contact_ids(Current.account.id))
|
||||
@contacts_count = contacts.count
|
||||
@contacts = contacts.page(@current_page)
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
@ -36,13 +51,19 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def search
|
||||
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
|
||||
private
|
||||
|
||||
@contacts = Current.account.contacts.where('name LIKE :search OR email LIKE :search', search: "%#{params[:q]}%")
|
||||
def set_current_page
|
||||
@current_page = params[:page] || 1
|
||||
end
|
||||
|
||||
private
|
||||
def fetch_contact_last_seen_at(contacts)
|
||||
contacts.left_outer_joins(:conversations)
|
||||
.select('contacts.*, COUNT(conversations.id) as conversations_count, MAX(conversations.contact_last_seen_at) as last_seen_at')
|
||||
.group('contacts.id')
|
||||
.includes([{ avatar_attachment: [:blob] }, { contact_inboxes: [:inbox] }])
|
||||
.page(@current_page).per(RESULTS_PER_PAGE)
|
||||
end
|
||||
|
||||
def build_contact_inbox
|
||||
return if params[:inbox_id].blank?
|
||||
|
|
|
@ -4,7 +4,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||
before_action :check_authorization
|
||||
|
||||
def index
|
||||
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, :avatar_attachment))
|
||||
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
||||
end
|
||||
|
||||
def create
|
||||
|
|
|
@ -62,9 +62,7 @@ class ConversationFinder
|
|||
end
|
||||
|
||||
def find_all_conversations
|
||||
@conversations = current_account.conversations.includes(
|
||||
:assignee, :inbox, :taggings, contact: [:avatar_attachment]
|
||||
).where(inbox_id: @inbox_ids)
|
||||
@conversations = current_account.conversations.where(inbox_id: @inbox_ids)
|
||||
end
|
||||
|
||||
def filter_by_assignee_type
|
||||
|
@ -106,6 +104,9 @@ class ConversationFinder
|
|||
end
|
||||
|
||||
def conversations
|
||||
@conversations = @conversations.includes(
|
||||
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }
|
||||
)
|
||||
current_page ? @conversations.latest.page(current_page) : @conversations.latest
|
||||
end
|
||||
end
|
||||
|
|
|
@ -11,7 +11,7 @@ class MessageFinder
|
|||
private
|
||||
|
||||
def conversation_messages
|
||||
@conversation.messages.includes(:attachments, :sender)
|
||||
@conversation.messages.includes(:attachments, :sender, sender: { avatar_attachment: [:blob] })
|
||||
end
|
||||
|
||||
def messages
|
||||
|
|
|
@ -6,9 +6,17 @@ class ContactAPI extends ApiClient {
|
|||
super('contacts', { accountScoped: true });
|
||||
}
|
||||
|
||||
get(page) {
|
||||
return axios.get(`${this.url}?page=${page}`);
|
||||
}
|
||||
|
||||
getConversations(contactId) {
|
||||
return axios.get(`${this.url}/${contactId}/conversations`);
|
||||
}
|
||||
|
||||
search(search = '', page = 1) {
|
||||
return axios.get(`${this.url}/search?q=${search}&page=${page}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ContactAPI();
|
||||
|
|
|
@ -26,3 +26,4 @@
|
|||
@import 'views/signup';
|
||||
|
||||
@import 'plugins/multiselect';
|
||||
@import 'plugins/dropdown';
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
@import 'shared/assets/stylesheets/colors';
|
||||
@import 'shared/assets/stylesheets/spacing';
|
||||
@import 'shared/assets/stylesheets/font-size';
|
||||
@import 'shared/assets/stylesheets/font-weights';
|
||||
@import 'variables';
|
||||
|
||||
@import '~spinkit/scss/spinners/7-three-bounce';
|
||||
|
|
27
app/javascript/dashboard/assets/scss/plugins/_dropdown.scss
Normal file
27
app/javascript/dashboard/assets/scss/plugins/_dropdown.scss
Normal file
|
@ -0,0 +1,27 @@
|
|||
.dropdown-pane.sleek {
|
||||
@include elegant-card;
|
||||
@include border-light;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
right: -12px;
|
||||
top: 48px;
|
||||
width: auto;
|
||||
|
||||
&::before {
|
||||
@include arrow(top, var(--color-border-light), 14px);
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: -14px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@include arrow(top, $color-white, var(--space-slab));
|
||||
position: absolute;
|
||||
right: var(--space-small);
|
||||
top: -12px;
|
||||
}
|
||||
|
||||
.dropdown>li>a:hover {
|
||||
background: var(--color-background);
|
||||
}
|
||||
}
|
|
@ -30,7 +30,7 @@
|
|||
<availability-status />
|
||||
</div>
|
||||
|
||||
<div class="bottom-nav">
|
||||
<div class="bottom-nav app-context-menu">
|
||||
<transition name="menu-slide">
|
||||
<div
|
||||
v-if="showOptionsMenu"
|
||||
|
@ -405,4 +405,7 @@ export default {
|
|||
}
|
||||
}
|
||||
}
|
||||
.app-context-menu {
|
||||
height: 6rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
<template>
|
||||
<div class="row empty-state">
|
||||
<h3 class="title">{{title}}</h3>
|
||||
<p class="message">{{message}}</p>
|
||||
<h3 class="title">{{ title }}</h3>
|
||||
<p class="message">{{ message }}</p>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
||||
export default {
|
||||
props: {
|
||||
title: String,
|
||||
|
|
|
@ -7,6 +7,7 @@ export const getSidebarItems = accountId => ({
|
|||
'inbox_dashboard',
|
||||
'inbox_conversation',
|
||||
'conversation_through_inbox',
|
||||
'contacts_dashboard',
|
||||
'settings_account_reports',
|
||||
'profile_settings',
|
||||
'profile_settings_index',
|
||||
|
@ -23,6 +24,13 @@ export const getSidebarItems = accountId => ({
|
|||
toolTip: 'Conversation from all subscribed inboxes',
|
||||
toStateName: 'home',
|
||||
},
|
||||
contacts: {
|
||||
icon: 'ion-person-stalker',
|
||||
label: 'CONTACTS',
|
||||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/contacts`),
|
||||
toStateName: 'contacts_dashboard',
|
||||
},
|
||||
report: {
|
||||
icon: 'ion-arrow-graph-up-right',
|
||||
label: 'REPORTS',
|
||||
|
|
|
@ -95,5 +95,21 @@
|
|||
"SUCCESS_MESSAGE": "Updated contact successfully",
|
||||
"CONTACT_ALREADY_EXIST": "This email address is in use for another contact.",
|
||||
"ERROR_MESSAGE": "There was an error updating the contact, please try again"
|
||||
},
|
||||
"CONTACTS_PAGE": {
|
||||
"HEADER": "Contacts",
|
||||
"SEARCH_BUTTON": "Search",
|
||||
"SEARCH_INPUT_PLACEHOLDER": "Search for contacts",
|
||||
"LIST": {
|
||||
"LOADING_MESSAGE": "Loading contacts...",
|
||||
"404": "No contacts matches your search 🔍",
|
||||
"TABLE_HEADER": [
|
||||
"Name",
|
||||
"Phone Number",
|
||||
"Conversations",
|
||||
"Last Contacted"
|
||||
],
|
||||
"EDIT_BUTTON": "Edit"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
21
app/javascript/dashboard/i18n/locale/en/contactsPage.json
Normal file
21
app/javascript/dashboard/i18n/locale/en/contactsPage.json
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"CONTACTS_PAGE": {
|
||||
"HEADER": "Contacts",
|
||||
"SEARCH_BUTTON": "Search",
|
||||
"SEARCH_INPUT_PLACEHOLDER": "Search for contacts",
|
||||
"LIST": {
|
||||
"404": "There are no canned responses available in this account.",
|
||||
"TITLE": "Manage canned responses",
|
||||
"DESC": "Canned Responses are predefined reply templates which can be used to quickly send out replies to tickets.",
|
||||
"TABLE_HEADER": [
|
||||
"Name",
|
||||
"Phone Number",
|
||||
"Conversations",
|
||||
"Last Contacted"
|
||||
],
|
||||
"EDIT_BUTTON": "Edit",
|
||||
"VIEW_BUTTON": "View"
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -57,8 +57,7 @@
|
|||
},
|
||||
"AVAILABILITY": {
|
||||
"LABEL": "Availability",
|
||||
"STATUSES_LIST": [
|
||||
{
|
||||
"STATUSES_LIST": [{
|
||||
"value": "online",
|
||||
"label": "Online"
|
||||
},
|
||||
|
@ -122,6 +121,7 @@
|
|||
"SIDEBAR": {
|
||||
"CONVERSATIONS": "Conversations",
|
||||
"REPORTS": "Reports",
|
||||
"CONTACTS": "Contacts",
|
||||
"SETTINGS": "Settings",
|
||||
"HOME": "Home",
|
||||
"AGENTS": "Agents",
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
/* global bus */
|
||||
import Sidebar from '../../components/layout/Sidebar';
|
||||
|
||||
export default {
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
<template>
|
||||
<div class="medium-3 bg-white contact--panel">
|
||||
<span class="close-button" @click="onClose">
|
||||
<i class="ion-android-close close-icon" />
|
||||
</span>
|
||||
<contact-info :contact="contact" />
|
||||
<contact-custom-attributes
|
||||
v-if="hasContactAttributes"
|
||||
:custom-attributes="contact.custom_attributes"
|
||||
/>
|
||||
<contact-conversations
|
||||
v-if="contact.id"
|
||||
:contact-id="contact.id"
|
||||
conversation-id=""
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ContactConversations from 'dashboard/routes/dashboard/conversation/ContactConversations';
|
||||
import ContactInfo from 'dashboard/routes/dashboard/conversation/contact/ContactInfo';
|
||||
import ContactCustomAttributes from 'dashboard/routes/dashboard/conversation/ContactCustomAttributes';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ContactCustomAttributes,
|
||||
ContactConversations,
|
||||
ContactInfo,
|
||||
},
|
||||
props: {
|
||||
contact: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
onClose: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
hasContactAttributes() {
|
||||
const { custom_attributes: customAttributes } = this.contact;
|
||||
return customAttributes && Object.keys(customAttributes).length;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~dashboard/assets/scss/variables';
|
||||
@import '~dashboard/assets/scss/mixins';
|
||||
|
||||
.contact--panel {
|
||||
@include border-normal-left;
|
||||
|
||||
background: white;
|
||||
font-size: var(--font-size-small);
|
||||
overflow-y: auto;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
padding: var(--space-one);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
right: var(--space-normal);
|
||||
top: var(--space-slab);
|
||||
font-size: var(--font-size-big);
|
||||
color: var(--color-heading);
|
||||
|
||||
.close-icon {
|
||||
margin-right: var(--space-smaller);
|
||||
}
|
||||
}
|
||||
|
||||
.conversation--details {
|
||||
border-top: 1px solid $color-border-light;
|
||||
padding: var(--space-normal);
|
||||
}
|
||||
|
||||
.contact-conversation--panel {
|
||||
border-top: 1px solid $color-border-light;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.contact--mute {
|
||||
color: var(--r-400);
|
||||
display: block;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.contact--actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,198 @@
|
|||
<template>
|
||||
<section class="contacts-table-wrap">
|
||||
<table class="woot-table contacts-table">
|
||||
<thead>
|
||||
<th
|
||||
v-for="thHeader in $t('CONTACTS_PAGE.LIST.TABLE_HEADER')"
|
||||
:key="thHeader"
|
||||
>
|
||||
{{ thHeader }}
|
||||
</th>
|
||||
</thead>
|
||||
<tbody v-show="showTableData">
|
||||
<tr
|
||||
v-for="contactItem in contacts"
|
||||
:key="contactItem.id"
|
||||
:class="{ 'is-active': contactItem.id === activeContactId }"
|
||||
@click="() => onClickContact(contactItem.id)"
|
||||
>
|
||||
<td>
|
||||
<div class="row-main-info">
|
||||
<thumbnail
|
||||
:src="contactItem.thumbnail"
|
||||
size="36px"
|
||||
:username="contactItem.name"
|
||||
:status="contactItem.availability_status"
|
||||
/>
|
||||
<div>
|
||||
<h4 class="sub-block-title user-name">
|
||||
{{ contactItem.name }}
|
||||
</h4>
|
||||
<p class="user-email">
|
||||
{{ contactItem.email || '--' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ contactItem.phone_number || '--' }}</td>
|
||||
<td class="conversation-count-item">
|
||||
{{ contactItem.conversations_count }}
|
||||
</td>
|
||||
<td>
|
||||
{{ contactItem.last_contacted_at || '--' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<empty-state
|
||||
v-if="showSearchEmptyState"
|
||||
:title="$t('CONTACTS_PAGE.LIST.404')"
|
||||
/>
|
||||
<div v-if="isLoading" class="contacts--loader">
|
||||
<spinner />
|
||||
<span>{{ $t('CONTACTS_PAGE.LIST.LOADING_MESSAGE') }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Thumbnail,
|
||||
EmptyState,
|
||||
Spinner,
|
||||
},
|
||||
mixins: [clickaway],
|
||||
props: {
|
||||
contacts: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
showSearchEmptyState: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
openEditModal: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
onClickContact: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
activeContactId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
currentRoute() {
|
||||
return ' ';
|
||||
},
|
||||
sidebarClassName() {
|
||||
if (this.isOnDesktop) {
|
||||
return '';
|
||||
}
|
||||
if (this.isSidebarOpen) {
|
||||
return 'off-canvas is-open ';
|
||||
}
|
||||
return 'off-canvas position-left is-transition-push is-closed';
|
||||
},
|
||||
contentClassName() {
|
||||
if (this.isOnDesktop) {
|
||||
return '';
|
||||
}
|
||||
if (this.isSidebarOpen) {
|
||||
return 'off-canvas-content is-open-left has-transition-push has-position-left';
|
||||
}
|
||||
return 'off-canvas-content';
|
||||
},
|
||||
showTableData() {
|
||||
return !this.showSearchEmptyState && !this.isLoading;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~dashboard/assets/scss/mixins';
|
||||
|
||||
.contacts-table-wrap {
|
||||
@include scroll-on-hover;
|
||||
background: var(--color-background-light);
|
||||
flex: 1 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.contacts-table {
|
||||
> thead {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: white;
|
||||
|
||||
> th:first-child {
|
||||
padding-left: var(--space-medium);
|
||||
width: 30%;
|
||||
}
|
||||
}
|
||||
|
||||
> tbody {
|
||||
> tr {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--b-50);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: var(--b-100);
|
||||
}
|
||||
|
||||
> td {
|
||||
padding: var(--space-slab);
|
||||
|
||||
&:first-child {
|
||||
padding-left: var(--space-medium);
|
||||
}
|
||||
|
||||
&.conversation-count-item {
|
||||
padding-left: var(--space-medium);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.row-main-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.user-thumbnail-box {
|
||||
margin-right: var(--space-small);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
text-transform: capitalize;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contacts--loader {
|
||||
font-size: var(--font-size-default);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-big);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,146 @@
|
|||
<template>
|
||||
<div class="contacts-page row">
|
||||
<div class="left-wrap" :class="wrapClas">
|
||||
<contacts-header
|
||||
:search-query="searchQuery"
|
||||
:on-search-submit="onSearchSubmit"
|
||||
:on-input-search="onInputSearch"
|
||||
/>
|
||||
<contacts-table
|
||||
:contacts="records"
|
||||
:show-search-empty-state="showEmptySearchResult"
|
||||
:open-edit-modal="openEditModal"
|
||||
:is-loading="uiFlags.isFetching"
|
||||
:on-click-contact="openContactInfoPanel"
|
||||
:active-contact-id="selectedContactId"
|
||||
/>
|
||||
<contacts-footer
|
||||
:on-page-change="onPageChange"
|
||||
:current-page="Number(meta.currentPage)"
|
||||
:total-count="meta.count"
|
||||
/>
|
||||
<edit-contact
|
||||
:show="showEditModal"
|
||||
:contact="selectedContact"
|
||||
@cancel="closeEditModal"
|
||||
/>
|
||||
</div>
|
||||
<contact-info-panel
|
||||
v-if="showContactViewPane"
|
||||
:contact="selectedContact"
|
||||
:on-close="closeContactInfoPanel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
import EditContact from 'dashboard/routes/dashboard/conversation/contact/EditContact';
|
||||
|
||||
import ContactsHeader from './Header';
|
||||
import ContactsTable from './ContactsTable';
|
||||
import ContactInfoPanel from './ContactInfoPanel';
|
||||
import ContactsFooter from './Footer';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ContactsHeader,
|
||||
ContactsTable,
|
||||
ContactsFooter,
|
||||
EditContact,
|
||||
ContactInfoPanel,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searchQuery: '',
|
||||
showEditModal: false,
|
||||
selectedContactId: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
records: 'contacts/getContacts',
|
||||
uiFlags: 'contacts/getUIFlags',
|
||||
meta: 'contacts/getMeta',
|
||||
}),
|
||||
showEmptySearchResult() {
|
||||
const hasEmptyResults = !!this.searchQuery && this.records.length === 0;
|
||||
return hasEmptyResults;
|
||||
},
|
||||
selectedContact() {
|
||||
if (this.selectedContactId) {
|
||||
const contact = this.records.find(
|
||||
item => this.selectedContactId === item.id
|
||||
);
|
||||
return contact;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
showContactViewPane() {
|
||||
return this.selectedContactId !== '';
|
||||
},
|
||||
wrapClas() {
|
||||
return this.showContactViewPane ? 'medium-9' : 'medium-12';
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('contacts/get', { page: 1 });
|
||||
},
|
||||
methods: {
|
||||
onInputSearch(event) {
|
||||
const newQuery = event.target.value;
|
||||
const refetchAllContacts = !!this.searchQuery && newQuery === '';
|
||||
|
||||
if (refetchAllContacts) {
|
||||
this.$store.dispatch('contacts/get', { page: 1 });
|
||||
}
|
||||
this.searchQuery = event.target.value;
|
||||
},
|
||||
onSearchSubmit() {
|
||||
this.$store.dispatch('contacts/search', {
|
||||
search: this.searchQuery,
|
||||
page: 1,
|
||||
});
|
||||
},
|
||||
onPageChange(page) {
|
||||
if (this.searchQuery) {
|
||||
this.$store.dispatch('contacts/search', {
|
||||
search: this.searchQuery,
|
||||
page,
|
||||
});
|
||||
} else {
|
||||
this.$store.dispatch('contacts/get', { page });
|
||||
}
|
||||
},
|
||||
openContactInfoPanel(contactId) {
|
||||
this.selectedContactId = contactId;
|
||||
this.showContactInfoPanelPane = true;
|
||||
},
|
||||
closeContactInfoPanel() {
|
||||
this.selectedContactId = '';
|
||||
this.showContactInfoPanelPane = false;
|
||||
},
|
||||
openEditModal(contactId) {
|
||||
this.selectedContactId = contactId;
|
||||
this.showEditModal = true;
|
||||
},
|
||||
closeEditModal() {
|
||||
this.selectedContactId = '';
|
||||
this.showEditModal = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.contacts-page {
|
||||
width: 100%;
|
||||
}
|
||||
.left-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: var(--space-normal);
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,204 @@
|
|||
<template>
|
||||
<footer class="footer">
|
||||
<div class="left-aligned-wrap">
|
||||
<div class="page-meta">
|
||||
<strong>{{ firstIndex }}</strong>
|
||||
- <strong>{{ lastIndex }}</strong> of
|
||||
<strong>{{ totalCount }}</strong> items
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-aligned-wrap">
|
||||
<div
|
||||
v-if="totalCount"
|
||||
class="primary button-group pagination-button-group"
|
||||
>
|
||||
<button
|
||||
class="button small goto-first"
|
||||
:class="firstPageButtonClass"
|
||||
@click="onFirstPage"
|
||||
>
|
||||
<i class="ion-chevron-left" />
|
||||
<i class="ion-chevron-left" />
|
||||
</button>
|
||||
<button
|
||||
class="button small"
|
||||
:class="prevPageButtonClass"
|
||||
@click="onPrevPage"
|
||||
>
|
||||
<i class="ion-chevron-left" />
|
||||
</button>
|
||||
<button class="button" @click.prevent>
|
||||
{{ currentPage }}
|
||||
</button>
|
||||
<button
|
||||
class="button small"
|
||||
:class="nextPageButtonClass"
|
||||
@click="onNextPage"
|
||||
>
|
||||
<i class="ion-chevron-right" />
|
||||
</button>
|
||||
<button
|
||||
class="button small goto-last"
|
||||
:class="lastPageButtonClass"
|
||||
@click="onLastPage"
|
||||
>
|
||||
<i class="ion-chevron-right" />
|
||||
<i class="ion-chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 25,
|
||||
},
|
||||
totalCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
onPageChange: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
firstIndex() {
|
||||
const firstIndex = this.pageSize * (this.currentPage - 1) + 1;
|
||||
return firstIndex;
|
||||
},
|
||||
lastIndex() {
|
||||
const index = Math.min(this.totalCount, this.pageSize * this.currentPage);
|
||||
return index;
|
||||
},
|
||||
searchButtonClass() {
|
||||
return this.searchQuery !== '' ? 'show' : '';
|
||||
},
|
||||
hasLastPage() {
|
||||
const isDisabled =
|
||||
this.currentPage === Math.ceil(this.totalCount / this.pageSize);
|
||||
return isDisabled;
|
||||
},
|
||||
lastPageButtonClass() {
|
||||
const className = this.hasLastPage ? 'disabled' : '';
|
||||
return className;
|
||||
},
|
||||
hasFirstPage() {
|
||||
const isDisabled = this.currentPage === 1;
|
||||
return isDisabled;
|
||||
},
|
||||
firstPageButtonClass() {
|
||||
const className = this.hasFirstPage ? 'disabled' : '';
|
||||
return className;
|
||||
},
|
||||
hasNextPage() {
|
||||
const isDisabled =
|
||||
this.currentPage === Math.ceil(this.totalCount / this.pageSize);
|
||||
return isDisabled;
|
||||
},
|
||||
nextPageButtonClass() {
|
||||
const className = this.hasNextPage ? 'disabled' : '';
|
||||
return className;
|
||||
},
|
||||
hasPrevPage() {
|
||||
const isDisabled = this.currentPage === 1;
|
||||
return isDisabled;
|
||||
},
|
||||
prevPageButtonClass() {
|
||||
const className = this.hasPrevPage ? 'disabled' : '';
|
||||
return className;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onNextPage() {
|
||||
if (this.hasNextPage) return;
|
||||
const newPage = this.currentPage + 1;
|
||||
this.onPageChange(newPage);
|
||||
},
|
||||
onPrevPage() {
|
||||
if (this.hasPrevPage) return;
|
||||
|
||||
const newPage = this.currentPage - 1;
|
||||
this.onPageChange(newPage);
|
||||
},
|
||||
onFirstPage() {
|
||||
if (this.hasFirstPage) return;
|
||||
|
||||
const newPage = 1;
|
||||
this.onPageChange(newPage);
|
||||
},
|
||||
onLastPage() {
|
||||
if (this.hasLastPage) return;
|
||||
|
||||
const newPage = Math.ceil(this.totalCount / this.pageSize);
|
||||
this.onPageChange(newPage);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.footer {
|
||||
height: 60px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--space-normal);
|
||||
}
|
||||
.page-meta {
|
||||
font-size: var(--font-size-mini);
|
||||
}
|
||||
.pagination-button-group {
|
||||
margin: 0;
|
||||
|
||||
.button {
|
||||
background: transparent;
|
||||
border-color: var(--b-400);
|
||||
color: var(--color-body);
|
||||
margin-bottom: 0;
|
||||
margin-left: -2px;
|
||||
font-size: var(--font-size-small);
|
||||
padding: var(--space-small) var(--space-normal);
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: var(--b-400);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
}
|
||||
&:last-child {
|
||||
border-top-right-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
}
|
||||
&.small {
|
||||
font-size: var(--font-size-micro);
|
||||
}
|
||||
&.disabled {
|
||||
background: var(--b-300);
|
||||
color: var(--b-900);
|
||||
}
|
||||
|
||||
&.goto-first,
|
||||
&.goto-last {
|
||||
i:last-child {
|
||||
margin-left: var(--space-minus-smaller);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,114 @@
|
|||
<template>
|
||||
<header class="header">
|
||||
<div class="table-actions-wrap">
|
||||
<div class="left-aligned-wrap">
|
||||
<h1 class="page-title">
|
||||
{{ $t('CONTACTS_PAGE.HEADER') }}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="right-aligned-wrap">
|
||||
<div class="search-wrap">
|
||||
<i class="ion-ios-search-strong search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
:placeholder="$t('CONTACTS_PAGE.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="contact-search"
|
||||
:value="searchQuery"
|
||||
@input="onInputSearch"
|
||||
/>
|
||||
<woot-submit-button
|
||||
:button-text="$t('CONTACTS_PAGE.SEARCH_BUTTON')"
|
||||
:loading="false"
|
||||
:button-class="searchButtonClass"
|
||||
@click="onSearchSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
searchQuery: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
onInputSearch: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
onSearchSubmit: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
searchButtonClass() {
|
||||
return this.searchQuery !== '' ? 'show' : '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* TODO-REM; Change variables sizing to rem after html font size change from 1.0 t0 1.6 */
|
||||
|
||||
.header {
|
||||
padding: 0 var(--space-medium);
|
||||
}
|
||||
.page-title {
|
||||
margin: 0;
|
||||
}
|
||||
.table-actions-wrap {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-bottom: var(--space-slab);
|
||||
}
|
||||
.search-wrap {
|
||||
width: 400px;
|
||||
height: 3.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: var(--space-one);
|
||||
height: 3.6rem;
|
||||
line-height: 3.6rem;
|
||||
font-size: var(--font-size-medium);
|
||||
color: var(--b-700);
|
||||
}
|
||||
.contact-search {
|
||||
margin: 0;
|
||||
height: 3.6rem;
|
||||
width: 100%;
|
||||
padding-left: var(--space-large);
|
||||
padding-right: 6rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-left: var(--space-small);
|
||||
height: 3.2rem;
|
||||
top: var(--space-micro);
|
||||
right: var(--space-micro);
|
||||
position: absolute;
|
||||
padding: 0 var(--space-small);
|
||||
transition: transform 100ms linear;
|
||||
opacity: 0;
|
||||
transform: translateX(-1px);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.button.show {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
</style>
|
12
app/javascript/dashboard/routes/dashboard/contacts/routes.js
Normal file
12
app/javascript/dashboard/routes/dashboard/contacts/routes.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
/* eslint arrow-body-style: 0 */
|
||||
import ContactsView from './components/ContactsView';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/contacts'),
|
||||
name: 'contacts_dashboard',
|
||||
roles: ['administrator', 'agent'],
|
||||
component: ContactsView,
|
||||
},
|
||||
];
|
|
@ -45,6 +45,7 @@
|
|||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
import ContactConversations from './ContactConversations.vue';
|
||||
import ContactDetailsItem from './ContactDetailsItem.vue';
|
||||
import ContactInfo from './contact/ContactInfo';
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import AppContainer from './Dashboard';
|
||||
import settings from './settings/settings.routes';
|
||||
import conversation from './conversation/conversation.routes';
|
||||
import { routes as contactRoutes } from './contacts/routes';
|
||||
import { frontendURL } from '../../helper/URLHelper';
|
||||
|
||||
export default {
|
||||
|
@ -8,7 +9,7 @@ export default {
|
|||
{
|
||||
path: frontendURL('accounts/:account_id'),
|
||||
component: AppContainer,
|
||||
children: [...conversation.routes, ...settings.routes],
|
||||
children: [...conversation.routes, ...settings.routes, ...contactRoutes],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
@ -1,126 +0,0 @@
|
|||
import { DuplicateContactException } from 'shared/helpers/CustomErrors';
|
||||
import * as types from '../mutation-types';
|
||||
import ContactAPI from '../../api/contacts';
|
||||
import Vue from 'vue';
|
||||
|
||||
const state = {
|
||||
records: {},
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
isFetchingItem: false,
|
||||
isUpdating: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
getContacts($state) {
|
||||
return Object.values($state.records);
|
||||
},
|
||||
getUIFlags($state) {
|
||||
return $state.uiFlags;
|
||||
},
|
||||
getContact: $state => id => {
|
||||
const contact = $state.records[id];
|
||||
return contact || {};
|
||||
},
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
get: async ({ commit }) => {
|
||||
commit(types.default.SET_CONTACT_UI_FLAG, { isFetching: true });
|
||||
try {
|
||||
const response = await ContactAPI.get();
|
||||
commit(types.default.SET_CONTACTS, response.data.payload);
|
||||
commit(types.default.SET_CONTACT_UI_FLAG, { isFetching: false });
|
||||
} catch (error) {
|
||||
commit(types.default.SET_CONTACT_UI_FLAG, { isFetching: false });
|
||||
}
|
||||
},
|
||||
|
||||
show: async ({ commit }, { id }) => {
|
||||
commit(types.default.SET_CONTACT_UI_FLAG, { isFetchingItem: true });
|
||||
try {
|
||||
const response = await ContactAPI.show(id);
|
||||
commit(types.default.SET_CONTACT_ITEM, response.data.payload);
|
||||
commit(types.default.SET_CONTACT_UI_FLAG, { isFetchingItem: false });
|
||||
} catch (error) {
|
||||
commit(types.default.SET_CONTACT_UI_FLAG, { isFetchingItem: false });
|
||||
}
|
||||
},
|
||||
|
||||
update: async ({ commit }, { id, ...updateObj }) => {
|
||||
commit(types.default.SET_CONTACT_UI_FLAG, { isUpdating: true });
|
||||
try {
|
||||
const response = await ContactAPI.update(id, updateObj);
|
||||
commit(types.default.EDIT_CONTACT, response.data.payload);
|
||||
commit(types.default.SET_CONTACT_UI_FLAG, { isUpdating: false });
|
||||
} catch (error) {
|
||||
commit(types.default.SET_CONTACT_UI_FLAG, { isUpdating: false });
|
||||
if (error.response?.data?.contact) {
|
||||
throw new DuplicateContactException(error.response.data.contact);
|
||||
} else {
|
||||
throw new Error(error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
updatePresence: ({ commit }, data) => {
|
||||
commit(types.default.UPDATE_CONTACTS_PRESENCE, data);
|
||||
},
|
||||
|
||||
setContact({ commit }, data) {
|
||||
commit(types.default.SET_CONTACT_ITEM, data);
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
[types.default.SET_CONTACT_UI_FLAG]($state, data) {
|
||||
$state.uiFlags = {
|
||||
...$state.uiFlags,
|
||||
...data,
|
||||
};
|
||||
},
|
||||
|
||||
[types.default.SET_CONTACTS]: ($state, data) => {
|
||||
data.forEach(contact => {
|
||||
Vue.set($state.records, contact.id, {
|
||||
...($state.records[contact.id] || {}),
|
||||
...contact,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
[types.default.SET_CONTACT_ITEM]: ($state, data) => {
|
||||
Vue.set($state.records, data.id, {
|
||||
...($state.records[data.id] || {}),
|
||||
...data,
|
||||
});
|
||||
},
|
||||
|
||||
[types.default.EDIT_CONTACT]: ($state, data) => {
|
||||
Vue.set($state.records, data.id, data);
|
||||
},
|
||||
|
||||
[types.default.UPDATE_CONTACTS_PRESENCE]: ($state, data) => {
|
||||
Object.values($state.records).forEach(element => {
|
||||
const availabilityStatus = data[element.id];
|
||||
if (availabilityStatus) {
|
||||
Vue.set(
|
||||
$state.records[element.id],
|
||||
'availability_status',
|
||||
availabilityStatus
|
||||
);
|
||||
} else {
|
||||
Vue.delete($state.records[element.id], 'availability_status');
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
74
app/javascript/dashboard/store/modules/contacts/actions.js
Normal file
74
app/javascript/dashboard/store/modules/contacts/actions.js
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { DuplicateContactException } from 'shared/helpers/CustomErrors';
|
||||
import types from '../../mutation-types';
|
||||
import ContactAPI from '../../../api/contacts';
|
||||
|
||||
export const actions = {
|
||||
search: async ({ commit }, { search, page }) => {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isFetching: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await ContactAPI.search(search, page);
|
||||
commit(types.CLEAR_CONTACTS);
|
||||
commit(types.SET_CONTACTS, payload);
|
||||
commit(types.SET_CONTACT_META, meta);
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
|
||||
} catch (error) {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
|
||||
}
|
||||
},
|
||||
|
||||
get: async ({ commit }, { page = 1 } = {}) => {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isFetching: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await ContactAPI.get(page);
|
||||
commit(types.CLEAR_CONTACTS);
|
||||
commit(types.SET_CONTACTS, payload);
|
||||
commit(types.SET_CONTACT_META, meta);
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
|
||||
} catch (error) {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
|
||||
}
|
||||
},
|
||||
|
||||
show: async ({ commit }, { id }) => {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isFetchingItem: true });
|
||||
try {
|
||||
const response = await ContactAPI.show(id);
|
||||
commit(types.SET_CONTACT_ITEM, response.data.payload);
|
||||
commit(types.SET_CONTACT_UI_FLAG, {
|
||||
isFetchingItem: false,
|
||||
});
|
||||
} catch (error) {
|
||||
commit(types.SET_CONTACT_UI_FLAG, {
|
||||
isFetchingItem: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
update: async ({ commit }, { id, ...updateObj }) => {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: true });
|
||||
try {
|
||||
const response = await ContactAPI.update(id, updateObj);
|
||||
commit(types.EDIT_CONTACT, response.data.payload);
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: false });
|
||||
} catch (error) {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: false });
|
||||
if (error.response?.data?.contact) {
|
||||
throw new DuplicateContactException(error.response.data.contact);
|
||||
} else {
|
||||
throw new Error(error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
updatePresence: ({ commit }, data) => {
|
||||
commit(types.UPDATE_CONTACTS_PRESENCE, data);
|
||||
},
|
||||
|
||||
setContact({ commit }, data) {
|
||||
commit(types.SET_CONTACT_ITEM, data);
|
||||
},
|
||||
};
|
15
app/javascript/dashboard/store/modules/contacts/getters.js
Normal file
15
app/javascript/dashboard/store/modules/contacts/getters.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
export const getters = {
|
||||
getContacts($state) {
|
||||
return Object.values($state.records);
|
||||
},
|
||||
getUIFlags($state) {
|
||||
return $state.uiFlags;
|
||||
},
|
||||
getContact: $state => id => {
|
||||
const contact = $state.records[id];
|
||||
return contact || {};
|
||||
},
|
||||
getMeta: $state => {
|
||||
return $state.meta;
|
||||
},
|
||||
};
|
24
app/javascript/dashboard/store/modules/contacts/index.js
Normal file
24
app/javascript/dashboard/store/modules/contacts/index.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { getters } from './getters';
|
||||
import { actions } from './actions';
|
||||
import { mutations } from './mutations';
|
||||
|
||||
const state = {
|
||||
meta: {
|
||||
count: 0,
|
||||
currentPage: 1,
|
||||
},
|
||||
records: {},
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
isFetchingItem: false,
|
||||
isUpdating: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
56
app/javascript/dashboard/store/modules/contacts/mutations.js
Normal file
56
app/javascript/dashboard/store/modules/contacts/mutations.js
Normal file
|
@ -0,0 +1,56 @@
|
|||
import Vue from 'vue';
|
||||
import types from '../../mutation-types';
|
||||
|
||||
export const mutations = {
|
||||
[types.SET_CONTACT_UI_FLAG]($state, data) {
|
||||
$state.uiFlags = {
|
||||
...$state.uiFlags,
|
||||
...data,
|
||||
};
|
||||
},
|
||||
|
||||
[types.CLEAR_CONTACTS]: $state => {
|
||||
Vue.set($state, 'records', {});
|
||||
},
|
||||
|
||||
[types.SET_CONTACT_META]: ($state, data) => {
|
||||
const { count, current_page: currentPage } = data;
|
||||
Vue.set($state.meta, 'count', count);
|
||||
Vue.set($state.meta, 'currentPage', currentPage);
|
||||
},
|
||||
|
||||
[types.SET_CONTACTS]: ($state, data) => {
|
||||
data.forEach(contact => {
|
||||
Vue.set($state.records, contact.id, {
|
||||
...($state.records[contact.id] || {}),
|
||||
...contact,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
[types.SET_CONTACT_ITEM]: ($state, data) => {
|
||||
Vue.set($state.records, data.id, {
|
||||
...($state.records[data.id] || {}),
|
||||
...data,
|
||||
});
|
||||
},
|
||||
|
||||
[types.EDIT_CONTACT]: ($state, data) => {
|
||||
Vue.set($state.records, data.id, data);
|
||||
},
|
||||
|
||||
[types.UPDATE_CONTACTS_PRESENCE]: ($state, data) => {
|
||||
Object.values($state.records).forEach(element => {
|
||||
const availabilityStatus = data[element.id];
|
||||
if (availabilityStatus) {
|
||||
Vue.set(
|
||||
$state.records[element.id],
|
||||
'availability_status',
|
||||
availabilityStatus
|
||||
);
|
||||
} else {
|
||||
Vue.delete($state.records[element.id], 'availability_status');
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
|
@ -1,9 +1,11 @@
|
|||
import axios from 'axios';
|
||||
import { actions } from '../../contacts';
|
||||
import * as types from '../../../mutation-types';
|
||||
import Contacts from '../../contacts';
|
||||
import types from '../../../mutation-types';
|
||||
import contactList from './fixtures';
|
||||
import { DuplicateContactException } from '../../../../../shared/helpers/CustomErrors';
|
||||
|
||||
const { actions } = Contacts;
|
||||
|
||||
const commit = jest.fn();
|
||||
global.axios = axios;
|
||||
jest.mock('axios');
|
||||
|
@ -11,20 +13,24 @@ jest.mock('axios');
|
|||
describe('#actions', () => {
|
||||
describe('#get', () => {
|
||||
it('sends correct mutations if API is success', async () => {
|
||||
axios.get.mockResolvedValue({ data: { payload: contactList } });
|
||||
axios.get.mockResolvedValue({
|
||||
data: { payload: contactList, meta: { count: 100, current_page: 1 } },
|
||||
});
|
||||
await actions.get({ commit });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_CONTACT_UI_FLAG, { isFetching: true }],
|
||||
[types.default.SET_CONTACTS, contactList],
|
||||
[types.default.SET_CONTACT_UI_FLAG, { isFetching: false }],
|
||||
[types.SET_CONTACT_UI_FLAG, { isFetching: true }],
|
||||
[types.CLEAR_CONTACTS],
|
||||
[types.SET_CONTACTS, contactList],
|
||||
[types.SET_CONTACT_META, { count: 100, current_page: 1 }],
|
||||
[types.SET_CONTACT_UI_FLAG, { isFetching: false }],
|
||||
]);
|
||||
});
|
||||
it('sends correct mutations if API is error', async () => {
|
||||
axios.get.mockRejectedValue({ message: 'Incorrect header' });
|
||||
await actions.get({ commit });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_CONTACT_UI_FLAG, { isFetching: true }],
|
||||
[types.default.SET_CONTACT_UI_FLAG, { isFetching: false }],
|
||||
[types.SET_CONTACT_UI_FLAG, { isFetching: true }],
|
||||
[types.SET_CONTACT_UI_FLAG, { isFetching: false }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -34,17 +40,17 @@ describe('#actions', () => {
|
|||
axios.get.mockResolvedValue({ data: { payload: contactList[0] } });
|
||||
await actions.show({ commit }, { id: contactList[0].id });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_CONTACT_UI_FLAG, { isFetchingItem: true }],
|
||||
[types.default.SET_CONTACT_ITEM, contactList[0]],
|
||||
[types.default.SET_CONTACT_UI_FLAG, { isFetchingItem: false }],
|
||||
[types.SET_CONTACT_UI_FLAG, { isFetchingItem: true }],
|
||||
[types.SET_CONTACT_ITEM, contactList[0]],
|
||||
[types.SET_CONTACT_UI_FLAG, { isFetchingItem: false }],
|
||||
]);
|
||||
});
|
||||
it('sends correct mutations if API is error', async () => {
|
||||
axios.get.mockRejectedValue({ message: 'Incorrect header' });
|
||||
await actions.show({ commit }, { id: contactList[0].id });
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_CONTACT_UI_FLAG, { isFetchingItem: true }],
|
||||
[types.default.SET_CONTACT_UI_FLAG, { isFetchingItem: false }],
|
||||
[types.SET_CONTACT_UI_FLAG, { isFetchingItem: true }],
|
||||
[types.SET_CONTACT_UI_FLAG, { isFetchingItem: false }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -54,9 +60,9 @@ describe('#actions', () => {
|
|||
axios.patch.mockResolvedValue({ data: { payload: contactList[0] } });
|
||||
await actions.update({ commit }, contactList[0]);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_CONTACT_UI_FLAG, { isUpdating: true }],
|
||||
[types.default.EDIT_CONTACT, contactList[0]],
|
||||
[types.default.SET_CONTACT_UI_FLAG, { isUpdating: false }],
|
||||
[types.SET_CONTACT_UI_FLAG, { isUpdating: true }],
|
||||
[types.EDIT_CONTACT, contactList[0]],
|
||||
[types.SET_CONTACT_UI_FLAG, { isUpdating: false }],
|
||||
]);
|
||||
});
|
||||
it('sends correct actions if API is error', async () => {
|
||||
|
@ -65,8 +71,8 @@ describe('#actions', () => {
|
|||
Error
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_CONTACT_UI_FLAG, { isUpdating: true }],
|
||||
[types.default.SET_CONTACT_UI_FLAG, { isUpdating: false }],
|
||||
[types.SET_CONTACT_UI_FLAG, { isUpdating: true }],
|
||||
[types.SET_CONTACT_UI_FLAG, { isUpdating: false }],
|
||||
]);
|
||||
});
|
||||
|
||||
|
@ -83,8 +89,8 @@ describe('#actions', () => {
|
|||
DuplicateContactException
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_CONTACT_UI_FLAG, { isUpdating: true }],
|
||||
[types.default.SET_CONTACT_UI_FLAG, { isUpdating: false }],
|
||||
[types.SET_CONTACT_UI_FLAG, { isUpdating: true }],
|
||||
[types.SET_CONTACT_UI_FLAG, { isUpdating: false }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -93,9 +99,7 @@ describe('#actions', () => {
|
|||
it('returns correct mutations', () => {
|
||||
const data = { id: 1, name: 'john doe', availability_status: 'online' };
|
||||
actions.setContact({ commit }, data);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_CONTACT_ITEM, data],
|
||||
]);
|
||||
expect(commit.mock.calls).toEqual([[types.SET_CONTACT_ITEM, data]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { getters } from '../../contacts';
|
||||
import Contacts from '../../contacts';
|
||||
import contactList from './fixtures';
|
||||
|
||||
const { getters } = Contacts;
|
||||
|
||||
describe('#getters', () => {
|
||||
it('getContacts', () => {
|
||||
const state = {
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import * as types from '../../../mutation-types';
|
||||
import { mutations } from '../../contacts';
|
||||
import types from '../../../mutation-types';
|
||||
import Contacts from '../../contacts';
|
||||
const { mutations } = Contacts;
|
||||
|
||||
describe('#mutations', () => {
|
||||
describe('#SET_CONTACTS', () => {
|
||||
it('set contact records', () => {
|
||||
const state = { records: {} };
|
||||
mutations[types.default.SET_CONTACTS](state, [
|
||||
mutations[types.SET_CONTACTS](state, [
|
||||
{ id: 1, name: 'contact1', email: 'contact1@chatwoot.com' },
|
||||
]);
|
||||
expect(state.records).toEqual({
|
||||
|
@ -25,7 +26,7 @@ describe('#mutations', () => {
|
|||
1: { id: 1, name: 'contact1', email: 'contact1@chatwoot.com' },
|
||||
},
|
||||
};
|
||||
mutations[types.default.SET_CONTACT_ITEM](state, {
|
||||
mutations[types.SET_CONTACT_ITEM](state, {
|
||||
id: 2,
|
||||
name: 'contact2',
|
||||
email: 'contact2@chatwoot.com',
|
||||
|
@ -44,7 +45,7 @@ describe('#mutations', () => {
|
|||
1: { id: 1, name: 'contact1', email: 'contact1@chatwoot.com' },
|
||||
},
|
||||
};
|
||||
mutations[types.default.EDIT_CONTACT](state, {
|
||||
mutations[types.EDIT_CONTACT](state, {
|
||||
id: 1,
|
||||
name: 'contact2',
|
||||
email: 'contact2@chatwoot.com',
|
||||
|
|
|
@ -88,9 +88,11 @@ export default {
|
|||
DELETE_WEBHOOK: 'DELETE_WEBHOOK',
|
||||
|
||||
// Contacts
|
||||
SET_CONTACT_META: 'SET_CONTACT_META',
|
||||
SET_CONTACT_UI_FLAG: 'SET_CONTACT_UI_FLAG',
|
||||
SET_CONTACT_ITEM: 'SET_CONTACT_ITEM',
|
||||
SET_CONTACTS: 'SET_CONTACTS',
|
||||
CLEAR_CONTACTS: 'CLEAR_CONTACTS',
|
||||
EDIT_CONTACT: 'EDIT_CONTACT',
|
||||
UPDATE_CONTACTS_PRESENCE: 'UPDATE_CONTACTS_PRESENCE',
|
||||
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
:root {
|
||||
--font-weight-feather: 100;
|
||||
--font-weight-light: 300;
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-bold: 600;
|
||||
--font-weight-black: 700;
|
||||
}
|
|
@ -13,4 +13,17 @@
|
|||
--space-larger: 4.8rem;
|
||||
--space-jumbo: 6.4rem;
|
||||
--space-mega: 10.0rem;
|
||||
|
||||
--space-minus-micro: -0.2rem;
|
||||
--space-minus-smaller: -0.4rem;
|
||||
--space-minus-small: -0.8rem;
|
||||
--space-minus-one: -1rem;
|
||||
--space-minus-slab: -1.2rem;
|
||||
--space-minus-normal: -1.6rem;
|
||||
--space-minus-two: -2.0rem;
|
||||
--space-minus-medium: -2.4rem;
|
||||
--space-minus-large: -3.2rem;
|
||||
--space-minus-larger: -4.8rem;
|
||||
--space-minus-jumbo: -6.4rem;
|
||||
--space-minus-mega: -10.0rem;
|
||||
}
|
||||
|
|
|
@ -37,7 +37,10 @@ export default {
|
|||
Spinner,
|
||||
},
|
||||
props: {
|
||||
groupedMessages: Array,
|
||||
groupedMessages: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
json.meta do
|
||||
json.count @contacts_count
|
||||
json.current_page @current_page
|
||||
end
|
||||
|
||||
json.payload do
|
||||
json.array! @contacts do |contact|
|
||||
json.partial! 'api/v1/models/contact.json.jbuilder', resource: contact, with_contact_inboxes: true
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
json.meta do
|
||||
json.count @contacts_count
|
||||
json.current_page @current_page
|
||||
end
|
||||
|
||||
json.payload do
|
||||
json.array! @contacts do |contact|
|
||||
json.partial! 'api/v1/models/contact.json.jbuilder', resource: contact, with_contact_inboxes: true
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
json.meta do
|
||||
json.count @contacts_count
|
||||
json.current_page @current_page
|
||||
end
|
||||
|
||||
json.payload do
|
||||
json.array! @contacts do |contact|
|
||||
json.partial! 'api/v1/models/contact.json.jbuilder', resource: contact, with_contact_inboxes: true
|
||||
|
|
|
@ -6,6 +6,8 @@ json.name resource.name
|
|||
json.phone_number resource.phone_number
|
||||
json.thumbnail resource.avatar_url
|
||||
json.custom_attributes resource.custom_attributes
|
||||
json.conversations_count resource.conversations_count if resource[:conversations_count].present?
|
||||
json.last_seen_at resource.last_seen_at if resource[:last_seen_at].present?
|
||||
|
||||
# we only want to output contact inbox when its /contacts endpoints
|
||||
if defined?(with_contact_inboxes) && with_contact_inboxes.present?
|
||||
|
|
|
@ -23,9 +23,22 @@ RSpec.describe 'Contacts API', type: :request do
|
|||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(contact.email)
|
||||
expect(response.body).to include(contact_inbox.source_id)
|
||||
expect(response.body).to include(contact_inbox.inbox.name)
|
||||
response_body = JSON.parse(response.body)
|
||||
expect(response_body['payload'].first['email']).to eq(contact.email)
|
||||
expect(response_body['payload'].first['contact_inboxes'].first['source_id']).to eq(contact_inbox.source_id)
|
||||
expect(response_body['payload'].first['contact_inboxes'].first['inbox']['name']).to eq(contact_inbox.inbox.name)
|
||||
end
|
||||
|
||||
it 'returns includes conversations count and last seen at' do
|
||||
create(:conversation, contact: contact, account: account, inbox: contact_inbox.inbox, contact_last_seen_at: Time.now.utc)
|
||||
get "/api/v1/accounts/#{account.id}/contacts",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = JSON.parse(response.body)
|
||||
expect(response_body['payload'].first['conversations_count']).to eq(contact.conversations.count)
|
||||
expect(response_body['payload'].first['last_seen_at']).present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue