diff --git a/app/controllers/api/v1/account/webhooks_controller.rb b/app/controllers/api/v1/account/webhooks_controller.rb index dc56debfc..730e7b9b1 100644 --- a/app/controllers/api/v1/account/webhooks_controller.rb +++ b/app/controllers/api/v1/account/webhooks_controller.rb @@ -23,7 +23,7 @@ class Api::V1::Account::WebhooksController < Api::BaseController private def webhook_params - params.require(:webhook).permit(:account_id, :inbox_id, :url) + params.require(:webhook).permit(:inbox_id, :url) end def fetch_webhook diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index a6087bb8c..6e1eae9fe 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -31,9 +31,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController def message_params { account_id: conversation.account_id, + contact_id: @contact.id, + content: permitted_params[:message][:content], inbox_id: conversation.inbox_id, - message_type: :incoming, - content: permitted_params[:message][:content] + message_type: :incoming } end diff --git a/app/dispatchers/async_dispatcher.rb b/app/dispatchers/async_dispatcher.rb index 97da6de93..49c63654d 100644 --- a/app/dispatchers/async_dispatcher.rb +++ b/app/dispatchers/async_dispatcher.rb @@ -5,7 +5,7 @@ class AsyncDispatcher < BaseDispatcher end def listeners - listeners = [ReportingListener.instance] + listeners = [ReportingListener.instance, WebhookListener.instance] listeners << SubscriptionListener.instance if ENV['BILLING_ENABLED'] listeners end diff --git a/app/javascript/dashboard/api/webhooks.js b/app/javascript/dashboard/api/webhooks.js new file mode 100644 index 000000000..229519dd7 --- /dev/null +++ b/app/javascript/dashboard/api/webhooks.js @@ -0,0 +1,9 @@ +import ApiClient from './ApiClient'; + +class WebHooks extends ApiClient { + constructor() { + super('account/webhooks'); + } +} + +export default new WebHooks(); diff --git a/app/javascript/dashboard/assets/images/integrations/cable.svg b/app/javascript/dashboard/assets/images/integrations/cable.svg new file mode 100644 index 000000000..2a9f7008d --- /dev/null +++ b/app/javascript/dashboard/assets/images/integrations/cable.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/javascript/dashboard/assets/scss/_woot.scss b/app/javascript/dashboard/assets/scss/_woot.scss index c85012e9f..d86e61271 100644 --- a/app/javascript/dashboard/assets/scss/_woot.scss +++ b/app/javascript/dashboard/assets/scss/_woot.scss @@ -25,6 +25,7 @@ @import 'views/settings/inbox'; @import 'views/settings/channel'; +@import 'views/settings/integrations'; @import 'views/signup'; @import 'plugins/multiselect'; diff --git a/app/javascript/dashboard/assets/scss/views/settings/integrations.scss b/app/javascript/dashboard/assets/scss/views/settings/integrations.scss new file mode 100644 index 000000000..183fa9a23 --- /dev/null +++ b/app/javascript/dashboard/assets/scss/views/settings/integrations.scss @@ -0,0 +1,37 @@ +.integrations-wrap { + .integration { + background: $color-white; + border: 2px solid $color-border; + border-radius: $space-slab; + padding: $space-normal; + + .integration--image { + display: flex; + margin-right: $space-normal; + width: 8rem; + + img { + max-width: 8rem; + padding: $space-small; + } + } + + .integration--title { + font-size: $font-size-large; + } + + .integration--description { + padding-right: $space-medium; + } + + .button-wrap { + @include flex; + @include flex-align(center, middle); + margin-bottom: 0; + } + } +} + +.help-wrap { + padding-left: $space-large; +} diff --git a/app/javascript/dashboard/components/Modal.vue b/app/javascript/dashboard/components/Modal.vue index b3179a641..7c690739c 100644 --- a/app/javascript/dashboard/components/Modal.vue +++ b/app/javascript/dashboard/components/Modal.vue @@ -12,9 +12,19 @@ diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Index.vue new file mode 100644 index 000000000..f12729b7c --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Index.vue @@ -0,0 +1,44 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/New.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/New.vue new file mode 100644 index 000000000..b3a1f4c8f --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/New.vue @@ -0,0 +1,114 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhook.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhook.vue new file mode 100644 index 000000000..8bdc7de9e --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhook.vue @@ -0,0 +1,141 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js b/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js new file mode 100644 index 000000000..ae267b18f --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js @@ -0,0 +1,35 @@ +import Index from './Index'; +import SettingsContent from '../Wrapper'; +import Webhook from './Webhook'; +import { frontendURL } from '../../../../helper/URLHelper'; + +export default { + routes: [ + { + path: frontendURL('settings/integrations'), + component: SettingsContent, + props: params => { + const showBackButton = params.name !== 'settings_integrations'; + return { + headerTitle: 'INTEGRATION_SETTINGS.HEADER', + icon: 'ion-flash', + showBackButton, + }; + }, + children: [ + { + path: '', + name: 'settings_integrations', + component: Index, + roles: ['administrator'], + }, + { + path: 'webhook', + component: Webhook, + name: 'settings_integrations_webhook', + roles: ['administrator'], + }, + ], + }, + ], +}; diff --git a/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js b/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js index bd1af5b01..c8fca3e4c 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js @@ -6,6 +6,7 @@ import canned from './canned/canned.routes'; import inbox from './inbox/inbox.routes'; import profile from './profile/profile.routes'; import reports from './reports/reports.routes'; +import integrations from './integrations/integrations.routes'; export default { routes: [ @@ -26,5 +27,6 @@ export default { ...inbox.routes, ...profile.routes, ...reports.routes, + ...integrations.routes, ], }; diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js index e4ac8f95b..e611985e7 100755 --- a/app/javascript/dashboard/store/index.js +++ b/app/javascript/dashboard/store/index.js @@ -15,6 +15,7 @@ import conversations from './modules/conversations'; import inboxes from './modules/inboxes'; import inboxMembers from './modules/inboxMembers'; import reports from './modules/reports'; +import webhooks from './modules/webhooks'; Vue.use(Vuex); export default new Vuex.Store({ @@ -33,5 +34,6 @@ export default new Vuex.Store({ inboxes, inboxMembers, reports, + webhooks, }, }); diff --git a/app/javascript/dashboard/store/modules/specs/webhooks/actions.spec.js b/app/javascript/dashboard/store/modules/specs/webhooks/actions.spec.js new file mode 100644 index 000000000..47c1debea --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/webhooks/actions.spec.js @@ -0,0 +1,76 @@ +import axios from 'axios'; +import { actions } from '../../webhooks'; +import * as types from '../../../mutation-types'; +import webhooks from './fixtures'; + +const commit = jest.fn(); +global.axios = axios; +jest.mock('axios'); + +describe('#actions', () => { + describe('#get', () => { + it('sends correct actions if API is success', async () => { + axios.get.mockResolvedValue({ data: { payload: { webhooks } } }); + await actions.get({ commit }); + expect(commit.mock.calls).toEqual([ + [types.default.SET_WEBHOOK_UI_FLAG, { fetchingList: true }], + [types.default.SET_WEBHOOK, webhooks], + [types.default.SET_WEBHOOK_UI_FLAG, { fetchingList: false }], + ]); + }); + it('sends correct actions if API is error', async () => { + axios.get.mockRejectedValue({ message: 'Incorrect header' }); + await actions.get({ commit }); + expect(commit.mock.calls).toEqual([ + [types.default.SET_WEBHOOK_UI_FLAG, { fetchingList: true }], + [types.default.SET_WEBHOOK_UI_FLAG, { fetchingList: false }], + ]); + }); + }); + + describe('#create', () => { + it('sends correct actions if API is success', async () => { + axios.post.mockResolvedValue({ + data: { payload: { webhook: webhooks[0] } }, + }); + await actions.create({ commit }, webhooks[0]); + expect(commit.mock.calls).toEqual([ + [types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: true }], + [types.default.ADD_WEBHOOK, webhooks[0]], + [types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false }], + ]); + }); + it('sends correct actions if API is error', async () => { + axios.post.mockRejectedValue({ message: 'Incorrect header' }); + await expect(actions.create({ commit }, webhooks[0].id)).rejects.toEqual({ + message: 'Incorrect header', + }); + expect(commit.mock.calls).toEqual([ + [types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: true }], + [types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false }], + ]); + }); + }); + + describe('#delete', () => { + it('sends correct actions if API is success', async () => { + axios.delete.mockResolvedValue({ data: webhooks[0] }); + await actions.delete({ commit }, webhooks[0].id); + expect(commit.mock.calls).toEqual([ + [types.default.SET_WEBHOOK_UI_FLAG, { deletingItem: true }], + [types.default.DELETE_WEBHOOK, webhooks[0].id], + [types.default.SET_WEBHOOK_UI_FLAG, { deletingItem: false }], + ]); + }); + it('sends correct actions if API is error', async () => { + axios.delete.mockRejectedValue({ message: 'Incorrect header' }); + await expect(actions.delete({ commit }, webhooks[0].id)).rejects.toEqual({ + message: 'Incorrect header', + }); + expect(commit.mock.calls).toEqual([ + [types.default.SET_WEBHOOK_UI_FLAG, { deletingItem: true }], + [types.default.SET_WEBHOOK_UI_FLAG, { deletingItem: false }], + ]); + }); + }); +}); diff --git a/app/javascript/dashboard/store/modules/specs/webhooks/fixtures.js b/app/javascript/dashboard/store/modules/specs/webhooks/fixtures.js new file mode 100644 index 000000000..0979cba10 --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/webhooks/fixtures.js @@ -0,0 +1,17 @@ +export default [ + { + id: 4, + url: 'https://1.chatwoot.com', + account_id: 1, + }, + { + id: 5, + url: 'https://2.chatwoot.com', + account_id: 1, + }, + { + id: 6, + url: 'https://3.chatwoot.com', + account_id: 1, + }, +]; diff --git a/app/javascript/dashboard/store/modules/specs/webhooks/getters.spec.js b/app/javascript/dashboard/store/modules/specs/webhooks/getters.spec.js new file mode 100644 index 000000000..fa5bb06ed --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/webhooks/getters.spec.js @@ -0,0 +1,30 @@ +import { getters } from '../../webhooks'; +import webhooks from './fixtures'; + +describe('#getters', () => { + it('getInboxes', () => { + const state = { + records: webhooks, + }; + expect(getters.getWebhooks(state)).toEqual(webhooks); + }); + + it('getUIFlags', () => { + const state = { + uiFlags: { + fetchingList: false, + fetchingItem: false, + creatingItem: false, + updatingItem: false, + deletingItem: false, + }, + }; + expect(getters.getUIFlags(state)).toEqual({ + fetchingList: false, + fetchingItem: false, + creatingItem: false, + updatingItem: false, + deletingItem: false, + }); + }); +}); diff --git a/app/javascript/dashboard/store/modules/specs/webhooks/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/webhooks/mutations.spec.js new file mode 100644 index 000000000..4a728f7ac --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/webhooks/mutations.spec.js @@ -0,0 +1,33 @@ +import * as types from '../../../mutation-types'; +import { mutations } from '../../webhooks'; +import webhooks from './fixtures'; + +describe('#mutations', () => { + describe('#SET_WEBHOOK', () => { + it('set webhook records', () => { + const state = { records: [] }; + mutations[types.default.SET_WEBHOOK](state, webhooks); + expect(state.records).toEqual(webhooks); + }); + }); + + describe('#ADD_WEBHOOK', () => { + it('push newly created webhook data to the store', () => { + const state = { + records: [], + }; + mutations[types.default.ADD_WEBHOOK](state, webhooks[0]); + expect(state.records).toEqual([webhooks[0]]); + }); + }); + + describe('#DELETE_WEBHOOK', () => { + it('delete webhook record', () => { + const state = { + records: [webhooks[0]], + }; + mutations[types.default.DELETE_WEBHOOK](state, webhooks[0].id); + expect(state.records).toEqual([]); + }); + }); +}); diff --git a/app/javascript/dashboard/store/modules/webhooks.js b/app/javascript/dashboard/store/modules/webhooks.js new file mode 100644 index 000000000..3d3a72107 --- /dev/null +++ b/app/javascript/dashboard/store/modules/webhooks.js @@ -0,0 +1,79 @@ +import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers'; +import * as types from '../mutation-types'; +import webHookAPI from '../../api/webhooks'; + +const state = { + records: [], + uiFlags: { + fetchingList: false, + creatingItem: false, + deletingItem: false, + }, +}; + +export const getters = { + getWebhooks(_state) { + return _state.records; + }, + getUIFlags(_state) { + return _state.uiFlags; + }, +}; + +export const actions = { + async get({ commit }) { + commit(types.default.SET_WEBHOOK_UI_FLAG, { fetchingList: true }); + try { + const response = await webHookAPI.get(); + commit(types.default.SET_WEBHOOK, response.data.payload.webhooks); + commit(types.default.SET_WEBHOOK_UI_FLAG, { fetchingList: false }); + } catch (error) { + commit(types.default.SET_WEBHOOK_UI_FLAG, { fetchingList: false }); + } + }, + + async create({ commit }, params) { + commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: true }); + try { + const response = await webHookAPI.create(params); + commit(types.default.ADD_WEBHOOK, response.data.payload.webhook); + commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false }); + } catch (error) { + commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false }); + throw error; + } + }, + + async delete({ commit }, id) { + commit(types.default.SET_WEBHOOK_UI_FLAG, { deletingItem: true }); + try { + await webHookAPI.delete(id); + commit(types.default.DELETE_WEBHOOK, id); + commit(types.default.SET_WEBHOOK_UI_FLAG, { deletingItem: false }); + } catch (error) { + commit(types.default.SET_WEBHOOK_UI_FLAG, { deletingItem: false }); + throw error; + } + }, +}; + +export const mutations = { + [types.default.SET_WEBHOOK_UI_FLAG](_state, data) { + _state.uiFlags = { + ..._state.uiFlags, + ...data, + }; + }, + + [types.default.SET_WEBHOOK]: MutationHelpers.set, + [types.default.ADD_WEBHOOK]: MutationHelpers.create, + [types.default.DELETE_WEBHOOK]: MutationHelpers.destroy, +}; + +export default { + namespaced: true, + state, + getters, + actions, + mutations, +}; diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index 07c0dc56f..0010364a7 100755 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -56,6 +56,12 @@ export default { EDIT_CANNED: 'EDIT_CANNED', DELETE_CANNED: 'DELETE_CANNED', + // WebHook + SET_WEBHOOK_UI_FLAG: 'SET_WEBHOOK_UI_FLAG', + SET_WEBHOOK: 'SET_WEBHOOK', + ADD_WEBHOOK: 'ADD_WEBHOOK', + DELETE_WEBHOOK: 'DELETE_WEBHOOK', + // Contacts SET_CONTACT_UI_FLAG: 'SET_CONTACT_UI_FLAG', SET_CONTACT_ITEM: 'SET_CONTACT_ITEM', diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 4e1810826..54baa022a 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -16,6 +16,7 @@ class Webhook < ApplicationRecord belongs_to :inbox, optional: true validates :account_id, presence: true + validates :url, uniqueness: { scope: [:account_id] }, format: { with: URI::DEFAULT_PARSER.make_regexp } enum webhook_type: { account: 0, inbox: 1 } end diff --git a/app/views/api/v1/account/webhooks/index.json.jbuilder b/app/views/api/v1/account/webhooks/index.json.jbuilder index 885bba602..d14616cb1 100644 --- a/app/views/api/v1/account/webhooks/index.json.jbuilder +++ b/app/views/api/v1/account/webhooks/index.json.jbuilder @@ -1,5 +1,5 @@ json.payload do json.webhooks do - json.array! @webhooks, partial: 'webhooks/webhook', as: :webhook + json.array! @webhooks, partial: 'webhook', as: :webhook end end diff --git a/package.json b/package.json index 1ef34eaec..f091a234a 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "vue-router": "~2.2.0", "vue-select": "~2.0.0", "vue-template-compiler": "^2.6.10", - "vuelidate": "~0.2.0", + "vuelidate": "~0.7.5", "vuex": "~2.1.1", "vuex-router-sync": "~4.1.2" }, diff --git a/spec/factories/webhooks.rb b/spec/factories/webhooks.rb index 53456ad1a..e80ed1aeb 100644 --- a/spec/factories/webhooks.rb +++ b/spec/factories/webhooks.rb @@ -2,6 +2,6 @@ FactoryBot.define do factory :webhook do account_id { 1 } inbox_id { 1 } - url { 'MyString' } + url { 'https://api.chatwoot.com' } end end diff --git a/yarn.lock b/yarn.lock index 91db3bcb5..261e054c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10466,10 +10466,10 @@ vue@^2.5.8, vue@^2.6.0: resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.11.tgz#76594d877d4b12234406e84e35275c6d514125c5" integrity sha512-VfPwgcGABbGAue9+sfrD4PuwFar7gPb1yl1UK1MwXoQPAw0BKSqWfoYCT/ThFrdEVWoI51dBuyCoiNU9bZDZxQ== -vuelidate@~0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/vuelidate/-/vuelidate-0.2.0.tgz#7c50b220ef3700b1a28900f32825c442df809337" - integrity sha1-fFCyIO83ALGiiQDzKCXEQt+Akzc= +vuelidate@~0.7.5: + version "0.7.5" + resolved "https://registry.yarnpkg.com/vuelidate/-/vuelidate-0.7.5.tgz#ff48c75ae9d24ea24c24e9ea08065eda0a0cba0a" + integrity sha512-GAAG8QAFVp7BFeQlNaThpTbimq3+HypBPNwdkCkHZZeVaD5zmXXfhp357dcUJXHXTZjSln0PvP6wiwLZXkFTwg== vuex-router-sync@~4.1.2: version "4.1.3"