feat: Add the ability to create dashboard apps from the UI (#4924)

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
Fayaz Ahmed
2022-07-08 14:25:32 +05:30
committed by GitHub
parent e4b159dd54
commit ef1d117717
14 changed files with 713 additions and 64 deletions

View File

@@ -18,6 +18,7 @@ const settings = accountId => ({
'settings_integrations_webhook',
'settings_integrations_integration',
'settings_applications',
'settings_integrations_dashboard_apps',
'settings_applications_webhook',
'settings_applications_integration',
'general_settings',

View File

@@ -35,7 +35,10 @@
"LIST": {
"404": "There are no webhooks configured for this account.",
"TITLE": "Manage webhooks",
"TABLE_HEADER": ["Webhook endpoint", "Actions"]
"TABLE_HEADER": [
"Webhook endpoint",
"Actions"
]
},
"EDIT": {
"BUTTON_TEXT": "Edit",
@@ -68,7 +71,7 @@
}
},
"SLACK": {
"HELP_TEXT" : {
"HELP_TEXT": {
"TITLE": "Using Slack Integration",
"BODY": "<br/><p>Chatwoot will now sync all the incoming conversations into the <b><i>customer-conversations</i></b> channel inside your slack workplace.</p><p>Replying to a conversation thread in <b><i>customer-conversations</i></b> slack channel will create a response back to the customer through chatwoot.</p><p>Start the replies with <b><i>note:</i></b> to create private notes instead of replies.</p><p>If the replier on slack has an agent profile in chatwoot under the same email, the replies will be associated accordingly.</p><p>When the replier doesn't have an associated agent profile, the replies will be made from the bot profile.</p>"
}
@@ -81,6 +84,52 @@
},
"CONNECT": {
"BUTTON_TEXT": "Connect"
},
"DASHBOARD_APPS": {
"TITLE": "Dashboard Apps",
"HEADER_BTN_TXT": "Add a new dashboard app",
"SIDEBAR_TXT": "<p><b>Dashboard Apps</b></p><p>Dashboard Apps allow organizations to embed an application inside the Chatwoot dashboard to provide the context for customer support agents. This feature allows you to create an application independently and embed that inside the dashboard to provide user information, their orders, or their previous payment history.</p><p>When you embed your application using the dashboard in Chatwoot, your application will get the context of the conversation and contact as a window event. Implement a listener for the message event on your page to receive the context.</p><p>To add a new dashboard app, click on the button 'Add a new dashboard app'.</p>",
"DESCRIPTION": "Dashboard Apps allow organizations to embed an application inside the dashboard to provide the context for customer support agents. This feature allows you to create an application independently and embed that to provide user information, their orders, or their previous payment history.",
"LIST": {
"404": "There are no dashboard apps configured on this account yet",
"LOADING": "Fetching dashboard apps...",
"TABLE_HEADER": [
"Name",
"Endpoint"
],
"EDIT_TOOLTIP": "Edit app",
"DELETE_TOOLTIP": "Delete app"
},
"FORM": {
"TITLE_LABEL": "Name",
"TITLE_PLACEHOLDER": "Enter a name for your dashboard app",
"TITLE_ERROR": "A name for the dashboard app is required",
"URL_LABEL": "Endpoint",
"URL_PLACEHOLDER": "Enter the endpoint URL where your app is hosted",
"URL_ERROR": "A valid URL is required"
},
"CREATE": {
"HEADER": "Add a new dashboard app",
"FORM_SUBMIT": "Submit",
"FORM_CANCEL": "Cancel",
"API_SUCCESS": "Dashboard app configured successfully",
"API_ERROR": "We couldn't create an app. Please try again later"
},
"UPDATE": {
"HEADER": "Edit dashboard app",
"FORM_SUBMIT": "Update",
"FORM_CANCEL": "Cancel",
"API_SUCCESS": "Dashboard app updated successfully",
"API_ERROR": "We couldn't update the app. Please try again later"
},
"DELETE": {
"CONFIRM_YES": "Yes, delete it",
"CONFIRM_NO": "No, keep it",
"TITLE": "Confirm deletion",
"MESSAGE": "Are you sure to delete the app - %{appName}?",
"API_SUCCESS": "Dashboard app deleted successfully",
"API_ERROR": "We couldn't delete the app. Please try again later"
}
}
}
}

View File

@@ -0,0 +1,174 @@
<template>
<woot-modal :show="show" :on-close="closeModal">
<div class="column content-box">
<woot-modal-header :header-title="header" />
<form class="row" @submit.prevent="submit">
<woot-input
v-model.trim="app.title"
:class="{ error: $v.app.title.$error }"
class="medium-12 columns"
:label="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.FORM.TITLE_LABEL')"
:placeholder="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.FORM.TITLE_PLACEHOLDER')
"
:error="
$v.app.title.$error
? $t('INTEGRATION_SETTINGS.DASHBOARD_APPS.FORM.TITLE_ERROR')
: null
"
data-testid="app-title"
@input="$v.app.title.$touch"
/>
<woot-input
v-model.trim="app.content.url"
:class="{ error: $v.app.content.url.$error }"
class="medium-12 columns app--url_input"
:label="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.FORM.URL_LABEL')"
:placeholder="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.FORM.URL_PLACEHOLDER')
"
:error="
$v.app.content.url.$error
? $t('INTEGRATION_SETTINGS.DASHBOARD_APPS.FORM.URL_ERROR')
: null
"
data-testid="app-url"
@input="$v.app.content.url.$touch"
/>
<div class="modal-footer">
<div class="medium-12 columns">
<woot-button
:is-loading="isLoading"
:is-disabled="$v.$invalid"
data-testid="label-submit"
>
{{ submitButtonLabel }}
</woot-button>
<woot-button class="button clear" @click.prevent="closeModal">
{{ $t('INTEGRATION_SETTINGS.DASHBOARD_APPS.CREATE.FORM_CANCEL') }}
</woot-button>
</div>
</div>
</form>
</div>
</woot-modal>
</template>
<script>
import { required, url } from 'vuelidate/lib/validators';
import alertMixin from 'shared/mixins/alertMixin';
export default {
mixins: [alertMixin],
props: {
show: {
type: Boolean,
default: false,
},
mode: {
type: String,
default: 'create',
},
selectedAppData: {
type: Object,
default: () => ({}),
},
},
validations: {
app: {
title: { required },
content: {
type: { required },
url: { required, url },
},
},
},
data() {
return {
isLoading: false,
app: {
title: '',
content: {
type: 'frame',
url: '',
},
},
};
},
computed: {
header() {
return this.$t(`INTEGRATION_SETTINGS.DASHBOARD_APPS.${this.mode}.HEADER`);
},
submitButtonLabel() {
return this.$t(
`INTEGRATION_SETTINGS.DASHBOARD_APPS.${this.mode}.FORM_SUBMIT`
);
},
},
mounted() {
if (this.mode === 'UPDATE' && this.selectedAppData) {
this.app.title = this.selectedAppData.title;
this.app.content = this.selectedAppData.content[0];
}
},
methods: {
closeModal() {
// Reset the data once closed
this.app = {
title: '',
content: { type: 'frame', url: '' },
};
this.$emit('close');
},
async submit() {
try {
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
const action = this.mode.toLowerCase();
const payload = {
title: this.app.title,
content: [this.app.content],
};
if (action === 'update') {
payload.id = this.selectedAppData.id;
}
this.isLoading = true;
await this.$store.dispatch(`dashboardApps/${action}`, payload);
this.showAlert(
this.$t(
`INTEGRATION_SETTINGS.DASHBOARD_APPS.${this.mode}.API_SUCCESS`
)
);
this.closeModal();
} catch (err) {
this.showAlert(
this.$t(`INTEGRATION_SETTINGS.DASHBOARD_APPS.${this.mode}.API_ERROR`)
);
} finally {
this.isLoading = false;
}
},
},
};
</script>
<style scoped lang="scss">
.content-row {
display: flex;
align-items: center;
width: 100%;
.app--url_input {
flex: 1;
}
.app--url_add_btn {
margin-left: var(--space-one);
margin-top: var(--space-one);
}
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<tr>
<td>{{ app.title }}</td>
<td>{{ app.content[0].url }}</td>
<td class="button-wrapper">
<woot-button
v-tooltip.top="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.EDIT_TOOLTIP')
"
variant="smooth"
size="tiny"
color-scheme="secondary"
class-names="grey-btn"
icon="edit"
@click="$emit('edit', app)"
/>
<woot-button
v-tooltip.top="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.DELETE_TOOLTIP')
"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="dismiss-circle"
class-names="grey-btn"
@click="$emit('delete', app)"
/>
</td>
</tr>
</template>
<script>
export default {
props: {
app: {
type: Object,
default: () => ({}),
},
},
loading: {
type: Boolean,
default: false,
},
};
</script>

View File

@@ -0,0 +1,158 @@
<template>
<div class="row content-box full-height">
<woot-button
color-scheme="success"
class-names="button--fixed-right-top"
icon="add-circle"
@click="openCreatePopup"
>
{{ $t('INTEGRATION_SETTINGS.DASHBOARD_APPS.HEADER_BTN_TXT') }}
</woot-button>
<div class="row">
<div class="small-8 columns with-right-space ">
<p
v-if="!uiFlags.isFetching && !records.length"
class="no-items-error-message"
>
{{ $t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.404') }}
</p>
<woot-loading-state
v-if="uiFlags.isFetching"
:message="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.LOADING')"
/>
<table v-if="!uiFlags.isFetching && records.length" class="woot-table">
<thead>
<th
v-for="thHeader in $t(
'INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.TABLE_HEADER'
)"
:key="thHeader"
>
{{ thHeader }}
</th>
</thead>
<tbody>
<dashboard-apps-row
v-for="(dashboardAppItem, index) in records"
:key="dashboardAppItem.id"
:index="index"
:app="dashboardAppItem"
@edit="editApp"
@delete="openDeletePopup"
/>
</tbody>
</table>
</div>
<div class="small-4 columns">
<span
v-dompurify-html="
useInstallationName(
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.SIDEBAR_TXT'),
globalConfig.installationName
)
"
/>
</div>
</div>
<dashboard-app-modal
v-if="showDashboardAppPopup"
:show="showDashboardAppPopup"
:mode="mode"
:selected-app-data="selectedApp"
@close="toggleDashboardAppPopup"
/>
<woot-delete-modal
:show.sync="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
:title="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.DELETE.TITLE')"
:message="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.DELETE.MESSAGE', {
appName: selectedApp.title,
})
"
:confirm-text="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.DELETE.CONFIRM_YES')
"
:reject-text="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.DELETE.CONFIRM_NO')"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import DashboardAppModal from './DashboardAppModal.vue';
import DashboardAppsRow from './DashboardAppsRow.vue';
import alertMixin from 'shared/mixins/alertMixin';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
export default {
components: {
DashboardAppModal,
DashboardAppsRow,
},
mixins: [alertMixin, globalConfigMixin],
data() {
return {
loading: {},
showDashboardAppPopup: false,
showDeleteConfirmationPopup: false,
selectedApp: {},
mode: 'CREATE',
};
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
records: 'dashboardApps/getRecords',
uiFlags: 'dashboardApps/getUIFlags',
}),
},
mounted() {
this.$store.dispatch('dashboardApps/get');
},
methods: {
toggleDashboardAppPopup() {
this.showDashboardAppPopup = !this.showDashboardAppPopup;
this.selectedApp = {};
},
openDeletePopup(response) {
this.showDeleteConfirmationPopup = true;
this.selectedApp = response;
},
openCreatePopup() {
this.mode = 'CREATE';
this.selectedApp = {};
this.showDashboardAppPopup = true;
},
closeDeletePopup() {
this.showDeleteConfirmationPopup = false;
},
editApp(app) {
this.loading[app.id] = true;
this.mode = 'UPDATE';
this.selectedApp = app;
this.showDashboardAppPopup = true;
},
confirmDeletion() {
this.loading[this.selectedApp.id] = true;
this.closeDeletePopup();
this.deleteApp(this.selectedApp.id);
},
async deleteApp(id) {
try {
await this.$store.dispatch('dashboardApps/delete', id);
this.showAlert(
this.$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.DELETE.API_SUCCESS')
);
} catch (error) {
this.showAlert(
this.$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.DELETE.API_ERROR')
);
}
},
},
};
</script>

