feat: Add Integration hooks UI (#2301)

This commit is contained in:
Muhsin Keloth 2021-06-06 16:59:05 +05:30 committed by GitHub
parent c6487877bf
commit 14b51e108a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 1108 additions and 31 deletions

View file

@ -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();

View file

@ -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;
});

View file

@ -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'
);
});
});
});

View file

@ -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%;
}
}

View file

@ -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';

View file

@ -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) {

View file

@ -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',

View file

@ -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,
};

View file

@ -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. <br /> <br /> 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. <br /> <br /> 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."
}
}
}

View file

@ -133,6 +133,7 @@
"CANNED_RESPONSES": "Canned Responses",
"INTEGRATIONS": "Integrations",
"ACCOUNT_SETTINGS": "Account Settings",
"APPLICATIONS": "Applications",
"LABELS": "Labels",
"TEAMS": "Teams"
},

View file

@ -0,0 +1,56 @@
<template>
<div class="column content-box">
<div class="row">
<div class="empty-wrapper">
<woot-loading-state
v-if="uiFlags.isFetching"
:message="$t('INTEGRATION_APPS.FETCHING')"
/>
</div>
<div class="small-12 columns integrations-wrap">
<div class="row integrations">
<div
v-for="item in integrationsList"
:key="item.id"
class="small-12 columns integration"
>
<integration-item
:integration-id="item.id"
:integration-logo="item.logo"
:integration-name="item.name"
:integration-description="item.description"
:integration-enabled="item.hooks.length"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import IntegrationItem from './IntegrationItem';
export default {
components: {
IntegrationItem,
},
computed: {
...mapGetters({
uiFlags: 'labels/getUIFlags',
integrationsList: 'integrations/getAppIntegrations',
}),
},
mounted() {
this.$store.dispatch('integrations/get');
},
};
</script>
<style scoped lang="scss">
@import '~dashboard/assets/scss/variables';
.empty-wrapper {
margin: var(--space-zero) auto;
}
</style>

View file

@ -0,0 +1,155 @@
<template>
<div class="row content-box full-height">
<woot-button
v-if="showAddButton"
color-scheme="success"
class-names="button--fixed-right-top"
icon="ion-android-add-circle"
@click="openAddHookModal"
>
{{ $t('INTEGRATION_APPS.ADD_BUTTON') }}
</woot-button>
<div v-if="showIntegrationHooks" class="integration-hooks">
<div v-if="isIntegrationMultiple">
<multiple-integration-hooks
:integration="integration"
@delete="openDeletePopup"
/>
</div>
<div v-if="isIntegrationSingle">
<single-integration-hooks
:integration="integration"
@add="openAddHookModal"
@delete="openDeletePopup"
/>
</div>
</div>
<woot-modal :show.sync="showAddHookModal" :on-close="hideAddHookModal">
<new-hook :integration="integration" @close="hideAddHookModal" />
</woot-modal>
<woot-delete-modal
:show.sync="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
:title="deleteTitle"
:message="deleteMessage"
:confirm-text="confirmText"
:reject-text="cancelText"
/>
</div>
</template>
<script>
import { isEmptyObject } from '../../../../helper/commons';
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import hookMixin from './hookMixin';
import NewHook from './NewHook';
import SingleIntegrationHooks from './SingleIntegrationHooks';
import MultipleIntegrationHooks from './MultipleIntegrationHooks';
export default {
components: {
NewHook,
SingleIntegrationHooks,
MultipleIntegrationHooks,
},
mixins: [alertMixin, hookMixin],
props: {
integrationId: {
type: [String, Number],
required: true,
},
},
data() {
return {
loading: {},
showAddHookModal: false,
showDeleteConfirmationPopup: false,
selectedHook: {},
alertMessage: '',
};
},
computed: {
...mapGetters({ uiFlags: 'integrations/getUIFlags' }),
integration() {
return this.$store.getters['integrations/getIntegration'](
this.integrationId
);
},
showIntegrationHooks() {
return !this.uiFlags.isFetching && !isEmptyObject(this.integration);
},
integrationType() {
return this.integration.allow_multiple_hooks ? 'multiple' : 'single';
},
isIntegrationMultiple() {
return this.integrationType === 'multiple';
},
isIntegrationSingle() {
return this.integrationType === 'single';
},
showAddButton() {
return this.showIntegrationHooks && this.isIntegrationMultiple;
},
deleteTitle() {
return this.isHookTypeInbox
? this.$t('INTEGRATION_APPS.DELETE.TITLE.INBOX')
: this.$t('INTEGRATION_APPS.DELETE.TITLE.ACCOUNT');
},
deleteMessage() {
return this.isHookTypeInbox
? this.$t('INTEGRATION_APPS.DELETE.MESSAGE.INBOX')
: this.$t('INTEGRATION_APPS.DELETE.MESSAGE.ACCOUNT');
},
confirmText() {
return this.isHookTypeInbox
? this.$t('INTEGRATION_APPS.DELETE.CONFIRM_BUTTON_TEXT.INBOX')
: this.$t('INTEGRATION_APPS.DELETE.CONFIRM_BUTTON_TEXT.ACCOUNT');
},
cancelText() {
return this.$t('INTEGRATION_APPS.DELETE.CANCEL_BUTTON_TEXT');
},
},
methods: {
openAddHookModal() {
this.showAddHookModal = true;
},
hideAddHookModal() {
this.showAddHookModal = false;
},
openDeletePopup(response) {
this.showDeleteConfirmationPopup = true;
this.selectedHook = response;
},
closeDeletePopup() {
this.showDeleteConfirmationPopup = false;
},
async confirmDeletion() {
try {
await this.$store.dispatch('integrations/deleteHook', {
hookId: this.selectedHook.id,
appId: this.selectedHook.app_id,
});
this.alertMessage = this.$t(
'INTEGRATION_APPS.DELETE.API.SUCCESS_MESSAGE'
);
this.closeDeletePopup();
} catch (error) {
const errorMessage = error?.response?.data?.message;
this.alertMessage =
errorMessage || this.$t('INTEGRATION_APPS.DELETE.API.ERROR_MESSAGE');
} finally {
this.showAlert(this.alertMessage);
}
},
},
};
</script>
<style scoped lang="scss">
.integration-hooks {
width: 100%;
}
</style>

