feat: Agent capacity policy Create/Edit pages (#12424)

# Pull Request Template

## Description

Fixes
https://linear.app/chatwoot/issue/CW-5573/feat-createedit-agent-capacity-policy-page

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/8de9e3c5d8824cd998d242636540dd18?sid=1314536f-c8d6-41fd-8139-cae9bf94f942

### Screenshots

**Light mode**
<img width="1666" height="1225" alt="image"
src="https://github.com/user-attachments/assets/7e6d83a4-ce02-47a7-91f6-87745f8f5549"
/>
<img width="1666" height="1225" alt="image"
src="https://github.com/user-attachments/assets/7dd1f840-2e25-4365-aa1d-ed9dac13385a"
/>

**Dark mode**
<img width="1666" height="1225" alt="image"
src="https://github.com/user-attachments/assets/0c787095-7146-4fb3-a61a-e2232973bcba"
/>
<img width="1666" height="1225" alt="image"
src="https://github.com/user-attachments/assets/481c21fd-03b5-4c1f-b59e-7f8c8017f9ce"
/>


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Sivin Varghese
2025-09-12 18:42:55 +05:30
committed by GitHub
parent 699731d351
commit ca579bd62a
21 changed files with 1965 additions and 33 deletions

View File

@@ -4,6 +4,7 @@ import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { picoSearch } from '@scmmishra/pico-search';
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -36,8 +37,8 @@ const filteredItems = computed(() => {
return picoSearch(props.items, query, ['name']);
});
const handleAdd = inbox => {
emit('add', inbox);
const handleAdd = item => {
emit('add', item);
togglePopover(false);
};
@@ -82,21 +83,35 @@ const handleClickOutside = () => {
<div
v-for="item in filteredItems"
:key="item.id"
class="flex items-start gap-3 min-w-0 w-full py-4 px-3 hover:bg-n-alpha-2 cursor-pointer"
class="flex gap-3 min-w-0 w-full py-4 px-3 hover:bg-n-alpha-2 cursor-pointer"
:class="{ 'items-center': item.color, 'items-start': !item.color }"
@click="handleAdd(item)"
>
<Icon
v-if="item.icon"
:icon="item.icon"
class="size-4 text-n-slate-12 flex-shrink-0 mt-0.5"
class="size-2 text-n-slate-12 flex-shrink-0 mt-0.5"
/>
<span
v-else-if="item.color"
:style="{ backgroundColor: item.color }"
class="size-3 rounded-sm"
/>
<Avatar
v-else
:title="item.name"
:src="item.avatarUrl"
:name="item.name"
:size="20"
rounded-full
/>
<div class="flex flex-col items-start gap-2 min-w-0">
<div class="flex items-center gap-1 min-w-0">
<span
:title="item.name"
:title="item.name || item.title"
class="text-sm text-n-slate-12 truncate min-w-0"
>
{{ item.name }}
{{ item.name || item.title }}
</span>
<span
v-if="item.id"

View File

@@ -65,6 +65,7 @@ watch(
() => {
emit('validationChange', {
isValid: isValid.value,
section: 'baseInfo',
});
},
{ immediate: true }
@@ -108,7 +109,7 @@ watch(
</div>
<!-- Status Field -->
<div class="flex items-center gap-6">
<div v-if="statusLabel" class="flex items-center gap-6">
<WithLabel
:label="statusLabel"
name="enabled"

View File

@@ -1,4 +1,5 @@
<script setup>
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
@@ -32,12 +33,14 @@ const handleDelete = itemId => {
>
<Spinner />
</div>
<span
<div
v-else-if="items.length === 0 && emptyStateMessage"
class="flex items-center justify-center pt-4 pb-8 w-full text-sm text-n-slate-11"
class="custom-dashed-border flex items-center justify-center py-6 w-full"
>
{{ emptyStateMessage }}
</span>
<span class="text-sm text-n-slate-11">
{{ emptyStateMessage }}
</span>
</div>
<div v-else class="flex flex-col divide-y divide-n-weak">
<div
v-for="item in items"
@@ -50,7 +53,14 @@ const handleDelete = itemId => {
:icon="item.icon"
class="size-4 text-n-slate-12 flex-shrink-0"
/>
<Avatar
v-else
:title="item.name"
:src="item.avatarUrl"
:name="item.name"
:size="20"
rounded-full
/>
<span class="text-sm text-n-slate-12 truncate min-w-0">
{{ item.name }}
</span>

View File

@@ -0,0 +1,149 @@
<script setup>
import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
const props = defineProps({
tagsList: {
type: Array,
default: () => [],
},
});
const excludedLabels = defineModel('excludedLabels', {
type: Array,
default: () => [],
});
const excludeOlderThanMinutes = defineModel('excludeOlderThanMinutes', {
type: Number,
default: 10,
});
// Duration limits: 10 minutes to 999 days (in minutes)
const MIN_DURATION_MINUTES = 10;
const MAX_DURATION_MINUTES = 1438560; // 999 days * 24 hours * 60 minutes
const { t } = useI18n();
const hoveredLabel = ref(null);
const windowUnit = ref(DURATION_UNITS.MINUTES);
const addedTags = computed(() =>
props.tagsList
.filter(label => excludedLabels.value.includes(label.name))
.map(label => ({ id: label.id, title: label.name, ...label }))
);
const filteredTags = computed(() =>
props.tagsList.filter(
label => !addedTags.value.some(tag => tag.id === label.id)
)
);
const detectUnit = minutes => {
const m = Number(minutes) || 0;
if (m === 0) return DURATION_UNITS.MINUTES;
if (m % (24 * 60) === 0) return DURATION_UNITS.DAYS;
if (m % 60 === 0) return DURATION_UNITS.HOURS;
return DURATION_UNITS.MINUTES;
};
const onClickAddTag = tag => {
excludedLabels.value = [...excludedLabels.value, tag.name];
};
const onClickRemoveTag = tag => {
excludedLabels.value = excludedLabels.value.filter(
name => name !== tag.title
);
};
onMounted(() => {
windowUnit.value = detectUnit(excludeOlderThanMinutes.value);
});
</script>
<template>
<div class="py-4 flex-col flex gap-6">
<div class="flex flex-col items-start gap-1 py-1">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{
t(
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.LABEL'
)
}}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{
t(
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.DESCRIPTION'
)
}}
</p>
</div>
<div class="flex flex-col items-start gap-4">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{
t(
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.TAGS.LABEL'
)
}}
</label>
<div
class="flex items-start gap-2 flex-wrap"
@mouseleave="hoveredLabel = null"
>
<LabelItem
v-for="tag in addedTags"
:key="tag.id"
:label="tag"
:is-hovered="hoveredLabel === tag.id"
class="h-8"
@remove="onClickRemoveTag"
@hover="hoveredLabel = tag.id"
/>
<AddDataDropdown
:label="
t(
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.TAGS.ADD_TAG'
)
"
:search-placeholder="
t(
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.TAGS.DROPDOWN.SEARCH_PLACEHOLDER'
)
"
:items="filteredTags"
class="[&>button]:!text-n-blue-text"
@add="onClickAddTag"
/>
</div>
</div>
<div class="flex flex-col items-start gap-4">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{
t(
'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.FORM.EXCLUSION_RULES.DURATION.LABEL'
)
}}
</label>
<div
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
>
<!-- allow 10 mins to 999 days -->
<DurationInput
v-model:unit="windowUnit"
v-model:model-value="excludeOlderThanMinutes"
:min="MIN_DURATION_MINUTES"
:max="MAX_DURATION_MINUTES"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,163 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
const props = defineProps({
inboxList: {
type: Array,
default: () => [],
},
isFetching: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['delete', 'add', 'update']);
const inboxCapacityLimits = defineModel('inboxCapacityLimits', {
type: Array,
default: () => [],
});
const { t } = useI18n();
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
const DEFAULT_CONVERSATION_LIMIT = 10;
const MIN_CONVERSATION_LIMIT = 1;
const MAX_CONVERSATION_LIMIT = 100000;
const selectedInboxIds = computed(
() => new Set(inboxCapacityLimits.value.map(limit => limit.inboxId))
);
const availableInboxes = computed(() =>
props.inboxList.filter(
inbox => inbox && !selectedInboxIds.value.has(inbox.id)
)
);
const isLimitValid = limit => {
return (
limit.conversationLimit >= MIN_CONVERSATION_LIMIT &&
limit.conversationLimit <= MAX_CONVERSATION_LIMIT
);
};
const inboxMap = computed(
() => new Map(props.inboxList.map(inbox => [inbox.id, inbox]))
);
const handleAddInbox = inbox => {
emit('add', {
inboxId: inbox.id,
conversationLimit: DEFAULT_CONVERSATION_LIMIT,
});
};
const handleRemoveLimit = limitId => {
emit('delete', limitId);
};
const handleLimitChange = limit => {
if (isLimitValid(limit)) {
emit('update', limit);
}
};
const getInboxName = inboxId => {
return inboxMap.value.get(inboxId)?.name || '';
};
</script>
<template>
<div class="py-4 flex-col flex gap-3">
<div class="flex items-center w-full gap-8 justify-between pt-1 pb-3">
<label class="text-sm font-medium text-n-slate-12">
{{ t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.LABEL`) }}
</label>
<AddDataDropdown
:label="t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.ADD_BUTTON`)"
:search-placeholder="
t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.SELECT_INBOX`)
"
:items="availableInboxes"
@add="handleAddInbox"
/>
</div>
<div
v-if="isFetching"
class="flex items-center justify-center py-3 w-full text-n-slate-11"
>
<Spinner />
</div>
<div
v-else-if="!inboxCapacityLimits.length"
class="custom-dashed-border flex items-center justify-center py-6 w-full"
>
<span class="text-sm text-n-slate-11">
{{ t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.EMPTY_STATE`) }}
</span>
</div>
<div v-else class="flex-col flex gap-3">
<div
v-for="(limit, index) in inboxCapacityLimits"
:key="limit.id || `temp-${index}`"
class="flex items-center gap-3"
>
<div
class="flex items-start rounded-lg outline-1 outline cursor-not-allowed text-n-slate-11 outline-n-weak py-2.5 px-3 text-sm w-full"
>
{{ getInboxName(limit.inboxId) }}
</div>
<div
class="py-2.5 px-3 rounded-lg gap-2 outline outline-1 flex-shrink-0 flex items-center"
:class="[
!isLimitValid(limit) ? 'outline-n-ruby-8' : 'outline-n-weak',
]"
>
<label class="text-sm text-n-slate-12 ltr:pr-2 rtl:pl-2">
{{
t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.MAX_CONVERSATIONS`)
}}
</label>
<div class="h-5 w-px bg-n-weak" />
<input
v-model.number="limit.conversationLimit"
type="number"
:min="MIN_CONVERSATION_LIMIT"
:max="MAX_CONVERSATION_LIMIT"
class="reset-base bg-transparent focus:outline-none max-w-20 text-sm"
:class="[
!isLimitValid(limit)
? 'placeholder:text-n-ruby-9 !text-n-ruby-9'
: 'placeholder:text-n-slate-10 text-n-slate-12',
]"
:placeholder="
t(`${BASE_KEY}.FORM.INBOX_CAPACITY_LIMIT.FIELD.SET_LIMIT`)
"
@blur="handleLimitChange(limit)"
/>
</div>
<Button
type="button"
slate
icon="i-lucide-trash"
class="flex-shrink-0"
@click="handleRemoveLimit(limit.id)"
/>
</div>
</div>
</div>
</template>

View File

@@ -34,6 +34,29 @@ const mockInboxes = [
},
];
const mockTags = [
{
id: 1,
name: 'urgent',
color: '#ff4757',
},
{
id: 2,
name: 'bug',
color: '#ff6b6b',
},
{
id: 3,
name: 'feature-request',
color: '#4834d4',
},
{
id: 4,
name: 'documentation',
color: '#26de81',
},
];
const handleAdd = item => {
console.log('Add item:', item);
};
@@ -42,9 +65,9 @@ const handleAdd = item => {
<template>
<Story
title="Components/AgentManagementPolicy/AddDataDropdown"
:layout="{ type: 'grid', width: '400px' }"
:layout="{ type: 'grid', width: '500px' }"
>
<Variant title="Basic Usage">
<Variant title="Basic Usage - Inboxes">
<div class="p-8 bg-n-background flex gap-4 h-[400px] items-start">
<AddDataDropdown
label="Add Inbox"
@@ -54,5 +77,16 @@ const handleAdd = item => {
/>
</div>
</Variant>
<Variant title="Basic Usage - Tags">
<div class="p-8 bg-n-background flex gap-4 h-[400px] items-start">
<AddDataDropdown
label="Add Tag"
search-placeholder="Search tags..."
:items="mockTags"
@add="handleAdd"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -22,6 +22,21 @@ const mockItems = [
},
];
const mockAgentList = [
{
id: 1,
name: 'John Doe',
email: 'john.doe@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=1',
},
{
id: 2,
name: 'Jane Smith',
email: 'jane.smith@example.com',
avatarUrl: 'https://i.pravatar.cc/150?img=2',
},
];
const handleDelete = itemId => {
console.log('Delete item:', itemId);
};
@@ -30,7 +45,7 @@ const handleDelete = itemId => {
<template>
<Story
title="Components/AgentManagementPolicy/DataTable"
:layout="{ type: 'grid', width: '600px' }"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="With Data">
<div class="p-8 bg-n-background">
@@ -42,6 +57,16 @@ const handleDelete = itemId => {
</div>
</Variant>
<Variant title="With Agents">
<div class="p-8 bg-n-background">
<DataTable
:items="mockAgentList"
:is-fetching="false"
@delete="handleDelete"
/>
</div>
</Variant>
<Variant title="Loading State">
<div class="p-8 bg-n-background">
<DataTable :items="[]" is-fetching @delete="handleDelete" />

View File

@@ -0,0 +1,67 @@
<script setup>
import ExclusionRules from '../ExclusionRules.vue';
import { ref } from 'vue';
const mockTagsList = [
{
id: 1,
name: 'urgent',
color: '#ff4757',
},
{
id: 2,
name: 'bug',
color: '#ff6b6b',
},
{
id: 3,
name: 'feature-request',
color: '#4834d4',
},
{
id: 4,
name: 'documentation',
color: '#26de81',
},
{
id: 5,
name: 'enhancement',
color: '#2ed573',
},
{
id: 6,
name: 'question',
color: '#ffa502',
},
{
id: 7,
name: 'duplicate',
color: '#747d8c',
},
{
id: 8,
name: 'wontfix',
color: '#57606f',
},
];
const excludedLabelsBasic = ref([]);
const excludeOlderThanHoursBasic = ref(10);
</script>
<template>
<Story
title="Components/AgentManagementPolicy/ExclusionRules"
:layout="{ type: 'grid', width: '1200px' }"
>
<Variant title="Basic Usage">
<div class="p-8 bg-n-background h-[600px]">
<ExclusionRules
v-model:excluded-labels="excludedLabelsBasic"
v-model:exclude-older-than-minutes="excludeOlderThanHoursBasic"
:tags-list="mockTagsList"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,108 @@
<script setup>
import InboxCapacityLimits from '../InboxCapacityLimits.vue';
import { ref } from 'vue';
const mockInboxList = [
{
value: 1,
label: 'Website Support',
icon: 'i-lucide-globe',
},
{
value: 2,
label: 'Email Support',
icon: 'i-lucide-mail',
},
{
value: 3,
label: 'WhatsApp Business',
icon: 'i-lucide-message-circle',
},
{
value: 4,
label: 'Facebook Messenger',
icon: 'i-lucide-facebook',
},
{
value: 5,
label: 'Twitter DM',
icon: 'i-lucide-twitter',
},
{
value: 6,
label: 'Telegram',
icon: 'i-lucide-send',
},
];
const inboxCapacityLimitsEmpty = ref([]);
const inboxCapacityLimitsNew = ref([
{ id: 1, inboxId: 1, conversationLimit: 5 },
{ inboxId: null, conversationLimit: null },
]);
const handleDelete = id => {
console.log('Delete capacity limit:', id);
};
</script>
<template>
<Story
title="Components/AgentManagementPolicy/InboxCapacityLimits"
:layout="{ type: 'grid', width: '900px' }"
>
<Variant title="Empty State">
<div class="p-8 bg-n-background">
<InboxCapacityLimits
v-model:inbox-capacity-limits="inboxCapacityLimitsEmpty"
:inbox-list="mockInboxList"
:is-fetching="false"
:is-updating="false"
@delete="handleDelete"
/>
</div>
</Variant>
<Variant title="Loading State">
<div class="p-8 bg-n-background">
<InboxCapacityLimits
v-model:inbox-capacity-limits="inboxCapacityLimitsEmpty"
:inbox-list="mockInboxList"
is-fetching
:is-updating="false"
@delete="handleDelete"
/>
</div>
</Variant>
<Variant title="With New Row and existing data">
<div class="p-8 bg-n-background">
<InboxCapacityLimits
v-model:inbox-capacity-limits="inboxCapacityLimitsNew"
:inbox-list="mockInboxList"
:is-fetching="false"
:is-updating="false"
@delete="handleDelete"
/>
</div>
</Variant>
<Variant title="Interactive Demo">
<div class="p-8 bg-n-background">
<InboxCapacityLimits
v-model:inbox-capacity-limits="inboxCapacityLimitsEmpty"
:inbox-list="mockInboxList"
:is-fetching="false"
:is-updating="false"
@delete="handleDelete"
/>
<div class="mt-4 p-4 bg-n-alpha-2 rounded-lg">
<h4 class="text-sm font-medium mb-2">Current Limits:</h4>
<pre class="text-xs">{{
JSON.stringify(inboxCapacityLimitsEmpty, null, 2)
}}</pre>
</div>
</div>
</Variant>
</Story>
</template>

View File

@@ -86,8 +86,8 @@ const handleLabelAction = async ({ value }) => {
}
};
const handleRemoveLabel = labelId => {
return handleLabelAction({ value: labelId });
const handleRemoveLabel = label => {
return handleLabelAction({ value: label.id });
};
watch(

View File

@@ -15,7 +15,7 @@ const props = defineProps({
const emit = defineEmits(['remove', 'hover']);
const handleRemoveLabel = () => {
emit('remove', props.label?.id);
emit('remove', props.label);
};
const handleMouseEnter = () => {
@@ -45,6 +45,7 @@ const handleMouseEnter = () => {
<Button
class="transition-opacity duration-200 !h-7 ltr:rounded-r-md rtl:rounded-l-md ltr:rounded-l-none rtl:rounded-r-none w-6 bg-transparent"
:class="{ 'opacity-0': !isHovered, 'opacity-100': isHovered }"
type="button"
slate
xs
faded

View File

@@ -579,6 +579,92 @@
},
"NO_RECORDS_FOUND": "No agent capacity policies found"
},
"CREATE": {
"HEADER": {
"TITLE": "Create agent capacity policy"
},
"CREATE_BUTTON": "Create policy",
"API": {
"SUCCESS_MESSAGE": "Agent capacity policy created successfully",
"ERROR_MESSAGE": "Failed to create agent capacity policy"
}
},
"EDIT": {
"HEADER": {
"TITLE": "Edit agent capacity policy"
},
"EDIT_BUTTON": "Update policy",
"CONFIRM_ADD_AGENT_DIALOG": {
"TITLE": "Add agent",
"DESCRIPTION": "{agentName} is already linked to another policy. Are you sure you want to link it to this policy? It will be unlinked from the other policy.",
"CONFIRM_BUTTON_LABEL": "Continue",
"CANCEL_BUTTON_LABEL": "Cancel"
},
"API": {
"SUCCESS_MESSAGE": "Agent capacity policy updated successfully",
"ERROR_MESSAGE": "Failed to update agent capacity policy"
},
"AGENT_API": {
"ADD": {
"SUCCESS_MESSAGE": "Agent added to policy successfully",
"ERROR_MESSAGE": "Failed to add agent to policy"
},
"REMOVE": {
"SUCCESS_MESSAGE": "Agent removed from policy successfully",
"ERROR_MESSAGE": "Failed to remove agent from policy"
}
}
},
"FORM": {
"NAME": {
"LABEL": "Policy name:",
"PLACEHOLDER": "Enter policy name"
},
"DESCRIPTION": {
"LABEL": "Description:",
"PLACEHOLDER": "Enter description"
},
"INBOX_CAPACITY_LIMIT": {
"LABEL": "Inbox capacity limits",
"ADD_BUTTON": "Add inbox",
"FIELD": {
"SELECT_INBOX": "Select inbox",
"MAX_CONVERSATIONS": "Max conversations",
"SET_LIMIT": "Set limit"
},
"EMPTY_STATE": "No inbox limit set"
},
"EXCLUSION_RULES": {
"LABEL": "Exclusion rules",
"DESCRIPTION": "Conversations that satisfy the following conditions would not count towards agent capacity",
"TAGS": {
"LABEL": "Exclude conversations tagged with specific labels",
"ADD_TAG": "add tag",
"DROPDOWN": {
"SEARCH_PLACEHOLDER": "Search and select tags to add"
},
"EMPTY_STATE": "No tags added to this policy."
},
"DURATION": {
"LABEL": "Exclude conversations older than a specified duration",
"PLACEHOLDER": "Set time"
}
},
"USERS": {
"LABEL": "Assigned agents",
"DESCRIPTION": "Add agents for which this policy will be applicable.",
"ADD_BUTTON": "Add agent",
"DROPDOWN": {
"SEARCH_PLACEHOLDER": "Search and select agents to add",
"ADD_BUTTON": "Add"
},
"EMPTY_STATE": "No agents added",
"API": {
"SUCCESS_MESSAGE": "Agent successfully added to policy",
"ERROR_MESSAGE": "Failed to add agent to policy"
}
}
},
"DELETE_POLICY": {
"SUCCESS_MESSAGE": "Agent capacity policy deleted successfully",
"ERROR_MESSAGE": "Failed to delete agent capacity policy"

View File

@@ -6,6 +6,8 @@ import AgentAssignmentIndex from './pages/AgentAssignmentIndexPage.vue';
import AgentAssignmentCreate from './pages/AgentAssignmentCreatePage.vue';
import AgentAssignmentEdit from './pages/AgentAssignmentEditPage.vue';
import AgentCapacityIndex from './pages/AgentCapacityIndexPage.vue';
import AgentCapacityCreate from './pages/AgentCapacityCreatePage.vue';
import AgentCapacityEdit from './pages/AgentCapacityEditPage.vue';
export default {
routes: [
@@ -64,6 +66,24 @@ export default {
permissions: ['administrator'],
},
},
{
path: 'capacity/create',
name: 'agent_capacity_policy_create',
component: AgentCapacityCreate,
meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'],
},
},
{
path: 'capacity/edit/:id',
name: 'agent_capacity_policy_edit',
component: AgentCapacityEdit,
meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'],
},
},
],
},
],

View File

@@ -0,0 +1,87 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AgentCapacityPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentCapacityPolicyForm.vue';
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const formRef = ref(null);
const uiFlags = useMapGetter('agentCapacityPolicies/getUIFlags');
const labelsList = useMapGetter('labels/getLabels');
const allLabels = computed(() =>
labelsList.value?.map(({ title, color, id }) => ({
id,
name: title,
color,
}))
);
const breadcrumbItems = computed(() => [
{
label: t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.INDEX.HEADER.TITLE'),
routeName: 'agent_capacity_policy_index',
},
{
label: t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.CREATE.HEADER.TITLE'),
},
]);
const handleBreadcrumbClick = item => {
router.push({
name: item.routeName,
});
};
const handleSubmit = async formState => {
try {
const policy = await store.dispatch(
'agentCapacityPolicies/create',
formState
);
useAlert(
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.CREATE.API.SUCCESS_MESSAGE')
);
formRef.value?.resetForm();
router.push({
name: 'agent_capacity_policy_edit',
params: {
id: policy.id,
},
});
} catch (error) {
useAlert(
t('ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY.CREATE.API.ERROR_MESSAGE')
);
}
};
</script>
<template>
<SettingsLayout class="xl:px-44">
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</div>
</template>
<template #body>
<AgentCapacityPolicyForm
ref="formRef"
mode="CREATE"
:is-loading="uiFlags.isCreating"
:label-list="allLabels"
@submit="handleSubmit"
/>
</template>
</SettingsLayout>
</template>

View File

@@ -0,0 +1,179 @@
<script setup>
import { computed, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute, useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import camelcaseKeys from 'camelcase-keys';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AgentCapacityPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentCapacityPolicyForm.vue';
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const uiFlags = useMapGetter('agentCapacityPolicies/getUIFlags');
const usersUiFlags = useMapGetter('agentCapacityPolicies/getUsersUIFlags');
const selectedPolicyById = useMapGetter(
'agentCapacityPolicies/getAgentCapacityPolicyById'
);
const agentsList = useMapGetter('agents/getAgents');
const labelsList = useMapGetter('labels/getLabels');
const inboxes = useMapGetter('inboxes/getAllInboxes');
const inboxesUiFlags = useMapGetter('inboxes/getUIFlags');
const routeId = computed(() => route.params.id);
const selectedPolicy = computed(() => selectedPolicyById.value(routeId.value));
const selectedPolicyId = computed(() => selectedPolicy.value?.id);
const breadcrumbItems = computed(() => [
{
label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
routeName: 'agent_capacity_policy_index',
},
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
]);
const buildList = items =>
items?.map(({ name, title, id, email, avatarUrl, thumbnail, color }) => ({
name: name || title,
id,
email,
avatarUrl: avatarUrl || thumbnail,
color,
})) || [];
const policyUsers = computed(() => buildList(selectedPolicy.value?.users));
const allAgents = computed(() =>
buildList(camelcaseKeys(agentsList.value)).filter(
agent => !policyUsers.value?.some(user => user.id === agent.id)
)
);
const allLabels = computed(() => buildList(labelsList.value));
const allInboxes = computed(() => buildList(inboxes.value));
const formData = computed(() => ({
name: selectedPolicy.value?.name || '',
description: selectedPolicy.value?.description || '',
exclusionRules: {
excludedLabels: [
...(selectedPolicy.value?.exclusionRules?.excludedLabels || []),
],
excludeOlderThanHours:
selectedPolicy.value?.exclusionRules?.excludeOlderThanHours || 10,
},
inboxCapacityLimits:
selectedPolicy.value?.inboxCapacityLimits?.map(limit => ({
...limit,
})) || [],
}));
const handleBreadcrumbClick = ({ routeName }) =>
router.push({ name: routeName });
const handleDeleteUser = agentId => {
store.dispatch('agentCapacityPolicies/removeUser', {
policyId: selectedPolicyId.value,
userId: agentId,
});
};
const handleAddUser = agent => {
store.dispatch('agentCapacityPolicies/addUser', {
policyId: selectedPolicyId.value,
userData: { id: agent.id, capacity: 20 },
});
};
const handleDeleteInboxLimit = limitId => {
store.dispatch('agentCapacityPolicies/deleteInboxLimit', {
policyId: selectedPolicyId.value,
limitId,
});
};
const handleAddInboxLimit = limit => {
store.dispatch('agentCapacityPolicies/createInboxLimit', {
policyId: selectedPolicyId.value,
limitData: {
inboxId: limit.inboxId,
conversationLimit: limit.conversationLimit,
},
});
};
const handleLimitChange = limit => {
store.dispatch('agentCapacityPolicies/updateInboxLimit', {
policyId: selectedPolicyId.value,
limitId: limit.id,
limitData: { conversationLimit: limit.conversationLimit },
});
};
const handleSubmit = async formState => {
try {
await store.dispatch('agentCapacityPolicies/update', {
id: selectedPolicyId.value,
...formState,
});
useAlert(t(`${BASE_KEY}.EDIT.API.SUCCESS_MESSAGE`));
} catch {
useAlert(t(`${BASE_KEY}.EDIT.API.ERROR_MESSAGE`));
}
};
const fetchPolicyData = async () => {
if (!routeId.value) return;
// Fetch policy if not available
if (!selectedPolicyId.value)
await store.dispatch('agentCapacityPolicies/show', routeId.value);
await store.dispatch('agentCapacityPolicies/getUsers', Number(routeId.value));
};
watch(routeId, fetchPolicyData, { immediate: true });
onMounted(() => store.dispatch('agents/get'));
</script>
<template>
<SettingsLayout :is-loading="uiFlags.isFetchingItem" class="xl:px-44">
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</div>
</template>
<template #body>
<AgentCapacityPolicyForm
:key="routeId"
mode="EDIT"
:initial-data="formData"
:policy-users="policyUsers"
:agent-list="allAgents"
:label-list="allLabels"
:inbox-list="allInboxes"
show-user-section
show-inbox-limit-section
:is-loading="uiFlags.isUpdating"
:is-users-loading="usersUiFlags.isFetching"
:is-inboxes-loading="inboxesUiFlags.isFetching"
@submit="handleSubmit"
@add-user="handleAddUser"
@delete-user="handleDeleteUser"
@add-inbox-limit="handleAddInboxLimit"
@update-inbox-limit="handleLimitChange"
@delete-inbox-limit="handleDeleteInboxLimit"
/>
</template>
</SettingsLayout>
</template>

View File

@@ -0,0 +1,214 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ExclusionRules from 'dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue';
import InboxCapacityLimits from 'dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue';
const props = defineProps({
initialData: {
type: Object,
default: () => ({
name: '',
description: '',
enabled: false,
exclusionRules: {
excludedLabels: [],
excludeOlderThanHours: 10,
},
inboxCapacityLimits: [],
}),
},
mode: {
type: String,
required: true,
validator: value => ['CREATE', 'EDIT'].includes(value),
},
policyUsers: {
type: Array,
default: () => [],
},
agentList: {
type: Array,
default: () => [],
},
labelList: {
type: Array,
default: () => [],
},
inboxList: {
type: Array,
default: () => [],
},
showUserSection: {
type: Boolean,
default: false,
},
showInboxLimitSection: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
isUsersLoading: {
type: Boolean,
default: false,
},
isInboxesLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'submit',
'addUser',
'deleteUser',
'validationChange',
'deleteInboxLimit',
'addInboxLimit',
'updateInboxLimit',
]);
const { t } = useI18n();
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
const state = reactive({
name: '',
description: '',
exclusionRules: {
excludedLabels: [],
excludeOlderThanHours: 10,
},
inboxCapacityLimits: [],
});
const validationState = ref({
isValid: false,
});
const buttonLabel = computed(() =>
t(`${BASE_KEY}.${props.mode.toUpperCase()}.${props.mode}_BUTTON`)
);
const handleValidationChange = validation => {
validationState.value = validation;
emit('validationChange', validation);
};
const handleDeleteInboxLimit = id => {
emit('deleteInboxLimit', id);
};
const handleAddInboxLimit = limit => {
emit('addInboxLimit', limit);
};
const handleLimitChange = limit => {
emit('updateInboxLimit', limit);
};
const resetForm = () => {
Object.assign(state, {
name: '',
description: '',
exclusionRules: {
excludedLabels: [],
excludeOlderThanHours: 10,
},
inboxCapacityLimits: [],
});
};
const handleSubmit = () => {
emit('submit', { ...state });
};
watch(
() => props.initialData,
newData => {
Object.assign(state, newData);
},
{ immediate: true, deep: true }
);
defineExpose({
resetForm,
});
</script>
<template>
<form @submit.prevent="handleSubmit">
<div class="flex flex-col gap-4 divide-y divide-n-weak">
<BaseInfo
v-model:policy-name="state.name"
v-model:description="state.description"
:name-label="t(`${BASE_KEY}.FORM.NAME.LABEL`)"
:name-placeholder="t(`${BASE_KEY}.FORM.NAME.PLACEHOLDER`)"
:description-label="t(`${BASE_KEY}.FORM.DESCRIPTION.LABEL`)"
:description-placeholder="t(`${BASE_KEY}.FORM.DESCRIPTION.PLACEHOLDER`)"
@validation-change="handleValidationChange"
/>
<ExclusionRules
v-model:excluded-labels="state.exclusionRules.excludedLabels"
v-model:exclude-older-than-minutes="
state.exclusionRules.excludeOlderThanHours
"
:tags-list="labelList"
/>
</div>
<Button
type="submit"
:label="buttonLabel"
:disabled="!validationState.isValid || isLoading"
:is-loading="isLoading"
/>
<div
v-if="showInboxLimitSection || showUserSection"
class="flex flex-col gap-4 divide-y divide-n-weak border-t border-n-weak mt-6"
>
<InboxCapacityLimits
v-if="showInboxLimitSection"
v-model:inbox-capacity-limits="state.inboxCapacityLimits"
:inbox-list="inboxList"
:is-fetching="isInboxesLoading"
@delete="handleDeleteInboxLimit"
@add="handleAddInboxLimit"
@update="handleLimitChange"
/>
<div v-if="showUserSection" class="py-4 flex-col flex gap-4">
<div class="flex items-end gap-4 w-full justify-between">
<div class="flex flex-col items-start gap-1 py-1">
<label class="text-sm font-medium text-n-slate-12 py-1">
{{ t(`${BASE_KEY}.FORM.USERS.LABEL`) }}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{ t(`${BASE_KEY}.FORM.USERS.DESCRIPTION`) }}
</p>
</div>
<AddDataDropdown
:label="t(`${BASE_KEY}.FORM.USERS.ADD_BUTTON`)"
:search-placeholder="
t(`${BASE_KEY}.FORM.USERS.DROPDOWN.SEARCH_PLACEHOLDER`)
"
:items="agentList"
@add="$emit('addUser', $event)"
/>
</div>
<DataTable
:items="policyUsers"
:is-fetching="isUsersLoading"
:empty-state-message="t(`${BASE_KEY}.FORM.USERS.EMPTY_STATE`)"
@delete="$emit('deleteUser', $event)"
/>
</div>
</div>
</form>
</template>

View File

@@ -40,7 +40,10 @@ export const actions = {
commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetching: true });
try {
const response = await AgentCapacityPoliciesAPI.get();
commit(types.SET_AGENT_CAPACITY_POLICIES, camelcaseKeys(response.data));
commit(
types.SET_AGENT_CAPACITY_POLICIES,
camelcaseKeys(response.data, { deep: true })
);
} catch (error) {
throwErrorMessage(error);
} finally {
@@ -52,7 +55,7 @@ export const actions = {
commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetchingItem: true });
try {
const response = await AgentCapacityPoliciesAPI.show(policyId);
const policy = camelcaseKeys(response.data);
const policy = camelcaseKeys(response.data, { deep: true });
commit(types.SET_AGENT_CAPACITY_POLICY, policy);
} catch (error) {
throwErrorMessage(error);
@@ -69,7 +72,10 @@ export const actions = {
const response = await AgentCapacityPoliciesAPI.create(
snakecaseKeys(policyObj)
);
commit(types.ADD_AGENT_CAPACITY_POLICY, camelcaseKeys(response.data));
commit(
types.ADD_AGENT_CAPACITY_POLICY,
camelcaseKeys(response.data, { deep: true })
);
return response.data;
} catch (error) {
throwErrorMessage(error);
@@ -86,7 +92,10 @@ export const actions = {
id,
snakecaseKeys(policyParams)
);
commit(types.EDIT_AGENT_CAPACITY_POLICY, camelcaseKeys(response.data));
commit(
types.EDIT_AGENT_CAPACITY_POLICY,
camelcaseKeys(response.data, { deep: true })
);
return response.data;
} catch (error) {
throwErrorMessage(error);
@@ -129,6 +138,97 @@ export const actions = {
});
}
},
addUser: async function addUser({ commit }, { policyId, userData }) {
try {
const response = await AgentCapacityPoliciesAPI.addUser(
policyId,
userData
);
commit(types.ADD_AGENT_CAPACITY_POLICIES_USERS, {
policyId,
user: camelcaseKeys(response.data),
});
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
}
},
removeUser: async function removeUser({ commit }, { policyId, userId }) {
commit(types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG, {
isDeleting: true,
});
try {
await AgentCapacityPoliciesAPI.removeUser(policyId, userId);
commit(types.DELETE_AGENT_CAPACITY_POLICIES_USERS, { policyId, userId });
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG, {
isDeleting: false,
});
}
},
createInboxLimit: async function createInboxLimit(
{ commit },
{ policyId, limitData }
) {
try {
const response = await AgentCapacityPoliciesAPI.createInboxLimit(
policyId,
limitData
);
commit(
types.SET_AGENT_CAPACITY_POLICIES_INBOXES,
camelcaseKeys(response.data)
);
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
}
},
updateInboxLimit: async function updateInboxLimit(
{ commit },
{ policyId, limitId, limitData }
) {
try {
const response = await AgentCapacityPoliciesAPI.updateInboxLimit(
policyId,
limitId,
limitData
);
commit(
types.EDIT_AGENT_CAPACITY_POLICIES_INBOXES,
camelcaseKeys(response.data)
);
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
}
},
deleteInboxLimit: async function deleteInboxLimit(
{ commit },
{ policyId, limitId }
) {
try {
await AgentCapacityPoliciesAPI.deleteInboxLimit(policyId, limitId);
commit(types.DELETE_AGENT_CAPACITY_POLICIES_INBOXES, {
policyId,
limitId,
});
} catch (error) {
throwErrorMessage(error);
throw error;
}
},
};
export const mutations = {
@@ -157,6 +257,54 @@ export const mutations = {
policy.users = users;
}
},
[types.ADD_AGENT_CAPACITY_POLICIES_USERS](_state, { policyId, user }) {
const policy = _state.records.find(p => p.id === policyId);
if (policy) {
policy.users = policy.users || [];
policy.users.push(user);
policy.assignedAgentCount = policy.users.length;
}
},
[types.DELETE_AGENT_CAPACITY_POLICIES_USERS](_state, { policyId, userId }) {
const policy = _state.records.find(p => p.id === policyId);
if (policy) {
policy.users = (policy.users || []).filter(user => user.id !== userId);
policy.assignedAgentCount = policy.users.length;
}
},
[types.SET_AGENT_CAPACITY_POLICIES_INBOXES](_state, data) {
const policy = _state.records.find(
p => p.id === data.agentCapacityPolicyId
);
policy?.inboxCapacityLimits.push({
id: data.id,
inboxId: data.inboxId,
conversationLimit: data.conversationLimit,
});
},
[types.EDIT_AGENT_CAPACITY_POLICIES_INBOXES](_state, data) {
const policy = _state.records.find(
p => p.id === data.agentCapacityPolicyId
);
const limit = policy?.inboxCapacityLimits.find(l => l.id === data.id);
if (limit) {
Object.assign(limit, {
conversationLimit: data.conversationLimit,
});
}
},
[types.DELETE_AGENT_CAPACITY_POLICIES_INBOXES](
_state,
{ policyId, limitId }
) {
const policy = _state.records.find(p => p.id === policyId);
if (policy) {
policy.inboxCapacityLimits = policy.inboxCapacityLimits.filter(
limit => limit.id !== limitId
);
}
},
};
export default {

View File

@@ -1,7 +1,12 @@
import axios from 'axios';
import { actions } from '../../agentCapacityPolicies';
import types from '../../../mutation-types';
import agentCapacityPoliciesList, { camelCaseFixtures } from './fixtures';
import agentCapacityPoliciesList, {
camelCaseFixtures,
mockUsers,
mockInboxLimits,
camelCaseMockInboxLimits,
} from './fixtures';
import camelcaseKeys from 'camelcase-keys';
import snakecaseKeys from 'snakecase-keys';
@@ -25,7 +30,9 @@ describe('#actions', () => {
await actions.get({ commit });
expect(camelcaseKeys).toHaveBeenCalledWith(agentCapacityPoliciesList);
expect(camelcaseKeys).toHaveBeenCalledWith(agentCapacityPoliciesList, {
deep: true,
});
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetching: true }],
[types.SET_AGENT_CAPACITY_POLICIES, camelCaseFixtures],
@@ -55,7 +62,9 @@ describe('#actions', () => {
await actions.show({ commit }, 1);
expect(camelcaseKeys).toHaveBeenCalledWith(policyData);
expect(camelcaseKeys).toHaveBeenCalledWith(policyData, {
deep: true,
});
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetchingItem: true }],
[types.SET_AGENT_CAPACITY_POLICY, camelCasedPolicy],
@@ -88,7 +97,9 @@ describe('#actions', () => {
const result = await actions.create({ commit }, newPolicy);
expect(snakecaseKeys).toHaveBeenCalledWith(newPolicy);
expect(camelcaseKeys).toHaveBeenCalledWith(newPolicy);
expect(camelcaseKeys).toHaveBeenCalledWith(newPolicy, {
deep: true,
});
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isCreating: true }],
[types.ADD_AGENT_CAPACITY_POLICY, camelCasedData],
@@ -129,7 +140,9 @@ describe('#actions', () => {
const result = await actions.update({ commit }, updateParams);
expect(snakecaseKeys).toHaveBeenCalledWith({ name: 'Updated Policy' });
expect(camelcaseKeys).toHaveBeenCalledWith(responseData);
expect(camelcaseKeys).toHaveBeenCalledWith(responseData, {
deep: true,
});
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isUpdating: true }],
[types.EDIT_AGENT_CAPACITY_POLICY, camelCasedData],
@@ -224,4 +237,172 @@ describe('#actions', () => {
]);
});
});
describe('#addUser', () => {
it('sends correct actions if API is success', async () => {
const policyId = 1;
const userData = { user_id: 3, capacity: 12 };
const responseData = mockUsers[2];
const camelCasedUser = mockUsers[2];
axios.post.mockResolvedValue({ data: responseData });
camelcaseKeys.mockReturnValue(camelCasedUser);
const result = await actions.addUser({ commit }, { policyId, userData });
expect(camelcaseKeys).toHaveBeenCalledWith(responseData);
expect(commit.mock.calls).toEqual([
[
types.ADD_AGENT_CAPACITY_POLICIES_USERS,
{ policyId, user: camelCasedUser },
],
]);
expect(result).toEqual(responseData);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue(new Error('Validation error'));
await expect(
actions.addUser({ commit }, { policyId: 1, userData: {} })
).rejects.toThrow(Error);
expect(commit).not.toHaveBeenCalled();
});
});
describe('#removeUser', () => {
it('sends correct actions if API is success', async () => {
const policyId = 1;
const userId = 2;
axios.delete.mockResolvedValue({});
await actions.removeUser({ commit }, { policyId, userId });
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG, { isDeleting: true }],
[types.DELETE_AGENT_CAPACITY_POLICIES_USERS, { policyId, userId }],
[
types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG,
{ isDeleting: false },
],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue(new Error('Not found'));
await expect(
actions.removeUser({ commit }, { policyId: 1, userId: 2 })
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG, { isDeleting: true }],
[
types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG,
{ isDeleting: false },
],
]);
});
});
describe('#createInboxLimit', () => {
it('sends correct actions if API is success', async () => {
const policyId = 1;
const limitData = { inbox_id: 3, conversation_limit: 20 };
const responseData = mockInboxLimits[2];
const camelCasedData = camelCaseMockInboxLimits[2];
axios.post.mockResolvedValue({ data: responseData });
camelcaseKeys.mockReturnValue(camelCasedData);
const result = await actions.createInboxLimit(
{ commit },
{ policyId, limitData }
);
expect(camelcaseKeys).toHaveBeenCalledWith(responseData);
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_CAPACITY_POLICIES_INBOXES, camelCasedData],
]);
expect(result).toEqual(responseData);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue(new Error('Validation error'));
await expect(
actions.createInboxLimit({ commit }, { policyId: 1, limitData: {} })
).rejects.toThrow(Error);
expect(commit).not.toHaveBeenCalled();
});
});
describe('#updateInboxLimit', () => {
it('sends correct actions if API is success', async () => {
const policyId = 1;
const limitId = 1;
const limitData = { conversation_limit: 25 };
const responseData = {
...mockInboxLimits[0],
conversation_limit: 25,
};
const camelCasedData = {
...camelCaseMockInboxLimits[0],
conversationLimit: 25,
};
axios.put.mockResolvedValue({ data: responseData });
camelcaseKeys.mockReturnValue(camelCasedData);
const result = await actions.updateInboxLimit(
{ commit },
{ policyId, limitId, limitData }
);
expect(camelcaseKeys).toHaveBeenCalledWith(responseData);
expect(commit.mock.calls).toEqual([
[types.EDIT_AGENT_CAPACITY_POLICIES_INBOXES, camelCasedData],
]);
expect(result).toEqual(responseData);
});
it('sends correct actions if API is error', async () => {
axios.put.mockRejectedValue(new Error('Validation error'));
await expect(
actions.updateInboxLimit(
{ commit },
{ policyId: 1, limitId: 1, limitData: {} }
)
).rejects.toThrow(Error);
expect(commit).not.toHaveBeenCalled();
});
});
describe('#deleteInboxLimit', () => {
it('sends correct actions if API is success', async () => {
const policyId = 1;
const limitId = 1;
axios.delete.mockResolvedValue({});
await actions.deleteInboxLimit({ commit }, { policyId, limitId });
expect(commit.mock.calls).toEqual([
[types.DELETE_AGENT_CAPACITY_POLICIES_INBOXES, { policyId, limitId }],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue(new Error('Not found'));
await expect(
actions.deleteInboxLimit({ commit }, { policyId: 1, limitId: 1 })
).rejects.toThrow(Error);
expect(commit).not.toHaveBeenCalled();
});
});
});

View File

@@ -10,6 +10,20 @@ export default [
created_at: '2024-01-01T10:00:00.000Z',
updated_at: '2024-01-01T10:00:00.000Z',
users: [],
inbox_capacity_limits: [
{
id: 1,
inbox_id: 1,
conversation_limit: 15,
agent_capacity_policy_id: 1,
},
{
id: 2,
inbox_id: 2,
conversation_limit: 8,
agent_capacity_policy_id: 1,
},
],
},
{
id: 2,
@@ -21,7 +35,21 @@ export default [
assigned_agent_count: 5,
created_at: '2024-01-01T11:00:00.000Z',
updated_at: '2024-01-01T11:00:00.000Z',
users: [],
users: [
{
id: 1,
name: 'Agent Smith',
email: 'agent.smith@example.com',
capacity: 25,
},
{
id: 2,
name: 'Agent Johnson',
email: 'agent.johnson@example.com',
capacity: 18,
},
],
inbox_capacity_limits: [],
},
{
id: 3,
@@ -34,6 +62,7 @@ export default [
created_at: '2024-01-01T12:00:00.000Z',
updated_at: '2024-01-01T12:00:00.000Z',
users: [],
inbox_capacity_limits: [],
},
];
@@ -49,6 +78,20 @@ export const camelCaseFixtures = [
createdAt: '2024-01-01T10:00:00.000Z',
updatedAt: '2024-01-01T10:00:00.000Z',
users: [],
inboxCapacityLimits: [
{
id: 1,
inboxId: 1,
conversationLimit: 15,
agentCapacityPolicyId: 1,
},
{
id: 2,
inboxId: 2,
conversationLimit: 8,
agentCapacityPolicyId: 1,
},
],
},
{
id: 2,
@@ -60,7 +103,21 @@ export const camelCaseFixtures = [
assignedAgentCount: 5,
createdAt: '2024-01-01T11:00:00.000Z',
updatedAt: '2024-01-01T11:00:00.000Z',
users: [],
users: [
{
id: 1,
name: 'Agent Smith',
email: 'agent.smith@example.com',
capacity: 25,
},
{
id: 2,
name: 'Agent Johnson',
email: 'agent.johnson@example.com',
capacity: 18,
},
],
inboxCapacityLimits: [],
},
{
id: 3,
@@ -73,5 +130,70 @@ export const camelCaseFixtures = [
createdAt: '2024-01-01T12:00:00.000Z',
updatedAt: '2024-01-01T12:00:00.000Z',
users: [],
inboxCapacityLimits: [],
},
];
// Additional test data for user and inbox limit operations
export const mockUsers = [
{
id: 1,
name: 'Agent Smith',
email: 'agent.smith@example.com',
capacity: 25,
},
{
id: 2,
name: 'Agent Johnson',
email: 'agent.johnson@example.com',
capacity: 18,
},
{
id: 3,
name: 'Agent Brown',
email: 'agent.brown@example.com',
capacity: 12,
},
];
export const mockInboxLimits = [
{
id: 1,
inbox_id: 1,
conversation_limit: 15,
agent_capacity_policy_id: 1,
},
{
id: 2,
inbox_id: 2,
conversation_limit: 8,
agent_capacity_policy_id: 1,
},
{
id: 3,
inbox_id: 3,
conversation_limit: 20,
agent_capacity_policy_id: 2,
},
];
export const camelCaseMockInboxLimits = [
{
id: 1,
inboxId: 1,
conversationLimit: 15,
agentCapacityPolicyId: 1,
},
{
id: 2,
inboxId: 2,
conversationLimit: 8,
agentCapacityPolicyId: 1,
},
{
id: 3,
inboxId: 3,
conversationLimit: 20,
agentCapacityPolicyId: 2,
},
];

View File

@@ -1,6 +1,6 @@
import { mutations } from '../../agentCapacityPolicies';
import types from '../../../mutation-types';
import agentCapacityPoliciesList from './fixtures';
import agentCapacityPoliciesList, { mockUsers } from './fixtures';
describe('#mutations', () => {
describe('#SET_AGENT_CAPACITY_POLICIES_UI_FLAG', () => {
@@ -248,7 +248,7 @@ describe('#mutations', () => {
describe('#SET_AGENT_CAPACITY_POLICIES_USERS', () => {
it('sets users for existing policy', () => {
const mockUsers = [
const testUsers = [
{ id: 1, name: 'Agent 1', email: 'agent1@example.com', capacity: 15 },
{ id: 2, name: 'Agent 2', email: 'agent2@example.com', capacity: 20 },
];
@@ -262,10 +262,10 @@ describe('#mutations', () => {
mutations[types.SET_AGENT_CAPACITY_POLICIES_USERS](state, {
policyId: 1,
users: mockUsers,
users: testUsers,
});
expect(state.records[0].users).toEqual(mockUsers);
expect(state.records[0].users).toEqual(testUsers);
expect(state.records[1].users).toEqual([]);
});
@@ -300,4 +300,320 @@ describe('#mutations', () => {
expect(state).toEqual(originalState);
});
});
describe('#ADD_AGENT_CAPACITY_POLICIES_USERS', () => {
it('adds user to existing policy', () => {
const state = {
records: [
{ id: 1, name: 'Policy 1', users: [] },
{ id: 2, name: 'Policy 2', users: [] },
],
};
mutations[types.ADD_AGENT_CAPACITY_POLICIES_USERS](state, {
policyId: 1,
user: mockUsers[0],
});
expect(state.records[0].users).toEqual([mockUsers[0]]);
expect(state.records[1].users).toEqual([]);
});
it('adds user to policy with existing users', () => {
const state = {
records: [{ id: 1, name: 'Policy 1', users: [mockUsers[0]] }],
};
mutations[types.ADD_AGENT_CAPACITY_POLICIES_USERS](state, {
policyId: 1,
user: mockUsers[1],
});
expect(state.records[0].users).toEqual([mockUsers[0], mockUsers[1]]);
});
it('initializes users array if undefined', () => {
const state = {
records: [{ id: 1, name: 'Policy 1' }],
};
mutations[types.ADD_AGENT_CAPACITY_POLICIES_USERS](state, {
policyId: 1,
user: mockUsers[0],
});
expect(state.records[0].users).toEqual([mockUsers[0]]);
});
it('updates assigned agent count', () => {
const state = {
records: [{ id: 1, name: 'Policy 1', users: [] }],
};
mutations[types.ADD_AGENT_CAPACITY_POLICIES_USERS](state, {
policyId: 1,
user: mockUsers[0],
});
expect(state.records[0].assignedAgentCount).toEqual(1);
});
});
describe('#DELETE_AGENT_CAPACITY_POLICIES_USERS', () => {
it('removes user from policy', () => {
const state = {
records: [
{
id: 1,
name: 'Policy 1',
users: [mockUsers[0], mockUsers[1], mockUsers[2]],
},
],
};
mutations[types.DELETE_AGENT_CAPACITY_POLICIES_USERS](state, {
policyId: 1,
userId: 2,
});
expect(state.records[0].users).toEqual([mockUsers[0], mockUsers[2]]);
});
it('handles removing non-existent user', () => {
const state = {
records: [
{
id: 1,
name: 'Policy 1',
users: [mockUsers[0]],
},
],
};
mutations[types.DELETE_AGENT_CAPACITY_POLICIES_USERS](state, {
policyId: 1,
userId: 999,
});
expect(state.records[0].users).toEqual([mockUsers[0]]);
});
it('updates assigned agent count', () => {
const state = {
records: [{ id: 1, name: 'Policy 1', users: [mockUsers[0]] }],
};
mutations[types.DELETE_AGENT_CAPACITY_POLICIES_USERS](state, {
policyId: 1,
userId: 1,
});
expect(state.records[0].assignedAgentCount).toEqual(0);
});
});
describe('#SET_AGENT_CAPACITY_POLICIES_INBOXES', () => {
it('adds inbox limit to policy', () => {
const state = {
records: [
{
id: 1,
name: 'Policy 1',
inboxCapacityLimits: [],
},
],
};
const inboxLimitData = {
id: 1,
inboxId: 1,
conversationLimit: 15,
agentCapacityPolicyId: 1,
};
mutations[types.SET_AGENT_CAPACITY_POLICIES_INBOXES](
state,
inboxLimitData
);
expect(state.records[0].inboxCapacityLimits).toEqual([
{
id: 1,
inboxId: 1,
conversationLimit: 15,
},
]);
});
it('does nothing if policy not found', () => {
const state = {
records: [{ id: 1, name: 'Policy 1', inboxCapacityLimits: [] }],
};
const originalState = JSON.parse(JSON.stringify(state));
mutations[types.SET_AGENT_CAPACITY_POLICIES_INBOXES](state, {
id: 1,
inboxId: 1,
conversationLimit: 15,
agentCapacityPolicyId: 999,
});
expect(state).toEqual(originalState);
});
});
describe('#EDIT_AGENT_CAPACITY_POLICIES_INBOXES', () => {
it('updates existing inbox limit', () => {
const state = {
records: [
{
id: 1,
name: 'Policy 1',
inboxCapacityLimits: [
{
id: 1,
inboxId: 1,
conversationLimit: 15,
},
{
id: 2,
inboxId: 2,
conversationLimit: 8,
},
],
},
],
};
mutations[types.EDIT_AGENT_CAPACITY_POLICIES_INBOXES](state, {
id: 1,
inboxId: 1,
conversationLimit: 25,
agentCapacityPolicyId: 1,
});
expect(state.records[0].inboxCapacityLimits[0]).toEqual({
id: 1,
inboxId: 1,
conversationLimit: 25,
});
expect(state.records[0].inboxCapacityLimits[1]).toEqual({
id: 2,
inboxId: 2,
conversationLimit: 8,
});
});
it('does nothing if limit not found', () => {
const state = {
records: [
{
id: 1,
name: 'Policy 1',
inboxCapacityLimits: [
{
id: 1,
inboxId: 1,
conversationLimit: 15,
},
],
},
],
};
const originalLimits = [...state.records[0].inboxCapacityLimits];
mutations[types.EDIT_AGENT_CAPACITY_POLICIES_INBOXES](state, {
id: 999,
inboxId: 1,
conversationLimit: 25,
agentCapacityPolicyId: 1,
});
expect(state.records[0].inboxCapacityLimits).toEqual(originalLimits);
});
it('does nothing if policy not found', () => {
const state = {
records: [{ id: 1, name: 'Policy 1', inboxCapacityLimits: [] }],
};
const originalState = JSON.parse(JSON.stringify(state));
mutations[types.EDIT_AGENT_CAPACITY_POLICIES_INBOXES](state, {
id: 1,
inboxId: 1,
conversationLimit: 25,
agentCapacityPolicyId: 999,
});
expect(state).toEqual(originalState);
});
});
describe('#DELETE_AGENT_CAPACITY_POLICIES_INBOXES', () => {
it('removes inbox limit from policy', () => {
const state = {
records: [
{
id: 1,
name: 'Policy 1',
inboxCapacityLimits: [
{
id: 1,
inboxId: 1,
conversationLimit: 15,
},
{
id: 2,
inboxId: 2,
conversationLimit: 8,
},
],
},
],
};
mutations[types.DELETE_AGENT_CAPACITY_POLICIES_INBOXES](state, {
policyId: 1,
limitId: 1,
});
expect(state.records[0].inboxCapacityLimits).toEqual([
{
id: 2,
inboxId: 2,
conversationLimit: 8,
},
]);
});
it('handles removing non-existent limit', () => {
const state = {
records: [
{
id: 1,
name: 'Policy 1',
inboxCapacityLimits: [
{
id: 1,
inboxId: 1,
conversationLimit: 15,
},
],
},
],
};
const originalLimits = [...state.records[0].inboxCapacityLimits];
mutations[types.DELETE_AGENT_CAPACITY_POLICIES_INBOXES](state, {
policyId: 1,
limitId: 999,
});
expect(state.records[0].inboxCapacityLimits).toEqual(originalLimits);
});
});
});

View File

@@ -372,4 +372,10 @@ export default {
SET_AGENT_CAPACITY_POLICIES_USERS: 'SET_AGENT_CAPACITY_POLICIES_USERS',
SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG:
'SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG',
ADD_AGENT_CAPACITY_POLICIES_USERS: 'ADD_AGENT_CAPACITY_POLICIES_USERS',
DELETE_AGENT_CAPACITY_POLICIES_USERS: 'DELETE_AGENT_CAPACITY_POLICIES_USERS',
SET_AGENT_CAPACITY_POLICIES_INBOXES: 'SET_AGENT_CAPACITY_POLICIES_INBOXES',
EDIT_AGENT_CAPACITY_POLICIES_INBOXES: 'EDIT_AGENT_CAPACITY_POLICIES_INBOXES',
DELETE_AGENT_CAPACITY_POLICIES_INBOXES:
'DELETE_AGENT_CAPACITY_POLICIES_INBOXES',
};