View File

@@ -17,6 +17,20 @@
:integration-action="item.action"
/>
</div>
<div class="small-12 columns integration">
<integration
integration-id="dashboard-apps"
integration-logo="dashboard-apps.svg"
:integration-name="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.TITLE')
"
:integration-description="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.DESCRIPTION')
"
integration-enabled
integration-action="/dashboard-apps"
/>
</div>
</div>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import Index from './Index';
import SettingsContent from '../Wrapper';
import Webhook from './Webhooks/Index';
import DashboardApps from './DashboardApps/Index';
import ShowIntegration from './ShowIntegration';
import { frontendURL } from '../../../../helper/URLHelper';
@@ -35,6 +36,12 @@ export default {
name: 'settings_integrations_webhook',
roles: ['administrator'],
},
{
path: 'dashboard-apps',
component: DashboardApps,
name: 'settings_integrations_dashboard_apps',
roles: ['administrator'],
},
{
path: ':integration_id',
name: 'settings_integrations_integration',

View File

@@ -32,6 +32,40 @@ export const actions = {
commit(types.SET_DASHBOARD_APPS_UI_FLAG, { isFetching: false });
}
},
create: async function createApp({ commit }, appObj) {
commit(types.SET_DASHBOARD_APPS_UI_FLAG, { isCreating: true });
try {
const response = await DashboardAppsAPI.create(appObj);
commit(types.CREATE_DASHBOARD_APP, response.data);
} catch (error) {
const errorMessage = error?.response?.data?.message;
throw new Error(errorMessage);
} finally {
commit(types.SET_DASHBOARD_APPS_UI_FLAG, { isCreating: false });
}
},
update: async function updateApp({ commit }, { id, ...updateObj }) {
commit(types.SET_DASHBOARD_APPS_UI_FLAG, { isUpdating: true });
try {
const response = await DashboardAppsAPI.update(id, updateObj);
commit(types.EDIT_DASHBOARD_APP, response.data);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_DASHBOARD_APPS_UI_FLAG, { isUpdating: false });
}
},
delete: async function deleteApp({ commit }, id) {
commit(types.SET_DASHBOARD_APPS_UI_FLAG, { isDeleting: true });
try {
await DashboardAppsAPI.delete(id);
commit(types.DELETE_DASHBOARD_APP, id);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_DASHBOARD_APPS_UI_FLAG, { isDeleting: false });
}
},
};
export const mutations = {
@@ -43,6 +77,9 @@ export const mutations = {
},
[types.SET_DASHBOARD_APPS]: MutationHelpers.set,
[types.CREATE_DASHBOARD_APP]: MutationHelpers.create,
[types.EDIT_DASHBOARD_APP]: MutationHelpers.update,
[types.DELETE_DASHBOARD_APP]: MutationHelpers.destroy,
};
export default {

View File

@@ -1,7 +1,7 @@
import axios from 'axios';
import { actions } from '../../dashboardApps';
import types from '../../../mutation-types';
import { payload, automationsList } from './fixtures';
const commit = jest.fn();
global.axios = axios;
jest.mock('axios');
@@ -18,4 +18,68 @@ describe('#actions', () => {
]);
});
});
describe('#create', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: payload });
await actions.create({ commit }, payload);
expect(commit.mock.calls).toEqual([
[types.SET_DASHBOARD_APPS_UI_FLAG, { isCreating: true }],
[types.CREATE_DASHBOARD_APP, payload],
[types.SET_DASHBOARD_APPS_UI_FLAG, { isCreating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.create({ commit })).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_DASHBOARD_APPS_UI_FLAG, { isCreating: true }],
[types.SET_DASHBOARD_APPS_UI_FLAG, { isCreating: false }],
]);
});
});
describe('#update', () => {
it('sends correct actions if API is success', async () => {
axios.patch.mockResolvedValue({ data: automationsList[0] });
await actions.update({ commit }, automationsList[0]);
expect(commit.mock.calls).toEqual([
[types.SET_DASHBOARD_APPS_UI_FLAG, { isUpdating: true }],
[types.EDIT_DASHBOARD_APP, automationsList[0]],
[types.SET_DASHBOARD_APPS_UI_FLAG, { isUpdating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.update({ commit }, automationsList[0])
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_DASHBOARD_APPS_UI_FLAG, { isUpdating: true }],
[types.SET_DASHBOARD_APPS_UI_FLAG, { isUpdating: false }],
]);
});
});
describe('#delete', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({ data: automationsList[0] });
await actions.delete({ commit }, automationsList[0].id);
expect(commit.mock.calls).toEqual([
[types.SET_DASHBOARD_APPS_UI_FLAG, { isDeleting: true }],
[types.DELETE_DASHBOARD_APP, automationsList[0].id],
[types.SET_DASHBOARD_APPS_UI_FLAG, { isDeleting: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.delete({ commit }, automationsList[0].id)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_DASHBOARD_APPS_UI_FLAG, { isDeleting: true }],
[types.SET_DASHBOARD_APPS_UI_FLAG, { isDeleting: false }],
]);
});
});
});

