feat: Add contact label filter (#2454)

Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
Pranav Raj S 2021-06-18 20:08:58 +05:30 committed by GitHub
parent 50e4bb3e63
commit 6c49e58ff8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 201 additions and 70 deletions

View file

@ -15,7 +15,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def index def index
@contacts_count = resolved_contacts.count @contacts_count = resolved_contacts.count
@contacts = fetch_contact_last_seen_at(resolved_contacts) @contacts = fetch_contacts_with_conversation_count(resolved_contacts)
end end
def search def search
@ -26,7 +26,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
search: "%#{params[:q]}%" search: "%#{params[:q]}%"
) )
@contacts_count = contacts.count @contacts_count = contacts.count
@contacts = fetch_contact_last_seen_at(contacts) @contacts = fetch_contacts_with_conversation_count(contacts)
end end
def import def import
@ -72,17 +72,22 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
private private
# TODO: Move this to a finder class
def resolved_contacts def resolved_contacts
@resolved_contacts ||= Current.account.contacts return @resolved_contacts if @resolved_contacts
@resolved_contacts = Current.account.contacts
.where.not(email: [nil, '']) .where.not(email: [nil, ''])
.or(Current.account.contacts.where.not(phone_number: [nil, ''])) .or(Current.account.contacts.where.not(phone_number: [nil, '']))
@resolved_contacts = @resolved_contacts.tagged_with(params[:labels], any: true) if params[:labels].present?
@resolved_contacts
end end
def set_current_page def set_current_page
@current_page = params[:page] || 1 @current_page = params[:page] || 1
end end
def fetch_contact_last_seen_at(contacts) def fetch_contacts_with_conversation_count(contacts)
filtrate(contacts).left_outer_joins(:conversations) filtrate(contacts).left_outer_joins(:conversations)
.select('contacts.*, COUNT(conversations.id) as conversations_count') .select('contacts.*, COUNT(conversations.id) as conversations_count')
.group('contacts.id') .group('contacts.id')

View file

@ -1,13 +1,30 @@
/* global axios */ /* global axios */
import ApiClient from './ApiClient'; import ApiClient from './ApiClient';
export const buildContactParams = (page, sortAttr, label, search) => {
let params = `page=${page}&sort=${sortAttr}`;
if (search) {
params = `${params}&q=${search}`;
}
if (label) {
params = `${params}&labels[]=${label}`;
}
return params;
};
class ContactAPI extends ApiClient { class ContactAPI extends ApiClient {
constructor() { constructor() {
super('contacts', { accountScoped: true }); super('contacts', { accountScoped: true });
} }
get(page, sortAttr = 'name') { get(page, sortAttr = 'name', label = '') {
return axios.get(`${this.url}?page=${page}&sort=${sortAttr}`); let requestURL = `${this.url}?${buildContactParams(
page,
sortAttr,
label,
''
)}`;
return axios.get(requestURL);
} }
getConversations(contactId) { getConversations(contactId) {
@ -26,10 +43,14 @@ class ContactAPI extends ApiClient {
return axios.post(`${this.url}/${contactId}/labels`, { labels }); return axios.post(`${this.url}/${contactId}/labels`, { labels });
} }
search(search = '', page = 1, sortAttr = 'name') { search(search = '', page = 1, sortAttr = 'name', label = '') {
return axios.get( let requestURL = `${this.url}/search?${buildContactParams(
`${this.url}/search?q=${search}&page=${page}&sort=${sortAttr}` page,
); sortAttr,
label,
search
)}`;
return axios.get(requestURL);
} }
} }

View file

@ -1,4 +1,4 @@
import contactAPI from '../contacts'; import contactAPI, { buildContactParams } from '../contacts';
import ApiClient from '../ApiClient'; import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper'; import describeWithAPIMock from './apiSpecHelper';
@ -15,9 +15,9 @@ describe('#ContactsAPI', () => {
describeWithAPIMock('API calls', context => { describeWithAPIMock('API calls', context => {
it('#get', () => { it('#get', () => {
contactAPI.get(1, 'name'); contactAPI.get(1, 'name', 'customer-support');
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts?page=1&sort=name' '/api/v1/contacts?page=1&sort=name&labels[]=customer-support'
); );
}); });
@ -54,10 +54,22 @@ describe('#ContactsAPI', () => {
}); });
it('#search', () => { it('#search', () => {
contactAPI.search('leads', 1, 'date'); contactAPI.search('leads', 1, 'date', 'customer-support');
expect(context.axiosMock.get).toHaveBeenCalledWith( expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/search?q=leads&page=1&sort=date' '/api/v1/contacts/search?page=1&sort=date&q=leads&labels[]=customer-support'
); );
}); });
}); });
}); });
describe('#buildContactParams', () => {
it('returns correct string', () => {
expect(buildContactParams(1, 'name', '', '')).toBe('page=1&sort=name');
expect(buildContactParams(1, 'name', 'customer-support', '')).toBe(
'page=1&sort=name&labels[]=customer-support'
);
expect(
buildContactParams(1, 'name', 'customer-support', 'message-content')
).toBe('page=1&sort=name&q=message-content&labels[]=customer-support');
});
});