View file

@ -0,0 +1,78 @@
<template>
<div class="row">
<div class="integration--image">
<img :src="'/dashboard/images/integrations/' + integrationLogo" />
</div>
<div class="column">
<h3 class="integration--title">
{{ integrationName }}
</h3>
<p class="integration--description">
{{ integrationDescription }}
</p>
</div>
<div class="small-2 column button-wrap">
<woot-label :title="labelText" :color-scheme="labelColor" />
</div>
<div class="small-2 column button-wrap">
<router-link
:to="
frontendURL(
`accounts/${accountId}/settings/applications/` + integrationId
)
"
>
<woot-button icon="ion-gear-b">
{{ $t('INTEGRATION_APPS.CONFIGURE') }}
</woot-button>
</router-link>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { frontendURL } from '../../../../helper/URLHelper';
import WootLabel from 'dashboard/components/ui/Label';
export default {
components: {
WootLabel,
},
props: {
integrationId: {
type: String,
default: '',
},
integrationLogo: {
type: String,
default: '',
},
integrationName: {
type: String,
default: '',
},
integrationDescription: {
type: String,
default: '',
},
integrationEnabled: {
type: Number,
default: 0,
},
},
computed: {
...mapGetters({ accountId: 'getCurrentAccountId' }),
labelText() {
return this.integrationEnabled
? this.$t('INTEGRATION_APPS.STATUS.ENABLED')
: this.$t('INTEGRATION_APPS.STATUS.DISABLED');
},
labelColor() {
return this.integrationEnabled ? 'success' : 'secondary';
},
},
methods: {
frontendURL,
},
};
</script>

View file

