diff --git a/Gemfile b/Gemfile index 409e5d493..f321b40d0 100644 --- a/Gemfile +++ b/Gemfile @@ -107,6 +107,8 @@ gem 'maxminddb' # to create db triggers gem 'hairtrigger' +gem 'procore-sift' + group :development do gem 'annotate' gem 'bullet' diff --git a/Gemfile.lock b/Gemfile.lock index 06dbcfa0e..0fd91f389 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -353,6 +353,8 @@ GEM parser (2.7.1.4) ast (~> 2.4.1) pg (1.2.3) + procore-sift (0.15.0) + rails (> 4.2.0) pry (0.13.1) coderay (~> 1.1) method_source (~> 1.0) @@ -649,6 +651,7 @@ DEPENDENCIES mini_magick mock_redis! pg + procore-sift pry-rails puma pundit diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 375eb9313..ac27314fc 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -1,4 +1,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController + include Sift + + sort_on :email, type: :string + sort_on :name, type: :string + sort_on :phone_number, type: :string + sort_on :last_activity_at, type: :datetime + RESULTS_PER_PAGE = 15 protect_from_forgery with: :null_session @@ -68,7 +75,6 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController @resolved_contacts ||= Current.account.contacts .where.not(email: [nil, '']) .or(Current.account.contacts.where.not(phone_number: [nil, ''])) - .order('LOWER(name)') end def set_current_page @@ -76,11 +82,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController end 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) + filtrate(contacts).left_outer_joins(:conversations) + .select('contacts.*, COUNT(conversations.id) as conversations_count') + .group('contacts.id') + .includes([{ avatar_attachment: [:blob] }, { contact_inboxes: [:inbox] }]) + .page(@current_page).per(RESULTS_PER_PAGE) end def build_contact_inbox diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index 5dd0bcc57..1a66db0e1 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -6,8 +6,8 @@ class ContactAPI extends ApiClient { super('contacts', { accountScoped: true }); } - get(page) { - return axios.get(`${this.url}?page=${page}`); + get(page, sortAttr = 'name') { + return axios.get(`${this.url}?page=${page}&sort=${sortAttr}`); } getConversations(contactId) { @@ -18,8 +18,10 @@ class ContactAPI extends ApiClient { return axios.get(`${this.url}/${contactId}/contactable_inboxes`); } - search(search = '', page = 1) { - return axios.get(`${this.url}/search?q=${search}&page=${page}`); + search(search = '', page = 1, sortAttr = 'name') { + return axios.get( + `${this.url}/search?q=${search}&page=${page}&sort=${sortAttr}` + ); } } diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsTable.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsTable.vue index cbee822dd..a631c2d15 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsTable.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsTable.vue @@ -7,6 +7,7 @@ :columns="columns" :table-data="tableData" :border-around="false" + :sort-option="sortOption" /> this.$emit('on-sort-change', params), + }, + }; + }, + computed: { + tableData() { + if (this.isLoading) { + return []; + } + return this.contacts.map(item => { + // Note: The attributes used here is in snake case + // as it simplier the sort attribute calculation + const additional = item.additional_attributes || {}; + const { last_activity_at: lastActivityAt } = item; + return { + ...item, + phone_number: item.phone_number || '---', + company: additional.company_name || '---', + location: additional.location || '---', + profiles: additional.social_profiles || {}, + city: additional.city || '---', + country: additional.country || '---', + conversations_count: item.conversations_count || '---', + last_activity_at: lastActivityAt + ? this.dynamicTime(lastActivityAt) + : '---', + }; + }); + }, + columns() { + return [ { field: 'name', key: 'name', title: this.$t('CONTACTS_PAGE.LIST.TABLE_HEADER.NAME'), fixed: 'left', align: 'left', + sortBy: this.sortConfig.name || '', width: 300, renderBodyCell: ({ row }) => ( { if (row.email) @@ -116,8 +160,9 @@ export default { }, }, { - field: 'phone', - key: 'phone', + field: 'phone_number', + key: 'phone_number', + sortBy: this.sortConfig.phone_number || '', title: this.$t('CONTACTS_PAGE.LIST.TABLE_HEADER.PHONE_NUMBER'), align: 'left', }, @@ -170,8 +215,9 @@ export default { }, }, { - field: 'lastSeen', - key: 'lastSeen', + field: 'last_activity_at', + key: 'last_activity_at', + sortBy: this.sortConfig.last_activity_at || '', title: this.$t('CONTACTS_PAGE.LIST.TABLE_HEADER.LAST_ACTIVITY'), align: 'left', }, @@ -182,29 +228,23 @@ export default { width: 150, align: 'left', }, - ], - }; + ]; + }, }, - computed: { - tableData() { - if (this.isLoading) { - return []; - } - return this.contacts.map(item => { - const additional = item.additional_attributes || {}; - const { last_seen_at: lastSeenAt } = item; - return { - ...item, - phone: item.phone_number || '---', - company: additional.company_name || '---', - location: additional.location || '---', - profiles: additional.social_profiles || {}, - city: additional.city || '---', - country: additional.country || '---', - conversationsCount: item.conversations_count || '---', - lastSeen: lastSeenAt ? this.dynamicTime(lastSeenAt) : '---', - }; - }); + watch: { + sortOrder() { + this.setSortConfig(); + }, + sortParam() { + this.setSortConfig(); + }, + }, + mounted() { + this.setSortConfig(); + }, + methods: { + setSortConfig() { + this.sortConfig = { [this.sortParam]: this.sortOrder }; }, }, }; @@ -258,6 +298,9 @@ export default { .ve-table-header-th { font-size: var(--font-size-mini) !important; } + .ve-table-sort { + top: -4px; + } } .contacts--loader { diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue index af99e088e..7874302ae 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue @@ -14,6 +14,8 @@ :is-loading="uiFlags.isFetching" :on-click-contact="openContactInfoPanel" :active-contact-id="selectedContactId" + :sort-config="sortConfig" + @on-sort-change="onSortChange" /> = 1 + return !Number.isNaN(selectedPageNumber) && + selectedPageNumber >= DEFAULT_PAGE ? selectedPageNumber - : 1; + : DEFAULT_PAGE; }, }, mounted() { - this.$store.dispatch('contacts/get', { page: this.pageParameter }); + this.fetchContacts(this.pageParameter); }, methods: { + updatePageParam(page) { + window.history.pushState({}, null, `${this.$route.path}?page=${page}`); + }, + getSortAttribute() { + let sortAttr = Object.keys(this.sortConfig).reduce((acc, sortKey) => { + const sortOrder = this.sortConfig[sortKey]; + if (sortOrder) { + const sortOrderSign = sortOrder === 'asc' ? '' : '-'; + return `${sortOrderSign}${sortKey}`; + } + return acc; + }, ''); + if (!sortAttr) { + this.sortConfig = { name: 'asc' }; + sortAttr = 'name'; + } + return sortAttr; + }, + fetchContacts(page) { + this.updatePageParam(page); + const requestParams = { page, sortAttr: this.getSortAttribute() }; + if (!this.searchQuery) { + this.$store.dispatch('contacts/get', requestParams); + } else { + this.$store.dispatch('contacts/search', { + search: this.searchQuery, + ...requestParams, + }); + } + }, onInputSearch(event) { const newQuery = event.target.value; const refetchAllContacts = !!this.searchQuery && newQuery === ''; - if (refetchAllContacts) { - this.$store.dispatch('contacts/get', { page: 1 }); - } this.searchQuery = newQuery; + if (refetchAllContacts) { + this.fetchContacts(DEFAULT_PAGE); + } }, onSearchSubmit() { this.selectedContactId = ''; if (this.searchQuery) { - this.$store.dispatch('contacts/search', { - search: this.searchQuery, - page: 1, - }); + this.fetchContacts(DEFAULT_PAGE); } }, onPageChange(page) { this.selectedContactId = ''; - window.history.pushState({}, null, `${this.$route.path}?page=${page}`); - if (this.searchQuery) { - this.$store.dispatch('contacts/search', { - search: this.searchQuery, - page, - }); - } else { - this.$store.dispatch('contacts/get', { page }); - } + this.fetchContacts(page); }, openContactInfoPanel(contactId) { this.selectedContactId = contactId; @@ -130,6 +155,10 @@ export default { onToggleCreate() { this.showCreateModal = !this.showCreateModal; }, + onSortChange(params) { + this.sortConfig = params; + this.fetchContacts(this.meta.currentPage); + }, }, }; @@ -138,6 +167,7 @@ export default { .contacts-page { width: 100%; } + .left-wrap { display: flex; flex-direction: column; diff --git a/app/javascript/dashboard/store/modules/contacts/actions.js b/app/javascript/dashboard/store/modules/contacts/actions.js index 075c82918..0e5e3a8df 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 }) => { + search: async ({ commit }, { search, page, sortAttr }) => { commit(types.SET_CONTACT_UI_FLAG, { isFetching: true }); try { const { data: { payload, meta }, - } = await ContactAPI.search(search, page); + } = await ContactAPI.search(search, page, sortAttr); 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 } = {}) => { + get: async ({ commit }, { page = 1, sortAttr } = {}) => { commit(types.SET_CONTACT_UI_FLAG, { isFetching: true }); try { const { data: { payload, meta }, - } = await ContactAPI.get(page); + } = await ContactAPI.get(page, sortAttr); commit(types.CLEAR_CONTACTS); commit(types.SET_CONTACTS, payload); commit(types.SET_CONTACT_META, meta); diff --git a/app/javascript/dashboard/store/modules/contacts/getters.js b/app/javascript/dashboard/store/modules/contacts/getters.js index cb9699a9b..cce21f796 100644 --- a/app/javascript/dashboard/store/modules/contacts/getters.js +++ b/app/javascript/dashboard/store/modules/contacts/getters.js @@ -1,6 +1,6 @@ export const getters = { getContacts($state) { - return Object.values($state.records); + return $state.sortOrder.map(contactId => $state.records[contactId]); }, getUIFlags($state) { return $state.uiFlags; diff --git a/app/javascript/dashboard/store/modules/contacts/index.js b/app/javascript/dashboard/store/modules/contacts/index.js index d0702fb1f..d5264169e 100644 --- a/app/javascript/dashboard/store/modules/contacts/index.js +++ b/app/javascript/dashboard/store/modules/contacts/index.js @@ -14,6 +14,7 @@ const state = { isFetchingInboxes: false, isUpdating: false, }, + sortOrder: [], }; export default { diff --git a/app/javascript/dashboard/store/modules/contacts/mutations.js b/app/javascript/dashboard/store/modules/contacts/mutations.js index abc2229f9..46f4d94fa 100644 --- a/app/javascript/dashboard/store/modules/contacts/mutations.js +++ b/app/javascript/dashboard/store/modules/contacts/mutations.js @@ -11,6 +11,7 @@ export const mutations = { [types.CLEAR_CONTACTS]: $state => { Vue.set($state, 'records', {}); + Vue.set($state, 'sortOrder', []); }, [types.SET_CONTACT_META]: ($state, data) => { @@ -20,12 +21,14 @@ export const mutations = { }, [types.SET_CONTACTS]: ($state, data) => { - data.forEach(contact => { + const sortOrder = data.map(contact => { Vue.set($state.records, contact.id, { ...($state.records[contact.id] || {}), ...contact, }); + return contact.id; }); + $state.sortOrder = sortOrder; }, [types.SET_CONTACT_ITEM]: ($state, data) => { @@ -33,6 +36,10 @@ export const mutations = { ...($state.records[data.id] || {}), ...data, }); + + if (!$state.sortOrder.includes(data.id)) { + $state.sortOrder.push(data.id); + } }, [types.EDIT_CONTACT]: ($state, data) => { diff --git a/app/javascript/dashboard/store/modules/specs/contacts/getters.spec.js b/app/javascript/dashboard/store/modules/specs/contacts/getters.spec.js index 369bb8af2..62c33ee51 100644 --- a/app/javascript/dashboard/store/modules/specs/contacts/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/contacts/getters.spec.js @@ -6,9 +6,13 @@ const { getters } = Contacts; describe('#getters', () => { it('getContacts', () => { const state = { - records: { 1: contactList[0] }, + records: { 1: contactList[0], 3: contactList[2] }, + sortOrder: [3, 1], }; - expect(getters.getContacts(state)).toEqual([contactList[0]]); + expect(getters.getContacts(state)).toEqual([ + contactList[2], + contactList[0], + ]); }); it('getContact', () => { diff --git a/app/javascript/dashboard/store/modules/specs/contacts/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/contacts/mutations.spec.js index d8e7ec44b..f725c88ba 100644 --- a/app/javascript/dashboard/store/modules/specs/contacts/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/specs/contacts/mutations.spec.js @@ -7,6 +7,7 @@ describe('#mutations', () => { it('set contact records', () => { const state = { records: {} }; mutations[types.SET_CONTACTS](state, [ + { id: 2, name: 'contact2', email: 'contact2@chatwoot.com' }, { id: 1, name: 'contact1', email: 'contact1@chatwoot.com' }, ]); expect(state.records).toEqual({ @@ -15,7 +16,13 @@ describe('#mutations', () => { name: 'contact1', email: 'contact1@chatwoot.com', }, + 2: { + id: 2, + name: 'contact2', + email: 'contact2@chatwoot.com', + }, }); + expect(state.sortOrder).toEqual([2, 1]); }); }); @@ -25,6 +32,7 @@ describe('#mutations', () => { records: { 1: { id: 1, name: 'contact1', email: 'contact1@chatwoot.com' }, }, + sortOrder: [1], }; mutations[types.SET_CONTACT_ITEM](state, { id: 2, @@ -35,6 +43,7 @@ describe('#mutations', () => { 1: { id: 1, name: 'contact1', email: 'contact1@chatwoot.com' }, 2: { id: 2, name: 'contact2', email: 'contact2@chatwoot.com' }, }); + expect(state.sortOrder).toEqual([1, 2]); }); }); diff --git a/app/models/contact.rb b/app/models/contact.rb index 47eb5352d..34d54bee6 100644 --- a/app/models/contact.rb +++ b/app/models/contact.rb @@ -7,6 +7,7 @@ # custom_attributes :jsonb # email :string # identifier :string +# last_activity_at :datetime # name :string # phone_number :string # pubsub_token :string diff --git a/app/models/message.rb b/app/models/message.rb index 8c834e2ed..ea41b15fa 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -133,6 +133,11 @@ class Message < ApplicationRecord dispatch_create_events send_reply execute_message_template_hooks + update_contact_activity + end + + def update_contact_activity + sender.update(last_activity_at: DateTime.now) if sender&.is_a?(Contact) end def dispatch_create_events diff --git a/app/views/api/v1/models/_contact.json.jbuilder b/app/views/api/v1/models/_contact.json.jbuilder index eaf4012f4..95d761e10 100644 --- a/app/views/api/v1/models/_contact.json.jbuilder +++ b/app/views/api/v1/models/_contact.json.jbuilder @@ -7,7 +7,7 @@ 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.to_i if resource[:last_seen_at].present? +json.last_activity_at resource.last_activity_at.to_i if resource[:last_activity_at].present? # we only want to output contact inbox when its /contacts endpoints if defined?(with_contact_inboxes) && with_contact_inboxes.present? diff --git a/db/migrate/20210306170117_add_last_activity_at_to_contacts.rb b/db/migrate/20210306170117_add_last_activity_at_to_contacts.rb new file mode 100644 index 000000000..b35c064fc --- /dev/null +++ b/db/migrate/20210306170117_add_last_activity_at_to_contacts.rb @@ -0,0 +1,22 @@ +class AddLastActivityAtToContacts < ActiveRecord::Migration[6.0] + def up + # rubocop:disable Rails/SkipsModelValidations + add_column :contacts, :last_activity_at, :datetime, index: true, default: nil + Conversation.find_in_batches do |conversation_batch| + conversation_batch.each do |conversation| + contact = conversation.contact + if contact.last_activity_at.nil? || conversation.updated_at > contact.last_activity_at + contact.update_columns(last_activity_at: conversation.updated_at) + end + end + end + + Contact.where(additional_attributes: nil).update_all(additional_attributes: {}) + Contact.where(phone_number: '').update_all(phone_number: nil) + # rubocop:enable Rails/SkipsModelValidations + end + + def down + remove_column :contacts, :last_activity_at, :datetime, index: true, default: nil + end +end diff --git a/db/schema.rb b/db/schema.rb index 553570303..415559101 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -228,6 +228,7 @@ ActiveRecord::Schema.define(version: 2021_04_30_100138) do t.jsonb "additional_attributes", default: {} t.string "identifier" t.jsonb "custom_attributes", default: {} + t.datetime "last_activity_at" t.index ["account_id"], name: "index_contacts_on_account_id" t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb index 061c5af9a..e08a152d9 100644 --- a/spec/models/message_spec.rb +++ b/spec/models/message_spec.rb @@ -17,6 +17,10 @@ RSpec.describe Message, type: :model do expect(message.created_at).to eq message.conversation.last_activity_at end + it 'updates contact last_activity_at when created' do + expect { message.save! }.to(change { message.sender.last_activity_at }) + end + it 'triggers ::MessageTemplates::HookExecutionService' do hook_execution_service = double allow(::MessageTemplates::HookExecutionService).to receive(:new).and_return(hook_execution_service) diff --git a/swagger/parameters/contact_sort.yml b/swagger/parameters/contact_sort.yml new file mode 100644 index 000000000..f25d07760 --- /dev/null +++ b/swagger/parameters/contact_sort.yml @@ -0,0 +1,15 @@ +in: query +name: sort +schema: + type: string + enum: + - name + - email + - phone_number + - last_activity_at + - -name + - -email + - -phone_number + - -last_activity_at +required: false +description: The attribute by which list should be sorted diff --git a/swagger/parameters/index.yml b/swagger/parameters/index.yml index 587e95893..6acf0f37d 100644 --- a/swagger/parameters/index.yml +++ b/swagger/parameters/index.yml @@ -4,7 +4,6 @@ account_id: team_id: $ref: ./team_id.yml - source_id: $ref: ./source_id.yml @@ -14,3 +13,8 @@ conversation_id: message_id: $ref: ./message_id.yml +contact_sort_param: + $ref: ./contact_sort.yml + +page: + $ref: ./page.yml diff --git a/swagger/parameters/page.yml b/swagger/parameters/page.yml new file mode 100644 index 000000000..3ef0f55b6 --- /dev/null +++ b/swagger/parameters/page.yml @@ -0,0 +1,7 @@ +in: query +name: page +schema: + type: integer + default: 1 +required: false +description: The page parameter diff --git a/swagger/paths/contact/list_create.yml b/swagger/paths/contact/list_create.yml index 28689b78d..d0fcb5fd7 100644 --- a/swagger/paths/contact/list_create.yml +++ b/swagger/paths/contact/list_create.yml @@ -2,12 +2,11 @@ get: tags: - Contact operationId: contactList - description: Listing all the contacts with pagination + description: Listing all the contacts with pagination (Page size = 15) summary: List Contacts parameters: - - name: query_hash - in: query - type: string + - $ref: '#/parameters/contact_sort_param' + - $ref: '#/parameters/page' responses: 200: description: Success diff --git a/swagger/paths/contact/search.yml b/swagger/paths/contact/search.yml index ad17fd8e1..30ade7b5a 100644 --- a/swagger/paths/contact/search.yml +++ b/swagger/paths/contact/search.yml @@ -2,12 +2,14 @@ get: tags: - Contact operationId: contactSearch - description: Search the contacts using a search key, currently supports email search + description: Search the contacts using a search key, currently supports email search (Page size = 15) summary: Search Contacts parameters: - name: q in: query type: string + - $ref: '#/parameters/contact_sort_param' + - $ref: '#/parameters/page' responses: 200: description: Success diff --git a/swagger/swagger.json b/swagger/swagger.json index 40dba44cf..2007a76cc 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -887,13 +887,14 @@ "Contact" ], "operationId": "contactList", - "description": "Listing all the contacts with pagination", + "description": "Listing all the contacts with pagination (Page size = 15)", "summary": "List Contacts", "parameters": [ { - "name": "query_hash", - "in": "query", - "type": "string" + "$ref": "#/parameters/contact_sort_param" + }, + { + "$ref": "#/parameters/page" } ], "responses": { @@ -1052,13 +1053,19 @@ "Contact" ], "operationId": "contactSearch", - "description": "Search the contacts using a search key, currently supports email search", + "description": "Search the contacts using a search key, currently supports email search (Page size = 15)", "summary": "Search Contacts", "parameters": [ { "name": "q", "in": "query", "type": "string" + }, + { + "$ref": "#/parameters/contact_sort_param" + }, + { + "$ref": "#/parameters/page" } ], "responses": { @@ -2083,6 +2090,35 @@ }, "required": true, "description": "The numeric ID of the message" + }, + "contact_sort_param": { + "in": "query", + "name": "sort", + "schema": { + "type": "string", + "enum": [ + "name", + "email", + "phone_number", + "last_activity_at", + "-name", + "-email", + "-phone_number", + "-last_activity_at" + ] + }, + "required": false, + "description": "The attribute by which list should be sorted" + }, + "page": { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "default": 1 + }, + "required": false, + "description": "The page parameter" } } } \ No newline at end of file