feat: UI to show the SLA threshold in chat screen (#9146)

- UI will show the breach in the conversation list.
- UI will show the breach in the conversation header.

Fixes: https://linear.app/chatwoot/issue/CW-3146/update-the-ui-to-show-the-breach-in-the-conversation-list
Fixes: https://linear.app/chatwoot/issue/CW-3144/ui-update-to-show-the-breachgoing-to-breach
This commit is contained in:
Sivin Varghese
2024-04-04 15:46:46 +05:30
committed by GitHub
parent e21d7552d3
commit e49ef773d8
21 changed files with 745 additions and 106 deletions

View File

@@ -1,14 +1,14 @@
<template> <template>
<div <div
class="conversations-list-wrap flex-basis-clamp flex-shrink-0 overflow-hidden flex flex-col border-r rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50" class="flex flex-col flex-shrink-0 overflow-hidden border-r conversations-list-wrap rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
:class="{ :class="[
hide: !showConversationList, { hidden: !showConversationList },
'list--full-width': isOnExpandedLayout, isOnExpandedLayout ? 'basis-full' : 'flex-basis-clamp',
}" ]"
> >
<slot /> <slot />
<div <div
class="flex items-center justify-between py-0 px-4" class="flex items-center justify-between px-4 py-0"
:class="{ :class="{
'pb-3 border-b border-slate-75 dark:border-slate-700': 'pb-3 border-b border-slate-75 dark:border-slate-700':
hasAppliedFiltersOrActiveFolders, hasAppliedFiltersOrActiveFolders,
@@ -16,7 +16,7 @@
> >
<div class="flex max-w-[85%] justify-center items-center"> <div class="flex max-w-[85%] justify-center items-center">
<h1 <h1
class="text-xl break-words overflow-hidden whitespace-nowrap font-medium text-ellipsis text-black-900 dark:text-slate-100 mb-0" class="text-xl font-medium break-words truncate text-black-900 dark:text-slate-100"
:title="pageTitle" :title="pageTitle"
> >
{{ pageTitle }} {{ pageTitle }}
@@ -107,7 +107,7 @@
<p <p
v-if="!chatListLoading && !conversationList.length" v-if="!chatListLoading && !conversationList.length"
class="overflow-auto p-4 flex justify-center items-center" class="flex items-center justify-center p-4 overflow-auto"
> >
{{ $t('CHAT_LIST.LIST.404') }} {{ $t('CHAT_LIST.LIST.404') }}
</p> </p>
@@ -127,7 +127,7 @@
/> />
<div <div
ref="conversationList" ref="conversationList"
class="conversations-list flex-1" class="flex-1 conversations-list"
:class="{ 'overflow-hidden': isContextMenuOpen }" :class="{ 'overflow-hidden': isContextMenuOpen }"
> >
<virtual-list <virtual-list
@@ -136,16 +136,16 @@
:data-sources="conversationList" :data-sources="conversationList"
:data-component="itemComponent" :data-component="itemComponent"
:extra-props="virtualListExtraProps" :extra-props="virtualListExtraProps"
class="w-full overflow-auto h-full" class="w-full h-full overflow-auto"
footer-tag="div" footer-tag="div"
> >
<template #footer> <template #footer>
<div v-if="chatListLoading" class="text-center"> <div v-if="chatListLoading" class="text-center">
<span class="spinner mt-4 mb-4" /> <span class="mt-4 mb-4 spinner" />
</div> </div>
<p <p
v-if="showEndOfListMessage" v-if="showEndOfListMessage"
class="text-center text-slate-400 dark:text-slate-300 p-4" class="p-4 text-center text-slate-400 dark:text-slate-300"
> >
{{ $t('CHAT_LIST.EOF') }} {{ $t('CHAT_LIST.EOF') }}
</p> </p>
@@ -1034,24 +1034,10 @@ export default {
</style> </style>
<style scoped lang="scss"> <style scoped lang="scss">
.conversations-list-wrap {
&.hide {
@apply hidden;
}
&.list--full-width {
@apply basis-full;
}
}
.conversations-list { .conversations-list {
@apply overflow-hidden hover:overflow-y-auto; @apply overflow-hidden hover:overflow-y-auto;
} }
.load-more--button {
@apply text-center rounded-none;
}
.tab--chat-type { .tab--chat-type {
@apply py-0 px-4; @apply py-0 px-4;

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="ltr:mr-1 rtl:ml-1 mb-1" class="inline-flex ltr:mr-1 rtl:ml-1 mb-1"
:class="labelClass" :class="labelClass"
:style="labelStyle" :style="labelStyle"
:title="description" :title="description"
@@ -111,7 +111,7 @@ export default {
<style scoped lang="scss"> <style scoped lang="scss">
.label { .label {
@apply inline-flex items-center font-medium text-xs rounded-[4px] gap-1 p-1 bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-100 border border-solid border-slate-75 dark:border-slate-600 h-6; @apply items-center font-medium text-xs rounded-[4px] gap-1 p-1 bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-100 border border-solid border-slate-75 dark:border-slate-600 h-6;
&.small { &.small {
@apply text-xs py-0.5 px-1 leading-tight h-5; @apply text-xs py-0.5 px-1 leading-tight h-5;

View File

@@ -86,7 +86,11 @@
{{ unreadCount > 9 ? '9+' : unreadCount }} {{ unreadCount > 9 ? '9+' : unreadCount }}
</span> </span>
</div> </div>
<card-labels :conversation-id="chat.id" /> <card-labels :conversation-id="chat.id" class="mt-0.5 mx-2 mb-0">
<template v-if="hasSlaPolicyId" #before>
<SLA-card-label :chat="chat" class="ltr:mr-1 rtl:ml-1" />
</template>
</card-labels>
</div> </div>
<woot-context-menu <woot-context-menu
v-if="showContextMenu" v-if="showContextMenu"
@@ -125,6 +129,7 @@ import alertMixin from 'shared/mixins/alertMixin';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue'; import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import CardLabels from './conversationCardComponents/CardLabels.vue'; import CardLabels from './conversationCardComponents/CardLabels.vue';
import PriorityMark from './PriorityMark.vue'; import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue';
export default { export default {
components: { components: {
@@ -135,6 +140,7 @@ export default {
TimeAgo, TimeAgo,
MessagePreview, MessagePreview,
PriorityMark, PriorityMark,
SLACardLabel,
}, },
mixins: [inboxMixin, timeMixin, conversationMixin, alertMixin], mixins: [inboxMixin, timeMixin, conversationMixin, alertMixin],
@@ -252,6 +258,9 @@ export default {
const stateInbox = this.inbox; const stateInbox = this.inbox;
return stateInbox.name || ''; return stateInbox.name || '';
}, },
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
}, },
methods: { methods: {
onCardClick(e) { onCardClick(e) {

View File

@@ -1,12 +1,12 @@
<template> <template>
<div <div
class="bg-white dark:bg-slate-900 flex justify-between items-center py-2 px-4 border-b border-slate-50 dark:border-slate-800/50 flex-col md:flex-row" class="flex flex-col items-center justify-between px-4 py-2 bg-white border-b dark:bg-slate-900 border-slate-50 dark:border-slate-800/50 md:flex-row"
> >
<div <div
class="flex-1 w-full min-w-0 flex flex-col items-center justify-center" class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
:class="isInboxView ? 'sm:flex-row' : 'md:flex-row'" :class="isInboxView ? 'sm:flex-row' : 'md:flex-row'"
> >
<div class="flex justify-start items-center min-w-0 w-fit max-w-full"> <div class="flex items-center justify-start max-w-full min-w-0 w-fit">
<back-button <back-button
v-if="showBackButton" v-if="showBackButton"
:back-url="backButtonUrl" :back-url="backButtonUrl"
@@ -19,10 +19,10 @@
:status="currentContact.availability_status" :status="currentContact.availability_status"
/> />
<div <div
class="items-start flex flex-col ml-2 rtl:ml-0 rtl:mr-2 min-w-0 w-fit overflow-hidden" class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
> >
<div <div
class="flex items-center flex-row gap-1 m-0 p-0 w-fit max-w-full" class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
> >
<woot-button <woot-button
variant="link" variant="link"
@@ -31,7 +31,7 @@
@click.prevent="$emit('contact-panel-toggle')" @click.prevent="$emit('contact-panel-toggle')"
> >
<span <span
class="text-base leading-tight font-medium text-slate-900 dark:text-slate-100" class="text-base font-medium leading-tight text-slate-900 dark:text-slate-100"
> >
{{ currentContact.name }} {{ currentContact.name }}
</span> </span>
@@ -46,7 +46,7 @@
</div> </div>
<div <div
class="conversation--header--actions items-center flex text-xs gap-2 text-ellipsis overflow-hidden whitespace-nowrap" class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
> >
<inbox-name v-if="hasMultipleInboxes" :inbox="inbox" /> <inbox-name v-if="hasMultipleInboxes" :inbox="inbox" />
<span <span
@@ -67,9 +67,10 @@
</div> </div>
</div> </div>
<div <div
class="header-actions-wrap items-center flex flex-row flex-grow justify-end mt-3 lg:mt-0" class="flex flex-row items-center justify-end flex-grow gap-2 mt-3 header-actions-wrap lg:mt-0"
:class="{ 'justify-end': isContactPanelOpen }" :class="{ 'justify-end': isContactPanelOpen }"
> >
<SLA-card-label v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
<more-actions :conversation-id="currentChat.id" /> <more-actions :conversation-id="currentChat.id" />
</div> </div>
</div> </div>
@@ -85,6 +86,7 @@ import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue'; import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue'; import MoreActions from './MoreActions.vue';
import Thumbnail from '../Thumbnail.vue'; import Thumbnail from '../Thumbnail.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper'; import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers'; import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
@@ -95,6 +97,7 @@ export default {
InboxName, InboxName,
MoreActions, MoreActions,
Thumbnail, Thumbnail,
SLACardLabel,
}, },
mixins: [inboxMixin, agentMixin, eventListenerMixins], mixins: [inboxMixin, agentMixin, eventListenerMixins],
props: { props: {
@@ -173,6 +176,9 @@ export default {
hasMultipleInboxes() { hasMultipleInboxes() {
return this.$store.getters['inboxes/getInboxes'].length > 1; return this.$store.getters['inboxes/getInboxes'].length > 1;
}, },
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
}, },
methods: { methods: {

View File

@@ -1,47 +1,63 @@
<template> <template>
<div <div
class="flex items-center px-2 truncate border min-w-fit border-slate-75 dark:border-slate-700" v-if="hasSlaThreshold"
:class="showExtendedInfo ? 'py-[5px] rounded-lg' : 'py-0.5 gap-1 rounded'" class="relative flex items-center border cursor-pointer min-w-fit border-slate-75 dark:border-slate-700"
:class="showExtendedInfo ? 'rounded-lg' : 'rounded'"
> >
<div <div
class="flex items-center gap-1" class="flex items-center w-full truncate"
:class=" :class="showExtendedInfo ? 'h-[26px] px-1.5' : 'h-5 px-2 gap-1'"
showExtendedInfo && @mouseover="openSlaPopover()"
'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-solid border-slate-75 dark:border-slate-700' @mouseleave="closeSlaPopover()"
"
> >
<fluent-icon <div
size="14" class="flex items-center gap-1"
:icon="slaStatus.icon" :class="
type="outline" showExtendedInfo &&
:icon-lib="isSlaMissed ? 'lucide' : 'fluent'" 'ltr:pr-1.5 rtl:pl-1.5 ltr:border-r rtl:border-l border-solid border-slate-75 dark:border-slate-700'
class="flex-shrink-0" "
:class="slaTextStyles"
/>
<span
v-if="showExtendedInfo"
class="text-xs font-medium"
:class="slaTextStyles"
> >
{{ slaStatusText }} <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> </span>
</div> </div>
<span <SLA-popover-card
class="text-xs font-medium" v-if="showSlaPopoverCard"
:class="[slaTextStyles, showExtendedInfo && 'ltr:pl-1.5 rtl:pr-1.5']" :all-missed-slas="slaEvents"
> class="right-0 top-7"
{{ slaStatus.threshold }} />
</span>
</div> </div>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex';
import { evaluateSLAStatus } from '../helpers/SLAHelper'; import { evaluateSLAStatus } from '../helpers/SLAHelper';
import SLAPopoverCard from './SLAPopoverCard.vue';
// const REFRESH_INTERVAL = 60000; const REFRESH_INTERVAL = 60000;
export default { export default {
components: {
SLAPopoverCard,
},
props: { props: {
chat: { chat: {
type: Object, type: Object,
@@ -55,19 +71,27 @@ export default {
data() { data() {
return { return {
timer: null, timer: null,
slaStatus: {}, showSlaPopover: false,
slaStatus: {
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
},
}; };
}, },
computed: { computed: {
...mapGetters({
activeSLA: 'sla/getSLAById',
}),
slaPolicyId() { slaPolicyId() {
return this.chat?.sla_policy_id; return this.chat?.sla_policy_id;
}, },
sla() { appliedSLA() {
if (!this.slaPolicyId) return null; return this.chat?.applied_sla;
return this.activeSLA(this.slaPolicyId); },
slaEvents() {
return this.chat?.sla_events;
},
hasSlaThreshold() {
return this.slaStatus?.threshold;
}, },
isSlaMissed() { isSlaMissed() {
return this.slaStatus?.isSlaMissed; return this.slaStatus?.isSlaMissed;
@@ -79,12 +103,17 @@ export default {
}, },
slaStatusText() { slaStatusText() {
const upperCaseType = this.slaStatus?.type?.toUpperCase(); // FRT, NRT, or RT const upperCaseType = this.slaStatus?.type?.toUpperCase(); // FRT, NRT, or RT
const statusKey = this.isSlaMissed ? 'BREACH' : 'DUE'; const statusKey = this.isSlaMissed ? 'MISSED' : 'DUE';
return this.$t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, { return this.$t(`CONVERSATION.HEADER.SLA_STATUS.${upperCaseType}`, {
status: this.$t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`), status: this.$t(`CONVERSATION.HEADER.SLA_STATUS.${statusKey}`),
}); });
}, },
showSlaPopoverCard() {
return (
this.showExtendedInfo && this.showSlaPopover && this.slaEvents.length
);
},
}, },
watch: { watch: {
chat() { chat() {
@@ -93,10 +122,29 @@ export default {
}, },
mounted() { mounted() {
this.updateSlaStatus(); this.updateSlaStatus();
this.createTimer();
},
beforeDestroy() {
if (this.timer) {
clearTimeout(this.timer);
}
}, },
methods: { methods: {
createTimer() {
this.timer = setTimeout(() => {
this.updateSlaStatus();
this.createTimer();
}, REFRESH_INTERVAL);
},
updateSlaStatus() { updateSlaStatus() {
this.slaStatus = evaluateSLAStatus(this.sla, this.chat); this.slaStatus = evaluateSLAStatus(this.appliedSLA, this.chat);
},
openSlaPopover() {
if (!this.showExtendedInfo) return;
this.showSlaPopover = true;
},
closeSlaPopover() {
this.showSlaPopover = false;
}, },
}, },
}; };

View File

@@ -0,0 +1,45 @@
<script setup>
import { format, fromUnixTime } from 'date-fns';
defineProps({
allMissedSlas: {
type: Array,
default: () => [],
},
});
const formatDate = timestamp => format(fromUnixTime(timestamp), 'PP');
const upperCase = str => str.toUpperCase();
</script>
<template>
<div
class="absolute flex flex-col items-start bg-[#fdfdfd] dark:bg-slate-800 z-50 p-4 border border-solid border-slate-75 dark:border-slate-700 w-[384px] rounded-xl gap-4"
>
<div
v-for="missedSLA in allMissedSlas"
:key="missedSLA.id"
class="flex items-center justify-between w-full"
>
<span
class="text-sm font-normal tracking-[-0.6%] w-[140px] truncate text-slate-900 dark:text-slate-50"
>
{{
$t(
`CONVERSATION.HEADER.SLA_POPOVER.${upperCase(missedSLA.event_type)}`
)
}}
</span>
<span
class="text-sm font-normal tracking-[-0.6%] text-slate-600 dark:text-slate-200"
>
{{ $t('CONVERSATION.HEADER.SLA_POPOVER.MISSED') }}
</span>
<span
class="text-sm font-normal tracking-[-0.6%] text-slate-900 dark:text-slate-50"
>
{{ formatDate(missedSLA.created_at) }}
</span>
</div>
</div>
</template>

View File

@@ -1,13 +1,14 @@
<template> <template>
<div <div
v-show="activeLabels.length" v-if="activeLabels.length || $slots.before"
ref="labelContainer" ref="labelContainer"
class="label-container mt-0.5 mx-2 mb-0" v-resize="computeVisibleLabelPosition"
> >
<div <div
class="labels-wrap flex items-end min-w-0 flex-shrink gap-y-1 flex-wrap" class="flex items-end flex-shrink min-w-0 gap-y-1"
:class="{ expand: showAllLabels }" :class="{ 'h-auto overflow-visible flex-row flex-wrap': showAllLabels }"
> >
<slot name="before" />
<woot-label <woot-label
v-for="(label, index) in activeLabels" v-for="(label, index) in activeLabels"
:key="label.id" :key="label.id"
@@ -26,7 +27,7 @@
? $t('CONVERSATION.CARD.HIDE_LABELS') ? $t('CONVERSATION.CARD.HIDE_LABELS')
: $t('CONVERSATION.CARD.SHOW_LABELS') : $t('CONVERSATION.CARD.SHOW_LABELS')
" "
class="show-more--button sticky flex-shrink-0 right-0 mr-6 rtl:rotate-180" class="sticky right-0 flex-shrink-0 mr-6 show-more--button rtl:rotate-180"
color-scheme="secondary" color-scheme="secondary"
variant="hollow" variant="hollow"
:icon="showAllLabels ? 'chevron-left' : 'chevron-right'" :icon="showAllLabels ? 'chevron-left' : 'chevron-right'"
@@ -59,26 +60,34 @@ export default {
}, },
}, },
mounted() { mounted() {
// the problem here is that there is a certain amount of delay between the conversation
// card being mounted and the resize event eventually being triggered
// This means we need to run the function immediately after the component is mounted
// Happens especially when used in a virtual list.
// We can make the first trigger, a standard part of the directive, in case
// we face this issue again
this.computeVisibleLabelPosition(); this.computeVisibleLabelPosition();
}, },
methods: { methods: {
onShowLabels(e) { onShowLabels(e) {
e.stopPropagation(); e.stopPropagation();
this.showAllLabels = !this.showAllLabels; this.showAllLabels = !this.showAllLabels;
this.$nextTick(() => this.computeVisibleLabelPosition());
}, },
computeVisibleLabelPosition() { computeVisibleLabelPosition() {
const beforeSlot = this.$slots.before ? 100 : 0;
const labelContainer = this.$refs.labelContainer; const labelContainer = this.$refs.labelContainer;
const labels = this.$refs.labelContainer.querySelectorAll('.label'); if (!labelContainer) return;
const labels = Array.from(labelContainer.querySelectorAll('.label'));
let labelOffset = 0; let labelOffset = 0;
this.showExpandLabelButton = false; this.showExpandLabelButton = false;
labels.forEach((label, index) => {
Array.from(labels).forEach((label, index) => {
labelOffset += label.offsetWidth + 8; labelOffset += label.offsetWidth + 8;
if (labelOffset < labelContainer.clientWidth - 16 - beforeSlot) {
if (labelOffset < labelContainer.clientWidth - 16) {
this.labelPosition = index; this.labelPosition = index;
} else { } else {
this.showExpandLabelButton = true; this.showExpandLabelButton = labels.length > 1;
} }
}); });
}, },
@@ -95,10 +104,6 @@ export default {
} }
.labels-wrap { .labels-wrap {
&.expand {
@apply h-auto overflow-visible flex-row flex-wrap;
}
.secondary { .secondary {
@apply border border-solid border-slate-100 dark:border-slate-700; @apply border border-solid border-slate-100 dark:border-slate-700;
} }

View File

@@ -0,0 +1,117 @@
const calculateThreshold = (timeOffset, threshold) => {
// Calculate the time left for the SLA to breach or the time since the SLA has missed
if (threshold === null) return null;
const currentTime = Math.floor(Date.now() / 1000);
return timeOffset + threshold - currentTime;
};
const findMostUrgentSLAStatus = SLAStatuses => {
// Sort the SLAs based on the threshold and return the most urgent SLA
SLAStatuses.sort(
(sla1, sla2) => Math.abs(sla1.threshold) - Math.abs(sla2.threshold)
);
return SLAStatuses[0];
};
const formatSLATime = seconds => {
const units = {
y: 31536000, // 60 * 60 * 24 * 365
mo: 2592000, // 60 * 60 * 24 * 30
d: 86400, // 60 * 60 * 24
h: 3600, // 60 * 60
m: 60,
};
if (seconds < 60) {
return '1m';
}
// we will only show two parts, two max granularity's, h-m, y-d, d-h, m, but no seconds
const parts = [];
Object.keys(units).forEach(unit => {
const value = Math.floor(seconds / units[unit]);
if (seconds < 60 && parts.length > 0) return;
if (parts.length === 2) return;
if (value > 0) {
parts.push(value + unit);
seconds -= value * units[unit];
}
});
return parts.join(' ');
};
const createSLAObject = (
type,
{
sla_first_response_time_threshold: frtThreshold,
sla_next_response_time_threshold: nrtThreshold,
sla_resolution_time_threshold: rtThreshold,
created_at: createdAt,
} = {},
{
first_reply_created_at: firstReplyCreatedAt,
waiting_since: waitingSince,
status,
} = {}
) => {
// Mapping of breach types to their logic
const SLATypes = {
FRT: {
threshold: calculateThreshold(createdAt, frtThreshold),
// Check FRT only if threshold is not null and first reply hasn't been made
condition:
frtThreshold !== null &&
(!firstReplyCreatedAt || firstReplyCreatedAt === 0),
},
NRT: {
threshold: calculateThreshold(waitingSince, nrtThreshold),
// Check NRT only if threshold is not null, first reply has been made and we are waiting since
condition:
nrtThreshold !== null && !!firstReplyCreatedAt && !!waitingSince,
},
RT: {
threshold: calculateThreshold(createdAt, rtThreshold),
// Check RT only if the conversation is open and threshold is not null
condition: status === 'open' && rtThreshold !== null,
},
};
const SLAStatus = SLATypes[type];
return SLAStatus ? { ...SLAStatus, type } : null;
};
const evaluateSLAConditions = (appliedSla, chat) => {
// Filter out the SLA based on conditions and update the object with the breach status(icon, isSlaMissed)
const SLATypes = ['FRT', 'NRT', 'RT'];
return SLATypes.map(type => createSLAObject(type, appliedSla, chat))
.filter(SLAStatus => SLAStatus && SLAStatus.condition)
.map(SLAStatus => ({
...SLAStatus,
icon: SLAStatus.threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: SLAStatus.threshold <= 0,
}));
};
export const evaluateSLAStatus = (appliedSla, chat) => {
if (!appliedSla || !chat)
return { type: '', threshold: '', icon: '', isSlaMissed: false };
// Filter out the SLA and create the object for each breach
const SLAStatuses = evaluateSLAConditions(appliedSla, chat);
// Return the most urgent SLA which is latest to breach or has missed
const mostUrgent = findMostUrgentSLAStatus(SLAStatuses);
return mostUrgent
? {
type: mostUrgent.type,
threshold: formatSLATime(
mostUrgent.threshold <= 0
? -mostUrgent.threshold
: mostUrgent.threshold
),
icon: mostUrgent.icon,
isSlaMissed: mostUrgent.isSlaMissed,
}
: { type: '', threshold: '', icon: '', isSlaMissed: false };
};

View File

@@ -0,0 +1,150 @@
import { evaluateSLAStatus } from '../SLAHelper';
beforeEach(() => {
jest
.spyOn(Date, 'now')
.mockImplementation(() => new Date('2024-01-01T00:00:00Z').getTime());
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('SLAHelper', () => {
describe('evaluateSLAStatus', () => {
it('returns an empty object when sla or chat is not present', () => {
expect(evaluateSLAStatus(null, null)).toEqual({
type: '',
threshold: '',
icon: '',
isSlaMissed: false,
});
});
// Case when FRT SLA is missed
it('correctly identifies a missed FRT SLA', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704066540,
};
const chatMissed = {
first_reply_created_at: 0,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
type: 'FRT',
threshold: '1m',
icon: 'flame',
isSlaMissed: true,
});
});
// Case when FRT SLA is not missed
it('correctly identifies an FRT SLA not yet breached', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704066660,
};
const chatNotMissed = {
first_reply_created_at: 0,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
type: 'FRT',
threshold: '1m',
icon: 'alarm',
isSlaMissed: false,
});
});
// Case when NRT SLA is missed
it('correctly identifies a missed NRT SLA', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065200,
};
const chatMissed = {
first_reply_created_at: 1704066200,
waiting_since: 1704065940,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
type: 'NRT',
threshold: '1m',
icon: 'flame',
isSlaMissed: true,
});
});
// Case when NRT SLA is not missed
it('correctly identifies an NRT SLA not yet breached', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065200 - 2000,
};
const chatNotMissed = {
first_reply_created_at: 1704066200,
waiting_since: 1704066060,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
type: 'NRT',
threshold: '1m',
icon: 'alarm',
isSlaMissed: false,
});
});
// Case when RT SLA is missed
it('correctly identifies a missed RT SLA', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065340,
};
const chatMissed = {
first_reply_created_at: 1704066200,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
type: 'RT',
threshold: '1m',
icon: 'flame',
isSlaMissed: true,
});
});
// Case when RT SLA is not missed
it('correctly identifies an RT SLA not yet breached', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065460,
};
const chatNotMissed = {
first_reply_created_at: 1704066200,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
type: 'RT',
threshold: '1m',
icon: 'alarm',
isSlaMissed: false,
});
});
});
});

View File

@@ -69,8 +69,14 @@
"FRT": "FRT {status}", "FRT": "FRT {status}",
"NRT": "NRT {status}", "NRT": "NRT {status}",
"RT": "RT {status}", "RT": "RT {status}",
"BREACH": "breach", "MISSED": "missed",
"DUE": "due" "DUE": "due"
},
"SLA_POPOVER": {
"FRT": "First Response Time",
"NRT": "Next Response Time",
"RT": "Resolution Time",
"MISSED": "missed on"
} }
}, },
"RESOLVE_DROPDOWN": { "RESOLVE_DROPDOWN": {

View File

@@ -8,6 +8,7 @@ export default {
this.conversationId this.conversationId
); );
}, },
// TODO - Get rid of this from the mixin
activeLabels() { activeLabels() {
return this.accountLabels.filter(({ title }) => return this.accountLabels.filter(({ title }) =>
this.savedLabels.includes(title) this.savedLabels.includes(title)

View File

@@ -0,0 +1,78 @@
import axios from 'axios';
import { actions } from '../../sla';
import * as types from '../../../mutation-types';
import SLAList from './fixtures';
const commit = jest.fn();
global.axios = axios;
jest.mock('axios');
describe('#actions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('#get', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
data: { payload: SLAList },
});
await actions.get({ commit });
expect(commit.mock.calls).toEqual([
[types.default.SET_SLA_UI_FLAG, { isFetching: true }],
[types.default.SET_SLA, SLAList],
[types.default.SET_SLA_UI_FLAG, { isFetching: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.get({ commit });
expect(commit.mock.calls).toEqual([
[types.default.SET_SLA_UI_FLAG, { isFetching: true }],
[types.default.SET_SLA_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#create', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({
data: { payload: SLAList[0] },
});
await actions.create({ commit }, SLAList[0]);
expect(commit.mock.calls).toEqual([
[types.default.SET_SLA_UI_FLAG, { isCreating: true }],
[types.default.ADD_SLA, SLAList[0]],
[types.default.SET_SLA_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.default.SET_SLA_UI_FLAG, { isCreating: true }],
[types.default.SET_SLA_UI_FLAG, { isCreating: false }],
]);
});
});
describe('#delete', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({});
await actions.delete({ commit }, 1);
expect(commit.mock.calls).toEqual([
[types.default.SET_SLA_UI_FLAG, { isDeleting: true }],
[types.default.DELETE_SLA, 1],
[types.default.SET_SLA_UI_FLAG, { isDeleting: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.delete({ commit })).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_SLA_UI_FLAG, { isDeleting: true }],
[types.default.SET_SLA_UI_FLAG, { isDeleting: false }],
]);
});
});
});

View File

@@ -0,0 +1,95 @@
export default [
{
id: 1,
name: 'Premium SLA',
description:
'SLA for chatwoot cloud premium and self-hosted premium customers. SLA for chatwoot cloud premium and self-hosted premium customers',
first_response_time_threshold: 14400,
next_response_time_threshold: 18000,
resolution_time_threshold: 86400,
only_during_business_hours: true,
},
{
id: 2,
name: 'Enterprise SLA',
description:
'SLA for chatwoot enterprise and self-hosted enterprise customers.',
first_response_time_threshold: 600,
next_response_time_threshold: 2400,
resolution_time_threshold: 3600,
only_during_business_hours: false,
},
{
id: 3,
name: 'Business SLA',
description:
'Chatwoot cloud Business and self-hosted Business customers SLA',
first_response_time_threshold: null,
next_response_time_threshold: null,
resolution_time_threshold: null,
only_during_business_hours: false,
},
{
id: 4,
name: 'Hacker SLA',
description: '',
first_response_time_threshold: 60,
next_response_time_threshold: 120,
resolution_time_threshold: 180,
only_during_business_hours: false,
},
{
id: 5,
name: 'SLA',
description: '',
first_response_time_threshold: 120,
next_response_time_threshold: 300,
resolution_time_threshold: 21600,
only_during_business_hours: false,
},
{
id: 6,
name: 'ALla',
description: '',
first_response_time_threshold: 5400,
next_response_time_threshold: 9000,
resolution_time_threshold: 23040,
only_during_business_hours: false,
},
{
id: 7,
name: '10',
description: '',
first_response_time_threshold: 120,
next_response_time_threshold: null,
resolution_time_threshold: null,
only_during_business_hours: false,
},
{
id: 8,
name: '11',
description: '',
first_response_time_threshold: null,
next_response_time_threshold: 240,
resolution_time_threshold: null,
only_during_business_hours: false,
},
{
id: 9,
name: '12',
description: '',
first_response_time_threshold: null,
next_response_time_threshold: null,
resolution_time_threshold: 300,
only_during_business_hours: false,
},
{
id: 10,
name: '14',
description: '',
first_response_time_threshold: null,
next_response_time_threshold: null,
resolution_time_threshold: null,
only_during_business_hours: false,
},
];

View File

@@ -0,0 +1,26 @@
import { getters } from '../../sla';
import SLAs from './fixtures';
describe('#getters', () => {
it('getSLA', () => {
const state = { records: SLAs };
expect(getters.getSLA(state)).toEqual(SLAs);
});
it('getUIFlags', () => {
const state = {
uiFlags: {
isFetching: true,
isCreating: false,
isUpdating: false,
isDeleting: false,
},
};
expect(getters.getUIFlags(state)).toEqual({
isFetching: true,
isCreating: false,
isUpdating: false,
isDeleting: false,
});
});
});

View File

@@ -0,0 +1,34 @@
import types from '../../../mutation-types';
import { mutations } from '../../sla';
import SLAs from './fixtures';
describe('#mutations', () => {
describe('#SET_SLA_UI_FLAG', () => {
it('set sla ui flags', () => {
const state = { uiFlags: {} };
mutations[types.SET_SLA_UI_FLAG](state, { isFetching: true });
expect(state.uiFlags).toEqual({ isFetching: true });
});
});
describe('#SET_SLA', () => {
it('set sla records', () => {
const state = { records: [] };
mutations[types.SET_SLA](state, SLAs);
expect(state.records).toEqual(SLAs);
});
});
describe('#ADD_SLA', () => {
it('push newly created sla to the store', () => {
const state = { records: [SLAs[0]] };
mutations[types.ADD_SLA](state, SLAs[1]);
expect(state.records).toEqual([SLAs[0], SLAs[1]]);
});
});
describe('#DELETE_SLA', () => {
it('delete sla record', () => {
const state = { records: [SLAs[0]] };
mutations[types.DELETE_SLA](state, 1);
expect(state.records).toEqual([]);
});
});
});

View File

@@ -2,6 +2,7 @@
"add-circle-outline": "M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 1.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17ZM12 7a.75.75 0 0 1 .75.75v3.5h3.5a.75.75 0 0 1 0 1.5h-3.5v3.5a.75.75 0 0 1-1.5 0v-3.5h-3.5a.75.75 0 0 1 0-1.5h3.5v-3.5A.75.75 0 0 1 12 7Z", "add-circle-outline": "M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 1.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17ZM12 7a.75.75 0 0 1 .75.75v3.5h3.5a.75.75 0 0 1 0 1.5h-3.5v3.5a.75.75 0 0 1-1.5 0v-3.5h-3.5a.75.75 0 0 1 0-1.5h3.5v-3.5A.75.75 0 0 1 12 7Z",
"add-outline": "M11.75 3a.75.75 0 0 1 .743.648l.007.102.001 7.25h7.253a.75.75 0 0 1 .102 1.493l-.102.007h-7.253l.002 7.25a.75.75 0 0 1-1.493.101l-.007-.102-.002-7.249H3.752a.75.75 0 0 1-.102-1.493L3.752 11h7.25L11 3.75a.75.75 0 0 1 .75-.75Z", "add-outline": "M11.75 3a.75.75 0 0 1 .743.648l.007.102.001 7.25h7.253a.75.75 0 0 1 .102 1.493l-.102.007h-7.253l.002 7.25a.75.75 0 0 1-1.493.101l-.007-.102-.002-7.249H3.752a.75.75 0 0 1-.102-1.493L3.752 11h7.25L11 3.75a.75.75 0 0 1 .75-.75Z",
"add-solid": "M11.883 3.007 12 3a1 1 0 0 1 .993.883L13 4v7h7a1 1 0 0 1 .993.883L21 12a1 1 0 0 1-.883.993L20 13h-7v7a1 1 0 0 1-.883.993L12 21a1 1 0 0 1-.993-.883L11 20v-7H4a1 1 0 0 1-.993-.883L3 12a1 1 0 0 1 .883-.993L4 11h7V4a1 1 0 0 1 .883-.993L12 3l-.117.007Z", "add-solid": "M11.883 3.007 12 3a1 1 0 0 1 .993.883L13 4v7h7a1 1 0 0 1 .993.883L21 12a1 1 0 0 1-.883.993L20 13h-7v7a1 1 0 0 1-.883.993L12 21a1 1 0 0 1-.993-.883L11 20v-7H4a1 1 0 0 1-.993-.883L3 12a1 1 0 0 1 .883-.993L4 11h7V4a1 1 0 0 1 .883-.993L12 3l-.117.007Z",
"alarm-outline": "M13 12.6V9q0-.425-.288-.713T12 8q-.425 0-.713.288T11 9v3.975q0 .2.075.388t.225.337l2.8 2.8q.275.275.7.275t.7-.275q.275-.275.275-.7t-.275-.7L13 12.6ZM12 22q-1.875 0-3.513-.713t-2.85-1.924q-1.212-1.213-1.924-2.85T3 13q0-1.875.713-3.513t1.924-2.85q1.213-1.212 2.85-1.924T12 4q1.875 0 3.513.713t2.85 1.925q1.212 1.212 1.925 2.85T21 13q0 1.875-.713 3.513t-1.924 2.85q-1.213 1.212-2.85 1.925T12 22Zm0-9ZM2.05 7.3q-.275-.275-.275-.7t.275-.7L4.9 3.05q.275-.275.7-.275t.7.275q.275.275.275.7t-.275.7L3.45 7.3q-.275.275-.7.275t-.7-.275Zm19.9 0q-.275.275-.7.275t-.7-.275L17.7 4.45q-.275-.275-.275-.7t.275-.7q.275-.275.7-.275t.7.275l2.85 2.85q.275.275.275.7t-.275.7ZM12 20q2.925 0 4.963-2.038T19 13q0-2.925-2.038-4.963T12 6Q9.075 6 7.037 8.038T5 13q0 2.925 2.038 4.963T12 20Z",
"alarm-on-outline": "m10.95 13.7l-1.425-1.425q-.3-.3-.7-.3t-.7.3q-.3.3-.3.713t.3.712l2.125 2.15q.3.3.7.3t.7-.3l4.25-4.25q.3-.3.3-.712t-.3-.713q-.3-.3-.713-.3t-.712.3L10.95 13.7ZM12 22q-1.875 0-3.512-.713t-2.85-1.924q-1.213-1.213-1.925-2.85T3 13q0-1.875.713-3.513t1.924-2.85q1.213-1.212 2.85-1.924T12 4q1.875 0 3.513.713t2.85 1.925q1.212 1.212 1.925 2.85T21 13q0 1.875-.713 3.513t-1.924 2.85q-1.213 1.212-2.85 1.925T12 22Zm0-9ZM2.05 7.3q-.275-.275-.275-.7t.275-.7L4.9 3.05q.275-.275.7-.275t.7.275q.275.275.275.7t-.275.7L3.45 7.3q-.275.275-.7.275t-.7-.275Zm19.9 0q-.275.275-.7.275t-.7-.275L17.7 4.45q-.275-.275-.275-.7t.275-.7q.275-.275.7-.275t.7.275l2.85 2.85q.275.275.275.7t-.275.7ZM12 20q2.925 0 4.963-2.038T19 13q0-2.925-2.038-4.963T12 6Q9.075 6 7.037 8.038T5 13q0 2.925 2.038 4.963T12 20Z", "alarm-on-outline": "m10.95 13.7l-1.425-1.425q-.3-.3-.7-.3t-.7.3q-.3.3-.3.713t.3.712l2.125 2.15q.3.3.7.3t.7-.3l4.25-4.25q.3-.3.3-.712t-.3-.713q-.3-.3-.713-.3t-.712.3L10.95 13.7ZM12 22q-1.875 0-3.512-.713t-2.85-1.924q-1.213-1.213-1.925-2.85T3 13q0-1.875.713-3.513t1.924-2.85q1.213-1.212 2.85-1.924T12 4q1.875 0 3.513.713t2.85 1.925q1.212 1.212 1.925 2.85T21 13q0 1.875-.713 3.513t-1.924 2.85q-1.213 1.212-2.85 1.925T12 22Zm0-9ZM2.05 7.3q-.275-.275-.275-.7t.275-.7L4.9 3.05q.275-.275.7-.275t.7.275q.275.275.275.7t-.275.7L3.45 7.3q-.275.275-.7.275t-.7-.275Zm19.9 0q-.275.275-.7.275t-.7-.275L17.7 4.45q-.275-.275-.275-.7t.275-.7q.275-.275.7-.275t.7.275l2.85 2.85q.275.275.275.7t-.275.7ZM12 20q2.925 0 4.963-2.038T19 13q0-2.925-2.038-4.963T12 6Q9.075 6 7.037 8.038T5 13q0 2.925 2.038 4.963T12 20Z",
"alarm-off-outline": "m19.95 17.25l-1.5-1.5q.275-.675.413-1.313T19 13.1q0-2.9-2.05-5T12 6q-.7 0-1.35.113t-1.3.387L7.85 5q.95-.5 1.988-.75T12 4q1.85 0 3.488.7t2.862 1.938q1.225 1.237 1.938 2.887T21 13.1q0 1.125-.275 2.163t-.775 1.987ZM17.7 4.45q-.275-.275-.275-.7t.275-.7q.275-.275.7-.275t.7.275l2.85 2.85q.275.275.275.7t-.275.7q-.275.275-.7.275t-.7-.275L17.7 4.45ZM12 22q-1.85 0-3.488-.7T5.65 19.4q-1.225-1.2-1.938-2.825T3 13.1q0-1.55.463-2.912T4.8 7.7l-.85-.85l-.5.5q-.275.275-.7.275t-.7-.275q-.275-.275-.275-.7t.275-.7l.5-.5L1.4 4.3q-.275-.275-.275-.7t.275-.7q.275-.275.7-.275t.7.275l18.4 18.4q.275.275.275.7t-.275.7q-.275.275-.7.275t-.7-.275l-2.45-2.45q-1.125.825-2.487 1.288T12 22Zm0-1.975q1.05 0 2.05-.325t1.85-.9L6.2 9.15q-.575.875-.887 1.888T5 13.1q0 2.9 2.05 4.913T12 20.025Zm-.95-6.05Zm2.85-2.85Z", "alarm-off-outline": "m19.95 17.25l-1.5-1.5q.275-.675.413-1.313T19 13.1q0-2.9-2.05-5T12 6q-.7 0-1.35.113t-1.3.387L7.85 5q.95-.5 1.988-.75T12 4q1.85 0 3.488.7t2.862 1.938q1.225 1.237 1.938 2.887T21 13.1q0 1.125-.275 2.163t-.775 1.987ZM17.7 4.45q-.275-.275-.275-.7t.275-.7q.275-.275.7-.275t.7.275l2.85 2.85q.275.275.275.7t-.275.7q-.275.275-.7.275t-.7-.275L17.7 4.45ZM12 22q-1.85 0-3.488-.7T5.65 19.4q-1.225-1.2-1.938-2.825T3 13.1q0-1.55.463-2.912T4.8 7.7l-.85-.85l-.5.5q-.275.275-.7.275t-.7-.275q-.275-.275-.275-.7t.275-.7l.5-.5L1.4 4.3q-.275-.275-.275-.7t.275-.7q.275-.275.7-.275t.7.275l18.4 18.4q.275.275.275.7t-.275.7q-.275.275-.7.275t-.7-.275l-2.45-2.45q-1.125.825-2.487 1.288T12 22Zm0-1.975q1.05 0 2.05-.325t1.85-.9L6.2 9.15q-.575.875-.887 1.888T5 13.1q0 2.9 2.05 4.913T12 20.025Zm-.95-6.05Zm2.85-2.85Z",
"alert-outline": "M12 1.996a7.49 7.49 0 0 1 7.496 7.25l.004.25v4.097l1.38 3.156a1.25 1.25 0 0 1-1.145 1.75L15 18.502a3 3 0 0 1-5.995.177L9 18.499H4.275a1.251 1.251 0 0 1-1.147-1.747L4.5 13.594V9.496c0-4.155 3.352-7.5 7.5-7.5ZM13.5 18.5l-3 .002a1.5 1.5 0 0 0 2.993.145l.006-.147ZM12 3.496c-3.32 0-6 2.674-6 6v4.41L4.656 17h14.697L18 13.907V9.509l-.004-.225A5.988 5.988 0 0 0 12 3.496Z", "alert-outline": "M12 1.996a7.49 7.49 0 0 1 7.496 7.25l.004.25v4.097l1.38 3.156a1.25 1.25 0 0 1-1.145 1.75L15 18.502a3 3 0 0 1-5.995.177L9 18.499H4.275a1.251 1.251 0 0 1-1.147-1.747L4.5 13.594V9.496c0-4.155 3.352-7.5 7.5-7.5ZM13.5 18.5l-3 .002a1.5 1.5 0 0 0 2.993.145l.006-.147ZM12 3.496c-3.32 0-6 2.674-6 6v4.41L4.656 17h14.697L18 13.907V9.509l-.004-.225A5.988 5.988 0 0 0 12 3.496Z",
@@ -112,6 +113,7 @@
"error-circle-outline": "M12 2c5.523 0 10 4.478 10 10s-4.477 10-10 10S2 17.522 2 12 6.477 2 12 2Zm0 1.667c-4.595 0-8.333 3.738-8.333 8.333 0 4.595 3.738 8.333 8.333 8.333 4.595 0 8.333-3.738 8.333-8.333 0-4.595-3.738-8.333-8.333-8.333Zm-.001 10.835a.999.999 0 1 1 0 1.998.999.999 0 0 1 0-1.998ZM11.994 7a.75.75 0 0 1 .744.648l.007.101.004 4.502a.75.75 0 0 1-1.493.103l-.007-.102-.004-4.501a.75.75 0 0 1 .75-.751Z", "error-circle-outline": "M12 2c5.523 0 10 4.478 10 10s-4.477 10-10 10S2 17.522 2 12 6.477 2 12 2Zm0 1.667c-4.595 0-8.333 3.738-8.333 8.333 0 4.595 3.738 8.333 8.333 8.333 4.595 0 8.333-3.738 8.333-8.333 0-4.595-3.738-8.333-8.333-8.333Zm-.001 10.835a.999.999 0 1 1 0 1.998.999.999 0 0 1 0-1.998ZM11.994 7a.75.75 0 0 1 .744.648l.007.101.004 4.502a.75.75 0 0 1-1.493.103l-.007-.102-.004-4.501a.75.75 0 0 1 .75-.751Z",
"file-upload-outline": "M6 2a2 2 0 0 0-2 2v5.207a5.48 5.48 0 0 1 1-.185V4a1 1 0 0 1 1-1h4v3.5A1.5 1.5 0 0 0 11.5 8H15v8a1 1 0 0 1-1 1h-3.6a5.507 5.507 0 0 1-.657 1H14a2 2 0 0 0 2-2V7.414a1.5 1.5 0 0 0-.44-1.06l-3.914-3.915A1.5 1.5 0 0 0 10.586 2H6Zm8.793 5H11.5a.5.5 0 0 1-.5-.5V3.207L14.793 7ZM5.5 19a4.5 4.5 0 1 0 0-9a4.5 4.5 0 0 0 0 9Zm2.354-4.854a.5.5 0 1 1-.708.708L6 13.707V16.5a.5.5 0 0 1-1 0v-2.793l-1.146 1.147a.5.5 0 1 1-.708-.707l2-2A.5.5 0 0 1 5.497 12h.006a.498.498 0 0 1 .348.144l.003.003l2 2Z", "file-upload-outline": "M6 2a2 2 0 0 0-2 2v5.207a5.48 5.48 0 0 1 1-.185V4a1 1 0 0 1 1-1h4v3.5A1.5 1.5 0 0 0 11.5 8H15v8a1 1 0 0 1-1 1h-3.6a5.507 5.507 0 0 1-.657 1H14a2 2 0 0 0 2-2V7.414a1.5 1.5 0 0 0-.44-1.06l-3.914-3.915A1.5 1.5 0 0 0 10.586 2H6Zm8.793 5H11.5a.5.5 0 0 1-.5-.5V3.207L14.793 7ZM5.5 19a4.5 4.5 0 1 0 0-9a4.5 4.5 0 0 0 0 9Zm2.354-4.854a.5.5 0 1 1-.708.708L6 13.707V16.5a.5.5 0 0 1-1 0v-2.793l-1.146 1.147a.5.5 0 1 1-.708-.707l2-2A.5.5 0 0 1 5.497 12h.006a.498.498 0 0 1 .348.144l.003.003l2 2Z",
"filter-outline": "M13.5 16a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1 0-1.5h3Zm3-5a.75.75 0 0 1 0 1.5h-9a.75.75 0 0 1 0-1.5h9Zm3-5a.75.75 0 0 1 0 1.5h-15a.75.75 0 0 1 0-1.5h15Z", "filter-outline": "M13.5 16a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1 0-1.5h3Zm3-5a.75.75 0 0 1 0 1.5h-9a.75.75 0 0 1 0-1.5h9Zm3-5a.75.75 0 0 1 0 1.5h-15a.75.75 0 0 1 0-1.5h15Z",
"flame-outline": "M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3c-1.072-2.143-.224-4.054 2-6c.5 2.5 2 4.9 4 6.5c2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5",
"flash-on-outline": "m8.294 14-1.767 7.068c-.187.746.736 1.256 1.269.701L19.79 9.27A.75.75 0 0 0 19.25 8h-4.46l1.672-5.013A.75.75 0 0 0 15.75 2h-7a.75.75 0 0 0-.721.544l-3 10.5A.75.75 0 0 0 5.75 14h2.544Zm4.745-5.487a.75.75 0 0 0 .711.987h3.74l-8.824 9.196 1.316-5.264a.75.75 0 0 0-.727-.932h-2.51l2.57-9h5.394l-1.67 5.013Z", "flash-on-outline": "m8.294 14-1.767 7.068c-.187.746.736 1.256 1.269.701L19.79 9.27A.75.75 0 0 0 19.25 8h-4.46l1.672-5.013A.75.75 0 0 0 15.75 2h-7a.75.75 0 0 0-.721.544l-3 10.5A.75.75 0 0 0 5.75 14h2.544Zm4.745-5.487a.75.75 0 0 0 .711.987h3.74l-8.824 9.196 1.316-5.264a.75.75 0 0 0-.727-.932h-2.51l2.57-9h5.394l-1.67 5.013Z",
"flash-settings-outline": "M6.19 2.77c.13-.455.547-.77 1.02-.77h5.25c.724 0 1.236.71 1.007 1.398l-.002.008L12.204 7h2.564c.946 0 1.407 1.144.766 1.811l-.003.004l-.237.242a5.545 5.545 0 0 0-1.374-.027l.894-.912a.056.056 0 0 0 .017-.032a.084.084 0 0 0-.007-.044a.079.079 0 0 0-.025-.034c-.005-.004-.013-.008-.031-.008h-3.27a.5.5 0 0 1-.471-.666L12.52 3.08a.062.062 0 0 0-.06-.08H7.211a.062.062 0 0 0-.06.045l-2.25 7.874c-.01.04.019.08.06.08H6.87a.5.5 0 0 1 .485.62l-1.325 5.3a.086.086 0 0 0-.003.03a.02.02 0 0 0 .003.011a.08.08 0 0 0 .072.04a.03.03 0 0 0 .01-.004a.087.087 0 0 0 .024-.018l.003-.004l2.882-2.94a5.573 5.573 0 0 0 .054 1.372l-2.22 2.267c-.754.782-2.059.06-1.795-.996l1.17-4.679H4.96a1.062 1.062 0 0 1-1.021-1.354l2.25-7.873Zm5.877 8.673a2 2 0 0 1-1.431 2.478l-.461.118a4.702 4.702 0 0 0 .01 1.016l.35.083a2 2 0 0 1 1.456 2.519l-.127.422c.257.204.537.378.835.518l.325-.344a2 2 0 0 1 2.91.002l.337.358c.292-.135.568-.302.822-.498l-.157-.556a2 2 0 0 1 1.431-2.479l.46-.117a4.702 4.702 0 0 0-.01-1.017l-.348-.082a2 2 0 0 1-1.456-2.52l.126-.421a4.32 4.32 0 0 0-.835-.519l-.325.344a2 2 0 0 1-2.91-.001l-.337-.358a4.316 4.316 0 0 0-.821.497l.156.557ZM14.5 15.5a1 1 0 1 1 0-2a1 1 0 0 1 0 2Z", "flash-settings-outline": "M6.19 2.77c.13-.455.547-.77 1.02-.77h5.25c.724 0 1.236.71 1.007 1.398l-.002.008L12.204 7h2.564c.946 0 1.407 1.144.766 1.811l-.003.004l-.237.242a5.545 5.545 0 0 0-1.374-.027l.894-.912a.056.056 0 0 0 .017-.032a.084.084 0 0 0-.007-.044a.079.079 0 0 0-.025-.034c-.005-.004-.013-.008-.031-.008h-3.27a.5.5 0 0 1-.471-.666L12.52 3.08a.062.062 0 0 0-.06-.08H7.211a.062.062 0 0 0-.06.045l-2.25 7.874c-.01.04.019.08.06.08H6.87a.5.5 0 0 1 .485.62l-1.325 5.3a.086.086 0 0 0-.003.03a.02.02 0 0 0 .003.011a.08.08 0 0 0 .072.04a.03.03 0 0 0 .01-.004a.087.087 0 0 0 .024-.018l.003-.004l2.882-2.94a5.573 5.573 0 0 0 .054 1.372l-2.22 2.267c-.754.782-2.059.06-1.795-.996l1.17-4.679H4.96a1.062 1.062 0 0 1-1.021-1.354l2.25-7.873Zm5.877 8.673a2 2 0 0 1-1.431 2.478l-.461.118a4.702 4.702 0 0 0 .01 1.016l.35.083a2 2 0 0 1 1.456 2.519l-.127.422c.257.204.537.378.835.518l.325-.344a2 2 0 0 1 2.91.002l.337.358c.292-.135.568-.302.822-.498l-.157-.556a2 2 0 0 1 1.431-2.479l.46-.117a4.702 4.702 0 0 0-.01-1.017l-.348-.082a2 2 0 0 1-1.456-2.52l.126-.421a4.32 4.32 0 0 0-.835-.519l-.325.344a2 2 0 0 1-2.91-.001l-.337-.358a4.316 4.316 0 0 0-.821.497l.156.557ZM14.5 15.5a1 1 0 1 1 0-2a1 1 0 0 1 0 2Z",
"folder-outline": "M8.207 4c.46 0 .908.141 1.284.402l.156.12L12.022 6.5h7.728a2.25 2.25 0 0 1 2.229 1.938l.016.158.005.154v9a2.25 2.25 0 0 1-2.096 2.245L19.75 20H4.25a2.25 2.25 0 0 1-2.245-2.096L2 17.75V6.25a2.25 2.25 0 0 1 2.096-2.245L4.25 4h3.957Zm1.44 5.979a2.25 2.25 0 0 1-1.244.512l-.196.009-4.707-.001v7.251c0 .38.282.694.648.743l.102.007h15.5a.75.75 0 0 0 .743-.648l.007-.102v-9a.75.75 0 0 0-.648-.743L19.75 8h-7.729L9.647 9.979ZM8.207 5.5H4.25a.75.75 0 0 0-.743.648L3.5 6.25v2.749L8.207 9a.75.75 0 0 0 .395-.113l.085-.06 1.891-1.578-1.89-1.575a.75.75 0 0 0-.377-.167L8.207 5.5Z", "folder-outline": "M8.207 4c.46 0 .908.141 1.284.402l.156.12L12.022 6.5h7.728a2.25 2.25 0 0 1 2.229 1.938l.016.158.005.154v9a2.25 2.25 0 0 1-2.096 2.245L19.75 20H4.25a2.25 2.25 0 0 1-2.245-2.096L2 17.75V6.25a2.25 2.25 0 0 1 2.096-2.245L4.25 4h3.957Zm1.44 5.979a2.25 2.25 0 0 1-1.244.512l-.196.009-4.707-.001v7.251c0 .38.282.694.648.743l.102.007h15.5a.75.75 0 0 0 .743-.648l.007-.102v-9a.75.75 0 0 0-.648-.743L19.75 8h-7.729L9.647 9.979ZM8.207 5.5H4.25a.75.75 0 0 0-.743.648L3.5 6.25v2.749L8.207 9a.75.75 0 0 0 .395-.113l.085-.06 1.891-1.578-1.89-1.575a.75.75 0 0 0-.377-.167L8.207 5.5Z",

View File

@@ -0,0 +1,15 @@
module PushDataHelper
extend ActiveSupport::Concern
def push_event_data
Conversations::EventDataPresenter.new(self).push_data
end
def lock_event_data
Conversations::EventDataPresenter.new(self).lock_data
end
def webhook_data
Conversations::EventDataPresenter.new(self).push_data
end
end

View File

@@ -57,6 +57,7 @@ class Conversation < ApplicationRecord
include ActivityMessageHandler include ActivityMessageHandler
include UrlHelper include UrlHelper
include SortHandler include SortHandler
include PushDataHelper
include ConversationMuteHelpers include ConversationMuteHelpers
validates :account_id, presence: true validates :account_id, presence: true
@@ -171,18 +172,6 @@ class Conversation < ApplicationRecord
unread_messages.where(account_id: account_id).incoming.last(10) unread_messages.where(account_id: account_id).incoming.last(10)
end end
def push_event_data
Conversations::EventDataPresenter.new(self).push_data
end
def lock_event_data
Conversations::EventDataPresenter.new(self).lock_data
end
def webhook_data
Conversations::EventDataPresenter.new(self).push_data
end
def cached_label_list_array def cached_label_list_array
(cached_label_list || '').split(',').map(&:strip) (cached_label_list || '').split(',').map(&:strip)
end end
@@ -207,6 +196,10 @@ class Conversation < ApplicationRecord
"#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{uuid}" "#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{uuid}"
end end
def dispatch_conversation_updated_event(previous_changes = nil)
dispatcher_dispatch(CONVERSATION_UPDATED, previous_changes)
end
private private
def execute_after_update_commit_callbacks def execute_after_update_commit_callbacks
@@ -245,13 +238,17 @@ class Conversation < ApplicationRecord
def notify_conversation_updation def notify_conversation_updation
return unless previous_changes.keys.present? && allowed_keys? return unless previous_changes.keys.present? && allowed_keys?
dispatcher_dispatch(CONVERSATION_UPDATED, previous_changes) dispatch_conversation_updated_event(previous_changes)
end
def list_of_keys
%w[team_id assignee_id status snoozed_until custom_attributes label_list waiting_since first_reply_created_at
priority]
end end
def allowed_keys? def allowed_keys?
( (
previous_changes.keys.intersect?(%w[team_id assignee_id status snoozed_until custom_attributes label_list waiting_since first_reply_created_at previous_changes.keys.intersect?(list_of_keys) ||
priority]) ||
(previous_changes['additional_attributes'].present? && previous_changes['additional_attributes'][1].keys.intersect?(%w[conversation_language])) (previous_changes['additional_attributes'].present? && previous_changes['additional_attributes'][1].keys.intersect?(%w[conversation_language]))
) )
end end
@@ -314,3 +311,4 @@ end
Conversation.include_mod_with('Concerns::Conversation') Conversation.include_mod_with('Concerns::Conversation')
Conversation.include_mod_with('SentimentAnalysisHelper') Conversation.include_mod_with('SentimentAnalysisHelper')
Conversation.prepend_mod_with('Conversation')

View File

@@ -41,6 +41,8 @@ class AppliedSla < ApplicationRecord
} }
scope :missed, -> { where(sla_status: :missed) } scope :missed, -> { where(sla_status: :missed) }
after_update_commit :push_conversation_event
def push_event_data def push_event_data
{ {
id: id, id: id,
@@ -59,6 +61,16 @@ class AppliedSla < ApplicationRecord
private private
def push_conversation_event
# right now we simply use `CONVERSATION_UPDATED` event to notify the frontend
# we can eventually start using `CONVERSATION_SLA_UPDATED` event as required later
# for now the updated event should suffice
return unless saved_change_to_sla_status?
conversation.dispatch_conversation_updated_event
end
def ensure_account_id def ensure_account_id
self.account_id ||= sla_policy&.account_id self.account_id ||= sla_policy&.account_id
end end

View File

@@ -0,0 +1,5 @@
module Enterprise::Conversation
def list_of_keys
super + %w[sla_policy_id]
end
end

View File

@@ -3,7 +3,8 @@ module Enterprise::Conversations::EventDataPresenter
if account.feature_enabled?('sla') if account.feature_enabled?('sla')
super.merge( super.merge(
applied_sla: applied_sla&.push_event_data, applied_sla: applied_sla&.push_event_data,
sla_events: sla_events.map(&:push_event_data) sla_events: sla_events.map(&:push_event_data),
sla_policy_id: sla_policy_id
) )
else else
super super