diff --git a/app/javascript/dashboard/i18n/locale/en/automation.json b/app/javascript/dashboard/i18n/locale/en/automation.json index 9c3f9f0f1..a5dfad970 100644 --- a/app/javascript/dashboard/i18n/locale/en/automation.json +++ b/app/javascript/dashboard/i18n/locale/en/automation.json @@ -29,15 +29,15 @@ "ERROR": "Event is required" }, "EXECUTE": { - "LABEL": "Wait", - "AFTER_DELAY": "Wait for", + "LABEL": "Delayed execution", + "AFTER_DELAY": "Run after", "UNITS": { "MINUTES": "Minutes", "HOURS": "Hours", "DAYS": "Days" }, - "ERROR": "Wait must be between 10 minutes and 30 days", - "ENDS_IF_LABEL": "Ends the wait if", + "ERROR": "Delay must be between 10 minutes and 30 days", + "ENDS_IF_LABEL": "Won't run if", "ENDS_IF": { "STATUS": "the conversation's status changes, or its conditions no longer match.", "CUSTOMER_REPLY": "the customer replies, or the conditions no longer match.", @@ -46,6 +46,17 @@ }, "HELP_TEXT": "Only applies to conversations with activity after the rule is created." }, + "TRIGGER": { + "LABEL": "Trigger", + "WHEN_LABEL": "When", + "STATUS_LABEL": "Status is", + "INBOX_LABEL": "Inbox", + "OPTIONS": { + "CUSTOMER_UNRESPONSIVE": "Customer unresponsive", + "AGENT_UNRESPONSIVE": "Teammate unresponsive", + "CONVERSATION_STATUS": "Conversation in a status" + } + }, "CONDITIONS": { "LABEL": "Conditions" }, @@ -70,10 +81,10 @@ "404": "No automation rules found", "SECTIONS": { "INSTANT": "Automations", - "DELAYED": "Delayed (Wait)" + "DELAYED": "Delayed execution" }, "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." + "DELAY_DISABLED_BANNER": "Delayed execution is disabled for this account. Delayed rules won't run until it is enabled again." }, "DELETE": { "TITLE": "Delete Automation Rule", diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue b/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue index 20af449cb..c63418045 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue @@ -7,6 +7,10 @@ import ConditionRow from 'dashboard/components-next/filter/ConditionRow.vue'; import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue'; import NextButton from 'dashboard/components-next/button/Button.vue'; import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue'; +import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue'; +import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue'; +import DurationInput from 'dashboard/components-next/input/DurationInput.vue'; +import { DURATION_UNITS } from 'dashboard/components-next/input/constants'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; import { generateAutomationPayload, @@ -73,21 +77,27 @@ const INPUT_TYPE_MAP = { date: 'date', }; -const DELAY_UNITS = [ - { key: 'MINUTES', factor: 1 }, - { key: 'HOURS', factor: 60 }, - { key: 'DAYS', factor: 1440 }, -]; +const DEFAULT_DELAY_MINUTES = 240; // 4 hours const MIN_DELAY_MINUTES = 10; const MAX_DELAY_MINUTES = 43200; // 30 days -// 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); +// A delayed rule is expressed as one meaningful trigger instead of a raw event + conditions. Each +// trigger maps to the automation's event_name plus a preset condition: message_type for the two +// unresponsive cases (reply-chase / awaiting-agent), or a chosen status for conversation_updated. +const DELAYED_TRIGGERS = [ + { key: 'conversation_status', eventName: 'conversation_updated' }, + { + key: 'customer_unresponsive', + eventName: 'message_created', + messageType: 'outgoing', + }, + { + key: 'agent_unresponsive', + eventName: 'message_created', + messageType: 'incoming', + }, +]; +const DEFAULT_TRIGGER = DELAYED_TRIGGERS[0].key; +const DEFAULT_TRIGGER_STATUS = 'pending'; const { t } = useI18n(); const { isCloudFeatureEnabled } = useAccount(); @@ -134,12 +144,7 @@ const filterTypes = computed(() => { const event = eventName.value; if (!event || !props.automationTypes[event]) return []; - let attributes = getTranslatedAttributes(props.automationTypes, event); - // 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)); - } + const attributes = getTranslatedAttributes(props.automationTypes, event); return attributes.map(attr => { if (attr.disabled) { @@ -149,11 +154,7 @@ const filterTypes = computed(() => { const mappedInputType = INPUT_TYPE_MAP[attr.inputType] || 'plainText'; const options = props.getConditionDropdownValues(attr.key) || []; - // attribute_changed can't be re-evaluated at fire time, so hide it for delayed rules. - const availableOperators = (attr.filterOperators || []).filter( - op => !isDelayed.value || op.value !== 'attribute_changed' - ); - const filterOperators = availableOperators.map(op => { + const filterOperators = (attr.filterOperators || []).map(op => { const enriched = operators.value[op.value]; if (enriched) return enriched; return { @@ -179,15 +180,12 @@ const filterTypes = computed(() => { }); }); -const automationRuleEvents = computed(() => { - const events = isDelayed.value - ? AUTOMATION_RULE_EVENTS.filter(event => DELAYED_EVENTS.includes(event.key)) - : AUTOMATION_RULE_EVENTS; - return events.map(event => ({ +const automationRuleEvents = computed(() => + AUTOMATION_RULE_EVENTS.map(event => ({ ...event, value: t(`AUTOMATION.EVENTS.${event.value}`), - })); -}); + })) +); const hasAutomationMutated = computed(() => { return Boolean( @@ -219,31 +217,37 @@ const allowsDelayedExecution = computed(() => isCloudFeatureEnabled(FEATURE_FLAGS.DELAYED_AUTOMATIONS) ); -// The UI curates delayed rules to the supported shapes (DELAYED_EVENT_ATTRS) plus the backend's -// no-attribute_changed rule; anything outside that resets to a supported default. -const delayRestrictionReason = computed(() => { - const conditions = automation.value?.conditions || []; - if ( - conditions.some( - condition => condition.filter_operator === 'attribute_changed' - ) - ) { - return 'ATTRIBUTE_CHANGED'; - } - const allowed = DELAYED_EVENT_ATTRS[eventName.value]; - if (!allowed) return 'UNSUPPORTED_EVENT'; - if ( - conditions.some( - condition => - condition.attribute_key && !allowed.includes(condition.attribute_key) - ) - ) { - return 'UNSUPPORTED_CONDITION'; - } - return null; -}); +// Trigger controls for the delayed flow. They own event_name + conditions while the wait is on. +// selectedTrigger / triggerStatus are plain values (FilterSelect); triggerInboxes is an array of +// { id, name } (MultiSelect) — empty means the rule applies to every inbox. +const selectedTrigger = ref(DEFAULT_TRIGGER); +const triggerStatus = ref(DEFAULT_TRIGGER_STATUS); +const triggerInboxes = ref([]); -const isDelaySupported = computed(() => delayRestrictionReason.value === null); +const isStatusTrigger = computed( + () => selectedTrigger.value === 'conversation_status' +); + +// FilterSelect expects { label, value }. +const statusSelectOptions = computed(() => + (props.getConditionDropdownValues('status') || []) + .filter(option => option.id !== 'all') + .map(option => ({ value: option.id, label: option.name })) +); + +const triggerSelectOptions = computed(() => + DELAYED_TRIGGERS.map(trigger => ({ + value: trigger.key, + label: t( + `AUTOMATION.ADD.FORM.TRIGGER.OPTIONS.${trigger.key.toUpperCase()}` + ), + })) +); + +// MultiSelect expects (and returns) { id, name } options. +const inboxOptions = computed( + () => props.getConditionDropdownValues('inbox_id') || [] +); // What ends the wait, mirroring the backend episode that arms the rule. Shown to the user so // they can predict when the rule runs. Conversation rules key on status; message rules key on @@ -263,82 +267,113 @@ const waitEndsKey = computed(() => { return 'GENERIC'; }); -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); -}); +// DurationInput holds the wait in minutes and clamps to [MIN, MAX]; the unit is display-only. +const delayMinutes = ref(DEFAULT_DELAY_MINUTES); +const delayUnit = ref(DURATION_UNITS.HOURS); const executionDelayInvalid = computed( - () => - isDelayed.value && - (!Number.isFinite(delayInMinutes.value) || - delayInMinutes.value < MIN_DELAY_MINUTES || - delayInMinutes.value > MAX_DELAY_MINUTES) + () => isDelayed.value && !Number.isFinite(delayMinutes.value) ); -// Hydrate the delay controls from a delay (minutes), using the largest clean unit. Passed in -// by open() rather than read from `automation`, whose model prop only settles a tick later. +// Show the wait in the largest whole unit (240 min → 4 hours). Passed in by open() rather than +// read from `automation`, whose model prop only settles a tick later. const syncDelayFromDelay = delay => { isDelayed.value = Boolean(delay); - 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; + const minutes = delay || DEFAULT_DELAY_MINUTES; + if (minutes % 1440 === 0) delayUnit.value = DURATION_UNITS.DAYS; + else if (minutes % 60 === 0) delayUnit.value = DURATION_UNITS.HOURS; + else delayUnit.value = DURATION_UNITS.MINUTES; + delayMinutes.value = minutes; }; -watch([isDelayed, delayInMinutes], () => { +watch([isDelayed, delayMinutes], () => { if (!automation.value || !allowsDelayedExecution.value) return; automation.value.execution_delay = isDelayed.value - ? delayInMinutes.value + ? delayMinutes.value : null; }); -// Reset to the first attribute the narrowed dropdown still offers for this event, so the -// attribute and its operators are guaranteed to exist (events differ: e.g. conversation_opened -// has no status attribute, only inbox). -const resetToSupportedCondition = () => { - const [firstType] = filterTypes.value; - if (!firstType) return; - automation.value.conditions = [ - { - attribute_key: firstType.value, - filter_operator: firstType.filterOperators?.[0]?.value ?? 'equal_to', - values: '', - query_operator: 'and', - custom_attribute_type: '', - }, +const buildTriggerCondition = (attributeKey, values) => ({ + attribute_key: attributeKey, + filter_operator: 'equal_to', + values, + query_operator: 'and', + custom_attribute_type: '', +}); + +// Write the selected trigger (plus optional inbox scope) onto the rule's event_name + conditions. +const applyDelayedTrigger = () => { + const trigger = DELAYED_TRIGGERS.find( + item => item.key === selectedTrigger.value + ); + if (!automation.value || !trigger) return; + automation.value.event_name = trigger.eventName; + const conditions = [ + trigger.messageType + ? buildTriggerCondition('message_type', trigger.messageType) + : buildTriggerCondition('status', triggerStatus.value), ]; + if (triggerInboxes.value.length) { + conditions.push( + buildTriggerCondition( + 'inbox_id', + triggerInboxes.value.map(inbox => inbox.id) + ) + ); + } + automation.value.conditions = conditions; }; -// 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. +// A single value is a raw string in create mode and an option object ({ id }) after edit-mode +// formatting; return its plain value either way. +const rawConditionValue = condition => { + const raw = Array.isArray(condition?.values) + ? condition.values[0] + : condition?.values; + return raw && typeof raw === 'object' ? raw.id : raw; +}; + +// Populate the trigger controls from an existing delayed rule when editing. +const hydrateTriggerFromAutomation = () => { + const conditions = automation.value?.conditions || []; + const byKey = key => conditions.find(c => c.attribute_key === key); + const messageType = rawConditionValue(byKey('message_type')); + if (messageType === 'incoming') selectedTrigger.value = 'agent_unresponsive'; + else if (messageType === 'outgoing') + selectedTrigger.value = 'customer_unresponsive'; + else { + selectedTrigger.value = 'conversation_status'; + triggerStatus.value = + rawConditionValue(byKey('status')) || DEFAULT_TRIGGER_STATUS; + } + const inboxValues = byKey('inbox_id')?.values || []; + const inboxIds = inboxValues.map(value => + value && typeof value === 'object' ? value.id : value + ); + triggerInboxes.value = inboxOptions.value.filter(inbox => + inboxIds.includes(inbox.id) + ); +}; + +// Turning the wait on (create) sets the default trigger's event + conditions. 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(); + if (delayed && automation.value && !isEditMode.value) applyDelayedTrigger(); }); -// 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. -// Actions are kept. Resetting makes the rule supported again, so this can't loop. -watch([isDelayed, isDelaySupported], () => { - if (!isDelayed.value || !automation.value) return; - if (!isDelaySupported.value) resetToSupportedCondition(); +// Any trigger-control change re-derives event_name + conditions. After hydration this simply +// re-writes the same values, so it stays idempotent (no reference change → no loop). +watch([selectedTrigger, triggerStatus, triggerInboxes], () => { + if (isDelayed.value) applyDelayedTrigger(); }); +// Opening an existing delayed rule mirrors its event/conditions into the trigger controls. +watch( + () => automation.value, + () => { + if (isDelayed.value && automation.value) hydrateTriggerFromAutomation(); + } +); + watch( () => automation.value, () => { @@ -427,85 +462,7 @@ defineExpose({ open, close }); :error="errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''" :placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')" /> -
- {{ $t('AUTOMATION.FORM.RESET_MESSAGE') }} -
-- - {{ $t('AUTOMATION.ADD.FORM.EXECUTE.ENDS_IF_LABEL') }} - - {{ $t(`AUTOMATION.ADD.FORM.EXECUTE.ENDS_IF.${waitEndsKey}`) }} -
-- {{ $t('AUTOMATION.ADD.FORM.EXECUTE.HELP_TEXT') }} -
-+ + {{ $t('AUTOMATION.ADD.FORM.EXECUTE.ENDS_IF_LABEL') }} + + {{ $t(`AUTOMATION.ADD.FORM.EXECUTE.ENDS_IF.${waitEndsKey}`) }} +
++ {{ $t('AUTOMATION.ADD.FORM.EXECUTE.HELP_TEXT') }} +
++ {{ $t('AUTOMATION.FORM.RESET_MESSAGE') }} +
+