feat: Conversation workflows(EE) (#13040)

We are expanding Chatwoot’s automation capabilities by
introducing **Conversation Workflows**, a dedicated section in settings
where teams can configure rules that govern how conversations are closed
and what information agents must fill before resolving. This feature
helps teams enforce data consistency, collect structured resolution
information, and ensure downstream reporting is accurate.

Instead of having auto‑resolution buried inside Account Settings, we
introduced a new sidebar item:
- Auto‑resolve conversations (existing behaviour)
- Required attributes on resolution (new)

This groups all conversation‑closing logic into a single place.

#### Required Attributes on Resolve

Admins can now pick which custom conversation attributes must be filled
before an agent can resolve a conversation.

**How it works**

- Admin selects one or more attributes from the list of existing
conversation level custom attributes.
- These selected attributes become mandatory during resolution.
- List all the attributes configured via Required Attributes (Text,
Number, Link, Date, List, Checkbox)
- When an agent clicks Resolve Conversation:
If attributes already have values → the conversation resolves normally.
If attributes are missing → a modal appears prompting the agent to fill
them.

<img width="1554" height="1282" alt="CleanShot 2025-12-10 at 11 42
23@2x"
src="https://github.com/user-attachments/assets/4cd5d6e1-abe8-4999-accd-d4a08913b373"
/>


#### Custom Attributes Integration

On the Custom Attributes page, we will surfaced indicators showing how
each attribute is being used.

Each attribute will show badges such as:

- Resolution → used in the required‑on‑resolve workflow

- Pre‑chat form → already existing

<img width="2390" height="1822" alt="CleanShot 2025-12-10 at 11 43
42@2x"
src="https://github.com/user-attachments/assets/b92a6eb7-7f6c-40e6-bf23-6a5310f2d9c5"
/>


#### Admin Flow

- Navigate to Settings → Conversation Workflows.
- Under Required attributes on resolve, click Add Required Attribute.
- Pick from the dropdown list of conversation attributes.
- Save changes.

Agents will now be prompted automatically whenever they resolve.

<img width="2434" height="872" alt="CleanShot 2025-12-10 at 11 44 42@2x"
src="https://github.com/user-attachments/assets/632fc0e5-767c-4a1c-8cf4-ffe3d058d319"
/>



#### NOTES
- The Required Attributes on Resolve modal should only appear when
values are missing.
- Required attributes must block the resolution action until satisfied.
- Bulk‑resolve actions should follow the same rules — any conversation
missing attributes cannot be bulk‑resolved, rest will be resolved, show
a notification that the resolution cannot be done.
- API resolution does not respect the attributes.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
Muhsin Keloth
2026-01-27 11:36:20 +04:00
committed by GitHub
parent 885b041a83
commit 04b2901e1f
39 changed files with 1514 additions and 329 deletions

View File

@@ -0,0 +1,56 @@
<script setup>
import { computed } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { ATTRIBUTE_TYPES } from './constants';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
});
const emit = defineEmits(['delete']);
const iconByType = {
[ATTRIBUTE_TYPES.TEXT]: 'i-lucide-align-justify',
[ATTRIBUTE_TYPES.CHECKBOX]: 'i-lucide-circle-check-big',
[ATTRIBUTE_TYPES.LIST]: 'i-lucide-list',
[ATTRIBUTE_TYPES.DATE]: 'i-lucide-calendar',
[ATTRIBUTE_TYPES.LINK]: 'i-lucide-link',
[ATTRIBUTE_TYPES.NUMBER]: 'i-lucide-hash',
};
const attributeIcon = computed(() => {
const typeKey = props.attribute.type?.toLowerCase();
return iconByType[typeKey] || 'i-lucide-align-justify';
});
const handleDelete = () => {
emit('delete', props.attribute);
};
</script>
<template>
<div class="flex justify-between items-center px-4 py-3 w-full">
<div class="flex gap-3 items-center">
<h5 class="text-sm font-medium text-n-slate-12 line-clamp-1">
{{ attribute.label }}
</h5>
<div class="w-px h-2.5 bg-n-slate-5" />
<div class="flex gap-1.5 items-center">
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
<span class="text-sm text-n-slate-11">{{ attribute.type }}</span>
</div>
<div class="w-px h-2.5 bg-n-slate-5" />
<div class="flex gap-1.5 items-center">
<Icon icon="i-lucide-key-round" class="size-4 text-n-slate-11" />
<span class="text-sm text-n-slate-11">{{ attribute.value }}</span>
</div>
</div>
<div class="flex gap-2 items-center">
<Button icon="i-lucide-trash" sm slate ghost @click.stop="handleDelete" />
</div>
</div>
</template>

