From 14b51e108ac57fd7106621fd5473fb56b81bc058 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Sun, 6 Jun 2021 16:59:05 +0530 Subject: [PATCH] feat: Add Integration hooks UI (#2301) --- app/javascript/dashboard/api/integrations.js | 8 + .../dashboard/api/specs/apiSpecHelper.js | 1 + .../dashboard/api/specs/integrations.spec.js | 55 +++++++ .../dashboard/assets/scss/_formulate.scss | 18 ++ .../dashboard/assets/scss/_woot.scss | 2 +- app/javascript/dashboard/helper/commons.js | 12 ++ .../dashboard/i18n/default-sidebar.js | 10 ++ .../dashboard/i18n/locale/en/index.js | 2 + .../i18n/locale/en/integrationApps.json | 62 +++++++ .../dashboard/i18n/locale/en/settings.json | 1 + .../settings/integrationapps/Index.vue | 56 +++++++ .../integrationapps/IntegrationHooks.vue | 155 ++++++++++++++++++ .../integrationapps/IntegrationItem.vue | 78 +++++++++ .../MultipleIntegrationHooks.vue | 106 ++++++++++++ .../settings/integrationapps/NewHook.vue | 136 +++++++++++++++ .../SingleIntegrationHooks.vue | 47 ++++++ .../settings/integrationapps/hookMixin.js | 10 ++ .../integrationapps/integrations.routes.js | 43 +++++ .../integrationapps/specs/hookMixin.spec.js | 26 +++ .../dashboard/settings/settings.routes.js | 2 + .../dashboard/store/modules/inboxes.js | 5 + .../dashboard/store/modules/integrations.js | 52 +++++- .../modules/specs/inboxes/getters.spec.js | 5 + .../specs/integrations/actions.spec.js | 81 ++++++--- .../specs/integrations/getters.spec.js | 27 +++ .../specs/integrations/mutations.spec.js | 59 ++++++- .../dashboard/store/mutation-types.js | 2 + app/javascript/packs/application.js | 8 +- app/models/integrations/app.rb | 4 +- app/views/api/v1/models/_hook.json.jbuilder | 2 +- config/integration/apps.yml | 31 +++- config/locales/en.yml | 5 +- package.json | 1 + .../images/integrations/fullcontact.png | Bin 0 -> 7204 bytes yarn.lock | 27 ++- 35 files changed, 1108 insertions(+), 31 deletions(-) create mode 100644 app/javascript/dashboard/api/specs/integrations.spec.js create mode 100644 app/javascript/dashboard/assets/scss/_formulate.scss create mode 100644 app/javascript/dashboard/i18n/locale/en/integrationApps.json create mode 100644 app/javascript/dashboard/routes/dashboard/settings/integrationapps/Index.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/integrationapps/IntegrationHooks.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/integrationapps/IntegrationItem.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/integrationapps/MultipleIntegrationHooks.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/integrationapps/NewHook.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/integrationapps/SingleIntegrationHooks.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/integrationapps/hookMixin.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/integrationapps/integrations.routes.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/integrationapps/specs/hookMixin.spec.js create mode 100644 public/dashboard/images/integrations/fullcontact.png diff --git a/app/javascript/dashboard/api/integrations.js b/app/javascript/dashboard/api/integrations.js index 587c94d43..72e433c25 100644 --- a/app/javascript/dashboard/api/integrations.js +++ b/app/javascript/dashboard/api/integrations.js @@ -16,6 +16,14 @@ class IntegrationsAPI extends ApiClient { delete(integrationId) { return axios.delete(`${this.baseUrl()}/integrations/${integrationId}`); } + + createHook(hookData) { + return axios.post(`${this.baseUrl()}/integrations/hooks`, hookData); + } + + deleteHook(hookId) { + return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`); + } } export default new IntegrationsAPI(); diff --git a/app/javascript/dashboard/api/specs/apiSpecHelper.js b/app/javascript/dashboard/api/specs/apiSpecHelper.js index aedea13f8..aab90b045 100644 --- a/app/javascript/dashboard/api/specs/apiSpecHelper.js +++ b/app/javascript/dashboard/api/specs/apiSpecHelper.js @@ -5,6 +5,7 @@ function apiSpecHelper() { post: jest.fn(() => Promise.resolve()), get: jest.fn(() => Promise.resolve()), patch: jest.fn(() => Promise.resolve()), + delete: jest.fn(() => Promise.resolve()), }; window.axios = this.axiosMock; }); diff --git a/app/javascript/dashboard/api/specs/integrations.spec.js b/app/javascript/dashboard/api/specs/integrations.spec.js new file mode 100644 index 000000000..05391ceb6 --- /dev/null +++ b/app/javascript/dashboard/api/specs/integrations.spec.js @@ -0,0 +1,55 @@ +import integrationAPI from '../integrations'; +import ApiClient from '../ApiClient'; +import describeWithAPIMock from './apiSpecHelper'; + +describe('#integrationAPI', () => { + it('creates correct instance', () => { + expect(integrationAPI).toBeInstanceOf(ApiClient); + expect(integrationAPI).toHaveProperty('get'); + expect(integrationAPI).toHaveProperty('show'); + expect(integrationAPI).toHaveProperty('create'); + expect(integrationAPI).toHaveProperty('update'); + expect(integrationAPI).toHaveProperty('delete'); + expect(integrationAPI).toHaveProperty('connectSlack'); + expect(integrationAPI).toHaveProperty('createHook'); + expect(integrationAPI).toHaveProperty('deleteHook'); + }); + describeWithAPIMock('API calls', context => { + it('#connectSlack', () => { + const code = 'SDNFJNSDFNDSJN'; + integrationAPI.connectSlack(code); + expect(context.axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/slack', + { + code, + } + ); + }); + + it('#delete', () => { + integrationAPI.delete(2); + expect(context.axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/integrations/2' + ); + }); + + it('#createHook', () => { + const hookData = { + app_id: 'fullcontact', + settings: { api_key: 'SDFSDGSVE' }, + }; + integrationAPI.createHook(hookData); + expect(context.axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/hooks', + hookData + ); + }); + + it('#deleteHook', () => { + integrationAPI.deleteHook(2); + expect(context.axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/integrations/hooks/2' + ); + }); + }); +}); diff --git a/app/javascript/dashboard/assets/scss/_formulate.scss b/app/javascript/dashboard/assets/scss/_formulate.scss new file mode 100644 index 000000000..57c43a3d6 --- /dev/null +++ b/app/javascript/dashboard/assets/scss/_formulate.scss @@ -0,0 +1,18 @@ +@import '~dashboard/assets/scss/variables'; + +.formulate-input { + .formulate-input-errors { + list-style-type: none; + margin: 0; + padding: 0; + } + + .formulate-input-error { + color: var(--r-400); + display: block; + font-size: var(--font-size-small); + font-weight: $font-weight-normal; + margin-bottom: $space-one; + width: 100%; + } +} diff --git a/app/javascript/dashboard/assets/scss/_woot.scss b/app/javascript/dashboard/assets/scss/_woot.scss index 1712e256e..d156f77b9 100644 --- a/app/javascript/dashboard/assets/scss/_woot.scss +++ b/app/javascript/dashboard/assets/scss/_woot.scss @@ -11,13 +11,13 @@ @import 'mixins'; @import 'foundation-settings'; @import 'helper-classes'; +@import 'formulate'; @import 'foundation-sites/scss/foundation'; @import '~bourbon/core/bourbon'; @include foundation-everything($flex: true); - @import 'typography'; @import 'layout'; @import 'animations'; diff --git a/app/javascript/dashboard/helper/commons.js b/app/javascript/dashboard/helper/commons.js index d7d34283a..40ff06c83 100644 --- a/app/javascript/dashboard/helper/commons.js +++ b/app/javascript/dashboard/helper/commons.js @@ -13,6 +13,18 @@ export default () => { } }; +export const isEmptyObject = obj => + Object.keys(obj).length === 0 && obj.constructor === Object; + +export const isJSONValid = value => { + try { + JSON.parse(value); + } catch (e) { + return false; + } + return true; +}; + export const getTypingUsersText = (users = []) => { const count = users.length; if (count === 1) { diff --git a/app/javascript/dashboard/i18n/default-sidebar.js b/app/javascript/dashboard/i18n/default-sidebar.js index 6488c022e..8bb78f61b 100644 --- a/app/javascript/dashboard/i18n/default-sidebar.js +++ b/app/javascript/dashboard/i18n/default-sidebar.js @@ -74,6 +74,9 @@ export const getSidebarItems = accountId => ({ 'settings_integrations', 'settings_integrations_webhook', 'settings_integrations_integration', + 'settings_applications', + 'settings_applications_webhook', + 'settings_applications_integration', 'general_settings', 'general_settings_index', 'settings_teams_list', @@ -136,6 +139,13 @@ export const getSidebarItems = accountId => ({ toState: frontendURL(`accounts/${accountId}/settings/integrations`), toStateName: 'settings_integrations', }, + settings_applications: { + icon: 'ion-asterisk', + label: 'APPLICATIONS', + hasSubMenu: false, + toState: frontendURL(`accounts/${accountId}/settings/applications`), + toStateName: 'settings_applications', + }, general_settings_index: { icon: 'ion-gear-a', label: 'ACCOUNT_SETTINGS', diff --git a/app/javascript/dashboard/i18n/locale/en/index.js b/app/javascript/dashboard/i18n/locale/en/index.js index cf1dda7fe..4afba79aa 100644 --- a/app/javascript/dashboard/i18n/locale/en/index.js +++ b/app/javascript/dashboard/i18n/locale/en/index.js @@ -15,6 +15,7 @@ import { default as _setNewPassword } from './setNewPassword.json'; import { default as _settings } from './settings.json'; import { default as _signup } from './signup.json'; import { default as _teamsSettings } from './teamsSettings.json'; +import { default as _integrationApps } from './integrationApps.json'; export default { ..._agentMgmt, @@ -34,4 +35,5 @@ export default { ..._settings, ..._signup, ..._teamsSettings, + ..._integrationApps, }; diff --git a/app/javascript/dashboard/i18n/locale/en/integrationApps.json b/app/javascript/dashboard/i18n/locale/en/integrationApps.json new file mode 100644 index 000000000..a80ecb837 --- /dev/null +++ b/app/javascript/dashboard/i18n/locale/en/integrationApps.json @@ -0,0 +1,62 @@ +{ + "INTEGRATION_APPS": { + "FETCHING": "Fetching Integrations", + "NO_HOOK_CONFIGURED": "There are no %{integrationId} integrations configured in this account.", + "HEADER": "Applications", + "STATUS": { + "ENABLED": "Enabled", + "DISABLED": "Disabled" + }, + "CONFIGURE": "Configure", + "ADD_BUTTON": "Add a new hook", + "DELETE": { + "TITLE": { + "INBOX": "Confirm deletion", + "ACCOUNT": "Disconnect" + }, + "MESSAGE": { + "INBOX": "Are you sure to delete?", + "ACCOUNT": "Are you sure to disconnect?" + }, + "CONFIRM_BUTTON_TEXT": { + "INBOX": "Yes, Delete", + "ACCOUNT": "Yes, Disconnect" + }, + "CANCEL_BUTTON_TEXT": "Cancel", + "API": { + "SUCCESS_MESSAGE": "Hook deleted successfully", + "ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later" + } + }, + "LIST": { + "FETCHING": "Fetching integration hooks", + "INBOX": "Inbox", + "DELETE": { + "BUTTON_TEXT": "Delete" + } + }, + "ADD": { + "FORM": { + "INBOX": { + "LABEL": "Select Inbox", + "PLACEHOLDER": "Select Inbox" + }, + "SUBMIT": "Create", + "CANCEL": "Cancel" + }, + "API": { + "SUCCESS_MESSAGE": "Integration hook added successfully", + "ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later" + } + }, + "CONNECT": { + "BUTTON_TEXT": "Connect" + }, + "DISCONNECT": { + "BUTTON_TEXT": "Disconnect" + }, + "SIDEBAR_DESCRIPTION": { + "DIALOGFLOW": "Dialogflow is a natural language understanding platform that makes it easy to design and integrate a conversational user interface into your mobile app, web application, device, bot, interactive voice response system, and so on.

Dialogflow integration with %{installationName} allows you to configure a Dialogflow bot with your inboxes which lets the bot handle the queries initially and hand them over to an agent when needed. Dialogflow can be used to qualifying the leads, reduce the workload of agents by providing frequently asked questions etc.

To add Dialogflow, you need to create a Service Account in your Google project console and share the credentials. Please refer to the Dialogflow docs for more information." + } + } +} diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index aa51c35be..c7bb0cb6d 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -133,6 +133,7 @@ "CANNED_RESPONSES": "Canned Responses", "INTEGRATIONS": "Integrations", "ACCOUNT_SETTINGS": "Account Settings", + "APPLICATIONS": "Applications", "LABELS": "Labels", "TEAMS": "Teams" }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrationapps/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/Index.vue new file mode 100644 index 000000000..5441742bd --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/Index.vue @@ -0,0 +1,56 @@ + + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrationapps/IntegrationHooks.vue b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/IntegrationHooks.vue new file mode 100644 index 000000000..6d9c4395c --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/IntegrationHooks.vue @@ -0,0 +1,155 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrationapps/IntegrationItem.vue b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/IntegrationItem.vue new file mode 100644 index 000000000..7d0e64550 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/IntegrationItem.vue @@ -0,0 +1,78 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrationapps/MultipleIntegrationHooks.vue b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/MultipleIntegrationHooks.vue new file mode 100644 index 000000000..fdce8cadc --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/MultipleIntegrationHooks.vue @@ -0,0 +1,106 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrationapps/NewHook.vue b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/NewHook.vue new file mode 100644 index 000000000..3d343ab14 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/NewHook.vue @@ -0,0 +1,136 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrationapps/SingleIntegrationHooks.vue b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/SingleIntegrationHooks.vue new file mode 100644 index 000000000..24d9f2610 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/SingleIntegrationHooks.vue @@ -0,0 +1,47 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrationapps/hookMixin.js b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/hookMixin.js new file mode 100644 index 000000000..fd545f480 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/hookMixin.js @@ -0,0 +1,10 @@ +export default { + computed: { + isHookTypeInbox() { + return this.integration.hook_type === 'inbox'; + }, + hasConnectedHooks() { + return !!this.integration.hooks.length; + }, + }, +}; diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrationapps/integrations.routes.js b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/integrations.routes.js new file mode 100644 index 000000000..c05982551 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/integrations.routes.js @@ -0,0 +1,43 @@ +import Index from './Index'; +import SettingsContent from '../Wrapper'; +import IntegrationHooks from './IntegrationHooks'; +import { frontendURL } from '../../../../helper/URLHelper'; + +export default { + routes: [ + { + path: frontendURL('accounts/:accountId/settings/applications'), + component: SettingsContent, + props: params => { + const showBackButton = params.name !== 'settings_applications'; + const backUrl = + params.name === 'settings_applications_integration' + ? { name: 'settings_applications' } + : ''; + return { + headerTitle: 'INTEGRATION_APPS.HEADER', + icon: 'ion-asterisk', + showBackButton, + backUrl, + }; + }, + children: [ + { + path: '', + name: 'settings_applications', + component: Index, + roles: ['administrator'], + }, + { + path: ':integration_id', + name: 'settings_applications_integration', + component: IntegrationHooks, + roles: ['administrator'], + props: route => ({ + integrationId: route.params.integration_id, + }), + }, + ], + }, + ], +}; diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrationapps/specs/hookMixin.spec.js b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/specs/hookMixin.spec.js new file mode 100644 index 000000000..eb895ccf3 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/integrationapps/specs/hookMixin.spec.js @@ -0,0 +1,26 @@ +import { shallowMount } from '@vue/test-utils'; +import hookMixin from '../hookMixin'; + +describe('hookMixin', () => { + const Component = { + render() {}, + mixins: [hookMixin], + data() { + return { + integration: { + hook_type: 'inbox', + hooks: [{ id: 1, properties: {} }], + }, + }; + }, + }; + const wrapper = shallowMount(Component); + + it('#isHookTypeInbox returns correct value', () => { + expect(wrapper.vm.isHookTypeInbox).toBe(true); + }); + + it('#hasConnectedHooks returns correct value', () => { + expect(wrapper.vm.hasConnectedHooks).toBe(true); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js b/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js index 46617349c..6293f08c1 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js @@ -4,6 +4,7 @@ import agent from './agents/agent.routes'; import canned from './canned/canned.routes'; import inbox from './inbox/inbox.routes'; import integrations from './integrations/integrations.routes'; +import integrationapps from './integrationapps/integrations.routes'; import labels from './labels/labels.routes'; import profile from './profile/profile.routes'; import reports from './reports/reports.routes'; @@ -32,5 +33,6 @@ export default { ...profile.routes, ...reports.routes, ...teams.routes, + ...integrationapps.routes, ], }; diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js index 753622ccd..c4f38c96e 100644 --- a/app/javascript/dashboard/store/modules/inboxes.js +++ b/app/javascript/dashboard/store/modules/inboxes.js @@ -65,6 +65,11 @@ export const getters = { getUIFlags($state) { return $state.uiFlags; }, + getWebsiteInboxes($state) { + return $state.records.filter( + item => item.channel_type === 'Channel::WebWidget' + ); + }, }; export const actions = { diff --git a/app/javascript/dashboard/store/modules/integrations.js b/app/javascript/dashboard/store/modules/integrations.js index 7c8d62ef8..6b3ebe8a4 100644 --- a/app/javascript/dashboard/store/modules/integrations.js +++ b/app/javascript/dashboard/store/modules/integrations.js @@ -1,4 +1,5 @@ /* eslint no-param-reassign: 0 */ +import Vue from 'vue'; import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers'; import * as types from '../mutation-types'; import IntegrationsAPI from '../../api/integrations'; @@ -6,15 +7,23 @@ import IntegrationsAPI from '../../api/integrations'; const state = { records: [], uiFlags: { + isCreating: false, isFetching: false, isFetchingItem: false, isUpdating: false, + isCreatingHook: false, + isDeletingHook: false, }, }; export const getters = { getIntegrations($state) { - return $state.records; + return $state.records.filter( + item => item.id !== 'fullcontact' && item.id !== 'dialogflow' + ); + }, + getAppIntegrations($state) { + return $state.records.filter(item => item.id === 'dialogflow'); }, getIntegration: $state => integrationId => { const [integration] = $state.records.filter( @@ -63,6 +72,28 @@ export const actions = { commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isDeleting: false }); } }, + createHook: async ({ commit }, hookData) => { + commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: true }); + try { + const response = await IntegrationsAPI.createHook(hookData); + commit(types.default.ADD_INTEGRATION_HOOKS, response.data); + commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: false }); + } catch (error) { + commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: false }); + throw new Error(error); + } + }, + deleteHook: async ({ commit }, { appId, hookId }) => { + commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isDeletingHook: true }); + try { + await IntegrationsAPI.deleteHook(hookId); + commit(types.default.DELETE_INTEGRATION_HOOKS, { appId, hookId }); + commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isDeletingHook: false }); + } catch (error) { + commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isDeletingHook: false }); + throw new Error(error); + } + }, }; export const mutations = { @@ -72,6 +103,25 @@ export const mutations = { [types.default.SET_INTEGRATIONS]: MutationHelpers.set, [types.default.ADD_INTEGRATION]: MutationHelpers.updateAttributes, [types.default.DELETE_INTEGRATION]: MutationHelpers.updateAttributes, + [types.default.ADD_INTEGRATION_HOOKS]: ($state, data) => { + $state.records.forEach((element, index) => { + if (element.id === data.app_id) { + const record = $state.records[index]; + Vue.set(record, 'hooks', [...record.hooks, data]); + } + }); + }, + [types.default.DELETE_INTEGRATION_HOOKS]: ($state, { appId, hookId }) => { + $state.records.forEach((element, index) => { + if (element.id === appId) { + const record = $state.records[index]; + const hooksWithoutDeletedHook = record.hooks.filter( + hook => hook.id !== hookId + ); + Vue.set(record, 'hooks', hooksWithoutDeletedHook); + } + }); + }, }; export default { diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js b/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js index 5bd531a80..f3e85773c 100644 --- a/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js @@ -9,6 +9,11 @@ describe('#getters', () => { expect(getters.getInboxes(state)).toEqual(inboxList); }); + it('getWebsiteInboxes', () => { + const state = { records: inboxList }; + expect(getters.getWebsiteInboxes(state).length).toEqual(3); + }); + it('getInbox', () => { const state = { records: inboxList, diff --git a/app/javascript/dashboard/store/modules/specs/integrations/actions.spec.js b/app/javascript/dashboard/store/modules/specs/integrations/actions.spec.js index cc0dd9d22..1f625b0c4 100644 --- a/app/javascript/dashboard/store/modules/specs/integrations/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/integrations/actions.spec.js @@ -1,29 +1,30 @@ import axios from 'axios'; import { actions } from '../../integrations'; -import * as types from '../../../mutation-types'; +import types from '../../../mutation-types'; import integrationsList from './fixtures'; const commit = jest.fn(); global.axios = axios; jest.mock('axios'); +const errorMessage = { message: 'Incorrect header' }; describe('#actions', () => { describe('#get', () => { it('sends correct actions if API is success', async () => { axios.get.mockResolvedValue({ data: integrationsList }); await actions.get({ commit }); expect(commit.mock.calls).toEqual([ - [types.default.SET_INTEGRATIONS_UI_FLAG, { isFetching: true }], - [types.default.SET_INTEGRATIONS, integrationsList.payload], - [types.default.SET_INTEGRATIONS_UI_FLAG, { isFetching: false }], + [types.SET_INTEGRATIONS_UI_FLAG, { isFetching: true }], + [types.SET_INTEGRATIONS, integrationsList.payload], + [types.SET_INTEGRATIONS_UI_FLAG, { isFetching: false }], ]); }); it('sends correct actions if API is error', async () => { - axios.get.mockRejectedValue({ message: 'Incorrect header' }); + axios.get.mockRejectedValue(errorMessage); await actions.get({ commit }); expect(commit.mock.calls).toEqual([ - [types.default.SET_INTEGRATIONS_UI_FLAG, { isFetching: true }], - [types.default.SET_INTEGRATIONS_UI_FLAG, { isFetching: false }], + [types.SET_INTEGRATIONS_UI_FLAG, { isFetching: true }], + [types.SET_INTEGRATIONS_UI_FLAG, { isFetching: false }], ]); }); }); @@ -34,17 +35,17 @@ describe('#actions', () => { axios.post.mockResolvedValue({ data: data }); await actions.connectSlack({ commit }); expect(commit.mock.calls).toEqual([ - [types.default.SET_INTEGRATIONS_UI_FLAG, { isUpdating: true }], - [types.default.ADD_INTEGRATION, data], - [types.default.SET_INTEGRATIONS_UI_FLAG, { isUpdating: false }], + [types.SET_INTEGRATIONS_UI_FLAG, { isUpdating: true }], + [types.ADD_INTEGRATION, data], + [types.SET_INTEGRATIONS_UI_FLAG, { isUpdating: false }], ]); }); it('sends correct actions if API is error', async () => { - axios.post.mockRejectedValue({ message: 'Incorrect header' }); + axios.post.mockRejectedValue(errorMessage); await actions.connectSlack({ commit }); expect(commit.mock.calls).toEqual([ - [types.default.SET_INTEGRATIONS_UI_FLAG, { isUpdating: true }], - [types.default.SET_INTEGRATIONS_UI_FLAG, { isUpdating: false }], + [types.SET_INTEGRATIONS_UI_FLAG, { isUpdating: true }], + [types.SET_INTEGRATIONS_UI_FLAG, { isUpdating: false }], ]); }); }); @@ -55,17 +56,59 @@ describe('#actions', () => { axios.delete.mockResolvedValue({ data: data }); await actions.deleteIntegration({ commit }, data.id); expect(commit.mock.calls).toEqual([ - [types.default.SET_INTEGRATIONS_UI_FLAG, { isDeleting: true }], - [types.default.DELETE_INTEGRATION, data], - [types.default.SET_INTEGRATIONS_UI_FLAG, { isDeleting: false }], + [types.SET_INTEGRATIONS_UI_FLAG, { isDeleting: true }], + [types.DELETE_INTEGRATION, data], + [types.SET_INTEGRATIONS_UI_FLAG, { isDeleting: false }], ]); }); it('sends correct actions if API is error', async () => { - axios.delete.mockRejectedValue({ message: 'Incorrect header' }); + axios.delete.mockRejectedValue(errorMessage); await actions.deleteIntegration({ commit }); expect(commit.mock.calls).toEqual([ - [types.default.SET_INTEGRATIONS_UI_FLAG, { isDeleting: true }], - [types.default.SET_INTEGRATIONS_UI_FLAG, { isDeleting: false }], + [types.SET_INTEGRATIONS_UI_FLAG, { isDeleting: true }], + [types.SET_INTEGRATIONS_UI_FLAG, { isDeleting: false }], + ]); + }); + }); + + describe('#createHooks', () => { + it('sends correct actions if API is success', async () => { + let data = { id: 'slack', enabled: false }; + axios.post.mockResolvedValue({ data: data }); + await actions.createHook({ commit }, data); + expect(commit.mock.calls).toEqual([ + [types.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: true }], + [types.ADD_INTEGRATION_HOOKS, data], + [types.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: false }], + ]); + }); + it('sends correct actions if API is error', async () => { + axios.post.mockRejectedValue(errorMessage); + await expect(actions.createHook({ commit })).rejects.toThrow(Error); + expect(commit.mock.calls).toEqual([ + [types.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: true }], + [types.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: false }], + ]); + }); + }); + + describe('#deleteHook', () => { + it('sends correct actions if API is success', async () => { + let data = { appId: 'dialogflow', hookId: 2 }; + axios.delete.mockResolvedValue({ data }); + await actions.deleteHook({ commit }, data); + expect(commit.mock.calls).toEqual([ + [types.SET_INTEGRATIONS_UI_FLAG, { isDeletingHook: true }], + [types.DELETE_INTEGRATION_HOOKS, { appId: 'dialogflow', hookId: 2 }], + [types.SET_INTEGRATIONS_UI_FLAG, { isDeletingHook: false }], + ]); + }); + it('sends correct actions if API is error', async () => { + axios.delete.mockRejectedValue(errorMessage); + await expect(actions.deleteHook({ commit }, {})).rejects.toThrow(Error); + expect(commit.mock.calls).toEqual([ + [types.SET_INTEGRATIONS_UI_FLAG, { isDeletingHook: true }], + [types.SET_INTEGRATIONS_UI_FLAG, { isDeletingHook: false }], ]); }); }); diff --git a/app/javascript/dashboard/store/modules/specs/integrations/getters.spec.js b/app/javascript/dashboard/store/modules/specs/integrations/getters.spec.js index 7ec4657a6..2c4ef9e63 100644 --- a/app/javascript/dashboard/store/modules/specs/integrations/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/integrations/getters.spec.js @@ -34,6 +34,33 @@ describe('#getters', () => { ]); }); + it('getAppIntegrations', () => { + const state = { + records: [ + { + id: 1, + name: 'test1', + logo: 'test', + enabled: true, + }, + { + id: 'dialogflow', + name: 'test2', + logo: 'test', + enabled: true, + }, + ], + }; + expect(getters.getAppIntegrations(state)).toEqual([ + { + id: 'dialogflow', + name: 'test2', + logo: 'test', + enabled: true, + }, + ]); + }); + it('getUIFlags', () => { const state = { uiFlags: { diff --git a/app/javascript/dashboard/store/modules/specs/integrations/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/integrations/mutations.spec.js index c485b5018..212ef44ce 100644 --- a/app/javascript/dashboard/store/modules/specs/integrations/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/specs/integrations/mutations.spec.js @@ -1,11 +1,11 @@ -import * as types from '../../../mutation-types'; +import types from '../../../mutation-types'; import { mutations } from '../../integrations'; describe('#mutations', () => { describe('#GET_INTEGRATIONS', () => { it('set integrations records', () => { const state = { records: [] }; - mutations[types.default.SET_INTEGRATIONS](state, [ + mutations[types.SET_INTEGRATIONS](state, [ { id: 1, name: 'test1', @@ -23,4 +23,59 @@ describe('#mutations', () => { ]); }); }); + + describe('#ADD_INTEGRATION_HOOKS', () => { + it('set integrations hook records', () => { + const state = { records: [{ id: 'dialogflow', hooks: [] }] }; + const hookRecord = { + id: 1, + app_id: 'dialogflow', + status: false, + inbox: { id: 1, name: 'Chatwoot' }, + account_id: 1, + hook_type: 'inbox', + settings: { project_id: 'test', credentials: {} }, + }; + mutations[types.ADD_INTEGRATION_HOOKS](state, hookRecord); + expect(state.records).toEqual([ + { + id: 'dialogflow', + hooks: [hookRecord], + }, + ]); + }); + }); + + describe('#DELETE_INTEGRATION_HOOKS', () => { + it('delete integrations hook record', () => { + const state = { + records: [ + { + id: 'dialogflow', + hooks: [ + { + id: 1, + app_id: 'dialogflow', + status: false, + inbox: { id: 1, name: 'Chatwoot' }, + account_id: 1, + hook_type: 'inbox', + settings: { project_id: 'test', credentials: {} }, + }, + ], + }, + ], + }; + mutations[types.DELETE_INTEGRATION_HOOKS](state, { + appId: 'dialogflow', + hookId: 1, + }); + expect(state.records).toEqual([ + { + id: 'dialogflow', + hooks: [], + }, + ]); + }); + }); }); diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index 29b1e45b5..ec9f527c4 100755 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -83,6 +83,8 @@ export default { SET_INTEGRATIONS: 'SET_INTEGRATIONS', ADD_INTEGRATION: 'ADD_INTEGRATION', DELETE_INTEGRATION: 'DELETE_INTEGRATION', + ADD_INTEGRATION_HOOKS: 'ADD_INTEGRATION_HOOKS', + DELETE_INTEGRATION_HOOKS: 'DELETE_INTEGRATION_HOOKS', // WebHook SET_WEBHOOK_UI_FLAG: 'SET_WEBHOOK_UI_FLAG', diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index e0443c15c..2b7b3cc30 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -10,6 +10,7 @@ import axios from 'axios'; // Global Components import hljs from 'highlight.js'; import Multiselect from 'vue-multiselect'; +import VueFormulate from '@braid/vue-formulate'; import WootSwitch from 'components/ui/Switch'; import WootWizard from 'components/ui/Wizard'; import { sync } from 'vuex-router-sync'; @@ -19,7 +20,7 @@ import WootUiKit from '../dashboard/components'; import App from '../dashboard/App'; import i18n from '../dashboard/i18n'; import createAxios from '../dashboard/helper/APIHelper'; -import commonHelpers from '../dashboard/helper/commons'; +import commonHelpers, { isJSONValid } from '../dashboard/helper/commons'; import { getAlertAudio } from '../shared/helpers/AudioNotificationHelper'; import { initFaviconSwitcher } from '../shared/helpers/faviconHelper'; import router from '../dashboard/routes'; @@ -48,6 +49,11 @@ Vue.use(VueRouter); Vue.use(VueI18n); Vue.use(WootUiKit); Vue.use(Vuelidate); +Vue.use(VueFormulate, { + rules: { + JSON: ({ value }) => isJSONValid(value), + }, +}); Vue.use(VTooltip, { defaultHtml: false, }); diff --git a/app/models/integrations/app.rb b/app/models/integrations/app.rb index bae1ca02f..149a305b3 100644 --- a/app/models/integrations/app.rb +++ b/app/models/integrations/app.rb @@ -38,8 +38,8 @@ class Integrations::App case params[:id] when 'slack' ENV['SLACK_CLIENT_SECRET'].present? - when 'dialogflow' - false + when 'dialogflow', 'fullcontact' + true else true end diff --git a/app/views/api/v1/models/_hook.json.jbuilder b/app/views/api/v1/models/_hook.json.jbuilder index e9c921516..3c692c13d 100644 --- a/app/views/api/v1/models/_hook.json.jbuilder +++ b/app/views/api/v1/models/_hook.json.jbuilder @@ -1,7 +1,7 @@ json.id resource.id json.app_id resource.app_id json.status resource.enabled? -json.inbox_id resource.inbox_id +json.inbox resource.inbox&.slice(:id, :name) json.account_id resource.account_id json.hook_type resource.hook_type json.settings resource.settings diff --git a/config/integration/apps.yml b/config/integration/apps.yml index 1c6276894..a9ae7b4a2 100644 --- a/config/integration/apps.yml +++ b/config/integration/apps.yml @@ -3,7 +3,7 @@ # logo: place the image in /public/dashboard/images/integrations and reference here # i18n_key: the key under which translations for the integration is placed in en.yml # action: if integration requires external redirect url -# hook_type: ( account / inbox ) +# hook_type: ( account / inbox ) # allow_multiple_hooks: whether multiple hooks can be created for the integration # settings_json_schema: the json schema used to validate the settings hash (https://json-schema.org/) # settings_form_schema: the formulate schema used in frontend to render settings form (https://vueformulate.com/) @@ -43,11 +43,38 @@ dialogflow: { "label": "Dialogflow Project ID", "type": "text", - "name": "project_id" + "name": "project_id", + "validation": "required", + "validationName": 'Project Id', }, { "label": "Dialogflow Project Key File", "type": "textarea", "name": "credentials", + "validation": "required|JSON", + "validationName": 'Credentials', + "validation-messages": { + "JSON": "Invalid JSON", + "required": "Credentials is required" + } } ] + visible_properties: ['project_id'] +fullcontact: + id: fullcontact + logo: fullcontact.png + i18n_key: fullcontact + action: /fullcontact + hook_type: account + allow_multiple_hooks: false + settings_json_schema: + { + 'type': 'object', + 'properties': { 'api_key': { 'type': 'string' } }, + 'required': ['api_key'], + 'additionalProperties': false, + } + settings_form_schema: + [{ 'label': 'API Key', 'type': 'text', 'name': 'api_key',"validation": "required", }] + visible_properties: ['api_key'] + diff --git a/config/locales/en.yml b/config/locales/en.yml index ecea2c545..b4bd4357c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -95,4 +95,7 @@ en: description: "Webhook events provide you the realtime information about what's happening in your account. You can make use of the webhooks to communicate the events to your favourite apps like Slack or Github. Click on Configure to set up your webhooks." dialogflow: name: "Dialogflow" - description: "Connect your Dialogflow bot to your inbox. Let the bots handle the queries before handing it off to the customer service agent." + description: "Build chatbots using Dialogflow and connect them to your inbox quickly. Let the bots handle the queries before handing them off to a customer service agent." + fullcontact: + name: "Fullcontact" + description: "FullContact integration helps to enrich visitor profiles. Identify the users as soon as they share their email address and offer them tailored customer service. Connect your FullContact to your account by sharing the FullContact API Key." diff --git a/package.json b/package.json index 8bbad0b4d..cb6b4fa42 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "build-storybook": "build-storybook" }, "dependencies": { + "@braid/vue-formulate": "^2.5.2", "@chatwoot/prosemirror-schema": "https://github.com/chatwoot/prosemirror-schema.git#7e8acadd10d7b932c0dc0bd0a18f804434f83517", "@chatwoot/utils": "^0.0.3", "@rails/actioncable": "6.1.3", diff --git a/public/dashboard/images/integrations/fullcontact.png b/public/dashboard/images/integrations/fullcontact.png new file mode 100644 index 0000000000000000000000000000000000000000..1e5d9b7f9e5c2b5bece94a6a9cae344f4d2f912b GIT binary patch literal 7204 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