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:
Nithin David Thomas 2020-11-10 15:25:26 +05:30 committed by GitHub
parent 2babfd6148
commit f214c9c47c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1163 additions and 179 deletions

View file

@ -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

View file

@ -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?

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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();

View file

@ -26,3 +26,4 @@
@import 'views/signup';
@import 'plugins/multiselect';
@import 'plugins/dropdown';

View file

@ -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';

View 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);
}
}

View file

@ -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>

View file

@ -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,

View file

@ -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',

View file

@ -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"
}
}
}

View 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"
}
}
}

View file

@ -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",

View file

@ -8,7 +8,6 @@
</template>
<script>
/* global bus */
import Sidebar from '../../components/layout/Sidebar';
export default {

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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,
},
];

View file

@ -45,6 +45,7 @@
<script>
import { mapGetters } from 'vuex';
import ContactConversations from './ContactConversations.vue';
import ContactDetailsItem from './ContactDetailsItem.vue';
import ContactInfo from './contact/ContactInfo';

View file

@ -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],
},
],
};

View file

@ -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,
};

View 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);
},
};

View 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;
},
};

View 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,
};

View 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');
}
});
},
};

View file

@ -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]]);
});
});
});

View file

@ -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 = {

View file

@ -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',

View file

@ -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',

View file

@ -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;
}

View file

@ -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;
}

View file

@ -37,7 +37,10 @@ export default {
Spinner,
},
props: {
groupedMessages: Array,
groupedMessages: {
type: Array,
default: () => [],
},
},
data() {
return {

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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?

View file

@ -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