View file

@ -27,6 +27,13 @@
v-if="shouldShowSidebarItem" v-if="shouldShowSidebarItem"
:key="labelSection.toState" :key="labelSection.toState"
:menu-item="labelSection" :menu-item="labelSection"
@add-label="showAddLabelPopup"
/>
<sidebar-item
v-if="showShowContactSideMenu"
:key="contactLabelSection.key"
:menu-item="contactLabelSection"
@add-label="showAddLabelPopup"
/> />
</transition-group> </transition-group>
</div> </div>
@ -57,6 +64,10 @@
:show="showCreateAccountModal" :show="showCreateAccountModal"
@close-account-create-modal="closeCreateAccountModal" @close-account-create-modal="closeCreateAccountModal"
/> />
<woot-modal :show.sync="showAddLabelModal" :on-close="hideAddLabelPopup">
<add-label-modal @close="hideAddLabelPopup" />
</woot-modal>
</aside> </aside>
</template> </template>
@ -74,6 +85,7 @@ import AgentDetails from './sidebarComponents/AgentDetails.vue';
import OptionsMenu from './sidebarComponents/OptionsMenu.vue'; import OptionsMenu from './sidebarComponents/OptionsMenu.vue';
import AccountSelector from './sidebarComponents/AccountSelector.vue'; import AccountSelector from './sidebarComponents/AccountSelector.vue';
import AddAccountModal from './sidebarComponents/AddAccountModal.vue'; import AddAccountModal from './sidebarComponents/AddAccountModal.vue';
import AddLabelModal from '../../routes/dashboard/settings/labels/AddLabel';
export default { export default {
components: { components: {
@ -84,6 +96,7 @@ export default {
OptionsMenu, OptionsMenu,
AccountSelector, AccountSelector,
AddAccountModal, AddAccountModal,
AddLabelModal,
}, },
mixins: [adminMixin, alertMixin], mixins: [adminMixin, alertMixin],
data() { data() {
@ -91,6 +104,7 @@ export default {
showOptionsMenu: false, showOptionsMenu: false,
showAccountModal: false, showAccountModal: false,
showCreateAccountModal: false, showCreateAccountModal: false,
showAddLabelModal: false,
}; };
}, },
@ -131,6 +145,9 @@ export default {
shouldShowSidebarItem() { shouldShowSidebarItem() {
return this.sidemenuItems.common.routes.includes(this.currentRoute); return this.sidemenuItems.common.routes.includes(this.currentRoute);
}, },
showShowContactSideMenu() {
return this.sidemenuItems.contacts.routes.includes(this.currentRoute);
},
shouldShowTeams() { shouldShowTeams() {
return this.shouldShowSidebarItem && this.teams.length; return this.shouldShowSidebarItem && this.teams.length;
}, },
@ -177,6 +194,29 @@ export default {
})), })),
}; };
}, },
contactLabelSection() {
return {
icon: 'ion-pound',
label: 'TAGGED_WITH',
hasSubMenu: true,
key: 'label',
newLink: false,
cssClass: 'menu-title align-justify',
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
toStateName: 'labels_list',
showModalForNewItem: true,
modalName: 'AddLabel',
children: this.accountLabels.map(label => ({
id: label.id,
label: label.title,
color: label.color,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/labels/${label.title}/contacts`
),
})),
};
},
teamSection() { teamSection() {
return { return {
icon: 'ion-ios-people', icon: 'ion-ios-people',
@ -253,6 +293,12 @@ export default {
closeCreateAccountModal() { closeCreateAccountModal() {
this.showCreateAccountModal = false; this.showCreateAccountModal = false;
}, },
showAddLabelPopup() {
this.showAddLabelModal = true;
},
hideAddLabelPopup() {
this.showAddLabelModal = false;
},
}, },
}; };
</script> </script>

View file

@ -52,11 +52,6 @@
</a> </a>
</router-link> </router-link>
</ul> </ul>
<add-label-modal
v-if="showAddLabel"
:show.sync="showAddLabel"
:on-close="hideAddLabelPopup"
/>
</router-link> </router-link>
</template> </template>
@ -66,17 +61,8 @@ import { mapGetters } from 'vuex';
import router from '../../routes'; import router from '../../routes';
import adminMixin from '../../mixins/isAdmin'; import adminMixin from '../../mixins/isAdmin';
import { getInboxClassByType } from 'dashboard/helper/inbox'; import { getInboxClassByType } from 'dashboard/helper/inbox';
import AddLabelModal from '../../routes/dashboard/settings/labels/AddLabel';
export default { export default {
components: {
AddLabelModal,
},
mixins: [adminMixin], mixins: [adminMixin],
data() {
return {
showAddLabel: false,
};
},
props: { props: {
menuItem: { menuItem: {
type: Object, type: Object,
@ -127,19 +113,13 @@ export default {
router.push({ name: item.newLinkRouteName, params: { page: 'new' } }); router.push({ name: item.newLinkRouteName, params: { page: 'new' } });
} else if (item.showModalForNewItem) { } else if (item.showModalForNewItem) {
if (item.modalName === 'AddLabel') { if (item.modalName === 'AddLabel') {
this.showAddLabelPopup(); this.$emit('add-label');
} }
} }
}, },
showItem(item) { showItem(item) {
return this.isAdmin && item.newLink !== undefined; return this.isAdmin && item.newLink !== undefined;
}, },
showAddLabelPopup() {
this.showAddLabel = true;
},
hideAddLabelPopup() {
this.showAddLabel = false;
},
}, },
}; };
</script> </script>

View file

@ -7,8 +7,6 @@ export const getSidebarItems = accountId => ({
'inbox_dashboard', 'inbox_dashboard',
'inbox_conversation', 'inbox_conversation',
'conversation_through_inbox', 'conversation_through_inbox',
'contacts_dashboard',
'contacts_dashboard_manage',
'notifications_dashboard', 'notifications_dashboard',
'settings_account_reports', 'settings_account_reports',
'profile_settings', 'profile_settings',
@ -59,6 +57,29 @@ export const getSidebarItems = accountId => ({
}, },
}, },
}, },
contacts: {
routes: [
'contacts_dashboard',
'contacts_dashboard_manage',
'contacts_labels_dashboard',
],
menuItems: {
back: {
icon: 'ion-ios-arrow-back',
label: 'HOME',
hasSubMenu: false,
toStateName: 'home',
toState: frontendURL(`accounts/${accountId}/dashboard`),
},
contacts: {
icon: 'ion-person',
label: 'ALL_CONTACTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard',
},
},
},
settings: { settings: {
routes: [ routes: [
'agent_list', 'agent_list',

View file

@ -136,6 +136,7 @@
"LIST": { "LIST": {
"LOADING_MESSAGE": "Loading contacts...", "LOADING_MESSAGE": "Loading contacts...",
"404": "No contacts matches your search 🔍", "404": "No contacts matches your search 🔍",
"NO_CONTACTS": "There are no available contacts",
"TABLE_HEADER": { "TABLE_HEADER": {
"NAME": "Name", "NAME": "Name",
"PHONE_NUMBER": "Phone Number", "PHONE_NUMBER": "Phone Number",

View file

@ -135,7 +135,9 @@
"ACCOUNT_SETTINGS": "Account Settings", "ACCOUNT_SETTINGS": "Account Settings",
"APPLICATIONS": "Applications", "APPLICATIONS": "Applications",
"LABELS": "Labels", "LABELS": "Labels",
"TEAMS": "Teams" "TEAMS": "Teams",
"ALL_CONTACTS": "All Contacts",
"TAGGED_WITH": "Tagged with"
}, },
"CREATE_ACCOUNT": { "CREATE_ACCOUNT": {
"NEW_ACCOUNT": "New Account", "NEW_ACCOUNT": "New Account",

View file

@ -14,6 +14,10 @@
v-if="showSearchEmptyState" v-if="showSearchEmptyState"
:title="$t('CONTACTS_PAGE.LIST.404')" :title="$t('CONTACTS_PAGE.LIST.404')"
/> />
<empty-state
v-else-if="!isLoading && !contacts.length"
:title="$t('CONTACTS_PAGE.LIST.NO_CONTACTS')"
/>
<div v-if="isLoading" class="contacts--loader"> <div v-if="isLoading" class="contacts--loader">
<spinner /> <spinner />
<span>{{ $t('CONTACTS_PAGE.LIST.LOADING_MESSAGE') }}</span> <span>{{ $t('CONTACTS_PAGE.LIST.LOADING_MESSAGE') }}</span>

View file

@ -7,6 +7,7 @@
this-selected-contact-id="" this-selected-contact-id=""
:on-input-search="onInputSearch" :on-input-search="onInputSearch"
:on-toggle-create="onToggleCreate" :on-toggle-create="onToggleCreate"
:header-title="label"
/> />
<contacts-table <contacts-table
:contacts="records" :contacts="records"
@ -51,6 +52,9 @@ export default {
ContactInfoPanel, ContactInfoPanel,
CreateContact, CreateContact,
}, },
props: {
label: { type: String, default: '' },
},
data() { data() {
return { return {
searchQuery: '', searchQuery: '',
@ -92,6 +96,11 @@ export default {
: DEFAULT_PAGE; : DEFAULT_PAGE;
}, },
}, },
watch: {
label() {
this.fetchContacts(DEFAULT_PAGE);
},
},
mounted() { mounted() {
this.fetchContacts(this.pageParameter); this.fetchContacts(this.pageParameter);
}, },
@ -116,7 +125,11 @@ export default {
}, },
fetchContacts(page) { fetchContacts(page) {
this.updatePageParam(page); this.updatePageParam(page);
const requestParams = { page, sortAttr: this.getSortAttribute() }; const requestParams = {
page,
sortAttr: this.getSortAttribute(),
label: this.label,
};
if (!this.searchQuery) { if (!this.searchQuery) {
this.$store.dispatch('contacts/get', requestParams); this.$store.dispatch('contacts/get', requestParams);
} else { } else {

View file

@ -3,7 +3,7 @@
<div class="table-actions-wrap"> <div class="table-actions-wrap">
<div class="left-aligned-wrap"> <div class="left-aligned-wrap">
<h1 class="page-title"> <h1 class="page-title">
{{ $t('CONTACTS_PAGE.HEADER') }} {{ headerTitle ? `#${headerTitle}` : $t('CONTACTS_PAGE.HEADER') }}
</h1> </h1>
</div> </div>
<div class="right-aligned-wrap"> <div class="right-aligned-wrap">
@ -42,6 +42,10 @@
export default { export default {
components: {}, components: {},
props: { props: {
headerTitle: {
type: String,
default: '',
},
searchQuery: { searchQuery: {
type: String, type: String,
default: '', default: '',

View file

@ -10,6 +10,15 @@ export const routes = [
roles: ['administrator', 'agent'], roles: ['administrator', 'agent'],
component: ContactsView, component: ContactsView,
}, },
{
path: frontendURL('accounts/:accountId/labels/:label/contacts'),
name: 'contacts_labels_dashboard',
roles: ['administrator', 'agent'],
component: ContactsView,
props: route => {
return { label: route.params.label };
},
},
{ {
path: frontendURL('accounts/:accountId/contacts/:contactId'), path: frontendURL('accounts/:accountId/contacts/:contactId'),
name: 'contacts_dashboard_manage', name: 'contacts_dashboard_manage',

View file

@ -26,11 +26,10 @@ describe('validationMixin', () => {
i18n: i18nConfig, i18n: i18nConfig,
localVue, localVue,
data() { data() {
return { return { title: 'sales' };
title: 'sales',
};
}, },
}); });
wrapper.vm.$v.$touch();
expect(wrapper.vm.getLabelTitleErrorMessage).toBe(''); expect(wrapper.vm.getLabelTitleErrorMessage).toBe('');
}); });
it('it should return label required error message if empty name is passed', async () => { it('it should return label required error message if empty name is passed', async () => {
@ -38,11 +37,10 @@ describe('validationMixin', () => {
i18n: i18nConfig, i18n: i18nConfig,
localVue, localVue,
data() { data() {
return { return { title: '' };
title: '',
};
}, },
}); });
wrapper.vm.$v.$touch();
expect(wrapper.vm.getLabelTitleErrorMessage).toBe('Label name is required'); expect(wrapper.vm.getLabelTitleErrorMessage).toBe('Label name is required');
}); });
it('it should return label minimum length error message if one charceter label name is passed', async () => { it('it should return label minimum length error message if one charceter label name is passed', async () => {
@ -50,11 +48,10 @@ describe('validationMixin', () => {
i18n: i18nConfig, i18n: i18nConfig,
localVue, localVue,
data() { data() {
return { return { title: 's' };
title: 's',
};
}, },
}); });
wrapper.vm.$v.$touch();
expect(wrapper.vm.getLabelTitleErrorMessage).toBe( expect(wrapper.vm.getLabelTitleErrorMessage).toBe(
'Minimum length 2 is required' 'Minimum length 2 is required'
); );
@ -64,11 +61,10 @@ describe('validationMixin', () => {
i18n: i18nConfig, i18n: i18nConfig,
localVue, localVue,
data() { data() {
return { return { title: 'sales enquiry' };
title: 'sales enquiry',
};
}, },
}); });
wrapper.vm.$v.$touch();
expect(wrapper.vm.getLabelTitleErrorMessage).toBe( expect(wrapper.vm.getLabelTitleErrorMessage).toBe(
'Only Alphabets, Numbers, Hyphen and Underscore are allowed' 'Only Alphabets, Numbers, Hyphen and Underscore are allowed'
); );

View file

@ -1,16 +1,17 @@
export default { export default {
computed: { computed: {
getLabelTitleErrorMessage() { getLabelTitleErrorMessage() {
if (!this.title) { let errorMessage = '';
return this.$t('LABEL_MGMT.FORM.NAME.REQUIRED_ERROR'); if (!this.$v.title.$error) {
errorMessage = '';
} else if (!this.$v.title.required) {
errorMessage = this.$t('LABEL_MGMT.FORM.NAME.REQUIRED_ERROR');
} else if (!this.$v.title.minLength) {
errorMessage = this.$t('LABEL_MGMT.FORM.NAME.MINIMUM_LENGTH_ERROR');
} else if (!this.$v.title.validLabelCharacters) {
errorMessage = this.$t('LABEL_MGMT.FORM.NAME.VALID_ERROR');
} }
if (!this.$v.title.minLength) { return errorMessage;
return this.$t('LABEL_MGMT.FORM.NAME.MINIMUM_LENGTH_ERROR');
}
if (!this.$v.title.validLabelCharacters) {
return this.$t('LABEL_MGMT.FORM.NAME.VALID_ERROR');
}
return '';
}, },
}, },
}; };

View file

@ -6,12 +6,12 @@ import types from '../../mutation-types';
import ContactAPI from '../../../api/contacts'; import ContactAPI from '../../../api/contacts';
export const actions = { export const actions = {
search: async ({ commit }, { search, page, sortAttr }) => { search: async ({ commit }, { search, page, sortAttr, label }) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetching: true }); commit(types.SET_CONTACT_UI_FLAG, { isFetching: true });
try { try {
const { const {
data: { payload, meta }, data: { payload, meta },
} = await ContactAPI.search(search, page, sortAttr); } = await ContactAPI.search(search, page, sortAttr, label);
commit(types.CLEAR_CONTACTS); commit(types.CLEAR_CONTACTS);
commit(types.SET_CONTACTS, payload); commit(types.SET_CONTACTS, payload);
commit(types.SET_CONTACT_META, meta); commit(types.SET_CONTACT_META, meta);
@ -21,12 +21,12 @@ export const actions = {
} }
}, },
get: async ({ commit }, { page = 1, sortAttr } = {}) => { get: async ({ commit }, { page = 1, sortAttr, label } = {}) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetching: true }); commit(types.SET_CONTACT_UI_FLAG, { isFetching: true });
try { try {
const { const {
data: { payload, meta }, data: { payload, meta },
} = await ContactAPI.get(page, sortAttr); } = await ContactAPI.get(page, sortAttr, label);
commit(types.CLEAR_CONTACTS); commit(types.CLEAR_CONTACTS);
commit(types.SET_CONTACTS, payload); commit(types.SET_CONTACTS, payload);
commit(types.SET_CONTACT_META, meta); commit(types.SET_CONTACT_META, meta);

View file

@ -1,6 +1,6 @@
module Redis::Config module Redis::Config
DEFAULT_SENTINEL_PORT = '26379'.freeze DEFAULT_SENTINEL_PORT ||= '26379'.freeze
SIDEKIQ_SIZE = 25 SIDEKIQ_SIZE ||= 25
class << self class << self
def app def app

View file

@ -40,6 +40,22 @@ RSpec.describe 'Contacts API', type: :request do
expect(response_body['payload'].first['conversations_count']).to eq(contact.conversations.count) expect(response_body['payload'].first['conversations_count']).to eq(contact.conversations.count)
expect(response_body['payload'].first['last_seen_at']).present? expect(response_body['payload'].first['last_seen_at']).present?
end end
it 'filters contacts based on label filter' do
contact_with_label1, contact_with_label2 = FactoryBot.create_list(:contact, 2, account: account)
contact_with_label1.update_labels(['label1'])
contact_with_label2.update_labels(['label2'])
get "/api/v1/accounts/#{account.id}/contacts",
params: { labels: %w[label1 label2] },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = JSON.parse(response.body)
expect(response_body['meta']['count']).to eq(2)
expect(response_body['payload'].pluck('email')).to include(contact_with_label1.email, contact_with_label2.email)
end
end end
end end