diff --git a/app/javascript/dashboard/api/sla.js b/app/javascript/dashboard/api/sla.js
new file mode 100644
index 000000000..8480b1846
--- /dev/null
+++ b/app/javascript/dashboard/api/sla.js
@@ -0,0 +1,9 @@
+import ApiClient from './ApiClient';
+
+class SlaAPI extends ApiClient {
+ constructor() {
+ super('sla_policies', { accountScoped: true });
+ }
+}
+
+export default new SlaAPI();
diff --git a/app/javascript/dashboard/components/layout/config/sidebarItems/settings.js b/app/javascript/dashboard/components/layout/config/sidebarItems/settings.js
index 75f09e9da..444285657 100644
--- a/app/javascript/dashboard/components/layout/config/sidebarItems/settings.js
+++ b/app/javascript/dashboard/components/layout/config/sidebarItems/settings.js
@@ -39,6 +39,7 @@ const settings = accountId => ({
'settings_teams_finish',
'settings_teams_list',
'settings_teams_new',
+ 'sla_list',
],
menuItems: [
{
@@ -158,6 +159,15 @@ const settings = accountId => ({
featureFlag: FEATURE_FLAGS.AUDIT_LOGS,
beta: true,
},
+ {
+ icon: 'key',
+ label: 'SLA',
+ hasSubMenu: false,
+ toState: frontendURL(`accounts/${accountId}/settings/sla/list`),
+ toStateName: 'sla_list',
+ featureFlag: FEATURE_FLAGS.SLA,
+ beta: true,
+ },
],
});
diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js
index 9d06f15c6..2936d22ea 100644
--- a/app/javascript/dashboard/featureFlags.js
+++ b/app/javascript/dashboard/featureFlags.js
@@ -18,4 +18,5 @@ export const FEATURE_FLAGS = {
AUDIT_LOGS: 'audit_logs',
INSERT_ARTICLE_IN_REPLY: 'insert_article_in_reply',
INBOX_VIEW: 'inbox_view',
+ SLA: 'sla',
};
diff --git a/app/javascript/dashboard/helper/AnalyticsHelper/events.js b/app/javascript/dashboard/helper/AnalyticsHelper/events.js
index 59361e446..4b378c74a 100644
--- a/app/javascript/dashboard/helper/AnalyticsHelper/events.js
+++ b/app/javascript/dashboard/helper/AnalyticsHelper/events.js
@@ -111,3 +111,9 @@ export const INBOX_EVENTS = Object.freeze({
DELETE_NOTIFICATION: 'Deleted notification',
DELETE_ALL_NOTIFICATIONS: 'Deleted all notifications',
});
+
+export const SLA_EVENTS = Object.freeze({
+ CREATE: 'Created an SLA',
+ UPDATE: 'Updated an SLA',
+ DELETED: 'Deleted an SLA',
+});
diff --git a/app/javascript/dashboard/i18n/locale/en/index.js b/app/javascript/dashboard/i18n/locale/en/index.js
index 6cc73c5b6..75cdb5836 100644
--- a/app/javascript/dashboard/i18n/locale/en/index.js
+++ b/app/javascript/dashboard/i18n/locale/en/index.js
@@ -29,6 +29,7 @@ import settings from './settings.json';
import signup from './signup.json';
import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
+import sla from './sla.json';
import inbox from './inbox.json';
export default {
@@ -61,6 +62,7 @@ export default {
...setNewPassword,
...settings,
...signup,
+ ...sla,
...teamsSettings,
...whatsappTemplates,
...inbox,
diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json
index 6c4d8cbfe..d1d16a607 100644
--- a/app/javascript/dashboard/i18n/locale/en/settings.json
+++ b/app/javascript/dashboard/i18n/locale/en/settings.json
@@ -238,6 +238,7 @@
"REPORTS_INBOX": "Inbox",
"REPORTS_TEAM": "Team",
"SET_AVAILABILITY_TITLE": "Set yourself as",
+ "SLA": "SLA",
"BETA": "Beta",
"REPORTS_OVERVIEW": "Overview",
"FACEBOOK_REAUTHORIZE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services",
diff --git a/app/javascript/dashboard/i18n/locale/en/sla.json b/app/javascript/dashboard/i18n/locale/en/sla.json
new file mode 100644
index 000000000..67ec4549f
--- /dev/null
+++ b/app/javascript/dashboard/i18n/locale/en/sla.json
@@ -0,0 +1,63 @@
+{
+ "SLA": {
+ "HEADER": "SLA",
+ "HEADER_BTN_TXT": "Add SLA",
+ "LOADING": "Fetching SLAs",
+ "SEARCH_404": "There are no items matching this query",
+ "SIDEBAR_TXT": "
SLA
Think of Service Level Agreements (SLAs) like friendly promises between a service provider and a customer.
These promises set clear expectations for things like how quickly the team will respond to issues, making sure you always get a reliable and top-notch experience!
",
+ "LIST": {
+ "404": "There are no SLAs available in this account.",
+ "TITLE": "Manage SLA",
+ "DESC": "SLAs: Friendly promises for great service!",
+ "TABLE_HEADER": ["Name", "Description", "FRT", "NRT", "RT", "Business Hours"]
+ },
+ "FORM": {
+ "NAME": {
+ "LABEL": "SLA Name",
+ "PLACEHOLDER": "SLA Name",
+ "REQUIRED_ERROR": "SLA name is required",
+ "MINIMUM_LENGTH_ERROR": "Minimum length 2 is required",
+ "VALID_ERROR": "Only Alphabets, Numbers, Hyphen and Underscore are allowed"
+ },
+ "DESCRIPTION": {
+ "LABEL": "Description",
+ "PLACEHOLDER": "SLA for premium customers"
+ },
+ "FIRST_RESPONSE_TIME": {
+ "LABEL": "First Response Time(Seconds)",
+ "PLACEHOLDER": "300 for 5 minutes"
+ },
+ "NEXT_RESPONSE_TIME": {
+ "LABEL": "Next Response Time(Seconds)",
+ "PLACEHOLDER": "600 for 10 minutes"
+ },
+ "RESOLUTION_TIME": {
+ "LABEL": "Resolution Time(Seconds)",
+ "PLACEHOLDER": "86400 for 1 day"
+ },
+ "BUSINESS_HOURS": {
+ "LABEL": "Business Hours",
+ "PLACEHOLDER": "Only during business hours"
+ },
+ "EDIT": "Edit",
+ "CREATE": "Create",
+ "DELETE": "Delete",
+ "CANCEL": "Cancel"
+ },
+ "ADD": {
+ "TITLE": "Add SLA",
+ "DESC": "SLAs: Friendly promises for great service!",
+ "API": {
+ "SUCCESS_MESSAGE": "SLA added successfully",
+ "ERROR_MESSAGE": "There was an error, please try again"
+ }
+ },
+ "EDIT": {
+ "TITLE": "Edit SLA",
+ "API": {
+ "SUCCESS_MESSAGE": "SLA updated successfully",
+ "ERROR_MESSAGE": "There was an error, please try again"
+ }
+ }
+ }
+}
diff --git a/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js b/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js
index 48ef316ef..98b81f47c 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js
@@ -16,6 +16,7 @@ import macros from './macros/macros.routes';
import profile from './profile/profile.routes';
import reports from './reports/reports.routes';
import store from '../../../store';
+import sla from './sla/sla.routes';
import teams from './teams/teams.routes';
export default {
@@ -47,6 +48,7 @@ export default {
...macros.routes,
...profile.routes,
...reports.routes,
+ ...sla.routes,
...teams.routes,
],
};
diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/AddSLA.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/AddSLA.vue
new file mode 100644
index 000000000..772ec4bcd
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/sla/AddSLA.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/EditSLA.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/EditSLA.vue
new file mode 100644
index 000000000..2fc577bad
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/sla/EditSLA.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/Index.vue
new file mode 100644
index 000000000..473583f6d
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/sla/Index.vue
@@ -0,0 +1,135 @@
+
+
+
+ {{ $t('SLA.HEADER_BTN_TXT') }}
+
+
+
+
+ {{ $t('SLA.LIST.404') }}
+
+
+
+
+ |
+ {{ thHeader }}
+ |
+
+
+
+ |
+
+ {{ sla.name }}
+
+ |
+ {{ sla.description }} |
+
+
+ {{ sla.first_response_time_threshold }}
+
+ |
+
+
+ {{ sla.next_response_time_threshold }}
+
+ |
+
+
+ {{ sla.resolution_time_threshold }}
+
+ |
+
+
+ {{ sla.only_during_business_hours }}
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/SlaForm.vue b/app/javascript/dashboard/routes/dashboard/settings/sla/SlaForm.vue
new file mode 100644
index 000000000..775c0ff2a
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/sla/SlaForm.vue
@@ -0,0 +1,141 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/sla.routes.js b/app/javascript/dashboard/routes/dashboard/settings/sla/sla.routes.js
new file mode 100644
index 000000000..360c5a1f5
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/sla/sla.routes.js
@@ -0,0 +1,32 @@
+import { frontendURL } from '../../../../helper/URLHelper';
+
+const SettingsContent = () => import('../Wrapper.vue');
+const Index = () => import('./Index.vue');
+
+export default {
+ routes: [
+ {
+ path: frontendURL('accounts/:accountId/settings/sla'),
+ component: SettingsContent,
+ props: {
+ headerTitle: 'SLA.HEADER',
+ icon: 'tag',
+ showNewButton: true,
+ },
+ children: [
+ {
+ path: '',
+ name: 'sla_wrapper',
+ roles: ['administrator'],
+ redirect: 'list',
+ },
+ {
+ path: 'list',
+ name: 'sla_list',
+ roles: ['administrator'],
+ component: Index,
+ },
+ ],
+ },
+ ],
+};
diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/specs/validationMixin.spec.js b/app/javascript/dashboard/routes/dashboard/settings/sla/specs/validationMixin.spec.js
new file mode 100644
index 000000000..a828c8556
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/sla/specs/validationMixin.spec.js
@@ -0,0 +1,65 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import VueI18n from 'vue-i18n';
+import Vuelidate from 'vuelidate';
+
+import validationMixin from '../validationMixin';
+import validations from '../validations';
+import i18n from 'dashboard/i18n';
+
+const localVue = createLocalVue();
+localVue.use(VueI18n);
+localVue.use(Vuelidate);
+
+const i18nConfig = new VueI18n({
+ locale: 'en',
+ messages: i18n,
+});
+
+const TestComponent = {
+ render() {},
+ mixins: [validationMixin],
+ validations,
+};
+
+describe('validationMixin', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = shallowMount(TestComponent, {
+ localVue,
+ i18n: i18nConfig,
+ data() {
+ return {
+ name: '',
+ };
+ },
+ });
+ });
+
+ it('should not return required error message if name is empty but not touched', () => {
+ wrapper.setData({ name: '' });
+ expect(wrapper.vm.getSlaNameErrorMessage).toBe('');
+ });
+
+ it('should return empty error message if name is valid', () => {
+ wrapper.setData({ name: 'ValidName' });
+ wrapper.vm.$v.name.$touch();
+ expect(wrapper.vm.getSlaNameErrorMessage).toBe('');
+ });
+
+ it('should return required error message if name is empty', () => {
+ wrapper.setData({ name: '' });
+ wrapper.vm.$v.name.$touch();
+ expect(wrapper.vm.getSlaNameErrorMessage).toBe(
+ wrapper.vm.$t('SLA.FORM.NAME.REQUIRED_ERROR')
+ );
+ });
+
+ it('should return minimum length error message if name is too short', () => {
+ wrapper.setData({ name: 'a' });
+ wrapper.vm.$v.name.$touch();
+ expect(wrapper.vm.getSlaNameErrorMessage).toBe(
+ wrapper.vm.$t('SLA.FORM.NAME.MINIMUM_LENGTH_ERROR')
+ );
+ });
+});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/validationMixin.js b/app/javascript/dashboard/routes/dashboard/settings/sla/validationMixin.js
new file mode 100644
index 000000000..28f7436c8
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/sla/validationMixin.js
@@ -0,0 +1,15 @@
+export default {
+ computed: {
+ getSlaNameErrorMessage() {
+ let errorMessage = '';
+ if (this.$v.name.$error) {
+ if (!this.$v.name.required) {
+ errorMessage = this.$t('SLA.FORM.NAME.REQUIRED_ERROR');
+ } else if (!this.$v.name.minLength) {
+ errorMessage = this.$t('SLA.FORM.NAME.MINIMUM_LENGTH_ERROR');
+ }
+ }
+ return errorMessage;
+ },
+ },
+};
diff --git a/app/javascript/dashboard/routes/dashboard/settings/sla/validations.js b/app/javascript/dashboard/routes/dashboard/settings/sla/validations.js
new file mode 100644
index 000000000..bda4b9556
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/sla/validations.js
@@ -0,0 +1,8 @@
+import { required, minLength } from 'vuelidate/lib/validators';
+
+export default {
+ name: {
+ required,
+ minLength: minLength(2),
+ },
+};
diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js
index 2d1564b79..91b84ddd1 100755
--- a/app/javascript/dashboard/store/index.js
+++ b/app/javascript/dashboard/store/index.js
@@ -38,6 +38,7 @@ import macros from './modules/macros';
import notifications from './modules/notifications';
import portals from './modules/helpCenterPortals';
import reports from './modules/reports';
+import sla from './modules/sla';
import teamMembers from './modules/teamMembers';
import teams from './modules/teams';
import userNotificationSettings from './modules/userNotificationSettings';
@@ -109,6 +110,7 @@ export default new Vuex.Store({
userNotificationSettings,
webhooks,
draftMessages,
+ sla,
},
plugins,
});
diff --git a/app/javascript/dashboard/store/modules/sla.js b/app/javascript/dashboard/store/modules/sla.js
new file mode 100644
index 000000000..e1ded7f51
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/sla.js
@@ -0,0 +1,86 @@
+import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
+import types from '../mutation-types';
+import SlaAPI from '../../api/sla';
+import AnalyticsHelper from '../../helper/AnalyticsHelper';
+import { SLA_EVENTS } from '../../helper/AnalyticsHelper/events';
+import { throwErrorMessage } from '../utils/api';
+
+export const state = {
+ records: [],
+ uiFlags: {
+ isFetching: false,
+ isFetchingItem: false,
+ isCreating: false,
+ isDeleting: false,
+ },
+};
+
+export const getters = {
+ getSLA(_state) {
+ return _state.records;
+ },
+ getUIFlags(_state) {
+ return _state.uiFlags;
+ },
+};
+
+export const actions = {
+ get: async function get({ commit }) {
+ commit(types.SET_SLA_UI_FLAG, { isFetching: true });
+ try {
+ const response = await SlaAPI.get();
+ commit(types.SET_SLA, response.data.payload);
+ } catch (error) {
+ // Ignore error
+ } finally {
+ commit(types.SET_SLA_UI_FLAG, { isFetching: false });
+ }
+ },
+
+ create: async function create({ commit }, slaObj) {
+ commit(types.SET_SLA_UI_FLAG, { isCreating: true });
+ try {
+ const response = await SlaAPI.create(slaObj);
+ AnalyticsHelper.track(SLA_EVENTS.CREATE);
+ commit(types.ADD_SLA, response.data.payload);
+ } catch (error) {
+ throwErrorMessage(error);
+ } finally {
+ commit(types.SET_SLA_UI_FLAG, { isCreating: false });
+ }
+ },
+
+ update: async function update({ commit }, { id, ...updateObj }) {
+ commit(types.SET_SLA_UI_FLAG, { isUpdating: true });
+ try {
+ const response = await SlaAPI.update(id, updateObj);
+ AnalyticsHelper.track(SLA_EVENTS.UPDATE);
+ commit(types.EDIT_SLA, response.data.payload);
+ } catch (error) {
+ throwErrorMessage(error);
+ } finally {
+ commit(types.SET_SLA_UI_FLAG, { isUpdating: false });
+ }
+ },
+};
+
+export const mutations = {
+ [types.SET_SLA_UI_FLAG](_state, data) {
+ _state.uiFlags = {
+ ..._state.uiFlags,
+ ...data,
+ };
+ },
+
+ [types.SET_SLA]: MutationHelpers.set,
+ [types.ADD_SLA]: MutationHelpers.create,
+ [types.EDIT_SLA]: MutationHelpers.update,
+};
+
+export default {
+ namespaced: true,
+ state,
+ getters,
+ actions,
+ mutations,
+};
diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js
index 0d4f0c010..c9d2d1767 100644
--- a/app/javascript/dashboard/store/mutation-types.js
+++ b/app/javascript/dashboard/store/mutation-types.js
@@ -301,4 +301,11 @@ export default {
SET_AUDIT_LOGS_UI_FLAG: 'SET_AUDIT_LOGS_UI_FLAG',
SET_AUDIT_LOGS: 'SET_AUDIT_LOGS',
SET_AUDIT_LOGS_META: 'SET_AUDIT_LOGS_META',
+
+ // SLA
+ SET_SLA_UI_FLAG: 'SET_SLA_UI_FLAG',
+ SET_SLA: 'SET_SLA',
+ ADD_SLA: 'ADD_SLA',
+ EDIT_SLA: 'EDIT_SLA',
+ DELETE_SLA: 'DELETE_SLA',
};
diff --git a/config/features.yml b/config/features.yml
index 529ad37d9..f0477c297 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -66,3 +66,6 @@
enabled: false
- name: inbox_view
enabled: false
+- name: sla
+ enabled: false
+ premium: true
diff --git a/enterprise/config/premium_features.yml b/enterprise/config/premium_features.yml
index 9628e1da4..18e5b15ba 100644
--- a/enterprise/config/premium_features.yml
+++ b/enterprise/config/premium_features.yml
@@ -2,3 +2,4 @@
- disable_branding
- audit_logs
- response_bot
+- sla