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
41 changed files with 1163 additions and 179 deletions

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