feat: Agent assignment policy Create/Edit pages (#12400)

This commit is contained in:
Sivin Varghese
2025-09-10 20:02:11 +05:30
committed by GitHub
parent aba4e8bc53
commit 257df30589
26 changed files with 1765 additions and 8 deletions

View File

@@ -0,0 +1,119 @@
<script setup>
import { computed, ref } from 'vue';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { picoSearch } from '@scmmishra/pico-search';
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';
const props = defineProps({
label: {
type: String,
default: '',
},
searchPlaceholder: {
type: String,
default: '',
},
items: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['add']);
const [showPopover, togglePopover] = useToggle();
const searchValue = ref('');
const filteredItems = computed(() => {
if (!searchValue.value) return props.items;
const query = searchValue.value.toLowerCase();
return picoSearch(props.items, query, ['name']);
});
const handleAdd = inbox => {
emit('add', inbox);
togglePopover(false);
};
const handleClickOutside = () => {
if (showPopover.value) {
togglePopover(false);
}
};
</script>
<template>
<div
v-on-click-outside="handleClickOutside"
class="relative flex items-center group"
>
<Button
slate
type="button"
icon="i-lucide-plus"
sm
:label="label"
@click="togglePopover(!showPopover)"
/>
<div
v-if="showPopover"
class="top-full mt-2 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 z-50 flex flex-col items-start absolute bg-n-alpha-3 backdrop-blur-[50px] border-0 gap-4 outline outline-1 outline-n-weak rounded-xl max-w-96 min-w-80 max-h-[20rem] overflow-y-auto py-2"
>
<div class="flex flex-col divide-y divide-n-slate-4 w-full">
<Input
v-model="searchValue"
:placeholder="searchPlaceholder"
custom-input-class="bg-transparent !outline-none w-full ltr:!pl-10 rtl:!pr-10 h-10"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-3 rtl:right-3"
/>
</template>
</Input>
<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"
@click="handleAdd(item)"
>
<Icon
v-if="item.icon"
:icon="item.icon"
class="size-4 text-n-slate-12 flex-shrink-0 mt-0.5"
/>
<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"
class="text-sm text-n-slate-12 truncate min-w-0"
>
{{ item.name }}
</span>
<span
v-if="item.id"
class="text-xs text-n-slate-11 flex-shrink-0"
>
{{ `#${item.id}` }}
</span>
</div>
<span
v-if="item.email || item.phoneNumber"
class="text-sm text-n-slate-11 truncate min-w-0"
>
{{ item.email || item.phoneNumber }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,126 @@
<script setup>
import { computed, watch } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
defineProps({
nameLabel: {
type: String,
default: '',
},
namePlaceholder: {
type: String,
default: '',
},
descriptionLabel: {
type: String,
default: '',
},
descriptionPlaceholder: {
type: String,
default: '',
},
statusLabel: {
type: String,
default: '',
},
statusPlaceholder: {
type: String,
default: '',
},
});
const emit = defineEmits(['validationChange']);
const policyName = defineModel('policyName', {
type: String,
default: '',
});
const description = defineModel('description', {
type: String,
default: '',
});
const enabled = defineModel('enabled', {
type: Boolean,
default: true,
});
const validationRules = {
policyName: { required, minLength: minLength(1) },
description: { required, minLength: minLength(1) },
};
const v$ = useVuelidate(validationRules, { policyName, description });
const isValid = computed(() => !v$.value.$invalid);
watch(
isValid,
() => {
emit('validationChange', {
isValid: isValid.value,
});
},
{ immediate: true }
);
</script>
<template>
<div class="flex flex-col gap-4 pb-4">
<!-- Policy Name Field -->
<div class="flex items-center gap-6">
<WithLabel
:label="nameLabel"
name="policyName"
class="flex items-center w-full [&>label]:min-w-[120px]"
>
<div class="flex-1">
<Input
v-model="policyName"
type="text"
:placeholder="namePlaceholder"
/>
</div>
</WithLabel>
</div>
<!-- Description Field -->
<div class="flex items-center gap-6">
<WithLabel
:label="descriptionLabel"
name="description"
class="flex items-center w-full [&>label]:min-w-[120px]"
>
<div class="flex-1">
<Input
v-model="description"
type="text"
:placeholder="descriptionPlaceholder"
/>
</div>
</WithLabel>
</div>
<!-- Status Field -->
<div class="flex items-center gap-6">
<WithLabel
:label="statusLabel"
name="enabled"
class="flex items-center w-full [&>label]:min-w-[120px]"
>
<div class="flex items-center gap-2">
<Switch v-model="enabled" />
<span class="text-sm text-n-slate-11">
{{ statusPlaceholder }}
</span>
</div>
</WithLabel>
</div>
</div>
</template>

View File

@@ -0,0 +1,80 @@
<script setup>
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';
defineProps({
items: {
type: Array,
default: () => [],
},
isFetching: {
type: Boolean,
default: false,
},
emptyStateMessage: {
type: String,
default: '',
},
});
const emit = defineEmits(['delete']);
const handleDelete = itemId => {
emit('delete', itemId);
};
</script>
<template>
<div
v-if="isFetching"
class="flex items-center justify-center py-3 w-full text-n-slate-11"
>
<Spinner />
</div>
<span
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"
>
{{ emptyStateMessage }}
</span>
<div v-else class="flex flex-col divide-y divide-n-weak">
<div
v-for="item in items"
:key="item.id"
class="grid grid-cols-4 items-center gap-3 min-w-0 w-full justify-between h-[3.25rem] ltr:pr-2 rtl:pl-2"
>
<div class="flex items-center gap-2 col-span-2">
<Icon
v-if="item.icon"
:icon="item.icon"
class="size-4 text-n-slate-12 flex-shrink-0"
/>
<span class="text-sm text-n-slate-12 truncate min-w-0">
{{ item.name }}
</span>
</div>
<div class="flex items-start gap-2 col-span-1">
<span
:title="item.email || item.phoneNumber"
class="text-sm text-n-slate-12 truncate min-w-0"
>
{{ item.email || item.phoneNumber }}
</span>
</div>
<div class="col-span-1 justify-end flex items-center">
<Button
icon="i-lucide-trash"
slate
ghost
sm
type="button"
@click="handleDelete(item.id)"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,86 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
const { t } = useI18n();
const fairDistributionLimit = defineModel('fairDistributionLimit', {
type: Number,
default: 100,
set(value) {
return Number(value) || 0;
},
});
const fairDistributionWindow = defineModel('fairDistributionWindow', {
type: Number,
default: 3600,
set(value) {
return Number(value) || 0;
},
});
const windowUnit = ref(DURATION_UNITS.MINUTES);
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;
};
onMounted(() => {
windowUnit.value = detectUnit(fairDistributionWindow.value);
});
</script>
<template>
<div
class="flex items-start xl:items-center flex-col md:flex-row gap-4 lg:gap-3 bg-n-solid-1 p-4 outline outline-1 outline-n-weak rounded-xl"
>
<div class="flex items-center gap-3">
<label class="text-sm font-medium text-n-slate-12">
{{
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.FAIR_DISTRIBUTION.INPUT_MAX'
)
}}
</label>
<div class="flex-1">
<Input
v-model="fairDistributionLimit"
type="number"
placeholder="100"
:max="100000"
class="w-full"
/>
</div>
</div>
<div class="flex sm:flex-row flex-col items-start sm:items-center gap-4">
<label class="text-sm font-medium text-n-slate-12">
{{
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.FAIR_DISTRIBUTION.DURATION'
)
}}
</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:model-value="fairDistributionWindow"
v-model:unit="windowUnit"
min="10"
max="1438560"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,60 @@
<script setup>
const props = defineProps({
id: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
isActive: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select']);
const handleChange = () => {
if (!props.isActive) {
emit('select', props.id);
}
};
</script>
<template>
<div
class="relative cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
:class="[
isActive ? 'outline-n-blue-9' : 'outline-n-weak hover:outline-n-strong',
]"
@click="handleChange"
>
<div class="absolute top-4 right-4">
<input
:id="`${id}`"
:checked="isActive"
:value="id"
:name="id"
type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
@change="handleChange"
/>
</div>
<!-- Content -->
<div class="flex flex-col gap-3 items-start">
<h3 class="text-sm font-medium text-n-slate-12">
{{ label }}
</h3>
<p class="text-sm text-n-slate-11">
{{ description }}
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,58 @@
<script setup>
import AddDataDropdown from '../AddDataDropdown.vue';
const mockInboxes = [
{
id: 1,
name: 'Website Support',
email: 'support@company.com',
icon: 'i-lucide-globe',
},
{
id: 2,
name: 'Email Support',
email: 'help@company.com',
icon: 'i-lucide-mail',
},
{
id: 3,
name: 'WhatsApp Business',
phoneNumber: '+1 555-0123',
icon: 'i-lucide-message-circle',
},
{
id: 4,
name: 'Facebook Messenger',
email: 'messenger@company.com',
icon: 'i-lucide-facebook',
},
{
id: 5,
name: 'Twitter DM',
email: 'twitter@company.com',
icon: 'i-lucide-twitter',
},
];
const handleAdd = item => {
console.log('Add item:', item);
};
</script>
<template>
<Story
title="Components/AgentManagementPolicy/AddDataDropdown"
:layout="{ type: 'grid', width: '400px' }"
>
<Variant title="Basic Usage">
<div class="p-8 bg-n-background flex gap-4 h-[400px] items-start">
<AddDataDropdown
label="Add Inbox"
search-placeholder="Search inboxes..."
:items="mockInboxes"
@add="handleAdd"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,33 @@
<script setup>
import { ref } from 'vue';
import BaseInfo from '../BaseInfo.vue';
const policyName = ref('Round Robin Policy');
const description = ref(
'Distributes conversations evenly among available agents'
);
const enabled = ref(true);
</script>
<template>
<Story
title="Components/AgentManagementPolicy/BaseInfo"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Basic Usage">
<div class="p-8 bg-n-background">
<BaseInfo
v-model:policy-name="policyName"
v-model:description="description"
v-model:enabled="enabled"
name-label="Policy Name"
name-placeholder="Enter policy name"
description-label="Description"
description-placeholder="Enter policy description"
status-label="Status"
status-placeholder="Active"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,62 @@
<script setup>
import DataTable from '../DataTable.vue';
const mockItems = [
{
id: 1,
name: 'Website Support',
email: 'support@company.com',
icon: 'i-lucide-globe',
},
{
id: 2,
name: 'Email Support',
email: 'help@company.com',
icon: 'i-lucide-mail',
},
{
id: 3,
name: 'WhatsApp Business',
phoneNumber: '+1 555-0123',
icon: 'i-lucide-message-circle',
},
];
const handleDelete = itemId => {
console.log('Delete item:', itemId);
};
</script>
<template>
<Story
title="Components/AgentManagementPolicy/DataTable"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="With Data">
<div class="p-8 bg-n-background">
<DataTable
:items="mockItems"
:is-fetching="false"
@delete="handleDelete"
/>
</div>
</Variant>
<Variant title="Loading State">
<div class="p-8 bg-n-background">
<DataTable :items="[]" is-fetching @delete="handleDelete" />
</div>
</Variant>
<Variant title="Empty State">
<div class="p-8 bg-n-background">
<DataTable
:items="[]"
:is-fetching="false"
empty-state-message="No items found"
@delete="handleDelete"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,25 @@
<script setup>
import { ref } from 'vue';
import FairDistribution from '../FairDistribution.vue';
const fairDistributionLimit = ref(100);
const fairDistributionWindow = ref(3600);
const windowUnit = ref('minutes');
</script>
<template>
<Story
title="Components/AgentManagementPolicy/FairDistribution"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Basic Usage">
<div class="p-8 bg-n-background">
<FairDistribution
v-model:fair-distribution-limit="fairDistributionLimit"
v-model:fair-distribution-window="fairDistributionWindow"
v-model:window-unit="windowUnit"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,61 @@
<script setup>
import { ref } from 'vue';
import RadioCard from '../RadioCard.vue';
const selectedOption = ref('round_robin');
const handleSelect = value => {
selectedOption.value = value;
console.log('Selected:', value);
};
</script>
<template>
<Story
title="Components/AgentManagementPolicy/RadioCard"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Basic Usage">
<div class="p-8 bg-n-background space-y-4">
<RadioCard
id="round_robin"
label="Round Robin"
description="Distributes conversations evenly among all available agents in a rotating manner"
:is-active="selectedOption === 'round_robin'"
@select="handleSelect"
/>
<RadioCard
id="balanced"
label="Balanced Assignment"
description="Assigns conversations based on agent workload to maintain balance"
:is-active="selectedOption === 'balanced'"
@select="handleSelect"
/>
</div>
</Variant>
<Variant title="Active State">
<div class="p-8 bg-n-background">
<RadioCard
id="active_option"
label="Active Option"
description="This option is currently selected and active"
is-active
@select="handleSelect"
/>
</div>
</Variant>
<Variant title="Inactive State">
<div class="p-8 bg-n-background">
<RadioCard
id="inactive_option"
label="Inactive Option"
description="This option is not selected and can be clicked to activate"
is-active
@select="handleSelect"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -78,6 +78,8 @@ watch(unit, () => {
<option :value="DURATION_UNITS.HOURS">
{{ t('DURATION_INPUT.HOURS') }}
</option>
<option :value="DURATION_UNITS.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
<option :value="DURATION_UNITS.DAYS">
{{ t('DURATION_INPUT.DAYS') }}
</option>
</select>
</template>

View File

@@ -15,6 +15,7 @@ const props = defineProps({
validator: value => ['info', 'error', 'success'].includes(value),
},
min: { type: String, default: '' },
max: { type: String, default: '' },
autofocus: { type: Boolean, default: false },
});
@@ -108,6 +109,11 @@ onMounted(() => {
:placeholder="placeholder"
:disabled="disabled"
:min="['date', 'datetime-local', 'time'].includes(type) ? min : undefined"
:max="
['date', 'datetime-local', 'time', 'number'].includes(type)
? max
: undefined
"
class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 outline outline-1 border-none border-0 outline-offset-[-1px] rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out"
@input="handleInput"
@focus="handleFocus"

View File

@@ -468,6 +468,100 @@
},
"NO_RECORDS_FOUND": "No assignment policies found"
},
"CREATE": {
"HEADER": {
"TITLE": "Create assignment policy"
},
"CREATE_BUTTON": "Create policy",
"API": {
"SUCCESS_MESSAGE": "Assignment policy created successfully",
"ERROR_MESSAGE": "Failed to create assignment policy"
}
},
"EDIT": {
"HEADER": {
"TITLE": "Edit assignment policy"
},
"EDIT_BUTTON": "Update policy",
"CONFIRM_ADD_INBOX_DIALOG": {
"TITLE": "Add inbox",
"DESCRIPTION": "{inboxName} inbox 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": "Assignment policy updated successfully",
"ERROR_MESSAGE": "Failed to update assignment policy"
},
"INBOX_API": {
"ADD": {
"SUCCESS_MESSAGE": "Inbox added to policy successfully",
"ERROR_MESSAGE": "Failed to add inbox to policy"
},
"REMOVE": {
"SUCCESS_MESSAGE": "Inbox removed from policy successfully",
"ERROR_MESSAGE": "Failed to remove inbox from policy"
}
}
},
"FORM": {
"NAME": {
"LABEL": "Policy name:",
"PLACEHOLDER": "Enter policy name"
},
"DESCRIPTION": {
"LABEL": "Description:",
"PLACEHOLDER": "Enter description"
},
"STATUS": {
"LABEL": "Status:",
"PLACEHOLDER": "Select status",
"ACTIVE": "Policy is active",
"INACTIVE": "Policy is inactive"
},
"ASSIGNMENT_ORDER": {
"LABEL": "Assignment order",
"ROUND_ROBIN": {
"LABEL": "Round robin",
"DESCRIPTION": "Assign conversations evenly among agents."
},
"BALANCED": {
"LABEL": "Balanced",
"DESCRIPTION": "Assign conversations based on available capacity."
}
},
"ASSIGNMENT_PRIORITY": {
"LABEL": "Assignment priority",
"EARLIEST_CREATED": {
"LABEL": "Earliest created",
"DESCRIPTION": "The conversation that was created first gets assigned first."
},
"LONGEST_WAITING": {
"LABEL": "Longest waiting",
"DESCRIPTION": "The conversation waiting the longest gets assigned first."
}
},
"FAIR_DISTRIBUTION": {
"LABEL": "Fair distribution policy",
"DESCRIPTION": "Set the maximum number of conversations that can be assigned per agent within a time window to avoid overloading any one agent. This required field defaults to 100 conversations per hour.",
"INPUT_MAX": "Assign max",
"DURATION": "Conversations per agent in every"
},
"INBOXES": {
"LABEL": "Added inboxes",
"DESCRIPTION": "Add inboxes for which this policy will be applicable.",
"ADD_BUTTON": "Add inbox",
"DROPDOWN": {
"SEARCH_PLACEHOLDER": "Search and select inboxes to add",
"ADD_BUTTON": "Add"
},
"EMPTY_STATE": "No inboxes added to this policy, add an inbox to get started",
"API": {
"SUCCESS_MESSAGE": "Inbox successfully added to policy",
"ERROR_MESSAGE": "Failed to add inbox to policy"
}
}
},
"DELETE_POLICY": {
"TITLE": "Delete policy",
"DESCRIPTION": "Are you sure you want to delete this policy? This action cannot be undone.",

View File

@@ -3,6 +3,8 @@ import { frontendURL } from '../../../../helper/URLHelper';
import SettingsWrapper from '../SettingsWrapper.vue';
import AssignmentPolicyIndex from './Index.vue';
import AgentAssignmentIndex from './pages/AgentAssignmentIndexPage.vue';
import AgentAssignmentCreate from './pages/AgentAssignmentCreatePage.vue';
import AgentAssignmentEdit from './pages/AgentAssignmentEditPage.vue';
export default {
routes: [
@@ -34,6 +36,24 @@ export default {
permissions: ['administrator'],
},
},
{
path: 'assignment/create',
name: 'agent_assignment_policy_create',
component: AgentAssignmentCreate,
meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'],
},
},
{
path: 'assignment/edit/:id',
name: 'agent_assignment_policy_edit',
component: AgentAssignmentEdit,
meta: {
featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2,
permissions: ['administrator'],
},
},
],
},
],

