diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 46f6b83c7..3e7b50bc2 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -15,7 +15,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController def index @contacts_count = resolved_contacts.count - @contacts = fetch_contact_last_seen_at(resolved_contacts) + @contacts = fetch_contacts_with_conversation_count(resolved_contacts) end def search @@ -26,7 +26,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController search: "%#{params[:q]}%" ) @contacts_count = contacts.count - @contacts = fetch_contact_last_seen_at(contacts) + @contacts = fetch_contacts_with_conversation_count(contacts) end def import @@ -72,17 +72,22 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController private + # TODO: Move this to a finder class def resolved_contacts - @resolved_contacts ||= Current.account.contacts - .where.not(email: [nil, '']) - .or(Current.account.contacts.where.not(phone_number: [nil, ''])) + return @resolved_contacts if @resolved_contacts + + @resolved_contacts = Current.account.contacts + .where.not(email: [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 def set_current_page @current_page = params[:page] || 1 end - def fetch_contact_last_seen_at(contacts) + def fetch_contacts_with_conversation_count(contacts) filtrate(contacts).left_outer_joins(:conversations) .select('contacts.*, COUNT(conversations.id) as conversations_count') .group('contacts.id') diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index cc8448873..443a5b8dd 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -1,13 +1,30 @@ /* global axios */ 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 { constructor() { super('contacts', { accountScoped: true }); } - get(page, sortAttr = 'name') { - return axios.get(`${this.url}?page=${page}&sort=${sortAttr}`); + get(page, sortAttr = 'name', label = '') { + let requestURL = `${this.url}?${buildContactParams( + page, + sortAttr, + label, + '' + )}`; + return axios.get(requestURL); } getConversations(contactId) { @@ -26,10 +43,14 @@ class ContactAPI extends ApiClient { return axios.post(`${this.url}/${contactId}/labels`, { labels }); } - search(search = '', page = 1, sortAttr = 'name') { - return axios.get( - `${this.url}/search?q=${search}&page=${page}&sort=${sortAttr}` - ); + search(search = '', page = 1, sortAttr = 'name', label = '') { + let requestURL = `${this.url}/search?${buildContactParams( + page, + sortAttr, + label, + search + )}`; + return axios.get(requestURL); } } diff --git a/app/javascript/dashboard/api/specs/contacts.spec.js b/app/javascript/dashboard/api/specs/contacts.spec.js index a7080a634..08f6ace03 100644 --- a/app/javascript/dashboard/api/specs/contacts.spec.js +++ b/app/javascript/dashboard/api/specs/contacts.spec.js @@ -1,4 +1,4 @@ -import contactAPI from '../contacts'; +import contactAPI, { buildContactParams } from '../contacts'; import ApiClient from '../ApiClient'; import describeWithAPIMock from './apiSpecHelper'; @@ -15,9 +15,9 @@ describe('#ContactsAPI', () => { describeWithAPIMock('API calls', context => { it('#get', () => { - contactAPI.get(1, 'name'); + contactAPI.get(1, 'name', 'customer-support'); 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', () => { - contactAPI.search('leads', 1, 'date'); + contactAPI.search('leads', 1, 'date', 'customer-support'); 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'); + }); +}); diff --git a/app/javascript/dashboard/components/layout/Sidebar.vue b/app/javascript/dashboard/components/layout/Sidebar.vue index 996494789..61405f6cd 100644 --- a/app/javascript/dashboard/components/layout/Sidebar.vue +++ b/app/javascript/dashboard/components/layout/Sidebar.vue @@ -27,6 +27,13 @@ v-if="shouldShowSidebarItem" :key="labelSection.toState" :menu-item="labelSection" + @add-label="showAddLabelPopup" + /> + @@ -57,6 +64,10 @@ :show="showCreateAccountModal" @close-account-create-modal="closeCreateAccountModal" /> + + + + @@ -74,6 +85,7 @@ import AgentDetails from './sidebarComponents/AgentDetails.vue'; import OptionsMenu from './sidebarComponents/OptionsMenu.vue'; import AccountSelector from './sidebarComponents/AccountSelector.vue'; import AddAccountModal from './sidebarComponents/AddAccountModal.vue'; +import AddLabelModal from '../../routes/dashboard/settings/labels/AddLabel'; export default { components: { @@ -84,6 +96,7 @@ export default { OptionsMenu, AccountSelector, AddAccountModal, + AddLabelModal, }, mixins: [adminMixin, alertMixin], data() { @@ -91,6 +104,7 @@ export default { showOptionsMenu: false, showAccountModal: false, showCreateAccountModal: false, + showAddLabelModal: false, }; }, @@ -131,6 +145,9 @@ export default { shouldShowSidebarItem() { return this.sidemenuItems.common.routes.includes(this.currentRoute); }, + showShowContactSideMenu() { + return this.sidemenuItems.contacts.routes.includes(this.currentRoute); + }, shouldShowTeams() { 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() { return { icon: 'ion-ios-people', @@ -253,6 +293,12 @@ export default { closeCreateAccountModal() { this.showCreateAccountModal = false; }, + showAddLabelPopup() { + this.showAddLabelModal = true; + }, + hideAddLabelPopup() { + this.showAddLabelModal = false; + }, }, }; diff --git a/app/javascript/dashboard/components/layout/SidebarItem.vue b/app/javascript/dashboard/components/layout/SidebarItem.vue index 810155bef..bd4a00487 100644 --- a/app/javascript/dashboard/components/layout/SidebarItem.vue +++ b/app/javascript/dashboard/components/layout/SidebarItem.vue @@ -52,11 +52,6 @@ - @@ -66,17 +61,8 @@ import { mapGetters } from 'vuex'; import router from '../../routes'; import adminMixin from '../../mixins/isAdmin'; import { getInboxClassByType } from 'dashboard/helper/inbox'; -import AddLabelModal from '../../routes/dashboard/settings/labels/AddLabel'; export default { - components: { - AddLabelModal, - }, mixins: [adminMixin], - data() { - return { - showAddLabel: false, - }; - }, props: { menuItem: { type: Object, @@ -127,19 +113,13 @@ export default { router.push({ name: item.newLinkRouteName, params: { page: 'new' } }); } else if (item.showModalForNewItem) { if (item.modalName === 'AddLabel') { - this.showAddLabelPopup(); + this.$emit('add-label'); } } }, showItem(item) { return this.isAdmin && item.newLink !== undefined; }, - showAddLabelPopup() { - this.showAddLabel = true; - }, - hideAddLabelPopup() { - this.showAddLabel = false; - }, }, }; diff --git a/app/javascript/dashboard/i18n/default-sidebar.js b/app/javascript/dashboard/i18n/default-sidebar.js index 8bb78f61b..e5c8b79c5 100644 --- a/app/javascript/dashboard/i18n/default-sidebar.js +++ b/app/javascript/dashboard/i18n/default-sidebar.js @@ -7,8 +7,6 @@ export const getSidebarItems = accountId => ({ 'inbox_dashboard', 'inbox_conversation', 'conversation_through_inbox', - 'contacts_dashboard', - 'contacts_dashboard_manage', 'notifications_dashboard', 'settings_account_reports', '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: { routes: [ 'agent_list', diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json index 84e6303fc..24bcbc27e 100644 --- a/app/javascript/dashboard/i18n/locale/en/contact.json +++ b/app/javascript/dashboard/i18n/locale/en/contact.json @@ -136,6 +136,7 @@ "LIST": { "LOADING_MESSAGE": "Loading contacts...", "404": "No contacts matches your search 🔍", + "NO_CONTACTS": "There are no available contacts", "TABLE_HEADER": { "NAME": "Name", "PHONE_NUMBER": "Phone Number", diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index c7bb0cb6d..509cc07f5 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -135,7 +135,9 @@ "ACCOUNT_SETTINGS": "Account Settings", "APPLICATIONS": "Applications", "LABELS": "Labels", - "TEAMS": "Teams" + "TEAMS": "Teams", + "ALL_CONTACTS": "All Contacts", + "TAGGED_WITH": "Tagged with" }, "CREATE_ACCOUNT": { "NEW_ACCOUNT": "New Account", diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsTable.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsTable.vue index 67faaac22..6f83cf0bd 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsTable.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsTable.vue @@ -14,6 +14,10 @@ v-if="showSearchEmptyState" :title="$t('CONTACTS_PAGE.LIST.404')" /> +
{{ $t('CONTACTS_PAGE.LIST.LOADING_MESSAGE') }} diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue index 7874302ae..ada19502e 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue @@ -7,6 +7,7 @@ this-selected-contact-id="" :on-input-search="onInputSearch" :on-toggle-create="onToggleCreate" + :header-title="label" />

