diff --git a/app/controllers/api/v1/accounts/automation_rules_controller.rb b/app/controllers/api/v1/accounts/automation_rules_controller.rb index 0840d0eea..d02979095 100644 --- a/app/controllers/api/v1/accounts/automation_rules_controller.rb +++ b/app/controllers/api/v1/accounts/automation_rules_controller.rb @@ -3,6 +3,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont before_action :check_authorization before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone] + before_action :ensure_execution_delay_allowed, only: [:create, :update] def index @automation_rules = Current.account.automation_rules @@ -47,21 +48,42 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont def clone automation_rule = Current.account.automation_rules.find_by(id: params[:automation_rule_id]) - new_rule = automation_rule.dup - new_rule.save! - @automation_rule = new_rule + # Dropping the delay here would clone a paused wait into a rule that fires instantly on the + # next matching event, so refuse rather than rewrite what the rule means. + return render_delayed_automations_error if automation_rule.execution_delay.present? && !delayed_automations_enabled? + + @automation_rule = automation_rule.dup + @automation_rule.save! end private def automation_rules_permit + permitted_attributes = [:name, :description, :event_name, :active] + permitted_attributes << :execution_delay if delayed_automations_enabled? + params.permit( - :name, :description, :event_name, :active, + *permitted_attributes, conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }], actions: [:action_name, { action_params: [] }] ) end + def ensure_execution_delay_allowed + return if delayed_automations_enabled? + return if params[:execution_delay].blank? + + render_delayed_automations_error + end + + def render_delayed_automations_error + render json: { error: 'Delayed automations are not enabled for this account.' }, status: :unprocessable_entity + end + + def delayed_automations_enabled? + Current.account.feature_enabled?('delayed_automations') + end + def fetch_automation_rule @automation_rule = Current.account.automation_rules.find_by(id: params[:id]) end diff --git a/app/javascript/dashboard/components-next/side-panel/SidePanel.vue b/app/javascript/dashboard/components-next/side-panel/SidePanel.vue new file mode 100644 index 000000000..55b7f7daf --- /dev/null +++ b/app/javascript/dashboard/components-next/side-panel/SidePanel.vue @@ -0,0 +1,154 @@ + + + diff --git a/app/javascript/dashboard/composables/useEditableAutomation.js b/app/javascript/dashboard/composables/useEditableAutomation.js index 3f4e65b3c..2c0686eb0 100644 --- a/app/javascript/dashboard/composables/useEditableAutomation.js +++ b/app/javascript/dashboard/composables/useEditableAutomation.js @@ -1,8 +1,8 @@ import useAutomationValues from './useAutomationValues'; import { - getCustomAttributeInputType, filterCustomAttributes, + getCustomAttributeInputType, getStandardAttributeInputType, isCustomAttribute, } from 'dashboard/helper/automationHelper'; @@ -40,11 +40,16 @@ export function useEditableAutomation() { condition.attribute_key ); } + const base = { + ...condition, + query_operator: condition.query_operator || 'and', + }; + if (inputType === 'plain_text' || inputType === 'date') { - return { ...condition, values: condition.values[0] }; + return { ...base, values: condition.values[0] }; } if (inputType === 'comma_separated_plain_text') { - return { ...condition, values: condition.values.join(',') }; + return { ...base, values: condition.values.join(',') }; } const dropdownValues = getConditionDropdownValues( condition.attribute_key @@ -56,14 +61,12 @@ export function useEditableAutomation() { if (hasBooleanOptions) { return { - ...condition, - query_operator: condition.query_operator || 'and', + ...base, values: dropdownValues.find(item => item.id === condition.values[0]), }; } return { - ...condition, - query_operator: condition.query_operator || 'and', + ...base, values: [...dropdownValues].filter(item => [...condition.values].includes(item.id) ), diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 04f46890e..b104097e9 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -14,6 +14,7 @@ export const FEATURE_FLAGS = { CRM: 'crm', CUSTOM_ATTRIBUTES: 'custom_attributes', DATA_IMPORT: 'data_import', + DELAYED_AUTOMATIONS: 'delayed_automations', API_AND_WEBHOOKS: 'api_and_webhooks', INBOX_MANAGEMENT: 'inbox_management', INTEGRATIONS: 'integrations', diff --git a/app/javascript/dashboard/helper/automationHelper.js b/app/javascript/dashboard/helper/automationHelper.js index 8aed8dcda..c66dc204c 100644 --- a/app/javascript/dashboard/helper/automationHelper.js +++ b/app/javascript/dashboard/helper/automationHelper.js @@ -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); }; diff --git a/app/javascript/dashboard/i18n/locale/en/automation.json b/app/javascript/dashboard/i18n/locale/en/automation.json index 2c4852dc8..98393bb26 100644 --- a/app/javascript/dashboard/i18n/locale/en/automation.json +++ b/app/javascript/dashboard/i18n/locale/en/automation.json @@ -28,6 +28,43 @@ "PLACEHOLDER": "Please select one", "ERROR": "Event is required" }, + "RUN_TYPE": { + "LABEL": "When should this rule run?", + "OPTIONS": { + "INSTANT": { + "LABEL": "Run instantly", + "DESCRIPTION": "Runs the moment an event happens, as long as the conditions match." + }, + "WAIT": { + "LABEL": "Run after a wait", + "DESCRIPTION": "Waits for a set time and runs only if things are still the same. If they change, the rule is skipped." + } + } + }, + "WAIT": { + "LABEL": "Wait condition", + "WHEN_LABEL": "When", + "STATUS_LABEL": "Status is", + "FOR_LABEL": "For", + "INBOX_LABEL": "Inbox", + "ERROR": "The wait must be between 10 minutes and 30 days", + "OPTIONS": { + "CONVERSATION_STATUS": "Conversation stays in a status", + "CUSTOMER_UNRESPONSIVE": "Customer hasn't replied", + "AGENT_UNRESPONSIVE": "No teammate has replied" + }, + "DURATION": { + "MINUTES": "{count} minute | {count} minutes", + "HOURS": "{count} hour | {count} hours", + "DAYS": "{count} day | {count} days" + }, + "EXPLANATION": { + "CONVERSATION_STATUS": "The clock starts when a conversation moves into this status. If it is still there {duration} later, the actions below run. If the status changes before then, nothing happens.", + "CUSTOMER_UNRESPONSIVE": "The clock starts when a teammate replies. If the customer hasn't written back {duration} later, the actions below run. If they do reply, nothing happens, and the next teammate reply starts the clock again.", + "AGENT_UNRESPONSIVE": "The clock starts when a customer sends a message. If no teammate has replied {duration} later, the actions below run. If someone replies before then, nothing happens." + }, + "FOOTNOTE": "Only new activity starts a clock. Conversations already sitting in this state are picked up the next time something happens on them." + }, "CONDITIONS": { "LABEL": "Conditions" }, @@ -49,7 +86,14 @@ "CREATED_ON": "Created on", "ACTIONS": "Actions" }, - "404": "No automation rules found" + "404": "No automation rules found", + "404_DELAYED": "No rules run after a wait yet", + "TABS": { + "INSTANT": "Runs instantly", + "DELAYED": "Runs after a wait" + }, + "DELAY_BADGE": "Runs after {delay}", + "DELAY_DISABLED_BANNER": "Delayed execution is turned off for this account, so the rules below that run after a wait are paused until it is turned back on." }, "DELETE": { "TITLE": "Delete Automation Rule", diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue b/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue index 472dfe08e..c404b15d1 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue @@ -10,6 +10,7 @@ const START_VALUE = { name: null, description: null, event_name: 'conversation_created', + execution_delay: null, conditions: [ { attribute_key: 'status', @@ -44,10 +45,10 @@ const { manifestCustomAttributes, } = useAutomation(START_VALUE); -const open = () => { +const open = (executionDelay = null) => { automation.value = structuredClone(START_VALUE); manifestCustomAttributes(); - formRef.value?.open(); + formRef.value?.open(executionDelay); }; const close = () => formRef.value?.close(); diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue b/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue index d78c40749..654c6e348 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue @@ -3,18 +3,24 @@ import { ref, computed, h, useTemplateRef, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { useAccount } from 'dashboard/composables/useAccount'; import { useOperators } from 'dashboard/components-next/filter/operators'; -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 Dialog from 'dashboard/components-next/dialog/Dialog.vue'; +import SidePanel from 'dashboard/components-next/side-panel/SidePanel.vue'; import { generateAutomationPayload, getAttributes, - getFileName, - showActionInput, } from 'dashboard/helper/automationHelper'; import { validateAutomation } from 'dashboard/helper/validations'; -import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants'; +import { FEATURE_FLAGS } from 'dashboard/featureFlags'; +import { DURATION_UNITS } from 'dashboard/components-next/input/constants'; +import { + AUTOMATION_RULE_EVENTS, + AUTOMATION_ACTION_TYPES, + DEFAULT_DELAY_MINUTES, +} from './constants'; +import AutomationRunTypeSelector from './components/AutomationRunTypeSelector.vue'; +import AutomationWaitCondition from './components/AutomationWaitCondition.vue'; +import AutomationInstantTrigger from './components/AutomationInstantTrigger.vue'; +import AutomationActions from './components/AutomationActions.vue'; const props = defineProps({ mode: { @@ -75,12 +81,62 @@ const { t } = useI18n(); const { isCloudFeatureEnabled } = useAccount(); const { operators } = useOperators(); -const dialogRef = ref(null); -const conditionsRef = useTemplateRef('conditionsRef'); +const panelRef = ref(null); +const instantTriggerRef = useTemplateRef('instantTriggerRef'); const errors = ref({}); const isEditMode = computed(() => props.mode === 'edit'); +const allowsDelayedExecution = computed(() => + isCloudFeatureEnabled(FEATURE_FLAGS.DELAYED_AUTOMATIONS) +); + +// The wait lives here rather than in the wait section so that switching between the two run +// types doesn't discard a duration the user already typed in. +const isDelayed = ref(false); +const isSavedWait = ref(false); +const delayMinutes = ref(DEFAULT_DELAY_MINUTES); +const delayUnit = ref(DURATION_UNITS.HOURS); +// Bumped on every open() so the wait section remounts and re-reads the rule it is given. +const waitSectionKey = ref(0); + +const executionDelayInvalid = computed( + () => isDelayed.value && !Number.isFinite(delayMinutes.value) +); + +const statusOptions = computed(() => + (props.getConditionDropdownValues('status') || []) + .filter(option => option.id !== 'all') + .map(option => ({ value: option.id, label: option.name })) +); + +const inboxOptions = computed( + () => props.getConditionDropdownValues('inbox_id') || [] +); + +// Show the wait in the largest whole unit (240 min → 4 hours). The delay is passed in by open() +// rather than read from `automation`, whose model prop only settles a tick later. +const syncDelayState = executionDelay => { + isDelayed.value = Boolean(executionDelay); + isSavedWait.value = isEditMode.value && Boolean(executionDelay); + const minutes = executionDelay || 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; + waitSectionKey.value += 1; +}; + +watch([isDelayed, delayMinutes], () => { + // Switching to "run instantly" hands the conditions back to the instant editor. + if (!isDelayed.value) isSavedWait.value = false; + + if (!automation.value || !allowsDelayedExecution.value) return; + automation.value.execution_delay = isDelayed.value + ? delayMinutes.value + : null; +}); + const titleKey = computed(() => isEditMode.value ? 'AUTOMATION.EDIT.TITLE' : 'AUTOMATION.ADD.TITLE' ); @@ -176,14 +232,6 @@ const automationActionTypes = computed(() => { })); }); -const hasConditionErrors = computed(() => - Object.keys(errors.value).some(key => key.startsWith('condition_')) -); - -const hasActionErrors = computed(() => - Object.keys(errors.value).some(key => key.startsWith('action_')) -); - watch( () => automation.value, () => { @@ -194,14 +242,11 @@ watch( { deep: true } ); -const isConditionsValid = () => { - if (!conditionsRef.value) return true; - return conditionsRef.value.every(condition => condition.validate()); -}; +const isConditionsValid = () => instantTriggerRef.value?.validate() ?? true; const resetValidation = () => { errors.value = {}; - conditionsRef.value?.forEach(c => c.resetValidation()); + instantTriggerRef.value?.resetValidation(); }; const syncCustomAttributeTypes = () => { @@ -216,22 +261,28 @@ const syncCustomAttributeTypes = () => { }); }; -const open = () => { +const open = (executionDelay = null) => { resetValidation(); - dialogRef.value?.open(); + syncDelayState(executionDelay); + panelRef.value?.open(); }; const close = () => { resetValidation(); - dialogRef.value?.close(); + panelRef.value?.close(); }; 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); } }; @@ -240,175 +291,86 @@ defineExpose({ open, close }); diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleRow.vue b/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleRow.vue index 7f573e81a..b595a52f0 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleRow.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleRow.vue @@ -1,6 +1,7 @@ diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/automation/Index.vue index 32139ccd5..d60e6d952 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/Index.vue @@ -10,7 +10,9 @@ import { useStoreGetters, useStore } from 'dashboard/composables/store'; import { picoSearch } from '@scmmishra/pico-search'; import AutomationRuleRow from './AutomationRuleRow.vue'; import Button from 'dashboard/components-next/button/Button.vue'; +import TabBar from 'dashboard/components-next/tabbar/TabBar.vue'; import { BaseTable } from 'dashboard/components-next/table'; +import { DEFAULT_DELAY_MINUTES } from './constants'; const getters = useStoreGetters(); const store = useStore(); @@ -35,9 +37,68 @@ const filteredRecords = computed(() => { if (!query) return records.value; return picoSearch(records.value, query, ['name', 'description']); }); + const uiFlags = computed(() => getters['automations/getUIFlags'].value); const accountId = computed(() => getters.getCurrentAccountId.value); +const isDelayedAutomationsEnabled = computed(() => + getters['accounts/isFeatureEnabledonAccount'].value( + accountId.value, + 'delayed_automations' + ) +); + +const instantRecords = computed(() => + filteredRecords.value.filter(automation => !automation.execution_delay) +); +const delayedRecords = computed(() => + filteredRecords.value.filter(automation => automation.execution_delay) +); + +// Accounts that can't create delayed rules, and have none left over, just see the plain list. +const showTabs = computed( + () => + isDelayedAutomationsEnabled.value || + records.value.some(automation => automation.execution_delay) +); + +const activeTab = ref('instant'); + +const tabs = computed(() => [ + { + key: 'instant', + label: t('AUTOMATION.LIST.TABS.INSTANT'), + count: instantRecords.value.length, + }, + { + key: 'delayed', + label: t('AUTOMATION.LIST.TABS.DELAYED'), + count: delayedRecords.value.length, + }, +]); + +const activeTabIndex = computed(() => + tabs.value.findIndex(tab => tab.key === activeTab.value) +); + +const visibleRecords = computed(() => { + if (!showTabs.value) return filteredRecords.value; + return activeTab.value === 'delayed' + ? delayedRecords.value + : instantRecords.value; +}); + +const noDataMessage = computed(() => { + if (searchQuery.value) return t('AUTOMATION.NO_RESULTS'); + return showTabs.value && activeTab.value === 'delayed' + ? t('AUTOMATION.LIST.404_DELAYED') + : t('AUTOMATION.LIST.404'); +}); + +const onTabChanged = tab => { + activeTab.value = tab.key; +}; + const deleteConfirmText = computed( () => `${t('AUTOMATION.DELETE.CONFIRM.YES')} ${selectedAutomation.value.name}` ); @@ -52,6 +113,12 @@ const isSLAEnabled = computed(() => getters['accounts/isFeatureEnabledonAccount'].value(accountId.value, 'sla') ); +const showDelayDisabledBanner = computed( + () => + !isDelayedAutomationsEnabled.value && + records.value.some(automation => automation.execution_delay) +); + onMounted(() => { store.dispatch('inboxes/get'); store.dispatch('agents/get'); @@ -66,7 +133,9 @@ onMounted(() => { }); const openAddPopup = () => { - addDialogRef.value?.open(); + const startsWithWait = + isDelayedAutomationsEnabled.value && activeTab.value === 'delayed'; + addDialogRef.value?.open(startsWithWait ? DEFAULT_DELAY_MINUTES : null); }; const hideAddPopup = () => { addDialogRef.value?.close(); @@ -74,7 +143,7 @@ const hideAddPopup = () => { const openEditPopup = response => { selectedAutomation.value = { ...response }; - editDialogRef.value?.open(); + editDialogRef.value?.open(response); }; const hideEditPopup = () => { editDialogRef.value?.close(); @@ -128,11 +197,11 @@ const submitAutomation = async (payload, mode) => { hideAddPopup(); hideEditPopup(); } catch (error) { - const errorMessage = + const fallbackMessage = mode === 'edit' ? t('AUTOMATION.EDIT.API.ERROR_MESSAGE') : t('AUTOMATION.ADD.API.ERROR_MESSAGE'); - useAlert(errorMessage); + useAlert(error?.response?.data?.error || fallbackMessage); } }; const toggleAutomation = async ({ id, name, status }) => { @@ -197,9 +266,16 @@ const tableHeaders = computed(() => { :search-placeholder="$t('AUTOMATION.SEARCH_PLACEHOLDER')" feature-name="automation" > -