View File

@@ -0,0 +1,17 @@
// Assignment order types
export const ROUND_ROBIN = 'round_robin';
export const BALANCED = 'balanced';
// Assignment priority types
export const EARLIEST_CREATED = 'earliest_created';
export const LONGEST_WAITING = 'longest_waiting';
// Default values for fair distribution
export const DEFAULT_FAIR_DISTRIBUTION_LIMIT = 100;
export const DEFAULT_FAIR_DISTRIBUTION_WINDOW = 3600;
// Options groupings
export const OPTIONS = {
ORDER: [ROUND_ROBIN, BALANCED],
PRIORITY: [EARLIEST_CREATED, LONGEST_WAITING],
};

View File

@@ -0,0 +1,74 @@
<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 AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const formRef = ref(null);
const uiFlags = useMapGetter('assignmentPolicies/getUIFlags');
const breadcrumbItems = computed(() => [
{
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.HEADER.TITLE'),
routeName: 'agent_assignment_policy_index',
},
{
label: t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.HEADER.TITLE'),
},
]);
const handleBreadcrumbClick = item => {
router.push({
name: item.routeName,
});
};
const handleSubmit = async formState => {
try {
const policy = await store.dispatch('assignmentPolicies/create', formState);
useAlert(
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.CREATE.API.SUCCESS_MESSAGE')
);
formRef.value?.resetForm();
router.push({
name: 'agent_assignment_policy_edit',
params: {
id: policy.id,
},
});
} catch (error) {
useAlert(
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_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>
<AssignmentPolicyForm
ref="formRef"
mode="CREATE"
:is-loading="uiFlags.isCreating"
@submit="handleSubmit"
/>
</template>
</SettingsLayout>
</template>

View File

@@ -0,0 +1,197 @@
<script setup>
import { computed, ref, watch } 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 { getInboxIconByType } from 'dashboard/helper/inbox';
import {
ROUND_ROBIN,
EARLIEST_CREATED,
} from 'dashboard/routes/dashboard/settings/assignmentPolicy/constants';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import SettingsLayout from 'dashboard/routes/dashboard/settings/SettingsLayout.vue';
import AssignmentPolicyForm from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue';
import ConfirmInboxDialog from 'dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmInboxDialog.vue';
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const uiFlags = useMapGetter('assignmentPolicies/getUIFlags');
const inboxes = useMapGetter('inboxes/getAllInboxes');
const inboxUiFlags = useMapGetter('assignmentPolicies/getInboxUiFlags');
const selectedPolicyById = useMapGetter(
'assignmentPolicies/getAssignmentPolicyById'
);
const routeId = computed(() => route.params.id);
const selectedPolicy = computed(() => selectedPolicyById.value(routeId.value));
const confirmInboxDialogRef = ref(null);
// Store the policy linked to the inbox when adding a new inbox
const inboxLinkedPolicy = ref(null);
const breadcrumbItems = computed(() => [
{
label: t(`${BASE_KEY}.INDEX.HEADER.TITLE`),
routeName: 'agent_assignment_policy_index',
},
{ label: t(`${BASE_KEY}.EDIT.HEADER.TITLE`) },
]);
const buildInboxList = allInboxes =>
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
name,
id,
email,
phoneNumber,
icon: getInboxIconByType(channelType, medium, 'line'),
})) || [];
const policyInboxes = computed(() =>
buildInboxList(selectedPolicy.value?.inboxes)
);
const inboxList = computed(() =>
buildInboxList(
inboxes.value?.slice().sort((a, b) => a.name.localeCompare(b.name))
)
);
const formData = computed(() => ({
name: selectedPolicy.value?.name || '',
description: selectedPolicy.value?.description || '',
enabled: selectedPolicy.value?.enabled || false,
assignmentOrder: selectedPolicy.value?.assignmentOrder || ROUND_ROBIN,
conversationPriority:
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 10,
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 60,
}));
const handleDeleteInbox = inboxId =>
store.dispatch('assignmentPolicies/removeInboxPolicy', {
policyId: selectedPolicy.value?.id,
inboxId,
});
const handleBreadcrumbClick = ({ routeName }) =>
router.push({ name: routeName });
const setInboxPolicy = async (inboxId, policyId) => {
try {
await store.dispatch('assignmentPolicies/setInboxPolicy', {
inboxId,
policyId,
});
useAlert(t(`${BASE_KEY}.FORM.INBOXES.API.SUCCESS_MESSAGE`));
await store.dispatch(
'assignmentPolicies/getInboxes',
Number(routeId.value)
);
return true;
} catch (error) {
useAlert(t(`${BASE_KEY}.FORM.INBOXES.API.ERROR_MESSAGE`));
return false;
}
};
const handleAddInbox = async inbox => {
try {
const policy = await store.dispatch('assignmentPolicies/getInboxPolicy', {
inboxId: inbox?.id,
});
if (policy?.id !== selectedPolicy.value?.id) {
inboxLinkedPolicy.value = {
...policy,
assignedInboxCount: policy.assignedInboxCount - 1,
};
confirmInboxDialogRef.value.openDialog(inbox);
return;
}
} catch (error) {
// If getInboxPolicy fails, continue to setInboxPolicy
}
await setInboxPolicy(inbox?.id, selectedPolicy.value?.id);
};
const handleConfirmAddInbox = async inboxId => {
const success = await setInboxPolicy(inboxId, selectedPolicy.value?.id);
if (success) {
// Update the policy to reflect the assigned inbox count change
await store.dispatch('assignmentPolicies/updateInboxPolicy', {
policy: inboxLinkedPolicy.value,
});
// Fetch the updated inboxes for the policy after update, to reflect real-time changes
store.dispatch(
'assignmentPolicies/getInboxes',
inboxLinkedPolicy.value?.id
);
inboxLinkedPolicy.value = null;
confirmInboxDialogRef.value.closeDialog();
}
};
const handleSubmit = async formState => {
try {
await store.dispatch('assignmentPolicies/update', {
id: selectedPolicy.value?.id,
...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 (!selectedPolicy.value?.id)
await store.dispatch('assignmentPolicies/show', routeId.value);
await store.dispatch('assignmentPolicies/getInboxes', Number(routeId.value));
};
watch(routeId, fetchPolicyData, { immediate: true });
</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>
<AssignmentPolicyForm
:key="routeId"
mode="EDIT"
:initial-data="formData"
:policy-inboxes="policyInboxes"
:inbox-list="inboxList"
show-inbox-section
:is-loading="uiFlags.isUpdating"
:is-inbox-loading="inboxUiFlags.isFetching"
@submit="handleSubmit"
@add-inbox="handleAddInbox"
@delete-inbox="handleDeleteInbox"
/>
</template>
<ConfirmInboxDialog
ref="confirmInboxDialogRef"
@add="handleConfirmAddInbox"
/>
</SettingsLayout>
</template>

View File

@@ -44,7 +44,16 @@ const handleBreadcrumbClick = item => {
const onClickCreatePolicy = () => {
router.push({
name: 'assignment_policy_create',
name: 'agent_assignment_policy_create',
});
};
const onClickEditPolicy = id => {
router.push({
name: 'agent_assignment_policy_edit',
params: {
id,
},
});
};
@@ -106,6 +115,7 @@ onMounted(() => {
v-bind="policy"
:is-fetching-inboxes="inboxUiFlags.isFetching"
@fetch-inboxes="handleFetchInboxes"
@edit="onClickEditPolicy"
@delete="handleDelete"
/>
</div>

View File

@@ -0,0 +1,254 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useConfig } from 'dashboard/composables/useConfig';
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import {
OPTIONS,
ROUND_ROBIN,
EARLIEST_CREATED,
DEFAULT_FAIR_DISTRIBUTION_LIMIT,
DEFAULT_FAIR_DISTRIBUTION_WINDOW,
} from 'dashboard/routes/dashboard/settings/assignmentPolicy/constants';
const props = defineProps({
initialData: {
type: Object,
default: () => ({
name: '',
description: '',
enabled: false,
assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
}),
},
mode: {
type: String,
required: true,
validator: value => ['CREATE', 'EDIT'].includes(value),
},
policyInboxes: {
type: Array,
default: () => [],
},
inboxList: {
type: Array,
default: () => [],
},
showInboxSection: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
isInboxLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'submit',
'addInbox',
'deleteInbox',
'validationChange',
]);
const { t } = useI18n();
const { isEnterprise } = useConfig();
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY';
const state = reactive({
name: '',
description: '',
enabled: false,
assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
});
const validationState = ref({
isValid: false,
});
const createOption = (type, key, stateKey) => ({
key,
label: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.LABEL`),
description: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.DESCRIPTION`),
isActive: state[stateKey] === key,
});
const assignmentOrderOptions = computed(() => {
const options = OPTIONS.ORDER.filter(
key => isEnterprise || key !== 'balanced'
);
return options.map(key =>
createOption('ASSIGNMENT_ORDER', key, 'assignmentOrder')
);
});
const assignmentPriorityOptions = computed(() =>
OPTIONS.PRIORITY.map(key =>
createOption('ASSIGNMENT_PRIORITY', key, 'conversationPriority')
)
);
const radioSections = computed(() => [
{
key: 'assignmentOrder',
label: t(`${BASE_KEY}.FORM.ASSIGNMENT_ORDER.LABEL`),
options: assignmentOrderOptions.value,
},
{
key: 'conversationPriority',
label: t(`${BASE_KEY}.FORM.ASSIGNMENT_PRIORITY.LABEL`),
options: assignmentPriorityOptions.value,
},
]);
const buttonLabel = computed(() =>
t(`${BASE_KEY}.${props.mode.toUpperCase()}.${props.mode}_BUTTON`)
);
const handleValidationChange = validation => {
validationState.value = validation;
emit('validationChange', validation);
};
const resetForm = () => {
Object.assign(state, {
name: '',
description: '',
enabled: false,
assignmentOrder: ROUND_ROBIN,
conversationPriority: EARLIEST_CREATED,
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
});
};
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 mb-4">
<BaseInfo
v-model:policy-name="state.name"
v-model:description="state.description"
v-model:enabled="state.enabled"
: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`)"
:status-label="t(`${BASE_KEY}.FORM.STATUS.LABEL`)"
:status-placeholder="
t(`${BASE_KEY}.FORM.STATUS.${state.enabled ? 'ACTIVE' : 'INACTIVE'}`)
"
@validation-change="handleValidationChange"
/>
<div class="flex flex-col items-center">
<div
v-for="section in radioSections"
:key="section.key"
class="py-4 flex flex-col items-start gap-3 w-full"
>
<WithLabel
:label="section.label"
name="assignmentPolicy"
class="w-full flex items-start flex-col gap-3"
>
<div class="grid grid-cols-1 xs:grid-cols-2 gap-4 w-full">
<RadioCard
v-for="option in section.options"
:id="option.key"
:key="option.key"
:label="option.label"
:description="option.description"
:is-active="option.isActive"
@select="state[section.key] = $event"
/>
</div>
</WithLabel>
</div>
</div>
<div class="pt-4 pb-2 flex-col flex gap-4">
<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.FAIR_DISTRIBUTION.LABEL`) }}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{ t(`${BASE_KEY}.FORM.FAIR_DISTRIBUTION.DESCRIPTION`) }}
</p>
</div>
<FairDistribution
v-model:fair-distribution-limit="state.fairDistributionLimit"
v-model:fair-distribution-window="state.fairDistributionWindow"
v-model:window-unit="state.windowUnit"
/>
</div>
<div v-if="showInboxSection" 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.INBOXES.LABEL`) }}
</label>
<p class="mb-0 text-n-slate-11 text-sm">
{{ t(`${BASE_KEY}.FORM.INBOXES.DESCRIPTION`) }}
</p>
</div>
<AddDataDropdown
:label="t(`${BASE_KEY}.FORM.INBOXES.ADD_BUTTON`)"
:search-placeholder="
t(`${BASE_KEY}.FORM.INBOXES.DROPDOWN.SEARCH_PLACEHOLDER`)
"
:items="inboxList"
@add="$emit('addInbox', $event)"
/>
</div>
<DataTable
:items="policyInboxes"
:is-fetching="isInboxLoading"
:empty-state-message="t(`${BASE_KEY}.FORM.INBOXES.EMPTY_STATE`)"
@delete="$emit('deleteInbox', $event)"
/>
</div>
</div>
<Button
type="submit"
:label="buttonLabel"
:disabled="!validationState.isValid || isLoading"
:is-loading="isLoading"
/>
</form>
</template>

View File

@@ -0,0 +1,59 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const emit = defineEmits(['add']);
const { t } = useI18n();
const dialogRef = ref(null);
const currentInbox = ref(null);
const openDialog = inbox => {
currentInbox.value = inbox;
dialogRef.value.open();
};
const closeDialog = () => {
dialogRef.value.close();
};
const handleDialogConfirm = () => {
emit('add', currentInbox.value.id);
};
defineExpose({ openDialog, closeDialog });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.CONFIRM_ADD_INBOX_DIALOG.TITLE'
)
"
:description="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.CONFIRM_ADD_INBOX_DIALOG.DESCRIPTION',
{
inboxName: currentInbox?.name,
}
)
"
:confirm-button-label="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.CONFIRM_ADD_INBOX_DIALOG.CONFIRM_BUTTON_LABEL'
)
"
:cancel-button-label="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.EDIT.CONFIRM_ADD_INBOX_DIALOG.CANCEL_BUTTON_LABEL'
)
"
@confirm="handleDialogConfirm"
/>
</template>

View File

@@ -3,6 +3,7 @@ import types from '../mutation-types';
import AssignmentPoliciesAPI from '../../api/assignmentPolicies';
import { throwErrorMessage } from '../utils/api';
import camelcaseKeys from 'camelcase-keys';
import snakecaseKeys from 'snakecase-keys';
export const state = {
records: [],
@@ -15,6 +16,7 @@ export const state = {
},
inboxUiFlags: {
isFetching: false,
isDeleting: false,
},
};
@@ -51,7 +53,7 @@ export const actions = {
try {
const response = await AssignmentPoliciesAPI.show(policyId);
const policy = camelcaseKeys(response.data);
commit(types.EDIT_ASSIGNMENT_POLICY, policy);
commit(types.SET_ASSIGNMENT_POLICY, policy);
} catch (error) {
throwErrorMessage(error);
} finally {
@@ -62,7 +64,9 @@ export const actions = {
create: async function create({ commit }, policyObj) {
commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: true });
try {
const response = await AssignmentPoliciesAPI.create(policyObj);
const response = await AssignmentPoliciesAPI.create(
snakecaseKeys(policyObj)
);
commit(types.ADD_ASSIGNMENT_POLICY, camelcaseKeys(response.data));
return response.data;
} catch (error) {
@@ -76,7 +80,10 @@ export const actions = {
update: async function update({ commit }, { id, ...policyParams }) {
commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: true });
try {
const response = await AssignmentPoliciesAPI.update(id, policyParams);
const response = await AssignmentPoliciesAPI.update(
id,
snakecaseKeys(policyParams)
);
commit(types.EDIT_ASSIGNMENT_POLICY, camelcaseKeys(response.data));
return response.data;
} catch (error) {
@@ -117,6 +124,68 @@ export const actions = {
});
}
},
setInboxPolicy: async function setInboxPolicy(
{ commit },
{ inboxId, policyId }
) {
try {
const response = await AssignmentPoliciesAPI.setInboxPolicy(
inboxId,
policyId
);
commit(
types.ADD_ASSIGNMENT_POLICIES_INBOXES,
camelcaseKeys(response.data)
);
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
}
},
getInboxPolicy: async function getInboxPolicy(_, { inboxId }) {
try {
const response = await AssignmentPoliciesAPI.getInboxPolicy(inboxId);
return camelcaseKeys(response.data);
} catch (error) {
throwErrorMessage(error);
throw error;
}
},
updateInboxPolicy: async function updateInboxPolicy({ commit }, { policy }) {
try {
commit(types.EDIT_ASSIGNMENT_POLICY, policy);
} catch (error) {
throwErrorMessage(error);
throw error;
}
},
removeInboxPolicy: async function removeInboxPolicy(
{ commit },
{ policyId, inboxId }
) {
commit(types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, {
isDeleting: true,
});
try {
await AssignmentPoliciesAPI.removeInboxPolicy(inboxId);
commit(types.DELETE_ASSIGNMENT_POLICIES_INBOXES, {
policyId,
inboxId,
});
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, {
isDeleting: false,
});
}
},
};
export const mutations = {
@@ -128,8 +197,9 @@ export const mutations = {
},
[types.SET_ASSIGNMENT_POLICIES]: MutationHelpers.set,
[types.SET_ASSIGNMENT_POLICY]: MutationHelpers.setSingleRecord,
[types.ADD_ASSIGNMENT_POLICY]: MutationHelpers.create,
[types.EDIT_ASSIGNMENT_POLICY]: MutationHelpers.update,
[types.EDIT_ASSIGNMENT_POLICY]: MutationHelpers.updateAttributes,
[types.DELETE_ASSIGNMENT_POLICY]: MutationHelpers.destroy,
[types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG](_state, data) {
@@ -138,13 +208,19 @@ export const mutations = {
...data,
};
},
[types.SET_ASSIGNMENT_POLICIES_INBOXES](_state, { policyId, inboxes }) {
const policy = _state.records.find(p => p.id === policyId);
if (policy) {
policy.inboxes = inboxes;
}
},
[types.DELETE_ASSIGNMENT_POLICIES_INBOXES](_state, { policyId, inboxId }) {
const policy = _state.records.find(p => p.id === policyId);
if (policy) {
policy.inboxes = policy?.inboxes?.filter(inbox => inbox.id !== inboxId);
}
},
[types.ADD_ASSIGNMENT_POLICIES_INBOXES]: MutationHelpers.updateAttributes,
};
export default {

View File

@@ -29,6 +29,9 @@ export const getters = {
getInboxes($state) {
return $state.records;
},
getAllInboxes($state) {
return camelcaseKeys($state.records, { deep: true });
},
getWhatsAppTemplates: $state => inboxId => {
const [inbox] = $state.records.filter(
record => record.id === Number(inboxId)

View File

@@ -3,12 +3,14 @@ import { actions } from '../../assignmentPolicies';
import types from '../../../mutation-types';
import assignmentPoliciesList, { camelCaseFixtures } from './fixtures';
import camelcaseKeys from 'camelcase-keys';
import snakecaseKeys from 'snakecase-keys';
const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
vi.mock('camelcase-keys');
vi.mock('snakecase-keys');
vi.mock('../../../utils/api');
describe('#actions', () => {
@@ -56,7 +58,7 @@ describe('#actions', () => {
expect(camelcaseKeys).toHaveBeenCalledWith(policyData);
expect(commit.mock.calls).toEqual([
[types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: true }],
[types.EDIT_ASSIGNMENT_POLICY, camelCasedPolicy],
[types.SET_ASSIGNMENT_POLICY, camelCasedPolicy],
[types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: false }],
]);
});
@@ -77,12 +79,15 @@ describe('#actions', () => {
it('sends correct actions if API is success', async () => {
const newPolicy = assignmentPoliciesList[0];
const camelCasedData = camelCaseFixtures[0];
const snakeCasedPolicy = { assignment_order: 'round_robin' };
axios.post.mockResolvedValue({ data: newPolicy });
camelcaseKeys.mockReturnValue(camelCasedData);
snakecaseKeys.mockReturnValue(snakeCasedPolicy);
const result = await actions.create({ commit }, newPolicy);
expect(snakecaseKeys).toHaveBeenCalledWith(newPolicy);
expect(camelcaseKeys).toHaveBeenCalledWith(newPolicy);
expect(commit.mock.calls).toEqual([
[types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: true }],
@@ -115,12 +120,15 @@ describe('#actions', () => {
...camelCaseFixtures[0],
name: 'Updated Policy',
};
const snakeCasedParams = { name: 'Updated Policy' };
axios.patch.mockResolvedValue({ data: responseData });
camelcaseKeys.mockReturnValue(camelCasedData);
snakecaseKeys.mockReturnValue(snakeCasedParams);
const result = await actions.update({ commit }, updateParams);
expect(snakecaseKeys).toHaveBeenCalledWith({ name: 'Updated Policy' });
expect(camelcaseKeys).toHaveBeenCalledWith(responseData);
expect(commit.mock.calls).toEqual([
[types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: true }],
@@ -211,4 +219,108 @@ describe('#actions', () => {
]);
});
});
describe('#setInboxPolicy', () => {
it('sends correct actions if API is success', async () => {
const responseData = { success: true, policy_id: 2 };
const camelCasedData = { success: true, policyId: 2 };
axios.post.mockResolvedValue({ data: responseData });
camelcaseKeys.mockReturnValue(camelCasedData);
const result = await actions.setInboxPolicy(
{ commit },
{ inboxId: 1, policyId: 2 }
);
expect(camelcaseKeys).toHaveBeenCalledWith(responseData);
expect(commit.mock.calls).toEqual([
[types.ADD_ASSIGNMENT_POLICIES_INBOXES, camelCasedData],
]);
expect(result).toEqual(responseData);
});
it('throws error if API fails', async () => {
axios.post.mockRejectedValue(new Error('API Error'));
await expect(
actions.setInboxPolicy({ commit }, { inboxId: 1, policyId: 2 })
).rejects.toThrow(Error);
});
});
describe('#getInboxPolicy', () => {
it('returns camelCased response data if API is success', async () => {
const responseData = { policy_id: 1, name: 'Round Robin' };
const camelCasedData = { policyId: 1, name: 'Round Robin' };
axios.get.mockResolvedValue({ data: responseData });
camelcaseKeys.mockReturnValue(camelCasedData);
const result = await actions.getInboxPolicy({}, { inboxId: 1 });
expect(camelcaseKeys).toHaveBeenCalledWith(responseData);
expect(result).toEqual(camelCasedData);
});
it('throws error if API fails', async () => {
axios.get.mockRejectedValue(new Error('Not found'));
await expect(
actions.getInboxPolicy({}, { inboxId: 999 })
).rejects.toThrow(Error);
});
});
describe('#updateInboxPolicy', () => {
it('commits EDIT_ASSIGNMENT_POLICY mutation', async () => {
const policy = { id: 1, name: 'Updated Policy' };
await actions.updateInboxPolicy({ commit }, { policy });
expect(commit.mock.calls).toEqual([
[types.EDIT_ASSIGNMENT_POLICY, policy],
]);
});
it('throws error if commit fails', async () => {
commit.mockImplementation(() => {
throw new Error('Commit failed');
});
await expect(
actions.updateInboxPolicy({ commit }, { policy: {} })
).rejects.toThrow(Error);
});
});
describe('#removeInboxPolicy', () => {
it('sends correct actions if API is success', async () => {
const policyId = 1;
const inboxId = 2;
axios.delete.mockResolvedValue({});
await actions.removeInboxPolicy({ commit }, { policyId, inboxId });
expect(commit.mock.calls).toEqual([
[types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isDeleting: true }],
[types.DELETE_ASSIGNMENT_POLICIES_INBOXES, { policyId, inboxId }],
[types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isDeleting: false }],
]);
});
it('sends correct actions if API fails', async () => {
axios.delete.mockRejectedValue(new Error('Not found'));
await expect(
actions.removeInboxPolicy({ commit }, { policyId: 1, inboxId: 999 })
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isDeleting: true }],
[types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isDeleting: false }],
]);
});
});
});

View File

@@ -32,10 +32,12 @@ describe('#getters', () => {
const state = {
inboxUiFlags: {
isFetching: false,
isDeleting: false,
},
};
expect(getters.getInboxUiFlags(state)).toEqual({
isFetching: false,
isDeleting: false,
});
});

View File

@@ -62,6 +62,24 @@ describe('#mutations', () => {
});
});
describe('#SET_ASSIGNMENT_POLICY', () => {
it('sets single assignment policy record', () => {
const state = { records: [] };
mutations[types.SET_ASSIGNMENT_POLICY](state, assignmentPoliciesList[0]);
expect(state.records).toEqual([assignmentPoliciesList[0]]);
});
it('replaces existing record', () => {
const state = { records: [{ id: 1, name: 'Old Policy' }] };
mutations[types.SET_ASSIGNMENT_POLICY](state, assignmentPoliciesList[0]);
expect(state.records).toEqual([assignmentPoliciesList[0]]);
});
});
describe('#ADD_ASSIGNMENT_POLICY', () => {
it('adds new policy to empty records', () => {
const state = { records: [] };
@@ -264,4 +282,104 @@ describe('#mutations', () => {
expect(state).toEqual(originalState);
});
});
describe('#DELETE_ASSIGNMENT_POLICIES_INBOXES', () => {
it('removes inbox from policy', () => {
const mockInboxes = [
{ id: 1, name: 'Support Inbox' },
{ id: 2, name: 'Sales Inbox' },
{ id: 3, name: 'Marketing Inbox' },
];
const state = {
records: [
{ id: 1, name: 'Policy 1', inboxes: mockInboxes },
{ id: 2, name: 'Policy 2', inboxes: [] },
],
};
mutations[types.DELETE_ASSIGNMENT_POLICIES_INBOXES](state, {
policyId: 1,
inboxId: 2,
});
expect(state.records[0].inboxes).toEqual([
{ id: 1, name: 'Support Inbox' },
{ id: 3, name: 'Marketing Inbox' },
]);
expect(state.records[1].inboxes).toEqual([]);
});
it('does nothing if policy not found', () => {
const state = {
records: [
{ id: 1, name: 'Policy 1', inboxes: [{ id: 1, name: 'Test' }] },
],
};
const originalState = JSON.parse(JSON.stringify(state));
mutations[types.DELETE_ASSIGNMENT_POLICIES_INBOXES](state, {
policyId: 999,
inboxId: 1,
});
expect(state).toEqual(originalState);
});
it('does nothing if inbox not found in policy', () => {
const mockInboxes = [{ id: 1, name: 'Support Inbox' }];
const state = {
records: [{ id: 1, name: 'Policy 1', inboxes: mockInboxes }],
};
mutations[types.DELETE_ASSIGNMENT_POLICIES_INBOXES](state, {
policyId: 1,
inboxId: 999,
});
expect(state.records[0].inboxes).toEqual(mockInboxes);
});
it('handles policy with no inboxes', () => {
const state = {
records: [{ id: 1, name: 'Policy 1' }],
};
mutations[types.DELETE_ASSIGNMENT_POLICIES_INBOXES](state, {
policyId: 1,
inboxId: 1,
});
expect(state.records[0]).toEqual({ id: 1, name: 'Policy 1' });
});
});
describe('#ADD_ASSIGNMENT_POLICIES_INBOXES', () => {
it('updates policy attributes using MutationHelpers.updateAttributes', () => {
const state = {
records: [
{ id: 1, name: 'Policy 1', assignedInboxCount: 2 },
{ id: 2, name: 'Policy 2', assignedInboxCount: 1 },
],
};
const updatedPolicy = {
id: 1,
name: 'Policy 1',
assignedInboxCount: 3,
inboxes: [{ id: 1, name: 'New Inbox' }],
};
mutations[types.ADD_ASSIGNMENT_POLICIES_INBOXES](state, updatedPolicy);
expect(state.records[0]).toEqual(updatedPolicy);
expect(state.records[1]).toEqual({
id: 2,
name: 'Policy 2',
assignedInboxCount: 1,
});
});
});
});

View File

@@ -352,10 +352,13 @@ export default {
// Assignment Policies
SET_ASSIGNMENT_POLICIES_UI_FLAG: 'SET_ASSIGNMENT_POLICIES_UI_FLAG',
SET_ASSIGNMENT_POLICIES: 'SET_ASSIGNMENT_POLICIES',
SET_ASSIGNMENT_POLICY: 'SET_ASSIGNMENT_POLICY',
ADD_ASSIGNMENT_POLICY: 'ADD_ASSIGNMENT_POLICY',
EDIT_ASSIGNMENT_POLICY: 'EDIT_ASSIGNMENT_POLICY',
DELETE_ASSIGNMENT_POLICY: 'DELETE_ASSIGNMENT_POLICY',
SET_ASSIGNMENT_POLICIES_INBOXES: 'SET_ASSIGNMENT_POLICIES_INBOXES',
SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG:
'SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG',
DELETE_ASSIGNMENT_POLICIES_INBOXES: 'DELETE_ASSIGNMENT_POLICIES_INBOXES',
ADD_ASSIGNMENT_POLICIES_INBOXES: 'ADD_ASSIGNMENT_POLICIES_INBOXES',
};