@ -0,0 +1,106 @@
<template>
<div class="row ">
<div class="small-8 columns">
<table v-if="hasConnectedHooks" class="woot-table">
<thead>
<th v-for="hookHeader in hookHeaders" :key="hookHeader">
{{ hookHeader }}
</th>
<th v-if="isHookTypeInbox">
{{ $t('INTEGRATION_APPS.LIST.INBOX') }}
</th>
</thead>
<tbody>
<tr v-for="hook in hooks" :key="hook.id">
<td
v-for="property in hook.properties"
:key="property"
class="hook-item"
>
{{ property }}
</td>
<td v-if="isHookTypeInbox" class="hook-item">
{{ inboxName(hook) }}
</td>
<td class="button-wrapper">
<woot-button
variant="link"
color-scheme="secondary"
icon="ion-close-circled"
class-names="grey-btn"
@click="$emit('delete', hook)"
>
{{ $t('INTEGRATION_APPS.LIST.DELETE.BUTTON_TEXT') }}
</woot-button>
</td>
</tr>
</tbody>
</table>
<p v-else class="no-items-error-message">
{{
$t('INTEGRATION_APPS.NO_HOOK_CONFIGURED', {
integrationId: integration.id,
})
}}
</p>
</div>
<div class="small-4 columns">
<p>
<b>{{ integration.name }}</b>
</p>
<p
v-html="
$t(
`INTEGRATION_APPS.SIDEBAR_DESCRIPTION.${integration.name.toUpperCase()}`,
{ installationName: globalConfig.installationName }
)
"
/>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import hookMixin from './hookMixin';
export default {
mixins: [hookMixin],
props: {
integration: {
type: Object,
default: () => ({}),
},
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
}),
hookHeaders() {
return this.integration.visible_properties;
},
hooks() {
if (!this.hasConnectedHooks) {
return [];
}
const { hooks } = this.integration;
return hooks.map(hook => ({
...hook,
id: hook.id,
properties: this.hookHeaders.map(property =>
hook.settings[property] ? hook.settings[property] : '--'
),
}));
},
},
mounted() {},
methods: {
inboxName(hook) {
return hook.inbox ? hook.inbox.name : '';
},
},
};
</script>
<style scoped lang="scss">
.hook-item {
word-break: break-word;
}
</style>

View file

@ -0,0 +1,136 @@
<template>
<div class="column content-box">
<woot-modal-header
:header-title="integration.name"
:header-content="integration.description"
/>
<formulate-form
#default="{ hasErrors }"
v-model="values"
@submit="submitForm"
>
<formulate-input
v-for="item in formItems"
:key="item.name"
v-bind="item"
/>
<formulate-input
v-if="isHookTypeInbox"
:options="inboxes"
type="select"
name="inbox"
:placeholder="$t('INTEGRATION_APPS.ADD.FORM.INBOX.LABEL')"
:label="$t('INTEGRATION_APPS.ADD.FORM.INBOX.PLACEHOLDER')"
validation="required"
validation-name="Inbox"
/>
<div class="modal-footer">
<woot-button :disabled="hasErrors" :loading="uiFlags.isCreatingHook">
{{ $t('INTEGRATION_APPS.ADD.FORM.SUBMIT') }}
</woot-button>
<woot-button class="button clear" @click.prevent="onClose">
{{ $t('INTEGRATION_APPS.ADD.FORM.CANCEL') }}
</woot-button>
</div>
</formulate-form>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import hookMixin from './hookMixin';
export default {
mixins: [alertMixin, hookMixin],
props: {
integration: {
type: Object,
default: () => ({}),
},
},
data() {
return {
endPoint: '',
alertMessage: '',
values: {},
};
},
computed: {
...mapGetters({
uiFlags: 'integrations/getUIFlags',
websiteInboxes: 'inboxes/getWebsiteInboxes',
}),
inboxes() {
return this.websiteInboxes
.filter(inbox => {
if (!this.isIntegrationDialogflow) {
return true;
}
return !this.connectedDialogflowInboxIds.includes(inbox.id);
})
.map(inbox => ({ label: inbox.name, value: inbox.id }));
},
connectedDialogflowInboxIds() {
if (!this.isIntegrationDialogflow) {
return [];
}
return this.integration.hooks.map(hook => hook.inbox?.id);
},
formItems() {
return this.integration.settings_form_schema;
},
isIntegrationDialogflow() {
return this.integration.id === 'dialogflow';
},
},
methods: {
onClose() {
this.$emit('close');
},
buildHookPayload() {
const hookPayload = {
app_id: this.integration.id,
settings: {},
};
hookPayload.settings = Object.keys(this.values).reduce((acc, key) => {
if (key !== 'inbox') {
acc[key] = this.values[key];
}
return acc;
}, {});
this.formItems.forEach(item => {
if (item.validation.includes('JSON')) {
hookPayload.settings[item.name] = JSON.parse(
hookPayload.settings[item.name]
);
}
});
if (this.isHookTypeInbox && this.values.inbox) {
hookPayload.inbox_id = this.values.inbox;
}
return hookPayload;
},
async submitForm() {
try {
await this.$store.dispatch(
'integrations/createHook',
this.buildHookPayload()
);
this.alertMessage = this.$t('INTEGRATION_APPS.ADD.API.SUCCESS_MESSAGE');
this.onClose();
} catch (error) {
const errorMessage = error?.response?.data?.message;
this.alertMessage =
errorMessage || this.$t('INTEGRATION_APPS.ADD.API.ERROR_MESSAGE');
} finally {
this.showAlert(this.alertMessage);
}
},
},
};
</script>

View file

