feat(automations): add delayed execution for automation rules
This commit is contained in:
@@ -10,6 +10,7 @@ export const FEATURE_FLAGS = {
|
||||
CANNED_RESPONSES: 'canned_responses',
|
||||
CRM: 'crm',
|
||||
CUSTOM_ATTRIBUTES: 'custom_attributes',
|
||||
DELAYED_AUTOMATIONS: 'delayed_automations',
|
||||
INBOX_MANAGEMENT: 'inbox_management',
|
||||
INTEGRATIONS: 'integrations',
|
||||
LABELS: 'labels',
|
||||
|
||||
@@ -208,6 +208,12 @@ export const generateAutomationPayload = payload => {
|
||||
return automation;
|
||||
};
|
||||
|
||||
export const formatDelay = minutes => {
|
||||
if (minutes % 1440 === 0) return `${minutes / 1440}d`;
|
||||
if (minutes % 60 === 0) return `${minutes / 60}h`;
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
export const isCustomAttribute = (attrs, key) => {
|
||||
return attrs.find(attr => attr.key === key);
|
||||
};
|
||||
|
||||
@@ -28,6 +28,18 @@
|
||||
"PLACEHOLDER": "Please select one",
|
||||
"ERROR": "Event is required"
|
||||
},
|
||||
"EXECUTE": {
|
||||
"LABEL": "Execute",
|
||||
"IMMEDIATELY": "Immediately",
|
||||
"AFTER_DELAY": "After a delay of",
|
||||
"UNITS": {
|
||||
"MINUTES": "Minutes",
|
||||
"HOURS": "Hours",
|
||||
"DAYS": "Days"
|
||||
},
|
||||
"ERROR": "Delay must be between 10 minutes and 30 days",
|
||||
"HELP_TEXT": "Delayed rules run only if the conditions still hold when the delay ends, and apply to conversations with activity after the rule is created."
|
||||
},
|
||||
"CONDITIONS": {
|
||||
"LABEL": "Conditions"
|
||||
},
|
||||
@@ -49,7 +61,9 @@
|
||||
"CREATED_ON": "Created on",
|
||||
"ACTIONS": "Actions"
|
||||
},
|
||||
"404": "No automation rules found"
|
||||
"404": "No automation rules found",
|
||||
"DELAY_BADGE": "Runs after {delay}",
|
||||
"DELAY_DISABLED_BANNER": "Delayed execution is disabled for this account. Rules with a delay will not run until it is enabled again."
|
||||
},
|
||||
"DELETE": {
|
||||
"TITLE": "Delete Automation Rule",
|
||||
|
||||
@@ -10,6 +10,7 @@ const START_VALUE = {
|
||||
name: null,
|
||||
description: null,
|
||||
event_name: 'conversation_created',
|
||||
execution_delay: null,
|
||||
conditions: [
|
||||
{
|
||||
attribute_key: 'status',
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
showActionInput,
|
||||
} from 'dashboard/helper/automationHelper';
|
||||
import { validateAutomation } from 'dashboard/helper/validations';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -71,6 +72,14 @@ const INPUT_TYPE_MAP = {
|
||||
date: 'date',
|
||||
};
|
||||
|
||||
const DELAY_UNITS = [
|
||||
{ key: 'MINUTES', factor: 1 },
|
||||
{ key: 'HOURS', factor: 60 },
|
||||
{ key: 'DAYS', factor: 1440 },
|
||||
];
|
||||
const MIN_DELAY_MINUTES = 10;
|
||||
const MAX_DELAY_MINUTES = 43200; // 30 days
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
const { operators } = useOperators();
|
||||
@@ -184,6 +193,50 @@ const hasActionErrors = computed(() =>
|
||||
Object.keys(errors.value).some(key => key.startsWith('action_'))
|
||||
);
|
||||
|
||||
const allowsDelayedExecution = computed(() =>
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.DELAYED_AUTOMATIONS)
|
||||
);
|
||||
|
||||
const executeMode = ref('immediate');
|
||||
const delayValue = ref(4);
|
||||
const delayUnit = ref('HOURS');
|
||||
|
||||
const delayInMinutes = computed(() => {
|
||||
const factor =
|
||||
DELAY_UNITS.find(unit => unit.key === delayUnit.value)?.factor || 1;
|
||||
return Math.round(Number(delayValue.value) * factor);
|
||||
});
|
||||
|
||||
const executionDelayInvalid = computed(
|
||||
() =>
|
||||
executeMode.value === 'delayed' &&
|
||||
(!Number.isFinite(delayInMinutes.value) ||
|
||||
delayInMinutes.value < MIN_DELAY_MINUTES ||
|
||||
delayInMinutes.value > MAX_DELAY_MINUTES)
|
||||
);
|
||||
|
||||
// Hydrate the delay controls from the automation, using the largest clean unit.
|
||||
const syncDelayFromAutomation = () => {
|
||||
const delay = automation.value?.execution_delay;
|
||||
executeMode.value = delay ? 'delayed' : 'immediate';
|
||||
if (!delay) {
|
||||
delayValue.value = 4;
|
||||
delayUnit.value = 'HOURS';
|
||||
return;
|
||||
}
|
||||
const unit =
|
||||
[...DELAY_UNITS].reverse().find(u => delay % u.factor === 0) ||
|
||||
DELAY_UNITS[0];
|
||||
delayUnit.value = unit.key;
|
||||
delayValue.value = delay / unit.factor;
|
||||
};
|
||||
|
||||
watch([executeMode, delayInMinutes], () => {
|
||||
if (!automation.value || !allowsDelayedExecution.value) return;
|
||||
automation.value.execution_delay =
|
||||
executeMode.value === 'delayed' ? delayInMinutes.value : null;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => automation.value,
|
||||
() => {
|
||||
@@ -218,6 +271,7 @@ const syncCustomAttributeTypes = () => {
|
||||
|
||||
const open = () => {
|
||||
resetValidation();
|
||||
syncDelayFromAutomation();
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
@@ -230,8 +284,13 @@ const emitSaveAutomation = () => {
|
||||
syncCustomAttributeTypes();
|
||||
const conditionsValid = isConditionsValid();
|
||||
errors.value = validateAutomation(automation.value);
|
||||
if (allowsDelayedExecution.value && executionDelayInvalid.value) {
|
||||
errors.value.execution_delay = true;
|
||||
}
|
||||
if (Object.keys(errors.value).length === 0 && conditionsValid) {
|
||||
const payload = generateAutomationPayload(automation.value);
|
||||
// The API rejects the param when the feature is off; existing values are kept server-side.
|
||||
if (!allowsDelayedExecution.value) delete payload.execution_delay;
|
||||
emit('save', payload, props.mode);
|
||||
}
|
||||
};
|
||||
@@ -293,6 +352,47 @@ defineExpose({ open, close });
|
||||
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="allowsDelayedExecution" class="mb-6">
|
||||
<label :class="{ error: errors.execution_delay }">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.LABEL') }}
|
||||
</label>
|
||||
<div class="flex flex-wrap items-center gap-4 mt-1">
|
||||
<label class="flex items-center gap-1.5 text-sm cursor-pointer">
|
||||
<input v-model="executeMode" type="radio" value="immediate" />
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.IMMEDIATELY') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-sm cursor-pointer">
|
||||
<input v-model="executeMode" type="radio" value="delayed" />
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.AFTER_DELAY') }}
|
||||
</label>
|
||||
<template v-if="executeMode === 'delayed'">
|
||||
<input
|
||||
v-model.number="delayValue"
|
||||
type="number"
|
||||
min="1"
|
||||
class="!m-0 !w-24"
|
||||
/>
|
||||
<select v-model="delayUnit" class="!m-0 !w-32">
|
||||
<option
|
||||
v-for="unit in DELAY_UNITS"
|
||||
:key="unit.key"
|
||||
:value="unit.key"
|
||||
>
|
||||
{{ $t(`AUTOMATION.ADD.FORM.EXECUTE.UNITS.${unit.key}`) }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
</div>
|
||||
<span v-if="errors.execution_delay" class="text-xs text-n-ruby-9">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.ERROR') }}
|
||||
</span>
|
||||
<p
|
||||
v-else-if="executeMode === 'delayed'"
|
||||
class="text-xs text-n-slate-11 pt-1 mb-0"
|
||||
>
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.HELP_TEXT') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Conditions Start -->
|
||||
<section class="mb-5">
|
||||
<label>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { messageStamp } from 'shared/helpers/timeHelper';
|
||||
import { formatDelay } from 'dashboard/helper/automationHelper';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
|
||||
@@ -43,6 +44,16 @@ const automationActive = computed({
|
||||
<span class="text-body-main text-n-slate-12 truncate">
|
||||
{{ automation.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="automation.execution_delay"
|
||||
class="text-xs px-1.5 py-0.5 rounded-md bg-n-alpha-2 text-n-slate-11 whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
{{
|
||||
$t('AUTOMATION.LIST.DELAY_BADGE', {
|
||||
delay: formatDelay(automation.execution_delay),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
|
||||
<span class="text-body-main text-n-slate-11 truncate">
|
||||
{{ automation.description }}
|
||||
|
||||
@@ -52,6 +52,14 @@ const isSLAEnabled = computed(() =>
|
||||
getters['accounts/isFeatureEnabledonAccount'].value(accountId.value, 'sla')
|
||||
);
|
||||
|
||||
const showDelayDisabledBanner = computed(
|
||||
() =>
|
||||
!getters['accounts/isFeatureEnabledonAccount'].value(
|
||||
accountId.value,
|
||||
'delayed_automations'
|
||||
) && records.value.some(automation => automation.execution_delay)
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('inboxes/get');
|
||||
store.dispatch('agents/get');
|
||||
@@ -212,6 +220,12 @@ const tableHeaders = computed(() => {
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
v-if="showDelayDisabledBanner"
|
||||
class="px-4 py-3 mb-4 text-sm rounded-lg bg-n-amber-3 text-n-amber-12"
|
||||
>
|
||||
{{ $t('AUTOMATION.LIST.DELAY_DISABLED_BANNER') }}
|
||||
</div>
|
||||
<BaseTable
|
||||
:headers="tableHeaders"
|
||||
:items="filteredRecords"
|
||||
|
||||
Reference in New Issue
Block a user