feat: Update the input for the SLA threshold selection (#8974)

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
Vishnu Narayanan
2024-02-28 12:30:24 +05:30
committed by GitHub
parent dca14ef82d
commit 9f905ce2e6
9 changed files with 278 additions and 72 deletions

View File

@@ -24,21 +24,24 @@
"PLACEHOLDER": "SLA for premium customers"
},
"FIRST_RESPONSE_TIME": {
"LABEL": "First Response Time(Seconds)",
"PLACEHOLDER": "300 for 5 minutes"
"LABEL": "First Response Time",
"PLACEHOLDER": "5"
},
"NEXT_RESPONSE_TIME": {
"LABEL": "Next Response Time(Seconds)",
"PLACEHOLDER": "600 for 10 minutes"
"LABEL": "Next Response Time",
"PLACEHOLDER": "5"
},
"RESOLUTION_TIME": {
"LABEL": "Resolution Time(Seconds)",
"PLACEHOLDER": "86400 for 1 day"
"LABEL": "Resolution Time",
"PLACEHOLDER": "60"
},
"BUSINESS_HOURS": {
"LABEL": "Business Hours",
"PLACEHOLDER": "Only during business hours"
},
"THRESHOLD_TIME": {
"INVALID_FORMAT_ERROR": "Threshold should be a number and greater than zero"
},
"EDIT": "Edit",
"CREATE": "Create",
"DELETE": "Delete",

View File

