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

@@ -92,8 +92,11 @@ class Api::V1::AccountsController < Api::BaseController
end
def settings_params
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
conversation_required_attributes: [])
params.permit(*permitted_settings_attributes)
end
def permitted_settings_attributes
[:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label]
end
def check_signup_enabled
@@ -112,3 +115,5 @@ class Api::V1::AccountsController < Api::BaseController
}
end
end
Api::V1::AccountsController.prepend_mod_with('Api::V1::AccountsSettings')

View File

@@ -0,0 +1,17 @@
<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
<template>
<div class="flex justify-between items-center px-4 py-4 w-full">
<div
class="flex justify-center items-center py-6 w-full custom-dashed-border"
>
<span class="text-sm text-n-slate-11">
{{ t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.NO_ATTRIBUTES') }}
</span>
</div>
</div>
</template>

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',
};

View File

@@ -0,0 +1,45 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
defineProps({
modelValue: {
type: Boolean,
},
});
const emit = defineEmits(['update:modelValue']);
const { t } = useI18n();
const options = computed(() => [
{ label: t('CHOICE_TOGGLE.YES'), value: true },
{ label: t('CHOICE_TOGGLE.NO'), value: false },
]);
const handleSelect = value => {
emit('update:modelValue', value);
};
</script>
<template>
<div
class="flex gap-4 items-center px-4 py-2.5 w-full rounded-lg divide-x transition-colors bg-n-solid-1 outline outline-1 outline-n-weak hover:outline-n-slate-6 focus-within:outline-n-brand divide-n-weak"
>
<div
v-for="option in options"
:key="option.value"
class="flex flex-1 gap-2 justify-center items-center"
>
<label class="inline-flex gap-2 items-center text-base cursor-pointer">
<input
type="radio"
:value="option.value"
:checked="modelValue === option.value"
class="size-4 accent-n-blue-9 text-n-blue-9"
@change="handleSelect(option.value)"
/>
<span class="text-sm text-n-slate-12">{{ option.label }}</span>
</label>
</div>
</div>
</template>

View File

@@ -529,7 +529,7 @@ const menuItems = computed(() => {
{
name: 'Settings Automation',
label: t('SIDEBAR.AUTOMATION'),
icon: 'i-lucide-workflow',
icon: 'i-lucide-repeat',
to: accountScopedRoute('automation_list'),
},
{
@@ -574,6 +574,12 @@ const menuItems = computed(() => {
icon: 'i-lucide-clock-alert',
to: accountScopedRoute('sla_list'),
},
{
name: 'Conversation Workflow',
label: t('SIDEBAR.CONVERSATION_WORKFLOW'),
icon: 'i-lucide-workflow',
to: accountScopedRoute('conversation_workflow_index'),
},
{
name: 'Settings Security',
label: t('SIDEBAR.SECURITY'),
@@ -655,7 +661,7 @@ const menuItems = computed(() => {
</ul>
</nav>
<section
class="flex flex-col flex-shrink-0 relative gap-1 justify-between items-center"
class="flex relative flex-col flex-shrink-0 gap-1 justify-between items-center"
>
<div
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"

View File

@@ -32,7 +32,7 @@ const props = defineProps({
allowSignature: { type: Boolean, default: false }, // allowSignature is a kill switch, ensuring no signature methods are triggered except when this flag is true
});
const emit = defineEmits(['update:modelValue']);
const emit = defineEmits(['update:modelValue', 'blur', 'focus']);
const textareaRef = ref(null);
const isFocused = ref(false);
@@ -96,15 +96,17 @@ const handleInput = event => {
}
};
const handleFocus = () => {
const handleFocus = event => {
if (!props.disabled) {
isFocused.value = true;
emit('focus', event);
}
};
const handleBlur = () => {
const handleBlur = event => {
if (!props.disabled) {
isFocused.value = false;
emit('blur', event);
}
};

View File

@@ -32,6 +32,7 @@ import ConversationBulkActions from './widgets/conversation/conversationBulkActi
import IntersectionObserver from './IntersectionObserver.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
@@ -46,6 +47,7 @@ import {
} from 'dashboard/composables/useTransformKeys';
import { useEmitter } from 'dashboard/composables/emitter';
import { useEventListener } from '@vueuse/core';
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
import { emitter } from 'shared/helpers/mitt';
@@ -87,6 +89,7 @@ const router = useRouter();
const route = useRoute();
const store = useStore();
const resolveAttributesModalRef = ref(null);
const conversationListRef = ref(null);
const conversationDynamicScroller = ref(null);
@@ -129,6 +132,7 @@ const labels = useMapGetter('labels/getLabels');
const currentAccountId = useMapGetter('getCurrentAccountId');
// We can't useFunctionGetter here since it needs to be called on setup?
const getTeamFn = useMapGetter('teams/getTeam');
const getConversationById = useMapGetter('getConversationById');
useChatListKeyboardEvents(conversationListRef);
const {
@@ -153,6 +157,8 @@ const {
attributeModel: 'conversation_attribute',
});
const { checkMissingAttributes } = useConversationRequiredAttributes();
// computed
const intersectionObserverOptions = computed(() => {
return {
@@ -729,22 +735,76 @@ async function onAssignTeam(team, conversationId = null) {
}
}
function toggleConversationStatus(conversationId, status, snoozedUntil) {
store
.dispatch('toggleStatus', {
function toggleConversationStatus(
conversationId,
status,
snoozedUntil,
})
.then(() => {
customAttributes = null
) {
const payload = {
conversationId,
status,
snoozedUntil,
};
if (customAttributes) {
payload.customAttributes = customAttributes;
}
store.dispatch('toggleStatus', payload).then(() => {
useAlert(t('CONVERSATION.CHANGE_STATUS'));
});
}
function handleResolveConversation(conversationId, status, snoozedUntil) {
if (status !== wootConstants.STATUS_TYPE.RESOLVED) {
toggleConversationStatus(conversationId, status, snoozedUntil);
return;
}
// Check for required attributes before resolving
const conversation = getConversationById.value(conversationId);
const currentCustomAttributes = conversation?.custom_attributes || {};
const { hasMissing, missing } = checkMissingAttributes(
currentCustomAttributes
);
if (hasMissing) {
// Pass conversation context through the modal's API
const conversationContext = {
id: conversationId,
snoozedUntil,
};
resolveAttributesModalRef.value?.open(
missing,
currentCustomAttributes,
conversationContext
);
} else {
toggleConversationStatus(conversationId, status, snoozedUntil);
}
}
function handleResolveWithAttributes({ attributes, context }) {
if (context) {
const existingConversation = getConversationById.value(context.id);
const currentCustomAttributes =
existingConversation?.custom_attributes || {};
const mergedAttributes = { ...currentCustomAttributes, ...attributes };
toggleConversationStatus(
context.id,
wootConstants.STATUS_TYPE.RESOLVED,
context.snoozedUntil,
mergedAttributes
);
}
}
function allSelectedConversationsStatus(status) {
if (!selectedConversations.value.length) return false;
return selectedConversations.value.every(item => {
return store.getters.getConversationById(item)?.status === status;
return getConversationById.value(item)?.status === status;
});
}
@@ -799,7 +859,7 @@ provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent);
provide('assignTeam', onAssignTeam);
provide('assignLabels', onAssignLabels);
provide('updateConversationStatus', toggleConversationStatus);
provide('updateConversationStatus', handleResolveConversation);
provide('toggleContextMenu', onContextMenuToggle);
provide('markAsUnread', markAsUnread);
provide('markAsRead', markAsRead);
@@ -895,7 +955,7 @@ watch(conversationFilters, (newVal, oldVal) => {
<p
v-if="!chatListLoading && !conversationList.length"
class="flex items-center justify-center p-4 overflow-auto"
class="flex overflow-auto justify-center items-center p-4"
>
{{ $t('CHAT_LIST.LIST.404') }}
</p>
@@ -915,14 +975,14 @@ watch(conversationFilters, (newVal, oldVal) => {
/>
<div
ref="conversationListRef"
class="flex-1 overflow-hidden conversations-list hover:overflow-y-auto"
class="overflow-hidden flex-1 conversations-list hover:overflow-y-auto"
:class="{ 'overflow-hidden': isContextMenuOpen }"
>
<DynamicScroller
ref="conversationDynamicScroller"
:items="conversationList"
:min-item-size="24"
class="w-full h-full overflow-auto"
class="overflow-auto w-full h-full"
>
<template #default="{ item, index, active }">
<!--
@@ -997,5 +1057,9 @@ watch(conversationFilters, (newVal, oldVal) => {
@close="closeAdvanceFiltersModal"
/>
</TeleportWithDirection>
<ConversationResolveAttributesModal
ref="resolveAttributesModalRef"
@submit="handleResolveWithAttributes"
/>
</div>
</template>

View File

@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { useEmitter } from 'dashboard/composables/emitter';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
@@ -17,13 +18,16 @@ import {
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
const store = useStore();
const getters = useStoreGetters();
const { t } = useI18n();
const { checkMissingAttributes } = useConversationRequiredAttributes();
const arrowDownButtonRef = ref(null);
const isLoading = ref(false);
const resolveAttributesModalRef = ref(null);
const [showActionsDropdown, toggleDropdown] = useToggle();
const closeDropdown = () => toggleDropdown(false);
@@ -77,27 +81,61 @@ const openSnoozeModal = () => {
ninja.open({ parent: 'snooze_conversation' });
};
const toggleStatus = (status, snoozedUntil) => {
const toggleStatus = (status, snoozedUntil, customAttributes = null) => {
closeDropdown();
isLoading.value = true;
store
.dispatch('toggleStatus', {
const payload = {
conversationId: currentChat.value.id,
status,
snoozedUntil,
})
.then(() => {
};
if (customAttributes) {
payload.customAttributes = customAttributes;
}
store.dispatch('toggleStatus', payload).then(() => {
useAlert(t('CONVERSATION.CHANGE_STATUS'));
isLoading.value = false;
});
};
const handleResolveWithAttributes = ({ attributes, context }) => {
if (context) {
const currentCustomAttributes = currentChat.value.custom_attributes || {};
const mergedAttributes = { ...currentCustomAttributes, ...attributes };
toggleStatus(
wootConstants.STATUS_TYPE.RESOLVED,
context.snoozedUntil,
mergedAttributes
);
}
};
const onCmdOpenConversation = () => {
toggleStatus(wootConstants.STATUS_TYPE.OPEN);
};
const onCmdResolveConversation = () => {
const currentCustomAttributes = currentChat.value.custom_attributes || {};
const { hasMissing, missing } = checkMissingAttributes(
currentCustomAttributes
);
if (hasMissing) {
const conversationContext = {
id: currentChat.value.id,
snoozedUntil: null,
};
resolveAttributesModalRef.value?.open(
missing,
currentCustomAttributes,
conversationContext
);
} else {
toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
}
};
const keyboardEvents = {
@@ -107,13 +145,13 @@ const keyboardEvents = {
},
'Alt+KeyE': {
action: async () => {
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
onCmdResolveConversation();
},
},
'$mod+Alt+KeyE': {
action: async event => {
const { all, activeIndex, lastIndex } = getConversationParams();
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
onCmdResolveConversation();
if (activeIndex < lastIndex) {
all[activeIndex + 1].click();
@@ -133,9 +171,9 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
</script>
<template>
<div class="relative flex items-center justify-end resolve-actions">
<div class="flex relative justify-end items-center resolve-actions">
<ButtonGroup
class="rounded-lg shadow outline-1 outline flex-shrink-0"
class="flex-shrink-0 rounded-lg shadow outline-1 outline"
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
>
<Button
@@ -212,5 +250,9 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
</WootDropdownItem>
</WootDropdownMenu>
</div>
<ConversationResolveAttributesModal
ref="resolveAttributesModalRef"
@submit="handleResolveWithAttributes"
/>
</div>
</template>

View File

@@ -3,10 +3,13 @@ import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
import wootConstants from 'dashboard/constants/globals';
export function useBulkActions() {
const store = useStore();
const { t } = useI18n();
const { checkMissingAttributes } = useConversationRequiredAttributes();
const selectedConversations = useMapGetter(
'bulkActions/getSelectedConversationIds'
@@ -116,17 +119,61 @@ export function useBulkActions() {
}
async function onUpdateConversations(status, snoozedUntil) {
let conversationIds = selectedConversations.value;
let skippedCount = 0;
// If resolving, check for required attributes
if (status === wootConstants.STATUS_TYPE.RESOLVED) {
const { validIds, skippedIds } = selectedConversations.value.reduce(
(acc, id) => {
const conversation = store.getters.getConversationById(id);
const currentCustomAttributes = conversation?.custom_attributes || {};
const { hasMissing } = checkMissingAttributes(
currentCustomAttributes
);
if (!hasMissing) {
acc.validIds.push(id);
} else {
acc.skippedIds.push(id);
}
return acc;
},
{ validIds: [], skippedIds: [] }
);
conversationIds = validIds;
skippedCount = skippedIds.length;
if (skippedCount > 0 && validIds.length === 0) {
// All conversations have missing attributes
useAlert(
t('BULK_ACTION.RESOLVE.ALL_MISSING_ATTRIBUTES') ||
'Cannot resolve conversations due to missing required attributes'
);
return;
}
}
try {
if (conversationIds.length > 0) {
await store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: selectedConversations.value,
ids: conversationIds,
fields: {
status,
},
snoozed_until: snoozedUntil,
});
}
store.dispatch('bulkActions/clearSelectedConversationIds');
if (skippedCount > 0) {
useAlert(t('BULK_ACTION.RESOLVE.PARTIAL_SUCCESS'));
} else {
useAlert(t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
}
} catch (err) {
useAlert(t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
}

View File

@@ -0,0 +1,348 @@
import { useConversationRequiredAttributes } from '../useConversationRequiredAttributes';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useAccount');
const defaultAttributes = [
{
attributeKey: 'priority',
attributeDisplayName: 'Priority',
attributeDisplayType: 'list',
attributeValues: ['High', 'Medium', 'Low'],
},
{
attributeKey: 'category',
attributeDisplayName: 'Category',
attributeDisplayType: 'text',
attributeValues: [],
},
{
attributeKey: 'is_urgent',
attributeDisplayName: 'Is Urgent',
attributeDisplayType: 'checkbox',
attributeValues: [],
},
];
describe('useConversationRequiredAttributes', () => {
beforeEach(() => {
useMapGetter.mockImplementation(getter => {
if (getter === 'accounts/isFeatureEnabledonAccount') {
return { value: () => true };
}
if (getter === 'attributes/getConversationAttributes') {
return { value: defaultAttributes };
}
return { value: null };
});
useAccount.mockReturnValue({
currentAccount: {
value: {
settings: {
conversation_required_attributes: [
'priority',
'category',
'is_urgent',
],
},
},
},
accountId: { value: 1 },
});
});
const setupMocks = (
requiredAttributes = ['priority', 'category', 'is_urgent'],
{ attributes = defaultAttributes, featureEnabled = true } = {}
) => {
useMapGetter.mockImplementation(getter => {
if (getter === 'accounts/isFeatureEnabledonAccount') {
return { value: () => featureEnabled };
}
if (getter === 'attributes/getConversationAttributes') {
return { value: attributes };
}
return { value: null };
});
useAccount.mockReturnValue({
currentAccount: {
value: {
settings: {
conversation_required_attributes: requiredAttributes,
},
},
},
accountId: { value: 1 },
});
};
describe('requiredAttributeKeys', () => {
it('should return required attribute keys from account settings', () => {
setupMocks();
const { requiredAttributeKeys } = useConversationRequiredAttributes();
expect(requiredAttributeKeys.value).toEqual([
'priority',
'category',
'is_urgent',
]);
});
it('should return empty array when no required attributes configured', () => {
setupMocks([]);
const { requiredAttributeKeys } = useConversationRequiredAttributes();
expect(requiredAttributeKeys.value).toEqual([]);
});
it('should return empty array when account settings is null', () => {
setupMocks([], { attributes: [] });
useAccount.mockReturnValue({
currentAccount: { value: { settings: null } },
accountId: { value: 1 },
});
const { requiredAttributeKeys } = useConversationRequiredAttributes();
expect(requiredAttributeKeys.value).toEqual([]);
});
});
describe('requiredAttributes', () => {
it('should return full attribute definitions for required attributes only', () => {
setupMocks();
const { requiredAttributes } = useConversationRequiredAttributes();
expect(requiredAttributes.value).toHaveLength(3);
expect(requiredAttributes.value[0]).toEqual({
attributeKey: 'priority',
attributeDisplayName: 'Priority',
attributeDisplayType: 'list',
attributeValues: ['High', 'Medium', 'Low'],
value: 'priority',
label: 'Priority',
type: 'list',
});
});
it('should filter out deleted attributes that no longer exist', () => {
// Mock with only 2 attributes available but 3 required
setupMocks(['priority', 'category', 'is_urgent'], {
attributes: [
{
attributeKey: 'priority',
attributeDisplayName: 'Priority',
attributeDisplayType: 'list',
attributeValues: ['High', 'Medium', 'Low'],
},
{
attributeKey: 'is_urgent',
attributeDisplayName: 'Is Urgent',
attributeDisplayType: 'checkbox',
attributeValues: [],
},
],
});
const { requiredAttributes } = useConversationRequiredAttributes();
expect(requiredAttributes.value).toHaveLength(2);
expect(requiredAttributes.value.map(attr => attr.value)).toEqual([
'priority',
'is_urgent',
]);
});
});
describe('checkMissingAttributes', () => {
beforeEach(() => {
setupMocks();
});
it('should return no missing when all attributes are filled', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: 'Bug Report',
is_urgent: true,
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(false);
expect(result.missing).toEqual([]);
expect(result.all).toHaveLength(3);
});
it('should detect missing text attributes', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
is_urgent: true,
// category is missing
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(true);
expect(result.missing).toHaveLength(1);
expect(result.missing[0].value).toBe('category');
});
it('should detect empty string values as missing', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: '', // empty string
is_urgent: true,
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(true);
expect(result.missing[0].value).toBe('category');
});
it('should consider checkbox attribute present when value is true', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: 'Bug Report',
is_urgent: true,
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(false);
expect(result.missing).toEqual([]);
});
it('should consider checkbox attribute present when value is false', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: 'Bug Report',
is_urgent: false, // false is still considered "filled"
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(false);
expect(result.missing).toEqual([]);
});
it('should detect missing checkbox when key does not exist', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: 'Bug Report',
// is_urgent key is completely missing
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(true);
expect(result.missing).toHaveLength(1);
expect(result.missing[0].value).toBe('is_urgent');
expect(result.missing[0].type).toBe('checkbox');
});
it('should handle falsy values correctly for non-checkbox attributes', () => {
setupMocks(['score', 'status_flag'], {
attributes: [
{
attributeKey: 'score',
attributeDisplayName: 'Score',
attributeDisplayType: 'number',
attributeValues: [],
},
{
attributeKey: 'status_flag',
attributeDisplayName: 'Status Flag',
attributeDisplayType: 'text',
attributeValues: [],
},
],
});
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
score: 0, // zero should be considered valid, not missing
status_flag: false, // false should be considered valid, not missing
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(false);
expect(result.missing).toEqual([]);
});
it('should handle null values as missing for text attributes', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: null, // null should be missing for text attribute
is_urgent: true, // checkbox is present
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(true);
expect(result.missing).toHaveLength(1);
expect(result.missing[0].value).toBe('category');
});
it('should consider undefined checkbox values as present when key exists', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: 'Bug Report',
is_urgent: undefined, // key exists but value is undefined - still considered "filled" for checkbox
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(false);
expect(result.missing).toEqual([]);
});
it('should return no missing when no attributes are required', () => {
setupMocks([]); // No required attributes
const { checkMissingAttributes } = useConversationRequiredAttributes();
const result = checkMissingAttributes({});
expect(result.hasMissing).toBe(false);
expect(result.missing).toEqual([]);
});
it('should handle whitespace-only values as missing', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: ' ', // whitespace only
is_urgent: true,
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(true);
expect(result.missing[0].value).toBe('category');
});
});
});

View File

@@ -0,0 +1,97 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { ATTRIBUTE_TYPES } from 'dashboard/components-next/ConversationWorkflow/constants';
/**
* Composable for managing conversation required attributes workflow
*
* This handles the logic for checking if conversations have all required
* custom attributes filled before they can be resolved.
*/
export function useConversationRequiredAttributes() {
const { currentAccount, accountId } = useAccount();
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const conversationAttributes = useMapGetter(
'attributes/getConversationAttributes'
);
const isFeatureEnabled = computed(() =>
isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES
)
);
const requiredAttributeKeys = computed(() => {
if (!isFeatureEnabled.value) return [];
return (
currentAccount.value?.settings?.conversation_required_attributes || []
);
});
const allAttributeOptions = computed(() =>
(conversationAttributes.value || []).map(attribute => ({
...attribute,
value: attribute.attributeKey,
label: attribute.attributeDisplayName,
type: attribute.attributeDisplayType,
attributeValues: attribute.attributeValues,
}))
);
/**
* Get the full attribute definitions for only the required attributes
* Filters allAttributeOptions to only include attributes marked as required
*/
const requiredAttributes = computed(
() =>
requiredAttributeKeys.value
.map(key =>
allAttributeOptions.value.find(attribute => attribute.value === key)
)
.filter(Boolean) // Remove any undefined attributes (deleted attributes)
);
/**
* Check if a conversation is missing any required attributes
*
* @param {Object} conversationCustomAttributes - Current conversation's custom attributes
* @returns {Object} - Analysis result with missing attributes info
*/
const checkMissingAttributes = (conversationCustomAttributes = {}) => {
// If no attributes are required, conversation can be resolved
if (!requiredAttributes.value.length) {
return { hasMissing: false, missing: [] };
}
// Find attributes that are missing or empty
const missing = requiredAttributes.value.filter(attribute => {
const value = conversationCustomAttributes[attribute.value];
// For checkbox/boolean attributes, only check if the key exists
if (attribute.type === ATTRIBUTE_TYPES.CHECKBOX) {
return !(attribute.value in conversationCustomAttributes);
}
// For other attribute types, only consider null, undefined, empty string, or whitespace-only as missing
// Allow falsy values like 0, false as they are valid filled values
return value == null || String(value).trim() === '';
});
return {
hasMissing: missing.length > 0,
missing,
all: requiredAttributes.value,
};
};
return {
requiredAttributeKeys,
requiredAttributes,
checkMissingAttributes,
};
}

View File

@@ -45,6 +45,7 @@ export const FEATURE_FLAGS = {
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
COMPANIES: 'companies',
ADVANCED_SEARCH: 'advanced_search',
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
};
export const PREMIUM_FEATURES = [
@@ -54,4 +55,5 @@ export const PREMIUM_FEATURES = [
FEATURE_FLAGS.AUDIT_LOGS,
FEATURE_FLAGS.HELP_CENTER,
FEATURE_FLAGS.SAML,
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES,
];

View File

@@ -63,6 +63,10 @@
},
"ENABLE_REGEX": {
"LABEL": "Enable regex validation"
},
"BADGES": {
"PRE_CHAT": "Pre-chat",
"RESOLUTION": "Resolution"
}
},
"API": {

View File

@@ -22,6 +22,10 @@
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
"UPDATE_FAILED": "Failed to update conversations. Please try again."
},
"RESOLVE": {
"ALL_MISSING_ATTRIBUTES": "Cannot resolve conversations due to missing required attributes",
"PARTIAL_SUCCESS": "Some conversations need required attributes before resolving and were skipped"
},
"LABELS": {
"ASSIGN_LABELS": "Assign labels",
"NO_LABELS_FOUND": "No labels found",

View File

@@ -11,5 +11,9 @@
"ACCEPT": "Accept",
"DISCARD": "Discard",
"PREFERRED": "Preferred"
},
"CHOICE_TOGGLE": {
"YES": "Yes",
"NO": "No"
}
}

View File

@@ -379,7 +379,8 @@
},
"DOCS": "Read docs",
"SECURITY": "Security",
"CAPTAIN_AI": "Captain"
"CAPTAIN_AI": "Captain",
"CONVERSATION_WORKFLOW": "Conversation Workflow"
},
"CAPTAIN_SETTINGS": {
"TITLE": "Captain Settings",
@@ -555,6 +556,58 @@
}
}
},
"CONVERSATION_WORKFLOW": {
"INDEX": {
"HEADER": {
"TITLE": "Conversation Workflows",
"DESCRIPTION": "Configure rules and required fields for conversation resolution."
}
},
"REQUIRED_ATTRIBUTES": {
"TITLE": "Attributes required on resolution",
"DESCRIPTION": "When resolving a conversation, agents will be prompted to fill these attributes if they haven't yet.",
"NO_ATTRIBUTES": "No attributes added yet",
"ADD": {
"TITLE": "Add Attributes",
"SEARCH_PLACEHOLDER": "Search attributes"
},
"SAVE": {
"SUCCESS": "Required attributes updated",
"ERROR": "Could not update required attributes, please try again"
},
"MODAL": {
"TITLE": "Resolve conversation",
"DESCRIPTION": "Please fill in the following custom attributes before resolving this conversation",
"ACTIONS": {
"RESOLVE": "Resolve conversation",
"CANCEL": "Cancel"
},
"PLACEHOLDERS": {
"TEXT": "Write a note...",
"NUMBER": "Enter a number",
"LINK": "Add a link",
"DATE": "Pick a date",
"LIST": "Select an option"
},
"CHECKBOX": {
"YES": "Yes",
"NO": "No"
}
},
"PAYWALL": {
"TITLE": "Upgrade to use required attributes",
"AVAILABLE_ON": "The required conversation attributes feature is available on the Business and Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade your plan to prompt agents to fill required attributes before conversation resolution.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "The required conversation attributes feature is available on the paid plans.",
"UPGRADE_PROMPT": "Upgrade to a paid plan to enforce required attributes before conversation resolution.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
}
}
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
"NEW_ACCOUNT": "New Account",

View File

@@ -76,10 +76,7 @@ const toggleButtonText = computed(() =>
const filteredCustomAttributes = computed(() =>
attributes.value.map(attribute => {
// Check if the attribute key exists in customAttributes
const hasValue = Object.hasOwnProperty.call(
customAttributes.value,
attribute.attribute_key
);
const hasValue = attribute.attribute_key in customAttributes.value;
return {
...attribute,

View File

@@ -14,7 +14,6 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
import AccountId from './components/AccountId.vue';
import BuildInfo from './components/BuildInfo.vue';
import AccountDelete from './components/AccountDelete.vue';
import AutoResolve from './components/AutoResolve.vue';
import AudioTranscription from './components/AudioTranscription.vue';
import SectionLayout from './components/SectionLayout.vue';
@@ -25,7 +24,6 @@ export default {
AccountId,
BuildInfo,
AccountDelete,
AutoResolve,
AudioTranscription,
SectionLayout,
WithLabel,
@@ -64,12 +62,6 @@ export default {
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
showAutoResolutionConfig() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.AUTO_RESOLVE_CONVERSATIONS
);
},
showAudioTranscriptionConfig() {
return this.isFeatureEnabledonAccount(
this.accountId,
@@ -239,7 +231,6 @@ export default {
<woot-loading-state v-if="uiFlags.isFetchingItem" />
</div>
<AutoResolve v-if="showAutoResolutionConfig" />
<AudioTranscription v-if="showAudioTranscriptionConfig" />
<AccountId />
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">

View File

@@ -4,7 +4,6 @@ import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import SectionLayout from './SectionLayout.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import TextArea from 'next/textarea/TextArea.vue';
import Switch from 'next/switch/Switch.vue';
@@ -125,18 +124,24 @@ const toggleAutoResolve = async () => {
</script>
<template>
<SectionLayout
:title="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE')"
:description="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE')"
:hide-content="!isEnabled"
with-border
<div
class="flex flex-col w-full outline-1 outline outline-n-container rounded-xl bg-n-solid-2 divide-y divide-n-weak"
>
<template #headerActions>
<div class="flex flex-col gap-2 items-start px-5 py-4">
<div class="flex justify-between items-center w-full">
<h3 class="text-base font-medium text-n-slate-12">
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE') }}
</h3>
<div class="flex justify-end">
<Switch v-model="isEnabled" @change="toggleAutoResolve" />
</div>
</template>
</div>
<p class="mb-0 text-sm text-n-slate-11">
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE') }}
</p>
</div>
<div v-if="isEnabled" class="px-5 py-4">
<form class="grid gap-5" @submit.prevent="handleSubmit">
<WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.LABEL')"
@@ -171,7 +176,9 @@ const toggleAutoResolve = async () => {
>
<div class="p-3 h-12 flex items-center justify-between">
<span>
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.IGNORE_WAITING.LABEL') }}
{{
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.IGNORE_WAITING.LABEL')
}}
</span>
<Switch v-model="ignoreWaiting" />
</div>
@@ -201,5 +208,6 @@ const toggleAutoResolve = async () => {
/>
</div>
</form>
</SectionLayout>
</div>
</div>
</template>

View File

@@ -1,176 +0,0 @@
<script setup>
import { useAlert } from 'dashboard/composables';
import EditAttribute from './EditAttribute.vue';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attributeModel: {
type: String,
default: 'conversation_attribute',
},
});
const { t } = useI18n();
const showEditPopup = ref(false);
const showDeletePopup = ref(false);
const selectedAttribute = ref({});
const getters = useStoreGetters();
const store = useStore();
const attributes = computed(() =>
getters['attributes/getAttributesByModel'].value(props.attributeModel)
);
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
const attributeDisplayName = computed(
() => selectedAttribute.value.attribute_display_name
);
const deleteConfirmText = computed(
() =>
`${t('ATTRIBUTES_MGMT.DELETE.CONFIRM.YES')} ${attributeDisplayName.value}`
);
const deleteRejectText = computed(() => t('ATTRIBUTES_MGMT.DELETE.CONFIRM.NO'));
const confirmDeleteTitle = computed(() =>
t('ATTRIBUTES_MGMT.DELETE.CONFIRM.TITLE', {
attributeName: attributeDisplayName.value,
})
);
const confirmPlaceHolderText = computed(
() =>
`${t('ATTRIBUTES_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
attributeName: attributeDisplayName.value,
})}`
);
const deleteAttributes = async ({ id }) => {
try {
await store.dispatch('attributes/delete', id);
useAlert(t('ATTRIBUTES_MGMT.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message || t('ATTRIBUTES_MGMT.DELETE.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const openEditPopup = response => {
showEditPopup.value = true;
selectedAttribute.value = response;
};
const hideEditPopup = () => {
showEditPopup.value = false;
};
const closeDelete = () => {
showDeletePopup.value = false;
selectedAttribute.value = {};
};
const confirmDeletion = () => {
deleteAttributes(selectedAttribute.value);
closeDelete();
};
const openDelete = value => {
showDeletePopup.value = true;
selectedAttribute.value = value;
};
const tableHeaders = computed(() => {
return [
t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER.NAME'),
t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER.DESCRIPTION'),
t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER.TYPE'),
t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER.KEY'),
];
});
</script>
<template>
<div class="flex flex-col">
<table class="min-w-full overflow-x-auto">
<thead>
<th
v-for="tableHeader in tableHeaders"
:key="tableHeader"
class="py-4 ltr:pr-4 rtl:pl-4 text-left font-semibold text-n-slate-11"
>
{{ tableHeader }}
</th>
</thead>
<tbody class="divide-y divide-n-weak flex-1 text-n-slate-12">
<tr v-for="attribute in attributes" :key="attribute.attribute_key">
<td
class="py-4 ltr:pr-4 rtl:pl-4 overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ attribute.attribute_display_name }}
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
{{ attribute.attribute_description }}
</td>
<td
class="py-4 ltr:pr-4 rtl:pl-4 overflow-hidden whitespace-nowrap text-ellipsis"
>
{{
$t(
`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${attribute.attribute_display_type?.toUpperCase()}`
)
}}
</td>
<td
class="py-4 ltr:pr-4 rtl:pl-4 attribute-key overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ attribute.attribute_key }}
</td>
<td class="py-4 min-w-xs">
<div class="flex gap-1 justify-end">
<Button
v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.EDIT')"
icon="i-lucide-pen"
slate
xs
faded
@click="openEditPopup(attribute)"
/>
<Button
v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.DELETE')"
icon="i-lucide-trash-2"
xs
ruby
faded
@click="openDelete(attribute)"
/>
</div>
</td>
</tr>
</tbody>
</table>
<woot-modal v-model:show="showEditPopup" :on-close="hideEditPopup">
<EditAttribute
:selected-attribute="selectedAttribute"
:is-updating="uiFlags.isUpdating"
@on-close="hideEditPopup"
/>
</woot-modal>
<woot-confirm-delete-modal
v-if="showDeletePopup"
v-model:show="showDeletePopup"
:title="confirmDeleteTitle"
:message="$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.MESSAGE')"
:confirm-text="deleteConfirmText"
:reject-text="deleteRejectText"
:confirm-value="selectedAttribute.attribute_display_name"
:confirm-place-holder-text="confirmPlaceHolderText"
@on-confirm="confirmDeletion"
@on-close="closeDelete"
/>
</div>
</template>
<style lang="scss" scoped>
.attribute-key {
font-family: monospace;
}
</style>

View File

@@ -0,0 +1,22 @@
import { frontendURL } from '../../../../helper/URLHelper';
import SettingsWrapper from '../SettingsWrapper.vue';
import ConversationWorkflowIndex from './index.vue';
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/conversation-workflow'),
component: SettingsWrapper,
children: [
{
path: '',
name: 'conversation_workflow_index',
component: ConversationWorkflowIndex,
meta: {
permissions: ['administrator'],
},
},
],
},
],
};

View File

@@ -0,0 +1,48 @@
<script setup>
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import ConversationRequiredAttributes from 'dashboard/components-next/ConversationWorkflow/ConversationRequiredAttributes.vue';
import AutoResolve from 'dashboard/routes/dashboard/settings/account/components/AutoResolve.vue';
const { accountId } = useAccount();
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showAutoResolutionConfig = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.AUTO_RESOLVE_CONVERSATIONS
);
});
const showRequiredAttributes = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES
);
});
</script>
<template>
<SettingsLayout :no-records-found="false" class="gap-10">
<template #header>
<BaseSettingsHeader
:title="$t('CONVERSATION_WORKFLOW.INDEX.HEADER.TITLE')"
:description="$t('CONVERSATION_WORKFLOW.INDEX.HEADER.DESCRIPTION')"
feature-name="conversation-workflow"
/>
</template>
<template #body>
<div class="flex flex-col gap-6">
<AutoResolve v-if="showAutoResolutionConfig" />
<ConversationRequiredAttributes :is-enabled="showRequiredAttributes" />
</div>
</template>
</SettingsLayout>
</template>

View File

@@ -24,6 +24,7 @@ import teams from './teams/teams.routes';
import customRoles from './customRoles/customRole.routes';
import profile from './profile/profile.routes';
import security from './security/security.routes';
import conversationWorkflow from './conversationWorkflow/conversationWorkflow.routes';
import captain from './captain/captain.routes';
export default {
@@ -64,6 +65,7 @@ export default {
...customRoles.routes,
...profile.routes,
...security.routes,
...conversationWorkflow.routes,
...captain.routes,
],
};

View File

@@ -240,9 +240,21 @@ const actions = {
toggleStatus: async (
{ commit },
{ conversationId, status, snoozedUntil = null }
{ conversationId, status, snoozedUntil = null, customAttributes = null }
) => {
try {
// Update custom attributes first if provided
if (customAttributes) {
await ConversationApi.updateCustomAttributes({
conversationId,
customAttributes,
});
commit(types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES, {
conversationId,
customAttributes,
});
}
const {
data: {
payload: {
@@ -459,7 +471,10 @@ const actions = {
customAttributes,
});
const { custom_attributes } = response.data;
commit(types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES, custom_attributes);
commit(types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES, {
conversationId,
customAttributes: custom_attributes,
});
} catch (error) {
// Handle error
}

View File

@@ -121,9 +121,19 @@ export const mutations = {
chat.priority = priority;
},
[types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES](_state, custom_attributes) {
const [chat] = getSelectedChatConversation(_state);
chat.custom_attributes = custom_attributes;
[types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES](
_state,
{ conversationId, customAttributes }
) {
const conversation = _state.allConversations.find(
c => c.id === conversationId
);
if (conversation) {
conversation.custom_attributes = {
...conversation.custom_attributes,
...customAttributes,
};
}
},
[types.CHANGE_CONVERSATION_STATUS](

View File

@@ -548,7 +548,13 @@ describe('#deleteMessage', () => {
}
);
expect(commit.mock.calls).toEqual([
[types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES, { order_d: '1001' }],
[
types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES,
{
conversationId: 1,
customAttributes: { order_d: '1001' },
},
],
]);
});
});

View File

@@ -239,14 +239,14 @@ describe('#mutations', () => {
describe('#UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES', () => {
it('update conversation custom attributes', () => {
const custom_attributes = { order_id: 1001 };
const state = { allConversations: [{ id: 1 }], selectedChatId: 1 };
const state = { allConversations: [{ id: 1, custom_attributes: {} }] };
mutations[types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES](state, {
conversationId: 1,
custom_attributes,
customAttributes: custom_attributes,
});
expect(
state.allConversations[0].custom_attributes.custom_attributes
).toEqual(custom_attributes);
expect(state.allConversations[0].custom_attributes).toEqual(
custom_attributes
);
});
});
});

View File

@@ -85,7 +85,7 @@ class Account < ApplicationRecord
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
store_accessor :settings, :audio_transcriptions, :auto_resolve_label, :conversation_required_attributes
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
store_accessor :settings, :captain_models, :captain_features
has_many :account_users, dependent: :destroy_async

View File

@@ -45,7 +45,6 @@ class CustomAttributeDefinition < ApplicationRecord
belongs_to :account
after_update :update_widget_pre_chat_custom_fields
after_destroy :sync_widget_pre_chat_custom_fields
after_destroy :cleanup_conversation_required_attributes
private
@@ -57,13 +56,6 @@ class CustomAttributeDefinition < ApplicationRecord
::Inboxes::UpdateWidgetPreChatCustomFieldsJob.perform_later(account, self)
end
def cleanup_conversation_required_attributes
return unless conversation_attribute? && account.conversation_required_attributes&.include?(attribute_key)
account.conversation_required_attributes = account.conversation_required_attributes - [attribute_key]
account.save!
end
def attribute_must_not_conflict
model_keys = attribute_model.to_sym == :conversation_attribute ? :conversation : :contact
return unless attribute_key.in?(STANDARD_ATTRIBUTES[model_keys])
@@ -71,3 +63,5 @@ class CustomAttributeDefinition < ApplicationRecord
errors.add(:attribute_key, I18n.t('errors.custom_attribute_definition.key_conflict'))
end
end
CustomAttributeDefinition.include_mod_with('Concerns::CustomAttributeDefinition')

View File

@@ -237,3 +237,7 @@
- name: captain_tasks
display_name: Captain Tasks
enabled: true
- name: conversation_required_attributes
display_name: Required Conversation Attributes
enabled: false
premium: true

View File

@@ -0,0 +1,7 @@
module Enterprise::Api::V1::AccountsSettings
private
def permitted_settings_attributes
super + [{ conversation_required_attributes: [] }]
end
end

View File

@@ -2,6 +2,8 @@ module Enterprise::Concerns::Account
extend ActiveSupport::Concern
included do
store_accessor :settings, :conversation_required_attributes
has_many :sla_policies, dependent: :destroy_async
has_many :applied_slas, dependent: :destroy_async
has_many :custom_roles, dependent: :destroy_async

View File

@@ -0,0 +1,16 @@
module Enterprise::Concerns::CustomAttributeDefinition
extend ActiveSupport::Concern
included do
after_destroy :cleanup_conversation_required_attributes
end
private
def cleanup_conversation_required_attributes
return unless conversation_attribute? && account.conversation_required_attributes&.include?(attribute_key)
account.conversation_required_attributes = account.conversation_required_attributes - [attribute_key]
account.save!
end
end

View File

@@ -22,7 +22,7 @@ class Enterprise::Billing::HandleStripeEventService
].freeze
# Additional features available starting with the Business plan
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes].freeze
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes].freeze
# Additional features available only in the Enterprise plan
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze

View File

@@ -6,3 +6,4 @@
- custom_roles
- captain_integration
- csat_review_notes
- conversation_required_attributes