- {{ $t('CONTACTS_PAGE.HEADER') }} + {{ headerTitle ? `#${headerTitle}` : $t('CONTACTS_PAGE.HEADER') }}

@@ -42,6 +42,10 @@ export default { components: {}, props: { + headerTitle: { + type: String, + default: '', + }, searchQuery: { type: String, default: '', diff --git a/app/javascript/dashboard/routes/dashboard/contacts/routes.js b/app/javascript/dashboard/routes/dashboard/contacts/routes.js index f5ef5ad14..b8b129435 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/routes.js +++ b/app/javascript/dashboard/routes/dashboard/contacts/routes.js @@ -10,6 +10,15 @@ export const routes = [ roles: ['administrator', 'agent'], 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'), name: 'contacts_dashboard_manage', diff --git a/app/javascript/dashboard/routes/dashboard/settings/labels/specs/validationMixin.spec.js b/app/javascript/dashboard/routes/dashboard/settings/labels/specs/validationMixin.spec.js index ee02f674e..8e1628bdb 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/labels/specs/validationMixin.spec.js +++ b/app/javascript/dashboard/routes/dashboard/settings/labels/specs/validationMixin.spec.js @@ -26,11 +26,10 @@ describe('validationMixin', () => { i18n: i18nConfig, localVue, data() { - return { - title: 'sales', - }; + return { title: 'sales' }; }, }); + wrapper.vm.$v.$touch(); expect(wrapper.vm.getLabelTitleErrorMessage).toBe(''); }); it('it should return label required error message if empty name is passed', async () => { @@ -38,11 +37,10 @@ describe('validationMixin', () => { i18n: i18nConfig, localVue, data() { - return { - title: '', - }; + return { title: '' }; }, }); + wrapper.vm.$v.$touch(); 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 () => { @@ -50,11 +48,10 @@ describe('validationMixin', () => { i18n: i18nConfig, localVue, data() { - return { - title: 's', - }; + return { title: 's' }; }, }); + wrapper.vm.$v.$touch(); expect(wrapper.vm.getLabelTitleErrorMessage).toBe( 'Minimum length 2 is required' ); @@ -64,11 +61,10 @@ describe('validationMixin', () => { i18n: i18nConfig, localVue, data() { - return { - title: 'sales enquiry', - }; + return { title: 'sales enquiry' }; }, }); + wrapper.vm.$v.$touch(); expect(wrapper.vm.getLabelTitleErrorMessage).toBe( 'Only Alphabets, Numbers, Hyphen and Underscore are allowed' ); diff --git a/app/javascript/dashboard/routes/dashboard/settings/labels/validationMixin.js b/app/javascript/dashboard/routes/dashboard/settings/labels/validationMixin.js index ca2a0b777..9f11646a6 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/labels/validationMixin.js +++ b/app/javascript/dashboard/routes/dashboard/settings/labels/validationMixin.js @@ -1,16 +1,17 @@ export default { computed: { getLabelTitleErrorMessage() { - if (!this.title) { - return this.$t('LABEL_MGMT.FORM.NAME.REQUIRED_ERROR'); + let errorMessage = ''; + 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 this.$t('LABEL_MGMT.FORM.NAME.MINIMUM_LENGTH_ERROR'); - } - if (!this.$v.title.validLabelCharacters) { - return this.$t('LABEL_MGMT.FORM.NAME.VALID_ERROR'); - } - return ''; + return errorMessage; }, }, }; diff --git a/app/javascript/dashboard/store/modules/contacts/actions.js b/app/javascript/dashboard/store/modules/contacts/actions.js index 0e5e3a8df..09761c443 100644 --- a/app/javascript/dashboard/store/modules/contacts/actions.js +++ b/app/javascript/dashboard/store/modules/contacts/actions.js @@ -6,12 +6,12 @@ import types from '../../mutation-types'; import ContactAPI from '../../../api/contacts'; export const actions = { - search: async ({ commit }, { search, page, sortAttr }) => { + search: async ({ commit }, { search, page, sortAttr, label }) => { commit(types.SET_CONTACT_UI_FLAG, { isFetching: true }); try { const { data: { payload, meta }, - } = await ContactAPI.search(search, page, sortAttr); + } = await ContactAPI.search(search, page, sortAttr, label); commit(types.CLEAR_CONTACTS); commit(types.SET_CONTACTS, payload); 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 }); try { const { data: { payload, meta }, - } = await ContactAPI.get(page, sortAttr); + } = await ContactAPI.get(page, sortAttr, label); commit(types.CLEAR_CONTACTS); commit(types.SET_CONTACTS, payload); commit(types.SET_CONTACT_META, meta); diff --git a/lib/redis/config.rb b/lib/redis/config.rb index 35ea3e617..f6027d1f6 100644 --- a/lib/redis/config.rb +++ b/lib/redis/config.rb @@ -1,6 +1,6 @@ module Redis::Config - DEFAULT_SENTINEL_PORT = '26379'.freeze - SIDEKIQ_SIZE = 25 + DEFAULT_SENTINEL_PORT ||= '26379'.freeze + SIDEKIQ_SIZE ||= 25 class << self def app diff --git a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb index 12b58c90f..0ebe6b8a0 100644 --- a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb @@ -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['last_seen_at']).present? 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