@@ -38,17 +38,17 @@
<td>{{ sla.description }}</td>
<td>
<span class="flex items-center">
{{ sla.first_response_time_threshold }}
{{ displayTime(sla.first_response_time_threshold) }}
</span>
</td>
<td>
<span class="flex items-center">
{{ sla.next_response_time_threshold }}
{{ displayTime(sla.next_response_time_threshold) }}
</span>
</td>
<td>
<span class="flex items-center">
{{ sla.resolution_time_threshold }}
{{ displayTime(sla.resolution_time_threshold) }}
</span>
</td>
<td>
@@ -88,6 +88,7 @@
</template>
<script>
import { mapGetters } from 'vuex';
import { convertSecondsToTimeUnit } from '@chatwoot/utils';
import AddSLA from './AddSLA.vue';
import EditSLA from './EditSLA.vue';
@@ -111,26 +112,12 @@ export default {
...mapGetters({
records: 'sla/getSLA',
uiFlags: 'sla/getUIFlags',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
isSLAEnabled() {
return this.isFeatureEnabledonAccount(this.accountId, 'sla');
},
},
mounted() {
this.isSLAfeatureEnabled();
this.$store.dispatch('sla/get');
},
methods: {
isSLAfeatureEnabled() {
if (!this.isSLAEnabled) {
this.$router.push({
name: 'general_settings_index',
});
} else {
this.$store.dispatch('sla/get');
}
},
openAddPopup() {
this.showAddPopup = true;
},
@@ -144,6 +131,15 @@ export default {
hideEditPopup() {
this.showEditPopup = false;
},
displayTime(threshold) {
const { time, unit } = convertSecondsToTimeUnit(threshold, {
minute: 'm',
hour: 'h',
day: 'd',
});
if (!time) return '-';
return `${time}${unit}`;
},
},
};
</script>

View File

@@ -2,44 +2,31 @@
<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"
v-model="name"
:class="{ error: $v.name.$error }"
class="w-full"
:sla="$t('SLA.FORM.NAME.LABEL')"
:label="$t('SLA.FORM.NAME.LABEL')"
:placeholder="$t('SLA.FORM.NAME.PLACEHOLDER')"
:error="getSlaNameErrorMessage"
@input="$v.name.$touch"
/>
<woot-input
v-model.trim="description"
v-model="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"
<sla-time-input
v-for="(input, index) in slaTimeInputs"
:key="index"
:threshold="input.threshold"
:threshold-unit="input.unit"
:label="$t(input.label)"
:placeholder="$t(input.placeholder)"
@input="updateThreshold(index, $event)"
@unit="updateUnit(index, $event)"
@isInValid="handleIsInvalid(index, $event)"
/>
<div class="w-full">
@@ -51,7 +38,7 @@
<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-disabled="isSubmitDisabled"
:is-loading="uiFlags.isUpdating"
>
{{ submitLabel }}
@@ -66,10 +53,15 @@
<script>
import { mapGetters } from 'vuex';
import { convertSecondsToTimeUnit } from '@chatwoot/utils';
import validationMixin from './validationMixin';
import validations from './validations';
import SlaTimeInput from './SlaTimeInput.vue';
export default {
components: {
SlaTimeInput,
},
mixins: [validationMixin],
props: {
selectedResponse: {
@@ -85,9 +77,28 @@ export default {
return {
name: '',
description: '',
firstResponseTimeThreshold: '',
nextResponseTimeThreshold: '',
resolutionTimeThreshold: '',
isSlaTimeInputsInvalid: false,
slaTimeInputsValidation: {},
slaTimeInputs: [
{
threshold: null,
unit: 'Minutes',
label: 'SLA.FORM.FIRST_RESPONSE_TIME.LABEL',
placeholder: 'SLA.FORM.FIRST_RESPONSE_TIME.PLACEHOLDER',
},
{
threshold: null,
unit: 'Minutes',
label: 'SLA.FORM.NEXT_RESPONSE_TIME.LABEL',
placeholder: 'SLA.FORM.NEXT_RESPONSE_TIME.PLACEHOLDER',
},
{
threshold: null,
unit: 'Minutes',
label: 'SLA.FORM.RESOLUTION_TIME.LABEL',
placeholder: 'SLA.FORM.RESOLUTION_TIME.PLACEHOLDER',
},
],
onlyDuringBusinessHours: false,
};
},
@@ -96,10 +107,12 @@ export default {
...mapGetters({
uiFlags: 'sla/getUIFlags',
}),
pageTitle() {
return `${this.$t('SLA.EDIT.TITLE')} - ${
this.selectedResponse?.name || ''
}`;
isSubmitDisabled() {
return (
this.$v.name.$invalid ||
this.isSlaTimeInputsInvalid ||
this.uiFlags.isUpdating
);
},
},
mounted() {
@@ -121,20 +134,59 @@ export default {
this.name = name;
this.description = description;
this.firstResponseTimeThreshold = firstResponseTimeThreshold;
this.nextResponseTimeThreshold = nextResponseTimeThreshold;
this.resolutionTimeThreshold = resolutionTimeThreshold;
this.onlyDuringBusinessHours = onlyDuringBusinessHours;
const thresholds = [
firstResponseTimeThreshold,
nextResponseTimeThreshold,
resolutionTimeThreshold,
];
this.slaTimeInputs.forEach((input, index) => {
const converted = convertSecondsToTimeUnit(thresholds[index], {
minute: 'Minutes',
hour: 'Hours',
day: 'Days',
});
input.threshold = converted.time;
input.unit = converted.unit;
});
},
updateThreshold(index, value) {
this.slaTimeInputs[index].threshold = value;
},
updateUnit(index, unit) {
this.slaTimeInputs[index].unit = unit;
},
onSubmit() {
this.$emit('submit', {
const payload = {
name: this.name,
description: this.description,
first_response_time_threshold: this.firstResponseTimeThreshold,
next_response_time_threshold: this.nextResponseTimeThreshold,
resolution_time_threshold: this.resolutionTimeThreshold,
first_response_time_threshold: this.convertToSeconds(0),
next_response_time_threshold: this.convertToSeconds(1),
resolution_time_threshold: this.convertToSeconds(2),
only_during_business_hours: this.onlyDuringBusinessHours,
});
};
this.$emit('submit', payload);
},
convertToSeconds(index) {
const { threshold, unit } = this.slaTimeInputs[index];
if (threshold === null || threshold === 0) return null;
const unitsToSeconds = { Minutes: 60, Hours: 3600, Days: 86400 };
return Number(threshold * (unitsToSeconds[unit] || 1));
},
handleIsInvalid(index, isInvalid) {
this.slaTimeInputsValidation = {
...this.slaTimeInputsValidation,
[index]: isInvalid,
};
this.checkValidationState();
},
checkValidationState() {
const isAnyInvalid = Object.values(this.slaTimeInputsValidation).some(
isInvalid => isInvalid
);
this.isSlaTimeInputsInvalid = isAnyInvalid;
},
},
};

View File

@@ -0,0 +1,94 @@
<template>
<div class="relative mt-2 w-full">
<woot-input
v-model="thresholdTime"
:class="{ error: $v.thresholdTime.$error }"
class="w-full [&>input]:pr-24"
:label="label"
:placeholder="placeholder"
:error="getThresholdTimeErrorMessage"
@input="onThresholdTimeChange"
/>
<div class="absolute right-px h-9 top-[27px] flex items-center">
<select
v-model="thresholdUnitValue"
class="h-full rounded-[4px] hover:cursor-pointer font-medium border-1 border-solid bg-transparent border-transparent dark:border-transparent mb-0 py-0 pl-2 pr-7 text-slate-600 dark:text-slate-300 dark:focus:border-woot-500 focus:border-woot-500 text-sm"
@change="onThresholdUnitChange"
>
<option
v-for="(option, index) in options"
:key="index"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
</div>
</template>
<script>
import validationMixin from './validationMixin';
import validations from './validations';
export default {
mixins: [validationMixin],
props: {
threshold: {
type: Number,
default: null,
},
thresholdUnit: {
type: String,
default: 'Minutes',
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
},
data() {
return {
thresholdTime: this.threshold || '',
thresholdUnitValue: this.thresholdUnit,
options: [
{ value: 'Minutes', label: 'Minutes' },
{ value: 'Hours', label: 'Hours' },
{ value: 'Days', label: 'Days' },
],
};
},
validations,
watch: {
threshold: {
immediate: true,
handler(value) {
if (!Number.isNaN(value)) {
this.thresholdTime = value;
}
},
},
thresholdUnit: {
immediate: true,
handler(value) {
this.thresholdUnitValue = value;
},
},
},
methods: {
onThresholdUnitChange() {
this.$emit('unit', this.thresholdUnitValue);
},
onThresholdTimeChange() {
this.$v.thresholdTime.$touch();
const isInvalid = this.$v.thresholdTime.$invalid;
this.$emit('isInValid', isInvalid);
this.$emit('input', Number(this.thresholdTime));
},
},
};
</script>

View File

@@ -31,6 +31,7 @@ describe('validationMixin', () => {
data() {
return {
name: '',
thresholdTime: '',
};
},
});
@@ -62,4 +63,44 @@ describe('validationMixin', () => {
wrapper.vm.$t('SLA.FORM.NAME.MINIMUM_LENGTH_ERROR')
);
});
it('should accept valid threshold values', () => {
wrapper.setData({ thresholdTime: 10 });
wrapper.vm.$v.thresholdTime.$touch();
expect(wrapper.vm.getThresholdTimeErrorMessage).toBe(wrapper.vm.$t(''));
wrapper.setData({ thresholdTime: 10.5 });
wrapper.vm.$v.thresholdTime.$touch();
expect(wrapper.vm.getThresholdTimeErrorMessage).toBe(wrapper.vm.$t(''));
});
it('should not return invalid format error message if thresholdTime is empty but not touched', () => {
wrapper.setData({ thresholdTime: '' });
expect(wrapper.vm.getThresholdTimeErrorMessage).toBe('');
});
it('should return invalid format error message if thresholdTime has an invalid format', () => {
wrapper.setData({ thresholdTime: 'fsdfsdfsdfsd' });
wrapper.vm.$v.thresholdTime.$touch();
expect(wrapper.vm.getThresholdTimeErrorMessage).toBe(
wrapper.vm.$t('SLA.FORM.THRESHOLD_TIME.INVALID_FORMAT_ERROR')
);
});
it('should reject invalid threshold values', () => {
wrapper.setData({ thresholdTime: 0 });
wrapper.vm.$v.thresholdTime.$touch();
expect(wrapper.vm.getThresholdTimeErrorMessage).toBe(
wrapper.vm.$t('SLA.FORM.THRESHOLD_TIME.INVALID_FORMAT_ERROR')
);
});
it('should reject invalid threshold values', () => {
wrapper.setData({ thresholdTime: -1 });
wrapper.vm.$v.thresholdTime.$touch();
expect(wrapper.vm.getThresholdTimeErrorMessage).toBe(
wrapper.vm.$t('SLA.FORM.THRESHOLD_TIME.INVALID_FORMAT_ERROR')
);
});
});

View File

@@ -11,5 +11,16 @@ export default {
}
return errorMessage;
},
getThresholdTimeErrorMessage() {
let errorMessage = '';
if (this.$v.thresholdTime.$error) {
if (!this.$v.thresholdTime.numeric || !this.$v.thresholdTime.minValue) {
errorMessage = this.$t(
'SLA.FORM.THRESHOLD_TIME.INVALID_FORMAT_ERROR'
);
}
}
return errorMessage;
},
},
};