View File

@@ -0,0 +1,19 @@
export const payload = {
title: 'Test',
content: [
{ url: 'https://example.com', type: 'frame' },
{ url: 'https://chatwoot.com', type: 'frame' },
],
};
export const automationsList = [
{
id: 15,
title: 'Test',
content: [
{ url: 'https://example.com', type: 'frame' },
{ url: 'https://chatwoot.com', type: 'frame' },
],
created_at: '2022-06-27T08:28:29.841Z',
},
];

View File

@@ -1,5 +1,6 @@
import types from '../../../mutation-types';
import { mutations } from '../../dashboardApps';
import { automationsList } from './fixtures';
describe('#mutations', () => {
describe('#SET_DASHBOARD_APPS_UI_FLAG', () => {
@@ -17,4 +18,31 @@ describe('#mutations', () => {
expect(state.records).toEqual([{ title: 'Title 1' }]);
});
});
describe('#ADD_DASHBOARD_APP', () => {
it('push newly created app to the store', () => {
const state = { records: [automationsList[0]] };
mutations[types.CREATE_DASHBOARD_APP](state, automationsList[1]);
expect(state.records).toEqual([automationsList[0], automationsList[1]]);
});
});
describe('#EDIT_DASHBOARD_APP', () => {
it('update label record', () => {
const state = { records: [automationsList[0]] };
mutations[types.EDIT_DASHBOARD_APP](state, {
id: 15,
title: 'updated-title',
});
expect(state.records[0].title).toEqual('updated-title');
});
});
describe('#DELETE_DASHBOARD_APP', () => {
it('delete label record', () => {
const state = { records: [automationsList[0]] };
mutations[types.DELETE_DASHBOARD_APP](state, 15);
expect(state.records).toEqual([]);
});
});
});

View File

@@ -217,4 +217,7 @@ export default {
// Dashboard Apps
SET_DASHBOARD_APPS_UI_FLAG: 'SET_DASHBOARD_APPS_UI_FLAG',
SET_DASHBOARD_APPS: 'SET_DASHBOARD_APPS',
CREATE_DASHBOARD_APP: 'CREATE_DASHBOARD_APP',
EDIT_DASHBOARD_APP: 'EDIT_DASHBOARD_APP',
DELETE_DASHBOARD_APP: 'DELETE_DASHBOARD_APP',
};