View File

@@ -0,0 +1,186 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import ConversationRequiredAttributeItem from 'dashboard/components-next/ConversationWorkflow/ConversationRequiredAttributeItem.vue';
import ConversationRequiredEmpty from 'dashboard/components-next/Conversation/ConversationRequiredEmpty.vue';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
const props = defineProps({
isEnabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click']);
const router = useRouter();
const { t } = useI18n();
const { currentAccount, accountId, isOnChatwootCloud, updateAccount } =
useAccount();
const [showDropdown, toggleDropdown] = useToggle(false);
const [isSaving, toggleSaving] = useToggle(false);
const conversationAttributes = useMapGetter(
'attributes/getConversationAttributes'
);
const currentUser = useMapGetter('getCurrentUser');
const isSuperAdmin = computed(() => currentUser.value.type === 'SuperAdmin');
const showPaywall = computed(() => !props.isEnabled && isOnChatwootCloud.value);
const i18nKey = computed(() =>
isOnChatwootCloud.value ? 'PAYWALL' : 'ENTERPRISE_PAYWALL'
);
const goToBillingSettings = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
const handleClick = () => {
emit('click');
};
const selectedAttributeKeys = computed(
() => currentAccount.value?.settings?.conversation_required_attributes || []
);
const allAttributeOptions = computed(() =>
(conversationAttributes.value || []).map(attribute => ({
...attribute,
action: 'add',
value: attribute.attributeKey,
label: attribute.attributeDisplayName,
type: attribute.attributeDisplayType,
}))
);
const attributeOptions = computed(() => {
const selectedKeysSet = new Set(selectedAttributeKeys.value);
return allAttributeOptions.value.filter(
attribute => !selectedKeysSet.has(attribute.value)
);
});
const conversationRequiredAttributes = computed(() => {
const attributeMap = new Map(
allAttributeOptions.value.map(attr => [attr.value, attr])
);
return selectedAttributeKeys.value
.map(key => attributeMap.get(key))
.filter(Boolean);
});
const handleAddAttributesClick = event => {
event.stopPropagation();
toggleDropdown();
};
const saveRequiredAttributes = async keys => {
try {
toggleSaving(true);
await updateAccount(
{ conversation_required_attributes: keys },
{ silent: true }
);
useAlert(t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.SAVE.SUCCESS'));
} catch (error) {
useAlert(t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.SAVE.ERROR'));
} finally {
toggleSaving(false);
toggleDropdown(false);
}
};
const handleAttributeAction = ({ value }) => {
if (!value || isSaving.value) return;
const updatedKeys = Array.from(
new Set([...selectedAttributeKeys.value, value])
);
saveRequiredAttributes(updatedKeys);
};
const closeDropdown = () => {
toggleDropdown(false);
};
const handleDelete = attribute => {
if (isSaving.value) return;
const updatedKeys = selectedAttributeKeys.value.filter(
key => key !== attribute.value
);
saveRequiredAttributes(updatedKeys);
};
</script>
<template>
<div
v-if="isEnabled || showPaywall"
class="flex flex-col w-full outline-1 outline outline-n-container rounded-xl bg-n-solid-2 divide-y divide-n-weak"
@click="handleClick"
>
<div class="flex flex-col gap-2 items-start px-5 py-4">
<div class="flex justify-between items-center w-full">
<div class="flex flex-col gap-2">
<h3 class="text-base font-medium text-n-slate-12">
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.TITLE') }}
</h3>
<p class="mb-0 text-sm text-n-slate-11">
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.DESCRIPTION') }}
</p>
</div>
<div v-if="isEnabled" v-on-clickaway="closeDropdown" class="relative">
<Button
icon="i-lucide-circle-plus"
:label="$t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.ADD.TITLE')"
:is-loading="isSaving"
:disabled="isSaving || attributeOptions.length === 0"
@click="handleAddAttributesClick"
/>
<DropdownMenu
v-if="showDropdown"
:menu-items="attributeOptions"
show-search
:search-placeholder="
$t(
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.ADD.SEARCH_PLACEHOLDER'
)
"
class="top-full mt-1 w-52 ltr:right-0 rtl:left-0"
@action="handleAttributeAction"
/>
</div>
</div>
</div>
<template v-if="isEnabled">
<ConversationRequiredEmpty
v-if="conversationRequiredAttributes.length === 0"
/>
<ConversationRequiredAttributeItem
v-for="attribute in conversationRequiredAttributes"
:key="attribute.value"
:attribute="attribute"
@delete="handleDelete"
/>
</template>
<BasePaywallModal
v-else
class="mx-auto my-8"
feature-prefix="CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES"
:i18n-key="i18nKey"
:is-on-chatwoot-cloud="isOnChatwootCloud"
:is-super-admin="isSuperAdmin"
@upgrade="goToBillingSettings"
/>
</div>
</template>

View File

@@ -0,0 +1,248 @@
<script setup>
import { ref, computed, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, url, helpers } from '@vuelidate/validators';
import { getRegexp } from 'shared/helpers/Validators';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import TextArea from 'next/textarea/TextArea.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ChoiceToggle from 'dashboard/components-next/input/ChoiceToggle.vue';
import { ATTRIBUTE_TYPES } from './constants';
const emit = defineEmits(['submit']);
const { t } = useI18n();
const dialogRef = ref(null);
const visibleAttributes = ref([]);
const formValues = reactive({});
const conversationContext = ref(null);
const placeholders = computed(() => ({
text: t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.TEXT'),
number: t(
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.NUMBER'
),
link: t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.LINK'),
date: t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.DATE'),
list: t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.LIST'),
}));
const getPlaceholder = type => placeholders.value[type] || '';
const validationRules = computed(() => {
const rules = {};
visibleAttributes.value.forEach(attribute => {
if (attribute.type === ATTRIBUTE_TYPES.LINK) {
rules[attribute.value] = { required, url };
} else if (attribute.type === ATTRIBUTE_TYPES.CHECKBOX) {
// Checkbox doesn't need validation - any selection is valid
rules[attribute.value] = {};
} else {
rules[attribute.value] = { required };
if (attribute.regexPattern) {
rules[attribute.value].regexValidation = helpers.withParams(
{ regexCue: attribute.regexCue },
value => !value || getRegexp(attribute.regexPattern).test(value)
);
}
}
});
return rules;
});
const v$ = useVuelidate(validationRules, formValues);
const getErrorMessage = attributeKey => {
const field = v$.value[attributeKey];
if (!field || !field.$error) return '';
if (field.url && field.url.$invalid) {
return t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL');
}
if (field.regexValidation && field.regexValidation.$invalid) {
return (
field.regexValidation.$params?.regexCue ||
t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT')
);
}
if (field.required && field.required.$invalid) {
return t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
}
return '';
};
const isFormComplete = computed(() =>
visibleAttributes.value.every(attribute => {
const value = formValues[attribute.value];
// For checkbox attributes, ensure the agent has explicitly selected a value
if (attribute.type === ATTRIBUTE_TYPES.CHECKBOX) {
return formValues[attribute.value] !== null;
}
// For other attribute types, check for valid non-empty values
return value !== undefined && value !== null && String(value).trim() !== '';
})
);
const comboBoxOptions = computed(() => {
const options = {};
visibleAttributes.value.forEach(attribute => {
if (attribute.type === ATTRIBUTE_TYPES.LIST) {
options[attribute.value] = (attribute.attributeValues || []).map(
option => ({
value: option,
label: option,
})
);
}
});
return options;
});
const close = () => {
dialogRef.value?.close();
conversationContext.value = null;
v$.value.$reset();
};
const open = (attributes = [], initialValues = {}, context = null) => {
visibleAttributes.value = attributes;
conversationContext.value = context;
// Clear existing formValues
Object.keys(formValues).forEach(key => {
delete formValues[key];
});
// Initialize form values
attributes.forEach(attribute => {
const presetValue = initialValues[attribute.value];
if (presetValue !== undefined && presetValue !== null) {
formValues[attribute.value] = presetValue;
} else {
// For checkbox attributes, initialize to null to avoid pre-selection
// For other attributes, initialize to empty string
formValues[attribute.value] =
attribute.type === ATTRIBUTE_TYPES.CHECKBOX ? null : '';
}
});
v$.value.$reset();
dialogRef.value?.open();
};
const handleConfirm = async () => {
v$.value.$touch();
if (v$.value.$invalid) {
return;
}
emit('submit', {
attributes: { ...formValues },
context: conversationContext.value,
});
close();
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
width="lg"
:title="t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.TITLE')"
:description="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.DESCRIPTION')
"
:confirm-button-label="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.ACTIONS.RESOLVE')
"
:cancel-button-label="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.ACTIONS.CANCEL')
"
:disable-confirm-button="!isFormComplete"
@confirm="handleConfirm"
>
<div class="flex flex-col gap-4">
<div
v-for="attribute in visibleAttributes"
:key="attribute.value"
class="flex flex-col gap-2"
>
<div class="flex justify-between items-center">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ attribute.label }}
</label>
</div>
<template v-if="attribute.type === ATTRIBUTE_TYPES.TEXT">
<TextArea
v-model="formValues[attribute.value]"
class="w-full"
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.TEXT)"
:message="getErrorMessage(attribute.value)"
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
@blur="v$[attribute.value].$touch"
/>
</template>
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.NUMBER">
<Input
v-model="formValues[attribute.value]"
type="number"
size="md"
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.NUMBER)"
:message="getErrorMessage(attribute.value)"
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
@blur="v$[attribute.value].$touch"
/>
</template>
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.LINK">
<Input
v-model="formValues[attribute.value]"
type="url"
size="md"
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.LINK)"
:message="getErrorMessage(attribute.value)"
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
@blur="v$[attribute.value].$touch"
/>
</template>
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.DATE">
<Input
v-model="formValues[attribute.value]"
type="date"
size="md"
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.DATE)"
:message="getErrorMessage(attribute.value)"
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
@blur="v$[attribute.value].$touch"
/>
</template>
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.LIST">
<ComboBox
v-model="formValues[attribute.value]"
:options="comboBoxOptions[attribute.value]"
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.LIST)"
:message="getErrorMessage(attribute.value)"
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
:has-error="v$[attribute.value].$error"
class="w-full"
/>
</template>
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.CHECKBOX">
<ChoiceToggle v-model="formValues[attribute.value]" />
</template>
</div>
</div>
</Dialog>
</template>

View File

@@ -0,0 +1,8 @@
export const ATTRIBUTE_TYPES = {
TEXT: 'text',
NUMBER: 'number',
LINK: 'link',
DATE: 'date',
LIST: 'list',
CHECKBOX: 'checkbox',
};