View File

@@ -1,8 +1,17 @@
import { required, minLength } from 'vuelidate/lib/validators';
import {
required,
minLength,
minValue,
decimal,
} from 'vuelidate/lib/validators';
export default {
name: {
required,
minLength: minLength(2),
},
thresholdTime: {
decimal,
minValue: minValue(0.001),
},
};

View File

@@ -32,7 +32,7 @@
"dependencies": {
"@braid/vue-formulate": "^2.5.2",
"@chatwoot/prosemirror-schema": "1.0.5",
"@chatwoot/utils": "^0.0.21",
"@chatwoot/utils": "^0.0.23",
"@hcaptcha/vue-hcaptcha": "^0.3.2",
"@june-so/analytics-next": "^2.0.0",
"@radix-ui/colors": "^1.0.1",

View File

@@ -3177,10 +3177,10 @@
prosemirror-utils "^0.9.6"
prosemirror-view "^1.17.2"
"@chatwoot/utils@^0.0.21":
version "0.0.21"
resolved "https://registry.yarnpkg.com/@chatwoot/utils/-/utils-0.0.21.tgz#f9116daac0514a8a8fa6ce594efff10062222be0"
integrity sha512-eUDJ1K5x1rFlBywRctU3hXXiJ1U0EZiklowNl/YJOh1/BWDns4It3DWrQmAcjvsNbEUNWMfY+ShJmjdeei71Cw==
"@chatwoot/utils@^0.0.23":
version "0.0.23"
resolved "https://registry.yarnpkg.com/@chatwoot/utils/-/utils-0.0.23.tgz#e961fd87ef9ee19c442bfcedac5fe0be2ef37726"
integrity sha512-BQ7DprXr7FIkSbHdDc1WonwH0rt/+B+WaaLaXNMjCcxJgkX/gZ8QMltruOfRp/S8cFyd9JfFQhNF0T9lz1OMvA==
dependencies:
date-fns "^2.29.1"