feat: SLA threshold card component (#9163)
- Component to display SLA timer in the conversation card and header
This commit is contained in:
@@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex items-center px-2 truncate border min-w-fit border-slate-75 dark:border-slate-700"
|
||||||
|
:class="showExtendedInfo ? 'py-[5px] rounded-lg' : 'py-0.5 gap-1 rounded'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
:class="
|
||||||
|
showExtendedInfo &&
|
||||||
|
'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-solid border-slate-75 dark:border-slate-700'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<fluent-icon
|
||||||
|
size="14"
|
||||||
|
:icon="slaStatus.icon"
|
||||||
|
type="outline"
|
||||||
|
:icon-lib="isSlaMissed ? 'lucide' : 'fluent'"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
:class="slaTextStyles"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="showExtendedInfo"
|
||||||
|
class="text-xs font-medium"
|
||||||
|
:class="slaTextStyles"
|
||||||
|
>
|
||||||
|
{{ slaStatusText }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="text-xs font-medium"
|
||||||
|
:class="[slaTextStyles, showExtendedInfo && 'ltr:pl-1.5 rtl:pr-1.5']"
|
||||||
|
>
|
||||||
|
{{ slaStatus.threshold }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import { evaluateSLAStatus } from '../helpers/SLAHelper';
|
||||||
|
|
||||||
|
// const REFRESH_INTERVAL = 60000;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
chat: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
showExtendedInfo: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
timer: null,
|
||||||
|
slaStatus: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
activeSLA: 'sla/getSLAById',
|
||||||
|
}),
|
||||||
|
slaPolicyId() {
|
||||||
|
return this.chat?.sla_policy_id;
|
||||||
|
},
|
||||||
|
sla() {
|
||||||
|
if (!this.slaPolicyId) return null;
|
||||||
|
return this.activeSLA(this.slaPolicyId);
|
||||||
|
},
|
||||||
|
isSlaMissed() {
|
||||||
|
return this.slaStatus?.isSlaMissed;
|
||||||
|
},
|
||||||
|
slaTextStyles() {
|
||||||
|
return this.isSlaMissed
|
||||||
|
? 'text-red-400 dark:text-red-300'
|
||||||
|
: 'text-yellow-600 dark:text-yellow-500';
|
||||||
|
},
|
||||||
|
slaStatusText() {
|
||||||
|
const upperCaseType = this.slaStatus?.type?.toUpperCase(); // FRT, NRT, or RT
|
||||||
|
const statusKey = this.isSlaMissed ? 'BREACH' : 'DUE';
|
||||||
|
|
||||||
|
return this.$t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
|
||||||
|
status: this.$t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
chat() {
|
||||||
|
this.updateSlaStatus();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.updateSlaStatus();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateSlaStatus() {
|
||||||
|
this.slaStatus = evaluateSLAStatus(this.sla, this.chat);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
41
app/javascript/dashboard/helper/directives/resize.js
Normal file
41
app/javascript/dashboard/helper/directives/resize.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { debounce } from '@chatwoot/utils';
|
||||||
|
|
||||||
|
const RESIZE_OBSERVER_DEBOUNCE_TIME = 100;
|
||||||
|
|
||||||
|
function createResizeObserver(el, binding) {
|
||||||
|
const { value } = binding;
|
||||||
|
const observer = new ResizeObserver(
|
||||||
|
debounce(entries => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (entry && value && typeof value === 'function') {
|
||||||
|
value(entry);
|
||||||
|
}
|
||||||
|
}, RESIZE_OBSERVER_DEBOUNCE_TIME)
|
||||||
|
);
|
||||||
|
|
||||||
|
el.cwResizeObserver = observer;
|
||||||
|
observer.observe(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyResizeObserver(el) {
|
||||||
|
if (el.cwResizeObserver) {
|
||||||
|
el.cwResizeObserver.unobserve(el);
|
||||||
|
el.cwResizeObserver.disconnect();
|
||||||
|
delete el.cwResizeObserver;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
bind(el, binding) {
|
||||||
|
createResizeObserver(el, binding);
|
||||||
|
},
|
||||||
|
update(el, binding) {
|
||||||
|
if (binding.oldValue !== binding.value) {
|
||||||
|
destroyResizeObserver(el);
|
||||||
|
createResizeObserver(el, binding);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
unbind(el) {
|
||||||
|
destroyResizeObserver(el);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import resize from '../../directives/resize';
|
||||||
|
|
||||||
|
class ResizeObserverMock {
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
observe() {}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
unobserve() {}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resize directive', () => {
|
||||||
|
let el;
|
||||||
|
let binding;
|
||||||
|
let observer;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
el = document.createElement('div');
|
||||||
|
binding = {
|
||||||
|
value: jest.fn(),
|
||||||
|
};
|
||||||
|
observer = {
|
||||||
|
observe: jest.fn(),
|
||||||
|
unobserve: jest.fn(),
|
||||||
|
disconnect: jest.fn(),
|
||||||
|
};
|
||||||
|
window.ResizeObserver = ResizeObserverMock;
|
||||||
|
jest.spyOn(window, 'ResizeObserver').mockImplementation(() => observer);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create ResizeObserver on bind', () => {
|
||||||
|
resize.bind(el, binding);
|
||||||
|
|
||||||
|
expect(ResizeObserver).toHaveBeenCalled();
|
||||||
|
expect(observer.observe).toHaveBeenCalledWith(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call callback on observer callback', () => {
|
||||||
|
el = document.createElement('div');
|
||||||
|
binding = {
|
||||||
|
value: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
resize.bind(el, binding);
|
||||||
|
|
||||||
|
const entries = [{ contentRect: { width: 100, height: 100 } }];
|
||||||
|
const callback = binding.value;
|
||||||
|
callback(entries[0]);
|
||||||
|
|
||||||
|
expect(binding.value).toHaveBeenCalledWith(entries[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should destroy and recreate observer on update', () => {
|
||||||
|
resize.bind(el, binding);
|
||||||
|
|
||||||
|
resize.update(el, { ...binding, oldValue: 'old' });
|
||||||
|
|
||||||
|
expect(observer.unobserve).toHaveBeenCalledWith(el);
|
||||||
|
expect(observer.disconnect).toHaveBeenCalled();
|
||||||
|
expect(ResizeObserver).toHaveBeenCalledTimes(2);
|
||||||
|
expect(observer.observe).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should destroy observer on unbind', () => {
|
||||||
|
resize.bind(el, binding);
|
||||||
|
|
||||||
|
resize.unbind(el);
|
||||||
|
|
||||||
|
expect(observer.unobserve).toHaveBeenCalledWith(el);
|
||||||
|
expect(observer.disconnect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -64,7 +64,14 @@
|
|||||||
"SNOOZED_UNTIL": "Snoozed until",
|
"SNOOZED_UNTIL": "Snoozed until",
|
||||||
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
|
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
|
||||||
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
|
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
|
||||||
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply"
|
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply",
|
||||||
|
"SLA_STATUS": {
|
||||||
|
"FRT": "FRT {status}",
|
||||||
|
"NRT": "NRT {status}",
|
||||||
|
"RT": "RT {status}",
|
||||||
|
"BREACH": "breach",
|
||||||
|
"DUE": "due"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"RESOLVE_DROPDOWN": {
|
"RESOLVE_DROPDOWN": {
|
||||||
"MARK_PENDING": "Mark as pending",
|
"MARK_PENDING": "Mark as pending",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
|
|||||||
import VueDOMPurifyHTML from 'vue-dompurify-html';
|
import VueDOMPurifyHTML from 'vue-dompurify-html';
|
||||||
import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer';
|
import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer';
|
||||||
import AnalyticsPlugin from '../dashboard/helper/AnalyticsHelper/plugin';
|
import AnalyticsPlugin from '../dashboard/helper/AnalyticsHelper/plugin';
|
||||||
|
import resizeDirective from '../dashboard/helper/directives/resize.js';
|
||||||
|
|
||||||
Vue.config.env = process.env;
|
Vue.config.env = process.env;
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ Vue.component('woot-switch', WootSwitch);
|
|||||||
Vue.component('woot-wizard', WootWizard);
|
Vue.component('woot-wizard', WootWizard);
|
||||||
Vue.component('fluent-icon', FluentIcon);
|
Vue.component('fluent-icon', FluentIcon);
|
||||||
|
|
||||||
|
Vue.directive('resize', resizeDirective);
|
||||||
const i18nConfig = new VueI18n({
|
const i18nConfig = new VueI18n({
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
messages: i18n,
|
messages: i18n,
|
||||||
|
|||||||
Reference in New Issue
Block a user