diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js index 8d5f5b82c..89bafa0f5 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -45,6 +45,14 @@ class ConversationApi extends ApiClient { }); } + fullSearch({ q }) { + return axios.get(`${this.url}/text_search`, { + params: { + q, + }, + }); + } + toggleStatus({ conversationId, status, snoozedUntil = null }) { return axios.post(`${this.url}/${conversationId}/toggle_status`, { status, diff --git a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js index d276b8053..02f09ee0c 100644 --- a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js +++ b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js @@ -20,6 +20,7 @@ describe('#ConversationAPI', () => { expect(conversationAPI).toHaveProperty('meta'); expect(conversationAPI).toHaveProperty('sendEmailTranscript'); expect(conversationAPI).toHaveProperty('filter'); + expect(conversationAPI).toHaveProperty('fullSearch'); }); describeWithAPIMock('API calls', context => { @@ -64,6 +65,16 @@ describe('#ConversationAPI', () => { ); }); + it('#fullSearch', () => { + conversationAPI.fullSearch({ q: 'john' }); + expect(context.axiosMock.get).toHaveBeenCalledWith( + '/api/v1/conversations/text_search', + { + params: { q: 'john' }, + } + ); + }); + it('#toggleStatus', () => { conversationAPI.toggleStatus({ conversationId: 12, status: 'online' }); expect(context.axiosMock.post).toHaveBeenCalledWith( diff --git a/app/javascript/dashboard/i18n/locale/en/index.js b/app/javascript/dashboard/i18n/locale/en/index.js index 0c674eef2..1f9b63a6f 100644 --- a/app/javascript/dashboard/i18n/locale/en/index.js +++ b/app/javascript/dashboard/i18n/locale/en/index.js @@ -27,6 +27,7 @@ import settings from './settings.json'; import signup from './signup.json'; import teamsSettings from './teamsSettings.json'; import whatsappTemplates from './whatsappTemplates.json'; +import search from './search.json'; export default { ...advancedFilters, @@ -58,4 +59,5 @@ export default { ...signup, ...teamsSettings, ...whatsappTemplates, + ...search, }; diff --git a/app/javascript/dashboard/i18n/locale/en/search.json b/app/javascript/dashboard/i18n/locale/en/search.json new file mode 100644 index 000000000..efcfcf86c --- /dev/null +++ b/app/javascript/dashboard/i18n/locale/en/search.json @@ -0,0 +1,17 @@ +{ + "SEARCH": { + "TABS": { + "ALL": "All", + "CONTACTS": "Contacts", + "CONVERSATIONS": "Conversations", + "MESSAGES": "Messages" + }, + "SECTION": { + "CONTACTS": "CONTACTS", + "CONVERSATIONS": "CONVERSATIONS", + "MESSAGES": "MESSAGES" + }, + "EMPTY_STATE": "No results found for this query", + "PLACEHOLDER_KEYBINDING": "/ to focus" + } +} diff --git a/app/javascript/dashboard/routes/dashboard/conversation/search/PopOverSearch.vue b/app/javascript/dashboard/routes/dashboard/conversation/search/PopOverSearch.vue index 7f7fb67df..cec7ec301 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/search/PopOverSearch.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/search/PopOverSearch.vue @@ -1,55 +1,18 @@ @@ -57,15 +20,13 @@ import { mixin as clickaway } from 'vue-clickaway'; import { mapGetters } from 'vuex'; import timeMixin from '../../../../mixins/time'; -import ResultItem from './ResultItem'; import messageFormatterMixin from 'shared/mixins/messageFormatterMixin'; import SwitchLayout from './SwitchLayout.vue'; +import { frontendURL } from 'dashboard/helper/URLHelper'; export default { components: { - ResultItem, SwitchLayout, }, - directives: { focus: { inserted(el) { @@ -73,9 +34,7 @@ export default { }, }, }, - mixins: [timeMixin, messageFormatterMixin, clickaway], - props: { isOnExpandedLayout: { type: Boolean, @@ -92,59 +51,10 @@ export default { computed: { ...mapGetters({ - conversations: 'conversationSearch/getConversations', - uiFlags: 'conversationSearch/getUIFlags', - currentPage: 'conversationPage/getCurrentPage', + accountId: 'getCurrentAccountId', }), - resultsCount() { - return this.conversations.length; - }, - showSearchResult() { - return ( - this.searchTerm && this.conversations.length && !this.uiFlags.isFetching - ); - }, - showEmptyResult() { - return ( - this.searchTerm && - !this.conversations.length && - !this.uiFlags.isFetching - ); - }, - }, - - watch: { - searchTerm(newValue) { - if (this.typingTimer) { - clearTimeout(this.typingTimer); - } - - this.typingTimer = setTimeout(() => { - this.hasSearched = true; - this.$store.dispatch('conversationSearch/get', { q: newValue }); - }, 1000); - }, - currentPage() { - this.clearSearchTerm(); - }, - }, - - mounted() { - this.$store.dispatch('conversationSearch/get', { q: '' }); - bus.$on('clearSearchInput', () => { - this.clearSearchTerm(); - }); - }, - - methods: { - onSearch() { - this.showSearchBox = true; - }, - closeSearch() { - this.showSearchBox = false; - }, - clearSearchTerm() { - this.searchTerm = ''; + searchUrl() { + return frontendURL(`accounts/${this.accountId}/search`); }, }, }; @@ -163,12 +73,24 @@ export default { var(--space-normal); &:hover { - .search--icon { + .search--icon, + .search--label { color: var(--w-500); } } } +.search--link { + display: inline-flex; + align-items: center; + flex: 1; +} + +.search--label { + color: var(--color-body); + margin-bottom: 0; +} + .search--input { align-items: center; border: 0; diff --git a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js index 8a7ebee72..4a503f76f 100644 --- a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js +++ b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js @@ -2,6 +2,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 { routes as searchRoutes } from './search/routes'; import { routes as notificationRoutes } from './notifications/routes'; import { frontendURL } from '../../helper/URLHelper'; import helpcenterRoutes from './helpcenter/helpcenter.routes'; @@ -18,6 +19,7 @@ export default { ...conversation.routes, ...settings.routes, ...contactRoutes, + ...searchRoutes, ...notificationRoutes, ], }, diff --git a/app/javascript/dashboard/routes/dashboard/search/components/SearchFocus.vue b/app/javascript/dashboard/routes/dashboard/search/components/SearchFocus.vue new file mode 100644 index 000000000..ab78de14b --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/search/components/SearchFocus.vue @@ -0,0 +1,22 @@ + + + + diff --git a/app/javascript/dashboard/routes/dashboard/search/components/SearchHeader.vue b/app/javascript/dashboard/routes/dashboard/search/components/SearchHeader.vue new file mode 100644 index 000000000..e34eaf617 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/search/components/SearchHeader.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/search/components/SearchResultContactItem.vue b/app/javascript/dashboard/routes/dashboard/search/components/SearchResultContactItem.vue new file mode 100644 index 000000000..701d5825d --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/search/components/SearchResultContactItem.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/search/components/SearchResultContactsList.vue b/app/javascript/dashboard/routes/dashboard/search/components/SearchResultContactsList.vue new file mode 100644 index 000000000..791cc30e0 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/search/components/SearchResultContactsList.vue @@ -0,0 +1,29 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/search/components/SearchResultConversationItem.vue b/app/javascript/dashboard/routes/dashboard/search/components/SearchResultConversationItem.vue new file mode 100644 index 000000000..4e68a8868 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/search/components/SearchResultConversationItem.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/search/components/SearchResultConversationsList.vue b/app/javascript/dashboard/routes/dashboard/search/components/SearchResultConversationsList.vue new file mode 100644 index 000000000..42af4d3b6 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/search/components/SearchResultConversationsList.vue @@ -0,0 +1,29 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/search/components/SearchResultMessageItem.vue b/app/javascript/dashboard/routes/dashboard/search/components/SearchResultMessageItem.vue new file mode 100644 index 000000000..7ac51962f --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/search/components/SearchResultMessageItem.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/search/components/SearchResultMessagesList.vue b/app/javascript/dashboard/routes/dashboard/search/components/SearchResultMessagesList.vue new file mode 100644 index 000000000..a74fa0520 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/search/components/SearchResultMessagesList.vue @@ -0,0 +1,29 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/search/components/SearchResultSection.vue b/app/javascript/dashboard/routes/dashboard/search/components/SearchResultSection.vue new file mode 100644 index 000000000..68fe7827e --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/search/components/SearchResultSection.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/search/components/SearchTabs.vue b/app/javascript/dashboard/routes/dashboard/search/components/SearchTabs.vue new file mode 100644 index 000000000..5667ec076 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/search/components/SearchTabs.vue @@ -0,0 +1,34 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/search/components/SearchView.vue b/app/javascript/dashboard/routes/dashboard/search/components/SearchView.vue new file mode 100644 index 000000000..2889af125 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/search/components/SearchView.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/search/routes.js b/app/javascript/dashboard/routes/dashboard/search/routes.js new file mode 100644 index 000000000..a2b526832 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/search/routes.js @@ -0,0 +1,12 @@ +/* eslint arrow-body-style: 0 */ +import SearchView from './components/SearchView.vue'; +import { frontendURL } from '../../../helper/URLHelper'; + +export const routes = [ + { + path: frontendURL('accounts/:accountId/search'), + name: 'search', + roles: ['administrator', 'agent'], + component: SearchView, + }, +]; diff --git a/app/javascript/dashboard/store/modules/conversationSearch.js b/app/javascript/dashboard/store/modules/conversationSearch.js index c289de62a..cc84954da 100644 --- a/app/javascript/dashboard/store/modules/conversationSearch.js +++ b/app/javascript/dashboard/store/modules/conversationSearch.js @@ -2,6 +2,7 @@ import ConversationAPI from '../../api/inbox/conversation'; import types from '../mutation-types'; export const initialState = { records: [], + fullSearchRecords: {}, uiFlags: { isFetching: false, }, @@ -11,6 +12,9 @@ export const getters = { getConversations(state) { return state.records; }, + getFullSearchRecords(state) { + return state.fullSearchRecords; + }, getUIFlags(state) { return state.uiFlags; }, @@ -34,15 +38,36 @@ export const actions = { commit(types.SEARCH_CONVERSATIONS_SET_UI_FLAG, { isFetching: false }); } }, + async fullSearch({ commit }, { q }) { + commit(types.FULL_SEARCH_SET, []); + if (!q) { + return; + } + commit(types.FULL_SEARCH_SET_UI_FLAG, { isFetching: true }); + try { + const { data } = await ConversationAPI.fullSearch({ q }); + commit(types.FULL_SEARCH_SET, data.payload); + } catch (error) { + // Ignore error + } finally { + commit(types.FULL_SEARCH_SET_UI_FLAG, { isFetching: false }); + } + }, }; export const mutations = { [types.SEARCH_CONVERSATIONS_SET](state, records) { state.records = records; }, + [types.FULL_SEARCH_SET](state, records) { + state.fullSearchRecords = records; + }, [types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, uiFlags) { state.uiFlags = { ...state.uiFlags, ...uiFlags }; }, + [types.FULL_SEARCH_SET_UI_FLAG](state, uiFlags) { + state.uiFlags = { ...state.uiFlags, ...uiFlags }; + }, }; export default { diff --git a/app/javascript/dashboard/store/modules/specs/conversationSearch/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversationSearch/actions.spec.js index 449e699c2..2537f35cb 100644 --- a/app/javascript/dashboard/store/modules/specs/conversationSearch/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversationSearch/actions.spec.js @@ -1,6 +1,7 @@ import { actions } from '../../conversationSearch'; import types from '../../../mutation-types'; import axios from 'axios'; +import { fullSearchResponse } from './fixtures'; const commit = jest.fn(); global.axios = axios; jest.mock('axios'); @@ -41,4 +42,36 @@ describe('#actions', () => { ]); }); }); + describe('#fullSearch', () => { + it('sends correct actions if no query param is provided', () => { + actions.fullSearch({ commit }, { q: '' }); + expect(commit.mock.calls).toEqual([[types.FULL_SEARCH_SET, []]]); + }); + + it('sends correct actions if query param is provided and API call is success', async () => { + axios.get.mockResolvedValue({ + data: { + payload: fullSearchResponse, + }, + }); + + await actions.fullSearch({ commit }, { q: 'value' }); + expect(commit.mock.calls).toEqual([ + [types.FULL_SEARCH_SET, []], + [types.FULL_SEARCH_SET_UI_FLAG, { isFetching: true }], + [types.FULL_SEARCH_SET, fullSearchResponse], + [types.FULL_SEARCH_SET_UI_FLAG, { isFetching: false }], + ]); + }); + + it('sends correct actions if query param is provided and API call is errored', async () => { + axios.get.mockRejectedValue({}); + await actions.fullSearch({ commit }, { q: 'value' }); + expect(commit.mock.calls).toEqual([ + [types.FULL_SEARCH_SET, []], + [types.FULL_SEARCH_SET_UI_FLAG, { isFetching: true }], + [types.FULL_SEARCH_SET_UI_FLAG, { isFetching: false }], + ]); + }); + }); }); diff --git a/app/javascript/dashboard/store/modules/specs/conversationSearch/fixtures.js b/app/javascript/dashboard/store/modules/specs/conversationSearch/fixtures.js new file mode 100644 index 000000000..d5d6433f5 --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/conversationSearch/fixtures.js @@ -0,0 +1,179 @@ +export const fullSearchResponse = { + conversations: [ + { + id: 93, + created_at: 1668756114, + contact: { + id: 168, + name: 'summer-wildflower-139', + }, + inbox: { + id: 1, + name: 'Acme Support', + channel_type: 'Channel::WebWidget', + }, + messages: [ + { + content: 'Hello', + id: 1324, + sender_name: 'summer-wildflower-139', + message_type: 0, + created_at: 1668756114, + }, + { + content: 'Hi There 👋', + id: 1325, + sender_name: 'Welcome Bot', + message_type: 1, + created_at: 1668756116, + }, + { + content: '', + id: 1326, + sender_name: 'Welcome Bot', + message_type: 1, + created_at: 1668756118, + }, + { + content: 'Enjoy the minion', + id: 1327, + sender_name: 'Welcome Bot', + message_type: 1, + created_at: 1668756118, + }, + { + content: 'Do you like Pizza', + id: 1328, + sender_name: 'Welcome Bot', + message_type: 1, + created_at: 1668756118, + }, + { + content: '', + id: 1329, + sender_name: 'Welcome Bot', + message_type: 1, + created_at: 1668756129, + }, + { + content: "Here's some markdown for you", + id: 1330, + sender_name: 'Welcome Bot', + message_type: 1, + created_at: 1668756129, + }, + { + content: + '[link text](https://google.com) **Some Bold text** # A large title', + id: 1331, + sender_name: 'Welcome Bot', + message_type: 1, + created_at: 1668756129, + }, + { + content: 'Hi', + id: 1332, + sender_name: 'summer-wildflower-139', + message_type: 0, + created_at: 1668756133, + }, + { + content: 'Hi', + id: 1333, + sender_name: 'Welcome Bot', + message_type: 1, + created_at: 1668756134, + }, + { + content: 'Bye', + id: 1334, + sender_name: 'summer-wildflower-139', + message_type: 0, + created_at: 1668756141, + }, + ], + account_id: 1, + }, + { + id: 96, + created_at: 1669368899, + contact: { + id: 6, + name: 'Fayaz', + }, + inbox: { + id: 15, + name: 'Whatsapp', + channel_type: 'Channel::Whatsapp', + }, + messages: [ + { + content: + 'Your package has been shipped. It will be delivered in 12 business days.', + id: 1351, + sender_name: 'Fayaz Ahmed', + message_type: 1, + created_at: 1669368899, + }, + { + content: 'This is your flight confirmation for 123-123 on 123.', + id: 1352, + sender_name: 'Fayaz Ahmed', + message_type: 1, + created_at: 1669368988, + }, + { + content: 'Esta é a sua confirmação de voo para das-zxc em qwe.', + id: 1353, + sender_name: 'Fayaz Ahmed', + message_type: 1, + created_at: 1669369021, + }, + ], + account_id: 1, + }, + ], + contacts: [ + { + additional_attributes: {}, + availability_status: 'offline', + email: null, + id: 94, + name: 'purple-hill-409', + phone_number: null, + identifier: null, + thumbnail: '', + custom_attributes: {}, + last_activity_at: 1649918760, + }, + ], + messages: [ + { + id: 1008, + content: 'Where can I download my invoices?', + inbox_id: 1, + conversation_id: 79, + message_type: 0, + content_type: 'text', + status: 'sent', + content_attributes: {}, + created_at: 1663941027, + private: false, + source_id: null, + sender: { + additional_attributes: {}, + custom_attributes: { + vfer: false, + xcvb: 'hello', + }, + email: null, + id: 133, + identifier: null, + name: 'morning-violet-166', + phone_number: null, + thumbnail: '', + type: 'contact', + }, + }, + ], +}; diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index c2d8b30f8..810a77c8c 100755 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -262,4 +262,8 @@ export default { SET_CONVERSATION_WATCHERS_UI_FLAG: 'SET_CONVERSATION_WATCHERS_UI_FLAG', SET_CONVERSATION_WATCHERS: 'SET_CONVERSATION_WATCHERS', + + // Full Search + FULL_SEARCH_SET: 'FULL_SEARCH_SET', + FULL_SEARCH_SET_UI_FLAG: 'FULL_SEARCH_SET_UI_FLAG', }; diff --git a/app/models/concerns/multi_searchable_helpers.rb b/app/models/concerns/multi_searchable_helpers.rb index f6b003867..0d0d8728c 100644 --- a/app/models/concerns/multi_searchable_helpers.rb +++ b/app/models/concerns/multi_searchable_helpers.rb @@ -4,9 +4,11 @@ module MultiSearchableHelpers included do PgSearch.multisearch_options = { using: { - trigram: {}, + # trigram: {}, tsearch: { - any_word: true + prefix: true, + any_word: true, + normalization: 3 } } } diff --git a/db/migrate/20221212061802_enable_multi_searchable.rb b/db/migrate/20221212061802_enable_multi_searchable.rb index d96eec08a..dba66f28d 100644 --- a/db/migrate/20221212061802_enable_multi_searchable.rb +++ b/db/migrate/20221212061802_enable_multi_searchable.rb @@ -3,7 +3,7 @@ class EnableMultiSearchable < ActiveRecord::Migration[6.1] Contact.rebuild_pg_search_documents PgSearch::Multisearch.rebuild(Conversation) PgSearch::Multisearch.rebuild(Message) - execute 'create extension pg_trgm;' + # execute 'create extension pg_trgm;' end def down