feat(ee): Add SLA management UI (#8777)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
@@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="h-auto overflow-auto flex flex-col">
|
||||
<woot-modal-header
|
||||
:header-title="$t('SLA.ADD.TITLE')"
|
||||
:header-content="$t('SLA.ADD.DESC')"
|
||||
/>
|
||||
<sla-form
|
||||
:submit-label="$t('SLA.FORM.CREATE')"
|
||||
@submit="addSLA"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import validationMixin from './validationMixin';
|
||||
import { mapGetters } from 'vuex';
|
||||
import validations from './validations';
|
||||
import SlaForm from './SlaForm.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SlaForm,
|
||||
},
|
||||
mixins: [alertMixin, validationMixin],
|
||||
validations,
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'sla/getUIFlags',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
async addSLA(payload) {
|
||||
try {
|
||||
await this.$store.dispatch('sla/create', payload);
|
||||
this.showAlert(this.$t('SLA.ADD.API.SUCCESS_MESSAGE'));
|
||||
this.onClose();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error.message || this.$t('SLA.ADD.API.ERROR_MESSAGE');
|
||||
this.showAlert(errorMessage);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="h-auto overflow-auto flex flex-col">
|
||||
<woot-modal-header :header-title="pageTitle" />
|
||||
<sla-form
|
||||
:submit-label="$t('SLA.FORM.EDIT')"
|
||||
:selected-response="selectedResponse"
|
||||
@submit="editSLA"
|
||||
@close="onClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import validationMixin from './validationMixin';
|
||||
import validations from './validations';
|
||||
import SlaForm from './SlaForm.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SlaForm,
|
||||
},
|
||||
mixins: [alertMixin, validationMixin],
|
||||
props: {
|
||||
selectedResponse: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
validations,
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'sla/getUIFlags',
|
||||
}),
|
||||
pageTitle() {
|
||||
return `${this.$t('SLA.EDIT.TITLE')} - ${this.selectedResponse.name}`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
async editSLA(payload) {
|
||||
try {
|
||||
await this.$store.dispatch('sla/update', {
|
||||
id: this.selectedResponse.id,
|
||||
...payload,
|
||||
});
|
||||
this.showAlert(this.$t('SLA.EDIT.API.SUCCESS_MESSAGE'));
|
||||
this.onClose();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error.message || this.$t('SLA.EDIT.API.ERROR_MESSAGE');
|
||||
this.showAlert(errorMessage);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
135
app/javascript/dashboard/routes/dashboard/settings/sla/Index.vue
Normal file
135
app/javascript/dashboard/routes/dashboard/settings/sla/Index.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
<woot-button
|
||||
color-scheme="success"
|
||||
class-names="button--fixed-top"
|
||||
icon="add-circle"
|
||||
@click="openAddPopup"
|
||||
>
|
||||
{{ $t('SLA.HEADER_BTN_TXT') }}
|
||||
</woot-button>
|
||||
<div class="flex flex-row gap-4">
|
||||
<div class="w-[60%]">
|
||||
<p
|
||||
v-if="!uiFlags.isFetching && !records.length"
|
||||
class="flex h-full items-center flex-col justify-center"
|
||||
>
|
||||
{{ $t('SLA.LIST.404') }}
|
||||
</p>
|
||||
<woot-loading-state
|
||||
v-if="uiFlags.isFetching"
|
||||
:message="$t('SLA.LOADING')"
|
||||
/>
|
||||
<table v-if="!uiFlags.isFetching && records.length" class="woot-table">
|
||||
<thead>
|
||||
<th v-for="thHeader in $t('SLA.LIST.TABLE_HEADER')" :key="thHeader">
|
||||
{{ thHeader }}
|
||||
</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="sla in records" :key="sla.title">
|
||||
<td>
|
||||
<span
|
||||
class="inline-block overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ sla.name }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ sla.description }}</td>
|
||||
<td>
|
||||
<span class="flex items-center">
|
||||
{{ sla.first_response_time_threshold }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="flex items-center">
|
||||
{{ sla.next_response_time_threshold }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="flex items-center">
|
||||
{{ sla.resolution_time_threshold }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="flex items-center">
|
||||
{{ sla.only_during_business_hours }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="button-wrapper">
|
||||
<woot-button
|
||||
v-tooltip.top="$t('SLA.FORM.EDIT')"
|
||||
variant="smooth"
|
||||
size="tiny"
|
||||
color-scheme="secondary"
|
||||
class-names="grey-btn"
|
||||
:is-loading="loading[sla.id]"
|
||||
icon="edit"
|
||||
@click="openEditPopup(sla)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="w-[34%]">
|
||||
<span v-dompurify-html="$t('SLA.SIDEBAR_TXT')" />
|
||||
</div>
|
||||
</div>
|
||||
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup">
|
||||
<add-SLA @close="hideAddPopup" />
|
||||
</woot-modal>
|
||||
|
||||
<woot-modal :show.sync="showEditPopup" :on-close="hideEditPopup">
|
||||
<edit-SLA :selected-response="selectedResponse" @close="hideEditPopup" />
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
import AddSLA from './AddSLA.vue';
|
||||
import EditSLA from './EditSLA.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AddSLA,
|
||||
EditSLA,
|
||||
},
|
||||
mixins: [alertMixin],
|
||||
data() {
|
||||
return {
|
||||
loading: {},
|
||||
showAddPopup: false,
|
||||
showEditPopup: false,
|
||||
selectedResponse: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
records: 'sla/getSLA',
|
||||
uiFlags: 'sla/getUIFlags',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('sla/get');
|
||||
},
|
||||
methods: {
|
||||
openAddPopup() {
|
||||
this.showAddPopup = true;
|
||||
},
|
||||
hideAddPopup() {
|
||||
this.showAddPopup = false;
|
||||
},
|
||||
openEditPopup(response) {
|
||||
this.showEditPopup = true;
|
||||
this.selectedResponse = response;
|
||||
},
|
||||
hideEditPopup() {
|
||||
this.showEditPopup = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="h-auto overflow-auto flex flex-col">
|
||||
<form class="mx-0 flex flex-wrap" @submit.prevent="onSubmit">
|
||||
<woot-input
|
||||
v-model.trim="name"
|
||||
:class="{ error: $v.name.$error }"
|
||||
class="w-full"
|
||||
:sla="$t('SLA.FORM.NAME.LABEL')"
|
||||
:placeholder="$t('SLA.FORM.NAME.PLACEHOLDER')"
|
||||
:error="getSlaNameErrorMessage"
|
||||
@input="$v.name.$touch"
|
||||
/>
|
||||
<woot-input
|
||||
v-model.trim="description"
|
||||
class="w-full"
|
||||
:label="$t('SLA.FORM.DESCRIPTION.LABEL')"
|
||||
:placeholder="$t('SLA.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||
data-testid="sla-description"
|
||||
/>
|
||||
|
||||
<woot-input
|
||||
v-model.trim="firstResponseTimeThreshold"
|
||||
class="w-full"
|
||||
:label="$t('SLA.FORM.FIRST_RESPONSE_TIME.LABEL')"
|
||||
:placeholder="$t('SLA.FORM.FIRST_RESPONSE_TIME.PLACEHOLDER')"
|
||||
data-testid="sla-firstResponseTimeThreshold"
|
||||
/>
|
||||
|
||||
<woot-input
|
||||
v-model.trim="nextResponseTimeThreshold"
|
||||
class="w-full"
|
||||
:label="$t('SLA.FORM.NEXT_RESPONSE_TIME.LABEL')"
|
||||
:placeholder="$t('SLA.FORM.NEXT_RESPONSE_TIME.PLACEHOLDER')"
|
||||
data-testid="sla-nextResponseTimeThreshold"
|
||||
/>
|
||||
|
||||
<woot-input
|
||||
v-model.trim="resolutionTimeThreshold"
|
||||
class="w-full"
|
||||
:label="$t('SLA.FORM.RESOLUTION_TIME.LABEL')"
|
||||
:placeholder="$t('SLA.FORM.RESOLUTION_TIME.PLACEHOLDER')"
|
||||
data-testid="sla-resolutionTimeThreshold"
|
||||
/>
|
||||
|
||||
<div class="w-full">
|
||||
<input id="sla_bh" v-model="onlyDuringBusinessHours" type="checkbox" />
|
||||
<label for="sla_bh">
|
||||
{{ $t('SLA.FORM.BUSINESS_HOURS.PLACEHOLDER') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end items-center py-2 px-0 gap-2 w-full">
|
||||
<woot-button
|
||||
:is-disabled="$v.name.$invalid || uiFlags.isUpdating"
|
||||
:is-loading="uiFlags.isUpdating"
|
||||
>
|
||||
{{ submitLabel }}
|
||||
</woot-button>
|
||||
<woot-button class="button clear" @click.prevent="onClose">
|
||||
{{ $t('SLA.FORM.CANCEL') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import validationMixin from './validationMixin';
|
||||
import validations from './validations';
|
||||
|
||||
export default {
|
||||
mixins: [validationMixin],
|
||||
props: {
|
||||
selectedResponse: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
submitLabel: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
firstResponseTimeThreshold: '',
|
||||
nextResponseTimeThreshold: '',
|
||||
resolutionTimeThreshold: '',
|
||||
onlyDuringBusinessHours: false,
|
||||
};
|
||||
},
|
||||
validations,
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'sla/getUIFlags',
|
||||
}),
|
||||
pageTitle() {
|
||||
return `${this.$t('SLA.EDIT.TITLE')} - ${
|
||||
this.selectedResponse?.name || ''
|
||||
}`;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.selectedResponse) this.setFormValues();
|
||||
},
|
||||
methods: {
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
setFormValues() {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
first_response_time_threshold: firstResponseTimeThreshold,
|
||||
next_response_time_threshold: nextResponseTimeThreshold,
|
||||
resolution_time_threshold: resolutionTimeThreshold,
|
||||
only_during_business_hours: onlyDuringBusinessHours,
|
||||
} = this.selectedResponse;
|
||||
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.firstResponseTimeThreshold = firstResponseTimeThreshold;
|
||||
this.nextResponseTimeThreshold = nextResponseTimeThreshold;
|
||||
this.resolutionTimeThreshold = resolutionTimeThreshold;
|
||||
this.onlyDuringBusinessHours = onlyDuringBusinessHours;
|
||||
},
|
||||
onSubmit() {
|
||||
this.$emit('submit', {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
first_response_time_threshold: this.firstResponseTimeThreshold,
|
||||
next_response_time_threshold: this.nextResponseTimeThreshold,
|
||||
resolution_time_threshold: this.resolutionTimeThreshold,
|
||||
only_during_business_hours: this.onlyDuringBusinessHours,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { required, minLength } from 'vuelidate/lib/validators';
|
||||
|
||||
export default {
|
||||
name: {
|
||||
required,
|
||||
minLength: minLength(2),
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user