feat(automations): scope delayed rules to meaningful events/conditions and group the list

This commit is contained in:
Tanmay Deep Sharma
2026-07-20 17:42:33 +05:30
parent a450d6437d
commit a363f22d82
4 changed files with 108 additions and 36 deletions
@@ -68,6 +68,10 @@
"ACTIONS": "Actions"
},
"404": "No automation rules found",
"SECTIONS": {
"INSTANT": "Automations",
"DELAYED": "Delayed (Wait)"
},
"DELAY_BADGE": "Runs after {delay}",
"DELAY_DISABLED_BANNER": "Waits are disabled for this account. Rules with a wait won't run until it is enabled again."
},
@@ -80,8 +80,14 @@ const DELAY_UNITS = [
];
const MIN_DELAY_MINUTES = 10;
const MAX_DELAY_MINUTES = 43200; // 30 days
// Conversation-level delayed rules can filter on status plus immutable attributes (inbox).
const DELAYED_CONVERSATION_ATTRS = ['status', 'inbox_id'];
// The only valid delayed (wait) rule shapes: each supported event maps to the conditions that
// stay meaningful across the wait — status / message_type re-checked at fire time, plus the
// immutable inbox for scoping. Every other event/condition is hidden while the wait is on.
const DELAYED_EVENT_ATTRS = {
conversation_updated: ['status', 'inbox_id'],
message_created: ['message_type', 'inbox_id'],
};
const DELAYED_EVENTS = Object.keys(DELAYED_EVENT_ATTRS);
const { t } = useI18n();
const { isCloudFeatureEnabled } = useAccount();
@@ -129,11 +135,10 @@ const filterTypes = computed(() => {
if (!event || !props.automationTypes[event]) return [];
let attributes = getTranslatedAttributes(props.automationTypes, event);
// A delayed conversation-level rule can filter only on status and immutable attributes (inbox).
if (isDelayed.value && event !== 'message_created') {
attributes = attributes.filter(attr =>
DELAYED_CONVERSATION_ATTRS.includes(attr.key)
);
// A delayed rule can only filter on the attributes that stay meaningful across the wait.
if (isDelayed.value) {
const allowed = DELAYED_EVENT_ATTRS[event] || [];
attributes = attributes.filter(attr => allowed.includes(attr.key));
}
return attributes.map(attr => {
@@ -174,12 +179,15 @@ const filterTypes = computed(() => {
});
});
const automationRuleEvents = computed(() =>
AUTOMATION_RULE_EVENTS.map(event => ({
const automationRuleEvents = computed(() => {
const events = isDelayed.value
? AUTOMATION_RULE_EVENTS.filter(event => DELAYED_EVENTS.includes(event.key))
: AUTOMATION_RULE_EVENTS;
return events.map(event => ({
...event,
value: t(`AUTOMATION.EVENTS.${event.value}`),
}))
);
}));
});
const hasAutomationMutated = computed(() => {
return Boolean(
@@ -221,15 +229,15 @@ const delayRestrictionReason = computed(() => {
) {
return 'ATTRIBUTE_CHANGED';
}
const allowed = DELAYED_EVENT_ATTRS[eventName.value];
if (!allowed) return 'UNSUPPORTED_EVENT';
if (
eventName.value !== 'message_created' &&
conditions.some(
condition =>
condition.attribute_key &&
!DELAYED_CONVERSATION_ATTRS.includes(condition.attribute_key)
condition.attribute_key && !allowed.includes(condition.attribute_key)
)
) {
return 'CONVERSATION_NON_STATUS';
return 'UNSUPPORTED_CONDITION';
}
return null;
});
@@ -311,6 +319,16 @@ const resetToSupportedCondition = () => {
];
};
// A delay narrows the event list, so if the wait is turned on while an unsupported event is
// selected (e.g. conversation_opened), switch to a meaningful default and reset its conditions
// and actions the same way the event dropdown would.
watch(isDelayed, delayed => {
if (!delayed || !automation.value) return;
if (DELAYED_EVENTS.includes(automation.value.event_name)) return;
automation.value.event_name = DELAYED_EVENTS[0];
props.onEventChange();
});
// A delay narrows the condition options, so whenever the rule becomes unsupported while the
// wait is on — toggling it on, or switching to an event whose default condition isn't allowed
// (e.g. conversation_opened defaults to browser_language) — reset to a supported default.
@@ -35,6 +35,31 @@ const filteredRecords = computed(() => {
if (!query) return records.value;
return picoSearch(records.value, query, ['name', 'description']);
});
// Delayed (wait) rules run on a different lifecycle, so list them in their own section.
const hasDelayedRecords = computed(() =>
records.value.some(automation => automation.execution_delay)
);
const sections = computed(() => {
const instant = [];
const delayed = [];
filteredRecords.value.forEach(automation =>
(automation.execution_delay ? delayed : instant).push(automation)
);
return [
{
key: 'instant',
label: t('AUTOMATION.LIST.SECTIONS.INSTANT'),
items: instant,
},
{
key: 'delayed',
label: t('AUTOMATION.LIST.SECTIONS.DELAYED'),
items: delayed,
},
].filter(section => section.items.length);
});
const uiFlags = computed(() => getters['automations/getUIFlags'].value);
const accountId = computed(() => getters.getCurrentAccountId.value);
@@ -226,25 +251,43 @@ const tableHeaders = computed(() => {
>
{{ $t('AUTOMATION.LIST.DELAY_DISABLED_BANNER') }}
</div>
<template v-if="filteredRecords.length">
<div
v-for="section in sections"
:key="section.key"
class="mb-6 last:mb-0"
>
<h4
v-if="hasDelayedRecords"
class="mb-2 text-sm font-medium text-n-slate-11"
>
{{ section.label }}
</h4>
<BaseTable :headers="tableHeaders" :items="section.items">
<template #row="{ items }">
<AutomationRuleRow
v-for="automation in items"
:key="automation.id"
:automation="automation"
:loading="loading[automation.id]"
@clone="cloneAutomation"
@toggle="toggleAutomation"
@edit="openEditPopup"
@delete="openDeletePopup"
/>
</template>
</BaseTable>
</div>
</template>
<BaseTable
v-else
:headers="tableHeaders"
:items="filteredRecords"
:items="[]"
:no-data-message="
searchQuery ? $t('AUTOMATION.NO_RESULTS') : $t('AUTOMATION.LIST.404')
"
>
<template #row="{ items }">
<AutomationRuleRow
v-for="automation in items"
:key="automation.id"
:automation="automation"
:loading="loading[automation.id]"
@clone="cloneAutomation"
@toggle="toggleAutomation"
@edit="openEditPopup"
@delete="openDeletePopup"
/>
</template>
<template #row />
</BaseTable>
</template>