@ -0,0 +1,47 @@
<template>
<div class="column content-box">
<div class="small-12 columns integrations-wrap">
<div class="small-12 columns integration">
<div class="row">
<div class="integration--image">
<img :src="'/dashboard/images/integrations/' + integration.logo" />
</div>
<div class="column">
<h3 class="integration--title">
{{ integration.name }}
</h3>
<p class="integration--description">
{{ integration.description }}
</p>
</div>
<div class="small-2 column button-wrap">
<div v-if="hasConnectedHooks">
<div @click="$emit('delete', integration.hooks[0])">
<woot-button class="nice alert">
{{ $t('INTEGRATION_APPS.DISCONNECT.BUTTON_TEXT') }}
</woot-button>
</div>
</div>
<div v-else>
<woot-button class="button nice" @click="$emit('add')">
{{ $t('INTEGRATION_APPS.CONNECT.BUTTON_TEXT') }}
</woot-button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import hookMixin from './hookMixin';
export default {
mixins: [hookMixin],
props: {
integration: {
type: Object,
default: () => ({}),
},
},
};
</script>

View file

@ -0,0 +1,10 @@
export default {
computed: {
isHookTypeInbox() {
return this.integration.hook_type === 'inbox';
},
hasConnectedHooks() {
return !!this.integration.hooks.length;
},
},
};

View file

@ -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,
}),
},
],
},
],
};

View file

@ -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);
});
});

View file

@ -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,
],
};

View file

@ -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 = {

View file

@ -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 {

View file

@ -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,

View file

@ -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 }],
]);
});
});

View file

@ -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: {

View file

@ -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: [],
},
]);
});
});
});

View file

@ -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',

View file

@ -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,
});

View file

@ -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

View file

@ -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

View file

@ -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']

View file

@ -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."

View file

@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

View file

@ -1081,6 +1081,21 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@braid/vue-formulate-i18n@^1.16.0":
version "1.16.0"
resolved "https://registry.yarnpkg.com/@braid/vue-formulate-i18n/-/vue-formulate-i18n-1.16.0.tgz#71c9121c909c7bbdfd4dfcf6a57d554f5bb1ec75"
integrity sha512-CuX1kN7bWg4ukynnTS3LGsmPKrUvS2Wh75zKatIe+nHQQy0gSvfmggQsCz4QZey7Ois+pGYmOIgrE1SvX+zQTw==
"@braid/vue-formulate@^2.5.2":
version "2.5.2"
resolved "https://registry.yarnpkg.com/@braid/vue-formulate/-/vue-formulate-2.5.2.tgz#32076d6a513760763a3a89dd69e8911c1bad1fcb"
integrity sha512-0LWKp3Rjq2a+WwA4y49VaXqQOVClJGy6mmdivxJJyRvNB0aUcTOKOINLumeU/XHf7/c0HNhtbZih2vs6hMDyvQ==
dependencies:
"@braid/vue-formulate-i18n" "^1.16.0"
is-plain-object "^3.0.1"
is-url "^1.2.4"
nanoid "^2.1.11"
"@chatwoot/prosemirror-schema@https://github.com/chatwoot/prosemirror-schema.git#7e8acadd10d7b932c0dc0bd0a18f804434f83517":
version "1.0.0"
resolved "https://github.com/chatwoot/prosemirror-schema.git#7e8acadd10d7b932c0dc0bd0a18f804434f83517"
@ -8366,7 +8381,7 @@ is-plain-obj@^2.0.0:
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==
is-plain-object@3.0.1:
is-plain-object@3.0.1, is-plain-object@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-3.0.1.tgz#662d92d24c0aa4302407b0d45d21f2251c85f85b"
integrity sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==
@ -8448,6 +8463,11 @@ is-unicode-supported@^0.1.0:
resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7"
integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==
is-url@^1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/is-url/-/is-url-1.2.4.tgz#04a4df46d28c4cff3d73d01ff06abeb318a1aa52"
integrity sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==
is-whitespace-character@^1.0.0:
version "1.0.4"
resolved "https://registry.yarnpkg.com/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz#0858edd94a95594c7c9dd0b5c174ec6e45ee4aa7"
@ -10116,6 +10136,11 @@ nan@^2.12.1:
resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.2.tgz#f5376400695168f4cc694ac9393d0c9585eeea19"
integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==
nanoid@^2.1.11:
version "2.1.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280"
integrity sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA==
nanoid@^3.1.22:
version "3.1.22"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.22.tgz#b35f8fb7d151990a8aebd5aa5015c03cf726f844"